You've already forked FrameTour-BE
Compare commits
13 Commits
1b9bebf7e4
...
bb2367c5a6
| Author | SHA1 | Date | |
|---|---|---|---|
| bb2367c5a6 | |||
| 3d361200b0 | |||
| 5c49a5af9e | |||
| a5ffb86790 | |||
| 755ba1153e | |||
| ebf05ab189 | |||
| e2b450682b | |||
| 443f92ff92 | |||
| 630d344b5a | |||
| 9eb3fd3e58 | |||
| 3463dcc9ae | |||
| d408c47963 | |||
| 9d708ae20c |
15
pom.xml
15
pom.xml
@@ -272,6 +272,18 @@
|
|||||||
<groupId>org.springframework.kafka</groupId>
|
<groupId>org.springframework.kafka</groupId>
|
||||||
<artifactId>spring-kafka</artifactId>
|
<artifactId>spring-kafka</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Apache POI - 处理Excel文件 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi</artifactId>
|
||||||
|
<version>5.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>5.4.0</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -307,6 +319,9 @@
|
|||||||
<testExcludes>
|
<testExcludes>
|
||||||
<testExclude>**/*Test.java</testExclude>
|
<testExclude>**/*Test.java</testExclude>
|
||||||
</testExcludes>
|
</testExcludes>
|
||||||
|
<source>21</source>
|
||||||
|
<target>21</target>
|
||||||
|
<compilerArgs>--enable-preview</compilerArgs>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
|
|||||||
@@ -287,14 +287,14 @@ public class OrderBiz {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
List<SourceEntity> imageSource = sourceMapper.listImageByFaceRelation(order.getMemberId(), orderItemVO.getGoodsId());
|
List<SourceEntity> imageSource = sourceMapper.listImageByFaceRelation(orderItemVO.getGoodsId());
|
||||||
Optional<SourceEntity> min = imageSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
|
Optional<SourceEntity> min = imageSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
|
||||||
if (min.isPresent()) {
|
if (min.isPresent()) {
|
||||||
goodsCreateTime = min.get().getCreateTime();
|
goodsCreateTime = min.get().getCreateTime();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
List<SourceEntity> videoSource = sourceMapper.listImageByFaceRelation(order.getMemberId(), orderItemVO.getGoodsId());
|
List<SourceEntity> videoSource = sourceMapper.listImageByFaceRelation(orderItemVO.getGoodsId());
|
||||||
Optional<SourceEntity> minTime = videoSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
|
Optional<SourceEntity> minTime = videoSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
|
||||||
if (minTime.isPresent()) {
|
if (minTime.isPresent()) {
|
||||||
goodsCreateTime = minTime.get().getCreateTime();
|
goodsCreateTime = minTime.get().getCreateTime();
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.ycwl.basic.controller;
|
||||||
|
|
||||||
|
import com.github.pagehelper.PageInfo;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewStatisticsRespDTO;
|
||||||
|
import com.ycwl.basic.service.VideoReviewService;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价Controller
|
||||||
|
* 管理端使用,通过token角色控制权限
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/video-review/v1")
|
||||||
|
public class VideoReviewController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private VideoReviewService videoReviewService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增视频评价
|
||||||
|
*
|
||||||
|
* @param reqDTO 评价信息
|
||||||
|
* @return 评价ID
|
||||||
|
*/
|
||||||
|
@PostMapping("/add")
|
||||||
|
public ApiResponse<Long> addReview(@RequestBody VideoReviewAddReqDTO reqDTO) {
|
||||||
|
log.info("新增视频评价,videoId: {}", reqDTO.getVideoId());
|
||||||
|
Long reviewId = videoReviewService.addReview(reqDTO);
|
||||||
|
return ApiResponse.success(reviewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询评价列表
|
||||||
|
*
|
||||||
|
* @param reqDTO 查询条件
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
public ApiResponse<PageInfo<VideoReviewRespDTO>> getReviewList(VideoReviewListReqDTO reqDTO) {
|
||||||
|
log.info("查询视频评价列表,pageNum: {}, pageSize: {}", reqDTO.getPageNum(), reqDTO.getPageSize());
|
||||||
|
PageInfo<VideoReviewRespDTO> pageInfo = videoReviewService.getReviewList(reqDTO);
|
||||||
|
return ApiResponse.success(pageInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取评价统计数据
|
||||||
|
*
|
||||||
|
* @return 统计结果
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ApiResponse<VideoReviewStatisticsRespDTO> getStatistics() {
|
||||||
|
log.info("获取视频评价统计数据");
|
||||||
|
VideoReviewStatisticsRespDTO statistics = videoReviewService.getStatistics();
|
||||||
|
return ApiResponse.success(statistics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出评价数据到Excel
|
||||||
|
*
|
||||||
|
* @param reqDTO 查询条件
|
||||||
|
* @param response HTTP响应
|
||||||
|
*/
|
||||||
|
@GetMapping("/export")
|
||||||
|
public void exportReviews(VideoReviewListReqDTO reqDTO, HttpServletResponse response) {
|
||||||
|
log.info("导出视频评价数据");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 设置响应头
|
||||||
|
String fileName = "video_reviews_" + System.currentTimeMillis() + ".xlsx";
|
||||||
|
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));
|
||||||
|
|
||||||
|
// 导出数据
|
||||||
|
videoReviewService.exportReviews(reqDTO, response.getOutputStream());
|
||||||
|
|
||||||
|
response.getOutputStream().flush();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("导出视频评价数据失败", e);
|
||||||
|
throw new RuntimeException("导出失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.ycwl.basic.controller.mobile;
|
package com.ycwl.basic.controller.mobile;
|
||||||
|
|
||||||
|
import com.ycwl.basic.exception.BaseException;
|
||||||
import com.ycwl.basic.model.jwt.JwtInfo;
|
import com.ycwl.basic.model.jwt.JwtInfo;
|
||||||
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
||||||
import com.ycwl.basic.model.mobile.face.FaceStatusResp;
|
import com.ycwl.basic.model.mobile.face.FaceStatusResp;
|
||||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionUpdateReq;
|
import com.ycwl.basic.model.mobile.face.FaceRecognitionUpdateReq;
|
||||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionDetailVO;
|
import com.ycwl.basic.model.mobile.face.FaceRecognitionDetailVO;
|
||||||
|
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
|
import com.ycwl.basic.repository.FaceRepository;
|
||||||
import com.ycwl.basic.service.pc.FaceService;
|
import com.ycwl.basic.service.pc.FaceService;
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||||
@@ -28,6 +31,8 @@ public class AppFaceController {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private FaceService faceService;
|
private FaceService faceService;
|
||||||
|
@Autowired
|
||||||
|
private FaceRepository faceRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1、上传人脸照片
|
* 1、上传人脸照片
|
||||||
@@ -65,6 +70,18 @@ public class AppFaceController {
|
|||||||
|
|
||||||
@DeleteMapping("/{faceId}")
|
@DeleteMapping("/{faceId}")
|
||||||
public ApiResponse<String> deleteFace(@PathVariable("faceId") Long faceId) {
|
public ApiResponse<String> deleteFace(@PathVariable("faceId") Long faceId) {
|
||||||
|
// 添加权限检查:验证当前用户是否拥有该 face
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
Long userId = worker.getUserId();
|
||||||
|
|
||||||
|
FaceEntity face = faceRepository.getFace(faceId);
|
||||||
|
if (face == null) {
|
||||||
|
throw new BaseException("人脸数据不存在");
|
||||||
|
}
|
||||||
|
if (!face.getMemberId().equals(userId)) {
|
||||||
|
throw new BaseException("无权删除此人脸");
|
||||||
|
}
|
||||||
|
|
||||||
return faceService.deleteFace(faceId);
|
return faceService.deleteFace(faceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,36 +262,21 @@ public class AppOrderV2Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户查询自己的订单详情
|
* 查询订单详情
|
||||||
*/
|
*/
|
||||||
@GetMapping("/detail/{orderId}")
|
@GetMapping("/detail/{orderId}")
|
||||||
public ApiResponse<OrderV2DetailResponse> getUserOrderDetail(@PathVariable("orderId") Long orderId) {
|
public ApiResponse<OrderV2DetailResponse> getUserOrderDetail(@PathVariable("orderId") Long orderId) {
|
||||||
String currentUserIdStr = BaseContextHandler.getUserId();
|
log.info("查询订单详情: orderId={}", orderId);
|
||||||
if (currentUserIdStr == null) {
|
|
||||||
log.warn("用户未登录");
|
|
||||||
return ApiResponse.fail("用户未登录");
|
|
||||||
}
|
|
||||||
|
|
||||||
Long currentUserId = Long.valueOf(currentUserIdStr);
|
|
||||||
|
|
||||||
log.info("用户查询订单详情: userId={}, orderId={}", currentUserId, orderId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
OrderV2DetailResponse detail = orderService.getOrderDetail(orderId);
|
OrderV2DetailResponse detail = orderService.getOrderDetail(orderId);
|
||||||
if (detail == null) {
|
if (detail == null) {
|
||||||
return ApiResponse.fail("订单不存在");
|
return ApiResponse.fail("订单不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证订单是否属于当前用户
|
|
||||||
if (!currentUserId.equals(detail.getMemberId())) {
|
|
||||||
log.warn("用户尝试访问他人订单: userId={}, orderId={}, orderOwner={}",
|
|
||||||
currentUserId, orderId, detail.getMemberId());
|
|
||||||
return ApiResponse.fail("无权访问该订单");
|
|
||||||
}
|
|
||||||
|
|
||||||
return ApiResponse.success(detail);
|
return ApiResponse.success(detail);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("查询用户订单详情失败: userId={}, orderId={}", currentUserId, orderId, e);
|
log.error("查询订单详情失败: orderId={}", orderId, e);
|
||||||
return ApiResponse.fail("查询失败:" + e.getMessage());
|
return ApiResponse.fail("查询失败:" + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,6 @@ public class AppVoucherController {
|
|||||||
if (face == null) {
|
if (face == null) {
|
||||||
throw new BaseException("请选择人脸");
|
throw new BaseException("请选择人脸");
|
||||||
}
|
}
|
||||||
if (!face.getMemberId().equals(Long.valueOf(BaseContextHandler.getUserId()))) {
|
|
||||||
throw new BaseException("自动领取失败");
|
|
||||||
}
|
|
||||||
req.setScenicId(face.getScenicId());
|
req.setScenicId(face.getScenicId());
|
||||||
VoucherCodeResp result = voucherCodeService.claimVoucher(req);
|
VoucherCodeResp result = voucherCodeService.claimVoucher(req);
|
||||||
return ApiResponse.success(result);
|
return ApiResponse.success(result);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.ycwl.basic.controller.pc;
|
package com.ycwl.basic.controller.pc;
|
||||||
|
|
||||||
import com.ycwl.basic.controller.dto.RenderWorkerWithStatusDTO;
|
import com.ycwl.basic.dto.RenderWorkerWithStatusDTO;
|
||||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||||
import com.ycwl.basic.integration.render.dto.worker.CreateRenderWorkerRequest;
|
import com.ycwl.basic.integration.render.dto.worker.CreateRenderWorkerRequest;
|
||||||
import com.ycwl.basic.integration.render.dto.worker.RenderWorkerV2DTO;
|
import com.ycwl.basic.integration.render.dto.worker.RenderWorkerV2DTO;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/task/v1")
|
@RequestMapping("/api/task/v1")
|
||||||
@Deprecated
|
|
||||||
// 任务列表管理
|
// 任务列表管理
|
||||||
public class TaskController {
|
public class TaskController {
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,16 @@ public class VideoController {
|
|||||||
return videoService.getById(id);
|
return videoService.getById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询视频是否被购买
|
||||||
|
*
|
||||||
|
* @param videoId 视频ID
|
||||||
|
* @return 是否已购买
|
||||||
|
*/
|
||||||
|
@GetMapping("/checkBuyStatus")
|
||||||
|
public ApiResponse<Boolean> checkBuyStatus(@RequestParam("videoId") Long videoId) {
|
||||||
|
Boolean isBuy = videoService.checkVideoBuyStatus(videoId);
|
||||||
|
return ApiResponse.success(isBuy);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.ycwl.basic.controller.dto;
|
package com.ycwl.basic.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
70
src/main/java/com/ycwl/basic/handler/MapTypeHandler.java
Normal file
70
src/main/java/com/ycwl/basic/handler/MapTypeHandler.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package com.ycwl.basic.handler;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.type.BaseTypeHandler;
|
||||||
|
import org.apache.ibatis.type.JdbcType;
|
||||||
|
|
||||||
|
import java.sql.CallableStatement;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map类型的TypeHandler,用于处理JSON字段与Map<String, Integer>的互转
|
||||||
|
* 主要用于机位快速评价等自由格式的JSON存储
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class MapTypeHandler extends BaseTypeHandler<Map<String, Integer>> {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final TypeReference<Map<String, Integer>> typeReference = new TypeReference<Map<String, Integer>>() {};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Integer> parameter, JdbcType jdbcType) throws SQLException {
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(parameter);
|
||||||
|
ps.setString(i, json);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("序列化Map为JSON失败", e);
|
||||||
|
throw new SQLException("序列化Map为JSON失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Integer> getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||||
|
String json = rs.getString(columnName);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Integer> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||||
|
String json = rs.getString(columnIndex);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Integer> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||||
|
String json = cs.getString(columnIndex);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析JSON字符串为Map
|
||||||
|
*/
|
||||||
|
private Map<String, Integer> parseJson(String json) {
|
||||||
|
if (json == null || json.trim().isEmpty()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, typeReference);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("解析JSON为Map失败, json={}", json, e);
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.ycwl.basic.handler;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.type.BaseTypeHandler;
|
||||||
|
import org.apache.ibatis.type.JdbcType;
|
||||||
|
|
||||||
|
import java.sql.CallableStatement;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 嵌套Map类型的TypeHandler,用于处理JSON字段与Map<String, Map<String, Integer>>的互转
|
||||||
|
* 主要用于机位评价功能:外层key为机位ID,内层Map为该机位的各维度评分
|
||||||
|
*
|
||||||
|
* 数据格式示例:
|
||||||
|
* {
|
||||||
|
* "12345": {"清晰度": 5, "构图": 4, "色彩": 5, "整体效果": 4},
|
||||||
|
* "12346": {"清晰度": 4, "构图": 5, "色彩": 4, "整体效果": 5}
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class NestedMapTypeHandler extends BaseTypeHandler<Map<String, Map<String, Integer>>> {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final TypeReference<Map<String, Map<String, Integer>>> typeReference =
|
||||||
|
new TypeReference<Map<String, Map<String, Integer>>>() {};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Map<String, Integer>> parameter, JdbcType jdbcType) throws SQLException {
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(parameter);
|
||||||
|
ps.setString(i, json);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("序列化嵌套Map为JSON失败", e);
|
||||||
|
throw new SQLException("序列化嵌套Map为JSON失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, Integer>> getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||||
|
String json = rs.getString(columnName);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, Integer>> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||||
|
String json = rs.getString(columnIndex);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, Integer>> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||||
|
String json = cs.getString(columnIndex);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析JSON字符串为嵌套Map
|
||||||
|
*/
|
||||||
|
private Map<String, Map<String, Integer>> parseJson(String json) {
|
||||||
|
if (json == null || json.trim().isEmpty()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, typeReference);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("解析JSON为嵌套Map失败, json={}", json, e);
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,9 @@ public interface SourceMapper {
|
|||||||
int hasRelationTo(Long memberId, Long sourceId, int type);
|
int hasRelationTo(Long memberId, Long sourceId, int type);
|
||||||
|
|
||||||
List<SourceEntity> listVideoByScenicFaceRelation(Long scenicId, Long faceId);
|
List<SourceEntity> listVideoByScenicFaceRelation(Long scenicId, Long faceId);
|
||||||
List<SourceEntity> listVideoByFaceRelation(Long memberId, Long faceId);
|
List<SourceEntity> listVideoByFaceRelation(Long faceId);
|
||||||
|
|
||||||
List<SourceEntity> listImageByFaceRelation(Long memberId, Long faceId);
|
List<SourceEntity> listImageByFaceRelation(Long faceId);
|
||||||
List<MemberSourceEntity> listByFaceRelation(Long faceId, Integer type);
|
List<MemberSourceEntity> listByFaceRelation(Long faceId, Integer type);
|
||||||
|
|
||||||
SourceEntity getEntity(Long id);
|
SourceEntity getEntity(Long id);
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ public interface VideoMapper {
|
|||||||
int deleteNotBuyFaceRelations(Long userId, Long faceId);
|
int deleteNotBuyFaceRelations(Long userId, Long faceId);
|
||||||
|
|
||||||
int deleteUselessVideo();
|
int deleteUselessVideo();
|
||||||
|
|
||||||
int updateMemberIdByFaceId(Long faceId, Long memberId);
|
int updateMemberIdByFaceId(Long faceId, Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定视频是否存在已购买记录
|
||||||
|
* @param videoId 视频ID
|
||||||
|
* @return 已购买记录数量
|
||||||
|
*/
|
||||||
|
int countBuyRecordByVideoId(Long videoId);
|
||||||
}
|
}
|
||||||
|
|||||||
71
src/main/java/com/ycwl/basic/mapper/VideoReviewMapper.java
Normal file
71
src/main/java/com/ycwl/basic/mapper/VideoReviewMapper.java
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ycwl.basic.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewStatisticsRespDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.entity.VideoReviewEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价Mapper接口
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface VideoReviewMapper extends BaseMapper<VideoReviewEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询评价列表(带关联查询)
|
||||||
|
*
|
||||||
|
* @param reqDTO 查询条件
|
||||||
|
* @return 评价列表
|
||||||
|
*/
|
||||||
|
List<VideoReviewRespDTO> selectReviewList(VideoReviewListReqDTO reqDTO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计总评价数
|
||||||
|
*
|
||||||
|
* @return 总数
|
||||||
|
*/
|
||||||
|
Long countTotal();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算平均评分
|
||||||
|
*
|
||||||
|
* @return 平均评分
|
||||||
|
*/
|
||||||
|
Double calculateAverageRating();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计评分分布
|
||||||
|
*
|
||||||
|
* @return key: 评分, value: 数量
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> countRatingDistribution();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计最近N天的评价趋势
|
||||||
|
*
|
||||||
|
* @param days 天数
|
||||||
|
* @return key: 日期, value: 数量
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> countRecentTrend(@Param("days") int days);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计景区评价排行(前N名)
|
||||||
|
*
|
||||||
|
* @param limit 限制数量
|
||||||
|
* @return 景区排行列表
|
||||||
|
*/
|
||||||
|
List<VideoReviewStatisticsRespDTO.ScenicReviewRank> countScenicRank(@Param("limit") int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有机位评价数据(用于后端计算平均值)
|
||||||
|
*
|
||||||
|
* @return 机位评价列表(嵌套Map结构)
|
||||||
|
*/
|
||||||
|
List<Map<String, Map<String, Integer>>> selectAllCameraPositionRatings();
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ public class VideoRespVO {
|
|||||||
*/
|
*/
|
||||||
// 任务id
|
// 任务id
|
||||||
private Long taskId;
|
private Long taskId;
|
||||||
|
/**
|
||||||
|
* 任务参数,JSON字符串
|
||||||
|
*/
|
||||||
|
private String taskParams;
|
||||||
/**
|
/**
|
||||||
* 执行任务的机器ID,render_worker.id
|
* 执行任务的机器ID,render_worker.id
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +63,10 @@ public class VideoRespVO {
|
|||||||
private Date createTime;
|
private Date createTime;
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
private Date updateTime;
|
private Date updateTime;
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
|
private Date startTime;
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
|
private Date endTime;
|
||||||
private Integer height;
|
private Integer height;
|
||||||
private Integer width;
|
private Integer width;
|
||||||
private BigDecimal duration;
|
private BigDecimal duration;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.ycwl.basic.model.pc.videoreview.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增视频评价请求DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class VideoReviewAddReqDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频ID(必填)
|
||||||
|
*/
|
||||||
|
private Long videoId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买评分 1-5(必填)
|
||||||
|
*/
|
||||||
|
private Integer rating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文字评价内容(可选)
|
||||||
|
*/
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机位评价JSON(可选)
|
||||||
|
* 格式: {"12345": {"清晰度":5,"构图":4,"色彩":5,"整体效果":4}, "12346": {...}}
|
||||||
|
* 外层key为机位ID,内层Map为该机位的各维度评分
|
||||||
|
* 评分维度: 清晰度, 构图, 色彩, 整体效果
|
||||||
|
*/
|
||||||
|
private Map<String, Map<String, Integer>> cameraPositionRating;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.ycwl.basic.model.pc.videoreview.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价列表查询请求DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class VideoReviewListReqDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频ID(可选)
|
||||||
|
*/
|
||||||
|
private Long videoId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区ID(可选)
|
||||||
|
*/
|
||||||
|
private Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评价人ID(可选)
|
||||||
|
*/
|
||||||
|
private Long creator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评分(可选,精确匹配)
|
||||||
|
*/
|
||||||
|
private Integer rating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最小评分(可选,范围查询)
|
||||||
|
*/
|
||||||
|
private Integer minRating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最大评分(可选,范围查询)
|
||||||
|
*/
|
||||||
|
private Integer maxRating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始时间(可选,格式: yyyy-MM-dd HH:mm:ss)
|
||||||
|
*/
|
||||||
|
private String startTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束时间(可选,格式: yyyy-MM-dd HH:mm:ss)
|
||||||
|
*/
|
||||||
|
private String endTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键词搜索(可选,搜索评价内容)
|
||||||
|
*/
|
||||||
|
private String keyword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页码(必填,默认1)
|
||||||
|
*/
|
||||||
|
private Integer pageNum = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每页数量(必填,默认10)
|
||||||
|
*/
|
||||||
|
private Integer pageSize = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序字段(可选,默认create_time)
|
||||||
|
* 可选值: create_time, rating, update_time
|
||||||
|
*/
|
||||||
|
private String orderBy = "create_time";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序方向(可选,默认DESC)
|
||||||
|
* 可选值: ASC, DESC
|
||||||
|
*/
|
||||||
|
private String orderDirection = "DESC";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.ycwl.basic.model.pc.videoreview.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价详情响应DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class VideoReviewRespDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评价ID
|
||||||
|
*/
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频ID
|
||||||
|
*/
|
||||||
|
private Long videoId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频URL(关联查询)
|
||||||
|
*/
|
||||||
|
private String videoUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区ID
|
||||||
|
*/
|
||||||
|
private Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区名称(关联查询)
|
||||||
|
*/
|
||||||
|
private String scenicName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评价人ID(管理员ID)
|
||||||
|
*/
|
||||||
|
private Long creator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评价人名称(关联查询)
|
||||||
|
*/
|
||||||
|
private String creatorName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买评分 1-5
|
||||||
|
*/
|
||||||
|
private Integer rating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文字评价内容
|
||||||
|
*/
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机位评价JSON
|
||||||
|
* 格式: {"12345": {"清晰度":5,"构图":4,"色彩":5,"整体效果":4}, "12346": {...}}
|
||||||
|
* 外层key为机位ID,内层Map为该机位的各维度评分
|
||||||
|
*/
|
||||||
|
private Map<String, Map<String, Integer>> cameraPositionRating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
|
private Date createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
|
private Date updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ycwl.basic.model.pc.videoreview.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价统计分析响应DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class VideoReviewStatisticsRespDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总评价数
|
||||||
|
*/
|
||||||
|
private Long totalCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平均评分
|
||||||
|
*/
|
||||||
|
private BigDecimal averageRating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评分分布
|
||||||
|
* key: 评分(1-5), value: 该评分的评价数量
|
||||||
|
*/
|
||||||
|
private Map<Integer, Long> ratingDistribution;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近7天评价趋势
|
||||||
|
* key: 日期(yyyy-MM-dd), value: 该日期的评价数量
|
||||||
|
*/
|
||||||
|
private Map<String, Long> recentTrend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区评价排行(前10)
|
||||||
|
*/
|
||||||
|
private List<ScenicReviewRank> scenicRankList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机位评价维度统计
|
||||||
|
* key: 维度名称, value: 平均分
|
||||||
|
*/
|
||||||
|
private Map<String, BigDecimal> cameraPositionAverage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区评价排行内部类
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public static class ScenicReviewRank {
|
||||||
|
/**
|
||||||
|
* 景区ID
|
||||||
|
*/
|
||||||
|
private Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区名称
|
||||||
|
*/
|
||||||
|
private String scenicName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评价数量
|
||||||
|
*/
|
||||||
|
private Long reviewCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平均评分
|
||||||
|
*/
|
||||||
|
private BigDecimal averageRating;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.ycwl.basic.model.pc.videoreview.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.ycwl.basic.handler.NestedMapTypeHandler;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.apache.ibatis.type.JdbcType;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价实体类
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("video_review")
|
||||||
|
public class VideoReviewEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键ID
|
||||||
|
*/
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频ID
|
||||||
|
*/
|
||||||
|
private Long videoId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区ID
|
||||||
|
*/
|
||||||
|
private Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评价人ID(管理员ID)
|
||||||
|
*/
|
||||||
|
private Long creator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买评分 1-5
|
||||||
|
*/
|
||||||
|
private Integer rating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文字评价内容
|
||||||
|
*/
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机位评价JSON
|
||||||
|
* 格式: {"12345": {"清晰度":5,"构图":4,"色彩":5,"整体效果":4}, "12346": {...}}
|
||||||
|
* 外层key为机位ID,内层Map为该机位的各维度评分
|
||||||
|
*/
|
||||||
|
@TableField(typeHandler = NestedMapTypeHandler.class, jdbcType = JdbcType.VARCHAR)
|
||||||
|
private Map<String, Map<String, Integer>> cameraPositionRating;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private Date createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
private Date updateTime;
|
||||||
|
}
|
||||||
@@ -70,17 +70,27 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
if (!isCouponApplicable(coupon, products, totalAmount)) {
|
if (!isCouponApplicable(coupon, products, totalAmount)) {
|
||||||
return BigDecimal.ZERO;
|
return BigDecimal.ZERO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算适用商品的总价
|
||||||
|
BigDecimal applicableProductsTotal = calculateApplicableProductsTotal(coupon, products);
|
||||||
|
|
||||||
BigDecimal discount;
|
BigDecimal discount;
|
||||||
if (coupon.getCouponType() == CouponType.PERCENTAGE) {
|
if (coupon.getCouponType() == CouponType.PERCENTAGE) {
|
||||||
discount = totalAmount.multiply(coupon.getDiscountValue().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
|
// 百分比优惠券基于适用商品总价计算,而非购物车总价
|
||||||
|
discount = applicableProductsTotal.multiply(coupon.getDiscountValue().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
|
||||||
if (coupon.getMaxDiscount() != null && discount.compareTo(coupon.getMaxDiscount()) > 0) {
|
if (coupon.getMaxDiscount() != null && discount.compareTo(coupon.getMaxDiscount()) > 0) {
|
||||||
discount = coupon.getMaxDiscount();
|
discount = coupon.getMaxDiscount();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 固定金额优惠券
|
||||||
discount = coupon.getDiscountValue();
|
discount = coupon.getDiscountValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 限制优惠金额不超过适用商品总价,防止优惠溢出到其他SKU
|
||||||
|
if (discount.compareTo(applicableProductsTotal) > 0) {
|
||||||
|
discount = applicableProductsTotal;
|
||||||
|
}
|
||||||
|
|
||||||
return discount.setScale(2, RoundingMode.HALF_UP);
|
return discount.setScale(2, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +138,37 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算适用商品的总价
|
||||||
|
* @param coupon 优惠券配置
|
||||||
|
* @param products 商品列表
|
||||||
|
* @return 适用商品的总价
|
||||||
|
*/
|
||||||
|
private BigDecimal calculateApplicableProductsTotal(PriceCouponConfig coupon, List<ProductItem> products) {
|
||||||
|
// 如果优惠券没有商品类型限制,返回所有商品总价
|
||||||
|
if (coupon.getApplicableProducts() == null || coupon.getApplicableProducts().isEmpty()) {
|
||||||
|
return products.stream()
|
||||||
|
.map(ProductItem::getSubtotal)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解析适用商品类型列表
|
||||||
|
List<String> applicableProductTypes = objectMapper.readValue(
|
||||||
|
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
|
||||||
|
|
||||||
|
// 计算适用商品的总价
|
||||||
|
return products.stream()
|
||||||
|
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
||||||
|
.map(ProductItem::getSubtotal)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解析适用商品类型失败", e);
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public CouponUseResult useCoupon(CouponUseRequest request) {
|
public CouponUseResult useCoupon(CouponUseRequest request) {
|
||||||
|
|||||||
611
src/main/java/com/ycwl/basic/puzzle/claude.md
Normal file
611
src/main/java/com/ycwl/basic/puzzle/claude.md
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
# Puzzle 拼图模块技术文档
|
||||||
|
|
||||||
|
## 📋 模块概述
|
||||||
|
|
||||||
|
Puzzle拼图模块是一个基于模板和元素的动态图片生成系统,支持按照预定义的模板配置,将动态数据渲染成最终的图片输出。常用于订单凭证、门票、证书等场景的图片生成。
|
||||||
|
|
||||||
|
**核心能力:**
|
||||||
|
- 模板化图片生成:通过模板+元素+动态数据生成定制化图片
|
||||||
|
- 多层次元素渲染:支持图片和文字元素的分层叠加
|
||||||
|
- 灵活的样式配置:支持位置、大小、透明度、旋转、圆角等属性
|
||||||
|
- 动态数据注入:通过elementKey进行动态数据替换
|
||||||
|
- 生成记录追踪:完整记录每次生成的参数和结果
|
||||||
|
|
||||||
|
**典型应用场景:**
|
||||||
|
- 订单凭证图片生成(用户头像+订单信息)
|
||||||
|
- 电子门票生成(二维码+用户信息)
|
||||||
|
- 电子证书生成(用户信息+证书模板)
|
||||||
|
- 营销海报生成(动态用户数据)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 架构设计
|
||||||
|
|
||||||
|
### 分层结构
|
||||||
|
|
||||||
|
```
|
||||||
|
puzzle/
|
||||||
|
├── controller/ # API接口层
|
||||||
|
│ ├── PuzzleGenerateController.java # 拼图生成接口
|
||||||
|
│ └── PuzzleTemplateController.java # 模板管理接口
|
||||||
|
├── service/ # 业务逻辑层
|
||||||
|
│ ├── IPuzzleGenerateService.java
|
||||||
|
│ ├── IPuzzleTemplateService.java
|
||||||
|
│ └── impl/
|
||||||
|
│ ├── PuzzleGenerateServiceImpl.java
|
||||||
|
│ └── PuzzleTemplateServiceImpl.java
|
||||||
|
├── mapper/ # 数据访问层
|
||||||
|
│ ├── PuzzleTemplateMapper.java
|
||||||
|
│ ├── PuzzleElementMapper.java
|
||||||
|
│ └── PuzzleGenerationRecordMapper.java
|
||||||
|
├── entity/ # 实体类
|
||||||
|
│ ├── PuzzleTemplateEntity.java # 模板实体
|
||||||
|
│ ├── PuzzleElementEntity.java # 元素实体
|
||||||
|
│ └── PuzzleGenerationRecordEntity.java # 生成记录实体
|
||||||
|
├── dto/ # 数据传输对象
|
||||||
|
│ ├── PuzzleGenerateRequest.java # 生成请求
|
||||||
|
│ ├── PuzzleGenerateResponse.java # 生成响应
|
||||||
|
│ ├── PuzzleTemplateDTO.java # 模板DTO
|
||||||
|
│ ├── PuzzleElementDTO.java # 元素DTO
|
||||||
|
│ ├── TemplateCreateRequest.java # 模板创建请求
|
||||||
|
│ └── ElementCreateRequest.java # 元素创建请求
|
||||||
|
└── util/ # 工具类
|
||||||
|
└── PuzzleImageRenderer.java # 图片渲染引擎(核心)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设计模式
|
||||||
|
|
||||||
|
1. **服务层模式(Service Layer)**:业务逻辑封装在service层,controller只负责接口适配
|
||||||
|
2. **DTO模式**:使用独立的DTO对象处理API输入输出,与Entity分离
|
||||||
|
3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)
|
||||||
|
4. **建造者模式**:通过模板+元素配置构建最终图片
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 核心组件详解
|
||||||
|
|
||||||
|
### 1. PuzzleImageRenderer - 图片渲染引擎
|
||||||
|
|
||||||
|
**职责**:核心渲染引擎,负责将模板配置和元素数据渲染成最终图片
|
||||||
|
|
||||||
|
**关键方法**:
|
||||||
|
- `render(PuzzleTemplateEntity, List<PuzzleElementEntity>, Map<String, String>)`:主渲染方法
|
||||||
|
- 创建画布(根据模板宽高)
|
||||||
|
- 绘制背景(纯色或图片背景)
|
||||||
|
- 按z-index顺序绘制元素
|
||||||
|
- 返回BufferedImage对象
|
||||||
|
|
||||||
|
**渲染流程**:
|
||||||
|
1. 创建画布:根据模板的canvasWidth和canvasHeight创建BufferedImage
|
||||||
|
2. 绘制背景:
|
||||||
|
- backgroundType=0:绘制纯色背景(backgroundColor)
|
||||||
|
- backgroundType=1:加载并绘制背景图片(backgroundImage)
|
||||||
|
3. 按z-index排序元素列表(升序,确保层级正确)
|
||||||
|
4. 逐个绘制元素:
|
||||||
|
- elementType=1(图片元素):
|
||||||
|
- 获取动态数据(dynamicData.get(elementKey))或使用defaultImageUrl
|
||||||
|
- 下载图片
|
||||||
|
- 根据imageFitMode缩放图片(CONTAIN/COVER/FILL/SCALE_DOWN)
|
||||||
|
- 应用borderRadius(圆角)
|
||||||
|
- 应用opacity(透明度)
|
||||||
|
- 应用rotation(旋转)
|
||||||
|
- 绘制到画布指定位置(xPosition, yPosition, width, height)
|
||||||
|
- elementType=2(文字元素):
|
||||||
|
- 获取动态数据或使用defaultText
|
||||||
|
- 设置字体(fontFamily, fontSize, fontWeight, fontStyle)
|
||||||
|
- 设置颜色(fontColor)
|
||||||
|
- 应用textAlign(对齐方式)
|
||||||
|
- 应用lineHeight(行高)
|
||||||
|
- 处理maxLines(最大行数截断)
|
||||||
|
- 应用textDecoration(下划线/删除线)
|
||||||
|
- 应用opacity和rotation
|
||||||
|
- 绘制到画布
|
||||||
|
|
||||||
|
**技术要点**:
|
||||||
|
- 使用Java AWT进行图形绘制
|
||||||
|
- 使用Hutool工具库处理图片下载和基础操作
|
||||||
|
- 支持图片圆角(通过Ellipse2D.Float或RoundRectangle2D.Float实现clip)
|
||||||
|
- 支持透明度(通过AlphaComposite实现)
|
||||||
|
- 支持旋转(通过Graphics2D.rotate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. PuzzleGenerateServiceImpl - 拼图生成服务
|
||||||
|
|
||||||
|
**职责**:协调拼图生成的完整流程
|
||||||
|
|
||||||
|
**核心方法**:
|
||||||
|
```java
|
||||||
|
PuzzleGenerateResponse generate(PuzzleGenerateRequest request)
|
||||||
|
```
|
||||||
|
|
||||||
|
**生成流程**:
|
||||||
|
1. **参数校验**:
|
||||||
|
- 校验templateCode是否提供
|
||||||
|
- 检查dynamicData是否为空
|
||||||
|
2. **加载模板**:
|
||||||
|
- 根据templateCode查询模板(PuzzleTemplateMapper)
|
||||||
|
- 检查模板是否存在且启用(status=1)
|
||||||
|
- 检查多租户权限(scenicId匹配)
|
||||||
|
3. **加载元素**:
|
||||||
|
- 根据templateId查询所有元素(PuzzleElementMapper)
|
||||||
|
- 按z-index升序排序
|
||||||
|
- 过滤未删除的元素(deleted=0)
|
||||||
|
4. **调用渲染引擎**:
|
||||||
|
- 调用`PuzzleImageRenderer.render()`
|
||||||
|
- 传入模板、元素列表、动态数据
|
||||||
|
5. **上传图片**:
|
||||||
|
- 将BufferedImage转换为字节流
|
||||||
|
- 估算文件大小
|
||||||
|
- 上传到对象存储(OSS)
|
||||||
|
- 获取图片URL
|
||||||
|
6. **创建生成记录**:
|
||||||
|
- 保存到puzzle_generation_record表
|
||||||
|
- 记录参数、结果、耗时等信息
|
||||||
|
7. **返回响应**:
|
||||||
|
- 返回图片URL、宽高、文件大小等信息
|
||||||
|
|
||||||
|
**辅助方法**:
|
||||||
|
- `createRecord()`:创建生成记录
|
||||||
|
- `uploadImage()`:上传图片到OSS
|
||||||
|
- `estimateFileSize()`:估算文件大小
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. PuzzleTemplateServiceImpl - 模板管理服务
|
||||||
|
|
||||||
|
**职责**:管理拼图模板和元素的CRUD操作
|
||||||
|
|
||||||
|
**模板管理方法**:
|
||||||
|
- `createTemplate(TemplateCreateRequest)`:创建模板
|
||||||
|
- `updateTemplate(Long, TemplateCreateRequest)`:更新模板
|
||||||
|
- `deleteTemplate(Long)`:逻辑删除模板(软删除)
|
||||||
|
- `getTemplateDetail(Long)`:查询模板详情(包含元素列表)
|
||||||
|
- `getTemplateByCode(String)`:根据code查询模板
|
||||||
|
- `listTemplates(Long, String, Integer)`:分页查询模板列表
|
||||||
|
|
||||||
|
**元素管理方法**:
|
||||||
|
- `addElement(ElementCreateRequest)`:添加单个元素
|
||||||
|
- `batchAddElements(Long, List<ElementCreateRequest>)`:批量添加元素
|
||||||
|
- `updateElement(Long, ElementCreateRequest)`:更新元素
|
||||||
|
- `deleteElement(Long)`:逻辑删除元素
|
||||||
|
- `getElementDetail(Long)`:查询元素详情
|
||||||
|
|
||||||
|
**业务逻辑要点**:
|
||||||
|
- 删除模板时会级联删除关联的所有元素
|
||||||
|
- 支持多租户隔离(根据scenicId)
|
||||||
|
- 支持按category分类查询
|
||||||
|
- 支持按status过滤启用/禁用的模板
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Controller接口层
|
||||||
|
|
||||||
|
#### PuzzleGenerateController
|
||||||
|
```java
|
||||||
|
POST /puzzle/generate
|
||||||
|
```
|
||||||
|
**功能**:生成拼图图片
|
||||||
|
|
||||||
|
**请求体**:`PuzzleGenerateRequest`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"templateCode": "order_certificate",
|
||||||
|
"userId": 123,
|
||||||
|
"orderId": "ORDER20250117001",
|
||||||
|
"businessType": "order",
|
||||||
|
"scenicId": 1,
|
||||||
|
"dynamicData": {
|
||||||
|
"userAvatar": "https://example.com/avatar.jpg",
|
||||||
|
"userName": "张三",
|
||||||
|
"orderNumber": "ORDER20250117001",
|
||||||
|
"qrCode": "https://example.com/qr.png"
|
||||||
|
},
|
||||||
|
"outputFormat": "PNG",
|
||||||
|
"quality": 90
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:`AjaxResult<PuzzleGenerateResponse>`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "生成成功",
|
||||||
|
"data": {
|
||||||
|
"imageUrl": "https://oss.example.com/puzzle/xxx.png",
|
||||||
|
"width": 750,
|
||||||
|
"height": 1334,
|
||||||
|
"fileSize": 245678,
|
||||||
|
"recordId": 12345
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PuzzleTemplateController
|
||||||
|
提供模板和元素的完整CRUD接口(详见Controller类)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 数据模型
|
||||||
|
|
||||||
|
### 1. puzzle_template - 拼图模板表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| id | BIGINT | 主键ID |
|
||||||
|
| name | VARCHAR(100) | 模板名称 |
|
||||||
|
| code | VARCHAR(50) | 模板编码(唯一,用于API调用) |
|
||||||
|
| canvas_width | INT | 画布宽度(像素) |
|
||||||
|
| canvas_height | INT | 画布高度(像素) |
|
||||||
|
| background_type | TINYINT | 背景类型:0-纯色 1-图片 |
|
||||||
|
| background_color | VARCHAR(20) | 背景颜色(hex格式,如#FFFFFF) |
|
||||||
|
| background_image | VARCHAR(500) | 背景图片URL |
|
||||||
|
| description | TEXT | 模板描述 |
|
||||||
|
| category | VARCHAR(50) | 模板分类(order/ticket/certificate等) |
|
||||||
|
| status | TINYINT | 状态:0-禁用 1-启用 |
|
||||||
|
| scenic_id | BIGINT | 景区ID(多租户隔离) |
|
||||||
|
| create_time | DATETIME | 创建时间 |
|
||||||
|
| update_time | DATETIME | 更新时间 |
|
||||||
|
| deleted | TINYINT | 删除标记:0-未删除 1-已删除 |
|
||||||
|
| deleted_at | DATETIME | 删除时间 |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- UNIQUE KEY `uk_code` (code, deleted)
|
||||||
|
- KEY `idx_scenic_id` (scenic_id)
|
||||||
|
- KEY `idx_category_status` (category, status, deleted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. puzzle_element - 拼图元素表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| id | BIGINT | 主键ID |
|
||||||
|
| template_id | BIGINT | 模板ID(外键) |
|
||||||
|
| element_type | TINYINT | 元素类型:1-图片 2-文字 |
|
||||||
|
| element_key | VARCHAR(50) | 元素标识(用于动态数据映射) |
|
||||||
|
| element_name | VARCHAR(100) | 元素名称(便于管理) |
|
||||||
|
|
||||||
|
**位置和布局属性**:
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| x_position | INT | X坐标(相对画布左上角) |
|
||||||
|
| y_position | INT | Y坐标(相对画布左上角) |
|
||||||
|
| width | INT | 宽度(像素) |
|
||||||
|
| height | INT | 高度(像素) |
|
||||||
|
| z_index | INT | 层级(数值越大越靠上) |
|
||||||
|
| rotation | INT | 旋转角度(0-360度,顺时针) |
|
||||||
|
| opacity | INT | 不透明度(0-100) |
|
||||||
|
|
||||||
|
**图片元素专有属性**:
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| default_image_url | VARCHAR(500) | 默认图片URL |
|
||||||
|
| image_fit_mode | VARCHAR(20) | 图片适配模式:CONTAIN/COVER/FILL/SCALE_DOWN |
|
||||||
|
| border_radius | INT | 圆角半径(像素) |
|
||||||
|
|
||||||
|
**文字元素专有属性**:
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| default_text | TEXT | 默认文本内容 |
|
||||||
|
| font_family | VARCHAR(50) | 字体名称 |
|
||||||
|
| font_size | INT | 字号(像素) |
|
||||||
|
| font_color | VARCHAR(20) | 字体颜色(hex) |
|
||||||
|
| font_weight | VARCHAR(20) | 字重:NORMAL/BOLD |
|
||||||
|
| font_style | VARCHAR(20) | 字体样式:NORMAL/ITALIC |
|
||||||
|
| text_align | VARCHAR(20) | 对齐方式:LEFT/CENTER/RIGHT |
|
||||||
|
| line_height | DECIMAL(3,2) | 行高倍数(如1.5) |
|
||||||
|
| max_lines | INT | 最大行数(NULL表示不限制) |
|
||||||
|
| text_decoration | VARCHAR(20) | 文本装饰:NONE/UNDERLINE/LINE_THROUGH |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- KEY `idx_template_id` (template_id, deleted)
|
||||||
|
- KEY `idx_element_key` (element_key)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. puzzle_generation_record - 拼图生成记录表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| id | BIGINT | 主键ID |
|
||||||
|
| template_id | BIGINT | 模板ID |
|
||||||
|
| template_code | VARCHAR(50) | 模板编码(冗余) |
|
||||||
|
| user_id | BIGINT | 用户ID |
|
||||||
|
| order_id | VARCHAR(50) | 关联订单号 |
|
||||||
|
| business_type | VARCHAR(50) | 业务类型 |
|
||||||
|
| generation_params | TEXT | 生成参数(JSON格式) |
|
||||||
|
| result_image_url | VARCHAR(500) | 生成的图片URL |
|
||||||
|
| result_file_size | BIGINT | 文件大小(字节) |
|
||||||
|
| result_width | INT | 图片宽度 |
|
||||||
|
| result_height | INT | 图片高度 |
|
||||||
|
| status | TINYINT | 状态:0-生成中 1-成功 2-失败 |
|
||||||
|
| error_message | TEXT | 错误信息(失败时) |
|
||||||
|
| generation_duration | INT | 生成耗时(毫秒) |
|
||||||
|
| retry_count | INT | 重试次数 |
|
||||||
|
| scenic_id | BIGINT | 景区ID |
|
||||||
|
| client_ip | VARCHAR(50) | 客户端IP |
|
||||||
|
| user_agent | VARCHAR(500) | 客户端User-Agent |
|
||||||
|
| create_time | DATETIME | 创建时间 |
|
||||||
|
| update_time | DATETIME | 更新时间 |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- KEY `idx_user_id` (user_id)
|
||||||
|
- KEY `idx_order_id` (order_id)
|
||||||
|
- KEY `idx_template_id` (template_id)
|
||||||
|
- KEY `idx_create_time` (create_time)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 关键业务流程
|
||||||
|
|
||||||
|
### 拼图生成完整流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户请求 → Controller接收
|
||||||
|
↓
|
||||||
|
验证templateCode和dynamicData
|
||||||
|
↓
|
||||||
|
根据templateCode查询模板(含权限校验)
|
||||||
|
↓
|
||||||
|
根据templateId查询所有元素(按z-index排序)
|
||||||
|
↓
|
||||||
|
调用PuzzleImageRenderer.render()
|
||||||
|
├─ 创建画布
|
||||||
|
├─ 绘制背景
|
||||||
|
├─ 遍历元素列表
|
||||||
|
│ ├─ 图片元素:下载图片 → 缩放/圆角/透明度/旋转 → 绘制
|
||||||
|
│ └─ 文字元素:设置字体样式 → 计算布局 → 绘制
|
||||||
|
└─ 返回BufferedImage
|
||||||
|
↓
|
||||||
|
将BufferedImage转换为字节流
|
||||||
|
↓
|
||||||
|
上传到OSS获取URL
|
||||||
|
↓
|
||||||
|
创建生成记录(保存参数和结果)
|
||||||
|
↓
|
||||||
|
返回响应(imageUrl、width、height、fileSize等)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图片适配模式说明
|
||||||
|
|
||||||
|
**CONTAIN(等比缩放适应)**:
|
||||||
|
- 图片完全显示在区域内
|
||||||
|
- 保持图片宽高比
|
||||||
|
- 可能留白
|
||||||
|
|
||||||
|
**COVER(等比缩放填充)**:
|
||||||
|
- 完全覆盖目标区域
|
||||||
|
- 保持图片宽高比
|
||||||
|
- 可能裁剪图片
|
||||||
|
|
||||||
|
**FILL(拉伸填充)**:
|
||||||
|
- 完全填充目标区域
|
||||||
|
- 不保持宽高比
|
||||||
|
- 可能变形
|
||||||
|
|
||||||
|
**SCALE_DOWN(缩小适应)**:
|
||||||
|
- 类似CONTAIN
|
||||||
|
- 但不放大图片(仅缩小)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
### 核心依赖
|
||||||
|
- **Spring Boot**:框架基础
|
||||||
|
- **MyBatis Plus**:数据访问
|
||||||
|
- **Lombok**:减少样板代码
|
||||||
|
- **Hutool**:工具类库(图片处理、HTTP下载)
|
||||||
|
- **Java AWT/ImageIO**:图形绘制和图片处理
|
||||||
|
- **SLF4J/Logback**:日志
|
||||||
|
|
||||||
|
### 外部依赖
|
||||||
|
- **OSS对象存储**:图片上传和存储
|
||||||
|
- **MySQL**:关系型数据库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 对外依赖
|
||||||
|
|
||||||
|
puzzle模块与其他模块的依赖关系:
|
||||||
|
|
||||||
|
| 依赖模块 | 依赖项 | 用途 |
|
||||||
|
|---------|--------|-----|
|
||||||
|
| storage | OSS上传服务 | 上传生成的图片到对象存储 |
|
||||||
|
| config | 全局配置 | 获取系统配置信息 |
|
||||||
|
| exception | 自定义异常 | 业务异常处理 |
|
||||||
|
| utils | 工具类 | 通用工具方法 |
|
||||||
|
|
||||||
|
**被依赖情况**:
|
||||||
|
- order模块:订单凭证图片生成
|
||||||
|
- ticket模块:电子门票图片生成
|
||||||
|
- 其他需要动态图片生成的业务模块
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 扩展指南
|
||||||
|
|
||||||
|
### 1. 新增元素类型
|
||||||
|
如需支持新的元素类型(如二维码元素、形状元素等):
|
||||||
|
1. 在`PuzzleElementEntity`中新增`elementType`枚举值
|
||||||
|
2. 在`PuzzleImageRenderer.render()`中添加新类型的渲染逻辑
|
||||||
|
3. 新增元素专有属性到`puzzle_element`表和实体类
|
||||||
|
4. 更新DTO和请求对象
|
||||||
|
|
||||||
|
### 2. 新增图片格式支持
|
||||||
|
当前支持PNG和JPEG,如需支持WebP、SVG等:
|
||||||
|
1. 更新`PuzzleGenerateRequest.outputFormat`校验逻辑
|
||||||
|
2. 修改`PuzzleGenerateServiceImpl.uploadImage()`中的格式转换逻辑
|
||||||
|
3. 注意浏览器兼容性
|
||||||
|
|
||||||
|
### 3. 新增渲染效果
|
||||||
|
如需支持阴影、边框、渐变等效果:
|
||||||
|
1. 在`PuzzleElementEntity`中新增对应的属性字段
|
||||||
|
2. 在`PuzzleImageRenderer`中实现对应的绘制逻辑
|
||||||
|
3. 使用Java AWT的相关API(如`setShadow`、`drawRect`等)
|
||||||
|
|
||||||
|
### 4. 批量生成优化
|
||||||
|
如需支持批量生成(如批量生成门票):
|
||||||
|
1. 新增批量生成接口`POST /puzzle/batchGenerate`
|
||||||
|
2. 使用线程池并发处理
|
||||||
|
3. 返回任务ID,支持异步查询结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 重要注意事项
|
||||||
|
|
||||||
|
### 1. 性能优化
|
||||||
|
- **图片缓存**:对于默认图片URL,考虑使用本地缓存避免重复下载
|
||||||
|
- **并发控制**:高并发场景下,生成接口应加限流保护
|
||||||
|
- **资源释放**:及时释放BufferedImage和Graphics2D对象,避免内存泄漏
|
||||||
|
- **异步处理**:对于复杂模板,考虑异步生成+回调通知
|
||||||
|
|
||||||
|
### 2. 安全性
|
||||||
|
- **URL校验**:对dynamicData中的图片URL进行白名单校验,防止SSRF攻击
|
||||||
|
- **文件大小限制**:限制下载图片的大小,防止资源耗尽
|
||||||
|
- **权限控制**:确保scenicId隔离,防止越权访问
|
||||||
|
- **输入校验**:严格校验所有输入参数,防止XSS和注入攻击
|
||||||
|
|
||||||
|
### 3. 多租户隔离
|
||||||
|
- 所有查询必须带上scenicId条件
|
||||||
|
- 创建模板和元素时必须关联正确的scenicId
|
||||||
|
- 生成拼图时校验模板的scenicId权限
|
||||||
|
|
||||||
|
### 4. 错误处理
|
||||||
|
- 图片下载失败时的降级策略(使用默认图片)
|
||||||
|
- 渲染失败时记录详细错误日志
|
||||||
|
- 对外暴露友好的错误提示
|
||||||
|
|
||||||
|
### 5. 字体问题
|
||||||
|
- **字体文件**:确保服务器安装了模板使用的字体文件
|
||||||
|
- **中文字体**:Linux服务器需要安装中文字体(如文泉驿)
|
||||||
|
- **字体回退**:设置字体回退机制,避免乱码
|
||||||
|
|
||||||
|
### 6. 数据一致性
|
||||||
|
- 删除模板时级联删除元素(软删除)
|
||||||
|
- 更新模板状态时考虑对正在生成的任务的影响
|
||||||
|
- 生成记录不可删除,仅供审计和统计
|
||||||
|
|
||||||
|
### 7. 监控和日志
|
||||||
|
- 记录每次生成的耗时,监控性能
|
||||||
|
- 记录生成失败的详细原因,便于排查
|
||||||
|
- 统计各模板的使用频率,优化热点模板
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 性能指标参考
|
||||||
|
|
||||||
|
**典型性能数据**(测试环境):
|
||||||
|
- 单张简单拼图(2-3个元素):< 500ms
|
||||||
|
- 单张复杂拼图(10+个元素):< 1500ms
|
||||||
|
- 图片下载耗时:200-500ms(取决于网络)
|
||||||
|
- 渲染耗时:50-200ms
|
||||||
|
- OSS上传耗时:100-300ms
|
||||||
|
|
||||||
|
**优化建议**:
|
||||||
|
- 使用CDN加速图片下载
|
||||||
|
- 预热常用模板的背景图片
|
||||||
|
- 使用Redis缓存模板和元素配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 示例代码
|
||||||
|
|
||||||
|
### 创建模板示例
|
||||||
|
```java
|
||||||
|
// 1. 创建模板
|
||||||
|
TemplateCreateRequest templateReq = new TemplateCreateRequest();
|
||||||
|
templateReq.setName("订单凭证模板");
|
||||||
|
templateReq.setCode("order_certificate_v1");
|
||||||
|
templateReq.setCanvasWidth(750);
|
||||||
|
templateReq.setCanvasHeight(1334);
|
||||||
|
templateReq.setBackgroundType(1);
|
||||||
|
templateReq.setBackgroundImage("https://oss.example.com/bg.jpg");
|
||||||
|
templateReq.setCategory("order");
|
||||||
|
templateReq.setScenicId(1L);
|
||||||
|
|
||||||
|
Long templateId = templateService.createTemplate(templateReq);
|
||||||
|
|
||||||
|
// 2. 添加元素 - 用户头像(图片元素)
|
||||||
|
ElementCreateRequest avatarElement = new ElementCreateRequest();
|
||||||
|
avatarElement.setTemplateId(templateId);
|
||||||
|
avatarElement.setElementType(1); // 图片
|
||||||
|
avatarElement.setElementKey("userAvatar");
|
||||||
|
avatarElement.setElementName("用户头像");
|
||||||
|
avatarElement.setXPosition(50);
|
||||||
|
avatarElement.setYPosition(100);
|
||||||
|
avatarElement.setWidth(100);
|
||||||
|
avatarElement.setHeight(100);
|
||||||
|
avatarElement.setZIndex(10);
|
||||||
|
avatarElement.setDefaultImageUrl("https://oss.example.com/default-avatar.png");
|
||||||
|
avatarElement.setImageFitMode("COVER");
|
||||||
|
avatarElement.setBorderRadius(50); // 圆形头像
|
||||||
|
avatarElement.setOpacity(100);
|
||||||
|
|
||||||
|
templateService.addElement(avatarElement);
|
||||||
|
|
||||||
|
// 3. 添加元素 - 用户名(文字元素)
|
||||||
|
ElementCreateRequest nameElement = new ElementCreateRequest();
|
||||||
|
nameElement.setTemplateId(templateId);
|
||||||
|
nameElement.setElementType(2); // 文字
|
||||||
|
nameElement.setElementKey("userName");
|
||||||
|
nameElement.setElementName("用户名");
|
||||||
|
nameElement.setXPosition(170);
|
||||||
|
nameElement.setYPosition(120);
|
||||||
|
nameElement.setWidth(300);
|
||||||
|
nameElement.setHeight(60);
|
||||||
|
nameElement.setZIndex(20);
|
||||||
|
nameElement.setDefaultText("用户名");
|
||||||
|
nameElement.setFontFamily("PingFang SC");
|
||||||
|
nameElement.setFontSize(28);
|
||||||
|
nameElement.setFontColor("#333333");
|
||||||
|
nameElement.setFontWeight("BOLD");
|
||||||
|
nameElement.setTextAlign("LEFT");
|
||||||
|
nameElement.setLineHeight(new BigDecimal("1.5"));
|
||||||
|
|
||||||
|
templateService.addElement(nameElement);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成拼图示例
|
||||||
|
```java
|
||||||
|
// 调用生成接口
|
||||||
|
PuzzleGenerateRequest request = new PuzzleGenerateRequest();
|
||||||
|
request.setTemplateCode("order_certificate_v1");
|
||||||
|
request.setUserId(123L);
|
||||||
|
request.setOrderId("ORDER20250117001");
|
||||||
|
request.setBusinessType("order");
|
||||||
|
request.setScenicId(1L);
|
||||||
|
|
||||||
|
Map<String, String> dynamicData = new HashMap<>();
|
||||||
|
dynamicData.put("userAvatar", "https://oss.example.com/user123/avatar.jpg");
|
||||||
|
dynamicData.put("userName", "张三");
|
||||||
|
dynamicData.put("orderNumber", "ORDER20250117001");
|
||||||
|
dynamicData.put("qrCode", "https://oss.example.com/qr/ORDER20250117001.png");
|
||||||
|
request.setDynamicData(dynamicData);
|
||||||
|
|
||||||
|
request.setOutputFormat("PNG");
|
||||||
|
request.setQuality(90);
|
||||||
|
|
||||||
|
PuzzleGenerateResponse response = generateService.generate(request);
|
||||||
|
System.out.println("生成成功,图片URL: " + response.getImageUrl());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关文档
|
||||||
|
|
||||||
|
- [MyBatis-Plus官方文档](https://baomidou.com/)
|
||||||
|
- [Hutool工具类文档](https://hutool.cn/)
|
||||||
|
- [Java AWT图形绘制教程](https://docs.oracle.com/javase/tutorial/2d/)
|
||||||
|
- [阿里云OSS Java SDK](https://help.aliyun.com/document_detail/32008.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 联系方式
|
||||||
|
|
||||||
|
如有问题或建议,请联系模块负责人或提交Issue。
|
||||||
|
|
||||||
|
**维护者**:Claude
|
||||||
|
**创建时间**:2025-01-17
|
||||||
|
**最后更新**:2025-01-17
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ycwl.basic.puzzle.controller;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||||
|
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图生成Controller(C端API)
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/puzzle")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PuzzleGenerateController {
|
||||||
|
|
||||||
|
private final IPuzzleGenerateService generateService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成拼图图片
|
||||||
|
*/
|
||||||
|
@PostMapping("/generate")
|
||||||
|
public ApiResponse<PuzzleGenerateResponse> generatePuzzle(@RequestBody PuzzleGenerateRequest request) {
|
||||||
|
log.info("拼图生成请求: templateCode={}, userId={}, orderId={}",
|
||||||
|
request.getTemplateCode(), request.getUserId(), request.getOrderId());
|
||||||
|
|
||||||
|
// 参数校验
|
||||||
|
if (request.getTemplateCode() == null || request.getTemplateCode().trim().isEmpty()) {
|
||||||
|
return ApiResponse.fail("模板编码不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
PuzzleGenerateResponse response = generateService.generate(request);
|
||||||
|
return ApiResponse.success(response);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("拼图生成参数错误: {}", e.getMessage());
|
||||||
|
return ApiResponse.fail(e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("拼图生成失败", e);
|
||||||
|
return ApiResponse.fail("图片生成失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.ycwl.basic.puzzle.controller;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||||
|
import com.ycwl.basic.puzzle.dto.TemplateCreateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图模板管理Controller(管理后台)
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/puzzle/admin")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PuzzleTemplateController {
|
||||||
|
|
||||||
|
private final IPuzzleTemplateService templateService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建模板
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates")
|
||||||
|
public ApiResponse<Long> createTemplate(@RequestBody TemplateCreateRequest request) {
|
||||||
|
log.info("创建模板请求: code={}, name={}", request.getCode(), request.getName());
|
||||||
|
Long templateId = templateService.createTemplate(request);
|
||||||
|
return ApiResponse.success(templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模板
|
||||||
|
*/
|
||||||
|
@PutMapping("/templates/{id}")
|
||||||
|
public ApiResponse<Void> updateTemplate(@PathVariable Long id,
|
||||||
|
@RequestBody TemplateCreateRequest request) {
|
||||||
|
log.info("更新模板请求: id={}", id);
|
||||||
|
templateService.updateTemplate(id, request);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除模板
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/templates/{id}")
|
||||||
|
public ApiResponse<Void> deleteTemplate(@PathVariable Long id) {
|
||||||
|
log.info("删除模板请求: id={}", id);
|
||||||
|
templateService.deleteTemplate(id);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模板详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/templates/{id}")
|
||||||
|
public ApiResponse<PuzzleTemplateDTO> getTemplateDetail(@PathVariable Long id) {
|
||||||
|
log.debug("获取模板详情: id={}", id);
|
||||||
|
PuzzleTemplateDTO template = templateService.getTemplateDetail(id);
|
||||||
|
return ApiResponse.success(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模板列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/templates")
|
||||||
|
public ApiResponse<List<PuzzleTemplateDTO>> listTemplates(
|
||||||
|
@RequestParam(required = false) Long scenicId,
|
||||||
|
@RequestParam(required = false) String category,
|
||||||
|
@RequestParam(required = false) Integer status) {
|
||||||
|
log.debug("查询模板列表: scenicId={}, category={}, status={}", scenicId, category, status);
|
||||||
|
List<PuzzleTemplateDTO> templates = templateService.listTemplates(scenicId, category, status);
|
||||||
|
return ApiResponse.success(templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为模板添加单个元素
|
||||||
|
*/
|
||||||
|
@PostMapping("/elements")
|
||||||
|
public ApiResponse<Long> addElement(@RequestBody ElementCreateRequest request) {
|
||||||
|
log.info("添加元素请求: templateId={}, elementKey={}", request.getTemplateId(), request.getElementKey());
|
||||||
|
Long elementId = templateService.addElement(request);
|
||||||
|
return ApiResponse.success(elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为模板批量添加元素
|
||||||
|
*/
|
||||||
|
@PostMapping("/templates/{templateId}/elements")
|
||||||
|
public ApiResponse<Void> batchAddElements(@PathVariable Long templateId,
|
||||||
|
@RequestBody List<ElementCreateRequest> elements) {
|
||||||
|
log.info("批量添加元素请求: templateId={}, count={}", templateId, elements.size());
|
||||||
|
templateService.batchAddElements(templateId, elements);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新元素
|
||||||
|
*/
|
||||||
|
@PutMapping("/elements/{id}")
|
||||||
|
public ApiResponse<Void> updateElement(@PathVariable Long id,
|
||||||
|
@RequestBody ElementCreateRequest request) {
|
||||||
|
log.info("更新元素请求: id={}", id);
|
||||||
|
templateService.updateElement(id, request);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除元素
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/elements/{id}")
|
||||||
|
public ApiResponse<Void> deleteElement(@PathVariable Long id) {
|
||||||
|
log.info("删除元素请求: id={}", id);
|
||||||
|
templateService.deleteElement(id);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取元素详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/elements/{id}")
|
||||||
|
public ApiResponse<PuzzleElementDTO> getElementDetail(@PathVariable Long id) {
|
||||||
|
log.debug("获取元素详情: id={}", id);
|
||||||
|
PuzzleElementDTO element = templateService.getElementDetail(id);
|
||||||
|
return ApiResponse.success(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.ycwl.basic.puzzle.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建元素请求DTO(重构版)
|
||||||
|
*
|
||||||
|
* 重构说明:
|
||||||
|
* - elementType从Integer改为String(TEXT、IMAGE等)
|
||||||
|
* - 删除所有type-specific字段
|
||||||
|
* - 新增config和configMap支持JSON配置
|
||||||
|
* - 支持两种方式:直接传JSON字符串 或 传Map对象
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ElementCreateRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板ID
|
||||||
|
*/
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
|
||||||
|
*/
|
||||||
|
private String elementType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素标识(用于动态数据映射)
|
||||||
|
*/
|
||||||
|
private String elementKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素名称(便于管理识别)
|
||||||
|
*/
|
||||||
|
private String elementName;
|
||||||
|
|
||||||
|
// ===== 位置和布局属性(所有元素通用) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X坐标(相对于画布左上角,像素)
|
||||||
|
*/
|
||||||
|
private Integer xPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Y坐标(相对于画布左上角,像素)
|
||||||
|
*/
|
||||||
|
private Integer yPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 宽度(像素)
|
||||||
|
*/
|
||||||
|
private Integer width;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高度(像素)
|
||||||
|
*/
|
||||||
|
private Integer height;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 层级(数值越大越靠上)
|
||||||
|
*/
|
||||||
|
private Integer zIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旋转角度(0-360度,顺时针)
|
||||||
|
*/
|
||||||
|
private Integer rotation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不透明度(0-100,100为完全不透明)
|
||||||
|
*/
|
||||||
|
private Integer opacity;
|
||||||
|
|
||||||
|
// ===== JSON配置(二选一) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON配置字符串(直接传入JSON字符串)
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* - 文字元素:"{\"defaultText\":\"用户名\", \"fontFamily\":\"微软雅黑\", \"fontSize\":14}"
|
||||||
|
* - 图片元素:"{\"defaultImageUrl\":\"https://...\", \"imageFitMode\":\"COVER\", \"borderRadius\":10}"
|
||||||
|
*/
|
||||||
|
private String config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON配置Map(传入Map对象,框架自动序列化为JSON)
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* Map<String, Object> configMap = new HashMap<>();
|
||||||
|
* configMap.put("defaultText", "用户名");
|
||||||
|
* configMap.put("fontSize", 14);
|
||||||
|
* request.setConfigMap(configMap);
|
||||||
|
*/
|
||||||
|
private Map<String, Object> configMap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.ycwl.basic.puzzle.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图元素DTO(重构版)
|
||||||
|
*
|
||||||
|
* 重构说明:
|
||||||
|
* - elementType从Integer改为String
|
||||||
|
* - 删除所有type-specific字段
|
||||||
|
* - 新增config和configMap字段
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PuzzleElementDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素ID
|
||||||
|
*/
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板ID
|
||||||
|
*/
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
|
||||||
|
*/
|
||||||
|
private String elementType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素标识(用于动态数据映射)
|
||||||
|
*/
|
||||||
|
private String elementKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素名称(便于管理识别)
|
||||||
|
*/
|
||||||
|
private String elementName;
|
||||||
|
|
||||||
|
// ===== 位置和布局属性(所有元素通用) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X坐标(相对于画布左上角,像素)
|
||||||
|
*/
|
||||||
|
private Integer xPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Y坐标(相对于画布左上角,像素)
|
||||||
|
*/
|
||||||
|
private Integer yPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 宽度(像素)
|
||||||
|
*/
|
||||||
|
private Integer width;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高度(像素)
|
||||||
|
*/
|
||||||
|
private Integer height;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 层级(数值越大越靠上)
|
||||||
|
*/
|
||||||
|
private Integer zIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旋转角度(0-360度,顺时针)
|
||||||
|
*/
|
||||||
|
private Integer rotation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不透明度(0-100,100为完全不透明)
|
||||||
|
*/
|
||||||
|
private Integer opacity;
|
||||||
|
|
||||||
|
// ===== JSON配置 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON配置字符串
|
||||||
|
*/
|
||||||
|
private String config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON配置Map(方便前端使用)
|
||||||
|
*/
|
||||||
|
private Map<String, Object> configMap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.ycwl.basic.puzzle.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图生成请求DTO
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PuzzleGenerateRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板编码(必填)
|
||||||
|
*/
|
||||||
|
private String templateCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID(可选)
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单ID(可选)
|
||||||
|
*/
|
||||||
|
private String orderId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务类型(可选)
|
||||||
|
*/
|
||||||
|
private String businessType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区ID(可选)
|
||||||
|
*/
|
||||||
|
private Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态数据(key为元素的elementKey,value为实际值)
|
||||||
|
* 例如:{"userAvatar": "https://...", "userName": "张三", "orderNumber": "ORDER123"}
|
||||||
|
*/
|
||||||
|
private Map<String, String> dynamicData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出格式(可选,默认PNG)
|
||||||
|
* 支持:PNG、JPEG
|
||||||
|
*/
|
||||||
|
private String outputFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片质量(可选,默认90,范围0-100)
|
||||||
|
* 仅对JPEG格式有效
|
||||||
|
*/
|
||||||
|
private Integer quality;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.ycwl.basic.puzzle.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图生成响应DTO
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class PuzzleGenerateResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成的图片URL
|
||||||
|
*/
|
||||||
|
private String imageUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件大小(字节)
|
||||||
|
*/
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片宽度
|
||||||
|
*/
|
||||||
|
private Integer width;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片高度
|
||||||
|
*/
|
||||||
|
private Integer height;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成耗时(毫秒)
|
||||||
|
*/
|
||||||
|
private Integer generationDuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成记录ID
|
||||||
|
*/
|
||||||
|
private Long recordId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建成功响应
|
||||||
|
*/
|
||||||
|
public static PuzzleGenerateResponse success(String imageUrl, Long fileSize, Integer width, Integer height, Integer duration, Long recordId) {
|
||||||
|
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.ycwl.basic.puzzle.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图模板详情DTO(包含元素列表)
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PuzzleTemplateDTO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板ID
|
||||||
|
*/
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板名称
|
||||||
|
*/
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板编码
|
||||||
|
*/
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布宽度(像素)
|
||||||
|
*/
|
||||||
|
private Integer canvasWidth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布高度(像素)
|
||||||
|
*/
|
||||||
|
private Integer canvasHeight;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景类型:0-纯色 1-图片
|
||||||
|
*/
|
||||||
|
private Integer backgroundType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景颜色
|
||||||
|
*/
|
||||||
|
private String backgroundColor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景图片URL
|
||||||
|
*/
|
||||||
|
private String backgroundImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板描述
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板分类
|
||||||
|
*/
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-禁用 1-启用
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区ID
|
||||||
|
*/
|
||||||
|
private Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素列表
|
||||||
|
*/
|
||||||
|
private List<PuzzleElementDTO> elements;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.ycwl.basic.puzzle.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建模板请求DTO
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class TemplateCreateRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板名称
|
||||||
|
*/
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板编码
|
||||||
|
*/
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布宽度
|
||||||
|
*/
|
||||||
|
private Integer canvasWidth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布高度
|
||||||
|
*/
|
||||||
|
private Integer canvasHeight;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景类型:0-纯色 1-图片
|
||||||
|
*/
|
||||||
|
private Integer backgroundType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景颜色
|
||||||
|
*/
|
||||||
|
private String backgroundColor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景图片URL
|
||||||
|
*/
|
||||||
|
private String backgroundImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板描述
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板分类
|
||||||
|
*/
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-禁用 1-启用
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区ID
|
||||||
|
*/
|
||||||
|
private Long scenicId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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() {
|
||||||
|
// 默认不做验证
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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注册表已清空");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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为直角)
|
||||||
|
*/
|
||||||
|
private Integer borderRadius = 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格式(可选)
|
||||||
|
if (StrUtil.isNotBlank(defaultImageUrl)) {
|
||||||
|
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||||
|
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getConfigSchema() {
|
||||||
|
return "{\n" +
|
||||||
|
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
|
||||||
|
" \"imageFitMode\": \"CONTAIN|COVER|FILL|SCALE_DOWN\",\n" +
|
||||||
|
" \"borderRadius\": 0\n" +
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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" +
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.ycwl.basic.puzzle.element.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素类型枚举
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
public enum ElementType {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文字元素
|
||||||
|
*/
|
||||||
|
TEXT("TEXT", "文字元素", "com.ycwl.basic.puzzle.element.impl.TextElement"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定图片元素
|
||||||
|
*/
|
||||||
|
IMAGE("IMAGE", "图片元素", "com.ycwl.basic.puzzle.element.impl.ImageElement"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二维码元素(未来扩展)
|
||||||
|
*/
|
||||||
|
QRCODE("QRCODE", "二维码元素", "com.ycwl.basic.puzzle.element.impl.QRCodeElement"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渐变元素(未来扩展)
|
||||||
|
*/
|
||||||
|
GRADIENT("GRADIENT", "渐变元素", "com.ycwl.basic.puzzle.element.impl.GradientElement"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 形状元素(未来扩展)
|
||||||
|
*/
|
||||||
|
SHAPE("SHAPE", "形状元素", "com.ycwl.basic.puzzle.element.impl.ShapeElement"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态图片元素(未来扩展)
|
||||||
|
*/
|
||||||
|
DYNAMIC_IMAGE("DYNAMIC_IMAGE", "动态图片元素", "com.ycwl.basic.puzzle.element.impl.DynamicImageElement");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型代码
|
||||||
|
*/
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型名称
|
||||||
|
*/
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实现类全限定名
|
||||||
|
*/
|
||||||
|
private final String implementationClass;
|
||||||
|
|
||||||
|
ElementType(String code, String name, String implementationClass) {
|
||||||
|
this.code = code;
|
||||||
|
this.name = name;
|
||||||
|
this.implementationClass = implementationClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getImplementationClass() {
|
||||||
|
return implementationClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据code获取枚举
|
||||||
|
*
|
||||||
|
* @param code 类型代码
|
||||||
|
* @return 枚举实例
|
||||||
|
*/
|
||||||
|
public static ElementType fromCode(String code) {
|
||||||
|
for (ElementType type : values()) {
|
||||||
|
if (type.code.equalsIgnoreCase(code)) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("未知的元素类型: " + code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查类型是否已实现
|
||||||
|
*
|
||||||
|
* @return true-已实现,false-未实现
|
||||||
|
*/
|
||||||
|
public boolean isImplemented() {
|
||||||
|
return this == TEXT || this == IMAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package com.ycwl.basic.puzzle.element.impl;
|
||||||
|
|
||||||
|
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.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.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片元素实现
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class ImageElement extends BaseElement {
|
||||||
|
|
||||||
|
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对象
|
||||||
|
*/
|
||||||
|
private BufferedImage downloadImage(String imageUrl) {
|
||||||
|
try {
|
||||||
|
log.debug("下载图片: url={}", imageUrl);
|
||||||
|
|
||||||
|
// 判断是否为本地文件路径
|
||||||
|
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
||||||
|
// 网络图片
|
||||||
|
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
|
||||||
|
return ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||||
|
} else {
|
||||||
|
// 本地文件
|
||||||
|
File file = new File(imageUrl);
|
||||||
|
if (file.exists()) {
|
||||||
|
return ImageIO.read(file);
|
||||||
|
} else {
|
||||||
|
log.error("本地图片文件不存在: path={}", imageUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("图片下载失败: url={}", imageUrl, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩放图片(根据适配模式)
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// 起始Y坐标
|
||||||
|
int y = position.getY() + 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.ycwl.basic.puzzle.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图元素实体类(重构版)
|
||||||
|
* 对应表:puzzle_element
|
||||||
|
*
|
||||||
|
* 重构说明:
|
||||||
|
* - element_type 从TINYINT改为VARCHAR,支持TEXT、IMAGE、QRCODE等类型
|
||||||
|
* - 删除所有type-specific字段,改用JSON配置存储
|
||||||
|
* - 通过config字段存储元素特定配置,支持灵活扩展
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("puzzle_element")
|
||||||
|
public class PuzzleElementEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键ID
|
||||||
|
*/
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板ID
|
||||||
|
*/
|
||||||
|
@TableField("template_id")
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
|
||||||
|
*/
|
||||||
|
@TableField("element_type")
|
||||||
|
private String elementType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素标识(用于动态数据映射)
|
||||||
|
*/
|
||||||
|
@TableField("element_key")
|
||||||
|
private String elementKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素名称(便于管理识别)
|
||||||
|
*/
|
||||||
|
@TableField("element_name")
|
||||||
|
private String elementName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON配置(元素特定配置)
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* - 文字元素:{"defaultText":"用户名", "fontFamily":"微软雅黑", "fontSize":14, ...}
|
||||||
|
* - 图片元素:{"defaultImageUrl":"https://...", "imageFitMode":"COVER", "borderRadius":10, ...}
|
||||||
|
*/
|
||||||
|
@TableField("config")
|
||||||
|
private String config;
|
||||||
|
|
||||||
|
// ===== 位置和布局属性(所有元素通用) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X坐标(相对于画布左上角,像素)
|
||||||
|
*/
|
||||||
|
@TableField("x_position")
|
||||||
|
private Integer xPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Y坐标(相对于画布左上角,像素)
|
||||||
|
*/
|
||||||
|
@TableField("y_position")
|
||||||
|
private Integer yPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 宽度(像素)
|
||||||
|
*/
|
||||||
|
@TableField("width")
|
||||||
|
private Integer width;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高度(像素)
|
||||||
|
*/
|
||||||
|
@TableField("height")
|
||||||
|
private Integer height;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 层级(数值越大越靠上,决定绘制顺序)
|
||||||
|
*/
|
||||||
|
@TableField("z_index")
|
||||||
|
private Integer zIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旋转角度(0-360度,顺时针)
|
||||||
|
*/
|
||||||
|
@TableField("rotation")
|
||||||
|
private Integer rotation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不透明度(0-100,100为完全不透明)
|
||||||
|
*/
|
||||||
|
@TableField("opacity")
|
||||||
|
private Integer opacity;
|
||||||
|
|
||||||
|
// ===== 元数据 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@TableField("create_time")
|
||||||
|
private Date createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@TableField("update_time")
|
||||||
|
private Date updateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除标记:0-未删除 1-已删除
|
||||||
|
*/
|
||||||
|
@TableField("deleted")
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除时间
|
||||||
|
*/
|
||||||
|
@TableField("deleted_at")
|
||||||
|
private Date deletedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.ycwl.basic.puzzle.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图生成记录实体类
|
||||||
|
* 对应表:puzzle_generation_record
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("puzzle_generation_record")
|
||||||
|
public class PuzzleGenerationRecordEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键ID
|
||||||
|
*/
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板ID
|
||||||
|
*/
|
||||||
|
@TableField("template_id")
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板编码(冗余字段,方便查询)
|
||||||
|
*/
|
||||||
|
@TableField("template_code")
|
||||||
|
private String templateCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
@TableField("user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联订单号
|
||||||
|
*/
|
||||||
|
@TableField("order_id")
|
||||||
|
private String orderId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务类型(如:order-订单 ticket-门票 certificate-证书)
|
||||||
|
*/
|
||||||
|
@TableField("business_type")
|
||||||
|
private String businessType;
|
||||||
|
|
||||||
|
// ===== 生成参数和结果 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成参数(动态数据,JSON格式存储)
|
||||||
|
*/
|
||||||
|
@TableField("generation_params")
|
||||||
|
private String generationParams;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成的图片URL
|
||||||
|
*/
|
||||||
|
@TableField("result_image_url")
|
||||||
|
private String resultImageUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件大小(字节)
|
||||||
|
*/
|
||||||
|
@TableField("result_file_size")
|
||||||
|
private Long resultFileSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成图片宽度
|
||||||
|
*/
|
||||||
|
@TableField("result_width")
|
||||||
|
private Integer resultWidth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成图片高度
|
||||||
|
*/
|
||||||
|
@TableField("result_height")
|
||||||
|
private Integer resultHeight;
|
||||||
|
|
||||||
|
// ===== 状态和性能统计 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-生成中 1-成功 2-失败
|
||||||
|
*/
|
||||||
|
@TableField("status")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误信息(失败时记录)
|
||||||
|
*/
|
||||||
|
@TableField("error_message")
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成耗时(毫秒)
|
||||||
|
*/
|
||||||
|
@TableField("generation_duration")
|
||||||
|
private Integer generationDuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试次数
|
||||||
|
*/
|
||||||
|
@TableField("retry_count")
|
||||||
|
private Integer retryCount;
|
||||||
|
|
||||||
|
// ===== 多租户和扩展字段 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区ID
|
||||||
|
*/
|
||||||
|
@TableField("scenic_id")
|
||||||
|
private Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端IP
|
||||||
|
*/
|
||||||
|
@TableField("client_ip")
|
||||||
|
private String clientIp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端User-Agent
|
||||||
|
*/
|
||||||
|
@TableField("user_agent")
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@TableField("create_time")
|
||||||
|
private Date createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@TableField("update_time")
|
||||||
|
private Date updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.ycwl.basic.puzzle.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图模板实体类
|
||||||
|
* 对应表:puzzle_template
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("puzzle_template")
|
||||||
|
public class PuzzleTemplateEntity {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键ID
|
||||||
|
*/
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板名称
|
||||||
|
*/
|
||||||
|
@TableField("name")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板编码(用于API调用)
|
||||||
|
*/
|
||||||
|
@TableField("code")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布宽度(像素)
|
||||||
|
*/
|
||||||
|
@TableField("canvas_width")
|
||||||
|
private Integer canvasWidth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布高度(像素)
|
||||||
|
*/
|
||||||
|
@TableField("canvas_height")
|
||||||
|
private Integer canvasHeight;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景类型:0-纯色 1-图片
|
||||||
|
*/
|
||||||
|
@TableField("background_type")
|
||||||
|
private Integer backgroundType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景颜色(hex格式)
|
||||||
|
*/
|
||||||
|
@TableField("background_color")
|
||||||
|
private String backgroundColor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 背景图片URL
|
||||||
|
*/
|
||||||
|
@TableField("background_image")
|
||||||
|
private String backgroundImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板描述
|
||||||
|
*/
|
||||||
|
@TableField("description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板分类(如:order-订单 ticket-票务 certificate-证书)
|
||||||
|
*/
|
||||||
|
@TableField("category")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-禁用 1-启用
|
||||||
|
*/
|
||||||
|
@TableField("status")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区ID(用于多租户隔离)
|
||||||
|
*/
|
||||||
|
@TableField("scenic_id")
|
||||||
|
private Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@TableField("create_time")
|
||||||
|
private Date createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@TableField("update_time")
|
||||||
|
private Date updateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除标记:0-未删除 1-已删除
|
||||||
|
*/
|
||||||
|
@TableField("deleted")
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除时间
|
||||||
|
*/
|
||||||
|
@TableField("deleted_at")
|
||||||
|
private Date deletedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.ycwl.basic.puzzle.mapper;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图元素Mapper接口
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
public interface PuzzleElementMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询元素
|
||||||
|
*/
|
||||||
|
PuzzleElementEntity getById(@Param("id") Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模板ID查询元素列表(按z-index排序)
|
||||||
|
*/
|
||||||
|
List<PuzzleElementEntity> getByTemplateId(@Param("templateId") Long templateId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入元素
|
||||||
|
*/
|
||||||
|
int insert(PuzzleElementEntity entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量插入元素
|
||||||
|
*/
|
||||||
|
int batchInsert(@Param("list") List<PuzzleElementEntity> list);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新元素
|
||||||
|
*/
|
||||||
|
int update(PuzzleElementEntity entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除元素(逻辑删除)
|
||||||
|
*/
|
||||||
|
int deleteById(@Param("id") Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模板ID删除所有元素(逻辑删除)
|
||||||
|
*/
|
||||||
|
int deleteByTemplateId(@Param("templateId") Long templateId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ycwl.basic.puzzle.mapper;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图生成记录Mapper接口
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
public interface PuzzleGenerationRecordMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询记录
|
||||||
|
*/
|
||||||
|
PuzzleGenerationRecordEntity getById(@Param("id") Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户的生成记录列表
|
||||||
|
*/
|
||||||
|
List<PuzzleGenerationRecordEntity> listByUserId(@Param("userId") Long userId,
|
||||||
|
@Param("limit") Integer limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询订单的生成记录列表
|
||||||
|
*/
|
||||||
|
List<PuzzleGenerationRecordEntity> listByOrderId(@Param("orderId") String orderId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入记录
|
||||||
|
*/
|
||||||
|
int insert(PuzzleGenerationRecordEntity entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新记录
|
||||||
|
*/
|
||||||
|
int update(PuzzleGenerationRecordEntity entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新为成功状态
|
||||||
|
*/
|
||||||
|
int updateSuccess(@Param("id") Long id,
|
||||||
|
@Param("resultImageUrl") String resultImageUrl,
|
||||||
|
@Param("resultFileSize") Long resultFileSize,
|
||||||
|
@Param("resultWidth") Integer resultWidth,
|
||||||
|
@Param("resultHeight") Integer resultHeight,
|
||||||
|
@Param("generationDuration") Integer generationDuration);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新为失败状态
|
||||||
|
*/
|
||||||
|
int updateFail(@Param("id") Long id,
|
||||||
|
@Param("errorMessage") String errorMessage);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.ycwl.basic.puzzle.mapper;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图模板Mapper接口
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
public interface PuzzleTemplateMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查询模板
|
||||||
|
*/
|
||||||
|
PuzzleTemplateEntity getById(@Param("id") Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模板编码查询模板
|
||||||
|
*/
|
||||||
|
PuzzleTemplateEntity getByCode(@Param("code") String code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询模板列表
|
||||||
|
*/
|
||||||
|
List<PuzzleTemplateEntity> list(@Param("scenicId") Long scenicId,
|
||||||
|
@Param("category") String category,
|
||||||
|
@Param("status") Integer status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入模板
|
||||||
|
*/
|
||||||
|
int insert(PuzzleTemplateEntity entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模板
|
||||||
|
*/
|
||||||
|
int update(PuzzleTemplateEntity entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除模板(逻辑删除)
|
||||||
|
*/
|
||||||
|
int deleteById(@Param("id") Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模板编码是否存在
|
||||||
|
*/
|
||||||
|
int countByCode(@Param("code") String code, @Param("excludeId") Long excludeId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ycwl.basic.puzzle.service;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图图片生成服务接口
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
public interface IPuzzleGenerateService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成拼图图片
|
||||||
|
*
|
||||||
|
* @param request 生成请求
|
||||||
|
* @return 生成结果(包含图片URL等信息)
|
||||||
|
*/
|
||||||
|
PuzzleGenerateResponse generate(PuzzleGenerateRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.ycwl.basic.puzzle.service;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||||
|
import com.ycwl.basic.puzzle.dto.TemplateCreateRequest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图模板管理服务接口
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
public interface IPuzzleTemplateService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建模板
|
||||||
|
*/
|
||||||
|
Long createTemplate(TemplateCreateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模板
|
||||||
|
*/
|
||||||
|
void updateTemplate(Long id, TemplateCreateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除模板(会同时删除关联的元素)
|
||||||
|
*/
|
||||||
|
void deleteTemplate(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模板详情(包含元素列表)
|
||||||
|
*/
|
||||||
|
PuzzleTemplateDTO getTemplateDetail(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据编码获取模板详情
|
||||||
|
*/
|
||||||
|
PuzzleTemplateDTO getTemplateByCode(String code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模板列表
|
||||||
|
*/
|
||||||
|
List<PuzzleTemplateDTO> listTemplates(Long scenicId, String category, Integer status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为模板添加元素
|
||||||
|
*/
|
||||||
|
Long addElement(ElementCreateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量添加元素
|
||||||
|
*/
|
||||||
|
void batchAddElements(Long templateId, List<ElementCreateRequest> elements);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新元素
|
||||||
|
*/
|
||||||
|
void updateElement(Long id, ElementCreateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除元素
|
||||||
|
*/
|
||||||
|
void deleteElement(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取元素详情
|
||||||
|
*/
|
||||||
|
PuzzleElementDTO getElementDetail(Long id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package com.ycwl.basic.puzzle.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||||
|
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||||
|
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||||
|
import com.ycwl.basic.storage.StorageFactory;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图图片生成服务实现
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||||
|
|
||||||
|
private final PuzzleTemplateMapper templateMapper;
|
||||||
|
private final PuzzleElementMapper elementMapper;
|
||||||
|
private final PuzzleGenerationRecordMapper recordMapper;
|
||||||
|
private final PuzzleImageRenderer imageRenderer;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) {
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
log.info("开始生成拼图: templateCode={}, userId={}, orderId={}",
|
||||||
|
request.getTemplateCode(), request.getUserId(), request.getOrderId());
|
||||||
|
|
||||||
|
// 1. 查询模板和元素
|
||||||
|
PuzzleTemplateEntity template = templateMapper.getByCode(request.getTemplateCode());
|
||||||
|
if (template == null) {
|
||||||
|
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.getStatus() != 1) {
|
||||||
|
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
|
||||||
|
if (elements.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 按z-index排序元素
|
||||||
|
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
|
||||||
|
Comparator.nullsFirst(Comparator.naturalOrder())));
|
||||||
|
|
||||||
|
// 3. 创建生成记录
|
||||||
|
PuzzleGenerationRecordEntity record = createRecord(template, request);
|
||||||
|
recordMapper.insert(record);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4. 渲染图片
|
||||||
|
BufferedImage resultImage = imageRenderer.render(template, elements, request.getDynamicData());
|
||||||
|
|
||||||
|
// 5. 上传到OSS
|
||||||
|
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
|
||||||
|
log.info("图片上传成功: url={}", imageUrl);
|
||||||
|
|
||||||
|
// 6. 更新记录为成功
|
||||||
|
long duration = (int) (System.currentTimeMillis() - startTime);
|
||||||
|
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
|
||||||
|
recordMapper.updateSuccess(
|
||||||
|
record.getId(),
|
||||||
|
imageUrl,
|
||||||
|
fileSize,
|
||||||
|
resultImage.getWidth(),
|
||||||
|
resultImage.getHeight(),
|
||||||
|
(int) duration
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
|
||||||
|
record.getId(), imageUrl, duration);
|
||||||
|
|
||||||
|
return PuzzleGenerateResponse.success(
|
||||||
|
imageUrl,
|
||||||
|
fileSize,
|
||||||
|
resultImage.getWidth(),
|
||||||
|
resultImage.getHeight(),
|
||||||
|
(int) duration,
|
||||||
|
record.getId()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
|
||||||
|
// 更新记录为失败
|
||||||
|
recordMapper.updateFail(record.getId(), e.getMessage());
|
||||||
|
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建生成记录
|
||||||
|
*/
|
||||||
|
private PuzzleGenerationRecordEntity createRecord(PuzzleTemplateEntity template, PuzzleGenerateRequest request) {
|
||||||
|
PuzzleGenerationRecordEntity record = new PuzzleGenerationRecordEntity();
|
||||||
|
record.setTemplateId(template.getId());
|
||||||
|
record.setTemplateCode(template.getCode());
|
||||||
|
record.setUserId(request.getUserId());
|
||||||
|
record.setOrderId(request.getOrderId());
|
||||||
|
record.setBusinessType(request.getBusinessType());
|
||||||
|
record.setScenicId(request.getScenicId());
|
||||||
|
record.setStatus(0); // 生成中
|
||||||
|
record.setRetryCount(0);
|
||||||
|
|
||||||
|
// 将动态数据保存为JSON
|
||||||
|
if (request.getDynamicData() != null && !request.getDynamicData().isEmpty()) {
|
||||||
|
record.setGenerationParams(JSONUtil.toJsonStr(request.getDynamicData()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package com.ycwl.basic.puzzle.service.impl;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.ycwl.basic.puzzle.util.ElementConfigHelper;
|
||||||
|
|
||||||
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
|
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||||
|
import com.ycwl.basic.puzzle.dto.TemplateCreateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||||
|
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图模板管理服务实现
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
|
||||||
|
|
||||||
|
private final PuzzleTemplateMapper templateMapper;
|
||||||
|
private final PuzzleElementMapper elementMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Long createTemplate(TemplateCreateRequest request) {
|
||||||
|
log.info("创建拼图模板: code={}, name={}", request.getCode(), request.getName());
|
||||||
|
|
||||||
|
// 检查编码是否已存在
|
||||||
|
int count = templateMapper.countByCode(request.getCode(), null);
|
||||||
|
if (count > 0) {
|
||||||
|
throw new IllegalArgumentException("模板编码已存在: " + request.getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为实体并插入
|
||||||
|
PuzzleTemplateEntity entity = BeanUtil.copyProperties(request, PuzzleTemplateEntity.class);
|
||||||
|
entity.setDeleted(0);
|
||||||
|
templateMapper.insert(entity);
|
||||||
|
|
||||||
|
log.info("拼图模板创建成功: id={}, code={}", entity.getId(), entity.getCode());
|
||||||
|
return entity.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void updateTemplate(Long id, TemplateCreateRequest request) {
|
||||||
|
log.info("更新拼图模板: id={}", id);
|
||||||
|
|
||||||
|
// 检查模板是否存在
|
||||||
|
PuzzleTemplateEntity existing = templateMapper.getById(id);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("模板不存在: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果修改了编码,检查新编码是否已存在
|
||||||
|
if (request.getCode() != null && !request.getCode().equals(existing.getCode())) {
|
||||||
|
int count = templateMapper.countByCode(request.getCode(), id);
|
||||||
|
if (count > 0) {
|
||||||
|
throw new IllegalArgumentException("模板编码已存在: " + request.getCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新
|
||||||
|
PuzzleTemplateEntity entity = BeanUtil.copyProperties(request, PuzzleTemplateEntity.class);
|
||||||
|
entity.setId(id);
|
||||||
|
templateMapper.update(entity);
|
||||||
|
|
||||||
|
log.info("拼图模板更新成功: id={}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void deleteTemplate(Long id) {
|
||||||
|
log.info("删除拼图模板: id={}", id);
|
||||||
|
|
||||||
|
// 检查模板是否存在
|
||||||
|
PuzzleTemplateEntity existing = templateMapper.getById(id);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("模板不存在: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除模板及其所有元素
|
||||||
|
templateMapper.deleteById(id);
|
||||||
|
elementMapper.deleteByTemplateId(id);
|
||||||
|
|
||||||
|
log.info("拼图模板删除成功: id={}, 同时删除了关联的元素", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PuzzleTemplateDTO getTemplateDetail(Long id) {
|
||||||
|
log.debug("获取拼图模板详情: id={}", id);
|
||||||
|
|
||||||
|
PuzzleTemplateEntity template = templateMapper.getById(id);
|
||||||
|
if (template == null) {
|
||||||
|
throw new IllegalArgumentException("模板不存在: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertToDTO(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PuzzleTemplateDTO getTemplateByCode(String code) {
|
||||||
|
log.debug("根据编码获取拼图模板: code={}", code);
|
||||||
|
|
||||||
|
PuzzleTemplateEntity template = templateMapper.getByCode(code);
|
||||||
|
if (template == null) {
|
||||||
|
throw new IllegalArgumentException("模板不存在: " + code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertToDTO(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PuzzleTemplateDTO> listTemplates(Long scenicId, String category, Integer status) {
|
||||||
|
log.debug("查询拼图模板列表: scenicId={}, category={}, status={}", scenicId, category, status);
|
||||||
|
|
||||||
|
List<PuzzleTemplateEntity> templates = templateMapper.list(scenicId, category, status);
|
||||||
|
return templates.stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Long addElement(ElementCreateRequest request) {
|
||||||
|
log.info("添加元素到模板: templateId={}, elementKey={}", request.getTemplateId(), request.getElementKey());
|
||||||
|
|
||||||
|
// 1. 验证请求
|
||||||
|
ElementConfigHelper.validateRequest(request);
|
||||||
|
|
||||||
|
// 2. 检查模板是否存在
|
||||||
|
PuzzleTemplateEntity template = templateMapper.getById(request.getTemplateId());
|
||||||
|
if (template == null) {
|
||||||
|
throw new IllegalArgumentException("模板不存在: " + request.getTemplateId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 转换为Entity(使用Helper)
|
||||||
|
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
|
||||||
|
entity.setDeleted(0);
|
||||||
|
|
||||||
|
// 4. 插入数据库
|
||||||
|
elementMapper.insert(entity);
|
||||||
|
|
||||||
|
log.info("元素添加成功: id={}, type={}, key={}",
|
||||||
|
entity.getId(), entity.getElementType(), entity.getElementKey());
|
||||||
|
return entity.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void batchAddElements(Long templateId, List<ElementCreateRequest> elements) {
|
||||||
|
log.info("批量添加元素到模板: templateId={}, count={}", templateId, elements.size());
|
||||||
|
|
||||||
|
// 1. 校验模板
|
||||||
|
PuzzleTemplateEntity template = templateMapper.getById(templateId);
|
||||||
|
if (template == null) {
|
||||||
|
throw new IllegalArgumentException("模板不存在: " + templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 批量转换
|
||||||
|
List<PuzzleElementEntity> entityList = elements.stream()
|
||||||
|
.peek(req -> {
|
||||||
|
req.setTemplateId(templateId);
|
||||||
|
ElementConfigHelper.validateRequest(req);
|
||||||
|
})
|
||||||
|
.map(ElementConfigHelper::toEntity)
|
||||||
|
.peek(entity -> entity.setDeleted(0))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 3. 批量插入
|
||||||
|
if (!entityList.isEmpty()) {
|
||||||
|
elementMapper.batchInsert(entityList);
|
||||||
|
log.info("批量添加元素成功: templateId={}, count={}", templateId, entityList.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void updateElement(Long id, ElementCreateRequest request) {
|
||||||
|
log.info("更新元素: id={}", id);
|
||||||
|
|
||||||
|
// 1. 校验元素存在
|
||||||
|
PuzzleElementEntity existing = elementMapper.getById(id);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("元素不存在: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 验证请求
|
||||||
|
ElementConfigHelper.validateRequest(request);
|
||||||
|
|
||||||
|
// 3. 转换并更新
|
||||||
|
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
|
||||||
|
entity.setId(id);
|
||||||
|
elementMapper.update(entity);
|
||||||
|
|
||||||
|
log.info("元素更新成功: id={}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void deleteElement(Long id) {
|
||||||
|
log.info("删除元素: id={}", id);
|
||||||
|
|
||||||
|
// 检查元素是否存在
|
||||||
|
PuzzleElementEntity existing = elementMapper.getById(id);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("元素不存在: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
elementMapper.deleteById(id);
|
||||||
|
log.info("元素删除成功: id={}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PuzzleElementDTO getElementDetail(Long id) {
|
||||||
|
log.debug("获取元素详情: id={}", id);
|
||||||
|
|
||||||
|
PuzzleElementEntity element = elementMapper.getById(id);
|
||||||
|
if (element == null) {
|
||||||
|
throw new IllegalArgumentException("元素不存在: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertElementToDTO(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为DTO(包含元素列表)
|
||||||
|
*/
|
||||||
|
private PuzzleTemplateDTO convertToDTO(PuzzleTemplateEntity template) {
|
||||||
|
PuzzleTemplateDTO dto = BeanUtil.copyProperties(template, PuzzleTemplateDTO.class);
|
||||||
|
|
||||||
|
// 查询元素列表
|
||||||
|
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
|
||||||
|
List<PuzzleElementDTO> elementDTOs = elements.stream()
|
||||||
|
.map(this::convertElementToDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
dto.setElements(elementDTOs);
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换元素为DTO
|
||||||
|
*/
|
||||||
|
private PuzzleElementDTO convertElementToDTO(PuzzleElementEntity entity) {
|
||||||
|
PuzzleElementDTO dto = new PuzzleElementDTO();
|
||||||
|
BeanUtil.copyProperties(entity, dto);
|
||||||
|
|
||||||
|
// 解析config为configMap(方便前端使用)
|
||||||
|
if (StrUtil.isNotBlank(entity.getConfig())) {
|
||||||
|
dto.setConfigMap(ElementConfigHelper.parseConfigToMap(entity.getConfig()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.ycwl.basic.puzzle.util;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.utils.JacksonUtil;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element配置辅助类
|
||||||
|
* 处理ElementCreateRequest到PuzzleElementEntity的转换
|
||||||
|
* 负责config和configMap之间的序列化/反序列化
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class ElementConfigHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将ElementCreateRequest转换为PuzzleElementEntity
|
||||||
|
*
|
||||||
|
* @param request 创建请求
|
||||||
|
* @return Entity对象
|
||||||
|
*/
|
||||||
|
public static PuzzleElementEntity toEntity(ElementCreateRequest request) {
|
||||||
|
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||||
|
|
||||||
|
// 基本属性
|
||||||
|
entity.setTemplateId(request.getTemplateId());
|
||||||
|
entity.setElementType(request.getElementType());
|
||||||
|
entity.setElementKey(request.getElementKey());
|
||||||
|
entity.setElementName(request.getElementName());
|
||||||
|
|
||||||
|
// 位置和布局属性
|
||||||
|
entity.setXPosition(request.getXPosition());
|
||||||
|
entity.setYPosition(request.getYPosition());
|
||||||
|
entity.setWidth(request.getWidth());
|
||||||
|
entity.setHeight(request.getHeight());
|
||||||
|
entity.setZIndex(request.getZIndex());
|
||||||
|
entity.setRotation(request.getRotation());
|
||||||
|
entity.setOpacity(request.getOpacity());
|
||||||
|
|
||||||
|
// 处理配置:优先使用config字符串,否则将configMap序列化为JSON
|
||||||
|
String configJson = getConfigJson(request);
|
||||||
|
entity.setConfig(configJson);
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从Request获取JSON配置字符串
|
||||||
|
* 优先级:config字符串 > configMap序列化
|
||||||
|
*
|
||||||
|
* @param request 创建请求
|
||||||
|
* @return JSON配置字符串
|
||||||
|
*/
|
||||||
|
public static String getConfigJson(ElementCreateRequest request) {
|
||||||
|
// 优先使用config字段
|
||||||
|
if (StrUtil.isNotBlank(request.getConfig())) {
|
||||||
|
return request.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则将configMap序列化为JSON
|
||||||
|
if (request.getConfigMap() != null && !request.getConfigMap().isEmpty()) {
|
||||||
|
try {
|
||||||
|
return JacksonUtil.toJson(request.getConfigMap());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("configMap序列化为JSON失败", e);
|
||||||
|
throw new IllegalArgumentException("配置序列化失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 都为空则返回空JSON对象
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将JSON配置字符串解析为Map
|
||||||
|
*
|
||||||
|
* @param configJson JSON配置字符串
|
||||||
|
* @return Map对象
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public static Map<String, Object> parseConfigToMap(String configJson) {
|
||||||
|
if (StrUtil.isBlank(configJson)) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JacksonUtil.fromJson(configJson, Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("JSON解析为Map失败: {}", configJson, e);
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证元素类型是否有效
|
||||||
|
*
|
||||||
|
* @param elementType 元素类型
|
||||||
|
* @return true-有效,false-无效
|
||||||
|
*/
|
||||||
|
public static boolean isValidElementType(String elementType) {
|
||||||
|
if (StrUtil.isBlank(elementType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前支持的类型
|
||||||
|
return "TEXT".equalsIgnoreCase(elementType) ||
|
||||||
|
"IMAGE".equalsIgnoreCase(elementType) ||
|
||||||
|
"QRCODE".equalsIgnoreCase(elementType) ||
|
||||||
|
"GRADIENT".equalsIgnoreCase(elementType) ||
|
||||||
|
"SHAPE".equalsIgnoreCase(elementType) ||
|
||||||
|
"DYNAMIC_IMAGE".equalsIgnoreCase(elementType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证元素配置是否完整
|
||||||
|
*
|
||||||
|
* @param request 创建请求
|
||||||
|
* @throws IllegalArgumentException 配置不完整时抛出
|
||||||
|
*/
|
||||||
|
public static void validateRequest(ElementCreateRequest request) {
|
||||||
|
if (request.getTemplateId() == null) {
|
||||||
|
throw new IllegalArgumentException("模板ID不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrUtil.isBlank(request.getElementType())) {
|
||||||
|
throw new IllegalArgumentException("元素类型不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElementType(request.getElementType())) {
|
||||||
|
throw new IllegalArgumentException("不支持的元素类型: " + request.getElementType());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrUtil.isBlank(request.getElementKey())) {
|
||||||
|
throw new IllegalArgumentException("元素标识不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证位置属性
|
||||||
|
if (request.getXPosition() == null || request.getYPosition() == null) {
|
||||||
|
throw new IllegalArgumentException("位置坐标不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getWidth() == null || request.getHeight() == null) {
|
||||||
|
throw new IllegalArgumentException("宽高不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getWidth() <= 0 || request.getHeight() <= 0) {
|
||||||
|
throw new IllegalArgumentException("宽高必须大于0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
if (StrUtil.isBlank(request.getConfig()) &&
|
||||||
|
(request.getConfigMap() == null || request.getConfigMap().isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("元素配置不能为空(config或configMap至少提供一个)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,13 +78,13 @@ public class SourceRepository {
|
|||||||
}
|
}
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 1:
|
case 1:
|
||||||
List<SourceEntity> videoSourceList = sourceMapper.listVideoByFaceRelation(userId, faceId);
|
List<SourceEntity> videoSourceList = sourceMapper.listVideoByFaceRelation(faceId);
|
||||||
if (videoSourceList == null || videoSourceList.isEmpty()) {
|
if (videoSourceList == null || videoSourceList.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return videoSourceList.stream().filter(Objects::nonNull).anyMatch(item -> Integer.valueOf(1).equals(item.getIsBuy()));
|
return videoSourceList.stream().filter(Objects::nonNull).anyMatch(item -> Integer.valueOf(1).equals(item.getIsBuy()));
|
||||||
case 2:
|
case 2:
|
||||||
List<SourceEntity> imageSourceList = sourceMapper.listImageByFaceRelation(userId, faceId);
|
List<SourceEntity> imageSourceList = sourceMapper.listImageByFaceRelation(faceId);
|
||||||
if (imageSourceList == null || imageSourceList.isEmpty()) {
|
if (imageSourceList == null || imageSourceList.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/main/java/com/ycwl/basic/service/VideoReviewService.java
Normal file
48
src/main/java/com/ycwl/basic/service/VideoReviewService.java
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package com.ycwl.basic.service;
|
||||||
|
|
||||||
|
import com.github.pagehelper.PageInfo;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewStatisticsRespDTO;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价Service接口
|
||||||
|
*/
|
||||||
|
public interface VideoReviewService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增视频评价
|
||||||
|
*
|
||||||
|
* @param reqDTO 评价信息
|
||||||
|
* @return 评价ID
|
||||||
|
*/
|
||||||
|
Long addReview(VideoReviewAddReqDTO reqDTO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询评价列表
|
||||||
|
*
|
||||||
|
* @param reqDTO 查询条件
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
PageInfo<VideoReviewRespDTO> getReviewList(VideoReviewListReqDTO reqDTO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取评价统计数据
|
||||||
|
*
|
||||||
|
* @return 统计结果
|
||||||
|
*/
|
||||||
|
VideoReviewStatisticsRespDTO getStatistics();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出评价数据到Excel
|
||||||
|
*
|
||||||
|
* @param reqDTO 查询条件
|
||||||
|
* @param outputStream 输出流
|
||||||
|
* @throws IOException IO异常
|
||||||
|
*/
|
||||||
|
void exportReviews(VideoReviewListReqDTO reqDTO, OutputStream outputStream) throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package com.ycwl.basic.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.github.pagehelper.PageHelper;
|
||||||
|
import com.github.pagehelper.PageInfo;
|
||||||
|
import com.ycwl.basic.constant.BaseContextHandler;
|
||||||
|
import com.ycwl.basic.exception.BaseException;
|
||||||
|
import com.ycwl.basic.exception.BizException;
|
||||||
|
import com.ycwl.basic.mapper.VideoMapper;
|
||||||
|
import com.ycwl.basic.mapper.VideoReviewMapper;
|
||||||
|
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewStatisticsRespDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.entity.VideoReviewEntity;
|
||||||
|
import com.ycwl.basic.service.VideoReviewService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.poi.ss.usermodel.*;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.springframework.beans.BeanUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价Service实现类
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class VideoReviewServiceImpl implements VideoReviewService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private VideoReviewMapper videoReviewMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private VideoMapper videoMapper;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Long addReview(VideoReviewAddReqDTO reqDTO) {
|
||||||
|
// 1. 参数校验
|
||||||
|
if (reqDTO.getVideoId() == null) {
|
||||||
|
throw new BaseException("视频ID不能为空");
|
||||||
|
}
|
||||||
|
if (reqDTO.getRating() == null || reqDTO.getRating() < 1 || reqDTO.getRating() > 5) {
|
||||||
|
throw new BaseException("评分必须在1-5之间");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询视频信息,获取景区ID
|
||||||
|
VideoEntity video = videoMapper.getEntity(reqDTO.getVideoId());
|
||||||
|
if (video == null) {
|
||||||
|
throw new BaseException("视频不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取当前登录用户(管理员)
|
||||||
|
String userIdStr = BaseContextHandler.getUserId();
|
||||||
|
if (userIdStr == null || userIdStr.isEmpty()) {
|
||||||
|
throw new BaseException("未登录或登录已过期");
|
||||||
|
}
|
||||||
|
Long creator = Long.valueOf(userIdStr);
|
||||||
|
|
||||||
|
// 4. 构建评价实体
|
||||||
|
VideoReviewEntity entity = new VideoReviewEntity();
|
||||||
|
entity.setVideoId(reqDTO.getVideoId());
|
||||||
|
entity.setScenicId(video.getScenicId());
|
||||||
|
entity.setCreator(creator);
|
||||||
|
entity.setRating(reqDTO.getRating());
|
||||||
|
entity.setContent(reqDTO.getContent());
|
||||||
|
entity.setCameraPositionRating(reqDTO.getCameraPositionRating());
|
||||||
|
|
||||||
|
// 5. 插入数据库
|
||||||
|
videoReviewMapper.insert(entity);
|
||||||
|
|
||||||
|
log.info("管理员[{}]对视频[{}]添加评价成功,评价ID: {}", creator, reqDTO.getVideoId(), entity.getId());
|
||||||
|
return entity.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageInfo<VideoReviewRespDTO> getReviewList(VideoReviewListReqDTO reqDTO) {
|
||||||
|
// 设置分页参数
|
||||||
|
PageHelper.startPage(reqDTO.getPageNum(), reqDTO.getPageSize());
|
||||||
|
|
||||||
|
// 查询列表
|
||||||
|
List<VideoReviewRespDTO> list = videoReviewMapper.selectReviewList(reqDTO);
|
||||||
|
|
||||||
|
// 封装分页结果
|
||||||
|
return new PageInfo<>(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VideoReviewStatisticsRespDTO getStatistics() {
|
||||||
|
VideoReviewStatisticsRespDTO statistics = new VideoReviewStatisticsRespDTO();
|
||||||
|
|
||||||
|
// 1. 总评价数
|
||||||
|
Long totalCount = videoReviewMapper.countTotal();
|
||||||
|
statistics.setTotalCount(totalCount);
|
||||||
|
|
||||||
|
// 2. 平均评分
|
||||||
|
Double avgRating = videoReviewMapper.calculateAverageRating();
|
||||||
|
if (avgRating != null) {
|
||||||
|
statistics.setAverageRating(BigDecimal.valueOf(avgRating).setScale(2, RoundingMode.HALF_UP));
|
||||||
|
} else {
|
||||||
|
statistics.setAverageRating(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 评分分布
|
||||||
|
List<Map<String, Object>> ratingDistList = videoReviewMapper.countRatingDistribution();
|
||||||
|
Map<Integer, Long> ratingDistribution = new HashMap<>();
|
||||||
|
for (Map<String, Object> item : ratingDistList) {
|
||||||
|
Integer rating = (Integer) item.get("ratingValue");
|
||||||
|
Long count = (Long) item.get("count");
|
||||||
|
ratingDistribution.put(rating, count);
|
||||||
|
}
|
||||||
|
statistics.setRatingDistribution(ratingDistribution);
|
||||||
|
|
||||||
|
// 4. 最近7天趋势
|
||||||
|
List<Map<String, Object>> trendList = videoReviewMapper.countRecentTrend(7);
|
||||||
|
Map<String, Long> recentTrend = new LinkedHashMap<>();
|
||||||
|
for (Map<String, Object> item : trendList) {
|
||||||
|
String date = (String) item.get("dateStr");
|
||||||
|
Long count = (Long) item.get("count");
|
||||||
|
recentTrend.put(date, count);
|
||||||
|
}
|
||||||
|
statistics.setRecentTrend(recentTrend);
|
||||||
|
|
||||||
|
// 5. 景区排行(前10)
|
||||||
|
List<VideoReviewStatisticsRespDTO.ScenicReviewRank> scenicRankList = videoReviewMapper.countScenicRank(10);
|
||||||
|
statistics.setScenicRankList(scenicRankList);
|
||||||
|
|
||||||
|
// 6. 机位评价维度平均值
|
||||||
|
Map<String, BigDecimal> cameraPositionAverage = calculateCameraPositionAverage();
|
||||||
|
statistics.setCameraPositionAverage(cameraPositionAverage);
|
||||||
|
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void exportReviews(VideoReviewListReqDTO reqDTO, OutputStream outputStream) throws IOException {
|
||||||
|
// 1. 查询所有数据(不分页)
|
||||||
|
reqDTO.setPageNum(1);
|
||||||
|
reqDTO.setPageSize(Integer.MAX_VALUE);
|
||||||
|
List<VideoReviewRespDTO> list = videoReviewMapper.selectReviewList(reqDTO);
|
||||||
|
|
||||||
|
// 2. 创建Excel工作簿
|
||||||
|
Workbook workbook = new XSSFWorkbook();
|
||||||
|
Sheet sheet = workbook.createSheet("视频评价数据");
|
||||||
|
|
||||||
|
// 3. 创建标题行样式
|
||||||
|
CellStyle headerStyle = workbook.createCellStyle();
|
||||||
|
Font headerFont = workbook.createFont();
|
||||||
|
headerFont.setBold(true);
|
||||||
|
headerStyle.setFont(headerFont);
|
||||||
|
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
|
||||||
|
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||||
|
|
||||||
|
// 4. 创建标题行
|
||||||
|
Row headerRow = sheet.createRow(0);
|
||||||
|
String[] headers = {"评价ID", "视频ID", "景区ID", "景区名称", "评价人ID", "评价人名称",
|
||||||
|
"评分", "文字评价", "机位评价", "创建时间", "更新时间"};
|
||||||
|
for (int i = 0; i < headers.length; i++) {
|
||||||
|
Cell cell = headerRow.createCell(i);
|
||||||
|
cell.setCellValue(headers[i]);
|
||||||
|
cell.setCellStyle(headerStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 填充数据
|
||||||
|
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||||
|
int rowNum = 1;
|
||||||
|
for (VideoReviewRespDTO review : list) {
|
||||||
|
Row row = sheet.createRow(rowNum++);
|
||||||
|
row.createCell(0).setCellValue(review.getId());
|
||||||
|
row.createCell(1).setCellValue(review.getVideoId());
|
||||||
|
row.createCell(2).setCellValue(review.getScenicId());
|
||||||
|
row.createCell(3).setCellValue(review.getScenicName());
|
||||||
|
row.createCell(4).setCellValue(review.getCreator());
|
||||||
|
row.createCell(5).setCellValue(review.getCreatorName());
|
||||||
|
row.createCell(6).setCellValue(review.getRating());
|
||||||
|
row.createCell(7).setCellValue(review.getContent());
|
||||||
|
|
||||||
|
// 机位评价JSON转字符串
|
||||||
|
try {
|
||||||
|
String cameraRatingJson = review.getCameraPositionRating() != null ?
|
||||||
|
objectMapper.writeValueAsString(review.getCameraPositionRating()) : "";
|
||||||
|
row.createCell(8).setCellValue(cameraRatingJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
row.createCell(8).setCellValue("");
|
||||||
|
}
|
||||||
|
|
||||||
|
row.createCell(9).setCellValue(review.getCreateTime() != null ? sdf.format(review.getCreateTime()) : "");
|
||||||
|
row.createCell(10).setCellValue(review.getUpdateTime() != null ? sdf.format(review.getUpdateTime()) : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 自动调整列宽
|
||||||
|
for (int i = 0; i < headers.length; i++) {
|
||||||
|
sheet.autoSizeColumn(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 写入输出流
|
||||||
|
workbook.write(outputStream);
|
||||||
|
workbook.close();
|
||||||
|
|
||||||
|
log.info("导出视频评价数据成功,共{}条", list.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算机位评价各维度的平均值
|
||||||
|
*/
|
||||||
|
private Map<String, BigDecimal> calculateCameraPositionAverage() {
|
||||||
|
List<Map<String, Map<String, Integer>>> allRatings = videoReviewMapper.selectAllCameraPositionRatings();
|
||||||
|
|
||||||
|
if (allRatings == null || allRatings.isEmpty()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计各维度的总分和次数
|
||||||
|
Map<String, List<Integer>> dimensionScores = new HashMap<>();
|
||||||
|
for (Map<String, Map<String, Integer>> rating : allRatings) {
|
||||||
|
if (rating == null) continue;
|
||||||
|
// 遍历每个机位
|
||||||
|
for (Map<String, Integer> deviceRatings : rating.values()) {
|
||||||
|
if (deviceRatings == null) continue;
|
||||||
|
// 遍历该机位的每个维度
|
||||||
|
for (Map.Entry<String, Integer> entry : deviceRatings.entrySet()) {
|
||||||
|
dimensionScores.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算平均值
|
||||||
|
Map<String, BigDecimal> result = new HashMap<>();
|
||||||
|
for (Map.Entry<String, List<Integer>> entry : dimensionScores.entrySet()) {
|
||||||
|
double avg = entry.getValue().stream().mapToInt(Integer::intValue).average().orElse(0.0);
|
||||||
|
result.put(entry.getKey(), BigDecimal.valueOf(avg).setScale(2, RoundingMode.HALF_UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,16 +282,8 @@ public class AppScenicServiceImpl implements AppScenicService {
|
|||||||
ScenicAppVO scenicAppVO = new ScenicAppVO();
|
ScenicAppVO scenicAppVO = new ScenicAppVO();
|
||||||
scenicAppVO.setId(scenicEntity.getId());
|
scenicAppVO.setId(scenicEntity.getId());
|
||||||
scenicAppVO.setName(scenicEntity.getName());
|
scenicAppVO.setName(scenicEntity.getName());
|
||||||
scenicAppVO.setPhone(scenicEntity.getPhone());
|
|
||||||
scenicAppVO.setIntroduction(scenicEntity.getIntroduction());
|
|
||||||
scenicAppVO.setCoverUrl(scenicEntity.getCoverUrl());
|
scenicAppVO.setCoverUrl(scenicEntity.getCoverUrl());
|
||||||
scenicAppVO.setLongitude(scenicEntity.getLongitude());
|
|
||||||
scenicAppVO.setLatitude(scenicEntity.getLatitude());
|
|
||||||
scenicAppVO.setRadius(scenicEntity.getRadius());
|
scenicAppVO.setRadius(scenicEntity.getRadius());
|
||||||
scenicAppVO.setProvince(scenicEntity.getProvince());
|
|
||||||
scenicAppVO.setCity(scenicEntity.getCity());
|
|
||||||
scenicAppVO.setArea(scenicEntity.getArea());
|
|
||||||
scenicAppVO.setAddress(scenicEntity.getAddress());
|
|
||||||
scenicAppVO.setDistance(distance);
|
scenicAppVO.setDistance(distance);
|
||||||
|
|
||||||
// 获取设备数量
|
// 获取设备数量
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ public class GoodsServiceImpl implements GoodsService {
|
|||||||
videoReqQuery.setScenicId(scenicId);
|
videoReqQuery.setScenicId(scenicId);
|
||||||
videoReqQuery.setIsBuy(query.getIsBuy());
|
videoReqQuery.setIsBuy(query.getIsBuy());
|
||||||
videoReqQuery.setFaceId(query.getFaceId());
|
videoReqQuery.setFaceId(query.getFaceId());
|
||||||
videoReqQuery.setMemberId(Long.valueOf(BaseContextHandler.getUserId()));
|
|
||||||
//查询成片vlog
|
//查询成片vlog
|
||||||
List<VideoRespVO> videoList = videoMapper.queryByRelation(videoReqQuery);
|
List<VideoRespVO> videoList = videoMapper.queryByRelation(videoReqQuery);
|
||||||
videoList.forEach(videoRespVO -> {
|
videoList.forEach(videoRespVO -> {
|
||||||
@@ -150,7 +149,6 @@ public class GoodsServiceImpl implements GoodsService {
|
|||||||
sourceReqQuery.setScenicId(scenicId);
|
sourceReqQuery.setScenicId(scenicId);
|
||||||
sourceReqQuery.setIsBuy(query.getIsBuy());
|
sourceReqQuery.setIsBuy(query.getIsBuy());
|
||||||
sourceReqQuery.setFaceId(query.getFaceId());
|
sourceReqQuery.setFaceId(query.getFaceId());
|
||||||
sourceReqQuery.setMemberId(Long.valueOf(BaseContextHandler.getUserId()));
|
|
||||||
//查询源素材
|
//查询源素材
|
||||||
List<SourceRespVO> sourceList = sourceMapper.queryByRelation(sourceReqQuery);
|
List<SourceRespVO> sourceList = sourceMapper.queryByRelation(sourceReqQuery);
|
||||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
|
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
|
||||||
@@ -204,7 +202,6 @@ public class GoodsServiceImpl implements GoodsService {
|
|||||||
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
||||||
sourceReqQuery.setScenicId(face.getScenicId());
|
sourceReqQuery.setScenicId(face.getScenicId());
|
||||||
sourceReqQuery.setIsBuy(query.getIsBuy());
|
sourceReqQuery.setIsBuy(query.getIsBuy());
|
||||||
sourceReqQuery.setMemberId(face.getMemberId());
|
|
||||||
sourceReqQuery.setType(sourceType);
|
sourceReqQuery.setType(sourceType);
|
||||||
sourceReqQuery.setFaceId(face.getId());
|
sourceReqQuery.setFaceId(face.getId());
|
||||||
List<SourceRespVO> list = sourceMapper.listUser(sourceReqQuery);
|
List<SourceRespVO> list = sourceMapper.listUser(sourceReqQuery);
|
||||||
@@ -563,7 +560,6 @@ public class GoodsServiceImpl implements GoodsService {
|
|||||||
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
||||||
sourceReqQuery.setScenicId(face.getScenicId());
|
sourceReqQuery.setScenicId(face.getScenicId());
|
||||||
sourceReqQuery.setIsBuy(query.getIsBuy());
|
sourceReqQuery.setIsBuy(query.getIsBuy());
|
||||||
sourceReqQuery.setMemberId(face.getMemberId());
|
|
||||||
sourceReqQuery.setType(sourceType);
|
sourceReqQuery.setType(sourceType);
|
||||||
sourceReqQuery.setFaceId(face.getId());
|
sourceReqQuery.setFaceId(face.getId());
|
||||||
List<SourceRespVO> list = sourceMapper.listUser(sourceReqQuery);
|
List<SourceRespVO> list = sourceMapper.listUser(sourceReqQuery);
|
||||||
@@ -652,7 +648,6 @@ public class GoodsServiceImpl implements GoodsService {
|
|||||||
Integer sourceType = query.getSourceType();
|
Integer sourceType = query.getSourceType();
|
||||||
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
||||||
sourceReqQuery.setScenicId(face.getScenicId());
|
sourceReqQuery.setScenicId(face.getScenicId());
|
||||||
sourceReqQuery.setMemberId(face.getMemberId());
|
|
||||||
sourceReqQuery.setType(sourceType);
|
sourceReqQuery.setType(sourceType);
|
||||||
sourceReqQuery.setFaceId(query.getFaceId());
|
sourceReqQuery.setFaceId(query.getFaceId());
|
||||||
List<SourceRespVO> list = sourceMapper.listUser(sourceReqQuery);
|
List<SourceRespVO> list = sourceMapper.listUser(sourceReqQuery);
|
||||||
|
|||||||
@@ -17,5 +17,12 @@ public interface VideoService {
|
|||||||
ApiResponse<List<VideoRespVO>> list(VideoReqQuery videoReqQuery);
|
ApiResponse<List<VideoRespVO>> list(VideoReqQuery videoReqQuery);
|
||||||
ApiResponse<VideoRespVO> getById(Long id);
|
ApiResponse<VideoRespVO> getById(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询视频是否被购买
|
||||||
|
*
|
||||||
|
* @param videoId 视频ID
|
||||||
|
* @return 是否已购买 (true-已购买, false-未购买)
|
||||||
|
*/
|
||||||
|
Boolean checkVideoBuyStatus(Long videoId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
List<Integer> _f = new ArrayList<>();
|
List<Integer> _f = new ArrayList<>();
|
||||||
orderItemList.forEach(item -> {
|
orderItemList.forEach(item -> {
|
||||||
if (Integer.valueOf(1).equals(item.getGoodsType())) { // 原片 goodsId就是人脸ID
|
if (Integer.valueOf(1).equals(item.getGoodsType())) { // 原片 goodsId就是人脸ID
|
||||||
List<SourceEntity> memberVideoEntityList = sourceMapper.listVideoByFaceRelation(order.getMemberId(), item.getGoodsId());
|
List<SourceEntity> memberVideoEntityList = sourceMapper.listVideoByFaceRelation(item.getGoodsId());
|
||||||
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
|
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
|
||||||
if (!_f.contains(1)) {
|
if (!_f.contains(1)) {
|
||||||
_f.add(1);
|
_f.add(1);
|
||||||
@@ -320,7 +320,7 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (Integer.valueOf(2).equals(item.getGoodsType())) { // 照片 goodsId就是人脸ID
|
} else if (Integer.valueOf(2).equals(item.getGoodsType())) { // 照片 goodsId就是人脸ID
|
||||||
List<SourceEntity> memberVideoEntityList = sourceMapper.listImageByFaceRelation(order.getMemberId(), item.getGoodsId());
|
List<SourceEntity> memberVideoEntityList = sourceMapper.listImageByFaceRelation(item.getGoodsId());
|
||||||
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
|
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
|
||||||
if (!_f.contains(2)) {
|
if (!_f.contains(2)) {
|
||||||
_f.add(2);
|
_f.add(2);
|
||||||
@@ -519,14 +519,14 @@ public class OrderServiceImpl implements OrderService {
|
|||||||
}
|
}
|
||||||
orderItemList.forEach(item -> {
|
orderItemList.forEach(item -> {
|
||||||
if (Integer.valueOf(1).equals(item.getGoodsType())) { // 原片
|
if (Integer.valueOf(1).equals(item.getGoodsType())) { // 原片
|
||||||
List<SourceEntity> memberVideoEntityList = sourceMapper.listVideoByFaceRelation(orderReqQuery.getMemberId(), item.getFaceId());
|
List<SourceEntity> memberVideoEntityList = sourceMapper.listVideoByFaceRelation(item.getFaceId());
|
||||||
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
|
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
|
||||||
if (!memberVideoEntityList.isEmpty()) {
|
if (!memberVideoEntityList.isEmpty()) {
|
||||||
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
|
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
|
||||||
item.setCount(1);
|
item.setCount(1);
|
||||||
}
|
}
|
||||||
} else if (Integer.valueOf(2).equals(item.getGoodsType())) {
|
} else if (Integer.valueOf(2).equals(item.getGoodsType())) {
|
||||||
List<SourceEntity> memberVideoEntityList = sourceMapper.listImageByFaceRelation(orderReqQuery.getMemberId(), item.getFaceId());
|
List<SourceEntity> memberVideoEntityList = sourceMapper.listImageByFaceRelation(item.getFaceId());
|
||||||
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
|
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
|
||||||
if (!memberVideoEntityList.isEmpty()) {
|
if (!memberVideoEntityList.isEmpty()) {
|
||||||
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
|
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package com.ycwl.basic.service.pc.impl;
|
|||||||
|
|
||||||
import com.github.pagehelper.PageHelper;
|
import com.github.pagehelper.PageHelper;
|
||||||
import com.github.pagehelper.PageInfo;
|
import com.github.pagehelper.PageInfo;
|
||||||
|
import com.ycwl.basic.exception.BaseException;
|
||||||
|
import com.ycwl.basic.exception.BizException;
|
||||||
import com.ycwl.basic.mapper.VideoMapper;
|
import com.ycwl.basic.mapper.VideoMapper;
|
||||||
|
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
|
||||||
import com.ycwl.basic.model.pc.video.req.VideoReqQuery;
|
import com.ycwl.basic.model.pc.video.req.VideoReqQuery;
|
||||||
import com.ycwl.basic.model.pc.video.resp.VideoRespVO;
|
import com.ycwl.basic.model.pc.video.resp.VideoRespVO;
|
||||||
import com.ycwl.basic.repository.ScenicRepository;
|
import com.ycwl.basic.repository.ScenicRepository;
|
||||||
@@ -79,4 +82,12 @@ public class VideoServiceImpl implements VideoService {
|
|||||||
return ApiResponse.success(videoMapper.getById(id));
|
return ApiResponse.success(videoMapper.getById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Boolean checkVideoBuyStatus(Long videoId) {
|
||||||
|
// 查询 member_video 表中是否存在该视频的已购买记录
|
||||||
|
int count = videoMapper.countBuyRecordByVideoId(videoId);
|
||||||
|
// count > 0 表示存在已购买记录
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,11 +396,6 @@
|
|||||||
o.scenic_id
|
o.scenic_id
|
||||||
from `order` AS o
|
from `order` AS o
|
||||||
left join face f on o.face_id = f.id
|
left join face f on o.face_id = f.id
|
||||||
<where>
|
|
||||||
<if test="memberId!=null">
|
|
||||||
and o.member_id=#{memberId}
|
|
||||||
</if>
|
|
||||||
</where>
|
|
||||||
order by o.create_at desc
|
order by o.create_at desc
|
||||||
</select>
|
</select>
|
||||||
<select id="appDetail" resultMap="AppBaseResultMap">
|
<select id="appDetail" resultMap="AppBaseResultMap">
|
||||||
|
|||||||
116
src/main/resources/mapper/PuzzleElementMapper.xml
Normal file
116
src/main/resources/mapper/PuzzleElementMapper.xml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleElementMapper">
|
||||||
|
|
||||||
|
<!-- 结果映射(重构版) -->
|
||||||
|
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleElementEntity">
|
||||||
|
<id column="id" property="id"/>
|
||||||
|
<result column="template_id" property="templateId"/>
|
||||||
|
<result column="element_type" property="elementType"/>
|
||||||
|
<result column="element_key" property="elementKey"/>
|
||||||
|
<result column="element_name" property="elementName"/>
|
||||||
|
<result column="config" property="config"/>
|
||||||
|
<!-- 位置和布局属性 -->
|
||||||
|
<result column="x_position" property="xPosition"/>
|
||||||
|
<result column="y_position" property="yPosition"/>
|
||||||
|
<result column="width" property="width"/>
|
||||||
|
<result column="height" property="height"/>
|
||||||
|
<result column="z_index" property="zIndex"/>
|
||||||
|
<result column="rotation" property="rotation"/>
|
||||||
|
<result column="opacity" property="opacity"/>
|
||||||
|
<!-- 元数据 -->
|
||||||
|
<result column="create_time" property="createTime"/>
|
||||||
|
<result column="update_time" property="updateTime"/>
|
||||||
|
<result column="deleted" property="deleted"/>
|
||||||
|
<result column="deleted_at" property="deletedAt"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!-- 基础列(重构版) -->
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
id, template_id, element_type, element_key, element_name, config,
|
||||||
|
x_position, y_position, width, height, z_index, rotation, opacity,
|
||||||
|
create_time, update_time, deleted, deleted_at
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- 根据ID查询 -->
|
||||||
|
<select id="getById" resultMap="BaseResultMap">
|
||||||
|
SELECT <include refid="Base_Column_List"/>
|
||||||
|
FROM puzzle_element
|
||||||
|
WHERE id = #{id} AND deleted = 0
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据模板ID查询元素列表(按z-index排序) -->
|
||||||
|
<select id="getByTemplateId" resultMap="BaseResultMap">
|
||||||
|
SELECT <include refid="Base_Column_List"/>
|
||||||
|
FROM puzzle_element
|
||||||
|
WHERE template_id = #{templateId} AND deleted = 0
|
||||||
|
ORDER BY z_index ASC, id ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 插入(重构版) -->
|
||||||
|
<insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity"
|
||||||
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO puzzle_element (
|
||||||
|
template_id, element_type, element_key, element_name, config,
|
||||||
|
x_position, y_position, width, height, z_index, rotation, opacity,
|
||||||
|
create_time, update_time, deleted
|
||||||
|
) VALUES (
|
||||||
|
#{templateId}, #{elementType}, #{elementKey}, #{elementName}, #{config},
|
||||||
|
#{xPosition}, #{yPosition}, #{width}, #{height}, #{zIndex}, #{rotation}, #{opacity},
|
||||||
|
NOW(), NOW(), 0
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 批量插入(重构版) -->
|
||||||
|
<insert id="batchInsert">
|
||||||
|
INSERT INTO puzzle_element (
|
||||||
|
template_id, element_type, element_key, element_name, config,
|
||||||
|
x_position, y_position, width, height, z_index, rotation, opacity,
|
||||||
|
create_time, update_time, deleted
|
||||||
|
) VALUES
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(
|
||||||
|
#{item.templateId}, #{item.elementType}, #{item.elementKey}, #{item.elementName}, #{item.config},
|
||||||
|
#{item.xPosition}, #{item.yPosition}, #{item.width}, #{item.height}, #{item.zIndex}, #{item.rotation}, #{item.opacity},
|
||||||
|
NOW(), NOW(), 0
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 更新(重构版) -->
|
||||||
|
<update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleElementEntity">
|
||||||
|
UPDATE puzzle_element
|
||||||
|
<set>
|
||||||
|
<if test="elementType != null">element_type = #{elementType},</if>
|
||||||
|
<if test="elementKey != null">element_key = #{elementKey},</if>
|
||||||
|
<if test="elementName != null">element_name = #{elementName},</if>
|
||||||
|
<if test="config != null">config = #{config},</if>
|
||||||
|
<if test="xPosition != null">x_position = #{xPosition},</if>
|
||||||
|
<if test="yPosition != null">y_position = #{yPosition},</if>
|
||||||
|
<if test="width != null">width = #{width},</if>
|
||||||
|
<if test="height != null">height = #{height},</if>
|
||||||
|
<if test="zIndex != null">z_index = #{zIndex},</if>
|
||||||
|
<if test="rotation != null">rotation = #{rotation},</if>
|
||||||
|
<if test="opacity != null">opacity = #{opacity},</if>
|
||||||
|
update_time = NOW()
|
||||||
|
</set>
|
||||||
|
WHERE id = #{id} AND deleted = 0
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 逻辑删除 -->
|
||||||
|
<update id="deleteById">
|
||||||
|
UPDATE puzzle_element
|
||||||
|
SET deleted = 1, deleted_at = NOW(), update_time = NOW()
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 根据模板ID删除所有元素 -->
|
||||||
|
<update id="deleteByTemplateId">
|
||||||
|
UPDATE puzzle_element
|
||||||
|
SET deleted = 1, deleted_at = NOW(), update_time = NOW()
|
||||||
|
WHERE template_id = #{templateId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
120
src/main/resources/mapper/PuzzleGenerationRecordMapper.xml
Normal file
120
src/main/resources/mapper/PuzzleGenerationRecordMapper.xml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper">
|
||||||
|
|
||||||
|
<!-- 结果映射 -->
|
||||||
|
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity">
|
||||||
|
<id column="id" property="id"/>
|
||||||
|
<result column="template_id" property="templateId"/>
|
||||||
|
<result column="template_code" property="templateCode"/>
|
||||||
|
<result column="user_id" property="userId"/>
|
||||||
|
<result column="order_id" property="orderId"/>
|
||||||
|
<result column="business_type" property="businessType"/>
|
||||||
|
<result column="generation_params" property="generationParams"/>
|
||||||
|
<result column="result_image_url" property="resultImageUrl"/>
|
||||||
|
<result column="result_file_size" property="resultFileSize"/>
|
||||||
|
<result column="result_width" property="resultWidth"/>
|
||||||
|
<result column="result_height" property="resultHeight"/>
|
||||||
|
<result column="status" property="status"/>
|
||||||
|
<result column="error_message" property="errorMessage"/>
|
||||||
|
<result column="generation_duration" property="generationDuration"/>
|
||||||
|
<result column="retry_count" property="retryCount"/>
|
||||||
|
<result column="scenic_id" property="scenicId"/>
|
||||||
|
<result column="client_ip" property="clientIp"/>
|
||||||
|
<result column="user_agent" property="userAgent"/>
|
||||||
|
<result column="create_time" property="createTime"/>
|
||||||
|
<result column="update_time" property="updateTime"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!-- 基础列 -->
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
id, template_id, template_code, user_id, order_id, business_type,
|
||||||
|
generation_params, result_image_url, result_file_size, result_width, result_height,
|
||||||
|
status, error_message, generation_duration, retry_count,
|
||||||
|
scenic_id, client_ip, user_agent, create_time, update_time
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- 根据ID查询 -->
|
||||||
|
<select id="getById" resultMap="BaseResultMap">
|
||||||
|
SELECT <include refid="Base_Column_List"/>
|
||||||
|
FROM puzzle_generation_record
|
||||||
|
WHERE id = #{id}
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 查询用户的生成记录列表 -->
|
||||||
|
<select id="listByUserId" resultMap="BaseResultMap">
|
||||||
|
SELECT <include refid="Base_Column_List"/>
|
||||||
|
FROM puzzle_generation_record
|
||||||
|
WHERE user_id = #{userId}
|
||||||
|
ORDER BY create_time DESC
|
||||||
|
<if test="limit != null">
|
||||||
|
LIMIT #{limit}
|
||||||
|
</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 查询订单的生成记录列表 -->
|
||||||
|
<select id="listByOrderId" resultMap="BaseResultMap">
|
||||||
|
SELECT <include refid="Base_Column_List"/>
|
||||||
|
FROM puzzle_generation_record
|
||||||
|
WHERE order_id = #{orderId}
|
||||||
|
ORDER BY create_time DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 插入 -->
|
||||||
|
<insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity"
|
||||||
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO puzzle_generation_record (
|
||||||
|
template_id, template_code, user_id, order_id, business_type,
|
||||||
|
generation_params, result_image_url, result_file_size, result_width, result_height,
|
||||||
|
status, error_message, generation_duration, retry_count,
|
||||||
|
scenic_id, client_ip, user_agent, create_time, update_time
|
||||||
|
) VALUES (
|
||||||
|
#{templateId}, #{templateCode}, #{userId}, #{orderId}, #{businessType},
|
||||||
|
#{generationParams}, #{resultImageUrl}, #{resultFileSize}, #{resultWidth}, #{resultHeight},
|
||||||
|
#{status}, #{errorMessage}, #{generationDuration}, #{retryCount},
|
||||||
|
#{scenicId}, #{clientIp}, #{userAgent}, NOW(), NOW()
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 更新 -->
|
||||||
|
<update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity">
|
||||||
|
UPDATE puzzle_generation_record
|
||||||
|
<set>
|
||||||
|
<if test="resultImageUrl != null">result_image_url = #{resultImageUrl},</if>
|
||||||
|
<if test="resultFileSize != null">result_file_size = #{resultFileSize},</if>
|
||||||
|
<if test="resultWidth != null">result_width = #{resultWidth},</if>
|
||||||
|
<if test="resultHeight != null">result_height = #{resultHeight},</if>
|
||||||
|
<if test="status != null">status = #{status},</if>
|
||||||
|
<if test="errorMessage != null">error_message = #{errorMessage},</if>
|
||||||
|
<if test="generationDuration != null">generation_duration = #{generationDuration},</if>
|
||||||
|
<if test="retryCount != null">retry_count = #{retryCount},</if>
|
||||||
|
update_time = NOW()
|
||||||
|
</set>
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 更新为成功状态 -->
|
||||||
|
<update id="updateSuccess">
|
||||||
|
UPDATE puzzle_generation_record
|
||||||
|
SET status = 1,
|
||||||
|
result_image_url = #{resultImageUrl},
|
||||||
|
result_file_size = #{resultFileSize},
|
||||||
|
result_width = #{resultWidth},
|
||||||
|
result_height = #{resultHeight},
|
||||||
|
generation_duration = #{generationDuration},
|
||||||
|
update_time = NOW()
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 更新为失败状态 -->
|
||||||
|
<update id="updateFail">
|
||||||
|
UPDATE puzzle_generation_record
|
||||||
|
SET status = 2,
|
||||||
|
error_message = #{errorMessage},
|
||||||
|
update_time = NOW()
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
114
src/main/resources/mapper/PuzzleTemplateMapper.xml
Normal file
114
src/main/resources/mapper/PuzzleTemplateMapper.xml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper">
|
||||||
|
|
||||||
|
<!-- 结果映射 -->
|
||||||
|
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity">
|
||||||
|
<id column="id" property="id"/>
|
||||||
|
<result column="name" property="name"/>
|
||||||
|
<result column="code" property="code"/>
|
||||||
|
<result column="canvas_width" property="canvasWidth"/>
|
||||||
|
<result column="canvas_height" property="canvasHeight"/>
|
||||||
|
<result column="background_type" property="backgroundType"/>
|
||||||
|
<result column="background_color" property="backgroundColor"/>
|
||||||
|
<result column="background_image" property="backgroundImage"/>
|
||||||
|
<result column="description" property="description"/>
|
||||||
|
<result column="category" property="category"/>
|
||||||
|
<result column="status" property="status"/>
|
||||||
|
<result column="scenic_id" property="scenicId"/>
|
||||||
|
<result column="create_time" property="createTime"/>
|
||||||
|
<result column="update_time" property="updateTime"/>
|
||||||
|
<result column="deleted" property="deleted"/>
|
||||||
|
<result column="deleted_at" property="deletedAt"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!-- 基础列 -->
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
id, name, code, canvas_width, canvas_height, background_type, background_color,
|
||||||
|
background_image, description, category, status, scenic_id, create_time, update_time, deleted, deleted_at
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- 根据ID查询 -->
|
||||||
|
<select id="getById" resultMap="BaseResultMap">
|
||||||
|
SELECT <include refid="Base_Column_List"/>
|
||||||
|
FROM puzzle_template
|
||||||
|
WHERE id = #{id} AND deleted = 0
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据编码查询 -->
|
||||||
|
<select id="getByCode" resultMap="BaseResultMap">
|
||||||
|
SELECT <include refid="Base_Column_List"/>
|
||||||
|
FROM puzzle_template
|
||||||
|
WHERE code = #{code} AND deleted = 0
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 查询列表 -->
|
||||||
|
<select id="list" resultMap="BaseResultMap">
|
||||||
|
SELECT <include refid="Base_Column_List"/>
|
||||||
|
FROM puzzle_template
|
||||||
|
WHERE deleted = 0
|
||||||
|
<if test="scenicId != null">
|
||||||
|
AND (scenic_id = #{scenicId} OR scenic_id IS NULL)
|
||||||
|
</if>
|
||||||
|
<if test="category != null and category != ''">
|
||||||
|
AND category = #{category}
|
||||||
|
</if>
|
||||||
|
<if test="status != null">
|
||||||
|
AND status = #{status}
|
||||||
|
</if>
|
||||||
|
ORDER BY create_time DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 插入 -->
|
||||||
|
<insert id="insert" parameterType="com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity"
|
||||||
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO puzzle_template (
|
||||||
|
name, code, canvas_width, canvas_height, background_type, background_color,
|
||||||
|
background_image, description, category, status, scenic_id, create_time, update_time, deleted
|
||||||
|
) VALUES (
|
||||||
|
#{name}, #{code}, #{canvasWidth}, #{canvasHeight}, #{backgroundType}, #{backgroundColor},
|
||||||
|
#{backgroundImage}, #{description}, #{category}, #{status}, #{scenicId}, NOW(), NOW(), 0
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 更新 -->
|
||||||
|
<update id="update" parameterType="com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity">
|
||||||
|
UPDATE puzzle_template
|
||||||
|
<set>
|
||||||
|
<if test="name != null">name = #{name},</if>
|
||||||
|
<if test="code != null">code = #{code},</if>
|
||||||
|
<if test="canvasWidth != null">canvas_width = #{canvasWidth},</if>
|
||||||
|
<if test="canvasHeight != null">canvas_height = #{canvasHeight},</if>
|
||||||
|
<if test="backgroundType != null">background_type = #{backgroundType},</if>
|
||||||
|
<if test="backgroundColor != null">background_color = #{backgroundColor},</if>
|
||||||
|
<if test="backgroundImage != null">background_image = #{backgroundImage},</if>
|
||||||
|
<if test="description != null">description = #{description},</if>
|
||||||
|
<if test="category != null">category = #{category},</if>
|
||||||
|
<if test="status != null">status = #{status},</if>
|
||||||
|
<if test="scenicId != null">scenic_id = #{scenicId},</if>
|
||||||
|
update_time = NOW()
|
||||||
|
</set>
|
||||||
|
WHERE id = #{id} AND deleted = 0
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 逻辑删除 -->
|
||||||
|
<update id="deleteById">
|
||||||
|
UPDATE puzzle_template
|
||||||
|
SET deleted = 1, deleted_at = NOW(), update_time = NOW()
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 检查编码是否存在 -->
|
||||||
|
<select id="countByCode" resultType="int">
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM puzzle_template
|
||||||
|
WHERE code = #{code} AND deleted = 0
|
||||||
|
<if test="excludeId != null">
|
||||||
|
AND id != #{excludeId}
|
||||||
|
</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
select s.*, ms.is_buy
|
select s.*, ms.is_buy
|
||||||
from member_source ms
|
from member_source ms
|
||||||
left join source s on ms.source_id = s.id
|
left join source s on ms.source_id = s.id
|
||||||
where ms.face_id = #{faceId} and ms.member_id = #{memberId} and ms.type = 1
|
where ms.face_id = #{faceId} and ms.type = 1
|
||||||
order by create_time desc
|
order by create_time desc
|
||||||
</select>
|
</select>
|
||||||
<select id="listVideoByScenicFaceRelation" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
|
<select id="listVideoByScenicFaceRelation" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
select s.*, ms.is_buy
|
select s.*, ms.is_buy
|
||||||
from member_source ms
|
from member_source ms
|
||||||
left join source s on ms.source_id = s.id
|
left join source s on ms.source_id = s.id
|
||||||
where ms.face_id = #{faceId} and ms.member_id = #{memberId} and ms.type = 2
|
where ms.face_id = #{faceId} and ms.type = 2
|
||||||
</select>
|
</select>
|
||||||
<select id="getEntity" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
|
<select id="getEntity" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
|
||||||
select *
|
select *
|
||||||
|
|||||||
@@ -76,15 +76,17 @@
|
|||||||
)
|
)
|
||||||
</delete>
|
</delete>
|
||||||
<select id="list" resultType="com.ycwl.basic.model.pc.video.resp.VideoRespVO">
|
<select id="list" resultType="com.ycwl.basic.model.pc.video.resp.VideoRespVO">
|
||||||
select v.id, v.scenic_id, template_id, task_id, worker_id, video_url, v.create_time, v.update_time,
|
select v.id, v.scenic_id, v.template_id, task_id, tk.worker_id, v.video_url, v.create_time, v.update_time,
|
||||||
t.name templateName, t.price templatePrice,t.cover_url templateCoverUrl
|
t.name templateName, t.cover_url templateCoverUrl,
|
||||||
|
tk.task_params taskParams, tk.start_time, tk.end_time
|
||||||
from video v
|
from video v
|
||||||
left join template t on v.template_id = t.id
|
left join template t on v.template_id = t.id
|
||||||
|
left join task tk on v.task_id = tk.id
|
||||||
<where>
|
<where>
|
||||||
<if test="scenicId!= null">and v.scenic_id = #{scenicId} </if>
|
<if test="scenicId!= null">and v.scenic_id = #{scenicId} </if>
|
||||||
<if test="templateId!= null">and template_id = #{templateId} </if>
|
<if test="templateId!= null">and v.template_id = #{templateId} </if>
|
||||||
<if test="taskId!=null">and task_id = #{taskId}</if>
|
<if test="taskId!=null">and task_id = #{taskId}</if>
|
||||||
<if test="workerId!= null">and worker_id = #{workerId} </if>
|
<if test="workerId!= null">and t.worker_id = #{workerId} </if>
|
||||||
<if test="startTime!= null">and v.create_time >= #{startTime} </if>
|
<if test="startTime!= null">and v.create_time >= #{startTime} </if>
|
||||||
<if test="endTime!= null">and v.create_time <= #{endTime} </if>
|
<if test="endTime!= null">and v.create_time <= #{endTime} </if>
|
||||||
<if test="faceId!= null">
|
<if test="faceId!= null">
|
||||||
@@ -96,22 +98,25 @@
|
|||||||
order by v.create_time desc
|
order by v.create_time desc
|
||||||
</select>
|
</select>
|
||||||
<select id="getById" resultType="com.ycwl.basic.model.pc.video.resp.VideoRespVO">
|
<select id="getById" resultType="com.ycwl.basic.model.pc.video.resp.VideoRespVO">
|
||||||
select v.id, v.scenic_id, template_id, task_id, worker_id, video_url, v.create_time, v.update_time,
|
select v.id, v.scenic_id, v.template_id, task_id, tk.worker_id, v.video_url, v.create_time, v.update_time,
|
||||||
t.name templateName,t.price templatePrice, t.cover_url templateCoverUrl, t.slash_price slashPrice,
|
t.name templateName, t.cover_url templateCoverUrl,
|
||||||
v.height, v.width, v.duration
|
tk.task_params taskParams, tk.start_time, tk.end_time
|
||||||
from video v
|
from video v
|
||||||
left join template t on v.template_id = t.id
|
left join template t on v.template_id = t.id
|
||||||
|
left join task tk on v.task_id = tk.id
|
||||||
where v.id = #{id}
|
where v.id = #{id}
|
||||||
</select>
|
</select>
|
||||||
<select id="findByTaskId" resultType="com.ycwl.basic.model.pc.video.entity.VideoEntity">
|
<select id="findByTaskId" resultType="com.ycwl.basic.model.pc.video.entity.VideoEntity">
|
||||||
select * from video where task_id = #{taskId} limit 1
|
select * from video where task_id = #{taskId} limit 1
|
||||||
</select>
|
</select>
|
||||||
<select id="queryByRelation" resultType="com.ycwl.basic.model.pc.video.resp.VideoRespVO">
|
<select id="queryByRelation" resultType="com.ycwl.basic.model.pc.video.resp.VideoRespVO">
|
||||||
select v.id, mv.scenic_id, v.template_id, mv.task_id, mv.face_id, worker_id, video_url, v.create_time, v.update_time,
|
select v.id, mv.scenic_id, v.template_id, mv.task_id, mv.face_id, tk.worker_id, v.video_url, v.create_time, v.update_time,
|
||||||
t.name templateName, t.price templatePrice,t.cover_url templateCoverUrl, mv.is_buy
|
t.name templateName, t.price templatePrice,t.cover_url templateCoverUrl, mv.is_buy,
|
||||||
|
tk.task_params taskParams
|
||||||
from member_video mv
|
from member_video mv
|
||||||
left join video v on mv.video_id = v.id
|
left join video v on mv.video_id = v.id
|
||||||
left join template t on mv.template_id = t.id
|
left join template t on mv.template_id = t.id
|
||||||
|
left join task tk on mv.task_id = tk.id
|
||||||
<where>
|
<where>
|
||||||
<if test="scenicId!= null">and mv.scenic_id = #{scenicId} </if>
|
<if test="scenicId!= null">and mv.scenic_id = #{scenicId} </if>
|
||||||
<if test="memberId!= null">and mv.member_id = #{memberId} </if>
|
<if test="memberId!= null">and mv.member_id = #{memberId} </if>
|
||||||
@@ -175,4 +180,11 @@
|
|||||||
set member_id = #{memberId}
|
set member_id = #{memberId}
|
||||||
where face_id = #{faceId}
|
where face_id = #{faceId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<!-- 查询指定视频是否存在已购买记录 -->
|
||||||
|
<select id="countBuyRecordByVideoId" resultType="int">
|
||||||
|
select count(*)
|
||||||
|
from member_video
|
||||||
|
where video_id = #{videoId} and is_buy = 1
|
||||||
|
</select>
|
||||||
</mapper>
|
</mapper>
|
||||||
145
src/main/resources/mapper/VideoReviewMapper.xml
Normal file
145
src/main/resources/mapper/VideoReviewMapper.xml
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ycwl.basic.mapper.VideoReviewMapper">
|
||||||
|
|
||||||
|
<!-- 结果映射 -->
|
||||||
|
<resultMap id="VideoReviewRespMap" type="com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO">
|
||||||
|
<id property="id" column="id"/>
|
||||||
|
<result property="videoId" column="video_id"/>
|
||||||
|
<result property="videoUrl" column="video_url"/>
|
||||||
|
<result property="scenicId" column="scenic_id"/>
|
||||||
|
<result property="scenicName" column="scenic_name"/>
|
||||||
|
<result property="creator" column="creator"/>
|
||||||
|
<result property="creatorName" column="creator_name"/>
|
||||||
|
<result property="rating" column="rating"/>
|
||||||
|
<result property="content" column="content"/>
|
||||||
|
<result property="cameraPositionRating" column="camera_position_rating"
|
||||||
|
typeHandler="com.ycwl.basic.handler.NestedMapTypeHandler"/>
|
||||||
|
<result property="createTime" column="create_time"/>
|
||||||
|
<result property="updateTime" column="update_time"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!-- 分页查询评价列表 -->
|
||||||
|
<select id="selectReviewList" parameterType="com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO"
|
||||||
|
resultMap="VideoReviewRespMap">
|
||||||
|
SELECT
|
||||||
|
vr.id,
|
||||||
|
vr.video_id,
|
||||||
|
vr.scenic_id,
|
||||||
|
vr.creator,
|
||||||
|
vr.rating,
|
||||||
|
vr.content,
|
||||||
|
vr.camera_position_rating,
|
||||||
|
vr.create_time,
|
||||||
|
vr.update_time,
|
||||||
|
v.video_url,
|
||||||
|
s.name AS scenic_name,
|
||||||
|
u.name AS creator_name
|
||||||
|
FROM video_review vr
|
||||||
|
LEFT JOIN video v ON vr.video_id = v.id
|
||||||
|
LEFT JOIN scenic s ON vr.scenic_id = s.id
|
||||||
|
LEFT JOIN admin_user u ON vr.creator = u.id
|
||||||
|
<where>
|
||||||
|
<if test="videoId != null">
|
||||||
|
AND vr.video_id = #{videoId}
|
||||||
|
</if>
|
||||||
|
<if test="scenicId != null">
|
||||||
|
AND vr.scenic_id = #{scenicId}
|
||||||
|
</if>
|
||||||
|
<if test="creator != null">
|
||||||
|
AND vr.creator = #{creator}
|
||||||
|
</if>
|
||||||
|
<if test="rating != null">
|
||||||
|
AND vr.rating = #{rating}
|
||||||
|
</if>
|
||||||
|
<if test="minRating != null">
|
||||||
|
AND vr.rating >= #{minRating}
|
||||||
|
</if>
|
||||||
|
<if test="maxRating != null">
|
||||||
|
AND vr.rating <= #{maxRating}
|
||||||
|
</if>
|
||||||
|
<if test="startTime != null and startTime != ''">
|
||||||
|
AND vr.create_time >= #{startTime}
|
||||||
|
</if>
|
||||||
|
<if test="endTime != null and endTime != ''">
|
||||||
|
AND vr.create_time <= #{endTime}
|
||||||
|
</if>
|
||||||
|
<if test="keyword != null and keyword != ''">
|
||||||
|
AND vr.content LIKE CONCAT('%', #{keyword}, '%')
|
||||||
|
</if>
|
||||||
|
</where>
|
||||||
|
ORDER BY
|
||||||
|
<choose>
|
||||||
|
<when test="orderBy == 'rating'">
|
||||||
|
vr.rating
|
||||||
|
</when>
|
||||||
|
<when test="orderBy == 'update_time'">
|
||||||
|
vr.update_time
|
||||||
|
</when>
|
||||||
|
<otherwise>
|
||||||
|
vr.create_time
|
||||||
|
</otherwise>
|
||||||
|
</choose>
|
||||||
|
<choose>
|
||||||
|
<when test="orderDirection == 'ASC'">
|
||||||
|
ASC
|
||||||
|
</when>
|
||||||
|
<otherwise>
|
||||||
|
DESC
|
||||||
|
</otherwise>
|
||||||
|
</choose>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 统计总评价数 -->
|
||||||
|
<select id="countTotal" resultType="java.lang.Long">
|
||||||
|
SELECT COUNT(*) FROM video_review
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 计算平均评分 -->
|
||||||
|
<select id="calculateAverageRating" resultType="java.lang.Double">
|
||||||
|
SELECT AVG(rating) FROM video_review
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 统计评分分布 -->
|
||||||
|
<select id="countRatingDistribution" resultType="java.util.Map">
|
||||||
|
SELECT
|
||||||
|
rating AS ratingValue,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM video_review
|
||||||
|
GROUP BY rating
|
||||||
|
ORDER BY rating
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 统计最近N天的评价趋势 -->
|
||||||
|
<select id="countRecentTrend" resultType="java.util.Map">
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(create_time, '%Y-%m-%d') AS dateStr,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM video_review
|
||||||
|
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL #{days} DAY)
|
||||||
|
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d')
|
||||||
|
ORDER BY dateStr
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 统计景区评价排行 -->
|
||||||
|
<select id="countScenicRank" resultType="com.ycwl.basic.model.pc.videoreview.dto.VideoReviewStatisticsRespDTO$ScenicReviewRank">
|
||||||
|
SELECT
|
||||||
|
vr.scenic_id AS scenicId,
|
||||||
|
s.name AS scenicName,
|
||||||
|
COUNT(*) AS reviewCount,
|
||||||
|
AVG(vr.rating) AS averageRating
|
||||||
|
FROM video_review vr
|
||||||
|
LEFT JOIN scenic s ON vr.scenic_id = s.id
|
||||||
|
GROUP BY vr.scenic_id, s.name
|
||||||
|
ORDER BY reviewCount DESC, averageRating DESC
|
||||||
|
LIMIT #{limit}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 查询所有机位评价数据 -->
|
||||||
|
<select id="selectAllCameraPositionRatings" resultType="java.util.Map">
|
||||||
|
SELECT camera_position_rating
|
||||||
|
FROM video_review
|
||||||
|
WHERE camera_position_rating IS NOT NULL AND camera_position_rating != ''
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package com.ycwl.basic.pricing.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ycwl.basic.pricing.dto.ProductItem;
|
||||||
|
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
|
||||||
|
import com.ycwl.basic.pricing.enums.CouponType;
|
||||||
|
import com.ycwl.basic.pricing.enums.ProductType;
|
||||||
|
import com.ycwl.basic.pricing.service.impl.CouponServiceImpl;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优惠券溢出问题测试
|
||||||
|
* 测试多SKU场景下优惠券优惠金额不会溢出到不适用的商品
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("优惠券溢出问题测试")
|
||||||
|
class CouponOverflowTest {
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private CouponServiceImpl couponService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// 使用真实的ObjectMapper,避免mock导致的问题
|
||||||
|
couponService = new CouponServiceImpl(null, null, new ObjectMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("固定金额优惠券 - 多SKU场景优惠金额超过适用商品总价应被截断")
|
||||||
|
void testFixedAmountCoupon_MultiSKU_DiscountExceedsApplicableTotal() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(3元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("13.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.FIXED_AMOUNT,
|
||||||
|
new BigDecimal("5.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
"[\"PHOTO_PRINT_MU\"]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 优惠金额应为3元(适用商品总价),而非5元
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("3.00")) == 0, "优惠金额应被截断为适用商品总价");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("固定金额优惠券 - 优惠金额小于适用商品总价应正常优惠")
|
||||||
|
void testFixedAmountCoupon_MultiSKU_DiscountWithinApplicableTotal() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("18.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.FIXED_AMOUNT,
|
||||||
|
new BigDecimal("5.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
"[\"PHOTO_PRINT_MU\"]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 优惠金额应为5元(配置值)
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "优惠金额未超过适用商品总价,应为配置值");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("百分比优惠券 - 应基于适用商品总价计算而非购物车总价")
|
||||||
|
void testPercentageCoupon_MultiSKU_CalculateOnApplicableTotal() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(50元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("50.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("150.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 9折,仅适用于PHOTO_PRINT_MU
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.PERCENTAGE,
|
||||||
|
new BigDecimal("10.00"), // 10% off = 9折
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
"[\"PHOTO_PRINT_MU\"]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 优惠金额应为5元(50 * 10% = 5),而非15元(150 * 10% = 15)
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "百分比优惠应基于适用商品总价计算");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("百分比优惠券 - 计算结果超过适用商品总价应被截断")
|
||||||
|
void testPercentageCoupon_MultiSKU_DiscountExceedsApplicableTotal() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(3元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("103.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 5折,仅适用于PHOTO_PRINT_MU
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.PERCENTAGE,
|
||||||
|
new BigDecimal("50.00"), // 50% off = 5折
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
"[\"PHOTO_PRINT_MU\"]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 理论优惠1.5元(3 * 50% = 1.5),实际不应超过3元
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("3.00")) <= 0, "优惠金额不应超过适用商品总价");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("无商品类型限制的优惠券 - 应用于所有商品")
|
||||||
|
void testCoupon_NoProductTypeRestriction() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("18.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 满0减5元,无商品类型限制
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.FIXED_AMOUNT,
|
||||||
|
new BigDecimal("5.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
null // 无商品类型限制
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 优惠金额应为5元
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "无商品类型限制时应应用于所有商品");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("无商品类型限制的优惠券 - 优惠金额超过总价应被截断")
|
||||||
|
void testCoupon_NoProductTypeRestriction_ExceedsTotalPrice() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(2元) + PHOTO_PRINT_MU(1元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("2.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("1.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("3.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 满0减10元,无商品类型限制
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.FIXED_AMOUNT,
|
||||||
|
new BigDecimal("10.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 优惠金额应被截断为3元
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("3.00")) == 0, "优惠金额应被截断为商品总价");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("多商品类型限制的优惠券 - 计算多个适用商品的总价")
|
||||||
|
void testCoupon_MultipleProductTypeRestriction() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元) + VLOG_VIDEO(20元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00")),
|
||||||
|
createProductItem(ProductType.VLOG_VIDEO, new BigDecimal("20.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("38.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 满0减10元,适用于PHOTO_PRINT和PHOTO_PRINT_MU
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.FIXED_AMOUNT,
|
||||||
|
new BigDecimal("10.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
"[\"PHOTO_PRINT\",\"PHOTO_PRINT_MU\"]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 优惠金额应为10元(适用商品总价18元,优惠10元未超限)
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("10.00")) == 0, "多商品类型限制时应正确计算适用商品总价");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("边界情况 - 适用商品总价为0")
|
||||||
|
void testCoupon_ApplicableTotalIsZero() {
|
||||||
|
// 准备商品列表: 仅有PHOTO_PRINT(10元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("10.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU(购物车中无此商品)
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.FIXED_AMOUNT,
|
||||||
|
new BigDecimal("5.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
"[\"PHOTO_PRINT_MU\"]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 优惠金额应为0(适用商品总价为0,自动截断)
|
||||||
|
assertTrue(new BigDecimal("0.00").compareTo(discount) == 0, "适用商品总价为0时优惠金额应为0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("边界情况 - 优惠金额刚好等于适用商品总价")
|
||||||
|
void testCoupon_DiscountEqualsApplicableTotal() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(5元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("5.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("15.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.FIXED_AMOUNT,
|
||||||
|
new BigDecimal("5.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
"[\"PHOTO_PRINT_MU\"]"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 优惠金额应为5元
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "优惠金额刚好等于适用商品总价时应正常优惠");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("百分比优惠券 - 带最大优惠金额限制且超过适用商品总价")
|
||||||
|
void testPercentageCoupon_WithMaxDiscount_ExceedsApplicableTotal() {
|
||||||
|
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(3元)
|
||||||
|
List<ProductItem> products = Arrays.asList(
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
|
||||||
|
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
|
||||||
|
);
|
||||||
|
BigDecimal totalAmount = new BigDecimal("103.00");
|
||||||
|
|
||||||
|
// 优惠券配置: 5折,最大优惠10元,仅适用于PHOTO_PRINT_MU
|
||||||
|
PriceCouponConfig coupon = createCoupon(
|
||||||
|
CouponType.PERCENTAGE,
|
||||||
|
new BigDecimal("50.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
"[\"PHOTO_PRINT_MU\"]"
|
||||||
|
);
|
||||||
|
coupon.setMaxDiscount(new BigDecimal("10.00"));
|
||||||
|
|
||||||
|
// 执行计算
|
||||||
|
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
|
||||||
|
|
||||||
|
// 验证: 理论优惠1.5元(3 * 50%),不受最大优惠10元限制,但仍不超过适用商品总价3元
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("3.00")) <= 0, "优惠金额不应超过适用商品总价");
|
||||||
|
assertTrue(discount.compareTo(new BigDecimal("1.50")) >= 0, "优惠金额应为计算值1.5元");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 辅助方法 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建商品项
|
||||||
|
*/
|
||||||
|
private ProductItem createProductItem(ProductType productType, BigDecimal subtotal) {
|
||||||
|
ProductItem item = new ProductItem();
|
||||||
|
item.setProductType(productType);
|
||||||
|
item.setSubtotal(subtotal);
|
||||||
|
item.setQuantity(1);
|
||||||
|
item.setUnitPrice(subtotal);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建优惠券配置
|
||||||
|
*/
|
||||||
|
private PriceCouponConfig createCoupon(CouponType type, BigDecimal value,
|
||||||
|
BigDecimal minAmount, String applicableProducts) {
|
||||||
|
PriceCouponConfig coupon = new PriceCouponConfig();
|
||||||
|
coupon.setId(1L);
|
||||||
|
coupon.setCouponName("测试优惠券");
|
||||||
|
coupon.setCouponType(type);
|
||||||
|
coupon.setDiscountValue(value);
|
||||||
|
coupon.setMinAmount(minAmount);
|
||||||
|
coupon.setApplicableProducts(applicableProducts);
|
||||||
|
coupon.setIsActive(true);
|
||||||
|
return coupon;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.ycwl.basic.puzzle.element;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||||
|
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||||
|
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 com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ElementFactory 单元测试
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
class ElementFactoryTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUp() {
|
||||||
|
// 确保Element已注册(Spring会自动调用ElementRegistrar)
|
||||||
|
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||||
|
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateTextElement_Success() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
|
||||||
|
1L, "userName", 100, 200, 300, 50, 10,
|
||||||
|
"测试文字", 24, "#333333"
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(element);
|
||||||
|
assertInstanceOf(TextElement.class, element);
|
||||||
|
assertEquals(ElementType.TEXT, element.getElementType());
|
||||||
|
assertEquals("userName", element.getElementKey());
|
||||||
|
assertEquals(100, element.getPosition().getX());
|
||||||
|
assertEquals(200, element.getPosition().getY());
|
||||||
|
assertEquals(300, element.getPosition().getWidth());
|
||||||
|
assertEquals(50, element.getPosition().getHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateImageElement_Success() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
|
||||||
|
1L, "userAvatar", 50, 100, 100, 100, 5,
|
||||||
|
"https://example.com/avatar.jpg"
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(element);
|
||||||
|
assertInstanceOf(ImageElement.class, element);
|
||||||
|
assertEquals(ElementType.IMAGE, element.getElementType());
|
||||||
|
assertEquals("userAvatar", element.getElementKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateElement_InvalidType() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||||
|
entity.setElementType("INVALID_TYPE");
|
||||||
|
entity.setElementKey("test");
|
||||||
|
entity.setConfig("{}");
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> ElementFactory.create(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateElement_NullConfig() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||||
|
entity.setElementType("TEXT");
|
||||||
|
entity.setElementKey("test");
|
||||||
|
entity.setConfig(null);
|
||||||
|
entity.setXPosition(0);
|
||||||
|
entity.setYPosition(0);
|
||||||
|
entity.setWidth(100);
|
||||||
|
entity.setHeight(50);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> ElementFactory.create(entity));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.ycwl.basic.puzzle.element;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||||
|
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||||
|
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||||
|
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ImageElement 单元测试
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
class ImageElementTest {
|
||||||
|
|
||||||
|
private Graphics2D graphics;
|
||||||
|
private RenderContext context;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
graphics = canvas.createGraphics();
|
||||||
|
|
||||||
|
Map<String, String> dynamicData = new HashMap<>();
|
||||||
|
context = new RenderContext(graphics, dynamicData, 800, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testImageElement_Creation_Success() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
|
||||||
|
1L, "userAvatar", 50, 100, 100, 100, 5,
|
||||||
|
"https://example.com/avatar.jpg"
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(element);
|
||||||
|
assertEquals("userAvatar", element.getElementKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testImageElement_RoundedImage_Success() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createRoundedImageElement(
|
||||||
|
1L, "userAvatar", 50, 100, 100, 100, 5,
|
||||||
|
"https://example.com/avatar.jpg", 50
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(element);
|
||||||
|
// 验证配置包含圆角信息
|
||||||
|
String schema = element.getConfigSchema();
|
||||||
|
assertTrue(schema.contains("borderRadius"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testImageElement_InvalidConfig_MissingDefaultImageUrl() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||||
|
entity.setElementType("IMAGE");
|
||||||
|
entity.setElementKey("testKey");
|
||||||
|
entity.setConfig("{\"imageFitMode\":\"FILL\"}"); // 缺少 defaultImageUrl
|
||||||
|
entity.setXPosition(0);
|
||||||
|
entity.setYPosition(0);
|
||||||
|
entity.setWidth(100);
|
||||||
|
entity.setHeight(100);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testImageElement_InvalidConfig_InvalidBorderRadius() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||||
|
entity.setElementType("IMAGE");
|
||||||
|
entity.setElementKey("testKey");
|
||||||
|
entity.setConfig("{\"defaultImageUrl\":\"test.jpg\",\"borderRadius\":-1}"); // 非法圆角
|
||||||
|
entity.setXPosition(0);
|
||||||
|
entity.setYPosition(0);
|
||||||
|
entity.setWidth(100);
|
||||||
|
entity.setHeight(100);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testImageElement_GetConfigSchema() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
|
||||||
|
1L, "userAvatar", 50, 100, 100, 100, 5,
|
||||||
|
"https://example.com/avatar.jpg"
|
||||||
|
);
|
||||||
|
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
|
||||||
|
// When
|
||||||
|
String schema = element.getConfigSchema();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(schema);
|
||||||
|
assertTrue(schema.contains("defaultImageUrl"));
|
||||||
|
assertTrue(schema.contains("imageFitMode"));
|
||||||
|
assertTrue(schema.contains("borderRadius"));
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java
Normal file
107
src/test/java/com/ycwl/basic/puzzle/element/TextElementTest.java
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package com.ycwl.basic.puzzle.element;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||||
|
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||||
|
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||||
|
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TextElement 单元测试
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-18
|
||||||
|
*/
|
||||||
|
class TextElementTest {
|
||||||
|
|
||||||
|
private Graphics2D graphics;
|
||||||
|
private RenderContext context;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
BufferedImage canvas = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
graphics = canvas.createGraphics();
|
||||||
|
|
||||||
|
Map<String, String> dynamicData = new HashMap<>();
|
||||||
|
context = new RenderContext(graphics, dynamicData, 800, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTextElement_Render_Success() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
|
||||||
|
1L, "userName", 100, 200, 300, 50, 10,
|
||||||
|
"默认文字", 24, "#333333"
|
||||||
|
);
|
||||||
|
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
|
||||||
|
// 添加动态数据
|
||||||
|
context.getDynamicData().put("userName", "张三");
|
||||||
|
|
||||||
|
// When & Then (不抛出异常即为成功)
|
||||||
|
assertDoesNotThrow(() -> element.render(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTextElement_InvalidConfig_MissingDefaultText() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||||
|
entity.setElementType("TEXT");
|
||||||
|
entity.setElementKey("testKey");
|
||||||
|
entity.setConfig("{\"fontSize\":14,\"fontColor\":\"#000000\"}"); // 缺少 defaultText
|
||||||
|
entity.setXPosition(0);
|
||||||
|
entity.setYPosition(0);
|
||||||
|
entity.setWidth(100);
|
||||||
|
entity.setHeight(50);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTextElement_InvalidConfig_InvalidFontSize() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = new PuzzleElementEntity();
|
||||||
|
entity.setElementType("TEXT");
|
||||||
|
entity.setElementKey("testKey");
|
||||||
|
entity.setConfig("{\"defaultText\":\"test\",\"fontSize\":0,\"fontColor\":\"#000000\"}"); // 非法字号
|
||||||
|
entity.setXPosition(0);
|
||||||
|
entity.setYPosition(0);
|
||||||
|
entity.setWidth(100);
|
||||||
|
entity.setHeight(50);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThrows(ElementValidationException.class, () -> ElementFactory.create(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTextElement_GetConfigSchema() {
|
||||||
|
// Given
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
|
||||||
|
1L, "userName", 100, 200, 300, 50, 10,
|
||||||
|
"测试文字", 24, "#333333"
|
||||||
|
);
|
||||||
|
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
|
||||||
|
// When
|
||||||
|
String schema = element.getConfigSchema();
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(schema);
|
||||||
|
assertTrue(schema.contains("defaultText"));
|
||||||
|
assertTrue(schema.contains("fontSize"));
|
||||||
|
assertTrue(schema.contains("fontColor"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.ycwl.basic.puzzle.integration;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||||
|
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||||
|
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 com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element 调试测试
|
||||||
|
* 用于验证 Element 创建和配置是否正确
|
||||||
|
*/
|
||||||
|
class ElementDebugTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void registerElements() {
|
||||||
|
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||||
|
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateTextElement() {
|
||||||
|
System.out.println("=== 测试创建 TEXT 元素 ===");
|
||||||
|
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createTextElement(
|
||||||
|
1L, "testText", 100, 200, 300, 50, 10,
|
||||||
|
"测试文字", 24, "#333333"
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println("Entity elementType: " + entity.getElementType());
|
||||||
|
System.out.println("Entity config: " + entity.getConfig());
|
||||||
|
|
||||||
|
try {
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
System.out.println("✅ Element 创建成功");
|
||||||
|
System.out.println("Element type: " + element.getElementType());
|
||||||
|
System.out.println("Element key: " + element.getElementKey());
|
||||||
|
assertNotNull(element);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("❌ Element 创建失败: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
fail("Element 创建失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateImageElement() {
|
||||||
|
System.out.println("\n=== 测试创建 IMAGE 元素 ===");
|
||||||
|
|
||||||
|
PuzzleElementEntity entity = PuzzleTestDataBuilder.createImageElement(
|
||||||
|
1L, "testImage", 50, 100, 100, 100, 5,
|
||||||
|
"https://example.com/test.jpg"
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println("Entity elementType: " + entity.getElementType());
|
||||||
|
System.out.println("Entity config: " + entity.getConfig());
|
||||||
|
|
||||||
|
try {
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
System.out.println("✅ Element 创建成功");
|
||||||
|
System.out.println("Element type: " + element.getElementType());
|
||||||
|
System.out.println("Element key: " + element.getElementKey());
|
||||||
|
assertNotNull(element);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("❌ Element 创建失败: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
fail("Element 创建失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRealScenarioElements() {
|
||||||
|
System.out.println("\n=== 测试现实场景元素创建 ===");
|
||||||
|
|
||||||
|
var elements = PuzzleTestDataBuilder.createRealScenarioElements(1L);
|
||||||
|
System.out.println("元素数量: " + elements.size());
|
||||||
|
|
||||||
|
int successCount = 0;
|
||||||
|
for (PuzzleElementEntity entity : elements) {
|
||||||
|
System.out.println("\n--- 测试元素: " + entity.getElementKey() + " ---");
|
||||||
|
System.out.println("Type: " + entity.getElementType());
|
||||||
|
System.out.println("Config: " + entity.getConfig());
|
||||||
|
|
||||||
|
try {
|
||||||
|
BaseElement element = ElementFactory.create(entity);
|
||||||
|
System.out.println("✅ 创建成功");
|
||||||
|
successCount++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("❌ 创建失败: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("\n总结: " + successCount + "/" + elements.size() + " 个元素创建成功");
|
||||||
|
assertEquals(elements.size(), successCount, "所有元素都应该创建成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package com.ycwl.basic.puzzle.integration;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
|
import com.ycwl.basic.puzzle.test.MockImageUtil;
|
||||||
|
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||||
|
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||||
|
import org.apache.commons.lang3.Strings;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图功能现实场景集成测试
|
||||||
|
* 测试场景:1020x1520画布,上方900高度分3份放图片,底部120像素左侧二维码右侧文字
|
||||||
|
*
|
||||||
|
* 特点:
|
||||||
|
* 1. 不依赖外部数据库
|
||||||
|
* 2. 不依赖外部图片资源(使用Mock图片)
|
||||||
|
* 3. 不依赖OSS存储(图片保存到临时目录)
|
||||||
|
* 4. 适合CI/CD自动化测试
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
class PuzzleRealScenarioIntegrationTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private PuzzleImageRenderer renderer;
|
||||||
|
private Map<String, File> mockImageFiles;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws IOException {
|
||||||
|
renderer = new PuzzleImageRenderer();
|
||||||
|
mockImageFiles = new HashMap<>();
|
||||||
|
|
||||||
|
// 准备Mock图片文件
|
||||||
|
prepareMockImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 准备Mock图片文件(模拟从URL下载的图片)
|
||||||
|
*/
|
||||||
|
private void prepareMockImages() throws IOException {
|
||||||
|
// 创建3张不同颜色的图片(每张470高度)
|
||||||
|
BufferedImage image1 = MockImageUtil.createImageWithText(
|
||||||
|
1020, 470, "图片1", new Color(255, 200, 200), Color.BLACK);
|
||||||
|
File file1 = tempDir.resolve("image1.jpg").toFile();
|
||||||
|
ImageIO.write(image1, "JPG", file1);
|
||||||
|
mockImageFiles.put("image1", file1);
|
||||||
|
|
||||||
|
BufferedImage image2 = MockImageUtil.createImageWithText(
|
||||||
|
1020, 470, "图片2", new Color(200, 255, 200), Color.BLACK);
|
||||||
|
File file2 = tempDir.resolve("image2.jpg").toFile();
|
||||||
|
ImageIO.write(image2, "JPG", file2);
|
||||||
|
mockImageFiles.put("image2", file2);
|
||||||
|
|
||||||
|
BufferedImage image3 = MockImageUtil.createImageWithText(
|
||||||
|
1020, 470, "图片3", new Color(200, 200, 255), Color.BLACK);
|
||||||
|
File file3 = tempDir.resolve("image3.jpg").toFile();
|
||||||
|
ImageIO.write(image3, "JPG", file3);
|
||||||
|
mockImageFiles.put("image3", file3);
|
||||||
|
|
||||||
|
// 创建二维码图片(100x100)
|
||||||
|
BufferedImage qrCode = MockImageUtil.createMockQRCode(100);
|
||||||
|
File qrFile = tempDir.resolve("qrcode.png").toFile();
|
||||||
|
ImageIO.write(qrCode, "PNG", qrFile);
|
||||||
|
mockImageFiles.put("qrCode", qrFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRealScenario_1020x1520_ThreeImagesAndQRCode() throws IOException {
|
||||||
|
// Given: 创建1020x1520的模板
|
||||||
|
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
|
||||||
|
"real_scenario",
|
||||||
|
1020,
|
||||||
|
1520,
|
||||||
|
"#F5F5F5"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Given: 创建元素列表
|
||||||
|
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
|
||||||
|
|
||||||
|
// Given: 准备动态数据(使用本地文件路径)
|
||||||
|
Map<String, String> dynamicData = new HashMap<>();
|
||||||
|
dynamicData.put("image1", mockImageFiles.get("image1").getAbsolutePath());
|
||||||
|
dynamicData.put("image2", mockImageFiles.get("image2").getAbsolutePath());
|
||||||
|
dynamicData.put("image3", mockImageFiles.get("image3").getAbsolutePath());
|
||||||
|
dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
|
||||||
|
dynamicData.put("bottomText", "奇遇时光乐园\n2025.11.11");
|
||||||
|
|
||||||
|
// 打印调试信息
|
||||||
|
System.out.println("\n=== 调试信息 ===");
|
||||||
|
System.out.println("元素数量: " + elements.size());
|
||||||
|
for (PuzzleElementEntity element : elements) {
|
||||||
|
System.out.println("元素: " + element.getElementKey() +
|
||||||
|
" | 类型: " + element.getElementType() +
|
||||||
|
" | 配置: " + element.getConfig());
|
||||||
|
}
|
||||||
|
System.out.println("\n动态数据:");
|
||||||
|
dynamicData.forEach((k, v) -> System.out.println(" " + k + " = " + v));
|
||||||
|
System.out.println("=================\n");
|
||||||
|
|
||||||
|
// When: 渲染拼图
|
||||||
|
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||||
|
|
||||||
|
// Then: 验证结果
|
||||||
|
assertNotNull(result, "渲染结果不应为空");
|
||||||
|
assertEquals(1020, result.getWidth(), "画布宽度应为1020");
|
||||||
|
assertEquals(1520, result.getHeight(), "画布高度应为1520");
|
||||||
|
assertTrue(MockImageUtil.isNotBlank(result), "图片不应为空白");
|
||||||
|
|
||||||
|
// 保存结果图片到临时目录(便于人工验证)
|
||||||
|
File outputFile = tempDir.resolve("real_scenario_output.png").toFile();
|
||||||
|
ImageIO.write(result, "PNG", outputFile);
|
||||||
|
assertTrue(outputFile.exists(), "输出文件应存在");
|
||||||
|
assertTrue(outputFile.length() > 0, "输出文件不应为空");
|
||||||
|
|
||||||
|
System.out.println("✅ 现实场景测试通过!");
|
||||||
|
System.out.println("📁 输出图片路径: " + outputFile.getAbsolutePath());
|
||||||
|
System.out.println("📊 图片尺寸: " + result.getWidth() + "x" + result.getHeight());
|
||||||
|
System.out.println("💾 文件大小: " + outputFile.length() / 1024 + " KB");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRealScenario_VerifyImagePositions() throws IOException {
|
||||||
|
// Given
|
||||||
|
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
|
||||||
|
"position_test",
|
||||||
|
1020,
|
||||||
|
1520,
|
||||||
|
"#FFFFFF"
|
||||||
|
);
|
||||||
|
|
||||||
|
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
|
||||||
|
|
||||||
|
Map<String, String> dynamicData = new HashMap<>();
|
||||||
|
dynamicData.put("image1", mockImageFiles.get("image1").getAbsolutePath());
|
||||||
|
dynamicData.put("image2", mockImageFiles.get("image2").getAbsolutePath());
|
||||||
|
dynamicData.put("image3", mockImageFiles.get("image3").getAbsolutePath());
|
||||||
|
dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
|
||||||
|
dynamicData.put("bottomText", "测试文字");
|
||||||
|
|
||||||
|
// When
|
||||||
|
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||||
|
|
||||||
|
// Then: 验证元素数量
|
||||||
|
assertEquals(5, elements.size(), "应有5个元素");
|
||||||
|
|
||||||
|
// 验证上方3张图片的位置(每张470高度)
|
||||||
|
PuzzleElementEntity img1 = elements.get(0);
|
||||||
|
assertEquals(0, img1.getYPosition(), "第1张图片Y坐标应为0");
|
||||||
|
assertEquals(470, img1.getHeight(), "第1张图片高度应为470");
|
||||||
|
|
||||||
|
PuzzleElementEntity img2 = elements.get(1);
|
||||||
|
assertEquals(470, img2.getYPosition(), "第2张图片Y坐标应为470");
|
||||||
|
assertEquals(470, img2.getHeight(), "第2张图片高度应为470");
|
||||||
|
|
||||||
|
PuzzleElementEntity img3 = elements.get(2);
|
||||||
|
assertEquals(940, img3.getYPosition(), "第3张图片Y坐标应为940");
|
||||||
|
assertEquals(470, img3.getHeight(), "第3张图片高度应为470");
|
||||||
|
|
||||||
|
// 验证底部二维码位置(Y坐标应为1410+5=1415)
|
||||||
|
PuzzleElementEntity qrElement = elements.get(3);
|
||||||
|
assertEquals(1415, qrElement.getYPosition(), "二维码Y坐标应在1410+5位置");
|
||||||
|
assertEquals(100, qrElement.getWidth(), "二维码宽度应为100");
|
||||||
|
assertEquals(100, qrElement.getHeight(), "二维码高度应为100");
|
||||||
|
|
||||||
|
// 验证底部文字位置
|
||||||
|
PuzzleElementEntity textElement = elements.get(4);
|
||||||
|
assertEquals(1420, textElement.getYPosition(), "文字Y坐标应在1410+10位置");
|
||||||
|
assertEquals(140, textElement.getXPosition(), "文字X坐标应为140");
|
||||||
|
assertTrue(textElement.getXPosition() > qrElement.getXPosition() + qrElement.getWidth(), "文字应在二维码右侧");
|
||||||
|
|
||||||
|
System.out.println("✅ 元素位置验证通过!");
|
||||||
|
System.out.println("📐 画布尺寸: " + template.getCanvasWidth() + "x" + template.getCanvasHeight());
|
||||||
|
System.out.println("🔢 元素数量: " + elements.size());
|
||||||
|
System.out.println("📊 图片高度: 470px * 3 = 1410px");
|
||||||
|
System.out.println("📊 底部高度: " + (1520 - 1410) + "px");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRealScenario_WithoutDynamicData_UsesDefaults() throws IOException {
|
||||||
|
// Given: 使用默认图片(测试默认值机制)
|
||||||
|
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
|
||||||
|
"default_test",
|
||||||
|
1020,
|
||||||
|
1520,
|
||||||
|
"#CCCCCC"
|
||||||
|
);
|
||||||
|
|
||||||
|
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
|
||||||
|
|
||||||
|
// When: 不传动态数据,使用默认值
|
||||||
|
Map<String, String> dynamicData = new HashMap<>();
|
||||||
|
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(result, "应使用默认图片渲染成功");
|
||||||
|
assertEquals(1020, result.getWidth());
|
||||||
|
assertEquals(1520, result.getHeight());
|
||||||
|
|
||||||
|
// 保存结果
|
||||||
|
File outputFile = tempDir.resolve("default_scenario_output.png").toFile();
|
||||||
|
ImageIO.write(result, "PNG", outputFile);
|
||||||
|
|
||||||
|
System.out.println("✅ 默认值测试通过!");
|
||||||
|
System.out.println("📁 输出图片路径: " + outputFile.getAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRealScenario_DifferentBackgroundTypes() throws IOException {
|
||||||
|
// Test 1: 纯色背景
|
||||||
|
PuzzleTemplateEntity solidBgTemplate = PuzzleTestDataBuilder.createTemplate(
|
||||||
|
"solid_bg",
|
||||||
|
1020,
|
||||||
|
1520,
|
||||||
|
"#E3F2FD"
|
||||||
|
);
|
||||||
|
solidBgTemplate.setBackgroundType(0);
|
||||||
|
|
||||||
|
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(solidBgTemplate.getId());
|
||||||
|
Map<String, String> dynamicData = prepareLocalFilePaths();
|
||||||
|
|
||||||
|
BufferedImage solidBgResult = renderer.render(solidBgTemplate, elements, dynamicData);
|
||||||
|
assertNotNull(solidBgResult);
|
||||||
|
assertEquals(new Color(227, 242, 253), new Color(solidBgResult.getRGB(0, 0)));
|
||||||
|
|
||||||
|
// Test 2: 图片背景
|
||||||
|
PuzzleTemplateEntity imageBgTemplate = PuzzleTestDataBuilder.createTemplate(
|
||||||
|
"image_bg",
|
||||||
|
1020,
|
||||||
|
1520,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
imageBgTemplate.setBackgroundType(1);
|
||||||
|
BufferedImage bgImage = MockImageUtil.createGradientImage(
|
||||||
|
1020, 1520, new Color(255, 240, 245), new Color(255, 250, 250));
|
||||||
|
File bgFile = tempDir.resolve("bg_image.jpg").toFile();
|
||||||
|
ImageIO.write(bgImage, "JPG", bgFile);
|
||||||
|
imageBgTemplate.setBackgroundImage(bgFile.getAbsolutePath());
|
||||||
|
|
||||||
|
BufferedImage imageBgResult = renderer.render(imageBgTemplate, elements, dynamicData);
|
||||||
|
assertNotNull(imageBgResult);
|
||||||
|
|
||||||
|
System.out.println("✅ 不同背景类型测试通过!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRealScenario_Performance() throws IOException {
|
||||||
|
// Given
|
||||||
|
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
|
||||||
|
"performance_test",
|
||||||
|
1020,
|
||||||
|
1520,
|
||||||
|
"#FFFFFF"
|
||||||
|
);
|
||||||
|
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
|
||||||
|
Map<String, String> dynamicData = prepareLocalFilePaths();
|
||||||
|
|
||||||
|
// When: 测试渲染性能
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||||
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(result);
|
||||||
|
assertTrue(duration < 5000, "渲染应在5秒内完成,实际耗时: " + duration + "ms");
|
||||||
|
|
||||||
|
System.out.println("✅ 性能测试通过!");
|
||||||
|
System.out.println("⏱️ 渲染耗时: " + duration + " ms");
|
||||||
|
System.out.println("📊 元素数量: " + elements.size());
|
||||||
|
System.out.println("📐 画布尺寸: " + template.getCanvasWidth() + "x" + template.getCanvasHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 准备本地文件路径的动态数据
|
||||||
|
*/
|
||||||
|
private Map<String, String> prepareLocalFilePaths() {
|
||||||
|
Map<String, String> data = new HashMap<>();
|
||||||
|
data.put("image1", mockImageFiles.get("image1").getAbsolutePath());
|
||||||
|
data.put("image2", mockImageFiles.get("image2").getAbsolutePath());
|
||||||
|
data.put("image3", mockImageFiles.get("image3").getAbsolutePath());
|
||||||
|
data.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
|
||||||
|
data.put("bottomText", "测试文字内容");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void registerElements() {
|
||||||
|
// 注册 Element 类型(确保工厂可用)
|
||||||
|
com.ycwl.basic.puzzle.element.base.ElementFactory.register(
|
||||||
|
com.ycwl.basic.puzzle.element.enums.ElementType.TEXT,
|
||||||
|
com.ycwl.basic.puzzle.element.impl.TextElement.class
|
||||||
|
);
|
||||||
|
com.ycwl.basic.puzzle.element.base.ElementFactory.register(
|
||||||
|
com.ycwl.basic.puzzle.element.enums.ElementType.IMAGE,
|
||||||
|
com.ycwl.basic.puzzle.element.impl.ImageElement.class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.ycwl.basic.puzzle.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||||
|
import com.ycwl.basic.puzzle.dto.TemplateCreateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||||
|
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PuzzleTemplateService单元测试
|
||||||
|
* 使用Mockito模拟数据库操作,不依赖外部数据库
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PuzzleTemplateServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PuzzleTemplateMapper templateMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PuzzleElementMapper elementMapper;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private PuzzleTemplateServiceImpl templateService;
|
||||||
|
|
||||||
|
private PuzzleTemplateEntity mockTemplate;
|
||||||
|
private List<PuzzleElementEntity> mockElements;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mockTemplate = PuzzleTestDataBuilder.createBasicTemplate();
|
||||||
|
mockElements = PuzzleTestDataBuilder.createFullTestElements(mockTemplate.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateTemplate_Success() {
|
||||||
|
// Given
|
||||||
|
TemplateCreateRequest request = new TemplateCreateRequest();
|
||||||
|
request.setCode("new_template");
|
||||||
|
request.setName("新模板");
|
||||||
|
request.setCanvasWidth(800);
|
||||||
|
request.setCanvasHeight(1200);
|
||||||
|
request.setBackgroundType(0);
|
||||||
|
request.setBackgroundColor("#FFFFFF");
|
||||||
|
|
||||||
|
when(templateMapper.countByCode(eq("new_template"), isNull())).thenReturn(0);
|
||||||
|
when(templateMapper.insert(any(PuzzleTemplateEntity.class))).thenAnswer(invocation -> {
|
||||||
|
PuzzleTemplateEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(100L);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// When
|
||||||
|
Long templateId = templateService.createTemplate(request);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(templateId);
|
||||||
|
assertEquals(100L, templateId);
|
||||||
|
verify(templateMapper).countByCode(eq("new_template"), isNull());
|
||||||
|
verify(templateMapper).insert(any(PuzzleTemplateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateTemplate_DuplicateCode() {
|
||||||
|
// Given
|
||||||
|
TemplateCreateRequest request = new TemplateCreateRequest();
|
||||||
|
request.setCode("existing_code");
|
||||||
|
request.setName("重复编码模板");
|
||||||
|
|
||||||
|
when(templateMapper.countByCode(eq("existing_code"), isNull())).thenReturn(1);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
IllegalArgumentException exception = assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> templateService.createTemplate(request)
|
||||||
|
);
|
||||||
|
assertTrue(exception.getMessage().contains("模板编码已存在"));
|
||||||
|
verify(templateMapper).countByCode(eq("existing_code"), isNull());
|
||||||
|
verify(templateMapper, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetTemplateDetail_Success() {
|
||||||
|
// Given
|
||||||
|
when(templateMapper.getById(1L)).thenReturn(mockTemplate);
|
||||||
|
when(elementMapper.getByTemplateId(1L)).thenReturn(mockElements);
|
||||||
|
|
||||||
|
// When
|
||||||
|
PuzzleTemplateDTO result = templateService.getTemplateDetail(1L);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(mockTemplate.getId(), result.getId());
|
||||||
|
assertEquals(mockTemplate.getCode(), result.getCode());
|
||||||
|
assertEquals(mockTemplate.getName(), result.getName());
|
||||||
|
assertNotNull(result.getElements());
|
||||||
|
assertEquals(mockElements.size(), result.getElements().size());
|
||||||
|
verify(templateMapper).getById(1L);
|
||||||
|
verify(elementMapper).getByTemplateId(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetTemplateDetail_NotFound() {
|
||||||
|
// Given
|
||||||
|
when(templateMapper.getById(999L)).thenReturn(null);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
IllegalArgumentException exception = assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> templateService.getTemplateDetail(999L)
|
||||||
|
);
|
||||||
|
assertTrue(exception.getMessage().contains("模板不存在"));
|
||||||
|
verify(templateMapper).getById(999L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetTemplateByCode_Success() {
|
||||||
|
// Given
|
||||||
|
when(templateMapper.getByCode("test_template")).thenReturn(mockTemplate);
|
||||||
|
when(elementMapper.getByTemplateId(1L)).thenReturn(mockElements);
|
||||||
|
|
||||||
|
// When
|
||||||
|
PuzzleTemplateDTO result = templateService.getTemplateByCode("test_template");
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals("test_template", result.getCode());
|
||||||
|
assertNotNull(result.getElements());
|
||||||
|
verify(templateMapper).getByCode("test_template");
|
||||||
|
verify(elementMapper).getByTemplateId(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdateTemplate_Success() {
|
||||||
|
// Given
|
||||||
|
TemplateCreateRequest request = new TemplateCreateRequest();
|
||||||
|
request.setName("更新后的名称");
|
||||||
|
request.setStatus(0);
|
||||||
|
|
||||||
|
when(templateMapper.getById(1L)).thenReturn(mockTemplate);
|
||||||
|
when(templateMapper.update(any(PuzzleTemplateEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
|
// When
|
||||||
|
templateService.updateTemplate(1L, request);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
verify(templateMapper).getById(1L);
|
||||||
|
verify(templateMapper).update(any(PuzzleTemplateEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteTemplate_Success() {
|
||||||
|
// Given
|
||||||
|
when(templateMapper.getById(1L)).thenReturn(mockTemplate);
|
||||||
|
when(templateMapper.deleteById(1L)).thenReturn(1);
|
||||||
|
when(elementMapper.deleteByTemplateId(1L)).thenReturn(mockElements.size());
|
||||||
|
|
||||||
|
// When
|
||||||
|
templateService.deleteTemplate(1L);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
verify(templateMapper).getById(1L);
|
||||||
|
verify(templateMapper).deleteById(1L);
|
||||||
|
verify(elementMapper).deleteByTemplateId(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testListTemplates_Success() {
|
||||||
|
// Given
|
||||||
|
List<PuzzleTemplateEntity> templates = List.of(mockTemplate);
|
||||||
|
when(templateMapper.list(null, "test", 1)).thenReturn(templates);
|
||||||
|
when(elementMapper.getByTemplateId(anyLong())).thenReturn(mockElements);
|
||||||
|
|
||||||
|
// When
|
||||||
|
List<PuzzleTemplateDTO> result = templateService.listTemplates(null, "test", 1);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
assertEquals(mockTemplate.getCode(), result.get(0).getCode());
|
||||||
|
verify(templateMapper).list(null, "test", 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/test/java/com/ycwl/basic/puzzle/test/MockImageUtil.java
Normal file
169
src/test/java/com/ycwl/basic/puzzle/test/MockImageUtil.java
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package com.ycwl.basic.puzzle.test;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock图片工具类
|
||||||
|
* 用于测试时生成假的图片数据,避免依赖外部资源
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
public class MockImageUtil {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建纯色图片
|
||||||
|
*/
|
||||||
|
public static BufferedImage createSolidColorImage(int width, int height, Color color) {
|
||||||
|
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g2d = image.createGraphics();
|
||||||
|
g2d.setColor(color);
|
||||||
|
g2d.fillRect(0, 0, width, height);
|
||||||
|
g2d.dispose();
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带文字的图片(用于测试)
|
||||||
|
*/
|
||||||
|
public static BufferedImage createImageWithText(int width, int height, String text, Color bgColor, Color textColor) {
|
||||||
|
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g2d = image.createGraphics();
|
||||||
|
|
||||||
|
// 背景
|
||||||
|
g2d.setColor(bgColor);
|
||||||
|
g2d.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 文字
|
||||||
|
g2d.setColor(textColor);
|
||||||
|
g2d.setFont(new Font("Arial", Font.BOLD, 24));
|
||||||
|
FontMetrics fm = g2d.getFontMetrics();
|
||||||
|
int textWidth = fm.stringWidth(text);
|
||||||
|
int textHeight = fm.getHeight();
|
||||||
|
g2d.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2 - fm.getDescent());
|
||||||
|
|
||||||
|
g2d.dispose();
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建渐变图片
|
||||||
|
*/
|
||||||
|
public static BufferedImage createGradientImage(int width, int height, Color startColor, Color endColor) {
|
||||||
|
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g2d = image.createGraphics();
|
||||||
|
GradientPaint gradient = new GradientPaint(0, 0, startColor, 0, height, endColor);
|
||||||
|
g2d.setPaint(gradient);
|
||||||
|
g2d.fillRect(0, 0, width, height);
|
||||||
|
g2d.dispose();
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建二维码样式的图片(黑白方块)
|
||||||
|
*/
|
||||||
|
public static BufferedImage createMockQRCode(int size) {
|
||||||
|
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g2d = image.createGraphics();
|
||||||
|
Random random = new Random(42); // 固定种子保证一致性
|
||||||
|
|
||||||
|
// 白色背景
|
||||||
|
g2d.setColor(Color.WHITE);
|
||||||
|
g2d.fillRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// 随机黑白方块
|
||||||
|
int blockSize = size / 20;
|
||||||
|
g2d.setColor(Color.BLACK);
|
||||||
|
for (int x = 0; x < size; x += blockSize) {
|
||||||
|
for (int y = 0; y < size; y += blockSize) {
|
||||||
|
if (random.nextBoolean()) {
|
||||||
|
g2d.fillRect(x, y, blockSize, blockSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g2d.dispose();
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存图片到文件(用于测试验证)
|
||||||
|
*/
|
||||||
|
public static void saveImage(BufferedImage image, String filePath) throws IOException {
|
||||||
|
File outputFile = new File(filePath);
|
||||||
|
outputFile.getParentFile().mkdirs();
|
||||||
|
ImageIO.write(image, "PNG", outputFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将图片转换为字节数组
|
||||||
|
*/
|
||||||
|
public static byte[] imageToBytes(BufferedImage image, String format) throws IOException {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, format, baos);
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建头像样式的图片(带首字母)
|
||||||
|
*/
|
||||||
|
public static BufferedImage createAvatarImage(int size, String initial, Color bgColor) {
|
||||||
|
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g2d = image.createGraphics();
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
|
||||||
|
// 圆形背景
|
||||||
|
g2d.setColor(bgColor);
|
||||||
|
g2d.fillOval(0, 0, size, size);
|
||||||
|
|
||||||
|
// 首字母
|
||||||
|
g2d.setColor(Color.WHITE);
|
||||||
|
g2d.setFont(new Font("Arial", Font.BOLD, size / 2));
|
||||||
|
FontMetrics fm = g2d.getFontMetrics();
|
||||||
|
int textWidth = fm.stringWidth(initial);
|
||||||
|
int textHeight = fm.getHeight();
|
||||||
|
g2d.drawString(initial, (size - textWidth) / 2, (size + textHeight) / 2 - fm.getDescent());
|
||||||
|
|
||||||
|
g2d.dispose();
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证图片尺寸
|
||||||
|
*/
|
||||||
|
public static boolean validateImageSize(BufferedImage image, int expectedWidth, int expectedHeight) {
|
||||||
|
return image.getWidth() == expectedWidth && image.getHeight() == expectedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证图片不为空白
|
||||||
|
*/
|
||||||
|
public static boolean isNotBlank(BufferedImage image) {
|
||||||
|
int width = image.getWidth();
|
||||||
|
int height = image.getHeight();
|
||||||
|
|
||||||
|
// 采样检查几个点
|
||||||
|
int samplePoints = 10;
|
||||||
|
Color firstColor = new Color(image.getRGB(0, 0));
|
||||||
|
boolean hasVariation = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < samplePoints && !hasVariation; i++) {
|
||||||
|
int x = (width * i) / samplePoints;
|
||||||
|
int y = (height * i) / samplePoints;
|
||||||
|
if (x < width && y < height) {
|
||||||
|
Color sampleColor = new Color(image.getRGB(x, y));
|
||||||
|
if (!sampleColor.equals(firstColor)) {
|
||||||
|
hasVariation = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasVariation;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package com.ycwl.basic.puzzle.test;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试数据构建器
|
||||||
|
* 用于创建测试用的模板和元素数据
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
public class PuzzleTestDataBuilder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建基础模板
|
||||||
|
*/
|
||||||
|
public static PuzzleTemplateEntity createBasicTemplate() {
|
||||||
|
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
|
||||||
|
template.setId(1L);
|
||||||
|
template.setName("测试模板");
|
||||||
|
template.setCode("test_template");
|
||||||
|
template.setCanvasWidth(750);
|
||||||
|
template.setCanvasHeight(1334);
|
||||||
|
template.setBackgroundType(0);
|
||||||
|
template.setBackgroundColor("#FFFFFF");
|
||||||
|
template.setDescription("用于测试的模板");
|
||||||
|
template.setCategory("test");
|
||||||
|
template.setStatus(1);
|
||||||
|
template.setScenicId(null);
|
||||||
|
template.setCreateTime(new Date());
|
||||||
|
template.setUpdateTime(new Date());
|
||||||
|
template.setDeleted(0);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建自定义尺寸的模板
|
||||||
|
*/
|
||||||
|
public static PuzzleTemplateEntity createTemplate(String code, int width, int height, String bgColor) {
|
||||||
|
PuzzleTemplateEntity template = createBasicTemplate();
|
||||||
|
template.setCode(code);
|
||||||
|
template.setCanvasWidth(width);
|
||||||
|
template.setCanvasHeight(height);
|
||||||
|
template.setBackgroundColor(bgColor);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建图片元素
|
||||||
|
*/
|
||||||
|
public static PuzzleElementEntity createImageElement(Long templateId, String elementKey,
|
||||||
|
int x, int y, int width, int height,
|
||||||
|
int zIndex, String imageUrl) {
|
||||||
|
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||||
|
element.setId(System.currentTimeMillis());
|
||||||
|
element.setTemplateId(templateId);
|
||||||
|
element.setElementType("IMAGE"); // 修改为字符串类型
|
||||||
|
element.setElementKey(elementKey);
|
||||||
|
element.setElementName(elementKey);
|
||||||
|
element.setXPosition(x);
|
||||||
|
element.setYPosition(y);
|
||||||
|
element.setWidth(width);
|
||||||
|
element.setHeight(height);
|
||||||
|
element.setZIndex(zIndex);
|
||||||
|
element.setRotation(0);
|
||||||
|
element.setOpacity(100);
|
||||||
|
|
||||||
|
// 使用JSON配置
|
||||||
|
String config = String.format("{\"defaultImageUrl\":\"%s\",\"imageFitMode\":\"FILL\",\"borderRadius\":0}", imageUrl);
|
||||||
|
element.setConfig(config);
|
||||||
|
|
||||||
|
element.setCreateTime(new Date());
|
||||||
|
element.setUpdateTime(new Date());
|
||||||
|
element.setDeleted(0);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建圆角图片元素
|
||||||
|
*/
|
||||||
|
public static PuzzleElementEntity createRoundedImageElement(Long templateId, String elementKey,
|
||||||
|
int x, int y, int width, int height,
|
||||||
|
int zIndex, String imageUrl, int borderRadius) {
|
||||||
|
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||||
|
element.setId(System.currentTimeMillis());
|
||||||
|
element.setTemplateId(templateId);
|
||||||
|
element.setElementType("IMAGE");
|
||||||
|
element.setElementKey(elementKey);
|
||||||
|
element.setElementName(elementKey);
|
||||||
|
element.setXPosition(x);
|
||||||
|
element.setYPosition(y);
|
||||||
|
element.setWidth(width);
|
||||||
|
element.setHeight(height);
|
||||||
|
element.setZIndex(zIndex);
|
||||||
|
element.setRotation(0);
|
||||||
|
element.setOpacity(100);
|
||||||
|
|
||||||
|
// 使用JSON配置(包含圆角)
|
||||||
|
String config = String.format("{\"defaultImageUrl\":\"%s\",\"imageFitMode\":\"COVER\",\"borderRadius\":%d}",
|
||||||
|
imageUrl, borderRadius);
|
||||||
|
element.setConfig(config);
|
||||||
|
|
||||||
|
element.setCreateTime(new Date());
|
||||||
|
element.setUpdateTime(new Date());
|
||||||
|
element.setDeleted(0);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文字元素
|
||||||
|
*/
|
||||||
|
public static PuzzleElementEntity createTextElement(Long templateId, String elementKey,
|
||||||
|
int x, int y, int width, int height,
|
||||||
|
int zIndex, String defaultText,
|
||||||
|
int fontSize, String fontColor) {
|
||||||
|
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||||
|
element.setId(System.currentTimeMillis());
|
||||||
|
element.setTemplateId(templateId);
|
||||||
|
element.setElementType("TEXT"); // 修改为字符串类型
|
||||||
|
element.setElementKey(elementKey);
|
||||||
|
element.setElementName(elementKey);
|
||||||
|
element.setXPosition(x);
|
||||||
|
element.setYPosition(y);
|
||||||
|
element.setWidth(width);
|
||||||
|
element.setHeight(height);
|
||||||
|
element.setZIndex(zIndex);
|
||||||
|
element.setRotation(0);
|
||||||
|
element.setOpacity(100);
|
||||||
|
|
||||||
|
// 使用JSON配置
|
||||||
|
String config = String.format(
|
||||||
|
"{\"defaultText\":\"%s\",\"fontFamily\":\"微软雅黑\",\"fontSize\":%d,\"fontColor\":\"%s\"," +
|
||||||
|
"\"fontWeight\":\"NORMAL\",\"fontStyle\":\"NORMAL\",\"textAlign\":\"LEFT\"," +
|
||||||
|
"\"lineHeight\":1.5,\"textDecoration\":\"NONE\"}",
|
||||||
|
defaultText, fontSize, fontColor);
|
||||||
|
element.setConfig(config);
|
||||||
|
|
||||||
|
element.setCreateTime(new Date());
|
||||||
|
element.setUpdateTime(new Date());
|
||||||
|
element.setDeleted(0);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建粗体文字元素
|
||||||
|
*/
|
||||||
|
public static PuzzleElementEntity createBoldTextElement(Long templateId, String elementKey,
|
||||||
|
int x, int y, int width, int height,
|
||||||
|
int zIndex, String defaultText,
|
||||||
|
int fontSize, String fontColor, String textAlign) {
|
||||||
|
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||||
|
element.setId(System.currentTimeMillis());
|
||||||
|
element.setTemplateId(templateId);
|
||||||
|
element.setElementType("TEXT");
|
||||||
|
element.setElementKey(elementKey);
|
||||||
|
element.setElementName(elementKey);
|
||||||
|
element.setXPosition(x);
|
||||||
|
element.setYPosition(y);
|
||||||
|
element.setWidth(width);
|
||||||
|
element.setHeight(height);
|
||||||
|
element.setZIndex(zIndex);
|
||||||
|
element.setRotation(0);
|
||||||
|
element.setOpacity(100);
|
||||||
|
|
||||||
|
// 使用JSON配置(粗体)
|
||||||
|
String config = String.format(
|
||||||
|
"{\"defaultText\":\"%s\",\"fontFamily\":\"微软雅黑\",\"fontSize\":%d,\"fontColor\":\"%s\"," +
|
||||||
|
"\"fontWeight\":\"BOLD\",\"fontStyle\":\"NORMAL\",\"textAlign\":\"%s\"," +
|
||||||
|
"\"lineHeight\":1.5,\"textDecoration\":\"NONE\"}",
|
||||||
|
defaultText, fontSize, fontColor, textAlign);
|
||||||
|
element.setConfig(config);
|
||||||
|
|
||||||
|
element.setCreateTime(new Date());
|
||||||
|
element.setUpdateTime(new Date());
|
||||||
|
element.setDeleted(0);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建完整的测试模板(带元素)
|
||||||
|
*/
|
||||||
|
public static List<PuzzleElementEntity> createFullTestElements(Long templateId) {
|
||||||
|
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||||
|
|
||||||
|
// 背景图片
|
||||||
|
elements.add(createImageElement(templateId, "bgImage", 0, 0, 750, 400, 0,
|
||||||
|
"https://example.com/bg.jpg"));
|
||||||
|
|
||||||
|
// 用户头像(圆形)
|
||||||
|
elements.add(createRoundedImageElement(templateId, "userAvatar", 50, 100, 80, 80, 1,
|
||||||
|
"https://example.com/avatar.jpg", 40));
|
||||||
|
|
||||||
|
// 用户名
|
||||||
|
elements.add(createBoldTextElement(templateId, "userName", 150, 110, 200, 60, 1,
|
||||||
|
"用户昵称", 24, "#333333", "LEFT"));
|
||||||
|
|
||||||
|
// 订单号标题
|
||||||
|
elements.add(createTextElement(templateId, "orderTitle", 50, 200, 650, 40, 1,
|
||||||
|
"订单号:", 16, "#666666"));
|
||||||
|
|
||||||
|
// 订单号内容
|
||||||
|
elements.add(createBoldTextElement(templateId, "orderNumber", 50, 240, 650, 40, 1,
|
||||||
|
"ORDER123456", 18, "#333333", "LEFT"));
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建1020x1520的现实场景元素
|
||||||
|
* 上方3张图片,每张470高度(共1410),底下110像素左侧二维码右侧文字
|
||||||
|
*/
|
||||||
|
public static List<PuzzleElementEntity> createRealScenarioElements(Long templateId) {
|
||||||
|
List<PuzzleElementEntity> elements = new ArrayList<>();
|
||||||
|
|
||||||
|
// 画布总高度1520,每张图片470高度
|
||||||
|
int imageHeight = 470;
|
||||||
|
int bottomHeight = 110; // 剩余高度:1520 - 470*3 = 110
|
||||||
|
|
||||||
|
// 上方3张图片,每张470高度
|
||||||
|
elements.add(createImageElement(templateId, "image1", 0, 0, 1020, imageHeight, 1,
|
||||||
|
"https://example.com/image1.jpg"));
|
||||||
|
|
||||||
|
elements.add(createImageElement(templateId, "image2", 0, imageHeight, 1020, imageHeight, 1,
|
||||||
|
"https://example.com/image2.jpg"));
|
||||||
|
|
||||||
|
elements.add(createImageElement(templateId, "image3", 0, imageHeight * 2, 1020, imageHeight, 1,
|
||||||
|
"https://example.com/image3.jpg"));
|
||||||
|
|
||||||
|
// 底部区域起始Y坐标
|
||||||
|
int bottomY = imageHeight * 3; // 1410
|
||||||
|
|
||||||
|
// 底部二维码(左侧,占满底部高度)
|
||||||
|
PuzzleElementEntity qrElement = new PuzzleElementEntity();
|
||||||
|
qrElement.setId(System.currentTimeMillis());
|
||||||
|
qrElement.setTemplateId(templateId);
|
||||||
|
qrElement.setElementType("IMAGE");
|
||||||
|
qrElement.setElementKey("qrCode");
|
||||||
|
qrElement.setElementName("qrCode");
|
||||||
|
qrElement.setXPosition(20);
|
||||||
|
qrElement.setYPosition(bottomY + 5);
|
||||||
|
qrElement.setWidth(100);
|
||||||
|
qrElement.setHeight(100);
|
||||||
|
qrElement.setZIndex(2);
|
||||||
|
qrElement.setRotation(0);
|
||||||
|
qrElement.setOpacity(100);
|
||||||
|
qrElement.setConfig("{\"defaultImageUrl\":\"https://example.com/qrcode.png\",\"imageFitMode\":\"CONTAIN\",\"borderRadius\":0}");
|
||||||
|
qrElement.setCreateTime(new Date());
|
||||||
|
qrElement.setUpdateTime(new Date());
|
||||||
|
qrElement.setDeleted(0);
|
||||||
|
elements.add(qrElement);
|
||||||
|
|
||||||
|
// 底部文字(右侧,占满剩余宽度和高度)
|
||||||
|
int textX = 140; // 二维码右侧留20像素间距
|
||||||
|
int textWidth = 1020 - textX - 20; // 右侧留20像素边距
|
||||||
|
|
||||||
|
PuzzleElementEntity textElement = new PuzzleElementEntity();
|
||||||
|
textElement.setId(System.currentTimeMillis() + 1);
|
||||||
|
textElement.setTemplateId(templateId);
|
||||||
|
textElement.setElementType("TEXT");
|
||||||
|
textElement.setElementKey("bottomText");
|
||||||
|
textElement.setElementName("bottomText");
|
||||||
|
textElement.setXPosition(textX);
|
||||||
|
textElement.setYPosition(bottomY + 10);
|
||||||
|
textElement.setWidth(textWidth);
|
||||||
|
textElement.setHeight(bottomHeight - 10);
|
||||||
|
textElement.setZIndex(2);
|
||||||
|
textElement.setRotation(0);
|
||||||
|
textElement.setOpacity(100);
|
||||||
|
textElement.setConfig("{\"defaultText\":\"扫码查看详情\",\"fontFamily\":\"微软雅黑\",\"fontSize\":18,\"fontColor\":\"#333333\",\"fontWeight\":\"BOLD\",\"textAlign\":\"RIGHT\",\"lineHeight\":1.5,\"textDecoration\":\"NONE\"}");
|
||||||
|
textElement.setCreateTime(new Date());
|
||||||
|
textElement.setUpdateTime(new Date());
|
||||||
|
textElement.setDeleted(0);
|
||||||
|
elements.add(textElement);
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package com.ycwl.basic.puzzle.test;
|
||||||
|
|
||||||
|
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||||
|
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 com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
|
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 现实场景自动化测试助手
|
||||||
|
* 提供便捷方法快速创建和测试1020x1520拼图场景
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-01-17
|
||||||
|
*/
|
||||||
|
public class RealScenarioTestHelper {
|
||||||
|
|
||||||
|
private final PuzzleImageRenderer renderer;
|
||||||
|
private final Path outputDir;
|
||||||
|
private final Map<String, File> resourceFiles;
|
||||||
|
|
||||||
|
public RealScenarioTestHelper(Path outputDir) throws IOException {
|
||||||
|
this.renderer = new PuzzleImageRenderer();
|
||||||
|
this.outputDir = outputDir;
|
||||||
|
this.resourceFiles = new HashMap<>();
|
||||||
|
initializeMockResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化Mock资源(图片、二维码等)
|
||||||
|
*/
|
||||||
|
private void initializeMockResources() throws IOException {
|
||||||
|
Files.createDirectories(outputDir);
|
||||||
|
|
||||||
|
// 创建3张场景图片(每张470高度)
|
||||||
|
createSceneImage("image1", "1", new Color(255, 200, 200));
|
||||||
|
createSceneImage("image2", "2", new Color(200, 255, 200));
|
||||||
|
createSceneImage("image3", "3", new Color(200, 200, 255));
|
||||||
|
|
||||||
|
// 创建二维码(100x100)
|
||||||
|
BufferedImage qrCode = MockImageUtil.createMockQRCode(100);
|
||||||
|
File qrFile = saveImage(qrCode, "qrcode.png");
|
||||||
|
resourceFiles.put("qrCode", qrFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建场景图片(1020x470)
|
||||||
|
*/
|
||||||
|
private void createSceneImage(String key, String text, Color bgColor) throws IOException {
|
||||||
|
BufferedImage image = MockImageUtil.createImageWithText(1020, 470, text, bgColor, Color.BLACK);
|
||||||
|
File file = saveImage(image, key + ".jpg");
|
||||||
|
resourceFiles.put(key, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存图片
|
||||||
|
*/
|
||||||
|
private File saveImage(BufferedImage image, String filename) throws IOException {
|
||||||
|
File file = outputDir.resolve(filename).toFile();
|
||||||
|
ImageIO.write(image, filename.endsWith(".png") ? "PNG" : "JPG", file);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并测试完整的现实场景
|
||||||
|
*
|
||||||
|
* @param bottomText 底部显示的文字
|
||||||
|
* @return 生成的图片文件
|
||||||
|
*/
|
||||||
|
public TestResult createAndTestRealScenario(String bottomText) throws IOException {
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// 1. 创建模板
|
||||||
|
PuzzleTemplateEntity template = createTemplate();
|
||||||
|
|
||||||
|
// 2. 创建元素
|
||||||
|
List<PuzzleElementEntity> elements = createElements(template.getId());
|
||||||
|
|
||||||
|
// 3. 准备动态数据
|
||||||
|
Map<String, String> dynamicData = prepareDynamicData(bottomText);
|
||||||
|
|
||||||
|
// 4. 渲染图片
|
||||||
|
BufferedImage result = renderer.render(template, elements, dynamicData);
|
||||||
|
|
||||||
|
// 5. 保存结果
|
||||||
|
File outputFile = saveImage(result, "real_scenario_result.png");
|
||||||
|
|
||||||
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
|
return new TestResult(
|
||||||
|
outputFile,
|
||||||
|
result.getWidth(),
|
||||||
|
result.getHeight(),
|
||||||
|
outputFile.length(),
|
||||||
|
duration,
|
||||||
|
true,
|
||||||
|
"测试成功"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建1020x1520模板
|
||||||
|
*/
|
||||||
|
private PuzzleTemplateEntity createTemplate() {
|
||||||
|
return PuzzleTestDataBuilder.createTemplate(
|
||||||
|
"real_scenario_auto",
|
||||||
|
1020,
|
||||||
|
1520,
|
||||||
|
"#F5F5F5"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建元素列表
|
||||||
|
*/
|
||||||
|
private List<PuzzleElementEntity> createElements(Long templateId) {
|
||||||
|
return PuzzleTestDataBuilder.createRealScenarioElements(templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 准备动态数据
|
||||||
|
*/
|
||||||
|
private Map<String, String> prepareDynamicData(String bottomText) {
|
||||||
|
Map<String, String> data = new HashMap<>();
|
||||||
|
data.put("image1", resourceFiles.get("image1").getAbsolutePath());
|
||||||
|
data.put("image2", resourceFiles.get("image2").getAbsolutePath());
|
||||||
|
data.put("image3", resourceFiles.get("image3").getAbsolutePath());
|
||||||
|
data.put("qrCode", resourceFiles.get("qrCode").getAbsolutePath());
|
||||||
|
data.put("bottomText", bottomText != null ? bottomText : "扫码查看详情");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证生成的图片
|
||||||
|
*/
|
||||||
|
public boolean validateResult(BufferedImage image) {
|
||||||
|
// 验证尺寸
|
||||||
|
if (image.getWidth() != 1020 || image.getHeight() != 1520) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证不为空白
|
||||||
|
return MockImageUtil.isNotBlank(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印测试报告
|
||||||
|
*/
|
||||||
|
public void printTestReport(TestResult result) {
|
||||||
|
System.out.println("\n" + "=".repeat(60));
|
||||||
|
System.out.println("📊 拼图功能现实场景测试报告");
|
||||||
|
System.out.println("=".repeat(60));
|
||||||
|
System.out.println("✅ 测试状态: " + (result.isSuccess() ? "通过" : "失败"));
|
||||||
|
System.out.println("📁 输出文件: " + result.getOutputFile().getAbsolutePath());
|
||||||
|
System.out.println("📐 图片尺寸: " + result.getWidth() + " x " + result.getHeight() + " 像素");
|
||||||
|
System.out.println("💾 文件大小: " + result.getFileSize() / 1024 + " KB");
|
||||||
|
System.out.println("⏱️ 渲染耗时: " + result.getDuration() + " ms");
|
||||||
|
System.out.println("📝 测试消息: " + result.getMessage());
|
||||||
|
System.out.println("=".repeat(60) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试结果类
|
||||||
|
*/
|
||||||
|
public static class TestResult {
|
||||||
|
private final File outputFile;
|
||||||
|
private final int width;
|
||||||
|
private final int height;
|
||||||
|
private final long fileSize;
|
||||||
|
private final long duration;
|
||||||
|
private final boolean success;
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
public TestResult(File outputFile, int width, int height, long fileSize,
|
||||||
|
long duration, boolean success, String message) {
|
||||||
|
this.outputFile = outputFile;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.fileSize = fileSize;
|
||||||
|
this.duration = duration;
|
||||||
|
this.success = success;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getOutputFile() {
|
||||||
|
return outputFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getWidth() {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getHeight() {
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getFileSize() {
|
||||||
|
return fileSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getDuration() {
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速测试方法(主方法,可直接运行)
|
||||||
|
*/
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
// 【重要】注册 Element 类型(主方法运行必须先注册)
|
||||||
|
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||||
|
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||||
|
|
||||||
|
// 创建临时目录
|
||||||
|
Path tempDir = Files.createTempDirectory("puzzle_test_");
|
||||||
|
System.out.println("📂 测试目录: " + tempDir.toAbsolutePath());
|
||||||
|
|
||||||
|
// 创建测试助手
|
||||||
|
RealScenarioTestHelper helper = new RealScenarioTestHelper(tempDir);
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
TestResult result = helper.createAndTestRealScenario("奇遇时光乐园\n2025.11.11");
|
||||||
|
|
||||||
|
// 打印报告
|
||||||
|
helper.printTestReport(result);
|
||||||
|
|
||||||
|
// 验证结果
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
System.out.println("🎉 恭喜!现实场景测试全部通过!");
|
||||||
|
System.out.println("💡 你可以打开输出图片查看效果: " + result.getOutputFile().getAbsolutePath());
|
||||||
|
} else {
|
||||||
|
System.err.println("❌ 测试失败: " + result.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user