feat(printer): 引入照片处理管线机制

- 新增Crop和PrinterOrderItem模型用于封装裁剪信息和打印订单项
- 实现基于Pipeline模式的照片处理流程,支持普通照片和拼图处理
- 添加多个处理阶段:下载、方向检测、条件旋转、水印、恢复方向、上传和清理
- 创建PipelineBuilder用于动态构建处理管线
- 实现抽象Stage基类和具体Stage实现类
- 添加Stage执行结果管理和异常处理机制
- 优化照片处理逻辑,使用管线替代原有复杂的嵌套处理代码
- 支持通过景区配置管理水印类型、存储适配器等参数
- 提供临时文件管理工具确保处理过程中文件及时清理
- 增强日志记录和错误处理能力,提升系统可维护性
This commit is contained in:
2025-11-24 21:07:52 +08:00
parent 4360ef1313
commit e418a5ccdb
20 changed files with 1393 additions and 165 deletions

View File

@@ -1,15 +1,21 @@
package com.ycwl.basic.service.printer.impl;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.constant.NumberConstant;
import com.ycwl.basic.constant.StorageConstant;
import com.ycwl.basic.enums.OrderStateEnum;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.Pipeline;
import com.ycwl.basic.image.pipeline.core.PipelineBuilder;
import com.ycwl.basic.image.pipeline.stages.CleanupStage;
import com.ycwl.basic.image.pipeline.stages.ConditionalRotateStage;
import com.ycwl.basic.image.pipeline.stages.DownloadStage;
import com.ycwl.basic.image.pipeline.stages.ImageOrientationStage;
import com.ycwl.basic.image.pipeline.stages.PuzzleBorderStage;
import com.ycwl.basic.image.pipeline.stages.RestoreOrientationStage;
import com.ycwl.basic.image.pipeline.stages.UploadStage;
import com.ycwl.basic.image.pipeline.stages.WatermarkStage;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.FaceMapper;
@@ -19,6 +25,7 @@ import com.ycwl.basic.mapper.OrderMapper;
import com.ycwl.basic.mapper.PrintTaskMapper;
import com.ycwl.basic.mapper.PrinterMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.PrinterOrderItem;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
@@ -55,7 +62,6 @@ import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.enums.StorageAcl;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.ImageUtils;
import com.ycwl.basic.utils.JacksonUtil;
@@ -731,6 +737,118 @@ public class PrinterServiceImpl implements PrinterService {
private static final int TASK_STATUS_PENDING_REVIEW = 4; // 待审核
private final Lock syncTaskLock = new ReentrantLock();
/**
* 创建普通照片处理管线
*/
private Pipeline<PhotoProcessContext> createNormalPhotoPipeline() {
return new PipelineBuilder<PhotoProcessContext>("NormalPhotoPipeline")
.addStage(new DownloadStage())
.addStage(new ImageOrientationStage())
.addStage(new ConditionalRotateStage())
.addStage(new WatermarkStage())
.addStage(new RestoreOrientationStage())
.addStage(new UploadStage())
.addStage(new CleanupStage())
.build();
}
/**
* 创建拼图处理管线
*/
private Pipeline<PhotoProcessContext> createPuzzlePipeline() {
return new PipelineBuilder<PhotoProcessContext>("PuzzlePipeline")
.addStage(new DownloadStage())
.addStage(new PuzzleBorderStage())
.addStage(new UploadStage())
.addStage(new CleanupStage())
.build();
}
/**
* 使用管线处理照片
* @param item 打印项
* @param scenicId 景区ID
* @param qrCodeFile 二维码文件
* @return 处理后的URL,失败返回原URL
*/
private String processPhotoWithPipeline(MemberPrintResp item, Long scenicId, File qrCodeFile) {
PrinterOrderItem orderItem = PrinterOrderItem.fromMemberPrintResp(item);
PhotoProcessContext context = new PhotoProcessContext(orderItem, scenicId);
context.setQrcodeFile(qrCodeFile);
try {
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
if (context.isNormalPhoto()) {
prepareNormalPhotoContext(context, scenicConfig);
} else if (context.isPuzzle()) {
prepareStorageAdapter(context, scenicConfig);
}
Pipeline<PhotoProcessContext> pipeline = context.isPuzzle()
? createPuzzlePipeline()
: createNormalPhotoPipeline();
boolean success = pipeline.execute(context);
if (success && context.getResultUrl() != null) {
log.info("照片处理成功: photoId={}, type={}, url={}",
item.getId(),
context.isPuzzle() ? "拼图" : "普通照片",
context.getResultUrl());
return context.getResultUrl();
} else {
log.warn("照片处理失败,使用原图: photoId={}", item.getId());
return item.getCropUrl();
}
} catch (Exception e) {
log.error("照片处理异常,使用原图: photoId={}", item.getId(), e);
return item.getCropUrl();
} finally {
context.cleanup();
}
}
/**
* 准备普通照片的Context配置
*/
private void prepareNormalPhotoContext(PhotoProcessContext context, ScenicConfigManager scenicConfig) {
String printWatermarkType = scenicConfig.getString("print_watermark_type");
if (StringUtils.isNotBlank(printWatermarkType)) {
ImageWatermarkOperatorEnum watermarkType = ImageWatermarkOperatorEnum.getByCode(printWatermarkType);
context.setWatermarkType(watermarkType);
}
String scenicText = scenicConfig.getString("print_watermark_scenic_text", "");
context.setScenicText(scenicText);
String dateFormat = scenicConfig.getString("print_watermark_dt_format", "yyyy.MM.dd");
context.setDateFormat(dateFormat);
prepareStorageAdapter(context, scenicConfig);
}
/**
* 准备存储适配器
*/
private void prepareStorageAdapter(PhotoProcessContext context, ScenicConfigManager scenicConfig) {
try {
String storeType = scenicConfig.getString("store_type");
if (storeType != null) {
IStorageAdapter adapter = StorageFactory.get(storeType);
String storeConfigJson = scenicConfig.getString("store_config_json");
if (StringUtils.isNotBlank(storeConfigJson)) {
adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class));
}
context.setStorageAdapter(adapter);
}
} catch (Exception e) {
log.warn("准备存储适配器失败,将使用默认存储: {}", e.getMessage());
}
}
@Override
public void setUserIsBuyItem(Long memberId, Long id, Long orderId) {
if (redisTemplate.opsForValue().get(USER_PHOTO_LIST_TO_PRINTER + memberId + ":" + orderId) != null) {
@@ -753,166 +871,9 @@ public class PrinterServiceImpl implements PrinterService {
}
userPhotoListByOrderId.forEach(item -> {
PrinterEntity printer = printerMapper.getById(item.getPrinterId());
// 水印处理逻辑(仅当sourceId不为空时执行)
String printUrl = item.getCropUrl();
if (item.getSourceId() != null && item.getSourceId() > 0) {
try {
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
String printWatermarkType = scenicConfig.getString("print_watermark_type");
if (StringUtils.isNotBlank(printWatermarkType)) {
ImageWatermarkOperatorEnum watermarkType = ImageWatermarkOperatorEnum.getByCode(printWatermarkType);
if (watermarkType != null) {
// 准备存储适配器
IStorageAdapter adapter;
String storeType = scenicConfig.getString("store_type");
if (storeType != null) {
adapter = StorageFactory.get(storeType);
String storeConfigJson = scenicConfig.getString("store_config_json");
if (StringUtils.isNotBlank(storeConfigJson)) {
adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class));
}
} else {
adapter = StorageFactory.use("assets-ext");
}
// 准备水印处理器
IOperator operator = ImageWatermarkFactory.get(watermarkType);
// 生成唯一的处理标识符,避免多线程环境下的文件冲突
String processId = item.getId() + "_" + UUID.randomUUID().toString();
// 下载原图
File originalFile = new File("print_" + processId + ".jpg");
File watermarkedFile = new File("print_" + processId + "_" + watermarkType.getType() + "." + watermarkType.getPreferFileType());
File rotatedOriginalFile = null;
File rotatedWatermarkedFile = null;
boolean needRotation = false;
try {
HttpUtil.downloadFile(item.getCropUrl(), originalFile);
WatermarkInfo watermarkInfo = new WatermarkInfo();
// 判断图片方向并处理旋转
boolean isLandscape = false;
try {
Integer rotate = JacksonUtil.getInt(item.getCrop(), "rotation");
if (rotate != null) {
isLandscape = rotate % 180 == 0;
}
} catch (Exception ignored) {
}
if (!isLandscape) {
// 竖图需要旋转为横图
needRotation = true;
rotatedOriginalFile = new File("print_" + processId + "_rotated.jpg");
ImageUtils.rotateImage90(originalFile, rotatedOriginalFile);
log.info("竖图已旋转为横图,照片ID: {}", item.getId());
watermarkInfo.setOffsetLeft(40);
}
// 处理水印
watermarkInfo.setScenicLine(scenicConfig.getString("print_watermark_scenic_text", ""));
watermarkInfo.setOriginalFile(needRotation ? rotatedOriginalFile : originalFile);
watermarkInfo.setWatermarkedFile(watermarkedFile);
watermarkInfo.setQrcodeFile(qrCodeFile);
watermarkInfo.setDatetime(new Date());
watermarkInfo.setDtFormat(scenicConfig.getString("print_watermark_dt_format", "yyyy.MM.dd"));
operator.process(watermarkInfo);
// 如果之前旋转了,需要将水印图片旋转回去
if (needRotation) {
rotatedWatermarkedFile = new File("print_" + processId + "_final_" + watermarkType.getType() + "." + watermarkType.getPreferFileType());
ImageUtils.rotateImage270(watermarkedFile, rotatedWatermarkedFile);
log.info("水印图片已旋转回竖图,照片ID: {}", item.getId());
// 删除中间的横图水印文件
if (watermarkedFile.exists()) {
watermarkedFile.delete();
}
// 将最终的竖图水印文件赋值给watermarkedFile
watermarkedFile = rotatedWatermarkedFile;
}
// 上传水印图片
String watermarkedUrl = adapter.uploadFile(null, watermarkedFile, StorageConstant.PHOTO_WATERMARKED_PATH, watermarkedFile.getName());
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH, watermarkedFile.getName());
printUrl = watermarkedUrl;
log.info("水印处理成功,打印照片ID: {}, 水印URL: {}", item.getId(), watermarkedUrl);
} catch (Exception e) {
log.error("水印处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
} finally {
// 清理临时文件
if (originalFile != null && originalFile.exists()) {
originalFile.delete();
}
if (rotatedOriginalFile != null && rotatedOriginalFile.exists()) {
rotatedOriginalFile.delete();
}
if (watermarkedFile != null && watermarkedFile.exists()) {
watermarkedFile.delete();
}
if (rotatedWatermarkedFile != null && rotatedWatermarkedFile.exists()) {
rotatedWatermarkedFile.delete();
}
}
}
}
} catch (Exception e) {
log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e);
}
} else if (item.getSourceId() != null && item.getSourceId() == 0) {
// 拼图:添加白边框并向上偏移以避免打印机偏移
try {
// 生成唯一的处理标识符,避免多线程环境下的文件冲突
String processId = item.getId() + "_" + UUID.randomUUID().toString();
File originalFile = new File("puzzle_" + processId + ".png");
File processedFile = new File("puzzle_" + processId + "_processed.png");
try {
// 下载原图
HttpUtil.downloadFile(item.getCropUrl(), originalFile);
// 添加白边框(左右20px,上下30px)并向上偏移15px
ImageUtils.addBorderAndShiftUp(originalFile, processedFile, 20, 30, 15);
// 上传处理后的图片
IStorageAdapter adapter;
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
String storeType = scenicConfig.getString("store_type");
if (storeType != null) {
adapter = StorageFactory.get(storeType);
String storeConfigJson = scenicConfig.getString("store_config_json");
if (StringUtils.isNotBlank(storeConfigJson)) {
adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class));
}
} else {
adapter = StorageFactory.use("assets-ext");
}
String processedUrl = adapter.uploadFile(null, processedFile, StorageConstant.PHOTO_WATERMARKED_PATH, processedFile.getName());
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH, processedFile.getName());
printUrl = processedUrl;
log.info("拼图照片添加白边框并向上偏移成功,照片ID: {}, 新URL: {}", item.getId(), processedUrl);
} catch (Exception e) {
log.error("拼图照片处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
} finally {
// 清理临时文件
if (originalFile != null && originalFile.exists()) {
originalFile.delete();
}
if (processedFile != null && processedFile.exists()) {
processedFile.delete();
}
}
} catch (Exception e) {
log.error("拼图照片处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
}
}
// 使用管线处理照片
String printUrl = processPhotoWithPipeline(item, item.getScenicId(), qrCodeFile);
// 根据数量创建多个打印任务
Integer quantity = item.getQuantity();