跳至主要内容

報表 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;不提交只能靠本機框架建置的程式碼

設計原則​

  1. 分層依「所有權」:與報表內容無關、每個應用都要寫的組裝放 feature core;URL、wire DTO、entity schema 與授權放附屬 reference domain;「產生哪份報表、誰能產生」屬業務域。
  2. 生成授權在業務域,生命週期授權在 job:業務域以 {domain}:export 類 capability 決定誰能送出;送出後的查詢、下載、 列印、刪除一律 object scoped(只有送出者),不另設報表 capability。
  3. fileId 不是憑證:對外只以 job id 定址;JSON 回應不輸出 partition 與 fileId,也沒有端點接受 fileId。下載回應的 ETag 沿用 FileResponseBuilder 以 fileId 為值,只有擁有者看得到(見「程式碼審閱裁決」)。
  4. 失敗不假裝成功:job 只有在檔案保存成功後才進入 READY;中斷、拒絕、取消都落到可辨識的失敗碼。
  5. 兩個伺服器同名同操作,實作各自擁有(ADR-011):差異只在 tenant 隔離、partition 與 entity 基底類別。

分層​

部分內容生命週期
Feature core feature/report/ReportConfig、ReportProperties、ReportMaintenanceScheduler、ReportMaintenanceTask(SPI)core/seam,見「Catalog」
附屬 reference domain reportentity/report、repository/report、service/report、controller/report、dto/reportprovision 時安裝,下游擁有
業務域(例:訂單)送出端點、{domain}:export capability、報表範本應用

Feature core​

類別職責
ReportPropertiesapp.report.* 的型別化設定(見「設定」),啟動期驗證
ReportConfig由設定建立 ReportFonts 與 thread-safe 的 ReportRenderer(PdfReportRenderer.create);建立有界的 reportExecutor(ThreadPoolTaskExecutor,固定執行緒數與佇列上限,拒絕時丟 TaskRejectedException,關閉時等待執行中 job)
ReportMaintenanceTaskSPI: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):

欄位說明
idUUID,對外唯一識別
type報表類型鍵(業務域自訂,例:order-detail),供列表與稽核
title顯示標題
parameterSummary參數摘要(業務域提供,限長 500,不含個資全文),供列表與稽核
statusGENERATING/READY/FAILED/EXPIRED(對外狀態)
phaseGENERATING 內的階段:QUEUED/RUNNING/SAVING;其他狀態為 null。只供內部轉換條件與維護判斷,不對外
pagesCompleted產生中已完成頁數
cancelRequested取消旗標
partition送出時由 FileStoragePartitionResolver 決定並寫入;崩潰後的孤兒對帳依此定位
filename顯示與下載用檔名(Content-Disposition);保存到儲存體的檔名另為 report-{id}.pdf(見「交易邊界」)
fileId、size、pageCount保存結果(READY 起才有值);FAILED 的 job 若經孤兒對帳登錄孤兒,fileId 為待刪檔案(不可下載)
startedAtworker 開始執行(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:

  1. SELECT id, cancel_requested FROM report_job WHERE id IN (…) AND status = 'GENERATING' FOR UPDATE SKIP LOCKED;
  2. 只對步驟 1 取得鎖的列更新 heartbeatAt = now、pagesCompleted = {記憶體計數};
  3. 以讀回的 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 = true202
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 為準,以每位擁有者一列的鎖序列化「檢查 + 新增」:

  1. 在送出交易中確保 report_owner_lock(owner[, tenant_id]) 存在(INSERT … ON DUPLICATE KEY UPDATE 不改值,冪等);
  2. SELECT … FOR UPDATE 鎖住該列;
  3. 以鎖定讀取計算該擁有者 GENERATING 的 job 數(SELECT id … WHERE owner = ? AND status = 'GENERATING' FOR SHARE), 已達上限丟 report.too-many-jobs(429);
  4. 新增 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,參考路徑)​

MethodPath分類說明
GET/api/v1/reportsObject scoped目前使用者的 job 列表,依 createdAt 倒序,分頁
GET/api/v1/reports/{id}Object scoped狀態(見「狀態回應」)
GET、HEAD/api/v1/reports/{id}/contentObject 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,5Object scopedPdfReportSubsets.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
GENERATING409 report.not-ready
FAILED409 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_REQUESTEDsubmit 成功建立記錄job id、type、parameterSummary
REPORT_COMPLETED/REPORT_FAILEDworker 結束job id、type、頁數、大小或 failureCode
REPORT_DOWNLOADEDcontent 回應完整內容,或 range 從 byte 0 開始job id、是否為 range
REPORT_PRINTEDprint 成功job id、正規化選頁
REPORT_DELETED使用者刪除或取消job id、原狀態

PDF.js 按需預覽會送出大量 range 請求;只記「從 byte 0 開始」的那一次,避免每個分段都產生稽核事件。

維護(ReportMaintenanceTask 實作)​

每次執行依序處理,每批上限 app.report.maintenance.batch-size:

  1. 中斷:以 SELECT … WHERE status = 'GENERATING' AND heartbeat_at < ? FOR UPDATE SKIP LOCKED 取候選,再轉 FAILED (report.interrupted),同時設 orphanCheckPending = true。正在保存的列被鎖住而跳過(見「終態競爭」)。涵蓋節點當機、重啟與排隊中遺失的 job。
  2. 孤兒對帳: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 相同,只能靠維運處理。
  3. 到期:以條件更新 READY 且 expiresAt <= now → EXPIRED、fileDeletePending = true(只改記錄,不刪檔)。
  4. 檔案刪除:fileDeletePending = true 的 job 依「檔案刪除」處理。
  5. 清除記錄: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 都留在記錄上,記錄也不會被清除。

  1. 決定刪除的交易(使用者刪除 READY、到期、孤兒對帳登錄)只設旗標與 fileId 並提交,不在該交易內刪檔;
  2. 提交後呼叫 fileStorage.delete(partition, fileId):Database 後端在自己的短交易內刪除;非交易後端在無交易時由 TransactionAwareFileStorage 立即刪除(不經 DELETE_PENDING);
  3. 以 fileStorage.exists(partition, fileId) 確認不存在後,以短交易清除 fileDeletePending;
  4. 步驟 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 LockProvider
    filereference domain:ReportJobService、維護實作FileStorage、FileStoragePartitionResolver、FileJournal bean
    authreference domain:ReportJobService、ReportJobController目前使用者(擁有者)與 AuditableTenantEntity/AuditableBase 的 auditor
  • core 不 import feature.file 或 feature.auth 的型別,也不 import reference domain(I2);FeatureSliceArchitectureTest 驗證。

  • audit 不列入:稽核經 Spring 事件發布,沒有 audit feature 時事件沒有接收者,不影響功能。

  • 驗證:兩個伺服器各自執行 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] ### report entry([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-serverapp-tenantless-server
entity 基底AuditableTenantEntityAuditableBase
object scope@TenantId + createdBycreatedBy
partitiontenant 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-01worker 寫入皆為獨立短交易;保存把「寫檔」與「記錄 fileId」放在同一交易;儲存檔名固定 report-{jobId}.pdf,崩潰後由報表自己的孤兒對帳處理,不新增全域 FileReferenceResolver「交易邊界」、「維護」
R1-02心跳由每節點元件定期寫入,涵蓋排隊、build、長頁;終態只經帶條件的更新進入,先取得者勝「心跳」、「終態競爭」
R1-03每人上限以每位擁有者一列的鎖序列化「計數 + 新增」,計數以 job 狀態為準「每人上限」
R1-04Catalog 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、updatedAtstorage_partition、file_size,時間沿用 AuditableTenantEntity/AuditableBase 的 created_date、last_modified_datepartition 是 MySQL 保留字;稽核欄位沿用既有基底類別
ReportJobRequesttype、title、parameterSummary、task另加 filename(下載用檔名)儲存檔名固定為 report-{jobId}.pdf,顯示檔名需要由業務域提供
錯誤回應409/410/429/503 帶 errorCode,409 帶 Retry-AfterReportJobException 繼承 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/416REP-06 裁決 DatabaseFileStorage 維持「不支援」。按需預覽的效益需要非 Database 後端
測試掛點可注入的階段 hookReportJobHooks bean,正式執行為 no-op,只有同 package 的測試能替換重現交錯需要在 worker 特定階段暫停
測試配置鎖行為測試在 MySQL 8.4 TestcontainersReportJobLocksMySqlIT 另開 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」
待決 2production migration 由誰產生依 29-database-migrations.md,於發行邊界由人執行 /entity-diff;FEATURE_CHANGELOG 已註明

/security-review 未發現信心 ≥ 8 的漏洞。預設建置(不帶 useLocalFramework)須待 appfuse-server alpha.20 發布並升級後通過,為 commit 前閘門。

後續​

  1. 本設計通過審閱後:發布 appfuse-server alpha → 兩個伺服器升級 → 實作 feature → 程式碼審閱 → commit。
  2. 產品 US:訂單明細報表(Epic 2)。走 /us → /sbe → /api-spec(授權矩陣含 order:export)→ /impl,app-office 以 ReportViewer+createReportPrintAdapter 接上本 feature 的端點。它同時是 REP-09 發布條件所需的實際使用案例之一。
  3. FU-103 在本 feature 通過程式碼審閱並提交後以 /follow-up --done 結案;訂單報表另外追蹤。

相關文檔​

固定表單交付擴充(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。 細節與限制見 固定版面表單。目前須以本地框架驗證,發布後才能更新下游相依版本。