Bläddra i källkod

fix(s8): close watch configuration runtime contract | server 1.0.428

CFG_WATCH 由 PARTIAL 转 READY(40 CLEAN + 1 PASS WITH FINDING),关闭 1 个 P0 与 4 个 P1。

P0 通用整实体 PUT /watch-rules/{id} 退役为 410 Gone:
该入口整列更新且 body 直接由客户端 JSON 绑定,服务端只重盖 5 个字段,实体与仓储层
均无 UpdateIgnoreColumns 保护,调用方因此可写入全部 13 个 scheduler-owned 运行时列
(lock_token / locked_by / lock_until / running_started_at / next_run_at / last_run_at /
last_status / last_error / last_duration_ms / last_run_id / consecutive_failure_count /
paused_until / pause_reason),足以窃取或作废租约、令第二实例重复拾取同一规则、
把 lock_until 或 paused_until 设为远未来实现静默 DoS、伪造调度审计;且无需恶意,
部分字段的 PUT body 就会让这 13 列静默变 NULL 并返回 200。
该入口无任何正式消费方(前端 s8ConfigApi.watchRules 无 update,e2e 只用 GET/POST/DELETE),
故整体退役而非改白名单。抛出发生在任何 DB access 之前,因此任意 id 一律 410 而非 404——
「这个能力没了」优先于「这条记录不属于你」,避免越权探测反推他租户数据是否存在。
正式配置修改仍走 UpdateParams / UpdateSchedule 等窄入口,GET 不受影响。

P1:
- canonical 词表校验,严格取自 RunSingleRuleAsync 的真实 evaluator dispatch
  (TIMEOUT / SHORTAGE / OUT_OF_RANGE);severity 门禁必须在 S8SeverityCode.Normalize
  之前执行,否则其兜底分支 _ => Follow 会把 HIGH 与拼写错误静默降级、门禁永不命中。
- Create 增加 params schema 校验。
- Create identity 修复:原 InsertAsync 只返回 bool,body.Id 恒为 0,
  调用方随后 GET/PUT/DELETE 一律 404;改用 ExecuteReturnBigIdentityAsync 回填自增主键。

新增 IS8ExceptionReportDispatcher 作为 Watch 主链的最窄纯委派 seam(建单边界),
使编排、检测、去重与通知边界可在认证中真实跑通而业务副作用为零;
测试钉住其「生产语义等价 + 纯委派」契约,防止后续有人往正式实现里加逻辑。

认证零漂移:Golden 14 / UAT exceptions 17 / Detection 8 全程未变,
synthetic residue 0,真实外发 0,Watch 基线由 5/1 纠正为 5/0。
YY968XX 19 timmar sedan
förälder
incheckning
7fffe4bb4b

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.427</AssemblyVersion>
-    <FileVersion>1.0.427</FileVersion>
-    <Version>1.0.427</Version>
+    <AssemblyVersion>1.0.428</AssemblyVersion>
+    <FileVersion>1.0.428</FileVersion>
+    <Version>1.0.428</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 103 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ExceptionReportDispatcherTests.cs

@@ -0,0 +1,103 @@
+using System.Reflection;
+using Furion.DependencyInjection;
+using System.Runtime.CompilerServices;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-STEP6E-CFG-WATCH-ORCHESTRATION-DEDUPE-UI-CERT-1:钉住建单 seam 的
+/// 「生产语义等价 + 纯委派」契约。
+///
+/// <para>seam 的全部价值在于「认证时可替换、生产时透明」。一旦有人往正式实现里加逻辑,
+/// Watch 认证得到的就不再是生产行为——本文件就是防这个的。</para>
+/// </summary>
+public class S8ExceptionReportDispatcherTests
+{
+    /// <summary>接口方法签名必须与被委派的真实方法逐字一致,否则 seam 就偷偷改了契约。</summary>
+    [Fact]
+    public void InterfaceSignature_MatchesRealService()
+    {
+        var iface = typeof(IS8ExceptionReportDispatcher).GetMethod("CreateFromHitAsync")!;
+        var real = typeof(S8ManualReportService).GetMethod("CreateFromHitAsync")!;
+
+        Assert.Equal(real.ReturnType, iface.ReturnType);
+        Assert.Equal(
+            real.GetParameters().Select(p => p.ParameterType).ToArray(),
+            iface.GetParameters().Select(p => p.ParameterType).ToArray());
+        Assert.Equal(
+            new[] { typeof(long), typeof(long), typeof(S8RuleHit) },
+            iface.GetParameters().Select(p => p.ParameterType).ToArray());
+    }
+
+    /// <summary>正式实现只能持有被委派的服务这一个依赖——多一个字段就说明它开始自己干活了。</summary>
+    [Fact]
+    public void ProductionImplementation_HasOnlyTheDelegateDependency()
+    {
+        var fields = typeof(S8ExceptionReportDispatcher)
+            .GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+        Assert.Single(fields);
+        Assert.Equal(typeof(S8ManualReportService), fields[0].FieldType);
+    }
+
+    /// <summary>
+    /// 真正的委派证据:用未初始化的 S8ManualReportService 构造 dispatcher 并调用,
+    /// 抛出的异常栈里必须出现 <c>S8ManualReportService.CreateFromHitAsync</c>——
+    /// 即调用确实**穿透到了真实服务**,而不是被 dispatcher 自己拦下或改写。
+    /// (不连 DB:真实服务在触仓储时抛 NRE,这正是我们要的穿透信号。)
+    /// </summary>
+    [Fact]
+    public void ProductionImplementation_DelegatesIntoRealService()
+    {
+        var svc = (S8ManualReportService)RuntimeHelpers.GetUninitializedObject(typeof(S8ManualReportService));
+        var dispatcher = new S8ExceptionReportDispatcher(svc);
+
+        var ex = Record.ExceptionAsync(
+            () => dispatcher.CreateFromHitAsync(1L, 2L, new S8RuleHit())).GetAwaiter().GetResult();
+
+        Assert.NotNull(ex);
+        var trace = ex!.ToString();
+        Assert.Contains(nameof(S8ManualReportService), trace);
+        Assert.Contains("CreateFromHitAsync", trace);
+    }
+
+    /// <summary>
+    /// 参数必须原样透传——tenantId / factoryId 不得被 dispatcher 改写或吞掉。
+    /// 用真实服务最外层的守卫(tenantId&lt;=0 或 factoryId&lt;=0 抛 S8BizException)作为探针:
+    /// 若 dispatcher 篡改了入参,这个守卫就不会按预期触发。
+    /// </summary>
+    [Theory]
+    [InlineData(0L, 2L)]
+    [InlineData(1L, 0L)]
+    [InlineData(-1L, -1L)]
+    public void ProductionImplementation_PassesArgumentsThroughUnchanged(long tenantId, long factoryId)
+    {
+        var svc = (S8ManualReportService)RuntimeHelpers.GetUninitializedObject(typeof(S8ManualReportService));
+        var dispatcher = new S8ExceptionReportDispatcher(svc);
+
+        var ex = Record.ExceptionAsync(
+            () => dispatcher.CreateFromHitAsync(tenantId, factoryId, new S8RuleHit())).GetAwaiter().GetResult();
+
+        Assert.IsType<S8BizException>(ex);
+    }
+
+    /// <summary>seam 必须被 Furion 自动注册(ITransient),不需要改 Startup。</summary>
+    [Fact]
+    public void ProductionImplementation_IsAutoRegistrable()
+    {
+        Assert.True(typeof(IS8ExceptionReportDispatcher).IsAssignableFrom(typeof(S8ExceptionReportDispatcher)));
+        Assert.True(typeof(ITransient).IsAssignableFrom(typeof(S8ExceptionReportDispatcher)));
+        Assert.True(typeof(S8ExceptionReportDispatcher).IsSealed);
+    }
+
+    /// <summary>调度器必须消费 seam 接口而不是直接持有具体服务去自动建单。</summary>
+    [Fact]
+    public void Scheduler_DependsOnTheSeamInterface()
+    {
+        var ctor = typeof(S8WatchSchedulerService).GetConstructors().Single();
+        Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(IS8ExceptionReportDispatcher));
+    }
+}

+ 153 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRuleWriteGuardTests.cs

@@ -0,0 +1,153 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-STEP6E-CFG-WATCH-FIX-AND-SAFE-CERT-1:钉住 CFG_WATCH 的
+/// 「整实体 PUT 退役 + 新建 canonical 词表」契约。
+///
+/// <para><b>不触任何数据库。</b> 退役守卫在**任何 DB 访问之前**抛出,词表校验是纯静态方法,
+/// 所以都不需要可用的仓储。用 <see cref="RuntimeHelpers.GetUninitializedObject"/> 绕开构造函数
+/// (其 <c>SqlSugarRepository&lt;T&gt;</c> 参数的无参构造会触发 Furion.App 静态初始化,
+/// 在无宿主的 xunit 进程内必抛 TypeInitializationException)。
+/// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败——
+/// 这正是我们要的信号:<b>守卫必须前置</b>。</para>
+/// </summary>
+public class S8WatchRuleWriteGuardTests
+{
+    private static S8WatchRuleService Svc() =>
+        (S8WatchRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8WatchRuleService));
+
+    private static readonly S8TrustedScope Scope = new(838257186181189L, 838257186320453L);
+
+    private static AdoS8WatchRule Body() => new()
+    {
+        RuleCode = "TEST-WATCH-X",
+        SceneCode = "S1",
+        RuleType = S8TimeoutRuleEvaluator.RuleTypeCode,
+        Severity = S8SeverityCode.Follow,
+        DataSourceId = 5,
+        WatchObjectType = "ORDER",
+        PollIntervalSeconds = 300,
+    };
+
+    // ---------------- P0:整实体 PUT 退役 ----------------
+
+    [Fact]
+    public void UpdateAsync_IsRetired_WithWatchSpecificMessage()
+    {
+        var ex = Assert.Throws<S8WriteRetiredException>(
+            () => { _ = Svc().UpdateAsync(1L, Body(), Scope); });
+        Assert.Equal(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message);
+        // 不能沿用 CFG_ALERT 的文案:那会让用户误以为「监控规则整体只读了」。
+        Assert.NotEqual(S8WriteRetiredException.UserMessage, ex.Message);
+        Assert.Contains("专用配置接口", ex.Message);
+    }
+
+    /// <summary>越权 / 不存在的 id 同样 410,不得因 id 不同回落 404 分支(避免存在性探测)。</summary>
+    [Theory]
+    [InlineData(0L)]
+    [InlineData(187L)]           // UAT 真实存在的 stale 行
+    [InlineData(1329909460001L)] // Golden watch rule
+    [InlineData(999999999L)]     // 不存在
+    public void UpdateAsync_IsRetired_RegardlessOfId(long id)
+    {
+        Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().UpdateAsync(id, Body(), Scope); });
+    }
+
+    /// <summary>退役异常必须继承 S8BizException,使只捕获基类的旧调用方安全降级为 400 而非 500。</summary>
+    [Fact]
+    public void RetiredException_DerivesFromBizException_AndIsNotNotFound()
+    {
+        Assert.True(typeof(S8BizException).IsAssignableFrom(typeof(S8WriteRetiredException)));
+        Assert.False(typeof(S8NotFoundException).IsAssignableFrom(typeof(S8WriteRetiredException)));
+    }
+
+    /// <summary>窄写入口与读路径必须保留——退役的只是整实体 PUT。</summary>
+    [Fact]
+    public void NarrowWritePathsAndReadPath_ArePreserved()
+    {
+        var t = typeof(S8WatchRuleService);
+        Assert.NotNull(t.GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
+        Assert.NotNull(t.GetMethod("UpdateParamsAsync", new[] { typeof(long), typeof(S8WatchRuleParamsPayload), typeof(S8TrustedScope) }));
+        Assert.NotNull(t.GetMethod("UpdateScheduleAsync", new[] { typeof(long), typeof(S8WatchRuleSchedulePayload), typeof(S8TrustedScope) }));
+        Assert.NotNull(t.GetMethod("PauseAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
+        Assert.NotNull(t.GetMethod("ResumeAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
+        Assert.NotNull(t.GetMethod("RunNowAsync", new[] { typeof(long), typeof(S8TrustedScope) }));
+    }
+
+    // ---------------- P1:新建 canonical 词表 ----------------
+
+    private static void Validate(AdoS8WatchRule body)
+    {
+        var m = typeof(S8WatchRuleService).GetMethod(
+            "ValidateVocabularyForCreate", BindingFlags.NonPublic | BindingFlags.Static)!;
+        try { m.Invoke(null, new object[] { body }); }
+        catch (TargetInvocationException tie) { throw tie.InnerException!; }
+    }
+
+    [Fact]
+    public void Create_AcceptsCanonicalVocabulary()
+    {
+        Validate(Body());   // 反向对照:合法值必须通过,否则「全拒」可能是误伤
+    }
+
+    [Theory]
+    [InlineData("timeout")]          // 大小写:调度器 switch 是 ordinal 敏感的
+    [InlineData("NOT_A_TYPE")]
+    [InlineData("")]
+    [InlineData(null)]
+    public void Create_RejectsNonCanonicalRuleType(string? ruleType)
+    {
+        var b = Body(); b.RuleType = ruleType;
+        Assert.Throws<S8BizException>(() => Validate(b));
+    }
+
+    [Theory]
+    [InlineData("S2S6_PRODUCTION")]  // legacy 复合场景
+    [InlineData("S8_DEMO_DEFAULT")]
+    [InlineData("NOT_A_SCENE")]
+    public void Create_RejectsNonCanonicalScene(string scene)
+    {
+        var b = Body(); b.SceneCode = scene;
+        Assert.Throws<S8BizException>(() => Validate(b));
+    }
+
+    /// <summary>
+    /// 关键回归:severity 必须在 Normalize **之前**校验。
+    /// HIGH 正是 DB 中 id=187 那一行的实际值——它当年就是被 Normalize 静默吞掉才存进来的。
+    /// </summary>
+    [Theory]
+    [InlineData("HIGH")]
+    [InlineData("LOW")]
+    [InlineData("MEDIUM")]
+    [InlineData("CRITICAL")]
+    [InlineData("bogus")]
+    public void Create_RejectsLegacyAndInvalidSeverity_BeforeNormalize(string severity)
+    {
+        var b = Body(); b.Severity = severity;
+        var ex = Assert.Throws<S8BizException>(() => Validate(b));
+        Assert.Contains(severity, ex.Message);
+        // 若校验被放到 Normalize 之后,HIGH 会被静默降级成 FOLLOW 而不是抛错。
+        Assert.NotEqual(S8SeverityCode.Normalize(severity), severity);
+    }
+
+    /// <summary>canonical rule_type 集合必须与真实 evaluator 常量同源,不得手抄字面量。</summary>
+    [Fact]
+    public void CanonicalRuleTypes_MatchEvaluatorRegistry()
+    {
+        var f = typeof(S8WatchRuleService).GetField(
+            "CanonicalRuleTypes", BindingFlags.NonPublic | BindingFlags.Static)!;
+        var actual = (string[])f.GetValue(null)!;
+        Assert.Equal(
+            new[] { S8TimeoutRuleEvaluator.RuleTypeCode, S8ShortageRuleEvaluator.RuleTypeCode, S8OutOfRangeRuleEvaluator.RuleTypeCode },
+            actual);
+    }
+}

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

@@ -54,16 +54,42 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
     /// Legacy endpoint. Rule configuration UI must use PUT /{id}/params.
     /// 保留兼容历史脚本/集成调用,但响应 header 与日志会标记为 deprecated。
     /// </summary>
-    [Obsolete("Legacy full-update endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params for safe params editing.")]
+    // ================================================================================
+    // S8-STEP6E-CFG-WATCH-FIX-AND-SAFE-CERT-1:本入口已退役 → 410 Gone。
+    //
+    // 它是整实体更新,会连带把 13 个 scheduler-owned 运行时列暴露给客户端
+    //(lock_token / lock_until / paused_until / next_run_at / last_* / consecutive_failure_count),
+    // 足以窃取租约、制造重复执行、静默停掉监控、伪造调度审计。详见 S8WatchRuleService.UpdateAsync 注释。
+    //
+    // 选 410 而非 400/404:语义是「该能力曾经存在、现已永久退役」。
+    // 400 会暗示「改对入参还能存」,404 会暗示「换个 Id 还能存」,都与事实不符。
+    //
+    // ⚠️ 不再调用 _scope.ResolveAsync():写能力已退役,不该为构造一个用不到的 scope 去读组织库;
+    //    这同时保证 DB access = 0,并让工厂组织 0 个/多个的租户也稳定得到 410 而非作用域错误。
+    // ⚠️ catch 顺序:S8WriteRetiredException 必须排在 S8NotFoundException / S8BizException 之前。
+    // GET 与 /params /schedule /run-now /pause /resume 全部不受影响。
+    // ================================================================================
+    [Obsolete("Retired full-update endpoint. Use PUT /api/aidop/s8/config/watch-rules/{id}/params or /schedule.")]
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS8WatchRule body)
     {
         MarkLegacyDeprecated($"PUT /api/aidop/s8/config/watch-rules/{id}", id);
-        try { return Ok(await _svc.UpdateAsync(id, body, await _scope.ResolveAsync())); }
+        try { return Ok(await _svc.UpdateAsync(id, body, RetiredWriteScope)); }
+        catch (S8WriteRetiredException ex)
+        {
+            return StatusCode(Microsoft.AspNetCore.Http.StatusCodes.Status410Gone, new { message = ex.Message });
+        }
         catch (S8NotFoundException) { return NotFound(); }
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>
+    /// 写能力已永久退役:不得为了构造一个不会被使用的 scope 而读取组织库。
+    /// Service 写方法必须在读取该占位值或访问任何仓储前抛 S8WriteRetiredException。
+    /// (与 CFG_ALERT 的 RetiredWriteScope 同一模式。)
+    /// </summary>
+    private static readonly S8TrustedScope RetiredWriteScope = new(0, 0);
+
     /// <summary>
     /// Legacy endpoint. Rule configuration UI must use PUT /{id}/params.
     /// 保留兼容历史脚本/集成调用,但响应 header 与日志会标记为 deprecated。

+ 18 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8BizException.cs

@@ -31,9 +31,26 @@ public sealed class S8NotFoundException : S8BizException
 /// </summary>
 public sealed class S8WriteRetiredException : S8BizException
 {
-    /// <summary>面向用户的统一文案:说明只读原因 + 指出当前真正的运行时权威。</summary>
+    /// <summary>
+    /// CFG_ALERT 的退役文案(默认值,保持既有调用方与测试引用不变):
+    /// 说明只读原因 + 指出当前真正的运行时权威。
+    /// </summary>
     public const string UserMessage =
         "告警规则已转为历史兼容只读配置,当前运行时告警判定由监控规则管理。";
 
+    /// <summary>
+    /// S8-STEP6E-CFG-WATCH-FIX-AND-SAFE-CERT-1:CFG_WATCH 通用整实体 PUT 的退役文案。
+    ///
+    /// 与 CFG_ALERT 的语义不同——监控规则本身**仍是生产权威**,退役的只是那个会连带
+    /// 暴露 scheduler-owned 运行时列(lock_token / lock_until / paused_until / next_run_at /
+    /// last_* / consecutive_failure_count)的整实体更新入口。正式配置修改仍走窄写入口。
+    /// 故文案必须引导用户去专用接口,而不能让人误以为「监控规则整体只读了」。
+    /// </summary>
+    public const string WatchRuleUpdateMessage =
+        "监控规则通用整实体更新接口已退役,请使用监控规则的专用配置接口。";
+
     public S8WriteRetiredException() : base(UserMessage) { }
+
+    /// <summary>按写入口给出各自的固定文案;调用方只能传预定义常量,不得拼入内部实现细节。</summary>
+    public S8WriteRetiredException(string userMessage) : base(userMessage) { }
 }

+ 42 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionReportDispatcher.cs

@@ -0,0 +1,42 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8-STEP6E-CFG-WATCH-ORCHESTRATION-DEDUPE-UI-CERT-1:Watch 主链的**业务建单边界**。
+///
+/// <para>只抽象一件事——「就一次命中提交一次异常报告」。这是
+/// <c>S8WatchSchedulerService</c> 调用 <c>S8ManualReportService</c> 的**唯一最窄位置**
+/// (抗抖门通过之后、计数回写/检测日志/通知派发之前)。</para>
+///
+/// <para><b>为什么需要它</b>:Watch 主链的编排(数据源加载 → evaluator → 检测状态 → 抗抖 →
+/// dedupe)必须能在认证中真实跑通,但真实建单会连带写 <c>ado_s8_exception</c>、
+/// <c>ado_s8_exception_timeline</c>,并触发 ApprovalFlow 建实例/任务/日志——这些既不可回滚
+/// (只有 exception + timeline 在同一事务内,其余全是自动提交),也依赖部门主数据。
+/// 把边界抽出来后,认证 Host 可以只替换这一个接口,从而做到:
+/// 上游全真实、建单意图可核对、业务副作用为零。</para>
+///
+/// <para>⚠️ <b>生产语义不得改变</b>:正式实现只做一次纯委派,不改参数、不改事务、
+/// 不改异常处理、不改 dedupe、不改通知、不改返回值语义。</para>
+/// </summary>
+public interface IS8ExceptionReportDispatcher
+{
+    /// <summary>把一次规则命中提交为标准异常单;参数与返回值同 <see cref="S8ManualReportService.CreateFromHitAsync"/>。</summary>
+    Task<AdoS8Exception> CreateFromHitAsync(long tenantId, long factoryId, S8RuleHit hit);
+}
+
+/// <summary>
+/// 正式实现:**纯委派**,零业务逻辑。Furion 按 <c>ITransient</c> 自动注册到
+/// <see cref="IS8ExceptionReportDispatcher"/>,无需改动 Startup。
+/// </summary>
+public sealed class S8ExceptionReportDispatcher : IS8ExceptionReportDispatcher, ITransient
+{
+    private readonly S8ManualReportService _manualReportService;
+
+    public S8ExceptionReportDispatcher(S8ManualReportService manualReportService) =>
+        _manualReportService = manualReportService;
+
+    public Task<AdoS8Exception> CreateFromHitAsync(long tenantId, long factoryId, S8RuleHit hit) =>
+        _manualReportService.CreateFromHitAsync(tenantId, factoryId, hit);
+}

+ 89 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -1,6 +1,7 @@
 using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
@@ -32,25 +33,50 @@ public class S8WatchRuleService : ITransient
         body.TenantId = scope.TenantId;
         body.FactoryId = scope.FactoryId;
         await ValidateAsync(body, scope);
+        // S8-STEP6E:新建一律走 canonical 词表 + params schema,杜绝「存得下但运行时永远不生效」。
+        ValidateVocabularyForCreate(body);
+        if (!string.IsNullOrWhiteSpace(body.ParamsJson))
+            ValidateParamsJsonByRuleType(body.RuleType, body.ParamsJson!.Trim());
         body.Id = 0;
         body.CreatedAt = DateTime.Now;
-        await _rep.InsertAsync(body);
+        // S8-STEP6E(与 CFG_DATASRC D-3 / CFG_ROLES 同源缺陷):回填自增主键。
+        // 原 InsertAsync 只返回 bool,body.Id 保持 0,调用方随后 GET/PUT/DELETE 一律 404。
+        body.Id = await _rep.AsInsertable(body).ExecuteReturnBigIdentityAsync();
         return body;
     }
 
-    // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。
-    public async Task<AdoS8WatchRule> UpdateAsync(long id, AdoS8WatchRule body, S8TrustedScope scope)
-    {
-        var e = await LoadScopedAsync(id, scope);
-        body.TenantId = e.TenantId;
-        body.FactoryId = e.FactoryId;
-        await ValidateAsync(body, scope, id);
-        body.Id = id;
-        body.CreatedAt = e.CreatedAt;
-        body.UpdatedAt = DateTime.Now;
-        await _rep.UpdateAsync(body);
-        return body;
-    }
+    // ================================================================================
+    // S8-STEP6E-CFG-WATCH-FIX-AND-SAFE-CERT-1:通用整实体 PUT 已退役。
+    //
+    // 原实现 `_rep.UpdateAsync(body)` 是**整列更新**,而 body 直接由客户端 JSON 绑定
+    // (本实体即 DTO),服务端只重新盖章 5 个字段(Tenant/Factory/Id/CreatedAt/UpdatedAt)。
+    // 实体与仓储层均无 UpdateIgnoreColumns / IsOnlyIgnoreUpdate 保护,因此调用方可写入
+    // 全部 13 个 scheduler-owned 运行时列:
+    //   lock_token / locked_by / lock_until / running_started_at /
+    //   next_run_at / last_run_at / last_status / last_error / last_duration_ms / last_run_id /
+    //   consecutive_failure_count / paused_until / pause_reason
+    //
+    // 后果(按 S8WatchSchedulerService 的租约语义):
+    //   · 写 lock_token/lock_until → 窃取或作废活跃租约,令运行中实例的回写静默失败
+    //   · 清 lock → 第二实例重复拾取同一规则 → 重复建单
+    //   · lock_until 设远未来 → 该规则永不再被 PickReadyRulesAsync 选中(静默 DoS)
+    //   · paused_until 设远未来 → UI 仍显示「启用」但监控实际已停
+    //   · 写 last_status/last_run_id/last_error → 伪造调度审计轨迹
+    // 且**无需恶意**:部分字段的 PUT body 会让这 13 列静默变 NULL(HTTP 200、无报错)。
+    //
+    // 退役而非改白名单,是因为该入口没有正式消费方(已穷举:前端 s8ConfigApi.watchRules
+    // 无 update;e2e 只用 GET/POST/DELETE;服务端唯一引用是本 controller),
+    // 而正式配置修改已有 UpdateParamsAsync / UpdateScheduleAsync 等窄入口。
+    //
+    // ⚠️ 抛出发生在**任何 DB 访问之前**:不做 LoadScopedAsync、不做重复性查询。
+    //    这既保证零 DB 触碰,也保证任意 id(含越权 id)一律 410 而非 404
+    //    ——「这个能力没了」优先于「这条记录不属于你」,避免越权探测反推他租户数据是否存在。
+    //
+    // 保留方法签名是硬约束:S8TenantIsolationContractTests 用反射断言带 S8TrustedScope
+    // 的写入口存在、且无 scope 的旧重载不存在。
+    // ================================================================================
+    public Task<AdoS8WatchRule> UpdateAsync(long id, AdoS8WatchRule body, S8TrustedScope scope) =>
+        throw new S8WriteRetiredException(S8WriteRetiredException.WatchRuleUpdateMessage);
 
     // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。
     public async Task DeleteAsync(long id, S8TrustedScope scope)
@@ -98,6 +124,55 @@ public class S8WatchRuleService : ITransient
         return trimmed.Length == 0 ? null : trimmed;
     }
 
+    /// <summary>
+    /// S8-STEP6E:WATCH 侧 canonical 词表,**严格取自真实 evaluator dispatch**
+    /// (S8WatchSchedulerService.RunSingleRuleAsync 的 switch,ordinal 大小写敏感),
+    /// 不是从 NOTIFY 词表套用过来的。
+    /// 注意 rule_type 为空/空白是**合法的历史态**(调度器按 rule_type_empty_skipped 跳过、
+    /// 不算失败),但**不允许新建**——新建一条永远不会被任何 evaluator 承载的规则没有产品意义。
+    /// </summary>
+    private static readonly string[] CanonicalRuleTypes =
+    {
+        S8TimeoutRuleEvaluator.RuleTypeCode,
+        S8ShortageRuleEvaluator.RuleTypeCode,
+        S8OutOfRangeRuleEvaluator.RuleTypeCode,
+    };
+
+    /// <summary>
+    /// S8-STEP6E:新建时的 canonical 词表校验(rule_type / scene / severity)。
+    ///
+    /// 只作用于 Create:
+    ///   · UpdateParamsAsync / UpdateScheduleAsync / Pause / Resume 都不修改这三个字段,无需重复校验;
+    ///   · TestAsync 是对**既有行**的探针,若在此加严会让 legacy 无效行连自检都跑不了。
+    /// 即「legacy 无效数据允许读取与停用,但禁止继续创建」。
+    /// </summary>
+    private static void ValidateVocabularyForCreate(AdoS8WatchRule body)
+    {
+        // rule_type:必须是真实存在 evaluator 的三类之一。
+        if (string.IsNullOrWhiteSpace(body.RuleType)
+            || Array.IndexOf(CanonicalRuleTypes, body.RuleType) < 0)
+            throw new S8BizException(
+                "不支持的规则类型:" + (string.IsNullOrWhiteSpace(body.RuleType) ? "(空)" : body.RuleType)
+                + ";当前仅支持 " + string.Join(" / ", CanonicalRuleTypes));
+
+        // scene:S8SceneCode 本身没有 IsValid,canonical 单模块场景判定复用 S8ModuleCode.IsValid
+        //(仓内唯一「严格 S1–S7、拒 legacy 复合场景」的现成实现,不另造第二套)。
+        if (!S8ModuleCode.IsValid(body.SceneCode))
+            throw new S8BizException(
+                "不支持的场景编码:" + body.SceneCode + ";当前仅支持 " + string.Join(" / ", S8ModuleCode.All));
+
+        // severity:必须在 S8SeverityCode.Normalize **之前**校验。
+        // Normalize 的兜底分支是 `_ => Follow`,放在之后会把 HIGH / 拼写错误静默降级成 FOLLOW,
+        // 门禁永远命中不了——DB 中 severity='HIGH' 的那一行正是这样进来的。
+        // 也刻意不复用 S8SeverityCode.IsValid:那是宽松六值版(含 LOW/MEDIUM/HIGH/CRITICAL),
+        // 供 legacy 查询参数兼容用,拿来当写入门禁会直接放行 legacy 值。
+        if (!string.Equals(body.Severity, S8SeverityCode.Follow, StringComparison.Ordinal)
+            && !string.Equals(body.Severity, S8SeverityCode.Serious, StringComparison.Ordinal))
+            throw new S8BizException(
+                "不支持的严重度:" + body.Severity + ";当前仅支持 "
+                + S8SeverityCode.Follow + " / " + S8SeverityCode.Serious);
+    }
+
     private static void ValidateParamsJsonByRuleType(string? ruleType, string paramsJson)
     {
         try

+ 6 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -25,6 +25,9 @@ public class S8WatchSchedulerService : ITransient
     private readonly S8NotificationLayerResolver _notificationLayerResolver;
     private readonly S8ImpactMetricsService _impactMetricsService;
     private readonly S8ManualReportService _manualReportService;
+    // S8-STEP6E:Watch 主链的业务建单边界(唯一最窄 seam)。仅 :1482 的自动建单走它;
+    // legacy debug 主链的 CreateFromWatchAsync 保持直连 _manualReportService,行为不变。
+    private readonly IS8ExceptionReportDispatcher _reportDispatcher;
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
     private readonly S8ShortageRuleEvaluator _shortageEvaluator;
     private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
@@ -64,6 +67,7 @@ public class S8WatchSchedulerService : ITransient
         S8NotificationLayerResolver notificationLayerResolver,
         S8ImpactMetricsService impactMetricsService,
         S8ManualReportService manualReportService,
+        IS8ExceptionReportDispatcher reportDispatcher,
         S8TimeoutRuleEvaluator timeoutEvaluator,
         S8ShortageRuleEvaluator shortageEvaluator,
         S8OutOfRangeRuleEvaluator outOfRangeEvaluator,
@@ -81,6 +85,7 @@ public class S8WatchSchedulerService : ITransient
         _notificationLayerResolver = notificationLayerResolver;
         _impactMetricsService = impactMetricsService;
         _manualReportService = manualReportService;
+        _reportDispatcher = reportDispatcher;
         _timeoutEvaluator = timeoutEvaluator;
         _shortageEvaluator = shortageEvaluator;
         _outOfRangeEvaluator = outOfRangeEvaluator;
@@ -1479,7 +1484,7 @@ public class S8WatchSchedulerService : ITransient
 
             try
             {
-                var entity = await _manualReportService.CreateFromHitAsync(tenantId, factoryId, hit);
+                var entity = await _reportDispatcher.CreateFromHitAsync(tenantId, factoryId, hit);
                 await _exceptionRep.Context.Updateable<AdoS8Exception>()
                     .SetColumns(x => new AdoS8Exception
                     {