fix(core): 修复 StageResult 中 nextStages 的不可变性问题

- 将 nextStages 初始化改为使用 Collections.unmodifiableList 包装
- 防止外部代码修改 nextStages 列表内容
- 保证 StageResult 的不可变性和线程安全性
- 添加完整的单元测试覆盖各种构造场景
This commit is contained in:
2025-11-26 09:03:36 +08:00
parent 7b18d7c2af
commit d2846e6d8e
14 changed files with 2275 additions and 1 deletions

View File

@@ -0,0 +1,595 @@
# Image Pipeline 图片处理管线
## 概述
Image Pipeline 是一个通用的、可扩展的图片处理管线框架,用于组织和执行一系列图片处理操作(Stage)。
### 核心特性
- **责任链模式**: 将图片处理流程拆分为独立的 Stage,按顺序执行
- **Builder 模式**: 灵活组装管线,支持条件性添加 Stage
- **动态 Stage 添加**: 支持在运行时根据条件动态添加后续 Stage
- **降级策略**: 支持多级降级执行,确保管线在异常情况下的稳定性
- **配置驱动**: 支持通过外部配置控制 Stage 的启用/禁用
- **类型安全**: 使用泛型和枚举确保类型安全
- **解耦设计**: Context 独立于业务模型,支持多种使用场景
## 包结构
```
com.ycwl.basic.image.pipeline
├── annotation/ # 注解定义
│ └── StageConfig # Stage 配置注解
├── core/ # 核心类
│ ├── AbstractPipelineStage # Stage 抽象基类
│ ├── PhotoProcessContext # 管线上下文
│ ├── Pipeline # 管线执行器
│ ├── PipelineBuilder # 管线构建器
│ ├── PipelineStage # Stage 接口
│ └── StageResult # Stage 执行结果
├── enums/ # 枚举定义
│ ├── ImageSource # 图片来源枚举
│ ├── ImageType # 图片类型枚举
│ ├── PipelineScene # 管线场景枚举
│ └── StageOptionalMode # Stage 可选模式枚举
├── exception/ # 异常类
│ ├── PipelineException # 管线异常
│ └── StageExecutionException # Stage 执行异常
├── stages/ # 具体 Stage 实现
│ ├── CleanupStage # 清理临时文件
│ ├── ConditionalRotateStage # 条件性旋转
│ ├── DownloadStage # 下载图片
│ ├── ImageEnhanceStage # 图像增强(超分)
│ ├── ImageOrientationStage # 图像方向检测
│ ├── ImageQualityCheckStage # 图像质量检测
│ ├── PuzzleBorderStage # 拼图边框处理
│ ├── RestoreOrientationStage # 恢复图片方向
│ ├── SourcePhotoUpdateStage # 源图片更新
│ ├── UploadStage # 上传图片
│ └── WatermarkStage # 水印处理
└── util/ # 工具类
└── TempFileManager # 临时文件管理器
```
## 核心组件
### 1. Pipeline - 管线执行器
**职责**: 按顺序执行一系列 Stage,管理执行流程和异常处理。
**关键特性**:
- 顺序执行所有 Stage
- 支持动态添加后续 Stage
- 循环检测(最大执行 100 个 Stage)
- 详细的日志输出(带状态图标)
**使用示例**:
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("MyPipeline")
.addStage(new DownloadStage())
.addStage(new WatermarkStage())
.addStage(new UploadStage())
.addStage(new CleanupStage())
.build();
boolean success = pipeline.execute(context);
```
### 2. PhotoProcessContext - 管线上下文
**职责**: 在各个 Stage 之间传递状态和数据,提供临时文件管理。
**核心字段**:
- `processId`: 处理过程唯一标识,用于隔离临时文件
- `originalUrl`: 原图 URL
- `scenicId`: 景区 ID
- `imageType`: 图片类型(普通照片/拼图/手机上传)
- `tempFileManager`: 临时文件管理器
**静态工厂方法**:
```java
// 从打印订单创建(打印场景)
PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, scenicId);
// 为超分辨率场景创建
PhotoProcessContext context = PhotoProcessContext.forSuperResolution(itemId, url, scenicId);
// 使用 Builder 自定义创建
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("custom-id")
.originalUrl("https://example.com/image.jpg")
.scenicId(12345L)
.imageType(ImageType.NORMAL_PHOTO)
.source(ImageSource.IPC)
.scene(PipelineScene.IMAGE_PRINT)
.build();
```
**重要方法**:
- `getCurrentFile()`: 获取当前处理中的文件
- `updateProcessedFile(File)`: 更新处理后的文件
- `setResultUrl(String)`: 设置最终结果 URL(会触发回调)
- `cleanup()`: 清理所有临时文件
- `isStageEnabled(stageId, default)`: 判断 Stage 是否启用
### 3. AbstractPipelineStage - Stage 抽象基类
**职责**: 提供 Stage 的通用实现和模板方法。
**执行流程**:
```
shouldExecute() → beforeExecute() → doExecute() → afterExecute()
```
**子类需要实现**:
- `getName()`: 返回 Stage 名称
- `doExecute(context)`: 实现具体处理逻辑
- `shouldExecuteByBusinessLogic(context)`: (可选)实现条件判断
**Stage 执行判断逻辑**:
1. 检查 `@StageConfig` 注解
2. 根据 `optionalMode` 决定是否检查外部配置
- `FORCE_ON`: 强制执行,不检查外部配置
- `SUPPORT`: 检查外部配置(`context.isStageEnabled()`
- `UNSUPPORT`: 不检查外部配置
3. 执行业务逻辑判断(`shouldExecuteByBusinessLogic()`
### 4. StageResult - Stage 执行结果
**状态类型**:
- `SUCCESS`: 执行成功
- `SKIPPED`: 跳过执行
- `FAILED`: 执行失败(会终止管线)
- `DEGRADED`: 降级执行(继续管线但记录警告)
**静态工厂方法**:
```java
// 成功
StageResult.success();
StageResult.success("处理完成");
// 成功并动态添加后续 Stage
StageResult.successWithNext("质量不佳,添加增强", new ImageEnhanceStage());
// 跳过
StageResult.skipped("条件不满足");
// 失败
StageResult.failed("下载失败");
StageResult.failed("处理失败", exception);
// 降级
StageResult.degraded("使用备用方案");
```
### 5. @StageConfig - Stage 配置注解
**字段**:
- `stageId`: Stage 唯一标识(用于外部配置控制)
- `optionalMode`: 可选模式
- `FORCE_ON`: 强制执行(如 DownloadStage、CleanupStage)
- `SUPPORT`: 支持外部控制(如 WatermarkStage、ImageEnhanceStage)
- `UNSUPPORT`: 不支持外部控制(如 RestoreOrientationStage)
- `defaultEnabled`: 默认是否启用
- `description`: 描述信息
**示例**:
```java
@StageConfig(
stageId = "watermark",
optionalMode = StageOptionalMode.SUPPORT,
description = "水印处理",
defaultEnabled = true
)
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
// ...
}
```
## Stage 列表
### 核心 Stage
| Stage | 职责 | Optional Mode | 执行条件 |
|-------|------|---------------|---------|
| DownloadStage | 从 URL 下载图片到本地 | FORCE_ON | 总是执行 |
| CleanupStage | 清理所有临时文件 | FORCE_ON | 总是执行(优先级 999) |
### 图片处理 Stage
| Stage | 职责 | Optional Mode | 执行条件 |
|-------|------|---------------|---------|
| ImageOrientationStage | 检测图片方向(横竖) | UNSUPPORT | 仅普通照片 |
| ConditionalRotateStage | 条件性旋转(竖图变横图) | UNSUPPORT | 仅竖图 |
| RestoreOrientationStage | 恢复图片方向(横图变回竖图) | UNSUPPORT | 需要旋转的照片 |
| WatermarkStage | 添加水印 | SUPPORT | 仅普通照片 |
| PuzzleBorderStage | 处理拼图边框 | UNSUPPORT | 仅拼图 |
| ImageEnhanceStage | 图像增强(超分) | SUPPORT | 可配置 |
| ImageQualityCheckStage | 图像质量检测 | SUPPORT | 仅普通照片 |
### 存储 Stage
| Stage | 职责 | Optional Mode | 执行条件 |
|-------|------|---------------|---------|
| UploadStage | 上传图片到存储服务 | FORCE_ON | 总是执行 |
| SourcePhotoUpdateStage | 更新源图片记录 | UNSUPPORT | 总是执行 |
## 典型管线示例
### 1. 打印照片处理管线
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("PrintPipeline")
.addStage(new DownloadStage()) // 1. 下载
.addStage(new ImageOrientationStage()) // 2. 检测方向
.addStage(new ConditionalRotateStage()) // 3. 旋转竖图
.addStage(new WatermarkStage()) // 4. 添加水印
.addStage(new RestoreOrientationStage()) // 5. 恢复方向
.addStage(new UploadStage()) // 6. 上传
.addStage(new CleanupStage()) // 7. 清理
.build();
```
### 2. 拼图处理管线
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("PuzzlePipeline")
.addStage(new DownloadStage()) // 1. 下载
.addStage(new PuzzleBorderStage()) // 2. 添加拼图边框
.addStage(new UploadStage()) // 3. 上传
.addStage(new CleanupStage()) // 4. 清理
.build();
```
### 3. 超分辨率增强管线
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("SuperResolutionPipeline")
.addStage(new DownloadStage()) // 1. 下载
.addStage(new ImageEnhanceStage(config)) // 2. 超分增强
.addStage(new SourcePhotoUpdateStage(sourceService, sourceId)) // 3. 更新记录
.addStage(new CleanupStage()) // 4. 清理
.build();
```
### 4. 带质量检测的管线(动态 Stage)
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("QualityCheckPipeline")
.addStage(new DownloadStage()) // 1. 下载
.addStage(new ImageQualityCheckStage()) // 2. 质量检测(可能动态添加 ImageEnhanceStage)
.addStage(new WatermarkStage()) // 3. 水印
.addStage(new UploadStage()) // 4. 上传
.addStage(new CleanupStage()) // 5. 清理
.build();
```
## 扩展指南
### 如何创建新的 Stage
1. **继承 AbstractPipelineStage**:
```java
@Slf4j
@StageConfig(
stageId = "my_stage",
optionalMode = StageOptionalMode.SUPPORT,
description = "我的自定义 Stage",
defaultEnabled = true
)
public class MyStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "MyStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
// 实现条件判断
return context.getImageType() == ImageType.NORMAL_PHOTO;
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
File currentFile = context.getCurrentFile();
try {
// 1. 获取输入文件
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
// 2. 处理图片
File outputFile = context.getTempFileManager()
.createTempFile("my_output", ".jpg");
// 执行具体处理逻辑
doSomethingWithImage(currentFile, outputFile);
// 3. 更新 Context
context.updateProcessedFile(outputFile);
// 4. 返回成功结果
return StageResult.success("处理完成");
} catch (Exception e) {
log.error("处理失败", e);
return StageResult.failed("处理失败: " + e.getMessage(), e);
}
}
private void doSomethingWithImage(File input, File output) {
// 具体实现
}
}
```
2. **添加到管线**:
```java
pipeline.addStage(new MyStage());
```
### 如何实现降级策略
参考 `WatermarkStage` 的实现,使用循环尝试多种方案:
```java
@Override
protected StageResult doExecute(PhotoProcessContext context) {
List<Strategy> strategies = Arrays.asList(
Strategy.ADVANCED,
Strategy.STANDARD,
Strategy.BASIC
);
for (int i = 0; i < strategies.size(); i++) {
Strategy strategy = strategies.get(i);
try {
StageResult result = tryStrategy(context, strategy);
if (i > 0) {
// 使用了降级策略
return StageResult.degraded("降级到: " + strategy);
}
return result;
} catch (Exception e) {
log.warn("策略 {} 失败: {}", strategy, e.getMessage());
}
}
// 所有策略都失败
return StageResult.degraded("所有策略失败,跳过处理");
}
```
### 如何动态添加后续 Stage
参考 `ImageQualityCheckStage` 的实现:
```java
@Override
protected StageResult doExecute(PhotoProcessContext context) {
boolean needsEnhancement = checkQuality(context.getCurrentFile());
if (needsEnhancement) {
ImageEnhanceStage enhanceStage = new ImageEnhanceStage();
return StageResult.successWithNext("质量不佳,添加增强", enhanceStage);
}
return StageResult.success("质量良好");
}
```
## 最佳实践
### 1. Context 管理
- **总是使用静态工厂方法或 Builder**: 避免直接调用构造函数
- **及时清理临时文件**: 在 finally 块或使用 CleanupStage
- **使用回调更新外部状态**: 通过 `resultUrlCallback` 而非直接操作业务对象
### 2. Stage 设计
- **单一职责**: 每个 Stage 只做一件事
- **可组合**: Stage 应该可以灵活组合
- **幂等性**: 相同输入应产生相同输出
- **异常处理**: 捕获异常并返回 `StageResult.failed()``StageResult.degraded()`
- **日志记录**: 在关键操作处记录 debug/info 日志
### 3. 管线构建
- **CleanupStage 总是最后**: 确保临时文件总是被清理
- **DownloadStage 总是最前**: 确保有本地文件可用
- **合理使用 optionalMode**:
- 必需的 Stage 使用 `FORCE_ON`
- 可选的 Stage 使用 `SUPPORT`
- 内部逻辑控制的 Stage 使用 `UNSUPPORT`
### 4. 性能优化
- **复用 TempFileManager**: 自动管理临时文件生命周期
- **避免重复下载**: 使用 `context.getCurrentFile()` 获取最新文件
- **及时更新 processedFile**: 使用 `context.updateProcessedFile()` 通知下一个 Stage
### 5. 错误处理
- **失败即停止**: 使用 `StageResult.failed()` 终止管线
- **降级继续执行**: 使用 `StageResult.degraded()` 记录问题但继续执行
- **跳过非关键 Stage**: 使用 `StageResult.skipped()` 表示条件不满足
- **携带异常信息**: `StageResult.failed(message, exception)` 便于排查问题
## 配置控制
### 1. 景区级配置
通过 `ScenicConfigManager` 加载景区配置:
```java
context.setScenicConfigManager(scenicConfigManager);
```
Stage 内部可以获取配置:
```java
ScenicConfigManager config = context.getScenicConfigManager();
Boolean enabled = config.getBoolean("my_feature_enabled");
String value = config.getString("my_setting");
```
### 2. 请求级配置
通过 `loadStageConfig()` 加载请求参数:
```java
Map<String, Boolean> stageConfig = new HashMap<>();
stageConfig.put("watermark", false); // 禁用水印
stageConfig.put("image_enhance", true); // 启用增强
context.loadStageConfig(scenicConfigManager, stageConfig);
```
### 3. Stage 启用判断
`AbstractPipelineStage.shouldExecute()` 中自动处理:
```java
// 对于 optionalMode = SUPPORT 的 Stage
@StageConfig(
stageId = "watermark",
optionalMode = StageOptionalMode.SUPPORT,
defaultEnabled = true
)
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
// 如果外部配置禁用了 watermark,则不执行
}
```
## 测试指南
### 单元测试结构
```java
@Test
public void testStageSuccess() {
// 1. 准备 Context
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-1")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
// 2. 创建 Stage
MyStage stage = new MyStage();
// 3. 执行
StageResult result = stage.execute(context);
// 4. 断言
assertTrue(result.isSuccess());
assertNotNull(context.getCurrentFile());
}
@Test
public void testStageSkipped() {
// 测试条件不满足时跳过
}
@Test
public void testStageFailed() {
// 测试异常情况
}
@Test
public void testStageDegraded() {
// 测试降级情况
}
```
### 管线集成测试
```java
@Test
public void testPipelineExecution() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("TestPipeline")
.addStage(new DownloadStage())
.addStage(new WatermarkStage())
.addStage(new UploadStage())
.addStage(new CleanupStage())
.build();
PhotoProcessContext context = createTestContext();
boolean success = pipeline.execute(context);
assertTrue(success);
assertNotNull(context.getResultUrl());
}
```
## 常见问题
### Q: 如何跳过某个 Stage?
A: 有三种方式:
1. 使用外部配置(适用于 `optionalMode = SUPPORT` 的 Stage)
2.`shouldExecuteByBusinessLogic()` 中返回 false
3. 构建管线时不添加该 Stage
### Q: 如何在运行时决定是否添加某个 Stage?
A: 使用 `PipelineBuilder.addStageIf()`:
```java
builder.addStageIf(needWatermark, new WatermarkStage());
```
或者使用动态 Stage 添加(`StageResult.successWithNext()`)。
### Q: 如何处理 Stage 执行失败?
A: 返回 `StageResult.failed()`,管线会立即终止。如果希望继续执行,使用 `StageResult.degraded()`
### Q: 临时文件什么时候被清理?
A: 由 `CleanupStage` 负责,通常放在管线最后。也可以手动调用 `context.cleanup()`
### Q: 如何获取最终处理结果?
A: 使用 `context.getResultUrl()`,或者在构建 Context 时提供 `resultUrlCallback`
### Q: 如何支持新的图片来源或场景?
A: 扩展 `ImageSource``PipelineScene` 枚举,然后在 Stage 中添加相应的判断逻辑。
## 架构演进
### 已实现的特性
- ✅ 责任链模式的基础管线框架
- ✅ Builder 模式的管线构建
- ✅ 动态 Stage 添加
- ✅ 多级降级策略
- ✅ 配置驱动的 Stage 控制
- ✅ Context 与业务模型解耦
- ✅ 类型安全的图片分类
### 未来可能的改进
- 🔄 支持并行执行某些 Stage
- 🔄 支持 Stage 执行超时控制
- 🔄 支持管线执行的暂停/恢复
- 🔄 支持更细粒度的性能监控
- 🔄 支持 Stage 执行的重试机制
- 🔄 支持管线执行的可视化追踪
## 相关文档
- [ImageUtils 工具类](../utils/ImageUtils.java)
- [StorageFactory 存储工厂](../storage/StorageFactory.java)
- [WatermarkFactory 水印工厂](../image/watermark/ImageWatermarkFactory.java)
## 维护者
- 图片处理管线 - 基础架构团队

View File

@@ -29,7 +29,9 @@ public class StageResult {
this.status = status;
this.message = message;
this.exception = exception;
this.nextStages = nextStages != null ? new ArrayList<>(nextStages) : Collections.emptyList();
this.nextStages = nextStages != null
? Collections.unmodifiableList(new ArrayList<>(nextStages))
: Collections.emptyList();
}
public static StageResult success() {

View File

@@ -0,0 +1,232 @@
package com.ycwl.basic.image.pipeline.core;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.PipelineScene;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
/**
* PhotoProcessContext 单元测试
*/
class PhotoProcessContextTest {
@Test
void testBuilder_WithRequiredFields() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
assertNotNull(context);
assertEquals("https://example.com/test.jpg", context.getOriginalUrl());
assertEquals(123L, context.getScenicId());
assertNotNull(context.getProcessId());
assertEquals(ImageType.NORMAL_PHOTO, context.getImageType());
}
@Test
void testBuilder_WithAllFields() {
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-123")
.originalUrl("https://example.com/test.jpg")
.scenicId(456L)
.imageType(ImageType.PUZZLE)
.source(ImageSource.IPC)
.scene(PipelineScene.IMAGE_PRINT)
.build();
assertEquals("test-123", context.getProcessId());
assertEquals("https://example.com/test.jpg", context.getOriginalUrl());
assertEquals(456L, context.getScenicId());
assertEquals(ImageType.PUZZLE, context.getImageType());
assertEquals(ImageSource.IPC, context.getSource());
assertEquals(PipelineScene.IMAGE_PRINT, context.getScene());
}
@Test
void testBuilder_MissingOriginalUrl_ShouldThrow() {
PhotoProcessContext.Builder builder = PhotoProcessContext.builder()
.scenicId(123L);
Exception exception = assertThrows(IllegalArgumentException.class, builder::build);
assertEquals("originalUrl is required", exception.getMessage());
}
@Test
void testBuilder_MissingScenicId_ShouldThrow() {
PhotoProcessContext.Builder builder = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg");
Exception exception = assertThrows(IllegalArgumentException.class, builder::build);
assertEquals("scenicId is required", exception.getMessage());
}
@Test
void testBuilder_AutoGenerateProcessId() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
assertNotNull(context.getProcessId());
assertFalse(context.getProcessId().isEmpty());
}
@Test
void testForSuperResolution() {
PhotoProcessContext context = PhotoProcessContext.forSuperResolution(
12345L,
"https://example.com/photo.jpg",
678L
);
assertEquals("12345", context.getProcessId());
assertEquals("https://example.com/photo.jpg", context.getOriginalUrl());
assertEquals(678L, context.getScenicId());
assertEquals(ImageType.NORMAL_PHOTO, context.getImageType());
assertEquals(ImageSource.IPC, context.getSource());
assertEquals(PipelineScene.SOURCE_PHOTO_SUPER_RESOLUTION, context.getScene());
}
@Test
void testResultUrlCallback() {
AtomicReference<String> capturedUrl = new AtomicReference<>();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.resultUrlCallback(capturedUrl::set)
.build();
context.setResultUrl("https://cdn.example.com/result.jpg");
assertEquals("https://cdn.example.com/result.jpg", context.getResultUrl());
assertEquals("https://cdn.example.com/result.jpg", capturedUrl.get());
}
@Test
void testResultUrlCallback_WithoutCallback() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
assertDoesNotThrow(() -> {
context.setResultUrl("https://cdn.example.com/result.jpg");
});
assertEquals("https://cdn.example.com/result.jpg", context.getResultUrl());
}
@Test
void testGetCurrentFile_NoProcessedFile() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
File originalFile = new File("original.jpg");
context.setOriginalFile(originalFile);
assertEquals(originalFile, context.getCurrentFile());
}
@Test
void testGetCurrentFile_WithProcessedFile() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
File originalFile = new File("original.jpg");
File processedFile = new File("processed.jpg");
context.setOriginalFile(originalFile);
context.setProcessedFile(processedFile);
assertEquals(processedFile, context.getCurrentFile());
}
@Test
void testLoadStageConfig() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
Map<String, Boolean> stageConfig = new HashMap<>();
stageConfig.put("watermark", false);
stageConfig.put("enhance", true);
context.loadStageConfig(null, stageConfig);
assertFalse(context.isStageEnabled("watermark", true));
assertTrue(context.isStageEnabled("enhance", false));
}
@Test
void testIsStageEnabled_WithDefault() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
assertTrue(context.isStageEnabled("unknown_stage", true));
assertFalse(context.isStageEnabled("unknown_stage", false));
}
@Test
void testIsStageEnabled_WithConfig() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
Map<String, Boolean> config = new HashMap<>();
config.put("watermark", false);
context.loadStageConfig(null, config);
assertFalse(context.isStageEnabled("watermark", true));
}
@Test
void testTempFileManager() {
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-temp")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
assertNotNull(context.getTempFileManager());
}
@Test
void testDefaultImageType() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
assertEquals(ImageType.NORMAL_PHOTO, context.getImageType());
}
@Test
void testSetImageType() {
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
assertEquals(ImageType.PUZZLE, context.getImageType());
}
}

View File

@@ -0,0 +1,120 @@
package com.ycwl.basic.image.pipeline.core;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* PipelineBuilder 单元测试
*/
class PipelineBuilderTest {
@Test
void testBuildEmptyPipeline_ShouldThrowException() {
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
Exception exception = assertThrows(IllegalStateException.class, builder::build);
assertEquals("管线至少需要一个Stage", exception.getMessage());
}
@Test
void testBuildWithDefaultName() {
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
builder.addStage(new MockStage("stage1"));
Pipeline<PhotoProcessContext> pipeline = builder.build();
assertEquals("DefaultPipeline", pipeline.getName());
assertEquals(1, pipeline.getStageCount());
}
@Test
void testBuildWithCustomName() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("CustomPipeline")
.addStage(new MockStage("stage1"))
.build();
assertEquals("CustomPipeline", pipeline.getName());
}
@Test
void testAddStage() {
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
builder.addStage(new MockStage("stage1"));
builder.addStage(new MockStage("stage2"));
Pipeline<PhotoProcessContext> pipeline = builder.build();
assertEquals(2, pipeline.getStageCount());
}
@Test
void testAddStageWithNull_ShouldIgnore() {
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
builder.addStage(new MockStage("stage1"));
builder.addStage(null);
builder.addStage(new MockStage("stage2"));
Pipeline<PhotoProcessContext> pipeline = builder.build();
assertEquals(2, pipeline.getStageCount());
}
@Test
void testAddStageIf_WhenTrue() {
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
builder.addStageIf(true, new MockStage("stage1"));
Pipeline<PhotoProcessContext> pipeline = builder.build();
assertEquals(1, pipeline.getStageCount());
}
@Test
void testAddStageIf_WhenFalse() {
PipelineBuilder<PhotoProcessContext> builder = new PipelineBuilder<>();
builder.addStage(new MockStage("required"));
builder.addStageIf(false, new MockStage("optional"));
Pipeline<PhotoProcessContext> pipeline = builder.build();
assertEquals(1, pipeline.getStageCount());
assertTrue(pipeline.getStageNames().contains("MockStage-required"));
assertFalse(pipeline.getStageNames().contains("MockStage-optional"));
}
@Test
void testFluentAPI() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>()
.name("FluentPipeline")
.addStage(new MockStage("stage1"))
.addStage(new MockStage("stage2"))
.addStageIf(true, new MockStage("stage3"))
.build();
assertEquals("FluentPipeline", pipeline.getName());
assertEquals(3, pipeline.getStageCount());
}
/**
* Mock Stage for testing
*/
private static class MockStage extends AbstractPipelineStage<PhotoProcessContext> {
private final String id;
MockStage(String id) {
this.id = id;
}
@Override
public String getName() {
return "MockStage-" + id;
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
return StageResult.success();
}
}
}

View File

@@ -0,0 +1,185 @@
package com.ycwl.basic.image.pipeline.core;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pipeline 单元测试
*/
class PipelineTest {
@Test
void testPipelineExecution_AllSuccess() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
.addStage(new MockStage("stage1", StageResult.success("Stage1完成")))
.addStage(new MockStage("stage2", StageResult.success("Stage2完成")))
.addStage(new MockStage("stage3", StageResult.success("Stage3完成")))
.build();
PhotoProcessContext context = createTestContext();
boolean success = pipeline.execute(context);
assertTrue(success);
}
@Test
void testPipelineExecution_WithSkippedStage() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
.addStage(new MockStage("stage1", StageResult.success()))
.addStage(new ConditionalStage("stage2", false))
.addStage(new MockStage("stage3", StageResult.success()))
.build();
PhotoProcessContext context = createTestContext();
boolean success = pipeline.execute(context);
assertTrue(success);
}
@Test
void testPipelineExecution_WithDegradedStage() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
.addStage(new MockStage("stage1", StageResult.success()))
.addStage(new MockStage("stage2", StageResult.degraded("降级执行")))
.addStage(new MockStage("stage3", StageResult.success()))
.build();
PhotoProcessContext context = createTestContext();
boolean success = pipeline.execute(context);
assertTrue(success);
}
@Test
void testPipelineExecution_WithFailedStage() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
.addStage(new MockStage("stage1", StageResult.success()))
.addStage(new MockStage("stage2", StageResult.failed("执行失败")))
.addStage(new MockStage("stage3", StageResult.success()))
.build();
PhotoProcessContext context = createTestContext();
boolean success = pipeline.execute(context);
assertFalse(success);
}
@Test
void testPipelineExecution_WithDynamicStageAddition() {
MockStage dynamicStage = new MockStage("dynamic", StageResult.success("动态Stage完成"));
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("TestPipeline")
.addStage(new MockStage("stage1", StageResult.success()))
.addStage(new MockStage("stage2", StageResult.successWithNext("添加动态Stage", dynamicStage)))
.addStage(new MockStage("stage3", StageResult.success()))
.build();
PhotoProcessContext context = createTestContext();
boolean success = pipeline.execute(context);
assertTrue(success);
assertEquals(4, pipeline.getStageCount()); // 原3个 + 动态添加1个
}
@Test
void testPipelineName() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("CustomPipeline")
.addStage(new MockStage("stage1", StageResult.success()))
.build();
assertEquals("CustomPipeline", pipeline.getName());
}
@Test
void testGetStageCount() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>()
.addStage(new MockStage("stage1", StageResult.success()))
.addStage(new MockStage("stage2", StageResult.success()))
.addStage(new MockStage("stage3", StageResult.success()))
.build();
assertEquals(3, pipeline.getStageCount());
}
@Test
void testGetStageNames() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>()
.addStage(new MockStage("stage1", StageResult.success()))
.addStage(new MockStage("stage2", StageResult.success()))
.build();
var names = pipeline.getStageNames();
assertEquals(2, names.size());
assertTrue(names.contains("MockStage-stage1"));
assertTrue(names.contains("MockStage-stage2"));
}
/**
* 创建测试用的 Context
*/
private PhotoProcessContext createTestContext() {
return PhotoProcessContext.builder()
.processId("test-pipeline")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
}
/**
* Mock Stage for testing
*/
private static class MockStage extends AbstractPipelineStage<PhotoProcessContext> {
private final String id;
private final StageResult result;
MockStage(String id, StageResult result) {
this.id = id;
this.result = result;
}
@Override
public String getName() {
return "MockStage-" + id;
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
return result;
}
}
/**
* Conditional Stage for testing
*/
private static class ConditionalStage extends AbstractPipelineStage<PhotoProcessContext> {
private final String id;
private final boolean shouldExecute;
ConditionalStage(String id, boolean shouldExecute) {
this.id = id;
this.shouldExecute = shouldExecute;
}
@Override
public String getName() {
return "ConditionalStage-" + id;
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return shouldExecute;
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
return StageResult.success();
}
}
}

View File

@@ -0,0 +1,160 @@
package com.ycwl.basic.image.pipeline.core;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* StageResult 单元测试
*/
class StageResultTest {
@Test
void testSuccess() {
StageResult result = StageResult.success();
assertEquals(StageResult.Status.SUCCESS, result.getStatus());
assertTrue(result.isSuccess());
assertFalse(result.isFailed());
assertFalse(result.isSkipped());
assertFalse(result.isDegraded());
assertTrue(result.canContinue());
}
@Test
void testSuccessWithMessage() {
StageResult result = StageResult.success("处理完成");
assertTrue(result.isSuccess());
assertEquals("处理完成", result.getMessage());
}
@Test
void testSuccessWithNextStages() {
MockStage nextStage1 = new MockStage("next1");
MockStage nextStage2 = new MockStage("next2");
StageResult result = StageResult.successWithNext("添加后续Stage", nextStage1, nextStage2);
assertTrue(result.isSuccess());
assertEquals("添加后续Stage", result.getMessage());
assertEquals(2, result.getNextStages().size());
assertEquals("MockStage-next1", result.getNextStages().get(0).getName());
assertEquals("MockStage-next2", result.getNextStages().get(1).getName());
}
@Test
void testSuccessWithNextStagesList() {
List<PipelineStage<?>> stages = List.of(
new MockStage("next1"),
new MockStage("next2")
);
StageResult result = StageResult.successWithNext("添加列表", stages);
assertEquals(2, result.getNextStages().size());
}
@Test
void testSkipped() {
StageResult result = StageResult.skipped();
assertEquals(StageResult.Status.SKIPPED, result.getStatus());
assertTrue(result.isSkipped());
assertTrue(result.canContinue());
assertEquals("条件不满足,跳过执行", result.getMessage());
}
@Test
void testSkippedWithReason() {
StageResult result = StageResult.skipped("图片类型不匹配");
assertTrue(result.isSkipped());
assertEquals("图片类型不匹配", result.getMessage());
}
@Test
void testFailed() {
StageResult result = StageResult.failed("下载失败");
assertEquals(StageResult.Status.FAILED, result.getStatus());
assertTrue(result.isFailed());
assertFalse(result.canContinue());
assertEquals("下载失败", result.getMessage());
}
@Test
void testFailedWithException() {
Exception exception = new RuntimeException("网络错误");
StageResult result = StageResult.failed("下载失败", exception);
assertTrue(result.isFailed());
assertEquals("下载失败", result.getMessage());
assertEquals(exception, result.getException());
}
@Test
void testDegraded() {
StageResult result = StageResult.degraded("使用备用方案");
assertEquals(StageResult.Status.DEGRADED, result.getStatus());
assertTrue(result.isDegraded());
assertTrue(result.canContinue());
assertEquals("使用备用方案", result.getMessage());
}
@Test
void testCanContinue_Success() {
assertTrue(StageResult.success().canContinue());
}
@Test
void testCanContinue_Skipped() {
assertTrue(StageResult.skipped().canContinue());
}
@Test
void testCanContinue_Degraded() {
assertTrue(StageResult.degraded("降级").canContinue());
}
@Test
void testCanContinue_Failed() {
assertFalse(StageResult.failed("失败").canContinue());
}
@Test
void testNextStagesImmutable() {
MockStage stage1 = new MockStage("stage1");
StageResult result = StageResult.successWithNext("test", stage1);
List<PipelineStage<?>> nextStages = result.getNextStages();
assertThrows(UnsupportedOperationException.class, () -> {
nextStages.add(new MockStage("stage2"));
});
}
/**
* Mock Stage for testing
*/
private static class MockStage extends AbstractPipelineStage<PhotoProcessContext> {
private final String id;
MockStage(String id) {
this.id = id;
}
@Override
public String getName() {
return "MockStage-" + id;
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
return StageResult.success();
}
}
}

View File

@@ -0,0 +1,86 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* CleanupStage 单元测试
*/
class CleanupStageTest {
@Test
void testGetName() {
CleanupStage stage = new CleanupStage();
assertEquals("CleanupStage", stage.getName());
}
@Test
void testGetPriority() {
CleanupStage stage = new CleanupStage();
assertEquals(999, stage.getPriority());
}
@Test
void testShouldExecute_AlwaysTrue() {
CleanupStage stage = new CleanupStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
assertTrue(stage.shouldExecute(context));
}
@Test
void testExecute_Success() {
CleanupStage stage = new CleanupStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-cleanup")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
StageResult result = stage.execute(context);
assertTrue(result.isSuccess());
assertNotNull(result.getMessage());
assertTrue(result.getMessage().contains("已清理"));
}
@Test
void testExecute_ForAllImageTypes() {
CleanupStage stage = new CleanupStage();
// 测试普通照片
PhotoProcessContext normalContext = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
assertTrue(stage.execute(normalContext).isSuccess());
// 测试拼图
PhotoProcessContext puzzleContext = PhotoProcessContext.builder()
.originalUrl("https://example.com/puzzle.png")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
assertTrue(stage.execute(puzzleContext).isSuccess());
// 测试手机上传
PhotoProcessContext mobileContext = PhotoProcessContext.builder()
.originalUrl("https://example.com/mobile.jpg")
.scenicId(123L)
.imageType(ImageType.MOBILE_UPLOAD)
.build();
assertTrue(stage.execute(mobileContext).isSuccess());
}
}

View File

@@ -0,0 +1,84 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* ConditionalRotateStage 单元测试
*/
class ConditionalRotateStageTest {
@Test
void testGetName() {
ConditionalRotateStage stage = new ConditionalRotateStage();
assertEquals("ConditionalRotateStage", stage.getName());
}
@Test
void testShouldExecute_NormalPhotoAndPortrait() {
ConditionalRotateStage stage = new ConditionalRotateStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
context.setLandscape(false); // 竖图
assertTrue(stage.shouldExecute(context));
}
@Test
void testShouldExecute_NormalPhotoButLandscape_ShouldSkip() {
ConditionalRotateStage stage = new ConditionalRotateStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
context.setLandscape(true); // 横图
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_Puzzle_ShouldSkip() {
ConditionalRotateStage stage = new ConditionalRotateStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/puzzle.png")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
context.setLandscape(false);
assertFalse(stage.shouldExecute(context));
}
@Test
void testExecute_NoCurrentFile_ShouldFail() {
ConditionalRotateStage stage = new ConditionalRotateStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
context.setLandscape(false);
StageResult result = stage.execute(context);
assertTrue(result.isFailed());
assertEquals("当前文件不存在", result.getMessage());
}
}

View File

@@ -0,0 +1,420 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/**
* ImageEnhanceStage 单元测试
*/
class ImageEnhanceStageTest {
@Test
void testGetName() {
ImageEnhanceStage stage = new ImageEnhanceStage();
assertEquals("ImageEnhanceStage", stage.getName());
}
@Test
void testDefaultConstructor_ShouldSetDefaultConfig() {
ImageEnhanceStage stage = new ImageEnhanceStage();
BceEnhancerConfig config = stage.getEnhancerConfig();
assertNotNull(config);
assertEquals("119554288", config.getAppId());
assertEquals("OX6QoijgKio3eVtA0PiUVf7f", config.getApiKey());
assertEquals("dYatXReVriPeiktTjUblhfubpcmYfuMk", config.getSecretKey());
assertEquals(1.0f, config.getQps());
}
@Test
void testConstructorWithConfig() {
BceEnhancerConfig customConfig = new BceEnhancerConfig();
customConfig.setAppId("custom-app-id");
customConfig.setApiKey("custom-api-key");
customConfig.setSecretKey("custom-secret-key");
customConfig.setQps(2.0f);
ImageEnhanceStage stage = new ImageEnhanceStage(customConfig);
BceEnhancerConfig config = stage.getEnhancerConfig();
assertEquals("custom-app-id", config.getAppId());
assertEquals("custom-api-key", config.getApiKey());
assertEquals("custom-secret-key", config.getSecretKey());
assertEquals(2.0f, config.getQps());
}
@Test
void testShouldExecute_IPCSource() {
ImageEnhanceStage stage = new ImageEnhanceStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 需要显式启用 image_enhance
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
assertTrue(stage.shouldExecute(context));
}
@Test
void testShouldExecute_PhoneSource_ShouldSkip() {
ImageEnhanceStage stage = new ImageEnhanceStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.PHONE)
.build();
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_CameraSource_ShouldSkip() {
ImageEnhanceStage stage = new ImageEnhanceStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.CAMERA)
.build();
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_UnknownSource_ShouldSkip() {
ImageEnhanceStage stage = new ImageEnhanceStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.UNKNOWN)
.build();
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_NullSource_ShouldSkip() {
ImageEnhanceStage stage = new ImageEnhanceStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_DisabledByConfig_ShouldSkip() {
ImageEnhanceStage stage = new ImageEnhanceStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 默认不启用,不加载配置
assertFalse(stage.shouldExecute(context));
}
@Test
void testExecute_NoCurrentFile_ShouldFail() {
ImageEnhanceStage stage = new ImageEnhanceStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
StageResult result = stage.execute(context);
assertTrue(result.isFailed());
assertEquals("当前文件不存在", result.getMessage());
}
@Test
void testExecute_InvalidConfig_ShouldSkip() {
// 创建无效配置(使用 TODO 占位符)
BceEnhancerConfig invalidConfig = new BceEnhancerConfig();
invalidConfig.setAppId("TODO_YOUR_APP_ID");
invalidConfig.setApiKey("TODO_YOUR_API_KEY");
invalidConfig.setSecretKey("TODO_YOUR_SECRET_KEY");
ImageEnhanceStage stage = new ImageEnhanceStage(invalidConfig);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-invalid-config")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 创建一个临时文件
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
context.setOriginalFile(tempFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过增强", result.getMessage());
}
@Test
void testExecute_NullConfig_ShouldSkip(@TempDir Path tempDir) throws Exception {
ImageEnhanceStage stage = new ImageEnhanceStage(null);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-null-config")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
// 创建一个临时文件
File testFile = tempDir.resolve("test.jpg").toFile();
try (FileOutputStream fos = new FileOutputStream(testFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
}
context.setOriginalFile(testFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过增强", result.getMessage());
}
@Test
void testExecute_NullAppId_ShouldSkip(@TempDir Path tempDir) throws Exception {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId(null);
config.setApiKey("valid-api-key");
config.setSecretKey("valid-secret-key");
ImageEnhanceStage stage = new ImageEnhanceStage(config);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-null-appid")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
File testFile = tempDir.resolve("test.jpg").toFile();
try (FileOutputStream fos = new FileOutputStream(testFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
}
context.setOriginalFile(testFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过增强", result.getMessage());
}
@Test
void testExecute_TodoPlaceholderAppId_ShouldSkip(@TempDir Path tempDir) throws Exception {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId("TODO_APP_ID");
config.setApiKey("valid-api-key");
config.setSecretKey("valid-secret-key");
ImageEnhanceStage stage = new ImageEnhanceStage(config);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-todo-appid")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
File testFile = tempDir.resolve("test.jpg").toFile();
try (FileOutputStream fos = new FileOutputStream(testFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
}
context.setOriginalFile(testFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("配置不完整,跳过增强", result.getMessage());
}
@Test
void testSetAndGetEnhancerConfig() {
ImageEnhanceStage stage = new ImageEnhanceStage();
BceEnhancerConfig newConfig = new BceEnhancerConfig();
newConfig.setAppId("new-app-id");
newConfig.setApiKey("new-api-key");
newConfig.setSecretKey("new-secret-key");
newConfig.setQps(3.0f);
stage.setEnhancerConfig(newConfig);
BceEnhancerConfig retrieved = stage.getEnhancerConfig();
assertEquals("new-app-id", retrieved.getAppId());
assertEquals("new-api-key", retrieved.getApiKey());
assertEquals("new-secret-key", retrieved.getSecretKey());
assertEquals(3.0f, retrieved.getQps());
}
@Test
void testExecute_AllImageTypes_WithIPCSource(@TempDir Path tempDir) throws Exception {
ImageEnhanceStage stage = new ImageEnhanceStage();
// 测试普通照片
testImageType(stage, tempDir, ImageType.NORMAL_PHOTO);
// 测试拼图
testImageType(stage, tempDir, ImageType.PUZZLE);
// 测试手机上传
testImageType(stage, tempDir, ImageType.MOBILE_UPLOAD);
}
private void testImageType(ImageEnhanceStage stage, Path tempDir, ImageType imageType) throws Exception {
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-" + imageType.getCode())
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(imageType)
.source(ImageSource.IPC)
.build();
File testFile = tempDir.resolve("test-" + imageType.getCode() + ".jpg").toFile();
try (FileOutputStream fos = new FileOutputStream(testFile)) {
fos.write(new byte[]{1, 2, 3, 4, 5});
}
context.setOriginalFile(testFile);
context.loadStageConfig(null, java.util.Map.of("image_enhance", true));
// 应该可以执行(因为 source 是 IPC)
assertTrue(stage.shouldExecute(context));
}
@Test
void testConfigValidation_AllTodoFields() {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId("TODO_APP_ID");
config.setApiKey("TODO_API_KEY");
config.setSecretKey("TODO_SECRET_KEY");
ImageEnhanceStage stage = new ImageEnhanceStage(config);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-all-todo")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
context.setOriginalFile(tempFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
}
@Test
void testConfigValidation_MixedTodoAndValid() {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId("valid-app-id");
config.setApiKey("TODO_API_KEY");
config.setSecretKey("valid-secret-key");
ImageEnhanceStage stage = new ImageEnhanceStage(config);
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-mixed-todo")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.source(ImageSource.IPC)
.build();
File tempFile = context.getTempFileManager().createTempFile("test", ".jpg");
context.setOriginalFile(tempFile);
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
}
/**
* 测试说明:
*
* 由于 ImageEnhanceStage 依赖外部 API (BceImageEnhancer),
* 以下测试场景需要 Mock 或集成测试环境:
*
* 1. testExecute_Success_ValidConfig()
* - 模拟成功的图像增强调用
* - 验证增强后文件大小变化
* - 验证 context.updateProcessedFile() 被调用
*
* 2. testExecute_ApiReturnsNull_ShouldDegrade()
* - 模拟 API 返回 null
* - 验证返回 StageResult.degraded()
*
* 3. testExecute_ApiReturnsEmptyFile_ShouldDegrade()
* - 模拟 API 返回空文件
* - 验证返回 StageResult.degraded()
*
* 4. testExecute_ApiThrowsException_ShouldDegrade()
* - 模拟 API 抛出异常
* - 验证返回 StageResult.degraded() 而不是 failed()
*
* 5. testExecute_SaveFileFails_ShouldDegrade()
* - 模拟文件保存失败
* - 验证返回 StageResult.degraded()
*
* 这些测试需要使用 Mockito 或类似的 Mock 框架来模拟 BceImageEnhancer 的行为。
*
* 示例(使用 Mockito):
*
* @Test
* void testExecute_Success_ValidConfig() {
* // 创建 Mock 的 BceImageEnhancer
* BceImageEnhancer mockEnhancer = mock(BceImageEnhancer.class);
*
* // 创建 Mock 的 MultipartFile
* MultipartFile mockEnhancedImage = mock(MultipartFile.class);
* when(mockEnhancedImage.isEmpty()).thenReturn(false);
* when(mockEnhancedImage.getBytes()).thenReturn(new byte[]{1, 2, 3, 4, 5, 6, 7, 8});
*
* // 配置 Mock 行为
* when(mockEnhancer.enhance(anyString())).thenReturn(mockEnhancedImage);
*
* // 测试逻辑...
* }
*/
}

View File

@@ -0,0 +1,75 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* ImageOrientationStage 单元测试
*/
class ImageOrientationStageTest {
@Test
void testGetName() {
ImageOrientationStage stage = new ImageOrientationStage();
assertEquals("ImageOrientationStage", stage.getName());
}
@Test
void testShouldExecute_NormalPhoto() {
ImageOrientationStage stage = new ImageOrientationStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
assertTrue(stage.shouldExecute(context));
}
@Test
void testShouldExecute_Puzzle_ShouldSkip() {
ImageOrientationStage stage = new ImageOrientationStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/puzzle.png")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_MobileUpload_ShouldSkip() {
ImageOrientationStage stage = new ImageOrientationStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/mobile.jpg")
.scenicId(123L)
.imageType(ImageType.MOBILE_UPLOAD)
.build();
assertFalse(stage.shouldExecute(context));
}
@Test
void testExecute_NoCurrentFile_ShouldFail() {
ImageOrientationStage stage = new ImageOrientationStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
StageResult result = stage.execute(context);
assertTrue(result.isFailed());
assertEquals("当前文件不存在", result.getMessage());
}
}

View File

@@ -0,0 +1,79 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* ImageQualityCheckStage 单元测试
*/
class ImageQualityCheckStageTest {
@Test
void testGetName() {
ImageQualityCheckStage stage = new ImageQualityCheckStage();
assertEquals("ImageQualityCheckStage", stage.getName());
}
@Test
void testShouldExecute_NormalPhoto() {
ImageQualityCheckStage stage = new ImageQualityCheckStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
// ImageQualityCheckStage 默认不启用,需要显式启用
context.loadStageConfig(null, java.util.Map.of("quality_check", true));
assertTrue(stage.shouldExecute(context));
}
@Test
void testShouldExecute_Puzzle_ShouldSkip() {
ImageQualityCheckStage stage = new ImageQualityCheckStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/puzzle.png")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_MobileUpload_ShouldSkip() {
ImageQualityCheckStage stage = new ImageQualityCheckStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/mobile.jpg")
.scenicId(123L)
.imageType(ImageType.MOBILE_UPLOAD)
.build();
assertFalse(stage.shouldExecute(context));
}
@Test
void testExecute_NoCurrentFile_ShouldFail() {
ImageQualityCheckStage stage = new ImageQualityCheckStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
StageResult result = stage.execute(context);
assertTrue(result.isFailed());
assertEquals("当前文件不存在", result.getMessage());
}
}

View File

@@ -0,0 +1,76 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* PuzzleBorderStage 单元测试
*/
class PuzzleBorderStageTest {
@Test
void testGetName() {
PuzzleBorderStage stage = new PuzzleBorderStage();
assertEquals("PuzzleBorderStage", stage.getName());
}
@Test
void testShouldExecute_Puzzle() {
PuzzleBorderStage stage = new PuzzleBorderStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/puzzle.png")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
assertTrue(stage.shouldExecute(context));
}
@Test
void testShouldExecute_NormalPhoto_ShouldSkip() {
PuzzleBorderStage stage = new PuzzleBorderStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_MobileUpload_ShouldSkip() {
PuzzleBorderStage stage = new PuzzleBorderStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/mobile.jpg")
.scenicId(123L)
.imageType(ImageType.MOBILE_UPLOAD)
.build();
assertFalse(stage.shouldExecute(context));
}
@Test
void testExecute_NoCurrentFile_ShouldFail() {
PuzzleBorderStage stage = new PuzzleBorderStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/puzzle.png")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
StageResult result = stage.execute(context);
assertTrue(result.isFailed());
assertEquals("当前文件不存在", result.getMessage());
}
}

View File

@@ -0,0 +1,84 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* RestoreOrientationStage 单元测试
*/
class RestoreOrientationStageTest {
@Test
void testGetName() {
RestoreOrientationStage stage = new RestoreOrientationStage();
assertEquals("RestoreOrientationStage", stage.getName());
}
@Test
void testShouldExecute_NormalPhotoAndNeedRotation() {
RestoreOrientationStage stage = new RestoreOrientationStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
context.setNeedRotation(true);
assertTrue(stage.shouldExecute(context));
}
@Test
void testShouldExecute_NormalPhotoButNoNeedRotation_ShouldSkip() {
RestoreOrientationStage stage = new RestoreOrientationStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
context.setNeedRotation(false);
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_Puzzle_ShouldSkip() {
RestoreOrientationStage stage = new RestoreOrientationStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/puzzle.png")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
context.setNeedRotation(true);
assertFalse(stage.shouldExecute(context));
}
@Test
void testExecute_NoCurrentFile_ShouldFail() {
RestoreOrientationStage stage = new RestoreOrientationStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
context.setNeedRotation(true);
StageResult result = stage.execute(context);
assertTrue(result.isFailed());
assertEquals("当前文件不存在", result.getMessage());
}
}

View File

@@ -0,0 +1,76 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* WatermarkStage 单元测试
*/
class WatermarkStageTest {
@Test
void testGetName() {
WatermarkStage stage = new WatermarkStage();
assertEquals("WatermarkStage", stage.getName());
}
@Test
void testShouldExecute_NormalPhoto() {
WatermarkStage stage = new WatermarkStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
assertTrue(stage.shouldExecute(context));
}
@Test
void testShouldExecute_Puzzle_ShouldSkip() {
WatermarkStage stage = new WatermarkStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/puzzle.png")
.scenicId(123L)
.imageType(ImageType.PUZZLE)
.build();
assertFalse(stage.shouldExecute(context));
}
@Test
void testShouldExecute_MobileUpload_ShouldSkip() {
WatermarkStage stage = new WatermarkStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/mobile.jpg")
.scenicId(123L)
.imageType(ImageType.MOBILE_UPLOAD)
.build();
assertFalse(stage.shouldExecute(context));
}
@Test
void testExecute_NoWatermarkType_ShouldSkip() {
WatermarkStage stage = new WatermarkStage();
PhotoProcessContext context = PhotoProcessContext.builder()
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.imageType(ImageType.NORMAL_PHOTO)
.build();
StageResult result = stage.execute(context);
assertTrue(result.isSkipped());
assertEquals("未配置水印", result.getMessage());
}
}