報表 Feature slice 設計(FU-103)
:::info 已發布的組裝修訂
本頁保留最初 FU-103 的背景 job 設計與驗證歷史。已於參考實作 app-v2026.1.23 發布的修訂見
ADR-035:report 僅提供字型、renderer 與同步容量,
背景 executor/排程及 report 資源改由 report-job 承接。下列舊 package、catalog、
app.report.fonts.dir 與目錄參數必填的描述是原設計,不能用於新組裝;
新版有預設目錄,字型資產仍須在啟動時可用,缺少或損壞即阻止啟動。
目前已發布版本仍以該 release 的 source/Feature Changelog 為準。共用預設與遷移契約以 ADR-035 及兩個 source 的 2026.1.23 Feature Changelog 為準。
:::
狀態:已通過設計審閱(4 輪,2026-09-30);已實作並通過程式碼審閱(3 輪,2026-09-30)。裁決見「已裁決的決策」,審閱中補強的規則見「設計審閱中補充的規則」,實作偏離見「實作註記」,程式碼審閱的裁決見「程式碼審閱裁決」 依據:ADR-029(第 6 點:job、排程、保存期限與權限由應用擁有)、報表產生開發計畫、REP-05 ReportArtifact 與交付(「已裁決的決策」第 2 項:參考伺服器的報表功能另走 feature/US 軌道)、REP-06 Ranged read、REP-08 選頁 subset 與列印、Feature 設計與使用、ADR-031、ADR-033、方法論 ADR-006/ADR-011/ADR-012 日期:2026-09-30
範圍
REP-02~08 在框架交付了報表能力,但刻意把 job 記錄、執行器、保存期限、授權與稽核留給應用。每個採用報表的應用
都要重寫這一套,而它與報表內容無關。本設計把這一層做成兩個參考伺服器的 optional feature report,讓下游可以
provision,業務域只需提供「怎麼產生這份報表」與「誰能產生」。
| 納入 | 不納入 |
|---|---|
app-tenant-server、app-tenantless-server 的 report feature:core、seam、附屬 reference domain report、Flyway migration、catalog、FEATURE_CHANGELOG、測試、Bruno | 具體業務報表(訂單明細報表另立產品 US,見「後續」) |
| 報表 job 生命週期:送出、非同步產生、進度、取消、完成、失敗、過期 | app-office 的 ReportViewer 畫面(隨產品 US) |
| 已保存報表的狀態查詢、range 下載、選頁列印、刪除 | 框架 appfuse-server 的新 API(預期不需要;實作中若發現缺口,另開工作包,不在 app 端繞過) |
| 保存期限與中斷 job 的維護排程 | 管理者檢視他人報表、報表分享 |
| 字型部署與 PDFBox 相依 | 同步直接串流的短報表(業務域可直接用 ReportResponses,不經本 feature) |
前置條件:框架版本
兩個參考伺服器目前相依 appfuse-server:4.0.0-alpha.18。io.leandev.appfuse.report、FileStorage#openRange 與
FileResponseBuilder.request(...) 都在 alpha.19 之後加入,仍在 [Unreleased];PDFBox 為框架的 compileOnly,兩個伺服器
都沒有宣告。
| 步驟 | 內容 |
|---|---|
| 開發期 | 以 -PuseLocalFramework=true(includeBuild("../appfuse-server"))編譯與測試 |
| 提交前 | 在 appfuse-server 以 /publish 發布含 REP-02~08 的 alpha(預期 alpha.20),兩個伺服器升級到該版並完成 alpha.19 起的相容遷移 |
| 閘門 | 預設建置(不帶 useLocalFramework)通過後才 commit;不提交只能靠本機框架建置的程式碼 |
設計原則
- 分層依「所有權」:與報表內容無關、每個應用都要寫的組裝放 feature core;URL、wire DTO、entity schema 與授權放附屬 reference domain;「產生哪份報表、誰能產生」屬業務域。
- 生成授權在業務域,生命週期授權在 job:業務域以
{domain}:export類 capability 決定誰能送出;送出後的查詢、下載、 列印、刪除一律 object scoped(只有送出者),不另設報表 capability。 - fileId 不是憑證:對外只以 job id 定址;JSON 回應不輸出 partition 與 fileId,也沒有端點接受 fileId。下載回應的
ETag沿用FileResponseBuilder以 fileId 為值,只有擁有者看得到(見「程式碼審閱裁決」)。 - 失敗不假裝成功:job 只有在檔案保存成功後才進入
READY;中斷、拒絕、取消都落到可辨識的失敗碼。 - 兩個伺服器同名同操作,實作各自擁有(ADR-011):差異只在 tenant 隔離、partition 與 entity 基底類別。
分層
| 部分 | 內容 | 生命週期 |
|---|---|---|
Feature core feature/report/ | ReportConfig、ReportProperties、ReportMaintenanceScheduler、ReportMaintenanceTask(SPI) | core/seam,見「Catalog」 |
附屬 reference domain report | entity/report、repository/report、service/report、controller/report、dto/report | provision 時安裝,下游擁有 |
| 業務域(例:訂單) | 送出端點、{domain}:export capability、報表範本 | 應用 |
Feature core
| 類別 | 職責 |
|---|---|
ReportProperties | app.report.* 的型別化設定(見「設定」),啟動期驗證 |
ReportConfig | 由設定建立 ReportFonts 與 thread-safe 的 ReportRenderer(PdfReportRenderer.create);建立有界的 reportExecutor(ThreadPoolTaskExecutor,固定執行緒數與佇列上限,拒絕時丟 TaskRejectedException,關閉時等待執行中 job) |
ReportMaintenanceTask | SPI:void run();reference domain 提供實作,負責過期與中斷 job 的處理 |
ReportMaintenanceScheduler | @Scheduled(cron = "${app.report.maintenance.cron}")+@SchedulerLock(name = "reportMaintenance");比照 StagingCleanupScheduler,最多一個 task,沒有時記錄並略過 |
core 不 import reference domain 或業務域(不變量 I2)。字型不隨 WAR 散布,見「字型」。
附屬 reference domain report
ReportJob entity(table report_job):
| 欄位 | 說明 |
|---|---|
id | UUID,對外唯一識別 |
type | 報表類型鍵(業務域自訂,例:order-detail),供列表與稽核 |
title | 顯示標題 |
parameterSummary | 參數摘要(業務域提供,限長 500,不含個資全文),供列表與稽核 |
status | GENERATING/READY/FAILED/EXPIRED(對外狀態) |
phase | GENERATING 內的階段:QUEUED/RUNNING/SAVING;其他狀態為 null。只供內部轉換條件與維護判斷,不對外 |
pagesCompleted | 產生中已完成頁數 |
cancelRequested | 取消旗標 |
partition | 送出時由 FileStoragePartitionResolver 決定並寫入;崩潰後的孤兒對帳依此定位 |
filename | 顯示與下載用檔名(Content-Disposition);保存到儲存體的檔名另為 report-{id}.pdf(見「交易邊界」) |
fileId、size、pageCount | 保存結果(READY 起才有值);FAILED 的 job 若經孤兒對帳登錄孤兒,fileId 為待刪檔案(不可下載) |
startedAt | worker 開始執行(QUEUED → RUNNING)的時間 |
storedAt、expiresAt | 保存時間與到期時間(expiresAt = storedAt + retention) |
failureCode、failureMessage | 取自 ReportStatus.Failed.of(...),不含例外訊息 |
heartbeatAt | 最後一次心跳時間,用於判斷中斷(見「心跳」) |
orphanCheckPending | 中斷的 job 尚待孤兒對帳(見「維護」) |
fileDeletePending | 已決定刪除檔案、但尚未確認檔案不存在;為 true 時保留 partition/fileId 且不刪記錄(見「檔案刪除」) |
removed | 擁有者已刪除;object scope 查詢一律排除,外部看來等同不存在(404) |
| 稽核欄位 | createdBy(擁有者)、createdAt、updatedAt;@Version |
- tenant:繼承
AuditableTenantEntity(@TenantId),索引(tenant_id, created_by, created_at)、(status, expires_at)、(status, heartbeat_at)。 - tenantless:繼承
AuditableBase,不使用任何 tenant 型別;索引同上去掉tenant_id。 - 狀態轉換一律以帶條件的更新(
UPDATE … SET …, version = version + 1 WHERE id = ? AND status = ? [AND phase = ?] [AND cancel_requested = false])執行, 以受影響列數判斷是否取得轉換;不以「讀出 entity 再存回」做狀態轉換。條件更新在 InnoDB 會鎖住該列到交易結束, 同一列的其他轉換必須等待,之後以新狀態重新比對條件(見「終態競爭」)。
ReportJobService:業務域的唯一入口。
/// 送出報表 job;在呼叫者的交易內建立記錄,交易提交後才交給 reportExecutor
ReportJobView submit(ReportJobRequest request);
public record ReportJobRequest(String type, String title, String parameterSummary, ReportJobTask task) { }
@FunctionalInterface
public interface ReportJobTask {
/// 在 worker 執行緒、送出者的安全與 tenant 脈絡、唯讀交易內呼叫
ReportDocument build(ReportJobContext context);
}
| 規則 | 內容 |
|---|---|
| 授權 | submit 不檢查 capability;呼叫它的業務域端點負責(@PreAuthorize("hasAuthority('order:export')")) |
| 資料範圍 | task 在 worker 執行緒執行,但以送出者的 SecurityContext 與 tenant(TenantContext.runAs)執行,業務域沿用既有的 data scope 查詢;不在 PDF 產生後過濾 |
| 大型資料 | task 可回傳使用 single-use row source 的文件;worker 在同一個唯讀交易內完成 build 與 render,row source 在交易關閉前耗盡 |
| 每人上限 | 見「每人上限」 |
| 執行器拒絕 | 交易提交後提交給 reportExecutor;被拒絕時以條件更新(status = GENERATING AND phase = QUEUED)轉 FAILED,failureCode = report.busy。送出端點已回傳 202,前端從狀態看到失敗並可重試 |
| 進度與取消 | 見「心跳」 |
| 完成與失敗 | 見「交易邊界」與「終態競爭」;例外經 ReportStatus.Failed.of(...) 轉為 failureCode/failureMessage,取消為 report.cancelled |
Partition:沿用 file feature 的 FileStoragePartitionResolver,使報表與其他檔案同一個儲存命名空間。tenant 伺服器為
目前 tenant ID;tenantless 為 app.storage.partition。partition 在送出時(請求執行緒,有 tenant 脈絡)決定並寫入 job。
隔離由 job 的 object scope 保證,不依賴 partition。
交易邊界
worker 的每個寫入都在自己的短交易(REQUIRES_NEW)中,不在 render 的唯讀交易內寫入:
| 階段 | 交易 | 內容 |
|---|---|---|
| 1. 送出 | 呼叫者的交易 | 取得每人上限鎖、新增 job(GENERATING/QUEUED、heartbeatAt = now);提交後才交給執行器 |
| 2. 開始 | 短交易 | 條件更新 QUEUED → RUNNING(AND cancel_requested = false),寫入 startedAt;0 列表示已被取消、拒絕或判定中斷,worker 直接結束 |
| 3. 產生 | 唯讀交易 | 以送出者脈絡呼叫 task.build,再 renderer.render(document, options) 產生 ReportArtifact(暫存檔)。本階段不寫資料庫 |
| 4. 保存 | 單一寫入交易 | ① 條件更新 RUNNING → SAVING(AND cancel_requested = false),0 列則關閉 artifact 並結束;② StoredReports.save(fileStorage, partition, artifact);③ 更新為 READY(fileId、size、pageCount、storedAt、expiresAt);④ 提交 |
| 5. 失敗 | 短交易 | 條件更新 GENERATING → FAILED;0 列表示已有其他終態,不覆蓋 |
保存階段把「寫檔」與「記錄 fileId」放在同一個交易,交給儲存後端既有的交易語意:
| 後端 | 交易提交前失敗或回滾 | 寫檔後、提交前程序中止(當機) |
|---|---|---|
Database(DatabaseFileStorage) | 檔案隨交易回滾 | 檔案隨交易回滾,無孤兒 |
Local/S3/Azure/SFTP(經 TransactionAwareFileStorage) | afterCompletion(ROLLED_BACK) 補償刪除 | 補償不會執行;journal 留下 CREATED,job 仍為 GENERATING/RUNNING,由維護的「中斷」與「孤兒對帳」處理 |
保存到儲存體的檔名固定為 report-{jobId}.pdf(RenderOptions.filename("report-" + jobId)),讓孤兒對帳能由
FileStorage#getMetadata 的檔名權威判定檔案屬於哪個 job,不需要全域的 FileReferenceResolver。
下載以 FileResponseBuilder.filename(job.filename()) 使用顯示檔名;選頁列印的 subset 檔名由 REP-08 依儲存檔名推導
(report-{jobId}-pages-….pdf),僅出現在列印視窗,接受此差異。
不新增 FileReferenceResolver:它是全域單一 bean,決定「任何 domain 的模糊檔案可否刪除」;報表只知道自己的參照,
提供它會讓 sweep 把其他 domain 的模糊項判為孤兒。
render 期間唯讀交易持有一條資料庫連線,心跳另用一條短連線;連線池需容納 executor.threads + 1 條報表用連線,
於「設定」的啟動期驗證中以警告提示。
心跳
每個節點有一個 ReportHeartbeat 元件,以登記表記住本節點已接手的 job(交給執行器時登記,worker 結束時移除),
每 progress-interval(預設 2 秒)在一個短交易中對登記表內的 job:
SELECT id, cancel_requested FROM report_job WHERE id IN (…) AND status = 'GENERATING' FOR UPDATE SKIP LOCKED;- 只對步驟 1 取得鎖的列更新
heartbeatAt = now、pagesCompleted = {記憶體計數}; - 以讀回的
cancelRequested設定各 job 的記憶體取消旗標。
被鎖住的列直接跳過,不等待。持有 job 列鎖超過一瞬間的只有保存交易(階段 4),因此被跳過的列就是「正在保存」的 job:
它不需要心跳,也不能在保存中途取消(見「終態競爭」)。一個保存很慢的 job 不會拖住同批其他 job 的心跳或取消讀回;
保存提交後該列恢復為 READY(不再需要心跳)或回滾為 RUNNING(下一輪心跳照常取得)。
心跳與 worker 執行緒分離,因此涵蓋排隊(QUEUED)、build 與單頁耗時很長的期間;RenderOptions.onPage 只更新記憶體計數,
RenderOptions.cancelWhen 只讀記憶體旗標,兩者都不碰資料庫。節點當機時心跳停止,維護依 heartbeatAt 判定中斷。
心跳交易失敗只記 WARN,下一輪重試;連續失敗超過 stale-after 時 job 會被判定中斷,屬預期行為。
終態競爭
READY、FAILED、EXPIRED 為終態,只能經條件更新進入,先取得轉換者勝:
| 交錯 | 結果 |
|---|---|
| 維護先判定中斷,延遲的 worker 之後完成 | 保存階段 ① 條件不成立(status 已非 GENERATING)→ 丟棄 artifact,不寫檔、不發布 READY |
| 取消先提交,worker 之後進入保存 | ① 條件含 cancel_requested = false → 不成立 → worker 以條件更新轉 FAILED(report.cancelled) |
| worker 已取得 ①(列鎖定中),取消請求到達 | DELETE 以 FOR UPDATE NOWAIT 取列鎖失敗 → 409 report.saving+Retry-After: 2,不等待保存完成。前端重試時 job 已是 READY(依刪除路徑回 204)或已回滾(依 RUNNING 路徑取消) |
| worker 已取得 ①,維護的中斷判定到達 | 維護以 FOR UPDATE SKIP LOCKED 挑候選,鎖住的列不列入;保存提交後已非 GENERATING,不覆蓋 READY |
| 保存交易回滾 | job 仍為 RUNNING,檔案依上表處理;worker 以條件更新轉 FAILED |
取消時 job 仍在排隊(QUEUED) | DELETE 先以條件更新 QUEUED → FAILED(report.cancelled);之後 worker 的階段 2 條件不成立,直接結束 |
取消時 job 已在 RUNNING | 設定 cancelRequested;心跳讀回後 cancelWhen 中止 render(ReportRenderCancelledException)→ 階段 5 轉 FAILED(report.cancelled);若 render 已結束,由保存階段 ① 攔下 |
DELETE 的流程:以 object scope 查詢並 FOR UPDATE NOWAIT 鎖住該列(鎖不到為 409 report.saving),依狀態:
| 狀態 | 動作 | 回應 |
|---|---|---|
GENERATING/QUEUED | 轉 FAILED(report.cancelled) | 202 |
GENERATING/RUNNING | 設定 cancelRequested = true | 202 |
READY | 設 removed = true、fileDeletePending = true,提交後依「檔案刪除」嘗試刪檔 | 204 |
FAILED/EXPIRED | 設 removed = true(EXPIRED 仍有待刪檔案時保留 fileDeletePending) | 204 |
刪檔失敗不影響 204:記錄已對擁有者隱藏,檔案由維護重試。
每人上限
同一擁有者(tenant 伺服器為同一 tenant 內)GENERATING 的 job 數不得超過 app.report.max-active-per-user。
計數以 report_job 為準,以每位擁有者一列的鎖序列化「檢查 + 新增」:
- 在送出交易中確保
report_owner_lock(owner[, tenant_id])存在(INSERT … ON DUPLICATE KEY UPDATE不改值,冪等); SELECT … FOR UPDATE鎖住該列;- 以鎖定讀取計算該擁有者
GENERATING的 job 數(SELECT id … WHERE owner = ? AND status = 'GENERATING' FOR SHARE), 已達上限丟report.too-many-jobs(429); - 新增 job;交易提交時釋放鎖。
送出沿用呼叫者的交易,讓 job 與業務域在同一交易內的其他寫入一起提交或回滾。呼叫者交易可能已在 REPEATABLE READ 下
建立一致性讀取快照,普通 SELECT 會讀到快照、看不到之後其他交易提交的 job;InnoDB 的鎖定讀取(FOR SHARE/FOR UPDATE)
一律讀取最新已提交版本,因此步驟 3 不受呼叫者隔離層級與快照時間影響。步驟 2 的鎖保證「讀到最新計數」到「新增」之間
沒有其他同擁有者的送出插入。
不同擁有者互不阻擋;多節點以資料庫列鎖保證。計數直接來自 job 狀態,不維護另一個計數欄位,因此不會因漏減而漂移。
report_owner_lock 只存鍵,不存狀態;擁有者沒有任何 job 時由維護清除。清除時先 FOR UPDATE 鎖住該列、以鎖定讀取確認
沒有 job 後才刪除;送出的步驟 2 若因該列剛被刪除而取不到,重做步驟 1~2(最多 3 次,仍失敗回 503 report.busy)。
端點(reference domain,參考路徑)
| Method | Path | 分類 | 說明 |
|---|---|---|---|
| GET | /api/v1/reports | Object scoped | 目前使用者的 job 列表,依 createdAt 倒序,分頁 |
| GET | /api/v1/reports/{id} | Object scoped | 狀態(見「狀態回應」) |
| GET、HEAD | /api/v1/reports/{id}/content | Object scoped | 以 FileResponseBuilder.from(...).request(request) 回應,支援 206/304/416;Cache-Control: no-cache, private;ETag 為 fileId |
| GET、HEAD | /api/v1/reports/{id}/print?pages=1-3,5 | Object scoped | PdfReportSubsets.extract → ReportResponses.inline,不保存 |
| DELETE | /api/v1/reports/{id} | Object scoped | 產生中:取消(202);保存中:409 report.saving;其他狀態:對擁有者移除並刪檔(204)。見「終態競爭」 |
object scope 以 findByIdAndCreatedByAndRemovedFalse(tenant 伺服器另由 @TenantId 限定)實作,不存在、已移除與無權讀取一律 404。
| 狀態 | content/print |
|---|---|
READY 且未到期 | 正常回應 |
EXPIRED,或 READY 已過 expiresAt 但維護尚未執行 | 410 report.expired |
GENERATING | 409 report.not-ready |
FAILED | 409 report.failed |
REP-08 的前端 adapter 以 410/404 區分「重新產生」與「無法取得」,本設計沿用。
狀態回應
欄位對齊 appfuse-web ReportViewerState 的鑑別值,前端不需轉換即可組出 viewer 狀態:
{
"id": "…", "type": "order-detail", "title": "2026-09 訂單明細",
"status": "ready",
"pagesCompleted": 42, "pageCount": 42,
"filename": "2026-09-orders.pdf", "size": 1843200,
"createdAt": "…", "storedAt": "…", "expiresAt": "…",
"failure": null
}
status 為 generating/ready/failed/expired;failure 只在 failed 時為 { "code": "report.busy", "message": "…" }。
前端以輪詢取得狀態(建議 2 秒,由 app-office 決定);本設計不提供 SSE 或 WebSocket 推送。
稽核
沿用 AuditApplicationEvent;只記摘要,不記報表內容。
| 事件 | 時機 | 資料 |
|---|---|---|
REPORT_REQUESTED | submit 成功建立記錄 | job id、type、parameterSummary |
REPORT_COMPLETED/REPORT_FAILED | worker 結束 | job id、type、頁數、大小或 failureCode |
REPORT_DOWNLOADED | content 回應完整內容,或 range 從 byte 0 開始 | job id、是否為 range |
REPORT_PRINTED | print 成功 | job id、正規化選頁 |
REPORT_DELETED | 使用者刪除或取消 | job id、原狀態 |
PDF.js 按需預覽會送出大量 range 請求;只記「從 byte 0 開始」的那一次,避免每個分段都產生稽核事件。
維護(ReportMaintenanceTask 實作)
每次執行依序處理,每批上限 app.report.maintenance.batch-size:
- 中斷:以
SELECT … WHERE status = 'GENERATING' AND heartbeat_at < ? FOR UPDATE SKIP LOCKED取候選,再轉FAILED(report.interrupted),同時設orphanCheckPending = true。正在保存的列被鎖住而跳過(見「終態競爭」)。涵蓋節點當機、重啟與排隊中遺失的 job。 - 孤兒對帳:
orphanCheckPending = true的 job,讀取其partition的FileJournal項目,取startedAt之後建立、且該 fileId 最後 狀態仍為CREATED的STORE項目,以getMetadata檔名等於report-{jobId}.pdf判定屬於此 job 的孤兒。對帳不在這裡刪檔:在同一個短交易中把孤兒的fileId寫入 job、設fileDeletePending = true、清除orphanCheckPending,之後由步驟 4 的可重試刪除流程處理。 每個 job 只執行一次保存階段,至多一個孤兒;若找到多於一個,先登錄第一個並保留orphanCheckPending,下一輪再處理其餘。getMetadata找不到(檔案已不存在)的項目直接略過;沒有任何孤兒時只清除旗標。startedAt為空(排隊中即中斷)時不可能已寫檔,直接清除旗標。Database 後端沒有 journal(交易已保證不留孤兒),直接清除旗標。journal 未設定(NoOpFileJournal)時無法對帳, 記 WARN 並清除旗標——此時孤兒與其他 domain 相同,只能靠維運處理。 - 到期:以條件更新
READY且expiresAt <= now→EXPIRED、fileDeletePending = true(只改記錄,不刪檔)。 - 檔案刪除:
fileDeletePending = true的 job 依「檔案刪除」處理。 - 清除記錄:
orphanCheckPending = false且fileDeletePending = false,並符合下列之一 → 刪除記錄:removed = true; 或EXPIRED/FAILED且updatedAt < now - record-retention。沒有任何 job 的擁有者,同時刪除其report_owner_lock列。
tenant 伺服器:排程執行緒沒有 tenant 脈絡,以跨 tenant 查詢取候選,逐筆以 TenantContext.runAs(tenantId, ...) 包住寫入交易
(20-scheduling.md)。tenantless 直接處理。每筆獨立交易;單筆失敗記 WARN 並繼續。
FileOrphanSweepScheduler 不處理報表的崩潰窗與刪除重試:沒有 FileReferenceResolver 時(策略 A),它對 CREATED 與
DELETE_PENDING 模糊項只報告、不刪除,而對無交易保存直接記為 CONFIRMED 的檔案永不刪除。本設計以「保存一律在交易內」
避免後者,以步驟 2 與步驟 4 自行處理前兩者,不依賴 sweep。
檔案刪除
檔案刪除以 job 記錄上的 fileDeletePending 作為可重試的刪除紀錄:在確認檔案不存在之前,旗標與 partition/fileId
都留在記錄上,記錄也不會被清除。
- 決定刪除的交易(使用者刪除
READY、到期、孤兒對帳登錄)只設旗標與fileId並提交,不在該交易內刪檔; - 提交後呼叫
fileStorage.delete(partition, fileId):Database 後端在自己的短交易內刪除;非交易後端在無交易時由TransactionAwareFileStorage立即刪除(不經DELETE_PENDING); - 以
fileStorage.exists(partition, fileId)確認不存在後,以短交易清除fileDeletePending; - 步驟 2 或 3 失敗、或程序在任一步中止,旗標仍為
true,由維護步驟 4 在下一輪重做步驟 2~3(檔案已不存在時delete冪等、exists為false,直接清除旗標)。
使用者刪除時,端點在回應前做一次步驟 2~3;失敗只記 WARN,交給維護重試。
設定
config/feature-report.yml:
app:
report:
fonts:
dir: ${APP_REPORT_FONTS_DIR} # 必填,缺少時啟動失敗(ADR-033)
default-family: sans
families:
sans:
regular: [NotoSans-Regular.ttf, NotoSansTC-Regular.ttf]
bold: [NotoSans-Bold.ttf, NotoSansTC-Bold.ttf]
executor:
threads: 2
queue-capacity: 20
max-active-per-user: 3
retention: P7D
record-retention: P30D
progress-interval: PT2S
stale-after: PT10M
maintenance:
cron: "0 */10 * * * *"
batch-size: 200
啟動期驗證:字型目錄與檔案存在、ReportFonts.build() 成功(含 PDFBox 版本檢查)、數值為正、stale-after 大於
progress-interval 的 10 倍。失敗訊息指出設定鍵與原因。資料庫連線池上限小於 executor.threads + 1 時記 WARN(見「交易邊界」)。
字型
字型不進 git,也不打包進 WAR(Noto Sans TC 單一字面約 7MB)。
| 環境 | 取得方式 |
|---|---|
| 開發與測試 | gradle/features/report.gradle.kts 提供 downloadReportFonts,沿用 appfuse-server 的固定 URL+SHA-256 清單,快取於 Gradle user home;bootRun 與測試自動設定 APP_REPORT_FONTS_DIR |
| SIT/UAT/正式 | 部署時把字型與授權檔放到主機目錄,以環境變數指定;/env-config 標記為必填、/deploy 文件補上步驟 |
Catalog
"report": {
"kind": "optional",
"requires": ["auth", "file", "scheduling"],
"beanRequires": ["auth", "file", "scheduling"],
"referenceDomains": ["report"],
"seams": [
"src/main/java/io/leandev/app/feature/report/ReportConfig.java",
"src/main/java/io/leandev/app/feature/report/ReportProperties.java",
"src/main/resources/config/feature-report.yml",
"gradle/features/report.gradle.kts"
],
"seamAdoption": { "(以上四者)": "defaultable" },
"testSupport": ["src/test/java/io/leandev/app/feature/report/ReportPropertiesValidationTest.java"]
}
-
I3 以「core 的 import 邊 ∪
beanRequires」推導requires;附屬 reference domain 的 import 不算 core 的邊。本 feature 的 core 只 import 框架(io.leandev.appfuse.report..)與 Spring,因此三個依賴都登錄在beanRequires(比照notification):依賴 來源 用途 schedulingcore: ReportMaintenanceScheduler的@Scheduled+@SchedulerLock排程與 ShedLock LockProviderfilereference domain: ReportJobService、維護實作FileStorage、FileStoragePartitionResolver、FileJournalbeanauthreference domain: ReportJobService、ReportJobController目前使用者(擁有者)與 AuditableTenantEntity/AuditableBase的 auditor -
core 不 import
feature.file或feature.auth的型別,也不 import reference domain(I2);FeatureSliceArchitectureTest驗證。 -
audit不列入:稽核經 Spring 事件發布,沒有auditfeature 時事件沒有接收者,不影響功能。 -
驗證:兩個伺服器各自執行
python3 .claude/skills/feature/assets/feature-inventory.py --check(I3)與./gradlew build。 -
app-server.yml以optional:classpath:config/feature-report.yml匯入。 -
gradle/features/report.gradle.kts:implementation("org.apache.pdfbox:pdfbox:3.0.x")(取框架相容範圍[3.0.8,4.0.0)內的最新修正版)與字型下載任務。 -
兩個伺服器各自一筆 catalog 與 FEATURE_CHANGELOG
[Unreleased] ### reportentry([core][informational]、[reference-domain][informational]、[catalog][informational])。
Migration
各伺服器新增下一個 Flyway 版本(V6__report_job.sql,以實作時的最大版本號為準),建立 report_job 與 report_owner_lock
(主鍵為擁有者;tenant 版為 (tenant_id, owner))。tenant 版含 tenant_id 與其索引;tenantless 不含。
tenant 與 tenantless 差異
| 項目 | app-tenant-server | app-tenantless-server |
|---|---|---|
| entity 基底 | AuditableTenantEntity | AuditableBase |
| object scope | @TenantId + createdBy | createdBy |
| partition | tenant ID(file 的 resolver) | app.storage.partition(file 的 resolver) |
| worker 脈絡 | 還原 SecurityContext + TenantContext.runAs | 還原 SecurityContext |
| 維護寫入 | 逐筆 TenantContext.runAs | 直接寫入 |
其餘(端點、狀態回應、設定、稽核事件名稱)兩者相同。
測試
| 層 | 內容 |
|---|---|
| 單元 | ReportProperties 驗證;心跳登記表與記憶體取消旗標;Failed.of 對應;維護四步驟的邊界時間;孤兒對帳只刪 report-{jobId}.pdf 且最後狀態為 CREATED 的項目 |
| 整合(每個伺服器) | 以測試用 ReportJobTask 走完送出 → 產生 → READY → content(200、206、304、416)→ print → DELETE;跨使用者 404;tenant 伺服器另測跨 tenant 404;過期 410;產生中 409;排隊中取消與執行中取消;執行器拒絕 → report.busy |
| 故障注入(Local+journal 與 Database 兩種後端) | ① 保存交易寫檔後、提交前丟例外 → 回滾:Local 補償刪檔、Database 無檔,job 轉 FAILED;② 模擬寫檔後程序中止(寫檔並留下 CREATED,不提交 job 更新)→ 維護判定中斷並由孤兒對帳刪檔;Database 後端同情境無孤兒;③ 到期交易回滾 → job 仍為 READY、檔案仍在;④ 到期或使用者刪除後,delete 丟例外或在清除旗標前中止 → 旗標與 fileId 保留、記錄不被清除,下一輪維護刪檔並清除旗標;檔案已被外部刪除時直接清除旗標;⑤ 孤兒對帳登錄後,delete 丟例外或程序在清除旗標前中止 → job 保留孤兒 fileId 與 fileDeletePending,下一輪維護完成刪除並清除旗標,記錄在此之前不被清除 |
交錯(以可注入的階段 hook+CountDownLatch 暫停 worker) | 維護先判定中斷、worker 之後完成 → 不發布 READY、不留檔;取消在保存 ① 之前 → report.cancelled;取消在 ① 之後 → 409 report.saving,保存提交後重試得 204;維護中斷判定與保存交錯 → 跳過鎖住的列、不覆蓋 READY;長時間 build 期間心跳持續,未被判定中斷;job A 卡在保存(hook 暫停於寫檔),同節點 job B 的心跳持續更新、B 可被取消並在下一輪心跳讀回 |
| 並行 | 同一擁有者以 N 個執行緒同時送出,恰好 max-active-per-user 筆成功、其餘 429;不同擁有者同時送出互不阻擋;tenant 伺服器不同 tenant 的同名擁有者各自計數;舊快照:呼叫者交易先做一次普通讀取建立快照、再等待 owner 鎖,期間另一交易送出並提交,之後的計數仍看得到該筆 |
鎖行為相關的交錯與並行測試(SKIP LOCKED、NOWAIT、鎖定讀取、REPEATABLE READ 快照)在 MySQL 8.4 Testcontainers 上執行
(*MySqlIT,沿用既有 migration 測試的模式);H2 的鎖語意與 InnoDB 不同,不作為這些行為的證據。
| 架構 | I1/I2(FeatureSliceArchitectureTest);feature-inventory.py --check(I3);tenantless 不使用 tenant 型別 |
| Bruno | bruno/reports/*:列表、狀態、下載、選頁、刪除(送出端點隨產品 US) |
測試字型沿用 downloadReportFonts;測試報表只用少量頁數,避免拖慢整合測試。
已裁決的決策
設計審閱通過時,六個待決點皆採作者方案。
| # | 問題 | 決定 | 未採用的方案 |
|---|---|---|---|
| 1 | 送出入口 | 業務域自己的端點呼叫 ReportJobService.submit(例:POST /api/v1/orders/reports),capability 與參數型別留在業務域 | 通用 POST /api/v1/reports {type, params} + ReportDefinition 登錄;參數成為無型別 map、capability 要動態決定 |
| 2 | 業務域相依的位置 | 業務域直接相依 reference domain 的 ReportJobService | 在 feature core 定義送出介面,由 reference domain 實作;業務域只相依 core,但多一層只有一個實作的介面 |
| 3 | 字型部署 | 外部目錄、必填設定、不打包進 WAR | 建置時下載並打包進 WAR(部署簡單,WAR 增加約 15MB) |
| 4 | 下載稽核 | 只記完整下載與「從 byte 0 開始」的 range | 每個請求都記;或完全不記下載、只記產生與列印 |
| 5 | 執行器拒絕 | 已回 202,job 轉 FAILED(report.busy),前端從狀態得知 | 送出前檢查佇列,額滿直接回 503;但檢查與提交之間仍有競爭,拒絕路徑仍需要 |
| 6 | 管理者存取 | 本版只有擁有者能存取 | 加 report:manage 讓管理者查看與刪除同 tenant 的所有 job |
設計審閱中補充的規則
| 輪次 | 補充 | 見 |
|---|---|---|
| R1-01 | worker 寫入皆為獨立短交易;保存把「寫檔」與「記錄 fileId」放在同一交易;儲存檔名固定 report-{jobId}.pdf,崩潰後由報表自己的孤兒對帳處理,不新增全域 FileReferenceResolver | 「交易邊界」、「維護」 |
| R1-02 | 心跳由每節點元件定期寫入,涵蓋排隊、build、長頁;終態只經帶條件的更新進入,先取得者勝 | 「心跳」、「終態競爭」 |
| R1-03 | 每人上限以每位擁有者一列的鎖序列化「計數 + 新增」,計數以 job 狀態為準 | 「每人上限」 |
| R1-04 | Catalog beanRequires 登錄 auth、file、scheduling 並說明來源 | 「Catalog」 |
| R2-01 | 檔案刪除以 job 上的 fileDeletePending 為可重試紀錄,以 exists 確認後才清除;不依賴 sweep 處理 DELETE_PENDING | 「檔案刪除」 |
| R2-02 | 心跳與中斷判定以 SKIP LOCKED 跳過保存中的列;DELETE 以 NOWAIT,保存中回 409 report.saving | 「心跳」、「終態競爭」 |
| R2-03 | 配額計數用鎖定讀取,不受呼叫者交易的 REPEATABLE READ 快照影響;補 owner 鎖列清除的競爭 | 「每人上限」 |
| R3-01 | 孤兒對帳只登錄 fileId 與 fileDeletePending,刪除交給可重試流程 | 「維護」 |
鎖行為的測試在 MySQL 8.4 Testcontainers 上執行;故障注入與交錯測試為實作的完成條件,程式碼審閱時核對。
實作註記
| 項目 | 設計 | 實作 | 理由 |
|---|---|---|---|
| Migration | 各伺服器新增 V6__report_job.sql | 不手寫;FEATURE_CHANGELOG 的 reference-domain entry 註明需要 migration | 兩個伺服器的 29-database-migrations.md 規定 migration 只由人類在發行邊界明示執行 /entity-diff 產生,任何 skill 不得代為觸發。測試以 ddl-auto: create 建表 |
| 狀態轉換 | 帶條件的 UPDATE … WHERE status = ?,以受影響列數判斷 | 先以 JPA 鎖住該列(PESSIMISTIC_WRITE,心跳與維護加 SKIP LOCKED、刪除加 NOWAIT),再以最新狀態判斷後修改 | 鎖定讀取讀最新版本並持有列鎖到交易結束,語意等同條件更新;鎖以 jakarta.persistence.lock.timeout hint 表達,由 Hibernate 依方言翻譯,維持五資料庫可攜(23-repository-reference.md)。因所有轉換都在列鎖下進行,ReportJob 不加 @Version |
| 每人上限計數 | 取得擁有者鎖後以鎖定讀取(FOR SHARE)計數 | 取得擁有者鎖後,以新的唯讀交易(REQUIRES_NEW)計數已提交的產生中 job,再加上本交易已送出、尚未提交的 job(綁在目前交易的 resource,交易結束即解除) | 新交易的快照建立在取得鎖之後,一樣讀得到所有已提交的同擁有者 job,也不受呼叫者舊快照影響(R2-03);FOR SHARE 會在 report_job 索引上加 next-key lock,不同擁有者同時送出時可能互相死鎖。新交易看不到呼叫者交易自己未提交的新增,同一業務交易連續送出會突破上限(程式碼審閱 R1-01),故另計本交易的送出數 |
| 擁有者鎖列建立 | INSERT … ON DUPLICATE KEY UPDATE;取不到鎖列時重做最多 3 次 | 先以一般讀取判斷是否存在,不存在時以獨立交易 JPA 新增(重複鍵視為已存在),再鎖定;鎖定時列已被清除則回 503 report.busy,不重試 | ON DUPLICATE KEY 是 MySQL 專屬語法。對不存在的列先做鎖定讀取會取得 gap lock,擋住建立該列的獨立交易,所以要先建立再鎖;同一交易內重試無法解除該 gap lock |
| 欄位名稱 | partition、size、createdAt、updatedAt | storage_partition、file_size,時間沿用 AuditableTenantEntity/AuditableBase 的 created_date、last_modified_date | partition 是 MySQL 保留字;稽核欄位沿用既有基底類別 |
ReportJobRequest | type、title、parameterSummary、task | 另加 filename(下載用檔名) | 儲存檔名固定為 report-{jobId}.pdf,顯示檔名需要由業務域提供 |
| 錯誤回應 | 409/410/429/503 帶 errorCode,409 帶 Retry-After | ReportJobException 繼承 Spring ErrorResponseException,由既有 ResponseEntityExceptionHandler 輸出 RFC 7807 並保留 headers | 不新增全域例外處理器(m-server-common.md 將全域例外轉換列為需人類批准的自製 primitive)。訊息皆為固定文字,不經揭露政策也不外洩內部細節 |
| 執行緒池 | reportExecutor(ThreadPoolTaskExecutor) | ReportExecutor 包住 ThreadPoolTaskExecutor,不以 Executor 型別註冊 | 容器出現 Executor bean 時,Spring Boot 的 applicationTaskExecutor 會退讓,改變其他 feature 的 @Async 行為 |
| 心跳(tenant 伺服器) | 一個短交易更新所有登記的 job | 依租戶分批,每批在 TenantContext.supplyAs(tenant, …) 內一個短交易 | 20-scheduling.md:排程緒寫入租戶資料須以 runAs 包住整個交易;tenantless 為單一交易 |
| Range 下載 | content 支援 206/304/416 | 參考實作預設的 Database 後端依 REP-06 回 200 完整內容與 Accept-Ranges: none,每個 GET 都記一次 REPORT_DOWNLOADED;Local/S3/Azure/SFTP 才有 206/416 | REP-06 裁決 DatabaseFileStorage 維持「不支援」。按需預覽的效益需要非 Database 後端 |
| 測試掛點 | 可注入的階段 hook | ReportJobHooks bean,正式執行為 no-op,只有同 package 的測試能替換 | 重現交錯需要在 worker 特定階段暫停 |
| 測試配置 | 鎖行為測試在 MySQL 8.4 Testcontainers | ReportJobLocksMySqlIT 另開 MySQL context(沒有 Docker 時跳過);生命週期與故障注入加入共用 H2 context;Local+journal 後端在測試中自行組裝 | 共用 context 的儲存後端是 Database;Local 後端的 compensation 與孤兒對帳需要真實的 TransactionAwareFileStorage 與 journal |
程式碼審閱裁決
| # | 項目 | 裁決 |
|---|---|---|
| R1-01 | 每人上限的新交易計數看不到呼叫者交易自己未提交的送出,同一業務交易連續送出可突破上限 | 修正:另以 ReportPendingSubmissions 記錄本交易已送出、未提交的 job 數,上限判斷為「已提交產生中數+本交易待提交數」;兩個模組補回歸測試 |
| R2-01 | 待提交計數綁在交易 resource 上,但交易暫停時未卸下:內層 REQUIRES_NEW 會沿用外層計數,內層結束時還會解除外層計數 | 修正:同步物件的 suspend() 卸下、resume() 綁回同一份計數(比照 Spring ResourceHolderSynchronization);單元測試以不連 DB 的交易管理器驗證暫停/恢復,整合測試涵蓋跨租戶同名使用者與內層提交/回滾 |
| 待決 1 | 下載的 ETag 為 fileId | 接受:只有擁有者看得到,fileId 不是憑證、沒有端點接受它;設計改述為「JSON 回應不輸出 partition 與 fileId」 |
| 待決 2 | production migration 由誰產生 | 依 29-database-migrations.md,於發行邊界由人執行 /entity-diff;FEATURE_CHANGELOG 已註明 |
/security-review 未發現信心 ≥ 8 的漏洞。預設建置(不帶 useLocalFramework)須待 appfuse-server alpha.20 發布並升級後通過,為 commit 前閘門。
後續
- 本設計通過審閱後:發布 appfuse-server alpha → 兩個伺服器升級 → 實作 feature → 程式碼審閱 → commit。
- 產品 US:訂單明細報表(Epic 2)。走
/us→/sbe→/api-spec(授權矩陣含order:export)→/impl,app-office 以ReportViewer+createReportPrintAdapter接上本 feature 的端點。它同時是 REP-09 發布條件所需的實際使用案例之一。 - FU-103 在本 feature 通過程式碼審閱並提交後以
/follow-up --done結案;訂單報表另外追蹤。
相關文檔
- REP-05 ReportArtifact 與交付:產物 ownership、
StoredReports、ReportStatus、授權分工 - REP-06 Ranged read:
FileResponseBuilder.request(...)與 CORS - REP-08 選頁 subset 與列印:
ReportPages、PdfReportSubsets、404/410 分工 - Feature 設計與使用:core/seam/reference domain 的分層
固定表單交付擴充(2026-10-01,尚未發布)
兩個 server 參考實作現在也接受 ReportJobRequest.fixed(type, title, filename, summary, task)。
task 接受 ReportJobContext 並回傳 FixedDocument;worker 提供 Clock、取消與進度,並保留既有身分及交易脈絡。
需要套印與校準時,使用帶 ReportJobWork.Fixed 的 request。
流式 request 的原五參數建構子保留;直接讀取 request 的自訂程式須將 task() 改為分派 work()。
兩種產物共用 PdfArtifact/StoredPdfs,再經原有 owner 隔離、下載、頁面選取、刪除及到期管線。 固定排版錯誤回傳 report.invalid,不保存半份文件;固定表單不帶 flow diagnostics。 細節與限制見 固定版面表單。目前須以本地框架驗證,發布後才能更新下游相依版本。