Browse Source

fix(s8): 首页告警改读异常权威表并提供模块灯态

chore: bump version server 1.0.596
Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 6 hours ago
parent
commit
ccdd2721a0

+ 1295 - 0
doc/plan/S8九宫格异常权威数据与模块灯态根治执行任务书.md

@@ -0,0 +1,1295 @@
+# S8 九宫格异常权威数据与模块灯态根治执行任务书
+
+> 文档状态:已执行  
+> 编制日期:2026-09-30  
+> 执行版本:server `1.0.596`、Web `2.4.415`  
+> 当前基线:server `1.0.595`、Web `2.4.414`、HEAD `294e32e1d`  
+> 适用入口:FUNC-S9-005 九宫格智慧运营看板、S8 异常监控看板、FUNC-S8-008 异常列表  
+> 目标读者:可直接修改本仓库并执行测试、构建、提交和推送的其它大模型/开发者
+
+---
+
+## 0. 执行前硬约束
+
+1. 先读取 `AGENTS.md` 及 `.cursor/rules/` 下全部 `alwaysApply` 规则。
+2. 修改前重新核对 HEAD、版本和全部工作区状态;不得覆盖或提交他人改动。
+3. 按仓库协作规则先向用户列出精确改动清单,得到确认后再应用补丁。
+4. 本任务默认交付根治方案,禁止:
+   - 给 `ado_s8_alert_record` 回填数据;
+   - 新增异常到旧表的双写链路;
+   - 把“未监测”“规则暂停”“检测过期”显示成绿色;
+   - 用 KPI 是否达标推断 S8 监控灯态;
+   - 用 `last_detected_at` 或每次轮询产生的 `REFRESHED` 次数统计“每日新增异常”;
+   - 直接修改 `ado_s8_exception`、`ado_s8_watch_rule` 业务数据来让页面变色;
+   - 复制其它租户异常或监控规则;
+   - 在前端写死 S1–S7 红黄绿状态。
+5. 若实际基线已不再是 server `1.0.595` / Web `2.4.414`,版本号按当前版本顺延,不得覆盖或降号。
+6. 本任务涉及前后端运行代码。执行提交时:
+   - server patch `+1`;
+   - Web patch `+1`;
+   - 锁文件若因版本更新发生变化,须与 `package.json` 一并提交。
+7. 用户说“提交”即 commit + push。不得使用 `git add .`,不得提交:
+   - `bin/`、`obj/`、`TestResults/`;
+   - `_tmp_*.log`、`frontend-dev.log`;
+   - `~$*.docx`;
+   - 口令、连接串、JWT、Cookie 或其它秘密。
+
+---
+
+## 1. 任务目标
+
+本任务同时根治两个同源问题:
+
+1. 九宫格 S8 区域显示“暂无异常告警”和 7 日 0 趋势,但 S8 当前异常主表实际存在未关闭异常。
+2. S8 区域内 S1–S7 七个模块灯全部硬编码为 `unknown`,不能表达:
+   - 当前是否有未关闭异常;
+   - 是否存在严重异常;
+   - 模块是否配置监控;
+   - 监控规则是否正常、暂停、失败或陈旧。
+
+最终要求:
+
+- S8 告警列表、区间汇总、每日趋势统一读取当前异常权威表 `ado_s8_exception`。
+- S1–S7 灯态使用“双维度表达”:
+  - **灯芯**:业务异常状态;
+  - **外圈/徽标**:监控运行状态。
+- 用户点击模块灯后进入 FUNC-S8-008 异常列表,并按对应 `stageCode=S1..S7` 自动过滤。
+- 所有数字都来自真实异常和真实规则状态,不造数、不回填旧表。
+
+---
+
+## 2. 已取证事实
+
+### 2.1 代码层
+
+#### 2.1.1 九宫格 S8 当前读取旧表
+
+调用链:
+
+```text
+Web/src/views/dashboard/home.vue
+  ├─ fetchS8Alerts()
+  │    └─ GET /api/AidopKanban/s8-alerts
+  └─ fetchS8HomeTrend()
+       └─ GET /api/AidopKanban/s8-home-trend
+```
+
+后端 `AidopKanbanController.cs` 中:
+
+- `GetS8Alerts` 查询 `ado_s8_alert_record`;
+- `GetS8AlertSummary` 查询 `ado_s8_alert_record`;
+- `GetS8HomeTrend` 查询 `ado_s8_alert_record`。
+
+全仓现状:
+
+- `ado_s8_alert_record` 只有读取方;
+- 当前 `server/` 下无正常业务 INSERT 产出链;
+- 因而该表不能作为当前 S8 告警 Authority。
+
+#### 2.1.2 当前异常 Authority 已是 `ado_s8_exception`
+
+下列现行能力均读取或写入 `ado_s8_exception`:
+
+- `S8MonitoringService`;
+- `S8DashboardService`;
+- `S8ExceptionService`;
+- `S8KpiSnapshotService`;
+- `S8OrderFlowService`;
+- 智慧诊断 S8 证据;
+- 规则检测、异常建单、处理闭环。
+
+实体:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs
+```
+
+关键字段:
+
+- `tenant_id`
+- `factory_id`(S8 tenant-only 终态下恒为全局工厂值 `0`)
+- `title`
+- `description`
+- `status`
+- `severity`
+- `created_at`
+- `closed_at`
+- `last_detected_at`
+- `module_code`
+- `stage_code`
+- `related_object_code`
+- `source_object_type`
+- `source_object_id`
+- `source_payload`
+
+#### 2.1.3 七个灯当前全部硬编码
+
+`Web/src/views/dashboard/home.vue` 中 `s8ModuleStatus` 固定为:
+
+```ts
+[
+  { code: 'S1', level: 'unknown', route: 's1' },
+  // ...
+  { code: 'S7', level: 'unknown', route: 's7' }
+]
+```
+
+现状行为:
+
+- 颜色统一中性灰;
+- tooltip 为“未监测(暂无模块健康数据)”;
+- 不请求后端;
+- 点击只跳普通模块页面。
+
+#### 2.1.4 异常列表后端已支持 `StageCode`
+
+`AdoS8ExceptionQueryDto.StageCode` 已存在,`S8ExceptionService` 已按:
+
+```csharp
+e.StageCode == q.StageCode
+```
+
+过滤。
+
+但 `S8ExceptionListPage.vue` 当前:
+
+- 未从路由读取 `stageCode`;
+- `loadList()` 未把 `stageCode` 传给 `s8ExceptionApi.list()`。
+
+因此灯态下钻仍需补前端路由接收。
+
+### 2.2 配置层
+
+2026-09-29 对租户 `838257186181189` 的只读取证:
+
+| 项目 | 结果 |
+|---|---:|
+| S8 场景 | 7 条启用 |
+| 监控指标 | 平台/租户合计 13 条,8 条启用 |
+| 监控规则 | 3 条 |
+| 启用且可运行规则 | 2 条 |
+| S1 规则 | 启用,最近 `SUCCESS` |
+| S2 规则 | 启用,最近 `SUCCESS` |
+| S4 规则 | 停用/自动暂停 |
+| S3/S5/S6/S7 | 没有启用规则 |
+
+这证明“页面为空是因为完全没有监控项”不成立。
+
+### 2.3 数据层
+
+同一租户只读取证:
+
+| 表/事实 | 结果 |
+|---|---:|
+| `ado_s8_alert_record` 当前有效行 | 0 |
+| `ado_s8_exception` 未删除异常 | 34 |
+| S1 `FOLLOW` 未关闭异常 | 2 |
+| S2 `SERIOUS` 未关闭异常 | 3 |
+| S4 `SERIOUS` 未关闭异常 | 29 |
+
+规则检测日志最近持续产生 `REFRESHED`,说明检测链在运行;首页为空是读错 Authority,不是检测链完全没有数据。
+
+### 2.4 可重跑基线 SQL
+
+执行前必须重新运行并保存结果。只允许 SELECT:
+
+```sql
+-- 1. 旧表是否仍为空
+SELECT COUNT(*) AS active_alert_count,
+       MAX(alert_time) AS latest_alert_time
+FROM ado_s8_alert_record
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND is_active = 1;
+```
+
+```sql
+-- 2. 当前异常按阶段和严重度分布
+SELECT stage_code,
+       severity,
+       COUNT(*) AS cnt,
+       MAX(COALESCE(last_detected_at, created_at)) AS latest_seen
+FROM ado_s8_exception
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND status NOT IN ('CLOSED', 'REJECTED', 'RECOVERED')
+GROUP BY stage_code, severity
+ORDER BY stage_code, severity;
+```
+
+```sql
+-- 3. 规则覆盖和运行态
+SELECT stage_code,
+       enabled,
+       COUNT(*) AS rule_count,
+       MAX(last_run_at) AS latest_run,
+       GROUP_CONCAT(DISTINCT IFNULL(last_status, 'NULL')) AS statuses,
+       MAX(paused_until) AS max_paused_until
+FROM ado_s8_watch_rule
+WHERE tenant_id = 838257186181189
+GROUP BY stage_code, enabled
+ORDER BY stage_code, enabled;
+```
+
+```sql
+-- 4. 最近 24 小时检测结果;REFRESHED 只能证明检测刷新,不能作为新增异常趋势
+SELECT rule_code,
+       detect_result,
+       COUNT(*) AS cnt,
+       MAX(detected_at) AS latest_detected
+FROM ado_s8_detection_log
+WHERE tenant_id = 838257186181189
+  AND detected_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)
+GROUP BY rule_code, detect_result
+ORDER BY rule_code, detect_result;
+```
+
+---
+
+## 3. 冻结架构决策
+
+执行者不得在实现时重新设计以下口径。
+
+### 3.1 唯一 Authority
+
+| 能力 | Authority |
+|---|---|
+| 当前异常、异常标题、严重度、状态 | `ado_s8_exception` |
+| 模块监控覆盖、启停、最近运行结果 | `ado_s8_watch_rule` |
+| 规则每次检测审计 | `ado_s8_detection_log` |
+| 旧首页告警表 | `ado_s8_alert_record`,停止作为运行期 Authority |
+
+禁止给旧表补写入链路。旧表是否后续物理删除属于独立迁移任务,本任务不 DROP。
+
+### 3.2 三种时间语义必须分开
+
+1. **重点告警列表排序时间**
+   - `COALESCE(last_detected_at, created_at)`;
+   - 用于把最近仍被命中的当前异常排到前面。
+2. **今日/区间新增预警数**
+   - 只按 `created_at`;
+   - 表示该窗口内新建了多少异常。
+3. **每日异常趋势**
+   - 只按 `DATE(created_at)`;
+   - 表示每日新增异常数。
+
+禁止:
+
+- 用 `last_detected_at` 聚合趋势;
+- 把每轮 `REFRESHED` 算成一条新异常;
+- 用当前 34 条异常的最后刷新时间全部堆到今天。
+
+### 3.3 当前异常集合
+
+灯态和“重点告警列表”的当前异常定义固定为:
+
+```text
+is_deleted = 0
+AND status NOT IN ('CLOSED', 'REJECTED', 'RECOVERED')
+```
+
+说明:
+
+- `RESOLVED`、`PENDING_VERIFICATION` 仍未闭环,应继续属于当前异常;
+- `CLOSED` 已闭环;
+- `REJECTED` 不应继续作为当前有效异常;
+- `RECOVERED` 在部分现行服务中仍作为兼容状态使用,即使字典未展示,也必须排除。
+
+区间汇总和历史趋势不排除已关闭项,因为它们统计的是窗口内“新增异常”,不是当前未闭环存量。
+
+### 3.4 S8 tenant-only 作用域
+
+S8 当前终态是租户级规则和异常:
+
+- 租户来自认证上下文;
+- 不信任客户端 `tenantId`;
+- `factory_id=0` 是租户级兼容值;
+- 新接口不要再把页面传入的 `factoryId=1` 当作过滤 Authority。
+
+禁止为让数据出现而写:
+
+```sql
+factory_id IN (0, @factoryId)
+```
+
+并把它当成长期口径。应明确按 tenant-only Authority 查询。
+
+### 3.5 严重度映射
+
+S8 当前业务枚举只有:
+
+- `FOLLOW`:关注;
+- `SERIOUS`:严重。
+
+保持现有前端告警行契约:
+
+| S8 severity | API level | 前端颜色 |
+|---|---|---|
+| `SERIOUS` | `critical` | 红 |
+| `FOLLOW` | `medium` | 黄 |
+
+兼容历史值时必须调用或等价复用 `S8SeverityCode.Normalize`,不得另写一套互相矛盾的映射。
+
+---
+
+## 4. S1–S7 双维度灯态设计
+
+### 4.1 为什么不能只做一个红黄绿字段
+
+单一颜色无法区分:
+
+- 没有异常且监控正常;
+- 没有异常但根本没有监控;
+- 异常仍存在但规则已暂停;
+- 异常仍存在且监控结果已经过期。
+
+因此必须拆成:
+
+```text
+灯芯 businessState:业务异常状态
+外圈 monitorState:监控运行状态
+```
+
+### 4.2 灯芯 `businessState`
+
+固定枚举:
+
+```text
+CRITICAL
+WARNING
+NORMAL
+UNKNOWN
+```
+
+判定优先级:
+
+1. 当前异常中 `SERIOUS > 0` → `CRITICAL`
+2. 否则当前异常中 `FOLLOW > 0` → `WARNING`
+3. 否则 `monitorState == HEALTHY` → `NORMAL`
+4. 其它情况 → `UNKNOWN`
+
+对应展示:
+
+| businessState | 灯芯 | 语义 |
+|---|---|---|
+| `CRITICAL` | 红 | 有严重未关闭异常 |
+| `WARNING` | 黄 | 有关注未关闭异常,无严重异常 |
+| `NORMAL` | 绿 | 有有效监控且新鲜,当前无未关闭异常 |
+| `UNKNOWN` | 灰 | 不能证明正常 |
+
+硬规则:只有 `monitorState=HEALTHY` 且当前异常为 0 时才允许绿色。
+
+### 4.3 外圈 `monitorState`
+
+固定枚举:
+
+```text
+HEALTHY
+DEGRADED
+PAUSED
+UNMONITORED
+```
+
+规则级健康定义:
+
+```text
+runnable =
+  enabled = 1
+  AND (paused_until IS NULL OR paused_until <= now)
+
+freshWindowSeconds = MAX(poll_interval_seconds * 2, 600)
+
+healthyRule =
+  runnable
+  AND last_status = 'SUCCESS'
+  AND last_run_at IS NOT NULL
+  AND now - last_run_at <= freshWindowSeconds
+```
+
+模块级聚合:
+
+1. 该模块没有任何规则 → `UNMONITORED`
+2. 有规则,但没有 runnable 规则 → `PAUSED`
+3. 有 runnable 规则,但任一规则:
+   - `last_status='FAILED'`;
+   - `last_run_at IS NULL`;
+   - 超出 `freshWindowSeconds`;
+   → `DEGRADED`
+4. 所有 runnable 规则均 healthy → `HEALTHY`
+
+展示规范:
+
+| monitorState | 外圈/徽标 | 禁止误解 |
+|---|---|---|
+| `HEALTHY` | 实线正常外圈 | 正在正常检测 |
+| `DEGRADED` | 橙色虚线或时钟/警告徽标 | 检测失败或过期 |
+| `PAUSED` | 灰色虚线 + 暂停徽标 | 有规则但未运行 |
+| `UNMONITORED` | 无外圈或灰色空心外圈 | 未配置监控 |
+
+异常颜色不得被外圈覆盖。例如:
+
+```text
+S4 = 红色灯芯 + 暂停外圈
+```
+
+表示“严重异常仍在,但规则已经暂停”,不能显示灰灯掩盖 29 条严重异常。
+
+### 4.4 当前基线的预期灯态
+
+若执行时数据未变化,UAT 租户应为:
+
+| 模块 | 业务异常 | 监控态 | 预期 |
+|---|---|---|---|
+| S1 | 2 条 FOLLOW | HEALTHY | 黄灯芯 + 正常外圈 |
+| S2 | 3 条 SERIOUS | HEALTHY | 红灯芯 + 正常外圈 |
+| S3 | 0 | UNMONITORED | 灰灯,无监控 |
+| S4 | 29 条 SERIOUS | PAUSED | 红灯芯 + 暂停外圈 |
+| S5 | 0 | UNMONITORED | 灰灯,无监控 |
+| S6 | 0 | UNMONITORED | 灰灯,无监控 |
+| S7 | 0 | UNMONITORED | 灰灯,无监控 |
+
+数据可能随检测和处理变化。验收必须按同一时刻 SQL 结果计算预期,禁止把上表数字写死进测试或前端。
+
+### 4.5 Tooltip
+
+前端根据结构化字段生成,不由后端返回整段中文。
+
+示例:
+
+```text
+S1:关注异常 2 条;监控正常;最后检测 2026-09-29 23:41
+S4:严重异常 29 条;监控规则已暂停;最后成功检测 2026-09-23 14:57
+S5:未配置监控规则;当前状态未知
+```
+
+至少显示:
+
+- 模块码和模块名;
+- 严重/关注/当前异常总数;
+- 监控态;
+- 最近运行时间;
+- 规则数;
+- 暂停或降级原因码。
+
+---
+
+## 5. 后端接口设计
+
+### 5.1 新增统一查询服务
+
+新增:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8HomeAlertQueryService.cs
+```
+
+职责:
+
+1. 统一当前异常 Authority;
+2. 统一活动状态集合;
+3. 统一 severity 映射;
+4. 统一基础查询文本过滤;
+5. 提供:
+   - 最近重点异常;
+   - 区间新增汇总;
+   - 每日新增趋势;
+   - S1–S7 灯态。
+
+禁止把三套 SQL 继续散落在 Controller 中独立演进。
+
+服务使用当前 DI 模式实现为 `ITransient`。可注入:
+
+- `ISqlSugarClient`;或
+- `SqlSugarRepository<AdoS8Exception>` + `SqlSugarRepository<AdoS8WatchRule>`。
+
+选择须与同目录服务风格一致。不得自行创建静态连接或第二套 DbContext。
+
+### 5.2 保留现有三个 API 契约
+
+#### `GET /api/AidopKanban/s8-alerts`
+
+响应字段保持:
+
+```json
+[
+  {
+    "time": "23:41",
+    "module": "S1",
+    "message": "异常标题",
+    "level": "medium",
+    "levelLabel": "关注"
+  }
+]
+```
+
+规则:
+
+- 数据源改为 `ado_s8_exception`;
+- 只返回当前异常;
+- 按 `COALESCE(last_detected_at, created_at) DESC, id DESC`;
+- `LIMIT 6`;
+- `module` 优先 `stage_code`,其次 `module_code`,最后 `S8`;
+- `message` 优先 `title`,空时回落 `description`,再回落“异常告警”;
+- `SERIOUS → critical/严重`;
+- `FOLLOW → medium/关注`。
+
+#### `GET /api/AidopKanban/s8-alert-summary`
+
+响应契约保持:
+
+```json
+{
+  "windowStart": "2026-09-30",
+  "windowEnd": "2026-09-30",
+  "isToday": true,
+  "total": 3,
+  "critical": 2
+}
+```
+
+规则:
+
+- 按 `created_at` 半开窗口 `[windowStart, windowEnd)`;
+- `total` = 该窗口新建异常数;
+- `critical` = 该窗口新建 `SERIOUS` 异常数;
+- 不带 `LIMIT`;
+- 不按当前状态过滤,避免异常关闭后历史新增数被改写。
+
+#### `GET /api/AidopKanban/s8-home-trend`
+
+响应契约保持:
+
+```json
+{
+  "title": "近 7 日异常趋势",
+  "points": [
+    { "date": "09/24", "count": 0 }
+  ]
+}
+```
+
+规则:
+
+- 按 `DATE(created_at)` 聚合;
+- 没有新增异常的日期补 0;
+- 默认最近 7 个服务器自然日;
+- 继续保留当前最大日期跨度保护;
+- 不按当前状态过滤;
+- 不使用 `last_detected_at`;
+- 不读取 `ado_s8_detection_log` 的 `REFRESHED` 次数。
+
+### 5.3 新增灯态接口
+
+新增:
+
+```http
+GET /api/AidopKanban/s8-module-lamps
+```
+
+不接受客户端 `tenantId`。租户从认证上下文读取。
+
+推荐响应:
+
+```json
+{
+  "asOf": "2026-09-30T00:10:00+08:00",
+  "items": [
+    {
+      "code": "S1",
+      "name": "S1 产销协同",
+      "businessState": "WARNING",
+      "monitorState": "HEALTHY",
+      "seriousCount": 0,
+      "followCount": 2,
+      "openCount": 2,
+      "configuredRuleCount": 1,
+      "enabledRuleCount": 1,
+      "runnableRuleCount": 1,
+      "latestRunAt": "2026-09-29T23:41:07+08:00",
+      "monitorReasonCode": "OK"
+    }
+  ]
+}
+```
+
+`items` 必须始终按 S1–S7 返回 7 条。没有异常/规则的模块也要返回结构化 `UNKNOWN + UNMONITORED`,前端不得自行补假状态。
+
+`monitorReasonCode` 固定建议:
+
+```text
+OK
+NO_RULE
+RULE_PAUSED
+RULE_DISABLED
+NEVER_RUN
+RUN_FAILED
+RUN_STALE
+```
+
+一个模块多规则时,按最差运行态返回原因;同时保留计数,便于 tooltip 解释。
+
+### 5.4 基础查询过滤
+
+原 `BuildS8AlertTextFilterSql()` 针对旧表的:
+
+- `message`
+- `metric_code`
+- `alert_code`
+
+必须替换为异常 Authority 字段:
+
+- `title`
+- `description`
+- `related_object_code`
+- `source_object_id`
+- `source_payload`
+- `exception_code`
+
+筛选语义:
+
+- `product`
+- `orderNo`
+- `productionLine`
+
+均在上述可追溯文本字段中匹配。
+
+参数必须参数化。禁止字符串拼接用户输入。
+
+若 `source_payload` 数据量导致性能问题,先通过真实 `EXPLAIN ANALYZE` 取证,再另立结构化投影/索引任务;本任务不得默默删掉筛选能力。
+
+---
+
+## 6. 前端设计
+
+### 6.1 API 与类型
+
+修改:
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+```
+
+新增:
+
+- `S8ModuleLampItem`
+- `S8ModuleLampPayload`
+- `fetchS8ModuleLamps()`
+
+枚举用联合类型,不使用任意字符串:
+
+```ts
+type S8BusinessState = 'CRITICAL' | 'WARNING' | 'NORMAL' | 'UNKNOWN'
+type S8MonitorState = 'HEALTHY' | 'DEGRADED' | 'PAUSED' | 'UNMONITORED'
+```
+
+### 6.2 九宫格 S8
+
+修改:
+
+```text
+Web/src/views/dashboard/home.vue
+```
+
+要求:
+
+1. 删除硬编码 `s8ModuleStatus` 状态;
+2. 初始化为结构化“加载中/未知”,不得闪现绿色;
+3. `loadS8Data()` 同时加载:
+   - 告警列表;
+   - 趋势;
+   - 模块灯态;
+4. 灯芯颜色只由 `businessState` 决定;
+5. 外圈/徽标只由 `monitorState` 决定;
+6. 请求失败:
+   - 不显示绿色;
+   - 统一显示灰色 `UNKNOWN`;
+   - tooltip 为“监控状态加载失败”,不得写“正常”;
+7. 保留 S1–S7 七项顺序;
+8. 使用后端 `asOf/latestRunAt`,不要前端猜服务端当前时间。
+
+### 6.3 下钻
+
+灯点击后跳转:
+
+```text
+/aidop/s8/exceptions?stageCode=S1
+```
+
+修改:
+
+```text
+Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue
+```
+
+要求:
+
+- `query` 增加 `stageCode`;
+- `onMounted` 和 `onActivated` 能读取路由 `stageCode`;
+- `loadList()` 传给后端;
+- reset 时清空;
+- 页面应有可见的阶段筛选状态,至少在查询条件摘要或筛选控件中展示,避免用户不知道为何只看到一个模块;
+- keep-alive 页面从 S1 灯切到 S2 灯时必须响应路由 query 变化,不得仍保留 S1 数据。
+
+不得新增 FUNC 编号:目标页已经登记为 FUNC-S8-008,详情页为 FUNC-S8-019。
+
+### 6.4 样式
+
+修改范围优先留在:
+
+```text
+Web/src/views/dashboard/home.vue
+```
+
+建议 class:
+
+```text
+s8-mod__dot--critical
+s8-mod__dot--warning
+s8-mod__dot--normal
+s8-mod__dot--unknown
+s8-mod--monitor-healthy
+s8-mod--monitor-degraded
+s8-mod--monitor-paused
+s8-mod--monitor-unmonitored
+```
+
+无障碍要求:
+
+- 不能只靠颜色;
+- tooltip/title 必须写出“严重/关注/正常/未监测/暂停/过期”;
+- 暂停和降级有形状或徽标差异;
+- 键盘可聚焦并可进入异常列表。
+
+---
+
+## 7. 精确文件清单
+
+### 7.1 固定修改
+
+后端:
+
+```text
+server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8HomeAlertQueryService.cs        新增
+server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs       修改
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DashboardSemanticsTests.cs      修改
+server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8HomeAlertQueryServiceTests.cs   新增
+server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj                            提交时升 patch
+```
+
+前端:
+
+```text
+Web/src/views/aidop/api/kanbanData.ts
+Web/src/views/dashboard/home.vue
+Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue
+Web/src/views/dashboard/home.s8-empty.spec.ts
+Web/src/views/aidop/kanban/dashboardSemantics.spec.ts
+Web/package.json                                                               提交时升 patch
+```
+
+### 7.2 条件修改
+
+只有在现有测试布局明显不适合时才新增:
+
+```text
+Web/src/views/dashboard/home.s8-module-lamps.spec.ts
+```
+
+若 Controller 构造函数注入 `S8HomeAlertQueryService`:
+
+- 全仓检索 `new AidopKanbanController(`;
+- 有手工构造点则同步改;
+- 无调用点则在交付报告写明全仓零匹配。
+
+### 7.3 明确不改
+
+- `AdoS8Exception` 表结构;
+- `AdoS8WatchRule` 表结构;
+- 历史成功迁移;
+- 规则启停数据;
+- 异常业务数据;
+- `ado_s8_alert_record` 数据;
+- S1–S7 KPI 计算;
+- S8 规则 Provider;
+- 菜单种子和 FUNC 编号。
+
+---
+
+## 8. 工作包
+
+### WP-A:Authority 收口
+
+1. 新建 `S8HomeAlertQueryService`。
+2. 将列表、汇总、趋势迁入服务。
+3. Controller 只负责:
+   - 认证租户解析;
+   - 查询参数转换;
+   - 返回 HTTP 结果。
+4. 删除或改写 `BuildS8AlertTextFilterSql()`,不得保留旧表字段残渣。
+5. 全仓检索 `ado_s8_alert_record`:
+   - 本任务三个端点不得再命中;
+   - 其它遗留读取点逐条报告,未经确认不顺手扩大范围。
+
+验收:
+
+```text
+s8-alerts        → 当前未关闭异常
+s8-alert-summary → created_at 窗口新增数
+s8-home-trend    → created_at 每日新增数
+```
+
+### WP-B:灯态聚合
+
+1. 服务一次读取租户的 S1–S7 当前异常;
+2. 一次读取该租户规则;
+3. 内存按 7 个模块聚合;
+4. 不允许每模块两次查询造成 N+1;
+5. 按 §4 固定优先级计算。
+
+必须使用单一 `now` 快照,避免 7 个模块判定跨秒漂移。
+
+### WP-C:前端双维度展示
+
+1. 接入新接口;
+2. 删除硬编码 `unknown` 数组;
+3. 实现灯芯和外圈;
+4. 请求失败 fail-closed;
+5. tooltip 使用结构化字段。
+
+### WP-D:下钻
+
+1. 点击灯跳 FUNC-S8-008;
+2. 路由带 `stageCode`;
+3. 异常列表页解析并透传;
+4. keep-alive 和路由切换正确刷新;
+5. 返回列表后筛选条件仍可解释。
+
+### WP-E:回归与真实数据验收
+
+1. 重跑基线 SQL;
+2. 用同一时刻数据计算预期灯态;
+3. 登录 UATAdminA 验证;
+4. 截图必须同时显示:
+   - S1 黄色;
+   - S2 红色;
+   - S4 红色且有暂停外圈;
+   - 无规则模块灰色;
+5. 点击 S1/S2/S4 分别验证列表过滤;
+6. 不得因数据之后变化而把具体数量写死为测试断言。
+
+---
+
+## 9. 测试要求
+
+### 9.1 后端服务单元测试
+
+新增 `S8HomeAlertQueryServiceTests.cs`,至少覆盖:
+
+1. `SERIOUS` 优先于 `FOLLOW`;
+2. 只有 FOLLOW → WARNING;
+3. 无异常 + 所有 runnable 规则新鲜成功 → NORMAL;
+4. 无规则 → UNKNOWN + UNMONITORED;
+5. 有规则但全停用/暂停 → PAUSED;
+6. 最近状态失败 → DEGRADED;
+7. 从未运行 → DEGRADED;
+8. `last_run_at` 超过 `max(2*poll, 600s)` → DEGRADED;
+9. S4 严重异常 + 规则暂停 → CRITICAL + PAUSED;
+10. `CLOSED/REJECTED/RECOVERED` 不计当前异常;
+11. 7 个模块总是完整、排序稳定;
+12. 同一 `now` 参与全部判定。
+
+### 9.2 后端契约测试
+
+更新 `S8DashboardSemanticsTests.cs`:
+
+- 三个端点不再包含 `FROM ado_s8_alert_record`;
+- 读取 `ado_s8_exception`;
+- summary/trend 使用 `created_at`;
+- trend 不使用 `last_detected_at`;
+- list 仍 `LIMIT 6`;
+- summary 不带 `LIMIT`;
+- 租户来自 `AidopTenantHelper`;
+- 灯态端点存在;
+- 无客户端 tenant 过滤入口;
+- 严重度契约仍返回 `critical/medium`。
+
+旧测试 `T23_LegacyAlertsEndpoint_StillReturnsRecentList` 应改名并改语义,禁止留下“旧端点不得改”的过期断言。
+
+### 9.3 前端测试
+
+至少覆盖:
+
+1. API 参数及类型;
+2. S1–S7 顺序;
+3. CRITICAL 红灯;
+4. WARNING 黄灯;
+5. HEALTHY 且 0 异常才绿;
+6. UNMONITORED 灰灯;
+7. CRITICAL + PAUSED 同时显示;
+8. 请求失败不显示绿;
+9. tooltip 含数量、监控态、最近时间;
+10. 点击生成 `/aidop/s8/exceptions?stageCode=Sx`;
+11. 异常列表读取并透传 `stageCode`;
+12. 从 S1 灯切 S2 灯会刷新;
+13. 旧 Demo 告警不会恢复;
+14. S8 真 0 行时仍显示“暂无异常告警”,请求失败显示“异常数据加载失败”。
+
+### 9.4 全量测试基线
+
+当前已知后端固定失败集合为 7 条;执行前重新确认:
+
+1. `S0DimContractTests.ProductDesign_bom_and_routing_sql_must_carry_tenant_predicate_everywhere`
+2. `S1RequirementExamineDwdContractTests.Table_HasNoS8SpecificColumns`
+3. `S1RequirementExamineDwdContractTests.CurrentSnapshotIndex_ExistsInBothInlineDdlAndMigration`
+4. `S1RequirementExamineDwdContractTests.BooleanColumn_FollowsRepoFlagConvention_NotBit`
+5. `S1RequirementExamineDwdContractTests.NewColumns_ExistInBothInlineDdlAndMigration`
+6. `S1RequirementExamineDwdContractTests.QuantityColumns_DocumentedAsStandardNames`
+7. `S6ProductionInstructionContractTests.Std_Schedule_HasNewNullableColumns`
+
+新增测试可以增加总数,但失败名字集合不得扩大。
+
+执行:
+
+```powershell
+dotnet build server\Admin.NET.Web.Entry\Admin.NET.Web.Entry.csproj -v q --nologo
+dotnet build server\Plugins\Admin.NET.Plugin.AiDOP.Tests\Admin.NET.Plugin.AiDOP.Tests.csproj -v q --nologo
+dotnet test server\Plugins\Admin.NET.Plugin.AiDOP.Tests\Admin.NET.Plugin.AiDOP.Tests.csproj `
+  --no-build --nologo --logger "trx;LogFileName=s8-home-lamps.trx"
+
+Set-Location Web
+npm run test:run
+npm run build
+```
+
+用 TRX 比较失败用例名。测试完成后删除本轮 `TestResults`。
+
+---
+
+## 10. 接口与 SQL 验收
+
+### 10.1 API 验收
+
+以 UATAdminA 登录态调用:
+
+```http
+GET /api/AidopKanban/s8-alerts
+GET /api/AidopKanban/s8-alert-summary
+GET /api/AidopKanban/s8-home-trend
+GET /api/AidopKanban/s8-module-lamps
+```
+
+检查:
+
+- 响应不包含其它租户;
+- 列表最多 6 条;
+- summary 今日窗口正确;
+- trend 连续补齐日期;
+- module lamps 恰好 7 条;
+- 无秘密、连接串、payload 全文泄漏。
+
+### 10.2 同时刻对账 SQL
+
+#### 当前异常灯芯
+
+```sql
+SELECT stage_code,
+       SUM(CASE WHEN severity = 'SERIOUS' THEN 1 ELSE 0 END) AS serious_count,
+       SUM(CASE WHEN severity = 'FOLLOW' THEN 1 ELSE 0 END) AS follow_count,
+       COUNT(*) AS open_count
+FROM ado_s8_exception
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND status NOT IN ('CLOSED', 'REJECTED', 'RECOVERED')
+  AND stage_code IN ('S1','S2','S3','S4','S5','S6','S7')
+GROUP BY stage_code
+ORDER BY stage_code;
+```
+
+#### 今日新增
+
+```sql
+SELECT COUNT(*) AS total,
+       SUM(CASE WHEN severity = 'SERIOUS' THEN 1 ELSE 0 END) AS critical
+FROM ado_s8_exception
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND created_at >= CURDATE()
+  AND created_at < DATE_ADD(CURDATE(), INTERVAL 1 DAY);
+```
+
+#### 7 日趋势
+
+```sql
+SELECT DATE(created_at) AS biz_date,
+       COUNT(*) AS cnt
+FROM ado_s8_exception
+WHERE tenant_id = 838257186181189
+  AND is_deleted = 0
+  AND created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
+  AND created_at < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
+GROUP BY DATE(created_at)
+ORDER BY biz_date;
+```
+
+API 与 SQL 必须能解释性对账。趋势补 0 日由 API 完成。
+
+---
+
+## 11. 提交与版本
+
+建议拆成两个可独立回滚的提交,但必须在同一发布窗口部署:
+
+### 提交 1:后端 Authority + 灯态接口
+
+范围:
+
+- `S8HomeAlertQueryService.cs`
+- `AidopKanbanController.cs`
+- 后端测试
+- server 版本
+
+若基线仍为 `1.0.595`:
+
+```text
+server → 1.0.596
+```
+
+建议提交信息:
+
+```text
+fix(s8): 首页告警改读异常权威表并提供模块灯态
+```
+
+### 提交 2:前端双维度灯 + 下钻
+
+范围:
+
+- `kanbanData.ts`
+- `home.vue`
+- `S8ExceptionListPage.vue`
+- 前端测试
+- Web 版本和对应锁文件
+
+若基线仍为 `2.4.414`:
+
+```text
+Web → 2.4.415
+```
+
+建议提交信息:
+
+```text
+fix(s8): 九宫格展示异常灯芯与监控运行外圈
+```
+
+两个提交均须 push。若用户明确要求只 commit 不 push,才停止在本地。
+
+---
+
+## 12. 反向影响推演
+
+### 12.1 代码调用面
+
+**需同步改:**
+
+- `s8-alerts` 被九宫格首页使用;
+- `s8-alert-summary` 被独立 S8 看板使用;
+- `s8-home-trend` 被九宫格首页使用;
+- `HomeModule` 灯下钻影响 FUNC-S8-008 异常列表;
+- `AidopKanbanController` 构造函数若加服务,须全仓检索手工构造调用方。
+
+### 12.2 数据契约面
+
+**需同步改但保持兼容:**
+
+- 三个既有接口 JSON 字段不变;
+- Authority 从旧告警表切到异常主表;
+- 严重度由 `SERIOUS/FOLLOW` 映射到现有 `critical/medium`;
+- 新增灯态接口是增量契约;
+- 智慧诊断、ChatBI、导出当前未发现直接调用灯态接口;执行时须再次全仓检索。
+
+### 12.3 多数据源面
+
+**无直接影响:**
+
+- 本任务不直接读取 T8、165、自建单或标准 API;
+- 各来源先由现有规则 Provider 形成 S8 异常;
+- 页面只消费 S8 统一异常 Authority。
+
+若某来源没有配置规则,应显示 `UNMONITORED`,不得在展示层特判来源。
+
+### 12.4 多租户 / Domain 面
+
+**需同步核查:**
+
+- 只按认证租户读取;
+- S8 规则和异常遵循 tenant-only;
+- 不信任请求 tenantId;
+- 不把普通业务 `factoryId=1` 强套到 S8 `factory_id=0`;
+- `stage_code` 只允许 S1–S7。
+
+### 12.5 运行面
+
+**无迁移、无种子、无定时任务改动。**
+
+但需注意:
+
+- 页面状态随规则最近运行时间变化;
+- 后端或 Scheduler 停止后灯外圈应自动转 `DEGRADED`;
+- 重启后不得短暂闪绿;
+- 旧表仍保留但不再供这三个端点读取。
+
+### 12.6 验证面
+
+**需同步改:**
+
+- 过期的 legacy Authority 契约测试;
+- 服务级灯态矩阵;
+- 前端双状态组合;
+- 下钻路由;
+- UATAdminA 实际数据对账。
+
+---
+
+## 13. 风险与回滚
+
+### 13.1 主要风险
+
+1. 把 `last_detected_at` 当新增日期导致趋势膨胀。
+2. 只看异常、不看规则健康,把未监测模块显示绿色。
+3. 规则暂停后把仍存在的异常隐藏。
+4. 前端 keep-alive 不响应路由 query,点击不同灯仍显示旧模块。
+5. tenant-only 语义被旧 `factoryId=1` 过滤掉。
+6. 直接显示 `source_payload` 导致敏感业务报文泄漏。
+
+### 13.2 回滚
+
+- 前端可回滚到灰色 unknown,但不应回写假灯态;
+- 后端可暂时回滚接口实现,但禁止通过向旧表造数据回滚;
+- 新接口为增量接口,不涉及 DROP;
+- 不删除历史异常和规则;
+- 回滚后需明确页面重新失去 Authority,不能把空白称为“无异常”。
+
+---
+
+## 14. 总验收清单
+
+### Authority
+
+- [ ] 三个首页/S8 接口不再读取 `ado_s8_alert_record`
+- [ ] 当前异常读取 `ado_s8_exception`
+- [ ] 规则健康读取 `ado_s8_watch_rule`
+- [ ] 未新增旧表写入方
+
+### 时间语义
+
+- [ ] 列表按最后命中时间排序
+- [ ] summary 按 `created_at`
+- [ ] trend 按 `created_at`
+- [ ] `REFRESHED` 未被当成新增异常
+
+### 灯态
+
+- [ ] SERIOUS 红
+- [ ] FOLLOW 黄
+- [ ] 监控健康且 0 异常才绿
+- [ ] 无规则灰
+- [ ] 暂停有独立外圈/徽标
+- [ ] 失败/陈旧有 DEGRADED 外圈
+- [ ] 异常颜色不会被暂停状态覆盖
+- [ ] 七灯不硬编码业务状态
+
+### 下钻
+
+- [ ] 点击进入 FUNC-S8-008
+- [ ] 带 `stageCode`
+- [ ] 列表实际按 stage 过滤
+- [ ] keep-alive 路由切换刷新
+- [ ] 筛选条件对用户可见
+
+### 真实性
+
+- [ ] 未改异常业务数据
+- [ ] 未改规则启停数据
+- [ ] 未复制其它租户
+- [ ] 未回填旧告警表
+- [ ] 同时刻 API 与 SQL 可对账
+
+### 工程
+
+- [ ] 后端构建 0 error
+- [ ] 前端构建成功
+- [ ] 新增测试通过
+- [ ] 既有失败集合未扩大
+- [ ] server/Web 版本正确
+- [ ] commit + push 成功
+
+---
+
+## 15. 交付报告模板
+
+执行者最终必须按以下结构汇报:
+
+```text
+1. 提交与版本
+2. 实际改动文件
+3. Authority 收口结果
+4. 三个既有接口验收
+5. S1–S7 逐灯验收
+6. 下钻验收
+7. 同时刻 SQL 对账
+8. 测试结果与既有失败对比
+9. 反向影响推演
+   - 代码调用面:无影响 / 需同步改(文件)
+   - 数据契约面:无影响 / 需同步改(接口/表)
+   - 多数据源面:无影响 / 需同步改(来源)
+   - 多租户/Domain:无影响 / 需同步改
+   - 运行面:无影响 / 需同步改
+   - 验证面:无影响 / 需同步改
+10. 未覆盖模块及原因
+```
+
+不得只汇报“灯亮了”或“页面有数据”。必须同时解释:
+
+- 异常来自哪里;
+- 为什么是红/黄/绿/灰;
+- 监控是否正常;
+- 最近一次检测时间;
+- 点击后能否看到对应异常。
+
+---
+
+## 16. 给执行模型的启动指令
+
+将本段与本文件路径一并交给新的执行模型:
+
+```text
+请严格执行:
+doc/plan/S8九宫格异常权威数据与模块灯态根治执行任务书.md
+
+开始时:
+1. 读取 AGENTS.md 和所有 alwaysApply 规则。
+2. 核对 HEAD、server/Web 版本、完整 git status,不覆盖或提交他人改动。
+3. 重新运行任务书 §2.4 的只读 SQL,按代码层/配置层/数据层分别记录证据。
+4. 全仓检索三个既有接口和 AidopKanbanController 手工构造调用方。
+5. 按 §7 列精确改动文件、跨模块影响和不做事项,等待用户确认后再改。
+
+实现时严格遵守冻结决策:
+- ado_s8_exception 是异常 Authority;
+- ado_s8_watch_rule 是监控覆盖和运行态 Authority;
+- 不写 ado_s8_alert_record;
+- list 可按 last_detected_at 排序,但 summary/trend 必须按 created_at;
+- 绿色只允许“监控健康且当前异常为 0”;
+- 异常灯芯与监控外圈必须分开;
+- S4 即使规则暂停,只要严重异常未关闭仍保持红灯;
+- 点击灯进入 FUNC-S8-008,并按 stageCode 过滤;
+- 不造数、不改异常和规则业务数据。
+
+完成后:
+- 后端、前端分别测试和构建;
+- 用 TRX 对比固定失败集合;
+- 用同一时刻 SQL 对账 S1-S7 灯态;
+- 按 §11 升版本、分提交并 push;
+- 按 §15 模板交付。
+```
+

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨÓÃȨÏÞ¿ª·¢Æ½Ì¨</Description>
-    <AssemblyVersion>1.0.595</AssemblyVersion>
-    <FileVersion>1.0.595</FileVersion>
-    <Version>1.0.595</Version>
+    <AssemblyVersion>1.0.596</AssemblyVersion>
+    <FileVersion>1.0.596</FileVersion>
+    <Version>1.0.596</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 43 - 21
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DashboardSemanticsTests.cs

@@ -32,6 +32,7 @@ public class S8DashboardSemanticsTests
 
     private static string MonitoringCode() => ReadCode("Service/S8/S8MonitoringService.cs");
     private static string KanbanCode() => ReadCode("Controllers/AidopKanbanController.cs");
+    private static string HomeAlertCode() => ReadCode("Service/S8/S8HomeAlertQueryService.cs");
 
     /// <summary>
     /// 取某个方法体:从签名起,到下一个「类成员级(4 空格缩进)声明」之前。
@@ -302,14 +303,15 @@ public class S8DashboardSemanticsTests
         Assert.Contains("[HttpGet(\"s8-alert-summary\")]", KanbanCode());
     }
 
-    /// <summary>⑲ 汇总端点必须 COUNT,且不得带 LIMIT。</summary>
+    /// <summary>⑲ 汇总计数在异常权威查询里,且不得带 LIMIT。</summary>
     [Fact]
     public void T19_AlertSummary_CountsWithoutLimit()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var body = MethodBody(HomeAlertCode(), "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync");
 
         Assert.Contains("COUNT(*)", body);
         Assert.DoesNotContain("LIMIT", body);
+        Assert.DoesNotContain("ado_s8_alert_record", body);
     }
 
     /// <summary>⑳ 「今日」必须是显式的服务端自然日窗口,不是「最近 N 条」的副作用。</summary>
@@ -324,46 +326,66 @@ public class S8DashboardSemanticsTests
         Assert.Contains("isToday", body);
     }
 
-    /// <summary>㉑ 汇总端点必须租户 + 工厂作用域,且窗口是半开区间。</summary>
+    /// <summary>㉑ 汇总按当前租户统计新建异常,窗口是 created_at 半开区间,不按工厂过滤。</summary>
     [Fact]
     public void T21_AlertSummary_IsTenantScoped()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var controller = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var body = MethodBody(HomeAlertCode(), "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync");
 
-        Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", body);
+        Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", controller);
         Assert.Contains("tenant_id=@tenantId", body);
-        Assert.Contains("factory_id=@factoryId", body);
-        Assert.Contains("alert_time >= @ws", body);
-        Assert.Contains("alert_time < @we", body);
+        Assert.DoesNotContain("factory_id=@factoryId", body);
+        Assert.Contains("created_at >= @ws", body);
+        Assert.Contains("created_at < @we", body);
+        Assert.DoesNotContain("alert_time", body);
+        Assert.DoesNotContain("last_detected_at", body);
     }
 
-    /// <summary>㉒ 汇总端点必须复用同一套维度过滤,不得另起一套口径。</summary>
+    /// <summary>㉒ 三个首页查询复用同一套异常字段过滤。</summary>
     [Fact]
     public void T22_AlertSummary_ReusesSharedDimensionFilter()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var controller = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        Assert.Contains("SmartOpsDashboardFilter.FromQuery", controller);
 
-        Assert.Contains("BuildS8AlertTextFilterSql()", body);
-        Assert.Contains("SmartOpsDashboardFilter.FromQuery", body);
+        foreach (var signature in new[]
+        {
+            "public async Task<List<S8HomeAlertItem>> GetRecentAsync",
+            "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync",
+            "public async Task<List<S8HomeTrendPoint>> GetCreatedTrendAsync"
+        })
+        {
+            Assert.Contains("TextFilterSql", MethodBody(HomeAlertCode(), signature));
+        }
     }
 
-    /// <summary>㉓ 既有 s8-alerts 列表端点不得被本批改动(home.vue 仍依赖它返回最近 6 条)。</summary>
+    /// <summary>㉓ 首页列表改读未关闭异常,仍只返回最近 6 条,排序用最近发现时间。</summary>
     [Fact]
-    public void T23_LegacyAlertsEndpoint_StillReturnsRecentList()
+    public void T23_AlertsEndpoint_ReadsOpenExceptions()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8Alerts");
-
-        Assert.Contains("LIMIT 6", body);
-        Assert.Contains("ORDER BY alert_time DESC", body);
+        var controller = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8Alerts");
+        var body = MethodBody(HomeAlertCode(), "public async Task<List<S8HomeAlertItem>> GetRecentAsync");
+
+        Assert.DoesNotContain("ado_s8_alert_record", controller);
+        Assert.Contains("ado_s8_exception", body);
+        Assert.Contains("LIMIT {RecentLimit}", body);
+        Assert.Contains("public const int RecentLimit = 6;", HomeAlertCode());
+        Assert.Contains("ORDER BY COALESCE(last_detected_at, created_at) DESC, id DESC", body);
+        Assert.Contains("ActiveStatusSql", body);
+        Assert.Contains("status NOT IN ('CLOSED','REJECTED','RECOVERED')", HomeAlertCode());
+        Assert.DoesNotContain("ado_s8_alert_record", body);
     }
 
     /// <summary>㉔ 汇总端点必须同时给出严重级计数,避免前端再拿 6 条列表推严重数。</summary>
     [Fact]
     public void T24_AlertSummary_ReturnsCriticalCount()
     {
-        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var controller = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+        var body = MethodBody(HomeAlertCode(), "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync");
 
-        Assert.Contains("critical", body);
-        Assert.Contains("level_code", body);
+        Assert.Contains("critical", controller);
+        Assert.Contains("SERIOUS", body);
+        Assert.DoesNotContain("level_code", body);
     }
 }

+ 194 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8HomeAlertQueryServiceTests.cs

@@ -0,0 +1,194 @@
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+public class S8HomeAlertQueryServiceTests
+{
+    private static readonly DateTime Now = new(2026, 9, 30, 12, 0, 0);
+
+    [Fact]
+    public void SeriousOutranksFollow()
+    {
+        var decision = S8ModuleLampPolicy.Decide(1, 4, new[] { Healthy() }, Now);
+
+        Assert.Equal("CRITICAL", decision.BusinessState);
+        Assert.Equal("HEALTHY", decision.MonitorState);
+    }
+
+    [Fact]
+    public void SeriousOpenException_StaysRedWhenRuleIsDisabled()
+    {
+        var decision = S8ModuleLampPolicy.Decide(1, 0, new[] { Rule(enabled: false) }, Now);
+
+        Assert.Equal("CRITICAL", decision.BusinessState);
+        Assert.Equal("PAUSED", decision.MonitorState);
+        Assert.Equal("RULE_DISABLED", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void FollowOnly_IsWarningWhileMonitorStaysHealthy()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 2, new[] { Healthy() }, Now);
+
+        Assert.Equal("WARNING", decision.BusinessState);
+        Assert.Equal("HEALTHY", decision.MonitorState);
+        Assert.Equal("OK", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void GreenRequiresHealthyMonitorAndZeroOpenExceptions()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 0, new[] { Healthy() }, Now);
+
+        Assert.Equal("NORMAL", decision.BusinessState);
+        Assert.Equal("HEALTHY", decision.MonitorState);
+    }
+
+    [Fact]
+    public void UnmonitoredModule_IsUnknown_NotGreen()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 0, Array.Empty<S8WatchRuleSignal>(), Now);
+
+        Assert.Equal("UNKNOWN", decision.BusinessState);
+        Assert.Equal("UNMONITORED", decision.MonitorState);
+        Assert.Equal("NO_RULE", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void StaleRunnableRule_IsDegraded_AndDoesNotTurnGreen()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 0, new[] { Healthy(Now.AddMinutes(-11)) }, Now);
+
+        Assert.Equal("UNKNOWN", decision.BusinessState);
+        Assert.Equal("DEGRADED", decision.MonitorState);
+        Assert.Equal("RUN_STALE", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void FailedRun_OutranksStaleAndNeverRun()
+    {
+        var rules = new[]
+        {
+            Healthy(Now.AddHours(-2)),
+            Rule(enabled: true, lastStatus: "FAILED", lastRunAt: Now.AddMinutes(-1))
+        };
+
+        var decision = S8ModuleLampPolicy.Decide(0, 0, rules, Now);
+
+        Assert.Equal("DEGRADED", decision.MonitorState);
+        Assert.Equal("RUN_FAILED", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void NeverRun_IsDegraded()
+    {
+        var decision = S8ModuleLampPolicy.Decide(0, 0, new[] { Rule(enabled: true) }, Now);
+
+        Assert.Equal("DEGRADED", decision.MonitorState);
+        Assert.Equal("NEVER_RUN", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void EnabledRulePausedUntilFuture_IsPausedNotUnmonitored()
+    {
+        var decision = S8ModuleLampPolicy.Decide(
+            3, 0, new[] { Rule(enabled: true, pausedUntil: Now.AddHours(1), lastStatus: "SUCCESS", lastRunAt: Now) }, Now);
+
+        Assert.Equal("CRITICAL", decision.BusinessState);
+        Assert.Equal("PAUSED", decision.MonitorState);
+        Assert.Equal("RULE_PAUSED", decision.MonitorReasonCode);
+    }
+
+    [Fact]
+    public void OneHealthyRunnableRule_IgnoresASiblingPausedRule()
+    {
+        var rules = new[]
+        {
+            Healthy(),
+            Rule(enabled: true, pausedUntil: Now.AddHours(1))
+        };
+
+        var decision = S8ModuleLampPolicy.Decide(0, 0, rules, Now);
+
+        Assert.Equal("NORMAL", decision.BusinessState);
+        Assert.Equal("HEALTHY", decision.MonitorState);
+    }
+
+    [Fact]
+    public void FreshWindow_IsAtLeastTenMinutes()
+    {
+        Assert.Equal(600, S8ModuleLampPolicy.FreshWindowSeconds(30));
+        Assert.Equal(800, S8ModuleLampPolicy.FreshWindowSeconds(400));
+    }
+
+    [Fact]
+    public void TrendCountsCreatedAt_AndDoesNotUseLastDetectedAt()
+    {
+        var code = ReadService();
+        var trend = MethodBody(code, "public async Task<List<S8HomeTrendPoint>> GetCreatedTrendAsync");
+        var summary = MethodBody(code, "public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync");
+
+        Assert.Contains("DATE(created_at)", trend);
+        Assert.DoesNotContain("last_detected_at", trend);
+        Assert.DoesNotContain("status NOT IN", summary);
+        Assert.DoesNotContain("factory_id", trend);
+        Assert.Contains("ado_s8_watch_rule", code);
+        var lamps = MethodBody(code, "public async Task<S8ModuleLampPayload> GetModuleLampsAsync");
+        Assert.Contains("status NOT IN ('CLOSED','REJECTED','RECOVERED')", lamps);
+        Assert.Contains("S8ModuleCode.All", lamps);
+        Assert.DoesNotContain("DateTime.Now", lamps);
+        var endpoint = MethodBody(ReadController(), "public async Task<IActionResult> GetS8ModuleLamps");
+        Assert.Contains("[HttpGet(\"s8-module-lamps\")]", ReadController());
+        Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", endpoint);
+        Assert.DoesNotContain("[FromQuery]", endpoint);
+        Assert.DoesNotContain("factoryId", endpoint);
+        foreach (var signature in new[]
+        {
+            "public async Task<IActionResult> GetS8Alerts",
+            "public async Task<IActionResult> GetS8AlertSummary",
+            "public async Task<IActionResult> GetS8HomeTrend"
+        })
+        {
+            Assert.DoesNotContain("ado_s8_alert_record", MethodBody(ReadController(), signature));
+        }
+    }
+
+    private static S8WatchRuleSignal Healthy(DateTime? lastRunAt = null) =>
+        Rule(enabled: true, lastStatus: "SUCCESS", lastRunAt: lastRunAt ?? Now.AddMinutes(-1));
+
+    private static S8WatchRuleSignal Rule(
+        bool enabled,
+        string? lastStatus = null,
+        DateTime? lastRunAt = null,
+        DateTime? pausedUntil = null) =>
+        new("S1", enabled, 300, lastStatus, lastRunAt, pausedUntil);
+
+    private static string ReadService() => Strip(Path.Combine(PluginRoot, "Service/S8/S8HomeAlertQueryService.cs"));
+    private static string ReadController() => Strip(Path.Combine(PluginRoot, "Controllers/AidopKanbanController.cs"));
+
+    private static string PluginRoot => Path.GetFullPath(
+        Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string Strip(string path)
+    {
+        var raw = File.ReadAllText(path);
+        var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
+        return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
+    }
+
+    private static string MethodBody(string code, string signature)
+    {
+        var start = code.IndexOf(signature, StringComparison.Ordinal);
+        Assert.True(start > 0, signature);
+        var from = start + signature.Length;
+        var end = code.Length;
+        foreach (var kw in new[] { "\n    public ", "\n    private ", "\n    internal ", "\n    protected " })
+        {
+            var i = code.IndexOf(kw, from, StringComparison.Ordinal);
+            if (i > 0 && i < end) end = i;
+        }
+        return code[start..end];
+    }
+}

+ 41 - 113
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -41,6 +41,7 @@ public partial class AidopKanbanController : ControllerBase
     private readonly SmartOpsTrustedScopeResolver _trustedScope;
     private readonly SmartDiagnosisEvidenceService _evidence;
     private readonly S8KpiSnapshotService _s8KpiSnapshot;
+    private readonly S8HomeAlertQueryService _s8HomeAlerts;
     private readonly ILogger<AidopKanbanController> _logger;
 
     public AidopKanbanController(
@@ -65,6 +66,7 @@ public partial class AidopKanbanController : ControllerBase
         SmartOpsTrustedScopeResolver trustedScope,
         SmartDiagnosisEvidenceService evidence,
         S8KpiSnapshotService s8KpiSnapshot,
+        S8HomeAlertQueryService s8HomeAlerts,
         ILogger<AidopKanbanController> logger)
     {
         _db = db;
@@ -88,6 +90,7 @@ public partial class AidopKanbanController : ControllerBase
         _trustedScope = trustedScope;
         _evidence = evidence;
         _s8KpiSnapshot = s8KpiSnapshot;
+        _s8HomeAlerts = s8HomeAlerts;
         _logger = logger;
     }
 
@@ -138,62 +141,27 @@ ORDER BY v.module_code
         [FromQuery] string? orderNo = null,
         [FromQuery] string? productionLine = null)
     {
+        _ = factoryId;
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
         var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
             dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
-        var dimSql = BuildS8AlertTextFilterSql();
-        var sql = $"""
-SELECT DATE_FORMAT(alert_time, '%H:%i') AS Time,
-       IFNULL(SUBSTRING_INDEX(metric_code, '_', 1), 'S8') AS Module,
-       IFNULL(message, '异常告警') AS Message,
-       IFNULL(level_code, 'medium') AS LevelCode
-FROM ado_s8_alert_record
-WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
-  AND (@ds IS NULL OR alert_time >= @ds)
-  AND (@de IS NULL OR alert_time < DATE_ADD(@de, INTERVAL 1 DAY))
-  {dimSql}
-ORDER BY alert_time DESC
-LIMIT 6
-""";
-        var rows = await _db.Ado.SqlQueryAsync<S8AlertDto>(sql, new
-        {
-            tenantId,
-            factoryId,
-            ds = filter.DateStart,
-            de = filter.DateEnd,
-            product = filter.Product,
-            orderNo = filter.OrderNo,
-            productionLine = filter.ProductionLine
-        });
+        var rows = await _s8HomeAlerts.GetRecentAsync(tenantId, filter);
         return Ok(rows.Select(x => new
         {
             time = x.Time,
             module = x.Module,
             message = x.Message,
-            level = x.LevelCode,
-            levelLabel = x.LevelCode switch
-            {
-                "critical" => "严重",
-                "high" => "高",
-                "medium" => "中",
-                _ => "一般"
-            }
+            level = x.Level,
+            levelLabel = x.LevelLabel
         }));
     }
 
     /// <summary>
-    /// S8-DASHBOARD-P2-SEMANTICS-1(P2-B):S8 看板预警计数。
+    /// S8 看板预警计数。计数的是 ado_s8_exception 在窗口内新建的异常,不看当前是否已关闭。
     ///
-    /// <para><b>为什么要单开一个端点</b>:<c>s8-alerts</c> 是「最近 6 条」列表,带 <c>LIMIT 6</c>
-    /// 且 SQL 里<b>没有任何今日谓词</b>。前端拿它的 <c>length</c> 当「今日预警数」,于是
-    /// ① 真实今日 0 条、表里存着几个月前的旧行时,卡片照样显示非 0;
-    /// ② 今日真有 7 条以上时,卡片永远封顶在 6。两个方向都错。</para>
+    /// <para><c>s8-alerts</c> 只返回未关闭的最近 6 条,不能拿它的长度当今日预警数。</para>
     ///
-    /// <para><b>窗口口径</b>:未传日期 ⇒ <c>[今日 00:00, 明日 00:00)</c>(服务器自然日,
-    /// <c>isToday=true</c>);传了日期 ⇒ <c>[dateStart, dateEnd + 1 天)</c>,<c>isToday=false</c>,
-    /// 由前端把标题从「今日预警数」切成「区间预警数」。一律半开区间,与 <c>s8-alerts</c> 同构。</para>
-    ///
-    /// <para>维度过滤复用 <see cref="BuildS8AlertTextFilterSql"/>,不另起口径。</para>
+    /// <para>未传日期时窗口是服务器自然日 <c>[今日 00:00, 明日 00:00)</c>;传了日期则是半开区间。</para>
     /// </summary>
     [HttpGet("s8-alert-summary")]
     public async Task<IActionResult> GetS8AlertSummary(
@@ -204,6 +172,7 @@ LIMIT 6
         [FromQuery] string? orderNo = null,
         [FromQuery] string? productionLine = null)
     {
+        _ = factoryId;
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
         var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
             dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
@@ -213,44 +182,17 @@ LIMIT 6
         var windowEnd = (filter.DateEnd ?? filter.DateStart ?? DateTime.Today).Date.AddDays(1);
         if (windowEnd <= windowStart) windowEnd = windowStart.AddDays(1);
 
-        var dimSql = BuildS8AlertTextFilterSql();
-        var sql = $"""
-SELECT COUNT(*) AS Total,
-       IFNULL(SUM(CASE WHEN level_code = 'critical' THEN 1 ELSE 0 END), 0) AS Critical
-FROM ado_s8_alert_record
-WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
-  AND alert_time >= @ws
-  AND alert_time < @we
-  {dimSql}
-""";
-        var rows = await _db.Ado.SqlQueryAsync<S8AlertSummaryRow>(sql, new
-        {
-            tenantId,
-            factoryId,
-            ws = windowStart,
-            we = windowEnd,
-            product = filter.Product,
-            orderNo = filter.OrderNo,
-            productionLine = filter.ProductionLine
-        });
-
-        var row = rows.FirstOrDefault();
+        var row = await _s8HomeAlerts.GetCreatedSummaryAsync(tenantId, filter, windowStart, windowEnd, isToday);
         return Ok(new
         {
             windowStart = windowStart.ToString("yyyy-MM-dd"),
             windowEnd = windowEnd.AddDays(-1).ToString("yyyy-MM-dd"),
             isToday,
-            total = row?.Total ?? 0,
-            critical = row?.Critical ?? 0
+            total = row.Total,
+            critical = row.Critical
         });
     }
 
-    private sealed class S8AlertSummaryRow
-    {
-        public int Total { get; set; }
-        public int Critical { get; set; }
-    }
-
     [HttpGet("s8-home-trend")]
     public async Task<IActionResult> GetS8HomeTrend(
         [FromQuery] long factoryId = 1,
@@ -260,64 +202,50 @@ WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
         [FromQuery] string? orderNo = null,
         [FromQuery] string? productionLine = null)
     {
+        _ = factoryId;
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
         var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
             dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
         var end = (filter.DateEnd ?? DateTime.Today).Date;
         var start = (filter.DateStart ?? end.AddDays(-6)).Date;
         if ((end - start).TotalDays > 13) start = end.AddDays(-6);
-        var dimSql = BuildS8AlertTextFilterSql();
-        var sql = $"""
-SELECT DATE(alert_time) AS BizDate, COUNT(*) AS Cnt
-FROM ado_s8_alert_record
-WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
-  AND alert_time >= @ds AND alert_time < DATE_ADD(@de, INTERVAL 1 DAY)
-  {dimSql}
-GROUP BY DATE(alert_time)
-ORDER BY BizDate
-""";
-        var rows = await _db.Ado.SqlQueryAsync<S8HomeTrendRow>(sql, new
-        {
-            tenantId,
-            factoryId,
-            ds = start,
-            de = end,
-            product = filter.Product,
-            orderNo = filter.OrderNo,
-            productionLine = filter.ProductionLine
-        });
-        var byDate = rows.ToDictionary(x => x.BizDate.Date, x => x.Cnt);
-        var points = new List<object>();
-        for (var day = start; day <= end; day = day.AddDays(1))
-            points.Add(new { date = day.ToString("MM/dd"), count = byDate.GetValueOrDefault(day) });
+        var points = await _s8HomeAlerts.GetCreatedTrendAsync(tenantId, filter, start, end);
         return Ok(new
         {
             title = start.Month == end.Month && start.Year == end.Year
                 ? $"{start.Month}月异常趋势"
                 : $"{start:MM/dd}~{end:MM/dd} 异常趋势",
-            points
+            points = points.Select(x => new { date = x.Date.ToString("MM/dd"), count = x.Count })
         });
     }
 
-    private sealed class S8HomeTrendRow
+    [HttpGet("s8-module-lamps")]
+    public async Task<IActionResult> GetS8ModuleLamps()
     {
-        public DateTime BizDate { get; set; }
-        public int Cnt { get; set; }
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var now = DateTime.Now;
+        var payload = await _s8HomeAlerts.GetModuleLampsAsync(tenantId, now);
+        return Ok(new
+        {
+            asOf = payload.AsOf.ToString("yyyy-MM-dd HH:mm:ss"),
+            items = payload.Items.Select(x => new
+            {
+                code = x.Code,
+                name = x.Name,
+                businessState = x.BusinessState,
+                monitorState = x.MonitorState,
+                seriousCount = x.SeriousCount,
+                followCount = x.FollowCount,
+                openCount = x.OpenCount,
+                configuredRuleCount = x.ConfiguredRuleCount,
+                enabledRuleCount = x.EnabledRuleCount,
+                runnableRuleCount = x.RunnableRuleCount,
+                latestRunAt = x.LatestRunAt?.ToString("yyyy-MM-dd HH:mm:ss"),
+                monitorReasonCode = x.MonitorReasonCode
+            })
+        });
     }
 
-    /// <summary>S8 报警表无独立产品/订单/产线列,按报文与编码模糊匹配基础查询。</summary>
-    private static string BuildS8AlertTextFilterSql() => """
-  AND (@product IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @product, '%')
-       OR IFNULL(metric_code,'') LIKE CONCAT('%', @product, '%')
-       OR IFNULL(alert_code,'') LIKE CONCAT('%', @product, '%'))
-  AND (@orderNo IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @orderNo, '%')
-       OR IFNULL(metric_code,'') LIKE CONCAT('%', @orderNo, '%')
-       OR IFNULL(alert_code,'') LIKE CONCAT('%', @orderNo, '%'))
-  AND (@productionLine IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @productionLine, '%')
-       OR IFNULL(metric_code,'') LIKE CONCAT('%', @productionLine, '%')
-       OR IFNULL(alert_code,'') LIKE CONCAT('%', @productionLine, '%'))
-""";
-
     [HttpGet("module-detail")]
     public async Task<IActionResult> GetModuleDetail([FromQuery] string moduleCode = "S1", [FromQuery] long factoryId = 1)
     {

+ 290 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8HomeAlertQueryService.cs

@@ -0,0 +1,290 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// 九宫格 S8 的当前异常与模块灯态。异常权威是 ado_s8_exception,监控权威是 ado_s8_watch_rule。
+/// </summary>
+public sealed class S8HomeAlertQueryService : ITransient
+{
+    public const int RecentLimit = 6;
+    public const int MinFreshWindowSeconds = 600;
+
+    /// <summary>列表、汇总、趋势共用的业务维度过滤。只匹配异常权威表上的可追溯字段。</summary>
+    public const string TextFilterSql = """
+  AND (@product IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(description,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(related_object_code,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(source_object_id,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(source_payload,'') LIKE CONCAT('%', @product, '%')
+       OR IFNULL(exception_code,'') LIKE CONCAT('%', @product, '%'))
+  AND (@orderNo IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(description,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(related_object_code,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(source_object_id,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(source_payload,'') LIKE CONCAT('%', @orderNo, '%')
+       OR IFNULL(exception_code,'') LIKE CONCAT('%', @orderNo, '%'))
+  AND (@productionLine IS NULL OR IFNULL(title,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(description,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(related_object_code,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(source_object_id,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(source_payload,'') LIKE CONCAT('%', @productionLine, '%')
+       OR IFNULL(exception_code,'') LIKE CONCAT('%', @productionLine, '%'))
+""";
+
+    private const string ActiveStatusSql = "status NOT IN ('CLOSED','REJECTED','RECOVERED')";
+
+    private readonly ISqlSugarClient _db;
+
+    public S8HomeAlertQueryService(ISqlSugarClient db) => _db = db;
+
+    public async Task<List<S8HomeAlertItem>> GetRecentAsync(long tenantId, SmartOpsDashboardFilter filter)
+    {
+        var sql = $"""
+SELECT DATE_FORMAT(COALESCE(last_detected_at, created_at), '%H:%i') AS Time,
+       IFNULL(NULLIF(stage_code,''), IFNULL(NULLIF(module_code,''), 'S8')) AS Module,
+       IFNULL(NULLIF(title,''), IFNULL(NULLIF(description,''), '异常告警')) AS Message,
+       severity AS Severity
+FROM ado_s8_exception
+WHERE tenant_id=@tenantId AND is_deleted=0 AND {ActiveStatusSql}
+  AND (@ds IS NULL OR created_at >= @ds)
+  AND (@de IS NULL OR created_at < DATE_ADD(@de, INTERVAL 1 DAY))
+  {TextFilterSql}
+ORDER BY COALESCE(last_detected_at, created_at) DESC, id DESC
+LIMIT {RecentLimit}
+""";
+        var args = FilterArgs(tenantId, filter);
+        args["ds"] = filter.DateStart;
+        args["de"] = filter.DateEnd;
+        var rows = await _db.Ado.SqlQueryAsync<S8HomeAlertRaw>(sql, args);
+        return rows.Select(MapAlert).ToList();
+    }
+
+    public async Task<S8HomeAlertSummary> GetCreatedSummaryAsync(
+        long tenantId, SmartOpsDashboardFilter filter, DateTime windowStart, DateTime windowEnd, bool isToday)
+    {
+        var sql = $"""
+SELECT COUNT(*) AS Total,
+       IFNULL(SUM(CASE WHEN UPPER(IFNULL(severity,'')) IN ('SERIOUS','HIGH','CRITICAL') THEN 1 ELSE 0 END), 0) AS Critical
+FROM ado_s8_exception
+WHERE tenant_id=@tenantId AND is_deleted=0
+  AND created_at >= @ws AND created_at < @we
+  {TextFilterSql}
+""";
+        var args = FilterArgs(tenantId, filter);
+        args["ws"] = windowStart;
+        args["we"] = windowEnd;
+        var row = (await _db.Ado.SqlQueryAsync<S8HomeSummaryRaw>(sql, args)).FirstOrDefault();
+        return new S8HomeAlertSummary(windowStart, windowEnd, isToday, row?.Total ?? 0, row?.Critical ?? 0);
+    }
+
+    public async Task<List<S8HomeTrendPoint>> GetCreatedTrendAsync(
+        long tenantId, SmartOpsDashboardFilter filter, DateTime start, DateTime end)
+    {
+        var sql = $"""
+SELECT DATE(created_at) AS BizDate, COUNT(*) AS Cnt
+FROM ado_s8_exception
+WHERE tenant_id=@tenantId AND is_deleted=0
+  AND created_at >= @ds AND created_at < DATE_ADD(@de, INTERVAL 1 DAY)
+  {TextFilterSql}
+GROUP BY DATE(created_at)
+ORDER BY BizDate
+""";
+        var args = FilterArgs(tenantId, filter);
+        args["ds"] = start;
+        args["de"] = end;
+        var rows = await _db.Ado.SqlQueryAsync<S8HomeTrendRaw>(sql, args);
+        var byDate = rows.ToDictionary(x => x.BizDate.Date, x => x.Cnt);
+        var points = new List<S8HomeTrendPoint>();
+        for (var day = start.Date; day <= end.Date; day = day.AddDays(1))
+            points.Add(new S8HomeTrendPoint(day, byDate.GetValueOrDefault(day)));
+        return points;
+    }
+
+    public async Task<S8ModuleLampPayload> GetModuleLampsAsync(long tenantId, DateTime now)
+    {
+        const string exceptionSql = """
+SELECT stage_code AS StageCode, severity AS Severity
+FROM ado_s8_exception
+WHERE tenant_id=@tenantId AND is_deleted=0
+  AND status NOT IN ('CLOSED','REJECTED','RECOVERED')
+  AND stage_code IN ('S1','S2','S3','S4','S5','S6','S7')
+""";
+        const string ruleSql = """
+SELECT stage_code AS StageCode, enabled AS Enabled, poll_interval_seconds AS PollIntervalSeconds,
+       last_status AS LastStatus, last_run_at AS LastRunAt, paused_until AS PausedUntil
+FROM ado_s8_watch_rule
+WHERE tenant_id=@tenantId
+  AND stage_code IN ('S1','S2','S3','S4','S5','S6','S7')
+""";
+        var exceptions = await _db.Ado.SqlQueryAsync<S8StageSeverityRaw>(exceptionSql, new { tenantId });
+        var ruleRows = await _db.Ado.SqlQueryAsync<S8WatchRuleRaw>(ruleSql, new { tenantId });
+        var rules = ruleRows.Select(x => new S8WatchRuleSignal(
+            x.StageCode ?? "",
+            x.Enabled,
+            x.PollIntervalSeconds,
+            x.LastStatus,
+            x.LastRunAt,
+            x.PausedUntil)).ToList();
+        var items = S8ModuleCode.All.Select(code =>
+        {
+            var stageRows = exceptions.Where(x => string.Equals(x.StageCode, code, StringComparison.Ordinal)).ToList();
+            var serious = stageRows.Count(x => S8SeverityCode.IsSerious(x.Severity));
+            var follow = stageRows.Count - serious;
+            var stageRules = rules.Where(x => string.Equals(x.StageCode, code, StringComparison.Ordinal)).ToList();
+            var decision = S8ModuleLampPolicy.Decide(serious, follow, stageRules, now);
+            return new S8ModuleLampItem(
+                code,
+                S8ModuleCode.Label(code),
+                decision.BusinessState,
+                decision.MonitorState,
+                serious,
+                follow,
+                stageRows.Count,
+                stageRules.Count,
+                stageRules.Count(x => x.Enabled),
+                stageRules.Count(x => S8ModuleLampPolicy.IsRunnable(x, now)),
+                stageRules.Where(x => x.LastRunAt.HasValue).Select(x => x.LastRunAt).Max(),
+                decision.MonitorReasonCode);
+        }).ToList();
+        return new S8ModuleLampPayload(now, items);
+    }
+
+    private static S8HomeAlertItem MapAlert(S8HomeAlertRaw row)
+    {
+        var serious = S8SeverityCode.IsSerious(row.Severity);
+        return new S8HomeAlertItem(
+            row.Time ?? "",
+            string.IsNullOrWhiteSpace(row.Module) ? "S8" : row.Module,
+            string.IsNullOrWhiteSpace(row.Message) ? "异常告警" : row.Message,
+            serious ? "critical" : "medium",
+            serious ? "严重" : "关注");
+    }
+
+    private static Dictionary<string, object?> FilterArgs(long tenantId, SmartOpsDashboardFilter filter) => new()
+    {
+        ["tenantId"] = tenantId,
+        ["product"] = BlankToNull(filter.Product),
+        ["orderNo"] = BlankToNull(filter.OrderNo),
+        ["productionLine"] = BlankToNull(filter.ProductionLine)
+    };
+
+    private static string? BlankToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
+}
+
+public static class S8ModuleLampPolicy
+{
+    public static int FreshWindowSeconds(int pollIntervalSeconds) =>
+        Math.Max(pollIntervalSeconds * 2, S8HomeAlertQueryService.MinFreshWindowSeconds);
+
+    public static bool IsRunnable(S8WatchRuleSignal rule, DateTime now) =>
+        rule.Enabled && (!rule.PausedUntil.HasValue || rule.PausedUntil.Value <= now);
+
+    public static S8ModuleLampDecision Decide(
+        int seriousCount, int followCount, IReadOnlyList<S8WatchRuleSignal> rules, DateTime now)
+    {
+        var (monitorState, reason) = ResolveMonitor(rules, now);
+        var business = seriousCount > 0 ? "CRITICAL"
+            : followCount > 0 ? "WARNING"
+            : monitorState == "HEALTHY" ? "NORMAL"
+            : "UNKNOWN";
+        return new S8ModuleLampDecision(business, monitorState, reason);
+    }
+
+    private static (string State, string Reason) ResolveMonitor(IReadOnlyList<S8WatchRuleSignal> rules, DateTime now)
+    {
+        if (rules.Count == 0)
+            return ("UNMONITORED", "NO_RULE");
+        var runnable = rules.Where(x => IsRunnable(x, now)).ToList();
+        if (runnable.Count == 0)
+            return ("PAUSED", rules.Any(x => x.Enabled) ? "RULE_PAUSED" : "RULE_DISABLED");
+
+        var reasons = runnable.Select(x => RuleReason(x, now)).Where(x => x != "OK").ToList();
+        if (reasons.Count == 0)
+            return ("HEALTHY", "OK");
+        if (reasons.Contains("RUN_FAILED"))
+            return ("DEGRADED", "RUN_FAILED");
+        if (reasons.Contains("NEVER_RUN"))
+            return ("DEGRADED", "NEVER_RUN");
+        return ("DEGRADED", "RUN_STALE");
+    }
+
+    private static string RuleReason(S8WatchRuleSignal rule, DateTime now)
+    {
+        if (!string.Equals(rule.LastStatus, "SUCCESS", StringComparison.OrdinalIgnoreCase))
+            return rule.LastRunAt.HasValue ? "RUN_FAILED" : "NEVER_RUN";
+        if (!rule.LastRunAt.HasValue)
+            return "NEVER_RUN";
+        var window = TimeSpan.FromSeconds(FreshWindowSeconds(rule.PollIntervalSeconds));
+        return now - rule.LastRunAt.Value <= window ? "OK" : "RUN_STALE";
+    }
+}
+
+public sealed record S8WatchRuleSignal(
+    string StageCode,
+    bool Enabled,
+    int PollIntervalSeconds,
+    string? LastStatus,
+    DateTime? LastRunAt,
+    DateTime? PausedUntil);
+
+public sealed record S8ModuleLampDecision(string BusinessState, string MonitorState, string MonitorReasonCode);
+
+public sealed record S8HomeAlertItem(string Time, string Module, string Message, string Level, string LevelLabel);
+
+public sealed record S8HomeAlertSummary(DateTime WindowStart, DateTime WindowEnd, bool IsToday, int Total, int Critical);
+
+public sealed record S8HomeTrendPoint(DateTime Date, int Count);
+
+public sealed record S8ModuleLampItem(
+    string Code,
+    string Name,
+    string BusinessState,
+    string MonitorState,
+    int SeriousCount,
+    int FollowCount,
+    int OpenCount,
+    int ConfiguredRuleCount,
+    int EnabledRuleCount,
+    int RunnableRuleCount,
+    DateTime? LatestRunAt,
+    string MonitorReasonCode);
+
+public sealed record S8ModuleLampPayload(DateTime AsOf, IReadOnlyList<S8ModuleLampItem> Items);
+
+internal sealed class S8HomeAlertRaw
+{
+    public string? Time { get; set; }
+    public string? Module { get; set; }
+    public string? Message { get; set; }
+    public string? Severity { get; set; }
+}
+
+internal sealed class S8HomeSummaryRaw
+{
+    public int Total { get; set; }
+    public int Critical { get; set; }
+}
+
+internal sealed class S8HomeTrendRaw
+{
+    public DateTime BizDate { get; set; }
+    public int Cnt { get; set; }
+}
+
+internal sealed class S8StageSeverityRaw
+{
+    public string? StageCode { get; set; }
+    public string? Severity { get; set; }
+}
+
+internal sealed class S8WatchRuleRaw
+{
+    public string? StageCode { get; set; }
+    public bool Enabled { get; set; }
+    public int PollIntervalSeconds { get; set; }
+    public string? LastStatus { get; set; }
+    public DateTime? LastRunAt { get; set; }
+    public DateTime? PausedUntil { get; set; }
+}