--- name: "ulthon-notice" description: "站内信与通知系统规范(NoticeService 统一发送、场景注册表、订阅矩阵、四渠道驱动、timer 异步投递)。需要给管理员发通知/消息提醒、新增通知场景、定制订阅、扩展渠道驱动(邮件/短信/公众号)、排查通知投递失败或未读数异常,或使用通知设置(sysconfig notify 组)配置 SMTP 时调用。" --- # notice(站内信与通知系统) 统一入口是 `NoticeService::send()`,业务代码一律 use `app\common\service\NoticeService`(App 层入口,双层铁律)。内核实现见 `extend/base/common/service/NoticeServiceBase.php`。 ## 何时调用 - 业务流程需要给管理员发站内信/邮件通知(如审批结果、预警、公告)。 - 新增通知场景(scene)、调整场景可用渠道、设置强投递(locked)。 - 排查通知未投递、未读数不对、邮件发不出去、投递行卡在待发送。 - 本地联调邮件链路(Mailpit),或对接短信/公众号渠道。 ## 发通知 API 三种姿态 ```php use app\common\service\NoticeService; // 签名(NoticeServiceBase.php:87) // send(array|int|string $adminIds, string $title, string $content, array $options = []): int // $adminIds 支持数组、单个 ID 或 'all'(status=1 全体);返回 notice_id ``` 三种姿态与结果: | 姿态 | 示例 | 渠道结果 | 订阅过滤 | |------|------|---------|---------| | 不传 scene(缺省 common) | `NoticeService::send(1, '标题', '内容')` | common 注册渠道 [inmail, email] | 生效 | | 传注册 scene | `NoticeService::send('all', '库存预警', '...', ['scene' => 'mall_stock'])` | 场景注册渠道,可再传 `channels` 收窄(∩ 交集) | 生效(locked 豁免) | | 传未注册 scene | `NoticeService::send(1, '标题', '内容', ['scene' => 'my_scene'])` | 缺省 [inmail],记 warning 放行 | 生效(但无法在页面退订) | options 还支持 `biz_id`(业务关联串)、`send_admin_id`(发送者,缺省 0)。 ## 渠道解析规则 `send()` 内两段解析(NoticeServiceBase.php:97-109): 1. 第一段:`options.channels` ?? 注册场景的 `scene.channels` ?? `[inmail]` 2. 第二段:注册场景再与 `scene.channels` 取交集(保序去重);locked 强投递同样仅限场景注册渠道,不豁免交集 3. 未注册场景跳过交集,缺省 `[inmail]`,记 `Log::warning` 放行 额外过滤(buildTargets): - email 渠道要求 `system_admin.email` 非空,空则跳过该渠道并记 info(地址来源就是管理员表的 email 字段) - inmail 全局总闸:`sysconfig('notify','notify_inmail_enable',1)`,`(int)` 强转后为 0 则不生成 inmail 行 - sysconfig 陷阱:DB 读出的 value 是字符串,`(int)` 强转后再比较,`'0'===0` 永假 - sms/wechat 的 target_addr 当前无地址解析逻辑(骨架驱动不外呼,见渠道章节) ## 场景注册表与 locked 场景是代码级注册表(`scenes()`,不建数据库表),内置四个: | scene | 标题 | channels | locked | |-------|------|----------|--------| | system | 系统公告 | [inmail] | 是 | | common | 通用通知 | [inmail, email] | 否 | | admin_login | 登录提醒 | [inmail, email] | 否 | | mall_stock | 库存预警 | [inmail, email] | 否 | 扩展新场景:app 层入口类覆盖合并,不要改 extend/base: ```php public static function scenes(): array { return array_merge(parent::scenes(), [ 'my_scene' => ['title' => '我的场景', 'channels' => [self::CHANNEL_INMAIL], 'locked' => false], ]); } ``` locked 语义:订阅设置页恒选中且置灰(必选),发送侧豁免订阅过滤(强投递);但渠道交集规则不豁免。场景拼写错误没有报错,只有 warning 日志,且不进订阅页与场景总览,排障先查拼写。 ## 订阅矩阵语义与过滤规则 - 存储:`system_notice_subscribe`(admin_id x scene x channel,UNIQUE,status 0/1) - 三态判定(`isSubscribed()`):locked 场景恒订阅;表无记录视为默认订阅;仅 status=0(显式退订)过滤掉该渠道 - 个人设置页 `/admin/notice/subscribe` 渲染矩阵(行=场景,列=站内信/邮箱);保存 POST `/admin/notice/saveSubscribe`,参数 `matrix[scene][channel]=1/0` - locked 行提交与否服务端都忽略(不可关闭语义);layui 只序列化勾选的开关,subscribe.js 在提交前把未勾选的补齐为 0 ## 四渠道说明 | 渠道 | 驱动 | 投递方式 | |------|------|---------| | inmail | InMailChannel | 投递即落库:send() 内直接置 status=2 成功终态,无外部依赖 | | email | EmailChannel | 异步:行置 status=0,timer 消费者经 PHPMailer SMTP 真实投递 | | sms | SmsChannel | 骨架:读开关、记日志、恒成功,不外呼 | | wechat | WechatChannel | 骨架:同上 | email 配置(后台"系统配置 > 通知设置",sysconfig notify 组):`notify_email_enable` 仅 '1' 视为启用(缺省关),`notify_smtp_host/port/secure/user/pass/from/from_name`。secure 映射:ssl 隐式 SMTPS、tls 走 STARTTLS、none 明文(驱动会显式关 SMTPAutoTLS,否则 Mailpit 等纯明文服务被自动升级误判);user 留空则不启用 SMTP 认证;异常消息即 PHPMailer ErrorInfo,落 target.error_message(截 500 字符)。 骨架对接指引(驱动方法体顶部 TODO 已写): - sms:用既有 guzzlehttp/guzzle 调短信服务商 API,读 `notify_sms_sign/appid/secret`,target_addr 需自行解析为手机号;服务商失败码抛 RuntimeException 交消费者退避 - wechat:access_token 管理(appid/secret,7200s,需集中缓存防刷)+ 模板消息接口,target_addr 为 openid,errcode 非 0 抛 RuntimeException ## timer 消费与退避排障 注册在 `extend/base/common/command/timer/config.php`: - `notice_consumer`:`/tools/timer.NoticeConsumer/do`,frequency=10,concurrency=2,消费 status=0 且 `next_retry_time<=now` 的行 - `notice_reclaim`:`/tools/timer.NoticeReclaim/do`,frequency=60,concurrency=1,回收 status=1 且 `claim_time < now-600s` 的卡死行(重置回待发送) 消费模式即 [ulthon-queue](../ulthon-queue/SKILL.md) 模式 A:`(status,id)` 复合索引 `idx_status_id`,每批 20,原子抢占(claim_token),成功回写 status=2(WHERE 含 claim_token + status=1 幂等兜底)。 失败退避:`next_retry_time = now + 60 * 2^retry_count`(retry_count 为自增后值,即 120/240/480/960 秒),第 5 次尝试失败置 status=3 终态(error_message 保留最后错误)。 排障路径: 1. 每轮执行结果查 `system_timer_log` 表(task_name 列定位 notice_consumer/notice_reclaim) 2. 手动触发:`php think tools:http:call --url="/tools/timer.NoticeConsumer/do"` 3. 日志位置:timer 进程(CLI)走 `runtime/log/`,web 请求走 `runtime/admin/log/YYYYMMDD.log` 4. 新增/调整 timer 任务后跑一次 `php think timer --local --temp` 同步 system_timer_config,常驻 timer 进程无需重启 红线清单: - QA 造数改 retry_count 时必须同步 next_retry_time(候选查询按它过滤,漏了行永远不被消费);该列 int unsigned,上限 4294967295 - 失败行是终态不自动删除,重发走管理页 `/admin/system.notice/resend`(仅 status=3 可重置) - 后台手动触发重发后靠消费者下一轮拾取,interval 最长 frequency=10 秒 ## target 表清理扩展思路 `system_notice_target` 每通知 x 每人 x 每渠道一行,持续增长。内置不提供清理,扩展思路(未实现,按需自建): - 清理条件建议:已读站内信 + 超期时间,或 notice 主体已软删的连带行(管理页 delete 软删主体,投递行保留不级联) - 分批删除防长事务锁表,循环直到 affected=0: ```sql DELETE FROM ul_system_notice_target WHERE read_time > 0 AND create_time < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 180 DAY)) LIMIT 1000; ``` - 落点形态:参照 NoticeReclaimBase 做一个低频 timer 任务(concurrency=1,run_type=auto 单节点跑),或用迁移脚本一次性清理 ## Mailpit 本地联调方法 Mailpit 是本地 SMTP 捕获容器,收到的邮件不真发出,Web/API 可查: ```bash # 1. 启动(1025 SMTP / 8025 Web+API),端口可按需换,避免与本机占用冲突 docker run -d --name mailpit-notice-qa -p 1025:1025 -p 8025:8025 axllent/mailpit # 2. 后台"系统配置 > 通知设置"保存:notify_email_enable=1、 # notify_smtp_host=127.0.0.1、notify_smtp_port=1025、notify_smtp_secure=none、 # notify_smtp_user 留空(无认证)、notify_smtp_from 填合法地址(如 qa@example.com) # 3. 发一条 email 渠道通知(send 是 PHP API 无 HTTP 端点),并手动触发消费 php think tools:http:call --url="/tools/timer.NoticeConsumer/do" # 4. API 断言收信(Web 界面 http://127.0.0.1:8025 可视化查看) curl -s http://127.0.0.1:8025/api/v1/messages # 5. 用完即删,不留常驻 docker rm -f mailpit-notice-qa ``` 红线:`notify_smtp_from` 缺省值 notice@localhost 在 PHP 8.2 下被 filter_var 判非法(裸域无 TLD),不配置合法地址邮件永远发送失败;`notify_smtp_secure` 必须为 none,否则明文会话被 TLS 升级拒绝。 ## 页面入口与响应形态 - 个人域(auth=false,登录即可,强制 admin_id=当前会话):`/admin/notice/mine` `/read` `/markRead` `/markAllRead` `/unreadCount` `/subscribe` `/saveSubscribe`。URL 动作段保持 camelCase 原样,不做 snake 转换 - 管理页:`/admin/system.notice/index` `/read`(投递明细+失败重发入口)`/delete`(软删)`/modify`(白名单为空,全拒绝)`/resend` `/sceneOverview`(只读场景总览) - 响应形态:success 包装 code=0,业务数据在 data 键,如 unreadCount 返回 `{"code":0,"msg":null,"data":{"count":N}}`;前端判断一律 `res.code === 0`