| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- using Admin.NET.Plugin.AiDOP.Const.S8;
- using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
- namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
- /// <summary>就绪门禁的失败原因码。与 <see cref="DataAccess.S8DatasetReasonCode"/> 分开:那一族说的是「数据集能不能取数」。</summary>
- public static class S8RuleReadinessReasonCode
- {
- /// <summary>规则声明为启用必需的运行参数尚未配置。</summary>
- public const string RequiredParameterMissing = "rule_required_parameter_missing";
- /// <summary>已配置的部门不存在于当前租户(或已停用)。</summary>
- public const string DepartmentNotInTenant = "rule_department_not_in_tenant";
- /// <summary>
- /// 规则没有任何处理账号(S8-HANDLER-POOL-1)。
- /// <para>启用一条没人负责的规则,结果是异常源源不断地建出来、静静躺在列表里没人认领——
- /// 而调度器每一轮都报成功。与"配了部门但没人处理"是同一类静默失败,必须在启用前挡住。</para>
- /// </summary>
- public const string HandlerPoolEmpty = "rule_handler_pool_empty";
- /// <summary>
- /// 规则声明了包含复核环节,却没有任何有效的复核账号(S8-RESPONSIBILITY-POOL-1)。
- /// <para>处理人做完点「提交复核」会发现候选人列表是空的,单据卡在 IN_PROGRESS 出不去,
- /// 而规则本身一路显示「已启用 / 最近结果:成功」。</para>
- /// </summary>
- public const string ReviewerPoolEmpty = "rule_reviewer_pool_empty";
- /// <summary>
- /// 规则声明了参与超时升级,却没有任何有效的升级账号(S8-RESPONSIBILITY-POOL-1)。
- /// <para>异常超时后没有任何人被通知,异常静静躺到过期 —— 升级作业每一轮都报成功。</para>
- /// </summary>
- public const string EscalationPoolEmpty = "rule_escalation_pool_empty";
- /// <summary>
- /// 规则声明了包含复核环节,却没有可用的 <c>EXCEPTION_CLOSURE</c> 审批流程定义
- /// (S8-RULE-FLOW-READINESS-1)。
- /// <para>与 <see cref="ReviewerPoolEmpty"/> 是同一条链的另一半:那条查「有没有人」,
- /// 这条查「有没有流程」。两者缺任一,提交复核都走不下去 —— 差别只在失败位置:
- /// 人池为空是候选人列表空,流程缺失是 <c>StartFlow</c> 抛「未找到已发布流程定义」,
- /// 而该调用被 <c>TryStartVerificationFlowAsync</c> 的 catch 吞成一条 warning,
- /// 业务侧完全无感。</para>
- /// </summary>
- public const string ClosureFlowMissing = "rule_closure_flow_missing";
- /// <summary>
- /// 规则声明了参与超时升级,却没有可用的 <c>EXCEPTION_ESCALATION</c> 审批流程定义
- /// (S8-RULE-FLOW-READINESS-1)。
- /// <para>与 <see cref="EscalationPoolEmpty"/> 同理成对。本条比复核那条更硬:
- /// <c>S8TaskFlowService.UpgradeAsync</c> 里的 <c>StartFlow</c> <b>没有 try/catch</b>,
- /// 缺流程时人工升级直接失败、自动升级作业记 <c>s8_timeout_auto_escalate_failed</c>。</para>
- /// </summary>
- public const string EscalationFlowMissing = "rule_escalation_flow_missing";
- }
- /// <summary>
- /// S8-RULE-FLOW-READINESS-1:「某个 BizType 在当前租户下有没有可用的审批流程定义」的<b>唯一</b>判定。
- ///
- /// <para><b>判据必须与 <c>FlowEngineService.StartFlowCore</c> 同源</b>:那里的候选集合是
- /// <c>IsPublished && !IsDelete && (TenantId == 当前租户 || TenantId IS NULL)</c>。
- /// 门禁若比运行时宽,会放行一条起不了流的规则;若比运行时严,会把「有全局流程可回落」的
- /// 租户误判成不可用、挡住本该允许的启用。两种偏差都比没有门禁更糟,所以判据同源、不重写。</para>
- ///
- /// <para><b>为什么必须 <c>ClearFilter()</c></b>:全局多租户 AOP 对 <c>ApprovalFlow</c> 生成的是
- /// <c>TenantId = @current</c>,<b>不含 <c>OR TenantId IS NULL</c></b>。不关掉它就永远看不见全局流程,
- /// 于是「该租户能回落到全局流程」会被判成「无流程」。这里关掉全局过滤后<b>由本方法自己写死
- /// 租户谓词</b>(<c>TenantId == tenantId || TenantId == null</c>)——是显式声明边界,不是绕过租户:
- /// 越权读别的租户的流程在本谓词下不可能发生。<c>FlowEngineService</c> 与
- /// <c>S8DepartmentScopeValidator</c> 用的是同一套做法。</para>
- ///
- /// <para><b>只读、只判存在性</b>:不返回流程实体、不参与运行时选择(优先本租户、再回落全局、
- /// 按 Version 倒序取最新那一条),那仍然只由 <c>StartFlowCore</c> 决定。</para>
- /// </summary>
- public interface IS8ApprovalFlowScopeReader
- {
- /// <summary>该 BizType 在当前租户下是否存在可用(已发布 / 未删 / 本租户或全局)的流程定义。</summary>
- Task<bool> HasUsableFlowAsync(string bizType, long tenantId);
- }
- /// <summary>
- /// 正式实现。<b>生产代码里只有这一份</b> —— 接口的存在是为了给测试留 seam,
- /// 不是为了允许出现第二种判据。
- /// </summary>
- public sealed class S8ApprovalFlowScopeReader : IS8ApprovalFlowScopeReader, ITransient
- {
- private readonly SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> _flowRep;
- public S8ApprovalFlowScopeReader(SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> flowRep)
- => _flowRep = flowRep;
- /// <summary>
- /// <paramref name="bizType"/> 为空或 <paramref name="tenantId"/> 非正数一律返回 false ——
- /// 拿不到可信租户时按「不可用」处理(fail closed),不静默放行。
- /// </summary>
- public async Task<bool> HasUsableFlowAsync(string bizType, long tenantId)
- {
- if (string.IsNullOrWhiteSpace(bizType) || tenantId <= 0) return false;
- return await _flowRep.AsQueryable().ClearFilter()
- .Where(f => f.BizType == bizType
- && f.IsPublished
- && !f.IsDelete
- && (f.TenantId == tenantId || f.TenantId == null))
- .AnyAsync();
- }
- }
- /// <summary>就绪判定结果。<see cref="Ok"/> 为 true 时另外两项为 null。</summary>
- public sealed class S8RuleReadinessCheck
- {
- private S8RuleReadinessCheck(bool ok, string? reasonCode, string? message)
- {
- Ok = ok;
- ReasonCode = reasonCode;
- Message = message;
- }
- public bool Ok { get; }
- public string? ReasonCode { get; }
- public string? Message { get; }
- public static S8RuleReadinessCheck Pass() => new(true, null, null);
- public static S8RuleReadinessCheck Fail(string reasonCode, string message) => new(false, reasonCode, message);
- }
- /// <summary>
- /// S8-RULE-READINESS-1:规则「能不能真的跑」的统一门禁。
- ///
- /// <para><b>要解决的问题</b>:Rule 01 的判定语义里没有部门 —— <c>dwd_supplier_delivery</c>
- /// 根本没有部门列,命中行也就带不出部门。于是建单时部门只能来自运行参数
- /// <c>defaultOccurrenceDeptId</c> / <c>defaultResponsibleDeptId</c>。没配这两项时,
- /// 规则会「启用成功、调度成功、一条异常都建不出来」:
- /// <c>tick_rule_done ... status=SUCCESS hits=28 created=0 failed=1</c>,
- /// 页面显示「已启用 / 最近结果:成功」。这是最难发现的一类失败 —— 每一层都报成功。</para>
- ///
- /// <para><b>S8-HANDLER-POOL-1 追加一条判据</b>:规则必须至少有一个当前有效的处理账号。
- /// 少了它,一条没人负责的规则会源源不断建出异常、静静躺在列表里没人认领,
- /// 而调度器每一轮都报成功 —— 与"配了部门但没人处理"同属一类静默失败。</para>
- ///
- /// <para><b>三条真实执行入口必须共用本门禁</b>:<c>EnableAsync</c> · <c>RunNowAsync</c> ·
- /// <c>S8WatchSchedulerService.RunSingleRuleAsync</c>。少接一条就等于留了旁路:
- /// 启用被挡但 RunNow 能跑、或启用时合法而部门事后被停用、调度器照样进 evaluator 再 create_failed。
- /// Scheduler 侧必须<b>在 evaluator 之前</b> fail-fast,不进入异常创建。</para>
- ///
- /// <para><b>Preview 刻意不接</b>:预演只做只读试算、不建任何异常,
- /// 部门缺失不影响它的正确性。管理员本来就该「先预演看命中,再配部门,再启用」。</para>
- ///
- /// <para><b>不是全局规则</b>:是否必需由 <see cref="Definitions.S8RuleParameterPolicy.RequiresDepartmentDefaultsForEnable"/>
- /// 逐规则声明,默认 <c>false</c>。将来某条规则的数据集自带部门列,它就不需要这两个参数。
- /// 在 S8 全局写死「所有规则必须配部门」会把那类规则一起误伤。</para>
- ///
- /// <para><b>参数不重新解析</b>:只消费调用方传入的 <see cref="S8EffectiveRule"/>
- /// (其 <c>Parameters</c> 已由 <see cref="S8RuleRuntimeParameters.Resolve"/> 解析)。
- /// 门禁自己再解一次 <c>params_json</c> 就会出现「门禁看到的值」与「evaluator 用的值」不同源,
- /// 那是另一种形式的静默背离。</para>
- /// </summary>
- public sealed class S8RuleReadinessGate : ITransient
- {
- /// <summary>
- /// 复核链审批流程的 BizType。与 <c>S8TaskFlowService.TryStartVerificationFlowAsync</c>
- /// 和 <c>S8ApprovalFlowRepairService.ClosureBizType</c> 必须一致 ——
- /// 门禁检查的流程必须正好是运行时要启动的那一条,否则「检查通过、起流失败」照旧发生。
- /// </summary>
- private const string ClosureBizType = "EXCEPTION_CLOSURE";
- /// <summary>
- /// 升级链审批流程的 BizType。与 <c>S8TaskFlowService.UpgradeAsync</c>
- /// 和 <c>S8ApprovalFlowRepairService.EscalationBizType</c> 必须一致,理由同上。
- /// </summary>
- private const string EscalationBizType = "EXCEPTION_ESCALATION";
- private readonly IS8DepartmentScopeValidator _deptValidator;
- private readonly IS8RuleResponsibilityReader _pools;
- private readonly IS8ApprovalFlowScopeReader _flows;
- public S8RuleReadinessGate(
- IS8DepartmentScopeValidator deptValidator,
- IS8RuleResponsibilityReader pools,
- IS8ApprovalFlowScopeReader flows)
- {
- _deptValidator = deptValidator;
- _pools = pools;
- _flows = flows;
- }
- /// <summary>
- /// 按生效形态判定就绪。不抛异常,由调用方决定失败语义(400 / run-result)。
- ///
- /// <para><b>每条判据由各自的声明驱动</b>(S8-RESPONSIBILITY-POOL-1):
- /// 拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。
- /// 判据之间没有业务上的从属关系,不该被一个开关捆在一起 ——
- /// 尤其在要再加复核 / 升级两条判据的时候。</para>
- /// </summary>
- public async Task<S8RuleReadinessCheck> CheckAsync(S8EffectiveRule effective, long tenantId)
- {
- var policy = effective.Definition.Parameters;
- if (policy == null) return S8RuleReadinessCheck.Pass();
- var ruleCode = effective.Row.RuleCode ?? string.Empty;
- // ① 部门兜底参数(仅数据集拿不出部门的规则声明)。
- if (policy.RequiresDepartmentDefaultsForEnable)
- {
- var deptCheck = await CheckDepartmentsAsync(effective, tenantId);
- if (!deptCheck.Ok) return deptCheck;
- }
- // ② 处理账号池 —— 没人负责的规则会源源不断建单、无人认领,而调度器每轮都报成功。
- if (policy.RequiresHandlerPoolForEnable)
- {
- var check = await CheckPoolAsync(
- tenantId, ruleCode, S8ResponsibilityType.Handler,
- S8RuleReadinessReasonCode.HandlerPoolEmpty,
- "启用失败:请先配置至少一名处理账号。",
- "启用失败:已配置的处理账号均已停用或不在当前租户,请重新配置。");
- if (!check.Ok) return check;
- }
- // ③ 复核账号池 —— 空池会让「提交复核」的候选人列表为空,单据卡在 IN_PROGRESS。
- if (policy.RequiresVerification)
- {
- var check = await CheckPoolAsync(
- tenantId, ruleCode, S8ResponsibilityType.Reviewer,
- S8RuleReadinessReasonCode.ReviewerPoolEmpty,
- "启用失败:请先配置至少一名复核账号。",
- "启用失败:已配置的复核账号均已停用或不在当前租户,请重新配置。");
- if (!check.Ok) return check;
- }
- // ④ 升级账号池 —— 空池会让超时升级发不给任何人,异常静静躺到过期。
- if (policy.SupportsTimeoutEscalation)
- {
- var check = await CheckPoolAsync(
- tenantId, ruleCode, S8ResponsibilityType.Escalation,
- S8RuleReadinessReasonCode.EscalationPoolEmpty,
- "启用失败:请先配置至少一名升级账号。",
- "启用失败:已配置的升级账号均已停用或不在当前租户,请重新配置。");
- if (!check.Ok) return check;
- }
- // ⑤⑥ 审批流程定义 —— 与 ③④ 的账号池判据成对:那两条查「有没有人」,这两条查「有没有流程」。
- //
- // 刻意<b>追加在末尾</b>而不是紧贴各自的池判据:插在中间会改变既有失败场景先命中哪个
- // reason code,而运维手册与既有测试按当前顺序写。判据之间无从属关系,顺序只影响报哪一条,
- // 不影响放不放行。
- if (policy.RequiresVerification)
- {
- var check = await CheckFlowAsync(
- tenantId, ClosureBizType,
- S8RuleReadinessReasonCode.ClosureFlowMissing,
- "启用失败:当前规则包含复核环节,依赖 EXCEPTION_CLOSURE 审批流程,"
- + "但当前租户没有可用的已发布流程(本租户与全局均无),请先完成审批流程配置。");
- if (!check.Ok) return check;
- }
- if (policy.SupportsTimeoutEscalation)
- {
- var check = await CheckFlowAsync(
- tenantId, EscalationBizType,
- S8RuleReadinessReasonCode.EscalationFlowMissing,
- "启用失败:当前规则参与超时升级,依赖 EXCEPTION_ESCALATION 审批流程,"
- + "但当前租户没有可用的已发布流程(本租户与全局均无),请先完成审批流程配置。");
- if (!check.Ok) return check;
- }
- return S8RuleReadinessCheck.Pass();
- }
- /// <summary>
- /// 审批流程判据:该 BizType 在本租户下必须有<b>可用</b>的流程定义。
- ///
- /// <para>「可用」的口径由 <see cref="IS8ApprovalFlowScopeReader"/> 与
- /// <c>FlowEngineService.StartFlowCore</c> 同源保证 —— 含全局流程回落。
- /// 因此「本租户没建、但有全局流程」是<b>通过</b>,与运行时行为一致。</para>
- ///
- /// <para><b>为什么必须在启用时挡</b>:缺流程的实际表现分两种,都不会让管理员看见问题 ——
- /// 复核链的 <c>StartFlow</c> 被 catch 吞成 warning(建单/提交照常成功,流程没起来);
- /// 升级链虽然会抛,但人工升级只回一条错误消息、自动升级作业记一条 warning,
- /// 而规则本身一路显示「已启用 / 最近结果:成功」。与本门禁其余判据是同一类静默失败。</para>
- ///
- /// <para><b>本门禁不负责创建流程</b>:只回答「依赖满不满足」。补流程是管理员在
- /// 审批流程管理页的动作,自动补一条空壳流程只会把配置错误变成审批空转。</para>
- /// </summary>
- private async Task<S8RuleReadinessCheck> CheckFlowAsync(
- long tenantId, string bizType, string reasonCode, string message)
- {
- return await _flows.HasUsableFlowAsync(bizType, tenantId)
- ? S8RuleReadinessCheck.Pass()
- : S8RuleReadinessCheck.Fail(reasonCode, message);
- }
- private async Task<S8RuleReadinessCheck> CheckDepartmentsAsync(S8EffectiveRule effective, long tenantId)
- {
- var occurrence = effective.Parameters.DefaultOccurrenceDeptId;
- var responsible = effective.Parameters.DefaultResponsibleDeptId;
- // 未配置 —— 与「配了但非法」分开报,管理员需要知道到底是哪一种。
- if (!occurrence.HasValue)
- return S8RuleReadinessCheck.Fail(
- S8RuleReadinessReasonCode.RequiredParameterMissing,
- "启用失败:请先配置默认发生部门。");
- if (!responsible.HasValue)
- return S8RuleReadinessCheck.Fail(
- S8RuleReadinessReasonCode.RequiredParameterMissing,
- "启用失败:请先配置默认责任部门。");
- // 配了但不属当前租户(或已停用)。判据与运行时建单同源,见 S8DepartmentScopeValidator。
- if (!await _deptValidator.ExistsInTenantAsync(occurrence, tenantId))
- return S8RuleReadinessCheck.Fail(
- S8RuleReadinessReasonCode.DepartmentNotInTenant,
- "启用失败:默认发生部门不属于当前租户。");
- if (!await _deptValidator.ExistsInTenantAsync(responsible, tenantId))
- return S8RuleReadinessCheck.Fail(
- S8RuleReadinessReasonCode.DepartmentNotInTenant,
- "启用失败:默认责任部门不属于当前租户。");
- return S8RuleReadinessCheck.Pass();
- }
- /// <summary>
- /// 责任池判据:至少要有一个<b>当前有效</b>的成员。
- ///
- /// <para>判据走 <c>GetMembersAsync</c> 而不是「表里有没有行」:账号被停用或移出租户之后,
- /// 池子里的那一行还在,但那个人已经不算数了。只数行数会让一条实际上没人负责的规则
- /// 通过启用检查 —— 那正是本门禁存在的意义。</para>
- ///
- /// <para><b>「一个都没配」与「配了但都失效了」分开报</b>:管理员的下一步动作完全不同,
- /// 前者是去配人,后者是去查那几个账号出了什么事。</para>
- ///
- /// <para><b>空池绝不回落到任何角色</b>(fail closed)。回落到 <c>ROLE_QC</c> 之流会让
- /// 「我明明没给这条规则配人」和「有人在处理」同时成立,是最难查的一类错配。</para>
- /// </summary>
- private async Task<S8RuleReadinessCheck> CheckPoolAsync(
- long tenantId, string ruleCode, string responsibilityType,
- string reasonCode, string emptyMessage, string allInvalidMessage)
- {
- var members = await _pools.GetMembersAsync(tenantId, ruleCode, responsibilityType);
- if (members.Any(m => m.Valid)) return S8RuleReadinessCheck.Pass();
- return S8RuleReadinessCheck.Fail(
- reasonCode, members.Count == 0 ? emptyMessage : allInvalidMessage);
- }
- /// <summary>启用 / 立即执行路径用这一个:不就绪即抛 <see cref="S8BizException"/>(→ 400)。</summary>
- public async Task EnsureReadyAsync(S8EffectiveRule effective, long tenantId)
- {
- var result = await CheckAsync(effective, tenantId);
- if (!result.Ok)
- throw new S8BizException($"[{result.ReasonCode}] {result.Message}");
- }
- }
|