跳至主要内容

appfuse-server Changelog

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


[Unreleased]

Added

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

  • Bearer WWW-Authenticateerror_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,並透過既有 RefreshCredentialCodecRefreshSessionStore 語意提供 rotation、replay revoke、expiration、remember-me 與 identity replacement。框架不 auto-configure;host 僅在 mock mode 建立 bean。預設 Cookie 名 APP_SERVER_REFRESHDEFAULT_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 可保存 actorSubjectLoginService 的 impersonation family 切換與 refresh 會維持 actor context。

Security

  • RefreshSessionStore.Session 可保存 SessionPolicyBindingLoginService 新增可插拔的 SessionEligibilityPolicy refresh-time revalidation 與 revokeSessionsByPolicy(...) 主動撤銷入口。 impersonation family 現在會在每次 refresh 重驗 subject/actor 帳號狀態、actor 必要 authority、 絕對 policy 效期與應用政策;拒絕時撤銷整個 family 並 blacklist 目前 access session。
  • Acting claim/context/request audit 新增 sessionIdactingPolicyACTING_ACCESS 同時記錄 stable subject、真實 actor、0..n grantors 與可並存的 actingModes,讓 delegation 與 impersonation 的有效身份、操作者、授權來源及 session family 可完整追溯。
  • 新增 SessionOptionsRefreshCredentialCodec 與 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 新增 authenticationRequiredaccessTokenInvalidinvalidCredentialsauthenticationServiceUnavailableinsufficientPermission,統一提供 stable RFC 9457 typeerrorCode 供 auth client 可靠分支。
  • 新增 PersistenceEncryptionTextCipher:應用可用單一 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、收件人或內容,也不宣告兩次業務通知等價。既有 OutboundMessageEmailEnvelope constructor 與只實作三參數 MailDelivery.send(...) 的 adapter 維持相容。
  • Outbox 新增可選 deliveryExpiresAt 遞送截止時間;NotificationOutboxDispatchernow >= deliveryExpiresAt 時直接將列標為 SUPPRESSED,不呼叫實際通道,供 OTP 等高時效通知 避免服務停機或排程延遲後寄出已失效內容。既有列的 null 維持無截止時間行為;應用資料表須加入:
    ALTER TABLE notification_outbox ADD delivery_expires_at TIMESTAMP NULL;
  • NotificationServiceNotificationOutboxDispatcher 新增可選 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 新增 StagingCleanupTaskLocalStagingCleanupTask;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-hourscleanup-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 保存 subjectactor、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 查詢邊界,從 >= 改為嚴格 >,對齊 AuditEventRepositoryInstant.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 明確區分 accessrefreshsigned-link token family;standalone Resource Server 僅接受 access,SignedLinkService 僅接受 signed-link。新增 expected-purpose consume overload,會在燒掉 single-use jti 前完成用途驗證;cache store 改以原子 compare-and-remove 保證同節點併發僅一個消費者成功。

  • 新增 PasswordlessRateLimiterCachePasswordlessRateLimiter 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 收斂為 DelegationApplicationLoginActingContributor 窄契約; 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 路徑(AuthenticationManagerAccountStatusUserDetailsChecker)對齊。先前只檢查 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,集中 DaoAuthenticationProviderProviderManager 建構並強制接上認證事件發布;消費端仍可選擇 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 憑證發放 capabilityio.leandev.appfuse.security.authADR-025,自參考實作 feature/serviceaccount 上收)——登入引擎的第四操作,補上 ADR-023 遺漏的一塊:

    • ClientCredentialsServiceclient_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_basicclient_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#newApiKeyak_ + base64url(32 bytes)
  • 登入引擎 capabilityio.leandev.appfuse.security.authADR-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 決策五;宿主自參考實作上收,簽章由 AccountBaseAuthPrincipal,政策實作 downcast 自家 adapter 取 per-project 欄位)。兩種拒絕通道(ADR-023 修訂,源自 ict fleet 實測回饋):回 false = 不透明 401;拋 LoginPolicyDeniedException(或子類)= 可辨識拒絕,引擎 audit log 後原樣傳播、消費端殼映射專屬狀態碼(如多前端「此帳號不可登入本系統」→ 403)
    • LoginPolicyDeniedException — 政策可辨識拒絕的例外型別(AuthenticationException 子類;可子類化攜帶 per-project 脈絡)
  • 主體目錄 SPIio.leandev.appfuse.security.directoryADR-024 決策三):

    • PrincipalView — feature 對「其他主體」的唯讀視圖契約(username/id/displayName/tenantId/interactiveLoginAllowed/primaryRoleName/roleNames/authorityNames;選用 email()〔通道非身分、預設 empty——ADR-024 fallback 條款,供通知收件人組裝;無 email 概念的實作回 empty、消費端退 RecipientResolver〕);刻意排除角色階層展開(wire 契約歸消費端)與密碼憑證
    • PrincipalDirectory — 查找 SPI(findByUsernamefindById〔供簽章連結等以不可變 id 為 subject 的機制精準載入〕/findByEmail〔重複容忍,ADR-018 決策三〕/findAllInteractive);scope 過濾不進契約、歸 feature 以 tenantId() 自行過濾。消費端以自己的帳號持久化實作、回不可變快照(參考 app-server 的 AccountPrincipalDirectory
  • security 貢獻點 SPIio.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)提供;與既有 ActingClaimsActingContext 同家族。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 且政策仍生效)
    • MailerSpecMailerSpecProvider — 郵件設定契約與來源 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 列重置為 PENDINGattempts=0nextAttemptAt=now、清 lastError/sentAtmaxAttempts 不變),供應用層重送 API 呼叫後另行 dispatchAsync。狀態守衛:SENDING(認領中)拋 ConflictException(對映 409,不與進行中遞送競爭)、PENDING 為冪等 no-op。與既有 markSent/markSuppressed/recordFailure 並列,屬狀態機自有轉換;重排不動 dedupeKey、不製造重複列,遞送仍走原子認領(叢集安全)

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

    • 新增 workbook.WorkbookFormatXLSX / 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/bem/ius/del/strikesupsubspan 顏色)逐段套用字型;一般文字不套字型、沿用儲存格樣式
    • 區塊標記壓平(Excel 儲存格內無區塊概念):p/br → 換行、ul/ol / 1. 前綴(沿用 document 模組的清單呈現慣例,使同一份內容匯出到 Word 與 Excel 讀起來一致)、h1h3 → 粗體、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.mdinternal 套件豁免,不屬對外契約
  • 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、把關實作 UserDetailsCheckerDaoAuthenticationProvider#setPreAuthenticationChecks(未鎖定時委派 AccountStatusUserDetailsChecker 做標準旗標檢查)。不再包裝 AuthenticationProvider,故對任何 provider 一體適用;鎖定曲線改由覆寫 lockoutDuration(int) 客製,不需另立政策類。伴隨 security.lockout.AttemptRecordrecord,取代原 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-templateapi,下游繼承),讓 run-once 週期任務(檔案 orphan sweep、staging 清理等)以 @SchedulerLock 標註後在叢集多節點下每 tick 只由一節點執行。Spring @Scheduled 預設每個 instance 獨立觸發、無協調,多節點會重複執行——此為通用解法
  • Schema ownership:框架 jar 不執行 DDL、也不擁有具體 @Entity。參考應用以純 schema mapping 的 ShedLockEntryshedlock 表交給 Hibernate,故 createupdatevalidatenone 完全沿用既有 spring.jpa.hibernate.ddl-auto,欄位型別亦由實際 Hibernate dialect 產生
  • 接線方式(對齊框架「app 接線」慣例):應用在排程配置 @EnableSchedulerLock + 宣告 LockProviderJdbcTemplateLockProvider,建議 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?
  • 值型別:RecipientChannelType(Phase 1 僅 EMAIL)、RenderedTemplateOutboundMessageDeliveryResult(三態: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.NotificationOutboxRepositoryoutbox.NotificationOutboxDispatcher(fast-path @Async + dispatchPending() 輪詢;遞送前依列 tenantIdTenantContext.runAs 重建租戶 context;指數退避重試逾上限轉 dead-letter)、outbox.OutboxStatusPENDING/SENDING/SENT/FAILED/DEAD/SUPPRESSED)。達成 at-least-once 遞送 + dedupeKey 去重 + 列即遞送稽核
  • 政策封鎖 → 抑制(不重試):遞送遇確定性政策封鎖(如郵件防火牆全擋 MailBlockedException)時,EmailNotificationChannelDeliveryResult.suppressed(...)NotificationOutboxDispatcherNotificationOutbox.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 — 遞送稽核 logoutbox.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-appinapp.InAppNotification(JPA 實體)+ inapp.InAppNotificationRepository + channel.InAppNotificationChannel(遞送即寫站內信,位址=userId)
    • SMSchannel.SmsNotificationChannel + channel.SmsDelivery SPI(委派應用層 provider)
    • LINEchannel.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 寫入該列)與 NotificationOutboxDispatchernextAttemptAt 依列 type 讀 backoff)共用同一 resolver,故同型別重試行為一致。應用層以設定(如 app.notification.retry.per-type)建「per-type 覆蓋 → fallback 全域」的 resolver(參考 app-serverNotificationConfig#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-serverNotificationConfig / 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() — 啟用後,所有外寄郵件(MimeMessageSimpleMailMessage 兩條送信路徑)的收件者一律收合成單一 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 的送信(含通知子系統、健康檢查等繞過上層服務的路徑)。主旨前綴對 MimeMessageSimpleMailMessage 兩條路徑皆生效;自訂標頭僅 MimeMessageSimpleMailMessage 無自訂標頭能力)
  • 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))+ BannerNotificationTemplateResolverNotificationTemplateResolver 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)(原子檢查並移除)。鏡像 TokenBlacklistStoreCacheTokenBlacklistStore 模式;快取實作的 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 / isHolidayLocalDateDate)、plusWorkingDays / minusWorkingDaysplusCalendarDays / minusCalendarDaysfindCalendarDays(year);資料取自 GET /api/v1/calendar/{year}
  • LocationService (almanac/location/) — ISO 國家代碼轉換 convertISO2CountryToISO3 / convertISO3CountryToISO2findSubdivisionCodeByISO2AndNamefindAllCountries / findSubdivisions;資料取自 GET /api/v1/location/...
  • AddressService (almanac/address/) — 台灣縣市 / 鄉鎮市區 / 村里:findAllCities / findAllTownsByCity / findAllVillagesByTown、名稱版(免代碼)findAllTownsByCityName / findAllVillagesByTownNamefindCity / findCityByName / findTown / findTownByName;資料取自 GET /api/v1/address/...。名稱版便利方法讓消費端以中文名稱直接查鄉鎮市區 / 村里、不需自行 resolve NLSC 代碼(查無對應縣市 / 鄉鎮市區時回空清單)。所有以名稱查詢的入口(findCityByName / findTownByName 及其衍生的 towns / villages 名稱版)查詢前先把俗寫「台」正規化為 almanac 官方用字「臺」,故傳入 台北市 / 台東市 等俗寫亦可命中(NLSC 行政區名一律用「臺」,整字替換安全)
  • 領域型別(record,皆 Serializable):CalendarDayCountrySubdivisionCityTownVillage
  • 認證:AlmanacCredentials 策略介面(可插拔)+ 內建 ApiKeyCredentialsX-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: Bearerinvalid_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)setMarginssetOrientationaddPageBreak/addSectionBreakrestartPageNumber)與頁首頁尾存取(header/footer/firstFooter/sectionFooter,皆回 Body
  • Body — 本文/頁首/頁尾/儲存格共用的容器面:段落/表格列舉、appendParagraph/appendTableappendCopyOf(樣板段落/表格深拷貝)、appendHtml、locator(findParagraphByText/BookmarkfindCellByText/BookmarkOptional)、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#appendHtmlParagraph#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)

  • TransactionAwareFileStorageFileStorage 裝飾器,把檔案系統副作用對齊資料庫交易邊界: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 Conflicturn: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(DelegatingJwtAuthenticationFilterJwtAuthenticationTokenLocal/OAuthJwtAuthenticationProvider)已於本開發週期內移除、未對外發布過。

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

  • LoginLockoutManagerLockoutExceptionLockoutErrorResponseLockoutExceptionMapper

雙層快取架構

  • 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
  • MemoryBudgetconfig/)/ MemoryBudgetResolvercore/)— 已解析的 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-serverCacheConfig

代理與模擬——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

  • Authentication/authorization 錯誤預設改為最大揭露LoginServiceClientCredentialsServiceJsonAuthenticationEntryPoint、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 管理

    • NotificationServiceNotificationOutboxDispatcher 建構子新增 Clock; Outbox 建立、認領、遞送與重試不再直接讀取系統時間
    • InAppNotificationBaseNotificationOutboxBaseNotificationDeliveryLogBaseNotificationTemplateBase 的 audit 欄位改由 @CreatedDate@LastModifiedDate 寫入,參考實作的 auditing provider 與業務時鐘一致
    • NotificationOutboxBase 的待送列在 @PrePersistcreatedDate 初始化 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 包含 MailConfigMailProperties 的中性組裝,以及固定的 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 的 ConfigurationDiagnosticsSystemInfofeature/platforminfo 提供 PlatformInfoConfigConfigCheckProperties 組裝,以及 /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 收斂為 ReferenceDataProviderReferenceDataRecordReferenceDataItem 三個中性契約;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

  • LoginService.refresh(String) — since 4.0.0, removal in 4.2.0
    • Replacement: refreshSession(String)
    • Migration: 實作 app-owned RefreshSessionStore adapter,使用新建構子組裝登入引擎,並把 refresh response 的 accessTokenrefreshToken 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

  • 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.PasswordlessRateLimiterCachePasswordlessRateLimiter — 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_DELEGATESActingContext.getDelegates()getDelegate() 與 JWT delegates claim — deprecated since N/A (pre-release contract cleanup), removed in 4.0.0-alpha.1

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

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

    • 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-SNAPSHOT

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

    • JwtDetailsTenantIdResolverauthentication.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-SNAPSHOT

    • 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-SNAPSHOT

    • 移除:lockout.api.LoginAttemptTrackerlockout.api.LockoutPolicylockout.api.LockoutException(死類,全樹零使用)、lockout.core.DefaultLoginAttemptTrackerlockout.core.IncrementalLockoutPolicylockout.core.FixedLockoutPolicylockout.core.ExponentialLockoutPolicylockout.spring.LockoutAwareDaoAuthenticationProviderlockout.store.AttemptStorelockout.store.CacheAttemptStorelockout.store.InMemoryAttemptStorelockout.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 一體適用。
  • AuthenticationErrorResponse — 舊版認證錯誤回應,統一改用 RFC 7807 ProblemDetail(見「錯誤處理框架」)

Fixed

  • 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-credentialsWWW-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 子集的文字色解析(影響 documentappendHtml / replaceWithHtmlworkbookCell#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#lockoutDurationstep × (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):ManagedCacheenabled 狀態原掛在臨時 wrapper 上,而 createCache/getCache 每次回新 wrapper,導致 manager.getCache(name).disable() 在下次 getCache(name) 後蒸發。狀態已上提到 CacheManager(依名稱共享旗標),停用狀態現跨重取一致且持久

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

  • 雲端 / SFTP staging 上傳遺失 metadata 導致下載 404(file/):S3FileStorageAzureBlobFileStorageSftpFileStorage 的 proxy / SAS / presigned staging 上傳不會把 original-filenamecontentType 寫進 staging 物件,persist 階段取不到 metadata → 下載端 getMetadata 失敗回 404。prepareStaging 改為額外寫入 .meta sidecar(與 LocalFileStorage 對齊),persist 優先讀物件自身 metadata、缺則退回 sidecar;S3 服務端複製改 MetadataDirective.REPLACE 顯式還原、Azure 於 copyFromUrlsetHttpHeaders + 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(放行),shouldBlockEmailtrue(封鎖)——導致 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.passwordapp.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 可能已含明文機密,建議檢視既有日誌並輪換曾出現的憑證

Breaking Changes

  • 移除 passwordless-login reference flow

    • Impact: POST /api/v1/auth/passwordlessPasswordlessRequestSignedLinkApplication.PURPOSE_PASSWORDLESScreatePasswordlessLoginLinksendPasswordlessLoginLink 與 passwordless notification template 不再存在
    • Migration: 登入頁改用 Email OTP;事件 deep link 繼續使用 GET /api/v1/auth/link/consumePOST /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)NotificationServiceNotificationOutboxDispatcher 建構子新增 Clock
    • Migration: 應用層注入同一個 Clock,在 use-case 邊界傳入 clock.instant();所有採用上述基類的應用都必須啟用 @EnableJpaAuditing 並提供使用該 ClockDateTimeProvider。未啟用 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 delegatesActingClaims.CLAIM_DELEGATESActingContext.getDelegates()getDelegate() 不再存在;actor 與 delegation 集合的值 不再是 username,而是與 JWT sub 相同的 stable subject。
    • Migration: 改用 claim grantorsCLAIM_GRANTORSgetGrantors()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 / NotificationTemplateRepositorynotification.preference.NotificationPreference / NotificationPreferenceRepositorynotification.inapp.InAppNotification / InAppNotificationRepositorynotification.outbox.NotificationOutbox / NotificationOutboxRepositorynotification.outbox.NotificationDeliveryLog / NotificationDeliveryLogRepositoryaudit.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 複製、改由 @TenantIdrunAs 內寫入時注入
    • 決策六(全域範本拆表):舊 notification_templatetenant_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/updatedAtcreatedDate/lastModifiedDate:基類稽核時戳對齊參考實作 audit 基類(Spring Data auditing)與整合面既定的日期命名 SoT,消除全站兩套並存。範圍:NotificationTemplateBaseNotificationOutboxBasecreatedAt/updatedAt 雙欄)與 InAppNotificationBasecreatedAt 單欄)的欄位、Lombok getter/setter(getCreatedAt()getCreatedDate()getUpdatedAt()getLastModifiedDate())、DB column(created_atcreated_dateupdated_atlast_modified_date);OutboxView SPI 同步改 getCreatedDate() / getLastModifiedDate()InAppNotificationRepositoryBase.findByUserIdOrderByCreatedAtDescfindByUserIdOrderByCreatedDateDesc領域語意時戳不受影響nextAttemptAt / sentAt / readAt / delivery-log attemptedAt 維持 *At)。引用舊名的下游升級後編譯失敗;DB 遷移見下 Migration 步驟 4 的 ⓪。
    • Migration:
      1. 應用宣告具體 entity + repository(參考 app-server io.leandev.app.notificationNotificationTemplate / GlobalNotificationTemplate / NotificationPreference / InAppNotification / NotificationOutbox / NotificationDeliveryLogio.leandev.app.auditAuditEventRecord,及各 repository;tenant 專案以 @TenantId 宣告租戶欄位、ownership 專案省略;audit 兩種模式皆無租戶欄)
      2. @EntityScan / @EnableJpaRepositories 移除 io.leandev.appfuse.notification.templateio.leandev.appfuse.notification.outboxio.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. 建構 Mailernew DirectMailer(sender, policy, defaultFrom);政策以 MailOutboundPolicy.builder()...build() 表達(build() 內含 fail-fast 驗證),不再於建構後以 mutator 拼裝
      3. 具名設定/快取管理/可用性:注入 RoutingMailer 具體型別(named(configName)evictevictAllcurrent()isAvailable()),對應原 MailerService.resolveevict*hasMailer
      4. 契約型別MailerSpecMailerSpecProvider 改 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.SecurityContributorio.leandev.appfuse.security.AuthorityContributorio.leandev.appfuse.security.auth.LoginActingContributorActingLogin 為其 nested record,隨之搬遷)
      2. implements AuthoritySeedProviderimplements 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()