Переглянути джерело

fix(s8): add scheduler runtime schema foundation

Add columns + index foundation for S8 watch scheduler runtime, plus
detection state table for pre-creation hit/miss debouncing. No service
or job logic wired up in this commit; only schema + entity registration.

ado_s8_watch_rule: 15 runtime columns (next_run_at / last_run_at /
last_status / last_error / last_duration_ms / last_run_id / lock_token
/ locked_by / lock_until / running_started_at / consecutive_failure_count
/ paused_until / pause_reason / trigger_count_required default 1 /
recover_count_required default 1) + 3 indexes (dispatch / lock_until /
rule_code; uses 'enabled' not 'is_enabled').

ado_s8_exception: 2 runtime columns (consecutive_hit_count default 1 /
consecutive_miss_count default 0) for post-creation debouncing.

new table ado_s8_rule_detection_state: pre-creation debouncing state
keyed by (tenant, factory, rule_code, dedup_key); UNIQUE
uk_s8_rule_detection_state_dedup; no soft-delete.

dev/test (aidopdev) initialization: stagger next_run_at, normalize
trigger/recover_count_required to 1.

Verified: dotnet build 0 error; CodeFirst auto-applied DDL on backend
restart; SchedulerJob first tick after migration succeeded
(hits=5 created=0 skipped=5 failed=0); demo rules id=10/11/12 still
enabled; exception_type baseline (tenant=0 factory=0 enabled=1) = 3
unchanged before/after migration.
YY968XX 2 тижнів тому
батько
коміт
09b6aa72de

+ 13 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8Exception.cs

@@ -167,4 +167,17 @@ public class AdoS8Exception
     /// <summary>R1 源对象主键或业务 ID(字符串以适配跨表/跨域,首版仅落列)。</summary>
     [SugarColumn(ColumnName = "source_object_id", Length = 64, IsNullable = true)]
     public string? SourceObjectId { get; set; }
+
+    // ============================================================
+    // S8-SCHED-SCHEMA-1:已建异常的抗抖累计字段(仅落列,本轮不接逻辑)
+    // 仅用于已建异常后的刷新/恢复抗抖;建单前抗抖落在 AdoS8RuleDetectionState。
+    // ============================================================
+
+    /// <summary>已建异常被规则连续命中的次数(用于刷新阶段抗抖)。</summary>
+    [SugarColumn(ColumnName = "consecutive_hit_count")]
+    public int ConsecutiveHitCount { get; set; } = 1;
+
+    /// <summary>已建异常被规则连续未命中的次数(用于恢复阶段抗抖)。</summary>
+    [SugarColumn(ColumnName = "consecutive_miss_count")]
+    public int ConsecutiveMissCount { get; set; } = 0;
 }

+ 79 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RuleDetectionState.cs

@@ -0,0 +1,79 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8 规则检测抗抖累计状态(建单前阶段)。
+/// 用途:在"持续 N 次命中才建单 / 持续 N 次未命中才标 recovered"路径上,
+/// 承载每条 (rule_code, dedup_key) 在还未建单时的累计计数。
+/// 本表无软删字段;唯一记录由 (tenant_id, factory_id, rule_code, dedup_key) 决定。
+/// 已建单后的抗抖累计落在 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)]
+public class AdoS8RuleDetectionState
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
+    public long FactoryId { get; set; }
+
+    /// <summary>规则人读编码(关联 ado_s8_watch_rule.rule_code)。</summary>
+    [SugarColumn(ColumnName = "rule_code", Length = 64)]
+    public string RuleCode { get; set; } = string.Empty;
+
+    /// <summary>命中去重键,与 ado_s8_exception.dedup_key 同模型。</summary>
+    [SugarColumn(ColumnName = "dedup_key", Length = 128)]
+    public string DedupKey { get; set; } = string.Empty;
+
+    /// <summary>源对象类型(DEVICE / ORDER / MATERIAL / QUALITY_CHECK 等)。</summary>
+    [SugarColumn(ColumnName = "source_object_type", Length = 64, IsNullable = true)]
+    public string? SourceObjectType { get; set; }
+
+    /// <summary>源对象主键或业务 ID(字符串以适配跨表/跨域)。</summary>
+    [SugarColumn(ColumnName = "source_object_id", Length = 64, IsNullable = true)]
+    public string? SourceObjectId { get; set; }
+
+    /// <summary>建单前的连续命中次数;累计到 trigger_count_required 才建单。</summary>
+    [SugarColumn(ColumnName = "consecutive_hit_count")]
+    public int ConsecutiveHitCount { get; set; } = 0;
+
+    /// <summary>建单前的连续未命中次数;用于尚未建单但抖动归零的场景。</summary>
+    [SugarColumn(ColumnName = "consecutive_miss_count")]
+    public int ConsecutiveMissCount { get; set; } = 0;
+
+    /// <summary>当前活跃的异常 Id(建单后写入;建单前为 NULL)。</summary>
+    [SugarColumn(ColumnName = "active_exception_id", ColumnDataType = "bigint", IsNullable = true)]
+    public long? ActiveExceptionId { get; set; }
+
+    /// <summary>最近一次评估时间(命中或未命中均刷新)。</summary>
+    [SugarColumn(ColumnName = "last_seen_at", IsNullable = true)]
+    public DateTime? LastSeenAt { get; set; }
+
+    /// <summary>最近一次命中时间。</summary>
+    [SugarColumn(ColumnName = "last_hit_at", IsNullable = true)]
+    public DateTime? LastHitAt { get; set; }
+
+    /// <summary>最近一次未命中时间。</summary>
+    [SugarColumn(ColumnName = "last_miss_at", IsNullable = true)]
+    public DateTime? LastMissAt { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

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

@@ -3,6 +3,9 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S8;
 [SugarTable("ado_s8_watch_rule", "S8 监视规则")]
 [SugarIndex("idx_s8_watch_rule_type", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(RuleType), OrderByType.Asc)]
 [SugarIndex("idx_s8_watch_rule_source_object", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(SourceObjectType), OrderByType.Asc)]
+[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)]
 public class AdoS8WatchRule
 {
     [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
@@ -55,4 +58,68 @@ public class AdoS8WatchRule
     /// <summary>规则类型相关结构化参数 JSON。R1 仅预留,service 不解析。</summary>
     [SugarColumn(ColumnName = "params_json", ColumnDataType = "mediumtext", IsNullable = true)]
     public string? ParamsJson { get; set; }
+
+    // ============================================================
+    // S8-SCHED-SCHEMA-1:调度运行态字段(仅落列,本轮不接调度逻辑)
+    // ============================================================
+
+    /// <summary>下次到期执行时间。NULL 视为可立即执行。</summary>
+    [SugarColumn(ColumnName = "next_run_at", IsNullable = true)]
+    public DateTime? NextRunAt { get; set; }
+
+    /// <summary>上次执行时间。</summary>
+    [SugarColumn(ColumnName = "last_run_at", IsNullable = true)]
+    public DateTime? LastRunAt { get; set; }
+
+    /// <summary>上次执行状态:SUCCESS / FAILED / SKIPPED。</summary>
+    [SugarColumn(ColumnName = "last_status", Length = 20, IsNullable = true)]
+    public string? LastStatus { get; set; }
+
+    /// <summary>上次执行错误摘要。</summary>
+    [SugarColumn(ColumnName = "last_error", Length = 500, IsNullable = true)]
+    public string? LastError { get; set; }
+
+    /// <summary>上次执行耗时(毫秒)。</summary>
+    [SugarColumn(ColumnName = "last_duration_ms", IsNullable = true)]
+    public int? LastDurationMs { get; set; }
+
+    /// <summary>上次执行 RunId(关联 detection_log)。</summary>
+    [SugarColumn(ColumnName = "last_run_id", Length = 64, IsNullable = true)]
+    public string? LastRunId { get; set; }
+
+    /// <summary>调度抢占租约 token(多实例并发安全)。</summary>
+    [SugarColumn(ColumnName = "lock_token", Length = 64, IsNullable = true)]
+    public string? LockToken { get; set; }
+
+    /// <summary>持有租约的实例标识。</summary>
+    [SugarColumn(ColumnName = "locked_by", Length = 128, IsNullable = true)]
+    public string? LockedBy { get; set; }
+
+    /// <summary>租约失效时间;超过此时间视为可被其他实例抢占(僵尸自愈)。</summary>
+    [SugarColumn(ColumnName = "lock_until", IsNullable = true)]
+    public DateTime? LockUntil { get; set; }
+
+    /// <summary>本次运行开始时间(与 LockUntil 配合用于僵尸判定)。</summary>
+    [SugarColumn(ColumnName = "running_started_at", IsNullable = true)]
+    public DateTime? RunningStartedAt { get; set; }
+
+    /// <summary>连续失败计数;用于到达阈值后写 PausedUntil 自动暂停。</summary>
+    [SugarColumn(ColumnName = "consecutive_failure_count")]
+    public int ConsecutiveFailureCount { get; set; } = 0;
+
+    /// <summary>暂停至何时;NULL 表示未暂停;远未来值表示手工暂停。</summary>
+    [SugarColumn(ColumnName = "paused_until", IsNullable = true)]
+    public DateTime? PausedUntil { get; set; }
+
+    /// <summary>暂停原因(auto_failure / manual / ...)。</summary>
+    [SugarColumn(ColumnName = "pause_reason", Length = 64, IsNullable = true)]
+    public string? PauseReason { get; set; }
+
+    /// <summary>持续命中 N 次才建单(抗抖触发);默认 1 = 立即建单。</summary>
+    [SugarColumn(ColumnName = "trigger_count_required")]
+    public int TriggerCountRequired { get; set; } = 1;
+
+    /// <summary>持续未命中 N 次才标 recovered(抗抖恢复);默认 1 = 立即恢复。</summary>
+    [SugarColumn(ColumnName = "recover_count_required")]
+    public int RecoverCountRequired { get; set; } = 1;
 }

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

@@ -132,6 +132,7 @@ public class Startup : AppStartup
                 typeof(AdoS8DashboardCellConfig),
                 typeof(AdoS8ExceptionType),
                 typeof(AdoS8DetectionLog),
+                typeof(AdoS8RuleDetectionState),
                 typeof(ContractReview),
                 typeof(ContractReviewFlow),
                 typeof(ProductDesign),