From 3b6cb665493b456c64d139c3cd727793b52e4618 Mon Sep 17 00:00:00 2001 From: augushong Date: Mon, 12 Oct 2026 12:59:14 +0000 Subject: [PATCH] =?UTF-8?q?docs(notice):=20=E7=AB=99=E5=86=85=E4=BF=A1?= =?UTF-8?q?=E4=B8=8E=E9=80=9A=E7=9F=A5=E6=9C=BA=E5=88=B6=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=8F=8A=E5=8F=98=E6=9B=B4=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/ulthon-system-config.md | 7 +- .agents/skills/ulthon-notice/SKILL.md | 167 ++++++++++++++++++++++++++ AGENTS.md | 3 + CHANGELOG.md | 1 + 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/ulthon-notice/SKILL.md diff --git a/.agents/rules/ulthon-system-config.md b/.agents/rules/ulthon-system-config.md index ab23157..a23539a 100644 --- a/.agents/rules/ulthon-system-config.md +++ b/.agents/rules/ulthon-system-config.md @@ -65,6 +65,7 @@ POST 到 `system.config/save`,控制器逻辑位于 `extend/base/admin/control extend/base/admin/view/system/config/site.html ← 网站设置 extend/base/admin/view/system/config/logo.html ← LOGO 配置 extend/base/admin/view/system/config/upload.html ← 上传配置 + extend/base/admin/view/system/config/notify.html ← 通知设置 ``` ### 2.2 index.html 结构(Tab 容器) @@ -75,12 +76,14 @@ POST 到 `system.config/save`,控制器逻辑位于 `extend/base/admin/control
  • 网站设置
  • LOGO 配置
  • 上传配置
  • +
  • 通知设置
  • {include file="system/config/site" /}
    - +
    ``` @@ -266,7 +269,7 @@ ThinkPHP 模板解析顺序(先找到先使用): - `extend/base/helper.php`(`sysconfig()` 定义,第 133 行) - `extend/base/admin/controller/system/ConfigBase.php`(配置控制器) - `extend/base/admin/service/TriggerServiceBase.php`(`updateSysconfig()` 清缓存,第 45 行) -- `extend/base/admin/view/system/config/`(框架默认视图:index/site/logo/upload) +- `extend/base/admin/view/system/config/`(框架默认视图:index/site/logo/upload/notify) - `app/admin/model/SystemConfig.php`(配置模型) ## 相关规则与技能 diff --git a/.agents/skills/ulthon-notice/SKILL.md b/.agents/skills/ulthon-notice/SKILL.md new file mode 100644 index 0000000..ac18628 --- /dev/null +++ b/.agents/skills/ulthon-notice/SKILL.md @@ -0,0 +1,167 @@ +--- +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` diff --git a/AGENTS.md b/AGENTS.md index a3bfadc..b215bb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ - 自定义规则索引(.agents/rules/project-*.md 清单) - 框架更新策略(默认对比更新,无需写) --> +本项目当前以 `source/stack/docker-dev` 栈运行:容器 `docker-dev-ulthon_admin-1`(HTTP 端口 8000),MySQL 走容器网络主机名 `mysql`(`docker-dev-mysql-1`),Redis 同栈。php think / phpunit / composer 一律在容器内执行:`docker exec docker-dev-ulthon_admin-1 sh -c "..."`;宿主机 PHP 版本不满足 composer platform 要求,git 等纯文件操作在宿主机仓库根执行即可。 + --- ## 代码分层铁律(Base/App 双层架构) @@ -35,6 +37,7 @@ - 调试与验证优先框架内置命令行工具(见下方快速命令),不借助外部数据库 MCP 或临时脚本 - 项目运行方式未在上方说明时,执行命令前先按 [ulthon-deploy-environment.md](./.agents/rules/ulthon-deploy-environment.md) 判断环境(如根目录有 Docker 编排文件则勿直接宿主机执行 `php think`) - 在线文档: +- 通知/站内信:统一走 `NoticeService::send()`,场景注册/订阅矩阵/渠道驱动见 [ulthon-notice](./.agents/skills/ulthon-notice/SKILL.md) ## 规则与技能 diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e3b3b..ff71322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- **站内信/通知系统**:通知三表(notice/target/subscribe)与管理员 email 字段,`NoticeService::send()` 统一发送入口(代码级场景注册表、订阅矩阵过滤、locked 强投递)。站内信投递即落库;邮件走 PHPMailer SMTP 由 timer 异步消费(指数退避重试、超时回收);短信/公众号为骨架驱动待对接。后台配套:顶栏未读铃铛与轮询、我的消息、订阅设置、通知管理(投递明细/失败重发/场景总览)、系统配置"通知设置"Tab。规范见 [.agents/skills/ulthon-notice/SKILL.md](./.agents/skills/ulthon-notice/SKILL.md)。 - **B-route 多模式运行**:每个运行模式(default/docker-serve/docker-dev/docker-dev-sync/full/author)独立目录,各自直接运行。业务代码始终一套,在仓库根目录。 - default 模式:根目录直接 `php think run` - Docker 模式:`cd source/stack/ && docker compose up -d`