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

@@ -0,0 +1,58 @@
package com.ycwl.basic.image.pipeline.core;
import lombok.extern.slf4j.Slf4j;
/**
* Pipeline Stage抽象基类
* 提供默认实现和通用逻辑
*/
@Slf4j
public abstract class AbstractPipelineStage<C extends PhotoProcessContext> implements PipelineStage<C> {
/**
* 默认总是执行
* 子类可以覆盖此方法实现条件性执行
*/
@Override
public boolean shouldExecute(C context) {
return true;
}
/**
* 模板方法:执行Stage前的准备工作
*/
protected void beforeExecute(C context) {
log.debug("[{}] 开始执行", getName());
}
/**
* 模板方法:执行Stage后的清理工作
*/
protected void afterExecute(C context, StageResult result) {
if (result.isSuccess()) {
log.debug("[{}] 执行成功: {}", getName(), result.getMessage());
} else if (result.isSkipped()) {
log.debug("[{}] 已跳过: {}", getName(), result.getMessage());
} else if (result.isDegraded()) {
log.warn("[{}] 降级执行: {}", getName(), result.getMessage());
} else {
log.error("[{}] 执行失败: {}", getName(), result.getMessage(), result.getException());
}
}
/**
* 子类实现具体的处理逻辑
*/
protected abstract StageResult doExecute(C context);
/**
* 最终执行方法(带钩子)
*/
@Override
public final StageResult execute(C context) {
beforeExecute(context);
StageResult result = doExecute(context);
afterExecute(context, result);
return result;
}
}

View File

@@ -0,0 +1,97 @@
package com.ycwl.basic.image.pipeline.core;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.model.Crop;
import com.ycwl.basic.model.PrinterOrderItem;
import com.ycwl.basic.image.pipeline.util.TempFileManager;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import lombok.Getter;
import lombok.Setter;
import java.io.File;
/**
* 图片处理管线上下文
* 在各个Stage之间传递状态和数据
*/
@Getter
@Setter
public class PhotoProcessContext {
private final PrinterOrderItem orderItem;
private final TempFileManager tempFileManager;
private final Long scenicId;
private File originalFile;
private File processedFile;
private boolean isLandscape = true;
private boolean needRotation = false;
private String resultUrl;
private IStorageAdapter storageAdapter;
private WatermarkInfo watermarkInfo;
private ImageWatermarkOperatorEnum watermarkType;
private String scenicText;
private String dateFormat;
private File qrcodeFile;
private Integer offsetLeft;
private Crop crop;
public PhotoProcessContext(PrinterOrderItem orderItem, Long scenicId) {
this.orderItem = orderItem;
this.scenicId = scenicId;
this.tempFileManager = new TempFileManager(orderItem.getId().toString());
}
/**
* 是否为拼图类型
*/
public boolean isPuzzle() {
return orderItem.getSourceId() != null && orderItem.getSourceId() == 0;
}
/**
* 是否为普通照片
*/
public boolean isNormalPhoto() {
return orderItem.getSourceId() != null && orderItem.getSourceId() > 0;
}
/**
* 获取原图URL
*/
public String getOriginalUrl() {
return orderItem.getCropUrl();
}
/**
* 设置最终处理结果URL
*/
public void setResultUrl(String url) {
this.resultUrl = url;
this.orderItem.setCropUrl(url);
}
/**
* 获取当前处理中的文件
* 如果有processedFile则返回,否则返回originalFile
*/
public File getCurrentFile() {
return processedFile != null ? processedFile : originalFile;
}
/**
* 更新处理后的文件
*/
public void updateProcessedFile(File newFile) {
this.processedFile = newFile;
tempFileManager.registerTempFile(newFile);
}
/**
* 清理所有临时文件
*/
public void cleanup() {
tempFileManager.cleanup();
}
}

View File

@@ -0,0 +1,96 @@
package com.ycwl.basic.image.pipeline.core;
import com.ycwl.basic.image.pipeline.exception.PipelineException;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* 图片处理管线
* 按顺序执行一系列Stage
*/
@Slf4j
public class Pipeline<C extends PhotoProcessContext> {
private final List<PipelineStage<C>> stages;
private final String name;
public Pipeline(String name, List<PipelineStage<C>> stages) {
this.name = name;
this.stages = new ArrayList<>(stages);
}
/**
* 执行管线
*
* @param context 管线上下文
* @return 执行成功返回true
* @throws PipelineException 管线执行异常
*/
public boolean execute(C context) {
log.info("[{}] 开始执行管线, Stage数量: {}", name, stages.size());
long startTime = System.currentTimeMillis();
try {
for (int i = 0; i < stages.size(); i++) {
PipelineStage<C> stage = stages.get(i);
String stageName = stage.getName();
log.debug("[{}] [{}/{}] 准备执行Stage: {}", name, i + 1, stages.size(), stageName);
if (!stage.shouldExecute(context)) {
log.debug("[{}] Stage {} 条件不满足,跳过执行", name, stageName);
continue;
}
long stageStartTime = System.currentTimeMillis();
StageResult result = stage.execute(context);
long stageDuration = System.currentTimeMillis() - stageStartTime;
logStageResult(stageName, result, stageDuration);
if (result.isFailed()) {
log.error("[{}] Stage {} 执行失败,管线终止", name, stageName);
return false;
}
}
long totalDuration = System.currentTimeMillis() - startTime;
log.info("[{}] 管线执行完成, 耗时: {}ms", name, totalDuration);
return true;
} catch (Exception e) {
log.error("[{}] 管线执行异常", name, e);
throw new PipelineException("管线执行失败: " + e.getMessage(), e);
}
}
private void logStageResult(String stageName, StageResult result, long duration) {
String statusIcon = switch (result.getStatus()) {
case SUCCESS -> "";
case SKIPPED -> "";
case DEGRADED -> "";
case FAILED -> "";
};
log.info("[{}] {} Stage {} - {} (耗时: {}ms)",
name, statusIcon, stageName, result.getStatus(), duration);
if (result.getMessage() != null) {
log.debug("[{}] 详情: {}", name, result.getMessage());
}
}
public String getName() {
return name;
}
public int getStageCount() {
return stages.size();
}
public List<String> getStageNames() {
return stages.stream().map(PipelineStage::getName).toList();
}
}

View File

@@ -0,0 +1,78 @@
package com.ycwl.basic.image.pipeline.core;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Pipeline构建器
* 使用Builder模式动态组装管线
*/
public class PipelineBuilder<C extends PhotoProcessContext> {
private String name = "DefaultPipeline";
private final List<PipelineStage<C>> stages = new ArrayList<>();
public PipelineBuilder() {
}
public PipelineBuilder(String name) {
this.name = name;
}
/**
* 设置管线名称
*/
public PipelineBuilder<C> name(String name) {
this.name = name;
return this;
}
/**
* 添加Stage
*/
public PipelineBuilder<C> addStage(PipelineStage<C> stage) {
if (stage != null) {
this.stages.add(stage);
}
return this;
}
/**
* 批量添加Stage
*/
public PipelineBuilder<C> addStages(List<PipelineStage<C>> stages) {
if (stages != null) {
this.stages.addAll(stages);
}
return this;
}
/**
* 条件性添加Stage
*/
public PipelineBuilder<C> addStageIf(boolean condition, PipelineStage<C> stage) {
if (condition && stage != null) {
this.stages.add(stage);
}
return this;
}
/**
* 按优先级排序Stage
*/
public PipelineBuilder<C> sortByPriority() {
this.stages.sort(Comparator.comparingInt(PipelineStage::getPriority));
return this;
}
/**
* 构建Pipeline
*/
public Pipeline<C> build() {
if (stages.isEmpty()) {
throw new IllegalStateException("管线至少需要一个Stage");
}
return new Pipeline<>(name, stages);
}
}

View File

@@ -0,0 +1,40 @@
package com.ycwl.basic.image.pipeline.core;
/**
* 管线处理阶段接口
* 每个Stage负责一个独立的图片处理步骤
*
* @param <C> Context类型
*/
public interface PipelineStage<C extends PhotoProcessContext> {
/**
* 获取Stage名称(用于日志和监控)
*/
String getName();
/**
* 判断是否需要执行此Stage
* 支持条件性执行(如:只有竖图才需要旋转)
*
* @param context 管线上下文
* @return true-执行, false-跳过
*/
boolean shouldExecute(C context);
/**
* 执行Stage处理逻辑
*
* @param context 管线上下文
* @return 执行结果
*/
StageResult execute(C context);
/**
* 获取Stage的执行优先级(用于排序)
* 数值越小优先级越高,默认为100
*/
default int getPriority() {
return 100;
}
}

View File

@@ -0,0 +1,75 @@
package com.ycwl.basic.image.pipeline.core;
import lombok.Getter;
/**
* Stage执行结果
*/
@Getter
public class StageResult {
public enum Status {
SUCCESS, // 执行成功
SKIPPED, // 跳过执行
FAILED, // 执行失败
DEGRADED // 降级执行
}
private final Status status;
private final String message;
private final Throwable exception;
private StageResult(Status status, String message, Throwable exception) {
this.status = status;
this.message = message;
this.exception = exception;
}
public static StageResult success() {
return new StageResult(Status.SUCCESS, null, null);
}
public static StageResult success(String message) {
return new StageResult(Status.SUCCESS, message, null);
}
public static StageResult skipped() {
return new StageResult(Status.SKIPPED, "条件不满足,跳过执行", null);
}
public static StageResult skipped(String reason) {
return new StageResult(Status.SKIPPED, reason, null);
}
public static StageResult failed(String message) {
return new StageResult(Status.FAILED, message, null);
}
public static StageResult failed(String message, Throwable exception) {
return new StageResult(Status.FAILED, message, exception);
}
public static StageResult degraded(String message) {
return new StageResult(Status.DEGRADED, message, null);
}
public boolean isSuccess() {
return status == Status.SUCCESS;
}
public boolean isSkipped() {
return status == Status.SKIPPED;
}
public boolean isFailed() {
return status == Status.FAILED;
}
public boolean isDegraded() {
return status == Status.DEGRADED;
}
public boolean canContinue() {
return status == Status.SUCCESS || status == Status.SKIPPED || status == Status.DEGRADED;
}
}

View File

@@ -0,0 +1,15 @@
package com.ycwl.basic.image.pipeline.exception;
/**
* 管线处理异常基类
*/
public class PipelineException extends RuntimeException {
public PipelineException(String message) {
super(message);
}
public PipelineException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,23 @@
package com.ycwl.basic.image.pipeline.exception;
/**
* Stage执行异常
*/
public class StageExecutionException extends PipelineException {
private final String stageName;
public StageExecutionException(String stageName, String message) {
super(String.format("Stage '%s' 执行失败: %s", stageName, message));
this.stageName = stageName;
}
public StageExecutionException(String stageName, String message, Throwable cause) {
super(String.format("Stage '%s' 执行失败: %s", stageName, message), cause);
this.stageName = stageName;
}
public String getStageName() {
return stageName;
}
}

View File

@@ -0,0 +1,39 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import lombok.extern.slf4j.Slf4j;
/**
* 清理临时文件Stage
* 总是在管线最后执行,清理所有临时文件
*/
@Slf4j
public class CleanupStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "CleanupStage";
}
@Override
public int getPriority() {
return 999;
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
try {
int fileCount = context.getTempFileManager().getTempFileCount();
context.cleanup();
log.info("临时文件清理完成: 共{}个文件", fileCount);
return StageResult.success(String.format("已清理 %d 个临时文件", fileCount));
} catch (Exception e) {
log.warn("临时文件清理失败,但不影响主流程", e);
return StageResult.degraded("清理失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,60 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 条件旋转Stage
* 如果是竖图,旋转90度变成横图(便于后续水印处理)
*/
@Slf4j
public class ConditionalRotateStage extends AbstractPipelineStage<PhotoProcessContext> {
private static final int OFFSET_LEFT_FOR_PORTRAIT = 40;
@Override
public String getName() {
return "ConditionalRotateStage";
}
@Override
public boolean shouldExecute(PhotoProcessContext context) {
return context.isNormalPhoto() && !context.isLandscape();
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
try {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
File rotatedFile = context.getTempFileManager()
.createTempFile("rotated", ".jpg");
log.debug("竖图旋转90度: {} -> {}", currentFile.getName(), rotatedFile.getName());
ImageUtils.rotateImage90(currentFile, rotatedFile);
if (!rotatedFile.exists()) {
return StageResult.failed("旋转后的文件未生成");
}
context.updateProcessedFile(rotatedFile);
context.setNeedRotation(true);
context.setOffsetLeft(OFFSET_LEFT_FOR_PORTRAIT);
log.info("竖图已旋转90度, offsetLeft={}", OFFSET_LEFT_FOR_PORTRAIT);
return StageResult.success("已旋转90度");
} catch (Exception e) {
log.error("图片旋转失败", e);
return StageResult.failed("旋转失败: " + e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,67 @@
package com.ycwl.basic.image.pipeline.stages;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.File;
/**
* 下载图片Stage
* 从URL下载原图到本地临时文件
*/
@Slf4j
public class DownloadStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "DownloadStage";
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
String url = context.getOriginalUrl();
if (StringUtils.isBlank(url)) {
return StageResult.failed("原图URL为空");
}
try {
String fileExtension = determineFileExtension(context);
String filePrefix = context.isPuzzle() ? "puzzle" : "print";
File downloadFile = context.getTempFileManager()
.createTempFile(filePrefix, fileExtension);
log.debug("开始下载图片: {} -> {}", url, downloadFile.getName());
HttpUtil.downloadFile(url, downloadFile);
if (!downloadFile.exists() || downloadFile.length() == 0) {
return StageResult.failed("下载的文件不存在或为空");
}
context.setOriginalFile(downloadFile);
log.info("图片下载成功: {} ({}KB)", downloadFile.getName(),
downloadFile.length() / 1024);
return StageResult.success(String.format("已下载 %dKB", downloadFile.length() / 1024));
} catch (Exception e) {
log.error("图片下载失败: {}", url, e);
return StageResult.failed("下载失败: " + e.getMessage(), e);
}
}
private String determineFileExtension(PhotoProcessContext context) {
if (context.isPuzzle()) {
return ".png";
}
String url = context.getOriginalUrl();
if (url.toLowerCase().endsWith(".png")) {
return ".png";
}
return ".jpg";
}
}

View File

@@ -0,0 +1,72 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.model.Crop;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 图片方向检测Stage
* 检测图片是横图还是竖图,并记录到Context
*/
@Slf4j
public class ImageOrientationStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "ImageOrientationStage";
}
@Override
public boolean shouldExecute(PhotoProcessContext context) {
return context.isNormalPhoto();
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
try {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
boolean isLandscape = detectOrientation(currentFile, context.getCrop());
context.setLandscape(isLandscape);
String orientation = isLandscape ? "横图" : "竖图";
log.info("图片方向检测: {}", orientation);
return StageResult.success(orientation);
} catch (Exception e) {
log.error("图片方向检测失败", e);
return StageResult.failed("方向检测失败: " + e.getMessage(), e);
}
}
/**
* 检测图片方向
* 优先使用Crop的rotation字段,其次使用图片宽高判断
*/
private boolean detectOrientation(File imageFile, Crop crop) {
if (crop != null && crop.getRotation() != null) {
int rotation = crop.getRotation();
boolean isLandscape = (rotation == 90 || rotation == 270);
log.debug("从Crop.rotation判断方向: rotation={}, isLandscape={}", rotation, isLandscape);
return isLandscape;
}
try {
boolean isLandscape = ImageUtils.isLandscape(imageFile);
log.debug("从图片尺寸判断方向: isLandscape={}", isLandscape);
return isLandscape;
} catch (Exception e) {
log.warn("从图片尺寸判断方向失败,默认为横图: {}", e.getMessage());
return true;
}
}
}

View File

@@ -0,0 +1,60 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 拼图边框处理Stage
* 为拼图添加白边框并向上偏移
*/
@Slf4j
public class PuzzleBorderStage extends AbstractPipelineStage<PhotoProcessContext> {
private static final int BORDER_LR = 20;
private static final int BORDER_TB = 30;
private static final int SHIFT_UP = 15;
@Override
public String getName() {
return "PuzzleBorderStage";
}
@Override
public boolean shouldExecute(PhotoProcessContext context) {
return context.isPuzzle();
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
try {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
File processedFile = context.getTempFileManager()
.createTempFile("puzzle_processed", ".png");
log.debug("拼图添加边框: lr={}, tb={}, shiftUp={}", BORDER_LR, BORDER_TB, SHIFT_UP);
ImageUtils.addBorderAndShiftUp(currentFile, processedFile, BORDER_LR, BORDER_TB, SHIFT_UP);
if (!processedFile.exists()) {
return StageResult.failed("处理后的文件未生成");
}
context.updateProcessedFile(processedFile);
log.info("拼图边框处理完成: {}KB", processedFile.length() / 1024);
return StageResult.success(String.format("边框处理完成 (%dKB)", processedFile.length() / 1024));
} catch (Exception e) {
log.error("拼图边框处理失败", e);
return StageResult.failed("边框处理失败: " + e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,66 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 恢复图片方向Stage
* 如果之前旋转过竖图,现在旋转270度恢复为竖图
*/
@Slf4j
public class RestoreOrientationStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "RestoreOrientationStage";
}
@Override
public boolean shouldExecute(PhotoProcessContext context) {
return context.isNormalPhoto() && context.isNeedRotation();
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
try {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
String extension = getFileExtension(currentFile);
File finalFile = context.getTempFileManager()
.createTempFile("final", extension);
log.debug("恢复竖图方向(旋转270度): {} -> {}", currentFile.getName(), finalFile.getName());
ImageUtils.rotateImage270(currentFile, finalFile);
if (!finalFile.exists()) {
return StageResult.failed("旋转后的文件未生成");
}
context.updateProcessedFile(finalFile);
log.info("竖图方向已恢复(旋转270度)");
return StageResult.success("已恢复竖图方向");
} catch (Exception e) {
log.error("图片旋转失败", e);
return StageResult.failed("旋转失败: " + e.getMessage(), e);
}
}
private String getFileExtension(File file) {
String name = file.getName();
int lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
return name.substring(lastDot);
}
return ".jpg";
}
}

View File

@@ -0,0 +1,91 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.enums.StorageAcl;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 上传图片Stage
* 支持降级: 配置的存储 -> 默认assets-ext存储
*/
@Slf4j
public class UploadStage extends AbstractPipelineStage<PhotoProcessContext> {
private static final String DEFAULT_STORAGE = "assets-ext";
@Override
public String getName() {
return "UploadStage";
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
File fileToUpload = context.getCurrentFile();
if (fileToUpload == null || !fileToUpload.exists()) {
return StageResult.failed("没有可上传的文件");
}
IStorageAdapter adapter = context.getStorageAdapter();
boolean usingDefaultStorage = false;
if (adapter == null) {
log.debug("未配置存储适配器,使用默认存储: {}", DEFAULT_STORAGE);
try {
adapter = StorageFactory.use(DEFAULT_STORAGE);
usingDefaultStorage = true;
} catch (Exception e) {
return StageResult.failed("无法获取默认存储: " + e.getMessage(), e);
}
}
try {
String uploadedUrl = uploadFile(adapter, fileToUpload);
context.setResultUrl(uploadedUrl);
log.info("文件上传成功: {}", uploadedUrl);
if (usingDefaultStorage) {
return StageResult.degraded("降级: 使用默认存储 " + DEFAULT_STORAGE);
}
return StageResult.success("已上传");
} catch (Exception e) {
log.error("文件上传失败", e);
if (!usingDefaultStorage) {
log.warn("尝试降级到默认存储");
try {
IStorageAdapter defaultAdapter = StorageFactory.use(DEFAULT_STORAGE);
String uploadedUrl = uploadFile(defaultAdapter, fileToUpload);
context.setResultUrl(uploadedUrl);
log.info("降级上传成功: {}", uploadedUrl);
return StageResult.degraded("降级: 使用默认存储 " + DEFAULT_STORAGE);
} catch (Exception fallbackEx) {
log.error("降级上传也失败", fallbackEx);
return StageResult.failed("上传失败(包括降级): " + fallbackEx.getMessage(), fallbackEx);
}
}
return StageResult.failed("上传失败: " + e.getMessage(), e);
}
}
private String uploadFile(IStorageAdapter adapter, File file) throws Exception {
String filename = file.getName();
String uploadPath = "print/" + filename;
String url = adapter.uploadFile(uploadPath, file);
adapter.setAcl(StorageAcl.PUBLIC_READ, uploadPath);
return url;
}
}

View File

@@ -0,0 +1,158 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.File;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
/**
* 水印处理Stage
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
*/
@Slf4j
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "WatermarkStage";
}
@Override
public boolean shouldExecute(PhotoProcessContext context) {
return context.isNormalPhoto();
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
ImageWatermarkOperatorEnum watermarkType = context.getWatermarkType();
if (watermarkType == null) {
log.info("未配置水印类型,跳过水印处理");
return StageResult.skipped("未配置水印");
}
List<ImageWatermarkOperatorEnum> fallbackChain = buildFallbackChain(watermarkType);
log.debug("水印降级链: {}", fallbackChain);
for (int i = 0; i < fallbackChain.size(); i++) {
ImageWatermarkOperatorEnum type = fallbackChain.get(i);
if (type == null) {
log.warn("所有水印处理均失败,跳过水印");
return StageResult.degraded("降级: 跳过水印处理");
}
try {
StageResult result = applyWatermark(context, type);
if (i > 0) {
String degradeMsg = String.format("降级: %s -> %s",
watermarkType.getType(), type.getType());
log.warn(degradeMsg);
return StageResult.degraded(degradeMsg);
}
return result;
} catch (Exception e) {
log.warn("水印类型 {} 处理失败: {}", type.getType(), e.getMessage());
if (i == fallbackChain.size() - 2) {
log.warn("所有水印类型均失败,准备跳过水印", e);
}
}
}
return StageResult.degraded("降级: 跳过水印处理");
}
/**
* 构建降级链
*/
private List<ImageWatermarkOperatorEnum> buildFallbackChain(ImageWatermarkOperatorEnum primary) {
if (primary == ImageWatermarkOperatorEnum.PRINTER_DEFAULT) {
return Arrays.asList(primary, null);
}
return Arrays.asList(
primary,
ImageWatermarkOperatorEnum.PRINTER_DEFAULT,
null
);
}
/**
* 应用水印
*/
private StageResult applyWatermark(PhotoProcessContext context, ImageWatermarkOperatorEnum type)
throws Exception {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
throw new IllegalStateException("当前文件不存在");
}
String fileExt = type.getPreferFileType();
File watermarkedFile = context.getTempFileManager()
.createTempFile("watermark_" + type.getType(), "." + fileExt);
WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
IOperator operator = ImageWatermarkFactory.get(type);
File result = operator.process(watermarkInfo);
if (result == null || !result.exists()) {
throw new RuntimeException("水印处理后文件不存在");
}
context.updateProcessedFile(result);
log.info("水印应用成功: type={}, size={}KB", type.getType(), result.length() / 1024);
return StageResult.success(String.format("水印: %s (%dKB)",
type.getType(), result.length() / 1024));
}
/**
* 构建水印参数
*/
private WatermarkInfo buildWatermarkInfo(PhotoProcessContext context, File originalFile,
File watermarkedFile, ImageWatermarkOperatorEnum type) {
WatermarkInfo info = new WatermarkInfo();
info.setOriginalFile(originalFile);
info.setWatermarkedFile(watermarkedFile);
String scenicText = context.getScenicText();
if (StringUtils.isNotBlank(scenicText)) {
info.setScenicLine(scenicText);
}
Date now = new Date();
String dateFormat = context.getDateFormat();
if (StringUtils.isBlank(dateFormat)) {
dateFormat = "yyyy.MM.dd";
}
info.setDatetime(now);
info.setDtFormat(dateFormat);
File qrcodeFile = context.getQrcodeFile();
if (qrcodeFile != null && qrcodeFile.exists()) {
info.setQrcodeFile(qrcodeFile);
}
Integer offsetLeft = context.getOffsetLeft();
if (offsetLeft != null) {
info.setOffsetLeft(offsetLeft);
}
context.setWatermarkInfo(info);
return info;
}
}

View File

@@ -0,0 +1,119 @@
package com.ycwl.basic.image.pipeline.util;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 临时文件管理器
* 统一管理所有临时文件的创建和清理
*/
@Slf4j
public class TempFileManager {
private final String processId;
private final Path tempDir;
private final List<File> tempFiles;
public TempFileManager() {
this.processId = UUID.randomUUID().toString();
this.tempDir = initTempDirectory();
this.tempFiles = new ArrayList<>();
}
public TempFileManager(String processId) {
this.processId = processId;
this.tempDir = initTempDirectory();
this.tempFiles = new ArrayList<>();
}
private Path initTempDirectory() {
try {
Path systemTempDir = Files.createTempDirectory("photo_process_");
log.debug("创建临时目录: {}", systemTempDir);
return systemTempDir;
} catch (IOException e) {
log.warn("无法创建系统临时目录,使用当前目录", e);
return Path.of(".");
}
}
/**
* 创建临时文件
*
* @param prefix 文件名前缀
* @param suffix 文件名后缀(如 .jpg, .png)
* @return 临时文件
*/
public File createTempFile(String prefix, String suffix) {
String filename = String.format("%s_%s%s", prefix, processId, suffix);
File tempFile = tempDir.resolve(filename).toFile();
tempFiles.add(tempFile);
log.debug("创建临时文件: {}", tempFile.getAbsolutePath());
return tempFile;
}
/**
* 注册外部创建的临时文件
*/
public void registerTempFile(File file) {
if (file != null && !tempFiles.contains(file)) {
tempFiles.add(file);
log.debug("注册临时文件: {}", file.getAbsolutePath());
}
}
/**
* 清理所有临时文件
*/
public void cleanup() {
int deletedCount = 0;
int failedCount = 0;
for (File file : tempFiles) {
if (file != null && file.exists()) {
boolean deleted = file.delete();
if (deleted) {
deletedCount++;
log.debug("删除临时文件: {}", file.getAbsolutePath());
} else {
failedCount++;
log.warn("无法删除临时文件: {}", file.getAbsolutePath());
}
}
}
tempFiles.clear();
if (deletedCount > 0) {
log.info("清理临时文件: 成功{}个, 失败{}个", deletedCount, failedCount);
}
cleanupTempDirectory();
}
private void cleanupTempDirectory() {
if (tempDir != null && !tempDir.equals(Path.of("."))) {
try {
Files.deleteIfExists(tempDir);
log.debug("删除临时目录: {}", tempDir);
} catch (IOException e) {
log.warn("无法删除临时目录: {}", tempDir, e);
}
}
}
public String getProcessId() {
return processId;
}
public int getTempFileCount() {
return tempFiles.size();
}
}

View File

@@ -0,0 +1,11 @@
package com.ycwl.basic.model;
import lombok.Data;
/**
* 裁剪信息
*/
@Data
public class Crop {
private Integer rotation;
}

View File

@@ -0,0 +1,42 @@
package com.ycwl.basic.model;
import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.Data;
/**
* 打印订单项(用于管线处理)
*/
@Data
public class PrinterOrderItem {
private Long id;
private Long sourceId;
private String cropUrl;
private Crop crop;
/**
* 从MemberPrintResp转换
*/
public static PrinterOrderItem fromMemberPrintResp(MemberPrintResp resp) {
PrinterOrderItem item = new PrinterOrderItem();
item.setId(resp.getId() != null ? resp.getId().longValue() : null);
item.setSourceId(resp.getSourceId());
item.setCropUrl(resp.getCropUrl());
if (resp.getCrop() != null) {
try {
Crop crop = new Crop();
Integer rotation = JacksonUtil.getInt(resp.getCrop(), "rotation");
if (rotation != null) {
crop.setRotation(rotation);
}
item.setCrop(crop);
} catch (Exception e) {
// 解析失败,crop为null
}
}
return item;
}
}

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();