You've already forked FrameTour-BE
feat(puzzle): 实现拼图生成功能模块
- 新增拼图生成控制器 PuzzleGenerateController,支持 /api/puzzle/generate 接口 - 新增拼图模板管理控制器 PuzzleTemplateController,提供完整的 CRUD 和元素管理功能 - 定义拼图相关 DTO 类,包括模板、元素、生成请求与响应等数据传输对象 - 创建拼图相关的实体类 PuzzleTemplateEntity、PuzzleElementEntity 和 PuzzleGenerationRecordEntity - 实现 Mapper 接口用于数据库操作,支持模板和元素的增删改查及生成记录管理 - 开发 PuzzleGenerateServiceImpl 服务,完成从模板渲染到图片上传的完整流程 - 提供 PuzzleTemplateServiceImpl 服务,实现模板及其元素的全生命周期管理 - 引入 PuzzleImageRenderer 工具类负责图像合成渲染逻辑 - 支持将生成结果上传至 OSS 并记录生成过程的日志和元数据
This commit is contained in:
@@ -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,180 @@
|
||||
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.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("puzzle/%s/%s.%s",
|
||||
templateCode,
|
||||
UUID.randomUUID().toString().replace("-", ""),
|
||||
outputFormat.toLowerCase()
|
||||
);
|
||||
|
||||
// 使用项目现有的存储工厂上传
|
||||
try {
|
||||
return StorageFactory.use().uploadFile(imageBytes, "puzzle", 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,243 @@
|
||||
package com.ycwl.basic.puzzle.service.impl;
|
||||
|
||||
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());
|
||||
|
||||
// 检查模板是否存在
|
||||
PuzzleTemplateEntity template = templateMapper.getById(request.getTemplateId());
|
||||
if (template == null) {
|
||||
throw new IllegalArgumentException("模板不存在: " + request.getTemplateId());
|
||||
}
|
||||
|
||||
// 转换为实体并插入
|
||||
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
|
||||
entity.setDeleted(0);
|
||||
elementMapper.insert(entity);
|
||||
|
||||
log.info("元素添加成功: id={}, elementKey={}", entity.getId(), 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());
|
||||
|
||||
// 检查模板是否存在
|
||||
PuzzleTemplateEntity template = templateMapper.getById(templateId);
|
||||
if (template == null) {
|
||||
throw new IllegalArgumentException("模板不存在: " + templateId);
|
||||
}
|
||||
|
||||
// 转换为实体列表
|
||||
List<PuzzleElementEntity> entityList = new ArrayList<>();
|
||||
for (ElementCreateRequest request : elements) {
|
||||
request.setTemplateId(templateId);
|
||||
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
|
||||
entity.setDeleted(0);
|
||||
entityList.add(entity);
|
||||
}
|
||||
|
||||
// 批量插入
|
||||
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);
|
||||
|
||||
// 检查元素是否存在
|
||||
PuzzleElementEntity existing = elementMapper.getById(id);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("元素不存在: " + id);
|
||||
}
|
||||
|
||||
// 更新
|
||||
PuzzleElementEntity entity = BeanUtil.copyProperties(request, PuzzleElementEntity.class);
|
||||
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 BeanUtil.copyProperties(element, PuzzleElementDTO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为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(e -> BeanUtil.copyProperties(e, PuzzleElementDTO.class))
|
||||
.collect(Collectors.toList());
|
||||
dto.setElements(elementDTOs);
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user