跳至主要内容

REP-05:ReportArtifact 與 server delivery 契約設計

狀態:已通過設計審閱(3 輪,2026-09-30)與程式碼審閱(2 輪,2026-09-30);已實作。實作細節見「實作註記」,程式碼審閱的裁決見「程式碼審閱裁決」 依據:ADR-029(第 6 點 ownership 與持久化,含 REP-01~04 實作註記)、ADR-017、報表產生開發計畫 REP-05、REP-03 契約、檔案儲存設計、風險導向 API 授權 日期:2026-09-30

範圍​

納入不納入(所屬工作包)
ReportArtifact:render 完成的 PDF 產物,含 MIME、檔名、大小、頁數、metadata、diagnostics 與暫存資源 ownershipreport job Entity、固定 API route(ADR-017、ADR-029 第 6 點:應用擁有)
同步產生:ReportRenderer#render(document, options) 回傳 artifactjob 排程、執行緒池、重試政策與 retention 排程(應用擁有)
產生後保存到 FileStorage 的 helper 與保存結果值型別FileStorage 的 ranged read SPI(REP-06)
非同步 seam:狀態值型別(對齊前端 ReportViewer 的 generating/ready/failed/expired)與頁數進度回呼前端按需預覽(REP-07)、選頁 subset 與列印(REP-08)
HTTP 交付:直接串流短報表的 response helper;保存後以 FileResponseBuilder 下載的規範subset PDF 的產生(REP-08;本文只定義其授權與 ETag 責任)
原始與 subset PDF 的授權、ETag、快取與稽核責任分工框架內建授權判斷或稽核寫入(應用擁有)
tenant/tenantless 的 partition 接線示範(框架測試與指南)參考伺服器的報表功能(另立 follow-up,走 feature/US 軌道)

已確定的方向​

以下三項已由框架維護者於 2026-09-30 決定,本文據此設計:

  1. 非同步 seam 只到值型別與 helper。 框架提供 ReportArtifact、狀態值型別與「產生並保存到 FileStorage」的 helper; job Entity、executor、重試與保存期限由應用擁有。不定義 job store 或 executor 介面。
  2. tenant/tenantless 以框架測試與文件示範。 在 appfuse-server 測試中以兩種 partition 策略接線並寫進指南; 參考伺服器(app-tenant-server、app-tenantless-server)的報表功能另開 follow-up。
  3. renderer 提供頁數進度回呼。 RenderOptions 增加每完成一頁即呼叫的回呼;總頁數要到完成才知道,因此只回報已完成頁數。

設計原則​

  1. 產物是暫存資源,不是 Entity。 ReportArtifact 是 render 結果的描述與串流存取;關閉即刪除暫存檔。 要保存就交給 FileStorage,保存後只有 fileId 與值型別,不留框架狀態。
  2. 失敗不留產物(沿用 REP-03):render 要嘛回傳完整、可讀取的 artifact,要嘛丟出例外且暫存檔已刪除。 保存失敗時 artifact 也已關閉,呼叫者拿不到「看似成功」的結果。
  3. 授權在應用,框架只提供不會洩漏的預設。 框架不判斷誰能看報表;但交付 helper 的預設快取策略不得讓已授權內容 進入共享快取。
  4. ETag 以 write-once 識別為準。 保存的產物具有自己的 write-once fileId,ETag 繼續以該保存物件識別,不以 PDF 位元組雜湊; 保存後以 fileId 為強驗證器(沿用 FileResponseBuilder),直接串流的回應不帶 ETag。 REP-01~04 的隨存檔時間變動 /ID 是歷史實證;流式 renderer 的內容摘要 ID 修正不改本交付契約, 也不保證未固定製表時間或不同版本的 PDF bytes 相同。

公開 API​

產物(io.leandev.appfuse.report,@ExperimentalApi)​

public interface ReportRenderer {
/// 既有(REP-03):產生後寫入 out
RenderResult render(ReportDocument document, OutputStream out, RenderOptions options);

/// 新增:產生到 renderer 的暫存檔,回傳 artifact(呼叫者擁有,須關閉)
ReportArtifact render(ReportDocument document, RenderOptions options);
}

/// render 完成的 PDF;關閉後刪除暫存檔
public interface ReportArtifact extends AutoCloseable {
String contentType(); // "application/pdf"
String filename(); // 見「檔名」
long size(); // 位元組數
int pageCount();
Instant createdAt(); // 文件時間:PDF metadata 的建立時間(可為歷史日期或固定時鐘,不用於保存期限)
ReportMetadata metadata(); // 文件 metadata(標題、作者、主旨、語言)
List<ReportDiagnostic> diagnostics(); // INFO/WARNING
InputStream openStream(); // 可多次開啟;每個串流由呼叫者關閉
long transferTo(OutputStream out);
@Override void close(); // 刪除暫存檔;可重複呼叫
}
規則內容
ownershiprender 回傳後由呼叫者擁有;以 try-with-resources 關閉,或交給會接手 ownership 的 helper(見下文,helper 的 javadoc 明示)
關閉後openStream、transferTo 丟出 IllegalStateException;已開啟的串流在關閉前仍可讀完(刪除延到最後一個串流關閉)
刪除失敗沿用 REP-03「成功路徑的清理失敗不影響結果」:close()、最後一個串流關閉、Cleaner 觸發時若刪除暫存檔失敗,一律不丟出例外,以 WARN 記錄(含路徑與 report.resource.cleanup-failed);串流本身的讀取或關閉錯誤照常傳出。暫存目錄的殘留交由系統清理 tmpdir 或維運處理
暫存位置與 REP-03 相同的 renderer 暫存目錄(java.io.tmpdir),檔案以擁有者權限建立
漏關的保險artifact 以 java.lang.ref.Cleaner 登記;未關閉而被回收時刪除暫存檔並記錄 WARN(見「已裁決的決策」第 2 項)。這是保險,不是 ownership 規則的替代
與 RenderResult串流版 render(document, out, options) 維持不變,適合已經有輸出串流的情境;需要先知道大小、頁數或要保存時用 artifact 版

檔名​

filename() 依序取 RenderOptions.filename(String)、文件 metadata 的標題、report,加上 .pdf(已有 .pdf 時不重複)。 檔名只做為 metadata 與 Content-Disposition 的建議值,移除路徑分隔字元、雙引號與控制字元。HTTP 輸出依 RFC 6266: ASCII 檔名只寫 filename;含非 ASCII 字元時寫 UTF-8 的 filename*(RFC 5987),filename 後備為 Spring ContentDisposition 產生的 RFC 2047 編碼字(見「實作註記」)。

進度(擴充 REP-03 RenderOptions)​

RenderOptions.defaults()
.cancelWhen(job::isCancelled)
.onPage(pages -> job.progress(pages)) // 每完成一頁呼叫,參數為已完成頁數(1, 2, 3, ...)
.filename("2026Q1-門市銷售明細");
規則內容
呼叫時機每頁繪製完成後,於 render 執行緒同步呼叫;回填總頁數(共 Y 頁)與序列化不另外回報
例外回呼丟出的例外中止 render,視同產生階段的失敗(沿用 REP-03 的清理與零位元組保證)
成本回呼必須輕量、不可阻塞;需要跨執行緒時由應用自行轉交(例如更新 volatile 欄位或原子計數)

保存(io.leandev.appfuse.report)​

/// 產生並保存;artifact 在所有路徑上都會關閉
StoredReport stored = StoredReports.save(fileStorage, partition, renderer.render(document, options));
StoredReport stored = StoredReports.save(fileStorage, partition, artifact, clock); // 保存時間取自指定時鐘

public record StoredReport(String partition, String fileId, String filename, String contentType, long size,
int pageCount, Instant createdAt, Instant storedAt,
List<ReportDiagnostic> diagnostics) { }
規則內容
ownershipsave 接手 artifact,成功或失敗都會關閉
保存成功後的清理寫入成功即回傳 StoredReport;之後的清理失敗——關閉讀取串流的錯誤與關閉 artifact 時的刪除失敗——只記錄 WARN(report.resource.cleanup-failed),不影響回傳,呼叫者一定拿得到已保存檔案的 fileId
寫入以 FileStorage#store(partition, filename, stream, contentType, size) 寫入永久區;partition 由應用決定(見「tenant 與 tenantless」)
失敗儲存丟出的例外為主例外原樣傳出,不回傳 StoredReport;關閉讀取串流與 artifact 都不會取代它(串流關閉錯誤與其他非預期的清理例外以 addSuppressed 附上;刪除失敗不丟例外)
兩種時間createdAt 是文件時間(PDF metadata,可由 ReportMetadata.createdAt 指定歷史日期或由固定 Clock 產生,用於重現輸出);storedAt 是實際保存完成的時間,取自 save 的 clock 參數(省略時為 Clock.systemUTC()),與 RenderOptions.clock 無關
保存期限以 storedAt(或應用自己記錄的保存時間)加上應用政策計算到期;不得以 createdAt 計算。到期後由應用呼叫 FileStorage#delete 並回報 Expired;框架不排程刪除

狀態值型別(io.leandev.appfuse.report)​

public sealed interface ReportStatus {
String status(); // "generating"|"ready"|"failed"|"expired",對齊前端 ReportViewerState

record Generating(Instant startedAt, int pagesCompleted) implements ReportStatus { }
record Ready(StoredReport report) implements ReportStatus { }
record Failed(String code, String message, List<ReportDiagnostic> diagnostics) implements ReportStatus {
public static Failed of(Throwable failure) { ... }
}
record Expired(Instant expiredAt) implements ReportStatus { }
}

Failed.of 的對應(見「已裁決的決策」第 3 項):

例外codemessagediagnostics
ReportValidationExceptionreport.invalid第一個 ERROR 的訊息全部 diagnostic
ReportRenderCancelledExceptionreport.cancelled固定文字空
其他report.failed固定文字,不含例外訊息(可能含路徑或內部細節)空

值型別不含 Jackson 註解;status() 讓應用以任何序列化方式輸出前端需要的鑑別欄位。job 的其他欄位(誰、何時、參數)屬應用。

HTTP 交付(新套件 io.leandev.appfuse.report.web,@ExperimentalApi)​

@RequestMapping(path = "/reports/sales", method = {RequestMethod.GET, RequestMethod.HEAD})
void sales(..., HttpServletRequest request, HttpServletResponse response) throws IOException {
var document = template.create(query, context); // 授權與資料範圍在此之前
ReportResponses.attachment(renderer.render(document, RenderOptions.defaults()), request, response);
}

ReportResponses.attachment(artifact, request, response)/inline(artifact, request, response) 在呼叫者的請求執行緒同步寫出, 從進入方法的第一行起就擁有 artifact,並在 finally 關閉;方法返回時 artifact 一定已關閉。不使用 StreamingResponseBody 或延後寫出的 Resource(見「已裁決的決策」第 1 項):前者交給 async executor,提交被拒絕或逾時未啟動時回呼不會執行; 後者在 HEAD、例外處理或 filter 提早結束時可能從未讀取串流。兩者都會讓清理依賴 GC。

請求方法(request.getMethod())行為
HEAD只寫 headers:Content-Type、Content-Length(等於 artifact.size(),與 GET 相同)、Content-Disposition、Cache-Control;不開啟 artifact 的串流、不寫 body;返回前關閉 artifact。不依賴容器抑制 body
GET寫 headers 後以 artifact.transferTo(response.getOutputStream()) 寫出 body
其他IllegalArgumentException(程式錯誤:report 下載只用於 GET/HEAD);丟出前關閉 artifact

因為 HEAD 也要先 render 才知道 Content-Length,應用若不想為 HEAD 付出產生成本,可以不把 HEAD 對應到此 endpoint(Spring MVC 預設會把 HEAD 交給 GET handler,此時 helper 以上表處理)。

項目直接串流(ReportResponses.inline/attachment)保存後下載(FileResponseBuilder)
適用短報表、一次性下載大型報表、非同步 job、預覽(REP-06/07)、選頁(REP-08)
回應200,Content-Type: application/pdf、Content-Length、Content-Disposition(inline 或 attachment)沿用 FileResponseBuilder:200/206/304/404
ETag不帶(沒有保存物件的 write-once 識別)"{fileId}"(write-once)
快取Cache-Control: no-store必須覆寫為私有:cacheControl(CacheControl.noCache().cachePrivate());不得使用 immutable()(其為 public,已授權內容會進入共享快取)
資源同步寫入 HttpServletResponse,finally 關閉 artifact;用戶端中斷、HEAD(依 request.getMethod() 只寫 headers,不開啟串流)、寫出例外都在方法返回前關閉,不依賴 executor 或 GC由 FileStorage 管理

失敗語意:render 在 helper 被呼叫前已完成,因此產生失敗仍能回應一般的錯誤(例如 ReportValidationException 經應用的錯誤處理轉為 4xx/5xx); 開始寫出後的失敗(用戶端中斷、I/O 錯誤)關閉 artifact 後把例外傳出,由 Spring 處理(回應已提交,不再寫錯誤內容)。 helper 在寫 headers 之前若 response 已提交,丟出 IllegalStateException 並關閉 artifact。

授權、ETag 與稽核的責任分工​

項目框架應用
誰能產生、下載哪份報表不判斷依 m-api-authorization 分類 endpoint:大量或可攜出的資料屬 {domain}:export 類 capability+稽核;已保存報表的下載是 object scoped(以應用記錄的擁有者、組織或 tenant 限定),越界時依威脅模型回 403 或 404
資料範圍不判斷在建立 ReportDocument 前套用(查詢層),不在 PDF 產生後過濾
已保存報表的定址fileId 為 write-once 的儲存識別對外 API 以應用自己的 job/report id 定址;fileId 不當作授權憑證,下載前以應用記錄確認授權並取得 fileId 與 partition
subset PDF(REP-08)由已授權的原始 artifact 產生授權沿用原始報表的判斷;subset 另存時同樣以 fileId 為 ETag、私有快取、短效保存
ETag保存後為 fileId;直接串流不帶不以 PDF 位元組雜湊自行計算
快取直接串流 no-store;指南規定保存後下載用私有快取不使用 immutable()
稽核不寫入產生(誰、參數摘要、頁數、大小)與下載(誰、哪份)依風險記錄;不記錄報表內容

tenant 與 tenantless​

FileStorage 的 partition 是呼叫端決定的前綴,框架不賦予業務語意(見 FileStorage javadoc)。報表保存沿用:

模組型別partition授權
server-tenant目前 tenant ID(TenantContext)tenant 隔離+應用的 object scope
server-tenantless應用常數,或依資料範圍設計(例如組織或擁有者)應用明確選擇的資料範圍

框架測試以兩種 partition 策略接線並驗證互不可見;指南提供兩者的範例。report 套件不相依 security.tenant(ArchUnit 規則)。

Diagnostic 與錯誤​

REP-05 不新增 diagnostic 代碼。新增的錯誤皆為程式錯誤或 I/O 錯誤:

情況行為
artifact 關閉後讀取IllegalStateException
漏關的 artifact 被回收刪除暫存檔並記錄 WARN(不是 diagnostic:render 已結束)
暫存檔刪除失敗(close、最後一個串流關閉、Cleaner)不丟例外,記錄 WARN(report.resource.cleanup-failed)
保存失敗FileStorage 的例外為主例外原樣傳出,artifact 已關閉;清理中的非預期例外以 suppressed 附上
保存成功、清理失敗照常回傳 StoredReport,記錄 WARN
寫出中用戶端中斷或 I/O 錯誤關閉 artifact 後把例外傳出,交由 Spring 處理(通常只記錄)

驗收方式(對應計畫的完成條件)​

完成條件驗證方式
應用可直接串流短報表,或保存後再下載MockMvc:attachment/inline 的狀態碼、headers(含中文檔名的 Content-Disposition、Content-Length、no-store)與 body 為可讀 PDF;保存後以 FileResponseBuilder 下載:ETag 為 fileId、If-None-Match 回 304、私有快取
HEAD以 HEAD 請求呼叫 helper:回應 headers 與 GET 相同(含 Content-Length 等於 artifact.size())、body 為空;以計數的 artifact 驗證 openStream/transferTo 從未被呼叫;返回前 artifact 已關閉、暫存檔已刪除。另驗證 GET/HEAD 以外的方法丟例外且 artifact 已關閉
renderer failure 不留下未關閉 stream 或誤認為成功的 artifact產生失敗、取消、進度回呼例外、保存失敗、寫出中斷、HEAD、response 已提交:暫存目錄在每種情境後為空(不經 GC);save 失敗不回傳結果;關閉後讀取丟例外;已開啟的串流在關閉 artifact 後仍可讀完
清理失敗(故障注入)以會在刪除時失敗的暫存儲存注入:close()、最後一個串流關閉與 Cleaner 皆不丟例外並記錄 WARN;保存成功後清理失敗仍回傳 StoredReport;保存失敗且清理失敗時主例外為儲存例外。另以關閉時失敗的讀取串流注入:保存成功後仍回傳結果並記錄 WARN,保存失敗時關閉錯誤附為 suppressed
保存期限的時間來源以 ReportMetadata.createdAt 指定歷史日期(例如 2020-01-01)產生並保存:createdAt 為該日期、storedAt 取自 save 的時鐘;以 storedAt 計算的到期判斷不會立即過期
tenant/tenantless 皆能由 application adapter 接線,框架無 tenant 假設測試以 tenant ID 與固定常數兩種 partition 保存與下載,彼此不可見;ArchUnit:report.. 不相依 security.tenant..
進度回呼次數等於頁數、依序遞增;回呼例外中止 render 並清理
狀態值型別status() 與前端狀態名稱一致;Failed.of 三種對應,report.failed 不含例外訊息
漏關保險未關閉的 artifact 在 GC 後暫存檔被刪除(以有限次數的 GC 輪詢驗證)

已裁決的決策​

以下 5 項於設計審閱(2026-09-30,3 輪)由審閱者裁決,皆採作者方案。

#議題裁決捨棄的方案與理由
1直接串流的交付方式在請求執行緒同步寫入 HttpServletResponse;傳入 request 判斷 GET/HEAD,返回前關閉 artifactStreamingResponseBody:async 提交被拒絕或逾時未啟動時回呼不執行,清理只能靠 GC;ResponseEntity<Resource>:HEAD、例外處理或 filter 提早結束時串流可能從未讀取
2漏關保險Cleaner 在 artifact 無人參照時刪除暫存檔並記錄 WARN不做:漏關即留下暫存檔
3Failed.of 的訊息一般失敗為 report.failed 與固定文字,不帶例外訊息;應用自行記錄完整例外帶例外訊息:有內部路徑或 SQL 等資訊洩漏風險
4檔名來源RenderOptions.filename → metadata 標題 → report只由交付 helper 指定:保存時也需要檔名,重複指定
5進度回呼例外中止 render(與取消一致)捕捉記錄後繼續:應用的錯誤被吞掉

程式碼審閱裁決​

程式碼審閱(2026-09-30,2 輪)的意見與裁決:

#議題裁決
R1-01保存成功後關閉讀取串流失敗會丟例外、呼叫者失去 fileId修正:寫入成功後的串流關閉失敗只記 WARN 並回傳 StoredReport;寫入失敗時關閉錯誤附為 suppressed;以故障注入測試兩種路徑。ReportArtifact#transferTo 的關閉錯誤依本契約照常傳出,不變
1FileResponseBuilder 的 ASCII 後備從未生效本工作包不改既有行為,另立 FU-104
2消費端指南併入 REP-09;接線範例暫留本文「實作註記」
3Failed.of 的 report.invalid 訊息維持第一個 ERROR diagnostic 的訊息(模型/排版驗證訊息,不含例外原文或伺服器路徑)
4ReportStatus 的形式採巢狀 record,上方片段已同步

實作註記​

項目內容
狀態值型別四個狀態為 ReportStatus 的巢狀 record(ReportStatus.Ready 等),permits 集中、不佔用 report 套件的頂層名稱;上方片段已同步
類別ReportArtifact(介面)與 report.internal.pdf.TempFileArtifact(暫存檔、串流計數、Cleaner);PdfRenderer 的產生階段抽成共用方法,串流版與 artifact 版輸出相同的頁數與大小;StoredReport、StoredReports、ReportStatus(sealed,巢狀 record);report.web.ReportResponses
RenderOptionsrecord 元件改為 (cancelRequested, clock, onPage, filename)。REP-03 起的 RenderOptions 尚未隨任何 alpha 發布(最近一次為 4.0.0-alpha.19,早於 REP-03),因此 CHANGELOG 只記 Added,不另列 Removed
Content-Disposition 後備原設計寫「沿用框架既有的 ASCII 後備」。MockMvc 測試發現 Spring ContentDisposition.Builder 設定 UTF-8 charset 後會覆蓋先前的 ASCII filename,改輸出 RFC 2047 編碼字;FileResponseBuilder(ResponseHeaderSupport)的 ASCII 後備同樣從未生效。ReportResponses 改為如實行為:ASCII 檔名只寫 filename,非 ASCII 檔名寫 filename* 與 Spring 的 RFC 2047 後備。FileResponseBuilder 的既有行為不在本工作包修改
交付測試以 MockHttpServletRequest/MockHttpServletResponse 直接呼叫 helper,驗證 GET/HEAD/其他方法/已提交/寫出中斷;另以 MockMvc standalone controller 驗證 Spring 把 HEAD 交給 GET handler 時的 headers 與空 body
ArchUnitreport.. 不相依 security.tenant.. 與 jpa.tenant..;report.web 以外不相依 servlet、Spring Web 與 org.springframework.http;report.web 列入 @ExperimentalApi 檢查
指南「tenant/tenantless 寫進指南」的消費端指南頁併入 REP-09「補齊 server 使用指南」;本工作包的接線範例見下方,框架測試(ReportDeliveryTest)已驗證兩種 partition 互不可見

接線範例​

server-tenant:partition 取目前 tenant ID;下載前以應用記錄確認授權並取得 fileId。

StoredReport stored = StoredReports.save(fileStorage, TenantContext.getCurrentTenantId(),
renderer.render(template.create(query, context), RenderOptions.defaults().cancelWhen(job::isCancelled)));
job.ready(stored); // 應用記錄 partition、fileId、storedAt 與擁有者

return FileResponseBuilder.from(fileStorage, job.partition(), job.fileId())
.filename(job.filename())
.ifNoneMatch(ifNoneMatch)
.cacheControl(CacheControl.noCache().cachePrivate())
.build();

server-tenantless:partition 由應用依資料範圍決定(例如組織或擁有者),或為應用常數。

StoredReport stored = StoredReports.save(fileStorage, "reports/" + owner.id(), renderer.render(document, options));

相關文檔​