跳至主要内容

appfuse-server Changelog

Framework Library 變更日誌(Spring Boot 工具集)。


[Unreleased]​

Added​

  • 新增 opt-in bounded Session family profile 與 Host opaque Mock Access adapter(目標 4.0.0-alpha.39,尚未發布)。 MockAuthSessionController.inMemoryWithAccessSessions(MockAccessSessionOptions) 與 Vite 使用同一 DTO/Problem/golden cases; 未選用的新路徑不可用,legacy Cookie 與 Access family 由同一 store 互斥處理。 SessionFamilyProfile、SessionPrincipalRegistry、SessionIssuanceContext、SessionMetadata、 SessionChangedException、SessionStoreCapability 支援可信發行、固定截止、Cookie replacement、 known-family refresh/logout 及 subject/actor 撤銷。保留 LoginResult/RefreshResult record components 與建構器。 JwtTokenProvider 新增明確 issuedAt/expiresAt overload、受保護 sessionKind 與 M2M 具名發行; ResourceServerSecurity.Builder.sessionFamilies 在 JWT 認證後逐請求讀取權威 family,拒絕撤銷/到期/脈絡不符, 儲存暫時故障回 503,未知缺陷回 500;啟動核對引擎/filter 的 profile、store、Clock 與 capabilities。 profile 僅對 Bearer 來源要求 converter 保留已驗章 Jwt principal,否則回 500 並中止授權; 可同時啟用 API key/Basic,其 AuthPrincipal 沿各自政策,不進入 JWT family 查核;匿名仍由原 policy 判定。 DefaultAuthPrincipal 保留舊 username fallback,但新 profile 以不可偽造的來源標記拒絕未明示 immutable subject 的實例。 採用時需與 Web 19.0.0-alpha.136 配對;HTTP 殼在 proof 前檢查 protocol,將 replacement/activation 與 metadata 組裝放在同一交易,commit 後才寫 Cookie。replacement/Cookie-only logout 共用秘密 selector 持有證明, 允許舊或未知 verifier 撤銷其 family;refresh 仍要求 current verifier,失敗不先撤舊。 未 opt-in 保留期限/remembered 的組裝選項;共用 refresh credential 前置驗證、resolved principal 簽章、 受保護 sessionKind claim 與 store 的 policy-inclusive expiredAt 判定也影響 legacy caller,須執行參考 Server 回歸。

  • flow PageSummary 左右內距與分節頁次(4.0.0-alpha.38):PageSummary.Builder.padding(top, right, bottom, left) 讓左側內容自內容區左緣加左內距起排、 右側內容右緣止於內容區右緣減右內距,可與表格儲存格文字對齊;摘要上線仍畫滿內容區,不換行、超寬失敗的語意不變。 PageSummary 的 everyPage/lastPage 及 Section.summaryLastPage 可使用 SectionPageNumber/SectionPageCount, 解析為所在頂層 pageBreakBefore=true Section 的頁碼與排版後回填的實際總頁數;一份文件即可逐段印「頁次:段內頁/段內總頁」與段末總筆數。 不屬於頂層分頁分節的頁面使用分節頁次時建構期回報 report.model.inline.page-field-outside-region,排版期無法歸屬則回報 report.layout.section.summary-outside-section,不靜默改用全份頁碼;文件固定頁首尾仍不允許分節頁次。 左右內距總和不得達到內容區寬度。保留 PageSummary 八參數建構器(左右內距為 0)與 padding(top, bottom); 未設定左右內距及未使用分節頁次者 PDF bytes 不變。新增 paddingLeft/paddingRight record components 影響反射、record 解構及 equals/hashCode/toString。

  • 固定 PDF(4.0.0-alpha.36)補齊下游報表回報的三個排版缺口: FixedDocument.Line 新增 dashPatternPt/dashPhasePt、withDash 與 DEFAULT_DASH,以 pt 自訂任意虛線並以單一路徑繪製; FixedDocument.Text 新增 letterSpacingPt(非負、行尾不加)與 withLetterSpacing,置中/靠右、換行及超框檢查皆含字距; 新增 io.leandev.appfuse.pdf.FixedTextMeasurer,以與 renderer 相同的字型登錄、fallback 鏈、字距及缺字拒絕規則量測單行寬度、ascent/descent 與實際使用字型。 保留 Line 的 dashed 建構子(等同 3pt/3pt)與 dashed() 查詢、Text 的十一參數建構子;字距 0 與舊虛線的繪圖指令不變。 Line 的 dashed record component 改為兩個 dash components、Text 新增 component,影響反射、record 解構及 equals/hashCode/toString。不提供 kerning。

  • ADR-036 P2(4.0.0-alpha.35,實作第 2 輪人審通過):PublicEndpointContext/PublicEndpointAvailability 中性 protocol/availability 接點,PublicEndpointBinding.credentialContext() 指紋;SessionContextPolicy、SessionContextBinding、ContextBoundPrincipal 綁定簽發與消費環境。LoginService/ClientCredentialsService 保留舊建構子,新增可選 policy;LoginPolicy 保留 functional interface,新增引擎已驗來源 family 的 identity-switch context。Refresh Session SPI 新增 fail-closed activatePending,LoginService 以此進行短效一次性交接,pending 不可普通 refresh/switch。 新增 LoginResult、RefreshSessionStore.NewSession/Session record components;保留舊建構子,反射、解構、equals/序列化 consumer 仍須審視,store adapter 必須保存 binding 並拒絕 pending use/rotate。Mock adapter 保留 binding,未支援 activation 時明確拒絕。

  • ADR-036 P1(4.0.0-alpha.35,實作第 2 輪人審通過):PublicOrigin、PublicEndpoint、PublicEndpointBinding、 PublicEndpointRegistry、PublicEndpointRequest 提供不可變受控入口、scope/protocol 快照與 request attribute 接點。 origin 採 IDNA、大小寫/預設 port 正規化,拒絕重複 scope/origin 與 active/retired 重疊;沒有 Entity、 自動組態或 Host 授權。SignedLinkService.consume(..., Consumer<SignedLinkToken>) 在驗章/用途之後、 single-use 消耗之前提供 context 核對;拒絕不燒毀合法單次 token。 PublicServerUrl 新增 IDNA host 支援;單一 URL 組合契約保留。

  • 固定 PDF(4.0.0-alpha.34)新增預設 FixedPdfRenderer.LayoutPolicy.OUTPUT_WITH_WARNINGS:只將文字超框、元素越頁及建立者宣告的幾何問題降為 warning,完整輸出並保留原字級、座標與內容;嚴格拒絕改為明確選用 STRICT;舊 Options 建構子也使用警示預設。 FixedDocument.LayoutIssue 支援應用的區塊重疊診斷;PdfDiagnostic 帶 severity、穩定 code、1-based 實體頁碼、elementId/regionId 與訊息。 Result.diagnostics()、PdfArtifact.layoutDiagnostics() 及 StoredPdf.diagnostics() 保留診斷;舊產物實作預設空清單。 負座標可建模,嚴格 renderer 仍拒絕越頁;越頁(含筆畫、校準及實際文字)回報可能裁切。缺字、未知字型、資產、容量、取消與 I/O 仍失敗。 保留舊 record 建構子;新增 components 影響反射、record 解構及 equals/hashCode/toString。

  • 密碼登入請求限流草稿(尚未發布、待本次人審與公開契約審議):PasswordLoginRateLimiter、 巢狀 Decision,及 CachePasswordLoginRateLimiter/不可變可序列化 Window。 預設同一連線 IP+trim/lowercase(ROOT) username 五次/滾動 60 秒,提供剩餘名額、reset 與向上取整 retry 秒數。 注入 Clock,原子性僅限單一物件;cache 淘汰/故障/重啟可能遺失配額,非跨節點硬上限。 HTTP、用途 cache 與分散式 adapter 由應用擁有。cache 停用/RuntimeException 時配額未知並繼續認證 (remaining=-1、resetAt=null),不略過密碼、帳號資格、政策或 SQL Session;不做 autoconfiguration。

  • flow 分頁片段配置(4.0.0-alpha.32):TableStyle.withPageBottomRuleOnContinuationOnly(true) 僅在表格內部換頁時 套用 pageBottomRule;自然結束保留原有列/儲存格底線,可讓末疾病與日期同筆不可拆紀錄且日期下方無線。 ParagraphStyle.withRepeatSpaceAfter(true) 在每頁正文段落片段(含單頁與最後片段)計算行數前預留 spaceAfter, 配合 Length.ofMillimeter(2)、TextFlow.preWrap() 保留完整文字、空白與原始換行;空間不足時移頁/失敗,不截斷。 兩項配置皆 null 繼承、false 為預設,保留 alpha.31 TableStyle 九參數、ParagraphStyle 十一參數及更早建構器。 未啟用時 PDF bytes 維持相容;新增 record component 影響反射、解構及 equals/hashCode/toString。

  • 非同步審計能力(目標 alpha.33,四輪人審通過、待發布):AsyncAuditEventRepository、AuditControl/AuditStatus、 AuditWriteSettings、AuditStorage/AuditEventSnapshot、JdbcAuditStorage/AuditJdbcMapping、 AuditConnectionDisposer 與安全分類 AuditStorageException。有界 JSON 快照與佇列、單一 daemon writer、 啟動/執行中開關、generation admission、明確 in-flight 批次邊界及終止生命週期。 JDBC 使用應用擁有的 DataSource/SQL 映射與 PreparedStatement,直接持有 lease,不加入業務交易; commit 未知或清理不安全時 FAILED,淘汰或隔離 lease,不能一般歸還池/關閉共享 DataSource。 支援版本驗證為 HikariCP 7.0.2;其他 pool 需明確 disposer。事件可遺失(含 admission gate 忙碌)、無重試、查詢僅讀已持久化資料; add 不等待鎖,競爭時以 BUSY 略過,即使佇列未滿亦可遺失。 AuditControl.setEnabled(boolean, String) 支援真實操作者;相容三參數 overload 可另傳模擬時的有效 subject。 實際切換的時間/操作者/模擬 subject(有值時)/前後狀態 由既有工作者輸出 INFO 日誌,操作 queue 與事件 queue 各自有界,事件停用仍嘗試記錄。 日誌阻塞/失敗不回到呼叫端;RuntimeException 後停止操作 logger,計數可查;停止丟棄待記操作。 操作者及可選 subject 各截斷100字元並替換控制字元,不帶 payload/原始例外;LOGGED 不是保存確認,不保證完整追責。 日誌安全需保留指南的 ProxyConnection/PoolBase/HikariPool logger OFF 設定;保留洩漏偵測, 固定背景計數取代原始 throwable。停用/清空只累計正常控制計數,不持續 WARN;狀態計數保持固定順序。 新寫入路徑已實測 H2/Hikari 受控故障,另以真實 MySQL 8.4.0/Connector/J 9.5.0/HikariCP 7.0.2/ JDK 25.0.3(JVM Asia/Taipei)驗證預設 client-prepared 批次、UTC 微秒、中文 JSON、UUID、排序與排他 after。 Docker IT 仍跳過;Hibernate 歷史互通、MySQL 故障注入及 PostgreSQL/SQL Server/Oracle 尚未驗證,由 FU-136 承接。 本項於2026-10-11經 Dev Lin 四輪人審通過(10 done、2 declined 均接受);相關測試163通過、1 MySQL 跳過。 保留 BUSY 不等待取捨;作者缺少 security-review skill,由審閱方第2輪補跑、第3/4輪人工複核, 未將作者人工預檢冒稱 skill 執行。FU-136 其餘資料庫驗證與 ⭐ FU-137 的產品決策保留,詳見 RFC 審閱結論。 原 PersistentAuditEventRepository 與 JPA base/converter 保留原同步契約;參考 feature provider 另遷移到 storage provider,見 Feature Changelog 與 審計事件指南。

  • flow 表格線型(4.0.0-alpha.31):LineStyle.dashPattern/dashPhase、withDash、dashed/solid; Length 轉 PDF pt,預設實線、虛線預設2pt/2pt、phase0,建構時驗證非負有限、非全零與PDF可表示範圍。 CellBorders、TableRowStyle 及其他既有 LineStyle 消費點均可配置;PDF虛線狀態保存/還原,後續實線與Figure不受影響。 TableStyle.withPageBottomRule 在每個含表身的實體頁片段底緣以全寬線取代同底緣的列線/儲存格水平邊, 涵蓋續頁、可拆列、不可拆紀錄及最後頁;不占排版空間。預設null不覆寫,零寬停用上層覆寫,空表只保留表頭。 保留LineStyle雙參數及TableStyle七/八參數建構器,未opt-in的繪製路徑維持相容; 新增record component影響反射、解構及equals/hashCode/toString。 指南 /server/guides/core/report-table-lines、完整公開API範例 examples/table-lines。

  • flow 人工文字配置 ParagraphStyle.withTextFlow(TextFlow)(4.0.0-alpha.30):opt-in保留行首尾/連續半形空白、 原始換行與空行、依tab-size或固定Tab stop定位,正常斷點優先的overflow-wrap:anywhere、grapheme安全拆詞, 可選CJK避頭/避尾與標點懸掛。withTrailingEmptyLine(true)額外保留末尾空行; ParagraphStyle.withRepeatSpaceBefore(true)在頁頂及備註續頁重複段前距。 Paragraph、materialized table cell及分節Block頁首共用量測/高度;九參數ParagraphStyle建構器與未opt-in的PDF bytes維持相容。 ParagraphStyle新增record component會影響反射、解構及equals/hashCode/toString;新段距配置適用正文Paragraph的續頁片段。 新增原始glyph/座標測試、Times/Kai/Msjh+Chromium逐行逐頁範例及100案8,000字128MB容量回歸。

  • flow 分節全寬 Block 頁首尾:SectionPageRegion.blocks 與 Builder.blocks(Block...)/blocks(List<Block>), 由不可變 4.0.0-alpha.29 提供。可組合獨立 Paragraph/Heading 樣式、materialized Table、Spacer、KeepTogether, 支援全寬抬頭與三組組內靠左資訊;同一 flow 排版器量測實際高度、保留正文空間並每頁重印一次。 與左中右 Inline 互斥;換頁、Section、lazy 來源、每頁紀錄數、圖形及 Block 頁碼欄位拒絕。 高度不足或表格需裁切時停止,文字不縮字、不截斷;頁尾沿用 Inline 分節頁碼。 保留 alpha.28 十參數建構器與原 Inline 繪製路徑;新增 record component 影響反射、解構、equals/hashCode/toString。 區域只讀與量測一次,額外記憶體為各節區域 boxes;指南與完整可執行範例位於既有分節頁首尾入口。

  • flow 分節每頁頁首尾:SectionBuilder.header/footer、Section.header/footer 與 node.SectionPageRegion(左中右換行、文字/行高、分隔線、上下內距、gap、最小預留高度及頁碼位數)。 node.SectionPageNumber/SectionPageCount 依實際排版逐節從1計數,涵蓋所有表格及段落續頁; PageNumber/PageCount 即使在分節區域內仍為全文語意。僅頂層 pageBreakBefore=true 的 Section 可配置, 頁面獨占本節,節後下一內容自動起頁;未配置的一端繼承文件區域,配置者取代同端文件區域。 同頁多表只印一次分節頁首;表格 repeatHeader 不變。空節、區域過高、單一字形超寬及頁碼超過預留位數均停止產生, 不裁切或縮字。lazy 來源只讀一次,回填記憶體 O(頁數+節數),保留取消與兩階段零部分輸出。 保留 Section 已發布四/五參數建構器;新增 components 影響反射、toString、equals/hashCode、record 解構模式; Inline sealed permits 新增兩型別,下游 exhaustive switch 須補對應 case。未使用功能的排版及全文頁碼保持。 下游使用範例見 examples/section-pages,指南見 /server/guides/core/report-section-pages。 由不可變版本 4.0.0-alpha.28 提供;1,000頁為RPT交付限制,使用既有RenderOptions.onPage設定,不新增全域預設上限。

  • 流式報表分節最後內容頁摘要:ReportDocument.Builder.section/SectionBuilder.summaryLastPage、 Section.summaryLastPage 與 node.PageSummaryOverride(left, right)。各側依分節覆寫 → 文件最後頁 → 一般頁繼承; null/未設定繼承,空清單/空 varargs 明確清空,支援 Text、PageNumber、PageCount 及明確換行。 共用文件摘要的線、間距及樣式,左右基線對齊,所有覆寫高度納入每頁預留。依實際最後內容頁綁定, lazy 來源只讀一次;結尾換頁不會把覆寫移至空白頁。巢狀 Section 覆寫、缺少文件 PageSummary、 無內容分節、同頁兩個覆寫分節結束及超寬內容均回報驗證錯誤,產生失敗不輸出部分 PDF。 保留 Section 已發布的四參數建構器;新增 record component 影響反射、toString、equals/hashCode, 舊四元件 record deconstruction pattern 須調整後重編;不保證 wire schema。 未使用分節覆寫的文件保持原繪製路徑;固定字型、metadata 與 PDFBox 版本的 alpha.24 完整 bytes 回歸比較。 本項於2026-10-06經兩輪人審通過,3條意見均已處置;由4.0.0-alpha.26提供。混合字級僅覆寫頁共用基線;完整 bytes 回歸限記錄的 PDFBox/JDK/平台/壓縮前提,不符時明確略過。

  • 流式表格 Cell.withRowSpan(1..256):限紀錄模式內不可拆明細列,同筆跨列格可垂直置中並畫完整外框, 續列省略已佔用欄位;內部逐列線略過跨列區段、合併外緣保留,整筆底色先於框線繪製。 跨紀錄/跨頁、懸空及重疊拒絕。有限預看最多256列+邊界列,不累積整份文件。 TableBuilder.firstHeaderRow/Table.firstHeaderRows 覆寫首張表格頁的表頭,續頁使用既有headerRows; 空覆寫維持舊行為,不能產生只有首頁表頭而無首筆的頁面。保留已發布的Cell四參數及Table六/八參數建構器;不納入未發布的Cell五參數過渡建構器, 四邊/跨列設定使用with方法或完整六參數建構器。 record components變動的反射/模式解構限制同下列四邊框線。於2026-10-05經兩輪人審通過,由4.0.0-alpha.25提供。

  • 流式表格四邊框線:CellBorders、TableStyle.withCellBorders 與 Cell.withBorders, 各邊 null 繼承、零線寬明確不畫。跨欄畫合併後外緣,跨頁列的每個實體片段重新收框; 線不增加內距或列高,相鄰邊獨立繪製,不提供 CSS border-collapse。 列分隔線仍獨立存在,可用既有 TableRowStyle.noRules() 關閉;未設定新 API 時不增加框線。 保留 Cell 四參數與 TableStyle 七參數建構器;新增 record component 會改變反射、 toString、equals/hashCode,舊元件數的 record deconstruction pattern 須調整後重編。 本項於2026-10-05經兩輪人審通過,由4.0.0-alpha.25提供;Java authoring API 不包含 HTML importer。

  • 流式報表可選版面控制:TableBuilder.columnTitles(false) 以指定 headerRows 完全取代自動欄標題; TableRowStyle 覆寫逐列內距與上下線,零線寬明確不畫線,null 保留繼承。 ReportDocument.Builder.pageSummary/PageSummary 在每頁內容末端排列左右摘要,預留較大高度、 延後回填頁數並支援最後頁合計;超寬摘要失敗而非截斷。既有表頭、每列線及固定頁尾預設不變; Table 六參數及 TableRow 五參數建構器保留。此為 Java authoring API,不提供 HTML importer。 新增 record component 會改變反射/toString、equals/hashCode,舊元件數的 record deconstruction pattern 需調整後重新編譯。 TableBuilder.maxRecordsPerPage 配合 TableRow.withRecordStart(false),以每筆多列的完整高度與每頁紀錄數共同分頁; 預設0維持舊模式,正值時整筆不拆頁,過高或超過有限預看上限回報 ERROR。表頭/合成續接列不計數。

  • StorageKeys 與 InvalidStorageKeyException(file/):驗證 partition 與 fileId/tempId 的共用規則。 partition 必須是單一區段;fileId/tempId 是以 / 分隔的相對路徑,任何區段都不可為空白、.、.., 不可含 \、: 或控制字元。驗證失敗拋 InvalidStorageKeyException(繼承 FileStorageException), 訊息不含原始值。自訂 FileStorage 實作應在組路徑或 object key 前呼叫。

  • 中立 PDF 交付 API:PdfArtifact、StoredPdf、StoredPdfs.save;流式 ReportArtifact 繼承 PdfArtifact,固定 renderer 提供串流輸出及回傳產物的 render(document, options, filename)。 保存成功或失敗均關閉產物;開啟中的串流保留至讀取端關閉。固定 Options.onPage 提供完成頁數, Options 提供含取消回呼的五參數及另含進度回呼的六參數建構子;固定排版失敗映射為 report.invalid,錯誤訊息不洩漏 payload。 中立交付型別遵循一般 API 相容政策,只有 Fixed* 型別標記為實驗性。

  • 固定版面表單(實驗性):新增 io.leandev.appfuse.pdf.FixedDocument、FixedPdfRenderer 與 FixedLayoutException,以 mm 指定頁面、文字框、線、矩形、橢圓、PNG/JPEG、PDF 頁面底稿與向量條碼。 與流式 ReportDocument/ReportArtifact 分離,共用 ReportFonts 與選用 PDFBox runtime。 支援完整表單/只印可變資料圖層、平移校準、固定框內換行、座標邊界及缺字/溢位拒絕; 明細容量與續頁由應用範本依 RPT 明確建立。產生完成前不寫入 caller OutputStream,取消或產生失敗不交付半份 PDF。 Code 39/Code 128/QR 另需應用加入選用 com.google.zxing:core:3.5.3;不提供代收機構業務檢查碼、 AcroForm 或數位簽章。合成範例及限制見固定表單契約文檔。

  • FileStorage 區段讀取(ADR-029、REP-06):新增 default 方法 rangeSupport() 與 openRange(partition, fileId, offset, length),以及列舉 RangeSupport(NATIVE/NONE)。預設為 NONE, 下游自訂 backend 不必改碼;LocalFileStorage(檔案通道定位)、S3FileStorage(Range 請求)、 AzureBlobFileStorage(blob range)、SftpFileStorage(帶 offset 的讀取請求)為 NATIVE, TransactionAwareFileStorage 轉送被裝飾者的能力。RangeUtils.evaluate(header, size) 回傳 RangeRequest(Absent/Satisfiable/Unsatisfiable),先檢查語法再判斷可滿足性,反向區間與超出 long 的數字不溢位。FileResponseBuilder 新增 request(HttpServletRequest)(一次讀取方法、Range、If-Range、 If-None-Match)、method(HttpMethod)、ifRange(String) 與常數 RANGE_EXPOSED_HEADERS(跨來源前端如 PDF.js 需 expose 的 headers);ZipFileResponseBuilder 新增 request(...)、method(...)、ifRange(...)。

  • io.leandev.appfuse.report 與 io.leandev.appfuse.report.node(實驗性):與 renderer 無關的報表文件模型 (ADR-029、REP-02)。ReportTemplate<T> 把應用資料轉成不可變的 ReportDocument;節點含段落、標題、表格、 圖片、Figure、KeepTogether 等,樣式分為覆寫值(null 表示繼承)與完整的 DocumentStyles。 ReportDocument.Builder#build() 執行建構時驗證,失敗丟出帶 diagnostic 清單的 ReportValidationException。 大型明細以 RowSource.lazy(...) 延遲到 render 時才取得資料,並由 renderer 在讀取時逐列驗證; GroupedRows 以事先註冊的 Collector 做串流分組與彙總,記憶體與列數無關。

  • 報表 PDF renderer(實驗性,ADR-029、REP-03):PdfReportRenderer.create(ReportFonts) 建立可重用、 thread-safe 的 ReportRenderer;render(document, out, RenderOptions) 回傳頁數、寫出位元組數與 INFO/WARNING diagnostic,ERROR 以 ReportValidationException 傳出。ReportFonts 以邏輯家族名稱登錄 靜態 TrueType 字型與 fallback 順序(FontSource.file/classpath/bytes),build() 拒絕 CFF 輪廓、 字型集合與可變字型,並要求預設家族含替代字元 □;框架基準樣式使用家族名稱 sans。 輸出分兩階段:完整 PDF 先寫入暫存檔,產生階段的錯誤或取消(RenderOptions.cancelWhen、執行緒中斷) 丟出例外且不寫出任何位元組(取消為 ReportRenderCancelledException);未提供 createdAt 時建立時間取自 RenderOptions.clock。 PDFBox 為選用相依(compileOnly):使用報表的應用須自行加入 org.apache.pdfbox:pdfbox 3.0.8 以上、 4.0 以前的版本;缺少或版本不符時 ReportFonts.Builder#build() 與 PdfReportRenderer.create 丟出 IllegalStateException(無法取得版本資訊時不阻擋)。

  • 報表 Figure 與圖表(實驗性,ADR-029、REP-04):FigureContent.Svg 只接受受限 SVG 子集(白名單元素與屬性、 無 DOCTYPE 與外部資源、複雜度上限與衍生數值範圍),於 ReportDocument.Builder#build() 驗證,不合法時回報 report.model.figure.svg-invalid/svg-unsupported/svg-too-complex/svg-out-of-range(ERROR);renderer 直接繪製成 PDF 向量,圖內文字以已登錄的字型輸出為可抽取的文字。新增 FigureContent.raster(ImageData) 供外部圖表函式庫的 PNG/JPEG 輸出。新套件 io.leandev.appfuse.report.chart:BarChart、LineChart(builder 與 toFigure(FigureFrame))、 ChartSeries、ValueAxis,含數值軸自動刻度、類別標籤抽樣與截斷、圖例、null 中斷折線與無資料狀態;資料不合法或 Figure 太小時丟出 IllegalArgumentException。

  • 報表產物與交付(實驗性,ADR-029、REP-05):ReportRenderer#render(document, RenderOptions) 回傳 ReportArtifact——以暫存檔承載的完整 PDF,含 content type、建議檔名、大小、頁數、文件時間 createdAt、metadata 與 diagnostic;可多次 openStream()/transferTo(out),呼叫端擁有並須 close(),關閉後刪除暫存檔(仍開啟的串流 讀完關閉時才刪),刪除失敗只記 WARN report.resource.cleanup-failed 不丟例外,漏關時由 Cleaner 回收並記 WARN。 RenderOptions 新增 onPage(IntConsumer) 逐頁進度回呼與 filename(String) 建議檔名(未指定時取文件標題, 否則為 report.pdf;移除路徑與控制字元並補 .pdf)。StoredReports.save(FileStorage, partition, artifact[, Clock]) 保存到既有 FileStorage 並接管產物(無論成敗皆關閉),回傳 StoredReport(fileId、檔名、大小、頁數、文件時間 createdAt 與保存時間 storedAt;保存期限以 storedAt 計算);partition 由應用決定,租戶模組取目前 tenant ID, 無租戶模組取應用自訂的資料範圍。ReportStatus(Generating/Ready/Failed/Expired,status() 對齊前端 ReportViewer)供非同步流程回報狀態,Failed.of(Throwable) 不外洩例外訊息。新套件 io.leandev.appfuse.report.web:ReportResponses.attachment/inline(artifact, request, response) 以同步方式把產物 寫入 HttpServletResponse(GET 送出內容;HEAD 只送 headers 不讀產物;其他方法丟 IllegalArgumentException; Cache-Control: no-store、RFC 6266 filename*),返回前一律關閉產物。保存後的下載沿用 FileResponseBuilder。

  • 報表選頁列印(實驗性,ADR-029、REP-08):ReportPages 解析 pages query 的選頁語法(1-3,5,8;1 起算、 不接受空白與前導零、上限 4096 字元),正規化為遞增、不重疊的區間,只以區間表示、不展開頁碼,頁碼上限為 ReportPages.MAX_PAGE(999999999); requireWithin(pageCount) 確認頁數後才可 forEachPage 逐頁走訪,size() 為 long。語法錯誤、反向範圍、 超長與超出頁數丟出 ReportPageSelectionException(繼承 InvalidDataException,經標準例外處理回 400; getCode() 為 report.pages.*)。PdfReportSubsets.extract(storage, partition, fileId, pages) 從已保存的 PDF 即時擷取選取的頁面,回傳暫存 ReportArtifact,交給 ReportResponses.inline 寫出後刪除、不保存 subset; 分區內找不到檔案丟出 NotFoundException。保留頁面外觀、允許清單內的標示 annotation(去除 action)、 http/https/mailto 連結(重建 URI action)與文件資訊,以原 physical page 設定頁碼標籤;移除 outline、 內部連結、附件、多媒體與其他主動內容;表單先重新產生外觀再 flatten,動態 XFA、有值卻無外觀的欄位、 加密或無法解析的 PDF 丟出 IllegalStateException。授權、資料範圍與保存期限由應用端點負責 (不存在或無權讀取回 404,已知過期回 410);subset 是列印用的節錄,不提供未選取頁資料的保密隔離。

  • io.leandev.appfuse.annotation.ExperimentalApi:標示實驗性公開 API。實驗性 API 可能不經 deprecation 期 直接調整或移除,移除時仍記入 Removed。

  • java.time.YearMonth Entity 欄位自動映射:以 ISO 8601 YYYY-MM 字串存成 VARCHAR(7), 不需 @Convert 或 @Column(length = 7),也不依賴應用的 entity scan 範圍(框架以 Hibernate TypeContributor 註冊)。既有欄位的遷移見下方 Breaking Changes。

  • web.url.PublicServerUrl、PublicServerUrlResolver 與 PublicServerUrlContext:提供與 request 無關的對外位址驗證、路徑/URI 組合及顯式 scope 選擇接點。應用明示組裝 resolver;既有 LinkBuilder 相對 href 行為保留(ADR-034)。

  • SftpSshClientFactory 提供檔案儲存、交易 journal 與暫存清理共用的 SSH client 建立入口。 create(serverKeyVerifier) 預設隔離 ~/.ssh/config、預設私鑰與 SSH agent; createWithUserConfiguration(serverKeyVerifier) 才明確啟用個人 SSH 設定。 兩者都要求應用提供主機金鑰驗證器,回傳未啟動的 client,由應用管理生命週期。 Feature 的 FileStorageConfig 升級後應改用此 factory,並將同一 bean 注入三個 SFTP 元件; 明確指定的密碼/私鑰仍可使用。既有自行建立的 client 不會被框架暗中修改。

  • ErrorDisclosurePolicy 與內建 MaximalErrorDisclosurePolicy/MinimalErrorDisclosurePolicy: auth、resource server 與 REST exception handler 把詳細/最小兩個 ProblemDetail 保留到最終 response 邊界再選擇。框架無參組裝預設最大揭露(實際錯誤語意、例外型別、訊息與有界 stack trace,預設最多 16,384 字元);應用可注入最小或自訂政策。最大政策會複製 mapper 產物再加 診斷,避免共用 ProblemDetail 跨 request 汙染;SPI 契約仍要求 mapper 每次回傳新實例。

  • Bearer WWW-Authenticate 的 error_description 改採 RFC 6750 ASCII 白名單並限制 256 字元, 阻擋 CR/LF header injection、非 ISO-8859-1 寫出失敗與過大 header。

  • RefreshSessionRejectedException:refresh session 拒絕不再於引擎內壓平成同一句訊息,保留 missing、expired、revoked、eligibility、rotation/replay 與 policy rejection 等實際原因。

  • ObjectMapperBuilder.withLowercaseEnums() — enum 的 wire 值一律小寫(PENDING_CONFIRMATION 序列化為 "pending_confirmation",反序列化容忍大小寫),取代逐個 enum 標註 @JsonValue / @JsonCreator 的樣板。預設關閉:既有應用可能已把 UPPER_SNAKE 常數名 當成 wire 值,開啟即變更其 API 契約,遷移時機由應用自行決定。不影響 DB 持久化 (@Enumerated(EnumType.STRING) 仍存 name())。

  • LowercaseEnumJsonMapperCustomizer — 讓 HTTP 層的 enum wire 值一律小寫。必須與 ObjectMapperBuilder.withLowercaseEnums() 分開註冊:Spring Boot 的 message converter 取用 自動配置的 JsonMapper bean,不是應用自宣告的 ObjectMapper bean,故只設後者對 request/response 序列化無效。以 @Bean JsonMapperBuilderCustomizer 顯式 opt-in,框架不 auto-configure。

  • EnumWireValues.of(Enum) — enum wire 值的純量形式,與 LowercaseEnumJsonMapperCustomizer 同一條規則。應用把 enum 手工轉成字串時(塞進 PropertyMap、稽核 payload)必須用它: String.valueOf(e) 與 e.name() 走常數名(大寫)、不受任何 mapper 設定影響,會讓同一個 概念在 API 回應與稽核事件裡呈現兩種值。

  • DeclaredCodeEnum — 自帶對外代碼的列舉契約(單一 getCode())。該概念已有既存代碼形式時 (代碼表、既有系統、已上線契約),代碼先於本系統存在、導不出來也不該導;實作本介面即宣告 「wire 值是 getCode()」。EnumWireValues.of() 因此對它回代碼而非小寫導出值,應用的架構 閘門也能據此分辨「宣告式代碼」與「純列舉偷偷脫隊」。

  • StringToEnumConverterFactory — 查詢參數/路徑變數的 enum 綁定改為大小寫不敏感。Spring 內建 轉換器走 Enum.valueOf,小寫 wire 值用在 @RequestParam / @PathVariable 時會被拒;於 WebMvcConfigurer.addFormatters() 以 registry.addConverterFactory(...) 註冊即可。語意與 Converters.convert() 的 enum 分支一致(RSQL filter 值與 MVC 參數綁定是同一規則的兩個 surface)。

  • MockAuthSessionController.inMemory() — 顯式 opt-in 的 process-scoped mock auth HTTP adapter;以原生 HttpOnly Cookie 保存 opaque credential,並透過既有 RefreshCredentialCodec/RefreshSessionStore 語意提供 rotation、replay revoke、expiration、remember-me 與 identity replacement。框架不 auto-configure;host 僅在 mock mode 建立 bean。預設 Cookie 名 APP_SERVER_REFRESH 由 DEFAULT_COOKIE_NAME 公開,亦可於 factory 覆寫。

  • LoginService 新增 RefreshSessionRotationMode 組裝入口:production-safe 的 STRICT 維持 compare-and-rotate/replay revoke;開發環境可由應用層明確選用 REUSABLE,以原子 credential-use 滑動 idle window 並保留同一 credential,支援無法共用 Browser Web Lock 的 多個 localhost origin。既有建構子仍預設 STRICT。

  • RefreshCredentialCodec 改採秘密 selector + verifier;RefreshSessionStore 新增 selector lookup, 讓 Server 可辨識已輪替 verifier,而不暴露或依賴 Access Token 內可見的 Session ID。

  • LoginService.refreshSession() 啟用 strict compare-and-rotate:成功即簽發下一代 credential;舊 generation replay 會撤銷整個 Session family 並 blacklist 目前 access family。principal lookup、 eligibility 與 Access Token 簽發都在 rotation 前完成,基礎設施/簽發失敗不會消耗 credential。

  • RefreshSessionStore 新增原子 credential-use/滑動 idle primitive,Session snapshot 可保存 actorSubject;LoginService 的 impersonation family 切換與 refresh 會維持 actor context。

Security​

  • RefreshSessionStore.Session 可保存 SessionPolicyBinding,LoginService 新增可插拔的 SessionEligibilityPolicy refresh-time revalidation 與 revokeSessionsByPolicy(...) 主動撤銷入口。 impersonation family 現在會在每次 refresh 重驗 subject/actor 帳號狀態、actor 必要 authority、 絕對 policy 效期與應用政策;拒絕時撤銷整個 family 並 blacklist 目前 access session。
  • Acting claim/context/request audit 新增 sessionId 與 actingPolicy,ACTING_ACCESS 同時記錄 stable subject、真實 actor、0..n grantors 與可並存的 actingModes,讓 delegation 與 impersonation 的有效身份、操作者、授權來源及 session family 可完整追溯。
  • 新增 SessionOptions、RefreshCredentialCodec 與 persistence-agnostic RefreshSessionStore capability:登入引擎可簽發 256-bit opaque Refresh Credential,Store 只保存 SHA-256 hash,並以 typed compare-and-rotate/revoke result 表達 Session family 狀態。LoginService 新增接受 app-owned store、Clock 與產品 idle policy 的組裝入口,以及 refreshSession() token-pair 回傳;未接 store 的既有組裝仍維持 JWT refresh 相容行為。
  • ProblemDetailFactory 新增 authenticationRequired、accessTokenInvalid、 invalidCredentials、authenticationServiceUnavailable 與 insufficientPermission,統一提供 stable RFC 9457 type/errorCode 供 auth client 可靠分支。
  • 新增 PersistenceEncryption 與 TextCipher:應用可用單一 32-byte root key ring,依固定 purpose 經 HKDF-SHA-256 衍生隔離的 AES-256-GCM key。密文 envelope 帶格式版本與 key id, 支援新增 active key、保留舊 key 的無停機輪替;框架不讀環境設定,也不暴露 root key。

Env​

  • 新增 ConfigValueMasker:設定屬性值遮蔽規則的單一事實來源(isSensitive / mask / maskUrl)。Environ 的啟動日誌與 ConfigurationDiagnostics 的 actuator 組態快照共用它, 確保「什麼算敏感」只定義一次、不會兩處漂移。

Notification​

  • OutboundMessage.deliveryId() 與 EmailEnvelope.deliveryId() 提供由 Outbox UUID 產生的 stable、 opaque 技術遞送識別;同一列跨 fast-path、輪詢與 retry 保持不變,channel adapter 可原樣傳給 支援 idempotency 的 provider,處理「provider 已接受、回應遺失」後的安全重試。此識別不含 domain event id、收件人或內容,也不宣告兩次業務通知等價。既有 OutboundMessage/ EmailEnvelope constructor 與只實作三參數 MailDelivery.send(...) 的 adapter 維持相容。
  • Outbox 新增可選 deliveryExpiresAt 遞送截止時間;NotificationOutboxDispatcher 在 now >= deliveryExpiresAt 時直接將列標為 SUPPRESSED,不呼叫實際通道,供 OTP 等高時效通知 避免服務停機或排程延遲後寄出已失效內容。既有列的 null 維持無截止時間行為;應用資料表須加入:
    ALTER TABLE notification_outbox ADD delivery_expires_at TIMESTAMP NULL;
  • NotificationService 與 NotificationOutboxDispatcher 新增可選 TextCipher 組裝入口,讓應用在 Outbox 落庫前保護已渲染的 subject、body、deep link,並於實際遞送前解密;既有建構子維持 identity 行為。reference implementation 維持 identity/plaintext Outbox;business data encryption 由應用依資料分類選配,並自行負責 schema、既有資料轉換與 key rotation。
  • NotificationRequest 新增 ccRecipients:EMAIL 仍以每位 recipients 為一封郵件的正本, 並把同一組 CC 放入該封郵件標頭;CC 不另建 Outbox 列,也不另行渲染內容。Outbox 新增 cc_recipient_addresses JSON 欄保存信封關係,確保重試時維持原始 To / Cc。
  • MailDelivery 新增相容式 send(EmailEnvelope) 預設方法;沒有 CC 的通知仍走既有 send(to, subject, htmlBody)。自訂 adapter 若要使用 CC,須覆寫新方法並把 EmailEnvelope.cc() 寫入實際郵件 CC 標頭;舊 adapter 遇 CC 會明確失敗,不會靜默漏寄。
  • MySQL 既有資料庫升級時加入可為 null 的大型文字欄位(舊列的 null 視為空 CC;其他方言 使用對應 large-text 型別):
    ALTER TABLE notification_outbox
    ADD cc_recipient_addresses LONGTEXT;

Cache​

  • Cache 新增原子 remove(key, expectedValue) 契約;Ehcache adapter 直接使用條件式 remove, 讓一次性安全狀態可在同一 backing store 上以 compare-and-remove 消費。
  • 新增 CacheManagerBuilder.disabled() hard-disable primitive:回傳不初始化 Ehcache、 persistence directory 或 offheap budget 的 No-Op manager;用途 cache 仍可完成 bean 組裝,但永遠 miss/忽略寫入且不能在執行期重新啟用。
  • 新增 CacheUnavailableException 與 REST 503 映射,供 signed-link/OIDC exchange 等 一次性 state store 在 cache 不可用時 fail-closed,不再誤報無效憑證。內建登入鎖定、 token blacklist 與 client-credentials rate limiter 明示採 availability-first fail-open 並輸出 warning。

File​

  • SFTP 統一採 Local 相容的 .bin/.meta staging 佈局,persist() 固定以遠端 rename 搬移檔案,不再提供會把內容讀回應用程式再上傳的第二條路徑。開發環境可透過 SFTP 寫入,測試環境則以 LocalFileStorage 直接存取同一個實體目錄。Local 與 SFTP 統一使用保存原始檔名、Content-Type、大小的 key-value sidecar metadata;雙方 journal reader 亦可互讀各自格式。
  • file capability 新增 StagingCleanupTask 與 LocalStagingCleanupTask;Local、S3、 Azure、SFTP 與 database reference backend 現在由 Feature 的單一 StagingCleanupScheduler 驅動,不再各自維護排程與 cleanup 路徑。Local task 直接從 LocalFileStorage 共用的 basePath 推導 {basePath}/staging,修正舊排程掃描另一個 app.staging.path 而永遠清不到檔案的問題;.meta/.bin 以同一 staging object 的最後活動時間原子判斷,避免只清掉半組檔案。所有 task 支援注入 Clock 並拒絕非正值 retention。
  • file Feature 擁有 backend SPI、交易裝飾與清理排程;partition resolver 與 staging REST adapter、wire shape、multipart/batch、authorization、database entity/repository/storage 留在應用 server 的 file reference domain。生命週期與 reference API 設定沿用 app.storage.staging.*,舊 app.staging.*/ app.storage.staging.retention-hours/cleanup-cron 仍採舊鍵優先相容。
  • tenant 與 ownership 的 partition 政策由各自獨立的應用 server 模組作者化;框架不提供 runtime isolation mode,也不依賴任一租戶 context。

Audit​

  • 新增 Entity history 的中立 AuditChangeContext/provider/target 契約,以及 optional Hibernate Envers revision listener adapter。changeset metadata 以 stable subject 保存 subject、actor、0..n grantors、principal kind、request/session/source 與應用選配的 tenant;框架不宣告具體 revision entity,也不把 Envers 變成 runtime 必要相依。
  • 持久 audit sink 對超過欄位上限的 principal 改存穩定 SHA-256 摘要,避免 Bearer 驗證失敗時將完整 credential 寫入資料庫,並防止超長 token 造成稽核寫入失敗。
  • audit Feature 現在提供可直接使用的 in-memory sink 預設,並擁有 audit:ops authority 與 /actuator/auditevents 授權政策;持久 entity、repository 與 AuditPersistenceConfig 才屬 app-server 的 audit reference domain;後者透過 repository provider 選用持久 sink,避免同時建立未使用的 in-memory bean。下游不採 持久化 reference implementation 時,端點仍維持相同的 feature-owned 安全邊界。
  • 修正持久 audit repository 的 after 查詢邊界,從 >= 改為嚴格 >,對齊 AuditEventRepository 的 Instant.isAfter 契約與 in-memory 實作。

Platform Info​

  • 新增 platforminfo.ConfigurationDiagnostics,集中安全遮蔽、property source 追蹤、 profile/設定快照與 datasource 連通性檢查;app-server 的 platform-info Feature 提供固定 Actuator adapter 與授權。datasource 失敗 payload 只回例外類型,完整錯誤留在 server log, 不再反射可能含連線資訊的 exception message。
  • 新增 platforminfo.SystemInfo,統一版本、部署環境與 commit 的缺值語意,供 REST、 Actuator info 或其他呈現面共用。
  • app-server reference implementation 移除與標準 /actuator/health、公開 /api/v1/system/info 重疊的 /actuator/helo,避免回傳完整 request headers 與 Authentication 的敏感資訊面。

Calendar​

  • 新增 BusinessCalendarCatalog capability,集中 Spring bean 名稱正規化、未知行事曆與最大 日期範圍驗證;應用 controller 只需把 HTTP 參數交給 catalog,Feature 可直接採 400 天的 安全預設。

Security​

  • JWT 明確區分 access、refresh、signed-link token family;standalone Resource Server 僅接受 access,SignedLinkService 僅接受 signed-link。新增 expected-purpose consume overload,會在燒掉 single-use jti 前完成用途驗證;cache store 改以原子 compare-and-remove 保證同節點併發僅一個消費者成功。

  • 新增 PasswordlessRateLimiter/CachePasswordlessRateLimiter capability,同時依 正規化 identifier 與 client address 限制公開 passwordless 入口;cache 僅保存 SHA-256 指紋,狀態不可用時 fail-closed。

  • LoginService 移除無效的 remember 參數;Remember Me 是消費端瀏覽器 session 持久化政策,不參與認證引擎的 token 簽發或有效期計算。

  • AuthPrincipal 新增可覆寫的穩定 subject()(預設相容地沿用 username), AuthPrincipalLookup 新增 findBySubject;JWT 簽發/refresh、API key 與 Basic Auth 均以 subject 作為已認證主體鍵。LoginService 同時接受應用自訂的 Authentication request,並可為已完成 OIDC 驗證與 ExternalIdentity 映射的主體建立同一套 app JWT session;tenant-qualified login DTO 與外部 IdP wire flow 不再被迫進入框架 capability。

  • delegation Feature 收斂為 DelegationApplication/LoginActingContributor 窄契約; grant entity/repository、REST、帳號目錄查詢與同隔離範圍政策移至 delegation reference domain。reference grant 不帶 tenant 欄位,tenant 以帳號範圍比對, ownership 下兩側皆 tenantless 時自然退化為同一範圍。

  • 新增通用 ActingAuditInterceptor capability:只負責把 ActingContext 與完成的 HTTP request 解算為 ACTING_ACCESS 事件;acting Feature 提供可覆寫的預設 bean,應用的 reference domain 才決定掛載 URL。controller、response wire 與 /api/** 政策不進 jar。

  • acting 身份契約改以 stable subject 對齊 AuthPrincipal.subject();delegation 維度改名 grantors,明確表示目前登入者的權限授權人。claim 組裝與解析會 trim、去空值及去重; username 等顯示欄位留給 application adapter 解析。

  • 新增 CacheClientCredentialsRateLimiter capability:以應用提供的 TTI/TTL cache 承擔 client-credentials 計數與 key 正規化,預設每視窗 60 次;應用只在改用 Redis、gateway 或不同政策時提供其他 ClientCredentialsRateLimiter。

  • LoginService.refresh() 現在檢查全部四個帳號狀態旗標(停用/鎖定/帳號過期/憑證過期),與 login 路徑(AuthenticationManager 的 AccountStatusUserDetailsChecker)對齊。先前只檢查 isEnabled() 與 interactiveLoginAllowed(),導致已鎖定或已過期的帳號仍能持續刷新 access token。引擎保留精確拒絕原因,由最終 ErrorDisclosurePolicy 決定 wire 投影。

  • 新增 RsaKeyPairs capability,集中 RSA key pair 的安全強度檢查、產生,以及 Base64 PKCS#8 private key/X.509 public key 解析;應用接線只保留金鑰來源與 ephemeral fallback 政策。

  • 新增 DaoAuthenticationManagers capability builder,集中 DaoAuthenticationProvider/ProviderManager 建構並強制接上認證事件發布;消費端仍可選擇 pre-authentication checks,讓互動登入套用 lockout、M2M 保留標準帳號狀態檢查。

  • 新增 ResourceServerSecurity capability builder,集中 stateless resource-server、blacklist、bearer、API-key 與 Basic Auth 的固定組裝與 filter 順序;URL 與授權規則仍由應用決定。

  • signed-link Feature 新增窄版 SignedLinkApplication 組合契約,並保留 SignedLinkService/cache store 的可覆寫預設;帳號查找、通知發布、session 換發、 HTTP URL 與前端導向政策移至 signedlink reference domain。該預設以不可變 account id 作 subject,不依賴 tenant context,可直接用於 tenant 與 ownership。

  • M2M 憑證發放 capability(io.leandev.appfuse.security.auth;ADR-025,自參考實作 feature/serviceaccount 上收)——登入引擎的第四操作,補上 ADR-023 遺漏的一塊:

    • ClientCredentialsService — client_credentials grant 的 orchestration:參數驗證 → scope 拒絕 → 限流 → 憑證比對 → 非互動資格(與登入互為反向:M2M 主體必須 interactiveLoginAllowed() == false)→ 租戶要求 → 簽發。憑證比對委派 AuthenticationManager(因而取得認證事件與統一稽核,引擎不碰 PasswordEncoder;用哪個 manager/是否套登入鎖定由消費端組裝決定)。短效、不發 refresh token(RFC 6749 §4.4.3 SHOULD NOT)、不帶 sessionId。租戶 claim 經 TenantAwareUserDetails 探詢(ADR-017 pattern)
    • 線格式由 capability 承擔(ADR-025 決策二)——ClientCredentialsRequest.of(params, authorizationHeader) 解析 RFC 6749 §4.4.2 參數與 §2.3.1 的 client_secret_basic/client_secret_post(兩者並用 → invalid_request);OAuth2TokenException 承載 §5.2 錯誤碼與 HTTP 狀態;ClientCredentialsTokenResponse 為 §4.4.3 回應。這與登入刻意不對稱:「登入」沒有標準故 wire 歸殼,client_credentials 有標準、讓各消費端各自實作解析與錯誤映射就是漂移來源
    • ClientCredentialsFactory — client_id/client_secret 產生(SecureRandom + url-safe base64,secret 256 bits)。上收理由是「零產品變異、錯了就是安全漏洞」;唯一性重試留消費端(只有持久層知道)
    • ClientCredentialsRateLimiter — 選用的限流接點,置於憑證比對之前(保護密碼雜湊的 CPU,非防猜測)。未提供時建構期發 WARN——公開端點無限流即為 CPU 耗盡面,不讓「忘了接」變成靜默失去保護
    • 租戶要求為建構子的 boolean requireTenant(非 SPI)——變異是一個布林值而非行為,依 21-feature-surface 紀律四的處置順序(值 → 設定;行為 → SPI)。ownership 取向的專案傳 false
  • API key 認證(io.leandev.appfuse.security.auth;ADR-025 決策五)——服務帳號的第二種憑證呈遞,供做不了 token exchange 的外部系統以靜態 key 直接呼叫 API:

    • ApiKeyAuthenticationFilter — 認證後放進 SecurityContext 的是同一個 AuthPrincipal,故走與 client_credentials 完全相同的權限與租戶鏈(UserDetailsTenantIdResolver 自主體解析租戶,零額外接線)。三條刻意行為:無標頭不介入、驗證失敗不自行回應(僅不設認證,fail-closed 交給 chain 統一回 401,錯誤形狀一致)、只接受非互動主體。jar 不做 autoconfiguration,由消費端在 filter chain 註冊、預設不註冊
    • ApiKeyHash — SHA-256(非 bcrypt):key 為 256 bits 隨機、無字典攻擊面,慢雜湊擋的是不存在的威脅,而 api-key 逐請求呈遞、用 bcrypt 是自我 DoS
    • ApiKeyPrincipalLookup — key 雜湊 → 主體的 SPI;持久化與 last-used 節流歸消費端
    • ClientCredentialsFactory#newApiKey — ak_ + base64url(32 bytes)
  • 登入引擎 capability(io.leandev.appfuse.security.auth;ADR-023,自參考實作 feature/auth 上收):

    • LoginService — login/refresh/logout 三操作的 orchestration:authenticate → 互動式登入資格 → 登入政策 → 代行合併 → 簽發;刷新驗 token → 黑名單檢查 → 重載當前權限重鑄(非 claim 複製);登出 session 進黑名單。無 autoconfiguration(ADR-014),由消費端組態 @Bean 建立(參考 app-server 的 SecurityConfig#loginService)。租戶 claim 經 TenantAwareUserDetails 探詢——引擎不假設租戶欄位存在(ADR-017 pattern)。wire 面(HTTP mapping、DTO、例外映射、使用者資訊查詢)歸消費端 HTTP 殼
    • AuthPrincipal — 引擎對「可登入主體」的 UserDetails 式最小契約;唯一新增成員 interactiveLoginAllowed()(服務帳號拒絕的一般化——引擎不認識帳號分類概念)
    • AuthPrincipalLookup — 主體查找 SPI(刷新與登入 fallback 用;與 UserDetailsService 共用資料來源保證權限一致,參考 app-server 的 AccountDetailsService 兼任)
    • LoginPolicy — 登入把關政策接點(ADR-018 決策五;宿主自參考實作上收,簽章由 AccountBase 改 AuthPrincipal,政策實作 downcast 自家 adapter 取 per-project 欄位)。兩種拒絕通道(ADR-023 修訂,源自 ict fleet 實測回饋):回 false = 不透明 401;拋 LoginPolicyDeniedException(或子類)= 可辨識拒絕,引擎 audit log 後原樣傳播、消費端殼映射專屬狀態碼(如多前端「此帳號不可登入本系統」→ 403)
    • LoginPolicyDeniedException — 政策可辨識拒絕的例外型別(AuthenticationException 子類;可子類化攜帶 per-project 脈絡)
  • 主體目錄 SPI(io.leandev.appfuse.security.directory;ADR-024 決策三):

    • PrincipalView — feature 對「其他主體」的唯讀視圖契約(username/id/displayName/tenantId/interactiveLoginAllowed/primaryRoleName/roleNames/authorityNames;選用 email()〔通道非身分、預設 empty——ADR-024 fallback 條款,供通知收件人組裝;無 email 概念的實作回 empty、消費端退 RecipientResolver〕);刻意排除角色階層展開(wire 契約歸消費端)與密碼憑證
    • PrincipalDirectory — 查找 SPI(findByUsername/findById〔供簽章連結等以不可變 id 為 subject 的機制精準載入〕/findByEmail〔重複容忍,ADR-018 決策三〕/findAllInteractive);scope 過濾不進契約、歸 feature 以 tenantId() 自行過濾。消費端以自己的帳號持久化實作、回不可變快照(參考 app-server 的 AccountPrincipalDirectory)
  • security 貢獻點 SPI(io.leandev.appfuse.security;自參考實作 feature/auth 上收):

    • SecurityContributor — feature 自有授權規則的貢獻點:消費端組唯一 filter chain、不點名各 feature 路徑;authorize(registry) + getOrder(),撰寫規則(matcher 互斥、不註冊 anyRequest()、以 order 收緊)隨 javadoc 出貨
    • AuthorityContributor — Authority 名稱的貢獻點(原 AuthoritySeedProvider 改名):feature/業務域宣告自己擁有的權限名(resource:action),消費方式歸消費端
    • security.auth.LoginActingContributor — 登入代行貢獻點(含 ActingLogin record):登入流程只認介面,實作由選用 feature(如 delegation)提供;與既有 ActingClaims/ActingContext 同家族。ActingLogin.roles 語意為合併後的直接角色(ROLE_ 前綴)——角色階層展開是 wire 契約、由消費端 wire 組裝層執行(ADR-024)
  • mail 動態解析與外寄政策上收框架(io.leandev.appfuse.mail;原住參考實作 feature slice):

    • Mailer 介面 — 消費端面向的寄信契約:compose()(from 預填)/send(MimeMessage)/testConnection()
    • DirectMailer — 原 Mailer 具體類更名;政策(MailOutboundPolicy)與 defaultFrom 改為建構參數,「所有 Mailer 走同一段政策碼」由建構子擔保
    • RoutingMailer — 寄信當下依 MailerSpecProvider 解析目標(範圍專屬設定 → 系統預設 fallback)、revision 指紋快取自動重建、named() 具名解析不 fallback;比照 Spring routing DataSource 模式,消費端只認 Mailer 介面
    • MailOutboundPolicy — 不可變外寄政策物件(firewall/allowed-domains/debug/redirect/notice),builder().build() 內含 fail-fast 驗證(危險組合於已配置郵件時擋啟動、未配置時 WARN 且政策仍生效)
    • MailerSpec/MailerSpecProvider — 郵件設定契約與來源 SPI(設定怎麼存歸消費端;範圍不明確時回空)
    • MailUnavailableException — 解析不到任何郵件設定時由 RoutingMailer 於寄信當下拋出
  • jpa.tenant.TenantContextIdentifierResolver — 把 TenantContext 接到 Hibernate 原生多租戶的 CurrentTenantIdentifierResolver。無依賴、可無參建構(供 Hibernate 以類名字串實例化)。無租戶 context 時回報 ROOT_TENANT 哨兵並經 isRoot() 宣告可存取所有分區——系統管理員與背景執行緒的跨租戶視野由此成為宣告模型,而非舊機制中「無 context → aspect 跳過」的湧現副作用

  • TenantAwareEntity 新增 @PrePersist 守衛:未指明租戶(未顯式 setTenantId 且 無 TenantContext)時拒絕持久化,避免 root 哨兵被寫成租戶歸屬、使該列對所有租戶皆不可見(靜默污染)。不比 Hibernate 更嚴——root 顯式指定租戶仍放行

  • notification.outbox.NotificationOutboxBase#requeue() — outbox 狀態機新增「重排供重送」轉換(US-014 通知遞送狀態查詢與重送的人工補救入口):把終態 / SENT / FAILED 列重置為 PENDING(attempts=0、nextAttemptAt=now、清 lastError/sentAt、maxAttempts 不變),供應用層重送 API 呼叫後另行 dispatchAsync。狀態守衛:SENDING(認領中)拋 ConflictException(對映 409,不與進行中遞送競爭)、PENDING 為冪等 no-op。與既有 markSent/markSuppressed/recordFailure 並列,屬狀態機自有轉換;重排不動 dedupeKey、不製造重複列,遞送仍走原子認領(叢集安全)

  • workbook 模組支援 .xls(HSSF,Excel 97–2003 舊版二進位格式)讀寫,補上 v1 出貨時明列的範圍邊界(見 guide workbook.md 的「範圍與限制」):

    • 新增 workbook.WorkbookFormat(XLSX / XLS)與 Workbook#create(WorkbookFormat);Workbook#format() 回報實際格式
    • Workbook#open(InputStream) 改走 POI WorkbookFactory 自動判定格式——呼叫端無需事先得知檔案是 .xlsx 或 .xls,既有 .xlsx 呼叫端不受影響
    • Workbook#create()(無參)語意不變,仍建立 .xlsx
    • 顏色的格式差異:.xls 先天只有 56 色索引調色盤、無法表示任意 RGB,故 CellStyle 的字型 / 背景 / 框線顏色在 .xls 會取調色盤中最接近者(不可逆的近似;調色盤內的顏色如 Color.RED 仍精確保留)。.xlsx 一律精確保留所指定的 RGB,行為與先前完全相同
    • 內部改以 POI ss.usermodel 介面(而非 XSSF 具體類)承載,POI 型別仍不洩漏到公開 API;無新增依賴(HSSF 隨既有 poi-ooxml 傳遞而來)
  • workbook.Cell#setRichText(String) — 以 HTML 子集寫入富文本(單格內混排字型),補上 v1 出貨時明列的第二條範圍邊界。輸入為 appfuse-web RichTextEditor(Tiptap/ProseMirror)輸出的 HTML,不依賴舊 appfuse-core 的 rtx AST:

    • 行內 marks(strong/b、em/i、u、s/del/strike、sup、sub、span 顏色)逐段套用字型;一般文字不套字型、沿用儲存格樣式
    • 區塊標記壓平(Excel 儲存格內無區塊概念):p/br → 換行、ul/ol → • / 1. 前綴(沿用 document 模組的清單呈現慣例,使同一份內容匯出到 Word 與 Excel 讀起來一致)、h1–h3 → 粗體、img → 略過。含換行時需自行 setWrapText(true)
    • 字型以呼叫當下儲存格樣式的字型為基底衍生(字型名稱、字級、顏色、粗斜體),再疊上 marks——Excel 的 rich text 字型是取代該範圍的儲存格字型而非疊加,不繼承基底會使 16pt 儲存格內的 <strong> 變成 11pt
    • 字型依「基底字型 + marks」快取重用:Excel 字型為活頁簿層級資源且有數量上限,逐 run 建立會在大量匯出時觸頂
    • .xlsx / .xls 皆支援;.xls 的字型顏色同樣落到 56 色調色盤(見上)
    • marks 詞彙抽出為內部共用的 internal.html.InlineFormat,與 document 模組的 Word 渲染共用同一份定義(RichTextEditor 長出新 mark 時兩邊一致認得,不會 Word 保留、Excel 靜默丟失)。依 30-public-api.md 的 internal 套件豁免,不屬對外契約
  • security.auth.DefaultAuthPrincipal — [AuthPrincipal] 的預設實作(extends User implements AuthPrincipal, TenantAwareUserDetails),對齊 Spring 自身「介面 UserDetails + 具體便利類 User」的模式。收編 fleet 三份一字不差的 AccountUserDetails 樣板;UserDetailsService 以 builder 攤平自家帳號即可:

    return DefaultAuthPrincipal.builder(account.getUsername())
    .password(account.getPassword()).authorities(authorities)
    .enabled(...).accountNonExpired(...).credentialsNonExpired(...).accountNonLocked(...)
    .tenantId(account.getTenantId()) // 無租戶部署省略
    .interactiveLoginAllowed(!account.isServiceAccount()) // 吃判定結果、非帳號分類
    .build();

    只承載引擎會讀的成員(UserDetails 四旗標 + tenantId + interactiveLoginAllowed)——原樣板的 accountId 經查證全 fleet 零讀取(請求期 principal 是 Jwt,本類只活在登入/刷新呼叫期間,控制器無從取得),故不納入;需要在主體上多帶欄位者繼承本類即可

  • security.lockout.LoginLockout — 登入失敗鎖定,改以 Spring Security 既有機制承載(取代整個 lockout.{api,core,spring,store} 子系統,見 Removed):計數監聽 AuthenticationFailureBadCredentialsEvent / AuthenticationSuccessEvent、把關實作 UserDetailsChecker 掛 DaoAuthenticationProvider#setPreAuthenticationChecks(未鎖定時委派 AccountStatusUserDetailsChecker 做標準旗標檢查)。不再包裝 AuthenticationProvider,故對任何 provider 一體適用;鎖定曲線改由覆寫 lockoutDuration(int) 客製,不需另立政策類。伴隨 security.lockout.AttemptRecord(record,取代原 lockout.store.AttemptRecord 可變類)

  • notification.EnvSubjectPrefixNotificationTemplateResolver — 通知主旨部署環境前綴 decorator(BannerNotificationTemplateResolver 的主旨版兄弟):包在範本 resolver 鏈最外層,env(慣例取 app.env,各部署外部 conf 提供)有設即於已渲染主旨前加 [{env}] 前綴,對所有 type × channel 一致生效;env 空白(正式環境慣例不設)或渲染主旨為空時 no-op。原為參考實作 app 層 decorator,經 fleet 三份重複實證後上收框架,下游升級後應移除自製副本改接框架版(見 ADR-019)

叢集排程分散式鎖(ShedLock) (scheduling/) — 見排程紀律(app-server/.claude/rules/20-scheduling.md)

  • 傳遞 net.javacrumbs.shedlock:shedlock-spring + shedlock-provider-jdbc-template(api,下游繼承),讓 run-once 週期任務(檔案 orphan sweep、staging 清理等)以 @SchedulerLock 標註後在叢集多節點下每 tick 只由一節點執行。Spring @Scheduled 預設每個 instance 獨立觸發、無協調,多節點會重複執行——此為通用解法
  • Schema ownership:框架 jar 不執行 DDL、也不擁有具體 @Entity。參考應用以純 schema mapping 的 ShedLockEntry 把 shedlock 表交給 Hibernate,故 create/update/validate/none 完全沿用既有 spring.jpa.hibernate.ddl-auto,欄位型別亦由實際 Hibernate dialect 產生
  • 接線方式(對齊框架「app 接線」慣例):應用在排程配置 @EnableSchedulerLock + 宣告 LockProvider(JdbcTemplateLockProvider,建議 usingDbTime() 避時鐘偏移),並於 run-once 排程方法掛 @SchedulerLock(name, lockAtMostFor)。通知 Outbox 輪詢不上鎖——它靠列認領保安全並保留多節點並行 drain(見上「叢集安全(競爭消費者)」)

檔案下載條件式快取(ETag / 304)+ 伺服端縮圖 (file/) — 見 檔案處理設計指南 §4.3

  • FileResponseBuilder 新增條件式下載:ifNoneMatch(String)、immutable()、cacheControl(CacheControl)。回應一律帶 ETag(= write-once 的 fileId,強驗證器)與 Cache-Control(預設 no-cache、帶 ETag 每次 revalidate)。If-None-Match 相符時在開啟儲存串流之前直接回 304 Not Modified,同時省下傳輸與 storage 讀取。immutable() 套用 public, max-age=31536000, immutable(適用以 fileId 定址、write-once 的 URL)。ETag / Cache-Control 於 200 完整回應與 206 Range 回應皆帶
  • ImageResponseBuilder(新類別)— 伺服端縮圖 + 條件式下載:from(fileStorage, partition, fileId).resize(width, height).format(fmt).ifNoneMatch(header).immutable()。變體 ETag 把 (fileId, width, height, format, transformVersion) 納入 → 不同尺寸各自快取、不會互相拿到錯圖;pre-decode 304:ETag 相符時在解碼前短路,跳過 decode → scale → encode。輸出為記憶體 buffered(正確 Content-Length);builder 不自行解析 Range,但因回應體為 ByteArrayResource,Spring 會自動就緩衝內容提供 range(206)。大檔仍建議用 FileResponseBuilder——它串流原始 bytes(低記憶體),resize 則整個輸出載入記憶體。無縮放需求(寬高皆 null)或來源非可解碼影像時,自動委派 FileResponseBuilder 服務原圖
  • 兩 builder 皆以 fileId 為 ETag 基礎(FileStorage 為 write-once,同一 fileId 內容永不改變,故可安全作為強驗證器)。設計動機與 URL 策略(fileId 定址 → immutable;實體型 URL → revalidate)見設計指南 §4.3

通用持久稽核 sink (audit/) — 見 ADR-013 界線修訂 R1

  • audit.PersistentAuditEventRepository<T>(implements Spring Boot Actuator AuditEventRepository)+ audit.AuditEventRecordBase(@MappedSuperclass)+ audit.AuditEventJpaRepositoryBase<T>(@NoRepositoryBean)— 把 Actuator 的 AuditEvent 持久化落表,取代消費端可選的 in-memory ring buffer(易失)。領域無關的 opt-in 基礎設施(性質同 cache/mail,不綁業務/身份模型):Spring Security 登入/授權事件、應用自訂 AuditApplicationEvent(如代行稽核)皆落此 sink,/actuator/auditevents 可查詢
  • 接線方式(對齊框架「app 接線」慣例;ADR-017——jar 不擁有 @Entity,具體 entity 由應用宣告):應用宣告具體 entity(繼承 AuditEventRecordBase,定表名/索引)與 repository(繼承 AuditEventJpaRepositoryBase<T>),並 @Bean 宣告 new PersistentAuditEventRepository<>(repository, 具體類::new)——提供此 AuditEventRepository bean 即讓 Spring Boot 的 AuditListener 與 /actuator/auditevents 使用持久 sink;不採持久化者須自行提供 repository(參考 app-server 的 audit Feature 提供 in-memory fallback)

事件驅動通知子系統 (notification/) — 見 ADR-010

  • NotificationService.notify(NotificationRequest) — 通知門面:解析收件人 → 渲染 → 寫入 Outbox → 觸發 fast-path 非同步遞送。中性、不認得業務語意(誰收、信長怎樣、租戶郵件設定、簽章深連結皆由 SPI 注入)
  • NotificationEvent(Spring ApplicationEvent)+ NotificationEventListener — 事件接縫:domain code publishEvent(new NotificationEvent(request)),框架以 @TransactionalEventListener(AFTER_COMMIT, fallbackExecution=true) 在交易提交後同步寫 Outbox(為 rollback 操作不誤送),實際遞送再非同步
  • NotificationRequest(record + builder)— type / model / recipients? / channels? / deepLink? / dedupeKey? / localeName?
  • 值型別:Recipient、ChannelType(Phase 1 僅 EMAIL)、RenderedTemplate、OutboundMessage、DeliveryResult(三態:ok() 成功 / failed(detail) 可重試失敗 / suppressed(detail) 確定性政策封鎖不重試)
  • SPI(業務語意注入點):RecipientResolver(誰該收)、NotificationTemplateResolver(渲染)、NotificationChannel(遞送通道)、channel.MailDelivery(EMAIL 通道委派應用層租戶寄信)、NotificationDeepLinkProvider(選用,per-recipient 深連結,與簽章連結組合)
  • 預設實作:template.NlsNotificationTemplateResolver(Spring MessageSource + ${} 具名插值)、channel.EmailNotificationChannel(經 MailDelivery 寄出)
  • 可靠遞送(Outbox):outbox.NotificationOutbox(JPA 實體,純 tenant_id 欄位、不繼承 TenantAwareEntity,相容 tenant / ownership 兩模式)、outbox.NotificationOutboxRepository、outbox.NotificationOutboxDispatcher(fast-path @Async + dispatchPending() 輪詢;遞送前依列 tenantId 以 TenantContext.runAs 重建租戶 context;指數退避重試逾上限轉 dead-letter)、outbox.OutboxStatus(PENDING/SENDING/SENT/FAILED/DEAD/SUPPRESSED)。達成 at-least-once 遞送 + dedupeKey 去重 + 列即遞送稽核
  • 政策封鎖 → 抑制(不重試):遞送遇確定性政策封鎖(如郵件防火牆全擋 MailBlockedException)時,EmailNotificationChannel 回 DeliveryResult.suppressed(...)、NotificationOutboxDispatcher 經 NotificationOutbox.markSuppressed(...) 轉終態 SUPPRESSED(不增 attempts、不排重試),與 DEAD(重試耗盡)區分。避免對「重試無益」的確定性封鎖白跑退避重試,並供告警/稽核辨識。輪詢只撈 PENDING/FAILED,故 SUPPRESSED 不被重撈
  • Phase 2 — per-tenant DB 範本:template.NotificationTemplate(JPA 實體,純 tenant_id,(tenant_id,type,channel,locale) 唯一;tenant_id/locale 為 null 表全域/不分語系)+ template.NotificationTemplateRepository + template.DbNotificationTemplateResolver(解析序 tenant+locale → tenant+null → global+locale → global+null → 委派 NlsNotificationTemplateResolver)+ template.TemplateInterpolation(共用 ${} 插值)
  • Phase 2 — 通知偏好(opt-out):NotificationPreferenceFilter SPI + preference.NotificationPreference(JPA 實體,(tenant_id,user_id,type,channel) 唯一)+ preference.NotificationPreferenceRepository + preference.DbNotificationPreferenceFilter(無列=啟用)。NotificationService 建構子新增選用 NotificationPreferenceFilter 參數,展開每則「收件人×通道」前過濾
  • Phase 2 — 遞送稽核 log:outbox.NotificationDeliveryListener hook(每次嘗試回呼)+ outbox.NotificationDeliveryLog(JPA 實體,逐次嘗試快照)+ outbox.NotificationDeliveryLogRepository + outbox.PersistentNotificationDeliveryListener(DB 預設)。NotificationOutboxDispatcher 建構子新增 List<NotificationDeliveryListener> 參數,每次嘗試回呼
  • Phase 3 — 多通道:ChannelType 新增 IN_APP / SMS / LINE(列舉新增=向後相容)
    • 定址:Recipient 改為帶 per-channel 位址(email / phone / lineUserId / userId)+ @Builder + addressFor(ChannelType);OutboundMessage 改帶已解析的 recipientUserId + recipientAddress(不再帶 Recipient)。NotificationService 展開時依通道取位址,缺位址者略過該通道
    • In-app:inapp.InAppNotification(JPA 實體)+ inapp.InAppNotificationRepository + channel.InAppNotificationChannel(遞送即寫站內信,位址=userId)
    • SMS:channel.SmsNotificationChannel + channel.SmsDelivery SPI(委派應用層 provider)
    • LINE:channel.LineNotificationChannel + channel.LineDelivery SPI(LINE Messaging API push;LINE Notify 已停止服務)
  • Per-type 重試政策:outbox.RetryPolicy(record:maxAttempts / baseBackoffSeconds / maxBackoffSeconds,附建構驗證)+ outbox.RetryPolicyResolver(SPI:forType(type) 依通知型別解析政策,附 constant(policy) 靜態工廠)。讓重試上限與退避可 per notification type 配置(如免密碼登入用低次數、高頻率;business 維持全域)。NotificationService(建列時讀 maxAttempts 寫入該列)與 NotificationOutboxDispatcher(nextAttemptAt 依列 type 讀 backoff)共用同一 resolver,故同型別重試行為一致。應用層以設定(如 app.notification.retry.per-type)建「per-type 覆蓋 → fallback 全域」的 resolver(參考 app-server 的 NotificationConfig#notificationRetryPolicyResolver)
  • 叢集安全(競爭消費者):NotificationOutboxDispatcher.dispatchOne 遞送前先經 NotificationOutboxRepository.claim(id, now, reclaimAt) 原子認領(條件式 UPDATE ... SET status=SENDING WHERE nextAttemptAt<=now AND status IN(PENDING,FAILED,SENDING),回傳受影響列數;1 才續行遞送)。多節點部署下 fast-path 與各輪詢節點競爭同一列時至多一個得標,杜絕重複遞送;認領不加分散式鎖,多節點仍可並行 drain 佇列。新增過渡態 OutboxStatus.SENDING(認領中);認領把 nextAttemptAt 推遲 claimTimeoutSeconds,遞送節點崩潰致列滯留 SENDING 時逾時由輪詢重新認領(復原重用既有到期查詢)。輪詢 dispatchPending() 撈取狀態納入 SENDING。取代原 dispatchOne 的 check-then-act 終態守衛(在叢集下無法防並發重複送)
  • 下游整合:框架 JPA 實體(notification.outbox / template / preference / inapp 四包)須納入 @EntityScan / @EnableJpaRepositories,並 @EnableAsync + 排程驅動 dispatchPending()(參考 app-server 的 NotificationConfig / NotificationOutboxPoller)。SMS/LINE 通道需應用層提供 SmsDelivery / LineDelivery 實作。注意(未發版前的增量):Recipient / OutboundMessage 結構於 Phase 3 變更;NotificationService(Phase 2 加偏好參數、int maxAttempts 參數改為 RetryPolicyResolver)與 NotificationOutboxDispatcher(Phase 2 加 listener 參數、baseBackoffSeconds/maxBackoffSeconds 改為 RetryPolicyResolver、本次新增 long claimTimeoutSeconds 參數)建構子簽章已更新

郵件收件者改寄(redirect,測試 / 展示用) (mail/)

  • Mailer.enableRedirect(String) / disableRedirect() / isRedirectEnabled() / getRedirectAddress() — 啟用後,所有外寄郵件(MimeMessage 與 SimpleMailMessage 兩條送信路徑)的收件者一律收合成單一 TO = 指定信箱、清空 CC/BCC,並在主旨前綴標註原收件者([REDIRECTED→...])、加 X-Original-To 標頭,每次改寄發出 WARN log。供測試 / 展示時把寄給真實客戶的信攔到單一信箱觀察,底層 SMTP 不會真正送給原收件者
  • MailerBuilder.redirectTo(String) — 以建構式設定改寄信箱(傳 null / 空白=不啟用)
  • redirect 與既有 firewall(網域過濾)為正交獨立機制:redirect 啟用時優先生效、繞過 firewall 過濾(改寄目標即唯一收件者)。下游以設定屬性(如 app.mail.redirect.*)注入,正式環境預設停用
  • MailBlockedException(mail/,繼承 Spring MailException)— 防火牆全擋(收件者全不在白名單,或啟用卻無白名單)時拋出,與「遞送失敗」語意區分(確定性政策封鎖、重試無益)。詳見 Fixed 段「防火牆全擋時靜默丟棄郵件」

外寄郵件特殊訊息(標記非正式環境郵件) (mail/)

  • Mailer.setNoticeSubjectPrefix(String) / getNoticeSubjectPrefix() / setNoticeHeaders(Map<String,String>) / addNoticeHeader(String,String) / getNoticeHeaders() — 在送信 chokepoint 對每封外寄信加上主旨前綴與自訂 MIME 標頭。送出前套用、先於 redirect / firewall,故覆蓋所有經 Mailer 的送信(含通知子系統、健康檢查等繞過上層服務的路徑)。主旨前綴對 MimeMessage 與 SimpleMailMessage 兩條路徑皆生效;自訂標頭僅 MimeMessage(SimpleMailMessage 無自訂標頭能力)
  • MailerBuilder.noticeSubjectPrefix(String) / noticeHeader(String,String) / noticeHeaders(Map<String,String>) — 以建構式設定
  • 典型用途:標記送給外部客戶的測試郵件——主旨 [TEST] ...(人辨識)+ 標頭 X-Environment: test(供外寄郵件閘道 / relay 規則程式化過濾,作 transport 層防呆)。與 firewall(過濾)/ redirect(改寄)正交:redirect 攔信、notice 標記放行的信,redirect 啟用時兩者皆套用(notice 先於 redirect,主旨同時帶 [TEST] 與 [REDIRECTED→...])。下游以設定屬性注入(如系統 app.mail.notice.* + per-tenant MailSetting)

通知內文橫幅(標記非正式環境通知) (notification/)

  • NotificationBanner(SPI,函式介面 bannerFor(ChannelType, Locale))+ BannerNotificationTemplateResolver(NotificationTemplateResolver decorator)— 在範本渲染後於 RenderedTemplate.body 前置 channel-aware 橫幅(EMAIL 回 HTML、SMS / LINE 回純文字、回 null 不加),主旨不變。包在 resolver 鏈最外層即生效。業務通知單一漏斗化經通知子系統時(見 ADR-010),此處即「標記所有業務通知內文」的單點。與 Mailer 的「主旨前綴 + 自訂標頭」chokepoint 互補:橫幅給人看(收件人直接看到)、主旨 / 標頭在 Mailer 層全攔(含繞過通知的路徑、供基礎設施過濾)。下游提供橫幅內容(如 app.notification.banner.* per-channel)並包入 resolver 鏈

簽章一次性連結原語:免密碼登入 / 事件通知深連結 (security/link/)

  • SignedLinkService — 簽章連結原語:issue(SignedLinkSpec) 鑄出帶用途(purpose)、導向目標(target)與泛用屬性袋(attributes)的簽章 JWT;consume(rawToken) 驗章 + 依政策贖回,回傳 SignedLinkToken。租戶中性、不碰寄信、不認得 HTTP——連結 URL 怎麼拼、信件主旨/內文、身分如何載入與 session 如何換發全由消費端決定。簽章金鑰由建構子注入(可與 JwtTokenProvider 共用同一組 RSA 金鑰)。下游用它實作「能收到該帳號 email 即可進入目標頁」的功能,登入只是「target = 首頁」的特例
  • LinkRedemption enum — 贖回政策:SINGLE_USE(簽章 + 未過期 + jti 原子消費,用過即廢,用於免密碼登入 / session 換發 code)/ REUSABLE(簽章 + 未過期即有效,TTL 視窗內可多次,純 stateless JWT 不查 store,用於事件深連結「先點開瞭解、稍後再點處理」)。issue 未指定時預設 SINGLE_USE(fail-safe)
  • SignedLinkToken(record)— consume 後的不可變結果:subject / purpose / target / attributes / jti / issuedAt / expiresAt / redemption
  • SignedLinkSpec(record + builder)— issue 入參:subject / purpose(必填)、target / attributes(選填)、ttl(必填正值)、redemption(缺省 SINGLE_USE)
  • SignedLinkStore(介面,security/link/store/)+ CacheSignedLinkStore(預設)— 僅 SINGLE_USE 連結使用的 jti 單次性 store:register(jti, ttl) / boolean consume(jti)(原子檢查並移除)。鏡像 TokenBlacklistStore/CacheTokenBlacklistStore 模式;快取實作的 consume 為 best-effort,需嚴格單次保證或多節點佈署者可改用資料庫實作(介面為其接縫)

Almanac 客戶端:行政曆 / 國家地區 / 台灣地址服務 (almanac/)

  • Almanac — 統一入口(builder),在消費端注入的 CacheManager 上組裝 AlmanacClient 與檔案型快取,提供 calendar() / location() / address() 三個與上一代 appfuse-common 等價的領域服務;資料來源由「直接連接原始資料源(CSV / NLSC)」改為「連接 almanac」(預設 https://almanac.leandev.io,base URL 可覆寫)
  • CalendarService (almanac/calendar/) — 行政機關辦公日曆:isWorkingDay / isHoliday(LocalDate 與 Date)、plusWorkingDays / minusWorkingDays、plusCalendarDays / minusCalendarDays、findCalendarDays(year);資料取自 GET /api/v1/calendar/{year}
  • LocationService (almanac/location/) — ISO 國家代碼轉換 convertISO2CountryToISO3 / convertISO3CountryToISO2、findSubdivisionCodeByISO2AndName、findAllCountries / findSubdivisions;資料取自 GET /api/v1/location/...
  • AddressService (almanac/address/) — 台灣縣市 / 鄉鎮市區 / 村里:findAllCities / findAllTownsByCity / findAllVillagesByTown、名稱版(免代碼)findAllTownsByCityName / findAllVillagesByTownName、findCity / findCityByName / findTown / findTownByName;資料取自 GET /api/v1/address/...。名稱版便利方法讓消費端以中文名稱直接查鄉鎮市區 / 村里、不需自行 resolve NLSC 代碼(查無對應縣市 / 鄉鎮市區時回空清單)。所有以名稱查詢的入口(findCityByName / findTownByName 及其衍生的 towns / villages 名稱版)查詢前先把俗寫「台」正規化為 almanac 官方用字「臺」,故傳入 台北市 / 台東市 等俗寫亦可命中(NLSC 行政區名一律用「臺」,整字替換安全)
  • 領域型別(record,皆 Serializable):CalendarDay、Country、Subdivision、City、Town、Village
  • 認證:AlmanacCredentials 策略介面(可插拔)+ 內建 ApiKeyCredentials(X-Almanac-Api-Key 標頭,API Key 由消費端以字串或 Supplier<String> 提供);保留 almanac service account 等其他認證方式的擴充空間
  • 快取:由消費端注入 CacheManager(必填);Almanac 不代建、也不提供 temp-dir 預設,未注入時 build() 拋 IllegalStateException,持久化位置與生命週期由消費端決定。各服務在注入的 manager 上建立 Ehcache 磁碟層(檔案)為主、極小筆數 heap(heap(n))為輔的快取(字典資料總量大、存取零散,避免記憶體配置過大或 hit rate 過低);almanac 視為可靠來源,不啟用雙層快取 fallback。治理模式(governed / ungoverned)由注入的 CacheManager 決定,almanac 保持治理無關

營業行事曆與日期可選性(BusinessCalendar) (calendar/)(ADR-012)

  • BusinessCalendar — 日期可選性的單一事實來源:statusOf(date) / isSelectable(date)、resolve(from, to)(解算範圍為 DayStatus[],經應用 endpoint 供前端日期元件渲染)、validate(date)(提交時後端權威驗證,不可選 → DateNotSelectableException → 400)、plusSelectableDays(from, days)(泛化的工作日加減:對任何組合後行事曆數「第 N 個可選日」,負數往前、掃描上限防無窮迴圈)。同一具名行事曆同時服務前端渲染與後端驗證,組合邏輯只存在後端一份,前後端日期限制語意結構性不漂移
  • Calendars — 組合工廠(builder):底座 government(CalendarService)(政府行事曆,假日 → HOLIDAY、節日名稱透傳)/ always()(全可選,純業務規則);規則組合子 closedOn(DayOfWeek...)(每週店休 → CLOSED_WEEKLY)、noPast()(→ PAST)、leadDays(n)(→ LEAD_TIME)、maxAdvance(n)(→ OUT_OF_RANGE)、closed(date[, reasonCode[, description]]) / open(date)(特例覆寫,最優先)、rule(predicate, reasonCode)(自訂);zone(...)(預設 Asia/Taipei)/ clock(...)(測試注入)。判定順序:特例覆寫 → 時間窗 → 每週店休 → 自訂規則 → 底座;政府底座查無該年資料時拋 AlmanacException(不靜默視為可選)
  • DayStatus(record、Serializable)— 跨端契約:date / selectable / reasonCode(結構化原因代碼,前端經 i18n 呈現)/ description(節日名稱等透傳說明);序列化即前端日期元件消費的 wire format
  • ReasonCodes — 框架常用原因代碼常數(PAST / HOLIDAY / CLOSED_WEEKLY / LEAD_TIME / OUT_OF_RANGE / CLOSED),非封閉清單、應用可擴充自訂字串
  • DateNotSelectableException — 繼承 InvalidDataException(映射 400 + i18n params:日期、reasonCode),帶完整 DayStatus 供應用層取用

雙模式資源伺服器(standalone / federated,靠設定切換) (security/resourceserver/)(ADR-009)

  • AuthMode enum + AuthProperties — 認證模式二擇一,綁定 app.security.auth.mode(預設 standalone,向後相容):standalone(本地自簽 JWT)/ federated(純資源伺服器,驗證企業 IdP OIDC token)。消費端以 @EnableConfigurationProperties(AuthProperties.class) 啟用
  • ResourceServerFactory — 「模式 → JwtDecoder / JwtAuthenticationConverter」切換核心(機制住框架):jwtDecoder(mode, localKey, issuerUri)(standalone 以本地 RSA 公鑰驗自簽 token、federated 以 issuer-uri JWKS 驗 IdP token)、authenticationConverter(mode)、validateMode(mode, issuerUri) 於啟動 fail-fast 驗證兩模式設定互斥(standalone 不得設 issuer-uri、federated 必須設)
  • StandaloneJwtAuthenticationConverter — 自簽 token 從 auth claim 還原權限(無狀態、不回查 DB;即時撤銷由 Token 黑名單 session id 承擔)
  • ScopeJwtAuthenticationConverter — IdP token 從 scope/scp claim 對映原樣 resource:action 權限(不加 SCOPE_ 前綴,直接滿足 @PreAuthorize("hasAuthority('order:read')")),roles claim → ROLE_*;與自簽 token 共用同一套權限詞彙
  • ProblemDetailBearerTokenAuthenticationEntryPoint / ProblemDetailBearerTokenAccessDeniedHandler — 受保護資源 401 / 403:RFC 6750 WWW-Authenticate: Bearer(invalid_token / insufficient_scope)挑戰標頭 + RFC 7807 ProblemDetail body
  • JwtTokenProvider.getPublicKey() — 新增 getter,供以本地公鑰建立 standalone JwtDecoder
  • 設計:兩模式共用標準 Spring oauth2ResourceServer().jwt() 驗證核心與 @PreAuthorize 授權,差異只在 token 來源、靠設定切換;機制上框架 jar,Account/Authority 詞彙與簽發端點等政策留 app-server reference impl(見 ADR-009)。資源伺服器路徑的標準組裝取代手動串接 DelegatingJwtAuthenticationFilter + Local/OAuthJwtAuthenticationProvider(後者仍保留於 jar,未刪)

Word(.docx)文件產生 (document/)(ADR-008)

  • Document — Word 文件入口(.docx/XWPF),封裝 Apache POI、不洩漏 POI/OOXML 型別;open(InputStream) 載入範本、create() 空白、write(OutputStream)、AutoCloseable;頁面/章節設定(setPageSize(Paper)、setMargins、setOrientation、addPageBreak/addSectionBreak、restartPageNumber)與頁首頁尾存取(header/footer/firstFooter/sectionFooter,皆回 Body)
  • Body — 本文/頁首/頁尾/儲存格共用的容器面:段落/表格列舉、appendParagraph/appendTable、appendCopyOf(樣板段落/表格深拷貝)、appendHtml、locator(findParagraphByText/Bookmark、findCellByText/Bookmark → Optional)、replaceText(跨段落/儲存格)
  • Paragraph / Run — 文字、樣板樣式名、對齊、縮排;run 粗斜底刪線、上下標、字級(double point)、顏色(java.awt.Color)、頁碼欄位、圖片
  • Table / TableRow / TableCell — 列增刪、duplicateRow、垂直/水平/區塊合併、寬度;TableCell extends Body(儲存格即容器)
  • HTML 富文本:把 appfuse-web RichTextEditor(Tiptap/ProseMirror)輸出的 HTML 子集渲染進 Word(Body#appendHtml、Paragraph#replaceWithHtml)——marks/區塊/清單/對齊/base64 圖片;HTML 內嵌 <table> 為後續
  • 設計採 handle/locator(非有狀態游標)、隔離 POI、尺寸用 measure.Length/Paper;決策與 csp/asp 實務取捨見 ADR-008
  • 底層 POI(XWPF,共用 workbook 的 poi-ooxml)與 jsoup(HTML 解析)皆以 implementation 引入,不成為傳遞依賴

Excel(.xlsx)讀寫 (workbook/)

  • Workbook — Excel 活頁簿讀寫入口(.xlsx/XSSF),封裝 Apache POI、不洩漏 POI 型別(與 CSV 模組同隔離策略);create() 建立、open(InputStream) 開啟、write(OutputStream) 寫出、AutoCloseable
  • Worksheet / Row / Cell / CellStyle — 工作表/列/儲存格/樣式的框架無關封裝;Cell 型別感知讀寫(setValue/getValue 自動對映字串、數值、日期、布林、公式),樣式涵蓋顏色、背景、框線、對齊、字型、資料格式、自動換行(顏色以 java.awt.Color 表示)
  • WorkbookReader / WorkbookRecord — 逐列/串流讀取,WorkbookRecord 實作框架 Record 介面,沿用型別安全的欄位名稱存取(getAsBigDecimal 等);自動跳過空白列
  • WorkbookWriter — Header 驅動寫入,write(Object) 透過 PropertyMap 依 Header 自動提取物件屬性(與 CsvWriter 同慣例)
  • Align / VerticalAlignment / BorderStyle / DataFormat — 框架無關的樣式與格式列舉
  • 範圍(v1):僅 .xlsx、記憶體模式;.xls(HSSF)、Rich Text、SXSSF 串流寫出列為後續規劃
  • 底層 POI 以 implementation 引入,不成為傳遞依賴

交易感知檔案儲存 + 檔案交易日誌 + 孤兒對帳 sweep (file/tx/)(ADR-005)

  • TransactionAwareFileStorage — FileStorage 裝飾器,把檔案系統副作用對齊資料庫交易邊界:persist/store 失敗(rollback)時補償刪除已寫入永久區的檔案(afterCompletion);delete 延到 commit 後才執行(afterCommit),避免 rollback 留下懸空參照。刻意把所有失敗模式偏向「孤兒」、永不「懸空」
  • FileJournal SPI + JournalEntry / JournalOp / JournalState / JournalLineCodec — append-only 檔案交易日誌(共用 TSV 編解碼),存於與檔案相同的介質(脫離業務交易而存活),作為崩潰殘留的孤兒對帳來源與稽核軌跡
  • LocalFileJournal — 本地檔案系統實作(同日事件 append 至 {basePath}/journal/{partition}/{date}.log)
  • S3FileJournal / AzureFileJournal / SftpFileJournal — 雲端與 SFTP 實作(物件不支援 append,採「一筆事件一物件」於 {prefix}/journal/{partition}/{epochMillis}-{uuid},readEntries 以 list + 逐筆讀回)
  • NoOpFileJournal — 交易對齊後端(Database:persist/delete 為交易內 JPA 操作、隨 rollback 復原)或關閉日誌時使用
  • FileOrphanSweepTask(後端無關)+ FileReferenceResolver SPI — 孤兒對帳:依 journal 每個 fileId 的最後狀態判定,ROLLED_BACK 殘留直接刪(可證明孤兒);CREATED/DELETE_PENDING 過 grace 的模糊項,預設策略 A(只報告、不誤刪),提供 FileReferenceResolver 則升級策略 B(查已 commit 參照、未參照才刪、權威全自動)
  • 框架提供工具類(builder 風格),由應用層 FileStorageConfig 組裝;只包非交易後端(Local/S3/Azure/SFTP),Database 維持裸用;以 app.storage.transaction-aware=false 可關閉,app.storage.orphan-sweep.* 配置 sweep
  • 對未採用者完全 inert——不套用裝飾器即行為不變

樂觀鎖併發衝突 → 409 (error/)

  • OptimisticLockExceptionMapper — 將 JPA @Version 樂觀鎖衝突(OptimisticLockingFailureException 及其 JPA 子型別 ObjectOptimisticLockingFailureException)映射為 RFC 7807 的 409 Conflict(urn:appfuse:error:optimistic-lock)。先前此例外落入 DataAccessExceptionMapper 兜底、誤映為 500
  • 註冊於 DataAccessExceptionMapper 之前(樂觀鎖例外為 DataAccessException 子型別)
  • 對未採用 @Version 的應用完全 inert——無版本欄位即不會拋出此例外,行為不變
  • 為 opt-in 樂觀鎖 pattern 補上正確的錯誤語義;併發更新策略(預設 last-write-wins、per-entity opt-in)見併發策略設計指南

Token 黑名單 (security/blacklist/)

  • TokenBlacklistStore — 黑名單儲存介面
  • CacheTokenBlacklistStore — 使用 AppFuse Cache 的實作
  • TokenBlacklistFilter — Spring Security Filter,在 JWT 驗證前檢查黑名單
  • 設計重點:TTL 與 access token 過期時間一致;與 Login Lockout 共用快取技術棧;可擴充 Redis 支援分散式部署

Login Lockout Cache 整合 (security/lockout/store/)

  • AttemptRecord — 登入嘗試記錄資料類別
  • CacheAttemptStore — 使用 AppFuse Cache 的實作(取代 InMemoryAttemptStore)
  • 設計重點:TTI 自動清理不活躍記錄;統一快取監控;可擴充 Redis 分散式

Basic Auth 認證 Filter (security/auth/)

  • BasicAuthenticationFilter — HTTP Basic Auth 認證 Filter
    • 處理 Authorization: Basic xxx header
    • 與 JWT Filter 共存(先檢查 SecurityContext)
    • 整合 LockoutAwareDaoAuthenticationProvider 支援登入鎖定

檔案下載工具 (file/)

  • RangeUtils — HTTP Range 請求解析工具(RFC 7233 格式)
  • FileResponseBuilder — 單一檔案下載 Response 建構器
    • 支援完整下載(200 OK)與部分內容下載(206 Partial Content)
    • 支援影音串流、斷點續傳
    • 整合 FileStorage 介面
  • ZipFileResponseBuilder — ZIP 批次下載 Response 建構器
    • 先產生 ZIP 暫存檔再提供下載
    • 支援 Range Request;下載完成後自動清理
  • ZipStreamResponseBuilder — ZIP 串流下載 Response 建構器
    • 邊壓縮邊傳輸;不支援 Range Request

錯誤處理框架

  • ErrorResponse — 標準化錯誤回應
  • ExceptionMappingRegistry — 異常映射註冊表
  • StandardRestExceptionHandler — REST 異常處理器
  • Violation — 驗證錯誤模型

JWT 認證系統 (security/auth/)

  • JwtTokenProvider — JWT 簽發 / 驗證 / refreshToken(保留 claims、更新過期時間);getPublicKey() 供建立 standalone JwtDecoder
  • JsonAuthenticationEntryPoint — 認證失敗以 RFC 7807 ProblemDetail 回應

bearer token 驗證改採標準 oauth2ResourceServer().jwt()(見「雙模式資源伺服器」段);原本手刻的委派式 filter/provider(DelegatingJwtAuthenticationFilter、JwtAuthenticationToken、Local/OAuthJwtAuthenticationProvider)已於本開發週期內移除、未對外發布過。

Login Lockout 防暴力破解 (security/lockout/)

  • LoginLockoutManager、LockoutException、LockoutErrorResponse、LockoutExceptionMapper

雙層快取架構

  • DualLayerCache — 雙層快取(快速層 + 持久層)

檔案處理 / 工具類

  • ContentTypeDetector — Content 類型檢測
  • PasswordGenerator — 密碼生成器

CacheManager 記憶體預算管制 (cache/)(ADR-011,supersede ADR-006)

  • CacheManagerBuilder.governed() / .ungoverned() — 啟用 / 停用記憶體管制(預設啟用,safe-by-default)
  • CacheManagerBuilder.offheapBudgetMB(long) — 明示 offheap 預算(省略則自動推導)
  • CacheManagerBuilder.onExceed(OnExceed) — 超額處置策略(預設 REJECT)
  • OnExceed enum(api/)— REJECT / WARN
  • MemoryBudget(config/)/ MemoryBudgetResolver(core/)— 已解析的 offheap 預算與其推導(四段 fallback:明示 → -XX:MaxDirectMemorySize×75% → 確認 cgroup 上限的總量反推 → 固定 64MB fallback)
  • 兩層防護:per-cache offheap byte 上限(Ehcache runtime 驅逐)+ manager 加總檢查(讀 live config 重算、含外部直接建立的 cache)
  • byte 預算只治理 offheap(序列化大小精確、不經 sizeof);heap 一律筆數計、不納入 byte 預算。真實 byte 封頂改用 offheap——原因是純 in-process 的 on-heap byte 封頂須靠 Ehcache sizeof 引擎,而該引擎在 JDK 25 已不可靠(見 ADR-011)

CacheManager 層快取啟用開關 (cache/api/)(ADR-007)

  • CacheManager.disableAll() / enableAll() / isEnabled() — 管理器層級總閘;停用為邏輯旁路、不清空資料(get 一律 miss、put 忽略),主要供除錯/測試強制讀資料源。停用期間**新建(含懶建)**的 managed 快取自動受總閘管制
  • CacheManager.disableCache(String) / enableCache(String) / isCacheEnabled(String) — 個別快取開關(與總閘正交、兩層 AND);可在快取建立前先指名,達成「預先停用懶建快取」
  • 介面新方法皆有 default 實作(mutator 拋 UnsupportedOperationException、query 回 sane 預設),不破壞既有第三方 CacheManager 實作;內建 Ehcache 實作覆寫為正解
  • 框架只提供原語;property 接線(如 app.cache.enabled / app.cache.disabled-caches)由消費端承擔(參考實作見 app-server 的 CacheConfig)

代理與模擬——ActingContext (security/auth/) — 見 ADR-013

  • ActingContext(介面 + 靜態工廠 current() / of(Jwt) / ofClaims(Map))— 代理(Delegation)/模擬(Impersonation)讀取端唯一入口:從當前 JWT 的代行 claim 算出 {subject, grantors[], actor}(身份一律為 stable subject,對齊 sub)。兩個正交維度、無單一模式:isImpersonating()(帶 actor claim)與 isDelegating()(帶非空 grantors)獨立判定、可同時成立。getGrantors() 取授權人 subject 全集(0..n)、getGrantor() 單數便利存取(存在多筆丟 IllegalStateException、零筆回 null)、getActor() 取模擬者 subject(非模擬時退化為有效身份)
  • ActingClaims — JWT claim wire 契約:grantors(stable subject 陣列)/ actor(stable subject)兩個正交 claim 常數 + delegation(List<String>) / impersonation(String) claim 組裝;無 act_mode 判別器——兩產物無 key 衝突、可 merge 成同時帶兩維的 token。簽發端與讀取端共用確保 round-trip 對稱
  • JwtTokenProvider.generateDelegationToken(subject, grantors, extraClaims) — sub 維持登入者、auth 取 subject 權限(呼叫端先併入授權人 ROLE 權限)、帶 grantors(授權人 stable subject)
  • JwtTokenProvider.generateImpersonationToken(impersonatedSubject, actor, extraClaims) — sub/auth 換為被模擬者、帶 actor(模擬者 stable subject);被模擬者 tenantId 由呼叫端經 extraClaims 帶入(模擬可跨租戶)。要同時具代理維(被模擬者本身被指派代理)於 extraClaims 併入 ActingClaims.delegation(...) 即可
  • 代行 claim 經既有 refreshToken() 自動保留(非標準 claim 一律複製),跨 refresh 延續;resource-server converter 無須改動(principal 仍為 Jwt,代行資訊由 ActingContext 另讀)
  • 框架只交付 token 機制 + claim 契約 + 讀取工具 + 稽核事件解算:啟用時機、閘門 authority、授權/啟用記錄、資料表、URL policy、instance 級過濾一律由應用層承擔(參考實作見 app-server/app-office)

Changed​

  • ReportStatus.Failed.of 將一般 CancellationException 從 report.failed 改映射為 report.cancelled。

  • FileResponseBuilder 的 Range 語意改依 RFC 9110(REP-06):只有 rangeSupport() 為 NATIVE 的儲存才回 206,並以 openRange 只讀取該區段;NONE 的儲存(例如應用自訂的資料庫儲存)忽略 Range、回 200 完整內容 與 Accept-Ranges: none,不再以 skip() 模擬 206。語法合法但超出檔案範圍的 Range 由 200 改為 416 (Content-Range: bytes */{size});格式錯誤與多個 range 回 200。If-Range 只接受與 ETag 相同的強 ETag。 以 request(...) 或 method(HEAD) 告知方法時,HEAD 由 metadata 建構 headers、不開啟內容串流;未設定方法時 視為 GET(行為不變)。方法為 GET/HEAD 以外時丟出 IllegalArgumentException。

  • ZipFileResponseBuilder 採用相同的 Range 評估:超界回 416;ZIP 每次重新產生、不送 ETag,帶 If-Range 的請求一律回完整內容;206 以檔案通道定位。HEAD、416 與產生失敗時於 build() 內刪除暫存檔。

  • PersistenceEncryption.fromBase64 改為建構期失敗:空白 root key 不再被靜默略過,active key 缺少時直接拋 IllegalStateException,讓 provision 了 persistence-encryption 卻沒給 key 的部署在 Spring context 啟動期失敗,而不是第一次 encrypt/decrypt 才失敗。應用不得自行產生或寫回 root key;由 /env-config 的 generate(secret) 提供(ADR-033)。

  • Authentication/authorization 錯誤預設改為最大揭露:LoginService、 ClientCredentialsService、JsonAuthenticationEntryPoint、bearer 401/403 handlers 與 StandardRestExceptionHandler 不再提早把帳號狀態、policy、token parser 或 downstream HTTP 診斷收斂成通用訊息。DaoAuthenticationManagers 同時關閉 Spring 的 hideUserNotFoundExceptions,保留 UsernameNotFoundException。需要原不透明 wire contract 的 應用應於組裝層注入 MinimalErrorDisclosurePolicy。

  • FileStorage 永久 fileId 改為 ASCII-only opaque ID:預設格式由 {yyyy-MM-dd}/{HH}/{filename}-{shortUuid}.{ext} 改為 {yyyy-MM-dd}/{HH}/{uuid}, Local、SFTP、S3、Azure 與 database reference backend 不再把原始檔名或副檔名混入 實體路徑/object key;完整原始檔名仍保存在 metadata。讀取 API 不限制舊格式,既有 fileId 可繼續使用;若 Local 主機無法處理既有非 ASCII 路徑,需依 file storage guide 同步遷移實體檔、sidecar、業務資料引用與 journal 終態。

  • 通知持久時間改由應用 Clock 與 Spring Data auditing 管理

    • NotificationService、NotificationOutboxDispatcher 建構子新增 Clock; Outbox 建立、認領、遞送與重試不再直接讀取系統時間
    • InAppNotificationBase、NotificationOutboxBase、 NotificationDeliveryLogBase、NotificationTemplateBase 的 audit 欄位改由 @CreatedDate/@LastModifiedDate 寫入,參考實作的 auditing provider 與業務時鐘一致
    • NotificationOutboxBase 的待送列在 @PrePersist 以 createdDate 初始化 nextAttemptAt;直接建立 Outbox entity 也可被首輪輪詢撈取
    • AuditEventDataConverter 遇無效 JSON 改為拋出含原始 cause 的 PersistenceException,不再以空物件隱藏資料錯誤
    • 大型文字欄位改以 Length.LONG32 表達,移除 magic length
  • STANDALONE 模式的 Authentication 多出 FACTOR_BEARER 權限——改用 Spring 內建 JwtAuthenticationConverter(見 Removed 的 StandaloneJwtAuthenticationConverter)後,Spring Security 6.5+ 會為 bearer 認證附加 FactorGrantedAuthority.BEARER_AUTHORITY,標記「本次認證使用的因子」,供 factor-based 授權規則使用。

    • 附加性、不影響既有授權:hasAuthority('order:read') / hasRole('SUPER_ADMIN') 行為不變;不會寫進任何簽發的 token(auth claim 由登入路徑自 DB 權限組出,與請求期 Authentication 無關)
    • 需要留意的只有「對 authentication.getAuthorities() 做完整集合斷言」的測試或程式碼——逐項比對(anyMatch / contains)不受影響
  • Mail Feature / reference implementation 分界與 ownership 預設

    • feature/mail 包含 MailConfig/MailProperties 的中性組裝,以及固定的 Actuator endpoint、health 與 mail:ops 授權;sender、外寄政策、system default 與 RoutingMailer 都採 @ConditionalOnMissingBean,應用只有在需要時才 override
    • MailSetting CRUD、provider adapter 與 mail_setting:* 權限位於附屬 mail reference domain;ownership 只 overlay tenantless MailSetting,其餘流程共用
    • MailSettingSpecProvider 直接從 entity 是否具 TenantAwareEntity 血緣判斷範圍,移除可能與 schema 矛盾的 scope-source 設定
  • Platform Info Feature 邊界

    • 固定診斷與系統識別行為上收 jar 的 ConfigurationDiagnostics/SystemInfo; feature/platforminfo 提供 PlatformInfoConfig/ConfigCheckProperties 組裝,以及 /api/v1/system/info、/actuator/configcheck 與 authority/security 等固定 application adapter;該 Feature 不附帶 reference domain
    • 移除 /actuator/helo;存活檢查使用標準 /actuator/health,前端可達性與版本識別使用 /api/v1/system/info
    • platform-info core 無資料隔離結構,tenant 與 ownership 共用同一份 Java(variant delta 0)
  • Reference Data Feature / reference implementation 分界

    • Feature 收斂為 ReferenceDataProvider、ReferenceDataRecord、ReferenceDataItem 三個中性契約;entity、repository、CRUD URL、產品型別、seed 與 authority 移至附屬 referencedata reference domain
    • ReferenceDataItem.from 改只依賴 ReferenceDataRecord,不再讓 Feature import 應用 entity
    • ownership 保留完整 reference domain,僅 overlay CodeDataType 將所有產品型別預設為全域
  • StandardRestExceptionHandler 日誌補上違規欄位(error/):handleProblemDetail 原本只記錄 title + detail,而 Bean Validation 的 detail 是通用句(Validation failed. Please check your input.)、違規欄位只存在於 violations[].props,導致日誌完全看不出是哪個欄位違規。新增 protected String formatViolations(ProblemDetail) 將違規渲染成 violations=[email=must not be blank, address.city=must not be blank] 附加於日誌訊息之後(Named Parameters 已代入;無 props 的全域違規標為 (global))。記錄等級不變(5xx error / 4xx debug),無違規時輸出與先前完全相同

  • 將依賴聲明從 implementation 改為 api,公開傳遞依賴(Spring Boot Starters、httpclient5、jakarta.ws.rs-api、jjwt-api)

  • FileStorage 介面(及 LocalFileStorage/S3FileStorage/AzureBlobFileStorage/SftpFileStorage 實作、FileResponseBuilder/ZipFileResponseBuilder/ZipStreamResponseBuilder)所有方法的首個參數由 tenantId 更名為 partition,語意正名為「儲存分區鍵」——呼叫端控制的路徑/key 前綴,與 entity 層多租戶機制(TenantContext、Hibernate filter)無耦合

    • 多租戶傳 tenant ID(行為不變);單租戶傳固定常數(如 "default")
    • 非破壞性:Java 方法簽章不含參數名,既有位置呼叫照常編譯執行;磁碟/雲端路徑由傳入值決定,值不變則路徑不變、無資料遷移

Deprecated​

  • RangeUtils.parseRange(String, long) — since 4.0.0, removal in 4.2.0

    • Replacement: RangeUtils.evaluate(String, long)
    • Note: 舊方法把「無法滿足」與「格式錯誤」都回 Optional.empty(),無法區分 416 與完整內容;行為不變
  • LoginService.refresh(String) — since 4.0.0, removal in 4.2.0

    • Replacement: refreshSession(String)
    • Migration: 實作 app-owned RefreshSessionStore adapter,使用新建構子組裝登入引擎,並把 refresh response 的 accessToken/refreshToken pair 一起更新;舊建構子暫時保留 JWT refresh 相容路徑。
  • jpa.tenant.TenantFilterSupport — since 4.0.0, removal in 4.2.0

    • Replacement: 無需替代——租戶 filter 由 Hibernate 於 Session 建構時自動套用,不再需要手動 enable/disable
    • Note: 跨租戶存取請改走 root session(Hibernate 明文支援的模型),而非關閉 filter

Removed​

歷史版本界線:下列原標記 SNAPSHOT 的移除,以及 AuthenticationErrorResponse, 均發生於不可變 alpha 發布之前;removed in 4.0.0-alpha.1 表示首個已不含該 API 的 不可變發布版,不代表刪除發生於 alpha.1 切版當天。Git 刪除證據:auth/ClientCredential 68602693、MailerBuilder/updateDelegate 4ed0129f、tenant resolver/Builder ec813f32、 Standalone converter 8429fd7d、舊 lockout c691d09e、AuthenticationErrorResponse 480d7c2a。 上述 commits 均為 alpha.1 發布 commit 502b1161 的祖先;發布 JAR 的 class/公開成員 亦已核對。舊 SNAPSHOT 無法僅憑版本字串裁切,仍須依消費端實際 JAR 或 commit 比對。

  • JsonAuthenticationEntryPoint.setCustomMessages(...) — deprecated since N/A(pre-release cleanup), removed in 4.0.0-alpha.15

    • Replacement: 注入 ErrorDisclosurePolicy;使用 MinimalErrorDisclosurePolicy 或應用自訂政策
    • Migration: 把 entry-point 內的 exception-message map 移到最終 response policy
  • OAuth2TokenException.invalidClient() 無參數工廠 — deprecated since N/A(pre-release cleanup), removed in 4.0.0-alpha.15

    • Replacement: invalidClient(String) 或 invalidClient(String, Throwable)
    • Migration: 傳入實際拒絕原因/cause,是否收斂留給 response policy
  • security.link.PasswordlessRateLimiter 與 CachePasswordlessRateLimiter — deprecated since N/A(未有正式下游使用的 pre-release reference API),removed in 4.0.0-alpha.6

    • Replacement: Email 免密碼登入改用 Email OTP;事件通知仍使用 SignedLinkService
    • Migration: 移除 passwordless request endpoint、identifier/IP limiter bean 與相關設定; 保留 event-action signed-link 的 consume/exchange
  • ActingClaims.CLAIM_DELEGATES、ActingContext.getDelegates()/getDelegate() 與 JWT delegates claim — deprecated since N/A (pre-release contract cleanup), removed in 4.0.0-alpha.1

    • Replacement: CLAIM_GRANTORS、getGrantors()/getGrantor() 與 grantors claim
    • Migration: claim 值改傳 stable subject;應用顯示層自行解析 username,既有 token 需重新登入或重簽
  • io.leandev.appfuse.auth 整個 package(Authenticator、Credential、AuthError、AuthException、PasswordAuthenticator、PasswordGenerator)與 oauth2.ClientCredential — deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-alpha.1

    • Replacement: 無——全部為死碼。全樹對該 package 的唯一 import 是 oauth2.ClientCredential,而 ClientCredential 的唯一「消費者」是 Credential 的 javadoc @see(互相引用的死對);其餘四類零引用
    • Migration: 若下游確有使用(機率極低,本 repo 內零使用),自行複製所需類別到專案內。oauth2.OAuth2Authenticator(mail OAuth2 使用)與 oauth2.AccessToken(http.StandardHttpClient 使用)不受影響
    • Note: 移除理由不只是死碼,也是命名混淆——io.leandev.appfuse.auth 與 io.leandev.appfuse.security.auth 兩個 package 同名不同義,且新增的 ClientCredentialsFactory(機器憑證產生)與舊 PasswordGenerator(人用密碼產生)分居兩者,需要 javadoc 才能消歧義。認證相關型別現一律在 security.auth
  • mail.MailerBuilder — deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-alpha.1

    • Replacement: MailOutboundPolicy.builder() + new DirectMailer(sender, policy, defaultFrom)
    • Migration: 政策旋鈕一對一對映——enableFirewall()/firewall(b) → firewall(b)、allowDomain(s)/allowDomains(...) → allowedDomains(...)、debug(b) → debug(b)、redirectTo(s) → redirectTo(s)、noticeSubjectPrefix(s)/noticeHeader(s)/noticeHeaders(m) 同名;build() → 以 policy 為建構參數建 DirectMailer
  • mail.Mailer#updateDelegate(JavaMailSender) — deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-alpha.1

    • Replacement: 無——delegate 於建構時固定;要換 sender 就重建 DirectMailer(RoutingMailer 的 revision 快取即此模式)
    • Migration: 呼叫端改重建實例
  • security.tenant.resolver.JwtDetailsTenantIdResolver 與 CompositeTenantIdResolver.Builder — deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-alpha.1

    • JwtDetailsTenantIdResolver 讀 authentication.getDetails() 內的 JJWT Claims,該形狀由 ADR-009 移除的手刻 JWT filter 產生;改採標準 oauth2ResourceServer().jwt() 後 principal 恆為 Jwt、details 不再是 Claims,此 resolver 全 fleet 零命中(死碼)。withDefaults() 的預設鏈相應縮為「bearer(JwtClaimTenantIdResolver)→ Basic Auth(UserDetailsTenantIdResolver)」,對應兩條實際存在的認證路徑
    • Builder(含 withJwtClaim / withJwtDetails / withUserDetails / add)與既有的 varargs 建構子完全重複,且全 fleet 僅出現於自身 javadoc 範例
    • Replacement: new CompositeTenantIdResolver(new JwtClaimTenantIdResolver("tenant_id"), new UserDetailsTenantIdResolver())
    • Migration: 使用 configureWithDefaults() 的消費端無須改動(tts 等);曾以 Builder 組裝者改用 varargs 建構子
  • security.resourceserver.StandaloneJwtAuthenticationConverter — deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-alpha.1

    • Replacement: ResourceServerFactory.authenticationConverter(AuthMode.STANDALONE)——內部改用 Spring 內建 JwtGrantedAuthoritiesConverter 組態化(claim 名 ResourceServerFactory.AUTHORITIES_CLAIM、前綴空字串、authoritiesClaimDelimiter 設 ","),不再自行剖析 claim。token wire 格式不變(仍為逗號分隔),已簽發的 token 不受影響
    • Migration: 直接呼叫該類的消費端改用 factory;公開常數 AUTHORITIES_CLAIM 移至 ResourceServerFactory
  • security.lockout 子系統整併為單一 LoginLockout(12 檔 → 2 檔)— deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-alpha.1

    • 移除:lockout.api.LoginAttemptTracker、lockout.api.LockoutPolicy、lockout.api.LockoutException(死類,全樹零使用)、lockout.core.DefaultLoginAttemptTracker、lockout.core.IncrementalLockoutPolicy、lockout.core.FixedLockoutPolicy、lockout.core.ExponentialLockoutPolicy、lockout.spring.LockoutAwareDaoAuthenticationProvider、lockout.store.AttemptStore、lockout.store.CacheAttemptStore、lockout.store.InMemoryAttemptStore;lockout.store.AttemptRecord 移至 lockout.AttemptRecord 並由可變類改為 record
    • Replacement: lockout.LoginLockout(實作 UserDetailsChecker + 以 @EventListener 消費 Spring 認證事件)
    • Migration: 組態由「建 tracker + 包裝 provider」改為「建 LoginLockout + 掛插槽 + 開事件」——
      // 舊
      var tracker = new DefaultLoginAttemptTracker(new CacheAttemptStore(cache),
      new IncrementalLockoutPolicy(5, Duration.ofMinutes(1)));
      var provider = new LockoutAwareDaoAuthenticationProvider(daoProvider, tracker);
      var manager = new ProviderManager(provider);
      // 新
      var lockout = new LoginLockout(cache, 5, Duration.ofMinutes(1));
      daoProvider.setPreAuthenticationChecks(lockout); // 把關(Spring 既有插槽)
      var manager = new ProviderManager(daoProvider);
      manager.setAuthenticationEventPublisher( // 計數(手建 ProviderManager 預設不發事件)
      new DefaultAuthenticationEventPublisher(applicationEventPublisher));
      自訂鎖定曲線由「實作 LockoutPolicy」改為「覆寫 LoginLockout#lockoutDuration(int)」;管理面解鎖由 tracker.clearAttempts(username) 改為 lockout.clear(username)。 鎖定機制不再攔截 AuthenticationProvider——計數走框架的 AuthenticationFailureBadCredentialsEvent / AuthenticationSuccessEvent,故對任何 provider 一體適用。
  • security.auth.AuthenticationErrorResponse — deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-alpha.1

    • Replacement: Spring ProblemDetail(RFC 7807;見「錯誤處理框架」)
    • Migration: 認證錯誤改用 ProblemDetail 回應;消費端依標準 status/detail 等欄位處理,不再依賴舊回應型別

Fixed​

  • LoginLockout 的 cache 停用或 isEnabled/get/put/remove 執行中 RuntimeException 不再取代認證結果。 保留原建構器/lockoutDuration 覆寫;只隔離 cache 邊界,密碼、帳號資格、政策、SQL Session 與 Error 不吞掉。 新增 Spring ApplicationEventPublisherAware 接點,透過既有 AuditApplicationEvent 發 CACHE_UNAVAILABLE/CACHE_RECOVERED(僅 cacheName/component,principal=system);管理停用不算 backend 故障。 所有曾失敗操作需真正成功才恢復,GET 不清除已知 PUT/REMOVE 故障。普通 listener 例外不改登入結果, 不保證通知送達,listener 仍可能增加延遲。非 Spring 建立者須顯式設定 publisher; tokenBlacklist、M2M limiter 與 OIDC/signed-link 的執行中契約維持原狀。本項尚未發布/通過本次人審。

  • audit.PersistentAuditEventRepository.add() 修正同步 AuditApplicationEvent 在 @Transactional(readOnly = true) 內因 Hibernate 不 flush 而靜默遺失:唯讀外層以 REQUIRES_NEW 可寫交易立即提交,外層 rollback 仍保留事件;無外層交易時建立可寫交易。 讀寫外層維持 REQUIRED 語意,稽核與業務資料一起 commit/rollback,不保證保留失敗操作。 寫入/提交前 flush 失敗會記 WARN 並傳遞例外;外層 commit 失敗或結果不明也記 WARN, log 不含原始 principal、payload 或可能攜帶資料的 SQL 例外訊息。 同時修正 find() 的時間參數 null 判斷在 PostgreSQL 造成參數型別不明的錯誤。 既有兩參數 @Bean 接線不變,由 Spring 注入 transaction manager;多 manager 或手動建立者 可用新增三參數建構子指定 repository 使用的 manager。唯讀路徑會暫停外層交易並額外使用 一條資料庫連線,connection pool 須保留獨立稽核交易所需容量。 下游升級後讀取類稽核會開始落入 audit_event,資料量將增加;表結構不變, 過去已遺失的事件無法補回。本修正由 4.0.0-alpha.27 提供,4.0.0-alpha.26 不包含。

  • 流式 PdfReportRenderer 的 PDF /ID 改由含 metadata/字型的已序列化內容 SHA-256 推導, 相同輸入與固定 createdAt/Clock、相同字型與 renderer/PDFBox 環境可重現完整 PDF bytes。 首次升級會改變輸出指紋;不保證跨版本 bytes 相同。增加一次暫存讀取與序列化,保持有界 heap、 取消及產生完成前零交付語意;不變更公開 API、固定表單、subset 或交付 ETag 政策。

  • LoginService.logout(null, refreshCredential) 以既有的秘密 family selector 查詢 Session, 修正 refresh 已完成 rotation、Browser 尚未收到新 Cookie 時,Cookie-only logout 回覆成功卻 未撤銷 family 的競態。legacy credential 仍以完整 hash 查詢;未知 selector 保持冪等, Bearer family 優先與 Cookie-only 不新增 Access Token blacklist 的契約不變。

  • AzureBlobFileStorage 的 store/persist 對真實 Azure Blob(與 Azurite)因 blob metadata 名稱 original-filename 含連字號而回 400 InvalidMetadata;blob metadata 名稱改為 original_filename (staging .meta sidecar 的文字格式不變)。舊名稱的寫入從未成功,不需資料遷移。

  • StandardHttpClient 產生的 HTTP 回應例外新增下游來源脈絡;StandardRestExceptionHandler 不再把任何下游錯誤的服務名稱、狀態、reason 或內部路徑放進對外 detail,並將下游 401/403 映為 502,避免前端誤觸本服務 token refresh/登出。其他下游狀態碼維持相容, 應用自行拋出的錯誤亦維持原狀。特殊整合可透過 DownstreamErrorMapper 明確 allowlist 已知 status/錯誤碼/payload schema,轉譯成安全的本地 ApplicationException;未匹配或 mapper 失敗仍回到不透明預設,handler 層亦可覆寫 mapDownstreamHttpException(...)。

  • 認證 entry point 不再洩漏底層 exception message 或回傳模糊 unauthorized:Bearer 缺少/無效、 Basic credential 拒絕、權限不足與認證服務不可用現在各有 stable type/errorCode;停用、鎖定、 帳號過期與憑證過期不再向未認證 client 枚舉狀態,全部收斂為 invalid-credentials。 WWW-Authenticate 的 error description 亦改為不透明訊息。欄位驗證錯誤現在固定帶 validation-error,產品明定的暫時登入鎖定則回 423 login-temporarily-locked。

  • 通知 Outbox 的超大 VARCHAR 會讓 MySQL schema update 失敗:subject VARCHAR(2000)、 deep_link VARCHAR(10000) 與 cc_recipient_addresses VARCHAR(10000) 的最大 byte budget 會和 同列其他欄位一起計入 InnoDB 65,535-byte row limit;使用 utf8mb4 時即使表內尚無長資料, Hibernate 執行 DDL 也可能拋 Row size too large。三欄現與 body 一致使用 Length.LONG32,讓 dialect 產生大型文字型別;NotificationService 同時在落庫前明確限制 type 100、user id 36、address 320、CC 100 位、渲染後 subject 500、deep link 2000、呼叫端 dedupe key 100 字元。顯式去重鍵改存 {requestKey}:{CHANNEL}:{SHA-256(address)},保留業務 prefix query、固定地址部分長度並移除 管理面地址洩漏;查重仍兼容 200 字元內的舊版明文地址鍵,既有列不必重寫。MySQL 既有表請在 啟動新版前執行:

    ALTER TABLE notification_outbox
    MODIFY subject LONGTEXT NOT NULL,
    MODIFY body LONGTEXT NOT NULL,
    MODIFY deep_link LONGTEXT NULL,
    MODIFY cc_recipient_addresses LONGTEXT NULL;
  • QueryRunner 遇到不存在的排序屬性時,不再讓 JPA IllegalArgumentException 外溢成 500;現在轉為 InvalidDataException,由標準 REST handler 回傳 400 invalid-data,detail 會保留 client 傳入的屬性名以利 除錯。canonical app-office、mockup 與 appfuse-web 參考實作的訂單初始排序/欄序/工作台排序亦同步由 deliveryDate 修正為 deliveryAt。

  • appfuse-web 花店參考實作補齊 app-office 的配送時區契約:輸入組合、Instant 反投影、日期篩選與 MSW 行事曆驗證共用單一 IANA 業務時區;bootstrap app.timeZone 缺漏或無效時 fail fast;直連 server 的 system-info 缺漏時沿用 bootstrap 值,無效覆寫則 warning 並保留已驗證值。參考實作 src/**/*.spec.* 亦正式納入 Vitest 閘門。

  • UUID 主鍵遷移後,canonical app-server 不再於 service/security 邊界直接 UUID.fromString(...):一般外部 ID 的畸形值統一映射為 RFC 7807 的 400 invalid-data,查找型 PrincipalDirectory/認證 SPI 則回 Optional.empty(),避免舊書籤、JWT subject 或 request body 造成 500 並洩漏 JDK 例外訊息。

  • app-office 與 app-office-mockup 的配送契約完整收斂為 deliveryAt;server 透過 /api/v1/system/info.timeZone 公布實際業務時區,前端的輸入組合、回程 投影、日期篩選及 MSW 行事曆驗證使用同一 IANA zone,不再混用瀏覽器與 server 時區。

  • document.Body#appendHtml(String) / document.Paragraph#replaceWithHtml(String) — 未以區塊標記包覆的 HTML 會掉字、且格式遺失。兩者原本只走訪 body.children()(僅回傳 Element),因此:

    • 區塊之間的裸文字節點被丟棄:appendHtml("價格 <strong>1280</strong> 元") 只渲染出「1280」,「價格 」與「 元」消失
    • 頂層行內元素自身的 mark 未被套用:上例的「1280」連粗體都沒有(extend 僅在走訪子元素時呼叫)

    改為走訪全部子節點、連續行內節點歸入同一隱含段落後修正。實務上 RichTextEditor 恆以 <p> 包覆,故此路徑長期未被觸發;但兩者皆為公開 API、未要求呼叫端包覆,replaceWithHtml 用於套印占位符替換時尤其容易餵入裸片段。

  • document.Paragraph#replaceWithHtml(String) — <ul>/<ol> 未渲染為項目符號(該路徑從未處理清單,項目會被壓成無前綴的段落)。改與 appendHtml 共用同一段落產生邏輯後,兩者行為一致(• / 1. 前綴)。

    • 附帶行為變更:appendHtml("")(空字串)不再產生一個空段落,改為不附加任何內容。
  • document.Paragraph#replaceText(String, String) / document.Body#replaceText(String, String) — 取代後整個段落的格式被塌縮成段首 run 的格式。原實作把 paragraph.getText() 整段取代後寫回第 0 個 run、再 removeRun 掉其餘所有 run,因此佔位符自身的格式(底線、粗體等)與段落其餘 run 的格式一併遺失——套印「帶底線的填空欄位」時尤其明顯(舊 javadoc 宣稱的「保留首個 run 的格式」保的是段首 run,且是強加到整段)。

    改為 run-aware:把佔位符涵蓋的 run 找出來,取代結果只寫進佔位符起點所在的 run(沿用它的格式),其餘涵蓋 run 僅清掉被佔位符蓋住的部分,不再刪 run。段落其餘 run 完全不受觸碰。此舉一併把「Word 因拼字檢查/rsid/格式邊界把佔位符拆散到多個 run」變成正式支援的情境——原實作是靠整段塌縮意外蓋掉它,代價即上述格式遺失。

    • 附帶行為變更:段落無 run、但 getText() 有內容時(文字來自結構化文件標籤、註腳參考等),不再新建一個 run 塞入整段文字——該路徑會使文字重複顯示。此類段落現視同無可取代內容。
    • 附帶行為變更:目標字串自身含 tab 或換行時不再取代(原實作會取代,但 <w:tab/> / <w:br/> 不隨文字塌縮移除,會殘留在結果中)。佔位符被 tab/換行切開的情形兩版皆不取代——結構元素在比對字串中即以 \t / \n 現身,佔位符本就不成立。
  • HTML 子集的文字色解析(影響 document 的 appendHtml / replaceWithHtml 與 workbook 的 Cell#setRichText)—— background-color 被當成文字色。原實作以 style.contains("color:") 判定、再以貪婪比對 .*color:\s*(...) 取值,故:

    • <mark style="background-color:#ffff00">(RichTextEditor 的螢光筆)會把螢光標記的文字整段染成該顏色
    • style="color:#ff0000;background-color:#ffff00" 的貪婪比對咬到最後一個 color:,文字色取到背景色(紅字變黃字)

    改為逐條解析 style 宣告、只認屬性名恰為 color 者(同屬性多次宣告以最後一筆為準,依 CSS 慣例)。螢光筆在 Word/Excel 皆僅保留文字——逐 run 底色未在 v1 範圍內。

  • 登入失敗鎖定的 off-by-one:達閾值當次實際未鎖定。舊 IncrementalLockoutPolicy 的時長為 offset + baseUnit × (failureCount − threshold),而消費端慣用的雙參數建構子 offset = 0,故第 threshold 次失敗算出 0 秒鎖定 → isLocked() 立刻為 false,實際要到第 threshold + 1 次才鎖 1 個單位。與組態註解宣稱的「連續失敗 5 次觸發鎖定」不符。新的 LoginLockout#lockoutDuration 為 step × (failureCount − threshold + 1),達閾值當次即鎖 1 個 step。

    • 併同修正顏色值解析:新增 rgb() / rgba() 支援(原本落到 Color.decode 失敗而靜默丟失顏色;alpha 忽略);#rgb 縮寫改依 CSS 展開為 #rrggbb(原本被當成 0xabc 解出完全不同的顏色)。具名顏色(red 等)仍不支援、回退為不套色。
  • workbook.Cell#setRichText(String) — 衍生字型以整數 point 複製基底字級,使 Workbook#open 讀進來的既有活頁簿中 10.5pt 之類的儲存格字級被截成 10pt(僅套了 marks 的段落受影響,形成同格內字級不一)。改以 twip 複製。以公開 API 建立的樣式不受影響(CellStyle#setFontSize 只吃整數 point)。

  • 快取 disable() 跨 getCache() 重取失效(cache/,ADR-007):ManagedCache 的 enabled 狀態原掛在臨時 wrapper 上,而 createCache/getCache 每次回新 wrapper,導致 manager.getCache(name).disable() 在下次 getCache(name) 後蒸發。狀態已上提到 CacheManager(依名稱共享旗標),停用狀態現跨重取一致且持久

  • 方法層 @PreAuthorize 授權拒絕誤判為 500(error/):StandardRestExceptionHandler 的兜底 @ExceptionHandler(Exception.class) 會吞掉 Spring Security 方法層授權拋出的 AuthorizationDeniedException(AccessDeniedException 子型別),經 ExceptionMappingRegistry 無對應 → 誤映為 500。新增 @ExceptionHandler(AccessDeniedException.class) 明確映為 RFC 7807 的 403 Forbidden。URL 層授權拒絕(authorizeHttpRequests)仍由 Spring Security filter 的 AccessDeniedHandler 處理、不變。對未用方法層授權的應用 inert

  • 雲端 / SFTP staging 上傳遺失 metadata 導致下載 404(file/):S3FileStorage、AzureBlobFileStorage、SftpFileStorage 的 proxy / SAS / presigned staging 上傳不會把 original-filename 與 contentType 寫進 staging 物件,persist 階段取不到 metadata → 下載端 getMetadata 失敗回 404。prepareStaging 改為額外寫入 .meta sidecar(與 LocalFileStorage 對齊),persist 優先讀物件自身 metadata、缺則退回 sidecar;S3 服務端複製改 MetadataDirective.REPLACE 顯式還原、Azure 於 copyFromUrl 後 setHttpHeaders + setMetadata 還原。deleteStaged / persist 收尾一併清理 .meta

  • Mailer.testConnection() 永遠連 localhost:25、拖垮 mail health check(mail/):testConnection() 走無參 transport.connect(),但 host/port 由 JavaMailSenderImpl.setHost/setPort 存於 instance 欄位、不在 Session properties,無參 connect 因此退回 JavaMail 硬預設 localhost:25 → 連線必然失敗、/actuator/health 被誤判 DOWN(每次探測洗一條 ERROR)。改為顯式 transport.connect(host, port, username, password),對齊 Spring 自身的 JavaMailSenderImpl.connectTransport()。實際寄信不受此 bug 影響(doSend() 本就顯式帶 host/port),僅健康探測路徑受害

  • 防火牆「啟用 + 空白名單」兩條送信路徑行為相反(mail/):isEmailAllowed 對空白名單回 true(放行),shouldBlockEmail 回 true(封鎖)——導致 send(MimeMessage) 路徑在空白名單時寄給所有人(防火牆形同失效、違反「空白名單 = 阻擋全部」的文檔契約),而 send(SimpleMailMessage) 路徑則封鎖。isEmailAllowed 空白名單改回 false,兩條路徑一致皆「空白名單 = 阻擋全部」。行為變更:先前靠此 bug「空白名單仍寄出」的(誤)行為將不再寄出

  • 防火牆全擋時靜默丟棄郵件(mail/):firewall 過濾後一個收件者都不剩時,send(SimpleMailMessage) 先前靜默 return(不寄、不拋、僅 info log)→ 經通知子系統會被誤標為遞送成功(假成功);send(MimeMessage) 則讓底層因「無收件者」拋出難解錯誤。兩條路徑統一改拋新增的 MailBlockedException(見 Added),不靜默、不假成功:通知通道據此轉為遞送失敗、進入重試 / dead-letter。部分收件者被過濾(仍有人在白名單)不受影響,照常寄給允許的子集

  • 設定中無法解析的 ${ENV_VAR} 導致應用啟動失敗(env/):Environ 的設定輸出會對每個 key 急切解析 placeholder;當 conf 使用 ${DB_PASSWORD} 這類寫法而環境變數未設時,PlaceholderResolutionException 會在 environmentPrepared 階段中止啟動,且堆疊指向日誌印表器而非缺少的變數,難以診斷。此組合是框架自身推薦的機密寫法(${UPPER_SNAKE} 值記號)必然會遇到的情況。改為攔下解析失敗、改印 <unresolved> 並輸出一行 warn 指出是哪個 key——日誌不再是應用啟動的必要路徑。缺少的環境變數仍會在真正被使用時以原本的方式失敗

  • Converters 的 String→Enum 轉換大小寫敏感(converter/):Converters.convert 原以 Enum.valueOf(type, value) 精確比對常數名(UPPER_SNAKE),導致 RSQL/Filter 查詢傳入小寫 enum 值(如 status=in=("active"))時拋 IllegalArgumentException。改為忽略大小寫比對 enum 常數名(PredicateBuilder 因此可接受小寫 enum 過濾值)。向後相容:既有大寫值照常解析,僅額外接受不同大小寫。與 Jackson 端的 case-insensitive enum 讀取對齊

Security​

  • Login Lockout 防暴力破解機制
  • JWT Token 驗證增強
  • 啟動日誌不再以明文輸出設定機密(env/):Environ.logConfiguration() 先前對每個設定檔 property source 的每個 key 呼叫 environment.getProperty(key) 並以 INFO 印出解析後的值,完全不遮蔽——外部 conf 的 spring.datasource.password、app.security.jwt.private-key、各式 API key 都會落進 server log(實測每個機密印 3 次:各 property source + merged config)。遮蔽先前只做在 actuator 組態快照端,啟動日誌端沒有。現改為套用與 actuator 快照同源的判準(ConfigValueMasker,見 Added):屬性名含 password / secret / token / credential / key 者整值遮蔽,URL/URI 內嵌帳密亦遮蔽;敏感屬性根本不解析即回傳遮蔽字串,不再有「為了印日誌而解析機密」的路徑
    • Impact:既有部署的 server log 可能已含明文機密,建議檢視既有日誌並輪換曾出現的憑證
  • 檔案儲存識別碼不能再離開所屬 partition(file/,FU-110):LocalFileStorage、SftpFileStorage、 S3FileStorage、AzureBlobFileStorage 先前把 partition、fileId、tempId 直接接成路徑或 key。若應用把 客戶端送來的 fileId 交給儲存層,含 .. 的值可讀取或刪除儲存根目錄外的檔案(Local/SFTP);含 / 的 partition 也可能與其他 partition 的 key 重疊(S3/Azure)。現在所有入口都先以 StorageKeys 驗證, LocalFileStorage 另在正規化後確認路徑仍位於該 partition 目錄內
    • Impact:不合法的識別碼一律拋 InvalidStorageKeyException,不再當成一般路徑處理。FileIdGenerator 與內建 backend 產生的 ID 不受影響;自訂 ID 產生器若產生含 :、\、空區段或 . 區段的 ID,需調整。 應用層仍應只把自己保存過的 fileId 交給儲存層,不要信任客戶端送來的永久檔識別碼

Breaking Changes​

  • ADR-036(4.0.0-alpha.35):LoginService.LoginResult、RefreshSessionStore.NewSession/Session 新增 contextBinding record component。

    • Impact: 舊 constructor 與 accessor 保留;既有 record pattern、record-component 反射、自動序列化及 equals/hashCode consumer 仍受影響。
    • Migration: 調整解構/反射/wire schema;HTTP 殼繼續使用 app DTO。Store adapter 保存 context,拒絕 pending use/rotate,未支援 atomic activation 時沿用預設拒絕。先發布 framework,再同步採用 source adapters;single 無 policy bean 時保留舊語意。
  • 未經應用 converter 映射的 java.time.YearMonth Entity 欄位改以 VARCHAR(7) 字串讀寫

    • Impact: 升級前,Hibernate 對這類欄位退回 Java 序列化,存成二進位欄位(VARBINARY/bytea 等, 依方言)。升級後框架自動映射改以 YYYY-MM 字串讀寫:既有二進位資料讀取失敗,ddl-auto=validate 亦會因欄位型別不符而啟動失敗。已由應用自己的 AttributeConverter 映射者(欄位上明確 @Convert, 或 @Converter(autoApply = true))不受影響:應用 converter 優先於框架映射,欄位型別與讀寫維持原狀。
    • Migration: 升級前盤點未經應用 converter 映射的 YearMonth Entity 欄位。有既有資料者,先以一次性 migration 反序列化舊值並寫入新 VARCHAR(7) 欄(值為 YearMonth.toString()),再替換原欄位; 無資料者直接把欄位改為 VARCHAR(7)。
  • PersistenceEncryption.fromBase64 不再接受空白 root key

    • Impact: 以 Map.of() 或空白值建立 PersistenceEncryption 會在建構期拋 IllegalStateException;原本「先建 bean、首次使用才失敗」的組裝會改為啟動失敗
    • Migration: 由部署提供 32-byte Base64 root key(/env-config 的 generate(secret)); 測試以 fixture 提供固定 key
  • 移除 passwordless-login reference flow

    • Impact: POST /api/v1/auth/passwordless、PasswordlessRequest、 SignedLinkApplication.PURPOSE_PASSWORDLESS、createPasswordlessLoginLink、 sendPasswordlessLoginLink 與 passwordless notification template 不再存在
    • Migration: 登入頁改用 Email OTP;事件 deep link 繼續使用 GET /api/v1/auth/link/consume 與 POST /api/v1/auth/exchange
  • Generated surrogate ID 的 Java 型別由 String 收斂為 UUID

    • Impact: audit 與 notification persistence base class、repository ID generic、 outbox dispatch API 及兩個 canonical app-server 的 generated Entity 均改用 UUID;JSON 線格式仍是 UUID 字串,資料庫共同基線仍是 VARCHAR(36)。
    • Migration: 自訂 repository、DTO assembler 與 service boundary 改用 UUID, 或只在 HTTP/security subject 邊界明確 parse/format。升級既有資料前先驗證 PK/FK 都是 canonical UUID;若有非 UUID 舊值,必須以對照表在同一 migration 更新全部 FK 與 PK,並驗證沒有 orphan。外部字串 ID 不得裸呼叫 UUID.fromString:必要輸入應轉成標準 400,Optional 查找契約則回 empty。
  • 通知時間 mutation 改為由呼叫端傳入 Instant

    • Impact: InAppNotificationBase.markRead()、NotificationOutboxBase.markSent()、 NotificationOutboxBase.requeue() 不再自行取得現在,分別改為 markRead(Instant)、markSent(Instant)、requeue(Instant); NotificationService 與 NotificationOutboxDispatcher 建構子新增 Clock
    • Migration: 應用層注入同一個 Clock,在 use-case 邊界傳入 clock.instant();所有採用上述基類的應用都必須啟用 @EnableJpaAuditing 並提供使用該 Clock 的 DateTimeProvider。未啟用 auditing 時,非 null 的 audit 欄位會使 insert 失敗;Outbox 亦會在 persist 時明確拒絕建立不可排程的列
  • SFTP staging 收斂為 Local 相容的唯一佈局

    • Impact: 舊版 SFTP 尚未 persist 的 staging 內容使用 {tempId},新版固定尋找 {tempId}.bin;永久區路徑與 key-value metadata 格式不變。
    • Migration: 升級前暫停上傳並完成或清除所有 SFTP staging;確認 {basePath}/staging 與 {basePath}/files 位於同一檔案系統,且帳號具備 rename 權限。升級後不需搬移既有永久檔案。
  • JWT 新增強制 token family

    • Impact: 舊版未帶 tokenType=access 的 access token、既有 signed-link 與 exchange code 在升級後不再通過新版驗證;共用 RSA 金鑰也不能再讓 refresh 或 signed-link token 被當作 Bearer access token。
    • Migration: 部署後讓既有 session 重新登入,並重新簽發尚未使用的 signed-link; 自製 JWT 簽發器須為 API access token 加入 tokenType=access。
  • Cache 實作者須提供條件式 remove

    • Impact: 自訂 Cache<K,V> 實作升級後因新增 remove(K key, V expectedValue) 而編譯失敗。
    • Migration: 以 backing store 的原子 compare-and-remove 實作;不可用 get 後再 remove 取代,否則 single-use credential 會重新出現併發重放窗口。
  • Acting delegation claim/API 由 delegates 改為 grantors

    • Impact: JWT claim delegates、ActingClaims.CLAIM_DELEGATES、 ActingContext.getDelegates()/getDelegate() 不再存在;actor 與 delegation 集合的值 不再是 username,而是與 JWT sub 相同的 stable subject。
    • Migration: 改用 claim grantors、CLAIM_GRANTORS、 getGrantors()/getGrantor();簽發端傳入授權人的 stable subject,顯示 username 時於 application boundary 透過 principal directory 映射。部署後讓既有 acting session 重新登入或重簽 token。
  • LoginService 不再接受 remember

    • Impact: login(..., app, remember) 與 issue(principal, app, remember) 簽章移除最後一個 boolean;該值原先除 audit log 外不影響任何 session 行為。
    • Migration: 改呼叫 login(..., app)/issue(principal, app);若產品提供 Remember Me, 由前端選擇 session storage 或 persistent storage,不把該偏好送入 auth capability。
  • File staging ticket 不再承載 application HTTP URL

    • Impact: StagingUploadInfo.uploadUrl()/isExternalUrl() 改為 directUploadUrl(): Optional<String>/isDirectUpload();Local、Database、SFTP 與 未啟用 S3 Presigned/Azure SAS 的 ticket 回傳 empty。Storage builder 不再接受 stagingBaseUrl(...)。FileDescriptor.ofStaging(tempId, filename, size, mimeType) 亦不再推測 /api/v1/staging/files,descriptor url 預設為 null。
    • Migration: application HTTP adapter 依自己的 mapping 與 tempId 建立 proxy upload URL;若 directUploadUrl() 有值則原樣回給 client。建立 API response 時明確使用 FileDescriptor.ofStaging(..., resolvedUploadUrl)。app-server 的 reference adapter 以明確的 /api/v1/staging/files reference route 組出 application URL;不再提供 app.storage.staging.upload-base-url,避免 mapping、security、client 與文件因 runtime 設定漂移。需要不同 API shape 的消費端應修改或替換 reference adapter。
  • 框架 jar 的具體 @Entity 全數下移應用層(ADR-017 階段 1+2+3:notification 五實體 + audit),jar 改出 @MappedSuperclass 欄位基類 + @NoRepositoryBean 泛型 repository 基底,SPI 同步泛型化/重塑;並以兩條 ArchUnit 不變量防復發(決策四)。詳見 ADR-017(原則二:框架 jar 不擁有 @Entity)

    • Impact: 所有消費 notification / audit 能力的下游。jar 移除具體類(未經 deprecation cycle——ADR-017 判定「jar 擁有 @Entity」本身即 ADR-014 判準零的誤置,於 MAJOR 直接移除): notification.template.NotificationTemplate / NotificationTemplateRepository、 notification.preference.NotificationPreference / NotificationPreferenceRepository、 notification.inapp.InAppNotification / InAppNotificationRepository、 notification.outbox.NotificationOutbox / NotificationOutboxRepository、 notification.outbox.NotificationDeliveryLog / NotificationDeliveryLogRepository、 audit.AuditEventRecord / AuditEventJpaRepository; 新增基類 NotificationTemplateBase / NotificationPreferenceBase / InAppNotificationBase / NotificationOutboxBase / NotificationDeliveryLogBase / AuditEventRecordBase 與對應 *RepositoryBase<T>(@NoRepositoryBean;outbox 的 findDueForDispatch / claim、audit 的 search 以 SpEL #{#entityName} 指涉應用 entity);DbNotificationTemplateResolver<T> / DbNotificationPreferenceFilter<T> / InAppNotificationChannel<T> / PersistentNotificationDeliveryListener<T> / PersistentAuditEventRepository<T> 泛型化。引用被移除類的下游升級後編譯失敗。
    • 決策四(機械保證):jar 測試新增 FrameworkPersistenceArchitectureTest 兩條 ArchUnit 不變量——① io.leandev.appfuse.. 不得有 @Entity;② 持久類(@Entity/@MappedSuperclass)的 tenantId 欄位必帶 @TenantId。違反即 build 失敗,補上 FU-56 潛伏至今的閘門缺口。
    • 階段 2 的 API 變更(outbox / delivery-log):
      • NotificationDeliveryListener.onAttempt 簽章由具體 NotificationOutbox 改為新 OutboxView 唯讀介面(ADR-014 規範一:SPI 不得洩漏 entity;NotificationOutboxBase 直接實作,應用 entity 自動滿足)
      • NotificationService 建構子改收 NotificationOutboxRepositoryBase<T> + Supplier<T> entity 工廠(jar 無從 new 應用 entity);STANDALONE_DEDUPE_PREFIX 升為 public
      • NotificationOutboxDispatcher 建構子改收 NotificationOutboxRepositoryBase<?>;對租戶的唯一接觸點改為 TenantAware 探詢(決策三)——列帶非 root 租戶才 runAs;root 緒建立的列由 Hibernate 注入 __root__ 哨兵、以 root 身分遞送(與舊版「tenantId 為 null 直接遞送」語意一致)
      • 去重查詢由 findByTenantIdAndDedupeKey(tenantId, key) 收斂為單欄 findByDedupeKey(key)——tenant 專案的租戶範圍由應用 entity 的 @TenantId 於 Session 建構時自動限縮;遞送稽核 log 的租戶不再由 listener 複製、改由 @TenantId 於 runAs 內寫入時注入
    • 決策六(全域範本拆表):舊 notification_template 以 tenant_id = null 表達全域預設範本——nullable uk 欄位使唯一性在 MySQL / PostgreSQL 靜默失效(重複全域範本可共存、resolver 的 Optional 查詢會拋 IncorrectResultSizeDataAccessException)。改由應用宣告兩個 entity:租戶覆寫表(tenant 專案帶 @TenantId,uk 首欄恆 NOT NULL)+ global_notification_template(無租戶欄,uk(notification_type, channel, locale))。跨表解析優先序改由解析鏈表達(租戶表 → 全域表 → 訊息束)。
    • 決策五(locale 哨兵):範本 locale 由 nullable 收緊 NOT NULL,「不分語系」以 wildcard 哨兵 '*'(NotificationTemplateBase.ANY_LOCALE)表達、不再以 NULL 偷渡第二語意;對外 REST 契約的 locale: null 由應用於 DTO 邊界對映 null ⇄ '*'(契約不變,參考 app-server NotificationTemplateService / NotificationTemplateResponse)。
    • 時戳命名統一(createdAt/updatedAt → createdDate/lastModifiedDate):基類稽核時戳對齊參考實作 audit 基類(Spring Data auditing)與整合面既定的日期命名 SoT,消除全站兩套並存。範圍:NotificationTemplateBase、NotificationOutboxBase(createdAt/updatedAt 雙欄)與 InAppNotificationBase(createdAt 單欄)的欄位、Lombok getter/setter(getCreatedAt() → getCreatedDate()、getUpdatedAt() → getLastModifiedDate())、DB column(created_at → created_date、updated_at → last_modified_date);OutboxView SPI 同步改 getCreatedDate() / getLastModifiedDate();InAppNotificationRepositoryBase.findByUserIdOrderByCreatedAtDesc → findByUserIdOrderByCreatedDateDesc。領域語意時戳不受影響(nextAttemptAt / sentAt / readAt / delivery-log attemptedAt 維持 *At)。引用舊名的下游升級後編譯失敗;DB 遷移見下 Migration 步驟 4 的 ⓪。
    • Migration:
      1. 應用宣告具體 entity + repository(參考 app-server io.leandev.app.notification 的 NotificationTemplate / GlobalNotificationTemplate / NotificationPreference / InAppNotification / NotificationOutbox / NotificationDeliveryLog 與 io.leandev.app.audit 的 AuditEventRecord,及各 repository;tenant 專案以 @TenantId 宣告租戶欄位、ownership 專案省略;audit 兩種模式皆無租戶欄)
      2. @EntityScan / @EnableJpaRepositories 移除 io.leandev.appfuse.notification.template、io.leandev.appfuse.notification.outbox 與 io.leandev.appfuse.audit(inapp / preference 若曾列亦移除);具體 entity 隨應用自身套件掃描
      3. 接線改泛型(參考 app-server NotificationConfig / AuditPersistenceConfig):範本解析鏈 new DbNotificationTemplateResolver<>(tenantRepo, new DbNotificationTemplateResolver<>(globalRepo, nlsFallback));偏好 new DbNotificationPreferenceFilter<>(preferenceRepo);站內信 new InAppNotificationChannel<>(inAppRepo, InAppNotification::new);遞送稽核 new PersistentNotificationDeliveryListener<>(deliveryLogRepo, NotificationDeliveryLog::new);NotificationService 建構子補 NotificationOutbox::new 工廠;自訂 NotificationDeliveryListener 實作把參數型別改 OutboxView;稽核 sink new PersistentAuditEventRepository<>(auditEventJpaRepository, AuditEventRecord::new)
      4. 資料遷移(既有部署;全新 DB 免;audit_event 表形狀不變、無資料遷移):
        -- ⓪ 升級前(舊 schema):時戳欄位改名(時戳命名統一;RENAME COLUMN 語法依 DB 方言調整)
        ALTER TABLE notification_template RENAME COLUMN created_at TO created_date;
        ALTER TABLE notification_template RENAME COLUMN updated_at TO last_modified_date;
        ALTER TABLE notification_outbox RENAME COLUMN created_at TO created_date;
        ALTER TABLE notification_outbox RENAME COLUMN updated_at TO last_modified_date;
        ALTER TABLE in_app_notification RENAME COLUMN created_at TO created_date;
        -- ① 升級前(舊 schema):locale wildcard 哨兵回填(決策五)
        UPDATE notification_template SET locale = '*' WHERE locale IS NULL;
        -- ② 以新版啟動一次(ddl-auto 建出 global_notification_template;
        -- Hibernate update 不會收緊既有欄位的 nullability,舊欄維持 nullable 無妨——
        -- 資料面由 ③ 清空 null-tenant 列、應用面不再寫入 null)
        -- ③ 啟動後:全域範本拆表搬遷(決策六)
        INSERT INTO global_notification_template
        (id, notification_type, channel, locale, subject, body, enabled, created_date, last_modified_date)
        SELECT id, notification_type, channel, locale, subject, body, enabled, created_date, last_modified_date
        FROM notification_template WHERE tenant_id IS NULL;
        DELETE FROM notification_template WHERE tenant_id IS NULL;
        -- ④ tenant 專案的 outbox / delivery-log:tenant_id 改由 @TenantId 宣告(NOT NULL),
        -- 既有 root 建立的 NULL 列回填 root 哨兵(「無租戶」不再以 NULL 表達,決策二)
        UPDATE notification_outbox SET tenant_id = '__root__' WHERE tenant_id IS NULL;
        UPDATE notification_delivery_log SET tenant_id = '__root__' WHERE tenant_id IS NULL;
        ②→③ 之間全域範本暫不生效(解析退回訊息束預設),建議於維護窗口內完成
      5. 自行建立範本列的測試 / 程式碼:「不分語系」改設 NotificationTemplateBase.ANY_LOCALE(或沿用基類預設值),不再 setLocale(null)
  • NotificationOutbox.dedupeKey(DB 欄位 dedupe_key)由 nullable 收緊為 NOT NULL。詳見 ADR-017 決策五

    • Impact: 所有消費 notification 能力的下游。動機是一個兩個方向都壞掉的 unique 約束——uk_notification_outbox_dedupe (tenant_id, dedupe_key) 含可為 null 的欄位,而各 DB 對「unique 中的 NULL」語意分歧:SQL Server / Oracle 視 NULL 相等 ⇒ 同租戶第二則「無 dedupeKey」的通知會撞 unique violation(dedupeKey 選填、outbox 又是 append-only ⇒ 每租戶史上只能有一列);MySQL / PostgreSQL 視 NULL 互不相等 ⇒ ownership 專案(tenant_id 恆 null)唯一性靜默失效。收緊後 uk 中不再有 nullable 欄位,各 DB 語意差異不再適用。
    • 對呼叫端無 API 變更:NotificationRequest.dedupeKey 維持選填(可為 null)。未提供時由 NotificationService 填入「每列自成一鍵」的唯一值(前綴 ~standalone:),語意等價於「不參與去重」——去重查詢仍只在呼叫端顯式提供 dedupeKey 時執行。
    • Migration:
      1. 升級前回填既有 NULL 列(否則 ddl-auto 收緊欄位時失敗):
        -- MySQL / PostgreSQL / H2
        UPDATE notification_outbox
        SET dedupe_key = CONCAT('~standalone:legacy-', id)
        WHERE dedupe_key IS NULL;
        -- Oracle / SQL Server 用 '~standalone:legacy-' || id 或 + id 串接
        id 為主鍵、必不重複,故回填值天然滿足 uk。
      2. 自行建立 NotificationOutbox 列的測試 / 程式碼須設 dedupeKey(不驗去重者給相異值即可,如 "...-" + UUID.randomUUID());經 NotificationService.notify(...) 的一般路徑不受影響。
  • 租戶隔離機制改採 Hibernate 原生 discriminator 多租戶(@TenantId),取代 @Filter + 應用層 AOP 手動啟用。詳見 ADR-016

    • Impact: 所有繼承 TenantAwareEntity 的下游。動機是一個靜默資安缺陷——舊機制的 filter 需由應用層 aspect 在「Session 已綁定」時呼叫 enableFilter,實際只有 OSIV 綁定時成立,故 spring.jpa.open-in-view=false 會使租戶 filter 從未啟用、跨租戶資料全可見,且例外被吞、完全無聲。新機制於 Session 建構時套用,不依賴 AOP 順序或 OSIV。
    • Migration:
      1. 刪除本模組的 TenantFilterAspect 副本(或跑 /feature sync tenant)
      2. 新增 config/feature-tenant.yml 接線 resolver,並加入 spring.config.import 清單: spring.jpa.properties.hibernate.tenant_identifier_resolver: io.leandev.appfuse.jpa.tenant.TenantContextIdentifierResolver (以類名字串交給 Hibernate 自行實例化——不可只註冊為 Spring @Bean,否則 @DataJpaTest 等 slice 會因取不到租戶識別而 context 載入失敗)
      3. 自訂租戶基類須移除租戶欄位上的 @NotBlank(Bean Validation 早於 @TenantId 值生成,必然先失敗)
      4. 修正覆寫 onPrePersist() 並呼叫 super 的子類(基類鉤子已移除)
      5. 移除 repository 中「因 entityManager.find() 不受 filter 影響」而手寫的租戶比對——@TenantId 的 filter applyToLoadByKey = true,已涵蓋 load-by-key
      6. 測試:不得以 class 層 @Transactional + @BeforeEach 設租戶(租戶綁在 Session 建構時,而 Spring 的 TransactionalTestExecutionListener 早於 @BeforeEach);改用 TransactionTemplate 先設 context、再開交易
      7. 建議以 spring.jpa.open-in-view=false 跑一次隔離測試,確認不再依賴 OSIV
    • 行為變更:① 租戶綁在 Session 建構時(非 persist 時)② 租戶值於 INSERT 時生成(flush 前 getTenantId() 為 null)③ @PreUpdate 的跨租戶 SecurityException 消失,防線前移至讀取過濾 ④ root(無 TenantContext)可修改他人租戶的既有資料(刻意放寬,對齊 Hibernate isRoot 語意;租戶歸屬不受影響)
  • mail 模組重塑:Mailer 由具體類別改為介面,動態解析能力上收框架(能力歸框架、組裝歸消費端——21-feature-surface.md 紀律四的落實;原能力住參考實作 feature slice 的 MailerService/EmailService)

    • Impact: 直接依賴 io.leandev.appfuse.mail.Mailer 具體行為的下游升級後編譯失敗——new Mailer(sender) 建構、政策 mutator(enableFirewall()/setAllowedDomains()/enableRedirect()/setNoticeSubjectPrefix() 等)、updateDelegate() 皆不復存在;具體類更名 DirectMailer,政策改為建構參數。參考實作 feature slice 的 MailerService/EmailService 已刪除(隨 /feature sync 下行),呼叫它們的下游業務碼同步失效
    • Migration:
      1. 寄信消費端:注入 Mailer 介面(@Primary bean 為 RoutingMailer),mailer.compose().to(...).subject(...).html(...).send()——from 已由解析結果預填,原 EmailService.send*Email(...) 各排列組合皆由 compose 鏈取代
      2. 建構 Mailer:new DirectMailer(sender, policy, defaultFrom);政策以 MailOutboundPolicy.builder()...build() 表達(build() 內含 fail-fast 驗證),不再於建構後以 mutator 拼裝
      3. 具名設定/快取管理/可用性:注入 RoutingMailer 具體型別(named(configName)/evict/evictAll/current()/isAvailable()),對應原 MailerService.resolve/evict*/hasMailer
      4. 契約型別:MailerSpec/MailerSpecProvider 改 import io.leandev.appfuse.mail(原 feature slice 套件),實作內容不變
      5. 通知通道:MailDelivery 實作改注入 Mailer(參考 app-server MailerMailDelivery,原名 EmailServiceMailDelivery)
  • auth-admin feature 撤銷,整域降級為業務層參考實作(ADR-022 取代 ADR-020:adoption 後 fleet 回饋顯示帳號來源異質——特殊管道建立的帳號不宜經本地 CRUD 管理——管理面分歧擴大,canonical service 前提不成立)

    • Impact: feature catalog 移除 auth-admin;程式碼原樣搬遷至 {controller|service|security}/authadmin/(@reference-surface 生命週期),wire 契約(/api/v1/accounts、/api/v1/roles)不變。已 adopt 的下游其副本即日起為下游自有碼、不再受 /feature sync 管理;非 base variant 的新 scaffold 不再帶帳號管理起點
    • Migration: 已 adopt 的下游從 catalog 移除 auth-admin 條目即可(碼不動——本就已依各自治理模式分歧);需要參考起點的新專案 retarget authadmin/ 業務域,確定不要則 /prune-reference
  • security 貢獻點 SPI 上收框架並改名(原住參考實作 feature/auth,是跨 feature 的公共契約、零 entity 知識——與 mail 契約上收同一原則)

    • Impact: 下游所有實作這三個 SPI 的類(各 feature 的 *SecurityContributor、*AuthoritySeedProvider、delegation 的 LoginActingContributor 實作,及業務層 seed contributor)升級後編譯失敗——app 層介面已刪除,且 AuthoritySeedProvider 改名 AuthorityContributor("Seed" 洩漏消費方式;契約只宣告權限名,seed 進 DB 是參考實作的持久化決策)
    • Migration:
      1. import 改指 jar:io.leandev.appfuse.security.SecurityContributor、io.leandev.appfuse.security.AuthorityContributor、io.leandev.appfuse.security.auth.LoginActingContributor(ActingLogin 為其 nested record,隨之搬遷)
      2. implements AuthoritySeedProvider → implements AuthorityContributor;實作類命名慣例同步為 {Feature}AuthorityContributor(參考 app-server 的九個改名)
      3. features.json:僅以 SPI import 依賴 auth 的 feature,import 邊消失後依 I3 改記 beanRequires: ["auth"](授權鏈與權限 seeding 的實質依賴仍在;參考 app-server 的 audit / mail / platform-info / reference-data)
  • Cache 記憶體管制 safe-by-default(cache/,ADR-011 supersede ADR-006):CacheManagerBuilder.build() / buildDefault() 預設啟用記憶體管制

    • Impact: 啟用管制後,各 cache 的 offheap 宣告大小加總超過預算會被 REJECT(拋 IllegalStateException)。heap 一律筆數計、不受 byte 預算限制(真實 byte 封頂改用 offheap)
    • Migration: 多 cache 共用一個 governed CacheManager 時,確認 offheap 加總在預算內(或明示 .offheapBudgetMB(MB));要完全沿用舊行為改 CacheManagerBuilder.newCacheManager().ungoverned().build()
  • TierConfiguration.heapSize 更名為 heapEntries(cache/config/)

    • Impact: 直接讀寫 TierConfiguration.heapSize / getHeapSize() 的程式碼編譯失敗
    • Migration: heapSize/getHeapSize() → heapEntries/getHeapEntries()