From 6fffb362a169e5058658fe441d70396d80597dca Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:53:15 +0800 Subject: [PATCH] feat(notification): bound email recovery and cancellation Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 4 +- compose.release.yml | 2 +- docs/notification-email.md | 14 +- .../add-notification-email-delivery/design.md | 40 +- .../specs/notification-email-delivery/spec.md | 53 +- .../add-notification-email-delivery/tasks.md | 1 + .../verification.md | 26 +- .../NotificationEmailDeliveryController.java | 5 +- ...otificationEmailDeliveryAdminResponse.java | 3 +- ...tificationEmailDeliveryDetailResponse.java | 5 +- .../skillhub/service/AdminUserAppService.java | 6 +- .../NotificationEmailAdmissionService.java | 40 + .../NotificationEmailContactService.java | 15 +- .../NotificationEmailSettingsService.java | 58 +- .../NotificationEmailDeliveryProperties.java | 14 +- ...NotificationEmailDeliveryQueryService.java | 63 +- .../NotificationEmailDispatcher.java | 206 +++-- .../NotificationEmailInboxService.java | 9 +- .../src/main/resources/application.yml | 2 +- ..._notification_email_recipient_settings.sql | 4 +- .../V121__notification_email_delivery.sql | 22 + .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_ru.properties | 1 + .../src/main/resources/messages_zh.properties | 2 + ...tificationEmailOpenApiIntegrationTest.java | 13 + ...otificationEmailAdmissionPostgresTest.java | 307 ++++++++ ...otificationEmailRecipientPostgresTest.java | 2 +- ...onEmailSettingsSharedLockPostgresTest.java | 240 ++++++ .../service/AdminUserAppServiceTest.java | 2 +- ...NotificationEmailAdmissionServiceTest.java | 51 ++ .../NotificationEmailPreferencesTest.java | 2 +- .../NotificationEmailSettingsServiceTest.java | 5 +- ...NotificationEmailDeliveryPostgresTest.java | 718 +++++++++++++++++- ...ficationEmailDeliveryQueryServiceTest.java | 61 +- ...ificationEmailSchedulingIsolationTest.java | 9 +- .../JdbcNotificationEmailCancellation.java | 39 +- ...bcNotificationEmailDeliveryRepository.java | 364 ++++++++- .../jpa/SmtpNotificationEmailSender.java | 33 +- .../jpa/SmtpNotificationEmailSenderTest.java | 227 ++++++ .../NotificationEmailAccountLifecycle.java | 6 + .../NotificationEmailAdmissionProvider.java | 13 + .../email/NotificationEmailContact.java | 10 + .../email/NotificationEmailPolicy.java | 9 +- .../NotificationEmailPolicyProvider.java | 4 + .../email/NotificationEmailPreference.java | 11 +- .../email/NotificationEmailRecipient.java | 9 +- .../delivery/NotificationEmailDelivery.java | 12 +- .../NotificationEmailDeliveryRepository.java | 21 +- .../email/NotificationEmailContactTest.java | 27 + web/src/api/generated/schema.d.ts | 21 +- .../notification-email-api.test.ts | 4 +- .../notification/notification-email-api.ts | 2 +- .../notification-email-settings.tsx | 5 +- web/src/i18n/locales/en.json | 18 +- web/src/i18n/locales/ru.json | 18 +- web/src/i18n/locales/zh.json | 18 +- .../pages/notification-email-batch.test.tsx | 23 +- web/src/pages/notification-email-batch.tsx | 12 +- 58 files changed, 2625 insertions(+), 288 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailAdmissionService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailAdmissionPostgresTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailSettingsSharedLockPostgresTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailAdmissionServiceTest.java create mode 100644 server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailAccountLifecycle.java create mode 100644 server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailAdmissionProvider.java diff --git a/.env.release.example b/.env.release.example index 1312a5395..a2b978b85 100644 --- a/.env.release.example +++ b/.env.release.example @@ -228,11 +228,11 @@ SKILLHUB_NOTIFICATION_EMAIL_FROM_NAME=SkillHub SPRING_MAIL_SMTP_CONNECTION_TIMEOUT_MS=5000 SPRING_MAIL_SMTP_READ_TIMEOUT_MS=10000 SPRING_MAIL_SMTP_WRITE_TIMEOUT_MS=10000 -# Worker defaults: 10s polling, 120s lease, 5 attempts, 20 batches per poll. +# Worker defaults: 10s polling, 120s lease, 24h temporary-failure recovery, 20 work items per poll. # Sent/suppressed metadata: 30 days; failed/unknown: 90 days (minimum 8 days). SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_POLL_DELAY_MS=10000 SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_LEASE_SECONDS=120 -SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_MAX_ATTEMPTS=5 +SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_RETRY_WINDOW_HOURS=24 SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_BATCH_SIZE=20 SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_SENT_RETENTION_DAYS=30 SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_FAILED_RETENTION_DAYS=90 diff --git a/compose.release.yml b/compose.release.yml index fa20efc15..46aeeedf7 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -170,7 +170,7 @@ services: SKILLHUB_NOTIFICATION_EMAIL_FROM_NAME: ${SKILLHUB_NOTIFICATION_EMAIL_FROM_NAME:-SkillHub} SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_POLL_DELAY_MS: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_POLL_DELAY_MS:-10000} SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_LEASE_SECONDS: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_LEASE_SECONDS:-120} - SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_MAX_ATTEMPTS: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_MAX_ATTEMPTS:-5} + SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_RETRY_WINDOW_HOURS: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_RETRY_WINDOW_HOURS:-24} SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_BATCH_SIZE: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_BATCH_SIZE:-20} SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_SENT_RETENTION_DAYS: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_SENT_RETENTION_DAYS:-30} SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_FAILED_RETENTION_DAYS: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_FAILED_RETENTION_DAYS:-90} diff --git a/docs/notification-email.md b/docs/notification-email.md index 3d1d849de..5bfabbc3a 100644 --- a/docs/notification-email.md +++ b/docs/notification-email.md @@ -23,7 +23,7 @@ SMTP 连接、TLS 和企业邮箱接入方式见 [SMTP 配置指南](19-smtp-pas “发布”类别同时包含作者的发布结果和订阅技能更新,建议使用摘要;目前不能为这两类分别设置频率。“审核”也可能包含个人资料审核,并非只有技能审核。密码重置、通知邮箱验证码等安全邮件始终即时发送,不参与业务摘要。 -每封摘要最多展开 50 条,超出的通知通过邮件中的 **批次详情** 入口分页查看;不会为分页再拆发多封。详情路径为 `/dashboard/notifications/email/{id}`,要求登录为对应收件账号,并逐项检查当前权限。退出空间、失去权限或资源删除后,相关项不再展示。保留期结束、批次不存在或不属于当前账号时,页面会提示不可用。此明细不依赖站内通知是否开启、是否已读或已删除。 +每封摘要最多展开 50 条,超出的通知通过邮件中的 **批次详情** 入口分页查看;不会为分页再拆发多封。详情路径为 `/dashboard/notifications/email/{id}`,要求登录为对应收件账号,并逐项检查当前权限。退出空间、失去权限或资源删除后,相关项不再展示。保留期结束、批次不存在或不属于当前账号时,页面会提示不可用。此明细不依赖站内通知是否开启、是否已读或已删除。明细采用游标逐页读取原始记录,再过滤当前权限;本页可见项为空但仍有下一游标时,可以继续查看后页。“原始记录数”不是当前可查看条数,“不可访问”数量仅对应本页。已合并的旧批次会提供目标摘要入口。 ## 管理员如何开启 @@ -54,7 +54,7 @@ Compose 继续使用 `.env.release` 与 `compose.release.yml`。以下为本功 | `SPRING_MAIL_SMTP_WRITE_TIMEOUT_MS` | `10000`,写入超时毫秒 | 部署侧,共享 SMTP | | `SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_POLL_DELAY_MS` | `10000`,轮询毫秒;最小 100 | 部署侧 | | `SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_LEASE_SECONDS` | `120`,租约秒;最小 30 | 部署侧;须覆盖有界 SMTP 尝试及确认开销 | -| `SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_MAX_ATTEMPTS` | `5`,自动尝试上限;范围 1–20 | 部署侧 | +| `SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_RETRY_WINDOW_HOURS` | `24`,临时故障恢复重试窗口(小时) | 部署侧;自首次临时失败计时,不是送达时限保证 | | `SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_BATCH_SIZE` | `20`,每轮领取批次数;范围 1–100 | 部署侧;不同于邮件内最多展开 50 条 | | `SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_SENT_RETENTION_DAYS` | `30`,已发送 / 已抑制记录保留天数;最小 8 | 部署侧 | | `SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_FAILED_RETENTION_DAYS` | `90`,失败 / 未知记录保留天数;最小 8 | 部署侧 | @@ -120,15 +120,19 @@ Chart 配置与安装方式见 [Helm 手册](../charts/skillhub/README.md)。渲 | 状态 | 含义与处理 | |---|---| | 待发送 | 等待即时任务或摘要窗口到期 | +| 正在整理摘要 | 正在按有界批次整理通知,尚未取得发送授权 | +| 已并入摘要 | 原批次已并入恢复摘要,用户可通过明细页打开目标批次 | | 已领取 | worker 持有租约,尚未取得发送授权 | | 正在发送 | 最终资格检查已提交发送授权;即使 SMTP 尚未开始,也不能保证撤回 | | 邮件服务器已接受 | SMTP 接受了邮件,不代表进入收件箱、用户已读或链接可访问 | -| 等待重试 | 明确的临时失败,系统在有限退避后重新检查资格并尝试 | -| 发送失败 | 永久拒绝或已达到重试上限;修复配置后可受限人工重试 | +| 等待重试 | 明确的临时失败,在首次临时失败起默认 24 小时恢复窗口内退避,并重新检查资格 | +| 发送失败 | 永久拒绝或临时故障恢复窗口耗尽;修复后可受限人工重试 | | 结果未知 | 邮件可能已被服务器接受,系统不会盲目自动重发 | | 已停止发送 | 平台关闭、用户关闭类别、邮箱变化或收件资格不满足等 | -人工重试只作用于原批次,不能修改收件人或内容,并重新核对收件资格、记录审计。结果未知时必须确认“可能产生重复邮件”的风险。SMTP 不提供可靠的全链路幂等保证,固定 Message-ID 也不代表恰好一次。 +临时故障恢复窗口从首次明确的临时失败开始,重启进程或追加到期通知不会延长这一窗口。重试时可将同一账号后来到期的摘要并入恢复摘要,避免恢复后按积压时间窗逐封发送;整理和详情读取均采用有界处理。永久拒绝直接失败,UNKNOWN 不自动重发。24 小时是重试策略,不代表服务承诺在 24 小时内送达。 + +人工重试保留原收件账号和记录,并重新核对收件资格、从本次首次临时故障起开启默认最长 24 小时的新恢复窗口、记录审计;不能任意改写收件人或注入通知内容。结果未知时必须确认“可能产生重复邮件”的风险。SMTP 不提供可靠的全链路幂等保证,固定 Message-ID 也不代表恰好一次。 关闭平台或用户类别会取消尚未开始发送的项;已经取得发送授权或被 SMTP 接受的尝试不能承诺撤回。重新开启不会补发关闭期间的历史事件。改为摘要或即时只影响新事件,不把之前积压的摘要转成即时群发。 diff --git a/openspec/changes/add-notification-email-delivery/design.md b/openspec/changes/add-notification-email-delivery/design.md index 3081a4e5a..ab225cb45 100644 --- a/openspec/changes/add-notification-email-delivery/design.md +++ b/openspec/changes/add-notification-email-delivery/design.md @@ -1,6 +1,6 @@ ## Context -基线:`main@4811b96129802be5028d5e4bc1b7b6a158b5b7bb`。本文件是待评审技术方案,只有“按类别选择即时或摘要”已由用户确认,以下推荐参数及交互尚未实施。 +基线:`main@4811b96129802be5028d5e4bc1b7b6a158b5b7bb`。本方案已获实施授权,基础实现见 Draft PR #1004;默认60分钟、按用户恢复汇总、24小时临时故障恢复窗口及主动关闭后严格丢弃迟到旧事件已获用户确认。积压补充仍在实施,正式验收尚未冻结。 ### 已核实的现状 @@ -49,11 +49,11 @@ - 以邮件项实际持久入箱的时间确定窗口,业务发生时间另存用于展示。延迟到达的事件不回填已发送的过去窗口。 - 同一窗口所有 DIGEST 类别按收件人合并,不能按空间、技能或类别拆成多封,也不能跨用户混发。 -- 摘要先封口固定成员,再发信;发送期间新增项不追加到已封口批次。若入箱事务遇到封口,重新计算当前可接纳窗口,不能丢弃或重新打开旧批次。 -- 每封建议最多展开 50 条,余下显示数量与**邮件批次自己的受保护详情入口**;统计和全部邮件项仍归属同一批次,不能为了分页再发几封。详情从邮件项分页读取,不依赖站内消息是否开启、存在或已读;每项仍按当前资源权限过滤,已失权/删除的项只显示不可用数量。明细保留期后明确提示已过期,不承诺永久邮件历史。 +- 摘要先封口固定成员,再发信;发送期间新增项不追加到已授权批次;明确临时失败后的同一 root 下一准备轮可按 D6a 受控补源。若入箱事务遇到封口,重新计算当前可接纳窗口,不能丢弃或重新打开旧批次。 +- 每封建议最多展开 50 条,余下显示数量与**邮件批次自己的受保护详情入口**;统计和全部邮件项仍归属同一批次,不能为了分页再发几封。详情从邮件项分页读取,不依赖站内消息是否开启、存在或已读;每项仍按当前资源权限过滤,已失权/删除的项只显示本原始页的不可用数量,不计算全批精确可见总数。明细保留期后明确提示已过期,不承诺永久邮件历史。 - 空窗口不发空邮件;某项不再符合资格时剔除,剔除后为空则不发送。 - 改模式只影响之后的新事件;改成 OFF 还会撤销未开始发送的同类项。摘要转即时不把旧摘要批量重新即时发送。 -- 改摘要周期不重分配已入箱项;它们沿旧窗口完成,之后新项使用新周期。UI 明示这一过渡,不宣称修改周期瞬间全量重排。 +- 改摘要周期不重分配已入箱项;原窗口与模式记录保留,符合恢复规则的待发摘要可合入执行父;之后新项使用新周期。UI 明示这一过渡,不宣称修改周期瞬间全量重排。 ### D3. 复用 SMTP,独立收件验证;不把历史 email 当验证事实 @@ -117,18 +117,38 @@ flowchart TD 4. worker 用第二个短事务完成最终资格检查及 `CLAIMED→SENDING`:按统一顺序锁定平台邮件设置、账号(涉及多账号时按ID排序)、contact、类别偏好和批次,刷新当前值并核验 token;检查账号 ACTIVE/非合并、当前邮箱修订与证明、类别未关闭,再读取当前订阅/管理资格,仅保留合格项,空批次为 SUPPRESSED。设置关闭、邮箱变更/合并、验证及偏好写操作遵循相同锁顺序,不允许交错读出半旧状态。 5. **提交 SENDING 是发送授权的线性点和不可撤回边界**:平台开关、账号、邮箱证明及邮件偏好的变更先提交时,后续授权必须拒绝;发送授权先提交时,即使真正 SMTP 调用尚未开始,之后的换址或关闭也不能承诺撤回该次已授权尝试。资源权限在授权事务内按快照检查,邮件仍是最小内容;详情访问始终重新鉴权。界面须解释“正在发送”的这项边界,不能宣称关闭后所有在途信必然消失。 6. 在事务外调用 SMTP,使用有限连接/读取/写入超时;完成后按租约 token 条件确认。过期 worker 不得覆盖新状态;SENDING 租约过期保守进入 UNKNOWN,不能像 CLAIMED 一样直接重新领取发送。 -7. 明确临时失败才有界退避重试(建议最多 5 次),永久拒绝转 FAILED;达到上限停重试并可查。每次重试均重新完成资格检查和发送授权。SMTP 接受后为 SENT/accepted,不叫“送达收件箱”或“已读”。 +7. 明确临时失败在首次失败起的 24 小时恢复窗口内退避重试,永久拒绝转 FAILED;到达恢复截止时间停止新的自动发送尝试并可查;已经提交 SENDING 的尝试不能强改 FAILED,返回接受仍为 SENT,结果不确定仍为 UNKNOWN。每次重试均重新完成资格检查和发送授权。SMTP 接受后为 SENT/accepted,不叫“送达收件箱”或“已读”。 8. DATA 后超时、发送后进程崩溃等不能确定是否已接受的情况为 UNKNOWN;不盲目自动重发,管理员可显式重试并获知重复风险。固定 Message-ID 仅用于关联,不当作 SMTP 幂等保证。 一个逻辑摘要批次可能因管理员明确重试或 SMTP 不确定性出现重复;不能承诺 exactly-once。两实例竞争、封口与入箱并发、重复事件、取消/重试竞争均须有真实 PostgreSQL 集成测试。 -### D6a. 积压恢复与调度隔离(补充讨论) +### D6a. 积压恢复、有界处理与调度隔离 -用户已确认:默认摘要窗口为 60 分钟,后台可调整;恢复时,同一用户积压的可确认尚未投递成功的到期摘要合为一封,保留原通知记录与发生时间。IMMEDIATE 不自动改为 DIGEST。SENT、SENDING、UNKNOWN 不自动合并或重发;FAILED 不因服务恢复自动复活。 +用户已确认:默认摘要窗口为 60 分钟,后台可调整;恢复时,同一用户积压的可确认尚未投递成功的到期摘要合为一封,保留原通知记录与发生时间。IMMEDIATE 不自动改为 DIGEST。SENT、SENDING、UNKNOWN 不自动合并或重发;FAILED 不因服务恢复自动复活。明确临时故障按用户确认的 24 小时恢复窗口退避重试,到期标记 FAILED;永久错误直接 FAILED;UNKNOWN 不自动重发。首次临时失败确定恢复截止时间,重启与补源不得延长;受限人工重试重新开启恢复尝试(清除旧恢复起点及截止,首次新 TEMP 再计 24 小时),审计旧截止与重置事实;UNKNOWN 还须原有重复风险确认。不新增额外重置开关,界面在重试前说明窗口重置。 -调度隔离属于必须修复的工程边界:`@Scheduled` 只做非阻塞触发,不在现有公共定时线程执行 SMTP 或数据库扫描。使用独立、容量有界、拒绝时不回落到调用线程的邮件执行器;同实例重复触发不堆积任务,按实际空闲容量领取,关闭后不继续领取。不能以新增默认 TaskScheduler 改变既有后台任务归属。 +**同用户互斥与固定恢复范围:** 所有 DIGEST 创建/领取、旧 CLAIMED 授权及人工重试都在现有 contact 行互斥内读取当前活跃 root;只做领取的子流程可从 contact→delivery 开始,完整资格操作仍按 policy→account→contact→prefs→delivery,禁止拿 contact 后再反取前级锁。活跃状态覆盖 PREPARING/PENDING/CLAIMED/SENDING/RETRY,租约过期继续原 root,不新建第二个。root 固定用户与邮箱修订;每次准备固定截止时间及源 ID 上限;仅收编同用户同修订、已到期、可确认未被接受的普通摘要源。普通源遇到活跃 root 时,不得独立授权,即使分段封源还没轮到该条。人工恢复另一旧 root 时如已有活跃 root,返回冲突,不绕过此门闩。 -独立专项审查确认,原实现的全量读批次、逐项权限查询期间持全局写锁,以及逐窗口恢复补发,不满足积压场景。以下细节仍在收敛,不能将本段视为已完成实现或验收:多实例同用户未授权批次合并、海量项的有界准备与真实数据库分页、即时与摘要的公平调度、供应商长期故障的恢复策略和容量信号。禁止只修改窗口数值就宣称积压问题已解决;未经产品确认,不新增自动丢弃旧邮件或改变通知模式的规则。 +**来源不搬行:** 普通源标记 MERGED 并关联 root,原 item/eventKey/occurredAt/enqueuedAt 保持不变。组成员包含 root 自身原项与直属 MERGED 来源;计数/游标重算或增量更新必须幂等,不重复累计、不复活 SUPPRESSED。root 不再作为其他 root 的源,避免父链。可将最早的普通待发批次原地升级为 root。PREPARING 的租约、截止范围、来源游标、项游标均持久化,进程重启从原进度恢复。每次事务最多封源 100 个、检查项 100 个;每次调度只推进有限页,提交后让出执行器,不因海量积压持有全局锁或占用无限内存。新到达事件不无限追加到正在执行的准备范围。明确 TEMPORARY_FAILURE 后,下次重试准备开始时可为同一 root 重新固定范围,纳入后来到期的普通源,再有界准备;不新增第二个 root,不刷新既有最早恢复截止时间;新并入源继承最早截止,人工重置后已属同组来源的旧截止不得重新倒灌。来源中已超过恢复窗口的失败项不得借合并复活。 + +**资格快照与内容:** 不要求海量资源在同一事务给出精确可见总数。PREPARING 分块检查资源资格;最终短授权事务重查账号/邮箱证明/设置/偏好及最多 50 条预览的当前资源资格。邮件只展示原始记录数量/时间等最少信息,明确实际可见以受保护明细为准,不冒称所有原始记录当前均可见。无有效项不发空邮件;有界扫描尚未找到有效项且未扫描完成时继续 PREPARING,不能提前判空或静默丢弃剩余项。明细使用数据库原始游标 LIMIT 分页,再逐页按当前权限过滤;总数如展示必须标为原始记录数,不冒称全量精确可见总数。 + +**取消与保留:** 平台关闭、类别 OFF、账号停用等取消必须覆盖 PREPARING 父与 MERGED 源的未授权项,恢复开关不能使源项复活。人工重试 UNKNOWN 仍须重复风险确认;恢复合并不能借此自动重试 UNKNOWN。清理按整个恢复组的完成状态处理,未完成父及其来源和去重事实不得先删;失败与未知的保留限制仍适用。 + +**未来尝试取消屏障:** 在现有策略→账号→contact→偏好锁序内,平台关闭、类别 OFF、账号停用的同一短事务写持久取消边界。边界记录覆盖当时已入箱的 item,包括父批次处于 SENDING、来源已 MERGED 的项;重新开启不清除边界。已授权在途尝试保持 SENDING、租约、payload 与最终 ACCEPTED/UNKNOWN/TEMP 记账,只有后续准备与授权按有限候选 item 检查边界;TEMP 回 RETRY 也不得让旧项再获授权。关闭不能在全局独占锁内全量载入批次或一次更新所有 item;独立扫尾按有限页逐步将命中项持久抑制,扫尾重启可续,期间由边界保证安全。item 的数据库 ID 边界必须与入箱和关闭事务锁序一致,并用真实 PostgreSQL 竞争测试验证未提交入箱不会落到屏障之外。普通 SMTP 故障恢复不产生关闭屏障;接纳水位只处理新入箱迟到事件,不代替此取消屏障。 + +恢复父批次因无合格项或关闭而终结时,只更新有条件租约的批次状态;不得随之一次更新所有来源项。有限页扫尾负责物理项状态,终态父和取消屏障在此期间作为详情与再次授权的逻辑过滤条件;已 SENT 的历史按原样保留。人工重试不清除取消边界,只能处理未被边界取消的新合法项。 + +**线程、锁与调度:** `@Scheduled` 只做非阻塞触发,不在公共定时线程执行 SMTP 或数据库扫描。使用独立、容量有界、拒绝时不回落到调用线程的邮件执行器;重复触发不无限堆任务,按实际空闲容量领取,关闭后不继续领取。不能以新增默认 TaskScheduler 改变既有任务归属。平台策略读侧使用共享锁、修改侧独占锁,保留关闭与最终授权的先后顺序;禁止重任务持全局独占锁。即时与摘要必须有明确的有限轮转,不能让旧摘要耗尽每轮全部领取额度;同一用户不得在一个领取轮次独占所有额度。此为公平调度,不承诺固定送达延迟或无限容量。 + +未经产品确认,不自动丢弃过旧邮件、不改变 IMMEDIATE 订阅选择;容量超限、供应商长期故障及有限重试耗尽必须在投递状态和积压观测中可辨认,不以“已入队”冒充“已投递”。 + +### D6b. 主动关闭与延迟事件的接纳水位 + +用户明确选择严格不补历史:平台主动关闭后重新开启,只接收重新开启后发生的业务事件;关闭期间发生但积压到重新开启后才到入口的事件不发送邮件。与 SMTP 故障自动恢复不同,站内通知仍按原规则运行。 + +持久记录平台 enabledSince、类别从 OFF 转入启用的 enabledSince、收件资格的 eligibleSince(验邮及账号恢复的最新有效时刻)。新入箱在统一锁内比较原始 occurredAt 与三者最大值,小于水位拒绝,等于边界允许;模式互换、摘要周期或URL修改不刷新水位。对缺水位的既有设置只在初始化持锁补一次基线,不能每次读取抬高水位。 + +水位只控制新入箱,不用重新开启/重新读取的时刻误杀 SMTP 故障期间已合法入队且仍符合账号/邮箱/偏好的恢复组。拒绝迟到事件不改原始事件时间、不清站内记录、不绕过原7天接纳及允许时钟偏差规则。时间判断使用同一可注入 Clock,并验证多实例等待锁后的值。 ### D7. 安全、邮件内容与权限 @@ -184,7 +204,7 @@ SMTP host/port/credentials/TLS 继续用部署侧 `spring.mail` 体系,补齐 2. 应用先以 enabled=false 发布,既有站内通知和密码重置回归必须通过;新后台/API 与生成类型同步。 3. 使用本地 SMTP 捕获器覆盖即时/摘要/错误路径;多实例、可控时钟、真实 PostgreSQL 验证封口及去重。部署方显式配置后才可启用。 4. 回退先关闭邮件、停止并等待发送 worker 退出,按已发/未知状态保留审计,不删除表以“回滚”。旧应用不维护 email_revision,因此回退前须通过受控维护操作失效全部通知验邮证明/挑战、抑制未发项;重新升级后保持关闭,用户重验并显式启用,不恢复旧证明。此操作只失效新增通知资格,不删除业务、站内或密码重置数据。以“新应用验邮→回退旧应用改址→重新升级”验证不会用旧证明发信,不能只说旧应用忽略新增列就安全。 -5. 后续实施完成后补验收结果;本轮只做方案结构校验和设计审查,不把任务勾选为开发完成。 +5. 实施完成后补精确 SHA 验收结果;开发反馈与正式运行验收分别记录,不把结构校验、旧 SHA 或计划项勾选为开发验收完成。 ## 评审状态与资料 diff --git a/openspec/changes/add-notification-email-delivery/specs/notification-email-delivery/spec.md b/openspec/changes/add-notification-email-delivery/specs/notification-email-delivery/spec.md index de0656ce5..606d6b6fd 100644 --- a/openspec/changes/add-notification-email-delivery/specs/notification-email-delivery/spec.md +++ b/openspec/changes/add-notification-email-delivery/specs/notification-email-delivery/spec.md @@ -28,7 +28,7 @@ #### Scenario: SCN-005 封口与迟到项 - **WHEN** 摘要封口与新项入箱并发,或延迟通知在旧窗口已发送后才入箱 -- **THEN** 已封口成员集合不变,新项进入可接纳的新窗口,不丢失、不重开旧批次、不重发旧内容 +- **THEN** 普通源的已封口原成员不变,新项进入可接纳的新窗口;恢复父在一次准备中固定范围,明确临时失败后下一准备轮按 REQ-016 可受控补源,不复活已抑制或已发送项 ### Requirement: REQ-003 即时邮件和安全邮件语义 IMMEDIATE SHALL 表示不等待摘要窗口,按独立通知实例尽快异步投递,不保证远端送达时限;验证码和密码重置邮件 SHALL 保持独立安全通道,不受业务摘要偏好影响。PUBLISH 中作者发布与订阅更新同属一类的事实 MUST 向用户说明。 @@ -42,7 +42,9 @@ IMMEDIATE SHALL 表示不等待摘要窗口,按独立通知实例尽快异步 - **THEN** 安全邮件走独立发送流程,不等待摘要,也不能用通知邮箱验证码完成重置密码 ### Requirement: REQ-004 配置和偏好变更边界 -用户将类别改为 OFF 或平台关闭邮件时,系统 SHALL 停止相应新入箱,并抑制尚未取得发送授权的项;恢复开启 SHALL 不补发关闭期间历史。发送授权提交 SHALL 是不可撤回边界,即使随后才开始 SMTP,也不能保证撤销已授权尝试。IMMEDIATE 与 DIGEST 互换及摘要周期变化 SHALL 只改变新入箱事件的路由,旧项按其原批次完成,界面 SHALL 解释该边界。 +用户将类别改为 OFF 或平台关闭邮件时,系统 SHALL 停止相应新入箱,并抑制尚未取得发送授权的项;恢复开启 SHALL 不补发关闭期间历史。发送授权提交 SHALL 是不可撤回边界,即使随后才开始 SMTP,也不能保证撤销已授权尝试。IMMEDIATE 与 DIGEST 互换及摘要周期变化 SHALL 只改变新入箱事件的路由,旧项保留原窗口/模式记录,符合 REQ-014/016 的待发摘要可合入恢复父执行,界面 SHALL 解释该边界。 + +主动关闭或停用 SHALL 在同一短事务中持久记录未来发送尝试的取消边界;不得靠重新开启后的当前开关状态恢复旧项。已取得 SENDING 授权的本次 SMTP 尝试保持其结果和租约处理,但若明确临时失败,其旧项不得再获得下一次自动发送授权。关闭事务不得全量加载或更新积压项,持久边界之后的抑制清理 SHALL 分批执行。 #### Scenario: SCN-008 关闭类别或平台 - **WHEN** 已有待发摘要或重试项时,用户关闭其中一类或超级管理员关闭邮件 @@ -52,6 +54,10 @@ IMMEDIATE SHALL 表示不等待摘要窗口,按独立通知实例尽快异步 - **WHEN** 用户切换即时/摘要,或管理员修改摘要周期后再发生新事件 - **THEN** 旧项不被重新复制和批量即时发送,新项采用新规则,原窗口的合法投递不变 +#### Scenario: SCN-033 在途临时失败后关闭再开启 +- **WHEN** 本次尝试已获 SENDING 授权,SMTP 尚未返回时平台、类别或账号被关闭再恢复,随后 SMTP 明确返回临时失败 +- **THEN** 本次尝试按实际结果记账,但关闭前旧项不得得到第二次发送授权;新合法项可按当前设置入箱和发送 + ### Requirement: REQ-005 提交后处理及可靠性边界 系统 SHALL 仅在原业务事务成功提交后处理业务通知,邮件网络故障 MUST 不回滚已完成的发布、审核或站内通知。持久入箱后的状态 SHALL 能恢复和审计;沿用现有异步入口时 MUST 明确业务提交到入箱之间的丢失窗口,不宣称端到端不丢失。 @@ -78,8 +84,8 @@ IMMEDIATE SHALL 表示不等待摘要窗口,按独立通知实例尽快异步 系统 SHALL 对明确临时失败进行有限退避,对永久失败停止重试,对无法判断 SMTP 是否接受的结果标识 UNKNOWN 而非盲目自动重发。SMTP 接受 SHALL 只表示服务器接受,不得宣称已进入收件箱或已读。受限人工重试 SHALL 审计并提示重复风险。 #### Scenario: SCN-014 临时失败与永久拒绝 -- **WHEN** 出现明确临时故障,或服务器永久拒绝,或达到最大尝试次数 -- **THEN** 分别进入有界重试、终态失败或重试耗尽状态,业务不受影响,运维可定位原因 +- **WHEN** 出现明确临时故障,或服务器永久拒绝,或达到24小时恢复截止时间 +- **THEN** 分别进入窗口内退避重试、终态失败或恢复窗口到期失败状态,业务不受影响,运维可定位原因 #### Scenario: SCN-015 接受结果不确定 - **WHEN** SMTP 数据发送后超时或进程在可能已接受后崩溃 @@ -90,7 +96,7 @@ IMMEDIATE SHALL 表示不等待摘要窗口,按独立通知实例尽快异步 #### Scenario: SCN-016 退订、移除或资源权限变化 - **WHEN** 入箱后用户取消订阅、退出空间、失去审核角色或账号被停用/合并 -- **THEN** 平台开关、账号、邮箱证明与邮件偏好按发送授权锁顺序判断;资源、角色和订阅按授权事务中的查询快照检查,不合格项抑制,其他合格项保留。资源资格在查询之后并发变化时,该次尝试可能已获授权;邮件不包含资源名称或敏感正文,明细访问始终按当前权限重查,已授权在途尝试保留不可撤回边界 +- **THEN** 平台开关、账号、邮箱证明与邮件偏好按发送授权锁顺序判断;资源、角色和订阅按分块准备时的查询快照及有限预览最终复核检查,不合格项抑制,其他合格项保留。资源资格在查询之后并发变化时,该次尝试可能已获授权;邮件不包含资源名称或敏感正文,明细访问始终按当前权限重查,已授权在途尝试保留不可撤回边界 #### Scenario: SCN-017 恶意内容、链接与用户隔离 - **WHEN** 多账号具有相同邮箱,或事件文本包含 HTML、换行、任意外部链接和私有审批意见 @@ -106,3 +112,40 @@ IMMEDIATE SHALL 表示不等待摘要窗口,按独立通知实例尽快异步 #### Scenario: SCN-019 通道记录生命周期独立 - **WHEN** 用户删除站内消息、站内清理任务执行、邮件历史清理执行或应用回退 - **THEN** 未完成邮件任务不会被站内删除牵连,邮件历史按自身策略清理,回退关闭能力而不删除数据表或重放旧队列 + + +### Requirement: REQ-014 积压摘要恢复合并与可追溯性 +系统 SHALL 将同用户同邮箱修订、已到期且可确认未被接受的普通摘要合入一封恢复汇总,保留原始记录与时间;SHALL 排除已授权在途、已发送和结果未知的批次,不自动复活 FAILED,不把 IMMEDIATE 改成 DIGEST。恢复范围 SHALL 固定,准备进度 SHALL 持久化并有界推进,多个 worker 不得为同一用户建立并发送重复恢复 root。 + +#### Scenario: SCN-028 多窗口积压恢复 +- **WHEN** 同用户有四个到期待发摘要窗口,恢复后多个 worker 同时处理,且有新事件到达 +- **THEN** 固定范围内的窗口合入一封恢复汇总,原事件和发生时间保留,新事件进入后续范围;来源可追溯,FAILED/UNKNOWN/SENDING/SENT 批次不被误合并,确认未接受的 TEMPORARY_FAILURE/RETRY 按原 root 恢复 + +#### Scenario: SCN-029 准备中崩溃、取消及竞争 +- **WHEN** 部分源已标 MERGED 后进程退出,或旧 CLAIMED worker 尝试独立授权,或发生关闭/OFF/换址及人工重试 +- **THEN** 重启续跑原 root,旧源不能独立发送,取消项不因恢复复活;人工重试不能创建第二个活跃 root;未完成恢复组及去重事实不被清理 + +### Requirement: REQ-015 有界执行与业务隔离 +系统 SHALL 将数据库/SMTP 工作隔离于公共定时线程,使用有界邮件执行容量且拒绝不回落业务线程;SHALL 以短事务、有限页准备和数据库游标分页处理积压,禁止在全局独占锁内全量读取海量项。摘要和即时领取 SHALL 公平轮转,避免旧摘要独占处理额度;容量和延迟不能靠固定窗口数值保证。 + +平台关闭、类别关闭与账号停用的取消记录 SHALL 在既有授权锁顺序下短事务提交;待发积压可随后按有限页扫尾。快速关闭再开启及扫尾中断均不得绕过持久取消边界。 + +#### Scenario: SCN-030 慢供应商、巨批次及公平性 +- **WHEN** SMTP 阻塞、同用户大量重复触发,或存在海量摘要源/项及其他用户的即时通知 +- **THEN** 公共定时任务继续运行,执行器及领取数有界,无无界内存队列;准备分页提交且其他用户可推进,即时/摘要都有处理机会,明细不全量加载后假分页;邮件原始数量不冒称精确可见总数 + + +### Requirement: REQ-016 临时故障恢复期限 +明确未被 SMTP 接受的临时失败 SHALL 在首次失败确定的 24 小时恢复窗口内退避重试,到期转 FAILED;重启、补源与自动重试不得刷新截止时间。永久失败 SHALL 直接终结;UNKNOWN SHALL 不自动重发。已确认未被接受的 root 重试前可以重新固定范围收编后续到期普通摘要,但不得在一次准备过程中无限追加。 + +#### Scenario: SCN-031 长时间供应商故障与期限 +- **WHEN** SMTP 连续返回明确临时失败四小时,其间持续产生新摘要,随后恢复;或故障跨过24小时、进程重启及人工重试 +- **THEN** 期限内沿同一 root 汇总本次固定准备截止范围内符合条件的到期摘要恢复投递,补源/重启不延长原截止;期限外标失败不自动复活;明确人工恢复另开窗口并审计,UNKNOWN 仍要求重复风险确认 + + +### Requirement: REQ-017 主动关闭后拒绝迟到历史 +平台、类别或收件资格恢复启用后,系统 SHALL 以持久接纳水位与不可变业务发生时间判断新入箱,只接受水位时刻及之后发生的事件。普通模式互换、周期/URL修改不得抬高水位;该规则 SHALL 不影响已经合法入队的 SMTP 故障恢复,亦不删除站内通知。 + +#### Scenario: SCN-032 关闭期间事件迟到 +- **WHEN** 11点主动关闭、12点重开,11点半的事件12点后才到入口,且同时有重新开启后新事件及之前合法入队恢复任务 +- **THEN** 迟到旧事件拒绝邮件入箱,水位前/等于/后按明确边界判断;新事件按偏好处理,SMTP恢复任务不因仅重新读取水位被误杀,原始发生时间及站内记录保持不变 diff --git a/openspec/changes/add-notification-email-delivery/tasks.md b/openspec/changes/add-notification-email-delivery/tasks.md index 15d8c4ffb..faae90fb3 100644 --- a/openspec/changes/add-notification-email-delivery/tasks.md +++ b/openspec/changes/add-notification-email-delivery/tasks.md @@ -30,6 +30,7 @@ - [ ] 4a.1 邮件调度与公共定时任务隔离;以阻塞 SMTP、重复触发和关闭屏障证明其他任务不中断、没有无界任务堆积。 - [ ] 4a.2 完成到期摘要按用户恢复合并的状态、来源追溯与并发方案;实现后验证多实例只发送一份恢复汇总,未知与已授权批次不自动重发。 - [ ] 4a.3 明确并实现有界准备、数据库分页、即时/摘要调度与容量可观测性;大批次、数小时故障及恢复场景通过前不放行。 +- [ ] 4a.4 关闭事务仅写持久取消边界,后台有限页扫尾;真实 PostgreSQL 验证入箱 ID 已分配未提交的竞争、三类关闭范围及 SENDING→TEMP 后快速恢复不会给旧项第二次授权,新合法项仍可发送。 ## 5. 验收与交付 diff --git a/openspec/changes/add-notification-email-delivery/verification.md b/openspec/changes/add-notification-email-delivery/verification.md index 3bd659f87..bba361a7a 100644 --- a/openspec/changes/add-notification-email-delivery/verification.md +++ b/openspec/changes/add-notification-email-delivery/verification.md @@ -1,6 +1,6 @@ # #978 验证设计与追踪矩阵 -**状态:设计,全部业务用例未执行。** 本轮没有业务代码、数据库迁移、真实 SMTP、浏览器或部署验收。以下是实施后的验收依据,不是“已经通过”的测试报告。 +**状态:实施中,正式验收矩阵尚未冻结执行。** 基础实现及开发反馈测试已有结果;积压恢复需求补充后仍在修改。本表是正式验收依据,不把开发中测试、静态审查或旧 SHA 结果冒称为全部验收通过。精确 SHA 的部署、SMTP 捕获器和浏览器验收须在实现与独立审查收敛后记录。 ## 1. 源码基线与事实入口 @@ -34,13 +34,13 @@ | TC-002 | 正常:密码重置;SCN-007 | REQ-003/REQ-011 | 所有业务邮件 OFF/DIGEST,走原重置流程 | 原安全码流程保持可用,不能交叉消费通知验证 | 集成+捕获器 | | TC-003 | 模式矩阵;SCN-001/SCN-002/SCN-006 | REQ-001/REQ-003 | 四类×三模式,IN_APP on/off,空偏好 | 邮件默认 OFF、渠道独立;即时不等窗口,旧 API 不变 | 参数化服务+API | | TC-004 | 摘要主路径;SCN-003 | REQ-002 | 同用户 12 更新+3审核,另有另一用户 | 一人一个窗口一批次,15项两节,另一用户独立 | PostgreSQL+SMTP | -| TC-005 | 摘要上下界;SCN-004 | REQ-002 | 0/1/49/50/51/大量项、UTC窗口边界;站内OFF+51项及站内记录删除 | 空不发;超过50仍一批次且总数准确;独立邮件详情分页可见合法其余项,过期明确提示且不串用户;窗口边界无重叠 | 可控时钟+集成+浏览器 | +| TC-005 | 摘要上下界;SCN-004 | REQ-002 | 0/1/49/50/51/大量项、UTC窗口边界;站内OFF+51项及站内记录删除 | 空不发;超过50仍一批次且原始记录总数准确,非全局精确可见总数;独立邮件详情分页可见合法其余项,过期明确提示且不串用户;窗口边界无重叠 | 可控时钟+集成+浏览器 | | TC-006 | 封口竞争;SCN-005 | REQ-002/REQ-006 | 两线程入箱与封口、长事务迟到 | 固定成员不变,迟到项可接纳、不丢、不重开旧批次 | PostgreSQL并发屏障 | | TC-007 | 变更/关闭;SCN-008/SCN-009/SCN-027 | REQ-004/REQ-013 | OFF、总开关关闭、模式互换、周期变更、再开启 | 未发项正确抑制、不历史重放;旧窗口按明确快照规则完成 | 状态机+集成 | | TC-008 | 事务隔离;SCN-010/SCN-011 | REQ-005 | 业务回滚、站内成功邮件入箱失败、SMTP故障 | 回滚无任务;已提交业务/站内不被邮件失败撤销;记录入口局限 | 集成+故障注入 | | TC-009 | 实例去重;SCN-012 | REQ-006 | 同事件重放、同技能不同版本、合法重复生命周期;接纳窗口前/边界/后和清理后重放 | 窗口内同事件仅一项,不同合法实例均存在;过期重放拒绝,occurredAt不被重设;不承诺永久账本 | 参数化+PostgreSQL | | TC-010 | 两 worker 竞争;SCN-013 | REQ-006 | 相同due批次、CLAIMED/SENDING租约过期、旧token、网络阻塞 | 单owner,旧worker不能确认;未授权可回收,已授权过期UNKNOWN;SMTP等待不占DB长事务 | 多实例集成 | -| TC-011 | 重试边界;SCN-014 | REQ-007 | 临时拒绝→恢复、永久拒绝、最大尝试次数 | 退避时间与终态正确,恢复不新建重复逻辑批次 | 可控时钟+SMTP协议夹具 | +| TC-011 | 重试边界;SCN-014 | REQ-007 | 真实 Spring SMTP 连接拒绝/DNS 失败、RCPT 451→恢复、永久 550、24小时恢复截止时间 | 明确连接前失败与 4xx 进入24小时退避;永久拒绝终结,DATA后结果不确定仍为 UNKNOWN;恢复不新建重复逻辑批次 | JavaMailSenderImpl+可控时钟+SMTP协议夹具 | | TC-012 | 不确定发送;SCN-015 | REQ-007 | DATA后断连、确认前崩溃、租约到期、人工重试 | UNKNOWN不盲目重发;管理员重试审计且说明重复风险 | SMTP故障+进程故障注入 | | TC-013 | 资格变化;SCN-016/SCN-026 | REQ-008/REQ-013 | 退订、退出空间、撤角色、隐藏/删除资源、账号停用 | 不合格项抑制,其他项可投递,旧权限不能泄露内容 | 业务集成 | | TC-014 | 模板与隔离;SCN-017 | REQ-008 | 同邮箱不同账号、HTML/换行、恶意Host/URL、子路径 | 不混信、不含敏感正文、链接白名单且重新鉴权 | 模板+API+浏览器 | @@ -86,9 +86,9 @@ - UI/设置/验邮需在精确 SHA 的标准 Compose 预览上用浏览器交互验证,检查页面、网络、最终数据库状态及无未解释错误。 - 实现时按项目 Makefile 运行相关后端/前端/类型生成检查,扩大范围用于共享邮箱修订与事件结构变化;源码测试、镜像启动、SMTP接受、真实收件箱送达分别报告。 - 业务提交至异步入箱前的崩溃窗口仍未闭合,应以故障分析和实际边界记录,而非把它列成“已保证不丢失”。 -- 当前没有真实 SMTP 凭证联调、企业邮箱送达、生产部署、性能实测,也没有已实现的新接口/配置;本轮仅可声明文档结构校验与独立设计审查结果。 +- 尚未进行真实企业邮箱送达、生产部署或最终精确 SHA 的运行验收。基础接口/配置与开发反馈测试已经实现和执行,不等同最终积压范围验收。 -## 6. 本轮方案检查结果(2026-10-22) +## 6. 最初方案历史检查(2026-10-22,非当前新增范围覆盖证明) | 检查 | 实际结果 | 证据边界 | |---|---|---| @@ -104,4 +104,18 @@ 3. 去重改为不可变发生时间与有限历史接纳窗口,保留期限覆盖窗口,清理后的旧事件不能重新入箱;同步 REQ-006、TC-009/016 和任务。 4. 自查补齐旧应用回退不维护邮箱修订号的边界:回退前停worker、使通知证明/挑战失效并抑制队列,再升级不恢复旧证明。 -下一门禁是用户评审技术取舍及推荐参数;尚未获得业务实现、发布或外部发信授权。方案文件在独立工作分支保留,未提交、未推送。 +用户随后批准实施;基础代码已提交并推送至 Draft PR #1004,积压补充在实施中。发布、合并和真实外部发信仍未授权;不得把本段历史检查冒称为新增需求验收。 + + +## 7. 积压恢复补充验收(计划,尚未执行) + +| 用例 | 需求 | 必测交错/输入 | 判据 | +|---|---|---|---| +| TC-023 | REQ-014 | 同用户 4/1000 个到期窗口、多实例同时 CLAIMED、恢复中继续来新事件、两个用户同邮箱、不同 emailRevision | 固定范围只一封恢复汇总;原事件时间和来源不变;用户/修订隔离;旧 CLAIMED 不能独立发;即时不混入 | +| TC-024 | REQ-014/REQ-004/REQ-013;SCN-033 | 源封一半后崩溃;PREPARING 租约到期;SENDING 阻塞时平台/类别 OFF→ON 或账号停用→恢复,随后 SMTP 返回明确 TEMP;UNKNOWN/FAILED 后关闭重开并人工 retry;人工 retry 遇另一 root;保留清理 | 从持久游标续同一 root;在途一次仍按结果记账,但取消前旧项不获得第二次自动或人工授权;新合法项可发;UNKNOWN 不自动重发;活跃恢复组与去重事实保留;人工竞争明确冲突 | +| TC-025 | REQ-015/REQ-008 | 海量项/全部失权/首有效项在后页、资源在分页与授权之间变化、详情翻页 | 每次最多有限行/有限事务;不全量装内存;无有效项不发空信;最终预览复核;展示原始数而非精确可见数,详情按当前权限过滤 | +| TC-026 | REQ-015/REQ-007 | SMTP 和清理同时阻塞、重复触发千次、其他用户即时与摘要、实例关闭、供应商临时拒绝与 DATA 不确定;巨型恢复组大量待发项下关闭并快速重开,随后批次终结、详情访问及扫尾 | 公共 timer 继续工作;独立执行容量有界且无 caller-runs;公平轮转推进;CLAIMED 与 SENDING 恢复区分;FAILED/UNKNOWN/SENDING/SENT 不被当成普通待发重合并;明确 TEMP 的 RETRY 沿原 root 恢复;关闭事务只记录有界持久屏障,批次终结无全组UPDATE,详情按逻辑状态过滤,有限页扫尾,不因重开或扫尾中断复活旧项 | +| TC-027 | REQ-016/REQ-014/REQ-007 | 明确临时故障4h持续入箱→恢复;24h截止前/等于/后、重启、补源、人工retry、永久拒绝、UNKNOWN | 同root有界补源并恢复一封;自动路径不延长deadline;截止边界失败且不自动复活;永久/UNKNOWN分类不被恢复窗口覆盖;人工新窗口有审计 | +| TC-028 | REQ-017/REQ-004/REQ-013 | 开关水位前/等于/后、关闭期间延迟事件、类别恢复/首次验邮/账号恢复、仅改周期/URL/模式、同事件重放 | 新入箱严格比较原始发生时间,水位不被普通配置改动刷新;旧合法队列不误杀,站内与发生时间不变;多实例持锁初始化只补一次 | + +负载验证必须记录实际规模、耗时、查询/领取边界与依赖条件;没有吞吐实测不宣称容量 SLA。供应商停机时,应分别验证 worker 暂停导致的积压、明确临时错误重试耗尽和 SMTP 接受结果未知,不能混称为同一种“断网恢复”。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationEmailDeliveryController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationEmailDeliveryController.java index 89844fef7..6caccbd86 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationEmailDeliveryController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationEmailDeliveryController.java @@ -18,8 +18,9 @@ public class NotificationEmailDeliveryController extends BaseApiController { } @GetMapping("/{id}") public ApiResponse detail(@PathVariable long id, - @RequestAttribute("userId") String userId, @RequestParam(defaultValue = "0") @Min(0) @Max(1000000) int page, + @RequestAttribute("userId") String userId, + @RequestParam(required = false) @Pattern(regexp = "[0-9]{1,19}") String cursor, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { - return ok("response.success.read", service.detail(id, userId, page, size)); + return ok("response.success.read", service.detail(id, userId, cursor, size)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationEmailDeliveryAdminResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationEmailDeliveryAdminResponse.java index 885af402c..8455c07a7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationEmailDeliveryAdminResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationEmailDeliveryAdminResponse.java @@ -1,4 +1,5 @@ package com.iflytek.skillhub.dto; public record NotificationEmailDeliveryAdminResponse(long id, String recipientId, String kind, String status, - int attempts, String resultCode, java.time.Instant createdAt, java.time.Instant acceptedAt) {} + int attempts, @io.swagger.v3.oas.annotations.media.Schema(nullable = true) String resultCode, java.time.Instant createdAt, @io.swagger.v3.oas.annotations.media.Schema(nullable = true) java.time.Instant acceptedAt, + @io.swagger.v3.oas.annotations.media.Schema(nullable = true) Long mergedIntoDeliveryId) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationEmailDeliveryDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationEmailDeliveryDetailResponse.java index ad9af5a2a..dde394163 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationEmailDeliveryDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationEmailDeliveryDetailResponse.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.dto; public record NotificationEmailDeliveryDetailResponse(long id, String status, String kind, java.time.Instant createdAt, - java.time.Instant acceptedAt, java.util.List items, long totalItems, - long unavailableItems, int page, int size, int totalPages) {} + @io.swagger.v3.oas.annotations.media.Schema(nullable = true) java.time.Instant acceptedAt, java.util.List items, long totalItems, + long unavailableCount, int size, @io.swagger.v3.oas.annotations.media.Schema(nullable = true) String nextCursor, + @io.swagger.v3.oas.annotations.media.Schema(nullable = true) Long mergedIntoDeliveryId) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index 99ed2d5be..00a5547bc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -52,6 +52,7 @@ public class AdminUserAppService { private final ApplicationEventPublisher eventPublisher; private final NotificationEmailPolicyProvider emailPolicy; private final NotificationEmailCancellation emailCancellation; + private final com.iflytek.skillhub.notification.email.NotificationEmailAccountLifecycle emailLifecycle; public AdminUserAppService( AdminUserSearchRepository adminUserSearchRepository, @@ -60,7 +61,8 @@ public class AdminUserAppService { RoleRepository roleRepository, ApplicationEventPublisher eventPublisher, NotificationEmailPolicyProvider emailPolicy, - NotificationEmailCancellation emailCancellation) { + NotificationEmailCancellation emailCancellation, + com.iflytek.skillhub.notification.email.NotificationEmailAccountLifecycle emailLifecycle) { this.adminUserSearchRepository = adminUserSearchRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; @@ -68,6 +70,7 @@ public class AdminUserAppService { this.eventPublisher = eventPublisher; this.emailPolicy = emailPolicy; this.emailCancellation = emailCancellation; + this.emailLifecycle = emailLifecycle; } @Transactional(readOnly = true) @@ -133,6 +136,7 @@ public class AdminUserAppService { emailCancellation.cancelCategories(userId, Set.of(NotificationCategory.values())); } if (nextStatus == UserStatus.ACTIVE && previousStatus != UserStatus.ACTIVE) { + emailLifecycle.markActive(userId); eventPublisher.publishEvent( new UserActivatedEvent(user.getId(), user.getDisplayName(), user.getEmail())); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailAdmissionService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailAdmissionService.java new file mode 100644 index 000000000..78fb8f801 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailAdmissionService.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.email.*; +import java.time.Instant; +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NotificationEmailAdmissionService implements NotificationEmailAdmissionProvider { + private final NotificationEmailPolicyProvider policies; + private final NotificationEmailRecipientProvider recipients; + private final NotificationEmailPreferenceRepository preferences; + + public NotificationEmailAdmissionService(NotificationEmailPolicyProvider policies, + NotificationEmailRecipientProvider recipients, NotificationEmailPreferenceRepository preferences) { + this.policies = policies; + this.recipients = recipients; + this.preferences = preferences; + } + + @Override + @Transactional(propagation = Propagation.MANDATORY) + public Optional currentUnderLocks(String userId, NotificationCategory category) { + var policy = policies.current(); + var recipient = recipients.current(userId); + var preference = preferences.findByUserIdOrderByCategoryAsc(userId).stream() + .filter(row -> row.getCategory() == category).findFirst(); + if (!policy.enabled() || policy.enabledSince() == null || recipient.isEmpty() + || recipient.get().eligibleSince() == null || preference.isEmpty() + || preference.get().getMode() == NotificationEmailMode.OFF + || preference.get().getEnabledSince() == null) + return Optional.empty(); + return Optional.of(java.util.stream.Stream.of(policy.enabledSince(), recipient.get().eligibleSince(), + preference.get().getEnabledSince()) + .max(Instant::compareTo).orElseThrow()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailContactService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailContactService.java index ac4db0468..edcbf8ba5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailContactService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailContactService.java @@ -18,7 +18,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @Service -public class NotificationEmailContactService implements NotificationEmailRecipientProvider { +public class NotificationEmailContactService implements NotificationEmailRecipientProvider, NotificationEmailAccountLifecycle { private static final SecureRandom RANDOM = new SecureRandom(); private final UserAccountRepository accounts; private final NotificationEmailContactRepository contacts; @@ -63,7 +63,7 @@ public class NotificationEmailContactService implements NotificationEmailRecipie .map(c -> new NotificationEmailRecipient(id, UserAccount.normalizeEmail(user.getEmail()), user.getEmailRevision(), - c.getLocale()))); + c.getLocale(), c.eligibleSince()))); } @Override @Transactional @@ -72,10 +72,17 @@ public class NotificationEmailContactService implements NotificationEmailRecipie var c = contacts.lockOrCreate(id); return c.verified(user.getEmail(), user.getEmailRevision()) ? Optional.of(new NotificationEmailRecipient( - id, UserAccount.normalizeEmail(user.getEmail()), user.getEmailRevision(), c.getLocale())) + id, UserAccount.normalizeEmail(user.getEmail()), user.getEmailRevision(), c.getLocale(), c.eligibleSince())) : Optional.empty(); }); } + @Override + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void markActive(String id) { + var contact = contacts.lockOrCreate(id); + contact.markActive(Instant.now(clock)); + contacts.save(contact); + } @Transactional(readOnly = true) public NotificationEmailContactResponse status(String id) { var policy = settings.current(); @@ -184,7 +191,7 @@ public class NotificationEmailContactService implements NotificationEmailRecipie .filter(p -> p.getCategory() == entry.getKey()) .findFirst() .orElseGet(() -> new NotificationEmailPreference(id, entry.getKey())); - row.setMode(entry.getValue()); + row.setMode(entry.getValue(), Instant.now(clock)); preferences.save(row); } var off = changes.entrySet() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailSettingsService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailSettingsService.java index 921fc78e2..514d60792 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailSettingsService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NotificationEmailSettingsService.java @@ -5,7 +5,9 @@ import com.iflytek.skillhub.domain.shared.exception.*; import com.iflytek.skillhub.notification.email.*; import jakarta.persistence.*; import java.net.URI; -import java.util.Map; +import java.time.Clock; +import java.time.Instant; +import java.util.HashMap; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -15,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; @Service public class NotificationEmailSettingsService implements NotificationEmailPolicyProvider, ApplicationRunner { public static final String SETTING_KEY = "notification.email"; + private final Clock clock; private final SystemSettingRepository settings; private final EntityManager entityManager; private final boolean initialEnabled; @@ -31,7 +34,8 @@ public class NotificationEmailSettingsService implements NotificationEmailPolicy @Value("${spring.mail.host:}") String smtpHost, @Value("${skillhub.notification.email.from-address:${spring.mail.username:}}") String fromAddress, com.iflytek.skillhub.domain.audit.AuditLogService audit, - com.iflytek.skillhub.notification.email.delivery.NotificationEmailCancellation cancellation) { + com.iflytek.skillhub.notification.email.delivery.NotificationEmailCancellation cancellation, Clock clock) { + this.clock = clock; this.settings = settings; this.entityManager = entityManager; this.initialEnabled = enabled; @@ -45,7 +49,9 @@ public class NotificationEmailSettingsService implements NotificationEmailPolicy @Override @Transactional public void run(ApplicationArguments args) { - if (settings.findBySettingKey(SETTING_KEY).isPresent()) + var existing = settings.findBySettingKey(SETTING_KEY); + if (existing.isPresent() && (!Boolean.TRUE.equals(existing.get().getValue().get("enabled")) + || existing.get().getValue().get("enabledSince") != null)) return; // Match the existing auth initializer: serialize replica seeding on PostgreSQL, // while keeping the H2 application-test profile supported. @@ -57,31 +63,45 @@ public class NotificationEmailSettingsService implements NotificationEmailPolicy } }); if (settings.findBySettingKey(SETTING_KEY).isPresent()) { + var setting = load(LockModeType.PESSIMISTIC_WRITE); + // Existing deployments receive a single startup baseline; reads never manufacture a new watermark. + if (Boolean.TRUE.equals(setting.getValue().get("enabled")) + && setting.getValue().get("enabledSince") == null) { + var values = new HashMap(setting.getValue()); + values.put("enabledSince", Instant.now(clock).toString()); + setting.update(values, null); + settings.saveAndFlush(setting); + } return; } validate(initialEnabled, initialWindow, initialUrl); - settings.saveAndFlush(new SystemSetting(SETTING_KEY, - Map.of("enabled", initialEnabled, "digestWindowMinutes", initialWindow, "publicBaseUrl", initialUrl))); + var values = new HashMap(); + values.put("enabled", initialEnabled); + values.put("digestWindowMinutes", initialWindow); + values.put("publicBaseUrl", initialUrl); + if (initialEnabled) + values.put("enabledSince", Instant.now(clock).toString()); + settings.saveAndFlush(new SystemSetting(SETTING_KEY, values)); } @Override @Transactional(readOnly = true) public NotificationEmailPolicy current() { - return policy(load(false)); + return policy(load(LockModeType.NONE)); } @Override @Transactional public NotificationEmailPolicy lockCurrent() { - return policy(load(true)); + return policy(load(LockModeType.PESSIMISTIC_READ)); } - private SystemSetting load(boolean lock) { + private SystemSetting load(LockModeType lockMode) { var query = entityManager.createQuery("select s from SystemSetting s where s.settingKey=:key", SystemSetting.class) .setParameter("key", SETTING_KEY); - if (lock) - query.setLockMode(LockModeType.PESSIMISTIC_WRITE); var setting = query.getSingleResult(); - if (lock) - entityManager.refresh(setting, LockModeType.PESSIMISTIC_WRITE); + // Lock via refresh: upgrading a query lock can reject a cached old @Version before refresh runs. + // Policy decisions and update version checks happen only after this locked database reload. + if (lockMode != LockModeType.NONE) + entityManager.refresh(setting, lockMode); return setting; } private NotificationEmailPolicy policy(SystemSetting s) { @@ -90,7 +110,9 @@ public class NotificationEmailSettingsService implements NotificationEmailPolicy || !(values.get("digestWindowMinutes") instanceof Number window) || !(values.get("publicBaseUrl") instanceof String url)) throw new IllegalStateException("Invalid notification email settings"); - return new NotificationEmailPolicy(enabled, window.intValue(), url, s.getVersion()); + var since = values.get("enabledSince"); + return new NotificationEmailPolicy(enabled, window.intValue(), url, s.getVersion(), + since instanceof String timestamp ? Instant.parse(timestamp) : null); } public boolean smtpConfigured() { return smtpHost != null && !smtpHost.isBlank() && fromAddress != null @@ -99,10 +121,16 @@ public class NotificationEmailSettingsService implements NotificationEmailPolicy @Transactional public NotificationEmailPolicy update(boolean enabled, int window, String url, long version, String actor) { validate(enabled, window, url); - var setting = load(true); + var setting = load(LockModeType.PESSIMISTIC_WRITE); if (setting.getVersion() != version) throw new DomainConflictException("error.system.settings.version.conflict"); - setting.update(Map.of("enabled", enabled, "digestWindowMinutes", window, "publicBaseUrl", url), actor); + var values = new HashMap(setting.getValue()); + if (enabled && !Boolean.TRUE.equals(values.get("enabled"))) + values.put("enabledSince", Instant.now(clock).toString()); + values.put("enabled", enabled); + values.put("digestWindowMinutes", window); + values.put("publicBaseUrl", url); + setting.update(values, actor); settings.saveAndFlush(setting); if (!enabled) cancellation.cancelAll(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryProperties.java index da5774ee6..b0c3233b4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryProperties.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Component; public class NotificationEmailDeliveryProperties { private long pollDelayMs = 10000; private int leaseSeconds = 120; - private int maxAttempts = 5; + private int retryWindowHours = 24; private int batchSize = 20; private int sentRetentionDays = 30; private int failedRetentionDays = 90; @@ -35,15 +35,15 @@ public class NotificationEmailDeliveryProperties { leaseSeconds = value; } - public int getMaxAttempts() { - return maxAttempts; + public int getRetryWindowHours() { + return retryWindowHours; } - public void setMaxAttempts(int value) { - if (value < 1 || value > 20) { - throw new IllegalArgumentException("attempts"); + public void setRetryWindowHours(int value) { + if (value < 1 || value > 168) { + throw new IllegalArgumentException("retry window"); } - maxAttempts = value; + retryWindowHours = value; } public int getBatchSize() { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryQueryService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryQueryService.java index 42d82e9f5..bf085370e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryQueryService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryQueryService.java @@ -15,39 +15,65 @@ public class NotificationEmailDeliveryQueryService { private final NotificationEmailEligibility eligibility; private final AuditLogService audit; private final Clock clock; + private final com.iflytek.skillhub.notification.email.NotificationEmailPolicyProvider policy; + private final com.iflytek.skillhub.notification.email.NotificationEmailRecipientProvider recipients; public NotificationEmailDeliveryQueryService(NotificationEmailDeliveryRepository repository, - NotificationEmailEligibility eligibility, AuditLogService audit, Clock clock) { + NotificationEmailEligibility eligibility, AuditLogService audit, Clock clock, + com.iflytek.skillhub.notification.email.NotificationEmailPolicyProvider policy, + com.iflytek.skillhub.notification.email.NotificationEmailRecipientProvider recipients) { this.repository = repository; this.eligibility = eligibility; this.audit = audit; this.clock = clock; + this.policy = policy; + this.recipients = recipients; } @Transactional(readOnly = true) - public NotificationEmailDeliveryDetailResponse detail(long id, String user, int page, int size) { + public NotificationEmailDeliveryDetailResponse detail(long id, String user, String cursor, int size) { var batch = repository.find(id) .filter(value -> value.recipientId().equals(user)) .orElseThrow(() -> new DomainNotFoundException("error.notification.email.delivery.notFound")); - var all = repository.items(id); - var visible = - all.stream().filter(item -> "ACTIVE".equals(item.status())).filter(eligibility::eligible).toList(); - var items = visible.stream() - .skip((long) page * size) - .limit(size) + long after; + try { + after = cursor == null ? 0 : Long.parseLong(cursor); + } catch (NumberFormatException invalid) { + throw new DomainBadRequestException("error.notification.email.delivery.status.invalid"); + } + if (after < 0 || size < 1 || size > 100) + throw new DomainBadRequestException("error.notification.email.delivery.status.invalid"); + var raw = repository.itemPage(id, after, size + 1); + var scanned = raw.subList(0, Math.min(size, raw.size())); + // A MERGED source has the same logical outcome as its parent, while retaining its original raw rows. + var effective = batch.mergedIntoDeliveryId() == null + ? batch + : repository.find(batch.mergedIntoDeliveryId()) + .filter(parent -> parent.recipientId().equals(user)) + .orElse(batch); + boolean suppressed = "SUPPRESSED".equals(effective.status()); + var cancelled = "SENT".equals(effective.status()) + ? Set.of() + : repository.cancelledItemIds(scanned.stream().map(NotificationEmailItem::id).toList()); + var items = scanned.stream() + .filter(item -> !suppressed && !cancelled.contains(item.id())) + .filter(item -> "ACTIVE".equals(item.status())) + .filter(eligibility::eligible) .map(item -> new NotificationEmailDeliveryItemResponse(item.id(), item.category().name(), item.eventType(), item.occurredAt(), eligibility.targetPath(item))) .toList(); + String next = raw.size() > size ? Long.toString(scanned.getLast().id()) : null; return new NotificationEmailDeliveryDetailResponse(id, batch.status(), batch.kind(), batch.createdAt(), - batch.acceptedAt(), items, visible.size(), all.size() - visible.size(), page, size, - pages(visible.size(), size)); + batch.acceptedAt(), items, batch.itemCount(), scanned.size() - items.size(), size, next, + batch.mergedIntoDeliveryId()); } @Transactional(readOnly = true) public NotificationEmailDeliveryAdminPageResponse list(String status, int page, int size) { if (status != null - && !Set.of("PENDING", "CLAIMED", "SENDING", "SENT", "RETRY", "FAILED", "UNKNOWN", "SUPPRESSED") + && !Set.of("PREPARING", "MERGED", "PENDING", "CLAIMED", "SENDING", "SENT", "RETRY", "FAILED", "UNKNOWN", + "SUPPRESSED") .contains(status)) throw new DomainBadRequestException("error.notification.email.delivery.status.invalid"); long count = repository.count(status); @@ -56,13 +82,22 @@ public class NotificationEmailDeliveryQueryService { .map(row -> new NotificationEmailDeliveryAdminResponse(row.id(), row.recipientId(), row.kind(), row.status(), row.attempts(), row.resultCode(), row.createdAt(), - row.acceptedAt())) + row.acceptedAt(), row.mergedIntoDeliveryId())) .toList(); return new NotificationEmailDeliveryAdminPageResponse(items, count, page, size, pages(count, size)); } @Transactional public void retry(long id, String actor, boolean confirmDuplicateRisk) { + var initial = repository.find(id).orElseThrow( + () -> new DomainNotFoundException("error.notification.email.delivery.notFound")); + policy.lockCurrent(); + recipients.lockCurrent(initial.recipientId()); + if (!repository.lockRecipient(initial.recipientId())) + throw new DomainConflictException("error.notification.email.delivery.retry.conflict"); + var root = repository.activeRoot(initial.recipientId()).orElse(null); + if (root != null && root.id() != id) + throw new DomainConflictException("error.notification.email.delivery.retry.conflict"); var batch = repository.lock(id); if (batch == null) { throw new DomainNotFoundException("error.notification.email.delivery.notFound"); @@ -76,7 +111,9 @@ public class NotificationEmailDeliveryQueryService { repository.retry(id, clock.instant()); audit.record(actor, "NOTIFICATION_EMAIL_RETRY", "NOTIFICATION_EMAIL_DELIVERY", id, null, null, null, "{\"previousStatus\":\"" + batch.status() + "\",\"duplicateRiskAcknowledged\":" + confirmDuplicateRisk - + "}"); + + ",\"previousDeadline\":" + + (batch.retryDeadline() == null ? "null" : "\"" + batch.retryDeadline() + "\"") + + ",\"recoveryWindowReset\":true}"); } private static int pages(long count, int size) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDispatcher.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDispatcher.java index c39c243ba..7350a9aa4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDispatcher.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDispatcher.java @@ -28,6 +28,7 @@ public class NotificationEmailDispatcher { private final TransactionTemplate transaction; private final AtomicBoolean dispatchRunning = new AtomicBoolean(); private final AtomicBoolean cleanupRunning = new AtomicBoolean(); + private final AtomicBoolean immediateFirst = new AtomicBoolean(); private final AtomicBoolean stopping = new AtomicBoolean(); public NotificationEmailDispatcher(NotificationEmailDeliveryRepository repository, NotificationEmailPolicyProvider policy, NotificationEmailRecipientProvider recipients, @@ -51,13 +52,27 @@ public class NotificationEmailDispatcher { return; } try { + transaction.executeWithoutResult(status -> { + policy.lockCurrent(); + repository.drainCancelledItems(); + }); transaction.executeWithoutResult(status -> repository.recoverExpired(clock.instant())); + Set visited = new HashSet<>(); + boolean firstImmediate = immediateFirst.getAndSet(!immediateFirst.get()); for (int i = 0; i < properties.getBatchSize() && !stopping.get(); i++) { - var claimed = transaction.execute( - status -> repository.claim(clock.instant(), leaseEnd(), UUID.randomUUID().toString())); + String preferred = (i % 2 == 0) == firstImmediate ? "IMMEDIATE" : "DIGEST"; + var claimed = transaction.execute(status + -> repository.claim( + clock.instant(), leaseEnd(), UUID.randomUUID().toString(), preferred, visited)); if (claimed == null || claimed.isEmpty()) { - break; + String fallback = "IMMEDIATE".equals(preferred) ? "DIGEST" : "IMMEDIATE"; + claimed = transaction.execute(status + -> repository.claim( + clock.instant(), leaseEnd(), UUID.randomUUID().toString(), fallback, visited)); } + if (claimed == null || claimed.isEmpty()) + break; + visited.add(claimed.get().recipientId()); process(claimed.get()); } } catch (RuntimeException ex) { @@ -69,6 +84,11 @@ public class NotificationEmailDispatcher { } public void process(NotificationEmailDelivery claimed) { + if ("DIGEST".equals(claimed.kind()) + && (!claimed.recoveryRoot() || !claimed.sourcesComplete() || claimed.previewItemIds().isBlank())) { + transaction.executeWithoutResult(status -> prepare(claimed)); + return; + } Authorized authorized = transaction.execute(status -> authorize(claimed)); if (authorized == null) { return; @@ -84,74 +104,158 @@ public class NotificationEmailDispatcher { transaction.executeWithoutResult(status -> { Instant now = clock.instant(); repository.recoverExpired(now); - String state = switch (outcome) { - case ACCEPTED -> "SENT"; - case PERMANENT_FAILURE -> "FAILED"; - case UNKNOWN -> "UNKNOWN"; - case TEMPORARY_FAILURE -> authorized.attempts >= properties.getMaxAttempts() ? "FAILED" : "RETRY"; - }; - String code = outcome == NotificationEmailSender.Result.TEMPORARY_FAILURE && "FAILED".equals(state) - ? "RETRY_EXHAUSTED" - : outcome.name(); long backoff = Math.min(3600, 30L << Math.min(authorized.attempts, 10)); - repository.finish(claimed.id(), claimed.leaseToken(), state, code, now.plusSeconds(backoff), - outcome == NotificationEmailSender.Result.ACCEPTED ? now : null); + if (outcome == NotificationEmailSender.Result.TEMPORARY_FAILURE) { + repository.temporaryFailure(claimed.id(), claimed.leaseToken(), now, now.plusSeconds(backoff), + properties.getRetryWindowHours()); + } else { + String state = switch (outcome) { + case ACCEPTED -> "SENT"; + case PERMANENT_FAILURE -> "FAILED"; + default -> "UNKNOWN"; + }; + repository.finish(claimed.id(), claimed.leaseToken(), state, outcome.name(), now, + outcome == NotificationEmailSender.Result.ACCEPTED ? now : null); + } }); } - private Authorized authorize(NotificationEmailDelivery claim) { - // Shared lock order with settings/contact/preference writes. Batch lock always last. + private boolean validClaim(NotificationEmailDelivery batch, NotificationEmailDelivery claim) { + return batch != null && "CLAIMED".equals(batch.status()) + && Objects.equals(batch.leaseToken(), claim.leaseToken()) + && batch.leaseUntil().isAfter(clock.instant()); + } + + private boolean blockedByRoot(NotificationEmailDelivery batch) { + var root = repository.activeRoot(batch.recipientId()).orElse(null); + return "DIGEST".equals(batch.kind()) && root != null && root.id() != batch.id(); + } + + private boolean available( + NotificationEmailDelivery batch, NotificationEmailRecipient recipient, NotificationEmailPolicy setting) { + if (batch.retryDeadline() != null && !batch.retryDeadline().isAfter(clock.instant())) { + repository.expireRecovery(batch.id(), clock.instant()); + return false; + } + if (!setting.enabled() || recipient == null || recipient.emailRevision() != batch.emailRevision()) { + repository.suppress(batch.id(), batch.leaseToken(), "RECIPIENT_OR_PLATFORM_UNAVAILABLE", clock.instant()); + return false; + } + return true; + } + + /** One bounded preparation page per scheduler round. The contact gate also blocks stale claimed sources. */ + private void prepare(NotificationEmailDelivery claim) { var setting = policy.lockCurrent(); var recipient = recipients.lockCurrent(claim.recipientId()).orElse(null); + if (!repository.lockRecipient(claim.recipientId())) + return; var modes = preferences.lockCurrent(claim.recipientId()); var batch = repository.lock(claim.id()); - if (batch == null || !"CLAIMED".equals(batch.status()) - || !Objects.equals(batch.leaseToken(), claim.leaseToken()) - || !batch.leaseUntil().isAfter(clock.instant())) - return null; - if (!setting.enabled() || recipient == null || recipient.emailRevision() != batch.emailRevision()) { - repository.suppress(batch.id(), claim.leaseToken(), "RECIPIENT_OR_PLATFORM_UNAVAILABLE"); - return null; - } - List active = new ArrayList<>(); - for (var item : repository.items(batch.id())) { - if (!"ACTIVE".equals(item.status())) { - continue; + if (!validClaim(batch, claim) || blockedByRoot(batch) || !available(batch, recipient, setting)) + return; + var root = repository.beginPreparation(batch.id(), claim.leaseToken(), clock.instant()); + if (root == null) + return; + if (!root.sourcesComplete()) { + boolean done = repository.mergeSourcePage(root, clock.instant()); + root = repository.find(root.id()).orElseThrow(); + if (!done) { + repository.savePreparation(root.id(), claim.leaseToken(), root.itemCursor(), root.itemsComplete(), + root.previewItemIds(), false); + return; } - if (modes.getOrDefault(item.category(), NotificationEmailMode.OFF) == NotificationEmailMode.OFF + } + if (root.retryDeadline() != null && !root.retryDeadline().isAfter(clock.instant())) { + repository.expireRecovery(root.id(), clock.instant()); + return; + } + var raw = repository.itemPage(root.id(), root.itemCursor(), 100); + var cancelled = repository.cancelledItemIds(raw.stream().map(NotificationEmailItem::id).toList()); + List previews = new ArrayList<>(); + long cursor = root.itemCursor(); + // Stop once a bounded preview exists. We do not scan an entire backlog to fill 50 slots. + for (var item : raw) { + cursor = item.id(); + if ("ACTIVE".equals(item.status()) && !cancelled.contains(item.id()) + && modes.getOrDefault(item.category(), NotificationEmailMode.OFF) != NotificationEmailMode.OFF + && eligibility.eligible(item)) + previews.add(item.id()); + else if ("ACTIVE".equals(item.status())) + repository.suppressItem(item.id()); + if (previews.size() == 50) + break; + } + boolean complete = raw.size() < 100 && (raw.isEmpty() || cursor == raw.getLast().id()); + if (previews.isEmpty() && complete) { + repository.suppress(root.id(), claim.leaseToken(), "NO_ELIGIBLE_ITEMS", clock.instant()); + return; + } + String ids = previews.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(",")); + repository.savePreparation(root.id(), claim.leaseToken(), cursor, complete, ids, !previews.isEmpty()); + } + + private Authorized authorize(NotificationEmailDelivery claim) { + var setting = policy.lockCurrent(); + var recipient = recipients.lockCurrent(claim.recipientId()).orElse(null); + if (!repository.lockRecipient(claim.recipientId())) + return null; + var modes = preferences.lockCurrent(claim.recipientId()); + var batch = repository.lock(claim.id()); + if (!validClaim(batch, claim) || blockedByRoot(batch) || !available(batch, recipient, setting)) + return null; + List candidates; + if (batch.recoveryRoot()) { + var ids = Arrays.stream(batch.previewItemIds().split(",")) + .filter(value -> !value.isBlank()) + .map(Long::parseLong) + .limit(50) + .toList(); + candidates = repository.previewItems(batch.id(), ids); + } else { + candidates = repository.itemPage(batch.id(), 0, 50); + } + var cancelled = repository.cancelledItemIds(candidates.stream().map(NotificationEmailItem::id).toList()); + List active = new ArrayList<>(); + for (var item : candidates) { + if (!"ACTIVE".equals(item.status())) + continue; + if (cancelled.contains(item.id()) + || modes.getOrDefault(item.category(), NotificationEmailMode.OFF) == NotificationEmailMode.OFF || !eligibility.eligible(item)) repository.suppressItem(item.id()); else active.add(item); } if (active.isEmpty()) { - repository.suppress(batch.id(), claim.leaseToken(), "NO_ELIGIBLE_ITEMS"); + if (batch.recoveryRoot() && !batch.itemsComplete()) { + repository.beginPreparation(batch.id(), claim.leaseToken(), clock.instant()); + repository.savePreparation(batch.id(), claim.leaseToken(), batch.itemCursor(), false, "", false); + } else + repository.suppress(batch.id(), claim.leaseToken(), "NO_ELIGIBLE_ITEMS", clock.instant()); return null; } - if (!repository.authorize(batch.id(), claim.leaseToken(), clock.instant(), leaseEnd())) { + if (!repository.authorize(batch.id(), claim.leaseToken(), clock.instant(), leaseEnd())) return null; - } String link = setting.publicBaseUrl().replaceAll("/+$", "") + "/dashboard/notifications/email/" + batch.id(); boolean zh = "zh".equals(recipient.locale()); - String subject = - zh ? "SkillHub 通知(" + active.size() + " 条)" : "SkillHub notifications (" + active.size() + ")"; - StringBuilder text = - new StringBuilder(zh ? "您有以下通知(时间为 UTC):\n" : "Your notifications (times in UTC):\n"); - var counts = new EnumMap( - com.iflytek.skillhub.notification.domain.NotificationCategory.class); - active.forEach(item -> counts.merge(item.category(), 1, Integer::sum)); - int displayed = 0; - for (var entry : counts.entrySet()) { - text.append('\n').append(entry.getKey().name()).append(": ").append(entry.getValue()).append('\n'); - for (var item : active) { - if (item.category() == entry.getKey() && displayed < 50) { - text.append(item.category()).append(" · ").append(item.occurredAt()).append('\n'); - displayed++; - } - } - } - if (active.size() > 50) { - text.append(zh ? "更多通知请查看详情。\n" : "See details for remaining notifications.\n"); + String subject = zh ? "SkillHub 通知摘要" : "SkillHub notifications"; + StringBuilder text = new StringBuilder(zh ? "原始通知记录数:" : "Original notification records: "); + text.append(batch.itemCount()) + .append(zh ? "。实际可见内容以详情为准。时间为 UTC。\n" + : ". Current access is checked in details. Times are UTC.\n"); + for (var category : com.iflytek.skillhub.notification.domain.NotificationCategory.values()) { + var section = active.stream().filter(item -> item.category() == category).toList(); + if (section.isEmpty()) + continue; + String label = switch (category) { + case PUBLISH -> zh ? "发布动态" : "Publishing"; + case REVIEW -> zh ? "审核进度" : "Reviews"; + case PROMOTION -> zh ? "全局提升" : "Promotions"; + case REPORT -> zh ? "举报处理" : "Reports"; + }; + text.append('\n').append(label).append(zh ? "(预览)\n" : " (preview)\n"); + for (var item : section) text.append(label).append(" · ").append(item.occurredAt()).append('\n'); } text.append('\n').append(link).append('\n'); return new Authorized(recipient, batch.attempts() + 1, subject, text.toString()); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailInboxService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailInboxService.java index 1e0d6bf2d..93bdacf94 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailInboxService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailInboxService.java @@ -20,10 +20,11 @@ public class NotificationEmailInboxService implements NotificationEmailEnqueuer private final NotificationEmailEligibility eligibility; private final ObjectMapper mapper; private final Clock clock; + private final NotificationEmailAdmissionProvider admission; public NotificationEmailInboxService(NotificationEmailPolicyProvider policy, NotificationEmailRecipientProvider recipients, NotificationEmailPreferenceService preferences, NotificationEmailDeliveryRepository repository, NotificationEmailEligibility eligibility, - ObjectMapper mapper, Clock clock) { + ObjectMapper mapper, Clock clock, NotificationEmailAdmissionProvider admission) { this.policy = policy; this.recipients = recipients; this.preferences = preferences; @@ -31,6 +32,7 @@ public class NotificationEmailInboxService implements NotificationEmailEnqueuer this.eligibility = eligibility; this.mapper = mapper; this.clock = clock; + this.admission = admission; } @Override @@ -55,6 +57,9 @@ public class NotificationEmailInboxService implements NotificationEmailEnqueuer if (mode == null || mode == NotificationEmailMode.OFF) { return; } + var watermark = admission.currentUnderLocks(command.recipientId(), command.category()).orElse(null); + if (watermark == null || command.occurredAt().isBefore(watermark)) + return; Long skillId = safeSkillId(command); var candidate = new NotificationEmailItem(0, 0, command.recipientId(), command.eventKey(), command.category(), command.eventType(), skillId, command.entityId(), command.occurredAt(), now, "ACTIVE"); @@ -71,7 +76,7 @@ public class NotificationEmailInboxService implements NotificationEmailEnqueuer mode == NotificationEmailMode.IMMEDIATE ? now : Instant.ofEpochSecond(start + (late + 1) * seconds); var batch = repository.openBatch(command.recipientId(), mode.name(), key, recipient.emailRevision(), due, now); - if ("PENDING".equals(batch.status())) { + if ("PENDING".equals(batch.status()) && !batch.recoveryRoot()) { repository.addItem(batch.id(), command, skillId, now); return; } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 47c90e471..43cf9e2a0 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -148,7 +148,7 @@ skillhub: delivery: poll-delay-ms: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_POLL_DELAY_MS:10000} lease-seconds: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_LEASE_SECONDS:120} - max-attempts: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_MAX_ATTEMPTS:5} + retry-window-hours: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_RETRY_WINDOW_HOURS:24} batch-size: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_BATCH_SIZE:20} sent-retention-days: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_SENT_RETENTION_DAYS:30} failed-retention-days: ${SKILLHUB_NOTIFICATION_EMAIL_DELIVERY_FAILED_RETENTION_DAYS:90} diff --git a/server/skillhub-app/src/main/resources/db/migration/V120__notification_email_recipient_settings.sql b/server/skillhub-app/src/main/resources/db/migration/V120__notification_email_recipient_settings.sql index 030d5247c..b6c14e975 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V120__notification_email_recipient_settings.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V120__notification_email_recipient_settings.sql @@ -1,7 +1,7 @@ ALTER TABLE user_account ADD COLUMN email_revision BIGINT NOT NULL DEFAULT 0; CREATE TABLE notification_email_contact ( user_id VARCHAR(128) PRIMARY KEY REFERENCES user_account(id), - verified_email VARCHAR(256), verified_revision BIGINT, verified_at TIMESTAMPTZ, + verified_email VARCHAR(256), verified_revision BIGINT, verified_at TIMESTAMPTZ, account_enabled_since TIMESTAMPTZ, challenge_hash VARCHAR(256), challenge_id VARCHAR(36), challenge_email VARCHAR(256), challenge_revision BIGINT, challenge_expires_at TIMESTAMPTZ, failed_attempts INTEGER NOT NULL DEFAULT 0, resend_available_at TIMESTAMPTZ, request_window_start TIMESTAMPTZ, request_count INTEGER NOT NULL DEFAULT 0, @@ -9,6 +9,6 @@ CREATE TABLE notification_email_contact ( ); CREATE TABLE notification_email_preference ( id BIGSERIAL PRIMARY KEY, user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), - category VARCHAR(32) NOT NULL, mode VARCHAR(16) NOT NULL DEFAULT 'OFF', version BIGINT NOT NULL DEFAULT 0, + category VARCHAR(32) NOT NULL, mode VARCHAR(16) NOT NULL DEFAULT 'OFF', enabled_since TIMESTAMPTZ, version BIGINT NOT NULL DEFAULT 0, UNIQUE(user_id,category) ); diff --git a/server/skillhub-app/src/main/resources/db/migration/V121__notification_email_delivery.sql b/server/skillhub-app/src/main/resources/db/migration/V121__notification_email_delivery.sql index 59dfb581c..ffc003ada 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V121__notification_email_delivery.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V121__notification_email_delivery.sql @@ -11,6 +11,13 @@ CREATE TABLE notification_email_delivery ( lease_token VARCHAR(36), lease_until TIMESTAMPTZ, result_code VARCHAR(48), accepted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, + is_recovery_root BOOLEAN NOT NULL DEFAULT FALSE, + merged_into_id BIGINT REFERENCES notification_email_delivery(id) ON DELETE CASCADE, + recovery_cutoff_at TIMESTAMPTZ, source_high_watermark BIGINT NOT NULL DEFAULT 0, + source_cursor BIGINT NOT NULL DEFAULT 0, sources_complete BOOLEAN NOT NULL DEFAULT FALSE, + item_cursor BIGINT NOT NULL DEFAULT 0, items_complete BOOLEAN NOT NULL DEFAULT FALSE, + preview_item_ids VARCHAR(1200) NOT NULL DEFAULT '', item_count BIGINT NOT NULL DEFAULT 0, + retry_started_at TIMESTAMPTZ, retry_deadline TIMESTAMPTZ, terminal_at TIMESTAMPTZ, UNIQUE(recipient_id, window_key) ); CREATE INDEX idx_notification_email_due ON notification_email_delivery(status,next_attempt_at,due_at); @@ -28,3 +35,18 @@ CREATE TABLE notification_email_item ( UNIQUE(recipient_id,event_key) ); CREATE INDEX idx_notification_email_item_batch ON notification_email_item(delivery_id,id); + +CREATE INDEX idx_notification_email_recipient ON notification_email_delivery(recipient_id,id); +CREATE INDEX idx_notification_email_parent ON notification_email_delivery(merged_into_id,id); +CREATE INDEX idx_notification_email_user_work ON notification_email_delivery(recipient_id,kind,status,id); + +CREATE TABLE notification_email_cancel_barrier ( + scope_key VARCHAR(256) PRIMARY KEY, + recipient_id VARCHAR(128), category VARCHAR(32), + through_item_id BIGINT NOT NULL, cursor_id BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX idx_notification_email_item_recipient_category ON notification_email_item(recipient_id,category,id); +CREATE INDEX idx_notification_email_cancel_pending ON notification_email_cancel_barrier(updated_at,scope_key) + WHERE cursor_id names = new java.util.HashSet<>(); + parameters.forEach(parameter -> names.add(parameter.path("name").asText())); + assertThat(names).contains("cursor", "size").doesNotContain("page", "offset"); String output = System.getProperty("skillhub.test.openapi-output"); if (output != null && !output.isBlank()) { Path target = Path.of(output); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailAdmissionPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailAdmissionPostgresTest.java new file mode 100644 index 000000000..5d054fd88 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailAdmissionPostgresTest.java @@ -0,0 +1,307 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.iflytek.skillhub.auth.settings.*; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.user.*; +import com.iflytek.skillhub.dto.*; +import com.iflytek.skillhub.infra.jpa.*; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.email.*; +import com.iflytek.skillhub.notification.email.delivery.NotificationEmailCancellation; +import com.iflytek.skillhub.service.*; +import jakarta.persistence.EntityManager; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import com.iflytek.skillhub.notification.email.delivery.*; +import com.iflytek.skillhub.service.notificationemail.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.*; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.*; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.*; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class NotificationEmailAdmissionPostgresTest { + @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + @Autowired SystemSettingRepository settingsRepository; + @Autowired UserAccountRepository accounts; + @Autowired EntityManager em; + @Autowired JdbcTemplate jdbc; + @Autowired PlatformTransactionManager manager; + final Instant initial = Instant.parse("2026-10-22T10:00:00Z"); + final MutableClock clock = new MutableClock(); + TransactionTemplate tx; + NotificationEmailSettingsService settings; + NotificationEmailContactPersistence contacts; + NotificationEmailPreferencePersistence preferences; + NotificationEmailContactService recipients; + NotificationEmailAdmissionService admission; + NotificationEmailPreferenceService modes; + String user; + + @BeforeEach + void initialize() { + clock.now = initial; + tx = new TransactionTemplate(manager); + jdbc.update("DELETE FROM system_setting WHERE setting_key = ?", NotificationEmailSettingsService.SETTING_KEY); + var cancellation = mock(NotificationEmailCancellation.class); + settings = new NotificationEmailSettingsService(settingsRepository, em, true, 60, "https://example.test", + "smtp.example.test", "noreply@example.test", mock(AuditLogService.class), cancellation, clock); + contacts = new NotificationEmailContactPersistence(em); + preferences = new NotificationEmailPreferencePersistence(em); + modes = new NotificationEmailPreferenceService(preferences); + recipients = new NotificationEmailContactService(accounts, contacts, preferences, modes, settings, + mock(JavaMailSender.class), new BCryptPasswordEncoder(4), manager, clock, cancellation, + "noreply@example.test", "SkillHub"); + admission = new NotificationEmailAdmissionService(settings, recipients, preferences); + user = "admission-" + UUID.randomUUID(); + tx.executeWithoutResult(status -> { + settings.run(null); + accounts.save(new UserAccount(user, "Name", "a@example.test", null)); + }); + } + + @Test + void platformOffOnDoesNotAdmitDelayedEventsFromDisabledInterval() { + verifyAndEnable(); + assertThat(watermark()).contains(initial); + clock.now = initial.plusSeconds(3600); + updatePolicy(false, 60); + assertThat(watermark()).isEmpty(); + clock.now = initial.plusSeconds(7200); + updatePolicy(true, 60); + assertThat(watermark()).contains(clock.now); + assertThat(initial.plusSeconds(5400).isBefore(watermark().orElseThrow())).isTrue(); + // The boundary itself remains admissible; the delivery inbox enforces this comparison. + assertThat(clock.now.isBefore(watermark().orElseThrow())).isFalse(); + clock.now = initial.plusSeconds(10800); + updatePolicy(true, 120); + tx.executeWithoutResult(status -> settings.run(null)); + assertThat(watermark()).contains(initial.plusSeconds(7200)); + } + + @Test + void categoryOffOnAndAccountReactivationAdvanceIndependentBaselines() { + verifyAndEnable(); + clock.now = initial.plusSeconds(60); + setMode(NotificationEmailMode.IMMEDIATE); + assertThat(watermark()).contains(initial); + clock.now = initial.plusSeconds(120); + setMode(NotificationEmailMode.OFF); + assertThat(watermark()).isEmpty(); + clock.now = initial.plusSeconds(180); + setMode(NotificationEmailMode.DIGEST); + assertThat(watermark()).contains(clock.now); + // A name change does not change admission eligibility. + tx.executeWithoutResult(status -> { + var account = accounts.findLockedFreshById(user).orElseThrow(); + account.setDisplayName("Renamed"); + accounts.save(account); + }); + assertThat(watermark()).contains(initial.plusSeconds(180)); + clock.now = initial.plusSeconds(240); + tx.executeWithoutResult(status -> { + settings.lockCurrent(); + accounts.findLockedFreshById(user).orElseThrow(); + recipients.markActive(user); + }); + assertThat(watermark()).contains(initial.plusSeconds(240)); + assertThat(recipients.current(user).orElseThrow().eligibleSince()).isEqualTo(initial.plusSeconds(240)); + } + + @Test + void verificationIsRequiredAndLegacyPolicyBaselineIsBackfilledExactlyOnce() { + assertThat(watermark()).isEmpty(); + jdbc.update("UPDATE system_setting SET value_json = ?::jsonb WHERE setting_key = ?", + "{\"enabled\":true,\"digestWindowMinutes\":60,\"publicBaseUrl\":\"https://example.test\"}", + NotificationEmailSettingsService.SETTING_KEY); + clock.now = initial.plusSeconds(60); + tx.executeWithoutResult(status -> settings.run(null)); + assertThat(settings.current().enabledSince()).isEqualTo(clock.now); + clock.now = initial.plusSeconds(120); + tx.executeWithoutResult(status -> settings.run(null)); + assertThat(settings.current().enabledSince()).isEqualTo(initial.plusSeconds(60)); + verifyAndEnable(); + assertThat(watermark()).contains(initial.plusSeconds(120)); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"GLOBAL", "CATEGORY", "ACCOUNT"}) + void inboxGuardsCoverAllocatedIdUntilCommitBeforeCancellationCutoff(String scope) throws Exception { + verifyAndEnable(); + var cancellation = new JdbcNotificationEmailCancellation(jdbc); + var settingsWithBarrier = new NotificationEmailSettingsService(settingsRepository, em, true, 60, + "https://example.test", "smtp.example.test", "noreply@example.test", + mock(AuditLogService.class), cancellation, clock); + var recipientWithBarrier = new NotificationEmailContactService(accounts, contacts, preferences, modes, + settingsWithBarrier, mock(JavaMailSender.class), new BCryptPasswordEncoder(4), manager, clock, + cancellation, "noreply@example.test", "SkillHub"); + String barrierKey = "GLOBAL".equals(scope) ? "GLOBAL" : "USER:" + user + ":PUBLISH"; + var repository = spy(new JdbcNotificationEmailDeliveryRepository(jdbc)); + var eligibility = mock(NotificationEmailEligibility.class); + when(eligibility.eligible(any())).thenReturn(true); + var inbox = new NotificationEmailInboxService(settingsWithBarrier, recipients, modes, repository, + eligibility, new com.fasterxml.jackson.databind.ObjectMapper(), clock, admission); + var proxy = new org.springframework.aop.framework.ProxyFactory(inbox); + proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager, + new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource())); + var transactionalInbox = (NotificationEmailEnqueuer) proxy.getProxy(); + CountDownLatch inserted = new CountDownLatch(1), releaseCommit = new CountDownLatch(1), + writerStarting = new CountDownLatch(1); + AtomicLong itemId = new AtomicLong(); + AtomicInteger writerPid = new AtomicInteger(), inboxPid = new AtomicInteger(); + doAnswer(call -> { + var result = call.callRealMethod(); + inboxPid.set(jdbc.queryForObject("SELECT pg_backend_pid()", Integer.class)); + itemId.set(jdbc.queryForObject("SELECT id FROM notification_email_item WHERE recipient_id=? AND event_key=?", + Long.class, user, "before-off")); + inserted.countDown(); + await(releaseCommit); + return result; + }).when(repository).addItem(anyLong(), any(), any(), any()); + try (var pool = Executors.newFixedThreadPool(2)) { + var source = pool.submit(() -> transactionalInbox.enqueue(new NotificationEmailCommand(user, + NotificationCategory.PUBLISH, "REVIEW_APPROVED", "before-off", initial, "{}", "SKILL", 1L))); + try { + await(inserted); + // This insertion is not yet committed, but its sequence number has already been allocated. + assertThat(jdbc.queryForObject("SELECT count(*) FROM notification_email_item WHERE id=?", + Long.class, itemId.get())).isZero(); + var off = pool.submit(() -> tx.execute(status -> { + writerPid.set(jdbc.queryForObject("SELECT pg_backend_pid()", Integer.class)); + writerStarting.countDown(); + switch (scope) { + case "GLOBAL" -> settingsWithBarrier.update(false, 60, "https://example.test", 0, "admin"); + case "CATEGORY" -> recipientWithBarrier.updatePreferences(user, new NotificationEmailPreferenceUpdateRequest( + List.of(new NotificationEmailPreferenceResponse("PUBLISH", "OFF")))); + case "ACCOUNT" -> { + // Exact AdminUser stop lock order; role/audit collaborators are irrelevant to this race. + settingsWithBarrier.lockCurrent(); + var account = accounts.findLockedFreshById(user).orElseThrow(); + account.setStatus(UserStatus.DISABLED); + accounts.save(account); + cancellation.cancelCategories(user, Set.of(NotificationCategory.values())); + } + default -> throw new AssertionError(scope); + } + return true; + })); + await(writerStarting); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + boolean blocked = false; + while (System.nanoTime() < deadline) { + if (jdbc.queryForList("SELECT unnest(pg_blocking_pids(?))", Integer.class, writerPid.get()) + .contains(inboxPid.get())) { + blocked = true; + break; + } + Thread.sleep(20); + } + assertThat(blocked).as("%s cancellation waits for actual inbox insertion transaction", scope).isTrue(); + releaseCommit.countDown(); + source.get(10, TimeUnit.SECONDS); + assertThat(off.get(10, TimeUnit.SECONDS)).isTrue(); + Long cutoff = jdbc.queryForObject("SELECT through_item_id FROM notification_email_cancel_barrier " + + "WHERE scope_key=?", Long.class, barrierKey); + assertThat(cutoff).isGreaterThanOrEqualTo(itemId.get()); + clock.now = initial.plusSeconds(60); + tx.executeWithoutResult(status -> { + switch (scope) { + case "GLOBAL" -> settingsWithBarrier.update(true, 60, "https://example.test", 1, "admin"); + case "CATEGORY" -> recipientWithBarrier.updatePreferences(user, new NotificationEmailPreferenceUpdateRequest( + List.of(new NotificationEmailPreferenceResponse("PUBLISH", "DIGEST")))); + case "ACCOUNT" -> { + settingsWithBarrier.lockCurrent(); + var account = accounts.findLockedFreshById(user).orElseThrow(); + account.setStatus(UserStatus.ACTIVE); + accounts.save(account); + recipientWithBarrier.markActive(user); + } + default -> throw new AssertionError(scope); + } + }); + assertThat(jdbc.queryForObject("SELECT through_item_id FROM notification_email_cancel_barrier " + + "WHERE scope_key=?", Long.class, barrierKey)).isEqualTo(cutoff); + reset(repository); + transactionalInbox.enqueue(new NotificationEmailCommand(user, NotificationCategory.PUBLISH, + "REVIEW_APPROVED", "after-on", clock.now, "{}", "SKILL", 1L)); + assertThat(jdbc.queryForObject("SELECT id FROM notification_email_item WHERE recipient_id=? " + + "AND event_key='after-on'", Long.class, user)).isGreaterThan(cutoff); + } finally { + releaseCommit.countDown(); + } + } + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(15, TimeUnit.SECONDS)) + throw new AssertionError("Barrier timed out"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + } + + private void verifyAndEnable() { + tx.executeWithoutResult(status -> { + settings.lockCurrent(); + accounts.findLockedFreshById(user).orElseThrow(); + var contact = contacts.lockOrCreate(user); + contact.challenge("fixture", "hash", "a@example.test", 0, "en", clock.now); + contact.consume(clock.now); + contacts.save(contact); + }); + setMode(NotificationEmailMode.DIGEST); + } + private void setMode(NotificationEmailMode mode) { + tx.executeWithoutResult(status -> recipients.updatePreferences(user, + new NotificationEmailPreferenceUpdateRequest(List.of( + new NotificationEmailPreferenceResponse(NotificationCategory.PUBLISH.name(), mode.name()))))); + } + private void updatePolicy(boolean enabled, int minutes) { + tx.executeWithoutResult(status -> settings.update(enabled, minutes, "https://example.test", + settings.current().version(), "admin")); + } + private Optional watermark() { + return tx.execute(status -> { + settings.lockCurrent(); + recipients.lockCurrent(user); + modes.lockCurrent(user); + return admission.currentUnderLocks(user, NotificationCategory.PUBLISH); + }); + } + private class MutableClock extends Clock { + Instant now; + public ZoneId getZone() { return ZoneOffset.UTC; } + public Clock withZone(ZoneId zone) { return this; } + public Instant instant() { return now; } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailRecipientPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailRecipientPostgresTest.java index ea8f1fbad..b35e3530b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailRecipientPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailRecipientPostgresTest.java @@ -161,7 +161,7 @@ class NotificationEmailRecipientPostgresTest { var cancellation = mock(NotificationEmailCancellation.class); var service = new NotificationEmailSettingsService(settingsRepository, em, false, 15, "https://example.test", "smtp.example.test", "noreply@example.test", - mock(com.iflytek.skillhub.domain.audit.AuditLogService.class), cancellation); + mock(com.iflytek.skillhub.domain.audit.AuditLogService.class), cancellation, Clock.systemUTC()); var tx = new TransactionTemplate(manager); tx.executeWithoutResult(status -> service.run(null)); assertThat(service.current().enabled()).isFalse(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailSettingsSharedLockPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailSettingsSharedLockPostgresTest.java new file mode 100644 index 000000000..ffc72e0ad --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NotificationEmailSettingsSharedLockPostgresTest.java @@ -0,0 +1,240 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.auth.settings.SystemSetting; +import com.iflytek.skillhub.auth.settings.SystemSettingRepository; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.notification.email.NotificationEmailPolicy; +import com.iflytek.skillhub.notification.email.delivery.NotificationEmailCancellation; +import com.iflytek.skillhub.service.NotificationEmailSettingsService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.*; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.*; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.*; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class NotificationEmailSettingsSharedLockPostgresTest { + @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + @Autowired SystemSettingRepository settings; + @Autowired EntityManager em; + @Autowired JdbcTemplate jdbc; + @Autowired PlatformTransactionManager manager; + NotificationEmailSettingsService service; + TransactionTemplate tx; + + @BeforeEach + void seed() { + tx = new TransactionTemplate(manager); + jdbc.update("DELETE FROM system_setting WHERE setting_key = ?", NotificationEmailSettingsService.SETTING_KEY); + tx.executeWithoutResult(status + -> settings.saveAndFlush(new SystemSetting(NotificationEmailSettingsService.SETTING_KEY, + Map.of("enabled", true, "digestWindowMinutes", 60, "publicBaseUrl", "https://example.test")))); + service = new NotificationEmailSettingsService(settings, em, false, 60, "https://example.test", + "smtp.example.test", "noreply@example.test", mock(AuditLogService.class), + mock(NotificationEmailCancellation.class), java.time.Clock.systemUTC()); + } + + @Test + void twoReadGuardsRunTogetherAndOffWriterWaitsForBoth() throws Exception { + CountDownLatch readersInside = new CountDownLatch(2), releaseFirst = new CountDownLatch(1), + releaseSecond = new CountDownLatch(1), writerStarting = new CountDownLatch(1); + AtomicInteger firstPid = new AtomicInteger(), secondPid = new AtomicInteger(), writerPid = new AtomicInteger(); + try (var pool = Executors.newFixedThreadPool(3)) { + var first = pool.submit(() -> guardedReader(firstPid, readersInside, releaseFirst)); + var second = pool.submit(() -> guardedReader(secondPid, readersInside, releaseSecond)); + try { + assertThat(readersInside.await(10, TimeUnit.SECONDS)).isTrue(); + var writer = pool.submit(() -> tx.execute(status -> { + writerPid.set(pid()); + writerStarting.countDown(); + return service.update(false, 60, "https://example.test", 0, "admin"); + })); + await(writerStarting); + int blocker = awaitAnyBlocker(writerPid.get(), Set.of(firstPid.get(), secondPid.get())); + // PostgreSQL may expose one MultiXact member at a time. Release that member, + // then prove the writer is still blocked by the other, rather than assuming both are listed. + if (blocker == firstPid.get()) { + releaseFirst.countDown(); + assertThat(first.get(10, TimeUnit.SECONDS).enabled()).isTrue(); + awaitBlockedBy(writerPid.get(), Set.of(secondPid.get())); + releaseSecond.countDown(); + assertThat(second.get(10, TimeUnit.SECONDS).enabled()).isTrue(); + } else { + releaseSecond.countDown(); + assertThat(second.get(10, TimeUnit.SECONDS).enabled()).isTrue(); + awaitBlockedBy(writerPid.get(), Set.of(firstPid.get())); + releaseFirst.countDown(); + assertThat(first.get(10, TimeUnit.SECONDS).enabled()).isTrue(); + } + assertThat(writer.get(10, TimeUnit.SECONDS).enabled()).isFalse(); + NotificationEmailPolicy after = tx.execute(status -> service.lockCurrent()); + assertThat(after.enabled()).isFalse(); + assertThat(after.version()).isEqualTo(1); + } finally { + releaseFirst.countDown(); + releaseSecond.countDown(); + } + } + } + + private NotificationEmailPolicy guardedReader( + AtomicInteger backend, CountDownLatch inside, CountDownLatch release) { + return tx.execute(status -> { + backend.set(pid()); + var policy = service.lockCurrent(); + assertThat(em.getLockMode( + settings.findBySettingKey(NotificationEmailSettingsService.SETTING_KEY).orElseThrow())) + .isEqualTo(LockModeType.PESSIMISTIC_READ); + inside.countDown(); + await(release); + return policy; + }); + } + + @Test + void guardWaitsForOffWriterThenRefreshesPreviouslyCachedSetting() throws Exception { + CountDownLatch cached = new CountDownLatch(1), writerHolding = new CountDownLatch(1), + readerStarting = new CountDownLatch(1), releaseWriter = new CountDownLatch(1); + AtomicInteger readerPid = new AtomicInteger(), writerPid = new AtomicInteger(); + try (var pool = Executors.newFixedThreadPool(2)) { + var reader = pool.submit(() -> tx.execute(status -> { + readerPid.set(pid()); + assertThat(service.current().enabled()).isTrue(); + cached.countDown(); + await(writerHolding); + readerStarting.countDown(); + return service.lockCurrent(); + })); + var writer = pool.submit(() -> { + await(cached); + return tx.execute(status -> { + writerPid.set(pid()); + var off = service.update(false, 60, "https://example.test", 0, "admin"); + writerHolding.countDown(); + await(releaseWriter); + return off; + }); + }); + try { + await(readerStarting); + awaitBlockedBy(readerPid.get(), Set.of(writerPid.get())); + releaseWriter.countDown(); + assertThat(writer.get(10, TimeUnit.SECONDS).enabled()).isFalse(); + var refreshed = reader.get(10, TimeUnit.SECONDS); + assertThat(refreshed.enabled()).isFalse(); + assertThat(refreshed.version()).isEqualTo(1); + } finally { + releaseWriter.countDown(); + } + } + } + + @Test + void guardWaitingForReenableSeesCommittedAdmissionWatermark() throws Exception { + var enabledAt = java.time.Instant.parse("2026-10-22T12:00:00Z"); + service = new NotificationEmailSettingsService(settings, em, false, 60, "https://example.test", + "smtp.example.test", "noreply@example.test", mock(AuditLogService.class), + mock(NotificationEmailCancellation.class), java.time.Clock.fixed(enabledAt, java.time.ZoneOffset.UTC)); + CountDownLatch cached = new CountDownLatch(1), writerHolding = new CountDownLatch(1), + readerStarting = new CountDownLatch(1), releaseWriter = new CountDownLatch(1); + AtomicInteger readerPid = new AtomicInteger(), writerPid = new AtomicInteger(); + try (var pool = Executors.newFixedThreadPool(2)) { + var reader = pool.submit(() -> tx.execute(status -> { + readerPid.set(pid()); + assertThat(service.current().enabledSince()).isNull(); + cached.countDown(); + await(writerHolding); + readerStarting.countDown(); + return service.lockCurrent(); + })); + var writer = pool.submit(() -> { + await(cached); + return tx.execute(status -> { + writerPid.set(pid()); + var off = service.update(false, 60, "https://example.test", 0, "admin"); + var on = service.update(true, 60, "https://example.test", off.version(), "admin"); + writerHolding.countDown(); + await(releaseWriter); + return on; + }); + }); + try { + await(readerStarting); + awaitBlockedBy(readerPid.get(), Set.of(writerPid.get())); + releaseWriter.countDown(); + assertThat(writer.get(10, TimeUnit.SECONDS).enabledSince()).isEqualTo(enabledAt); + var refreshed = reader.get(10, TimeUnit.SECONDS); + assertThat(refreshed.enabled()).isTrue(); + assertThat(refreshed.version()).isEqualTo(2); + assertThat(refreshed.enabledSince()).isEqualTo(enabledAt); + } finally { + releaseWriter.countDown(); + } + } + } + + private int pid() { + return jdbc.queryForObject("SELECT pg_backend_pid()", Integer.class); + } + private int awaitAnyBlocker(int backend, Set candidates) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (System.nanoTime() < deadline) { + var actual = jdbc.queryForList("SELECT unnest(pg_blocking_pids(?))", Integer.class, backend); + for (int blocker : actual) + if (candidates.contains(blocker)) + return blocker; + Thread.sleep(20); + } + throw new AssertionError("Writer was not blocked by either shared policy reader"); + } + private void awaitBlockedBy(int pid, Set blockers) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + List actual = List.of(); + while (System.nanoTime() < deadline) { + actual = jdbc.queryForList("SELECT unnest(pg_blocking_pids(?))", Integer.class, pid); + if (actual.containsAll(blockers)) + return; + Thread.sleep(20); + } + assertThat(actual).as("Actual PostgreSQL lock blockers for backend %s", pid).containsAll(blockers); + } + private static void await(CountDownLatch latch) { + try { + if (!latch.await(15, TimeUnit.SECONDS)) + throw new AssertionError("Barrier timed out"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 1cdbddf78..458a6a72b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -44,7 +44,7 @@ class AdminUserAppServiceTest { userAccountRepository, userRoleBindingRepository, roleRepository, - eventPublisher, emailPolicy, emailCancellation + eventPublisher, emailPolicy, emailCancellation, mock(com.iflytek.skillhub.notification.email.NotificationEmailAccountLifecycle.class) ); @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailAdmissionServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailAdmissionServiceTest.java new file mode 100644 index 000000000..c1646199c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailAdmissionServiceTest.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.email.*; +import java.time.Instant; +import java.util.*; +import org.junit.jupiter.api.Test; + +class NotificationEmailAdmissionServiceTest { + final Instant now = Instant.parse("2026-10-22T12:00:00Z"); + final NotificationEmailPolicyProvider policies = mock(NotificationEmailPolicyProvider.class); + final NotificationEmailRecipientProvider recipients = mock(NotificationEmailRecipientProvider.class); + final NotificationEmailPreferenceRepository preferences = mock(NotificationEmailPreferenceRepository.class); + final NotificationEmailAdmissionService service = new NotificationEmailAdmissionService(policies, recipients, preferences); + + @Test + void missingAnyProofOrWatermarkFailsClosedWithoutManufacturingTime() { + var row = new NotificationEmailPreference("user", NotificationCategory.PUBLISH); + row.setMode(NotificationEmailMode.DIGEST, now); + when(preferences.findByUserIdOrderByCategoryAsc("user")).thenReturn(List.of(row)); + when(policies.current()).thenReturn(new NotificationEmailPolicy(true, 60, "https://example.test", 0)); + when(recipients.current("user")).thenReturn(Optional.of( + new NotificationEmailRecipient("user", "a@example.test", 0, "en", now))); + assertThat(service.currentUnderLocks("user", NotificationCategory.PUBLISH)).isEmpty(); + when(policies.current()).thenReturn(new NotificationEmailPolicy(true, 60, "https://example.test", 0, now)); + when(recipients.current("user")).thenReturn(Optional.of( + new NotificationEmailRecipient("user", "a@example.test", 0, "en"))); + assertThat(service.currentUnderLocks("user", NotificationCategory.PUBLISH)).isEmpty(); + when(recipients.current("user")).thenReturn(Optional.empty()); + assertThat(service.currentUnderLocks("user", NotificationCategory.PUBLISH)).isEmpty(); + } + + @Test + void mergesLatestBaselineAndOnlyReadsAlreadyGuardedRows() { + when(policies.current()).thenReturn(new NotificationEmailPolicy(true, 60, "https://example.test", 0, now)); + when(recipients.current("user")).thenReturn(Optional.of( + new NotificationEmailRecipient("user", "a@example.test", 0, "en", now.plusSeconds(60)))); + var row = new NotificationEmailPreference("user", NotificationCategory.PUBLISH); + row.setMode(NotificationEmailMode.DIGEST, now.plusSeconds(120)); + when(preferences.findByUserIdOrderByCategoryAsc("user")).thenReturn(List.of(row)); + assertThat(service.currentUnderLocks("user", NotificationCategory.PUBLISH)).contains(now.plusSeconds(120)); + row.setMode(NotificationEmailMode.OFF, now.plusSeconds(180)); + assertThat(service.currentUnderLocks("user", NotificationCategory.PUBLISH)).isEmpty(); + verify(policies, never()).lockCurrent(); + verify(recipients, never()).lockCurrent(anyString()); + verify(preferences, never()).lockAll(anyString()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailPreferencesTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailPreferencesTest.java index 458bb949a..db32ec877 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailPreferencesTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailPreferencesTest.java @@ -20,7 +20,7 @@ class NotificationEmailPreferencesTest { var rows = Arrays.stream(NotificationCategory.values()) .map(category -> { var row = new NotificationEmailPreference("u", category); - row.setMode(mode); + row.setMode(mode, java.time.Instant.parse("2026-10-22T10:00:00Z")); return row; }) .toList(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailSettingsServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailSettingsServiceTest.java index 2ffca3b7e..503847675 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailSettingsServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NotificationEmailSettingsServiceTest.java @@ -21,6 +21,7 @@ class NotificationEmailSettingsServiceTest { .withBean(EntityManager.class, () -> em) .withBean(AuditLogService.class, () -> mock(AuditLogService.class)) .withBean(NotificationEmailCancellation.class, () -> mock(NotificationEmailCancellation.class)) + .withBean(java.time.Clock.class, java.time.Clock::systemUTC) .withBean(NotificationEmailSettingsService.class) .run(context -> { context.getBean(NotificationEmailSettingsService.class).run(null); @@ -39,7 +40,7 @@ class NotificationEmailSettingsServiceTest { .thenReturn(Optional.of(new SystemSetting("notification.email", Map.of("enabled", false, "digestWindowMinutes", 15, "publicBaseUrl", "")))); var service = new NotificationEmailSettingsService(repository, em, true, -1, "invalid", "", "", - mock(AuditLogService.class), mock(NotificationEmailCancellation.class)); + mock(AuditLogService.class), mock(NotificationEmailCancellation.class), java.time.Clock.systemUTC()); assertThatCode(() -> service.run(null)).doesNotThrowAnyException(); verifyNoInteractions(em); } @@ -48,7 +49,7 @@ class NotificationEmailSettingsServiceTest { var repository = mock(SystemSettingRepository.class); var em = mock(EntityManager.class); var service = new NotificationEmailSettingsService(repository, em, false, 15, "", "smtp.test", - "noreply@test.com", mock(AuditLogService.class), mock(NotificationEmailCancellation.class)); + "noreply@test.com", mock(AuditLogService.class), mock(NotificationEmailCancellation.class), java.time.Clock.systemUTC()); for (String url : List.of("https://good.test/#bad", "https://user:password@good.test", "javascript:alert(1)", "https://good.test?x=1")) assertThatThrownBy(() -> service.update(true, 15, url, 0, "admin")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryPostgresTest.java index b487e089a..d444da09c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryPostgresTest.java @@ -31,11 +31,13 @@ class NotificationEmailDeliveryPostgresTest { NotificationEmailRecipientProvider recipients; NotificationEmailPreferenceService preferences; NotificationEmailEligibility eligibility; + NotificationEmailAdmissionProvider admission; Clock clock = Clock.fixed(Instant.parse("2026-10-22T10:00:00Z"), ZoneOffset.UTC); @BeforeAll static void schema() { var ds = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE notification_email_contact(user_id VARCHAR(128) PRIMARY KEY)"); manager = new DataSourceTransactionManager(ds); tx = new TransactionTemplate(manager); new ResourceDatabasePopulator(new ClassPathResource("db/migration/V121__notification_email_delivery.sql")) @@ -43,12 +45,16 @@ class NotificationEmailDeliveryPostgresTest { } @BeforeEach void setup() { - jdbc.execute("TRUNCATE notification_email_item,notification_email_delivery RESTART IDENTITY CASCADE"); + jdbc.execute( + "TRUNCATE notification_email_item,notification_email_delivery,notification_email_cancel_barrier RESTART IDENTITY CASCADE"); repository = new JdbcNotificationEmailDeliveryRepository(jdbc); policy = mock(NotificationEmailPolicyProvider.class); recipients = mock(NotificationEmailRecipientProvider.class); preferences = mock(NotificationEmailPreferenceService.class); eligibility = mock(NotificationEmailEligibility.class); + admission = mock(NotificationEmailAdmissionProvider.class); + when(admission.currentUnderLocks(anyString(), any())) + .thenReturn(Optional.of(clock.instant().minus(Duration.ofDays(7)))); when(policy.lockCurrent()).thenReturn(new NotificationEmailPolicy(true, 15, "https://example.test", 0)); when(recipients.lockCurrent(anyString())) .thenAnswer(inv @@ -57,7 +63,7 @@ class NotificationEmailDeliveryPostgresTest { when(preferences.lockCurrent(anyString())) .thenReturn(Map.of(NotificationCategory.PUBLISH, NotificationEmailMode.DIGEST, NotificationCategory.REVIEW, NotificationEmailMode.DIGEST)); - when(eligibility.eligible(any())).thenReturn(true); + doReturn(true).when(eligibility).eligible(any()); } NotificationEmailCommand command(String user, String key, NotificationCategory category) { return new NotificationEmailCommand(user, category, "REVIEW_APPROVED", key, clock.instant(), @@ -65,18 +71,34 @@ class NotificationEmailDeliveryPostgresTest { } NotificationEmailInboxService inbox() { return new NotificationEmailInboxService( - policy, recipients, preferences, repository, eligibility, new ObjectMapper(), clock); + policy, recipients, preferences, repository, eligibility, new ObjectMapper(), clock, admission); } void enqueue(NotificationEmailCommand command) { - tx.executeWithoutResult(s -> inbox().enqueue(command)); + tx.executeWithoutResult(s -> { + jdbc.update("INSERT INTO notification_email_contact(user_id) VALUES(?) ON CONFLICT DO NOTHING", + command.recipientId()); + inbox().enqueue(command); + }); } NotificationEmailDelivery batch() { return repository.list(null, 0, 100).getFirst(); } NotificationEmailDelivery claim(String token) { - return tx.execute(s + NotificationEmailDelivery claimed = tx.execute(s -> repository.claim(clock.instant().plusSeconds(901), clock.instant().plusSeconds(1000), token) .orElseThrow()); + for (int page = 0; page < 30 && "DIGEST".equals(claimed.kind()) + && (!claimed.recoveryRoot() || !claimed.sourcesComplete() || claimed.previewItemIds().isBlank()); + page++) { + dispatcher((a, b, c, d, e) -> { throw new AssertionError("preparation cannot send"); }).process(claimed); + var current = repository.find(claimed.id()).orElseThrow(); + if ("SUPPRESSED".equals(current.status())) + return current; + claimed = tx.execute(s + -> repository.claim(clock.instant().plusSeconds(901), clock.instant().plusSeconds(1000), token) + .orElseThrow()); + } + return claimed; } NotificationEmailDispatcher dispatcher(NotificationEmailSender sender) { return new NotificationEmailDispatcher(repository, policy, recipients, preferences, eligibility, sender, @@ -90,7 +112,7 @@ class NotificationEmailDeliveryPostgresTest { for (int i = 0; i < 3; i++) enqueue(command("user", "r" + i, NotificationCategory.REVIEW)); enqueue(command("user", "r0", NotificationCategory.REVIEW)); assertThat(repository.count(null)).isEqualTo(1); - assertThat(repository.items(batch().id())).hasSize(15); + assertThat(repository.itemPage(batch().id(), 0, 100)).hasSize(15); assertThat(jdbc.queryForObject("SELECT count(*) FROM information_schema.columns WHERE " + "table_name='notification_email_item' AND column_name LIKE '%body%'", Long.class)) @@ -113,7 +135,7 @@ class NotificationEmailDeliveryPostgresTest { clock.instant().minus(Duration.ofDays(7)).minusMillis(1), null, "SKILL", 1L)); enqueue(new NotificationEmailCommand( "u", cmd.category(), cmd.eventType(), "future", clock.instant().plusSeconds(61), null, "SKILL", 1L)); - assertThat(repository.items(batch().id())).hasSize(1); + assertThat(repository.itemPage(batch().id(), 0, 100)).hasSize(1); } @Test @@ -122,7 +144,7 @@ class NotificationEmailDeliveryPostgresTest { var closed = claim("a"); enqueue(command("u", "two", NotificationCategory.REVIEW)); assertThat(repository.count(null)).isEqualTo(2); - assertThat(repository.items(closed.id())).hasSize(1); + assertThat(repository.itemPage(closed.id(), 0, 100)).hasSize(1); } @Test @@ -174,7 +196,8 @@ class NotificationEmailDeliveryPostgresTest { NotificationEmailSender sender = (recipient, locale, id, subject, text) -> { assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); assertThat(text) - .contains("PUBLISH: 27", "REVIEW: 28", "/dashboard/notifications/email/" + id) + .contains("Original notification records: 55", "Publishing (preview)", "Reviews (preview)", + "/dashboard/notifications/email/" + id) .doesNotContain("PRIVATE", "reason", "skillId", "same@example.test"); assertThat(text.lines().filter(line -> line.contains(" · ")).count()).isEqualTo(50); // A separate transaction obtains the row lock while SMTP is still executing. @@ -245,7 +268,11 @@ class NotificationEmailDeliveryPostgresTest { sent.add(e); return NotificationEmailSender.Result.ACCEPTED; }).process(claim("x")); - assertThat(sent).singleElement().asString().contains("REVIEW: 1").doesNotContain("PUBLISH:"); + assertThat(sent) + .singleElement() + .asString() + .contains("Reviews (preview)") + .doesNotContain("Publishing (preview)"); } @Test @@ -268,14 +295,17 @@ class NotificationEmailDeliveryPostgresTest { } @Test - void retryExhaustionStopsAtConfiguredLimit() { + void retryWindowExpiresWithoutAnotherSmtpAttempt() { enqueue(command("u", "one", NotificationCategory.PUBLISH)); var claimed = claim("last"); - jdbc.update("UPDATE notification_email_delivery SET attempts=4 WHERE id=?", claimed.id()); - dispatcher((a, b, c, d, e) -> NotificationEmailSender.Result.TEMPORARY_FAILURE).process(claimed); - assertThat(batch().status()).isEqualTo("FAILED"); - assertThat(batch().attempts()).isEqualTo(5); - assertThat(batch().resultCode()).isEqualTo("RETRY_EXHAUSTED"); + jdbc.update("UPDATE notification_email_delivery SET retry_started_at=?,retry_deadline=? WHERE id=?", + java.sql.Timestamp.from(clock.instant().minusSeconds(86400)), java.sql.Timestamp.from(clock.instant()), + claimed.id()); + var sender = mock(NotificationEmailSender.class); + dispatcher(sender).process(repository.find(claimed.id()).orElseThrow()); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("FAILED"); + assertThat(repository.find(claimed.id()).orElseThrow().resultCode()).isEqualTo("RETRY_WINDOW_EXPIRED"); + verifyNoInteractions(sender); } @Test @@ -306,7 +336,7 @@ class NotificationEmailDeliveryPostgresTest { assertThat(sealing.get(10, TimeUnit.SECONDS)).isPresent(); adding.get(10, TimeUnit.SECONDS); } - assertThat(repository.items(id)).hasSize(1); + assertThat(repository.itemPage(id, 0, 100)).hasSize(1); assertThat(repository.count(null)).isEqualTo(2); assertThat(jdbc.queryForObject("SELECT count(*) FROM notification_email_item", Long.class)).isEqualTo(2); } @@ -392,4 +422,658 @@ class NotificationEmailDeliveryPostgresTest { } assertThat(batch().status()).isEqualTo("SENT"); } + private void backlog(String user, int windows, int perWindow) { + tx.executeWithoutResult(status -> { + jdbc.update("INSERT INTO notification_email_contact(user_id) VALUES(?) ON CONFLICT DO NOTHING", user); + for (int window = 0; window < windows; window++) { + var row = + repository.openBatch(user, "DIGEST", "backlog-" + window, 0, clock.instant(), clock.instant()); + for (int item = 0; item < perWindow; item++) + repository.addItem(row.id(), + command(user, "window-" + window + "-" + item, NotificationCategory.PUBLISH), 1L, + clock.instant()); + } + }); + } + + private NotificationEmailDelivery rawClaim(Instant now) { + return tx.execute( + status -> repository.claim(now, now.plusSeconds(120), UUID.randomUUID().toString()).orElseThrow()); + } + + private NotificationEmailDispatcher at(Instant now, NotificationEmailSender sender) { + return new NotificationEmailDispatcher(repository, policy, recipients, preferences, eligibility, sender, + new NotificationEmailDeliveryProperties(), Clock.fixed(now, ZoneOffset.UTC), manager); + } + + @Test + void thousandOverdueWindowsAreSealedInBoundedPagesAndSendOneRootWithoutMovingItems() { + backlog("u", 1000, 1); + var sender = mock(NotificationEmailSender.class); + when(sender.send(any(), any(), anyLong(), any(), any())).thenReturn(NotificationEmailSender.Result.ACCEPTED); + var worker = dispatcher(sender); + var first = rawClaim(clock.instant().plusSeconds(901)); + worker.process(first); + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM notification_email_delivery WHERE status='MERGED'", Long.class)) + .isEqualTo(100); + assertThat(repository.find(first.id()).orElseThrow().status()).isEqualTo("PREPARING"); + verifyNoInteractions(sender); + tx.executeWithoutResult(status + -> repository.cleanup(clock.instant().plusSeconds(9999999), clock.instant().plusSeconds(9999999))); + assertThat(repository.count(null)).isEqualTo(1000); + for (int page = 0; page < 15 && !"SENT".equals(repository.find(first.id()).orElseThrow().status()); page++) + worker.process(rawClaim(clock.instant().plusSeconds(901))); + var root = repository.find(first.id()).orElseThrow(); + assertThat(root.status()).isEqualTo("SENT"); + assertThat(root.itemCount()).isEqualTo(1000); + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM notification_email_delivery WHERE status='MERGED'", Long.class)) + .isEqualTo(999); + assertThat(jdbc.queryForObject("SELECT count(DISTINCT delivery_id) FROM notification_email_item", Long.class)) + .isEqualTo(1000); + assertThat(repository.itemPage(root.id(), 0, 21)).hasSize(21); + verify(sender, times(1)) + .send(any(), any(), eq(root.id()), any(), contains("Original notification records: 1000")); + } + + @Test + void oldClaimedSourceCannotAuthorizeAfterRootExistsEvenBeforeItsPageIsSealed() { + backlog("u", 150, 1); + var now = clock.instant().plusSeconds(901); + var first = rawClaim(now); + // Claim the last source to ensure the first merge page has not yet reached it. + var last = repository.list(null, 0, 1).getFirst(); + jdbc.update( + "UPDATE notification_email_delivery SET status='CLAIMED',lease_token='old',lease_until=? WHERE id=?", + java.sql.Timestamp.from(now.plusSeconds(120)), last.id()); + var legacy = repository.find(last.id()).orElseThrow(); + var sender = mock(NotificationEmailSender.class); + dispatcher(sender).process(first); + assertThat(repository.find(last.id()).orElseThrow().status()).isEqualTo("CLAIMED"); + dispatcher(sender).process(legacy); + verifyNoInteractions(sender); + assertThat(repository.find(last.id()).orElseThrow().status()).isEqualTo("CLAIMED"); + assertThat(repository.activeRoot("u")).isPresent(); + } + + @Test + void lateEligibleItemSurvivesBoundedEmptyPagesAndCancelledSourcesNeverReappear() { + backlog("u", 1, 350); + when(eligibility.eligible(any())).thenAnswer(inv -> ((NotificationEmailItem) inv.getArgument(0)).id() == 350); + var sender = mock(NotificationEmailSender.class); + var worker = dispatcher(sender); + var first = rawClaim(clock.instant().plusSeconds(901)); + worker.process(first); + var root = repository.find(first.id()).orElseThrow(); + assertThat(root.status()).isEqualTo("PREPARING"); + assertThat(root.itemCursor()).isEqualTo(100); + verifyNoInteractions(sender); + for (int page = 0; page < 3; page++) worker.process(rawClaim(clock.instant().plusSeconds(901))); + assertThat(repository.find(root.id()).orElseThrow().previewItemIds()).isEqualTo("350"); + tx.executeWithoutResult(status + -> new JdbcNotificationEmailCancellation(jdbc).cancelCategories( + "u", Set.of(NotificationCategory.PUBLISH))); + doReturn(true).when(eligibility).eligible(any()); + worker.process(rawClaim(clock.instant().plusSeconds(901))); + assertThat(repository.find(root.id()).orElseThrow().status()).isEqualTo("SUPPRESSED"); + verifyNoInteractions(sender); + } + + @Test + void temporaryOutageAddsLaterWindowsToSameRootWithoutExtendingFirstDeadline() { + backlog("u", 1, 1); + var first = claim("initial"); + dispatcher((a, b, c, d, e) -> NotificationEmailSender.Result.TEMPORARY_FAILURE).process(first); + var failed = repository.find(first.id()).orElseThrow(); + assertThat(failed.retryDeadline()).isEqualTo(clock.instant().plusSeconds(901 + 86400)); + tx.executeWithoutResult(status -> { + for (int hour = 1; hour <= 3; hour++) { + var row = repository.openBatch( + "u", "DIGEST", "later-" + hour, 0, clock.instant().plusSeconds(hour * 3600), clock.instant()); + repository.addItem( + row.id(), command("u", "later-" + hour, NotificationCategory.PUBLISH), 1L, clock.instant()); + } + }); + Instant recovered = clock.instant().plusSeconds(4 * 3600); + var sender = mock(NotificationEmailSender.class); + when(sender.send(any(), any(), anyLong(), any(), any())).thenReturn(NotificationEmailSender.Result.ACCEPTED); + var worker = at(recovered, sender); + worker.process(rawClaim(recovered)); + var root = repository.find(first.id()).orElseThrow(); + assertThat(root.id()).isEqualTo(first.id()); + assertThat(root.itemCount()).isEqualTo(4); + assertThat(root.retryDeadline()).isEqualTo(failed.retryDeadline()); + worker.process(rawClaim(recovered)); + verify(sender, times(1)) + .send(any(), any(), eq(first.id()), any(), contains("Original notification records: 4")); + assertThat(repository.count("SENT")).isEqualTo(1); + assertThat(repository.count("MERGED")).isEqualTo(3); + } + + @Test + void mergedSourcesAreCancelledAndCleanupRetainsWholeGroupUntilTerminalAndReplaySafe() { + backlog("u", 4, 1); + var first = rawClaim(clock.instant().plusSeconds(901)); + var worker = dispatcher(mock(NotificationEmailSender.class)); + worker.process(first); + assertThat(repository.count("MERGED")).isEqualTo(3); + tx.executeWithoutResult(status + -> new JdbcNotificationEmailCancellation(jdbc).cancelCategories( + "u", Set.of(NotificationCategory.PUBLISH))); + tx.executeWithoutResult(status -> repository.drainCancelledItems()); + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM notification_email_item WHERE status='SUPPRESSED'", Long.class)) + .isEqualTo(4); + worker.process(rawClaim(clock.instant().plusSeconds(901))); + assertThat(repository.find(first.id()).orElseThrow().status()).isEqualTo("SUPPRESSED"); + // A recent source keeps the root and all event deduplication facts, despite its old creation time. + jdbc.update("UPDATE notification_email_item SET enqueued_at=? WHERE id=4", + java.sql.Timestamp.from(clock.instant().plusSeconds(1000))); + tx.executeWithoutResult( + status -> repository.cleanup(clock.instant().plusSeconds(902), clock.instant().plusSeconds(902))); + assertThat(repository.count(null)).isEqualTo(4); + tx.executeWithoutResult( + status -> repository.cleanup(clock.instant().plusSeconds(1001), clock.instant().plusSeconds(1001))); + assertThat(repository.count(null)).isZero(); + } + + @Test + void knownTemporarySourceContributesEarliestDeadlineAndExpiredSourceDoesNotRevive() { + backlog("u", 3, 1); + Instant now = clock.instant().plusSeconds(901); + jdbc.update("UPDATE notification_email_delivery SET retry_started_at=?,retry_deadline=? WHERE id=2", + java.sql.Timestamp.from(now.minusSeconds(100)), java.sql.Timestamp.from(now.plusSeconds(100))); + jdbc.update("UPDATE notification_email_delivery SET retry_started_at=?,retry_deadline=? WHERE id=3", + java.sql.Timestamp.from(now.minusSeconds(86500)), java.sql.Timestamp.from(now.minusSeconds(1))); + var first = rawClaim(now); + dispatcher(mock(NotificationEmailSender.class)).process(first); + var root = repository.find(first.id()).orElseThrow(); + assertThat(root.retryDeadline()).isEqualTo(now.plusSeconds(100)); + assertThat(root.itemCount()).isEqualTo(2); + assertThat(repository.find(3).orElseThrow().status()).isEqualTo("FAILED"); + assertThat(repository.find(3).orElseThrow().mergedIntoDeliveryId()).isNull(); + } + + @Test + void immediateAndDigestRoundsAlternateAndOneUserCannotConsumeWholeRound() { + backlog("u", 1, 1); + tx.executeWithoutResult(status -> { + for (int item = 0; item < 10; item++) { + var row = + repository.openBatch("u", "IMMEDIATE", "instant-" + item, 0, clock.instant(), clock.instant()); + repository.addItem( + row.id(), command("u", "instant-" + item, NotificationCategory.PUBLISH), 1L, clock.instant()); + } + }); + var sender = mock(NotificationEmailSender.class); + when(sender.send(any(), any(), anyLong(), any(), any())).thenReturn(NotificationEmailSender.Result.ACCEPTED); + var worker = dispatcher(sender); + worker.dispatch(); // digest preparation, one user work item only + assertThat(repository.count("SENT")).isZero(); + worker.dispatch(); // immediate turn + assertThat(repository.count("SENT")).isEqualTo(1); + worker.dispatch(); // prepared digest turn, despite more immediate work + assertThat(repository.find(1).orElseThrow().status()).isEqualTo("SENT"); + verify(sender, times(2)).send(any(), any(), anyLong(), any(), any()); + } + + @Test + void delayedEventsFromDisabledIntervalAreRejectedButExactReenableBoundaryIsAccepted() { + Instant enabled = clock.instant().minusSeconds(60); + when(admission.currentUnderLocks(eq("u"), any())).thenReturn(Optional.of(enabled)); + enqueue(new NotificationEmailCommand("u", NotificationCategory.PUBLISH, "SKILL_PUBLISHED", "disabled", + enabled.minusSeconds(1), null, "SKILL", 1L)); + assertThat(repository.count(null)).isZero(); + enqueue(new NotificationEmailCommand( + "u", NotificationCategory.PUBLISH, "SKILL_PUBLISHED", "boundary", enabled, null, "SKILL", 1L)); + assertThat(repository.count(null)).isEqualTo(1); + // Watermark changes only affect new admission, not existing SMTP recovery work. + var claimed = claim("admitted"); + when(admission.currentUnderLocks(eq("u"), any())).thenReturn(Optional.of(clock.instant().plusSeconds(1000))); + dispatcher((a, b, c, d, e) -> NotificationEmailSender.Result.ACCEPTED).process(claimed); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("SENT"); + } + + @Test + void contactGateSerializesTwoPreviouslyClaimedWindowsIntoOnlyOneRoot() throws Exception { + backlog("u", 2, 1); + Instant now = clock.instant().plusSeconds(901); + var one = rawClaim(now); + var two = rawClaim(now); + CountDownLatch start = new CountDownLatch(1); + var sender = mock(NotificationEmailSender.class); + try (var pool = Executors.newFixedThreadPool(2)) { + var a = pool.submit(() -> { + start.await(); + dispatcher(sender).process(one); + return null; + }); + var b = pool.submit(() -> { + start.await(); + dispatcher(sender).process(two); + return null; + }); + start.countDown(); + a.get(10, TimeUnit.SECONDS); + b.get(10, TimeUnit.SECONDS); + } + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM notification_email_delivery WHERE is_recovery_root", Long.class)) + .isEqualTo(1); + var root = repository.activeRoot("u").orElseThrow(); + assertThat(root.itemCount()).isEqualTo(2); + assertThat(repository.count("MERGED")).isEqualTo(1); + verifyNoInteractions(sender); + dispatcher((a, b, c, d, e) -> NotificationEmailSender.Result.ACCEPTED).process(rawClaim(now)); + assertThat(repository.find(root.id()).orElseThrow().status()).isEqualTo("SENT"); + } + + @Test + void manualRetryResetsWindowOnceAndOldMergedDeadlineDoesNotFlowBack() { + backlog("u", 2, 1); + var claimed = claim("one"); + var root = repository.find(claimed.id()).orElseThrow(); + Instant previous = clock.instant().plusSeconds(1000); + jdbc.update("UPDATE notification_email_delivery SET retry_started_at=?,retry_deadline=? WHERE id<>?", + java.sql.Timestamp.from(previous.minusSeconds(86400)), java.sql.Timestamp.from(previous), root.id()); + dispatcher((a, b, c, d, e) -> NotificationEmailSender.Result.PERMANENT_FAILURE).process(claimed); + jdbc.update("UPDATE notification_email_delivery SET retry_started_at=?,retry_deadline=? WHERE id=?", + java.sql.Timestamp.from(previous.minusSeconds(86400)), java.sql.Timestamp.from(previous), root.id()); + var audit = mock(com.iflytek.skillhub.domain.audit.AuditLogService.class); + var query = + new NotificationEmailDeliveryQueryService(repository, eligibility, audit, clock, policy, recipients); + tx.executeWithoutResult(status -> query.retry(root.id(), "admin", false)); + assertThat(repository.find(root.id()).orElseThrow().retryDeadline()).isNull(); + assertThatThrownBy(() -> tx.executeWithoutResult(status -> query.retry(root.id(), "admin", false))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class); + var restored = claim("manual"); + assertThat(repository.find(root.id()).orElseThrow().retryDeadline()).isNull(); + assertThat(repository.find(root.id()).orElseThrow().itemCount()).isEqualTo(2); + dispatcher((a, b, c, d, e) -> NotificationEmailSender.Result.TEMPORARY_FAILURE).process(restored); + assertThat(repository.find(root.id()).orElseThrow().retryDeadline()) + .isEqualTo(clock.instant().plusSeconds(901 + 86400)); + verify(audit).record(eq("admin"), eq("NOTIFICATION_EMAIL_RETRY"), eq("NOTIFICATION_EMAIL_DELIVERY"), + eq(root.id()), isNull(), isNull(), isNull(), contains("recoveryWindowReset")); + } + + @Test + void terminalRetentionStartsAfterSuccessfulRecoveryRatherThanOldBatchCreation() { + backlog("u", 1, 1); + var claimed = claim("old"); + dispatcher((a, b, c, d, e) -> NotificationEmailSender.Result.ACCEPTED).process(claimed); + jdbc.update("UPDATE notification_email_delivery SET created_at=? WHERE id=?", + java.sql.Timestamp.from(clock.instant().minus(Duration.ofDays(40))), claimed.id()); + jdbc.update("UPDATE notification_email_item SET enqueued_at=?", + java.sql.Timestamp.from(clock.instant().minus(Duration.ofDays(40)))); + tx.executeWithoutResult(status -> repository.cleanup(clock.instant(), clock.instant())); + assertThat(repository.find(claimed.id())).isPresent(); + tx.executeWithoutResult( + status -> repository.cleanup(clock.instant().plusSeconds(902), clock.instant().plusSeconds(902))); + assertThat(repository.find(claimed.id())).isEmpty(); + } + @Test + void recoveryDeadlineOnlyBlocksNewAuthorizationNotAcceptedInFlightSmtp() { + backlog("u", 1, 1); + var claimed = claim("deadline"); + var time = new java.util.concurrent.atomic.AtomicReference<>(clock.instant().plusSeconds(901)); + Clock mutable = new Clock() { + public ZoneId getZone() { + return ZoneOffset.UTC; + } + public Clock withZone(ZoneId zone) { + return this; + } + public Instant instant() { + return time.get(); + } + }; + jdbc.update("UPDATE notification_email_delivery SET retry_started_at=?,retry_deadline=? WHERE id=?", + java.sql.Timestamp.from(time.get().minusSeconds(86399)), + java.sql.Timestamp.from(time.get().plusSeconds(1)), claimed.id()); + var worker = new NotificationEmailDispatcher( + repository, policy, recipients, preferences, eligibility, (a, b, c, d, e) -> { + time.set(time.get().plusSeconds(2)); + return NotificationEmailSender.Result.ACCEPTED; + }, new NotificationEmailDeliveryProperties(), mutable, manager); + worker.process(repository.find(claimed.id()).orElseThrow()); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("SENT"); + } + + @Test + void chinesePreviewUsesReadableCategorySectionsWithoutExposingBody() { + enqueue(command("u", "publish", NotificationCategory.PUBLISH)); + enqueue(command("u", "review", NotificationCategory.REVIEW)); + var claimed = claim("localized"); + when(recipients.lockCurrent("u")) + .thenReturn(Optional.of(new NotificationEmailRecipient("u", "same@example.test", 0, "zh"))); + var sent = new ArrayList(); + dispatcher((a, b, c, d, e) -> { + sent.add(e); + return NotificationEmailSender.Result.ACCEPTED; + }).process(claimed); + assertThat(sent) + .singleElement() + .asString() + .contains("发布动态(预览)", "审核进度(预览)", "原始通知记录数:2") + .doesNotContain("PUBLISH", "REVIEW", "PRIVATE", "reason", "skillId"); + } + + @Test + void leaseRecoveryIsBoundedAndResumesOnNextRound() { + backlog("u", 150, 1); + jdbc.update("UPDATE notification_email_delivery SET status='CLAIMED',lease_token='dead',lease_until=?", + java.sql.Timestamp.from(clock.instant().minusSeconds(1))); + tx.executeWithoutResult(status -> repository.recoverExpired(clock.instant())); + assertThat(repository.count("RETRY")).isEqualTo(100); + assertThat(repository.count("CLAIMED")).isEqualTo(50); + tx.executeWithoutResult(status -> repository.recoverExpired(clock.instant())); + assertThat(repository.count("RETRY")).isEqualTo(150); + } + + enum ClosingScope { GLOBAL, CATEGORY, ACCOUNT } + + static java.util.stream.Stream closingOutcomes() { + return Arrays.stream(ClosingScope.values()) + .flatMap(scope + -> java.util.stream.Stream + .of(NotificationEmailSender.Result.ACCEPTED, + NotificationEmailSender.Result.TEMPORARY_FAILURE, + NotificationEmailSender.Result.UNKNOWN) + .map(result -> org.junit.jupiter.params.provider.Arguments.of(scope, result))); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.MethodSource("closingOutcomes") + void closingDuringSendingPreservesOutcomeButCannotReviveCancelledSourceOnRetry( + ClosingScope scope, NotificationEmailSender.Result result) { + enqueue(command("u", "publish", NotificationCategory.PUBLISH)); + tx.executeWithoutResult(status -> { + var source = repository.openBatch("u", "DIGEST", "review-source", 0, clock.instant(), clock.instant()); + repository.addItem(source.id(), command("u", "review", NotificationCategory.REVIEW), 1L, clock.instant()); + }); + var claimed = claim("inflight"); + assertThat(repository.count("MERGED")).isEqualTo(1); + var attempted = new java.util.concurrent.atomic.AtomicInteger(); + NotificationEmailSender sender = (a, b, c, d, e) -> { + attempted.incrementAndGet(); + tx.executeWithoutResult(status -> { + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("SENDING"); + var cancellation = new JdbcNotificationEmailCancellation(jdbc); + if (scope == ClosingScope.GLOBAL) + cancellation.cancelAll(); + else + cancellation.cancelCategories("u", + scope == ClosingScope.CATEGORY ? Set.of(NotificationCategory.PUBLISH) + : Set.of(NotificationCategory.values())); + // Current payload/result remains intact even after sweep catches up; re-enable does not clear barriers. + repository.drainCancelledItems(); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("SENDING"); + assertThat(repository.itemPage(claimed.id(), 0, 100)).allMatch(item -> "ACTIVE".equals(item.status())); + }); + return result; + }; + dispatcher(sender).process(claimed); + assertThat(attempted).hasValue(1); + String expected = switch (result) { + case ACCEPTED -> "SENT"; + case UNKNOWN -> "UNKNOWN"; + default -> "RETRY"; + }; + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo(expected); + if (result != NotificationEmailSender.Result.TEMPORARY_FAILURE) { + Optional replay = tx.execute(status + -> repository.claim( + clock.instant().plusSeconds(1200), clock.instant().plusSeconds(1300), "no-replay")); + assertThat(replay).isEmpty(); + return; + } + Instant recovered = clock.instant().plusSeconds(1200); + var retries = new ArrayList(); + var worker = at(recovered, (a, b, c, d, e) -> { + retries.add(e); + return NotificationEmailSender.Result.ACCEPTED; + }); + worker.process(rawClaim(recovered)); + if (scope == ClosingScope.CATEGORY) { + worker.process(rawClaim(recovered)); + assertThat(retries) + .singleElement() + .asString() + .contains("Reviews (preview)") + .doesNotContain("Publishing (preview)"); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("SENT"); + } else { + assertThat(retries).isEmpty(); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("SUPPRESSED"); + } + } + + @Test + void closingWritesOnlyBarrierAndSweepNeverLoadsMoreThanHundredItems() { + backlog("u", 1, 1000); + tx.executeWithoutResult(status -> new JdbcNotificationEmailCancellation(jdbc).cancelAll()); + assertThat( + jdbc.queryForObject("SELECT count(*) FROM notification_email_item WHERE status='ACTIVE'", Long.class)) + .isEqualTo(1000); + assertThat(repository.count("PENDING")).isEqualTo(1); + assertThat(jdbc.queryForObject( + "SELECT through_item_id FROM notification_email_cancel_barrier WHERE scope_key='GLOBAL'", + Long.class)) + .isEqualTo(1000); + Integer scanned = tx.execute(status -> repository.drainCancelledItems()); + assertThat(scanned).isEqualTo(100); + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM notification_email_item WHERE status='SUPPRESSED'", Long.class)) + .isEqualTo(100); + assertThat( + jdbc.queryForObject( + "SELECT cursor_id FROM notification_email_cancel_barrier WHERE scope_key='GLOBAL'", Long.class)) + .isEqualTo(100); + // Even before sweep reaches the tail, final authorization reads the barrier directly. + assertThat(repository.cancelledItemIds(List.of(1000L))).containsExactly(1000L); + } + @Test + void reenabledNewInboxItemJoinsSameRetryRootWithoutRevivingOldItemsAndSentHistorySurvivesLaterClose() { + enqueue(command("u", "before-close", NotificationCategory.PUBLISH)); + var claimed = claim("first-attempt"); + dispatcher((a, b, c, d, e) -> { + tx.executeWithoutResult(status -> { + new JdbcNotificationEmailCancellation(jdbc).cancelAll(); + repository.drainCancelledItems(); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("SENDING"); + }); + return NotificationEmailSender.Result.TEMPORARY_FAILURE; + }).process(claimed); + Instant reopened = clock.instant().plusSeconds(910); + Clock reopenedClock = Clock.fixed(reopened, ZoneOffset.UTC); + var newInbox = new NotificationEmailInboxService( + policy, recipients, preferences, repository, eligibility, new ObjectMapper(), reopenedClock, admission); + tx.executeWithoutResult(status + -> newInbox.enqueue(new NotificationEmailCommand("u", NotificationCategory.PUBLISH, "SKILL_PUBLISHED", + "after-reenable", reopened, null, "SKILL", 1L))); + assertThat(repository.count(null)).isEqualTo(2); + Instant recovered = clock.instant().plusSeconds(1901); + var sent = new ArrayList(); + var worker = at(recovered, (a, b, c, d, e) -> { + sent.add(e); + return NotificationEmailSender.Result.ACCEPTED; + }); + worker.process(rawClaim(recovered)); + worker.process(rawClaim(recovered)); + var root = repository.find(claimed.id()).orElseThrow(); + assertThat(root.status()).isEqualTo("SENT"); + assertThat(root.itemCount()).isEqualTo(2); + assertThat(repository.count("MERGED")).isEqualTo(1); + assertThat(sent).singleElement().asString().contains("Original notification records: 2"); + assertThat(sent.getFirst().lines().filter(line -> line.contains(" · ")).count()).isEqualTo(1); + var items = repository.itemPage(root.id(), 0, 100); + assertThat(items) + .filteredOn(item -> item.eventKey().equals("before-close")) + .singleElement() + .extracting(NotificationEmailItem::status) + .isEqualTo("SUPPRESSED"); + assertThat(items) + .filteredOn(item -> item.eventKey().equals("after-reenable")) + .singleElement() + .extracting(NotificationEmailItem::status) + .isEqualTo("ACTIVE"); + tx.executeWithoutResult(status -> { + new JdbcNotificationEmailCancellation(jdbc).cancelAll(); + repository.drainCancelledItems(); + }); + // Completed sent history is not rewritten by future switches; barriers restrict future authorization only. + assertThat(repository.find(root.id()).orElseThrow().status()).isEqualTo("SENT"); + assertThat(repository.itemPage(root.id(), 0, 100)) + .filteredOn(item -> item.eventKey().equals("after-reenable")) + .singleElement() + .extracting(NotificationEmailItem::status) + .isEqualTo("ACTIVE"); + } + private NotificationEmailDeliveryQueryService query(Clock queryClock) { + return new NotificationEmailDeliveryQueryService(repository, eligibility, + mock(com.iflytek.skillhub.domain.audit.AuditLogService.class), queryClock, policy, recipients); + } + + @Test + void closingLargeRecoveryRootTerminatesOnlyBatchThenSweepsHundredAndHidesUnscannedDetails() { + backlog("u", 1000, 2); + var claimed = claim("large-root"); + assertThat(repository.count("MERGED")).isEqualTo(999); + tx.executeWithoutResult(status -> new JdbcNotificationEmailCancellation(jdbc).cancelAll()); + when(policy.lockCurrent()).thenReturn(new NotificationEmailPolicy(false, 15, "https://example.test", 1)); + var sender = mock(NotificationEmailSender.class); + dispatcher(sender).process(claimed); + var root = repository.find(claimed.id()).orElseThrow(); + assertThat(root.status()).isEqualTo("SUPPRESSED"); + assertThat(root.itemCount()).isEqualTo(2000); + assertThat( + jdbc.queryForObject("SELECT count(*) FROM notification_email_item WHERE status='ACTIVE'", Long.class)) + .isEqualTo(2000); + var detail = query(clock).detail(root.id(), "u", null, 100); + assertThat(detail.items()).isEmpty(); + assertThat(detail.unavailableCount()).isEqualTo(100); + assertThat(detail.nextCursor()).isNotNull(); + var source = repository.list("MERGED", 0, 1).getFirst(); + assertThat(query(clock).detail(source.id(), "u", null, 100).items()).isEmpty(); + Integer scanned = tx.execute(status -> repository.drainCancelledItems()); + assertThat(scanned).isEqualTo(100); + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM notification_email_item WHERE status='SUPPRESSED'", Long.class)) + .isEqualTo(100); + assertThat( + jdbc.queryForObject("SELECT count(*) FROM notification_email_item WHERE status='ACTIVE'", Long.class)) + .isEqualTo(1900); + verifyNoInteractions(sender); + } + + @Test + void nonSwitchSuppressionIsLogicalImmediatelyAndUsesSeparateBoundedPhysicalCursor() { + backlog("u", 10, 150); + var claimed = claim("recipient-changed"); + tx.executeWithoutResult(status -> repository.suppress(claimed.id(), "stale-token", "INVALID", clock.instant())); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("CLAIMED"); + assertThat( + jdbc.queryForObject("SELECT count(*) FROM notification_email_item WHERE status='ACTIVE'", Long.class)) + .isEqualTo(1500); + when(recipients.lockCurrent("u")) + .thenReturn(Optional.of(new NotificationEmailRecipient("u", "new@example.test", 1, "en"))); + dispatcher(mock(NotificationEmailSender.class)).process(claimed); + var root = repository.find(claimed.id()).orElseThrow(); + assertThat(root.status()).isEqualTo("SUPPRESSED"); + assertThat(root.itemCursor()).isZero(); + assertThat(root.itemsComplete()).isFalse(); + assertThat( + jdbc.queryForObject("SELECT count(*) FROM notification_email_item WHERE status='ACTIVE'", Long.class)) + .isEqualTo(1500); + assertThat(query(clock).detail(root.id(), "u", null, 100).items()).isEmpty(); + var source = repository.list("MERGED", 0, 1).getFirst(); + assertThat(query(clock).detail(source.id(), "u", null, 100).items()).isEmpty(); + Integer scanned = tx.execute(status -> repository.drainCancelledItems()); + assertThat(scanned).isEqualTo(100); + assertThat(repository.find(root.id()).orElseThrow().itemCursor()).isEqualTo(100); + assertThat(repository.find(root.id()).orElseThrow().itemsComplete()).isFalse(); + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM notification_email_item WHERE status='SUPPRESSED'", Long.class)) + .isEqualTo(100); + assertThat( + jdbc.queryForObject("SELECT count(*) FROM notification_email_item WHERE status='ACTIVE'", Long.class)) + .isEqualTo(1400); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"FAILED,false", "FAILED,true", "UNKNOWN,false", "UNKNOWN,true"}) + void manualRetryCannotBypassCancellationBeforeSweepButNewLegalItemStillWorks(String outcome, boolean withNewItem) { + backlog("u", 2, 1); + var claimed = claim("original"); + dispatcher((a, b, c, d, e) + -> "UNKNOWN".equals(outcome) ? NotificationEmailSender.Result.UNKNOWN + : NotificationEmailSender.Result.PERMANENT_FAILURE) + .process(claimed); + tx.executeWithoutResult(status -> new JdbcNotificationEmailCancellation(jdbc).cancelAll()); + assertThat( + jdbc.queryForObject("SELECT count(*) FROM notification_email_item WHERE status='ACTIVE'", Long.class)) + .isEqualTo(2); + var before = query(clock).detail(claimed.id(), "u", null, 100); + assertThat(before.items()).isEmpty(); + assertThat(before.unavailableCount()).isEqualTo(2); + Instant reopened = clock.instant().plusSeconds(910); + Clock later = Clock.fixed(reopened, ZoneOffset.UTC); + var service = query(later); + tx.executeWithoutResult(status -> service.retry(claimed.id(), "admin", true)); + assertThat(query(later).detail(claimed.id(), "u", null, 100).items()).isEmpty(); + if (withNewItem) { + var newInbox = new NotificationEmailInboxService( + policy, recipients, preferences, repository, eligibility, new ObjectMapper(), later, admission); + tx.executeWithoutResult(status + -> newInbox.enqueue(new NotificationEmailCommand("u", NotificationCategory.PUBLISH, + "SKILL_PUBLISHED", "new-after-manual", reopened, null, "SKILL", 1L))); + } + Instant recovered = clock.instant().plusSeconds(1901); + var mail = new ArrayList(); + var worker = at(recovered, (a, b, c, d, e) -> { + mail.add(e); + return NotificationEmailSender.Result.ACCEPTED; + }); + worker.process(rawClaim(recovered)); + if (withNewItem) { + worker.process(rawClaim(recovered)); + assertThat(mail) + .singleElement() + .asString() + .contains(reopened.toString()) + .doesNotContain(clock.instant().toString()); + assertThat(mail.getFirst().lines().filter(line -> line.contains(" · ")).count()).isEqualTo(1); + assertThat(repository.find(claimed.id()).orElseThrow().itemCount()).isEqualTo(3); + assertThat(query(later).detail(claimed.id(), "u", null, 100).items()).hasSize(1); + } else { + assertThat(mail).isEmpty(); + assertThat(repository.find(claimed.id()).orElseThrow().status()).isEqualTo("SUPPRESSED"); + assertThat(query(later).detail(claimed.id(), "u", null, 100).items()).isEmpty(); + } + } + @Test + void realSpringSmtpConnectionRefusalStartsPersistentTwentyFourHourRecovery() throws Exception { + enqueue(command("u", "real-smtp-refusal", NotificationCategory.PUBLISH)); + var claimed = claim("real-connection"); + try (var reserved = new java.net.Socket()) { + reserved.bind(new java.net.InetSocketAddress("127.0.0.1", 0)); + var mail = new org.springframework.mail.javamail.JavaMailSenderImpl(); + mail.setHost("127.0.0.1"); + mail.setPort(reserved.getLocalPort()); + mail.getJavaMailProperties().setProperty("mail.smtp.connectiontimeout", "300"); + mail.getJavaMailProperties().setProperty("mail.smtp.timeout", "300"); + var beans = new org.springframework.beans.factory.support.StaticListableBeanFactory(); + beans.addBean("mailSender", mail); + var realSender = new SmtpNotificationEmailSender( + beans.getBeanProvider(org.springframework.mail.javamail.JavaMailSender.class), + "notify@example.test", "SkillHub"); + dispatcher(realSender).process(claimed); + } + var root = repository.find(claimed.id()).orElseThrow(); + assertThat(root.status()).isEqualTo("RETRY"); + assertThat(root.resultCode()).isEqualTo("TEMPORARY_FAILURE"); + assertThat(root.retryStartedAt()).isEqualTo(clock.instant().plusSeconds(901)); + assertThat(root.retryDeadline()).isEqualTo(clock.instant().plusSeconds(901 + 86400)); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryQueryServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryQueryServiceTest.java index 74a312777..b88292df4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryQueryServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailDeliveryQueryServiceTest.java @@ -11,12 +11,18 @@ import java.time.*; import java.util.*; import org.junit.jupiter.api.Test; class NotificationEmailDeliveryQueryServiceTest { + @org.junit.jupiter.api.BeforeEach + void gate() { + when(repository.lockRecipient(anyString())).thenReturn(true); + } NotificationEmailDeliveryRepository repository = mock(NotificationEmailDeliveryRepository.class); NotificationEmailEligibility eligibility = mock(NotificationEmailEligibility.class); AuditLogService audit = mock(AuditLogService.class); Instant now = Instant.parse("2026-10-22T00:00:00Z"); NotificationEmailDeliveryQueryService service = - new NotificationEmailDeliveryQueryService(repository, eligibility, audit, Clock.fixed(now, ZoneOffset.UTC)); + new NotificationEmailDeliveryQueryService(repository, eligibility, audit, Clock.fixed(now, ZoneOffset.UTC), + mock(com.iflytek.skillhub.notification.email.NotificationEmailPolicyProvider.class), + mock(com.iflytek.skillhub.notification.email.NotificationEmailRecipientProvider.class)); NotificationEmailDelivery batch(String status) { return new NotificationEmailDelivery( 11, "owner", "DIGEST", "window", 0, status, 1, now, null, null, null, now, null); @@ -25,9 +31,9 @@ class NotificationEmailDeliveryQueryServiceTest { @Test void anotherAccountAndCleanedBatchCannotReadDetails() { when(repository.find(11)).thenReturn(Optional.of(batch("SENT"))); - assertThatThrownBy(() -> service.detail(11, "other", 0, 20)).isInstanceOf(DomainNotFoundException.class); - verify(repository, never()).items(anyLong()); - assertThatThrownBy(() -> service.detail(12, "owner", 0, 20)).isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.detail(11, "other", null, 20)).isInstanceOf(DomainNotFoundException.class); + verify(repository, never()).itemPage(anyLong(),anyLong(),anyInt()); + assertThatThrownBy(() -> service.detail(12, "owner", null, 20)).isInstanceOf(DomainNotFoundException.class); } @Test @@ -37,19 +43,20 @@ class NotificationEmailDeliveryQueryServiceTest { for (int i = 1; i <= 55; i++) items.add(new NotificationEmailItem(i, 11, "owner", "e" + i, NotificationCategory.PUBLISH, "SKILL_PUBLISHED", 1L, 1L, now, now, "ACTIVE")); - when(repository.items(11)).thenReturn(items); + when(repository.itemPage(11, 40, 21)).thenReturn(items.subList(40, 55)); when(eligibility.eligible(any())).thenAnswer(inv -> ((NotificationEmailItem) inv.getArgument(0)).id() != 55); when(eligibility.targetPath(any())).thenReturn("/dashboard/notifications"); - var details = service.detail(11, "owner", 2, 20); - assertThat(details.totalItems()).isEqualTo(54); - assertThat(details.unavailableItems()).isEqualTo(1); + var details = service.detail(11, "owner", "40", 20); + assertThat(details.totalItems()).isZero(); // fixture has no stored raw count + assertThat(details.unavailableCount()).isEqualTo(1); assertThat(details.items()).hasSize(14); - assertThat(details.totalPages()).isEqualTo(3); + assertThat(details.nextCursor()).isNull(); assertThat(details.items().getFirst().id()).isEqualTo(41); } @Test void unknownRequiresExplicitDuplicateRiskAndRetryIsAuditedWithoutEmail() { + when(repository.find(11)).thenReturn(Optional.of(batch("UNKNOWN"))); when(repository.lock(11)).thenReturn(batch("UNKNOWN")); assertThatThrownBy(() -> service.retry(11, "admin", false)).isInstanceOf(DomainBadRequestException.class); verify(repository, never()).retry(anyLong(), any()); @@ -57,7 +64,8 @@ class NotificationEmailDeliveryQueryServiceTest { verify(repository).retry(11, now); verify(audit).record(eq("admin"), eq("NOTIFICATION_EMAIL_RETRY"), eq("NOTIFICATION_EMAIL_DELIVERY"), eq(11L), isNull(), isNull(), isNull(), - eq("{\"previousStatus\":\"UNKNOWN\",\"duplicateRiskAcknowledged\":true}")); + eq("{\"previousStatus\":\"UNKNOWN\",\"duplicateRiskAcknowledged\":true,\"previousDeadline\":null," + + "\"recoveryWindowReset\":true}")); when(repository.lock(11)).thenReturn(batch("SENT")); assertThatThrownBy(() -> service.retry(11, "admin", true)).isInstanceOf(DomainBadRequestException.class); } @@ -69,16 +77,41 @@ class NotificationEmailDeliveryQueryServiceTest { "SKILL_PUBLISHED", 1L, 1L, now, now, "SUPPRESSED"); var sent = new NotificationEmailItem( 2, 11, "owner", "review", NotificationCategory.REVIEW, "REVIEW_APPROVED", 1L, 1L, now, now, "ACTIVE"); - when(repository.items(11)).thenReturn(List.of(cancelled, sent)); + when(repository.itemPage(11, 0, 21)).thenReturn(List.of(cancelled, sent)); // Both resources are currently accessible after re-enable; persisted suppression must still win. when(eligibility.eligible(any())).thenReturn(true); when(eligibility.targetPath(sent)).thenReturn("/dashboard/notifications"); - var details = service.detail(11, "owner", 0, 20); + var details = service.detail(11, "owner", null, 20); assertThat(details.items()) .extracting(com.iflytek.skillhub.dto.NotificationEmailDeliveryItemResponse::id) .containsExactly(2L); - assertThat(details.unavailableItems()).isEqualTo(1); - assertThat(details.totalItems()).isEqualTo(1); + assertThat(details.unavailableCount()).isEqualTo(1); + assertThat(details.totalItems()).isZero(); verify(eligibility, never()).eligible(cancelled); } + @Test + void inaccessibleRawPageStillOffersCursorAndDoesNotPretendVisibleTotal() { + when(repository.find(11)).thenReturn(Optional.of(batch("SENT"))); + var raw = new ArrayList(); + for (int id = 1; id <= 3; id++) + raw.add(new NotificationEmailItem(id, 11, "owner", "e" + id, NotificationCategory.PUBLISH, + "SKILL_PUBLISHED", 1L, 1L, now, now, "SUPPRESSED")); + when(repository.itemPage(11, 0, 3)).thenReturn(raw); + var details = service.detail(11, "owner", null, 2); + assertThat(details.items()).isEmpty(); + assertThat(details.unavailableCount()).isEqualTo(2); + assertThat(details.nextCursor()).isEqualTo("2"); + verifyNoInteractions(eligibility); + } + + @Test + void manualRetryCannotStartSecondRootForSameUser() { + when(repository.find(11)).thenReturn(Optional.of(batch("FAILED"))); + when(repository.activeRoot("owner")) + .thenReturn(Optional.of(new NotificationEmailDelivery(12, "owner", "DIGEST", "active", 0, "PREPARING", + 0, now, "t", now.plusSeconds(120), null, now, null))); + assertThatThrownBy(() -> service.retry(11, "admin", false)).isInstanceOf(DomainConflictException.class); + verify(repository, never()).retry(anyLong(), any()); + verifyNoInteractions(audit); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailSchedulingIsolationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailSchedulingIsolationTest.java index c0f934d3a..6ab887d0e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailSchedulingIsolationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/notificationemail/NotificationEmailSchedulingIsolationTest.java @@ -50,10 +50,11 @@ class NotificationEmailSchedulingIsolationTest { Instant now = Instant.parse("2026-10-22T00:00:00Z"); var batch = new NotificationEmailDelivery( 1, "user", "IMMEDIATE", "one", 0, "CLAIMED", 0, now, "token", now.plusSeconds(120), null, now, null); - when(repository.claim(any(), any(), any())).thenReturn(Optional.of(batch), Optional.empty()); + when(repository.claim(any(), any(), any(), any(), anySet())).thenReturn(Optional.of(batch), Optional.empty()); when(repository.lock(1)).thenReturn(batch); + when(repository.lockRecipient("user")).thenReturn(true); when(repository.authorize(eq(1L), eq("token"), any(), any())).thenReturn(true); - when(repository.items(1)) + when(repository.itemPage(1, 0, 50)) .thenReturn(List.of(new NotificationEmailItem(1, 1, "user", "event", NotificationCategory.REVIEW, "REVIEW_APPROVED", 1L, 1L, now, now, "ACTIVE"))); when(policy.lockCurrent()).thenReturn(new NotificationEmailPolicy(true, 15, "https://example.test", 0)); @@ -123,7 +124,7 @@ class NotificationEmailSchedulingIsolationTest { } assertThat(worker.getQueueSize()).isZero(); assertThat(worker.getActiveCount()).isEqualTo(2); - verify(repository, times(1)).claim(any(), any(), any()); + verify(repository, times(1)).claim(any(), any(), any(), any(), anySet()); verify(repository, times(1)).cleanup(any(), any()); try (var closer = Executors.newSingleThreadExecutor()) { var closed = closer.submit(context::close); @@ -135,7 +136,7 @@ class NotificationEmailSchedulingIsolationTest { assertThat(worker.getThreadPoolExecutor().isTerminated()).isTrue(); assertThat(authoringTimer.isShutdown()).isTrue(); assertThat(releaseTimedOut).isFalse(); - verify(repository, times(1)).claim(any(), any(), any()); + verify(repository, times(1)).claim(any(), any(), any(), any(), anySet()); } finally { release.countDown(); context.close(); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JdbcNotificationEmailCancellation.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JdbcNotificationEmailCancellation.java index 7c3085157..1fd283102 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JdbcNotificationEmailCancellation.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JdbcNotificationEmailCancellation.java @@ -5,40 +5,37 @@ import com.iflytek.skillhub.notification.email.delivery.NotificationEmailCancell import java.util.Set; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; + +/** Closing a switch records a constant-size barrier; background work suppresses old items in bounded pages. */ @Repository public class JdbcNotificationEmailCancellation implements NotificationEmailCancellation { private final JdbcTemplate jdbc; + public JdbcNotificationEmailCancellation(JdbcTemplate jdbc) { this.jdbc = jdbc; } @Override public void cancelAll() { - // Lock batches before item updates, matching inbox/sealing order. SENDING is irreversible. - jdbc.queryForList("SELECT id FROM notification_email_delivery WHERE status IN ('PENDING','RETRY','CLAIMED') " - + "ORDER BY id FOR UPDATE", - Long.class); - jdbc.update(""" - UPDATE notification_email_item SET status='SUPPRESSED' WHERE delivery_id IN - (SELECT id FROM notification_email_delivery WHERE status IN ('PENDING','RETRY','CLAIMED')) - """); - jdbc.update(""" - UPDATE notification_email_delivery SET status='SUPPRESSED',result_code='PLATFORM_DISABLED', - lease_token=NULL,lease_until=NULL WHERE status IN ('PENDING','RETRY','CLAIMED') - """); + barrier("GLOBAL", null, null); } @Override public void cancelCategories(String userId, Set categories) { - if (categories.isEmpty()) { - return; + for (var category : categories.stream().sorted().toList()) { + barrier("USER:" + userId + ":" + category.name(), userId, category.name()); } - jdbc.queryForList("SELECT id FROM notification_email_delivery WHERE recipient_id=? AND status IN " - + "('PENDING','RETRY','CLAIMED') ORDER BY id FOR UPDATE", - Long.class, userId); - for (var category : categories) jdbc.update(""" - UPDATE notification_email_item SET status='SUPPRESSED' WHERE recipient_id=? AND category=? AND delivery_id IN - (SELECT id FROM notification_email_delivery WHERE status IN ('PENDING','RETRY','CLAIMED')) - """, userId, category.name()); + } + + private void barrier(String key, String user, String category) { + // The caller holds policy/account/contact/preferences guards. Inbox allocates item IDs only after + // those guards, so a concurrent same-scope admission either commits before this cutoff or after it. + Long cutoff = jdbc.queryForObject("SELECT coalesce(max(id),0) FROM notification_email_item", Long.class); + jdbc.update(""" + INSERT INTO notification_email_cancel_barrier(scope_key,recipient_id,category,through_item_id,updated_at) + VALUES (?,?,?,?,CURRENT_TIMESTAMP) ON CONFLICT(scope_key) DO UPDATE + SET through_item_id=GREATEST(notification_email_cancel_barrier.through_item_id,EXCLUDED.through_item_id), + updated_at=EXCLUDED.updated_at + """, key, user, category, cutoff); } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JdbcNotificationEmailDeliveryRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JdbcNotificationEmailDeliveryRepository.java index ef15d4508..1224d0345 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JdbcNotificationEmailDeliveryRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JdbcNotificationEmailDeliveryRepository.java @@ -31,7 +31,11 @@ public class JdbcNotificationEmailDeliveryRepository implements NotificationEmai return new NotificationEmailDelivery(r.getLong("id"), r.getString("recipient_id"), r.getString("kind"), r.getString("window_key"), r.getLong("email_revision"), r.getString("status"), r.getInt("attempts"), instant(r, "due_at"), r.getString("lease_token"), instant(r, "lease_until"), r.getString("result_code"), - instant(r, "created_at"), instant(r, "accepted_at")); + instant(r, "created_at"), instant(r, "accepted_at"), r.getBoolean("is_recovery_root"), + (Long) r.getObject("merged_into_id"), instant(r, "recovery_cutoff_at"), + r.getLong("source_high_watermark"), r.getLong("source_cursor"), r.getBoolean("sources_complete"), + r.getLong("item_cursor"), r.getBoolean("items_complete"), r.getString("preview_item_ids"), + r.getLong("item_count"), instant(r, "retry_started_at"), instant(r, "retry_deadline")); } @Override @@ -64,25 +68,16 @@ public class JdbcNotificationEmailDeliveryRepository implements NotificationEmai @Override public boolean addItem(long deliveryId, NotificationEmailCommand command, Long skillId, Instant now) { - return jdbc.update(""" + int inserted = jdbc.update(""" INSERT INTO notification_email_item(delivery_id,recipient_id,event_key,category,event_type,skill_id, entity_id,occurred_at,enqueued_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(recipient_id,event_key) DO NOTHING """, deliveryId, command.recipientId(), command.eventKey(), command.category().name(), - command.eventType(), skillId, command.entityId(), ts(command.occurredAt()), ts(now)) - == 1; - } - - @Override - public List items(long deliveryId) { - return jdbc.query("SELECT * FROM notification_email_item WHERE delivery_id=? ORDER BY id", - (r, n) - -> new NotificationEmailItem(r.getLong("id"), r.getLong("delivery_id"), - r.getString("recipient_id"), r.getString("event_key"), - NotificationCategory.valueOf(r.getString("category")), r.getString("event_type"), - (Long) r.getObject("skill_id"), (Long) r.getObject("entity_id"), - instant(r, "occurred_at"), instant(r, "enqueued_at"), r.getString("status")), - deliveryId); + command.eventType(), skillId, command.entityId(), ts(command.occurredAt()), ts(now)); + if (inserted == 1) { + jdbc.update("UPDATE notification_email_delivery SET item_count=item_count+1 WHERE id=?", deliveryId); + } + return inserted == 1; } @Override @@ -91,14 +86,282 @@ public class JdbcNotificationEmailDeliveryRepository implements NotificationEmai } @Override - public Optional claim(Instant now, Instant until, String token) { + public Set cancelledItemIds(List ids) { + if (ids.isEmpty()) + return Set.of(); + if (ids.size() > 100) + throw new IllegalArgumentException("Cancellation checks require a bounded item page"); + String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); + return new HashSet<>(jdbc.queryForList(""" + SELECT i.id FROM notification_email_item i WHERE i.id IN ( + """ + placeholders + """ + ) AND i.id<=GREATEST( + coalesce((SELECT through_item_id FROM notification_email_cancel_barrier WHERE scope_key='GLOBAL'),0), + coalesce((SELECT through_item_id FROM notification_email_cancel_barrier + WHERE scope_key='USER:'||i.recipient_id||':'||i.category),0)) + """, Long.class, ids.toArray())); + } + + @Override + public int drainCancelledItems() { + var barriers = jdbc.queryForList(""" + SELECT * FROM notification_email_cancel_barrier WHERE cursor_id? AND id<=? + AND (CAST(? AS VARCHAR) IS NULL OR (recipient_id=? AND category=?)) ORDER BY id LIMIT 100 + """, Long.class, cursor, through, barrier.get("recipient_id"), + barrier.get("recipient_id"), barrier.get("category")); + for (long id : ids) { + // A committed SENDING payload is immutable. Its eventual TEMP retry is stopped by the barrier, + // even when this cursor has already passed it; completed SENT details retain their original items. + jdbc.update(""" + UPDATE notification_email_item i SET status='SUPPRESSED' WHERE i.id=? AND i.status='ACTIVE' + AND EXISTS (SELECT 1 FROM notification_email_delivery d + LEFT JOIN notification_email_delivery p ON p.id=d.merged_into_id + WHERE d.id=i.delivery_id AND coalesce(p.status,d.status) NOT IN ('SENDING','SENT')) + """, id); + } + long next = ids.size() < 100 ? through : ids.getLast(); + jdbc.update("UPDATE notification_email_cancel_barrier SET cursor_id=? WHERE scope_key=?", next, + barrier.get("scope_key")); + return ids.size(); + } + + /** A terminal parent's state is authoritative immediately; physical item writes yield after one page. */ + private int drainSuppressedItems() { + var roots = jdbc.query(""" + SELECT * FROM notification_email_delivery WHERE status='SUPPRESSED' AND NOT items_complete + AND merged_into_id IS NULL ORDER BY terminal_at,id LIMIT 1 FOR UPDATE SKIP LOCKED + """, this::delivery); + if (roots.isEmpty()) + return 0; + var root = roots.getFirst(); + var page = itemPage(root.id(), root.itemCursor(), 100); + for (var item : page) { + jdbc.update( + "UPDATE notification_email_item SET status='SUPPRESSED' WHERE id=? AND status='ACTIVE'", item.id()); + } + long next = page.isEmpty() ? root.itemCursor() : page.getLast().id(); + jdbc.update( + "UPDATE notification_email_delivery SET item_cursor=?,items_complete=? WHERE id=? AND status='SUPPRESSED'", + next, page.size() < 100, root.id()); + return page.size(); + } + + private NotificationEmailItem item(ResultSet r, int ignored) throws SQLException { + return new NotificationEmailItem(r.getLong("id"), r.getLong("delivery_id"), r.getString("recipient_id"), + r.getString("event_key"), NotificationCategory.valueOf(r.getString("category")), + r.getString("event_type"), (Long) r.getObject("skill_id"), (Long) r.getObject("entity_id"), + instant(r, "occurred_at"), instant(r, "enqueued_at"), r.getString("status")); + } + + @Override + public List itemPage(long id, long after, int limit) { return jdbc.query(""" - WITH candidate AS ( - SELECT id FROM notification_email_delivery WHERE status IN ('PENDING','RETRY') - AND due_at<=? AND next_attempt_at<=? ORDER BY next_attempt_at,id FOR UPDATE SKIP LOCKED LIMIT 1) - UPDATE notification_email_delivery d SET status='CLAIMED',lease_token=?,lease_until=? - FROM candidate c WHERE d.id=c.id RETURNING d.* - """, this::delivery, ts(now), ts(now), token, ts(until)).stream().findFirst(); + SELECT i.* FROM notification_email_item i JOIN notification_email_delivery d ON d.id=i.delivery_id + WHERE (d.id=? OR d.merged_into_id=?) AND i.id>? ORDER BY i.id LIMIT ? + """, this::item, id, id, after, limit); + } + + @Override + public List previewItems(long id, List ids) { + if (ids.isEmpty()) + return List.of(); + String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); + List args = new ArrayList<>(List.of(id, id)); + args.addAll(ids); + return jdbc.query( + "SELECT i.* FROM notification_email_item i JOIN notification_email_delivery d ON d.id=i.delivery_id " + + "WHERE (d.id=? OR d.merged_into_id=?) AND i.id IN (" + placeholders + ") ORDER BY i.id", + this::item, args.toArray()); + } + + @Override + public boolean lockRecipient(String user) { + return !jdbc.queryForList( + "SELECT user_id FROM notification_email_contact WHERE user_id=? FOR UPDATE SKIP LOCKED", + String.class, user) + .isEmpty(); + } + + @Override + public Optional activeRoot(String user) { + return jdbc + .query("SELECT * FROM notification_email_delivery WHERE recipient_id=? AND is_recovery_root " + + "AND status IN ('PREPARING','PENDING','CLAIMED','SENDING','RETRY') ORDER BY id LIMIT " + + "1", + this::delivery, user) + .stream() + .findFirst(); + } + + @Override + public Optional claim(Instant now, Instant until, String token) { + return claim(now, until, token, null, Set.of()); + } + + @Override + public Optional claim( + Instant now, Instant until, String token, String kind, Set excluded) { + var users = jdbc.queryForList(""" + SELECT recipient_id FROM notification_email_delivery + WHERE status IN ('PREPARING','PENDING','RETRY') AND due_at<=? AND next_attempt_at<=? + AND (CAST(? AS VARCHAR) IS NULL OR kind=?) + GROUP BY recipient_id ORDER BY min(next_attempt_at),min(id) LIMIT 100 + """, String.class, ts(now), ts(now), kind, kind); + for (String user : users) { + if (excluded.contains(user) || !lockRecipient(user)) + continue; + var root = activeRoot(user).orElse(null); + if (root != null && (kind == null || "DIGEST".equals(kind))) { + if (Set.of("PREPARING", "PENDING", "RETRY").contains(root.status()) && !root.dueAt().isAfter(now)) { + var due = + jdbc.queryForObject("SELECT next_attempt_at<=? FROM notification_email_delivery WHERE id=?", + Boolean.class, ts(now), root.id()); + if (Boolean.TRUE.equals(due)) { + if (root.retryDeadline() != null && !root.retryDeadline().isAfter(now)) { + expireRecovery(root.id(), now); + } else { + jdbc.update("UPDATE notification_email_delivery SET " + + "status='CLAIMED',lease_token=?,lease_until=? WHERE id=?", + token, ts(until), root.id()); + return find(root.id()); + } + } + } + } + var candidates = jdbc.query(""" + SELECT * FROM notification_email_delivery WHERE recipient_id=? + AND status IN ('PREPARING','PENDING','RETRY') AND due_at<=? AND next_attempt_at<=? + AND (CAST(? AS VARCHAR) IS NULL OR kind=?) ORDER BY next_attempt_at,id LIMIT 100 FOR UPDATE SKIP LOCKED + """, this::delivery, user, ts(now), ts(now), kind, kind); + for (var candidate : candidates) { + if ("DIGEST".equals(candidate.kind()) && root != null && candidate.id() != root.id()) + continue; + if (candidate.retryDeadline() != null && !candidate.retryDeadline().isAfter(now)) { + jdbc.update("UPDATE notification_email_delivery SET " + + "status='FAILED',result_code='RETRY_WINDOW_EXPIRED',terminal_at=? WHERE id=?", + ts(now), candidate.id()); + continue; + } + jdbc.update("UPDATE notification_email_delivery SET status='CLAIMED',lease_token=?,lease_until=? WHERE " + + "id=?", + token, ts(until), candidate.id()); + return find(candidate.id()); + } + } + return Optional.empty(); + } + + @Override + public NotificationEmailDelivery beginPreparation(long id, String token, Instant now) { + var row = lock(id); + if (row == null || !"CLAIMED".equals(row.status()) || !Objects.equals(token, row.leaseToken())) + return null; + if (!row.recoveryRoot()) { + Long high = jdbc.queryForObject( + "SELECT coalesce(max(id),0) FROM notification_email_delivery WHERE recipient_id=?", Long.class, + row.recipientId()); + jdbc.update(""" + UPDATE notification_email_delivery SET is_recovery_root=TRUE,status='PREPARING', + recovery_cutoff_at=?,source_high_watermark=?,source_cursor=0,sources_complete=FALSE, + item_cursor=0,items_complete=FALSE,preview_item_ids='' WHERE id=? + """, ts(now), high, id); + } else { + if (row.recoveryCutoffAt() == null) { + Long high = jdbc.queryForObject( + "SELECT coalesce(max(id),0) FROM notification_email_delivery WHERE recipient_id=?", Long.class, + row.recipientId()); + jdbc.update("UPDATE notification_email_delivery SET recovery_cutoff_at=?,source_high_watermark=? WHERE " + + "id=?", + ts(now), high, id); + } + jdbc.update("UPDATE notification_email_delivery SET status='PREPARING' WHERE id=?", id); + } + return find(id).orElseThrow(); + } + + @Override + public boolean mergeSourcePage(NotificationEmailDelivery root, Instant now) { + var sources = jdbc.query(""" + SELECT * FROM notification_email_delivery WHERE recipient_id=? AND email_revision=? AND kind='DIGEST' + AND NOT is_recovery_root AND merged_into_id IS NULL AND id>? AND id<=? AND due_at<=? + AND status IN ('PENDING','RETRY','CLAIMED') AND coalesce(result_code,'')<>'ADMIN_RETRY' + ORDER BY id LIMIT 100 FOR UPDATE + """, this::delivery, root.recipientId(), root.emailRevision(), root.sourceCursor(), + root.sourceHighWatermark(), ts(root.recoveryCutoffAt())); + long cursor = root.sourceCursor(), added = 0; + Instant deadline = root.retryDeadline(), started = root.retryStartedAt(); + for (var source : sources) { + cursor = source.id(); + if (source.retryDeadline() != null && !source.retryDeadline().isAfter(now)) { + jdbc.update("UPDATE notification_email_delivery SET " + + "status='FAILED',result_code='RETRY_WINDOW_EXPIRED',terminal_at=?,lease_token=NULL,lease_" + + "until=NULL WHERE id=?", + ts(now), source.id()); + continue; + } + if (source.retryDeadline() != null && (deadline == null || source.retryDeadline().isBefore(deadline))) { + deadline = source.retryDeadline(); + started = source.retryStartedAt(); + } + jdbc.update("UPDATE notification_email_delivery SET " + + "status='MERGED',merged_into_id=?,lease_token=NULL,lease_until=NULL WHERE id=?", + root.id(), source.id()); + added += source.itemCount(); + } + boolean complete = sources.size() < 100; + jdbc.update("UPDATE notification_email_delivery SET " + + "source_cursor=?,sources_complete=?,item_count=item_count+?,retry_started_at=?,retry_deadline=? " + + "WHERE id=?", + cursor, complete, added, ts(started), ts(deadline), root.id()); + return complete; + } + + @Override + public void savePreparation(long id, String token, long cursor, boolean complete, String preview, boolean ready) { + jdbc.update(""" + UPDATE notification_email_delivery SET status=?,item_cursor=?,items_complete=?,preview_item_ids=?, + lease_token=NULL,lease_until=NULL WHERE id=? AND status='PREPARING' AND lease_token=? + """, ready ? "PENDING" : "PREPARING", cursor, complete, preview, id, token); + } + + @Override + public void temporaryFailure(long id, String token, Instant now, Instant next, int hours) { + var row = lock(id); + if (row == null || !"SENDING".equals(row.status()) || !Objects.equals(token, row.leaseToken())) + return; + Instant start = row.retryStartedAt() == null ? now : row.retryStartedAt(); + Instant deadline = row.retryDeadline() == null ? start.plusSeconds(hours * 3600L) : row.retryDeadline(); + boolean expired = !deadline.isAfter(now); + Instant due = next.isBefore(deadline) ? next : deadline; + jdbc.update(""" + UPDATE notification_email_delivery SET status=?,result_code=?,retry_started_at=?,retry_deadline=?, + next_attempt_at=?,terminal_at=?,lease_token=NULL,lease_until=NULL WHERE id=? + """, expired ? "FAILED" : "RETRY", expired ? "RETRY_WINDOW_EXPIRED" : "TEMPORARY_FAILURE", ts(start), + ts(deadline), ts(due), expired ? ts(now) : null, id); + if (row.recoveryRoot() && !expired) { + // The next attempt fixes a new range; existing merged sources and their immutable items stay attached. + jdbc.update(""" + UPDATE notification_email_delivery SET recovery_cutoff_at=NULL,sources_complete=FALSE, + source_cursor=0,item_cursor=0,items_complete=FALSE,preview_item_ids='' WHERE id=? + """, id); + } + } + + @Override + public void expireRecovery(long id, Instant now) { + jdbc.update("UPDATE notification_email_delivery SET " + + "status='FAILED',result_code='RETRY_WINDOW_EXPIRED',terminal_at=?,lease_token=NULL,lease_until=" + + "NULL WHERE id=? AND retry_deadline<=? AND status IN ('PREPARING','PENDING','CLAIMED','RETRY')", + ts(now), id, ts(now)); } @Override @@ -113,29 +376,39 @@ public class JdbcNotificationEmailDeliveryRepository implements NotificationEmai public boolean finish(long id, String token, String status, String code, Instant next, Instant accepted) { return jdbc.update(""" UPDATE notification_email_delivery SET status=?,result_code=?,next_attempt_at=?,accepted_at=?, - lease_token=NULL,lease_until=NULL WHERE id=? AND status='SENDING' AND lease_token=? - """, status, code, ts(next), ts(accepted), id, token) == 1; + terminal_at=?,lease_token=NULL,lease_until=NULL WHERE id=? AND status='SENDING' AND lease_token=? + """, status, code, ts(next), ts(accepted), ts(next), id, token) == 1; } @Override - public void suppress(long id, String token, String reason) { + public void suppress(long id, String token, String reason, Instant now) { jdbc.update(""" - UPDATE notification_email_delivery SET status='SUPPRESSED',result_code=?,lease_token=NULL,lease_until=NULL - WHERE id=? AND status='CLAIMED' AND lease_token=? - """, reason, id, token); - jdbc.update("UPDATE notification_email_item SET status='SUPPRESSED' WHERE delivery_id=?", id); + UPDATE notification_email_delivery SET status='SUPPRESSED',result_code=?,terminal_at=?,lease_token=NULL,lease_until=NULL, + item_cursor=0,items_complete=FALSE + WHERE id=? AND status IN ('CLAIMED','PREPARING') AND lease_token=? + """, reason, ts(now), id, token); } @Override public void recoverExpired(Instant now) { - jdbc.update(""" - UPDATE notification_email_delivery SET status='RETRY',lease_token=NULL,lease_until=NULL,next_attempt_at=? - WHERE status='CLAIMED' AND lease_until<=? - """, ts(now), ts(now)); - jdbc.update(""" - UPDATE notification_email_delivery SET status='UNKNOWN',result_code='LEASE_EXPIRED_AFTER_AUTHORIZATION', - lease_token=NULL,lease_until=NULL WHERE status='SENDING' AND lease_until<=? - """, ts(now)); + // Do not lock batches before their user gate: preparation locks a root followed by its sources. + var expired = jdbc.query(""" + SELECT * FROM notification_email_delivery + WHERE status IN ('CLAIMED','PREPARING','SENDING') AND lease_until<=? + ORDER BY lease_until,id LIMIT 100 + """, this::delivery, ts(now)); + for (var candidate : expired) { + if (!lockRecipient(candidate.recipientId())) + continue; + jdbc.update(""" + UPDATE notification_email_delivery SET status='RETRY',lease_token=NULL,lease_until=NULL,next_attempt_at=? + WHERE id=? AND status IN ('CLAIMED','PREPARING') AND lease_until<=? + """, ts(now), candidate.id(), ts(now)); + jdbc.update(""" + UPDATE notification_email_delivery SET status='UNKNOWN',result_code='LEASE_EXPIRED_AFTER_AUTHORIZATION', + terminal_at=?,lease_token=NULL,lease_until=NULL WHERE id=? AND status='SENDING' AND lease_until<=? + """, ts(now), candidate.id(), ts(now)); + } } @Override @@ -156,15 +429,22 @@ public class JdbcNotificationEmailDeliveryRepository implements NotificationEmai public void retry(long id, Instant now) { jdbc.update(""" UPDATE notification_email_delivery SET status='RETRY',attempts=0,next_attempt_at=?,lease_token=NULL,lease_until=NULL, - result_code='ADMIN_RETRY' WHERE id=? AND status IN ('FAILED','UNKNOWN') + result_code='ADMIN_RETRY',retry_started_at=NULL,retry_deadline=NULL,terminal_at=NULL, + recovery_cutoff_at=NULL,sources_complete=FALSE,source_cursor=0,item_cursor=0,items_complete=FALSE,preview_item_ids='' WHERE id=? AND status IN ('FAILED','UNKNOWN') """, ts(now), id); } @Override public int cleanup(Instant sentBefore, Instant failedBefore) { return jdbc.update(""" - DELETE FROM notification_email_delivery WHERE - (status IN ('SENT','SUPPRESSED') AND created_at'MERGED') + AND NOT EXISTS (SELECT 1 FROM notification_email_item i JOIN notification_email_delivery s ON s.id=i.delivery_id + WHERE (s.id=d.id OR s.merged_into_id=d.id) AND i.enqueued_at>= + CASE WHEN d.status IN ('SENT','SUPPRESSED') THEN CAST(? AS TIMESTAMPTZ) ELSE CAST(? AS TIMESTAMPTZ) END) + """, ts(sentBefore), ts(failedBefore), ts(sentBefore), ts(failedBefore)); } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SmtpNotificationEmailSender.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SmtpNotificationEmailSender.java index 1bf0a0519..f284d4187 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SmtpNotificationEmailSender.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SmtpNotificationEmailSender.java @@ -5,12 +5,14 @@ import jakarta.mail.MessagingException; import jakarta.mail.internet.InternetAddress; import org.eclipse.angus.mail.smtp.SMTPAddressFailedException; import org.eclipse.angus.mail.smtp.SMTPSendFailedException; +import org.eclipse.angus.mail.util.MailConnectException; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.MailAuthenticationException; import org.springframework.mail.MailException; import org.springframework.mail.MailSendException; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; @@ -61,8 +63,7 @@ public class SmtpNotificationEmailSender implements NotificationEmailSender { if (rejected != null) { return rejected; } - boolean connectFailure = ex.getFailedMessages().isEmpty() && hasConnectFailure(ex); - return connectFailure ? Result.TEMPORARY_FAILURE : Result.UNKNOWN; + return connectionSetupFailure(sender, ex) ? Result.TEMPORARY_FAILURE : Result.UNKNOWN; } catch (MailException ex) { return Result.UNKNOWN; } catch (MessagingException | java.io.UnsupportedEncodingException | IllegalArgumentException ex) { @@ -88,11 +89,31 @@ public class SmtpNotificationEmailSender implements NotificationEmailSender { return null; } - private boolean hasConnectFailure(Throwable ex) { - for (Throwable cause = ex; cause != null; cause = cause.getCause()) - if (cause instanceof java.net.ConnectException || cause instanceof java.net.UnknownHostException) { + private boolean connectionSetupFailure(JavaMailSender sender, MailSendException failure) { + Throwable setup = failure.getCause(); + // Spring doSend attaches the same connectTransport exception to every not-yet-sent message. + // sendMessage failures instead have no top-level cause; close failures have an empty map or + // a different cause. A nonempty failedMessages map alone never proves a pre-DATA failure. + if (!(sender instanceof JavaMailSenderImpl) || setup == null || failure.getFailedMessages().isEmpty() + || failure.getFailedMessages().values().stream().anyMatch(value -> value != setup)) + return false; + var pending = new java.util.ArrayDeque(); + var visited = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap()); + pending.add(setup); + while (!pending.isEmpty()) { + var cause = pending.removeFirst(); + if (!visited.add(cause)) + continue; + if (cause instanceof MailConnectException || cause instanceof java.net.ConnectException + || cause instanceof java.net.UnknownHostException + || cause instanceof java.net.SocketTimeoutException || cause instanceof java.net.SocketException + || cause instanceof javax.net.ssl.SSLException) return true; - } + if (cause.getCause() != null) + pending.add(cause.getCause()); + if (cause instanceof MessagingException messaging && messaging.getNextException() != null) + pending.add(messaging.getNextException()); + } return false; } } diff --git a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/jpa/SmtpNotificationEmailSenderTest.java b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/jpa/SmtpNotificationEmailSenderTest.java index c5963e331..2eb945d9d 100644 --- a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/jpa/SmtpNotificationEmailSenderTest.java +++ b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/jpa/SmtpNotificationEmailSenderTest.java @@ -52,4 +52,231 @@ class SmtpNotificationEmailSenderTest { doThrow(new MailAuthenticationException("auth")).when(mail).send(any(MimeMessage.class)); assertThat(sender.send("u@example.test", "en", 1, "s", "t")).isEqualTo(Result.PERMANENT_FAILURE); } + private SmtpNotificationEmailSender component(JavaMailSender mail) { + var beans = new org.springframework.beans.factory.support.StaticListableBeanFactory(); + beans.addBean("mailSender", mail); + return new SmtpNotificationEmailSender( + beans.getBeanProvider(JavaMailSender.class), "notify@example.test", "SkillHub"); + } + + private org.springframework.mail.javamail.JavaMailSenderImpl networkMail(int port) { + var mail = new org.springframework.mail.javamail.JavaMailSenderImpl(); + mail.setHost("127.0.0.1"); + mail.setPort(port); + mail.getJavaMailProperties().setProperty("mail.smtp.localhost", "localhost"); + mail.getJavaMailProperties().setProperty("mail.smtp.connectiontimeout", "300"); + mail.getJavaMailProperties().setProperty("mail.smtp.timeout", "300"); + mail.getJavaMailProperties().setProperty("mail.smtp.writetimeout", "300"); + return mail; + } + + private static org.eclipse.angus.mail.util.MailConnectException connectionFailure(Exception cause) { + return new org.eclipse.angus.mail.util.MailConnectException( + new org.eclipse.angus.mail.util.SocketConnectException( + "test setup failure", cause, "local.test", 25, 300)); + } + + private org.springframework.mail.javamail.JavaMailSenderImpl connectFailureMail( + jakarta.mail.MessagingException failure) { + return new org.springframework.mail.javamail.JavaMailSenderImpl() { + @Override + protected jakarta.mail.Transport connectTransport() throws jakarta.mail.MessagingException { + throw failure; + } + }; + } + + @Test + void actualSpringConnectionRefusalWithNonemptyFailedMessagesIsTemporary() throws Exception { + // Reserve an unconnected TCP socket: its port cannot be stolen, and no server is listening there. + try (var reserved = new java.net.Socket()) { + reserved.bind(new java.net.InetSocketAddress("127.0.0.1", 0)); + var mail = networkMail(reserved.getLocalPort()); + MailSendException error = org.junit.jupiter.api.Assertions.assertThrows( + MailSendException.class, () -> mail.send(mail.createMimeMessage())); + assertThat(error.getFailedMessages()).hasSize(1); + assertThat(error.getCause()).isInstanceOf(org.eclipse.angus.mail.util.MailConnectException.class); + assertThat(error.getFailedMessages().values()).allMatch(value -> value == error.getCause()); + assertThat(component(mail).send("u@example.test", "en", 1, "s", "t")).isEqualTo(Result.TEMPORARY_FAILURE); + } + } + + static java.util.stream.Stream setupFailures() { + return java.util.stream.Stream.of(org.junit.jupiter.params.provider.Arguments.of("connect timeout", + connectionFailure(new java.net.SocketTimeoutException())), + org.junit.jupiter.params.provider.Arguments.of( + "DNS", connectionFailure(new java.net.UnknownHostException())), + org.junit.jupiter.params.provider.Arguments.of("greeting timeout", + new jakarta.mail.MessagingException("greeting", new java.net.SocketTimeoutException())), + org.junit.jupiter.params.provider.Arguments.of("TLS handshake", + new jakarta.mail.MessagingException("TLS", new javax.net.ssl.SSLHandshakeException("test")))); + } + + @org.junit.jupiter.params.ParameterizedTest(name = "{0}") + @org.junit.jupiter.params.provider.MethodSource("setupFailures") + void realSpringDoSendRetainsConnectionStageForTimeoutDnsAndHandshake( + String description, jakarta.mail.MessagingException cause) { + var mail = connectFailureMail(cause); + MailSendException error = org.junit.jupiter.api.Assertions.assertThrows( + MailSendException.class, () -> mail.send(mail.createMimeMessage())); + assertThat(error.getFailedMessages()).hasSize(1); + assertThat(error.getCause()).isSameAs(cause); + assertThat(error.getFailedMessages().values()).containsExactly(cause); + assertThat(component(mail).send("u@example.test", "en", 1, "s", "t")).isEqualTo(Result.TEMPORARY_FAILURE); + } + + private static final class FailingTransport extends jakarta.mail.Transport { + final jakarta.mail.MessagingException sendFailure; + final jakarta.mail.MessagingException closeFailure; + int messages; + FailingTransport(jakarta.mail.MessagingException sendFailure, jakarta.mail.MessagingException closeFailure) { + super(Session.getInstance(new Properties()), null); + this.sendFailure = sendFailure; + this.closeFailure = closeFailure; + } + @Override + public boolean isConnected() { + return true; + } + @Override + public void sendMessage(jakarta.mail.Message message, jakarta.mail.Address[] recipients) + throws jakarta.mail.MessagingException { + messages++; + if (sendFailure != null) + throw sendFailure; + } + @Override + public void close() throws jakarta.mail.MessagingException { + if (closeFailure != null) + throw closeFailure; + } + } + + private org.springframework.mail.javamail.JavaMailSenderImpl transportMail(FailingTransport transport) { + return new org.springframework.mail.javamail.JavaMailSenderImpl() { + @Override + protected jakarta.mail.Transport connectTransport() { + return transport; + } + }; + } + + static java.util.stream.Stream uncertainSendFailures() { + return java.util.stream.Stream.of( + new jakarta.mail.MessagingException("waiting for DATA response", new java.net.SocketTimeoutException()), + new jakarta.mail.MessagingException("send stage", new java.net.ConnectException()), + connectionFailure(new java.net.SocketTimeoutException())); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.MethodSource("uncertainSendFailures") + void actualSpringSendStageFailuresRemainUnknownEvenWithNonemptyFailedMessages( + jakarta.mail.MessagingException failure) { + var transport = new FailingTransport(failure, null); + var mail = transportMail(transport); + MailSendException error = org.junit.jupiter.api.Assertions.assertThrows( + MailSendException.class, () -> mail.send(mail.createMimeMessage())); + assertThat(error.getFailedMessages()).hasSize(1); + assertThat(error.getCause()).isNull(); + assertThat(component(mail).send("u@example.test", "en", 1, "s", "t")).isEqualTo(Result.UNKNOWN); + assertThat(transport.messages).isEqualTo(2); + } + + @Test + void actualSpringCloseFailureAfterSuccessfulSendIsUnknown() { + var transport = new FailingTransport( + null, new jakarta.mail.MessagingException("close", new java.net.SocketTimeoutException())); + var mail = transportMail(transport); + assertThat(component(mail).send("u@example.test", "en", 1, "s", "t")).isEqualTo(Result.UNKNOWN); + assertThat(transport.messages).isEqualTo(1); + } + + @Test + void actualSpringCloseFailureCannotTurnAnUncertainSendIntoTemporary() { + var sendFailure = new jakarta.mail.MessagingException("DATA response", new java.net.SocketTimeoutException()); + var closeFailure = connectionFailure(new java.net.ConnectException()); + var transport = new FailingTransport(sendFailure, closeFailure); + var mail = transportMail(transport); + MailSendException error = org.junit.jupiter.api.Assertions.assertThrows( + MailSendException.class, () -> mail.send(mail.createMimeMessage())); + assertThat(error.getCause()).isSameAs(closeFailure); + assertThat(error.getFailedMessages().values()).containsExactly(sendFailure); + assertThat(component(mail).send("u@example.test", "en", 1, "s", "t")).isEqualTo(Result.UNKNOWN); + } + + @Test + void actualSmtpGreetingReadTimeoutIsPreMessageAndTemporary() throws Exception { + try (var listener = new java.net.ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1")); + var pool = java.util.concurrent.Executors.newSingleThreadExecutor()) { + listener.setSoTimeout(3000); + var release = new java.util.concurrent.CountDownLatch(1); + var accepted = pool.submit(() -> { + try (var socket = listener.accept()) { + if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) + throw new AssertionError("test did not finish"); + } + return null; + }); + try { + assertThat(component(networkMail(listener.getLocalPort())).send("u@example.test", "en", 1, "s", "t")) + .isEqualTo(Result.TEMPORARY_FAILURE); + } finally { + release.countDown(); + } + accepted.get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + @Test + void actualSmtpMissingResponseAfterCompleteDataRemainsUnknown() throws Exception { + try (var listener = new java.net.ServerSocket(0, 1, java.net.InetAddress.getByName("127.0.0.1")); + var pool = java.util.concurrent.Executors.newSingleThreadExecutor()) { + listener.setSoTimeout(3000); + var release = new java.util.concurrent.CountDownLatch(1); + var dataReceived = new java.util.concurrent.atomic.AtomicBoolean(); + var accepted = pool.submit(() -> { + try (var socket = listener.accept()) { + socket.setSoTimeout(3000); + var reader = new java.io.BufferedReader(new java.io.InputStreamReader( + socket.getInputStream(), java.nio.charset.StandardCharsets.US_ASCII)); + var writer = new java.io.PrintWriter(new java.io.OutputStreamWriter(socket.getOutputStream(), + java.nio.charset.StandardCharsets.US_ASCII), + true); + writer.print("220 localhost test SMTP\r\n"); + writer.flush(); + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("EHLO") || line.startsWith("HELO")) + writer.print("250 localhost\r\n"); + else if (line.startsWith("MAIL FROM") || line.startsWith("RCPT TO")) + writer.print("250 OK\r\n"); + else if (line.equals("DATA")) { + writer.print("354 End with dot\r\n"); + writer.flush(); + while ((line = reader.readLine()) != null && !line.equals(".")) { + } + if (line == null) + throw new AssertionError("client disconnected before complete DATA"); + dataReceived.set(true); + // The server received DATA but does not return acceptance. It might have queued it. + if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) + throw new AssertionError("test did not finish"); + return null; + } else + throw new AssertionError("Unexpected SMTP command"); + writer.flush(); + } + throw new AssertionError("No DATA received"); + } + }); + try { + assertThat(component(networkMail(listener.getLocalPort())) + .send("u@example.test", "en", 1, "s", "safe text")) + .isEqualTo(Result.UNKNOWN); + assertThat(dataReceived).isTrue(); + } finally { + release.countDown(); + } + accepted.get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } } diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailAccountLifecycle.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailAccountLifecycle.java new file mode 100644 index 000000000..75f053b13 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailAccountLifecycle.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.notification.email; + +public interface NotificationEmailAccountLifecycle { + /** Caller holds the account lock and changes a non-active account to active in the same transaction. */ + void markActive(String userId); +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailAdmissionProvider.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailAdmissionProvider.java new file mode 100644 index 000000000..6a61f61e9 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailAdmissionProvider.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.notification.email; + +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import java.time.Instant; +import java.util.Optional; + +public interface NotificationEmailAdmissionProvider { + /** Caller holds policy, account/contact and category preference guards, in that order. + * Returns the earliest event time eligible for admission, or empty if any proof is missing. + * Does not acquire additional locks. This gate does not apply to already admitted deliveries. + */ + Optional currentUnderLocks(String userId, NotificationCategory category); +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailContact.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailContact.java index 57141b065..e200652aa 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailContact.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailContact.java @@ -10,6 +10,7 @@ public class NotificationEmailContact { @Column(name = "verified_email", length = 256) private String verifiedEmail; @Column(name = "verified_revision") private Long verifiedRevision; @Column(name = "verified_at") private Instant verifiedAt; + @Column(name = "account_enabled_since") private Instant accountEnabledSince; @Column(name = "challenge_hash", length = 256) private String challengeHash; @Column(name = "challenge_id", length = 36) private String challengeId; @Column(name = "challenge_email", length = 256) private String challengeEmail; @@ -71,6 +72,15 @@ public class NotificationEmailContact { private String normalize(String email) { return com.iflytek.skillhub.domain.user.UserAccount.normalizeEmail(email); } + public void markActive(Instant now) { + accountEnabledSince = java.util.Objects.requireNonNull(now); + } + public Instant eligibleSince() { + if (verifiedAt == null) + return null; + return accountEnabledSince != null && accountEnabledSince.isAfter(verifiedAt) + ? accountEnabledSince : verifiedAt; + } public String getChallengeHash() { return challengeHash; } diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPolicy.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPolicy.java index 070215583..2231bd1bc 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPolicy.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPolicy.java @@ -1,3 +1,10 @@ package com.iflytek.skillhub.notification.email; -public record NotificationEmailPolicy(boolean enabled, int digestWindowMinutes, String publicBaseUrl, long version) {} +import java.time.Instant; + +public record NotificationEmailPolicy(boolean enabled, int digestWindowMinutes, String publicBaseUrl, + long version, Instant enabledSince) { + public NotificationEmailPolicy(boolean enabled, int digestWindowMinutes, String publicBaseUrl, long version) { + this(enabled, digestWindowMinutes, publicBaseUrl, version, null); + } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPolicyProvider.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPolicyProvider.java index d2f122fde..3ec7dea0c 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPolicyProvider.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPolicyProvider.java @@ -2,5 +2,9 @@ package com.iflytek.skillhub.notification.email; public interface NotificationEmailPolicyProvider { NotificationEmailPolicy current(); + /** + * Shared row read guard held until the caller's read/write transaction ends. + * Refreshes after waiting for policy writes; PostgreSQL locking reads require a writable transaction. + */ NotificationEmailPolicy lockCurrent(); } diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPreference.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPreference.java index 6d46dec88..683ee6b48 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPreference.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailPreference.java @@ -13,6 +13,7 @@ public class NotificationEmailPreference { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 16) private NotificationEmailMode mode = NotificationEmailMode.OFF; + @Column(name = "enabled_since") private java.time.Instant enabledSince; @Version private long version; protected NotificationEmailPreference() {} public NotificationEmailPreference(String userId, NotificationCategory category) { @@ -25,7 +26,13 @@ public class NotificationEmailPreference { public NotificationEmailMode getMode() { return mode; } - public void setMode(NotificationEmailMode mode) { - this.mode = java.util.Objects.requireNonNull(mode); + public java.time.Instant getEnabledSince() { + return enabledSince; + } + public void setMode(NotificationEmailMode mode, java.time.Instant now) { + java.util.Objects.requireNonNull(mode); + if (this.mode == NotificationEmailMode.OFF && mode != NotificationEmailMode.OFF) + enabledSince = java.util.Objects.requireNonNull(now); + this.mode = mode; } } diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailRecipient.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailRecipient.java index e6bdb453f..ed04d4dca 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailRecipient.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/NotificationEmailRecipient.java @@ -1,3 +1,10 @@ package com.iflytek.skillhub.notification.email; -public record NotificationEmailRecipient(String userId, String email, long emailRevision, String locale) {} +import java.time.Instant; + +public record NotificationEmailRecipient(String userId, String email, long emailRevision, String locale, + Instant eligibleSince) { + public NotificationEmailRecipient(String userId, String email, long emailRevision, String locale) { + this(userId, email, emailRevision, locale, null); + } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/delivery/NotificationEmailDelivery.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/delivery/NotificationEmailDelivery.java index a13b885e1..b016744f9 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/delivery/NotificationEmailDelivery.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/delivery/NotificationEmailDelivery.java @@ -1,6 +1,16 @@ package com.iflytek.skillhub.notification.email.delivery; import java.time.Instant; + public record NotificationEmailDelivery(long id, String recipientId, String kind, String windowKey, long emailRevision, String status, int attempts, Instant dueAt, String leaseToken, Instant leaseUntil, String resultCode, - Instant createdAt, Instant acceptedAt) {} + Instant createdAt, Instant acceptedAt, boolean recoveryRoot, Long mergedIntoDeliveryId, + Instant recoveryCutoffAt, long sourceHighWatermark, long sourceCursor, boolean sourcesComplete, long itemCursor, + boolean itemsComplete, String previewItemIds, long itemCount, Instant retryStartedAt, Instant retryDeadline) { + public NotificationEmailDelivery(long id, String recipientId, String kind, String windowKey, long emailRevision, + String status, int attempts, Instant dueAt, String leaseToken, Instant leaseUntil, String resultCode, + Instant createdAt, Instant acceptedAt) { + this(id, recipientId, kind, windowKey, emailRevision, status, attempts, dueAt, leaseToken, leaseUntil, + resultCode, createdAt, acceptedAt, false, null, null, 0, 0, false, 0, false, "", 0, null, null); + } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/delivery/NotificationEmailDeliveryRepository.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/delivery/NotificationEmailDeliveryRepository.java index bc67d7370..2383be4c3 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/delivery/NotificationEmailDeliveryRepository.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/email/delivery/NotificationEmailDeliveryRepository.java @@ -8,12 +8,29 @@ public interface NotificationEmailDeliveryRepository { NotificationEmailDelivery openBatch( String recipientId, String kind, String key, long emailRevision, Instant dueAt, Instant now); boolean addItem(long deliveryId, NotificationEmailCommand command, Long skillId, Instant now); - List items(long deliveryId); void suppressItem(long id); + /** Checks at most one bounded preparation/preview page against durable OFF barriers. */ + Set cancelledItemIds(List ids); + /** Advances one persisted cancellation cursor by at most 100 raw items. */ + int drainCancelledItems(); Optional claim(Instant now, Instant until, String token); + /** Raw immutable group rows in global item-ID order; callers apply current permission checks after LIMIT. */ + List itemPage(long deliveryId, long afterId, int limit); + List previewItems(long deliveryId, List ids); + Optional activeRoot(String userId); + /** Acquire the existing contact gate without waiting; caller must keep the transaction open. */ + boolean lockRecipient(String userId); + Optional claim( + Instant now, Instant until, String token, String preferredKind, Set excludedRecipients); + NotificationEmailDelivery beginPreparation(long id, String token, Instant now); + /** Caller holds the contact and root locks; seals at most 100 eligible sources. */ + boolean mergeSourcePage(NotificationEmailDelivery root, Instant now); + void savePreparation(long id, String token, long cursor, boolean complete, String previewIds, boolean ready); + void temporaryFailure(long id, String token, Instant now, Instant nextAttempt, int retryWindowHours); + void expireRecovery(long id, Instant now); boolean authorize(long id, String token, Instant now, Instant until); boolean finish(long id, String token, String status, String resultCode, Instant nextAttempt, Instant acceptedAt); - void suppress(long id, String token, String reason); + void suppress(long id, String token, String reason, Instant now); void recoverExpired(Instant now); List list(String status, int offset, int limit); long count(String status); diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/email/NotificationEmailContactTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/email/NotificationEmailContactTest.java index fb305fbc9..34cde4083 100644 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/email/NotificationEmailContactTest.java +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/email/NotificationEmailContactTest.java @@ -55,4 +55,31 @@ class NotificationEmailContactTest { assertThat(contact.verified(" A@EXAMPLE.TEST ", 0)).isTrue(); assertThat(contact.verified("a@example.test", 1)).isFalse(); } + @Test + void recipientWatermarkTracksVerificationAndActualReactivationOnly() { + var contact = new NotificationEmailContact("user"); + assertThat(contact.eligibleSince()).isNull(); + contact.markActive(now.minusSeconds(30)); + contact.challenge("id", "hash", "a@test.com", 0, "en", now); + contact.consume(now); + assertThat(contact.eligibleSince()).isEqualTo(now); + contact.markActive(now.plusSeconds(60)); + assertThat(contact.eligibleSince()).isEqualTo(now.plusSeconds(60)); + contact.challenge("id2", "hash", "a@test.com", 0, "en", now.plusSeconds(120)); + contact.consume(now.plusSeconds(120)); + assertThat(contact.eligibleSince()).isEqualTo(now.plusSeconds(120)); + } + @Test + void categoryAdmissionBaselineChangesOnlyWhenTurningOn() { + var preference = new NotificationEmailPreference("user", + com.iflytek.skillhub.notification.domain.NotificationCategory.PUBLISH); + assertThat(preference.getEnabledSince()).isNull(); + preference.setMode(NotificationEmailMode.DIGEST, now); + preference.setMode(NotificationEmailMode.IMMEDIATE, now.plusSeconds(60)); + assertThat(preference.getEnabledSince()).isEqualTo(now); + preference.setMode(NotificationEmailMode.OFF, now.plusSeconds(120)); + preference.setMode(NotificationEmailMode.IMMEDIATE, now.plusSeconds(180)); + assertThat(preference.getEnabledSince()).isEqualTo(now.plusSeconds(180)); + } + } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 3a1ca392c..c4acae809 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -10168,18 +10168,17 @@ export interface components { /** Format: date-time */ createdAt?: string; /** Format: date-time */ - acceptedAt?: string; + acceptedAt?: string | null; items?: components["schemas"]["NotificationEmailDeliveryItemResponse"][]; /** Format: int64 */ totalItems?: number; /** Format: int64 */ - unavailableItems?: number; - /** Format: int32 */ - page?: number; + unavailableCount?: number; /** Format: int32 */ size?: number; - /** Format: int32 */ - totalPages?: number; + nextCursor?: string | null; + /** Format: int64 */ + mergedIntoDeliveryId?: number | null; }; NotificationEmailDeliveryItemResponse: { /** Format: int64 */ @@ -10832,11 +10831,13 @@ export interface components { status?: string; /** Format: int32 */ attempts?: number; - resultCode?: string; + resultCode?: string | null; /** Format: date-time */ createdAt?: string; /** Format: date-time */ - acceptedAt?: string; + acceptedAt?: string | null; + /** Format: int64 */ + mergedIntoDeliveryId?: number | null; }; ClawHubWhoamiResponse: { user?: components["schemas"]["User"]; @@ -22153,7 +22154,7 @@ export interface operations { detail: { parameters: { query?: { - page?: number; + cursor?: string; size?: number; }; header?: never; @@ -22178,7 +22179,7 @@ export interface operations { detail_1: { parameters: { query?: { - page?: number; + cursor?: string; size?: number; }; header?: never; diff --git a/web/src/features/notification/notification-email-api.test.ts b/web/src/features/notification/notification-email-api.test.ts index 0a5200190..257ec14b0 100644 --- a/web/src/features/notification/notification-email-api.test.ts +++ b/web/src/features/notification/notification-email-api.test.ts @@ -23,8 +23,8 @@ describe('typed notification email API boundary', () => { expect(init.method).toBe('POST') }) it('encodes the batch identifier and requests its independent page', async () => { - await notificationEmailApi.batch('23/unsafe', 2) - expect(fetchMock.mock.calls[0][0]).toBe('/api/web/notification-email-deliveries/23%2Funsafe?page=2&size=20') + await notificationEmailApi.batch('23/unsafe', '100') + expect(fetchMock.mock.calls[0][0]).toBe('/api/web/notification-email-deliveries/23%2Funsafe?size=20&cursor=100') }) it('honors the runtime API subpath exactly once', async () => { window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: '/skillhub' } diff --git a/web/src/features/notification/notification-email-api.ts b/web/src/features/notification/notification-email-api.ts index 560ca6076..a4607db5c 100644 --- a/web/src/features/notification/notification-email-api.ts +++ b/web/src/features/notification/notification-email-api.ts @@ -22,7 +22,7 @@ export const notificationEmailApi = { updatePreferences: (preferences: EmailPreferences) => write('/notification-email-preferences', 'PUT', { preferences }), settings: () => fetchJson(`${WEB_API_PREFIX}/admin/system-config/notification/email`), updateSettings: (settings: Pick) => write('/admin/system-config/notification/email', 'PUT', settings), - batch: (id: string, page: number) => fetchJson(`${WEB_API_PREFIX}/notification-email-deliveries/${encodeURIComponent(id)}?page=${page}&size=20`), + batch: (id: string, cursor: string | null) => fetchJson(`${WEB_API_PREFIX}/notification-email-deliveries/${encodeURIComponent(id)}?size=20${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`), deliveries: (page: number, status: string) => fetchJson(`${WEB_API_PREFIX}/admin/notification-email-deliveries?page=${page}&size=20${status ? `&status=${encodeURIComponent(status)}` : ''}`), retry: (id: number, confirmDuplicateRisk: boolean) => write(`/admin/notification-email-deliveries/${id}/retry`, 'POST', { confirmDuplicateRisk }), } diff --git a/web/src/features/notification/notification-email-settings.tsx b/web/src/features/notification/notification-email-settings.tsx index 499a2231e..b155bd937 100644 --- a/web/src/features/notification/notification-email-settings.tsx +++ b/web/src/features/notification/notification-email-settings.tsx @@ -48,11 +48,12 @@ function EmailDeliveryList() { const [selected, setSelected] = useState(null) const deliveries = useQuery({ queryKey: ['system-config', 'email-deliveries', page, status], queryFn: () => notificationEmailApi.deliveries(page, status) }) const retry = useMutation({ mutationFn: ({ id, risk }: { id: number; risk: boolean }) => notificationEmailApi.retry(id, risk), onSuccess: async () => { setSelected(null); await client.invalidateQueries({ queryKey: ['system-config', 'email-deliveries'] }) } }) - return

{t('notificationEmail.deliveries')}

+ return

{t('notificationEmail.deliveries')}

+

{t('notificationEmail.retryPolicy')}

{deliveries.isPending ?

{t('notificationEmail.loading')}

: null} {deliveries.isError ?

{t('notificationEmail.loadFailed')}

: null} {deliveries.data?.items?.length === 0 ?

{t('notificationEmail.noDeliveries')}

: null} - {!!deliveries.data?.items?.length &&
{['batch', 'recipient', 'state', 'attempts', 'created', 'actions'].map((key) => )}{deliveries.data.items.map((item) => )}
{t(`notificationEmail.column.${key}`)}
#{item.id}{item.recipientId}{t(`notificationEmail.status.${item.status}`)}{item.resultCode ? {item.resultCode} : null}{item.attempts}{item.createdAt ? new Date(item.createdAt).toLocaleString(i18n.language) : ''}{item.status === 'FAILED' || item.status === 'UNKNOWN' ? : null}
} + {!!deliveries.data?.items?.length &&
{['batch', 'recipient', 'state', 'attempts', 'created', 'actions'].map((key) => )}{deliveries.data.items.map((item) => )}
{t(`notificationEmail.column.${key}`)}
#{item.id}{item.mergedIntoDeliveryId ? {t('notificationEmail.mergedTarget', { id: item.mergedIntoDeliveryId })} : null}{item.recipientId}{t(`notificationEmail.status.${item.status}`)}{item.resultCode ? {item.resultCode} : null}{item.attempts}{item.createdAt ? new Date(item.createdAt).toLocaleString(i18n.language) : ''}{item.status === 'FAILED' || item.status === 'UNKNOWN' ? : null}
} {deliveries.data ? : null} { if (!open) setSelected(null) }} title={t('notificationEmail.retryTitle', { id: selected?.id })} description={<>{t(selected?.status === 'UNKNOWN' ? 'notificationEmail.duplicateRisk' : 'notificationEmail.retryHelp')}{retry.isError ? {t('notificationEmail.actionFailed')} : null}} confirmText={t('notificationEmail.retry')} closeOnConfirm={false} onConfirm={async () => { if (selected?.id !== undefined) { try { await retry.mutateAsync({ id: selected.id, risk: selected.status === 'UNKNOWN' }) } catch { /* Keep the dialog open with an actionable error. */ } } }} />
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 816469239..15b8ac103 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3270,8 +3270,8 @@ "batchHelp": "Independent of in-app notifications. Only currently authorized items are shown. SMTP acceptance does not guarantee inbox delivery.", "expired": "This batch is unavailable, expired, or belongs to another account.", "forbidden": "You cannot access this email batch.", - "itemCount": "{{count}} notifications in this batch", - "unavailableItems": "{{count}} items have been deleted or are no longer accessible.", + "itemCount": "{{count}} original records in this batch; this is not a count of currently accessible items", + "unavailableItems": "{{count}} records on this page are deleted or no longer accessible.", "noItems": "No accessible notifications on this page", "openItem": "View details", "adminTitle": "Business email notifications", @@ -3289,8 +3289,8 @@ "noDeliveries": "No delivery records", "retry": "Retry", "retryTitle": "Retry email batch #{{id}}?", - "duplicateRisk": "The previous result is unknown and the email may already have been accepted. Retrying may create a duplicate. This action is audited and eligibility is checked again.", - "retryHelp": "Retry keeps the original recipient and content and checks eligibility again. Expired records cannot be retried.", + "duplicateRisk": "The previous result is unknown and the email may already have been accepted. Retrying may create a duplicate. Eligibility is checked again; a new recovery window starts at the next temporary failure, up to 24 hours by default. The action is audited.", + "retryHelp": "Retry keeps the original recipient account and checks eligibility again. A new recovery window starts at the next temporary failure, up to 24 hours by default, and the action is audited. Expired records cannot be retried.", "column": { "batch": "Batch", "recipient": "Recipient account", @@ -3307,8 +3307,14 @@ "RETRY": "Waiting to retry", "FAILED": "Failed", "UNKNOWN": "Unknown result", - "SUPPRESSED": "Suppressed" + "SUPPRESSED": "Suppressed", + "PREPARING": "Preparing digest", + "MERGED": "Merged into digest" }, - "inAppTitle": "In-app notifications" + "inAppTitle": "In-app notifications", + "mergedHelp": "This batch was merged into another digest. Open that batch to view details you can currently access.", + "viewMergedBatch": "View merged batch #{{id}}", + "mergedTarget": "Merged into #{{id}}", + "retryPolicy": "Temporary SMTP failures use a 24-hour retry window from the first failure by default, not a delivery guarantee. Restarting or adding notifications does not extend it. Permanent rejection fails immediately; unknown results are not automatically resent. An audited manual retry starts a new window." } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index d10fd1ccd..8b862be63 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3272,8 +3272,8 @@ "batchHelp": "Запись не зависит от уведомлений на сайте. Показаны только доступные вам элементы. Принятие SMTP не гарантирует доставку во входящие.", "expired": "Пакет недоступен, срок хранения истёк или он принадлежит другой учётной записи.", "forbidden": "У вас нет доступа к этому почтовому пакету.", - "itemCount": "Уведомлений в пакете: {{count}}", - "unavailableItems": "Удалено или недоступно элементов: {{count}}.", + "itemCount": "Исходных записей в пакете: {{count}}; это не число доступных сейчас элементов", + "unavailableItems": "На этой странице удалено или недоступно записей: {{count}}.", "noItems": "На этой странице нет доступных уведомлений", "openItem": "Подробнее", "adminTitle": "Почтовые бизнес-уведомления", @@ -3291,8 +3291,8 @@ "noDeliveries": "Записей отправки нет", "retry": "Повторить", "retryTitle": "Повторить отправку пакета №{{id}}?", - "duplicateRisk": "Результат предыдущей попытки неизвестен: письмо могло быть принято. Повтор может создать дубликат. Действие будет записано в аудит; права получателя проверяются повторно.", - "retryHelp": "Получатель и содержимое сохраняются; право на получение проверяется повторно. Записи с истёкшим сроком хранения повторно не отправляются.", + "duplicateRisk": "Результат предыдущей попытки неизвестен: письмо могло быть принято. Повтор может создать дубликат. Права проверяются заново; с первой новой временной ошибки начинается новое окно восстановления, по умолчанию до 24 часов. Действие записывается в аудит.", + "retryHelp": "Повтор сохраняет получателя и заново проверяет право на получение. С первой новой временной ошибки начинается новое окно восстановления, по умолчанию до 24 часов; действие записывается в аудит. Истёкшие записи повторно не отправляются.", "column": { "batch": "Пакет", "recipient": "Получатель", @@ -3309,8 +3309,14 @@ "RETRY": "Ожидает повтора", "FAILED": "Ошибка отправки", "UNKNOWN": "Результат неизвестен", - "SUPPRESSED": "Отправка отменена" + "SUPPRESSED": "Отправка отменена", + "PREPARING": "Подготовка дайджеста", + "MERGED": "Объединён с дайджестом" }, - "inAppTitle": "Уведомления на сайте" + "inAppTitle": "Уведомления на сайте", + "mergedHelp": "Этот пакет объединён с другим дайджестом. Откройте итоговый пакет, чтобы увидеть доступные вам сведения.", + "viewMergedBatch": "Открыть объединённый пакет №{{id}}", + "mergedTarget": "Объединён с №{{id}}", + "retryPolicy": "При временном сбое SMTP повторные попытки по умолчанию выполняются в течение 24 часов с первой ошибки; это не гарантия доставки. Перезапуск и новые уведомления не продлевают окно. Постоянный отказ завершает отправку, неизвестный результат автоматически не повторяется. Ручной повтор открывает новое окно и записывается в аудит." } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 7cee6d403..87a88f755 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -3269,8 +3269,8 @@ "batchHelp": "此记录独立于站内通知。仅展示当前仍有权访问的通知;邮件服务器接受不代表已进入收件箱。", "expired": "此批次不存在、已过保留期或不属于当前账号。", "forbidden": "你无权查看此邮件批次。", - "itemCount": "本批次共 {{count}} 条通知", - "unavailableItems": "{{count}} 条内容已删除或当前不可访问。", + "itemCount": "本批次原始记录 {{count}} 条,不代表当前可查看总数", + "unavailableItems": "本页 {{count}} 条内容已删除或当前不可访问。", "noItems": "本页没有可查看的通知", "openItem": "查看详情", "adminTitle": "业务邮件通知", @@ -3288,8 +3288,8 @@ "noDeliveries": "暂无投递记录", "retry": "重试", "retryTitle": "重试邮件批次 #{{id}}?", - "duplicateRisk": "此前投递结果未知,邮件可能已经被接受。继续重试可能产生重复邮件;重试会记录审计且重新检查收件资格。", - "retryHelp": "重试保留原收件人和内容,并重新检查收件资格;已过保留期的记录不能重试。", + "duplicateRisk": "此前投递结果未知,邮件可能已经被接受。继续重试可能产生重复邮件;将重新检查收件资格,从本次首次临时故障起开启默认最长 24 小时的新恢复窗口,并记录审计。", + "retryHelp": "重试保留原收件账号并重新检查收件资格;将从本次首次临时故障起开启新的恢复窗口,默认最长 24 小时,并记录审计。已过保留期的记录不能重试。", "column": { "batch": "批次", "recipient": "收件账号", @@ -3306,8 +3306,14 @@ "RETRY": "等待重试", "FAILED": "发送失败", "UNKNOWN": "结果未知", - "SUPPRESSED": "已停止发送" + "SUPPRESSED": "已停止发送", + "PREPARING": "正在整理摘要", + "MERGED": "已并入摘要" }, - "inAppTitle": "站内通知" + "inAppTitle": "站内通知", + "mergedHelp": "此批次已并入另一封摘要,请打开目标批次查看当前有权访问的明细。", + "viewMergedBatch": "查看合并后的批次 #{{id}}", + "mergedTarget": "并入 #{{id}}", + "retryPolicy": "临时 SMTP 故障默认在首次失败起 24 小时内退避重试,不保证窗口内送达;重启或追加通知不延长窗口。永久拒绝直接失败,结果未知不自动重发;人工重试会另开窗口并记录审计。" } } diff --git a/web/src/pages/notification-email-batch.test.tsx b/web/src/pages/notification-email-batch.test.tsx index f141d3a71..ed97070f1 100644 --- a/web/src/pages/notification-email-batch.test.tsx +++ b/web/src/pages/notification-email-batch.test.tsx @@ -12,14 +12,14 @@ vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ id: '23' }), Link import { EmailBatchDetail } from './notification-email-batch' function setup() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render() } describe('protected email batch details', () => { - beforeEach(() => api.batch.mockResolvedValue({ id: 23, status: 'SENT', createdAt: '2026-10-22T00:00:00Z', items: [{ id: 1, category: 'PUBLISH', occurredAt: '2026-10-22T00:00:00Z', targetPath: '/space/team/test' }], totalItems: 22, unavailableItems: 2, totalPages: 2 })) + beforeEach(() => api.batch.mockResolvedValue({ id: 23, status: 'SENT', createdAt: '2026-10-22T00:00:00Z', items: [{ id: 1, category: 'PUBLISH', occurredAt: '2026-10-22T00:00:00Z', targetPath: '/space/team/test' }], totalItems: 22, unavailableCount: 2, nextCursor: '100' })) afterEach(() => { cleanup(); vi.resetAllMocks() }) it('shows independent batch items, unavailable count and fetches the next page', async () => { setup(); await screen.findByText('notificationEmail.unavailableItems') expect(screen.getByRole('link', { name: 'notificationEmail.openItem' }).getAttribute('href')).toBe('/space/team/test') - api.batch.mockResolvedValue({ id: 23, status: 'SENT', items: [], totalPages: 2 }) + api.batch.mockResolvedValue({ id: 23, status: 'SENT', items: [], nextCursor: null }) fireEvent.click(screen.getByRole('button', { name: 'pagination.next' })) - await waitFor(() => expect(api.batch).toHaveBeenCalledWith('23', 1)) + await waitFor(() => expect(api.batch).toHaveBeenCalledWith('23', '100')) await screen.findByText('notificationEmail.noItems') expect(screen.queryByRole('link', { name: 'notificationEmail.openItem' })).toBeNull() }) @@ -29,8 +29,23 @@ describe('protected email batch details', () => { expect(screen.queryByText('notificationEmail.noItems')).toBeNull() }) it('does not turn an external target into a clickable link', async () => { - api.batch.mockResolvedValue({ id: 23, status: 'SENT', items: [{ id: 1, category: 'PUBLISH', targetPath: '//evil.example' }], totalPages: 1 }); setup() + api.batch.mockResolvedValue({ id: 23, status: 'SENT', items: [{ id: 1, category: 'PUBLISH', targetPath: '//evil.example' }], nextCursor: null }); setup() await screen.findByText('notification.preferences.publish') expect(screen.queryByRole('link', { name: 'notificationEmail.openItem' })).toBeNull() }) + it('can continue past a raw page with no currently visible items', async () => { + api.batch.mockResolvedValue({ id: 23, status: 'SENT', items: [], totalItems: 80, unavailableCount: 20, nextCursor: '200' }) + setup(); await screen.findByText('notificationEmail.noItems') + const next = screen.getByRole('button', { name: 'pagination.next' }) + expect(next.hasAttribute('disabled')).toBe(false) + fireEvent.click(next) + await waitFor(() => expect(api.batch).toHaveBeenCalledWith('23', '200')) + }) + it('shows the root delivery when an older batch has been merged', async () => { + api.batch.mockResolvedValue({ id: 23, status: 'MERGED', items: [], totalItems: 0, nextCursor: null, mergedIntoDeliveryId: 31 }) + setup(); await screen.findByText('notificationEmail.mergedHelp') + expect(screen.getByText('notificationEmail.viewMergedBatch')).toBeTruthy() + expect(screen.getByRole('button', { name: 'pagination.next' }).hasAttribute('disabled')).toBe(true) + }) + }) diff --git a/web/src/pages/notification-email-batch.tsx b/web/src/pages/notification-email-batch.tsx index 383b1ec07..dd101da93 100644 --- a/web/src/pages/notification-email-batch.tsx +++ b/web/src/pages/notification-email-batch.tsx @@ -7,7 +7,6 @@ import { useAuth } from '@/features/auth/use-auth' import { notificationEmailApi } from '@/features/notification/notification-email-api' import { getNotificationQueryKeyScope } from '@/features/notification/notification-session' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' -import { Pagination } from '@/shared/components/pagination' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { withBasePath } from '@/shared/lib/base-path' @@ -20,8 +19,10 @@ export function NotificationEmailBatchPage() { export function EmailBatchDetail({ id }: { id: string }) { const { t, i18n } = useTranslation() const { user } = useAuth() - const [page, setPage] = useState(0) - const query = useQuery({ queryKey: [...getNotificationQueryKeyScope(user?.userId), 'email-batch', id, page], queryFn: () => notificationEmailApi.batch(id, page), enabled: !!user }) + const [cursors, setCursors] = useState<(string | null)[]>([null]) + const [index, setIndex] = useState(0) + const cursor = cursors[index] + const query = useQuery({ queryKey: [...getNotificationQueryKeyScope(user?.userId), 'email-batch', id, cursor], queryFn: () => notificationEmailApi.batch(id, cursor), enabled: !!user }) const batch = query.data return
{t('notificationEmail.back')} @@ -30,10 +31,11 @@ export function EmailBatchDetail({ id }: { id: string }) { {query.isError ?

{t(query.error instanceof ApiError && query.error.status === 404 ? 'notificationEmail.expired' : query.error instanceof ApiError && query.error.status === 403 ? 'notificationEmail.forbidden' : 'notificationEmail.loadFailed')}

: null} {batch ?
{t(`notificationEmail.status.${batch.status}`)}
+ {batch.mergedIntoDeliveryId ?

{t('notificationEmail.mergedHelp')}

{t('notificationEmail.viewMergedBatch', { id: batch.mergedIntoDeliveryId })}
: null}

{t('notificationEmail.itemCount', { count: batch.totalItems })}

- {!!batch.unavailableItems &&

{t('notificationEmail.unavailableItems', { count: batch.unavailableItems })}

} + {!!batch.unavailableCount &&

{t('notificationEmail.unavailableItems', { count: batch.unavailableCount })}

} {!batch.items?.length ?

{t('notificationEmail.noItems')}

:
    {batch.items.map((item) =>
  • {t(`notification.preferences.${item.category?.toLowerCase()}`)}

    {item.targetPath?.startsWith('/') && !item.targetPath.startsWith('//') && !item.targetPath.includes('\\') ? {t('notificationEmail.openItem')} : null}
  • )}
} - +
: null}
}