feat(puzzle): 实现拼图生成功能模块

- 新增拼图生成控制器 PuzzleGenerateController,支持 /api/puzzle/generate 接口
- 新增拼图模板管理控制器 PuzzleTemplateController,提供完整的 CRUD 和元素管理功能
- 定义拼图相关 DTO 类,包括模板、元素、生成请求与响应等数据传输对象
- 创建拼图相关的实体类 PuzzleTemplateEntity、PuzzleElementEntity 和 PuzzleGenerationRecordEntity
- 实现 Mapper 接口用于数据库操作,支持模板和元素的增删改查及生成记录管理
- 开发 PuzzleGenerateServiceImpl 服务,完成从模板渲染到图片上传的完整流程
- 提供 PuzzleTemplateServiceImpl 服务,实现模板及其元素的全生命周期管理
- 引入 PuzzleImageRenderer 工具类负责图像合成渲染逻辑
- 支持将生成结果上传至 OSS 并记录生成过程的日志和元数据
This commit is contained in:
2025-11-17 12:54:56 +08:00
parent 630d344b5a
commit 443f92ff92
22 changed files with 2600 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}