using BaseOUDAL; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; using YLErp.BLL; using YLErp.Commons; using YLErp.DBModels; using YLErp.Modules.RiskEngine.Dto; /* ================================================================================ 风控规则管理服务 — RiskRuleService 技术方案说明 ================================================================================ 【职责定位】 RiskRuleService 是风控引擎模块的 CRUD 管理服务层,负责规则(Rule)、应用配置 (Application)、变量(Variable)和操作审计日志(AuditLog)的全生命周期管理。 与 RiskEngineService(引擎执行层)的关系: - 本服务:配置管理(增删改查 + 审计日志) - RiskEngineService:运行时执行(缓存 + 公式解析 + 风控判定) - 交互方式:本服务在每次写操作后调用 RiskEngineService.RefreshCache() 通知引擎刷新 【设计文档】 详细设计文档:.trae/documents/RiskRuleService详细设计.md(v1.1) 需求来源文档:.trae/documents/风控引擎需求详细设计.md(v1.17) 核心概念: - Rule(规则):定义"什么情况下触发",支持两种公式模式(互斥): · 结构化模式(ConditionJson 有值):变量 + 操作符 + 阈值/变量 的条件列表,前端拼接 RuleExpr · 自由文本模式(RuleExpr 有值,ConditionJson 为空):前端直接编辑类 C# 表达式 - Application(应用配置):定义"何时、对谁、怎么处理",关联多条规则(RuleIds 逗号分隔)+ 策略/时点/范围 - Variable(变量):变量池中的可选项,用于构建规则条件(含取值表达式 VariableExpr) - AuditLog(审计日志):记录所有 CRUD 操作,支持版本追溯(SnapshotData 存储版本号快照) 【架构位置】 ┌─────────────────────────────────────────────────────────────────┐ │ API 层 │ │ RiskRuleController(27 个端点) │ │ - /api/risk-rules/* 规则 CRUD(10 个端点) │ │ - /api/risk-applications/* 应用配置 CRUD(9 个端点) │ │ - /api/risk-variables/* 变量池 CRUD(6 个端点) │ │ - /api/risk-audit-logs/* 审计日志(3 个端点) │ └──────────────────────────┬──────────────────────────────────────┘ │ ┌──────────────────────────▼──────────────────────────────────────┐ │ 服务层(本文件所在层) │ │ │ │ RiskRuleService(本文件) │ │ - 规则 / 应用 / 变量 / 审计日志的 CRUD │ │ - ConditionJson 完整校验(结构化模式:变量存在性、操作符、类型匹配)│ │ - RuleExpr 基础语法校验(自由文本模式:括号匹配、变量引用校验) │ │ - 乐观锁并发控制(Version 字段) │ │ - 规则删除同步解除应用关联;变量被任一未删除规则引用时禁止删除 │ │ - CRUD 后触发 RiskEngineService.RefreshCache() │ │ │ │ RiskEngineService(另一团队开发,单例) │ │ - 内存缓存管理 + 公式执行 + 风控判定 │ │ - RefreshCache():从数据库重新加载 Active 规则/应用/变量 │ └──────────────────────────┬──────────────────────────────────────┘ │ ┌──────────────────────────▼──────────────────────────────────────┐ │ 数据访问层(EF Core DbContext 直接访问,无独立 Repository) │ │ - glms_risk_rule 规则定义表 │ │ - glms_risk_rule_application 规则应用配置表(RuleIds 逗号分隔) │ │ - glms_risk_variable 变量池定义表 │ │ - glms_risk_rule_audit_log 操作审计日志表 │ └─────────────────────────────────────────────────────────────────┘ 【方法清单】(共 27 个公开方法 + 9 个私有辅助方法) ── 规则管理(10 个) ────────────────────────────────────────────── QueryRuleList(QueryRiskRuleReq) 查询规则列表(分页,含 ApplicationCount 子查询) GetRuleDetail(long) 获取规则详情(含关联应用摘要) CreateRule(CreateRiskRuleReq) 新建规则(ConditionJson 完整校验) UpdateRule(long, UpdateRiskRuleReq) 修改规则(乐观锁校验,Version+1) DeleteRule(long) 删除规则(从未删除应用中移除关联) EnableRule(long) 启用规则(仅 Disabled → Active) DisableRule(long) 停用规则(仅 Active → Disabled,不修改应用状态) GetRuleVersions(long) 获取规则版本历史(从审计日志 SnapshotData 提取版本号) BatchDeleteRules(List) 批量删除规则(同步移除应用关联,单次提交) BatchEnableRules(List) 批量启用规则(逐条编译校验) BatchDisableRules(List) 批量停用规则(不修改应用状态) GetRuleApplications(long) 获取规则关联的应用配置列表 GetAllRuleList() 获取所有未删除规则(轻量字段,供应用配置下拉) ── 应用配置管理(9 个) ─────────────────────────────────────────── QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(分页后批量计算规则统计) GetApplicationDetail(long) 获取应用详情(含关联规则信息) CreateApplication(CreateRiskApplicationReq) 新建应用(关联规则 Active 校验,触发时点/Scope 校验) UpdateApplication(long, UpdateRiskApplicationReq) 修改应用(乐观锁校验) DeleteApplication(long) 删除应用(软删除) BatchDeleteApplications(List) 批量删除应用(软删除) EnableApplication(long) 启用应用(至少一条关联规则 Active) DisableApplication(long) 停用应用 BatchEnableApplications(List) 批量启用(逐条校验至少一条 Active 规则) BatchDisableApplications(List) 批量停用 ── 变量管理(6 个) ─────────────────────────────────────────────── QueryVariableList(QueryRiskVariableReq) 查询变量列表(全量返回,Category/Keyword 筛选) GetVariableDetail(long) 获取变量详情(含 ReferenceCount 引用统计) CreateVariable(CreateRiskVariableReq) 新建变量(VariableExpr 长度校验 ≤10000) UpdateVariable(long, UpdateRiskVariableReq) 修改变量(乐观锁,DataType 变更保护) DeleteVariable(long) 删除变量(引用保护:有未删除规则引用时拒绝,软删除) GetAllVariableList() 获取所有已实现变量(轻量字段,供规则编辑器下拉) ── 审计日志(4 个) ─────────────────────────────────────────────── QueryAuditLogs(QueryRiskAuditLogReq) 查询审计日志(多条件筛选,分页) BuildAuditLogExportFileName(...) 根据查询条件生成导出文件名 ExportAuditLogs(QueryRiskAuditLogReq) 导出审计日志 Excel(ID 保持整数文本、类型中文映射) GetAuditLogDetail(long) 获取审计日志详情(含完整 SnapshotData) ── 私有辅助方法 ─────────────────────────────────────────────────── WriteAuditLog(...) 写入审计日志(含 snapshotData 可选参数) GetVariableExprSet() 获取变量表达式集合(5 分钟本地缓存) InvalidateVariableCache() 清除变量缓存 ValidateConditionJson(conditionJson) ConditionJson 完整校验(结构化模式) ValidateRuleExpr(ruleExpr) RuleExpr 基础语法校验(自由文本模式:括号匹配 + 变量引用校验) ValidateTriggerPoints(triggerPoints) 触发时点校验(5 个合法时点) ValidateScopeFields(req) Scope 字段校验(全局互斥 + ID 格式校验) ValidateIdList(idList, fieldName) ID 列表格式校验 RefreshOneRuleCache(ruleId) 单条规则缓存刷新(规则变更后调用) GetRuleOrThrow / GetApplicationOrThrow / GetVariableOrThrow 实体获取快捷方法 【关键设计决策】 1. 数据访问:直接使用 EF Core DbContext(无独立 Repository,与项目风格一致) 2. 事务策略:Create* 方法使用 BeginTransaction 包裹双 SaveChanges(实体 + 审计日志同事务) 3. 并发控制:乐观锁(Version 字段),修改时版本不匹配抛 ServiceException 4. 删除策略:规则删除同步从应用移除;变量删除保护所有未删除规则引用 5. ConditionJson 校验:变量存在性(按 VariableId)+ 操作符合法性(按 DataType 映射)+ 类型匹配 + 阈值变量引用 6. RuleExpr:自由文本模式下前端直接编辑,后端做基础语法校验(括号匹配 + 变量引用校验) 7. Scope 字段:逗号分隔字符串存储,全局适用时不允许指定其他维度 8. 缓存刷新:规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication() 9. 批量操作:逐项返回成功/未变更/失败,成功项统一提交 10. 变量缓存:5 分钟本地 HashSet 缓存,CRUD 后 InvalidateVariableCache() 11. 审计日志:CRUD + 审计日志同次提交,SnapshotData 存储版本快照 12. 多规则支持:Application 通过 RuleIds(逗号分隔)关联多条规则,内存解析关联关系 【数据库表】(MySQL,迁移脚本位于 Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql) glms_risk_rule 规则定义表 - 普通索引:Status, OptDate - 关键字段:ConditionJson(结构化条件列表), RuleExpr(类 C# 表达式,引擎执行用), Version(乐观锁) glms_risk_rule_application 规则应用配置表 - 普通索引:Status, OptDate - 关键字段:RuleIds(逗号分隔的规则 ID 列表), ControlStrategy(禁止/审批/提示), TriggerPoints(触发时点), Scope*(适用范围) glms_risk_variable 变量池定义表 - 关键字段:VariableExpr(取值表达式), DataType(数值/日期/布尔) glms_risk_rule_audit_log 操作审计日志表 - 普通索引:TargetType+TargetId, OperationType, OptDate - 关键字段:SnapshotData(JSON 快照,存储版本号等) 【关键文件清单】 本服务: - RiskRuleService.cs(本文件):规则/应用/变量/日志 CRUD 管理 - Dto/ 目录:请求/响应 DTO 模型 关联服务: - RiskEngineService.cs:引擎执行层(单例,本服务通过 GetInstance() 获取引用) - RiskRuleController.cs(YLErpWeb/Controllers/):API 控制器(27 个端点) 实体模型: - Framework/YLErp.Core/DBModels/glms_risk_rule.cs - Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs - Framework/YLErp.Core/DBModels/glms_risk_variable.cs - Framework/YLErp.Core/DBModels/glms_risk_rule_audit_log.cs 枚举定义: - Framework/YLErp.Core/DBModels/Enums/RiskRuleStatus.cs(Active/Disabled/Deleted) - Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.cs(Block/Approve/Warn) - Framework/YLErp.Core/DBModels/Enums/RiskVariableCategory.cs - Framework/YLErp.Core/DBModels/Enums/RiskVariableDataType.cs(Numeric/Date/Boolean) 【命名空间】 YLErp.Modules.RiskEngine 【注意事项】 1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新 3. 变量/规则/应用删除均为软删除(Status=Deleted),新建变量默认 Active 4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名 5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库 6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性) ================================================================================ */ namespace YLErp.Modules.RiskEngine { public class RiskRuleService : YLBaseService { private readonly IYcLogger _logger = LogFactory.GetLogger("RiskRuleService"); private HashSet _variableExprCache; private DateTime _variableCacheUpdateTime; private static readonly HashSet ValidTriggerPoints = new HashSet { "BOOK_CONFIRM", "CLOSE_REVIEW", "UPLOAD_CONFIRMATION", "EVENT_TRIGGER", "FUND_PAYMENT" }; private static readonly Dictionary AuditOperationTypeNames = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "RULE_CREATE", "新建规则" }, { "RULE_UPDATE", "修改规则" }, { "RULE_DELETE", "删除规则" }, { "RULE_ENABLE", "启用规则" }, { "RULE_DISABLE", "停用规则" }, { "RULE_BATCH_DELETE", "批量删除规则" }, { "RULE_BATCH_ENABLE", "批量启用规则" }, { "RULE_BATCH_DISABLE", "批量停用规则" }, { "APP_CREATE", "新建应用" }, { "APP_UPDATE", "修改应用" }, { "APP_DELETE", "删除应用" }, { "APP_ENABLE", "启用应用" }, { "APP_DISABLE", "停用应用" }, { "APP_BATCH_ENABLE", "批量启用应用" }, { "APP_BATCH_DISABLE", "批量停用应用" }, { "APP_BATCH_DELETE", "批量删除应用" }, // 仅用于历史审计日志展示;解耦后不再写入该操作类型。 { "RULE_CASCADE_DISABLE_APP", "规则级联停用应用" }, { "RULE_REMOVE_FROM_APP", "规则从应用移除" }, { "VAR_CREATE", "新增变量" }, { "VARIABLE_CREATE", "新增变量" }, { "VAR_UPDATE", "修改变量" }, { "VAR_DELETE", "删除变量" }, { "VAR_CASCADE_UPDATE_RULE", "变量级联更新规则" } }; private static readonly Dictionary AuditTargetTypeNames = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "RULE", "规则" }, { "APPLICATION", "应用" }, { "VARIABLE", "变量" } }; public RiskRuleService(OptUserInfo userInfo) : base(userInfo) { } public RiskRuleService(YLBaseService baseService) : base(baseService) { } public RiskRuleService(OptUserInfo optUser, YLContext dbContext) : base(optUser, dbContext) { } #region Private Helpers /// /// 保存变更,并将 EF 乐观并发冲突转换为可识别的业务异常。 /// Version 仍由业务代码手工递增;并发令牌只负责将原 Version 加入 UPDATE/DELETE 条件。 /// private void SaveChangesWithConcurrencyCheck() { try { DbContext.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { _logger.Error("风控配置发生并发修改冲突", ex); throw new ServiceException("数据已被其他用户修改,请重新加载后再操作", ex); } } private static string GetAuditOperationTypeName(string operationType) { if (string.IsNullOrWhiteSpace(operationType)) return string.Empty; return AuditOperationTypeNames.TryGetValue(operationType.Trim(), out var name) ? name : operationType; } private static string GetAuditTargetTypeName(string targetType) { if (string.IsNullOrWhiteSpace(targetType)) return string.Empty; return AuditTargetTypeNames.TryGetValue(targetType.Trim(), out var name) ? name : targetType; } private static string FormatAuditId(object value) { return value == null ? string.Empty : value.ToString(); } private static string SanitizeFileNamePart(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; var sanitized = Regex.Replace(value.Trim(), @"[\\/:*?""<>|\r\n]+", "_"); return sanitized.Length <= 40 ? sanitized : sanitized.Substring(0, 40); } /// /// 写入审计日志 /// private void WriteAuditLog(string operationType, string targetType, long targetId, string targetName, string operationDetail, string result = "成功", string snapshotData = null) { var log = new glms_risk_rule_audit_log { OperationType = operationType, TargetType = targetType, TargetId = targetId, TargetName = targetName, OperationDetail = operationDetail, Result = result, SnapshotData = snapshotData, OptId = UserId, OptName = UserName, OptDate = DateTime.Now }; DbContext.glms_risk_rule_audit_log.Add(log); } /// /// 获取变量表达式集合(5 分钟本地缓存) /// private HashSet GetVariableExprSet() { if (_variableExprCache == null || DateTime.Now - _variableCacheUpdateTime > TimeSpan.FromMinutes(5)) { _variableExprCache = DbContext.glms_risk_variable .Where(v => v.VariableExpr != null && v.VariableExpr != "") .Select(v => v.VariableExpr) .ToHashSet(); _variableCacheUpdateTime = DateTime.Now; } return _variableExprCache; } /// /// 失效变量表达式缓存 /// private void InvalidateVariableCache() { _variableExprCache = null; } /// /// 校验 ConditionJson 并生成服务端期望的简洁 RuleExpr。 /// private string ValidateConditionJson(string conditionJson) { return BuildRuleExprFromConditionJson(conditionJson); } /// /// 校验 RuleExpr(自由文本模式:括号匹配) /// private void ValidateRuleExpr(string ruleExpr) { if (ruleExpr.Length > 10000) throw new ServiceException("公式表达式长度不能超过 10000 字符"); int parenDepth = 0; foreach (char c in ruleExpr) { if (c == '(') parenDepth++; else if (c == ')') parenDepth--; if (parenDepth < 0) throw new ServiceException("公式表达式括号不匹配:多余的右括号"); } if (parenDepth != 0) throw new ServiceException("公式表达式括号不匹配:缺少右括号"); } /// /// 校验触发时点 /// private void ValidateTriggerPoints(string triggerPoints) { if (string.IsNullOrWhiteSpace(triggerPoints)) throw new ServiceException("触发时点不能为空"); var points = triggerPoints.Split(','); foreach (var point in points) { var trimmed = point.Trim(); if (!ValidTriggerPoints.Contains(trimmed)) throw new ServiceException($"触发时点 '{trimmed}' 不合法"); } if (points.All(p => string.IsNullOrWhiteSpace(p))) throw new ServiceException("至少需要指定一个触发时点"); } /// /// 校验 Scope 适用范围字段 /// private void ValidateScopeFields(CreateRiskApplicationReq req) { var hasAnyScope = !string.IsNullOrWhiteSpace(req.ScopeAssetBookIds) || !string.IsNullOrWhiteSpace(req.ScopeClientIds) || !string.IsNullOrWhiteSpace(req.ScopeUnderlyingTypes) || !string.IsNullOrWhiteSpace(req.ScopeTradeTypes); if (req.ScopeIsGlobal == 1) { if (hasAnyScope) { throw new ServiceException("全局适用时,不应指定其他适用范围维度"); } return; } if (!hasAnyScope) { throw new ServiceException("非全局应用至少需要指定一个适用范围"); } if (!string.IsNullOrWhiteSpace(req.ScopeAssetBookIds)) { ValidateIdList(req.ScopeAssetBookIds, "适用账户"); } if (!string.IsNullOrWhiteSpace(req.ScopeClientIds)) { ValidateIdList(req.ScopeClientIds, "适用对手方"); } } /// /// 校验 ID 列表格式 /// private void ValidateIdList(string idList, string fieldName) { var parts = idList.Split(','); foreach (var part in parts) { if (!int.TryParse(part.Trim(), out _)) throw new ServiceException($"{fieldName}列表格式不合法,应为逗号分隔的数字 ID"); } } /// /// 尝试刷新引擎缓存(不抛异常) /// private void TryRefreshCache() { try { RiskEngineService.GetInstance().RefreshCache(); } catch (Exception ex) { _logger.Error($"缓存刷新失败:{ex.Message}", ex); } } /// /// 获取数据类型中文名 /// private string GetDataTypeName(RiskVariableDataType dataType) { return dataType switch { RiskVariableDataType.Numeric => "数值", RiskVariableDataType.Date => "日期", RiskVariableDataType.Boolean => "布尔", _ => "未知" }; } /// /// 获取规则实体,不存在则抛异常 /// private glms_risk_rule GetRuleOrThrow(long ruleId) { var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status != RiskRuleStatus.Deleted); if (rule == null) throw new ServiceException("规则不存在或已删除"); return rule; } /// /// 获取应用配置实体,不存在则抛异常 /// private glms_risk_rule_application GetApplicationOrThrow(long applicationId) { var app = DbContext.glms_risk_rule_application.FirstOrDefault(a => a.id == applicationId && a.Status != RiskRuleStatus.Deleted); if (app == null) throw new ServiceException("应用配置不存在或已删除"); return app; } /// /// 获取变量实体,不存在则抛异常 /// private glms_risk_variable GetVariableOrThrow(long variableId) { var variable = DbContext.glms_risk_variable .FirstOrDefault(v => v.id == variableId && v.Status != RiskRuleStatus.Deleted); if (variable == null) throw new ServiceException("变量不存在或已删除"); return variable; } /// /// 校验规则参数(名称 + ConditionJson + RuleExpr) /// private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr, long? ruleId = null) { if (string.IsNullOrWhiteSpace(ruleName)) throw new ServiceException("规则名称不能为空"); if (ruleName.Length > 200) throw new ServiceException("规则名称长度不能超过200字符"); if (string.IsNullOrWhiteSpace(ruleExpr)) throw new ServiceException("规则表达式不能为空"); if (!string.IsNullOrWhiteSpace(conditionJson)) { var expectedRuleExpr = ValidateConditionJson(conditionJson); if (!string.Equals(expectedRuleExpr, ruleExpr, StringComparison.Ordinal)) { _logger.Info($"结构化规则公式一致性校验失败。RuleId={ruleId?.ToString() ?? "NEW"}; Expected={expectedRuleExpr}; Actual={ruleExpr}"); throw new ServiceException("公式与结构化条件不一致,请刷新后重试"); } } ValidateRuleExpr(ruleExpr); } /// /// 解析逗号分隔的规则 ID 字符串为 List /// private List ParseRuleIds(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) throw new ServiceException("关联规则 ID 不能为空"); var ids = new List(); foreach (var part in ruleIds.Split(',')) { if (long.TryParse(part.Trim(), out long id)) ids.Add(id); else throw new ServiceException($"规则 ID 列表格式不合法:'{part.Trim()}'"); } if (ids.Count == 0) throw new ServiceException("关联规则 ID 不能为空"); return ids; } /// /// 解析数据库中已保存的规则 ID。删除应用关联的最后一条规则后允许为空。 /// private static List ParseStoredRuleIds(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) return new List(); var ids = new List(); foreach (var part in ruleIds.Split(',')) { var value = part.Trim(); if (string.IsNullOrEmpty(value)) continue; if (!long.TryParse(value, out var id)) throw new ServiceException($"规则 ID 列表格式不合法:'{value}'"); ids.Add(id); } return ids.Distinct().ToList(); } private bool HasAnyActiveRule(string ruleIds) { var ids = ParseStoredRuleIds(ruleIds); return ids.Count > 0 && DbContext.glms_risk_rule .Any(r => ids.Contains(r.id) && r.Status == RiskRuleStatus.Active); } private Dictionary LoadRuleStatuses(IEnumerable storedRuleIds) { var ids = storedRuleIds.SelectMany(ParseStoredRuleIds).Distinct().ToList(); if (ids.Count == 0) return new Dictionary(); return DbContext.glms_risk_rule .Where(r => ids.Contains(r.id)) .Select(r => new { r.id, r.Status }) .ToDictionary(r => (long)r.id, r => r.Status); } private static void PopulateApplicationAvailability( RiskApplicationListItem item, IReadOnlyDictionary ruleStatuses) { var statuses = ParseStoredRuleIds(item.RuleIds) .Where(ruleStatuses.ContainsKey) .Select(id => ruleStatuses[id]) .ToList(); item.AssociatedRuleCount = statuses.Count(status => status != RiskRuleStatus.Deleted); item.EffectiveRuleCount = statuses.Count(status => status == RiskRuleStatus.Active); item.CanEnable = item.Status == (int)RiskRuleStatus.Disabled && item.EffectiveRuleCount > 0; item.CanDisable = item.Status == (int)RiskRuleStatus.Active && item.EffectiveRuleCount > 0; item.EnableButtonTooltip = item.Status == (int)RiskRuleStatus.Disabled && !item.CanEnable ? "该应用下所有规则均已全局停用,无法启用应用。请先在规则管理中启用至少一条规则" : string.Empty; item.DisableButtonTooltip = item.Status == (int)RiskRuleStatus.Active && !item.CanDisable ? "该应用下所有规则均已全局停用,应用无法生效。请先在规则管理中启用至少一条关联规则" : string.Empty; } private static void PopulateApplicationAvailability( RiskApplicationDetail item, IReadOnlyDictionary ruleStatuses) { var statuses = ParseStoredRuleIds(item.RuleIds) .Where(ruleStatuses.ContainsKey) .Select(id => ruleStatuses[id]) .ToList(); item.AssociatedRuleCount = statuses.Count(status => status != RiskRuleStatus.Deleted); item.EffectiveRuleCount = statuses.Count(status => status == RiskRuleStatus.Active); item.CanEnable = item.Status == RiskRuleStatus.Disabled && item.EffectiveRuleCount > 0; item.CanDisable = item.Status == RiskRuleStatus.Active && item.EffectiveRuleCount > 0; item.EnableButtonTooltip = item.Status == RiskRuleStatus.Disabled && !item.CanEnable ? "该应用下所有规则均已全局停用,无法启用应用。请先在规则管理中启用至少一条规则" : string.Empty; item.DisableButtonTooltip = item.Status == RiskRuleStatus.Active && !item.CanDisable ? "该应用下所有规则均已全局停用,应用无法生效。请先在规则管理中启用至少一条关联规则" : string.Empty; } /// /// 规范化逗号分隔的规则 ID:去除空格和重复项,统一为无空格格式。 /// private string NormalizeRuleIds(string ruleIds) { return string.Join(",", ParseRuleIds(ruleIds).Distinct()); } /// /// EF Core 可翻译的逗号分隔字段精确匹配表达式 /// private static Expression> RuleIdsMatchExpr(long ruleId) { var idStr = ruleId.ToString(); return a => a.RuleIds != null && (a.RuleIds == idStr || a.RuleIds.StartsWith(idStr + ",") || a.RuleIds.EndsWith("," + idStr) || a.RuleIds.Contains("," + idStr + ",")); } /// /// 校验规则状态可用性并确保编译缓存就绪。 /// 返回不可用规则信息列表,列表为空表示全部通过。 /// 规则不可用的情况:已删除、已停用、编译缓存缺失且刷新失败。 /// private List ValidateAndEnsureRuleCache(string ruleIds) { var errors = new List(); var ids = ParseRuleIds(ruleIds); var rules = DbContext.glms_risk_rule .Where(r => ids.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) .ToList(); if (rules.Count != ids.Count) { var missingIds = ids.Except(rules.Select(r => (long)r.id)).ToList(); foreach (var id in missingIds) errors.Add($"规则ID {id} 不存在或已删除"); return errors; } foreach (var rule in rules) { if (rule.Status == RiskRuleStatus.Disabled) { errors.Add($"规则 '{rule.RuleName}' 已停用"); continue; } // 检查编译缓存,未命中则刷新 if (!RuleCompiledCache.TryGet(rule.id.ToString(), out _)) { var result = RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); if (!result.Success) errors.Add($"规则 '{rule.RuleName}' 编译失败:{result.ErrorMessage}"); } } return errors; } /// /// 根据规则 ID 字符串解析为规则名称字符串 /// private string ResolveRuleNames(string ruleIds) { if (string.IsNullOrWhiteSpace(ruleIds)) return ""; var ids = ruleIds.Split(',') .Select(s => long.TryParse(s.Trim(), out long id) ? id : (long?)null) .Where(id => id.HasValue) .Select(id => id.Value) .ToList(); if (ids.Count == 0) return ""; var names = DbContext.glms_risk_rule .Where(r => ids.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) .Select(r => r.RuleName) .ToList(); return string.Join(", ", names); } /// /// 构建 Scope 字段的 OR 过滤谓词(逗号分隔值精确匹配) /// private static IQueryable ApplyScopeFilter( IQueryable query, Expression> fieldSelector, string csvValues) { var ids = csvValues.Split(',', StringSplitOptions.RemoveEmptyEntries); if (ids.Length == 0) return query; Expression> predicate = null; var param = fieldSelector.Parameters[0]; var member = fieldSelector.Body; foreach (var trimmed in ids.Select(id => id.Trim())) { var eq = Expression.Equal(member, Expression.Constant(trimmed)); var startsExpr = Expression.Call(member, nameof(string.StartsWith), null, Expression.Constant(trimmed + ",")); var endsExpr = Expression.Call(member, nameof(string.EndsWith), null, Expression.Constant("," + trimmed)); var containsExpr = Expression.Call(member, nameof(string.Contains), null, Expression.Constant("," + trimmed + ",")); var orExpr = Expression.OrElse(Expression.OrElse(eq, startsExpr), Expression.OrElse(endsExpr, containsExpr)); var lambda = Expression.Lambda>(orExpr, param); predicate = predicate == null ? lambda : predicate.Or(lambda); } return predicate != null ? query.Where(predicate) : query; } /// /// 获取控制策略中文名 /// private static string GetStrategyName(RiskControlStrategy strategy) { return strategy switch { RiskControlStrategy.Block => "阻断", RiskControlStrategy.Approval => "审批", RiskControlStrategy.ShowTip => "提醒", _ => strategy.ToString() }; } private static readonly Dictionary TriggerPointCnMap = new Dictionary { ["BOOK_CONFIRM"] = "交易录入确认", ["CLOSE_REVIEW"] = "平仓审核", ["UPLOAD_CONFIRMATION"] = "上传确认书", ["EVENT_TRIGGER"] = "事件触发", ["FUND_PAYMENT"] = "资金支付" }; /// /// 将触发时点映射为中文 /// private static string MapTriggerPointsToChinese(string triggerPoints) { if (string.IsNullOrWhiteSpace(triggerPoints)) return ""; var parts = triggerPoints.Split(','); var translated = parts.Select(p => TriggerPointCnMap.TryGetValue(p.Trim(), out var cn) ? cn : p.Trim()); return string.Join(", ", translated); } private static List DistinctIds(List ids) { return ids.Distinct().ToList(); } private static BatchOperationItemResult BatchItem(long id, BatchOperationItemStatus status, string code, string message, IEnumerable affectedApplicationIds = null, IEnumerable causedNoAvailableRuleApplicationIds = null) { return new BatchOperationItemResult { Id = id, Status = status, Code = code, Message = message, AffectedApplicationIds = affectedApplicationIds?.Distinct().ToList() ?? new List(), CausedNoAvailableRuleApplicationIds = causedNoAvailableRuleApplicationIds?.Distinct().ToList() ?? new List(), CascadedApplicationIds = new List() }; } /// /// 汇总逐项结果。业务失败彼此隔离,只有 Succeeded 表示实体已实际变更。 /// private static BatchOperationResult BuildBatchResult(List items) { var result = new BatchOperationResult { Items = items, TotalCount = items.Count, SucceededCount = items.Count(i => i.Status == BatchOperationItemStatus.Succeeded), UnchangedCount = items.Count(i => i.Status == BatchOperationItemStatus.Unchanged), FailedCount = items.Count(i => i.Status == BatchOperationItemStatus.Failed), AffectedApplicationIds = items.SelectMany(i => i.AffectedApplicationIds).Distinct().ToList(), CausedNoAvailableRuleApplicationIds = items.SelectMany(i => i.CausedNoAvailableRuleApplicationIds).Distinct().ToList(), CascadedApplicationIds = new List() }; result.HasChanges = result.SucceededCount > 0; result.Outcome = result.FailedCount == 0 ? BatchOperationOutcome.AllSucceeded : result.FailedCount == result.TotalCount ? BatchOperationOutcome.AllFailed : BatchOperationOutcome.PartiallySucceeded; return result; } /// /// 记录批量业务结果。预期业务失败使用 Info,避免触发生产 Error 告警。 /// private void LogBatchResult(string operationName, BatchOperationResult result) { const int maxFailedItems = 20; var failedItems = result.Items .Where(item => item.Status == BatchOperationItemStatus.Failed) .Take(maxFailedItems) .Select(item => $"{item.Id}:{item.Code}") .ToList(); var failedSummary = failedItems.Any() ? string.Join(",", failedItems) : "无"; var truncated = result.FailedCount > maxFailedItems ? ",...(已截断)" : string.Empty; _logger.Info($"[{operationName}] 批量操作完成。Total={result.TotalCount}, Succeeded={result.SucceededCount}, Unchanged={result.UnchangedCount}, Failed={result.FailedCount}, FailedItems={failedSummary}{truncated}"); } /// /// 数据库提交完成后刷新缓存;刷新失败仅记录 Error 告警,不将已提交操作返回为失败。 /// private void TryRefreshCache(Action refreshAction, string cacheName, IEnumerable affectedIds) { try { refreshAction(); } catch (Exception ex) { var ids = affectedIds?.Distinct().Take(20).ToList() ?? new List(); _logger.Error($"{cacheName}刷新失败,AffectedIds={string.Join(",", ids)}", ex); } } #endregion #region Rule Management /// /// 查询规则列表(分页,含 ApplicationCount 子查询) /// public SearchListResult QueryRuleList(QueryRiskRuleReq req) { var query = DbContext.glms_risk_rule .Where(r => r.Status != RiskRuleStatus.Deleted); if (!string.IsNullOrWhiteSpace(req.Keyword)) { query = query.Where(r => r.RuleName.Contains(req.Keyword) || r.RuleText.Contains(req.Keyword)); } if (req.Status.HasValue) { query = query.Where(r => r.Status == req.Status.Value); } if (req.VariableId.HasValue) { var referencedRuleIds = GetRulesByVariableId(req.VariableId.Value) .Select(r => (long)r.id) .ToList(); query = query.Where(r => referencedRuleIds.Contains(r.id)); } var result = query.OrderByDescending(r => r.UpdateDate) .Select(r => new RiskRuleListItem { Id = r.id, RuleName = r.RuleName, RuleText = r.RuleText, ConditionJson = r.ConditionJson, RuleExpr = r.RuleExpr, Description = r.Description, Status = r.Status, Version = r.Version, OptName = r.OptName, OptDate = r.OptDate.GetValueOrDefault(), UpdateOptName = r.UpdateOptName, UpdateDate = r.UpdateDate ?? r.OptDate.GetValueOrDefault(), ApplicationCount = DbContext.glms_risk_rule_application .Count(a => a.Status != RiskRuleStatus.Deleted && a.RuleIds != null && (a.RuleIds == r.id.ToString() || a.RuleIds.StartsWith(r.id.ToString() + ",") || a.RuleIds.EndsWith("," + r.id.ToString()) || a.RuleIds.Contains("," + r.id.ToString() + ","))) }); return result.ToSearchList(req); } /// /// 获取规则详情(含关联应用摘要) /// public RiskRuleDetail GetRuleDetail(long ruleId) { var rule = GetRuleOrThrow(ruleId); var applications = DbContext.glms_risk_rule_application .Where(a => a.Status != RiskRuleStatus.Deleted) .Where(RuleIdsMatchExpr(ruleId)) .Select(a => new RiskRuleApplicationSummary { Id = a.id, Status = a.Status, ControlStrategy = a.ControlStrategy, TriggerPoints = a.TriggerPoints }) .ToList(); return new RiskRuleDetail { Id = rule.id, RuleName = rule.RuleName, RuleText = rule.RuleText, ConditionJson = rule.ConditionJson, RuleExpr = rule.RuleExpr, Description = rule.Description, Status = rule.Status, Version = rule.Version, OptName = rule.OptName, OptDate = rule.OptDate.GetValueOrDefault(), UpdateOptName = rule.UpdateOptName, UpdateDate = rule.UpdateDate ?? rule.OptDate.GetValueOrDefault(), Applications = applications }; } /// /// 创建规则(ConditionJson 完整校验,事务包裹) /// public RiskRuleDetail CreateRule(CreateRiskRuleReq req) { ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); var compileResult = RuleCompiler.ValidateAndCompileFormula(null, req.RuleExpr); if (!compileResult.Success) throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); var entity = new glms_risk_rule { RuleName = req.RuleName, RuleText = req.RuleText, ConditionJson = req.ConditionJson, RuleExpr = req.RuleExpr, Description = req.Description, Status = RiskRuleStatus.Disabled, Version = 1 }; SetDBModelOpt(entity); entity.UpdateOptId = UserId; entity.UpdateOptName = UserName; entity.UpdateDate = DateTime.Now; using (var transaction = DbContext.Database.BeginTransaction()) { try { DbContext.glms_risk_rule.Add(entity); SaveChangesWithConcurrencyCheck(); WriteAuditLog("RULE_CREATE", "RULE", entity.id, req.RuleName, "创建规则", snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); SaveChangesWithConcurrencyCheck(); transaction.Commit(); } catch { transaction.Rollback(); throw; } } RiskEngineService.GetInstance().RefreshOneRuleCache(entity.id); return GetRuleDetail(entity.id); } /// /// 更新规则(乐观锁校验,Version+1) /// public RiskRuleDetail UpdateRule(long ruleId, UpdateRiskRuleReq req) { var rule = GetRuleOrThrow(ruleId); if (rule.Version != req.Version) throw new ServiceException("规则已被其他用户修改,请重新加载后再编辑"); ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr, ruleId); var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, req.RuleExpr); if (!compileResult.Success) throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); rule.RuleName = req.RuleName; rule.RuleText = req.RuleText; rule.ConditionJson = req.ConditionJson; rule.RuleExpr = req.RuleExpr; rule.Description = req.Description; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_UPDATE", "RULE", ruleId, req.RuleName, "修改规则"); SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); return GetRuleDetail(ruleId); } /// /// 删除规则(软删除,并从所有未删除应用中移除关联) /// public DeleteRuleResult DeleteRule(long ruleId) { var rule = GetRuleOrThrow(ruleId); var affectedApps = DbContext.glms_risk_rule_application .Where(a => a.Status != RiskRuleStatus.Deleted) .Where(RuleIdsMatchExpr(ruleId)) .ToList(); var affectedActiveAppCount = affectedApps.Count(a => a.Status == RiskRuleStatus.Active); var activeRuleIds = DbContext.glms_risk_rule .Where(r => r.Status == RiskRuleStatus.Active && r.id != ruleId) .Select(r => (long)r.id) .ToHashSet(); var causedNoAvailableRuleAppIds = new List(); rule.Status = RiskRuleStatus.Deleted; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_DELETE", "RULE", ruleId, rule.RuleName, "删除规则"); foreach (var app in affectedApps) { var remainingIds = ParseStoredRuleIds(app.RuleIds).Where(id => id != ruleId).ToList(); app.RuleIds = string.Join(",", remainingIds); app.Version++; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; if (!remainingIds.Any(activeRuleIds.Contains)) causedNoAvailableRuleAppIds.Add(app.id); WriteAuditLog("RULE_REMOVE_FROM_APP", "APPLICATION", app.id, $"应用{app.id}", $"删除规则'{rule.RuleName}'(ID:{ruleId}),已从应用关联规则中移除"); } SaveChangesWithConcurrencyCheck(); TryRefreshCache(() => RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId), "风控规则缓存", new[] { ruleId }); if (affectedApps.Any()) TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), "风控应用缓存", affectedApps.Select(a => (long)a.id)); return new DeleteRuleResult { RuleId = ruleId, AffectedActiveAppCount = affectedActiveAppCount, CausedNoAvailableRuleAppIds = causedNoAvailableRuleAppIds.Distinct().ToList() }; } /// /// 启用规则(仅 Disabled → Active) /// public void EnableRule(long ruleId) { var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status == RiskRuleStatus.Disabled); if (rule == null) throw new ServiceException("仅已停用的规则可以启用"); var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, rule.RuleExpr); if (!compileResult.Success) throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); rule.Status = RiskRuleStatus.Active; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_ENABLE", "RULE", ruleId, rule.RuleName, "启用规则"); SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); } /// /// 停用规则(仅 Active → Disabled,不修改关联应用状态) /// public DisableRuleResult DisableRule(long ruleId) { var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status == RiskRuleStatus.Active); if (rule == null) throw new ServiceException("仅已启用的规则可以停用"); var affectedActiveAppCount = DbContext.glms_risk_rule_application .Where(a => a.Status == RiskRuleStatus.Active) .Count(RuleIdsMatchExpr(ruleId)); rule.Status = RiskRuleStatus.Disabled; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_DISABLE", "RULE", ruleId, rule.RuleName, "停用规则"); SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshOneRuleCache(ruleId); return new DisableRuleResult { RuleId = ruleId, AffectedActiveAppCount = affectedActiveAppCount }; } /// /// 获取规则版本历史(从审计日志 SnapshotData 提取) /// public List GetRuleVersions(long ruleId) { GetRuleOrThrow(ruleId); var logs = DbContext.glms_risk_rule_audit_log .Where(l => l.TargetType == "RULE" && l.TargetId == ruleId) .OrderByDescending(l => l.OptDate) .ToList(); var result = new List(); foreach (var l in logs) { int version = 0; if (!string.IsNullOrEmpty(l.SnapshotData)) { try { var snapshot = JsonConvert.DeserializeAnonymousType(l.SnapshotData, new { Version = 0 }); if (snapshot != null) version = snapshot.Version; } catch { } } result.Add(new RiskRuleVersionItem { Version = version, OperationType = l.OperationType, OperationDetail = l.OperationDetail, OptName = l.OptName, OptDate = l.OptDate }); } return result; } /// /// 批量删除规则,并从所有未删除应用中移除成功删除的规则关联 /// public BatchOperationResult BatchDeleteRules(List ruleIds) { if (ruleIds == null || ruleIds.Count == 0) throw new ServiceException("规则ID列表不能为空"); var ids = DistinctIds(ruleIds); var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id); var changedIds = ids .Where(id => rules.TryGetValue(id, out var rule) && rule.Status != RiskRuleStatus.Deleted) .ToList(); var changedIdSet = changedIds.ToHashSet(); var affectedApps = changedIds.Count == 0 ? new List() : DbContext.glms_risk_rule_application .Where(a => a.Status != RiskRuleStatus.Deleted && a.RuleIds != null && a.RuleIds != "") .ToList() .Where(a => ParseStoredRuleIds(a.RuleIds).Any(changedIdSet.Contains)) .ToList(); var affectedByRule = changedIds.ToDictionary(id => id, _ => new List()); var noAvailableByRule = changedIds.ToDictionary(id => id, _ => new List()); var activeRuleIdsAfterDelete = DbContext.glms_risk_rule .Where(r => r.Status == RiskRuleStatus.Active && !changedIds.Contains(r.id)) .Select(r => (long)r.id) .ToHashSet(); foreach (var app in affectedApps) { var currentIds = ParseStoredRuleIds(app.RuleIds); var removedIds = currentIds.Where(changedIdSet.Contains).Distinct().ToList(); var remainingIds = currentIds.Where(id => !changedIdSet.Contains(id)).ToList(); app.RuleIds = string.Join(",", remainingIds); app.Version++; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; var hasActiveRule = remainingIds.Any(activeRuleIdsAfterDelete.Contains); foreach (var removedId in removedIds) { affectedByRule[removedId].Add(app.id); if (!hasActiveRule) noAvailableByRule[removedId].Add(app.id); WriteAuditLog("RULE_REMOVE_FROM_APP", "APPLICATION", app.id, $"应用{app.id}", $"批量删除规则'{rules[removedId].RuleName}'(ID:{removedId}),已从应用关联规则中移除"); } } var items = new List(); foreach (var id in ids) { if (!changedIdSet.Contains(id)) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "规则不存在或已删除")); continue; } var rule = rules[id]; rule.Status = RiskRuleStatus.Deleted; rule.Version++; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_BATCH_DELETE", "RULE", rule.id, rule.RuleName, "批量删除规则"); items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "规则已删除", affectedByRule[id], noAvailableByRule[id])); } if (changedIds.Any()) { SaveChangesWithConcurrencyCheck(); TryRefreshCache( () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), "风控规则缓存", changedIds); if (affectedApps.Any()) TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), "风控应用缓存", affectedApps.Select(a => (long)a.id)); } var result = BuildBatchResult(items); LogBatchResult("批量删除规则", result); return result; } /// /// 批量启用规则(仅 Disabled → Active,逐条编译校验) /// public BatchOperationResult BatchEnableRules(List ruleIds) { if (ruleIds == null || ruleIds.Count == 0) throw new ServiceException("规则ID列表不能为空"); var ids = DistinctIds(ruleIds); var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id); var items = new List(); var changedIds = new List(); foreach (var id in ids) { if (!rules.TryGetValue(id, out var rule) || rule.Status == RiskRuleStatus.Deleted) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "规则不存在或已删除")); continue; } if (rule.Status == RiskRuleStatus.Active) { items.Add(BatchItem(id, BatchOperationItemStatus.Unchanged, "ALREADY_ENABLED", "规则已启用,无需重复操作")); continue; } var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rule.RuleExpr); if (!compileResult.Success) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", $"规则表达式编译失败:{compileResult.ErrorMessage}")); continue; } rule.Status = RiskRuleStatus.Active; rule.Version++; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_BATCH_ENABLE", "RULE", rule.id, rule.RuleName, "批量启用规则"); changedIds.Add(id); items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "ENABLED", "规则已启用")); } if (changedIds.Any()) { // 仅成功启用项参与同一次提交和审计,编译失败不影响其他规则。 SaveChangesWithConcurrencyCheck(); TryRefreshCache( () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), "风控规则缓存", changedIds); } var result = BuildBatchResult(items); LogBatchResult("批量启用规则", result); return result; } /// /// 批量停用规则(仅 Active → Disabled,不修改关联应用状态) /// public BatchOperationResult BatchDisableRules(List ruleIds) { if (ruleIds == null || ruleIds.Count == 0) throw new ServiceException("规则ID列表不能为空"); var ids = DistinctIds(ruleIds); var rules = DbContext.glms_risk_rule.Where(r => ids.Contains(r.id)).ToDictionary(r => (long)r.id); var activeApps = DbContext.glms_risk_rule_application .Where(a => a.Status == RiskRuleStatus.Active && a.RuleIds != null && a.RuleIds != "") .ToList(); var items = new List(); var changedIds = new List(); foreach (var id in ids) { if (!rules.TryGetValue(id, out var rule) || rule.Status == RiskRuleStatus.Deleted) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "规则不存在或已删除")); continue; } if (rule.Status == RiskRuleStatus.Disabled) { items.Add(BatchItem(id, BatchOperationItemStatus.Unchanged, "ALREADY_DISABLED", "规则已停用,无需重复操作")); continue; } var affectedApplicationIds = activeApps .Where(a => ParseStoredRuleIds(a.RuleIds).Contains(id)) .Select(a => (long)a.id) .Distinct() .ToList(); rule.Status = RiskRuleStatus.Disabled; rule.Version++; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("RULE_BATCH_DISABLE", "RULE", rule.id, rule.RuleName, "批量停用规则"); changedIds.Add(id); items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "规则已停用", affectedApplicationIds)); } if (changedIds.Any()) { SaveChangesWithConcurrencyCheck(); TryRefreshCache( () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), "风控规则缓存", changedIds); } var result = BuildBatchResult(items); LogBatchResult("批量停用规则", result); return result; } /// /// 获取规则关联的应用配置列表 /// public List GetRuleApplications(long ruleId) { GetRuleOrThrow(ruleId); var apps = DbContext.glms_risk_rule_application .Where(a => a.Status != RiskRuleStatus.Deleted) .Where(RuleIdsMatchExpr(ruleId)) .OrderByDescending(a => a.UpdateDate) .ToList(); var ruleStatuses = LoadRuleStatuses(apps.Select(a => a.RuleIds)); var result = new List(); foreach (var app in apps) { var item = new RiskApplicationListItem { Id = app.id, RuleIds = app.RuleIds, Description = app.Description, Status = (int)app.Status, ControlStrategy = app.ControlStrategy, TriggerPoints = app.TriggerPoints, ScopeAssetBookIds = app.ScopeAssetBookIds, ScopeClientIds = app.ScopeClientIds, ScopeUnderlyingTypes = app.ScopeUnderlyingTypes, ScopeTradeTypes = app.ScopeTradeTypes, ScopeIsGlobal = app.ScopeIsGlobal, Version = app.Version, OptName = app.OptName, OptDate = app.OptDate.GetValueOrDefault(), UpdateOptName = app.UpdateOptName, UpdateDate = app.UpdateDate ?? app.OptDate.GetValueOrDefault() }; PopulateApplicationAvailability(item, ruleStatuses); result.Add(item); } return result; } /// /// 获取所有未删除规则列表(轻量字段,供下拉选择) /// public List GetAllRuleList() { return DbContext.glms_risk_rule .Where(r => r.Status != RiskRuleStatus.Deleted) .OrderBy(r => r.id) .Select(r => new RiskRuleSimpleItem { Id = r.id, RuleName = r.RuleName, RuleText = r.RuleText, Status = r.Status }) .ToList(); } #endregion #region Application Management /// /// 查询应用配置列表(多条件筛选,批量计算关联规则统计) /// public SearchListResult QueryApplicationList(QueryRiskApplicationReq req) { var query = DbContext.glms_risk_rule_application .Where(a => a.Status != RiskRuleStatus.Deleted); if (req.Status.HasValue) query = query.Where(a => a.Status == req.Status.Value); if (req.ControlStrategy.HasValue) query = query.Where(a => a.ControlStrategy == req.ControlStrategy.Value); if (!string.IsNullOrWhiteSpace(req.TriggerPoint)) query = query.Where(a => a.TriggerPoints.Contains(req.TriggerPoint)); if (!string.IsNullOrWhiteSpace(req.Keyword)) { query = query.Where(a => DbContext.glms_risk_rule .Any(r => r.Status != RiskRuleStatus.Deleted && r.RuleName.Contains(req.Keyword) && a.RuleIds != null && (a.RuleIds == r.id.ToString() || a.RuleIds.StartsWith(r.id.ToString() + ",") || a.RuleIds.EndsWith("," + r.id.ToString()) || a.RuleIds.Contains("," + r.id.ToString() + ",")))); } if (!string.IsNullOrWhiteSpace(req.ScopeClientIds)) query = ApplyScopeFilter(query, a => a.ScopeClientIds, req.ScopeClientIds); if (!string.IsNullOrWhiteSpace(req.ScopeUnderlyingTypes)) query = ApplyScopeFilter(query, a => a.ScopeUnderlyingTypes, req.ScopeUnderlyingTypes); if (!string.IsNullOrWhiteSpace(req.ScopeTradeTypes)) query = ApplyScopeFilter(query, a => a.ScopeTradeTypes, req.ScopeTradeTypes); if (!string.IsNullOrWhiteSpace(req.ScopeAssetBookIds)) query = ApplyScopeFilter(query, a => a.ScopeAssetBookIds, req.ScopeAssetBookIds); var pagedResult = query.OrderByDescending(a => a.UpdateDate) .Select(a => new RiskApplicationListItem { Id = a.id, RuleIds = a.RuleIds, Description = a.Description, Status = (int)a.Status, ControlStrategy = a.ControlStrategy, TriggerPoints = a.TriggerPoints, ScopeAssetBookIds = a.ScopeAssetBookIds, ScopeClientIds = a.ScopeClientIds, ScopeUnderlyingTypes = a.ScopeUnderlyingTypes, ScopeTradeTypes = a.ScopeTradeTypes, ScopeIsGlobal = a.ScopeIsGlobal, Version = a.Version, OptName = a.OptName, OptDate = a.OptDate.GetValueOrDefault(), UpdateOptName = a.UpdateOptName, UpdateDate = a.UpdateDate ?? a.OptDate.GetValueOrDefault() }) .ToSearchList(req); var items = pagedResult.rows?.ToList() ?? new List(); var ruleStatuses = LoadRuleStatuses(items.Select(item => item.RuleIds)); foreach (var item in items) PopulateApplicationAvailability(item, ruleStatuses); pagedResult.rows = items; return pagedResult; } /// /// 获取应用配置详情(含关联规则信息和可用状态) /// public RiskApplicationDetail GetApplicationDetail(long applicationId) { var app = DbContext.glms_risk_rule_application .FirstOrDefault(a => a.id == applicationId && a.Status != RiskRuleStatus.Deleted); if (app == null) throw new ServiceException("应用配置不存在或已删除"); var result = new RiskApplicationDetail { Id = app.id, RuleIds = app.RuleIds, Description = app.Description, Status = app.Status, ControlStrategy = app.ControlStrategy, TriggerPoints = app.TriggerPoints, ScopeAssetBookIds = app.ScopeAssetBookIds, ScopeClientIds = app.ScopeClientIds, ScopeUnderlyingTypes = app.ScopeUnderlyingTypes, ScopeTradeTypes = app.ScopeTradeTypes, ScopeIsGlobal = app.ScopeIsGlobal, Version = app.Version, OptName = app.OptName, OptDate = app.OptDate.GetValueOrDefault(), UpdateOptName = app.UpdateOptName, UpdateDate = app.UpdateDate ?? app.OptDate.GetValueOrDefault() }; PopulateApplicationAvailability(result, LoadRuleStatuses(new[] { app.RuleIds })); return result; } /// /// 创建应用配置(关联规则 Active 校验,事务包裹) /// public RiskApplicationDetail CreateApplication(CreateRiskApplicationReq req) { var normalizedRuleIds = NormalizeRuleIds(req.RuleIds); var errors = ValidateAndEnsureRuleCache(normalizedRuleIds); if (errors.Any()) throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}"); ValidateTriggerPoints(req.TriggerPoints); ValidateScopeFields(req); var entity = new glms_risk_rule_application { RuleIds = normalizedRuleIds, Description = req.Description, Status = RiskRuleStatus.Disabled, ControlStrategy = req.ControlStrategy, TriggerPoints = req.TriggerPoints, ScopeAssetBookIds = req.ScopeAssetBookIds, ScopeClientIds = req.ScopeClientIds, ScopeUnderlyingTypes = req.ScopeUnderlyingTypes, ScopeTradeTypes = req.ScopeTradeTypes, ScopeIsGlobal = req.ScopeIsGlobal, Version = 1 }; SetDBModelOpt(entity); entity.UpdateOptId = UserId; entity.UpdateOptName = UserName; entity.UpdateDate = DateTime.Now; using (var transaction = DbContext.Database.BeginTransaction()) { try { DbContext.glms_risk_rule_application.Add(entity); SaveChangesWithConcurrencyCheck(); WriteAuditLog("APP_CREATE", "APPLICATION", entity.id, ResolveRuleNames(entity.RuleIds), $"创建应用配置:策略={GetStrategyName(req.ControlStrategy)}, 触发时点={MapTriggerPointsToChinese(req.TriggerPoints)}", snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); SaveChangesWithConcurrencyCheck(); transaction.Commit(); } catch { transaction.Rollback(); throw; } } RiskEngineService.GetInstance().RefreshApplication(); return GetApplicationDetail(entity.id); } /// /// 更新应用配置(乐观锁校验) /// public RiskApplicationDetail UpdateApplication(long applicationId, UpdateRiskApplicationReq req) { var app = GetApplicationOrThrow(applicationId); if (app.Version != req.Version) throw new ServiceException("应用配置已被其他用户修改,请重新加载后再编辑"); string normalizedRuleIds = null; if (!string.IsNullOrWhiteSpace(req.RuleIds)) { normalizedRuleIds = NormalizeRuleIds(req.RuleIds); var errors = ValidateAndEnsureRuleCache(normalizedRuleIds); if (errors.Any()) throw new ServiceException($"以下关联规则不可用:{string.Join(";", errors)}"); } ValidateTriggerPoints(req.TriggerPoints); var scopeReq = new CreateRiskApplicationReq { ScopeAssetBookIds = req.ScopeAssetBookIds, ScopeClientIds = req.ScopeClientIds, ScopeUnderlyingTypes = req.ScopeUnderlyingTypes, ScopeTradeTypes = req.ScopeTradeTypes, ScopeIsGlobal = req.ScopeIsGlobal }; ValidateScopeFields(scopeReq); if (normalizedRuleIds != null) app.RuleIds = normalizedRuleIds; app.Description = req.Description; app.ControlStrategy = req.ControlStrategy; app.TriggerPoints = req.TriggerPoints; app.ScopeAssetBookIds = req.ScopeAssetBookIds; app.ScopeClientIds = req.ScopeClientIds; app.ScopeUnderlyingTypes = req.ScopeUnderlyingTypes; app.ScopeTradeTypes = req.ScopeTradeTypes; app.ScopeIsGlobal = req.ScopeIsGlobal; app.Version = app.Version + 1; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_UPDATE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), $"修改应用配置:策略={GetStrategyName(req.ControlStrategy)}, 触发时点={MapTriggerPointsToChinese(req.TriggerPoints)}"); SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshApplication(); return GetApplicationDetail(applicationId); } /// /// 删除应用配置(软删除) /// public void DeleteApplication(long applicationId) { var app = GetApplicationOrThrow(applicationId); app.Status = RiskRuleStatus.Deleted; app.Version = app.Version + 1; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_DELETE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "删除应用配置"); SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshApplication(); } /// /// 批量删除应用配置(软删除) /// public BatchOperationResult BatchDeleteApplications(List applicationIds) { if (applicationIds == null || applicationIds.Count == 0) throw new ServiceException("应用配置ID列表不能为空"); var ids = DistinctIds(applicationIds); var apps = DbContext.glms_risk_rule_application.Where(a => ids.Contains(a.id)).ToDictionary(a => (long)a.id); var items = new List(); foreach (var id in ids) { if (!apps.TryGetValue(id, out var app) || app.Status == RiskRuleStatus.Deleted) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "应用配置不存在或已删除")); continue; } app.Status = RiskRuleStatus.Deleted; app.Version++; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_BATCH_DELETE", "APPLICATION", app.id, ResolveRuleNames(app.RuleIds), "批量删除应用配置"); items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "应用配置已删除")); } if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } var result = BuildBatchResult(items); LogBatchResult("批量删除应用配置", result); return result; } /// /// 启用应用配置(至少存在一条 Active 关联规则) /// public void EnableApplication(long applicationId) { var app = DbContext.glms_risk_rule_application.FirstOrDefault(a => a.id == applicationId && a.Status == RiskRuleStatus.Disabled); if (app == null) throw new ServiceException("仅已停用的应用配置可以启用"); if (!HasAnyActiveRule(app.RuleIds)) throw new ServiceException("该应用下所有规则均已停用,无法启用应用。请先在规则管理中启用至少一条关联规则"); app.Status = RiskRuleStatus.Active; app.Version = app.Version + 1; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_ENABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "启用应用配置"); SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshApplication(); } /// /// 停用应用配置 /// public void DisableApplication(long applicationId) { var app = DbContext.glms_risk_rule_application.FirstOrDefault(a => a.id == applicationId && a.Status == RiskRuleStatus.Active); if (app == null) throw new ServiceException("仅已启用的应用配置可以停用"); app.Status = RiskRuleStatus.Disabled; app.Version = app.Version + 1; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_DISABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "停用应用配置"); SaveChangesWithConcurrencyCheck(); RiskEngineService.GetInstance().RefreshApplication(); } /// /// 批量启用应用配置(逐条校验至少一条关联规则已启用) /// public BatchOperationResult BatchEnableApplications(List applicationIds) { if (applicationIds == null || applicationIds.Count == 0) throw new ServiceException("应用配置ID列表不能为空"); var ids = DistinctIds(applicationIds); var apps = DbContext.glms_risk_rule_application.Where(a => ids.Contains(a.id)).ToDictionary(a => (long)a.id); var items = new List(); foreach (var id in ids) { if (!apps.TryGetValue(id, out var app)) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "应用配置不存在")); continue; } // Deleted 是终态,不能通过批量启用恢复,避免已删除配置重新参与风控执行。 if (app.Status == RiskRuleStatus.Deleted) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "DELETED_ENTITY", "已删除应用不能启用")); continue; } if (app.Status == RiskRuleStatus.Active) { items.Add(BatchItem(id, BatchOperationItemStatus.Unchanged, "ALREADY_ENABLED", "应用配置已启用,无需重复操作")); continue; } if (!HasAnyActiveRule(app.RuleIds)) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NO_ACTIVE_RULE", "该应用下所有规则均已停用,无法启用应用。请先在规则管理中启用至少一条关联规则")); continue; } app.Status = RiskRuleStatus.Active; app.Version++; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_BATCH_ENABLE", "APPLICATION", app.id, ResolveRuleNames(app.RuleIds), "批量启用应用配置"); items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "ENABLED", "应用配置已启用")); } if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } var result = BuildBatchResult(items); LogBatchResult("批量启用应用配置", result); return result; } /// /// 批量停用应用配置 /// public BatchOperationResult BatchDisableApplications(List applicationIds) { if (applicationIds == null || applicationIds.Count == 0) throw new ServiceException("应用配置ID列表不能为空"); var ids = DistinctIds(applicationIds); var apps = DbContext.glms_risk_rule_application.Where(a => ids.Contains(a.id)).ToDictionary(a => (long)a.id); var items = new List(); foreach (var id in ids) { if (!apps.TryGetValue(id, out var app)) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "应用配置不存在")); continue; } if (app.Status == RiskRuleStatus.Deleted) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "DELETED_ENTITY", "已删除应用不能停用")); continue; } if (app.Status == RiskRuleStatus.Disabled) { items.Add(BatchItem(id, BatchOperationItemStatus.Unchanged, "ALREADY_DISABLED", "应用配置已停用,无需重复操作")); continue; } app.Status = RiskRuleStatus.Disabled; app.Version++; app.UpdateOptId = UserId; app.UpdateOptName = UserName; app.UpdateDate = DateTime.Now; WriteAuditLog("APP_BATCH_DISABLE", "APPLICATION", app.id, ResolveRuleNames(app.RuleIds), "批量停用应用配置"); items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "应用配置已停用")); } if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } var result = BuildBatchResult(items); LogBatchResult("批量停用应用配置", result); return result; } #endregion /// /// 查询 ConditionJson 中四类变量引用字段命中指定变量 ID 的规则(仅结构化模式)。 /// private List GetRulesByVariableId(long variableId) { var allRules = DbContext.glms_risk_rule .Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null && r.ConditionJson != "") .ToList(); return allRules .Where(rule => RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, variableId)) .ToList(); } /// /// 生成变量删除被阻止时的引用规则提示。 /// private static string BuildVariableDeleteReferenceMessage(IEnumerable referencedRules) { var ruleIds = referencedRules .Select(rule => rule.id) .Distinct() .OrderBy(id => id) .ToList(); return $"该变量被 {ruleIds.Count} 个规则引用(规则ID:{string.Join("、", ruleIds)}),无法删除"; } /// /// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。 /// private string BuildRuleExprFromConditionJson(string conditionJson) { var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList(); var variables = DbContext.glms_risk_variable .Where(variable => variableIds.Contains(variable.id) && variable.Status != RiskRuleStatus.Deleted) .ToList() .ToDictionary(variable => (long)variable.id); var missingVariableIds = variableIds.Where(id => !variables.ContainsKey(id)).ToList(); if (missingVariableIds.Any()) throw new ServiceException($"公式引用的变量不存在:{string.Join(",", missingVariableIds)}"); return RuleConditionExpressionBuilder.Build(conditions, variables); } #region Variable Management /// /// 查询变量列表(全量返回,Category/Keyword 筛选) /// public SearchListResult QueryVariableList(QueryRiskVariableReq req) { var query = DbContext.glms_risk_variable .Where(v => v.Status != RiskRuleStatus.Deleted); if (req.Category.HasValue) { query = query.Where(v => v.Category == req.Category.Value); } if (req.DataType.HasValue) { query = query.Where(v => v.DataType == req.DataType.Value); } if (!string.IsNullOrWhiteSpace(req.VariableName)) { query = query.Where(v => v.VariableName.Contains(req.VariableName)); } if (req.Status.HasValue) { query = query.Where(v => v.Status == req.Status.Value); } var result = query.OrderBy(v => v.SortOrder).ThenBy(v => v.VariableName) .Select(v => new RiskVariableListItem { Id = v.id, VariableName = v.VariableName, Category = v.Category, DataType = v.DataType, Unit = v.Unit, ValueDomain = v.ValueDomain, Description = v.Description, VariableExpr = v.VariableExpr, Version = v.Version, SortOrder = v.SortOrder, Status = v.Status }); return result.ToSearchList(req); } /// /// 获取变量详情(含 ReferenceCount 引用统计) /// public RiskVariableDetail GetVariableDetail(long variableId) { var variable = GetVariableOrThrow(variableId); var referenceCount = GetRulesByVariableId(variableId) .Count(r => r.Status == RiskRuleStatus.Active); return new RiskVariableDetail { Id = variable.id, VariableName = variable.VariableName, Category = variable.Category, DataType = variable.DataType, Unit = variable.Unit, ValueDomain = variable.ValueDomain, Description = variable.Description, VariableExpr = variable.VariableExpr, Version = variable.Version, SortOrder = variable.SortOrder, Status = variable.Status, OptName = variable.OptName, OptDate = variable.OptDate.GetValueOrDefault(), UpdateOptName = variable.UpdateOptName, UpdateDate = variable.UpdateDate ?? variable.OptDate.GetValueOrDefault(), ReferenceCount = referenceCount }; } /// /// 创建变量(VariableExpr 长度校验 ≤10000,事务包裹) /// public RiskVariableDetail CreateVariable(CreateRiskVariableReq req) { if (string.IsNullOrWhiteSpace(req.VariableName)) throw new ServiceException("变量名称不能为空"); if (string.IsNullOrWhiteSpace(req.VariableExpr)) throw new ServiceException("取值表达式不能为空"); if (req.VariableExpr.Length > 10000) throw new ServiceException("取值表达式长度不能超过 10000 字符"); var entity = new glms_risk_variable { VariableName = req.VariableName, Category = req.Category, DataType = req.DataType, Unit = req.Unit, ValueDomain = req.ValueDomain, Description = req.Description, VariableExpr = req.VariableExpr, Status = RiskRuleStatus.Active, SortOrder = req.SortOrder, Version = 1 }; SetDBModelOpt(entity); entity.UpdateOptId = UserId; entity.UpdateOptName = UserName; entity.UpdateDate = DateTime.Now; using (var transaction = DbContext.Database.BeginTransaction()) { try { DbContext.glms_risk_variable.Add(entity); SaveChangesWithConcurrencyCheck(); WriteAuditLog("VARIABLE_CREATE", "VARIABLE", entity.id, req.VariableName, $"创建变量:{req.VariableName}", snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); SaveChangesWithConcurrencyCheck(); transaction.Commit(); } catch { transaction.Rollback(); throw; } } InvalidateVariableCache(); return GetVariableDetail(entity.id); } /// /// 更新变量(乐观锁,DataType 变更保护,级联更新引用规则 RuleExpr) /// public RiskVariableDetail UpdateVariable(long variableId, UpdateRiskVariableReq req) { var variable = GetVariableOrThrow(variableId); if (variable.Version != req.Version) throw new ServiceException("变量已被其他用户修改,请重新加载后再编辑"); if (string.IsNullOrWhiteSpace(req.VariableName)) throw new ServiceException("变量名称不能为空"); if (string.IsNullOrWhiteSpace(req.VariableExpr)) throw new ServiceException("取值表达式不能为空"); if (req.DataType != variable.DataType) { var activeRefCount = GetRulesByVariableId(variableId) .Count(r => r.Status == RiskRuleStatus.Active); if (activeRefCount > 0) throw new ServiceException($"该变量被 {activeRefCount} 个生效中的规则引用,无法修改数据类型"); } var oldVariableExpr = variable.VariableExpr; var oldDataType = variable.DataType; variable.VariableName = req.VariableName; variable.Category = req.Category; variable.DataType = req.DataType; variable.Unit = req.Unit; variable.ValueDomain = req.ValueDomain; variable.Description = req.Description; variable.VariableExpr = req.VariableExpr; variable.SortOrder = req.SortOrder; variable.Version = variable.Version + 1; variable.UpdateOptId = UserId; variable.UpdateOptName = UserName; variable.UpdateDate = DateTime.Now; var variableDefinitionChanged = oldVariableExpr != req.VariableExpr || oldDataType != req.DataType; if (variableDefinitionChanged) { List affectedRules; using (var transaction = DbContext.Database.BeginTransaction()) { try { // 在同一事务内完成引用发现、公式重建、编译和持久化;任一规则失败时整体回滚。 affectedRules = GetRulesByVariableId(variableId) .Where(r => r.RuleExpr != null) .ToList(); var rebuiltRuleExpressions = new Dictionary(); foreach (var rule in affectedRules) { var rebuiltRuleExpr = BuildRuleExprFromConditionJson(rule.ConditionJson); ValidateRuleExpr(rebuiltRuleExpr); var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rebuiltRuleExpr); if (!compileResult.Success) throw new ServiceException($"变量定义变更导致规则'{rule.RuleName}'(ID:{rule.id})编译失败:{compileResult.ErrorMessage}"); rebuiltRuleExpressions[rule.id] = rebuiltRuleExpr; } foreach (var rule in affectedRules) { rule.RuleExpr = rebuiltRuleExpressions[rule.id]; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("VAR_CASCADE_UPDATE_RULE", "RULE", rule.id, rule.RuleName, $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,规则表达式被级联更新"); } SaveChangesWithConcurrencyCheck(); WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); SaveChangesWithConcurrencyCheck(); transaction.Commit(); } catch { transaction.Rollback(); throw; } } InvalidateVariableCache(); foreach (var rule in affectedRules) { RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id); } } else { WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); SaveChangesWithConcurrencyCheck(); InvalidateVariableCache(); } return GetVariableDetail(variableId); } /// /// 删除变量(软删除:有未删除规则引用时拒绝) /// public void DeleteVariable(long variableId) { var variable = GetVariableOrThrow(variableId); var referencedRules = GetRulesByVariableId(variableId); if (referencedRules.Count > 0) throw new ServiceException(BuildVariableDeleteReferenceMessage(referencedRules)); variable.Status = RiskRuleStatus.Deleted; variable.Version = variable.Version + 1; variable.UpdateOptId = UserId; variable.UpdateOptName = UserName; variable.UpdateDate = DateTime.Now; WriteAuditLog("VAR_DELETE", "VARIABLE", variableId, variable.VariableName, $"删除变量:{variable.VariableName}"); SaveChangesWithConcurrencyCheck(); InvalidateVariableCache(); } /// /// 批量删除变量(不存在或被未删除规则引用的变量将被跳过) /// public BatchOperationResult BatchDeleteVariables(List variableIds) { if (variableIds == null || variableIds.Count == 0) throw new ServiceException("变量ID列表不能为空"); var ids = DistinctIds(variableIds); var variables = DbContext.glms_risk_variable .Where(v => ids.Contains(v.id) && v.Status != RiskRuleStatus.Deleted) .ToDictionary(v => (long)v.id); var rules = DbContext.glms_risk_rule .Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null && r.ConditionJson != "") .ToList(); var items = new List(); foreach (var id in ids) { if (!variables.TryGetValue(id, out var variable)) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "变量不存在或已删除")); continue; } var referencedRules = rules.Where(rule => RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, id)) .ToList(); if (referencedRules.Count > 0) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "RULE_REFERENCE", BuildVariableDeleteReferenceMessage(referencedRules))); continue; } variable.Status = RiskRuleStatus.Deleted; variable.Version = variable.Version + 1; variable.UpdateOptId = UserId; variable.UpdateOptName = UserName; variable.UpdateDate = DateTime.Now; WriteAuditLog("VAR_BATCH_DELETE", "VARIABLE", variable.id, variable.VariableName, $"批量删除变量:{variable.VariableName}"); items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DELETED", "变量已删除")); } if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { // 只有实际删除项写审计并参与一次提交;缺失和引用阻塞项不影响其他变量。 SaveChangesWithConcurrencyCheck(); TryRefreshCache(InvalidateVariableCache, "风控变量缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } var result = BuildBatchResult(items); LogBatchResult("批量删除变量", result); return result; } /// /// 获取所有变量列表(供下拉选择) /// public List GetAllVariableList() { var result = DbContext.glms_risk_variable .Where(v => v.VariableExpr != null && v.VariableExpr != "" && v.Status != RiskRuleStatus.Deleted) .OrderBy(v => v.SortOrder) .Select(v => new RiskVariableSimpleItem { Id = v.id, VariableName = v.VariableName, Category = v.Category, DataType = v.DataType, Unit = v.Unit, VariableExpr = v.VariableExpr, Status = v.Status }) .ToList(); return result; } #endregion #region Audit Log /// /// 查询审计日志列表(分页) /// public SearchListResult QueryAuditLogs(QueryRiskAuditLogReq req) { return BuildAuditLogQuery(req).ToSearchList(req); } private IQueryable BuildAuditLogQuery(QueryRiskAuditLogReq req) { var query = DbContext.glms_risk_rule_audit_log.AsQueryable(); if (!string.IsNullOrWhiteSpace(req.OperationType)) { query = query.Where(l => l.OperationType == req.OperationType); } if (!string.IsNullOrWhiteSpace(req.OperationTypes)) { var types = req.OperationTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); query = query.Where(l => types.Contains(l.OperationType)); } if (!string.IsNullOrWhiteSpace(req.TargetType)) { query = query.Where(l => l.TargetType == req.TargetType); } if (!string.IsNullOrWhiteSpace(req.TargetName)) { query = query.Where(l => l.TargetName.Contains(req.TargetName)); } if (!string.IsNullOrWhiteSpace(req.OptName)) { query = query.Where(l => l.OptName.Contains(req.OptName)); } if (req.StartDate.HasValue) { query = query.Where(l => l.OptDate >= req.StartDate.Value.Date); } if (req.EndDate.HasValue) { var end = req.EndDate.Value.Date.AddDays(1); query = query.Where(l => l.OptDate < end); } if (!string.IsNullOrWhiteSpace(req.Keyword)) { query = query.Where(l => l.TargetName.Contains(req.Keyword) || l.OperationDetail.Contains(req.Keyword)); } var result = query.OrderByDescending(l => l.OptDate) .Select(l => new RiskAuditLogListItem { Id = l.id, OperationType = l.OperationType, TargetType = l.TargetType, TargetId = l.TargetId, TargetName = l.TargetName, OperationDetail = l.OperationDetail, Result = l.Result, OptName = l.OptName, OptDate = l.OptDate }); return result; } /// /// 根据查询条件生成审计日志导出文件名 /// public string BuildAuditLogExportFileName(QueryRiskAuditLogReq req) { req ??= new QueryRiskAuditLogReq(); var conditions = new List(); var operationTypes = new List(); if (!string.IsNullOrWhiteSpace(req.OperationType)) operationTypes.Add(req.OperationType.Trim()); if (!string.IsNullOrWhiteSpace(req.OperationTypes)) { operationTypes.AddRange(req.OperationTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } var operationTypeNames = operationTypes .Distinct(StringComparer.OrdinalIgnoreCase) .Select(type => SanitizeFileNamePart(GetAuditOperationTypeName(type))) .ToList(); if (operationTypeNames.Any()) conditions.Add($"操作类型-{string.Join("+", operationTypeNames)}"); if (!string.IsNullOrWhiteSpace(req.TargetType)) conditions.Add($"目标类型-{SanitizeFileNamePart(GetAuditTargetTypeName(req.TargetType))}"); if (!string.IsNullOrWhiteSpace(req.TargetName)) conditions.Add($"目标名称-{SanitizeFileNamePart(req.TargetName)}"); if (!string.IsNullOrWhiteSpace(req.OptName)) conditions.Add($"操作人-{SanitizeFileNamePart(req.OptName)}"); if (req.StartDate.HasValue && req.EndDate.HasValue) conditions.Add($"日期-{req.StartDate.Value:yyyyMMdd}至{req.EndDate.Value:yyyyMMdd}"); else if (req.StartDate.HasValue) conditions.Add($"开始日期-{req.StartDate.Value:yyyyMMdd}"); else if (req.EndDate.HasValue) conditions.Add($"结束日期-{req.EndDate.Value:yyyyMMdd}"); if (!string.IsNullOrWhiteSpace(req.Keyword)) conditions.Add($"关键词-{SanitizeFileNamePart(req.Keyword)}"); var suffix = conditions.Any() ? string.Join("_", conditions) : "全部"; var fileNameWithoutExtension = $"风控操作日志_{suffix}"; if (fileNameWithoutExtension.Length > 180) fileNameWithoutExtension = fileNameWithoutExtension.Substring(0, 180); return $"{fileNameWithoutExtension}.xlsx"; } /// /// 导出审计日志为 Excel /// public byte[] ExportAuditLogs(QueryRiskAuditLogReq req) { var logs = BuildAuditLogQuery(req).ToList(); var columns = new List { new ExcelHelper.DataColumnModel("日志ID", "Id", typeof(string), (value, _) => FormatAuditId(value)), new ExcelHelper.DataColumnModel("操作类型", "OperationType", typeof(string), (value, _) => GetAuditOperationTypeName(value?.ToString())), new ExcelHelper.DataColumnModel("目标类型", "TargetType", typeof(string), (value, _) => GetAuditTargetTypeName(value?.ToString())), new ExcelHelper.DataColumnModel("目标ID", "TargetId", typeof(string), (value, _) => FormatAuditId(value)), new ExcelHelper.DataColumnModel("目标名称", "TargetName", typeof(string)), new ExcelHelper.DataColumnModel("操作详情", "OperationDetail", typeof(string)), new ExcelHelper.DataColumnModel("操作结果", "Result", typeof(string)), new ExcelHelper.DataColumnModel("操作人", "OptName", typeof(string)), new ExcelHelper.DataColumnModel("操作时间", "OptDate", (value, _) => value == null ? string.Empty : ((DateTime)value).ToString("yyyy-MM-dd HH:mm:ss")) }; new ExcelHelper().ListToExcel( columns.ToArray(), logs, "风控操作日志", true, out var buffer); return buffer; } /// /// 获取审计日志详情 /// public RiskAuditLogDetail GetAuditLogDetail(long logId) { var log = DbContext.glms_risk_rule_audit_log.FirstOrDefault(l => l.id == logId); if (log == null) throw new ServiceException("审计日志不存在"); return new RiskAuditLogDetail { Id = log.id, OperationType = log.OperationType, TargetType = log.TargetType, TargetId = log.TargetId, TargetName = log.TargetName, OperationDetail = log.OperationDetail, Result = log.Result, SnapshotData = log.SnapshotData, OptId = log.OptId, OptName = log.OptName, OptDate = log.OptDate }; } #endregion } }