Procházet zdrojové kódy

feat(s8): 增加代码规则的租户运行策略自动供给

Batch 1 之后规则定义在代码里,但新租户并不会因此拥有 ado_s8_watch_rule
上的运行策略行——现存 Rule 01 那一行是历史上人在页面手工建的。没有供给,
Batch 3 一旦封掉 Create API,新租户将永远拿不到任何规则。

本批建立 Catalog → Provisioning → DB 的系统内部写路径:

- 新增 S8RuleProvisioningService,提供全量对账 SyncAsync 与单租户
  SyncTenantAsync;租户有效性沿用本仓既有口径 SysTenant.Status = 1
  (与 ListEnabledScopesAsync / InventoryReconService / SourceDomainTenantResolver
  三处一致),不另造判定。
- 运行策略的逻辑唯一键是 (TenantId, RuleCode),不含 FactoryId。一个租户
  有 0 个、1 个还是 3 个工厂,都只产出一条 Rule 01 策略;历史上按工厂各建
  一条的行被视为同一身份,只刷新 Id 最小的一行,多余行上报不删。
- 新行只写定义投影与定义声明的参数默认值,enabled=false,params_json
  只含 B 类运行参数;13 个调度运行态列保持"尚未运行"初值,不伪造运行历史。
- 既有行只刷新定义投影,用 SetColumns 白名单写入;enabled / severity /
  poll / trigger / recover / params_json / factory_id 与全部运行态列
  物理上碰不到。params_json 不重写——旧 A+B 混装 JSON 的 A 类字段运行期
  已被忽略,重写整块反而可能误伤租户 B 类参数;G1 造成的 NULL 同样不补写。
- 孤儿规则(库里有、目录里没有)不删除、不改写、不启用,仅计数上报。
- 新增 POST /config/watch-rules/provision,只供给调用方自己的租户;
  全租户对账是启动期的系统级动作。返回分项计数而非只回 200。
- Startup.Configure 增加启动期对账,失败策略沿用同一方法内既有 sync 惯例
  (记录后放行、不阻断 S0-S7 启动),异常对象完整落日志。与之对照,规则目录
  自身构造失败仍然 fail-fast,因为那是代码包问题。

新行 factory_id 写 0:该列实测为 bigint NOT NULL,不能写 NULL;刻意不去
解析"租户唯一工厂",否则零工厂/多工厂租户会直接供给失败,也会把 Factory
重新拉回规则身份。已知过渡态:Batch 5 之前 ListEnabledScopesAsync 仍带
factory_id > 0,故这些行即使启用也不会被调度拾取——新供给的规则默认停用,
正式部署要等 Tenant-only 完成。

幂等性边界不夸大:本批只保证应用层顺序幂等。DB 上 UNIQUE(tenant_id, rule_code)
尚未建立(属 Tenant-only migration 批次),双实例并发供给理论上仍存在 race,
不引入分布式锁去假装解决。

测试打在纯决策函数 Plan 上,不接数据库;已做定向变异验证(覆盖租户参数、
身份混入 factory 两种回归各自能让对应用例变红)。

本批不含:Create/Delete/Wizard 退役、前端改动、Tenant-only 改造、
Dedup 与 Exception 改动、任何 DB migration。

chore: bump version server 1.0.490
YY968XX před 3 dny
rodič
revize
2d4e680812

+ 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.489</AssemblyVersion>
-    <FileVersion>1.0.489</FileVersion>
-    <Version>1.0.489</Version>
+    <AssemblyVersion>1.0.490</AssemblyVersion>
+    <FileVersion>1.0.490</FileVersion>
+    <Version>1.0.490</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 556 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleProvisioningIdempotencyTests.cs

@@ -0,0 +1,556 @@
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc;
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+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-RULE-GOVERNANCE-BATCH2:租户运行策略供给契约。
+///
+/// <para>本文件要证明的三条不变量:</para>
+/// <list type="number">
+///   <item><b>身份 = (TenantId, RuleCode)</b>。Factory 不参与 —— 0 / 1 / N 个工厂都只产出一条策略;</item>
+///   <item><b>租户隔离</b>。同一条规则定义在不同租户下是彼此独立的策略,改 A 不影响 B;</item>
+///   <item><b>不覆盖租户已调整的参数与运行态</b>。供给只纠正定义投影。</item>
+/// </list>
+///
+/// <para>测试打在纯决策函数 <c>S8RuleProvisioningService.Plan</c> 上,不接数据库 ——
+/// 这三条不变量必须能被逐字段断言,而不是"跑一遍看起来对"。</para>
+/// </summary>
+public class S8RuleProvisioningIdempotencyTests
+{
+    private const long TenantA = 838257186181189L;
+    private const long TenantB = 838257212780613L;
+    private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
+
+    private static readonly DateTime Now = new(2026, 9, 6, 14, 0, 0);
+
+    private static List<S8RuleDefinition> Definitions() =>
+        new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
+            .Definitions.ToList();
+
+    private static S8RuleProvisioningPlan Plan(
+        IReadOnlyCollection<long> tenants,
+        IReadOnlyCollection<AdoS8WatchRule> existing,
+        IReadOnlyCollection<S8RuleDefinition>? definitions = null) =>
+        S8RuleProvisioningService.Plan(tenants, definitions ?? Definitions(), existing, Now);
+
+    /// <summary>一条租户已经调整过、并且跑过一段时间的既有策略行。</summary>
+    private static AdoS8WatchRule TunedRow(long tenantId, long id = 1000, string ruleCode = Rule01) => new()
+    {
+        Id = id,
+        TenantId = tenantId,
+        FactoryId = 838257186320453L,          // 历史值,供给不得改写
+        RuleCode = ruleCode,
+
+        // 定义投影:与代码定义一致
+        DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode,
+        RuleType = "TIMEOUT",
+        SourceObjectType = S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType,
+        WatchObjectType = S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType,
+        SceneCode = "S4",
+        StageCode = "S4",
+        OrderFlowCode = "MATERIAL_PURCHASE",
+        RuleMechanism = "DATE",
+
+        // 租户调过的参数
+        Enabled = true,
+        Severity = S8SeverityCode.Follow,
+        PollIntervalSeconds = 900,
+        TriggerCountRequired = 3,
+        RecoverCountRequired = 5,
+        ParamsJson = """{"graceMinutes":60,"defaultOccurrenceDeptId":245}""",
+
+        // 运行态
+        NextRunAt = new DateTime(2026, 9, 6, 13, 50, 0),
+        LastRunAt = new DateTime(2026, 9, 6, 13, 40, 0),
+        LastStatus = "SUCCESS",
+        LastError = "prev-error",
+        LastDurationMs = 255,
+        LastRunId = "0da4435a127c47dd",
+        LockToken = "tok",
+        LockedBy = "host-1",
+        LockUntil = new DateTime(2026, 9, 6, 13, 45, 0),
+        RunningStartedAt = new DateTime(2026, 9, 6, 13, 40, 0),
+        ConsecutiveFailureCount = 2,
+        PausedUntil = new DateTime(2026, 9, 6, 12, 0, 0),
+        PauseReason = "MANUAL_PAUSED",
+        CreatedAt = new DateTime(2026, 9, 5, 11, 24, 43)
+    };
+
+    // ───────────────────────── T1 / T2:建行与幂等 ─────────────────────────
+
+    [Fact]
+    public void T1_MissingTenantRule_CreatesExactlyOne()
+    {
+        var plan = Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>());
+
+        var row = Assert.Single(plan.Inserts);
+        Assert.Equal(TenantA, row.TenantId);
+        Assert.Equal(Rule01, row.RuleCode);
+        Assert.Empty(plan.Updates);
+        Assert.Equal(0, plan.UnchangedCount);
+    }
+
+    [Fact]
+    public void T2_SecondAndThirdRun_CreateNothing()
+    {
+        var created = Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>()).Inserts;
+        created[0].Id = 1;   // 模拟落库后回填主键
+
+        var second = Plan(new[] { TenantA }, created);
+        Assert.Empty(second.Inserts);
+        Assert.Empty(second.Updates);
+        Assert.Equal(1, second.UnchangedCount);
+
+        var third = Plan(new[] { TenantA }, created);
+        Assert.Empty(third.Inserts);
+        Assert.Empty(third.Updates);
+    }
+
+    // ───────────────────────── T3 / T4 / T5:绝不覆盖租户侧 ─────────────────────────
+
+    [Fact]
+    public void T3_ExistingRuntimeParameters_AreUntouched()
+    {
+        var row = TunedRow(TenantA);
+        Plan(new[] { TenantA }, new[] { row });
+
+        Assert.Equal(900, row.PollIntervalSeconds);
+        Assert.Equal(3, row.TriggerCountRequired);
+        Assert.Equal(5, row.RecoverCountRequired);
+        Assert.Equal(S8SeverityCode.Follow, row.Severity);
+        Assert.Equal("""{"graceMinutes":60,"defaultOccurrenceDeptId":245}""", row.ParamsJson);
+    }
+
+    [Fact]
+    public void T4_ExistingEnabled_IsUntouched()
+    {
+        var enabled = TunedRow(TenantA);
+        Plan(new[] { TenantA }, new[] { enabled });
+        Assert.True(enabled.Enabled);
+
+        var disabled = TunedRow(TenantA);
+        disabled.Enabled = false;
+        Plan(new[] { TenantA }, new[] { disabled });
+        Assert.False(disabled.Enabled);
+    }
+
+    [Fact]
+    public void T5_ExistingRuntimeState_IsUntouched()
+    {
+        var row = TunedRow(TenantA);
+        Plan(new[] { TenantA }, new[] { row });
+
+        Assert.Equal(new DateTime(2026, 9, 6, 13, 50, 0), row.NextRunAt);
+        Assert.Equal(new DateTime(2026, 9, 6, 13, 40, 0), row.LastRunAt);
+        Assert.Equal("SUCCESS", row.LastStatus);
+        Assert.Equal("prev-error", row.LastError);
+        Assert.Equal(255, row.LastDurationMs);
+        Assert.Equal("0da4435a127c47dd", row.LastRunId);
+        Assert.Equal("tok", row.LockToken);
+        Assert.Equal("host-1", row.LockedBy);
+        Assert.Equal(new DateTime(2026, 9, 6, 13, 45, 0), row.LockUntil);
+        Assert.Equal(new DateTime(2026, 9, 6, 13, 40, 0), row.RunningStartedAt);
+        Assert.Equal(2, row.ConsecutiveFailureCount);
+        Assert.Equal(new DateTime(2026, 9, 6, 12, 0, 0), row.PausedUntil);
+        Assert.Equal("MANUAL_PAUSED", row.PauseReason);
+        Assert.Equal(838257186320453L, row.FactoryId);   // FactoryId 同样不得改写
+    }
+
+    // ───────────────────────── T6:投影漂移被纠正 ─────────────────────────
+
+    [Fact]
+    public void T6_WrongDefinitionProjection_IsRefreshed_WithoutTouchingTenantPolicy()
+    {
+        var row = TunedRow(TenantA);
+        row.DatasetCode = "WRONG_DATASET";
+        row.RuleType = "OUT_OF_RANGE";
+        row.SceneCode = "S1";
+        row.SourceObjectType = "EVIL_OBJECT";
+        row.WatchObjectType = "EVIL_OBJECT";
+        row.StageCode = null;
+        row.OrderFlowCode = null;
+        row.RuleMechanism = null;
+
+        var plan = Plan(new[] { TenantA }, new[] { row });
+
+        Assert.Same(row, Assert.Single(plan.Updates));
+        Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, row.DatasetCode);
+        Assert.Equal("TIMEOUT", row.RuleType);
+        Assert.Equal("S4", row.SceneCode);
+        Assert.Equal("S4", row.StageCode);
+        Assert.Equal("MATERIAL_PURCHASE", row.OrderFlowCode);
+        Assert.Equal("DATE", row.RuleMechanism);
+        Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, row.SourceObjectType);
+        Assert.Equal(S8PurchaseDeliveryRuleDefinitions.PurchaseOrderLineObjectType, row.WatchObjectType);
+
+        // 代码定义是权威,但它只管定义 —— 租户策略与运行态原封不动。
+        Assert.True(row.Enabled);
+        Assert.Equal(900, row.PollIntervalSeconds);
+        Assert.Equal(S8SeverityCode.Follow, row.Severity);
+        Assert.Equal("""{"graceMinutes":60,"defaultOccurrenceDeptId":245}""", row.ParamsJson);
+        Assert.Equal("SUCCESS", row.LastStatus);
+        Assert.Equal(838257186320453L, row.FactoryId);
+    }
+
+    // ───────────────────────── T7 / T8:ParamsJson 不重写 ─────────────────────────
+
+    [Fact]
+    public void T7_LegacyMixedParamsJson_IsNotRewritten()
+    {
+        const string legacy = """
+        {"dueAtField":"due_at","statusField":"status","completedStates":["COMPLETED"],
+         "objectIdField":"source_object_id","exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL",
+         "graceMinutes":30,"defaultOccurrenceDeptId":245}
+        """;
+        var row = TunedRow(TenantA);
+        row.ParamsJson = legacy;
+        row.DatasetCode = "WRONG";       // 同时触发一次投影刷新,证明刷新不会顺手动 params
+
+        Plan(new[] { TenantA }, new[] { row });
+
+        Assert.Equal(legacy, row.ParamsJson);
+    }
+
+    /// <summary>
+    /// G1 造成的历史状态:params_json = NULL。
+    /// 供给**不得**自动补写默认 JSON —— Batch 1 已证明 NULL + 代码定义可以正常运行,
+    /// 悄悄补写反而是在未经请求的情况下改变租户策略。
+    /// </summary>
+    [Fact]
+    public void T8_NullParamsJson_IsNotBackfilled()
+    {
+        var row = TunedRow(TenantA);
+        row.ParamsJson = null;
+        row.RuleType = "WRONG";          // 触发投影刷新
+
+        var plan = Plan(new[] { TenantA }, new[] { row });
+
+        Assert.Single(plan.Updates);
+        Assert.Null(row.ParamsJson);
+    }
+
+    // ───────────────────────── T9 / T10:租户隔离(本批核心) ─────────────────────────
+
+    [Fact]
+    public void T9_TwoTenants_GetTwoIndependentRows()
+    {
+        var plan = Plan(new[] { TenantA, TenantB }, Array.Empty<AdoS8WatchRule>());
+
+        Assert.Equal(2, plan.Inserts.Count);
+        Assert.Equal(new[] { TenantA, TenantB }, plan.Inserts.Select(r => r.TenantId).ToArray());
+        Assert.All(plan.Inserts, r => Assert.Equal(Rule01, r.RuleCode));
+
+        // 两行必须是不同实例:共享引用会让一个租户改参数波及另一个。
+        Assert.NotSame(plan.Inserts[0], plan.Inserts[1]);
+    }
+
+    [Fact]
+    public void T10_TenantParametersDoNotLeakAcrossTenants()
+    {
+        var a = TunedRow(TenantA, id: 1);
+        a.ParamsJson = """{"graceMinutes":60}""";
+        var b = TunedRow(TenantB, id: 2);
+        b.ParamsJson = """{"graceMinutes":0}""";
+        b.Enabled = false;
+        b.PollIntervalSeconds = 300;
+
+        Plan(new[] { TenantA, TenantB }, new[] { a, b });
+
+        Assert.Equal("""{"graceMinutes":60}""", a.ParamsJson);
+        Assert.Equal("""{"graceMinutes":0}""", b.ParamsJson);
+        Assert.True(a.Enabled);
+        Assert.False(b.Enabled);
+        Assert.Equal(900, a.PollIntervalSeconds);
+        Assert.Equal(300, b.PollIntervalSeconds);
+    }
+
+    [Fact]
+    public void T10_SyncingOneTenant_DoesNotTouchAnother()
+    {
+        var a = TunedRow(TenantA, id: 1);
+        var b = TunedRow(TenantB, id: 2);
+        b.DatasetCode = "WRONG_BUT_OUT_OF_SCOPE";
+
+        // 只对 A 做对账;B 的行即使投影是错的也不该进入方案。
+        var plan = Plan(new[] { TenantA }, new[] { a, b });
+
+        Assert.Empty(plan.Inserts);
+        Assert.Empty(plan.Updates);
+        Assert.Equal("WRONG_BUT_OUT_OF_SCOPE", b.DatasetCode);
+    }
+
+    // ───────────────────────── T11 / T12:Factory 与身份无关 ─────────────────────────
+
+    /// <summary>
+    /// 防回归:多工厂租户绝不能产出多条策略。
+    /// 这是最容易被后来者"顺手改回去"的地方 —— 一旦有人把 factory 加进身份,本用例立刻失败。
+    /// </summary>
+    [Theory]
+    [InlineData(0)]     // 零工厂租户
+    [InlineData(1)]     // 单工厂租户
+    [InlineData(3)]     // 多工厂租户
+    public void T11_T12_TenantAlwaysGetsExactlyOnePolicyPerRule_RegardlessOfFactoryCount(int factoryCount)
+    {
+        // 工厂数量对 Plan 而言根本不是输入 —— 这正是要断言的事实:
+        // 供给的输入只有租户与定义,工厂无从参与。
+        _ = factoryCount;
+
+        var plan = Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>());
+
+        var row = Assert.Single(plan.Inserts);
+        Assert.Equal(S8RuleProvisioningService.CompatibilityFactoryId, row.FactoryId);
+    }
+
+    /// <summary>
+    /// 历史上按工厂各建了一条 Rule 01(同 tenant + 同 rule_code,不同 factory_id)。
+    /// 供给必须把它们视为**同一个身份**:只刷新 Id 最小的一行、不再新建、多余行上报但不删。
+    /// </summary>
+    [Fact]
+    public void T11_PerFactoryLegacyRows_AreTreatedAsOneIdentity()
+    {
+        var f1 = TunedRow(TenantA, id: 10); f1.FactoryId = 111; f1.DatasetCode = "WRONG";
+        var f2 = TunedRow(TenantA, id: 20); f2.FactoryId = 222; f2.DatasetCode = "WRONG";
+        var f3 = TunedRow(TenantA, id: 30); f3.FactoryId = 333; f3.DatasetCode = "WRONG";
+
+        var plan = Plan(new[] { TenantA }, new[] { f2, f3, f1 });
+
+        Assert.Empty(plan.Inserts);                       // 不因"工厂缺行"而新建
+        Assert.Same(f1, Assert.Single(plan.Updates));     // 只刷新 Id 最小的一行
+        Assert.Equal(2, plan.DuplicateCount);             // 其余上报
+        Assert.Equal("WRONG", f2.DatasetCode);            // 不动
+        Assert.Equal("WRONG", f3.DatasetCode);
+        Assert.Equal(111, f1.FactoryId);                  // 既有 FactoryId 保持
+    }
+
+    [Fact]
+    public void T12_NewRowUsesCompatibilityFactoryId_NotAResolvedFactory()
+    {
+        var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>()).Inserts);
+
+        // 0 = 无工厂作用域的兼容 metadata。绝不是"解析出来的唯一工厂"——
+        // 那样零工厂 / 多工厂租户会直接供给失败。
+        Assert.Equal(0L, row.FactoryId);
+    }
+
+    // ───────────────────────── T13 / T14:停用租户与孤儿 ─────────────────────────
+
+    /// <summary>
+    /// 停用租户不进入租户列表(<c>SysTenant.Status = 1</c> 过滤发生在 <c>SyncAsync</c>),
+    /// 因此不会产生新行。本用例从 Plan 层确认:不在列表里 = 不产出。
+    /// </summary>
+    [Fact]
+    public void T13_TenantNotInActiveList_GetsNoNewRow()
+    {
+        var plan = Plan(Array.Empty<long>(), Array.Empty<AdoS8WatchRule>());
+        Assert.Empty(plan.Inserts);
+        Assert.Empty(plan.Updates);
+    }
+
+    [Fact]
+    public void T13_ExistingRowOfInactiveTenant_IsNotDeletedNorDisabled()
+    {
+        var inactive = TunedRow(TenantB, id: 77);
+        // 只对 A 对账(B 停用故不在列表)。B 的行必须原封不动。
+        Plan(new[] { TenantA }, new[] { inactive });
+
+        Assert.True(inactive.Enabled);
+        Assert.Equal(S8SeverityCode.Follow, inactive.Severity);
+    }
+
+    [Fact]
+    public void T14_OrphanRule_IsReportedButNeverDeletedOrChanged()
+    {
+        var orphan = TunedRow(TenantA, id: 55, ruleCode: "RULE_THAT_NO_LONGER_EXISTS");
+        orphan.DatasetCode = "SOME_OLD_DATASET";
+
+        var plan = Plan(new[] { TenantA }, new[] { orphan });
+
+        Assert.Equal(1, plan.OrphanedCount);
+        Assert.DoesNotContain(orphan, plan.Updates);
+        Assert.Equal("SOME_OLD_DATASET", orphan.DatasetCode);
+        Assert.True(orphan.Enabled);
+
+        // 孤儿不阻碍正常供给:Rule 01 仍会被建出来。
+        Assert.Equal(Rule01, Assert.Single(plan.Inserts).RuleCode);
+    }
+
+    // ───────────────────────── T15:新增定义自动铺开 ─────────────────────────
+
+    [Fact]
+    public void T15_NewDefinition_IsProvisionedToEveryExistingTenant()
+    {
+        var definitions = Definitions();
+        definitions.Add(FakeRule02());
+
+        var existingA = TunedRow(TenantA, id: 1);
+        var existingB = TunedRow(TenantB, id: 2);
+
+        var plan = Plan(new[] { TenantA, TenantB }, new[] { existingA, existingB }, definitions);
+
+        Assert.Equal(2, plan.Inserts.Count);
+        Assert.All(plan.Inserts, r => Assert.Equal("RULE_S4_FAKE_FOR_TEST", r.RuleCode));
+        Assert.Equal(new[] { TenantA, TenantB }, plan.Inserts.Select(r => r.TenantId).ToArray());
+
+        // 既有 Rule 01 不受影响。
+        Assert.Equal(2, plan.UnchangedCount);
+    }
+
+    // ───────────────────────── 新行契约 ─────────────────────────
+
+    [Fact]
+    public void NewRow_UsesDefinitionDefaults_AndNoFakedRunHistory()
+    {
+        var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>()).Inserts);
+
+        // 定义投影
+        Assert.Equal(S8BusinessDatasetDefinitions.PurchaseDeliveryCode, row.DatasetCode);
+        Assert.Equal("TIMEOUT", row.RuleType);
+        Assert.Equal("DATE", row.RuleMechanism);
+        Assert.Equal("S4", row.SceneCode);
+        Assert.Equal("S4", row.StageCode);
+        Assert.Equal("MATERIAL_PURCHASE", row.OrderFlowCode);
+
+        // 运行参数默认值来自定义策略
+        Assert.False(row.Enabled);
+        Assert.Equal(S8SeverityCode.Serious, row.Severity);
+        Assert.Equal(300, row.PollIntervalSeconds);
+        Assert.Equal(1, row.TriggerCountRequired);
+        Assert.Equal(2, row.RecoverCountRequired);
+        Assert.Equal("""{"graceMinutes":0}""", row.ParamsJson);
+
+        // 运行态:一条尚未运行的规则该有的样子,不伪造历史
+        Assert.Null(row.NextRunAt);
+        Assert.Null(row.LastRunAt);
+        Assert.Null(row.LastStatus);
+        Assert.Null(row.LastError);
+        Assert.Null(row.LastDurationMs);
+        Assert.Null(row.LastRunId);
+        Assert.Null(row.LockToken);
+        Assert.Null(row.LockedBy);
+        Assert.Null(row.LockUntil);
+        Assert.Null(row.RunningStartedAt);
+        Assert.Equal(0, row.ConsecutiveFailureCount);
+        Assert.Null(row.PausedUntil);
+        Assert.Null(row.PauseReason);
+        Assert.Null(row.UpdatedAt);
+        Assert.Equal(0, row.Id);
+    }
+
+    /// <summary>新行的 params_json 只能是 B 类运行参数,绝不能带任何判定语义。</summary>
+    [Fact]
+    public void NewRow_ParamsJson_CarriesNoJudgementSemantics()
+    {
+        var row = Assert.Single(Plan(new[] { TenantA }, Array.Empty<AdoS8WatchRule>()).Inserts);
+
+        foreach (var forbidden in new[]
+                 { "dueAtField", "statusField", "completedStates", "objectIdField", "objectCodeField", "exceptionTypeCode" })
+            Assert.DoesNotContain(forbidden, row.ParamsJson!, StringComparison.Ordinal);
+    }
+
+    // ───────────────────────── T16 / T17 / T18:端点 · 注册 · 依赖方向 ─────────────────────────
+
+    [Fact]
+    public void T16_ProvisionEndpoint_IsPost_Scoped_AndReturnsObservableCounters()
+    {
+        var action = Assert.Single(typeof(AdoS8ConfigWatchRulesController)
+            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+            .Where(m => m.Name == "ProvisionAsync"));
+
+        Assert.Equal("provision", Assert.Single(action.GetCustomAttributes<HttpPostAttribute>()).Template);
+        Assert.NotEmpty(action.GetCustomAttributes()
+            .Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
+
+        // 结果必须可观测:只回 200 会让运维不知道新规则有没有铺开。
+        foreach (var counter in new[]
+                 { "TenantCount", "DefinitionCount", "CreatedCount", "RefreshedCount", "UnchangedCount", "OrphanedCount" })
+            Assert.NotNull(typeof(S8RuleProvisioningResult).GetProperty(counter));
+    }
+
+    [Fact]
+    public void T17_Service_ExposesBothFullAndPerTenantSync()
+    {
+        var t = typeof(S8RuleProvisioningService);
+        Assert.NotNull(t.GetMethod("SyncAsync"));
+        Assert.NotNull(t.GetMethod("SyncTenantAsync"));
+    }
+
+    /// <summary>
+    /// T18 + 依赖方向守卫:供给**不得**架在即将退役的业务建规则路径之上。
+    /// Batch 3 会删掉 <c>CreateAsync</c>;如果这里依赖它,那一批会连带炸掉供给。
+    /// </summary>
+    [Fact]
+    public void T18_ProvisioningDoesNotDependOnLegacyCreatePath()
+    {
+        var deps = Assert.Single(typeof(S8RuleProvisioningService).GetConstructors())
+            .GetParameters().Select(p => p.ParameterType.Name).ToArray();
+
+        Assert.DoesNotContain(deps, d => d.Contains("S8WatchRuleService", StringComparison.Ordinal));
+        Assert.DoesNotContain(deps, d => d.Contains("S8ConfigDraftService", StringComparison.Ordinal));
+
+        // 正向:依赖方向必须是 Catalog → Provisioning → DB。
+        Assert.Contains(deps, d => d.Contains("IS8RuleCatalog", StringComparison.Ordinal));
+    }
+
+    /// <summary>
+    /// Factory 守卫:身份判定不得含 FactoryId。用源码扫描而非行为断言 ——
+    /// 谓词写没写在方法体里,反射看不到。
+    /// </summary>
+    [Fact]
+    public void FactoryGuard_IdentityPredicateNeverIncludesFactory()
+    {
+        var code = SourceOf("Service/S8/S8RuleProvisioningService.cs");
+
+        // 存在性查询只按 tenant_id 过滤
+        Assert.Contains("Where(x => tenantIds.Contains(x.TenantId))", code);
+
+        // 身份分组键只有 (TenantId, RuleCode)
+        Assert.Contains("GroupBy(r => (r.TenantId, Code: r.RuleCode ?? string.Empty)", code);
+
+        // 可执行代码里不得出现按工厂比较的身份谓词
+        foreach (var forbidden in new[] { "x.FactoryId == ", "r.FactoryId == ", "FactoryId == scope.FactoryId" })
+            Assert.DoesNotContain(forbidden, ExecutableLines(code));
+    }
+
+    private static S8RuleDefinition FakeRule02() => new()
+    {
+        RuleCode = "RULE_S4_FAKE_FOR_TEST",
+        DisplayName = "测试用第二条规则",
+        Description = "验证新增定义会自动铺开到每个既有租户",
+        DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode,
+        RuleType = "TIMEOUT",
+        RuleMechanism = "DATE",
+        SourceObjectType = "PURCHASE_ORDER_LINE",
+        SceneCode = "S4",
+        StageCode = "S4",
+        ExceptionTypeCode = "PURCHASE_DELIVERY_ABNORMAL",
+        Timeout = new S8TimeoutSemantics()
+    };
+
+    private static string SourceOf(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 File.ReadAllText(full);
+    }
+
+    /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
+    private static string ExecutableLines(string code) =>
+        string.Join('\n', code.Split('\n')
+            .Select(l => l.TrimStart())
+            .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
+                        && !l.StartsWith("//", StringComparison.Ordinal)));
+}

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

@@ -189,6 +189,28 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
     }
 
+    /// <summary>
+    /// S8-RULE-GOVERNANCE-BATCH2:为**当前租户**补齐代码定义规则的运行策略行。
+    ///
+    /// <para>用途:研发发布了一条新规则之后,管理员无需重启即可让本租户立刻拿到对应的运行策略
+    /// (默认停用)。幂等 —— 重复调用不会重复建行。</para>
+    ///
+    /// <para><b>只处理调用方自己的租户</b>:全租户对账是启动期的系统级动作。
+    /// 若在这里放开全量,一个租户管理员就能对全平台其他租户写入运行策略行 ——
+    /// 那与 S8 其余接口"作用域由服务端从登录身份盖章"的口径直接矛盾。</para>
+    /// </summary>
+    [HttpPost("provision")]
+    [S8Permission(S8PermissionCatalog.ConfigWatchRule)]
+    public async Task<IActionResult> ProvisionAsync([FromServices] S8RuleProvisioningService provisioning)
+    {
+        try
+        {
+            var scope = await _scope.ResolveAsync();
+            return Ok(await provisioning.SyncTenantAsync(scope.TenantId));
+        }
+        catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); }
+    }
+
     /// <summary>
     /// 停用规则。只写 enabled;幂等。
     /// <b>刻意不过 Enable Gate</b>:数据集出问题之后仍然必须能把规则关掉。

+ 379 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8RuleProvisioningService.cs

@@ -0,0 +1,379 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8-RULE-GOVERNANCE-BATCH2:把**代码定义的规则**投射成**每租户一条运行策略**。
+///
+/// <para><b>解决什么</b>:Batch 1 之后规则定义在代码里,但一个新租户并不会因此拥有
+/// <c>ado_s8_watch_rule</c> 上的运行策略行 —— 现存的 Rule 01 那一行是历史上人在页面里手工建的。
+/// 没有本服务,Batch 3 一旦封掉 Create API,新租户将永远拿不到任何规则。</para>
+///
+/// <para><b>身份口径(本批最关键的一条)</b>:运行策略的逻辑唯一键是
+/// <c>(TenantId, RuleCode)</c>,<b>不含 FactoryId</b>。
+/// 一个租户有 0 个、1 个还是 3 个工厂,都只应有**一条** Rule 01 运行策略。
+/// 「Tenant → Factory → Rule」这个形状一旦写回来,规则实例数会随工厂数膨胀,
+/// 而 Tenant-only 改造(Batch 5/6)之后又要再清理一次。</para>
+///
+/// <para><b>刻意不复用 <see cref="S8WatchRuleService.CreateAsync"/></b>:那是业务自助建规则的旧路径,
+/// Batch 3 就要退役。Provisioning 是系统内部写路径,依赖方向必须是
+/// <c>Catalog → Provisioning → DB</c>,而不是架在一条即将删除的 API 之上。</para>
+///
+/// <para><b>幂等性边界(不得夸大)</b>:本批只保证**应用层顺序幂等** ——
+/// 同一进程内重复执行不会重复建行。DB 上 <c>UNIQUE(tenant_id, rule_code)</c> 尚未建立
+/// (属 Tenant-only migration 批次),因此两个实例同时 provisioning 理论上仍存在 race。
+/// 该缺口由后续唯一索引最终封死,本批不引入分布式锁去假装解决它。</para>
+/// </summary>
+public class S8RuleProvisioningService : ITransient
+{
+    /// <summary>
+    /// 新行的 <c>factory_id</c> 兼容值。
+    ///
+    /// <para>该列在库中是 <c>bigint NOT NULL</c>(实测 information_schema),因此不能写 NULL;
+    /// 取 0 表示「无工厂作用域」。<b>刻意不去解析租户的唯一工厂</b>:那会让
+    /// 零工厂 / 多工厂租户直接 provisioning 失败,也会把 Factory 重新拉回规则身份。
+    /// 0 只是过渡期的兼容 metadata,不参与任何判定。</para>
+    ///
+    /// <para>已知过渡态:Batch 5 之前,<c>ListEnabledScopesAsync</c> 仍带
+    /// <c>AND r.factory_id &gt; 0</c>,因此 factory_id=0 的新行即使被启用也不会被调度拾取。
+    /// 这是可接受的 —— 新 provision 的规则默认停用,正式部署要等 Tenant-only 完成。
+    /// 不得为了让过渡期能启用而把工厂作用域写回 Provisioning。</para>
+    /// </summary>
+    internal const long CompatibilityFactoryId = 0L;
+
+    private readonly SqlSugarRepository<AdoS8WatchRule> _rep;
+    private readonly IS8RuleCatalog _catalog;
+    private readonly ILogger<S8RuleProvisioningService> _logger;
+
+    public S8RuleProvisioningService(
+        SqlSugarRepository<AdoS8WatchRule> rep,
+        IS8RuleCatalog catalog,
+        ILogger<S8RuleProvisioningService> logger)
+    {
+        _rep = rep;
+        _catalog = catalog;
+        _logger = logger;
+    }
+
+    /// <summary>全量对账:所有启用租户 × 所有代码定义。启动期与运维触发使用。</summary>
+    public async Task<S8RuleProvisioningResult> SyncAsync(CancellationToken cancellationToken = default)
+    {
+        // Status = 1(StatusEnum.Enable)是本仓判定"租户有效"的既有口径:
+        // S8WatchSchedulerService.ListEnabledScopesAsync、InventoryReconService、
+        // SourceDomainTenantResolver 三处都是 `SysTenant t ... AND t.Status = 1`。
+        // 这里沿用同一口径,不另造一套。
+        var tenantIds = await _rep.Context.Queryable<SysTenant>()
+            .Where(t => t.Status == StatusEnum.Enable)
+            .Select(t => t.Id)
+            .ToListAsync(cancellationToken);
+
+        return await SyncTenantsAsync(tenantIds, cancellationToken);
+    }
+
+    /// <summary>
+    /// 单租户对账。供新租户初始化 / 管理端手动补齐 / 测试使用。
+    /// <b>只处理该租户</b>,不会连带修改其他租户的任何一行。
+    /// </summary>
+    public async Task<S8RuleProvisioningResult> SyncTenantAsync(long tenantId, CancellationToken cancellationToken = default)
+    {
+        if (tenantId <= 0) throw new S8BizException("租户作用域非法");
+
+        var active = await _rep.Context.Queryable<SysTenant>()
+            .Where(t => t.Id == tenantId && t.Status == StatusEnum.Enable)
+            .AnyAsync(cancellationToken);
+        if (!active)
+            throw new S8BizException("租户不存在或已停用,不予供给监控规则运行策略");
+
+        return await SyncTenantsAsync(new List<long> { tenantId }, cancellationToken);
+    }
+
+    private async Task<S8RuleProvisioningResult> SyncTenantsAsync(
+        List<long> tenantIds, CancellationToken cancellationToken)
+    {
+        var definitions = _catalog.Definitions.ToList();
+        var result = new S8RuleProvisioningResult
+        {
+            TenantCount = tenantIds.Count,
+            DefinitionCount = definitions.Count
+        };
+
+        if (tenantIds.Count == 0 || definitions.Count == 0)
+        {
+            _logger.LogInformation(
+                "s8_rule_provisioning_skipped tenants={Tenants} definitions={Definitions}",
+                tenantIds.Count, definitions.Count);
+            return result;
+        }
+
+        // 一次性取回这些租户的全部规则行:既用于存在性判断,也用于孤儿与重复统计。
+        // **查询谓词只有 tenant_id**,绝不含 factory_id —— 那正是本批要杜绝的身份形状。
+        var existingRows = await _rep.AsQueryable()
+            .Where(x => tenantIds.Contains(x.TenantId))
+            .ToListAsync(cancellationToken);
+
+        var plan = Plan(tenantIds, definitions, existingRows, DateTime.Now);
+
+        result.UnchangedCount = plan.UnchangedCount;
+        result.OrphanedCount = plan.OrphanedCount;
+        result.DuplicateCount = plan.DuplicateCount;
+
+        foreach (var row in plan.Inserts)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            row.Id = await _rep.AsInsertable(row).ExecuteReturnBigIdentityAsync();
+            result.CreatedCount++;
+            _logger.LogInformation(
+                "s8_rule_provisioned tenant={Tenant} rule={Rule} id={Id}", row.TenantId, row.RuleCode, row.Id);
+        }
+
+        foreach (var row in plan.Updates)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+
+            // 白名单更新:物理上只可能写到 Definition 投影列。
+            // 租户参数(enabled / severity / poll / trigger / recover / params_json)
+            // 与全部 13 个调度运行态列都不在 SetColumns 里,因此不可能被覆盖。
+            await _rep.Context.Updateable<AdoS8WatchRule>()
+                .SetColumns(x => new AdoS8WatchRule
+                {
+                    DatasetCode = row.DatasetCode,
+                    RuleType = row.RuleType,
+                    SourceObjectType = row.SourceObjectType,
+                    WatchObjectType = row.WatchObjectType,
+                    SceneCode = row.SceneCode,
+                    StageCode = row.StageCode,
+                    OrderFlowCode = row.OrderFlowCode,
+                    RuleMechanism = row.RuleMechanism,
+                    UpdatedAt = DateTime.Now
+                })
+                .Where(x => x.Id == row.Id)
+                .ExecuteCommandAsync(cancellationToken);
+
+            result.RefreshedCount++;
+            _logger.LogInformation(
+                "s8_rule_projection_refreshed tenant={Tenant} rule={Rule} id={Id}",
+                row.TenantId, row.RuleCode, row.Id);
+        }
+
+        if (result.OrphanedCount > 0)
+            _logger.LogWarning(
+                "s8_rule_orphaned count={Count};这些规则在当前代码版本中没有定义,保持原样不删除、不启用",
+                result.OrphanedCount);
+
+        if (result.DuplicateCount > 0)
+            _logger.LogWarning(
+                "s8_rule_duplicate_identity count={Count};同 (tenant, rule_code) 存在多行,"
+                + "在 UNIQUE(tenant_id, rule_code) 建立前只刷新 Id 最小的一行",
+                result.DuplicateCount);
+
+        _logger.LogInformation(
+            "s8_rule_provisioning_done tenants={Tenants} definitions={Definitions} created={Created} refreshed={Refreshed} unchanged={Unchanged} orphaned={Orphaned} duplicate={Duplicate}",
+            result.TenantCount, result.DefinitionCount, result.CreatedCount,
+            result.RefreshedCount, result.UnchangedCount, result.OrphanedCount, result.DuplicateCount);
+
+        return result;
+    }
+
+    // ================================================================================
+    // 纯决策核心
+    //
+    // 抽成 static 是为了让「租户隔离」「工厂无关」「不覆盖租户参数」这三条不变量
+    // 能在**不接数据库**的情况下被逐字段断言。Batch 1 的 ApplyParameters 是同一动机:
+    // 藏在必须有仓储才能跑的方法里的规则,实际上没人验证过。
+    // ================================================================================
+
+    /// <summary>
+    /// 计算供给方案。<paramref name="existingRows"/> 应为这些租户的**全部**规则行。
+    /// </summary>
+    internal static S8RuleProvisioningPlan Plan(
+        IReadOnlyCollection<long> tenantIds,
+        IReadOnlyCollection<S8RuleDefinition> definitions,
+        IReadOnlyCollection<AdoS8WatchRule> existingRows,
+        DateTime now)
+    {
+        var plan = new S8RuleProvisioningPlan();
+        var definedCodes = definitions
+            .Select(d => d.RuleCode)
+            .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+        // 身份 = (TenantId, RuleCode)。**没有 FactoryId。**
+        var byIdentity = existingRows
+            .GroupBy(r => (r.TenantId, Code: r.RuleCode ?? string.Empty), TenantRuleComparer.Instance)
+            .ToDictionary(g => g.Key, g => g.OrderBy(r => r.Id).ToList(), TenantRuleComparer.Instance);
+
+        foreach (var tenantId in tenantIds)
+        {
+            foreach (var definition in definitions)
+            {
+                var key = (tenantId, Code: definition.RuleCode);
+                if (!byIdentity.TryGetValue(key, out var rows) || rows.Count == 0)
+                {
+                    plan.Inserts.Add(BuildNewRow(tenantId, definition, now));
+                    continue;
+                }
+
+                // 同身份多行是历史遗留(曾按工厂各建一条)。在唯一索引建立前,
+                // 只把 Id 最小的一行当作 canonical 刷新,其余计数上报、不动、不删。
+                if (rows.Count > 1) plan.DuplicateCount += rows.Count - 1;
+
+                var canonical = rows[0];
+                if (ApplyProjection(canonical, definition))
+                    plan.Updates.Add(canonical);
+                else
+                    plan.UnchangedCount++;
+            }
+        }
+
+        // 孤儿:库里有、目录里没有。不删、不改、不启用。
+        plan.OrphanedCount = existingRows.Count(r => !definedCodes.Contains(r.RuleCode ?? string.Empty));
+
+        return plan;
+    }
+
+    /// <summary>
+    /// 构造新租户运行策略行。
+    /// <b>只写定义投影 + 运行参数默认值</b>;运行态列全部保持实体的"尚未运行"初值,
+    /// 不伪造 last_run_at / last_status 之类的运行历史。
+    /// </summary>
+    internal static AdoS8WatchRule BuildNewRow(long tenantId, S8RuleDefinition definition, DateTime now)
+    {
+        var policy = definition.Parameters ?? new S8RuleParameterPolicy();
+
+        var row = new AdoS8WatchRule
+        {
+            Id = 0,
+            TenantId = tenantId,
+            FactoryId = CompatibilityFactoryId,
+
+            // ── Definition 投影 ──
+            RuleCode = definition.RuleCode,
+            DatasetCode = definition.DatasetCode,
+            RuleType = definition.RuleType,
+            SourceObjectType = definition.SourceObjectType,
+            WatchObjectType = definition.SourceObjectType,
+            SceneCode = definition.SceneCode,
+            StageCode = definition.StageCode,
+            OrderFlowCode = definition.OrderFlowCode,
+            RuleMechanism = definition.RuleMechanism,
+
+            // ── 运行参数默认值(全部来自定义声明的策略,不写死在这里)──
+            Enabled = false,
+            Severity = policy.SeverityDefault,
+            PollIntervalSeconds = policy.PollIntervalSecondsDefault,
+            TriggerCountRequired = policy.TriggerCountRequiredDefault,
+            RecoverCountRequired = policy.RecoverCountRequiredDefault,
+
+            CreatedAt = now,
+            UpdatedAt = null
+        };
+
+        // params_json 只承载 B 类运行参数;新行不写任何判定语义。
+        row.ParamsJson = new S8RuleRuntimeParameters
+        {
+            GraceMinutes = policy.GraceMinutesDefault
+        }.ToParamsJson();
+
+        return row;
+    }
+
+    /// <summary>
+    /// 把定义投影刷到既有行上。<b>只碰投影列</b>;返回是否确有变化。
+    ///
+    /// <para>刻意<b>不碰</b>:<c>Enabled</c> / <c>Severity</c> / <c>PollIntervalSeconds</c> /
+    /// <c>TriggerCountRequired</c> / <c>RecoverCountRequired</c> / <c>ParamsJson</c> /
+    /// <c>FactoryId</c> / 全部运行态列。把租户调过的参数刷回默认值是本服务最大的风险,
+    /// 这条边界必须在类型层面而不是靠自觉守住。</para>
+    ///
+    /// <para><c>ParamsJson</c> 尤其不能重写:既有行可能仍是旧的 A+B 混装 JSON,
+    /// 而 Batch 1 已保证 A 类字段在运行期被忽略;重写整块反而可能误伤租户的 B 类参数。
+    /// 同理,<c>ParamsJson = NULL</c>(G1 造成的历史状态)也不自动补写 ——
+    /// 定义在代码里,NULL 时运行参数会正常回落默认值。</para>
+    /// </summary>
+    internal static bool ApplyProjection(AdoS8WatchRule row, S8RuleDefinition definition)
+    {
+        var changed = false;
+
+        void SetString(Func<AdoS8WatchRule, string?> get, Action<string?> set, string? next)
+        {
+            if (string.Equals(get(row), next, StringComparison.Ordinal)) return;
+            set(next);
+            changed = true;
+        }
+
+        SetString(r => r.DatasetCode, v => row.DatasetCode = v, definition.DatasetCode);
+        SetString(r => r.RuleType, v => row.RuleType = v, definition.RuleType);
+        SetString(r => r.SourceObjectType, v => row.SourceObjectType = v, definition.SourceObjectType);
+        // WatchObjectType 是 SourceObjectType 的 legacy 同义列。它同样是定义派生的,
+        // 留两份互相矛盾的"这条规则看什么对象"正是本批要消除的漂移。
+        SetString(r => r.WatchObjectType, v => row.WatchObjectType = v ?? string.Empty, definition.SourceObjectType);
+        SetString(r => r.SceneCode, v => row.SceneCode = v ?? string.Empty, definition.SceneCode);
+        SetString(r => r.StageCode, v => row.StageCode = v, definition.StageCode);
+        SetString(r => r.OrderFlowCode, v => row.OrderFlowCode = v, definition.OrderFlowCode);
+        SetString(r => r.RuleMechanism, v => row.RuleMechanism = v, definition.RuleMechanism);
+
+        return changed;
+    }
+
+    /// <summary>(TenantId, RuleCode) 身份比较器。RuleCode 按不敏感比较,与 Catalog 查找一致。</summary>
+    private sealed class TenantRuleComparer : IEqualityComparer<(long TenantId, string Code)>
+    {
+        public static readonly TenantRuleComparer Instance = new();
+
+        public bool Equals((long TenantId, string Code) x, (long TenantId, string Code) y) =>
+            x.TenantId == y.TenantId && string.Equals(x.Code, y.Code, StringComparison.OrdinalIgnoreCase);
+
+        public int GetHashCode((long TenantId, string Code) obj) =>
+            HashCode.Combine(obj.TenantId, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Code ?? string.Empty));
+    }
+}
+
+/// <summary>供给方案(纯数据,不含任何 DB 访问)。</summary>
+internal sealed class S8RuleProvisioningPlan
+{
+    /// <summary>待新建的运行策略行。</summary>
+    public List<AdoS8WatchRule> Inserts { get; } = new();
+
+    /// <summary>待刷新投影的既有行(投影列已就地更新)。</summary>
+    public List<AdoS8WatchRule> Updates { get; } = new();
+
+    /// <summary>投影已一致、无需写入的数量。</summary>
+    public int UnchangedCount { get; set; }
+
+    /// <summary>库中存在但当前代码版本没有定义的规则行数。</summary>
+    public int OrphanedCount { get; set; }
+
+    /// <summary>同 (tenant, rule_code) 的多余行数(唯一索引建立前的历史遗留)。</summary>
+    public int DuplicateCount { get; set; }
+}
+
+/// <summary>
+/// 供给结果。刻意给出分项计数而不是只回 200 ——
+/// 调用方必须能知道「这次到底做了什么」,否则运维无法判断新规则是否已经铺开。
+/// </summary>
+public sealed class S8RuleProvisioningResult
+{
+    /// <summary>本次处理的租户数。</summary>
+    public int TenantCount { get; set; }
+
+    /// <summary>当前代码版本中的规则定义数。</summary>
+    public int DefinitionCount { get; set; }
+
+    /// <summary>新建的运行策略行数。</summary>
+    public int CreatedCount { get; set; }
+
+    /// <summary>投影被纠正的行数。</summary>
+    public int RefreshedCount { get; set; }
+
+    /// <summary>投影已一致、未写入的行数。</summary>
+    public int UnchangedCount { get; set; }
+
+    /// <summary>无代码定义的孤儿行数(保持原样)。</summary>
+    public int OrphanedCount { get; set; }
+
+    /// <summary>同 (tenant, rule_code) 的多余行数(未删除,仅上报)。</summary>
+    public int DuplicateCount { get; set; }
+}

+ 29 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -89,6 +89,10 @@ public class Startup : AppStartup
         // Singleton + Configure 里强制解析一次 = 定义写错就起不来,见下方 Configure。
         services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8PurchaseDeliveryRuleDefinitions>();
         services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleCatalog, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8RuleCatalog>();
+
+        // S8-RULE-GOVERNANCE-BATCH2:代码定义 → 每租户运行策略的供给服务。
+        // ITransient 在本仓不做按接口自动注册,且它没有接口,故显式登记具体类型。
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.S8RuleProvisioningService>();
     }
 
     /// <summary>
@@ -115,6 +119,31 @@ public class Startup : AppStartup
             Trace.TraceWarning("Ai-DOP AidopMenuLinkSync: " + ex);
         }
 
+        // S8-RULE-GOVERNANCE-BATCH2:启动期对账 —— 确保每个启用租户都拥有全部代码定义规则的运行策略行。
+        //
+        // 失败策略沿用本方法内既有的 sync 惯例(AidopMenuLinkSync 等):**记录后放行,不阻断启动**。
+        // 理由是这段 Configure 与 S0–S7 共用同一个宿主,供给失败不该把整个平台拖下线;
+        // 且新 provision 的规则一律 disabled,缺行的后果是"该租户暂时看不到这条规则",
+        // 不是数据错误。异常对象完整落日志,不做静默吞掉 —— 「以为同步成功」比同步失败更危险。
+        //
+        // 与之对照:规则目录自身构造失败(重复定义 / 定义写错)**不在此列**,
+        // 它在上方 GetRequiredService 处直接抛出、阻断启动,因为那是代码包本身有问题。
+        try
+        {
+            using var provisioningScope = app.ApplicationServices.CreateScope();
+            var provisioning = provisioningScope.ServiceProvider
+                .GetRequiredService<Admin.NET.Plugin.AiDOP.Service.S8.S8RuleProvisioningService>();
+            var summary = provisioning.SyncAsync().GetAwaiter().GetResult();
+            Trace.TraceInformation(
+                $"Ai-DOP S8RuleProvisioning: tenants={summary.TenantCount} definitions={summary.DefinitionCount} "
+                + $"created={summary.CreatedCount} refreshed={summary.RefreshedCount} unchanged={summary.UnchangedCount} "
+                + $"orphaned={summary.OrphanedCount} duplicate={summary.DuplicateCount}");
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceError("Ai-DOP S8RuleProvisioning FAILED(本次启动未完成规则运行策略对账): " + ex);
+        }
+
         try
         {
             using var scopeS4 = app.ApplicationServices.CreateScope();