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
{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`)
- 在线文档: