Просмотр исходного кода

refactor(s8): 将规则执行主链收敛为租户级作用域

S8 此前存在"伪 Factory Scope":取数从一开始就是租户级的——唯一的
PURCHASE_DELIVERY Provider 的 WHERE 只有 tenant_id——但规则归属、调度作用域、
dedup 身份、抗抖状态唯一键却统统按工厂切。两个后果同时存在:

- 零工厂 / 多工厂租户被形式门槛拒之门外,而放不放行对取出的数据毫无影响
  (S8MonitoringDataRequest 要求 FactoryId>0、EnableGate 报 invalid_factory_scope);
- Batch 2 供给出来的 factory_id=0 运行策略永远不被调度,且不报任何错——
  ListEnabledScopesAsync 带 AND r.factory_id > 0,页面显示已启用、实际从不执行。

代码侧收敛为 Tenant → Rule:

- 取数契约:S8MonitoringDataRequest 去掉 FactoryId 与其校验;Gateway 只传租户,
  rule.FactoryId 降为日志 metadata。Provider SQL 一个字未改——它本来就只按租户。
- Enable Gate:删除第 ⑩ 步的工厂检查,只留租户;重载签名同步收窄。
- Scheduler:ListEnabledScopesAsync → ListEnabledTenantsAsync(DISTINCT tenant_id);
  RunDispatchTick / ResetExpiredLeases / PickReadyRules / RunSingleRule /
  ProcessSingleRule / ProcessRulesByType / ReconcileRecoveries /
  HasRecentRefreshedDetectionLog 等全链去掉 factoryId 形参与谓词。
  归属复核与 rule_scope_mismatch 保留,只是少一维。
- Rule lookup:LoadScopedAsync / ListAsync 只按租户绑行,否则 factory_id=0 的
  供给行做任何配置操作都会 404。
- Evaluator:EvaluateAsync 形参去掉 factoryId;判定语义仍来自代码定义,不回退 params_json。
- DedupKey:T{t}:F{f}:R... → T{t}:R...。租户隔离由首段前缀与各查询的 tenant_id
  谓词双重保证;工厂段既不提供隔离,又让同一业务对象在不同工厂元数据下被当成两个异常。
- 异常类型存在性检查去掉工厂条件:那是一次 AnyAsync,少一个条件只会更宽松,
  否则 factory_id=0 的规则只能匹配平台默认行、找不到就整条 exception_type_missing。
  t.TenantId==0 是平台默认哨兵(S8ConfigScope),不是工厂语义,保留。
- Preview 与调度器同源:活动异常按 (租户, dedup_key) 查,两边分叉预演就不再可信。

DB 迁移 1.0.493 与代码是一个不可分割的单元。只改代码不迁数据,旧 active exception
仍是含 F 段的键而新 detection 产出无 F 段的键,调度器会认为旧异常"不再命中"从而开始
recovery,同时按新键再建一条重复异常。脚本按 preflight → 归档 → 改写 → 复验 →
重建唯一键的顺序,全部 DDL 由 information_schema 守卫,不依赖事务回滚。

改写只针对严格匹配 ^T{数字}:F{数字}:R 的行,不做无界 REPLACE(':F','')——那会误伤
任何恰好含 ":F" 的业务对象标识。碰撞门禁用具名 CHECK 约束实现(执行器拒绝含自定义
语句分隔符指令的脚本,且是全文子串匹配,故用不了存储过程 + SIGNAL),约束名即失败
原因,会进 sys_db_migration_log.error_message。碰撞明细另落诊断表,便于人工治理。

本地隔离库实测:改写 exception 33 / detection_log 155 / detection_state 28 行,
1:1 精确(逐行比对 mismatched=0),68 行非本批格式的 exception 零触碰,行数守恒,
Rule 01 在 exception / detection_state / detection_log 三处身份一致,
重复执行退出码 0 且归档不被覆盖。

首次执行时踩到一个真实缺陷并已修正:REGEXP_REPLACE 的反向引用在 MySQL 里必须写 $1,
写成 \1 会被吞成字面量 "1",租户前缀整段丢失。由归档表完整还原后重跑通过——
这也顺带验证了归档确实可用。

共享库全程只读:无 1.0.488~493 迁移记录、无新索引、无新表,
detection_state 唯一键仍含 factory_id,Rule 01 enabled=0 未变。

不做:DROP 任何 factory_id 列、Exception/TaskFlow/OperatorBinding/Notification/
MasterData 的作用域清理(Batch 6)、S8ConfigScope 的 factory_id=0 平台默认哨兵。

已知过渡态:factory_id=0 的规则建出的异常,其 exception.factory_id 也是 0,
而 S8ExceptionService 的列表查询仍按工厂过滤,因此在 Batch 6 之前这类异常不会
出现在异常中心列表里。新供给规则默认停用,正式部署要等 Tenant-only 全部完成。

chore: bump version server 1.0.493
YY968XX 6 дней назад
Родитель
Сommit
712bb6f751
25 измененных файлов с 959 добавлено и 187 удалено
  1. 6 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  2. 250 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.493.sql
  3. 2 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8LegacySymbolsRemovedTests.cs
  4. 64 22
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SchedulerScopeGuardTests.cs
  5. 11 2
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantIsolationContractTests.cs
  6. 403 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantOnlyMainChainTests.cs
  7. 3 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRuleWriteGuardTests.cs
  8. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs
  9. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8WatchDebugController.cs
  10. 7 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RuleDetectionState.cs
  11. 7 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8WatchRule.cs
  12. 11 12
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs
  13. 3 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/IS8MonitoringDataProvider.cs
  14. 3 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8PurchaseDeliveryDataProvider.cs
  15. 12 11
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs
  16. 5 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataGateway.cs
  17. 9 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataRequest.cs
  18. 2 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8WatchRuleDataAccessValidator.cs
  19. 5 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/IS8RuleEvaluator.cs
  20. 20 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs
  21. 20 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs
  22. 20 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs
  23. 7 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs
  24. 17 11
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs
  25. 70 67
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

+ 6 - 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.492</AssemblyVersion>
-    <FileVersion>1.0.492</FileVersion>
-    <Version>1.0.492</Version>
+    <AssemblyVersion>1.0.493</AssemblyVersion>
+    <FileVersion>1.0.493</FileVersion>
+    <Version>1.0.493</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -682,6 +682,9 @@
     <None Update="UpdateScripts\1.0.487.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.493.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 250 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.493.sql

@@ -0,0 +1,250 @@
+-- S8-TENANT-ONLY-BATCH5:把 S8 规则执行主链的身份从 (Tenant, Factory) 收敛为 Tenant。
+--
+-- 本脚本与同批代码是**一个不可分割的单元**。单独跑任何一边都会出事:
+--   · 只改代码不迁数据 → 旧 active exception 仍是 T{t}:F{f}:R... 而新 detection 产出 T{t}:R...,
+--     调度器会认为旧异常"不再命中"从而开始 recovery 计数,同时按新键再建一条重复异常;
+--   · 只迁数据不改代码 → 每次 tick 生成的仍是含 F 段的键,与库里刚改完的键对不上,同样重复建单。
+--
+-- 变更(DDL 部分在 MySQL 下会自动提交,脚本按"每步可独立重跑"设计,不依赖事务回滚):
+--   ① preflight:去掉 :F{factory} 段后是否产生键碰撞 —— 有则整脚本中止,不自动挑一条
+--   ② 归档受影响行的最小恢复字段
+--   ③ 重写 dedup_key:仅限严格匹配 ^T{数字}:F{数字}:R 的行
+--   ④ 复验:重写后 collision 必须为 0
+--   ⑤ ado_s8_rule_detection_state 唯一键 (tenant,factory,rule_code,dedup_key) → (tenant,rule_code,dedup_key)
+--   ⑥ ado_s8_watch_rule 新增 UNIQUE(tenant_id, rule_code)
+--
+-- 明确**不做**:
+--   · 不 DROP 任何 factory_id 列(本批做的是"移除隔离语义",不是 schema 美化)
+--   · 不动 ado_s8_exception 的 uk(tenant,factory,exception_code)(异常侧属 Batch 6)
+--   · 不动 ado_s8_exception_bak_1_0_487(那是 1.0.487 的还原点,重写它等于毁掉备份)
+--   · 不动 mdp_entity.dedup_key_path(数据中台的同名概念,与 S8 规则去重无关)
+--
+-- 迁移前实测(2026-09-06,本地隔离克隆 aidopdev_s8_local,非沿用历史数字):
+--   旧格式行数    : exception 33 · detection_log 155 · detection_state 28
+--   非旧格式行数  : exception 68(不得触碰)· detection_log 0 · detection_state 0
+--   去 F 段后碰撞: exception 活动单 0 · detection_state 0
+--   watch_rule (tenant_id, rule_code) 重复组: 0
+
+-- ============================================================================
+-- ① PREFLIGHT:碰撞门禁
+--
+-- 去掉 F 段后若出现「同租户 + 同规则 + 同对象」的多个旧键映射到同一个新键,
+-- 说明历史上确实靠工厂区分过两条不同的异常。那种情况下自动挑一条留下会静默丢数据,
+-- 因此直接让脚本失败、交人工治理。
+-- ============================================================================
+
+SET @collision_exception := (
+  SELECT COUNT(*) FROM (
+    SELECT tenant_id, REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:') nk, COUNT(*) c
+    FROM ado_s8_exception
+    WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R' AND is_deleted = 0 AND status <> 'CLOSED'
+    GROUP BY 1, 2 HAVING c > 1) z);
+
+SET @collision_state := (
+  SELECT COUNT(*) FROM (
+    SELECT tenant_id, rule_code, REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:') nk, COUNT(*) c
+    FROM ado_s8_rule_detection_state
+    WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R'
+    GROUP BY 1, 2, 3 HAVING c > 1) z);
+
+SET @dup_watch_rule := (
+  SELECT COUNT(*) FROM (
+    SELECT tenant_id, rule_code, COUNT(*) c FROM ado_s8_watch_rule GROUP BY 1, 2 HAVING c > 1) z);
+
+-- 门禁实现说明:
+--   AutoVersionUpdate 明确拒绝含自定义语句分隔符指令的脚本("当前执行器不支持"),
+--   而且它是对全文做子串匹配 —— 连注释里出现那个关键字都会被拦,
+--   因此本文件通篇不出现该词。既然用不了存储过程 + SIGNAL,
+--   改用**具名 CHECK 约束**:约束名本身携带失败原因,
+--   MySQL 的报错文案会带上它("Check constraint 'xxx' is violated"),
+--   而该文案会被 AutoVersionUpdate 写进 sys_db_migration_log.error_message,
+--   于是运维在日志里直接看得到"为什么被挡",不必回头翻脚本。
+--   本版本随即被记为 Failed 并阻止应用启动 —— 迁移没过这件事不可能被忽略。
+
+-- 先把碰撞明细落一张诊断表:被挡住时运维需要知道**具体哪几个键**撞了,
+-- 只给一个计数会让人无从下手。
+DROP TABLE IF EXISTS ado_s8_tenant_only_collision_1_0_493;
+CREATE TABLE ado_s8_tenant_only_collision_1_0_493 (
+  source_table VARCHAR(64) NOT NULL,
+  tenant_id BIGINT NULL,
+  rule_code VARCHAR(64) NULL,
+  new_dedup_key VARCHAR(256) NULL,
+  row_count INT NULL
+) COMMENT 'S8-TENANT-ONLY-BATCH5 去 F 段后的 dedup_key 碰撞明细(为空表示无碰撞)';
+
+INSERT INTO ado_s8_tenant_only_collision_1_0_493 (source_table, tenant_id, rule_code, new_dedup_key, row_count)
+SELECT 'ado_s8_exception', tenant_id, source_rule_code,
+       REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:'), COUNT(*)
+FROM ado_s8_exception
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R' AND is_deleted = 0 AND status <> 'CLOSED'
+GROUP BY 1, 2, 3, 4 HAVING COUNT(*) > 1;
+
+INSERT INTO ado_s8_tenant_only_collision_1_0_493 (source_table, tenant_id, rule_code, new_dedup_key, row_count)
+SELECT 'ado_s8_rule_detection_state', tenant_id, rule_code,
+       REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:'), COUNT(*)
+FROM ado_s8_rule_detection_state
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R'
+GROUP BY 1, 2, 3, 4 HAVING COUNT(*) > 1;
+
+INSERT INTO ado_s8_tenant_only_collision_1_0_493 (source_table, tenant_id, rule_code, new_dedup_key, row_count)
+SELECT 'ado_s8_watch_rule_duplicate_identity', tenant_id, rule_code, NULL, COUNT(*)
+FROM ado_s8_watch_rule GROUP BY 1, 2, 3, 4 HAVING COUNT(*) > 1;
+
+-- 门禁本体:probe 非 0 即违反 CHECK,脚本在**任何写入之前**失败。
+DROP TABLE IF EXISTS tmp_s8_493_preflight_guard;
+CREATE TABLE tmp_s8_493_preflight_guard (
+  probe INT NOT NULL,
+  CONSTRAINT s8_tenant_only_BLOCKED_see_collision_table_1_0_493 CHECK (probe = 0)
+) COMMENT 'S8-TENANT-ONLY-BATCH5 preflight 门禁;成功后自动删除';
+
+INSERT INTO tmp_s8_493_preflight_guard (probe)
+VALUES (@collision_exception + @collision_state + @dup_watch_rule);
+
+DROP TABLE IF EXISTS tmp_s8_493_preflight_guard;
+
+-- ============================================================================
+-- ② 归档
+--
+-- 只存恢复所需的最小字段,不整表复制:受影响的是 dedup_key 这一列,
+-- 备份整张 exception 表既慢又没有额外价值。
+-- INSERT IGNORE + 主键:脚本重跑时不会把已改写后的值再存一遍覆盖原值。
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS ado_s8_exception_dedup_bak_1_0_493 (
+  id BIGINT NOT NULL PRIMARY KEY,
+  tenant_id BIGINT NULL,
+  factory_id BIGINT NULL,
+  source_rule_code VARCHAR(64) NULL,
+  dedup_key VARCHAR(128) NULL
+) COMMENT 'S8-TENANT-ONLY-BATCH5 dedup_key 迁移前快照';
+
+CREATE TABLE IF NOT EXISTS ado_s8_detection_log_dedup_bak_1_0_493 (
+  id BIGINT NOT NULL PRIMARY KEY,
+  tenant_id BIGINT NULL,
+  factory_id BIGINT NULL,
+  rule_code VARCHAR(64) NULL,
+  dedup_key VARCHAR(256) NULL
+) COMMENT 'S8-TENANT-ONLY-BATCH5 dedup_key 迁移前快照';
+
+CREATE TABLE IF NOT EXISTS ado_s8_rule_detection_state_dedup_bak_1_0_493 (
+  id BIGINT NOT NULL PRIMARY KEY,
+  tenant_id BIGINT NULL,
+  factory_id BIGINT NULL,
+  rule_code VARCHAR(64) NULL,
+  dedup_key VARCHAR(128) NULL
+) COMMENT 'S8-TENANT-ONLY-BATCH5 dedup_key 迁移前快照';
+
+INSERT IGNORE INTO ado_s8_exception_dedup_bak_1_0_493 (id, tenant_id, factory_id, source_rule_code, dedup_key)
+SELECT id, tenant_id, factory_id, source_rule_code, dedup_key
+FROM ado_s8_exception WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+INSERT IGNORE INTO ado_s8_detection_log_dedup_bak_1_0_493 (id, tenant_id, factory_id, rule_code, dedup_key)
+SELECT id, tenant_id, factory_id, rule_code, dedup_key
+FROM ado_s8_detection_log WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+INSERT IGNORE INTO ado_s8_rule_detection_state_dedup_bak_1_0_493 (id, tenant_id, factory_id, rule_code, dedup_key)
+SELECT id, tenant_id, factory_id, rule_code, dedup_key
+FROM ado_s8_rule_detection_state WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+-- ============================================================================
+-- ③ 重写 dedup_key
+--
+-- 只改严格匹配 ^T{数字}:F{数字}:R 的行,且只去掉 :F{数字} 这一段 ——
+-- ⚠️ 反向引用必须写 $1:MySQL 的 REGEXP_REPLACE 用 ICU 语法,
+--    写成 \1 不会被当作分组引用,而是被吞成字面量 "1" —— 租户前缀会整段丢失,
+--    得到 "1:RRULE_..." 这种既不是旧格式也不是新格式的坏键。
+--    本地隔离库首次执行时实测踩到过,已由归档表还原后修正。
+-- 不做全表 REPLACE(':F', ''):那会误伤任何恰好含 ":F" 的对象标识(例如 PO 号里带 :F)。
+-- 正则锚定行首 + 数字类,把改写面收敛到确实由旧 BuildDedupKey 生成的键。
+-- 幂等:改写后的行不再匹配该正则,重跑即 0 行受影响。
+-- ============================================================================
+
+UPDATE ado_s8_exception
+SET dedup_key = REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:')
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+UPDATE ado_s8_detection_log
+SET dedup_key = REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:')
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+UPDATE ado_s8_rule_detection_state
+SET dedup_key = REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:')
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+-- ============================================================================
+-- ④ 复验:重写后不得残留旧格式、不得出现碰撞
+-- ============================================================================
+
+SET @left_over := (
+  (SELECT COUNT(*) FROM ado_s8_exception WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R')
+  + (SELECT COUNT(*) FROM ado_s8_detection_log WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R')
+  + (SELECT COUNT(*) FROM ado_s8_rule_detection_state WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R'));
+
+SET @post_collision := (
+  SELECT COUNT(*) FROM (
+    SELECT tenant_id, rule_code, dedup_key, COUNT(*) c
+    FROM ado_s8_rule_detection_state GROUP BY 1, 2, 3 HAVING c > 1) z);
+
+-- 与 preflight 同一机制:约束名即失败原因。
+DROP TABLE IF EXISTS tmp_s8_493_postcheck_guard;
+CREATE TABLE tmp_s8_493_postcheck_guard (
+  probe INT NOT NULL,
+  CONSTRAINT s8_tenant_only_POSTCHECK_leftover_or_collision_1_0_493 CHECK (probe = 0)
+) COMMENT 'S8-TENANT-ONLY-BATCH5 改写后复验门禁;成功后自动删除';
+
+INSERT INTO tmp_s8_493_postcheck_guard (probe) VALUES (@left_over + @post_collision);
+
+DROP TABLE IF EXISTS tmp_s8_493_postcheck_guard;
+
+-- ============================================================================
+-- ⑤ ado_s8_rule_detection_state 唯一键去掉 factory_id
+--
+-- 必须与代码同批:upsert 的 WHERE 已改为 (tenant, rule_code, dedup_key),
+-- 而旧唯一键仍允许同键在不同 factory 下存两行 —— 抗抖计数会分裂,
+-- trigger/recover 阈值静默失效。
+-- 用 information_schema 判存在性:MySQL 无 DROP INDEX IF EXISTS。
+-- ============================================================================
+
+SET @has_old_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_detection_state'
+    AND INDEX_NAME = 'uk_s8_rule_detection_state_dedup');
+SET @sql := IF(@has_old_uk > 0,
+  'ALTER TABLE ado_s8_rule_detection_state DROP INDEX uk_s8_rule_detection_state_dedup', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @has_new_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_detection_state'
+    AND INDEX_NAME = 'uk_s8_rule_detection_state_dedup');
+SET @sql := IF(@has_new_uk = 0,
+  'ALTER TABLE ado_s8_rule_detection_state ADD UNIQUE INDEX uk_s8_rule_detection_state_dedup (tenant_id, rule_code, dedup_key)', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 辅助索引同步去掉 factory_id(非唯一,纯查询效率)。
+SET @has_idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_detection_state'
+    AND INDEX_NAME = 'idx_s8_rule_detection_state_rule' AND COLUMN_NAME = 'factory_id');
+SET @sql := IF(@has_idx > 0,
+  'ALTER TABLE ado_s8_rule_detection_state DROP INDEX idx_s8_rule_detection_state_rule', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @has_idx2 := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_detection_state'
+    AND INDEX_NAME = 'idx_s8_rule_detection_state_rule');
+SET @sql := IF(@has_idx2 = 0,
+  'ALTER TABLE ado_s8_rule_detection_state ADD INDEX idx_s8_rule_detection_state_rule (tenant_id, rule_code)', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ============================================================================
+-- ⑥ ado_s8_watch_rule 新增 UNIQUE(tenant_id, rule_code)
+--
+-- Batch 2 把运行策略身份定为 (TenantId, RuleCode),但当时只有应用层 AnyAsync 兜底,
+-- 因此只保证"顺序幂等",两个实例同时供给理论上仍可能建出重复行。
+-- 本索引把那个缺口在 DB 层封死。preflight 已确认无重复行。
+-- ============================================================================
+
+SET @has_rule_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_watch_rule'
+    AND INDEX_NAME = 'uk_s8_watch_rule_tenant_code');
+SET @sql := IF(@has_rule_uk = 0,
+  'ALTER TABLE ado_s8_watch_rule ADD UNIQUE INDEX uk_s8_watch_rule_tenant_code (tenant_id, rule_code)', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 2 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8LegacySymbolsRemovedTests.cs

@@ -55,7 +55,8 @@ public class S8LegacySymbolsRemovedTests
         var names = evaluate.GetParameters().Select(p => p.Name).ToArray();
 
         Assert.DoesNotContain("alertRules", names);
-        Assert.Equal(new[] { "tenantId", "factoryId", "rule", "cancellationToken" }, names);
+        // S8-TENANT-ONLY-BATCH5:factoryId 已退出判定作用域。
+        Assert.Equal(new[] { "tenantId", "rule", "cancellationToken" }, names);
     }
 
     /// <summary>

+ 64 - 22
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SchedulerScopeGuardTests.cs

@@ -48,18 +48,26 @@ public class S8SchedulerScopeGuardTests
 
     /// <summary>
     /// 缺口 1:执行入口必须按本次 tick 的 scope 绑行,并对归属不符 fail-fast。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5</b>:作用域收敛为租户。绑行与归属复核**必须仍在**
+    /// —— 去掉的只是工厂那一维,不是这道防线本身。同时反向断言工厂谓词不得回归,
+    /// 否则 Batch 2 供给出来的 factory_id=0 的规则会再次被静默排除在执行之外。</para>
     /// </summary>
     [Fact]
-    public void RunSingleRule_BindsRuleToTickScope_AndFailsFastOnMismatch()
+    public void RunSingleRule_BindsRuleToTenantScope_AndFailsFastOnMismatch()
     {
         var code = CodeOnly(SchedulerSrc);
 
-        // 装载规则时必须同时带 TenantId / FactoryId 谓词,禁止裸 Id 装载。
-        Assert.Contains("x.Id == lease.RuleId && x.TenantId == tenantId && x.FactoryId == factoryId", code);
+        // 装载规则时必须带 TenantId 谓词,禁止裸 Id 装载。
+        Assert.Contains("x.Id == lease.RuleId && x.TenantId == tenantId", code);
 
         // 归属复核 + 明确的失败原因码;不得静默继续执行。
         Assert.Contains("rule_scope_mismatch", code);
-        Assert.Contains("rule.TenantId != tenantId || rule.FactoryId != factoryId", code);
+        Assert.Contains("rule.TenantId != tenantId", code);
+
+        // 反向:工厂不得重新进入规则身份判定。
+        Assert.DoesNotContain("x.FactoryId == factoryId", code);
+        Assert.DoesNotContain("rule.FactoryId != factoryId", code);
     }
 
     /// <summary>
@@ -113,35 +121,69 @@ public class S8SchedulerScopeGuardTests
     }
 
     /// <summary>
-    /// 遍历入口本身保持「平台后台任务」语义:按租户枚举 + 只取 Status=1 的租户,
-    /// 但每个 scope 必须带 tenant_id / factory_id 两个维度(不能只按租户)。
+    /// 遍历入口保持「平台后台任务」语义:按租户枚举 + 只取 Status=1 的租户。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5</b>:遍历维度由 (tenant, factory) 改为 tenant。
+    /// 特别断言 <c>AND r.factory_id &gt; 0</c> **不得回归** —— 它会让 Batch 2 供给出来的
+    /// factory_id=0 的运行策略永远不被调度,且不报任何错:页面显示已启用,实际从不执行。</para>
     /// </summary>
     [Fact]
-    public void EnabledScopeEnumeration_IsTenantAndFactoryPaired()
+    public void EnabledScopeEnumeration_IsTenantOnly()
     {
         var code = CodeOnly(SchedulerSrc);
-        Assert.Contains("SELECT DISTINCT r.tenant_id AS TenantId, r.factory_id AS FactoryId", code);
+        Assert.Contains("SELECT DISTINCT r.tenant_id", code);
         Assert.Contains("INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1", code);
         Assert.Contains("AND r.tenant_id > 0", code);
-        Assert.Contains("AND r.factory_id > 0", code);
+
+        Assert.DoesNotContain("r.factory_id AS FactoryId", code);
+        Assert.DoesNotContain("AND r.factory_id > 0", code);
+    }
+
+    /// <summary>
+    /// 下游写入口必须继续以**显式租户**承接,不允许退回从 rule / 客户端推导。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5</b>:第二个 long 形参(factoryId)已移除。
+    /// 这里改为断言"第一个形参是 tenantId 且形参里不再出现 factoryId" ——
+    /// 显式作用域这条要求没有放松,只是维度少了一个。</para>
+    /// </summary>
+    [Theory]
+    [InlineData(nameof(S8WatchSchedulerService.PickReadyRulesAsync))]
+    [InlineData(nameof(S8WatchSchedulerService.RunSingleRuleAsync))]
+    [InlineData(nameof(S8WatchSchedulerService.ResetExpiredLeasesAsync))]
+    [InlineData(nameof(S8WatchSchedulerService.RunDispatchTickAsync))]
+    [InlineData(nameof(S8WatchSchedulerService.OnRuleCompletedAsync))]
+    public void DownstreamWriters_KeepExplicitTenantScope(string method)
+    {
+        var m = typeof(S8WatchSchedulerService).GetMethod(method);
+        Assert.NotNull(m);
+
+        var ps = m!.GetParameters();
+        Assert.Equal("tenantId", ps[0].Name);
+        Assert.Equal(typeof(long), ps[0].ParameterType);
+
+        Assert.DoesNotContain(ps, p => p.Name == "factoryId");
     }
 
     /// <summary>
-    /// 下游写入口必须继续以显式 (tenantId, factoryId) 承接,不允许退回从 rule / 客户端推导。
+    /// S8-TENANT-ONLY-BATCH5:dedup_key 不得再含工厂段。
+    ///
+    /// <para>这条是本批最关键的防回归点:一旦有人把 <c>:F{factory}</c> 加回去,
+    /// 历史异常与新命中会分裂成两套身份 —— 旧异常开始 recovery、新键再建一条重复单,
+    /// 而且这个后果只会在生产跑一段时间之后才显形。</para>
     /// </summary>
     [Fact]
-    public void DownstreamWriters_KeepExplicitScopeParameters()
+    public void DedupKey_CarriesNoFactorySegment()
     {
-        var pick = typeof(S8WatchSchedulerService).GetMethod(
-            nameof(S8WatchSchedulerService.PickReadyRulesAsync));
-        Assert.NotNull(pick);
-        Assert.Equal(typeof(long), pick!.GetParameters()[0].ParameterType);
-        Assert.Equal(typeof(long), pick.GetParameters()[1].ParameterType);
-
-        var run = typeof(S8WatchSchedulerService).GetMethod(
-            nameof(S8WatchSchedulerService.RunSingleRuleAsync));
-        Assert.NotNull(run);
-        Assert.Equal(typeof(long), run!.GetParameters()[0].ParameterType);
-        Assert.Equal(typeof(long), run.GetParameters()[1].ParameterType);
+        foreach (var src in new[]
+                 {
+                     "Service/S8/Rules/S8TimeoutRuleEvaluator.cs",
+                     "Service/S8/Rules/S8ShortageRuleEvaluator.cs",
+                     "Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs",
+                 })
+        {
+            var code = CodeOnly(src);
+            Assert.Contains("$\"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}\"", code);
+            Assert.DoesNotContain(":F{factoryId}", code);
+        }
     }
 }

+ 11 - 2
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantIsolationContractTests.cs

@@ -24,11 +24,20 @@ public class S8TenantIsolationContractTests
             [typeof(long), typeof(long), typeof(S8RuleHit)]));
     }
 
+    /// <summary>
+    /// S8-TENANT-ONLY-BATCH5:规则调度的遍历维度已从 (租户, 工厂) 收敛为租户。
+    /// 断言的是「新形态存在 + 旧形态不存在」——只断言新方法在,挡不住有人把旧方法加回来。
+    ///
+    /// <para><c>S8ActiveFlowWatchService.ListActiveScopesAsync</c> 仍是 (租户, 工厂):
+    /// 它属于异常侧,归 Batch 6,本批刻意不动。</para>
+    /// </summary>
     [Fact]
-    public void SchedulerServices_DiscoverValidTenantScopes()
+    public void SchedulerDiscoversTenants_NotTenantFactoryPairs()
     {
         Assert.NotNull(typeof(S8WatchSchedulerService).GetMethod(
-            nameof(S8WatchSchedulerService.ListEnabledScopesAsync)));
+            nameof(S8WatchSchedulerService.ListEnabledTenantsAsync)));
+        Assert.Null(typeof(S8WatchSchedulerService).GetMethod("ListEnabledScopesAsync"));
+
         Assert.NotNull(typeof(S8ActiveFlowWatchService).GetMethod(
             nameof(S8ActiveFlowWatchService.ListActiveScopesAsync)));
     }

+ 403 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantOnlyMainChainTests.cs

@@ -0,0 +1,403 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using SqlSugar;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-TENANT-ONLY-BATCH5:规则执行主链收敛为租户级作用域。
+///
+/// <para><b>本批要消灭的是"伪 Factory Scope"</b>:取数从一开始就是租户级的
+/// (唯一 Provider 的 WHERE 只有 <c>tenant_id</c>),但规则归属、调度作用域、
+/// dedup 身份、抗抖状态唯一键却统统按工厂切。结果是两件事同时发生:</para>
+/// <list type="bullet">
+///   <item>零工厂 / 多工厂租户被形式门槛拒之门外,而放不放行对取出的数据毫无影响;</item>
+///   <item>Batch 2 供给出来的 <c>factory_id = 0</c> 运行策略永远不被调度,且不报任何错
+///         —— 页面显示已启用,实际从不执行。</item>
+/// </list>
+///
+/// <para>本文件的多数断言是**反向的**("不得出现 factory"),因为这类回归的特征是
+/// 悄悄加回一个谓词就让一批规则静默失效,正向断言抓不到。</para>
+/// </summary>
+public class S8TenantOnlyMainChainTests
+{
+    private const long TenantA = 838257186181189L;
+    private const long TenantB = 838257212780613L;
+    private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
+
+    private static S8RuleDefinition Definition() =>
+        new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
+            .GetRequired(Rule01);
+
+    // ───────────────── T1:取数请求契约 ─────────────────
+
+    [Fact]
+    public void T1_MonitoringDataRequest_HasNoFactoryScope()
+    {
+        Assert.Null(typeof(S8MonitoringDataRequest).GetProperty("FactoryId"));
+
+        // 只缺租户才失败;工厂已不是作用域的一部分,无从缺失。
+        var req = new S8MonitoringDataRequest
+        {
+            TenantId = TenantA, DatasetCode = "D", RuleCode = Rule01, RuleType = "TIMEOUT"
+        };
+        req.EnsureValid();   // 不抛即通过
+
+        var invalid = new S8MonitoringDataRequest
+        {
+            TenantId = 0, DatasetCode = "D", RuleCode = Rule01, RuleType = "TIMEOUT"
+        };
+        var ex = Assert.Throws<S8RuleEvaluatorException>(() => invalid.EnsureValid());
+        Assert.Equal("dataset_scope_invalid", ex.Reason);
+        Assert.DoesNotContain("FactoryId", ex.Message, StringComparison.Ordinal);
+    }
+
+    // ───────────────── T2 / T4 / T6 / T16:FactoryId = 0 不再阻塞 ─────────────────
+
+    /// <summary>
+    /// Enable Gate 不得再因 <c>factory_id &lt;= 0</c> 拒绝规则。
+    /// 这条直接决定 Batch 2 供给出来的运行策略能不能被启用。
+    /// </summary>
+    [Fact]
+    public void T2_EnableGate_TakesTenantOnly_AndHasNoFactoryOverload()
+    {
+        var gate = typeof(S8DatasetEnableGate);
+
+        Assert.NotNull(gate.GetMethod("Check", new[] { typeof(AdoS8WatchRule), typeof(long) }));
+        Assert.Null(gate.GetMethod("Check", new[] { typeof(AdoS8WatchRule), typeof(long), typeof(long) }));
+
+        Assert.NotNull(gate.GetMethod("EnsureCanEnable", new[] { typeof(AdoS8WatchRule), typeof(long) }));
+        Assert.Null(gate.GetMethod("EnsureCanEnable", new[] { typeof(AdoS8WatchRule), typeof(long), typeof(long) }));
+    }
+
+    [Fact]
+    public void T2_EnableGate_SourceHasNoInvalidFactoryScopeCheck()
+    {
+        var code = CodeOnly("Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs");
+        Assert.DoesNotContain("InvalidFactoryScope", code);
+        Assert.DoesNotContain("factoryId <= 0", code);
+
+        // 反向对照:租户门槛必须还在,否则是把门整个拆了而不是收敛。
+        Assert.Contains("tenantId <= 0", code);
+        Assert.Contains("InvalidTenantScope", code);
+    }
+
+    /// <summary>
+    /// T4 / T16:Batch 2 供给出来的行 <c>factory_id = 0</c>。
+    /// 调度候选查询若还带工厂谓词,这些行会被静默排除 —— 断言源码里不存在该谓词。
+    /// </summary>
+    [Fact]
+    public void T4_T16_ProvisionedFactoryZeroRule_IsNotExcludedByScheduler()
+    {
+        Assert.Equal(0L, S8RuleProvisioningService.CompatibilityFactoryId);
+
+        var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
+        Assert.DoesNotContain("AND r.factory_id > 0", code);
+        Assert.DoesNotContain("x.FactoryId == factoryId", code);
+    }
+
+    // ───────────────── T3 / T5 / T6:Tenant → Rule ─────────────────
+
+    [Fact]
+    public void T3_Scheduler_EnumeratesTenants_ReturningPlainTenantIds()
+    {
+        var m = typeof(S8WatchSchedulerService).GetMethod("ListEnabledTenantsAsync");
+        Assert.NotNull(m);
+        Assert.Equal(typeof(Task<List<long>>), m!.ReturnType);
+
+        Assert.Null(typeof(S8WatchSchedulerService).GetMethod("ListEnabledScopesAsync"));
+    }
+
+    /// <summary>
+    /// T5 / T6:0 / 1 / N 个工厂的租户都只产生**一次**调度。
+    ///
+    /// <para>工厂数量根本不是调度遍历的输入 —— 这正是要断言的事实:
+    /// 遍历返回的是租户 id 列表,一个租户在里面只可能出现一次(SQL 的 DISTINCT tenant_id),
+    /// 因此无论它有几个工厂,规则都只跑一遍。</para>
+    /// </summary>
+    [Theory]
+    [InlineData(0)]
+    [InlineData(1)]
+    [InlineData(3)]
+    public void T5_T6_TenantIsDispatchedOnce_RegardlessOfFactoryCount(int factoryCount)
+    {
+        _ = factoryCount;   // 不是输入,故意不用
+
+        var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
+        Assert.Contains("SELECT DISTINCT r.tenant_id", code);
+        Assert.DoesNotContain("DISTINCT r.tenant_id AS TenantId, r.factory_id", code);
+
+        // Job 侧按租户遍历,不再按 (租户, 工厂) 对。
+        var job = CodeOnly("Job/S8WatchSchedulerJob.cs");
+        Assert.Contains("foreach (var tenantId in tenantIds)", job);
+        Assert.Contains("RunDispatchTickAsync(tenantId, BatchSize, lockedBy)", job);
+    }
+
+    // ───────────────── T7:Evaluator 契约 ─────────────────
+
+    [Fact]
+    public void T7_EvaluatorContract_HasNoFactoryScope()
+    {
+        var names = typeof(IS8RuleEvaluator).GetMethod(nameof(IS8RuleEvaluator.EvaluateAsync))!
+            .GetParameters().Select(p => p.Name).ToArray();
+        Assert.Equal(new[] { "tenantId", "rule", "cancellationToken" }, names);
+
+        foreach (var impl in new[]
+                 { typeof(S8TimeoutRuleEvaluator), typeof(S8ShortageRuleEvaluator), typeof(S8OutOfRangeRuleEvaluator) })
+        {
+            var ps = impl.GetMethod("EvaluateAsync")!.GetParameters().Select(p => p.Name).ToArray();
+            Assert.DoesNotContain("factoryId", ps);
+        }
+    }
+
+    // ───────────────── T8 / T9 / T10:DedupKey vNext ─────────────────
+
+    [Fact]
+    public void T8_DedupKey_ContainsNoFactorySegment()
+    {
+        var key = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, Rule01, "PURCHASE_ORDER_LINE", "UATA-PO-001#1");
+
+        Assert.Equal($"T{TenantA}:R{Rule01}:PURCHASE_ORDER_LINE:UATA-PO-001#1", key);
+        Assert.DoesNotMatch(new Regex("^T[0-9]+:F[0-9]+:"), key);
+
+        // 租户前缀必须完整保留 —— 迁移脚本首版曾把它写掉过(MySQL 反向引用要用 $1 而非 \\1)。
+        Assert.StartsWith($"T{TenantA}:", key, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// T9:同租户 / 同规则 / 同对象,无论工厂元数据是什么,dedup 身份必须相同。
+    /// 三个 evaluator 的公式必须逐字一致,否则跨类型的历史迁移会出现两套格式。
+    /// </summary>
+    [Fact]
+    public void T9_SameTenantRuleObject_YieldsSameDedup_AcrossEvaluators()
+    {
+        var t = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
+        var s = S8ShortageRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
+        var o = S8OutOfRangeRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
+
+        Assert.Equal(t, s);
+        Assert.Equal(t, o);
+    }
+
+    [Fact]
+    public void T10_DifferentTenant_SameObject_YieldsDifferentDedup()
+    {
+        var a = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
+        var b = S8TimeoutRuleEvaluator.BuildDedupKey(TenantB, "R", "ORDER", "ID");
+
+        Assert.NotEqual(a, b);
+        Assert.StartsWith($"T{TenantA}:", a, StringComparison.Ordinal);
+        Assert.StartsWith($"T{TenantB}:", b, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T8_DedupKey_StillFitsVarchar128()
+    {
+        // 去掉 F 段后前缀更短,余量只会变大;本断言防的是将来又往键里塞东西。
+        var k = S8TimeoutRuleEvaluator.BuildDedupKey(
+            9999999999999999L, "VERY_LONG_RULE_CODE_FOR_PADDING_64____",
+            "EXTREMELY_LONG_OBJECT_TYPE______", "EXTREMELY_LONG_OBJECT_ID________");
+        Assert.True(k.Length <= 128, $"dedup_key length {k.Length} exceeds 128: {k}");
+    }
+
+    // ───────────────── T11:DetectionState identity ─────────────────
+
+    [Fact]
+    public void T11_DetectionStateUniqueKey_IsTenantRuleDedup()
+    {
+        var uk = typeof(AdoS8RuleDetectionState)
+            .GetCustomAttributes<SugarIndexAttribute>()
+            .Single(a => a.IndexName == "uk_s8_rule_detection_state_dedup");
+
+        Assert.True(uk.IsUnique);
+        Assert.Equal(new[] { "TenantId", "RuleCode", "DedupKey" }, uk.IndexFields.Keys.ToArray());
+        Assert.DoesNotContain("FactoryId", uk.IndexFields.Keys);
+    }
+
+    [Fact]
+    public void T11_DetectionStateUpsert_LooksUpWithoutFactory()
+    {
+        var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
+
+        // 抗抖状态的 upsert / 累计必须只按 (tenant, rule_code, dedup_key)。
+        Assert.Contains("x.RuleCode == rule.RuleCode", code);
+        Assert.DoesNotContain("x.FactoryId == factoryId", code);
+    }
+
+    // ───────────────── T14 / T15:Rule scope ─────────────────
+
+    [Fact]
+    public void T14_WatchRuleLookup_IsTenantOnly()
+    {
+        var code = CodeOnly("Service/S8/S8WatchRuleService.cs");
+
+        Assert.Contains("x.Id == id && x.TenantId == scope.TenantId", code);
+        Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
+    }
+
+    [Fact]
+    public void T15_WatchRuleUniqueIdentity_IsTenantAndRuleCode()
+    {
+        var uk = typeof(AdoS8WatchRule)
+            .GetCustomAttributes<SugarIndexAttribute>()
+            .Single(a => a.IndexName == "uk_s8_watch_rule_tenant_code");
+
+        Assert.True(uk.IsUnique);
+        Assert.Equal(new[] { "TenantId", "RuleCode" }, uk.IndexFields.Keys.ToArray());
+    }
+
+    // ───────────────── T13:Preview 与真跑同源 ─────────────────
+
+    /// <summary>
+    /// 预演必须与调度器用同一套作用域与 dedup 口径。
+    /// 两边一旦分叉,预演给出的"会新建还是会刷新"就不再可信 —— 而那正是预演唯一的价值。
+    /// </summary>
+    [Fact]
+    public void T13_Preview_UsesSameTenantOnlyScopeAsScheduler()
+    {
+        var code = CodeOnly("Service/S8/S8WatchRulePreviewService.cs");
+
+        Assert.Contains("_gateway.LoadAsync(scope.TenantId, rule, ruleType, maxRows)", code);
+        Assert.Contains("evaluator.EvaluateAsync(scope.TenantId, rule)", code);
+        Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
+    }
+
+    // ───────────────── T17–T22:迁移脚本自身的守卫 ─────────────────
+
+    /// <summary>
+    /// 迁移脚本必须自带碰撞门禁:去掉 F 段后若两个旧键映射到同一个新键,
+    /// 自动挑一条会静默丢数据,必须让脚本失败。
+    /// </summary>
+    [Fact]
+    public void T17_Migration_HasCollisionPreflightGate()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("@collision_exception", sql);
+        Assert.Contains("@collision_state", sql);
+        Assert.Contains("@dup_watch_rule", sql);
+
+        // 门禁靠具名 CHECK 约束;约束名即失败原因,会出现在 error_message 里。
+        Assert.Contains("s8_tenant_only_BLOCKED_see_collision_table_1_0_493", sql);
+        Assert.Contains("CHECK (probe = 0)", sql);
+    }
+
+    /// <summary>
+    /// T18:改写必须用 <c>$1</c> 反向引用。
+    ///
+    /// <para>MySQL 的 <c>REGEXP_REPLACE</c> 用 ICU 语法,<c>\\1</c> 不是分组引用而会被吞成字面量 "1"
+    /// —— 租户前缀整段丢失,得到既不是旧格式也不是新格式的坏键。
+    /// 本地隔离库首次执行时实测踩到过,本断言防它复发。</para>
+    /// </summary>
+    [Fact]
+    public void T18_Migration_UsesDollarBackreference_NotBackslash()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("'$1:'", sql);
+        Assert.DoesNotContain("'\\\\1:'", sql);
+    }
+
+    /// <summary>T19:改写面必须锚定旧格式,不得做无界字符串替换。</summary>
+    [Fact]
+    public void T19_Migration_RewritesOnlyAnchoredLegacyFormat()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("'^T[0-9]+:F[0-9]+:R'", sql);
+        // 无界替换会误伤任何恰好含 ":F" 的业务对象标识。
+        Assert.DoesNotContain("REPLACE(dedup_key, ':F'", sql);
+
+        // 归档表必须先于改写建立,否则失败后无从恢复。
+        Assert.True(sql.IndexOf("_dedup_bak_1_0_493", StringComparison.Ordinal)
+                    < sql.IndexOf("UPDATE ado_s8_exception", StringComparison.Ordinal),
+            "归档必须发生在改写之前");
+    }
+
+    [Fact]
+    public void T20_T21_Migration_RebuildsTenantOnlyUniqueKeys()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("uk_s8_rule_detection_state_dedup (tenant_id, rule_code, dedup_key)", sql);
+        Assert.Contains("uk_s8_watch_rule_tenant_code (tenant_id, rule_code)", sql);
+    }
+
+    /// <summary>
+    /// T22:重复执行安全。改写谓词自带幂等(改完就不再匹配);
+    /// DDL 全部由 information_schema 存在性守卫,不依赖事务回滚
+    /// —— MySQL 的 DDL 多数自动提交,假设能回滚是危险的。
+    /// </summary>
+    [Fact]
+    public void T22_Migration_IsRerunSafe()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("information_schema.STATISTICS", sql);
+        Assert.Contains("INSERT IGNORE INTO ado_s8_exception_dedup_bak_1_0_493", sql);
+        Assert.Contains("CREATE TABLE IF NOT EXISTS ado_s8_exception_dedup_bak_1_0_493", sql);
+
+        // 执行器不支持自定义语句分隔符指令,脚本通篇不得出现该关键字(它做的是全文子串匹配)。
+        Assert.DoesNotContain("DELIMITER", sql, StringComparison.OrdinalIgnoreCase);
+    }
+
+    /// <summary>迁移刻意不碰的对象:1.0.487 的还原点、异常表的工厂唯一键、以及任何 factory_id 列。</summary>
+    [Fact]
+    public void Migration_DoesNotTouchOutOfScopeObjects()
+    {
+        // 只看可执行 SQL:脚本注释里会**明确列出"不动哪些对象"**,
+        // 那正是我们想要的留档,不该被当成触碰。
+        var sql = ExecutableSql(MigrationSql());
+
+        Assert.DoesNotContain("ado_s8_exception_bak_1_0_487", sql);
+        Assert.DoesNotContain("uk_ado_s8_exception_tenant_factory_code", sql);
+        Assert.DoesNotContain("DROP COLUMN", sql);
+        Assert.DoesNotContain("mdp_entity", sql);
+    }
+
+    /// <summary>去掉 <c>--</c> 注释行,只留可执行语句。</summary>
+    private static string ExecutableSql(string sql) =>
+        string.Join('\n', sql.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
+
+    // ───────────────── helpers ─────────────────
+
+    private static string MigrationSql()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+
+        var path = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.493.sql");
+        Assert.True(File.Exists(path), $"迁移脚本不存在,路径需同步更新:{path}");
+        return File.ReadAllText(path);
+    }
+
+    private static string CodeOnly(string relative)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+
+        var full = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP",
+            relative.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+
+        // 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+}

+ 3 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRuleWriteGuardTests.cs

@@ -75,7 +75,9 @@ public class S8WatchRuleWriteGuardTests
     public void NarrowWritePathsAndReadPath_ArePreserved()
     {
         var t = typeof(S8WatchRuleService);
-        Assert.NotNull(t.GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
+        // S8-TENANT-ONLY-BATCH5:列表按租户取,不再按 (租户, 工厂)。
+        Assert.NotNull(t.GetMethod("ListAsync", new[] { typeof(long) }));
+        Assert.Null(t.GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
         // S8-RULE-GOVERNANCE-BATCH1:整块 params 入口已被强类型白名单入口取代(G1 修复)。
         // 断言旧签名**不存在**,否则那条"缺字段即置 NULL"的路径就还活着。
         Assert.Null(t.GetMethods().FirstOrDefault(m => m.Name == "UpdateParamsAsync"));

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -37,7 +37,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         // Compatibility-only. Security scope is resolved server-side.
         _ = tenantId; _ = factoryId;
         var scope = await _scope.ResolveAsync();
-        return Ok(await _svc.ListAsync(scope.TenantId, scope.FactoryId));
+        return Ok(await _svc.ListAsync(scope.TenantId));
     }
 
     // ================================================================================

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8WatchDebugController.cs

@@ -34,7 +34,7 @@ public class AdoS8WatchDebugController : ControllerBase
     {
         if (!_debugEndpointEnabled) return NotFound();
 
-        var results = await _svc.CreateExceptionsAsync(tenantId, factoryId);
+        var results = await _svc.CreateExceptionsAsync(tenantId);
         return Ok(new
         {
             count = results.Count,

+ 7 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RuleDetectionState.cs

@@ -4,19 +4,19 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S8;
 /// S8 规则检测抗抖累计状态(建单前阶段)。
 /// 用途:在"持续 N 次命中才建单 / 持续 N 次未命中才标 recovered"路径上,
 /// 承载每条 (rule_code, dedup_key) 在还未建单时的累计计数。
-/// 本表无软删字段;唯一记录由 (tenant_id, factory_id, rule_code, dedup_key) 决定。
+/// 本表无软删字段;唯一记录由 (tenant_id, rule_code, dedup_key) 决定。
+/// S8-TENANT-ONLY-BATCH5:factory_id 已退出唯一键。它此前把同一 (tenant, rule, dedup)
+/// 按工厂切成多行,抗抖计数因此可能分裂在两行上、trigger/recover 阈值静默失效。
 /// 已建单后的抗抖累计落在 ado_s8_exception.consecutive_hit_count / consecutive_miss_count。
 /// </summary>
 [SugarTable("ado_s8_rule_detection_state", "S8 规则检测抗抖状态")]
 [SugarIndex("uk_s8_rule_detection_state_dedup",
     nameof(TenantId), OrderByType.Asc,
-    nameof(FactoryId), OrderByType.Asc,
     nameof(RuleCode), OrderByType.Asc,
     nameof(DedupKey), OrderByType.Asc,
     IsUnique = true)]
 [SugarIndex("idx_s8_rule_detection_state_rule",
     nameof(TenantId), OrderByType.Asc,
-    nameof(FactoryId), OrderByType.Asc,
     nameof(RuleCode), OrderByType.Asc)]
 [SugarIndex("idx_s8_rule_detection_state_active_exception",
     nameof(ActiveExceptionId), OrderByType.Asc)]
@@ -28,6 +28,10 @@ public class AdoS8RuleDetectionState
     [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
     public long TenantId { get; set; }
 
+    /// <summary>
+    /// 历史兼容列。S8-TENANT-ONLY-BATCH5 起**不再参与唯一键与任何查询谓词**,
+    /// 仅作元数据保留(写入时取规则行的 factory_id)。列不删除,等后续 schema 清理。
+    /// </summary>
     [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
     public long FactoryId { get; set; }
 

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8WatchRule.cs

@@ -6,6 +6,9 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S8;
 [SugarIndex("idx_s8_watch_rule_dispatch", nameof(Enabled), OrderByType.Asc, nameof(PausedUntil), OrderByType.Asc, nameof(NextRunAt), OrderByType.Asc)]
 [SugarIndex("idx_s8_watch_rule_lock_until", nameof(LockUntil), OrderByType.Asc)]
 [SugarIndex("idx_s8_watch_rule_rule_code", nameof(RuleCode), OrderByType.Asc)]
+// S8-TENANT-ONLY-BATCH5:运行策略的逻辑唯一键在 Batch 2 已定为 (TenantId, RuleCode),
+// 此前只有应用层 AnyAsync 保证。加 DB 兜底后,并发供给不再可能建出同码重复行。
+[SugarIndex("uk_s8_watch_rule_tenant_code", nameof(TenantId), OrderByType.Asc, nameof(RuleCode), OrderByType.Asc, IsUnique = true)]
 public class AdoS8WatchRule
 {
     [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
@@ -14,6 +17,10 @@ public class AdoS8WatchRule
     [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
     public long TenantId { get; set; }
 
+    /// <summary>
+    /// 历史兼容列。S8-TENANT-ONLY-BATCH5 起**不再参与规则身份、调度作用域与任何查询谓词**,
+    /// 仅作元数据保留(Batch 2 供给出来的新行恒为 0)。列不删除,等后续 schema 清理。
+    /// </summary>
     [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
     public long FactoryId { get; set; }
 

+ 11 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs

@@ -10,7 +10,9 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 
 /// <summary>
 /// G-08:S8 自动监控主链定时调度作业。
-/// 固定租户 / 工厂上下文下,按周期触发一次 G-01 主链(<see cref="S8WatchSchedulerService.CreateExceptionsAsync"/>)。
+/// 按租户遍历,周期触发规则调度主链。
+/// S8-TENANT-ONLY-BATCH5:遍历维度由 (租户, 工厂) 改为租户 —— 运行策略每租户一条,
+/// 按工厂重复 tick 只会让多次 tick 抢同一把租约。
 /// 本轮仅完成"正式定时触发接入";不在调度层追加业务逻辑、不改写主链入参语义、不做真实 SQL / 认证链路 / SLA 治理。
 /// 周期由 [Period] 属性固化为默认值(30 分钟);运行期调整走 Admin.NET 作业管理,不走配置中心。
 /// 开关:默认通过 Admin.NET 作业管理的触发器状态控制;本作业构造不自动启动。
@@ -127,24 +129,23 @@ public class S8WatchSchedulerJob : IJob
         try
         {
             var lockedBy = BuildLockedBy();
-            var scopes = await scheduler.ListEnabledScopesAsync();
+            var tenantIds = await scheduler.ListEnabledTenantsAsync();
             if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
             {
                 _logger.LogInformation(
-                    "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} TickIntervalMs={TickInterval} BatchSize={Batch} ScopeCount={ScopeCount} LockedBy={LockedBy}",
-                    IntervalMs, tickIntervalMs, BatchSize, scopes.Count, lockedBy);
+                    "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} TickIntervalMs={TickInterval} BatchSize={Batch} TenantCount={TenantCount} LockedBy={LockedBy}",
+                    IntervalMs, tickIntervalMs, BatchSize, tenantIds.Count, lockedBy);
             }
 
-            foreach (var tenantScope in scopes)
+            foreach (var tenantId in tenantIds)
             {
-                var summary = await scheduler.RunDispatchTickAsync(
-                    tenantScope.TenantId, tenantScope.FactoryId, BatchSize, lockedBy);
+                var summary = await scheduler.RunDispatchTickAsync(tenantId, BatchSize, lockedBy);
                 // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑(6 项摘要全 0)降为 LogDebug;
                 // 任何非零字段(picked/failed/perRuleFailed/created/refreshed/leaseReleased)保持 LogInformation。
                 if (IsEmptyTick(summary))
-                    _logger.LogDebug(BuildTraceLine(triggeredAt, "success", summary, null, tenantScope.TenantId, tenantScope.FactoryId));
+                    _logger.LogDebug(BuildTraceLine(triggeredAt, "success", summary, null, tenantId));
                 else
-                    _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null, tenantScope.TenantId, tenantScope.FactoryId));
+                    _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null, tenantId));
             }
         }
         catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -177,8 +178,7 @@ public class S8WatchSchedulerJob : IJob
         string result,
         S8DispatchTickResult? summary,
         string? failureSummary,
-        long? tenantId = null,
-        long? factoryId = null)
+        long? tenantId = null)
     {
         var payload = new
         {
@@ -186,7 +186,6 @@ public class S8WatchSchedulerJob : IJob
             triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"),
             result,
             tenantId,
-            factoryId,
             tickId = summary?.TickId,
             runId = summary?.RunId,
             picked = summary?.Picked ?? 0,

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/IS8MonitoringDataProvider.cs

@@ -9,7 +9,8 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 ///     待其提供 freshness 接口后由本层消费,不在 S8 内造替代实现;
 ///   · 不管理源系统连接串。
 ///
-/// Provider 只回答一件事:给定租户 / 工厂作用域,返回该数据集当前的 canonical 行。
+/// Provider 只回答一件事:给定**租户**作用域,返回该数据集当前的 canonical 行。
+/// S8-TENANT-ONLY-BATCH5:Factory 已退出取数作用域 —— 它从未参与任何 Provider 的过滤条件。
 /// </summary>
 public interface IS8MonitoringDataProvider
 {
@@ -22,7 +23,7 @@ public interface IS8MonitoringDataProvider
     /// <summary>
     /// 按作用域取 canonical 行。
     /// 实现必须先调用 <see cref="S8MonitoringDataRequest.EnsureValid"/>,
-    /// 且只能使用请求参数中的 TenantId / FactoryId 作为作用域,不得隐式取值。
+    /// 且只能使用请求参数中的 TenantId 作为作用域,不得隐式取值。
     /// </summary>
     Task<S8MonitoringRowSet> LoadAsync(
         S8MonitoringDataRequest request,

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8PurchaseDeliveryDataProvider.cs

@@ -183,13 +183,14 @@ public sealed class S8PurchaseDeliveryDataProvider : IS8MonitoringDataProvider
     /// 写死一个数字会在换规则或换租户时悄悄失效。
     ///
     /// 注意这个预算通常比 <c>source_object_id</c> 的列宽(64)更紧:
-    /// 15 位租户 + 15 位工厂 + 36 字符规则编码 + PURCHASE_ORDER_LINE 时前缀已占 92,只剩 36。
+    /// S8-TENANT-ONLY-BATCH5 去掉 <c>:F{factory}</c> 段后,
+    /// 15 位租户 + 36 字符规则编码 + PURCHASE_ORDER_LINE 时前缀约占 76,余量比之前宽松了 16 字符。
     /// 落在 37–64 之间的键会过得了列校验、卡在 DedupKey 上,因此必须按本预算判定。
     /// </summary>
     internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
     {
         var prefixLength =
-            $"T{request.TenantId}:F{request.FactoryId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
+            $"T{request.TenantId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
         return Math.Max(0, DedupKeyMaxLength - prefixLength);
     }
 

+ 12 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs

@@ -36,7 +36,10 @@ public sealed class S8DatasetEnableCheck
 ///   7. Provider 是否注册          → dataset_provider_not_registered
 ///   8. Provider 编码是否一致      → dataset_provider_code_mismatch
 ///   9. Provider 能力是否同样满足   → dataset_capability_mismatch / dataset_missing_required_field
-///  10. 租户 / 工厂作用域          → invalid_tenant_scope / invalid_factory_scope
+///  10. 租户作用域                → invalid_tenant_scope
+///
+/// S8-TENANT-ONLY-BATCH5:第 ⑩ 步的工厂检查已删除。它此前会因 factoryId &lt;= 0 拒绝启用,
+/// 而 Batch 2 供给出来的运行策略行 factory_id 恒为 0 —— 那些规则本来永远启用不了。
 ///
 /// S8-STANDARD-DATASET-HARD-CUTOVER-1:取数路径唯一,Gate 不再有"先判模式"这一步。
 /// </summary>
@@ -56,14 +59,14 @@ public class S8DatasetEnableGate : ITransient
     /// 它读的是 <c>rule.DatasetCode</c> / <c>rule.RuleType</c>,而这两列现在只是
     /// provisioning 维护的投影,可能与代码定义不一致。保留是为了既有校验路径与测试不受影响。
     /// </summary>
-    public S8DatasetEnableCheck Check(AdoS8WatchRule rule, long tenantId, long factoryId) =>
-        Check(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId, factoryId);
+    public S8DatasetEnableCheck Check(AdoS8WatchRule rule, long tenantId) =>
+        Check(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId);
 
     /// <summary>
     /// 按**显式的数据集编码与规则类型**检查。启用路径应使用本重载并传入代码定义的值,
     /// 这样「能不能启用」的判定就不受任何人手工改 DB 投影列的影响。
     /// </summary>
-    public S8DatasetEnableCheck Check(string? datasetCode, string? ruleType, string? ruleCode, long tenantId, long factoryId)
+    public S8DatasetEnableCheck Check(string? datasetCode, string? ruleType, string? ruleCode, long tenantId)
     {
         // ① dataset_code
         if (string.IsNullOrWhiteSpace(datasetCode))
@@ -128,23 +131,21 @@ public class S8DatasetEnableGate : ITransient
                 S8DatasetReasonCode.MissingRequiredField,
                 $"Provider 实现缺少 {ruleType} 必需字段:{string.Join(", ", providerMissing)}");
 
-        // ⑩ 作用域
+        // ⑩ 作用域。只认 Tenant —— Factory 不是 S8 的隔离边界。
         if (tenantId <= 0)
             return S8DatasetEnableCheck.Fail(S8DatasetReasonCode.InvalidTenantScope, "租户作用域非法");
-        if (factoryId <= 0)
-            return S8DatasetEnableCheck.Fail(S8DatasetReasonCode.InvalidFactoryScope, "工厂作用域非法");
 
         return S8DatasetEnableCheck.Pass();
     }
 
     /// <summary>检查并在失败时抛 <see cref="S8BizException"/>(配置类错误,API 层直接回给调用方)。</summary>
-    public void EnsureCanEnable(AdoS8WatchRule rule, long tenantId, long factoryId) =>
-        EnsureCanEnable(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId, factoryId);
+    public void EnsureCanEnable(AdoS8WatchRule rule, long tenantId) =>
+        EnsureCanEnable(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId);
 
     /// <summary>按显式数据集编码与规则类型检查并在失败时抛 <see cref="S8BizException"/>。启用路径用这一个。</summary>
-    public void EnsureCanEnable(string? datasetCode, string? ruleType, string? ruleCode, long tenantId, long factoryId)
+    public void EnsureCanEnable(string? datasetCode, string? ruleType, string? ruleCode, long tenantId)
     {
-        var result = Check(datasetCode, ruleType, ruleCode, tenantId, factoryId);
+        var result = Check(datasetCode, ruleType, ruleCode, tenantId);
         if (!result.Ok)
             throw new S8BizException($"[{result.ReasonCode}] {result.Message}");
     }

+ 5 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataGateway.cs

@@ -38,13 +38,12 @@ public class S8MonitoringDataGateway : ITransient
     /// </summary>
     public async Task<S8MonitoringDataResult> LoadAsync(
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         string ruleType,
         int maxRows,
         CancellationToken cancellationToken = default)
     {
-        var result = await LoadFromDatasetAsync(tenantId, factoryId, rule, ruleType, cancellationToken);
+        var result = await LoadFromDatasetAsync(tenantId, rule, ruleType, cancellationToken);
 
         S8EvaluatorGuard.EnsureRowCountWithinLimit(result.RowSet.Rows.Count, maxRows, ruleType, rule.RuleCode);
         return result;
@@ -52,7 +51,6 @@ public class S8MonitoringDataGateway : ITransient
 
     private async Task<S8MonitoringDataResult> LoadFromDatasetAsync(
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         string ruleType,
         CancellationToken cancellationToken)
@@ -96,7 +94,6 @@ public class S8MonitoringDataGateway : ITransient
         var request = new S8MonitoringDataRequest
         {
             TenantId = tenantId,
-            FactoryId = factoryId,
             DatasetCode = definition.DatasetCode,
             RuleId = rule.Id,
             RuleCode = rule.RuleCode,
@@ -121,9 +118,11 @@ public class S8MonitoringDataGateway : ITransient
                 ex);
         }
 
+        // rule.FactoryId 仅作日志 metadata:它不再是取数作用域的一部分,
+        // 保留是为了排查历史数据时还能对上号。
         _logger.LogDebug(
-            "s8_dataset_loaded dataset={Dataset} rule={Rule} tenant={Tenant} factory={Factory} rows={Rows}",
-            definition.DatasetCode, rule.RuleCode, tenantId, factoryId, rowSet.Rows.Count);
+            "s8_dataset_loaded dataset={Dataset} rule={Rule} tenant={Tenant} factoryMeta={FactoryMeta} rows={Rows}",
+            definition.DatasetCode, rule.RuleCode, tenantId, rule.FactoryId, rowSet.Rows.Count);
 
         return new S8MonitoringDataResult { RowSet = rowSet };
     }

+ 9 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataRequest.cs

@@ -3,18 +3,22 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 /// <summary>
 /// 向 Provider 取数的请求上下文。
 ///
-/// 硬性设计约束:<see cref="TenantId"/> / <see cref="FactoryId"/> **必须由调用方显式传入**。
+/// 硬性设计约束:<see cref="TenantId"/> **必须由调用方显式传入**。
 /// Provider 实现禁止从 HttpContext / 当前登录用户 / 全局单租户假设推导作用域,
 /// 也禁止把租户写成 SQL 字面量 —— 这两种做法正是既有 LEGACY_SQL 规则里的已知问题形态。
+///
+/// <para><b>S8-TENANT-ONLY-BATCH5:Factory 已退出取数作用域。</b>
+/// 它此前是必填项(<c>FactoryId &lt;= 0</c> 直接 <c>dataset_scope_invalid</c>),
+/// 但**没有任何 Provider 真的用它过滤** —— 唯一的 PURCHASE_DELIVERY Provider 的 WHERE
+/// 从一开始就只有 <c>tenant_id</c>。于是它是一道纯粹的形式门槛:
+/// 零工厂 / 多工厂租户会在这里被拒,而放行与否对取出的数据没有任何影响。
+/// 现在作用域只认 Tenant,与真实数据边界一致。</para>
 /// </summary>
 public sealed class S8MonitoringDataRequest
 {
     /// <summary>租户作用域。必填且必须 &gt; 0。</summary>
     public long TenantId { get; init; }
 
-    /// <summary>工厂作用域。必填且必须 &gt; 0。</summary>
-    public long FactoryId { get; init; }
-
     /// <summary>数据集编码(STANDARD_DATASET 模式)。</summary>
     public string DatasetCode { get; init; } = string.Empty;
 
@@ -28,7 +32,7 @@ public sealed class S8MonitoringDataRequest
     public string RuleType { get; init; } = string.Empty;
 
     /// <summary>
-    /// 作用域校验。缺失租户 / 工厂时显式失败,不允许 Provider 用默认值兜底
+    /// 作用域校验。缺失租户时显式失败,不允许 Provider 用默认值兜底
     /// (兜底会让跨租户读取以"看起来正常"的方式发生)。
     /// </summary>
     public void EnsureValid()
@@ -37,10 +41,5 @@ public sealed class S8MonitoringDataRequest
             throw new S8RuleEvaluatorException(
                 "dataset_scope_invalid",
                 $"数据集取数缺少有效 TenantId(rule={RuleCode}, dataset={DatasetCode})");
-
-        if (FactoryId <= 0)
-            throw new S8RuleEvaluatorException(
-                "dataset_scope_invalid",
-                $"数据集取数缺少有效 FactoryId(rule={RuleCode}, dataset={DatasetCode})");
     }
 }

+ 2 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8WatchRuleDataAccessValidator.cs

@@ -41,6 +41,6 @@ public static class S8WatchRuleDataAccessValidator
     /// 这里是「Create 时带 enabled=true」与「UpdateParams 由停用切启用」两条路径的**共同必经点**。
     /// </summary>
     public static void ValidateForEnable(
-        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId, long factoryId) =>
-        gate.EnsureCanEnable(rule, tenantId, factoryId);
+        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId) =>
+        gate.EnsureCanEnable(rule, tenantId);
 }

+ 5 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/IS8RuleEvaluator.cs

@@ -13,9 +13,13 @@ public interface IS8RuleEvaluator
     string RuleType { get; }
 
     /// <summary>评估一条 WatchRule,产出 0..N 条命中。不做去重、不建单。</summary>
+    /// <remarks>
+    /// S8-TENANT-ONLY-BATCH5:形参不再含 factoryId。判定作用域只认 Tenant ——
+    /// 工厂从未参与任何 Provider 的过滤,却曾经决定 dedup 身份与规则归属,
+    /// 那是"伪 Factory Scope":取数是租户级的,身份却按工厂切。
+    /// </remarks>
     Task<List<S8RuleHit>> EvaluateAsync(
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default);
 }

+ 20 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -46,7 +46,6 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default)
     {
@@ -57,9 +56,9 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
 
         var data = await _dataGateway.LoadAsync(
-            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
+            tenantId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, DateTime.Now);
     }
 
     /// <summary>解析生效规则:定义必须存在、类型必须匹配、OUT_OF_RANGE 语义必须齐备。</summary>
@@ -91,7 +90,6 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         S8MonitoringRowSet rowSet,
         S8EffectiveRule effective,
         long tenantId,
-        long factoryId,
         DateTime detectedAt)
     {
         var definition = effective.Definition;
@@ -149,7 +147,7 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
 
             var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
             hits.Add(new S8RuleHit
             {
@@ -173,9 +171,23 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
     }
 
-    /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>
-    internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
-        $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
+    /// <summary>
+    /// 构造 dedup_key:<c>T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}</c>。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5:去掉了 <c>:F{factory}</c> 段。</b>
+    /// 租户隔离由首段 <c>T{tenant}</c> 与各查询的 <c>tenant_id</c> 谓词双重保证;
+    /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。</para>
+    ///
+    /// <para>真库证据(迁移前取证):<c>dwd_supplier_delivery</c> 全表 39854 行 / 4 租户 / 97 个快照,
+    /// <c>(tenant_id, stat_date, po_no, po_line)</c> 重复组为 0,跨工厂碰撞组为 0。
+    /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
+    /// <b>SourceObjectId contract</b> 负责(例如把工厂编号并进对象标识),
+    /// 而不是把工厂重新变回隔离维度。</para>
+    ///
+    /// internal 暴露供测试。
+    /// </summary>
+    internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
+        $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
     private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
     {

+ 20 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -46,7 +46,6 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default)
     {
@@ -57,9 +56,9 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
 
         var data = await _dataGateway.LoadAsync(
-            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
+            tenantId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, DateTime.Now);
     }
 
     /// <summary>解析生效规则:定义必须存在、类型必须匹配、SHORTAGE 语义必须齐备。</summary>
@@ -91,7 +90,6 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         S8MonitoringRowSet rowSet,
         S8EffectiveRule effective,
         long tenantId,
-        long factoryId,
         DateTime detectedAt)
     {
         var definition = effective.Definition;
@@ -121,7 +119,7 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
 
             var sourceObjectId = row.GetString(semantics.SourceObjectIdColumn) ?? relatedObjectCode;
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
             hits.Add(new S8RuleHit
             {
@@ -145,9 +143,23 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
     }
 
-    /// <summary>构造 R3 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>
-    internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
-        $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
+    /// <summary>
+    /// 构造 dedup_key:<c>T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}</c>。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5:去掉了 <c>:F{factory}</c> 段。</b>
+    /// 租户隔离由首段 <c>T{tenant}</c> 与各查询的 <c>tenant_id</c> 谓词双重保证;
+    /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。</para>
+    ///
+    /// <para>真库证据(迁移前取证):<c>dwd_supplier_delivery</c> 全表 39854 行 / 4 租户 / 97 个快照,
+    /// <c>(tenant_id, stat_date, po_no, po_line)</c> 重复组为 0,跨工厂碰撞组为 0。
+    /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
+    /// <b>SourceObjectId contract</b> 负责(例如把工厂编号并进对象标识),
+    /// 而不是把工厂重新变回隔离维度。</para>
+    ///
+    /// internal 暴露供测试。
+    /// </summary>
+    internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
+        $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
     private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId,
         decimal target, decimal actual, decimal shortage, decimal ratio, S8RuleDefinition definition)

+ 20 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -46,7 +46,6 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default)
     {
@@ -64,9 +63,9 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
 
         var data = await _dataGateway.LoadAsync(
-            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
+            tenantId, rule, RuleTypeCode, maxRows, cancellationToken);
 
-        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, DateTime.Now);
     }
 
     /// <summary>
@@ -101,7 +100,6 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         S8MonitoringRowSet rowSet,
         S8EffectiveRule effective,
         long tenantId,
-        long factoryId,
         DateTime detectedAt)
     {
         var definition = effective.Definition;
@@ -137,7 +135,7 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
 
             var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
             hits.Add(new S8RuleHit
             {
@@ -161,9 +159,23 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
     }
 
-    /// <summary>构造 R2 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>
-    internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
-        $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
+    /// <summary>
+    /// 构造 dedup_key:<c>T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}</c>。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5:去掉了 <c>:F{factory}</c> 段。</b>
+    /// 租户隔离由首段 <c>T{tenant}</c> 与各查询的 <c>tenant_id</c> 谓词双重保证;
+    /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。</para>
+    ///
+    /// <para>真库证据(迁移前取证):<c>dwd_supplier_delivery</c> 全表 39854 行 / 4 租户 / 97 个快照,
+    /// <c>(tenant_id, stat_date, po_no, po_line)</c> 重复组为 0,跨工厂碰撞组为 0。
+    /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
+    /// <b>SourceObjectId contract</b> 负责(例如把工厂编号并进对象标识),
+    /// 而不是把工厂重新变回隔离维度。</para>
+    ///
+    /// internal 暴露供测试。
+    /// </summary>
+    internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
+        $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
     private static string BuildPayload(
         S8MonitoringRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status,

+ 7 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs

@@ -65,7 +65,7 @@ public class S8WatchRulePreviewService : ITransient
     public async Task<S8WatchRulePreviewResult> PreviewAsync(long id, S8TrustedScope scope)
     {
         var rule = await _ruleRep.AsQueryable()
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .FirstAsync() ?? throw new S8NotFoundException();
 
         // S8-RULE-GOVERNANCE-BATCH1:预演必须与真实运行走同一份语义来源。
@@ -85,10 +85,10 @@ public class S8WatchRulePreviewService : ITransient
         // 与生产同一条取数链。evaluator 内部会再次经 Gateway 取数,
         // 这里先单独取一次仅为拿到 candidateRows(判定前的行数),用于解释「筛掉了多少」。
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(null);
-        var data = await _gateway.LoadAsync(scope.TenantId, scope.FactoryId, rule, ruleType, maxRows);
+        var data = await _gateway.LoadAsync(scope.TenantId, rule, ruleType, maxRows);
         var candidateRows = data.RowSet.Rows.Count;
 
-        var hits = await evaluator.EvaluateAsync(scope.TenantId, scope.FactoryId, rule);
+        var hits = await evaluator.EvaluateAsync(scope.TenantId, rule);
 
         // 逐条回答「若真的建单,是新建还是命中既有活动异常」。
         // 与 S8WatchSchedulerService 的去重口径一致:同 dedup_key 且未 CLOSED 未删即视为活动。
@@ -96,7 +96,10 @@ public class S8WatchRulePreviewService : ITransient
         var activeByDedupKey = dedupKeys.Count == 0
             ? new Dictionary<string, long>(StringComparer.Ordinal)
             : (await _exceptionRep.AsQueryable()
-                .Where(x => x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId
+                // S8-TENANT-ONLY-BATCH5:活动异常按 Tenant + dedup_key 查,不再按工厂切。
+                // 预演必须与真跑同源:调度器侧的 FindOpenExceptionByDedupKeyAsync 也已只认 Tenant,
+                // 两边一旦分叉,预演给出的"会新建还是会刷新"就不再可信。
+                .Where(x => x.TenantId == scope.TenantId
                             && !x.IsDeleted && x.Status != "CLOSED"
                             && x.DedupKey != null && dedupKeys.Contains(x.DedupKey))
                 .Select(x => new { x.DedupKey, x.Id })
@@ -112,7 +115,6 @@ public class S8WatchRulePreviewService : ITransient
             DatasetCode = definition.DatasetCode,
             Enabled = rule.Enabled,
             TenantId = scope.TenantId,
-            FactoryId = scope.FactoryId,
             EvaluatedAt = DateTime.Now,
             CandidateRows = candidateRows,
             HitCount = hits.Count,

+ 17 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -39,10 +39,10 @@ public class S8WatchRuleService : ITransient
     /// 照常返回,但 <c>DefinitionMissing = true</c>、定义字段为 null,
     /// 由页面禁用相关操作 —— 不删、不隐藏,运维需要看得见它们。</para>
     /// </summary>
-    public async Task<List<S8WatchRuleRowDto>> ListAsync(long tenantId, long factoryId)
+    public async Task<List<S8WatchRuleRowDto>> ListAsync(long tenantId)
     {
         var rows = await _rep.AsQueryable()
-            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .Where(x => x.TenantId == tenantId)
             .ToListAsync();
 
         return rows.Select(ToRowDto).ToList();
@@ -190,10 +190,16 @@ public class S8WatchRuleService : ITransient
     public Task DeleteAsync(long id, S8TrustedScope scope) =>
         throw new S8WriteRetiredException(S8WriteRetiredException.WatchRuleDeleteMessage);
 
-    /// <summary>按 Id + 可信作用域取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。</summary>
+    /// <summary>
+    /// 按 Id + 租户取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5:绑行只认 Tenant。</b>
+    /// 运行策略的身份在 Batch 2 已定为 (TenantId, RuleCode),而此处仍按工厂绑行 ——
+    /// 结果是 Batch 2 供给出来的 factory_id=0 的行,任何配置操作都会 404。</para>
+    /// </summary>
     private async Task<AdoS8WatchRule> LoadScopedAsync(long id, S8TrustedScope scope) =>
         await _rep.AsQueryable()
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .FirstAsync() ?? throw new S8NotFoundException();
 
     /// <summary>
@@ -232,7 +238,7 @@ public class S8WatchRuleService : ITransient
                 ParamsJson = next.ToParamsJson(),
                 UpdatedAt = DateTime.Now
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
 
         return await LoadScopedAsync(id, scope);
@@ -328,7 +334,7 @@ public class S8WatchRuleService : ITransient
         // ② 数据集侧完整运行条件。按 Definition 的 dataset_code / rule_type 判定,
         //    而不是 DB 上那两列 —— 后者是投影,可能被人为改过。
         _datasetEnableGate.EnsureCanEnable(
-            definition.DatasetCode, definition.RuleType, definition.RuleCode, scope.TenantId, scope.FactoryId);
+            definition.DatasetCode, definition.RuleType, definition.RuleCode, scope.TenantId);
 
         var now = DateTime.Now;
         await _rep.Context.Updateable<AdoS8WatchRule>()
@@ -338,7 +344,7 @@ public class S8WatchRuleService : ITransient
                 NextRunAt = now,
                 UpdatedAt = now
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
 
         return await LoadScopedAsync(id, scope);
@@ -364,7 +370,7 @@ public class S8WatchRuleService : ITransient
                 Enabled = false,
                 UpdatedAt = DateTime.Now
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
 
         return await LoadScopedAsync(id, scope);
@@ -418,7 +424,7 @@ public class S8WatchRuleService : ITransient
                 NextRunAt = now,
                 UpdatedAt = now
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
         return new { id, queued = true, message = "已排队,最长 1 分钟内执行" };
     }
@@ -438,7 +444,7 @@ public class S8WatchRuleService : ITransient
                 PauseReason = "MANUAL_PAUSED",
                 UpdatedAt = DateTime.Now
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
         return new { id, paused = true, message = "已暂停" };
     }
@@ -461,7 +467,7 @@ public class S8WatchRuleService : ITransient
                 NextRunAt = now,
                 UpdatedAt = now
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
         return new { id, resumed = true, message = "已恢复,并将在下一轮调度中执行" };
     }

+ 70 - 67
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -90,17 +90,26 @@ public class S8WatchSchedulerService : ITransient
         _ruleCatalog = ruleCatalog;
     }
 
-    public async Task<List<S8TenantFactoryScope>> ListEnabledScopesAsync()
+    /// <summary>
+    /// S8-TENANT-ONLY-BATCH5:调度遍历改为 <b>Tenant → Rule</b>。
+    ///
+    /// <para>原实现返回 DISTINCT (tenant_id, factory_id),于是同一租户在 N 个工厂下会被 tick N 次;
+    /// 而规则的运行策略按 Batch 2 的口径每租户只有一条,重复 tick 只会互相抢同一把租约。</para>
+    ///
+    /// <para>同时删掉 <c>AND r.factory_id &gt; 0</c>:Batch 2 供给出来的运行策略行
+    /// <c>factory_id</c> 恒为 0,那条谓词会让它们**永远不被调度、且不报任何错**
+    /// —— 页面显示已启用,实际从不执行。这是本批必须与供给同时闭合的缺口。</para>
+    /// </summary>
+    public async Task<List<long>> ListEnabledTenantsAsync()
     {
-        return await _ruleRep.Context.Ado.SqlQueryAsync<S8TenantFactoryScope>(
+        return await _ruleRep.Context.Ado.SqlQueryAsync<long>(
             """
-            SELECT DISTINCT r.tenant_id AS TenantId, r.factory_id AS FactoryId
+            SELECT DISTINCT r.tenant_id
             FROM ado_s8_watch_rule r
             INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1
             WHERE r.enabled = 1
               AND r.tenant_id > 0
-              AND r.factory_id > 0
-            ORDER BY r.tenant_id, r.factory_id
+            ORDER BY r.tenant_id
             """);
     }
 
@@ -122,15 +131,15 @@ public class S8WatchSchedulerService : ITransient
     /// 经证实其唯一入口是 404 门禁的 debug controller,且 LoadExecutionRulesAsync 的过滤条件
     /// 在当前数据下恒返回空,已整体删除。本方法现在只保留三类正式 evaluator 路径。
     /// </summary>
-    public async Task<List<S8WatchCreationResult>> CreateExceptionsAsync(long tenantId, long factoryId)
+    public async Task<List<S8WatchCreationResult>> CreateExceptionsAsync(long tenantId)
     {
         var results = new List<S8WatchCreationResult>();
 
         // R6 RunId:本次 CreateExceptionsAsync 调用对应的统一关联 id,落入 detection_log。
         var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
-        results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, runId));
-        results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, runId));
-        results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, runId));
+        results.AddRange(await ProcessRulesByTypeAsync(tenantId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, runId));
+        results.AddRange(await ProcessRulesByTypeAsync(tenantId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, runId));
+        results.AddRange(await ProcessRulesByTypeAsync(tenantId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, runId));
 
         return results;
     }
@@ -138,14 +147,14 @@ public class S8WatchSchedulerService : ITransient
     /// <summary>
     /// R2 TIMEOUT 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。RunId 由内部生成。
     /// </summary>
-    public Task<List<S8WatchCreationResult>> ProcessTimeoutRulesAsync(long tenantId, long factoryId) =>
-        ProcessRulesByTypeAsync(tenantId, factoryId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
+    public Task<List<S8WatchCreationResult>> ProcessTimeoutRulesAsync(long tenantId) =>
+        ProcessRulesByTypeAsync(tenantId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
 
     /// <summary>
     /// R3 SHORTAGE 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。RunId 由内部生成。
     /// </summary>
-    public Task<List<S8WatchCreationResult>> ProcessShortageRulesAsync(long tenantId, long factoryId) =>
-        ProcessRulesByTypeAsync(tenantId, factoryId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
+    public Task<List<S8WatchCreationResult>> ProcessShortageRulesAsync(long tenantId) =>
+        ProcessRulesByTypeAsync(tenantId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
 
     /// <summary>
     /// R3-OUT_OF_RANGE-REWRITE-1:OUT_OF_RANGE 类规则主链。
@@ -153,8 +162,8 @@ public class S8WatchSchedulerService : ITransient
     /// (source_rule_id=rule.Id AND related_object_code=hit AND status!=CLOSED AND dedup_key IS NULL AND is_deleted=0)
     /// 命中则 backfill 6 列,避免重复建单。RunId 由内部生成。
     /// </summary>
-    public Task<List<S8WatchCreationResult>> ProcessOutOfRangeRulesAsync(long tenantId, long factoryId) =>
-        ProcessRulesByTypeAsync(tenantId, factoryId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
+    public Task<List<S8WatchCreationResult>> ProcessOutOfRangeRulesAsync(long tenantId) =>
+        ProcessRulesByTypeAsync(tenantId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
 
     /// <summary>
     /// R2/R3 通用规则主链。S8-SCHED-CLEANUP-LEGACY-PATH-1:本方法已收敛为 thin wrapper,
@@ -171,13 +180,12 @@ public class S8WatchSchedulerService : ITransient
     /// 与正在持锁运行的 Job 撕扯。
     /// </summary>
     private async Task<List<S8WatchCreationResult>> ProcessRulesByTypeAsync(
-        long tenantId, long factoryId, IS8RuleEvaluator evaluator, string ruleType, string runId)
+        long tenantId, IS8RuleEvaluator evaluator, string ruleType, string runId)
     {
         var aggregate = new List<S8WatchCreationResult>();
 
         var rules = await _ruleRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && x.Enabled
                         && x.RuleType == ruleType)
             .ToListAsync();
@@ -214,7 +222,7 @@ public class S8WatchSchedulerService : ITransient
             S8RuleRunResult completion;
             try
             {
-                var ruleResults = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, runId);
+                var ruleResults = await ProcessSingleRuleAsync(tenantId, rule, ruleType, evaluator, runId);
                 aggregate.AddRange(ruleResults);
                 completion = new S8RuleRunResult
                 {
@@ -316,12 +324,11 @@ public class S8WatchSchedulerService : ITransient
     /// 注意:本函数不消费 trigger_count_required;上游决定是否进入 CreateFromHitAsync。
     /// </summary>
     private async Task<(AdoS8RuleDetectionState? state, int hitCount)> UpsertDetectionStateOnHitAsync(
-        long tenantId, long factoryId, AdoS8WatchRule rule, S8RuleHit hit)
+        long tenantId, AdoS8WatchRule rule, S8RuleHit hit)
     {
         var now = DateTime.Now;
         var existing = await _detectionStateRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && x.RuleCode == rule.RuleCode
                         && x.DedupKey == hit.DedupKey)
             .FirstAsync();
@@ -331,7 +338,7 @@ public class S8WatchSchedulerService : ITransient
             var fresh = new AdoS8RuleDetectionState
             {
                 TenantId = tenantId,
-                FactoryId = factoryId,
+                FactoryId = rule.FactoryId,
                 RuleCode = rule.RuleCode,
                 DedupKey = hit.DedupKey,
                 SourceObjectType = string.IsNullOrEmpty(hit.SourceObjectType) ? null : hit.SourceObjectType,
@@ -368,11 +375,10 @@ public class S8WatchSchedulerService : ITransient
         return (existing, newHitCount);
     }
 
-    private async Task<long> FindOpenExceptionByDedupKeyAsync(long tenantId, long factoryId, string dedupKey)
+    private async Task<long> FindOpenExceptionByDedupKeyAsync(long tenantId, string dedupKey)
     {
         var ids = await _exceptionRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && !x.IsDeleted
                         && x.Status != "CLOSED"
                         && x.DedupKey == dedupKey)
@@ -386,12 +392,11 @@ public class S8WatchSchedulerService : ITransient
     /// R3 OUT_OF_RANGE compat fallback 查找:用 (source_rule_id, related_object_code, status!=CLOSED,
     /// dedup_key IS NULL, is_deleted=0) 严格条件定位旧 AlertRule 主链留下的历史记录。
     /// </summary>
-    private async Task<long> FindLegacyOutOfRangeExceptionAsync(long tenantId, long factoryId, long sourceRuleId, string relatedObjectCode)
+    private async Task<long> FindLegacyOutOfRangeExceptionAsync(long tenantId, long sourceRuleId, string relatedObjectCode)
     {
         if (string.IsNullOrWhiteSpace(relatedObjectCode)) return 0L;
         var ids = await _exceptionRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && !x.IsDeleted
                         && x.Status != "CLOSED"
                         && x.DedupKey == null
@@ -432,7 +437,7 @@ public class S8WatchSchedulerService : ITransient
     /// R6 返回 recoveredIds 供上游决定是否写 NO_HIT 日志,并对每个 recovered exception 写一条 RECOVERED 日志。
     /// </summary>
     private async Task<List<long>> ReconcileRecoveriesForRuleAsync(
-        long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType, List<S8RuleHit> hits, string runId)
+        long tenantId, AdoS8WatchRule rule, string ruleType, List<S8RuleHit> hits, string runId)
     {
         var hitDedupKeys = hits
             .Where(h => !string.IsNullOrWhiteSpace(h.DedupKey))
@@ -441,7 +446,6 @@ public class S8WatchSchedulerService : ITransient
 
         var candidates = await _exceptionRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && !x.IsDeleted
                         && x.Status != "CLOSED"
                         && x.SourceRuleCode == rule.RuleCode
@@ -484,8 +488,7 @@ public class S8WatchSchedulerService : ITransient
                     UpdatedAt = now
                 })
                 .Where(x => x.TenantId == tenantId
-                            && x.FactoryId == factoryId
-                            && x.RuleCode == rule.RuleCode
+                                && x.RuleCode == rule.RuleCode
                             && x.DedupKey == c.DedupKey)
                 .ExecuteCommandAsync();
 
@@ -509,7 +512,7 @@ public class S8WatchSchedulerService : ITransient
 
             await WriteDetectionLogAsync(new AdoS8DetectionLog
             {
-                TenantId = tenantId, FactoryId = factoryId,
+                TenantId = tenantId, FactoryId = rule.FactoryId,
                 RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
                 SourceObjectType = c.SourceObjectType, SourceObjectId = c.SourceObjectId,
                 RelatedObjectCode = c.RelatedObjectCode, DedupKey = c.DedupKey,
@@ -537,10 +540,10 @@ public class S8WatchSchedulerService : ITransient
 
     /// <summary>R6 通用 hit 日志构造(CREATED / REFRESHED 共用)。</summary>
     private static AdoS8DetectionLog BuildHitLog(
-        long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType, S8RuleHit hit,
+        long tenantId, AdoS8WatchRule rule, string ruleType, S8RuleHit hit,
         string detectResult, long exceptionId, string runId) => new()
     {
-        TenantId = tenantId, FactoryId = factoryId,
+        TenantId = tenantId, FactoryId = rule.FactoryId,
         RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
         SourceObjectType = hit.SourceObjectType, SourceObjectId = hit.SourceObjectId,
         RelatedObjectCode = hit.RelatedObjectCode, DedupKey = hit.DedupKey,
@@ -576,7 +579,7 @@ public class S8WatchSchedulerService : ITransient
     /// 业务侧 RefreshDetectionAsync / BackfillLegacyExceptionAsync 不在限频范围。
     /// </summary>
     private async Task<bool> HasRecentRefreshedDetectionLogAsync(
-        long tenantId, long factoryId, string ruleCode, string dedupKey, DateTime detectedAt)
+        long tenantId, string ruleCode, string dedupKey, DateTime detectedAt)
     {
         if (string.IsNullOrWhiteSpace(ruleCode) || string.IsNullOrWhiteSpace(dedupKey))
             return false;
@@ -586,8 +589,7 @@ public class S8WatchSchedulerService : ITransient
         {
             return await _detectionLogRep.AsQueryable()
                 .Where(x => x.TenantId == tenantId
-                            && x.FactoryId == factoryId
-                            && x.DedupKey == dedupKey
+                                && x.DedupKey == dedupKey
                             && x.RuleCode == ruleCode
                             && x.DetectResult == DetectResultRefreshed
                             && x.DetectedAt >= cutoff)
@@ -721,7 +723,7 @@ public class S8WatchSchedulerService : ITransient
     /// S8-SCHED-EXEC-1:释放过期 lease(lock_until &lt; NOW),不修改 last_status / last_error,仅清空 lock 三件套 + running_started_at。
     /// 返回释放的行数。
     /// </summary>
-    public async Task<int> ResetExpiredLeasesAsync(long tenantId, long factoryId)
+    public async Task<int> ResetExpiredLeasesAsync(long tenantId)
     {
         var now = DateTime.Now;
         var affected = await _ruleRep.Context.Updateable<AdoS8WatchRule>()
@@ -734,15 +736,14 @@ public class S8WatchSchedulerService : ITransient
                 UpdatedAt = now
             })
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && x.LockUntil != null
                         && x.LockUntil < now)
             .ExecuteCommandAsync();
         if (affected > 0)
         {
             _logger.LogWarning(
-                "lease_reset tenantId={Tenant} factoryId={Factory} releasedCount={Count}",
-                tenantId, factoryId, affected);
+                "lease_reset tenantId={Tenant} releasedCount={Count}",
+                tenantId, affected);
         }
         return affected;
     }
@@ -755,14 +756,13 @@ public class S8WatchSchedulerService : ITransient
     /// 抢锁回写:lock_token / locked_by / lock_until = NOW + 5min / running_started_at = NOW / last_run_id = runId。
     /// affectedRows == 1 才算抢到;后续 OnRuleCompletedAsync 必须按 lockToken 回写,避免旧进程覆盖新 lease。
     /// </summary>
-    public async Task<List<S8RuleLease>> PickReadyRulesAsync(long tenantId, long factoryId, int batchSize, string lockedBy, string runId)
+    public async Task<List<S8RuleLease>> PickReadyRulesAsync(long tenantId, int batchSize, string lockedBy, string runId)
     {
         if (batchSize <= 0) batchSize = 16;
         var now = DateTime.Now;
 
         var candidates = await _ruleRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && x.Enabled
                         // S8-STANDARD-DATASET-HARD-CUTOVER-1:取数唯一依赖 dataset_code。
                         // 保留这条谓词而非依赖 Enable Gate:Gate 只在"切换启用"那一刻生效,
@@ -830,24 +830,24 @@ public class S8WatchSchedulerService : ITransient
     /// S8-SCHED-EXEC-1:执行单条已抢锁规则的 evaluator → 抗抖去重 → 建单/刷新 → 恢复 reconcile。
     /// 不释放 lease(OnRuleCompletedAsync 负责);evaluator 抛异常时 Result.Success=false 并保留 ErrorMessage。
     /// </summary>
-    public async Task<S8RuleRunResult> RunSingleRuleAsync(long tenantId, long factoryId, S8RuleLease lease)
+    public async Task<S8RuleRunResult> RunSingleRuleAsync(long tenantId, S8RuleLease lease)
     {
         // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:执行入口必须按本次 tick 的可信作用域绑行。
         // 原实现只按 lease.RuleId 装载,不校验 rule 归属;一旦 lease 来源被改写 / 未来新增调用方,
         // 就会在 A 租户的 scope 上下文里执行 B 租户的规则,而下游全部以传入的 tenantId/factoryId 落库。
         // 这里同时做「谓词绑行」与「归属复核」两层,任一不符即 fail-fast,绝不静默降级。
         var rule = await _ruleRep.AsQueryable()
-            .Where(x => x.Id == lease.RuleId && x.TenantId == tenantId && x.FactoryId == factoryId)
+            .Where(x => x.Id == lease.RuleId && x.TenantId == tenantId)
             .FirstAsync();
         if (rule == null)
         {
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_not_found", Stats = new() };
         }
-        if (rule.TenantId != tenantId || rule.FactoryId != factoryId)
+        if (rule.TenantId != tenantId)
         {
             _logger.LogError(
-                "rule_scope_mismatch ruleId={RuleId} ruleTenant={RuleTenant} ruleFactory={RuleFactory} scopeTenant={ScopeTenant} scopeFactory={ScopeFactory}",
-                rule.Id, rule.TenantId, rule.FactoryId, tenantId, factoryId);
+                "rule_scope_mismatch ruleId={RuleId} ruleTenant={RuleTenant} scopeTenant={ScopeTenant}",
+                rule.Id, rule.TenantId, tenantId);
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_scope_mismatch", Stats = new() };
         }
 
@@ -880,7 +880,7 @@ public class S8WatchSchedulerService : ITransient
 
         try
         {
-            var results = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, lease.RunId);
+            var results = await ProcessSingleRuleAsync(tenantId, rule, ruleType, evaluator, lease.RunId);
             var stats = new S8RuleRunStats
             {
                 Hits = results.Count,
@@ -907,21 +907,21 @@ public class S8WatchSchedulerService : ITransient
     /// 与 ProcessRulesByTypeAsync 内单规则循环体语义一致;此处抽出便于新调度路径直接调用单条 rule。
     /// </summary>
     private async Task<List<S8WatchCreationResult>> ProcessSingleRuleAsync(
-        long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
+        long tenantId, AdoS8WatchRule rule, string ruleType,
         IS8RuleEvaluator evaluator, string runId)
     {
         var results = new List<S8WatchCreationResult>();
         List<S8RuleHit> hits;
         try
         {
-            hits = await evaluator.EvaluateAsync(tenantId, factoryId, rule);
+            hits = await evaluator.EvaluateAsync(tenantId, rule);
         }
         catch (Exception ex)
         {
             var failureReason = ex is S8RuleEvaluatorException sre ? sre.Reason : ex.GetType().Name;
             await WriteDetectionLogAsync(new AdoS8DetectionLog
             {
-                TenantId = tenantId, FactoryId = factoryId,
+                TenantId = tenantId, FactoryId = rule.FactoryId,
                 RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
                 SourceObjectType = rule.SourceObjectType,
                 DetectResult = DetectResultEvaluateFailed,
@@ -937,7 +937,7 @@ public class S8WatchSchedulerService : ITransient
         List<long> recoveredIds;
         try
         {
-            recoveredIds = await ReconcileRecoveriesForRuleAsync(tenantId, factoryId, rule, ruleType, hits, runId);
+            recoveredIds = await ReconcileRecoveriesForRuleAsync(tenantId, rule, ruleType, hits, runId);
         }
         catch (Exception ex)
         {
@@ -961,7 +961,7 @@ public class S8WatchSchedulerService : ITransient
             long matchedId;
             try
             {
-                matchedId = await FindOpenExceptionByDedupKeyAsync(tenantId, factoryId, hit.DedupKey);
+                matchedId = await FindOpenExceptionByDedupKeyAsync(tenantId, hit.DedupKey);
             }
             catch (Exception ex)
             {
@@ -974,9 +974,9 @@ public class S8WatchSchedulerService : ITransient
                 try
                 {
                     await RefreshDetectionAsync(matchedId, hit);
-                    if (!await HasRecentRefreshedDetectionLogAsync(tenantId, factoryId, rule.RuleCode, hit.DedupKey, hit.DetectedAt))
+                    if (!await HasRecentRefreshedDetectionLogAsync(tenantId, rule.RuleCode, hit.DedupKey, hit.DetectedAt))
                     {
-                        await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultRefreshed, matchedId, runId));
+                        await WriteDetectionLogAsync(BuildHitLog(tenantId, rule, ruleType, hit, DetectResultRefreshed, matchedId, runId));
                     }
                     results.Add(BuildSkippedDuplicate(rule, hit, matchedId));
                 }
@@ -992,7 +992,7 @@ public class S8WatchSchedulerService : ITransient
                 long compatId;
                 try
                 {
-                    compatId = await FindLegacyOutOfRangeExceptionAsync(tenantId, factoryId, rule.Id, hit.RelatedObjectCode);
+                    compatId = await FindLegacyOutOfRangeExceptionAsync(tenantId, rule.Id, hit.RelatedObjectCode);
                 }
                 catch (Exception ex)
                 {
@@ -1004,9 +1004,9 @@ public class S8WatchSchedulerService : ITransient
                     try
                     {
                         await BackfillLegacyExceptionAsync(compatId, hit);
-                        if (!await HasRecentRefreshedDetectionLogAsync(tenantId, factoryId, rule.RuleCode, hit.DedupKey, hit.DetectedAt))
+                        if (!await HasRecentRefreshedDetectionLogAsync(tenantId, rule.RuleCode, hit.DedupKey, hit.DetectedAt))
                         {
-                            await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultRefreshed, compatId, runId));
+                            await WriteDetectionLogAsync(BuildHitLog(tenantId, rule, ruleType, hit, DetectResultRefreshed, compatId, runId));
                         }
                         results.Add(BuildSkippedDuplicate(rule, hit, compatId));
                     }
@@ -1022,9 +1022,12 @@ public class S8WatchSchedulerService : ITransient
             try
             {
                 typeExists = await _exceptionTypeRep.AsQueryable()
+                    // S8-TENANT-ONLY-BATCH5:去掉工厂条件。这是一次**存在性**检查,
+                    // 少一个条件只会更宽松:factory_id=0 的供给规则也能看到本租户的工厂级异常类型行,
+                    // 否则它只能匹配到平台默认行、找不到就整条规则 exception_type_missing。
+                    // 注意 t.TenantId == 0 是平台默认哨兵(S8ConfigScope),不是工厂语义,保留。
                     .Where(t => t.TypeCode == hit.ExceptionTypeCode
                                 && (t.TenantId == 0 || t.TenantId == tenantId)
-                                && (t.FactoryId == 0 || t.FactoryId == factoryId)
                                 && t.Enabled)
                     .AnyAsync();
             }
@@ -1044,7 +1047,7 @@ public class S8WatchSchedulerService : ITransient
             AdoS8RuleDetectionState? state;
             try
             {
-                (state, hitCount) = await UpsertDetectionStateOnHitAsync(tenantId, factoryId, rule, hit);
+                (state, hitCount) = await UpsertDetectionStateOnHitAsync(tenantId, rule, hit);
             }
             catch (Exception ex)
             {
@@ -1064,7 +1067,7 @@ public class S8WatchSchedulerService : ITransient
 
             try
             {
-                var entity = await _reportDispatcher.CreateFromHitAsync(tenantId, factoryId, hit);
+                var entity = await _reportDispatcher.CreateFromHitAsync(tenantId, rule.FactoryId, hit);
                 await _exceptionRep.Context.Updateable<AdoS8Exception>()
                     .SetColumns(x => new AdoS8Exception
                     {
@@ -1085,7 +1088,7 @@ public class S8WatchSchedulerService : ITransient
                         .Where(x => x.Id == state.Id)
                         .ExecuteCommandAsync();
                 }
-                await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultCreated, entity.Id, runId));
+                await WriteDetectionLogAsync(BuildHitLog(tenantId, rule, ruleType, hit, DetectResultCreated, entity.Id, runId));
                 await TryDispatchLayerNotificationAsync(entity);
                 results.Add(BuildCreatedResult(rule, hit, entity.Id));
             }
@@ -1103,7 +1106,7 @@ public class S8WatchSchedulerService : ITransient
     /// 必须 WHERE id = lease.RuleId AND lock_token = lease.LockToken;affectedRows = 0 视为 lease 丢失,记录 Warning,不覆盖状态。
     /// 失败 ≥ 阈值(默认 3)写 paused_until = NOW + 1h。
     /// </summary>
-    public async Task OnRuleCompletedAsync(long tenantId, long factoryId, S8RuleLease lease, S8RuleRunResult result, int durationMs)
+    public async Task OnRuleCompletedAsync(long tenantId, S8RuleLease lease, S8RuleRunResult result, int durationMs)
     {
         var rule = await _ruleRep.AsQueryable().Where(x => x.Id == lease.RuleId).FirstAsync();
         if (rule == null)
@@ -1184,7 +1187,7 @@ public class S8WatchSchedulerService : ITransient
     /// 3) 每条 rule 独立 try/catch 调用 RunSingleRuleAsync + OnRuleCompletedAsync
     /// 单条规则失败不影响其他规则;整 tick 不抛异常。
     /// </summary>
-    public async Task<S8DispatchTickResult> RunDispatchTickAsync(long tenantId, long factoryId, int batchSize, string lockedBy)
+    public async Task<S8DispatchTickResult> RunDispatchTickAsync(long tenantId, int batchSize, string lockedBy)
     {
         var tickId = Guid.NewGuid().ToString("N").Substring(0, 8);
         var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
@@ -1192,7 +1195,7 @@ public class S8WatchSchedulerService : ITransient
 
         try
         {
-            summary.LeaseReleased = await ResetExpiredLeasesAsync(tenantId, factoryId);
+            summary.LeaseReleased = await ResetExpiredLeasesAsync(tenantId);
         }
         catch (Exception ex)
         {
@@ -1202,7 +1205,7 @@ public class S8WatchSchedulerService : ITransient
         List<S8RuleLease> leases;
         try
         {
-            leases = await PickReadyRulesAsync(tenantId, factoryId, batchSize, lockedBy, runId);
+            leases = await PickReadyRulesAsync(tenantId, batchSize, lockedBy, runId);
         }
         catch (Exception ex)
         {
@@ -1217,7 +1220,7 @@ public class S8WatchSchedulerService : ITransient
             S8RuleRunResult runResult;
             try
             {
-                runResult = await RunSingleRuleAsync(tenantId, factoryId, lease);
+                runResult = await RunSingleRuleAsync(tenantId, lease);
             }
             catch (Exception ex)
             {
@@ -1226,7 +1229,7 @@ public class S8WatchSchedulerService : ITransient
             sw.Stop();
             try
             {
-                await OnRuleCompletedAsync(tenantId, factoryId, lease, runResult, (int)sw.ElapsedMilliseconds);
+                await OnRuleCompletedAsync(tenantId, lease, runResult, (int)sw.ElapsedMilliseconds);
             }
             catch (Exception ex)
             {