feat(notification): scaffold verified email delivery settings

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-22 14:08:55 +08:00
parent 4811b96129
commit 73d4379fb6
18 changed files with 676 additions and 0 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-22

View file

@ -0,0 +1,187 @@
## Context
基线:`main@4811b96129802be5028d5e4bc1b7b6a158b5b7bb`。本文件是待评审技术方案,只有“按类别选择即时或摘要”已由用户确认,以下推荐参数及交互尚未实施。
### 已核实的现状
| 事实 | 仓库依据 | 方案影响 |
|---|---|---|
| 只有 PUBLISH、REVIEW、PROMOTION、REPORT 四类 | `server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationCategory.java` | 复用四类,不凭空宣称已有订阅类别 |
| 作者发布、订阅新版、订阅撤回均属于 PUBLISH | `server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java` | 设置页将其说明为“发布与订阅更新”;当前类别粒度不能让两者分别设置频率 |
| 关闭 IN_APP 会提前返回;偏好缺省 true,接口仅支持 IN_APP | `notification/service/NotificationService.java`、`NotificationPreferenceService.java` | 邮件必须独立路由,默认 OFF,不能复用此默认值 |
| 通知监听器采用 @Async + @TransactionalEventListener | `NotificationEventListener.java` | 入口是提交后异步执行,不是业务事务内可靠 outbox |
| UserAccount 只有 email,没有邮箱验证状态或邮箱修订号 | `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java` | 旧账号不能直接推定已验证;地址变化需要可靠失效标记 |
| 外部 claims 的 emailVerified 未形成通用持久证明 | `auth/identity/IdentityBindingService.java`、`auth/entity/IdentityBinding.java` | 本方案不自动信任历史邮箱,先统一完成通知收件验证 |
| JavaMailSender、验证码重置密码、SMTP 部署配置已存在 | `auth/local/PasswordResetService.java`、`docs/19-smtp-password-reset-email-setup.md` | 复用传输,不复用密码重置验证码的用途和消费逻辑 |
| system_setting 有 JSON 值、版本及操作者 | `auth/settings/SystemSetting.java` | 普通全局配置复用表,但不能塞入 auth.local 的两个既有键 |
| 存在扫描任务数据库租约和 SKIP LOCKED 实现 | `app/task/ScanTaskOutboxDispatcher.java`、`infra/jpa/ScanTaskOutboxJpaRepository.java` | 参考领取方式,不复制其事务内网络发布模式 |
| 站内消息可被用户删除,已读/未读有清理策略 | `notification/service/NotificationCleanupTask.java` | 邮件不能依赖站内记录存在或已读状态来决定投递 |
表内缩写路径均相对于 `server/skillhub-* /src/main/java/com/iflytek/skillhub/` 对应模块;完整定位在 `verification.md` 中。
## Goals / Non-Goals
**Goals:** 支持用户按类别选择模式;控制邮件数量;复用现有部署能力;保护邮箱归属、空间权限和业务事务;为每次投递保留可解释状态。
**Non-Goals:** 强制注册验邮、邮箱验证码登录、自动身份合并、收件人任填、全站领域事件可靠化、短信/IM、可编程通知规则、按技能单独建配置、多种每日/每周调度、营销邮件和邮件打开追踪。
## Decisions
### D1. 类别是选择单位,邮件批次不是技能
沿用四类,**全部默认 OFF**,避免升级或平台开启时给旧用户自动订阅。用户可逐类选择:
| 模式 | 对用户的含义 | 数量语义 |
|---|---|---|
| OFF | 此类别不发邮件 | 不生成新邮件项;取消尚未发出的同类项 |
| IMMEDIATE | 发生后尽快发送,不等摘要窗口 | 一个有效通知实例一封;同技能不同事件也可能多封 |
| DIGEST | 等窗口结束后合并发送 | 同一收件人、同一窗口,所有摘要类别最多形成一个逻辑邮件批次 |
设置界面建议给 PUBLISH 标注“适合摘要”,其他三类标注“可选即时”;不自动替用户启用。REVIEW 还包含个人资料审核,邮件模板必须识别事件类型,不能全部叫“技能审核”。
示例:用户将 PUBLISH、REVIEW 设为 DIGEST,09:00—09:15 收到 12 个技能更新和 3 个待审,窗口结束合成 **一封、两个分节、15 条通知**。另一用户将 REVIEW 设为 IMMEDIATE,则其三条待审可产生三封,这由用户选择决定。验证码和密码重置属于安全邮件,不参与该业务摘要或偏好。
不采用“每技能一封摘要”:无法控制大量订阅的邮件数。暂不新增独立 SUBSCRIPTION 类别;若产品之后要求作者发布即时、订阅摘要,必须另行拆分类契约,不能偷偷按 eventType 覆盖用户选择。
### D2. 一个全局摘要窗口;按入箱时间分组,不追溯历史
建议默认窗口 15 分钟,后台允许在 5—1440 分钟内调整。该范围是待评审的新配置设计,不是既有参数。使用 UTC 固定窗口及可注入 Clock;界面、邮件展示明确时间和时区。
- 以邮件项实际持久入箱的时间确定窗口,业务发生时间另存用于展示。延迟到达的事件不回填已发送的过去窗口。
- 同一窗口所有 DIGEST 类别按收件人合并,不能按空间、技能或类别拆成多封,也不能跨用户混发。
- 摘要先封口固定成员,再发信;发送期间新增项不追加到已封口批次。若入箱事务遇到封口,重新计算当前可接纳窗口,不能丢弃或重新打开旧批次。
- 每封建议最多展开 50 条,余下显示数量与**邮件批次自己的受保护详情入口**;统计和全部邮件项仍归属同一批次,不能为了分页再发几封。详情从邮件项分页读取,不依赖站内消息是否开启、存在或已读;每项仍按当前资源权限过滤,已失权/删除的项只显示不可用数量。明细保留期后明确提示已过期,不承诺永久邮件历史。
- 空窗口不发空邮件;某项不再符合资格时剔除,剔除后为空则不发送。
- 改模式只影响之后的新事件;改成 OFF 还会撤销未开始发送的同类项。摘要转即时不把旧摘要批量重新即时发送。
- 改摘要周期不重分配已入箱项;它们沿旧窗口完成,之后新项使用新周期。UI 明示这一过渡,不宣称修改周期瞬间全量重排。
### D3. 复用 SMTP,独立收件验证;不把历史 email 当验证事实
本期建议所有账号均对**当前账号邮箱**完成一次通知收件验证,不自动恢复或推断外部历史验证事实。未来可以接入有来源和版本的持久可信事实,但不阻塞本功能,也不扩大成身份平台。
流程:读取当前邮箱 → 向该地址发送独立验证码 → 验证成功 → 用户选择邮件类别与模式。接口不接受任意收件地址,且从会话取 userId。
验证建议采用 6 位随机码、10 分钟有效、单次最多 5 次错误、60 秒重发冷却及每账号/来源限流;这些是新方案推荐值。只存安全哈希,不存明文码;验证码用途固定为通知收件验证,不得用于登录、重置密码或自动授权。
并发重发仅最新挑战可用;验证成功后消费挑战。挑战生成与发送分开为短事务及事务外 SMTP,失败不产生“已验证”状态,API 显示可重试错误。
为防止 A→B→A 地址变化重新激活旧证明,拟给 `user_account` 增加 `email_revision`:规范化地址实际变化时递增;同地址重存不递增。证明和挑战都绑定 userId、规范化邮箱、该修订号。所有现有写入路径(外部登录、账号合并、管理员初始化、本地初始化)通过同一受控变更入口,在事务内锁定并刷新当前账号行后读改写,不再直接用未锁的旧实体 setter 修改地址;不为了此事给全账号添加会影响无关字段的全局乐观锁。禁止用整条 user.updatedAt 代替邮箱修订号,验证并发更新不会丢修订。
账号合并不迁移通知验邮证明、订阅模式或旧队列给另一用户;被合并账号未发项取消,主账号保留自己的设置。主账号邮箱被替换时修订号递增,必须重验。没有邮箱的用户看到原因及现有账号资料维护入口,不新增任意地址转发。
### D4. 通道独立,并如实界定可靠入口
推荐保持当前业务提交后异步监听的入口;在公共通知编排入口先构造渠道无关的通知命令,再分别处理 IN_APP 和 EMAIL。站内的 early return 只能结束站内分支,不得结束邮件分支。
```mermaid
flowchart TD
A[发布、审核等业务事务提交] --> B[既有提交后异步监听]
B --> C[共享通知命令与收件资格解析]
C --> D[站内分支:按原布尔偏好保存]
C --> E[邮件分支:按类别与验邮状态入箱]
E --> F[即时批次或摘要窗口]
F --> G[短事务领取租约]
G --> H[事务外 SMTP]
H --> I[短事务确认结果]
```
分支调用各自独立事务及异常边界:站内写入/邮件入箱某一路失败不通过同一回滚事务抹掉另一路。原业务事务已提交,SMTP 永不在发布/审核事务内调用。复用事件的收件解析、订阅资格与目标解析,不能复制两套逐渐漂移的权限逻辑。
**可靠性从邮件项成功持久化开始**:入箱之前若异步线程尚未执行进程就崩溃,邮件可能丢失;创建邮件表并不会修复这个现有窗口。记录入箱错误与监控,不承诺业务事件端到端至少一次。
替代方案:将通知意图写进原业务事务,可闭合该窗口,但要改业务事件入口及扇出成本,并明确入箱数据库故障对业务提交的影响。本次不默认引入这一更大改造;若评审要求端到端不丢失,应先修订该决策再实施,不能仅改报告措辞。
### D5. 数据模型:四张邮件表,业务规则放代码
以下名称、字段均为**拟新增**契约,具体迁移编号在实施时按最新 main 分配,不在方案中占号。所有用户 ID 延续 String/128;不新增 SQL CHECK,不以数据库枚举锁死状态机。主键、必要唯一键、FK 和索引属于结构约束。
| 表/变更 | 主要字段 | 结构与职责 |
|---|---|---|
| `user_account.email_revision` | 单调递增邮箱修订号 | 仅地址真实变化递增;旧值初始化 0,不给历史邮箱自动授信 |
| `notification_email_contact` | user_id、verified_email/revision/at、challenge_hash/revision/expires_at、失败次数、冷却时间、locale、version | user_id 唯一;每用户一个当前挑战;不存明文码;状态由当前账号及修订号推导 |
| `notification_email_preference` | user_id、category、mode、version、updated_at | user/category 唯一;缺省 OFF;不改旧布尔偏好表的含义 |
| `notification_email_item` | id、recipient_id、event_key、category/type、必要资源引用、occurred_at、enqueued_at、mode_snapshot、window、delivery_id、status | recipient/event_key 唯一;重复投递不新增;独立于站内消息删除;不保存包、正文、拒绝理由或凭证 |
| `notification_email_delivery` | id、recipient_id、kind、window_key、status、attempts、next_attempt_at、lease_until/token、结果码、accepted_at、created_at | 摘要 recipient/window_key 唯一;即时批次按 item 唯一;明确租约、已接受和未知结果 |
必须使用稳定的**事件发生实例身份**及不可变发生时间,不能用 recipient+skill 或仅 eventType+entityId:同技能不同版本必须都保留,重复分发同一事件才去重。实施时逐一枚举现有事件,能由不可变操作/版本/申请记录唯一标识的用其稳定键及时间;存在合法重复生命周期或缺少稳定时间的事件需在业务发布点增加一次生成、随事件传递的内部 eventId/occurredAt。每种事件的键规则先写测试再接入;禁止每次重试重新生成去重键或把发生时间改为重放时间。
需要 due/status、delivery_id、recipient/window、lease 等索引。已发送/抑制明细和批次建议保留 30 天、终态失败/未知记录 90 天后清理;保留策略必须可由部署参数调整。清理独立于站内 30/90 天规则,不能删除未完成队列。
去重不承诺永久:**新入箱只接纳发生时间距今 7 天内的内部事件**(推荐值,边界含第7天,超出拒绝且计数;显著未来时间亦拒绝)。保留唯一键的最短期限必须覆盖此接纳窗口及允许时钟偏差,配置校验不允许清理期限更短。30天后已清理的旧事件重放,会因原始发生时间超窗被拒,不新增永久账本。已入箱任务的正常重试或人工重试复用原记录,不重新走历史事件入箱、不受这个7天接纳条件误伤;明细已清理后不再支持历史人工重试。
### D6. 封口、租约、SMTP 与去重
1. 入箱检查平台开启、类别非 OFF、当前验邮有效及收件资格;写 item 的唯一键防重。
2. DIGEST 在同一数据库事务中创建/锁定窗口批次并附加 item;封口也锁同一批次。封口后的迟到项转当前开放窗口,不能追加到已发或正在发的集合。IMMEDIATE 为独立 item 创建批次。
3. 到期批次由 `FOR UPDATE SKIP LOCKED` 短事务领取为 CLAIMED,写租约 token 后立即提交;CLAIMED **尚未取得发送授权**。此时 worker 崩溃可在租约后安全重新领取,不在持锁事务中等网络。
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,不叫“送达收件箱”或“已读”。
8. DATA 后超时、发送后进程崩溃等不能确定是否已接受的情况为 UNKNOWN;不盲目自动重发,管理员可显式重试并获知重复风险。固定 Message-ID 仅用于关联,不当作 SMTP 幂等保证。
一个逻辑摘要批次可能因管理员明确重试或 SMTP 不确定性出现重复;不能承诺 exactly-once。两实例竞争、封口与入箱并发、重复事件、取消/重试竞争均须有真实 PostgreSQL 集成测试。
### D7. 安全、邮件内容与权限
同一邮箱恰好出现在不同账号时,也不能合并账号或混合摘要,聚合键是平台 userId;不使用 CC/BCC 向多人广播一份含混合内容的摘要。
默认标题和邮件正文只包含类别、时间、数量以及最少必要信息,不复制站内 bodyJson、审批意见、举报理由、个人资料字段或私有技能详情。详情链接要求重新登录并按当前权限检查。可展示资源名时必须发送时重新检查可见性;不能把入箱时的一次权限检查当永久许可。邮件一旦被服务器接受无法收回,正文保持最小化。
链接只由管理员配置的可信站点根地址与服务器白名单路由构造,不读取 Host、客户端 returnTo 或事件中的任意完整 URL;处理子路径部署、路径编码和中英文模板。每用户单收件人,日志不打印验证码、完整邮件正文或凭证,运维页面收件地址脱敏。
### D8. 配置、API 与页面
复用 `system_setting`,拟新增独立组 `notification.email`,由 app 适配器接入现有设置仓储,notification 不依赖 auth。**这些键均为拟新增**:enabled=false、digestWindowMinutes=15、publicBaseUrl。参数仅给空库初始值,之后以数据库/超级管理员页面为准,关闭后抑制未发项、不补发关闭期间历史。
SMTP host/port/credentials/TLS 继续用部署侧 `spring.mail` 体系,补齐有限网络超时;发件人使用通知专属配置,不挪用 `auth.password-reset` 名义。重试/租约/轮询/批量上限是部署技术参数,不全部堆进用户设置。正常关闭不需要 SMTP 可用;开启前校验必要配置,保存设置不做无界网络探测。
候选 API(均非当前已存在接口,实施前更新 OpenAPI):
| 接口候选 | 用途/权限 |
|---|---|
| `GET/PUT /api/v1/notification-email-preferences` | 当前用户四类模式;新增并行邮件契约,旧站内 API 不变 |
| `GET /api/v1/notification-email-contact` | 当前邮箱脱敏、验邮是否有效、语言、禁用原因;不返回挑战哈希 |
| `POST /api/v1/notification-email-contact/verification-code` | 向当前账号邮箱请求码,认证+CSRF+限流,不接受目标邮箱/目标 userId |
| `POST /api/v1/notification-email-contact/verify` | 消费当前挑战;过期、旧挑战、换址、错误次数超限分别拒绝 |
| `GET /api/v1/notification-email-deliveries/{id}` | 仅收件账号查看自己的邮件批次与分页有效项,独立于站内消息;资源逐项重新鉴权,过期返回明确过期结果 |
| 现有系统配置资源增加通知组 | 仅 SUPER_ADMIN;普通用户最多读取非敏感可用性 |
| 管理投递列表/受限重试接口 | 仅 SUPER_ADMIN,脱敏、分页、审计,不允许借重试改变收件人或内容 |
`/api/web` 别名按项目统一规则同步。服务端从会话确定主体,不相信 payload 的角色或账号。新增枚举只影响新增邮件契约,不让老站内客户端遇到未知 EMAIL 通道。需更新生成 Web 类型和中英文资源。
页面:沿用“通知设置”,站内列保持开关,邮件列使用“关闭 / 即时 / 摘要”选择;顶端显示验邮状态和操作;旁边说明摘要周期。平台关闭时说明不可用,不假装能成功开启。邮件摘要链接打开独立批次明细视图(候选路由 `/dashboard/notifications/email/{id}`),分页、空/已失权/过期均有明确反馈,普通管理员不能借管理列表读取其他用户邮件内容。系统配置只放启停、周期、站点地址等普通设置,SMTP 密钥不入后台。
### D9. 模块与实现边界
- notification:类别模式、contact/item/delivery 模型、仓储和发送端口、摘要/状态规则,不读取 auth settings、不直接访问 Web 会话。
- infra:上述仓储适配及 Spring Mail 发送实现;使用现有受管理依赖,新增直接依赖仅为模块正确声明,不引入厂商 SDK。
- app:从已有通知事件解析共享命令、各渠道独立编排、邮箱修订号调用链协调、controller/DTO、系统配置适配、调度和目标解析。
- domain/auth:仅邮箱修订号规则及必要的内部事件发生身份;禁止通过通知验证改账号身份、角色或登录状态。
- 不修改 CLI、DSH、技能包格式、审核准入和命名空间权限。共享代码抽取须证明原站内通知接收者、分类、内容与清理规则不变。
## Risks / Trade-offs
- [业务提交到异步入箱前可能丢失] → 本次明确继承并监控此边界;若产品要求不丢,先改方案,不能伪称 outbox 修复入口。
- [即时类别仍可能多封] → 用户明确选择;PUBLISH 推荐摘要,不把该类别的全部通知偷偷降级。
- [PUBLISH 混合作者与订阅更新] → UI 明确范围;本期不额外拆类别,后续有需要再定契约。
- [SMTP 发送不确定与租约回收] → UNKNOWN、人工受限重试及风险提示;不宣称恰好一次或保证进入收件箱。
- [邮箱更新/账号合并/权限变化] → 修订号、发送时资格复核、最小正文;已接受邮件无法撤回。
- [摘要聚合并发与过大窗口] → 同批次锁、固定成员、唯一键、最大展示条数、数据库索引与负载测试。
- [设置关闭或格式变化] → 默认关闭、新接口保持兼容、取消未发而不补历史;旧客户端不受影响。
## Migration Plan
1. 最新 main 上分配未占用迁移号,增加表、结构索引和 email_revision=0;不新增 SQL CHECK,不为旧邮箱自动生成证明或偏好。
2. 应用先以 enabled=false 发布,既有站内通知和密码重置回归必须通过;新后台/API 与生成类型同步。
3. 使用本地 SMTP 捕获器覆盖即时/摘要/错误路径;多实例、可控时钟、真实 PostgreSQL 验证封口及去重。部署方显式配置后才可启用。
4. 回退先关闭邮件、停止并等待发送 worker 退出,按已发/未知状态保留审计,不删除表以“回滚”。旧应用不维护 email_revision,因此回退前须通过受控维护操作失效全部通知验邮证明/挑战、抑制未发项;重新升级后保持关闭,用户重验并显式启用,不恢复旧证明。此操作只失效新增通知资格,不删除业务、站内或密码重置数据。以“新应用验邮→回退旧应用改址→重新升级”验证不会用旧证明发信,不能只说旧应用忽略新增列就安全。
5. 后续实施完成后补验收结果;本轮只做方案结构校验和设计审查,不把任务勾选为开发完成。
## 评审状态与资料
本次提交物是可评审方案,不是已批准实施计划。15 分钟、50 条、验证码阈值和保留天数为推荐初值;业务可靠入口及历史邮箱不自动授信的取舍在本文件显式列出,批准前不得悄悄变成另一套保证。
- [Spring 事务事件阶段](https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html):默认 AFTER_COMMIT 及异步入口不能代替持久意图;此引用只说明通用语义,不要求升级仓库 Spring 版本。
- SMTP 网络超时参数按实际依赖版本的 Spring Mail/Jakarta Mail 文档核对后写入部署文档;本方案未将未核实的新环境变量写成既有配置。
- 完整需求、场景及计划测试见两个 delta spec 与 `verification.md`;实施拆解见 `tasks.md`。

View file

@ -0,0 +1,34 @@
## Why
[#978](https://github.com/iflytek/skillhub/issues/978) 希望用户离开网页后仍能收到发布、审核、提升和举报相关通知。现有 SMTP 已用于密码重置,但业务通知只有站内通道;直接逐条转成邮件会造成订阅更新刷屏。
用户已批准按本技术方案实施。按通知类别分别选择即时或摘要,默认关闭;先以本地 SMTP 捕获器验证,不发送真实外部邮件。实现、验证与剩余限制回写本变更;合并仍需单独授权。
## What Changes
- 用户对每个现有通知类别选择 `OFF / IMMEDIATE / DIGEST`,邮件默认关闭,与站内开关独立。
- 摘要按收件人和时间窗口合并,多个技能、多个摘要类别合为一封,类别分节;即时模式仍按通知实例投递,不承诺所有通知都聚合。
- 复用 SMTP,独立验证当前账号的通知收件邮箱,不修改注册、登录准入或账号绑定规则。
- 增加持久化邮件项与投递批次、多实例领取、明确的重试/不确定状态和治理记录;不引入消息中间件。
- 后台普通运行设置复用 `system_setting`,SMTP 凭证继续由部署侧管理;增加用户设置和运维文档。
- 如实保留现有“业务提交后异步产生通知”的入口丢失窗口,可靠性保证从邮件项成功入箱开始,不冒称业务事件端到端不丢失。
## Capabilities
### New Capabilities
- `notification-email-delivery`:类别投递模式、按人聚合、通道隔离、可靠投递边界和运维控制。
- `notification-email-recipient`:当前账号邮箱的独立验证、地址变更失效及发送资格检查。
### Modified Capabilities
无。本仓库尚未将现有站内通知归档为独立主规格;本变更新增邮件规格并明确保留既有站内接口,不借机改写注册/身份规格。
## Impact
- `skillhub-notification`:邮件偏好、收件验证与投递模型、仓储/发信端口和业务规则。
- `skillhub-infra`:新增持久化适配及 SMTP 实现;复用项目已使用的 Spring Mail,不指定新厂商 SDK。
- `skillhub-app`:通知渠道编排、设置与验证 API、调度、邮件目标解析和管理可观测性。
- `skillhub-domain` / `skillhub-auth`:当前邮箱修订号及必要内部事件身份,覆盖外部登录、合并、初始化等已有写入路径;不改变身份匹配、登录或注册规则。
- Web 通知设置、系统配置、OpenAPI/生成类型、数据库新增迁移、Compose/Helm 的配置说明及测试。
- 现有 `/api/v1/notification-preferences` 及 `/api/web/notification-preferences` 的站内布尔偏好保持兼容;新增邮件接口的候选契约见设计。

View file

@ -0,0 +1,108 @@
## Purpose
为现有发布、审核、提升和举报通知增加可选邮件通道,让用户按类别选择即时或摘要并控制收件数量,同时明确站内兼容、业务事务隔离、聚合与重试边界,避免把点击、邮件服务器接受和实际收件混为一谈。
## ADDED Requirements
### Requirement: REQ-001 独立的类别邮件偏好
系统 SHALL 允许当前用户对现有 PUBLISH、REVIEW、PROMOTION、REPORT 类别分别选择 OFF、IMMEDIATE 或 DIGEST;缺省 SHALL 为 OFF,且邮件选择 SHALL 独立于站内偏好。现有站内偏好 API 和布尔语义 MUST 保持兼容。
#### Scenario: SCN-001 关闭站内但开启邮件
- **WHEN** 用户关闭 REVIEW 站内通知、已验证当前邮箱并开启 REVIEW 即时邮件,平台邮件能力可用且产生合法审核事件
- **THEN** 系统只创建邮件投递,不因站内开关提前退出,也不把站内消息写回开启
#### Scenario: SCN-002 升级与平台开启不自动订阅
- **WHEN** 旧用户尚未保存邮件偏好且部署方启用邮件功能
- **THEN** 四类均仍为 OFF,不发送历史消息,不改变站内默认值
### Requirement: REQ-002 按人和窗口聚合摘要
系统 SHALL 将同一收件人、同一开放窗口内所有 DIGEST 类别的邮件项合入一个逻辑批次,按类别分节;不得按技能拆批次、跨用户混发或为了正文分页额外发送多封。空批次 SHALL 不发信,超出展示上限的内容 SHALL 用数量与邮件独立的受保护批次入口表达,不依赖站内消息存在。
#### Scenario: SCN-003 多技能跨类别合并
- **WHEN** 同一用户在一个窗口内收到 12 个技能的发布/订阅通知及 3 条审核通知,两个类别均设为 DIGEST
- **THEN** 窗口结束后只有一个逻辑邮件批次,分两节统计 15 条;不是每技能一封或每类别一封
#### Scenario: SCN-004 空窗口与正文上限
- **WHEN** 窗口无有效项,或有效项超过正文展示上限
- **THEN** 前者不发空邮件;后者仍是一批次,并展示总数及独立的受保护分页批次入口;站内关闭或记录删除后仍可在邮件保留期内读取当前有权查看的项,过期明确提示
#### Scenario: SCN-005 封口与迟到项
- **WHEN** 摘要封口与新项入箱并发,或延迟通知在旧窗口已发送后才入箱
- **THEN** 已封口成员集合不变,新项进入可接纳的新窗口,不丢失、不重开旧批次、不重发旧内容
### Requirement: REQ-003 即时邮件和安全邮件语义
IMMEDIATE SHALL 表示不等待摘要窗口,按独立通知实例尽快异步投递,不保证远端送达时限;验证码和密码重置邮件 SHALL 保持独立安全通道,不受业务摘要偏好影响。PUBLISH 中作者发布与订阅更新同属一类的事实 MUST 向用户说明。
#### Scenario: SCN-006 即时类别仍按事件发送
- **WHEN** 用户选择 REVIEW 为 IMMEDIATE 并收到三个不同审核通知实例
- **THEN** 可以产生三封即时邮件,而不是等摘要;设置页面明确这种数量语义
#### Scenario: SCN-007 不合并安全验证码
- **WHEN** 用户所有业务邮件均为 OFF 或 DIGEST 且请求有效验证码或密码重置
- **THEN** 安全邮件走独立发送流程,不等待摘要,也不能用通知邮箱验证码完成重置密码
### Requirement: REQ-004 配置和偏好变更边界
用户将类别改为 OFF 或平台关闭邮件时,系统 SHALL 停止相应新入箱,并抑制尚未取得发送授权的项;恢复开启 SHALL 不补发关闭期间历史。发送授权提交 SHALL 是不可撤回边界,即使随后才开始 SMTP,也不能保证撤销已授权尝试。IMMEDIATE 与 DIGEST 互换及摘要周期变化 SHALL 只改变新入箱事件的路由,旧项按其原批次完成,界面 SHALL 解释该边界。
#### Scenario: SCN-008 关闭类别或平台
- **WHEN** 已有待发摘要或重试项时,用户关闭其中一类或超级管理员关闭邮件
- **THEN** 尚未取得发送授权的对应项被抑制,其他合格项仍可投递;已授权在途尝试及已被 SMTP 接受的邮件不承诺收回
#### Scenario: SCN-009 变频率不造成历史重放
- **WHEN** 用户切换即时/摘要,或管理员修改摘要周期后再发生新事件
- **THEN** 旧项不被重新复制和批量即时发送,新项采用新规则,原窗口的合法投递不变
### Requirement: REQ-005 提交后处理及可靠性边界
系统 SHALL 仅在原业务事务成功提交后处理业务通知,邮件网络故障 MUST 不回滚已完成的发布、审核或站内通知。持久入箱后的状态 SHALL 能恢复和审计;沿用现有异步入口时 MUST 明确业务提交到入箱之间的丢失窗口,不宣称端到端不丢失。
#### Scenario: SCN-010 业务事务回滚
- **WHEN** 业务事件产生后原事务回滚
- **THEN** 不产生此事件的站内通知或邮件任务,也没有 SMTP 调用
#### Scenario: SCN-011 邮件分支失败与入口窗口
- **WHEN** SMTP 失败或邮件入箱失败,但业务已提交且站内分支可用
- **THEN** 不撤销业务及站内记录,失败可观测;进程在异步入口入箱前崩溃属于明确未闭合的入口风险,不能被记录为持久重试已覆盖
### Requirement: REQ-006 稳定去重与多实例投递
在声明的历史接纳/去重窗口内,同一通知发生实例对同一收件人的重复分发 SHALL 最多产生一个持久邮件项;同技能不同合法实例 MUST 不被合并为同一实例。系统 SHALL 拒绝超出接纳窗口的旧事件入箱,去重记录保留期 MUST 覆盖该窗口及允许时钟偏差;重放不得重写原始发生时间。多实例 SHALL 通过租约和条件确认避免正常路径重复领取,外部网络发送 MUST 不长时间持有数据库事务锁。
#### Scenario: SCN-012 同事件重放与不同事件
- **WHEN** 同事件实例被重复处理,同时存在同技能的另一个版本或另一条合法审核操作
- **THEN** 接纳窗口内重放仅保留一个邮件项,不同实例各自保留;清理后的过期事件因原始发生时间超窗被拒,不能按技能 ID 去掉合法新通知,也不承诺永久去重账本
#### Scenario: SCN-013 并发领取和失效 worker
- **WHEN** 两个 worker 竞争同批次,或旧 worker 租约过期后返回
- **THEN** 正常领取只有一个 owner,旧 token 无法覆盖新状态,SMTP 等待期间其他数据库事务可继续执行;未授权领取超时可安全重领,已授权发送超时进入 UNKNOWN
### Requirement: REQ-007 有界重试和不确定投递
系统 SHALL 对明确临时失败进行有限退避,对永久失败停止重试,对无法判断 SMTP 是否接受的结果标识 UNKNOWN 而非盲目自动重发。SMTP 接受 SHALL 只表示服务器接受,不得宣称已进入收件箱或已读。受限人工重试 SHALL 审计并提示重复风险。
#### Scenario: SCN-014 临时失败与永久拒绝
- **WHEN** 出现明确临时故障,或服务器永久拒绝,或达到最大尝试次数
- **THEN** 分别进入有界重试、终态失败或重试耗尽状态,业务不受影响,运维可定位原因
#### Scenario: SCN-015 接受结果不确定
- **WHEN** SMTP 数据发送后超时或进程在可能已接受后崩溃
- **THEN** 状态为 UNKNOWN,不因租约到期立即重发;人工重试需显式确认重复风险,固定 Message-ID 不被当作恰好一次保证
### Requirement: REQ-008 当前权限与最小邮件内容
取得发送授权时系统 SHALL 重查账号、当前收件证明、通知类别、订阅或管理资格;授权后的在途边界按 REQ-004 执行。邮件 SHALL 不混合不同账号的内容,不复制敏感业务正文,详情链接 SHALL 由可信站点地址和白名单目标构造,并在访问时继续鉴权。
#### Scenario: SCN-016 退订、移除或资源权限变化
- **WHEN** 入箱后、发送授权前用户取消订阅、退出空间、失去审核角色或账号被停用/合并
- **THEN** 未授权项按当前资格抑制,其他合格项保留;不泄露已不可见资源的名称或内容,已授权在途尝试保留不可撤回边界
#### Scenario: SCN-017 恶意内容、链接与用户隔离
- **WHEN** 多账号具有相同邮箱,或事件文本包含 HTML、换行、任意外部链接和私有审批意见
- **THEN** 按平台账号分别聚合,仅向各自验证地址发送,内容安全转义且敏感文本不进入模板;链接不受客户端 Host/returnTo 控制
### Requirement: REQ-009 配置治理和可观测性
普通邮件运行配置 SHALL 由现有系统配置存储管理并受 SUPER_ADMIN 权限保护,部署参数仅提供首次空值初始化;SMTP 凭证 SHALL 留在部署侧。投递列表 SHALL 分页、脱敏,包含积压、失败、重试耗尽及 UNKNOWN 状态,日志 SHALL 不包含验证码、凭证或完整邮件正文。
#### Scenario: SCN-018 默认关闭及普通用户越权
- **WHEN** 系统升级、SMTP 尚未完整配置,或普通用户直接调用全局设置/投递重试接口
- **THEN** 邮件默认关闭且不阻断站内功能,开启缺配置会明确拒绝,越权操作被拒绝且不修改配置或任务
#### Scenario: SCN-019 通道记录生命周期独立
- **WHEN** 用户删除站内消息、站内清理任务执行、邮件历史清理执行或应用回退
- **THEN** 未完成邮件任务不会被站内删除牵连,邮件历史按自身策略清理,回退关闭能力而不删除数据表或重放旧队列

View file

@ -0,0 +1,49 @@
## Purpose
为业务通知提供独立、可审计的收件邮箱归属证明,覆盖旧账号未验证邮箱、验证码用途隔离、并发重发、地址变化和账号合并等情况,同时保留现有注册、登录、密码重置和身份绑定行为。
## ADDED Requirements
### Requirement: REQ-010 当前账号邮箱验证
系统 SHALL 只向当前登录账号的当前邮箱请求通知收件验证码,不接受调用者指定其他账号或任意目标地址;历史账号邮箱和未持久证明的外部声明 MUST 不自动等价于通知验邮成功。完成验证 SHALL 不改变注册、登录准入、账号绑定或权限。
#### Scenario: SCN-020 正常验证及无邮箱账号
- **WHEN** ACTIVE 用户请求通知验邮并正确消费有效挑战,或账号没有邮箱
- **THEN** 前者产生绑定该账号和当前地址修订的通知证明;后者显示缺失原因,不向任意备用地址发信,不自动补建身份
#### Scenario: SCN-021 认证、主体与验证码用途隔离
- **WHEN** 匿名用户、其他账号或使用密码重置码的调用者尝试验证
- **THEN** 请求被拒绝,不生成或消费其他人的通知证明,不创建会话、不重置密码、不授予角色
### Requirement: REQ-011 有期限且有界的挑战
挑战 SHALL 有到期时间、错误次数上限、发送冷却和请求限流,仅保存安全哈希;重发 SHALL 使旧挑战失效,成功后 SHALL 消费。并发验证与重发 MUST 有确定的单一有效结果,发送故障不得标记为已验证。
#### Scenario: SCN-022 过期、错误次数和重发
- **WHEN** 使用到期码、超限错误码、旧码或在冷却内重发
- **THEN** 按对应错误拒绝且不产生证明,最新合法挑战不被旧请求复活,明文码不进入数据库和日志
#### Scenario: SCN-023 并发确认与 SMTP 故障
- **WHEN** 两次确认或确认与重发并发,或发送验证码时 SMTP 不可用
- **THEN** 只有当前挑战可能被成功消费一次,旧挑战不能覆盖新结果;发信失败显示可重试状态但不产生有效验邮证明
### Requirement: REQ-012 地址修订与账号合并
收件证明和挑战 SHALL 绑定平台 userId、规范化地址和邮箱修订;任何实际地址变更 MUST 使旧证明失效,包括 A→B→A。账号合并 SHALL 不迁移另一账号的验邮证明或未发队列;主账号邮箱被替换时 SHALL 重验。
#### Scenario: SCN-024 地址往返与无变化重存
- **WHEN** 邮箱从 A 改为 B 再改回 A,或只是同一规范化地址重复保存
- **THEN** 前者旧 A 的证明不可再用,后者不因无实际变化而无故失效;旧 revision 验证请求和投递均被拒绝
#### Scenario: SCN-025 外部同步、初始化和合并写邮箱
- **WHEN** 外部登录同步、管理员初始化或账号合并修改当前邮箱
- **THEN** 遵循与普通变更相同的修订规则;被合并账号的未发项取消,主账号不继承其订阅或证明
### Requirement: REQ-013 发送时重新确认收件资格
系统 SHALL 在短事务内重查当前 ACTIVE/非合并账号、非空邮箱及匹配修订的有效证明,并取得发送授权;资格变更与发送授权 SHALL 有明确的并发顺序。变更在授权前生效时 MUST 拒绝,授权先提交时后续换址/关闭不能保证撤回已授权尝试。通知设置 SHALL 提供明确状态及该在途边界说明,且站内通知 SHALL 不因此被关闭。
#### Scenario: SCN-026 验证后地址或账号状态变化
- **WHEN** 邮件已入箱,邮箱修订改变、账号停用或证明失效与发送授权并发
- **THEN** 变更先提交时不向旧地址发送,也不自动改投未经验证的新地址;发送授权先提交时该次在途尝试可能发出,界面明确边界,后续尝试均重查;站内通道按原规则运行
#### Scenario: SCN-027 恢复资格不重放旧事件
- **WHEN** 用户重新验邮或管理员重新开启邮件功能
- **THEN** 仅后续符合偏好的新事件可入箱,不自动补发无资格或关闭期间的历史通知

View file

@ -0,0 +1,33 @@
## 1. 方案批准与契约冻结
- [x] 1.1 评审 proposal/design/两个规格及 verification,确认类别共用、摘要周期、默认关闭、入口 best-effort 与 UNKNOWN 取舍;完成标准为批准记录及所有影响实现的分歧已回写 OpenSpec。
- [ ] 1.2 枚举每个现有通知事件的收件人、类别、发生实例去重键和权限规则;以同事件重放/同技能不同操作的对照测试证明不会漏通知。
- [ ] 1.3 确定新增 API/设置/表结构与错误契约;用 OpenAPI 校验、并发迁移编号检查及未新增 SQL CHECK 的审查通过证明完成。
## 2. 收件资格与设置
- [ ] 2.1 实现邮箱修订号,覆盖外部登录、合并和初始化写入,验证 A→B→A、同地址重存及并发更新不接受旧证明。
- [ ] 2.2 实现通知专用验邮及限流,验证过期、错误上限、重发竞争、消费一次、SMTP 故障和跨用户/用途拒绝。
- [ ] 2.3 实现独立邮件偏好和系统配置组,验证四类×三模式、缺省 OFF、普通用户越权及现有 IN_APP API 完全兼容。
## 3. 入箱、摘要与投递
- [ ] 3.1 抽取共享通知命令/收件解析并拆开渠道事务,原站内监听流程回归通过;故障注入证明邮件分支失败不阻断站内及已提交业务。
- [ ] 3.2 实现稳定事件身份/发生时间、有限历史接纳与结构唯一键,真实 PostgreSQL 测试验证窗口内重复只一项、不同版本/操作不丢失,过期重放被拒且保留期不会提前移除去重事实。
- [ ] 3.3 实现窗口附加与封口锁,验证多技能跨类别一封、空窗口、上限不拆信、迟到及封口并发无丢失/重开。
- [ ] 3.4 实现 CLAIMED 与 SENDING 授权线性点、统一锁顺序、事务外 SMTP、有限重试/UNKNOWN 与受限人工重试,双 worker、资格变更两侧屏障、过期 token 和 SMTP 捕获器故障测试通过。
- [ ] 3.5 实现发送前邮箱/权限/订阅复核与安全模板,验证换址、停用、退出、退订、删除、XSS/链接注入和同邮箱不同账号隔离。
- [ ] 3.6 实现独立清理、分页脱敏投递记录和失败指标;证明未完成任务不被清理,日志无验证码/密钥/正文。
## 4. 用户交互与部署文档
- [ ] 4.1 在通知设置加入验邮状态和四类模式选择,现有站内列保持不变;增加独立邮件批次明细视图及分页,验证站内OFF/已删仍能查看有权明细、过期和跨用户拒绝;组件及浏览器验证同时覆盖不可用、保存失败和类别说明。
- [ ] 4.2 在系统配置加入普通邮件设置,不显示 SMTP 密钥;生成 API 类型及中英文资源检查通过。
- [ ] 4.3 补充 Compose/Helm/环境参数、SMTP 超时/发件人、摘要示例、UNKNOWN/重试和故障排查说明;按实际镜像和本地捕获器复测文档命令。
## 5. 验收与交付
- [ ] 5.1 将 verification 中计划矩阵转为当前代码 SHA 的正式测试用例,独立复核类别/边界/失败/权限/状态/并发覆盖,通过后才能执行并填写实际结果。
- [ ] 5.2 执行相关后端/前端/接口生成检查、真实 PostgreSQL/本地 SMTP、多实例租约和精确 SHA Compose 浏览器验收;以日志、邮件内容和数据库最终状态为证据。
- [ ] 5.3 验证默认关闭升级、暂停/恢复、应用回退保表和未完成队列边界;记录仍存在的异步入口丢失窗口,不能把未执行项勾选通过。
- [ ] 5.4 更新用户/运维文档、CI、独立关键阶段 Review 及最终验证报告;明确本地捕获器成功不同于真实企业邮箱送达,发布/合并另行授权。

View file

@ -0,0 +1,107 @@
# #978 验证设计与追踪矩阵
**状态:设计,全部业务用例未执行。** 本轮没有业务代码、数据库迁移、真实 SMTP、浏览器或部署验收。以下是实施后的验收依据,不是“已经通过”的测试报告。
## 1. 源码基线与事实入口
基线 `4811b96129802be5028d5e4bc1b7b6a158b5b7bb`;设计范围为 `proposal.md` 所列模块。
| 源码路径 | 当前事实 | 覆盖需求 |
|---|---|---|
| `server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java` | 提交后异步监听;订阅同属 PUBLISH;多种审核/提升/举报事件 | REQ-001/REQ-003/REQ-005/REQ-006 |
| `server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationService.java` | IN_APP 关闭提前返回 | REQ-001/REQ-005 |
| `server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationPreferenceService.java` | 默认 true,只支持站内布尔值 | REQ-001/REQ-004 |
| `server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationCategory.java` | 四个现有类别 | REQ-001/REQ-003 |
| `server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationCleanupTask.java` | 站内已读/未读独立清理 | REQ-009 |
| `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java` | 无独立邮件证明和地址修订号 | REQ-010/REQ-012/REQ-013 |
| `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java` | 外部可信 claim 可写 email,未存通用持久证明 | REQ-010/REQ-012 |
| `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java` | 合并可替换主账号邮箱 | REQ-012 |
| `server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java` | 初始化可改邮箱 | REQ-012 |
| `server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java` | 本地初始化同样是邮箱写入点 | REQ-012 |
| `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java` | 已有 SMTP,但验证码用途是重置密码 | REQ-003/REQ-010/REQ-011 |
| `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceController.java` | 两个既有站内偏好 API 别名 | REQ-001/REQ-009 |
| `server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ScanTaskOutboxDispatcher.java` | 既有租约/调度参考,不等于邮件实现 | REQ-006/REQ-007 |
| `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ScanTaskOutboxJpaRepository.java` | 可参考真实数据库领取模式 | REQ-006 |
| `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/settings/SystemSetting.java` | 系统普通配置可复用的持久模型 | REQ-009 |
## 2. 正常流程与变更流程
下表每一行均须在实施后保存请求/操作、数据、原始结果及最终状态;同方法的数据变体可合并执行,但不能把正常回归借作新功能证据。所有状态当前均为 **计划/未执行**。
| 用例 | 轨道/场景 | 需求 | 数据和动作 | 独立判据/最终状态 | 验证层 |
|---|---|---|---|---|---|
| TC-001 | 正常:发布→审核→站内查看;SCN-002 | REQ-001/REQ-005 | 邮件总开关关,复测四类站内消息及已读/删除 | 原接收者、分类、权限、站内偏好完全不变;无 SMTP | 集成+浏览器 |
| 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-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-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+浏览器 |
| TC-015 | 系统设置权限;SCN-018 | REQ-009 | 普通/空间管理员/超级管理员、缺SMTP、配置冲突 | 仅超管可写/重试;默认关闭;版本冲突不覆盖新值 | API权限+集成 |
| TC-016 | 清理与升级;SCN-019 | REQ-009 | 站内删除、已读/未读清理、邮件保留期边界与清理后同事件重放;新应用验邮→回退旧应用改址→再升级 | 正常清理不删未完成队列;保留期不得短于接纳窗口,旧事件清理后不能重入箱;回退维护明确抑制队列/失效证明,旧应用改址后不得恢复旧验邮资格,业务数据及站内兼容 | 迁移+集成 |
| TC-017 | 正常验邮;SCN-020 | REQ-010 | 有/无当前邮箱,正确挑战,已验证后开启类别 | 只对当前账号当前地址产生证明,无邮箱不发备用地址 | API+SMTP+浏览器 |
| TC-018 | 主体与用途;SCN-021 | REQ-010/REQ-011 | 匿名、另一用户、伪造userId/收件地址、重置码混用 | 全部拒绝,目标账号/邮箱/角色/会话不变 | 安全负向API |
| TC-019 | 挑战边界;SCN-022 | REQ-011 | 到期前/点上/后、错误4/5/6次、冷却59/60秒、限流 | 明确上限,旧码失效,明文码不落库/日志 | 可控时钟+安全检查 |
| TC-020 | 挑战并发;SCN-023 | REQ-011 | 双确认、重发与确认、SMTP失败 | 仅最新挑战可消费一次,发送失败不等于已验证 | PostgreSQL并发+SMTP |
| TC-021 | 地址修订;SCN-024/SCN-025 | REQ-012 | A→B→A、同地址重存、外部同步/初始化/账号合并 | 所有写入遵循修订号,旧证明失效;不迁移次账号队列/偏好 | 单元+跨模块集成 |
| TC-022 | 队列中换址;SCN-026/SCN-027 | REQ-013 | 入箱后换邮箱、停用/合并/OFF与发送授权双向并发屏障、重新验邮 | 变更先提交则拒绝授权且零SMTP;授权先提交则仅该在途尝试可能发送、后续尝试重查;不改投未验新址,不补历史事件 | 状态机+并发集成 |
## 3. 八类覆盖决定
| 类别 | 计划覆盖 | 说明 |
|---|---|---|
| 主路径 | TC-001/002/003/004/017 | 正常回归与邮件新流程分别举证 |
| 边界 | TC-005/007/019 | 窗口、数量、有效期、错误次数、冷却 |
| 负向 | TC-008/018/019 | 回滚、错误码、非法主体与用途 |
| 特殊/兼容 | TC-003/014/016/021 | 老用户默认值、旧客户端、别名、地址写入与部署子路径 |
| 失败/恢复 | TC-008/011/012/020 | 确定失败恢复与UNKNOWN分别验证,不混称恰好一次 |
| 权限/安全 | TC-013/014/015/018 | 当前资格、越权、最小内容及收件人隔离 |
| 状态迁移 | TC-007/010/012/016/021/022 | 持久队列、开关、邮箱修订及清理终态 |
| 并发/异步 | TC-006/009/010/012/020/021 | 封口、去重、领取、崩溃、挑战与邮箱写入竞争 |
## 4. 六维输入与组合选择
| 维度 | 代表值 | 对应测试 |
|---|---|---|
| 角色/权限 | 匿名、普通用户、技能作者、订阅者、空间管理员、超级管理员、停用/合并账号 | 003/013/015/018/022 |
| 初始状态 | 全新/旧账号、未验/已验/失效、空队列、收集中/已封口/租约中/重试/UNKNOWN/终态 | 005/006/007/010/012/017/021 |
| 输入数据 | 四类别×三模式;0/1/50/51/大量项;单/多技能;同/不同用户;空/恶意文本;地址往返 | 003/004/005/009/014/019/021 |
| 依赖故障 | SMTP连接拒绝/临时/永久/接受未知,数据库入箱失败,进程崩溃 | 008/010/011/012/020 |
| 时间/并发 | UTC窗口边界、延迟入箱、租约到期、确认/重发竞争、邮箱/权限变更与发送竞争 | 005/006/010/012/019/020/021/022 |
| 配置/兼容 | 总开关、SMTP缺失、部署初始化与DB优先级、旧站内API、子路径、中英文、双实例 | 001/003/007/014/015/016 |
不展开全部笛卡尔积:四类×三模式做完整参数化;影响资格的“总开关/邮箱有效/类别非OFF/账号与资源资格”用决策表逐项翻转证明拒绝;多实例和SMTP不确定性单独测试,不能用mock happy path代替。
## 5. 完成门禁和限制
- 设计批准后才实施;每个 REQ 必须有当前 SHA 的 passed 用例,不能靠本文“计划覆盖”通过发布门禁。
- 真实 PostgreSQL 验证唯一键、事务、并发领取与封口;可控时钟验证窗口/重试;本地 SMTP 捕获器验证封数、收件人、内容及失败协议。
- UI/设置/验邮需在精确 SHA 的标准 Compose 预览上用浏览器交互验证,检查页面、网络、最终数据库状态及无未解释错误。
- 实现时按项目 Makefile 运行相关后端/前端/类型生成检查,扩大范围用于共享邮箱修订与事件结构变化;源码测试、镜像启动、SMTP接受、真实收件箱送达分别报告。
- 业务提交至异步入箱前的崩溃窗口仍未闭合,应以故障分析和实际边界记录,而非把它列成“已保证不丢失”。
- 当前没有真实 SMTP 凭证联调、企业邮箱送达、生产部署、性能实测,也没有已实现的新接口/配置;本轮仅可声明文档结构校验与独立设计审查结果。
## 6. 本轮方案检查结果(2026-10-22)
| 检查 | 实际结果 | 证据边界 |
|---|---|---|
| `openspec validate add-notification-email-delivery --strict` | 通过 | 证明规格结构有效,不证明实现正确或产品方案已批准 |
| 追踪与源码引用检查 | 13个唯一REQ、27个唯一SCN全部显式映射到22组计划用例;15个完整源码路径均存在 | 是方案完整性检查,22组业务用例仍全部未执行 |
| 新文档空白检查、实施状态检查 | 6份Markdown无空白错误,实施任务全部保持未勾选 | 对未跟踪新文件使用 `git diff --no-index --check` 检查;其正常差异退出码与空白诊断分开判断 |
| 独立技术方案审查 | `trellis-check /root/review997` 完成一次全文审查及一次仅针对修订的复核;3项major均闭合,无剩余major | 不代替实施后的代码Review、测试、SMTP及运行验收 |
本轮审查及修订:
1. 明确 CLAIMED 与 SENDING 授权线性点、统一锁序、换址/关闭在边界两侧的结果,以及 SENDING 租约过期只能保守进入 UNKNOWN;同步 REQ-004/006/008/013 和 TC-010/022。
2. 摘要溢出改用邮件独立的分页详情,不依赖站内消息开启或保留;同步 REQ-002、候选API、页面任务和 TC-005。
3. 去重改为不可变发生时间与有限历史接纳窗口,保留期限覆盖窗口,清理后的旧事件不能重新入箱;同步 REQ-006、TC-009/016 和任务。
4. 自查补齐旧应用回退不维护邮箱修订号的边界:回退前停worker、使通知证明/挑战失效并抑制队列,再升级不恢复旧证明。
下一门禁是用户评审技术取舍及推荐参数;尚未获得业务实现、发布或外部发信授权。方案文件在独立工作分支保留,未提交、未推送。

View file

@ -0,0 +1,14 @@
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,
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,
locale VARCHAR(8) NOT NULL DEFAULT 'en', version BIGINT NOT NULL DEFAULT 0
);
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,
UNIQUE(user_id,category)
);

View file

@ -0,0 +1,52 @@
package com.iflytek.skillhub.notification.email;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name="notification_email_contact")
public class NotificationEmailContact {
@Id @Column(name="user_id", length=128) private String userId;
@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="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;
@Column(name="challenge_revision") private Long challengeRevision;
@Column(name="challenge_expires_at") private Instant challengeExpiresAt;
@Column(name="failed_attempts", nullable=false) private int failedAttempts;
@Column(name="resend_available_at") private Instant resendAvailableAt;
@Column(name="request_window_start") private Instant requestWindowStart;
@Column(name="request_count",nullable=false) private int requestCount;
@Column(nullable=false,length=8) private String locale="en";
@Version private long version;
protected NotificationEmailContact() {}
public NotificationEmailContact(String userId) { this.userId=userId; }
public boolean verified(String email,long revision) {
return verifiedAt!=null && java.util.Objects.equals(email,verifiedEmail)
&& java.util.Objects.equals(revision,verifiedRevision);
}
public void challenge(String id,String hash,String email,long revision,String locale,Instant now) {
if(requestWindowStart==null || !now.isBefore(requestWindowStart.plusSeconds(3600))) {
requestWindowStart=now; requestCount=0;
}
if((resendAvailableAt!=null && now.isBefore(resendAvailableAt)) || requestCount>=10)
throw new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException("error.notification.email.rateLimited");
this.challengeId=id; this.challengeHash=hash; this.challengeEmail=email;
this.challengeRevision=revision; this.challengeExpiresAt=now.plusSeconds(600);
this.resendAvailableAt=now.plusSeconds(60); this.failedAttempts=0;
this.locale=locale; requestCount++;
}
public boolean accepts(String email,long revision,Instant now) {
return challengeHash!=null && failedAttempts<5 && now.isBefore(challengeExpiresAt)
&& java.util.Objects.equals(email,challengeEmail) && java.util.Objects.equals(revision,challengeRevision);
}
public void wrongCode() { failedAttempts++; }
public void consume(Instant now) { verifiedEmail=challengeEmail; verifiedRevision=challengeRevision;
verifiedAt=now; challengeHash=null; challengeId=null; }
public void failSend(String id) { if(java.util.Objects.equals(id,challengeId)) {challengeHash=null;challengeId=null;} }
public String getChallengeHash(){return challengeHash;}
public String getLocale(){return locale;}
public Instant getResendAvailableAt(){return resendAvailableAt;}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.notification.email;
import java.util.Optional;
public interface NotificationEmailContactRepository {
Optional<NotificationEmailContact> findById(String userId);
NotificationEmailContact lockOrCreate(String userId);
NotificationEmailContact save(NotificationEmailContact contact);
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.notification.email;
public enum NotificationEmailMode { OFF, IMMEDIATE, DIGEST }

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.notification.email;
public record NotificationEmailPolicy(boolean enabled, int digestWindowMinutes, String publicBaseUrl, long version) {}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.notification.email;
public interface NotificationEmailPolicyProvider {
NotificationEmailPolicy current();
NotificationEmailPolicy lockCurrent();
}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.notification.email;
import jakarta.persistence.*;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
@Entity
@Table(name="notification_email_preference",uniqueConstraints=@UniqueConstraint(columnNames={"user_id","category"}))
public class NotificationEmailPreference {
@Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id;
@Column(name="user_id",nullable=false,length=128) private String userId;
@Enumerated(EnumType.STRING) @Column(nullable=false,length=32) private NotificationCategory category;
@Enumerated(EnumType.STRING) @Column(nullable=false,length=16) private NotificationEmailMode mode=NotificationEmailMode.OFF;
@Version private long version;
protected NotificationEmailPreference() {}
public NotificationEmailPreference(String userId,NotificationCategory category){this.userId=userId;this.category=category;}
public NotificationCategory getCategory(){return category;}
public NotificationEmailMode getMode(){return mode;}
public void setMode(NotificationEmailMode mode){this.mode=java.util.Objects.requireNonNull(mode);}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.notification.email;
import java.util.List;
public interface NotificationEmailPreferenceRepository {
List<NotificationEmailPreference> findByUserIdOrderByCategoryAsc(String userId);
List<NotificationEmailPreference> lockAll(String userId);
NotificationEmailPreference save(NotificationEmailPreference preference);
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.notification.email;
public record NotificationEmailRecipient(String userId, String email, long emailRevision, String locale) {}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.notification.email;
public interface NotificationEmailRecipientProvider {
java.util.Optional<NotificationEmailRecipient> current(String userId);
java.util.Optional<NotificationEmailRecipient> lockCurrent(String userId);
}

View file

@ -0,0 +1,34 @@
package com.iflytek.skillhub.notification.email;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class NotificationEmailContactTest {
private final Instant now=Instant.parse("2026-10-22T10:00:00Z");
@Test void proofBindsRevisionAndChallengeIsConsumed(){
var contact=new NotificationEmailContact("user");
contact.challenge("id","hash","a@test.com",1,"en",now);
assertThat(contact.accepts("a@test.com",1,now)).isTrue();
contact.consume(now);assertThat(contact.verified("a@test.com",1)).isTrue();
assertThat(contact.verified("a@test.com",3)).isFalse();
assertThat(contact.accepts("a@test.com",1,now)).isFalse();
}
@Test void expiryAttemptsAndCooldownAreBounded(){
var contact=new NotificationEmailContact("user");contact.challenge("id","hash","a@test.com",0,"zh",now);
assertThatThrownBy(()->contact.challenge("id2","hash2","a@test.com",0,"zh",now.plusSeconds(59))).isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class);
assertThat(contact.accepts("a@test.com",0,now.plusSeconds(600))).isFalse();
for(int i=0;i<5;i++)contact.wrongCode();assertThat(contact.accepts("a@test.com",0,now)).isFalse();
}
@Test void staleSendFailureCannotInvalidateReplacement(){
var contact=new NotificationEmailContact("user");contact.challenge("old","hash","a@test.com",0,"en",now);
contact.challenge("new","hash2","a@test.com",0,"en",now.plusSeconds(60));contact.failSend("old");
assertThat(contact.getChallengeHash()).isEqualTo("hash2");
}
@Test void accountHourlyLimitPersistsAcrossCooldowns(){
var contact=new NotificationEmailContact("user");
for(int i=0;i<10;i++)contact.challenge("id"+i,"hash","a@test.com",0,"en",now.plusSeconds(i*60));
assertThatThrownBy(()->contact.challenge("eleven","hash","a@test.com",0,"en",now.plusSeconds(600))).isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class);
assertThatCode(()->contact.challenge("reset","hash","a@test.com",0,"en",now.plusSeconds(3600))).doesNotThrowAnyException();
}
}