refactor(puzzle): 重构拼图功能实现会员拼图关联管理

- 移除原有的图片裁切功能和userArea字段
- 删除originalImageUrl字段,统一使用resultImageUrl
- 添加MemberPuzzleEntity实体类管理会员拼图关联关系
- 创建MemberPuzzleMapper接口及XML映射文件
- 实现PuzzleRelationProcessor处理器负责关联记录创建
- 在拼图生成完成后自动创建会员拼图关联记录
- 添加景区配置中的免费拼图数量设置
- 实现免费拼图逻辑控制
- 更新拼图模板和生成记录的数据结构
- 修改AppPuzzleController中图片URL的获取方式
- 优化PuzzleEdgeRenderTaskService中的图片处理流程
This commit is contained in:
2026-01-16 10:41:54 +08:00
parent fb4568721a
commit d15d070cb4
17 changed files with 295 additions and 123 deletions

View File

@@ -147,8 +147,8 @@ public class AppPuzzleController {
}
// 检查是否有图片URL
String originalImageUrl = record.getOriginalImageUrl();
if (originalImageUrl == null || originalImageUrl.isEmpty()) {
String imageUrl = record.getResultImageUrl();
if (imageUrl == null || imageUrl.isEmpty()) {
return ApiResponse.fail("该拼图记录没有可用的图片URL");
}
@@ -163,7 +163,7 @@ public class AppPuzzleController {
face.getMemberId(),
face.getScenicId(),
record.getFaceId(),
originalImageUrl,
imageUrl,
0L // 打印特有
);

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.model.pc.puzzle.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 会员拼图关联实体
* 记录人脸与拼图生成记录的关联关系,包含免费和购买状态
*/
@Data
@TableName("member_puzzle")
public class MemberPuzzleEntity {
private Long id;
private Long memberId;
private Long scenicId;
private Long faceId;
private Long recordId;
private Integer isBuy;
private Long orderId;
private Integer isFree;
}

View File

@@ -88,11 +88,6 @@ public class PuzzleTemplateDTO {
*/
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
private String userArea;
/**
* 元素列表
*/

View File

@@ -71,11 +71,6 @@ public class TemplateCreateRequest {
*/
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
private String userArea;
/**
* 状态:0-禁用 1-启用
*/

View File

@@ -17,6 +17,7 @@ import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
@@ -134,6 +135,7 @@ public class PuzzleEdgeRenderTaskService {
private final PuzzleGenerationRecordMapper recordMapper;
private final PuzzleRepository puzzleRepository;
private final PrinterService printerService;
private final PuzzleRelationProcessor puzzleRelationProcessor;
/**
* 固定的 workerId,用于标识通过 IP CIDR 验证的 worker
@@ -205,10 +207,7 @@ public class PuzzleEdgeRenderTaskService {
}
IStorageAdapter storage = StorageFactory.use();
String originalImageUrl = storage.getUrl(task.getOriginalObjectKey());
String resultImageUrl = StrUtil.isNotBlank(task.getCroppedObjectKey())
? storage.getUrl(task.getCroppedObjectKey())
: originalImageUrl;
String resultImageUrl = storage.getUrl(task.getOriginalObjectKey());
Long resultFileSize = req != null ? req.getResultFileSize() : null;
Integer resultWidth = req != null ? req.getResultWidth() : null;
@@ -218,7 +217,6 @@ public class PuzzleEdgeRenderTaskService {
recordMapper.updateSuccess(
record.getId(),
resultImageUrl,
originalImageUrl,
resultFileSize,
resultWidth,
resultHeight,
@@ -228,6 +226,14 @@ public class PuzzleEdgeRenderTaskService {
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(record.getId(), record.getFaceId());
// 创建member_puzzle关联记录(使用INSERT IGNORE避免重复)
puzzleRelationProcessor.createPuzzleRelation(
record.getUserId(),
record.getScenicId(),
record.getFaceId(),
record.getId()
);
// 通知等待方任务完成
completeWaitFuture(taskId, TaskWaitResult.success(resultImageUrl));
@@ -238,7 +244,7 @@ public class PuzzleEdgeRenderTaskService {
record.getUserId(),
record.getScenicId(),
record.getFaceId(),
originalImageUrl,
resultImageUrl,
0L // 打印特有
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
@@ -421,9 +427,6 @@ public class PuzzleEdgeRenderTaskService {
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
String originalObjectKey = String.format("puzzle/%s/%s", template.getCode(), fileName);
String croppedObjectKey = StrUtil.isNotBlank(template.getUserArea())
? String.format("puzzle/%s_cropped/%s", template.getCode(), fileName)
: null;
Map<String, Object> payload = new HashMap<>();
payload.put("recordId", record.getId());
@@ -436,7 +439,6 @@ public class PuzzleEdgeRenderTaskService {
templatePayload.put("backgroundType", template.getBackgroundType());
templatePayload.put("backgroundColor", template.getBackgroundColor());
templatePayload.put("backgroundImage", template.getBackgroundImage());
templatePayload.put("userArea", template.getUserArea());
payload.put("template", templatePayload);
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
@@ -476,7 +478,7 @@ public class PuzzleEdgeRenderTaskService {
task.setOutputFormat(normalizedFormat);
task.setOutputQuality(outputQuality);
task.setOriginalObjectKey(originalObjectKey);
task.setCroppedObjectKey(croppedObjectKey);
task.setCroppedObjectKey(null);
task.setPayloadJson(JacksonUtil.toJson(payload));
Long taskId = taskIdSequence.incrementAndGet();

View File

@@ -76,12 +76,6 @@ public class PuzzleGenerationRecordEntity {
@TableField("result_image_url")
private String resultImageUrl;
/**
* 原始图片URL(未裁切的图片,用于打印)
*/
@TableField("original_image_url")
private String originalImageUrl;
/**
* 文件大小(字节)
*/

View File

@@ -109,12 +109,6 @@ public class PuzzleTemplateEntity {
@TableField("can_print")
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
@TableField("user_area")
private String userArea;
/**
* 创建时间
*/

View File

@@ -0,0 +1,49 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 会员拼图关联Mapper接口
*/
@Mapper
public interface MemberPuzzleMapper {
/**
* 添加关联记录
*/
int addRelation(MemberPuzzleEntity entity);
/**
* 批量添加关联记录
*/
int addRelations(@Param("list") List<MemberPuzzleEntity> list);
/**
* 根据人脸ID查询关联列表
*/
List<MemberPuzzleEntity> listByFaceId(@Param("faceId") Long faceId);
/**
* 根据人脸ID统计免费数量
*/
int countFreeByFaceId(@Param("faceId") Long faceId);
/**
* 更新关联记录(购买状态、订单ID等)
*/
int updateRelation(MemberPuzzleEntity entity);
/**
* 批量标记为免费
*/
int freeRelations(@Param("ids") List<Long> ids);
/**
* 根据人脸ID和记录ID查询
*/
MemberPuzzleEntity getByFaceAndRecord(@Param("faceId") Long faceId, @Param("recordId") Long recordId);
}

View File

@@ -52,7 +52,6 @@ public interface PuzzleGenerationRecordMapper {
*/
int updateSuccess(@Param("id") Long id,
@Param("resultImageUrl") String resultImageUrl,
@Param("originalImageUrl") String originalImageUrl,
@Param("resultFileSize") Long resultFileSize,
@Param("resultWidth") Integer resultWidth,
@Param("resultHeight") Integer resultHeight,

View File

@@ -18,6 +18,7 @@ import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.utils.WxMpUtil;
@@ -58,6 +59,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final PrinterService printerService;
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
private final FaceStatusManager faceStatusManager;
private final PuzzleRelationProcessor puzzleRelationProcessor;
public PuzzleGenerateServiceImpl(
PuzzleRepository puzzleRepository,
@@ -68,7 +70,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
@Lazy PuzzleDuplicationDetector duplicationDetector,
@Lazy PrinterService printerService,
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService,
@Lazy FaceStatusManager faceStatusManager) {
@Lazy FaceStatusManager faceStatusManager,
@Lazy PuzzleRelationProcessor puzzleRelationProcessor) {
this.puzzleRepository = puzzleRepository;
this.recordMapper = recordMapper;
this.imageRenderer = imageRenderer;
@@ -78,6 +81,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
this.printerService = printerService;
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
this.faceStatusManager = faceStatusManager;
this.puzzleRelationProcessor = puzzleRelationProcessor;
}
@Override
@@ -206,6 +210,14 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
}
// 创建member_puzzle关联记录
puzzleRelationProcessor.createPuzzleRelation(
request.getUserId(),
resolvedScenicId,
request.getFaceId(),
record.getId()
);
// 重新查询记录获取完整信息(边缘渲染回调已更新)
PuzzleGenerationRecordEntity updatedRecord = recordMapper.getById(record.getId());
if (updatedRecord != null && updatedRecord.getResultImageUrl() != null) {
@@ -456,43 +468,27 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
// 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
// 上传图到OSS(未裁切)
String originalImageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("图上传成功: url={}", originalImageUrl);
// 处理用户区域裁切
String finalImageUrl = originalImageUrl;
BufferedImage finalImage = resultImage;
if (StrUtil.isNotBlank(template.getUserArea())) {
try {
BufferedImage croppedImage = cropImage(resultImage, template.getUserArea());
finalImageUrl = uploadImage(croppedImage, template.getCode() + "_cropped", request.getOutputFormat(), request.getQuality());
finalImage = croppedImage;
log.info("裁切后图片上传成功: userArea={}, url={}", template.getUserArea(), finalImageUrl);
} catch (Exception e) {
log.error("图片裁切失败,使用原图: userArea={}", template.getUserArea(), e);
}
}
// 上传图到OSS
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("上传成功: url={}", imageUrl);
// 更新记录为成功
long duration = System.currentTimeMillis() - startTime;
long fileSize = estimateFileSize(finalImage, request.getOutputFormat());
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
recordMapper.updateSuccess(
record.getId(),
finalImageUrl,
originalImageUrl,
imageUrl,
fileSize,
finalImage.getWidth(),
finalImage.getHeight(),
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration
);
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
log.info("拼图生成成功: recordId={}, originalUrl={}, finalUrl={}, duration={}ms",
record.getId(), originalImageUrl, finalImageUrl, duration);
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
record.getId(), imageUrl, duration);
// 检查是否自动添加到打印队列
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
@@ -501,7 +497,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
request.getUserId(),
resolvedScenicId,
request.getFaceId(),
originalImageUrl,
imageUrl,
0L // 打印特有
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
@@ -511,10 +507,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
}
return PuzzleGenerateResponse.success(
finalImageUrl,
imageUrl,
fileSize,
finalImage.getWidth(),
finalImage.getHeight(),
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration,
record.getId(),
false,
@@ -761,43 +757,4 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
return templateScenicId;
}
/**
* 裁切图片
* @param image 原图
* @param userArea 裁切区域,格式:x,y,w,h
* @return 裁切后的图片
*/
private BufferedImage cropImage(BufferedImage image, String userArea) {
if (StrUtil.isBlank(userArea)) {
return image;
}
try {
String[] parts = userArea.split(",");
if (parts.length != 4) {
throw new IllegalArgumentException("userArea格式错误,应为:x,y,w,h");
}
int x = Integer.parseInt(parts[0].trim());
int y = Integer.parseInt(parts[1].trim());
int w = Integer.parseInt(parts[2].trim());
int h = Integer.parseInt(parts[3].trim());
// 边界检查
if (x < 0 || y < 0 || w <= 0 || h <= 0) {
throw new IllegalArgumentException("裁切区域参数必须为正数");
}
if (x + w > image.getWidth() || y + h > image.getHeight()) {
throw new IllegalArgumentException("裁切区域超出图片边界");
}
// 执行裁切
return image.getSubimage(x, y, w, h);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("userArea格式错误,参数必须为数字", e);
}
}
}

View File

@@ -168,6 +168,23 @@ public class ScenicConfigFacade {
return config.getInteger("photo_free_num");
}
// ==================== 拼图相关配置 ====================
/**
* 获取免费拼图数量
* 新用户首次识别时赠送的免费拼图数量
*
* @param scenicId 景区ID
* @return 免费拼图数量,null 或 0 表示不赠送
*/
public Integer getPuzzleFreeNum(Long scenicId) {
ScenicConfigManager config = getConfig(scenicId);
if (config == null) {
return null;
}
return config.getInteger("puzzle_free_num");
}
// ==================== 游玩时间相关配置 ====================
/**

View File

@@ -0,0 +1,84 @@
package com.ycwl.basic.service.pc.processor;
import com.ycwl.basic.constant.FreeStatus;
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
import com.ycwl.basic.puzzle.mapper.MemberPuzzleMapper;
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 拼图关联记录处理器
* 负责创建和管理member_puzzle关联记录
*/
@Slf4j
@Component
public class PuzzleRelationProcessor {
@Autowired
private MemberPuzzleMapper memberPuzzleMapper;
@Autowired
private ScenicConfigFacade scenicConfigFacade;
/**
* 创建拼图关联记录
*
* @param memberId 会员ID
* @param scenicId 景区ID
* @param faceId 人脸ID
* @param recordId 拼图生成记录ID
*/
public void createPuzzleRelation(Long memberId, Long scenicId, Long faceId, Long recordId) {
if (faceId == null || recordId == null) {
log.warn("创建拼图关联记录失败:faceId或recordId为空, faceId={}, recordId={}", faceId, recordId);
return;
}
MemberPuzzleEntity entity = new MemberPuzzleEntity();
entity.setMemberId(memberId);
entity.setScenicId(scenicId);
entity.setFaceId(faceId);
entity.setRecordId(recordId);
entity.setIsBuy(0);
// 处理免费逻辑
Integer isFree = processFreeLogic(scenicId, faceId);
entity.setIsFree(isFree);
try {
memberPuzzleMapper.addRelation(entity);
log.debug("创建拼图关联记录成功: faceId={}, recordId={}, isFree={}", faceId, recordId, isFree);
} catch (Exception e) {
log.error("创建拼图关联记录失败: faceId={}, recordId={}", faceId, recordId, e);
}
}
/**
* 处理免费逻辑
* 根据景区配置的puzzle_free_num决定是否免费
*
* @param scenicId 景区ID
* @param faceId 人脸ID
* @return 免费状态码
*/
private Integer processFreeLogic(Long scenicId, Long faceId) {
Integer puzzleFreeNum = scenicConfigFacade.getPuzzleFreeNum(scenicId);
if (puzzleFreeNum == null || puzzleFreeNum <= 0) {
return FreeStatus.PAID.getCode();
}
// 统计已有的免费拼图数量
int existingFreeCount = memberPuzzleMapper.countFreeByFaceId(faceId);
if (existingFreeCount < puzzleFreeNum) {
log.debug("免费拼图逻辑: scenicId={}, faceId={}, 配置免费数量={}, 已免费={}, 本次免费",
scenicId, faceId, puzzleFreeNum, existingFreeCount);
return FreeStatus.FREE.getCode();
}
return FreeStatus.PAID.getCode();
}
}