業務場景
教師批量下載學生作業附件:根據作業 ID 查詢所有提交記錄,遍歷附件 URL 下載文件,打包成 ZIP 返回前端下載。
核心流程
- 查詢作業詳情與提交列表
- 獲取班級學生名單,構建「學生 → 提交記錄」映射
- 篩選已提交且有附件的記錄
- 創建臨時 ZIP 文件,流式寫入
- 將 ZIP 文件流式輸出至 HTTP 響應
- 清理臨時文件
完整 Controller 實現
@RestController
@RequestMapping("/assignment")
public class AssignmentController {
@Autowired AssignmentService assignmentService;
@Autowired MemberService memberService;
@Autowired AnnounceService announceService;
@GetMapping("/downloadAllHomework")
public void downloadAllHomework(HttpSession session, HttpServletRequest request,
HttpServletResponse response,
@RequestParam String assignmentId,
@RequestParam int classCode) throws Exception {
Site site = (Site) session.getAttribute("site");
String siteId = site.getId();
// 1. 獲取作業詳情
AssignmentDetail detail = assignmentService.getDetail(assignmentId);
// 2. 獲取所有提交記錄
List submits = assignmentService.getSubmitted(assignmentId);
List submitIds = submits.stream()
.map(AssignmentSubmit::getId)
.filter(Objects::nonNull)
.collect(Collectors.toList());
// 3. 獲取提交詳情 (含附件)
Map submitMap = new HashMap<>();
for (String sid : submitIds) {
AssignmentSubmit s = assignmentService.getSubmitDetail(sid);
submitMap.put(s.getUserName(), s);
}
// 4. 獲取班級學生,合併提交信息
List students = memberService.getStudents(siteId, classCode);
List exportList = new ArrayList<>();
for (MemberVO stu : students) {
AssignmentSubmit s = submitMap.getOrDefault(stu.getId(), new AssignmentSubmit());
s.setRealName(stu.getName());
s.setUserName(stu.getId());
exportList.add(s);
}
// 5. 篩選有附件的提交
List withFiles = exportList.stream()
.filter(s -> s.getAttachIDs() != null && !s.getAttachIDs().isEmpty())
.collect(Collectors.toList());
// 6. 創建臨時 ZIP 文件
File zipFile = File.createTempFile("homework_", ".zip");
zipFile.deleteOnExit(); // JVM 退出時刪除
try (FileOutputStream fos = new FileOutputStream(zipFile);
ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(fos))) {
for (AssignmentSubmit submit : withFiles) {
for (AttachIDs attach : submit.getAttachIDs()) {
try {
// 6.1 獲取文件元數據
AttachmentMeta meta = announceService.getAttachment(attach.getId());
String fileName = meta.getName();
String fileUrl = meta.getUrlUpload();
// 6.2 構建雲存儲下載 URL
String cloudUrl = "https://storage.example.com:8066/ImageService/DownloadFile/"
+ fileUrl.substring(fileUrl.lastIndexOf("/") + 1);
// 6.3 解決 ZIP 中文文件名編碼 (GBK 兼容 Windows)
ZipEntry entry = new ZipEntry(
new String(fileName.getBytes("GBK"), "ISO-8859-1"));
zos.putNextEntry(entry);
// 6.4 流式下載寫入 ZIP
try (InputStream is = downloadFile(cloudUrl)) {
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) > 0) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
} catch (Exception e) {
log.error("附件處理失敗: {}", attach.getId(), e);
}
}
}
zos.finish(); // 完成壓縮
}
// 7. 流式輸出 ZIP 文件
downloadZip(zipFile, response);
}
private InputStream downloadFile(String url) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
return conn.getInputStream();
}
private void downloadZip(File file, HttpServletResponse response) throws IOException {
response.reset();
response.setContentType("application/zip");
response.setHeader("Content-Disposition",
"attachment;filename=" + URLEncoder.encode(file.getName(), "UTF-8"));
response.setContentLengthLong(file.length());
try (FileInputStream fis = new FileInputStream(file);
BufferedInputStream bis = new BufferedInputStream(fis);
OutputStream os = new BufferedOutputStream(response.getOutputStream())) {
byte[] buffer = new byte[8192];
int len;
while ((len = bis.read(buffer)) > 0) {
os.write(buffer, 0, len);
}
os.flush();
} finally {
// 異步刪除臨時文件 (響應完成後)
CompletableFuture.runAsync(() -> {
try { Files.deleteIfExists(file.toPath()); } catch (IOException ignored) {}
});
}
}
}
關鍵技術點解析
| 技術點 | 實現細節 |
| 流式壓縮 | `ZipOutputStream` 直接寫入 `FileOutputStream`,無需將所有文件載入記憶體 |
| 中文文件名編碼 | `new String(name.getBytes("GBK"), "ISO-8859-1")` 兼容 Windows 資源管理器預覽 |
| 大文件支援 | 8KB 緩衝區分塊讀寫,避免 OOM;`setContentLengthLong` 支持 >2GB |
| 遠程文件下載 | `HttpURLConnection` 獲取輸入流,直接管道寫入 ZIP,無本地緩存 |
| 異常隔離 | 單個附件失敗不影響其他,記錄錯誤日誌繼續處理 |
| 臨時文件清理 | `deleteOnExit()` + 異步 `Files.deleteIfExists()` 雙重保障 |
| 響應頭設置 | `application/zip` + `attachment` 強制瀏覽器下載而非預覽 |
優化方向
- 異步預生成:大量文件時,後台任務預生成 ZIP,前端輪詢下載鏈接
- 斷點續傳:支援 `Range` 請求,大 ZIP 分片下載
- 對象存儲簽名 URL:替換直接下載為預簽名 URL,減輕應用服務器帶寬
- 壓縮級別: `zos.setLevel(Deflater.BEST_COMPRESSION)` 或 `BEST_SPEED`
- 進度回調:WebSocket/Server-Sent Events 推送壓縮/下載進度
前端調用示例
// 普通表單提交 (非 AJAX,因涉及文件下載)
function downloadAllHomework(assignmentId, classCode) {
const url = `/assignment/downloadAllHomework?assignmentId=${assignmentId}&classCode=${classCode}`;
window.location.href = url; // 瀏覽器原生下載
}
// 或使用 a 標籤
批量下載作業
常見問題排查
| 現象 | 原因 | 解決 |
| ZIP 解壓亂碼 | 編碼不匹配 | Windows 用 GBK,Linux/Mac 用 UTF-8,可嘗試 `ZipEntry.setExtra()` 存 UTF-8 標誌 |
| 下載中斷 | 超時/連接重置 | 增加連接/讀取超時,啟用斷點續傳,檢查代理/防火牆 |
| 文件名截斷 | 響應頭編碼錯誤 | `URLEncoder.encode(fileName, "UTF-8")`,避免手動拼接 |
| 內存溢出 | 緩衝區過大/文件過多 | 縮小 buffer (4-8KB),分批處理,使用臨時文件而非 ByteArrayOutputStream |