2499 lines
111 KiB
C#
2499 lines
111 KiB
C#
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<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
|
||
- 关键字段: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 保存时校验长度,并通过 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
|
||
}
|
||
}
|