Files
ulthon_admin/.agents/skills/ulthon-notice/SKILL.md

168 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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`