appfuse-server Changelog
Framework Library 變更日誌(Spring Boot 工具集)。
- 格式遵循 Keep a Changelog
- 版本策略遵循
appfuse-docs/docs-guides/reference/versioning.md- Removed / Deprecated / Breaking Changes 三段格式遵循
m-changelog-format.md
[Unreleased]
Added
-
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 取用 自動配置的JsonMapperbean,不是應用自宣告的ObjectMapperbean,故只設後者對 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新增可插拔的SessionEligibilityPolicyrefresh-time revalidation 與revokeSessionsByPolicy(...)主動撤銷入口。 impersonation family 現在會在每次 refresh 重驗 subject/actor 帳號狀態、actor 必要 authority、 絕對 policy 效期與應用政策;拒絕時撤銷整個 family 並 blacklist 目前 access session。- Acting claim/context/request audit 新增
sessionId與actingPolicy,ACTING_ACCESS同時記錄 stablesubject、真實actor、0..ngrantors與可並存的actingModes,讓 delegation 與 impersonation 的有效身份、操作者、授權來源及 session family 可完整追溯。 - 新增
SessionOptions、RefreshCredentialCodec與 persistence-agnosticRefreshSessionStorecapability:登入引擎可簽發 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 9457type/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/EmailEnvelopeconstructor 與只實作三參數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_addressesJSON 欄保存信封關係,確保重試時維持原始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_outboxADD 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/.metastaging 佈局,persist()固定以遠端 rename 搬移檔案,不再提供會把內容讀回應用程式再上傳的第二條路徑。開發環境可透過 SFTP 寫入,測試環境則以LocalFileStorage直接存取同一個實體目錄。Local 與 SFTP 統一使用保存原始檔名、Content-Type、大小的 key-value sidecar metadata;雙方 journal reader 亦可互讀各自格式。 filecapability 新增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。fileFeature 擁有 backend SPI、交易裝飾與清理排程;partition resolver 與 staging REST adapter、wire shape、multipart/batch、authorization、database entity/repository/storage 留在應用 server 的filereference 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..ngrantors、principal kind、request/session/source 與應用選配的 tenant;框架不宣告具體 revision entity,也不把 Envers 變成 runtime 必要相依。 - 持久 audit sink 對超過欄位上限的 principal 改存穩定 SHA-256 摘要,避免 Bearer 驗證失敗時將完整 credential 寫入資料庫,並防止超長 token 造成稽核寫入失敗。
auditFeature 現在提供可直接使用的 in-memory sink 預設,並擁有audit:opsauthority 與/actuator/auditevents授權政策;持久 entity、repository 與AuditPersistenceConfig才屬 app-server 的auditreference 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
- 新增
BusinessCalendarCatalogcapability,集中 Spring bean 名稱正規化、未知行事曆與最大 日期範圍驗證;應用 controller 只需把 HTTP 參數交給 catalog,Feature 可直接採 400 天的 安全預設。
Security
-
JWT 明確區分
access、refresh、signed-linktoken family;standalone Resource Server 僅接受access,SignedLinkService 僅接受signed-link。新增 expected-purpose consume overload,會在燒掉 single-use jti 前完成用途驗證;cache store 改以原子 compare-and-remove 保證同節點併發僅一個消費者成功。 -
新增
PasswordlessRateLimiter/CachePasswordlessRateLimitercapability,同時依 正規化 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同時接受應用自訂的Authenticationrequest,並可為已完成 OIDC 驗證與 ExternalIdentity 映射的主體建立同一套 app JWT session;tenant-qualified login DTO 與外部 IdP wire flow 不再被迫進入框架 capability。 -
delegationFeature 收斂為DelegationApplication/LoginActingContributor窄契約; grant entity/repository、REST、帳號目錄查詢與同隔離範圍政策移至delegationreference domain。reference grant 不帶 tenant 欄位,tenant 以帳號範圍比對, ownership 下兩側皆 tenantless 時自然退化為同一範圍。 -
新增通用
ActingAuditInterceptorcapability:只負責把ActingContext與完成的 HTTP request 解算為ACTING_ACCESS事件;actingFeature 提供可覆寫的預設 bean,應用的 reference domain 才決定掛載 URL。controller、response wire 與/api/**政策不進 jar。 -
acting 身份契約改以 stable subject 對齊
AuthPrincipal.subject();delegation 維度改名grantors,明確表示目前登入者的權限授權人。claim 組裝與解析會 trim、去空值及去重; username 等顯示欄位留給 application adapter 解析。 -
新增
CacheClientCredentialsRateLimitercapability:以應用提供的 TTI/TTL cache 承擔 client-credentials 計數與 key 正規化,預設每視窗 60 次;應用只在改用 Redis、gateway 或不同政策時提供其他ClientCredentialsRateLimiter。 -
LoginService.refresh()現在檢查全部四個帳號狀態旗標(停用/鎖定/帳號過期/憑證過期),與 login 路徑(AuthenticationManager的AccountStatusUserDetailsChecker)對齊。先前只檢查isEnabled()與interactiveLoginAllowed(),導致已鎖定或已過期的帳號仍能持續刷新 access token。引擎保留精確拒絕原因,由最終ErrorDisclosurePolicy決定 wire 投影。 -
新增
RsaKeyPairscapability,集中 RSA key pair 的安全強度檢查、產生,以及 Base64 PKCS#8 private key/X.509 public key 解析;應用接線只保留金鑰來源與 ephemeral fallback 政策。 -
新增
DaoAuthenticationManagerscapability builder,集中DaoAuthenticationProvider/ProviderManager建構並強制接上認證事件發布;消費端仍可選擇 pre-authentication checks,讓互動登入套用 lockout、M2M 保留標準帳號狀態檢查。 -
新增
ResourceServerSecuritycapability builder,集中 stateless resource-server、blacklist、bearer、API-key 與 Basic Auth 的固定組裝與 filter 順序;URL 與授權規則仍由應用決定。 -
signed-linkFeature 新增窄版SignedLinkApplication組合契約,並保留SignedLinkService/cache store 的可覆寫預設;帳號查找、通知發布、session 換發、 HTTP URL 與前端導向政策移至signedlinkreference domain。該預設以不可變 account id 作 subject,不依賴 tenant context,可直接用於 tenant 與 ownership。 -
M2M 憑證發放 capability(
io.leandev.appfuse.security.auth;ADR-025,自參考實作feature/serviceaccount上收)——登入引擎的第四操作,補上 ADR-023 遺漏的一塊:ClientCredentialsService—client_credentialsgrant 的 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 是自我 DoSApiKeyPrincipalLookup— 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— 登入代行貢獻點(含ActingLoginrecord):登入流程只認介面,實作由選用 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)改走 POIWorkbookFactory自動判定格式——呼叫端無需事先得知檔案是.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-webRichTextEditor(Tiptap/ProseMirror)輸出的 HTML,不依賴舊 appfuse-core 的rtxAST:- 行內 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套件豁免,不屬對外契約
- 行內 marks(
-
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 ActuatorAuditEventRepository)+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)——提供此AuditEventRepositorybean 即讓 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(SpringApplicationEvent)+NotificationEventListener— 事件接縫:domain codepublishEvent(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(SpringMessageSource+${}具名插值)、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):
NotificationPreferenceFilterSPI +preference.NotificationPreference(JPA 實體,(tenant_id,user_id,type,channel)唯一)+preference.NotificationPreferenceRepository+preference.DbNotificationPreferenceFilter(無列=啟用)。NotificationService建構子新增選用NotificationPreferenceFilter參數,展開每則「收件人×通道」前過濾 - Phase 2 — 遞送稽核 log:
outbox.NotificationDeliveryListenerhook(每次嘗試回呼)+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.SmsDeliverySPI(委派應用層 provider) - LINE:
channel.LineNotificationChannel+channel.LineDeliverySPI(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/,繼承 SpringMailException)— 防火牆全擋(收件者全不在白名單,或啟用卻無白名單)時拋出,與「遞送失敗」語意區分(確定性政策封鎖、重試無益)。詳見 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-tenantMailSetting)
通知內文橫幅(標記非正式環境通知) (notification/)
NotificationBanner(SPI,函式介面bannerFor(ChannelType, Locale))+BannerNotificationTemplateResolver(NotificationTemplateResolverdecorator)— 在範本渲染後於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 = 首頁」的特例LinkRedemptionenum — 贖回政策: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/redemptionSignedLinkSpec(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 formatReasonCodes— 框架常用原因代碼常數(PAST/HOLIDAY/CLOSED_WEEKLY/LEAD_TIME/OUT_OF_RANGE/CLOSED),非封閉清單、應用可擴充自訂字串DateNotSelectableException— 繼承InvalidDataException(映射 400 + i18n params:日期、reasonCode),帶完整DayStatus供應用層取用
雙模式資源伺服器(standalone / federated,靠設定切換) (security/resourceserver/)(ADR-009)
AuthModeenum +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-uriJWKS 驗 IdP token)、authenticationConverter(mode)、validateMode(mode, issuerUri)於啟動 fail-fast 驗證兩模式設定互斥(standalone 不得設 issuer-uri、federated 必須設)StandaloneJwtAuthenticationConverter— 自簽 token 從authclaim 還原權限(無狀態、不回查 DB;即時撤銷由 Token 黑名單 session id 承擔)ScopeJwtAuthenticationConverter— IdP token 從scope/scpclaim 對映原樣resource:action權限(不加SCOPE_前綴,直接滿足@PreAuthorize("hasAuthority('order:read')")),rolesclaim →ROLE_*;與自簽 token 共用同一套權限詞彙ProblemDetailBearerTokenAuthenticationEntryPoint/ProblemDetailBearerTokenAccessDeniedHandler— 受保護資源 401 / 403:RFC 6750WWW-Authenticate: Bearer(invalid_token/insufficient_scope)挑戰標頭 + RFC 7807ProblemDetailbodyJwtTokenProvider.getPublicKey()— 新增 getter,供以本地公鑰建立 standaloneJwtDecoder- 設計:兩模式共用標準 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 粗斜底刪線、上下標、字級(doublepoint)、顏色(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)寫出、AutoCloseableWorksheet/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 留下懸空參照。刻意把所有失敗模式偏向「孤兒」、永不「懸空」FileJournalSPI +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(後端無關)+FileReferenceResolverSPI — 孤兒對帳:依 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 xxxheader - 與 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()供建立 standaloneJwtDecoderJsonAuthenticationEntryPoint— 認證失敗以 RFC 7807ProblemDetail回應
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)OnExceedenum(api/)—REJECT/WARNMemoryBudget(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()(帶actorclaim)與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 錯誤預設改為最大揭露:
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(authclaim 由登入路徑自 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,應用只有在需要時才 overrideMailSettingCRUD、provider adapter 與mail_setting:*權限位於附屬mailreference domain;ownership 只 overlay tenantlessMailSetting,其餘流程共用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)
- 固定診斷與系統識別行為上收 jar 的
-
Reference Data Feature / reference implementation 分界
- Feature 收斂為
ReferenceDataProvider、ReferenceDataRecord、ReferenceDataItem三個中性契約;entity、repository、CRUD URL、產品型別、seed 與 authority 移至附屬referencedatareference domain ReferenceDataItem.from改只依賴ReferenceDataRecord,不再讓 Feature import 應用 entity- ownership 保留完整 reference domain,僅 overlay
CodeDataType將所有產品型別預設為全域
- Feature 收斂為
-
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))。記錄等級不變(5xxerror/ 4xxdebug),無違規時輸出與先前完全相同 -
將依賴聲明從
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 方法簽章不含參數名,既有位置呼叫照常編譯執行;磁碟/雲端路徑由傳入值決定,值不變則路徑不變、無資料遷移
- 多租戶傳 tenant ID(行為不變);單租戶傳固定常數(如
Deprecated
LoginService.refresh(String)— since 4.0.0, removal in 4.2.0- Replacement:
refreshSession(String) - Migration: 實作 app-owned
RefreshSessionStoreadapter,使用新建構子組裝登入引擎,並把 refresh response 的accessToken/refreshTokenpair 一起更新;舊建構子暫時保留 JWT refresh 相容路徑。
- Replacement:
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
- Replacement: 注入
-
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
- Replacement:
-
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-actionsigned-link 的 consume/exchange
- Replacement: Email 免密碼登入改用 Email OTP;事件通知仍使用
-
ActingClaims.CLAIM_DELEGATES、ActingContext.getDelegates()/getDelegate()與 JWTdelegatesclaim — deprecated since N/A (pre-release contract cleanup), removed in 4.0.0-alpha.1- Replacement:
CLAIM_GRANTORS、getGrantors()/getGrantor()與grantorsclaim - Migration: claim 值改傳 stable subject;應用顯示層自行解析 username,既有 token 需重新登入或重簽
- Replacement:
-
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-SNAPSHOT- 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
- Replacement: 無——全部為死碼。全樹對該 package 的唯一 import 是
-
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
- Replacement:
-
mail.Mailer#updateDelegate(JavaMailSender)— deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-SNAPSHOT- Replacement: 無——delegate 於建構時固定;要換 sender 就重建
DirectMailer(RoutingMailer的 revision 快取即此模式) - Migration: 呼叫端改重建實例
- Replacement: 無——delegate 於建構時固定;要換 sender 就重建
-
security.tenant.resolver.JwtDetailsTenantIdResolver與CompositeTenantIdResolver.Builder— deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-SNAPSHOTJwtDetailsTenantIdResolver讀authentication.getDetails()內的 JJWTClaims,該形狀由 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
- Replacement:
-
security.lockout子系統整併為單一LoginLockout(12 檔 → 2 檔)— deprecated since N/A(pre-release,未經 deprecation 期), removed in 4.0.0-SNAPSHOT- 移除:
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 一體適用。
- 移除:
-
AuthenticationErrorResponse— 舊版認證錯誤回應,統一改用 RFC 7807ProblemDetail(見「錯誤處理框架」)
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-credentials。WWW-Authenticate的 error description 亦改為不透明訊息。欄位驗證錯誤現在固定帶validation-error,產品明定的暫時登入鎖定則回 423login-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_outboxMODIFY subject LONGTEXT NOT NULL,MODIFY body LONGTEXT NOT NULL,MODIFY deep_link LONGTEXT NULL,MODIFY cc_recipient_addresses LONGTEXT NULL; -
QueryRunner遇到不存在的排序屬性時,不再讓 JPAIllegalArgumentException外溢成 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現身,佔位符本就不成立。
- 附帶行為變更:段落無 run、但
-
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改為額外寫入.metasidecar(與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 可能已含明文機密,建議檢視既有日誌並輪換曾出現的憑證
Breaking Changes
-
移除 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
- Impact:
-
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。
- Impact: audit 與 notification persistence base class、repository ID generic、
outbox dispatch API 及兩個 canonical app-server 的 generated Entity 均改用
-
通知時間 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 時明確拒絕建立不可排程的列
- Impact:
-
SFTP staging 收斂為 Local 相容的唯一佈局
- Impact: 舊版 SFTP 尚未 persist 的 staging 內容使用
{tempId},新版固定尋找{tempId}.bin;永久區路徑與 key-value metadata 格式不變。 - Migration: 升級前暫停上傳並完成或清除所有 SFTP staging;確認
{basePath}/staging與{basePath}/files位於同一檔案系統,且帳號具備 rename 權限。升級後不需搬移既有永久檔案。
- Impact: 舊版 SFTP 尚未 persist 的 staging 內容使用
-
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。
- Impact: 舊版未帶
-
Cache實作者須提供條件式 remove- Impact: 自訂
Cache<K,V>實作升級後因新增remove(K key, V expectedValue)而編譯失敗。 - Migration: 以 backing store 的原子 compare-and-remove 實作;不可用
get後再remove取代,否則 single-use credential 會重新出現併發重放窗口。
- Impact: 自訂
-
Acting delegation claim/API 由 delegates 改為 grantors
- Impact: JWT claim
delegates、ActingClaims.CLAIM_DELEGATES、ActingContext.getDelegates()/getDelegate()不再存在;actor與 delegation 集合的值 不再是 username,而是與 JWTsub相同的 stable subject。 - Migration: 改用 claim
grantors、CLAIM_GRANTORS、getGrantors()/getGrantor();簽發端傳入授權人的 stable subject,顯示 username 時於 application boundary 透過 principal directory 映射。部署後讓既有 acting session 重新登入或重簽 token。
- Impact: JWT claim
-
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。
- Impact:
-
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,descriptorurl預設為null。 - Migration: application HTTP adapter 依自己的 mapping 與
tempId建立 proxy upload URL;若directUploadUrl()有值則原樣回給 client。建立 API response 時明確使用FileDescriptor.ofStaging(..., resolvedUploadUrl)。app-server 的 reference adapter 以明確的/api/v1/staging/filesreference route 組出 application URL;不再提供app.storage.staging.upload-base-url,避免 mapping、security、client 與文件因 runtime 設定漂移。需要不同 API shape 的消費端應修改或替換 reference adapter。
- Impact:
-
框架 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升為publicNotificationOutboxDispatcher建構子改收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-serverNotificationTemplateService/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);OutboxViewSPI 同步改getCreatedDate()/getLastModifiedDate();InAppNotificationRepositoryBase.findByUserIdOrderByCreatedAtDesc→findByUserIdOrderByCreatedDateDesc。領域語意時戳不受影響(nextAttemptAt/sentAt/readAt/ delivery-logattemptedAt維持*At)。引用舊名的下游升級後編譯失敗;DB 遷移見下 Migration 步驟 4 的 ⓪。 - Migration:
- 應用宣告具體 entity + repository(參考 app-server
io.leandev.app.notification的NotificationTemplate/GlobalNotificationTemplate/NotificationPreference/InAppNotification/NotificationOutbox/NotificationDeliveryLog與io.leandev.app.audit的AuditEventRecord,及各 repository;tenant專案以@TenantId宣告租戶欄位、ownership專案省略;audit 兩種模式皆無租戶欄) @EntityScan/@EnableJpaRepositories移除io.leandev.appfuse.notification.template、io.leandev.appfuse.notification.outbox與io.leandev.appfuse.audit(inapp / preference 若曾列亦移除);具體 entity 隨應用自身套件掃描- 接線改泛型(參考 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;稽核 sinknew PersistentAuditEventRepository<>(auditEventJpaRepository, AuditEventRecord::new) - 資料遷移(既有部署;全新 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_dateFROM 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; - 自行建立範本列的測試 / 程式碼:「不分語系」改設
NotificationTemplateBase.ANY_LOCALE(或沿用基類預設值),不再setLocale(null)
- 應用宣告具體 entity + repository(參考 app-server
- Impact: 所有消費
-
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:
- 升級前回填既有 NULL 列(否則
ddl-auto收緊欄位時失敗):-- MySQL / PostgreSQL / H2UPDATE notification_outboxSET dedupe_key = CONCAT('~standalone:legacy-', id)WHERE dedupe_key IS NULL;-- Oracle / SQL Server 用 '~standalone:legacy-' || id 或 + id 串接id為主鍵、必不重複,故回填值天然滿足 uk。 - 自行建立
NotificationOutbox列的測試 / 程式碼須設dedupeKey(不驗去重者給相異值即可,如"...-" + UUID.randomUUID());經NotificationService.notify(...)的一般路徑不受影響。
- 升級前回填既有 NULL 列(否則
- Impact: 所有消費
-
租戶隔離機制改採 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:
- 刪除本模組的
TenantFilterAspect副本(或跑/feature sync tenant) - 新增
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 載入失敗) - 自訂租戶基類須移除租戶欄位上的
@NotBlank(Bean Validation 早於@TenantId值生成,必然先失敗) - 修正覆寫
onPrePersist()並呼叫super的子類(基類鉤子已移除) - 移除 repository 中「因
entityManager.find()不受 filter 影響」而手寫的租戶比對——@TenantId的 filterapplyToLoadByKey = true,已涵蓋 load-by-key - 測試:不得以 class 層
@Transactional+@BeforeEach設租戶(租戶綁在 Session 建構時,而 Spring 的TransactionalTestExecutionListener早於@BeforeEach);改用TransactionTemplate先設 context、再開交易 - 建議以
spring.jpa.open-in-view=false跑一次隔離測試,確認不再依賴 OSIV
- 刪除本模組的
- 行為變更:① 租戶綁在 Session 建構時(非 persist 時)② 租戶值於 INSERT 時生成(flush 前
getTenantId()為null)③@PreUpdate的跨租戶SecurityException消失,防線前移至讀取過濾 ④ root(無TenantContext)可修改他人租戶的既有資料(刻意放寬,對齊 HibernateisRoot語意;租戶歸屬不受影響)
- Impact: 所有繼承
-
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:
- 寄信消費端:注入
Mailer介面(@Primarybean 為RoutingMailer),mailer.compose().to(...).subject(...).html(...).send()——from 已由解析結果預填,原EmailService.send*Email(...)各排列組合皆由 compose 鏈取代 - 建構 Mailer:
new DirectMailer(sender, policy, defaultFrom);政策以MailOutboundPolicy.builder()...build()表達(build()內含 fail-fast 驗證),不再於建構後以 mutator 拼裝 - 具名設定/快取管理/可用性:注入
RoutingMailer具體型別(named(configName)/evict/evictAll/current()/isAvailable()),對應原MailerService.resolve/evict*/hasMailer - 契約型別:
MailerSpec/MailerSpecProvider改 importio.leandev.appfuse.mail(原 feature slice 套件),實作內容不變 - 通知通道:
MailDelivery實作改注入Mailer(參考 app-serverMailerMailDelivery,原名EmailServiceMailDelivery)
- 寄信消費端:注入
- Impact: 直接依賴
-
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條目即可(碼不動——本就已依各自治理模式分歧);需要參考起點的新專案 retargetauthadmin/業務域,確定不要則/prune-reference
- Impact: feature catalog 移除
-
security 貢獻點 SPI 上收框架並改名(原住參考實作
feature/auth,是跨 feature 的公共契約、零 entity 知識——與 mail 契約上收同一原則)- Impact: 下游所有實作這三個 SPI 的類(各 feature 的
*SecurityContributor、*AuthoritySeedProvider、delegation 的LoginActingContributor實作,及業務層 seed contributor)升級後編譯失敗——app 層介面已刪除,且AuthoritySeedProvider改名AuthorityContributor("Seed" 洩漏消費方式;契約只宣告權限名,seed 進 DB 是參考實作的持久化決策) - Migration:
- import 改指 jar:
io.leandev.appfuse.security.SecurityContributor、io.leandev.appfuse.security.AuthorityContributor、io.leandev.appfuse.security.auth.LoginActingContributor(ActingLogin為其 nested record,隨之搬遷) implements AuthoritySeedProvider→implements AuthorityContributor;實作類命名慣例同步為{Feature}AuthorityContributor(參考 app-server 的九個改名)features.json:僅以 SPI import 依賴auth的 feature,import 邊消失後依 I3 改記beanRequires: ["auth"](授權鏈與權限 seeding 的實質依賴仍在;參考 app-server 的 audit / mail / platform-info / reference-data)
- import 改指 jar:
- Impact: 下游所有實作這三個 SPI 的類(各 feature 的
-
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()
- Impact: 啟用管制後,各 cache 的 offheap 宣告大小加總超過預算會被
-
TierConfiguration.heapSize更名為heapEntries(cache/config/)- Impact: 直接讀寫
TierConfiguration.heapSize/getHeapSize()的程式碼編譯失敗 - Migration:
heapSize/getHeapSize()→heapEntries/getHeapEntries()
- Impact: 直接讀寫