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