D10 通知与触达 · 设计文档

本域总览见《账号计费中心 · 结构大纲》。本文为该域完整设计:定位与边界 / 角色与依赖 / 数据模型 / 核心流程 / 接口契约 / 关键约束与验收标准。 排序理由:先数据模型(事实结构)→ 再核心流程(运行路径)→ 最后接口契约(流程的对外投影)。


1. 定位与边界

本域回答四件事:通道怎么配、模板怎么管、消息怎么发、发得怎么样。触达形态为短信、邮件、站内信三种。

口径 定论
配置唯一真相 sms_configs / email_configs;发送前实时读启用行,改库即生效、消费方零重启
每通道一行 唯一索引 ux_sms_configs_provider / ux_email_configs_provider;「启用」是多行共存的开关,不是互斥位
选路顺序 启用行按 priority 升序,并列按 created_at、id 升序,顺序稳定可复现
默认通道 启用行中优先级最高者;priority 默认 100(队尾档位),默认通道档位 10,取值 1~10000
场景与模板 场景代码枚举 verify_code / invite / security_notice;短信以 sms_template_bindings(通道 × 场景)为准,邮件 email_templates 按场景单表、缺失回落内置
站内信与凭据 notifications 承载告警送达,已读态组织共享;凭据只在发送或探活瞬间解密成内存快照,响应只出 *_configured,明文永不回传,只写不读

本域不管什么:资金与账务属 D04;计量聚合属 D05;账户状态与准入属 D06;风控与实名属 D09;支付渠道属 D03。运营面端点的语义细节写在本文,索引统一登记在 D11;门户侧消费体验见 D12。

2. 角色与依赖

角色 能力 边界
运营面(渠道运营权限) 通道新建 / 改配 / 启停 / 优先级重排 / 删除 / 探活 / 测试发送;场景绑定与在线模板读写、预览、删除;健康概览与事件查询 不能改冷却态与失败计数(进程内自动态);不能绕过联调白名单实发
计费路由(router)与统一登录服务 经内部发送端点发验证码短信与邮件、读状态概要 不接触凭据,不做选路决策
账户主体 站内信查询与已读、告警阈值订阅开关 订阅开关即阈值列,阈值 0 = 关闭
部署侧定时任务 触发低频维护扫描(告警扫描 + 过期一次性凭证清理) 幂等可重跑

上游依赖:

上游 依赖内容 语义
D04 钱包与账务 customers.cash_balance、customers.balance_alert_threshold 判据为「现金余额 < 阈值」;不读欠费派生标记、不读实时可用额度
平台级运行时配置(platform_settings) 验证码与邀请有效期、手机认证开关 模板正文的有效期以该配置为准,非硬编码常量
对称加密 + 密钥环境注入 access_key_id_encrypted、access_key_secret_encrypted、password_encrypted 明文不入日志、不进响应

下游被引用:

引用方 引用内容 语义
D12 用户自助与门户 notifications 列表与已读、订阅开关 门户是站内信的消费方
D11 运营管理 运营面渠道端点的索引登记 语义细节在本文,索引不重复展开
D09 风控与合规 规则命中后的告警送达面 同为 notifications,type 含 usage_spike

3. 数据模型

3.1 域内关联总览

场景绑定产生事件产生事件归属收件个人收件

sms_configs

uuid

id

PK

string

provider

通道标识,唯一

boolean

is_active

启用参与选路

int

priority

越小越优先

text

access_key_id_encrypted

凭据密文

text

access_key_secret_encrypted

凭据密文

sms_template_bindings

uuid

id

PK

uuid

config_id

FK

string

scenario

string

template_code

渠道侧模板 ID

channel_send_events

uuid

id

PK

string

channel_type

sms / email

uuid

config_id

空 = 末位兜底通道

string

target_masked

入库前已脱敏

string

outcome

ok / fallback_ok / rejected / failed

string

error_kind

boolean

is_test

email_configs

uuid

id

PK

string

provider

smtp 或云通道标识,唯一

boolean

is_active

int

priority

text

password_encrypted

凭据密文

int

daily_limit

customers

notifications

uuid

id

PK

uuid

customer_id

FK

uuid

recipient_user_id

FK

空 = 组织级

string

type

balance_low / usage_spike / org_closed

uuid

group_id

非空 = 组级定位

datetime

read_at

组织共享已读态

users

email_templates

uuid

id

PK

string

scenario

唯一

string

subject_template

text

body_template

boolean

is_enabled

图 1

3.2 域间引用

sms_configs

D04 钱包与账务

channel_send_events

notifications

D11 运营管理

email_templates

D12 用户自助与门户

图 2

customers 与 users 由本域只读引用,不新增外键到本域;授权主体与权限范围由 D11 定义。

3.3 表清单

表 职责 关键字段
sms_configs 短信通道配置,每通道标识一行 provider、is_active、priority、sign_name、template_code、access_key_id_encrypted、sdk_app_id、region、daily_limit、interval_seconds
email_configs 邮件通道配置,每通道标识一行 provider、is_active、priority、smtp_host、smtp_port、encryption、username、password_encrypted、from_email、daily_limit
sms_template_bindings (通道 × 场景)→ 渠道侧模板 ID config_id、scenario、template_code、is_enabled
email_templates 场景维度在线邮件模板,与通道无关 scenario、subject_template、body_template、is_enabled
channel_send_events 降级 / 失败 / 用量可观测的唯一事实流 channel_type、config_id、provider、scenario、target_masked、outcome、fallback_from、error_kind、latency_ms、is_test
notifications 站内信(余额 / 用量 / 关户类告警送达面) customer_id、recipient_user_id、type、group_id、title、content、read_at

3.4 设计约束

  1. 每通道一行:两个唯一索引强制一个通道标识一行;冲突回滚后归一为 409 provider_in_use,非唯一冲突原样抛出、不伪装成「已存在」。
  2. 一通道一场景一行:ux_sms_template_bindings_config_scenario 约束绑定维度;绑定随通道级联删除。email_templates.scenario 唯一,一个场景至多一份在线模板。
  3. 凭据只写不读:凭据列只接受密文写入,读取路径只判非空。
  4. 事件读取走索引:当日配额与健康概览按 ix_channel_events_cfg 聚合,事件倒序浏览走 ix_channel_events_created。
  5. 事件必须脱敏入库:target_masked 在写事件前完成脱敏,本表不存任何可直接触达的地址。
  6. 站内信索引:ix_notif_customer_created、ix_notif_recipient、ix_notif_group_created 分别服务组织级、个人级、组级视角。
  7. 审计同事务:通道与模板的变更动作与 operation_logs 同事务落库;只读动作不留痕。created_by 指向本库 users,跨服务操作人标识无法满足外键时降级记空值。

4. 核心流程

4.1 短信发送:选路、降级与失败记账

"通道""发送侧选路""内部发送端点""调用方(计费路由 / 统一登录服务)""通道""发送侧选路""内部发送端点""调用方(计费路由 / 统一登录服务)"alt["成功"]["业务拒绝或异常"]loop["启用行按优先级升序逐个尝试"]alt["白名单外"]["白名单内"]"手机号 + 场景 + 模板变量""委派发送""联调白名单判定""不选路、不产生事件、不耗配额""sent=false""冷却中 / 配额耗尽 / 场景缺绑定 → 记 rejected 并跳过""单次尝试(首选 5 秒 / 备用 3 秒)""true;事件 ok 或 fallback_ok,清零失败计数""已发送""false 或 raise;事件 rejected 或 failed,累计失败后换下一通道""末位兜底通道(2 秒)""全耗尽则未发送,或上抛最后一个异常""sent=false 或 503 code_send_failed"
图 3

4.2 邮件发送:模板来源与选路

"发送侧选路""模板渲染""调用方""发送侧选路""模板渲染""调用方"alt["任一通道成功"]["无启用行且无兜底"]["网络或传输异常"]alt["场景不在契约内"]["渲染出主题与正文"]"发码形态:按场景解析模板""在线模板启用行优先,缺失或停用回落代码内置""422 scenario_invalid""白名单判定后逐通道尝试""sent=true""409 no_active_provider""503 code_send_failed"
图 4

4.3 熔断与自动回主

否是否是

单次尝试失败

记时间戳并淘汰窗口外的过期时间戳

60 秒窗口内失败 ≥ 5 次

保持参与选路

进入冷却 60 秒并清空失败计数

冷却到期

冷却期内选路直接跳过

清除冷却标记,自动回到优先级原位

发送成功即清零失败计数与冷却标记

图 5

4.4 通道健康徽标与状态流转

启用成为启用行中优先级最高写入更高优先级通道 / 停用冷却到期自动恢复次日零时自然恢复启用

新建(未启用)

standby 可用(备用)

active 生效中

cooldown 冷却中

quota_exhausted 配额耗尽

disabled 已停用

图 6

4.5 当日配额聚合口径

否是是否否是

当日零时(世界时)起的事件

outcome 属于 ok 或 fallback_ok

不计入配额

is_test 为真

按 config_id 聚合计数

计数 ≥ daily_limit

该通道继续参与选路

当日不再选路,触发即记 rejected 并跳过

次日零时自然恢复

图 7

4.6 通道配置生命周期与模板契约

新建:不自动启用,落队尾档位

改配:未传字段不改,凭据传了才替换

探活:只读列真 / 登录类探测,不实际发送

测试发送:指定通道真实发出,仅白名单内

启用:参与选路,保持现优先级落位

优先级整体重排:提交 id 与 priority 列表

停用:允许零行启用,回落部署侧兜底

删除:启用中拒绝,先停用再删

模板契约:verify_code 校验 code + time;invite 校验 code + days;security_notice 校验 contact_type + new_contact

缺失 → 422 template_placeholder_missing;渲染时未提供值的占位符渲染为空串

图 8

4.7 站内信:余额告警扫描与去重

否是是否

扫描启动:筛出阈值大于零的客户

现金余额 < 阈值

跳过

24 小时内已有同类站内信

写入余额不足站内信,返回新建通知数

同一次触发另执行过期一次性凭证清理

图 9

4.8 事件归类、脱敏与探活口径

场景 规则
error_kind 值域 auth / business_rejected / timeout / network,另有 quota(配额耗尽)与 rejected(业务拒绝 / 配置不全)两个非异常分类
归类顺序与日志 凭据类先判,其次 4xx 业务拒绝,最后兜底网络;原始异常完整入日志,事件表只存分类
事件写入 经独立短会话即时落库(不随调用方事务回滚丢失),写入失败只记日志、不阻塞主流程
脱敏(写事件前) 手机号取数字位,前 3 位 + 四掩码 + 后 4 位(不足 7 位则全掩码);邮箱本地名首字符 + 三掩码 + 域名;事件、日志、查询响应一律为脱敏形态
探活与失败文案 只做只读列真与登录类探测,不实际发送、不产生事件、不耗配额;失败文案按特征归一为凭据无效 / 签名不符 / 无权限 / 地址解析失败 / 超时 / 认证失败 / 连接失败,可行动但不透内部细节

5. 接口契约

5.1 内部短信(前缀 /internal/sms,16 条)

方法 路径 语义
GET /internal/sms/status 默认通道概要:启用态 + 通道标识 + 配额上限 + 限速间隔
GET /internal/sms/configs 通道列表:启用行在前按优先级升序,附 health 与 today_sent
POST /internal/sms/configs 新建通道:不自动启用、落队尾档位;标识占用 → 409 provider_in_use
PATCH /internal/sms/configs/{config_id} 改配:未传不改,凭据传了才替换;不存在 → 404 sms_config_not_found
POST /internal/sms/configs/{config_id}/enable 启用参与选路,保持现优先级落位;幂等
POST /internal/sms/configs/{config_id}/disable 停用(允许零行启用);无可用通道时自动关闭手机认证开关
PUT /internal/sms/configs/priorities 优先级整体重排(items:id + priority);重复值 → 422 priority_invalid
POST /internal/sms/configs/{config_id}/activate 一期兼容转发,等价启用
POST /internal/sms/configs/{config_id}/deactivate 一期兼容转发,等价停用
DELETE /internal/sms/configs/{config_id} 删除;启用中 → 409 active_config_delete_forbidden;成功 204
POST /internal/sms/configs/{config_id}/probe 只读探活,不发短信;返回 ok 与归一化 error
GET /internal/sms/configs/{config_id}/templates 场景绑定列表(含全部场景),附 required_placeholders、template_example、placeholder_values
PUT /internal/sms/configs/{config_id}/templates/{scenario} 按场景 upsert 模板 ID 与绑定开关;场景非法 → 404 scenario_not_found
DELETE /internal/sms/configs/{config_id}/templates/{scenario} 删除场景绑定,该通道对此场景回到配置不全降级态;成功 204
POST /internal/sms/configs/{config_id}/test-send 指定通道真发测试短信;白名单外 → 403 target_not_allowed;事件带 is_test,不计熔断与配额
POST /internal/sms/send 统一发送入口:任一通道成功即 sent=true;全耗尽无兜底 → sent=false;异常 → 503 code_send_failed;场景非法 → 422 scenario_invalid

5.2 内部邮件(前缀 /internal/email,17 条)

方法 路径 语义
GET /internal/email/status 默认通道概要:启用态 + 通道标识 + 发件地址 + 配额上限
GET /internal/email/configs 通道列表:启用行在前按优先级升序,附 health 与 today_sent
POST /internal/email/configs 新建通道:按标识分别校验凭据组;占用 → 409 provider_in_use;成功 201
PATCH /internal/email/configs/{config_id} 改配:留空或未传不改,凭据传了才替换;不存在 → 404 email_config_not_found
POST /internal/email/configs/{config_id}/enable 启用参与选路,保持现优先级落位;幂等
POST /internal/email/configs/{config_id}/disable 停用(允许零行启用,回落部署侧兜底);幂等
PUT /internal/email/configs/priorities 优先级整体重排;重复值 → 422 priority_invalid
POST /internal/email/configs/{config_id}/activate 一期兼容转发,等价启用
POST /internal/email/configs/{config_id}/deactivate 一期兼容转发,等价停用
DELETE /internal/email/configs/{config_id} 删除;启用中 → 409 active_config_delete_forbidden;成功 204
POST /internal/email/configs/{config_id}/probe 只读探活:连接加登录判定,不实际发信;返回 ok 与归一化 error
GET /internal/email/templates 在线模板列表(全部场景,未配置为 None 表示回落内置),附契约、示例、内置文案、placeholder_values
PUT /internal/email/templates/{scenario} 按场景 upsert 在线模板;缺必需占位符 → 422 template_placeholder_missing;未知场景 → 404 scenario_not_found
POST /internal/email/templates/{scenario}/preview 假数据渲染返回主题与正文,有效期取真实值;未知场景 → 404 scenario_not_found
DELETE /internal/email/templates/{scenario} 删除在线模板,该场景回落代码内置;成功 204
POST /internal/email/configs/{config_id}/test-send 指定通道真发测试邮件;白名单外 → 403 target_not_allowed;事件带 is_test,不计熔断与配额
POST /internal/email/send 统一发送入口:直发或发码(按模板渲染);无启用行无兜底 → 409 no_active_provider;场景非法 → 422 scenario_invalid;异常 → 503 code_send_failed

5.3 内部通道与告警扫描(前缀 /internal/channels、/internal/alerts)

方法 路径 语义
GET /internal/channels/health 两类通道合并健康概览:徽标、当日用量、配额上限、冷却态与窗口内失败数,附近 24 小时降级次数与最近错误(脱敏)
GET /internal/channels/events 发送事件查询:创建时间倒序取最近 N 条(上限 200),可按通道类型过滤;目标一律脱敏
POST /internal/alerts/scan 低频维护扫描:余额阈值告警(24 小时去重)+ 过期一次性凭证清理;返回新建通知数与清理数,幂等

5.4 运营面通道(前缀 /api/account/ops/channels,32 条)

方法 路径 语义
GET /api/account/ops/channels/sms 短信通道列表(附徽标与当日用量)
POST /api/account/ops/channels/sms 新建短信通道
PATCH /api/account/ops/channels/sms/{config_id} 编辑短信通道(凭据留空不改)
POST /api/account/ops/channels/sms/{config_id}/enable 启用短信通道
POST /api/account/ops/channels/sms/{config_id}/disable 停用短信通道
PUT /api/account/ops/channels/sms/priorities 短信通道优先级整体重排
POST /api/account/ops/channels/sms/{config_id}/activate 一期兼容转发,等价启用
POST /api/account/ops/channels/sms/{config_id}/deactivate 一期兼容转发,等价停用
POST /api/account/ops/channels/sms/{config_id}/probe 短信通道只读探活
DELETE /api/account/ops/channels/sms/{config_id} 删除短信通道(启用中拒绝)
POST /api/account/ops/channels/sms/test-send 一期兼容路径:经统一发送入口真发一条验证码短信
POST /api/account/ops/channels/sms/{config_id}/test-send 对指定短信通道真实发测试短信(可选场景)
GET /api/account/ops/channels/sms/{config_id}/templates 该通道的场景绑定列表(含契约与变量当前值)
PUT /api/account/ops/channels/sms/{config_id}/templates/{scenario} 按场景 upsert 渠道侧模板 ID
DELETE /api/account/ops/channels/sms/{config_id}/templates/{scenario} 删除短信场景绑定
GET /api/account/ops/channels/email 邮件通道列表(附徽标与当日用量)
POST /api/account/ops/channels/email 新建邮件通道
PATCH /api/account/ops/channels/email/{config_id} 编辑邮件通道(凭据留空不改)
POST /api/account/ops/channels/email/{config_id}/enable 启用邮件通道
POST /api/account/ops/channels/email/{config_id}/disable 停用邮件通道
PUT /api/account/ops/channels/email/priorities 邮件通道优先级整体重排
POST /api/account/ops/channels/email/{config_id}/activate 一期兼容转发,等价启用
POST /api/account/ops/channels/email/{config_id}/deactivate 一期兼容转发,等价停用
POST /api/account/ops/channels/email/{config_id}/probe 邮件通道只读探活(连接加登录,不实际发信)
DELETE /api/account/ops/channels/email/{config_id} 删除邮件通道(启用中拒绝)
POST /api/account/ops/channels/email/{config_id}/test-send 对指定邮件通道真实发一封测试邮件
GET /api/account/ops/channels/email/templates 邮件在线模板列表(未配置场景以代码内置兜底)
PUT /api/account/ops/channels/email/templates/{scenario} 按场景 upsert 在线模板(占位符契约校验,缺失 422)
POST /api/account/ops/channels/email/templates/{scenario}/preview 渲染预览(后端假数据渲染,只读)
DELETE /api/account/ops/channels/email/templates/{scenario} 删除在线模板(回落代码内置)
GET /api/account/ops/channels/health 两类通道合并健康概览
GET /api/account/ops/channels/events 发送事件查询(最近 N 条倒序,目标脱敏)

运营面与内部面共用同一套实现(单一真相不双轨):运营面以门户令牌与渠道运营权限码鉴权,内部面以内部令牌鉴权;语义以 5.1~5.3 为准,端点索引登记见 D11。

6. 关键约束与验收标准

6.1 约束

  1. 顺序可复现:选路与列表顺序均由「优先级升序 + 创建时间升序 + 标识升序」共同确定,同一批通道的尝试顺序唯一。
  2. 降级必有痕迹:每次降级成功写 fallback_ok 并记录原定通道;每次跳过(冷却、配额、缺绑定)都能被事件或徽标解释。
  3. 配额只算成功:失败、拒绝与测试发送都不消耗通道当日配额;配额是「已成功触达」的计数,不是调用次数。
  4. 白名单是硬闸:发送路径与测试发送共用同一判定,白名单外一律不实发,且不进入选路、不产生事件、不耗配额。
  5. 错误分类不掩盖真相:四类错误必须分开落库;配置类问题(缺绑定、凭据不全、配额耗尽)与通道故障(超时、网络)分开,前者不计熔断。
  6. 凭据只写不读:任何读接口不得回传凭据明文,其存在范围限于单次发送或探活调用的内存快照。
  7. 脱敏不可逆:事件、日志与查询响应中的地址一律为脱敏形态,且脱敏在写事件之前完成。
  8. 模板契约前置:邮件的必需占位符在保存时校验,短信的必需变量在配置界面前置展示;渲染期未提供的占位符渲染为空串。同一场景的短信与邮件变量名完全一致,邮件超文本与纯文本版本逐句一致。
  9. 无通道不谎报:无可用通道时如实返回未发送,非开发环境绝不返回成功。
  10. 变更留痕且探活不发送:通道与模板的创建、改配、启停、调序、删除、绑定变更、测试发送均与操作日志同事务落库;只读动作不留痕,探活不产生事件、不耗配额、不写审计。

6.2 验收标准

# 验收口径(可实测)
1 建两条短信通道并分别启用,二者均进入选路;以同一标识再次新建返回 409 provider_in_use
2 两条启用通道优先级为 20 与 10,列表中优先级 10 的行 health 为 active,另一行为 standby
3 改错首选通道凭据后发码,事件流出现 outcome=rejected,最终成功事件 outcome=fallback_ok 且带 fallback_from
4 对首选通道连续制造 5 次失败后,60 秒内其 health 为 cooldown 且选路跳到备用通道;冷却到期自动恢复为 active
5 某通道当日上限设为 2,成功发送 2 条后第 3 条不再走该通道并出现 error_kind=quota;测试发送 3 次后 today_sent 不变
6 停用全部短信通道且无兜底配置时发送接口返回未发送;开发环境取码只在日志出现掩码手机号与场景,正文不出现
7 白名单外号码走发送与测试发送都不实发:测试发送返回 403 target_not_allowed,发送返回未发送且事件流无新增
8 优先级重排提交重复值返回 422 priority_invalid;提交合法列表后越靠前的通道先被尝试
9 删除启用中的通道返回 409 active_config_delete_forbidden;停用后再删返回 204,其场景绑定随通道级联消失
10 删除某通道的验证码场景绑定后,该通道对该场景记 rejected 并降级,但失败计数不增加、不进入冷却
11 保存缺少必需占位符的邮件模板返回 422 template_placeholder_missing 并列出缺失变量名;补齐后保存成功,预览返回渲染结果;删除在线模板后该场景回落代码内置模板
12 事件与查询响应中的地址全为脱敏形态,任何接口都不回传凭据明文、只回 *_configured 布尔
13 探活返回成功率与归一化错误文案,不产生事件、不改当日用量;客户阈值为 0 时不产生余额告警,阈值大于零且现金余额低于阈值时产生一条余额不足站内信,24 小时内重复扫描不再新增,同一扫描端点连续调用两次第二次新建通知数为 0

6.3 待后续设计细化的开放项

  1. 多副本熔断口径:冷却与失败计数为进程内内存态,负载不均时可能出现「同一通道在一个副本冷却、在另一个副本仍被选中」;共享态方案需单独立项,并明确读取失败时的降级语义。
  2. 事件长周期观测:保留窗口为固定值,未设计按通道分级的保留策略与聚合归档,长周期降级率分析缺数据底座。
  3. 个人级已读与模板版本:已读态为组织共享,个人视角的细粒度已读待演进;在线模板修改为原地覆盖,未设计版本留存与一键回滚。
  4. 配额与限速的执行面:配额在发送侧执行、限速由消费方读取后自行执行,两者的联合语义与违约处置待明确。