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

9.8 KiB
Raw Blame History

name, description
name description
ulthon-notice 站内信与通知系统规范(NoticeService 统一发送、场景注册表、订阅矩阵、四渠道驱动、timer 异步投递)。需要给管理员发通知/消息提醒、新增通知场景、定制订阅、扩展渠道驱动(邮件/短信/公众号)、排查通知投递失败或未读数异常,或使用通知设置(sysconfig notify 组)配置 SMTP 时调用。

notice(站内信与通知系统)

统一入口是 NoticeService::send(),业务代码一律 use app\common\service\NoticeService(App 层入口,双层铁律)。内核实现见 extend/base/common/service/NoticeServiceBase.php。

何时调用

  • 业务流程需要给管理员发站内信/邮件通知(如审批结果、预警、公告)。
  • 新增通知场景(scene)、调整场景可用渠道、设置强投递(locked)。
  • 排查通知未投递、未读数不对、邮件发不出去、投递行卡在待发送。
  • 本地联调邮件链路(Mailpit),或对接短信/公众号渠道。

发通知 API 三种姿态

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:

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 模式 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:
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 可查:

# 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