整體架構
- 前端:JCrop (jQuery 插件) 選取裁剪區域 → 獲取座標 (x, y, w, h)。
- 上傳:原圖上傳 → 後端等比縮放生成預覽圖 → 返回預覽 URL。
- 裁剪:前端在預覽圖上選區 → 提交座標 + 原圖標識 → 後端按原圖比例映射座標 → 裁剪生成最終圖片。
- 存儲:裁剪後圖片上傳文件服務器/對象存儲 → 返回業務 URL。
後端核心工具類
1. 圖片縮放(生成預覽圖)
public class ImageUtils {
public static BufferedImage scale(BufferedImage src, int targetWidth, int targetHeight, boolean keepRatio) {
int srcW = src.getWidth(), srcH = src.getHeight();
double scale = keepRatio ?
Math.min((double) targetWidth / srcW, (double) targetHeight / srcH) :
1.0;
int w = (int) (srcW * scale);
int h = (int) (srcH * scale);
BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = dst.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(src, 0, 0, w, h, null);
g.dispose();
return dst;
}
public static void writeJpg(BufferedImage img, OutputStream out, float quality) throws IOException {
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(quality); // 0.7-0.9 平衡體積/質量
writer.setOutput(new MemoryCacheImageOutputStream(out));
writer.write(null, new IIOImage(img, null, null), param);
writer.dispose();
}
}
2. 圖片裁剪(核心邏輯:座標映射)
public class CropUtils {
public record CropResult(File file, int width, int height) {}
public static CropResult crop(File srcFile, int previewW, int previewH,
int cropX, int cropY, int cropW, int cropH,
File outputDir, String prefix) throws IOException {
BufferedImage src = ImageIO.read(srcFile);
int origW = src.getWidth(), origH = src.getHeight();
// 座標映射:預覽圖座標 -> 原圖座標
double scaleX = (double) origW / previewW;
double scaleY = (double) origH / previewH;
int srcX = (int) Math.round(cropX * scaleX);
int srcY = (int) Math.round(cropY * scaleY);
int srcW = (int) Math.round(cropW * scaleX);
int srcH = (int) Math.round(cropH * scaleY);
// 邊界保護
srcX = Math.max(0, Math.min(srcX, origW - 1));
srcY = Math.max(0, Math.min(srcY, origH - 1));
srcW = Math.min(srcW, origW - srcX);
srcH = Math.min(srcH, origH - srcY);
// 執行裁剪
BufferedImage cropped = src.getSubimage(srcX, srcY, srcW, srcH);
// 輸出文件
File outFile = new File(outputDir, prefix + "_" + System.currentTimeMillis() + ".jpg");
ImageUtils.writeJpg(cropped, new FileOutputStream(outFile), 0.85f);
return new CropResult(outFile, srcW, srcH);
}
}
Spring MVC 控制器
@RestController
@RequestMapping("/api/avatar")
public class AvatarController {
private static final String TEMP_DIR = System.getProperty("java.io.tmpdir") + "/avatar_uploads/";
private final FileStorageService storageService;
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) {
// 1. 校驗文件類型/大小
if (!isImage(file)) return Result.error("僅支援 JPG/PNG");
if (file.getSize() > 5 * 1024 * 1024) return Result.error("文件不超過 5MB");
// 2. 保存原圖到臨時目錄
File tempDir = new File(TEMP_DIR);
tempDir.mkdirs();
String originalName = UUID.randomUUID() + "_" + file.getOriginalFilename();
File originalFile = new File(tempDir, originalName);
file.transferTo(originalFile);
// 3. 生成預覽圖(寬 400px 等比縮放)
BufferedImage src = ImageIO.read(originalFile);
BufferedImage preview = ImageUtils.scale(src, 400, 300, true);
String previewName = "preview_" + originalName;
File previewFile = new File(tempDir, previewName);
ImageUtils.writeJpg(preview, new FileOutputStream(previewFile), 0.8f);
// 4. 返回預覽信息供前端 JCrop 使用
return Result.success(Map.of(
"originalId", originalName,
"previewUrl", "/api/avatar/preview/" + previewName,
"previewWidth", preview.getWidth(),
"previewHeight", preview.getHeight()
));
}
@GetMapping("/preview/{filename}")
public void preview(@PathVariable String filename, HttpServletResponse resp) throws IOException {
File file = new File(TEMP_DIR, filename);
if (!file.exists()) { resp.setStatus(404); return; }
resp.setContentType("image/jpeg");
Files.copy(file.toPath(), resp.getOutputStream());
}
@PostMapping("/crop")
public Result crop(@RequestParam String originalId,
@RequestParam int x, @RequestParam int y,
@RequestParam int w, @RequestParam int h) {
File originalFile = new File(TEMP_DIR, originalId);
if (!originalFile.exists()) return Result.error("原圖已過期,請重新上傳");
// 獲取預覽圖尺寸(需前端傳回或服務端緩存)
int previewW = 400; // 示例固定值,實際應從緩存/數據庫獲取
int previewH = 300;
CropUtils.CropResult result = CropUtils.crop(originalFile, previewW, previewH,
x, y, w, h, new File(TEMP_DIR), "cropped");
// 上傳到永久存儲(OSS/S3/文件服務器)
String permanentUrl = storageService.upload(result.file(), "avatar/");
// 清理臨時文件
originalFile.delete();
new File(TEMP_DIR, "preview_" + originalId).delete();
result.file().delete();
return Result.success(permanentUrl);
}
}
前端關鍵點 (JCrop 初始化)
$('#originalImg').Jcrop({
aspectRatio: 4/3, // 固定比例 400:300
setSelect: [0, 0, 400, 300], // 預設選區
onChange: updateCoords,
onSelect: updateCoords,
boxWidth: 500, // 容器寬度限制
boxHeight: 400
}, function() {
jcropApi = this;
});
function updateCoords(c) {
$('#x').val(c.x);
$('#y').val(c.y);
$('#w').val(c.w);
$('#h').val(c.h);
}
// 提交裁剪
$('#saveImage').click(function() {
$.post('/api/avatar/crop', {
originalId: $('#originalId').val(),
x: $('#x').val(), y: $('#y').val(),
w: $('#w').val(), h: $('#h').val()
}, function(res) {
if (res.success) {
$('#finalAvatar').attr('src', res.data);
alert('頭像更新成功');
}
});
});
常見坑與解決方案
| 問題 | 原因 | 解決 |
| 裁剪位置偏移 | 預覽圖縮放比例與原圖不一致,或未正確映射座標 | 後端記錄並返回預覽圖實際寬高,裁剪時按 原圖尺寸/預覽尺寸 精確映射 |
| 圖片變形/模糊 | 非等比縮放、JPG 質量過低、多次重編碼 | 強制等比縮放、質量 0.8-0.9、原圖→預覽→裁剪鏈路中僅最終輸出編碼一次 |
| 大圖 OOM | BufferedImage 全量載入堆內存 | 限制上傳大小(<5MB)、使用 ImageIO.createImageInputStream() 流式讀取、或引入 TwelveMonkeys ImageIO 支援大圖分塊處理 |
| 臨時文件洩漏 | 異常分支未清理、定時任務缺失 | try-finally 清理、獨立定時掃描臨時目錄刪除過期文件(>1小時) |
| 並發文件名衝突 | UUID 機率極低但理論存在 | UUID + timestamp + 原始文件名哈希,或使用数据库序列/雪花算法 ID |
生產級擴展建議
- 異步處理:上傳返回預覽 URL,裁剪提交進消息隊列,Worker 處理後推送 WebSocket 通知前端。
- 多尺寸輸出:一次裁剪生成多種規格(大頭像 200x200、中 100x100、小 40x40),響應式顯示。
- 圖片審核:接入內容安全 API(色情/暴恐/廣告識別)在存儲前同步檢測。
- CDN 緩存:裁剪後圖片上傳 OSS 並配置 CDN,URL 帶版本號實現緩存更新。