using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Const.S8; using System.Security.Claims; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Controllers.S8; [ApiController] [Route("api/aidop/s8/config/watch-rules")] [NonUnify] public class AdoS8ConfigWatchRulesController : ControllerBase { private const string LegacyHeaderUseInstead = "PUT /api/aidop/s8/config/watch-rules/{id}/params"; private readonly S8WatchRuleService _svc; private readonly ILogger _logger; private readonly S8TrustedScopeResolver _scope; public AdoS8ConfigWatchRulesController( S8WatchRuleService svc, ILogger logger, S8TrustedScopeResolver scope) { _svc = svc; _logger = logger; _scope = scope; } [HttpGet] [S8Permission(S8PermissionCatalog.ConfigRead)] public async Task ListAsync([FromQuery] long tenantId = 1, [FromQuery] long factoryId = 1) { // Compatibility-only. Security scope is resolved server-side. _ = tenantId; _ = factoryId; var scope = await _scope.ResolveAsync(); return Ok(await _svc.ListAsync(scope.TenantId)); } // ================================================================================ // S8-RULE-GOVERNANCE-BATCH3:业务侧建规则入口已退役 → 410 Gone。 // // 选 410 墓碑而非直接删掉 route:直接删会让遗留调用方拿到 405(该路径上还有 GET), // 而 405 说的是"方法不对",会让人以为换个动词就能建。410 才准确表达 // 「这个能力曾经存在、现已永久退役」,并能把替代路径写进文案。 // // ⚠️ 不再调用 _scope.ResolveAsync():能力已退役,不该为构造一个用不到的 scope 去读组织库。 // 这保证 DB access = 0,并让工厂组织 0 个/多个的租户也稳定得到 410 而非作用域错误。 // ⚠️ catch 顺序:S8WriteRetiredException 必须排在 S8BizException 之前,否则被基类吞成 400。 // ================================================================================ [Obsolete("Retired. Rules are defined in code (S8RuleCatalog) and provisioned per tenant.")] [HttpPost] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public IActionResult CreateAsync([FromBody] AdoS8WatchRule body) { MarkLegacyDeprecated("POST /api/aidop/s8/config/watch-rules"); _ = body; try { _ = _svc.CreateAsync(body, RetiredWriteScope); return Ok(); } catch (S8WriteRetiredException ex) { return StatusCode(Microsoft.AspNetCore.Http.StatusCodes.Status410Gone, new { message = ex.Message }); } } /// /// Legacy endpoint. Rule configuration UI must use PUT /{id}/params. /// 保留兼容历史脚本/集成调用,但响应 header 与日志会标记为 deprecated。 /// // ================================================================================ // 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}")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task UpdateAsync(long id, [FromBody] AdoS8WatchRule body) { MarkLegacyDeprecated($"PUT /api/aidop/s8/config/watch-rules/{id}", id); 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 }); } } /// /// 写能力已永久退役:不得为了构造一个不会被使用的 scope 而读取组织库。 /// Service 写方法必须在读取该占位值或访问任何仓储前抛 S8WriteRetiredException。 /// (与 CFG_ALERT 的 RetiredWriteScope 同一模式。) /// private static readonly S8TrustedScope RetiredWriteScope = new(0); /// /// S8-RULE-GOVERNANCE-BATCH3:业务侧删规则入口已退役 → 410 Gone。 /// 规则由系统版本定义;"不想让它跑"的正确表达是 /disable,不是删除。 /// 与 / 同一墓碑形态。 /// [Obsolete("Retired. Disable the rule instead; rule lifecycle belongs to the code release.")] [HttpDelete("{id:long}")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public IActionResult DeleteAsync(long id) { MarkLegacyDeprecated($"DELETE /api/aidop/s8/config/watch-rules/{id}", id); try { _ = _svc.DeleteAsync(id, RetiredWriteScope); return Ok(); } catch (S8WriteRetiredException ex) { return StatusCode(Microsoft.AspNetCore.Http.StatusCodes.Status410Gone, new { message = ex.Message }); } } // S8-RULE-GOVERNANCE-BATCH3:POST /{id}/test 已物理移除。 // 它做的只是 LoadScoped + 配置字段校验(rule_code/scene 非空、编码唯一、dataset 在目录内、 // 关联场景存在且启用、poll>0),**不取数、不判定**,返回一句"规则基础校验通过"。 // 这些前提现在要么由代码定义保证、要么已不适用(场景行对代码定义的规则是无关约束)。 // 「这条规则启用后会命中什么」由 GET /{id}/preview 真实回答,能力完全覆盖且更强。 // 仓内无任何调用方:前端 s8ConfigApi.watchRules 无 test,通用 CRUD 页的 test 只用于 // 场景/大屏卡片/角色三个 endpoint,不指向 watch-rules。 /// /// S8-RULE01-INTEGRATION-PREVIEW-1:只读预演 —— 「这条规则现在启用会命中什么」。 /// /// 在此之前想回答这个问题只能真把规则 enabled=true 然后等调度器跑, /// 对 Rule 01 而言那意味着一次性生成 28 条真实异常单,而验证只需要 1 条。 /// 本端点补上这个中间档位:走**与生产完全相同**的 Gateway → Provider → Evaluator 链, /// 但不写 detection_log / exception / rule_detection_state,不改规则任何列,不发通知。 /// /// 用 GET 而非 POST 是刻意的:它是一次查询,没有副作用, /// 用 POST 会让调用方以为「点了就会发生什么」。 /// /// 权限沿用 ConfigWatchRule(能配规则的人才能预演),作用域由服务端盖章。 /// [HttpGet("{id:long}/preview")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task PreviewAsync(long id, [FromServices] S8WatchRulePreviewService preview) { try { return Ok(await preview.PreviewAsync(id, await _scope.ResolveAsync())); } catch (S8NotFoundException) { return NotFound(); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } catch (S8RuleEvaluatorException ex) { return BadRequest(new { reason = ex.Reason, message = ex.Message }); } } /// /// S8-RULE-GOVERNANCE-BATCH1:运行参数的**部分更新**。 /// /// 只接受该规则的代码定义所声明的运行参数白名单 /// (轮询间隔 / 抗抖次数 / 宽限分钟 / 严重度 / 部门兜底),未提供的字段保持原值。 /// /// 不再接受 params_json 原文与任何判定语义字段 —— 那些由代码定义, /// 出现在载荷里会得到 400 而不是被静默忽略。也不再接受 enabled, /// 启停请用 /enable/disable /// /// 路由暂时保留 /params 以免与前端一次性大改耦合; /// 前端适配排在 Batch 4。 /// [HttpPut("{id:long}/params")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task UpdateParamsAsync(long id, [FromBody] S8RuleParametersPayload body) { try { return Ok(await _svc.UpdateParametersAsync(id, body, await _scope.ResolveAsync())); } catch (S8NotFoundException) { return NotFound(); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// /// 启用规则。与参数写入完全分离:本端点只写 enabled 与下次执行时间, /// 不触碰 params_json、不触碰任何参数列、不触碰定义投影列。 /// /// 这条分离是 G1 的结构性修复:此前启停与 params_json 共用一个写入口, /// 一次「只想关个开关」的调用就会把规则的全部业务配置抹成 NULL。 /// /// 幂等:重复启用不产生写入。 /// /// /// S8-HANDLER-POOL-1:规则处理账号池。 /// /// 回答「哪些账号负责这条规则」。与「异常操作权限」(哪个角色能做认领这类动作) /// 正交:最终能否认领 = 动作权限 ∩ 规则责任 ∩ 状态机。 /// [HttpGet("{id:long}/handler-pool")] [S8Permission(S8PermissionCatalog.ConfigRead)] public async Task HandlerPoolAsync(long id, [FromServices] S8RuleHandlerPoolService pool, [FromServices] SqlSugarRepository ruleRep) { try { var scope = await _scope.ResolveAsync(); var ruleCode = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => x.RuleCode).FirstAsync() ?? throw new S8BizException("规则不存在"); return Ok(await pool.GetMembersAsync(scope.TenantId, ruleCode)); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// 全量替换规则处理账号池。成员必须是本租户的启用账号。 [HttpPut("{id:long}/handler-pool")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task SetHandlerPoolAsync(long id, [FromBody] AdoS8HandlerPoolSetDto? body, [FromServices] S8RuleHandlerPoolService pool, [FromServices] SqlSugarRepository ruleRep) { try { var scope = await _scope.ResolveAsync(); var ruleCode = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => x.RuleCode).FirstAsync() ?? throw new S8BizException("规则不存在"); await pool.SetMembersAsync(scope, ruleCode, body?.UserIds); return Ok(new { ruleCode, memberCount = body?.UserIds?.Count ?? 0 }); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// /// S8-RESPONSIBILITY-POOL-1:读取该规则某一类责任池。 /// typeHANDLER / REVIEWER / ESCALATION。 /// 三类共用同一套读写 API —— 存储差异(HANDLER 在存量表)被收敛在服务层, /// 调用方不需要知道。 /// [HttpGet("{id:long}/responsibility/{type}")] [S8Permission(S8PermissionCatalog.ConfigRead)] public async Task ResponsibilityPoolAsync(long id, string type, [FromServices] S8RuleResponsibilityPoolService pool, [FromServices] SqlSugarRepository ruleRep) { try { if (!S8ResponsibilityCatalog.IsKnown(type)) throw new S8BizException($"未知的责任类型:{type}"); var scope = await _scope.ResolveAsync(); var ruleCode = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => x.RuleCode).FirstAsync() ?? throw new S8BizException("规则不存在"); var members = await pool.GetMembersAsync(scope.TenantId, ruleCode, type); var def = S8ResponsibilityCatalog.Find(type); return Ok(new { ruleCode, type, title = def?.DisplayName, description = def?.Description, members, validCount = members.Count(m => m.Valid), }); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// 全量替换该规则某一类责任池。成员必须是本租户的启用账号。 [HttpPut("{id:long}/responsibility/{type}")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task SetResponsibilityPoolAsync(long id, string type, [FromBody] AdoS8HandlerPoolSetDto? body, [FromServices] S8RuleResponsibilityPoolService pool, [FromServices] SqlSugarRepository ruleRep) { try { var scope = await _scope.ResolveAsync(); var ruleCode = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => x.RuleCode).FirstAsync() ?? throw new S8BizException("规则不存在"); await pool.SetMembersAsync(scope, ruleCode, type, body?.UserIds); return Ok(new { ruleCode, type, memberCount = body?.UserIds?.Count ?? 0 }); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// /// S8-RESPONSIBILITY-POOL-1:「通知设置」页的事件开关矩阵。 /// 只回答「什么事件需要提醒」;提醒谁由责任关系推导,页面只读展示业务文案。 /// [HttpGet("{id:long}/notify-events")] [S8Permission(S8PermissionCatalog.ConfigRead)] public async Task NotifyEventsAsync(long id, [FromServices] S8NotificationRecipientConfigService svc, [FromServices] SqlSugarRepository ruleRep) { try { var scope = await _scope.ResolveAsync(); var ruleCode = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => x.RuleCode).FirstAsync() ?? throw new S8BizException("规则不存在"); return Ok(new { ruleCode, events = await svc.GetEventSwitchesAsync(scope.TenantId, ruleCode) }); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// 按开关启用 / 关闭某事件的通知。收件人类型由目录推导,请求体不传。 [HttpPut("{id:long}/notify-events/{eventCode}")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task SetNotifyEventAsync(long id, string eventCode, [FromBody] AdoS8NotifyEventToggleDto? body, [FromServices] S8NotificationRecipientConfigService svc, [FromServices] SqlSugarRepository ruleRep) { try { var scope = await _scope.ResolveAsync(); var ruleCode = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => x.RuleCode).FirstAsync() ?? throw new S8BizException("规则不存在"); await svc.SetEventEnabledAsync(scope, ruleCode, eventCode, body?.Enabled ?? false); return Ok(new { ruleCode, eventCode, enabled = body?.Enabled ?? false }); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// /// S8-RULE-4TAB-1:只读返回「这条规则的异常,审核时到底由谁审」。 /// /// 本批不新建 Rule 级 Reviewer Pool、不改 ApproverType、不动流程节点 —— /// 只把当前事实如实呈现,包括跨租户引用与"解析为 0 人"这类不好看的事实。 /// 此前页面把审核人显示为「主管」,而系统里根本不存在组织主管关系。 /// [HttpGet("{id:long}/review-source")] [S8Permission(S8PermissionCatalog.ConfigRead)] public async Task ReviewSourceAsync(long id, [FromServices] S8ReviewSourceService reviewSource, [FromServices] SqlSugarRepository ruleRep) { try { var scope = await _scope.ResolveAsync(); var row = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => new { x.SceneCode }) .FirstAsync() ?? throw new S8BizException("规则不存在"); return Ok(new { stages = await reviewSource.DescribeAsync(scope.TenantId), escalateRoles = await reviewSource.DescribeEscalateRolesAsync(scope.TenantId, row.SceneCode), }); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// S8-NOTIFY-RECIPIENT-1:该规则的事件通知收件人配置。 [HttpGet("{id:long}/notify-recipients")] [S8Permission(S8PermissionCatalog.ConfigRead)] public async Task NotifyRecipientsAsync(long id, [FromServices] S8NotificationRecipientConfigService svc, [FromServices] SqlSugarRepository ruleRep) { try { var scope = await _scope.ResolveAsync(); var ruleCode = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => x.RuleCode).FirstAsync() ?? throw new S8BizException("规则不存在"); return Ok(new { ruleCode, recipientTypes = S8NotificationCatalog.RecipientTypes, events = await svc.GetMatrixAsync(scope.TenantId, ruleCode), }); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// 全量替换某事件的收件人配置。 /// /// 权限位用 ConfigWatchRule 而不是 ConfigNotification:本端点是规则级配置 /// (路由挂在规则下、作用范围就是这一条规则),一个控制器只用自己那一个 config 能力, /// 是本仓既有的授权分组约束 —— 混用会让「只有通知权限的人」能从规则控制器写入。 /// 全局的通知分层配置仍由 ConfigNotification 守护,两者互不越界。 /// [HttpPut("{id:long}/notify-recipients/{eventCode}")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task SetNotifyRecipientsAsync(long id, string eventCode, [FromBody] AdoS8NotifyRecipientSetDto? body, [FromServices] S8NotificationRecipientConfigService svc, [FromServices] SqlSugarRepository ruleRep) { try { var scope = await _scope.ResolveAsync(); var ruleCode = await ruleRep.AsQueryable().ClearFilter() .Where(x => x.Id == id && x.TenantId == scope.TenantId) .Select(x => x.RuleCode).FirstAsync() ?? throw new S8BizException("规则不存在"); await svc.SetAsync(scope, ruleCode, eventCode, body?.RecipientTypes, body?.SpecificUserIds); return Ok(new { ruleCode, eventCode, typeCount = body?.RecipientTypes?.Count ?? 0 }); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } [HttpPost("{id:long}/enable")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task EnableAsync(long id) { try { return Ok(await _svc.EnableAsync(id, await _scope.ResolveAsync())); } catch (S8NotFoundException) { return NotFound(); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// /// S8-RULE-GOVERNANCE-BATCH2:为**当前租户**补齐代码定义规则的运行策略行。 /// /// 用途:研发发布了一条新规则之后,管理员无需重启即可让本租户立刻拿到对应的运行策略 /// (默认停用)。幂等 —— 重复调用不会重复建行。 /// /// 只处理调用方自己的租户:全租户对账是启动期的系统级动作。 /// 若在这里放开全量,一个租户管理员就能对全平台其他租户写入运行策略行 —— /// 那与 S8 其余接口"作用域由服务端从登录身份盖章"的口径直接矛盾。 /// [HttpPost("provision")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task 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 }); } } /// /// 停用规则。只写 enabled;幂等。 /// 刻意不过 Enable Gate:数据集出问题之后仍然必须能把规则关掉。 /// [HttpPost("{id:long}/disable")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task DisableAsync(long id) { try { return Ok(await _svc.DisableAsync(id, await _scope.ResolveAsync())); } catch (S8NotFoundException) { return NotFound(); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// /// S8-SCHED-FRONTEND-1:调度参数安全更新(仅 poll_interval_seconds / trigger_count_required / recover_count_required)。 /// S8-RULE-GOVERNANCE-BATCH1:已委托到统一的参数写入路径,语义与 /params 一致。 /// [HttpPut("{id:long}/schedule")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task UpdateScheduleAsync(long id, [FromBody] S8WatchRuleSchedulePayload body) { try { return Ok(await _svc.UpdateScheduleAsync(id, body, await _scope.ResolveAsync())); } catch (S8NotFoundException) { return NotFound(); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// S8-SCHED-FRONTEND-1:立即执行一次,next_run_at 置为 NOW,由下一 tick 拾取。 [HttpPost("{id:long}/run-now")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task RunNowAsync(long id) { try { return Ok(await _svc.RunNowAsync(id, await _scope.ResolveAsync())); } catch (S8NotFoundException) { return NotFound(); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// S8-SCHED-FRONTEND-1:手工暂停,paused_until 置为远未来哨兵 + pause_reason=MANUAL_PAUSED。 [HttpPost("{id:long}/pause")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task PauseAsync(long id) { try { return Ok(await _svc.PauseAsync(id, await _scope.ResolveAsync())); } catch (S8NotFoundException) { return NotFound(); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// S8-SCHED-FRONTEND-1:恢复,清 paused_until / pause_reason / last_error / consecutive_failure_count,next_run_at = NOW。 [HttpPost("{id:long}/resume")] [S8Permission(S8PermissionCatalog.ConfigWatchRule)] public async Task ResumeAsync(long id) { try { return Ok(await _svc.ResumeAsync(id, await _scope.ResolveAsync())); } catch (S8NotFoundException) { return NotFound(); } catch (S8BizException ex) { return BadRequest(new { message = ex.Message }); } } /// /// 旧端点 deprecated 收口:写入响应 header 标记 + 结构化 warning 日志,便于运行期识别 legacy 调用。 /// 不阻断调用,不改返回结构。 /// private void MarkLegacyDeprecated(string endpoint, long? ruleId = null) { // 响应 header — Headers 在响应已开始发送后会抛 InvalidOperationException,此处守卫一下避免污染主调用。 var headers = Response.Headers; if (!headers.ContainsKey("X-AiDOP-Deprecated")) headers["X-AiDOP-Deprecated"] = "true"; if (!headers.ContainsKey("X-AiDOP-Use-Instead")) headers["X-AiDOP-Use-Instead"] = LegacyHeaderUseInstead; var userId = User?.FindFirst("UserId")?.Value ?? User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; var tenantId = HttpContext?.Request?.Query["tenantId"].ToString(); var factoryId = HttpContext?.Request?.Query["factoryId"].ToString(); _logger.LogWarning( "legacy_watch_rule_endpoint endpoint={Endpoint} ruleId={RuleId} userId={UserId} tenantId={TenantId} factoryId={FactoryId} useInstead={UseInstead}", endpoint, ruleId, userId, tenantId, factoryId, LegacyHeaderUseInstead); } } /// 责任池设置载荷(处理 / 复核 / 升级三类共用)。 public class AdoS8HandlerPoolSetDto { /// 完整成员集合(全量替换)。传空数组 = 清空责任池,该规则将无法启用。 public List? UserIds { get; set; } } /// /// 事件通知开关载荷。 /// 刻意只有一个布尔:收件人由责任关系推导,业务用户不选技术类型。 /// public class AdoS8NotifyEventToggleDto { public bool Enabled { get; set; } } /// 事件收件人设置载荷。 public class AdoS8NotifyRecipientSetDto { /// 收件人类型集合(全量替换)。传空 = 该事件不发通知(会回落 LEGACY 分层)。 public List? RecipientTypes { get; set; } /// 仅当包含 SPECIFIC_USER 时有效。 public List? SpecificUserIds { get; set; } }