Files
zszq-trs/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs
T
2026-08-06 13:09:27 +08:00

2499 lines
111 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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详细设计.mdv1.1
需求来源文档:.trae/documents/风控引擎需求详细设计.mdv1.17)
核心概念:
- Rule(规则):定义"什么情况下触发",支持两种公式模式(互斥):
· 结构化模式(ConditionJson 有值):变量 + 操作符 + 阈值/变量 的条件列表,前端拼接 RuleExpr
· 自由文本模式(RuleExpr 有值,ConditionJson 为空):前端直接编辑类 C# 表达式
- Application(应用配置):定义"何时、对谁、怎么处理",关联多条规则(RuleIds 逗号分隔)+ 策略/时点/范围
- Variable(变量):变量池中的可选项,用于构建规则条件(含取值表达式 VariableExpr
- AuditLog(审计日志):记录所有 CRUD 操作,支持版本追溯(SnapshotData 存储版本号快照)
【架构位置】
┌─────────────────────────────────────────────────────────────────┐
│ API 层 │
│ RiskRuleController27 个端点) │
│ - /api/risk-rules/* 规则 CRUD10 个端点) │
│ - /api/risk-applications/* 应用配置 CRUD9 个端点) │
│ - /api/risk-variables/* 变量池 CRUD6 个端点) │
│ - /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<long>) 批量删除规则(同步移除应用关联,单次提交)
BatchEnableRules(List<long>) 批量启用规则(逐条编译校验)
BatchDisableRules(List<long>) 批量停用规则(不修改应用状态)
GetRuleApplications(long) 获取规则关联的应用配置列表
GetAllRuleList() 获取所有未删除规则(轻量字段,供应用配置下拉)
── 应用配置管理(9 个) ───────────────────────────────────────────
QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(分页后批量计算规则统计)
GetApplicationDetail(long) 获取应用详情(含关联规则信息)
CreateApplication(CreateRiskApplicationReq) 新建应用(关联规则 Active 校验,触发时点/Scope 校验)
UpdateApplication(long, UpdateRiskApplicationReq) 修改应用(乐观锁校验)
DeleteApplication(long) 删除应用(软删除)
BatchDeleteApplications(List<long>) 批量删除应用(软删除)
EnableApplication(long) 启用应用(至少一条关联规则 Active)
DisableApplication(long) 停用应用
BatchEnableApplications(List<long>) 批量启用(逐条校验至少一条 Active 规则)
BatchDisableApplications(List<long>) 批量停用
── 变量管理(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<string> 缓存,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
- 关键字段:SnapshotDataJSON 快照,存储版本号等)
【关键文件清单】
本服务:
- RiskRuleService.cs(本文件):规则/应用/变量/日志 CRUD 管理
- Dto/ 目录:请求/响应 DTO 模型
关联服务:
- RiskEngineService.cs:引擎执行层(单例,本服务通过 GetInstance() 获取引用)
- RiskRuleController.csYLErpWeb/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.csActive/Disabled/Deleted
- Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.csBlock/Approve/Warn
- Framework/YLErp.Core/DBModels/Enums/RiskVariableCategory.cs
- Framework/YLErp.Core/DBModels/Enums/RiskVariableDataType.csNumeric/Date/Boolean
【命名空间】
YLErp.Modules.RiskEngine
【注意事项】
1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用
2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新
3. 变量/规则/应用删除均为软删除(Status=Deleted),新建变量默认 Active
4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名
5. VariableExpr 保存时校验长度,并通过 Roslyn 做编译与安全校验
6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性)
================================================================================
*/
namespace YLErp.Modules.RiskEngine
{
public class RiskRuleService : YLBaseService
{
private readonly IYcLogger _logger = LogFactory.GetLogger("RiskRuleService");
private HashSet<string> _variableExprCache;
private DateTime _variableCacheUpdateTime;
private static readonly HashSet<string> ValidTriggerPoints = new HashSet<string>
{
"BOOK_CONFIRM",
"CLOSE_REVIEW",
"UPLOAD_CONFIRMATION",
"EVENT_TRIGGER",
"FUND_PAYMENT"
};
private static readonly Dictionary<string, string> AuditOperationTypeNames = new Dictionary<string, string>(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<string, string> AuditTargetTypeNames = new Dictionary<string, string>(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
/// <summary>
/// 保存变更,并将 EF 乐观并发冲突转换为可识别的业务异常。
/// Version 仍由业务代码手工递增;并发令牌只负责将原 Version 加入 UPDATE/DELETE 条件。
/// </summary>
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);
}
/// <summary>
/// 写入审计日志
/// </summary>
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);
}
/// <summary>
/// 获取变量表达式集合(5 分钟本地缓存)
/// </summary>
private HashSet<string> 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;
}
/// <summary>
/// 失效变量表达式缓存
/// </summary>
private void InvalidateVariableCache()
{
_variableExprCache = null;
}
/// <summary>
/// 校验 ConditionJson 结构、引用变量以及操作符/阈值/类型一致性。
/// 结构化规则运行时以 ConditionJson 为准,RuleExpr 只作为兼容/展示字段。
/// </summary>
private void ValidateConditionJson(string conditionJson)
{
var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson);
var variables = ValidateConditionJsonVariables(conditions);
var variableMap = variables.ToDictionary(variable => (long)variable.id);
RuleConditionExpressionBuilder.Build(conditions, variableMap);
}
/// <summary>
/// 校验 RuleExpr(自由文本模式:括号匹配)
/// </summary>
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("公式表达式括号不匹配:缺少右括号");
}
/// <summary>
/// 校验触发时点
/// </summary>
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("至少需要指定一个触发时点");
}
/// <summary>
/// 校验 Scope 适用范围字段
/// </summary>
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, "适用对手方");
}
}
/// <summary>
/// 校验 ID 列表格式
/// </summary>
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");
}
}
/// <summary>
/// 尝试刷新引擎缓存(不抛异常)
/// </summary>
private void TryRefreshCache()
{
try
{
RiskEngineService.GetInstance().RefreshCache();
}
catch (Exception ex)
{
_logger.Error($"缓存刷新失败:{ex.Message}", ex);
}
}
/// <summary>
/// 获取数据类型中文名
/// </summary>
private string GetDataTypeName(RiskVariableDataType dataType)
{
return dataType switch
{
RiskVariableDataType.Numeric => "数值",
RiskVariableDataType.Date => "日期",
RiskVariableDataType.Boolean => "布尔",
_ => "未知"
};
}
/// <summary>
/// 获取规则实体,不存在则抛异常
/// </summary>
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;
}
/// <summary>
/// 获取应用配置实体,不存在则抛异常
/// </summary>
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;
}
/// <summary>
/// 获取变量实体,不存在则抛异常
/// </summary>
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;
}
/// <summary>
/// 校验规则参数(名称 + ConditionJson / RuleExpr)。
/// 自由文本规则继续使用 RuleExpr;结构化规则允许保留 RuleExpr 作为兼容/展示字段,运行时以 ConditionJson 为准。
/// </summary>
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(conditionJson))
{
ValidateConditionJson(conditionJson);
return;
}
if (string.IsNullOrWhiteSpace(ruleExpr))
throw new ServiceException("规则表达式不能为空");
ValidateRuleExpr(ruleExpr);
}
/// <summary>
/// 校验规则编译:结构化规则运行时以 ConditionJson 为准,只有自由文本规则必须编译 RuleExpr。
/// </summary>
private void ValidateRuleCompile(long? ruleId, string conditionJson, string ruleExpr)
{
if (!string.IsNullOrWhiteSpace(conditionJson))
return;
var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, ruleExpr);
if (!compileResult.Success)
throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}");
}
/// <summary>
/// 解析逗号分隔的规则 ID 字符串为 List
/// </summary>
private List<long> ParseRuleIds(string ruleIds)
{
if (string.IsNullOrWhiteSpace(ruleIds))
throw new ServiceException("关联规则 ID 不能为空");
var ids = new List<long>();
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;
}
/// <summary>
/// 解析数据库中已保存的规则 ID。删除应用关联的最后一条规则后允许为空。
/// </summary>
private static List<long> ParseStoredRuleIds(string ruleIds)
{
if (string.IsNullOrWhiteSpace(ruleIds))
return new List<long>();
var ids = new List<long>();
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<long, RiskRuleStatus> LoadRuleStatuses(IEnumerable<string> storedRuleIds)
{
var ids = storedRuleIds.SelectMany(ParseStoredRuleIds).Distinct().ToList();
if (ids.Count == 0)
return new Dictionary<long, RiskRuleStatus>();
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<long, RiskRuleStatus> 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<long, RiskRuleStatus> 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;
}
/// <summary>
/// 规范化逗号分隔的规则 ID:去除空格和重复项,统一为无空格格式。
/// </summary>
private string NormalizeRuleIds(string ruleIds)
{
return string.Join(",", ParseRuleIds(ruleIds).Distinct());
}
/// <summary>
/// EF Core 可翻译的逗号分隔字段精确匹配表达式
/// </summary>
private static Expression<Func<glms_risk_rule_application, bool>> 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 + ","));
}
/// <summary>
/// 校验规则状态可用性并确保编译缓存就绪。
/// 返回不可用规则信息列表,列表为空表示全部通过。
/// 规则不可用的情况:已删除、已停用、编译缓存缺失且刷新失败。
/// </summary>
private List<string> ValidateAndEnsureRuleCache(string ruleIds)
{
var errors = new List<string>();
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;
}
/// <summary>
/// 根据规则 ID 字符串解析为规则名称字符串
/// </summary>
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);
}
/// <summary>
/// 构建 Scope 字段的 OR 过滤谓词(逗号分隔值精确匹配)
/// </summary>
private static IQueryable<glms_risk_rule_application> ApplyScopeFilter(
IQueryable<glms_risk_rule_application> query,
Expression<Func<glms_risk_rule_application, string>> fieldSelector,
string csvValues)
{
var ids = csvValues.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (ids.Length == 0)
return query;
Expression<Func<glms_risk_rule_application, bool>> 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<Func<glms_risk_rule_application, bool>>(orExpr, param);
predicate = predicate == null ? lambda : predicate.Or(lambda);
}
return predicate != null ? query.Where(predicate) : query;
}
/// <summary>
/// 获取控制策略中文名
/// </summary>
private static string GetStrategyName(RiskControlStrategy strategy)
{
return strategy switch
{
RiskControlStrategy.Block => "阻断",
RiskControlStrategy.Approval => "审批",
RiskControlStrategy.ShowTip => "提醒",
_ => strategy.ToString()
};
}
private static readonly Dictionary<string, string> TriggerPointCnMap = new Dictionary<string, string>
{
["BOOK_CONFIRM"] = "交易录入确认",
["CLOSE_REVIEW"] = "平仓审核",
["UPLOAD_CONFIRMATION"] = "上传确认书",
["EVENT_TRIGGER"] = "事件触发",
["FUND_PAYMENT"] = "资金支付"
};
/// <summary>
/// 将触发时点映射为中文
/// </summary>
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<long> DistinctIds(List<long> ids)
{
return ids.Distinct().ToList();
}
private static BatchOperationItemResult BatchItem(long id, BatchOperationItemStatus status,
string code, string message, IEnumerable<long> affectedApplicationIds = null,
IEnumerable<long> causedNoAvailableRuleApplicationIds = null)
{
return new BatchOperationItemResult
{
Id = id,
Status = status,
Code = code,
Message = message,
AffectedApplicationIds = affectedApplicationIds?.Distinct().ToList() ?? new List<long>(),
CausedNoAvailableRuleApplicationIds = causedNoAvailableRuleApplicationIds?.Distinct().ToList() ?? new List<long>(),
CascadedApplicationIds = new List<long>()
};
}
/// <summary>
/// 汇总逐项结果。业务失败彼此隔离,只有 Succeeded 表示实体已实际变更。
/// </summary>
private static BatchOperationResult BuildBatchResult(List<BatchOperationItemResult> 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<long>()
};
result.HasChanges = result.SucceededCount > 0;
result.Outcome = result.FailedCount == 0
? BatchOperationOutcome.AllSucceeded
: result.FailedCount == result.TotalCount
? BatchOperationOutcome.AllFailed
: BatchOperationOutcome.PartiallySucceeded;
return result;
}
/// <summary>
/// 记录批量业务结果。预期业务失败使用 Info,避免触发生产 Error 告警。
/// </summary>
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}");
}
/// <summary>
/// 数据库提交完成后刷新缓存;刷新失败仅记录 Error 告警,不将已提交操作返回为失败。
/// </summary>
private void TryRefreshCache(Action refreshAction, string cacheName, IEnumerable<long> affectedIds)
{
try
{
refreshAction();
}
catch (Exception ex)
{
var ids = affectedIds?.Distinct().Take(20).ToList() ?? new List<long>();
_logger.Error($"{cacheName}刷新失败,AffectedIds={string.Join(",", ids)}", ex);
}
}
#endregion
#region Rule Management
/// <summary>
/// 查询规则列表(分页,含 ApplicationCount 子查询)
/// </summary>
public SearchListResult<RiskRuleListItem> 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);
}
/// <summary>
/// 获取规则详情(含关联应用摘要)
/// </summary>
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
};
}
/// <summary>
/// 创建规则(ConditionJson 完整校验,事务包裹)
/// </summary>
public RiskRuleDetail CreateRule(CreateRiskRuleReq req)
{
ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr);
ValidateRuleCompile(null, req.ConditionJson, req.RuleExpr);
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);
}
/// <summary>
/// 更新规则(乐观锁校验,Version+1)
/// </summary>
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);
ValidateRuleCompile(ruleId, req.ConditionJson, req.RuleExpr);
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);
}
/// <summary>
/// 删除规则(软删除,并从所有未删除应用中移除关联)
/// </summary>
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<long>();
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)
{
List<long> remainingIds;
try
{
remainingIds = ParseStoredRuleIds(app.RuleIds).Where(id => id != ruleId).ToList();
}
catch (ServiceException ex)
{
_logger.Error($"删除规则时解析应用关联规则 ID 格式异常,跳过应用 {app.id}{ex.Message}");
continue;
}
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()
};
}
/// <summary>
/// 启用规则(仅 Disabled → Active
/// </summary>
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("仅已停用的规则可以启用");
ValidateRuleCompile(ruleId, rule.ConditionJson, rule.RuleExpr);
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);
}
/// <summary>
/// 停用规则(仅 Active → Disabled,不修改关联应用状态)
/// </summary>
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
};
}
/// <summary>
/// 获取规则版本历史(从审计日志 SnapshotData 提取)
/// </summary>
public List<RiskRuleVersionItem> 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<RiskRuleVersionItem>();
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;
}
/// <summary>
/// 批量删除规则,并从所有未删除应用中移除成功删除的规则关联
/// </summary>
public BatchOperationResult BatchDeleteRules(List<long> 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<glms_risk_rule_application>()
: 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<long>());
var noAvailableByRule = changedIds.ToDictionary(id => id, _ => new List<long>());
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<BatchOperationItemResult>();
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;
}
/// <summary>
/// 批量启用规则(仅 Disabled → Active,逐条编译校验)
/// </summary>
public BatchOperationResult BatchEnableRules(List<long> 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<BatchOperationItemResult>();
var changedIds = new List<long>();
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;
}
try
{
ValidateRuleCompile(rule.id, rule.ConditionJson, rule.RuleExpr);
}
catch (ServiceException ex)
{
items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", ex.Message));
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;
}
/// <summary>
/// 批量停用规则(仅 Active → Disabled,不修改关联应用状态)
/// </summary>
public BatchOperationResult BatchDisableRules(List<long> 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<BatchOperationItemResult>();
var changedIds = new List<long>();
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;
}
/// <summary>
/// 获取规则关联的应用配置列表
/// </summary>
public List<RiskApplicationListItem> 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<RiskApplicationListItem>();
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;
}
/// <summary>
/// 获取所有未删除规则列表(轻量字段,供下拉选择)
/// </summary>
public List<RiskRuleSimpleItem> 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
/// <summary>
/// 查询应用配置列表(多条件筛选,批量计算关联规则统计)
/// </summary>
public SearchListResult<RiskApplicationListItem> 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<RiskApplicationListItem>();
var ruleStatuses = LoadRuleStatuses(items.Select(item => item.RuleIds));
foreach (var item in items)
PopulateApplicationAvailability(item, ruleStatuses);
pagedResult.rows = items;
return pagedResult;
}
/// <summary>
/// 获取应用配置详情(含关联规则信息和可用状态)
/// </summary>
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;
}
/// <summary>
/// 创建应用配置(关联规则 Active 校验,事务包裹)
/// </summary>
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);
}
/// <summary>
/// 更新应用配置(乐观锁校验)
/// </summary>
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);
}
/// <summary>
/// 删除应用配置(软删除)
/// </summary>
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();
}
/// <summary>
/// 批量删除应用配置(软删除)
/// </summary>
public BatchOperationResult BatchDeleteApplications(List<long> 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<BatchOperationItemResult>();
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;
}
/// <summary>
/// 启用应用配置(至少存在一条 Active 关联规则)
/// </summary>
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();
}
/// <summary>
/// 停用应用配置
/// </summary>
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();
}
/// <summary>
/// 批量启用应用配置(逐条校验至少一条关联规则已启用)
/// </summary>
public BatchOperationResult BatchEnableApplications(List<long> 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<BatchOperationItemResult>();
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;
}
/// <summary>
/// 批量停用应用配置
/// </summary>
public BatchOperationResult BatchDisableApplications(List<long> 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<BatchOperationItemResult>();
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
/// <summary>
/// 查询 ConditionJson 中四类变量引用字段命中指定变量 ID 的规则(仅结构化模式)。
/// </summary>
private List<glms_risk_rule> GetRulesByVariableId(long variableId)
{
var idText = variableId.ToString();
var allRules = DbContext.glms_risk_rule
.Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null && r.ConditionJson != "")
.Where(r => r.ConditionJson.Contains(idText))
.ToList();
return allRules
.Where(rule => RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, variableId))
.ToList();
}
/// <summary>
/// 生成变量删除被阻止时的引用规则提示。
/// </summary>
private static string BuildVariableDeleteReferenceMessage(IEnumerable<glms_risk_rule> referencedRules)
{
var ruleIds = referencedRules
.Select(rule => rule.id)
.Distinct()
.OrderBy(id => id)
.ToList();
return $"该变量被 {ruleIds.Count} 个规则引用(规则ID{string.Join("", ruleIds)}),无法删除";
}
/// <summary>
/// 校验结构化 ConditionJson 引用的变量是否存在,并返回未删除的变量定义。
/// </summary>
private List<glms_risk_variable> ValidateConditionJsonVariables(IReadOnlyList<RuleCondition> conditions)
{
var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList();
var variables = DbContext.glms_risk_variable
.Where(variable => variableIds.Contains(variable.id) && variable.Status != RiskRuleStatus.Deleted)
.ToList();
var variableMap = variables.ToDictionary(variable => (long)variable.id);
var missingVariableIds = variableIds.Where(id => !variableMap.ContainsKey(id)).ToList();
if (missingVariableIds.Any())
throw new ServiceException($"公式引用的变量不存在:{string.Join(",", missingVariableIds)}");
return variables;
}
/// <summary>
/// 校验变量取值表达式是否可编译且不包含写库等危险调用。
/// 这里只做静态编译与安全检查,不执行表达式,避免依赖真实交易数据。
/// </summary>
private void ValidateVariableExpr(string variableExpr)
{
var compileResult = RuleCompiler.ValidateVariableExpression(variableExpr);
if (!compileResult.Success)
throw new ServiceException($"变量取值表达式校验失败:{compileResult.ErrorMessage}");
}
#region Variable Management
/// <summary>
/// 查询变量列表(全量返回,Category/Keyword 筛选)
/// </summary>
public SearchListResult<RiskVariableListItem> 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);
}
/// <summary>
/// 获取变量详情(含 ReferenceCount 引用统计)
/// </summary>
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
};
}
/// <summary>
/// 创建变量(VariableExpr 长度校验 ≤10000,事务包裹)
/// </summary>
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 字符");
ValidateVariableExpr(req.VariableExpr);
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);
}
/// <summary>
/// 更新变量(乐观锁,DataType 变更保护,级联更新引用规则 RuleExpr)
/// </summary>
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.VariableExpr.Length > 10000)
throw new ServiceException("取值表达式长度不能超过 10000 字符");
ValidateVariableExpr(req.VariableExpr);
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<glms_risk_rule> affectedRules;
using (var transaction = DbContext.Database.BeginTransaction())
{
try
{
// 结构化规则运行时直接按 ConditionJson 读取变量定义,不再把变量表达式展开回 RuleExpr。
// 因此变量定义变化时只标记引用规则受影响,避免多语句变量表达式被拼成非法公式。
affectedRules = GetRulesByVariableId(variableId)
.ToList();
foreach (var rule in affectedRules)
{
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();
StructuredRuleExecutor.RemoveVariableCompiledCache(variableId);
foreach (var rule in affectedRules)
{
RiskEngineService.GetInstance().RefreshOneRuleCache(rule.id);
}
}
else
{
WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}");
SaveChangesWithConcurrencyCheck();
InvalidateVariableCache();
StructuredRuleExecutor.RemoveVariableCompiledCache(variableId);
}
return GetVariableDetail(variableId);
}
/// <summary>
/// 删除变量(软删除:有未删除规则引用时拒绝)
/// </summary>
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();
StructuredRuleExecutor.RemoveVariableCompiledCache(variableId);
}
/// <summary>
/// 批量删除变量(不存在或被未删除规则引用的变量将被跳过)
/// </summary>
public BatchOperationResult BatchDeleteVariables(List<long> 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<BatchOperationItemResult>();
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();
var changedIds = items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id).ToList();
TryRefreshCache(() =>
{
InvalidateVariableCache();
StructuredRuleExecutor.RemoveVariableCompiledCache(changedIds);
}, "风控变量缓存", changedIds);
}
var result = BuildBatchResult(items);
LogBatchResult("批量删除变量", result);
return result;
}
/// <summary>
/// 获取所有变量列表(供下拉选择)
/// </summary>
public List<RiskVariableSimpleItem> 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
/// <summary>
/// 查询审计日志列表(分页)
/// </summary>
public SearchListResult<RiskAuditLogListItem> QueryAuditLogs(QueryRiskAuditLogReq req)
{
return BuildAuditLogQuery(req).ToSearchList(req);
}
private IQueryable<RiskAuditLogListItem> 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;
}
/// <summary>
/// 根据查询条件生成审计日志导出文件名
/// </summary>
public string BuildAuditLogExportFileName(QueryRiskAuditLogReq req)
{
req ??= new QueryRiskAuditLogReq();
var conditions = new List<string>();
var operationTypes = new List<string>();
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";
}
/// <summary>
/// 导出审计日志为 Excel
/// </summary>
public byte[] ExportAuditLogs(QueryRiskAuditLogReq req)
{
var logs = BuildAuditLogQuery(req).ToList();
var columns = new List<ExcelHelper.DataColumnModel>
{
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<RiskAuditLogListItem>(
columns.ToArray(), logs, "风控操作日志", true, out var buffer);
return buffer;
}
/// <summary>
/// 获取审计日志详情
/// </summary>
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
}
}