diff --git a/.editorconfig b/.editorconfig index 40eb71f1..e2964ef3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -160,3 +160,17 @@ csharp_preserve_single_line_statements = true # CA1819: Properties should not return arrays dotnet_diagnostic.CA1819.severity = silent + +# JavaScript 和 TypeScript 文件 +[*.{js,jsx,ts,tsx}] +# 缩进和间距 +indent_size = 4 +indent_style = space +tab_width = 4 + +# 新行首选项 +end_of_line = crlf +insert_final_newline = false + +# 拖尾逗号不添加 +trailing_comma = none diff --git a/Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.cs b/Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.cs new file mode 100644 index 00000000..c8d8ce8a --- /dev/null +++ b/Framework/YLErp.Core/DBModels/Enums/RiskControlStrategy.cs @@ -0,0 +1,9 @@ +namespace YLErp.DBModels +{ + public enum RiskControlStrategy + { + Block = 1, + Approval = 2, + ShowTip = 3 + } +} diff --git a/Framework/YLErp.Core/DBModels/Enums/RiskRuleStatus.cs b/Framework/YLErp.Core/DBModels/Enums/RiskRuleStatus.cs new file mode 100644 index 00000000..b4539183 --- /dev/null +++ b/Framework/YLErp.Core/DBModels/Enums/RiskRuleStatus.cs @@ -0,0 +1,9 @@ +namespace YLErp.DBModels +{ + public enum RiskRuleStatus + { + Active = 1, + Disabled = 2, + Deleted = 3 + } +} diff --git a/Framework/YLErp.Core/DBModels/Enums/RiskVariableCategory.cs b/Framework/YLErp.Core/DBModels/Enums/RiskVariableCategory.cs new file mode 100644 index 00000000..28a9e859 --- /dev/null +++ b/Framework/YLErp.Core/DBModels/Enums/RiskVariableCategory.cs @@ -0,0 +1,10 @@ +namespace YLErp.DBModels +{ + public enum RiskVariableCategory + { + BookingElement = 1, + MarketData = 2, + SystemCalc = 3, + BooleanCheck = 4 + } +} diff --git a/Framework/YLErp.Core/DBModels/Enums/RiskVariableDataType.cs b/Framework/YLErp.Core/DBModels/Enums/RiskVariableDataType.cs new file mode 100644 index 00000000..b5cd9b3d --- /dev/null +++ b/Framework/YLErp.Core/DBModels/Enums/RiskVariableDataType.cs @@ -0,0 +1,9 @@ +namespace YLErp.DBModels +{ + public enum RiskVariableDataType + { + Numeric = 1, + Date = 2, + Boolean = 3 + } +} diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule.cs new file mode 100644 index 00000000..1581b02f --- /dev/null +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + [Table("glms_risk_rule")] + public class glms_risk_rule : DBModelWithOperator + { + public string RuleName { get; set; } + public string RuleText { get; set; } + public string ConditionJson { get; set; } + public string RuleExpr { get; set; } + public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; + public int Version { get; set; } = 1; + public int? UpdateOptId { get; set; } + public string UpdateOptName { get; set; } + public DateTime? UpdateDate { get; set; } + } +} diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs new file mode 100644 index 00000000..a1d577ac --- /dev/null +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule_application.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + [Table("glms_risk_rule_application")] + public class glms_risk_rule_application : DBModelWithOperator + { + public string RuleIds { get; set; } + public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active; + public RiskControlStrategy ControlStrategy { get; set; } + public string TriggerPoints { get; set; } + public string ScopeAssetBookIds { get; set; } + public string ScopeClientIds { get; set; } + public string ScopeUnderlyingTypes { get; set; } + public string ScopeTradeTypes { get; set; } + public int ScopeIsGlobal { get; set; } + public int Version { get; set; } = 1; + public int? UpdateOptId { get; set; } + public string UpdateOptName { get; set; } + public DateTime? UpdateDate { get; set; } + } +} diff --git a/Framework/YLErp.Core/DBModels/glms_risk_rule_audit_log.cs b/Framework/YLErp.Core/DBModels/glms_risk_rule_audit_log.cs new file mode 100644 index 00000000..fca43e2b --- /dev/null +++ b/Framework/YLErp.Core/DBModels/glms_risk_rule_audit_log.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + [Table("glms_risk_rule_audit_log")] + public class glms_risk_rule_audit_log : DBModelBase + { + public string OperationType { get; set; } + public string TargetType { get; set; } + public long TargetId { get; set; } + public string TargetName { get; set; } + public string OperationDetail { get; set; } + public string Result { get; set; } + public string SnapshotData { get; set; } + public long OptId { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + } +} diff --git a/Framework/YLErp.Core/DBModels/glms_risk_variable.cs b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs new file mode 100644 index 00000000..099312ae --- /dev/null +++ b/Framework/YLErp.Core/DBModels/glms_risk_variable.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace YLErp.DBModels +{ + [Table("glms_risk_variable")] + public class glms_risk_variable : DBModelWithOperator + { + public string VariableName { get; set; } + public RiskVariableCategory Category { get; set; } + public RiskVariableDataType DataType { get; set; } + public string Unit { get; set; } + public string ValueDomain { get; set; } + public string Description { get; set; } + public string VariableExpr { get; set; } + public int Version { get; set; } = 1; + public int SortOrder { get; set; } + public int? UpdateOptId { get; set; } + public string UpdateOptName { get; set; } + public DateTime? UpdateDate { get; set; } + } +} diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql new file mode 100644 index 00000000..d9642a21 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql @@ -0,0 +1,82 @@ +SET FOREIGN_KEY_CHECKS=0; + +CREATE TABLE `glms_risk_rule` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', + `RuleName` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '规则名称', + `RuleText` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '规则文本', + `ConditionJson` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '条件JSON(结构化模式, RuleCondition数组)', + `RuleExpr` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '规则表达式(自由文本模式, 类C#表达式)', + `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', + `Version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)', + `OptId` int NULL DEFAULT NULL COMMENT '创建人Id', + `OptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建人名称', + `OptDate` datetime NULL DEFAULT NULL COMMENT '创建时间', + `UpdateOptId` int NULL DEFAULT NULL COMMENT '最后修改人Id', + `UpdateOptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '最后修改人名称', + `UpdateDate` datetime NULL DEFAULT NULL COMMENT '最后修改时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_status`(`Status` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控规则定义表' ROW_FORMAT = Dynamic; + +CREATE TABLE `glms_risk_rule_application` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', + `RuleIds` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '关联规则Id列表(逗号分隔)', + `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted', + `ControlStrategy` tinyint NOT NULL COMMENT '控制策略: 1=Block, 2=Approval, 3=Warning', + `TriggerPoints` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '触发时点(逗号分隔)', + `ScopeAssetBookIds` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '适用账户Id列表', + `ScopeClientIds` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '适用对手方Id列表', + `ScopeUnderlyingTypes` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '适用标的类型', + `ScopeTradeTypes` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '适用合约类型', + `ScopeIsGlobal` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否全局适用: 0=否, 1=是', + `Version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)', + `OptId` int NULL DEFAULT NULL COMMENT '创建人Id', + `OptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建人名称', + `OptDate` datetime NULL DEFAULT NULL COMMENT '创建时间', + `UpdateOptId` int NULL DEFAULT NULL COMMENT '最后修改人Id', + `UpdateOptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '最后修改人名称', + `UpdateDate` datetime NULL DEFAULT NULL COMMENT '最后修改时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_status`(`Status` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控规则应用配置表' ROW_FORMAT = Dynamic; + +CREATE TABLE `glms_risk_rule_audit_log` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', + `OperationType` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '操作类型(RULE_CREATE/RULE_UPDATE等)', + `TargetType` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '目标类型(RULE/APPLICATION/VARIABLE)', + `TargetId` bigint NOT NULL COMMENT '目标记录Id', + `TargetName` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '目标名称', + `OperationDetail` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '操作详情', + `Result` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '操作结果', + `SnapshotData` longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '快照数据(JSON)', + `OptId` bigint NOT NULL COMMENT '操作人Id', + `OptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '操作人名称', + `OptDate` datetime NOT NULL COMMENT '操作时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_opt_date`(`OptDate` ASC) USING BTREE, + INDEX `idx_operation_type`(`OperationType` ASC) USING BTREE, + INDEX `idx_target_type`(`TargetType` ASC) USING BTREE, + INDEX `idx_target_id`(`TargetId` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控规则操作审计日志表' ROW_FORMAT = Dynamic; + +CREATE TABLE `glms_risk_variable` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT '主键Id', + `VariableName` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '变量名称', + `Category` tinyint NOT NULL COMMENT '分类: 1=BookingElement, 2=MarketData, 3=SystemCalc, 4=BooleanCheck', + `DataType` tinyint NOT NULL COMMENT '数据类型: 1=Numeric, 2=Date, 3=Boolean', + `Unit` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '单位', + `ValueDomain` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '值域描述', + `Description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述', + `VariableExpr` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '变量表达式(C#表达式)', + `Version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)', + `SortOrder` int NOT NULL DEFAULT 0 COMMENT '排序', + `OptId` int NULL DEFAULT NULL COMMENT '创建人Id', + `OptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建人名称', + `OptDate` datetime NULL DEFAULT NULL COMMENT '创建时间', + `UpdateOptId` int NULL DEFAULT NULL COMMENT '最后修改人Id', + `UpdateOptName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '最后修改人名称', + `UpdateDate` datetime NULL DEFAULT NULL COMMENT '最后修改时间', + PRIMARY KEY (`id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控变量池定义表' ROW_FORMAT = Dynamic; + +SET FOREIGN_KEY_CHECKS=1; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql new file mode 100644 index 00000000..4573b7d0 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql @@ -0,0 +1,135 @@ +-- ============================================================ +-- 规则应用初始数据(对应设计文档 §4.9.1 通用规则预置参考) +-- 依赖:seed_rules.sql(规则数据需先插入) +-- +-- 每条规则对应一条应用配置(1:1),定义"何时、对谁、怎么处理" +-- ControlStrategy: 1=Block(禁止), 2=Approval(审批), 3=Warning(提示) +-- TriggerPoints: BOOK_CONFIRM=簿记交易确认 +-- ScopeIsGlobal: 1=全局适用, 0=按维度配置(Scope字段为NULL表示"全部") +-- ============================================================ + +-- 规则1:挂钩标的集中度超阈值(审批,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '挂钩标的集中度超阈值'; + +-- 规则2:挂钩标的到期日小于合约到期日(禁止,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 1, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '挂钩标的到期日小于合约到期日'; + +-- 规则3:名义本金超阈值(审批,账户/合约类型) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '名义本金超阈值'; + +-- 规则4:保证金支付比例超阈值(审批,账户/标的类型) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '保证金支付比例超阈值'; + +-- 规则5:保证金利率偏离(审批,账户) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '保证金利率偏离'; + +-- 规则6:保证金收取比例低于最低标准(审批,账户/标的类型) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '保证金收取比例低于最低标准'; + +-- 规则7:起息日早于当前日期(审批,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '起息日早于当前日期'; + +-- 规则8:支付日为银行间交易日(审批,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '支付日为银行间交易日'; + +-- 规则9:到期日为银行间交易日(审批,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '到期日为银行间交易日'; + +-- 规则10:平仓日为银行间交易日(审批,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '平仓日为银行间交易日'; + +-- 规则11:合约期限超阈值(审批,账户) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '合约期限超阈值'; + +-- 规则12:债券类净价偏离(审批,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '债券类净价偏离'; + +-- 规则13:债券类收益率偏离(审批,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '债券类收益率偏离'; + +-- 规则14:非债券类价格偏离(审批,全局) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '非债券类价格偏离'; + +-- 规则15:单一交易对手累计标的数量超阈值(审批,对手方) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '单一交易对手累计标的数量超阈值'; + +-- 规则16:多头支付固定端利率偏离(审批,账户) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '多头支付固定端利率偏离'; + +-- 规则17:空头利率减点借贷加权偏离(审批,账户) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '空头利率减点借贷加权偏离'; + +-- 规则18:账户授权收支方向不匹配(禁止,账户) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 1, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '账户授权收支方向不匹配'; + +-- 规则19:执行价偏离超阈值(审批,账户) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 2, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '执行价偏离超阈值'; + +-- 规则20:希腊字母限额超阈值(提示,账户/标的,预留接口) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 3, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 0, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '希腊字母限额超阈值'; + +-- 规则21:接近/触发敲入敲出价(提示,全局,预留接口) +INSERT INTO `glms_risk_rule_application` (`RuleIds`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`) +SELECT + CAST(r.id AS CHAR), 1, 3, 'BOOK_CONFIRM', NULL, NULL, NULL, NULL, 1, 1, 0, 'SYSTEM', NOW() +FROM `glms_risk_rule` r WHERE r.RuleName = '接近/触发敲入敲出价'; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql new file mode 100644 index 00000000..9751a1d9 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql @@ -0,0 +1,292 @@ +-- ============================================================ +-- 规则初始数据(对应设计文档 §4.9.1 通用规则预置参考) +-- 依赖:seed_variables.sql(变量池数据需先插入,ID 1~44) +-- +-- 变量 ID 映射(seed_variables.sql 自增): +-- 1=合约名义本金 2=合约起息日 3=合约到期日 4=合约平仓日 +-- 5=合约支付日 6=期初净价 7=期初全价 8=期初收益率 +-- 9=期初价格 10=期末全价 11=期末价格 12=保证金利率 +-- 13=保证金比例 14=客户授信额度 +-- 15=上一收盘日中债估值净价 16=上一收盘日中债估值全价 17=上一收盘日中债估值收益率 +-- 18=上一日收盘价 19=借贷加权费率 20=FR007 21=当前日期 +-- 22=挂钩标的到期日 23=标的发行余额 +-- 24=挂钩标的集中度 25=授信占用率 26=合约期限 27=Delta +-- 28=Gamma 29=Vega 30=Theta 31=利息端利率 +-- 32=对手方累计标的数量 33=同一标的累计名义本金 34=同一客户累计名义本金 +-- 35=总持仓名义本金 +-- 36=到期日是否银行间交易日 37=平仓日是否银行间交易日 38=支付日是否银行间交易日 +-- 39=利息端/浮动端方向是否同向 40=关键业务要素是否一致 +-- 41=多空方向为多头 42=多空方向为空头 +-- 43=保证金收支方向为支付 44=保证金收支方向为收取 +-- 45=期初净价偏离度 46=期初收益率偏离度 47=期初价格偏离度 +-- 48=利息端利率与FR007偏离度 49=利息端利率与借贷加权费率偏离度 +-- 50=执行价偏离度 51=执行价 52=客户品种最低保证金率 53=参考价格 +-- +-- ConditionJson 精简设计:只存 VariableId/Operator/ThresholdType/Value/ThresholdVariableId +-- 展示字段(VariableName/VariableType/Unit/ThresholdVariableName)从变量接口关联获取 +-- ============================================================ + + +-- ============================================================ +-- 插入 21 条通用规则 +-- ============================================================ + +-- 规则1:挂钩标的集中度超阈值(审批,全局) +-- 条件:挂钩标的集中度(ID=24) > 30% +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('挂钩标的集中度超阈值', + '挂钩标的集中度超过阈值(默认30%)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 24, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 30 + )), + 'calc.UnderlyingConcentration > 30', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则2:挂钩标的到期日小于合约到期日(禁止,全局) +-- 条件:挂钩标的到期日(ID=22) < 合约到期日(ID=3) +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('挂钩标的到期日小于合约到期日', + '挂钩标的到期日早于合约到期日时禁止交易', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 22, + 'Operator', '早于', 'ThresholdType', 'Variable', + 'ThresholdVariableId', 3 + )), + 'market.UnderlyingMaturityDate < trade.ExerciseDate', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则3:名义本金超阈值(审批,账户/合约类型) +-- 条件:合约名义本金(ID=1) > 100000000(1亿元) +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('名义本金超阈值', + '合约名义本金超过阈值(默认1亿元)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 1, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 100000000 + )), + 'trade.StockEqvNotional > 100000000', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则4:保证金支付比例超阈值(审批,账户/标的类型) +-- 条件:保证金比例(ID=13) > 50% +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('保证金支付比例超阈值', + '保证金比例超过阈值(默认50%)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 13, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 50 + )), + 'trade.MarginRate > 50', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则5:保证金利率偏离(审批,账户) +-- 条件:保证金利率(ID=12) 不介于 [2%, 5%] +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('保证金利率偏离', + '保证金利率不在配置区间内(默认2%~5%)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 12, + 'Operator', '不介于', 'ThresholdType', 'Fixed', 'Value', JSON_ARRAY(2, 5), + 'IncludeLowerBound', true, 'IncludeUpperBound', true + )), + '!(client_marginrate.InitMarginRebateRate >= 2 && client_marginrate.InitMarginRebateRate <= 5)', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则6:保证金收取比例低于最低标准(审批,账户/标的类型) +-- 条件:保证金比例(ID=13) < 客户品种最低保证金率(ID=52) +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('保证金收取比例低于最低标准', + '保证金比例低于客户品种最低保证金率时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 13, + 'Operator', '<', 'ThresholdType', 'Variable', + 'ThresholdVariableId', 52 + )), + 'trade.MarginRate < config.MinMarginRate', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则7:起息日早于当前日期(审批,全局) +-- 条件:合约起息日(ID=2) < 当前日期(ID=21) +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('起息日早于当前日期', + '合约起息日早于当前日期时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 2, + 'Operator', '早于', 'ThresholdType', 'Variable', + 'ThresholdVariableId', 21 + )), + 'trade.StartDate < sys.CurrentDate', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则8:支付日为银行间交易日(审批,全局) +-- 条件:支付日是否银行间交易日(ID=38) = 是 +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('支付日为银行间交易日', + '支付日为银行间交易日时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 38, + 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true + )), + 'calc.IsSettlementDateTradingDay == true', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则9:到期日为银行间交易日(审批,全局) +-- 条件:到期日是否银行间交易日(ID=36) = 是 +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('到期日为银行间交易日', + '到期日为银行间交易日时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 36, + 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true + )), + 'calc.IsExerciseDateTradingDay == true', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则10:平仓日为银行间交易日(审批,全局) +-- 条件:平仓日是否银行间交易日(ID=37) = 是 +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('平仓日为银行间交易日', + '平仓日为银行间交易日时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 37, + 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true + )), + 'calc.IsUnwindDateTradingDay == true', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则11:合约期限超阈值(审批,账户) +-- 条件:合约期限(ID=26) > 365天 +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('合约期限超阈值', + '合约期限超过阈值(默认365天)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 26, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 365 + )), + 'calc.MaturityDays > 365', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则12:债券类净价偏离(审批,全局) +-- 条件:期初净价偏离度(ID=45) > 5% +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('债券类净价偏离', + '期初净价与上一收盘日中债估值净价偏离度超阈值(默认5%)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 45, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 + )), + 'Math.Abs(swap_position.PosiNetNoFeePrice - market.CBValuationNetPrice) / market.CBValuationNetPrice * 100 > 5', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则13:债券类收益率偏离(审批,全局) +-- 条件:期初收益率偏离度(ID=46) > 5% +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('债券类收益率偏离', + '期初收益率与上一收盘日中债估值收益率偏离度超阈值(默认5%)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 46, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 + )), + 'Math.Abs(trade.InitYtm - market.CBValuationYtm) / market.CBValuationYtm * 100 > 5', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则14:非债券类价格偏离(审批,全局) +-- 条件:期初价格偏离度(ID=47) > 5% +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('非债券类价格偏离', + '期初价格与上一日收盘价偏离度超阈值(默认5%)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 47, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 + )), + 'Math.Abs(trade.SpotPrice - market.LastClosePrice) / market.LastClosePrice * 100 > 5', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则15:单一交易对手累计标的数量超阈值(审批,对手方) +-- 条件:对手方累计标的数量(ID=32) > 10个 +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('单一交易对手累计标的数量超阈值', + '对手方累计标的数量超过阈值(默认10个)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 32, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 10 + )), + 'calc.CounterpartyUnderlyingCount > 10', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则16:多头支付固定端利率偏离(审批,账户) +-- 条件:多空方向为多头(ID=41) AND 利息端利率与FR007偏离度(ID=48) > 3% +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('多头支付固定端利率偏离', + '多头方向支付固定端场景下,利息端利率与FR007偏离度超阈值(默认3%)时触发审批', + JSON_ARRAY( + JSON_OBJECT( + 'VariableId', 41, + 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true + ), + JSON_OBJECT( + 'VariableId', 48, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 3 + ) + ), + 'calc.IsLongDirection == true && Math.Abs(calc.InterestRate - market.FR007) / market.FR007 * 100 > 3', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则17:空头利率减点借贷加权偏离(审批,账户) +-- 条件:多空方向为空头(ID=42) AND 利息端利率与借贷加权费率偏离度(ID=49) > 2% +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('空头利率减点借贷加权偏离', + '空头方向场景下,利息端利率与借贷加权费率偏离度超阈值(默认2%)时触发审批', + JSON_ARRAY( + JSON_OBJECT( + 'VariableId', 42, + 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true + ), + JSON_OBJECT( + 'VariableId', 49, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 2 + ) + ), + 'calc.IsShortDirection == true && Math.Abs(calc.InterestRate - market.BondLendingRate) / market.BondLendingRate * 100 > 2', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则18:账户授权收支方向不匹配(禁止,账户) +-- 条件:保证金收支方向为支付(ID=43)(实际需结合账户授权方向判断,此处简化) +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('账户授权收支方向不匹配', + '保证金收支方向与账户授权方向不匹配时禁止交易', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 43, + 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true + )), + 'calc.IsMarginPay == true', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则19:执行价偏离超阈值(审批,账户) +-- 条件:执行价偏离度(ID=50) > 5% +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('执行价偏离超阈值', + '执行价与参考价格偏离度超阈值(默认5%)时触发审批', + JSON_ARRAY(JSON_OBJECT( + 'VariableId', 50, + 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 + )), + 'Math.Abs(trade.StrikePrice - market.ReferencePrice) / market.ReferencePrice * 100 > 5', + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则20:希腊字母限额超阈值(提示,预留接口,一期不纳入) +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('希腊字母限额超阈值', + 'Delta/Gamma/Vega/Theta任一超阈值时提示(预留接口,一期不纳入)', + NULL, + NULL, + 1, 1, 0, 'SYSTEM', NOW()); + +-- 规则21:接近/触发敲入敲出价(提示,预留接口,需确认具体判断逻辑) +INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES +('接近/触发敲入敲出价', + '标的价格接近敲入/敲出价时提示(预留接口,需确认具体判断逻辑)', + NULL, + NULL, + 1, 1, 0, 'SYSTEM', NOW()); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql new file mode 100644 index 00000000..934a3a27 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql @@ -0,0 +1,73 @@ +-- ============================================================ +-- 变量池初始数据(对应设计文档 §4.4 变量池完整清单) +-- ============================================================ + +-- 4.4.1 簿记要素类(Category=1) +INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES +('合约名义本金', 1, 1, '元', '≥ 0', 'trade.StockEqvNotional 或 swap_position.PosiNotionalValue', 'trade.StockEqvNotional', 101, 0, 'SYSTEM', NOW()), +('合约起息日', 1, 2, NULL, NULL, 'trade.StartDate 或 swap_position.PosiStartDate', 'trade.StartDate', 102, 0, 'SYSTEM', NOW()), +('合约到期日', 1, 2, NULL, NULL, 'trade.ExerciseDate 或 swap_position.PosiMatuirityDate', 'trade.ExerciseDate', 103, 0, 'SYSTEM', NOW()), +('合约平仓日', 1, 2, NULL, NULL, 'trade.UnWindDate', 'trade.UnWindDate', 104, 0, 'SYSTEM', NOW()), +('合约支付日', 1, 2, NULL, NULL, 'trade.SettlementDate', 'trade.SettlementDate', 105, 0, 'SYSTEM', NOW()), +('期初净价', 1, 1, '元', '≥ 0', 'swap_position.PosiNetNoFeePrice(债券 TRS)', 'swap_position.PosiNetNoFeePrice', 106, 0, 'SYSTEM', NOW()), +('期初全价', 1, 1, '元', '≥ 0', 'swap_position.PosiNetFeePrice(债券 TRS)/ trade.SpotPrice(其他)', 'swap_position.PosiNetFeePrice', 107, 0, 'SYSTEM', NOW()), +('期初收益率', 1, 1, '%', NULL, 'trade.InitYtm 或 swap_position.InitYtm', 'trade.InitYtm', 108, 0, 'SYSTEM', NOW()), +('期初价格', 1, 1, '元', '≥ 0', 'trade.SpotPrice(非债券类)', 'trade.SpotPrice', 109, 0, 'SYSTEM', NOW()), +('期末全价', 1, 1, '元', '≥ 0', 'eod_swap_position.UnderlyingPrice(债券 TRS)', 'eod_swap_position.UnderlyingPrice', 110, 0, 'SYSTEM', NOW()), +('期末价格', 1, 1, '元', '≥ 0', 'trade.FinalPrice', 'trade.FinalPrice', 111, 0, 'SYSTEM', NOW()), +('保证金利率', 1, 1, '%', NULL, 'client_marginrate.InitMarginRebateRate', 'client_marginrate.InitMarginRebateRate', 112, 0, 'SYSTEM', NOW()), +('保证金比例', 1, 1, '%', '0~100', 'trade.MarginRate', 'trade.MarginRate', 113, 0, 'SYSTEM', NOW()), +('客户授信额度', 1, 1, '元', '≥ 0', 'credit.Credit', 'credit.Credit', 114, 0, 'SYSTEM', NOW()); + +-- 4.4.2 行情类(Category=2,统一取上一交易日收盘价) +INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES +('上一收盘日中债估值净价', 2, 1, '元', '≥ 0', '资讯数据', 'market.CBValuationNetPrice', 201, 0, 'SYSTEM', NOW()), +('上一收盘日中债估值全价', 2, 1, '元', '≥ 0', '资讯数据', 'market.CBValuationFullPrice', 202, 0, 'SYSTEM', NOW()), +('上一收盘日中债估值收益率', 2, 1, '%', NULL, '资讯数据', 'market.CBValuationYtm', 203, 0, 'SYSTEM', NOW()), +('上一日收盘价', 2, 1, '元', '≥ 0', '行情数据,按标的区分', 'market.LastClosePrice', 204, 0, 'SYSTEM', NOW()), +('借贷加权费率', 2, 1, '%', NULL, 'CMDM 标的债券借贷费率行情表', 'market.BondLendingRate', 205, 0, 'SYSTEM', NOW()), +('FR007', 2, 1, '%', NULL, '上一交易日收盘价', 'market.FR007', 206, 0, 'SYSTEM', NOW()), +('当前日期', 2, 2, NULL, NULL, 'DateTime.Today', 'sys.CurrentDate', 207, 0, 'SYSTEM', NOW()), +('挂钩标的到期日', 2, 2, NULL, NULL, '资讯数据', 'market.UnderlyingMaturityDate', 208, 0, 'SYSTEM', NOW()), +('标的发行余额', 2, 1, '元', '≥ 0', '资讯数据', 'market.UnderlyingIssueBalance', 209, 0, 'SYSTEM', NOW()); + +-- 4.4.3 系统计算值类(Category=3) +INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES +('挂钩标的集中度', 3, 1, '%', '0~100', '同一标的存续交易总名义本金 ÷ 标的发行余额 × 100', 'calc.UnderlyingConcentration', 301, 0, 'SYSTEM', NOW()), +('授信占用率', 3, 1, '%', '0~100', '(已占用授信 + 本笔授信占用) ÷ 授信总额 × 100', 'calc.CreditUsageRate', 302, 0, 'SYSTEM', NOW()), +('合约期限', 3, 1, '天', '≥ 0', '(ExerciseDate - StartDate).Days', 'calc.MaturityDays', 303, 0, 'SYSTEM', NOW()), +('Delta', 3, 1, NULL, NULL, 'realtime_trade_risk.Delta(预留接口,一期不纳入)', 'realtime_trade_risk.Delta', 304, 0, 'SYSTEM', NOW()), +('Gamma', 3, 1, NULL, NULL, 'realtime_trade_risk.Gamma(预留接口,一期不纳入)', 'realtime_trade_risk.Gamma', 305, 0, 'SYSTEM', NOW()), +('Vega', 3, 1, NULL, NULL, 'realtime_trade_risk.Vega(预留接口,一期不纳入)', 'realtime_trade_risk.Vega', 306, 0, 'SYSTEM', NOW()), +('Theta', 3, 1, NULL, NULL, 'realtime_trade_risk.Theta(预留接口,一期不纳入)', 'realtime_trade_risk.Theta', 307, 0, 'SYSTEM', NOW()), +('利息端利率', 3, 1, '%', NULL, '固定利率 或 FR007 ± 加点', 'calc.InterestRate', 308, 0, 'SYSTEM', NOW()), +('对手方累计标的数量', 3, 1, '个', '≥ 0', 'COUNT(DISTINCT UnderlyingId) 该对手方所有存续交易,含本笔', 'calc.CounterpartyUnderlyingCount', 309, 0, 'SYSTEM', NOW()), +('同一标的累计名义本金', 3, 1, '元', '≥ 0', 'SUM(该标的所有存续交易的 StockEqvNotional),含本笔', 'calc.SameUnderlyingTotalNotional', 310, 0, 'SYSTEM', NOW()), +('同一客户累计名义本金', 3, 1, '元', '≥ 0', 'SUM(该客户所有存续交易的 StockEqvNotional),含本笔', 'calc.SameClientTotalNotional', 311, 0, 'SYSTEM', NOW()), +('总持仓名义本金', 3, 1, '元', '≥ 0', 'SUM(所有存续交易的 StockEqvNotional)', 'calc.TotalPositionNotional', 312, 0, 'SYSTEM', NOW()); +-- 新增变量(ID 45~53): +-- 45=期初净价偏离度 46=期初收益率偏离度 47=期初价格偏离度 +-- 48=利息端利率与FR007偏离度 49=利息端利率与借贷加权费率偏离度 +-- 50=执行价偏离度 51=执行价 52=客户品种最低保证金率 53=参考价格 +INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES +('期初净价偏离度', 3, 1, '%', '≥ 0', 'ABS(期初净价-中债估值净价)/中债估值净价*100', 'Math.Abs(swap_position.PosiNetNoFeePrice - market.CBValuationNetPrice) / market.CBValuationNetPrice * 100', 313, 0, 'SYSTEM', NOW()), +('期初收益率偏离度', 3, 1, '%', '≥ 0', 'ABS(期初收益率-中债估值收益率)/中债估值收益率*100', 'Math.Abs(trade.InitYtm - market.CBValuationYtm) / market.CBValuationYtm * 100', 314, 0, 'SYSTEM', NOW()), +('期初价格偏离度', 3, 1, '%', '≥ 0', 'ABS(期初价格-上一日收盘价)/上一日收盘价*100', 'Math.Abs(trade.SpotPrice - market.LastClosePrice) / market.LastClosePrice * 100', 315, 0, 'SYSTEM', NOW()), +('利息端利率与FR007偏离度', 3, 1, '%', '≥ 0', 'ABS(利息端利率-FR007)/FR007*100', 'Math.Abs(calc.InterestRate - market.FR007) / market.FR007 * 100', 316, 0, 'SYSTEM', NOW()), +('利息端利率与借贷加权费率偏离度', 3, 1, '%', '≥ 0', 'ABS(利息端利率-借贷加权费率)/借贷加权费率*100', 'Math.Abs(calc.InterestRate - market.BondLendingRate) / market.BondLendingRate * 100', 317, 0, 'SYSTEM', NOW()), +('执行价偏离度', 3, 1, '%', '≥ 0', 'ABS(执行价-参考价格)/参考价格*100', 'Math.Abs(trade.StrikePrice - market.ReferencePrice) / market.ReferencePrice * 100', 318, 0, 'SYSTEM', NOW()), +('执行价', 1, 1, '元', '≥ 0', 'trade.StrikePrice', 'trade.StrikePrice', 115, 0, 'SYSTEM', NOW()), +('客户品种最低保证金率', 1, 1, '%', '0~100', '客户品种最低保证金率(由账户/标的配置决定)', 'config.MinMarginRate', 116, 0, 'SYSTEM', NOW()), +('参考价格', 2, 1, '元', '≥ 0', '执行价参考价格(行情数据)', 'market.ReferencePrice', 210, 0, 'SYSTEM', NOW()); + +-- 4.4.4 布尔判断类(Category=4) +INSERT INTO `glms_risk_variable` (`VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `SortOrder`, `OptId`, `OptName`, `OptDate`) VALUES +('到期日是否银行间交易日', 4, 3, NULL, NULL, '查询银行间交易日历', 'calc.IsExerciseDateTradingDay', 401, 0, 'SYSTEM', NOW()), +('平仓日是否银行间交易日', 4, 3, NULL, NULL, '查询银行间交易日历', 'calc.IsUnwindDateTradingDay', 402, 0, 'SYSTEM', NOW()), +('支付日是否银行间交易日', 4, 3, NULL, NULL, '查询银行间交易日历', 'calc.IsSettlementDateTradingDay', 403, 0, 'SYSTEM', NOW()), +('利息端/浮动端方向是否同向', 4, 3, NULL, NULL, '利息端"收取"↔浮动端"多头",利息端"支付"↔浮动端"空头"', 'calc.IsInterestFloatSameDirection', 404, 0, 'SYSTEM', NOW()), +('关键业务要素是否一致', 4, 3, NULL, NULL, '交易确认书 vs 簿记要素(大模型方案)', 'calc.IsKeyElementsConsistent', 405, 0, 'SYSTEM', NOW()), +('多空方向为多头', 4, 3, NULL, NULL, 'trade.BuySell == "买入" 或浮动端为多头', 'calc.IsLongDirection', 406, 0, 'SYSTEM', NOW()), +('多空方向为空头', 4, 3, NULL, NULL, '与多头互斥', 'calc.IsShortDirection', 407, 0, 'SYSTEM', NOW()), +('保证金收支方向为支付', 4, 3, NULL, NULL, '保证金方向为支付', 'calc.IsMarginPay', 408, 0, 'SYSTEM', NOW()), +('保证金收支方向为收取', 4, 3, NULL, NULL, '与支付互斥', 'calc.IsMarginReceive', 409, 0, 'SYSTEM', NOW()); diff --git a/YLErpDAL/DataBase/YLContext.cs b/YLErpDAL/DataBase/YLContext.cs index b6ed6e6d..ce383a6d 100644 --- a/YLErpDAL/DataBase/YLContext.cs +++ b/YLErpDAL/DataBase/YLContext.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using YLErp.Core.DBModels; using YLErp.Model; @@ -408,5 +408,10 @@ namespace YLErp.BLL public DbSet tradeContractOaResult { get; set; } public DbSet bondPayment { get; set; } + public DbSet glms_risk_rule { get; set; } + public DbSet glms_risk_rule_application { get; set; } + public DbSet glms_risk_rule_audit_log { get; set; } + public DbSet glms_risk_variable { get; set; } + } } \ No newline at end of file diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompileResult.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompileResult.cs new file mode 100644 index 00000000..65a88901 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompileResult.cs @@ -0,0 +1,50 @@ +using System; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 规则编译结果。 + /// 用于承载公式校验、Roslyn 编译结果和错误信息。 + /// + public class RuleCompileResult + { + /// + /// 是否编译成功。 + /// + public bool Success { get; set; } + + /// + /// 编译失败时的错误信息。 + /// + public string ErrorMessage { get; set; } + + /// + /// 编译成功后的可执行委托。 + /// + public Func CompiledScript { get; set; } + + /// + /// 创建成功结果。 + /// + public static RuleCompileResult Ok(Func compiledScript) + { + return new RuleCompileResult + { + Success = true, + CompiledScript = compiledScript + }; + } + + /// + /// 创建失败结果。 + /// + public static RuleCompileResult Fail(string errorMessage) + { + return new RuleCompileResult + { + Success = false, + ErrorMessage = errorMessage + }; + } + } +} \ No newline at end of file diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs new file mode 100644 index 00000000..61fcd263 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiledCache.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Concurrent; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 规则编译结果的进程内缓存。 + /// 当前第一版按规则 Id 缓存 Roslyn 编译后的可执行委托。 + /// + public static class RuleCompiledCache + { + /// + /// 规则编译结果的进程内缓存。ConcurrentDictionary为线程安全的字典,用于缓存编译后的规则委托,性能略低。 + /// + private static readonly ConcurrentDictionary> Cache + = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + + /// + /// 写入或更新规则编译结果。 + /// + public static void Set(string ruleCode, Func compiledScript) + { + if (string.IsNullOrWhiteSpace(ruleCode) || compiledScript == null) + { + return; + } + + Cache[ruleCode] = compiledScript; + } + + /// + /// 尝试获取规则编译结果。 + /// + public static bool TryGet(string ruleCode, out Func compiledScript) + { + compiledScript = null; + + if (string.IsNullOrWhiteSpace(ruleCode)) + { + return false; + } + + return Cache.TryGetValue(ruleCode, out compiledScript); + } + + /// + /// 删除指定规则的编译结果。 + /// + public static void Remove(string ruleCode) + { + if (string.IsNullOrWhiteSpace(ruleCode)) + { + return; + } + + Cache.TryRemove(ruleCode, out _); + } + + /// + /// 清空全部规则编译结果。 + /// + public static void Clear() + { + Cache.Clear(); + } + } +} \ No newline at end of file diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs new file mode 100644 index 00000000..85875f15 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -0,0 +1,324 @@ +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// Roslyn 脚本全局变量容器 + /// CSharpScript 执行时通过此对象传递 DataMap + /// + public class ScriptGlobals + { + /// + /// 数据字典:key=表名/前缀,value=对应数据对象 + /// + public Dictionary DataMap { get; set; } + } + + /// + /// 规则编译器(Roslyn 版) + /// + /// 编译时机:规则定义时(零运行时解析开销) + /// 执行方式:判风控时直接调用 rule.CompiledScript(context) → bool + /// + /// 相比表达式树方案的优势: + /// - 代码简洁:生成 C# 字符串即可,无需手动拼接 Expression 节点 + /// - 灵活性高:天然支持多条件 AND/OR、复杂运算、未来可扩展任意 C# 语法 + /// - 可调试:生成的脚本代码可直接阅读和理解 + /// + /// 示例: + /// RuleExpr: Convert.ToDecimal(((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional) > 100000000m + /// 编译结果:Func<RiskContext, bool>(内部通过 Roslyn 编译缓存) + /// + public static class RuleCompiler + { + /// + /// 变量编码 → 类型全名的映射 + /// 用于 Roslyn 脚本中的类型强转,如 ((YLErp.DBModels.trade)DataMap["trade"]) + /// + static IYcLogger _logger =LogFactory.GetLogger("RuleCompiler"); + private static readonly Dictionary VariableTypeMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["trade"] = "YLErp.DBModels.trade", + ["swap_position"] = "YLErp.DBModels.swap_position", + ["client"] = "YLErp.DBModels.client", + ["credit"] = "YLErp.DBModels.credit", + ["client_marginrate"] = "YLErp.DBModels.client_marginrate", + ["market"] = "YLErp.DBModels.market", + ["calc"] = "YLErp.DBModels.calc", + ["sys"] = "YLErp.DBModels.sys", + ["eod_swap_position"] = "YLErp.DBModels.eod_swap_position", + ["realtime_trade_risk"] = "YLErp.DBModels.realtime_trade_risk", + }; + + /// + /// 将规则编译为可执行委托,并写入 rule.CompiledScript + /// 调用时机:规则创建时 / 规则加载时 / 缓存刷新时 + /// + public static void Compile(this RiskRule rule) + { + var result = ValidateAndCompileRule(rule); + if (!result.Success) + { + throw new InvalidOperationException(result.ErrorMessage); + } + } + + /// + /// 校验并编译规则表达式。 + /// 当前 RuleExpr 要求是 Roslyn 可直接执行的 bool 表达式。 + /// + public static RuleCompileResult ValidateAndCompileFormula(string formulaExp) + { + if (string.IsNullOrWhiteSpace(formulaExp)) + { + return RuleCompileResult.Fail("规则 RuleExpr 不能为空"); + } + + try + { + var compiled = CompileScript(formulaExp); + return RuleCompileResult.Ok(compiled); + } + catch (Exception ex) + { + return RuleCompileResult.Fail(ex.Message); + } + } + + /// + /// 校验并编译规则,编译成功后同时写入规则对象和内存缓存。 + /// + public static RuleCompileResult ValidateAndCompileRule(RiskRule rule) + { + if (rule == null) + { + return RuleCompileResult.Fail("规则不能为空"); + } + + if (rule.Id <= 0) + { + return RuleCompileResult.Fail("规则 Id 不能为空"); + } + + var result = ValidateAndCompileFormula(rule.RuleExpr); + if (!result.Success) + { + return RuleCompileResult.Fail($"规则[{rule.Id}]编译失败:{result.ErrorMessage}"); + } + + rule.CompiledScript = result.CompiledScript; + RuleCompiledCache.Set(rule.Id.ToString(), result.CompiledScript); + return result; + } + + /// + /// 解析 ConditionJson,提取条件列表。 + /// 要求 expression 明确描述实际取值路径。 + /// 当前主执行链不再使用该方法,仅作为早期方案保留。 + /// + // private static List<(string variableCode, string expression, string op, object value, string variableType)> ParseConditionJson(string conditionJson) + // { + // var result = new List<(string, string, string, object, string)>(); + // var jObj = JObject.Parse(formulaJson); + // var conditions = jObj["conditions"] as JArray; + + // if (conditions == null || !conditions.Any()) + // { + // throw new ArgumentException("ConditionJson 中缺少 conditions"); + // } + + // foreach (var cond in conditions) + // { + // string variableCode = cond["variableCode"]?.Value(); + // string expression = cond["expression"]?.Value(); + // string op = cond["operator"]?.Value(); + // object value = cond["value"]?.Value(); + // string variableType = cond["variableType"]?.Value() ?? "numeric"; + + // if (string.IsNullOrWhiteSpace(expression) || string.IsNullOrWhiteSpace(op)) + // { + // throw new ArgumentException("条件中缺少 expression 或 operator"); + // } + + // result.Add((variableCode, expression, op, value, variableType)); + // } + + // return result; + // } + + /// + /// 根据条件列表生成 Roslyn C# 脚本代码。 + /// 多条件自动用 &&(AND)连接。 + /// 左值表达式统一基于 expression 构建。 + /// + private static string BuildScriptCode(List<(string variableCode, string expression, string op, object value, string variableType)> conditions) + { + var exprParts = new List(); + + foreach (var (_, expression, op, value, variableType) in conditions) + { + // 先根据 expression 构建左值访问表达式,再拼接比较符和右值字面量 + string leftExpr = BuildMemberAccessExpression(expression, variableType); + + // 格式化阈值 + string valueLiteral = FormatValueLiteral(value, variableType); + + // 格式化操作符 + string csharpOp = FormatOperator(op); + + // 生成单条件表达式 + string expr = $"{leftExpr} {csharpOp} {valueLiteral}"; + exprParts.Add(expr); + } + + // 多条件用 && 连接 + return string.Join(" && ", exprParts); + } + + /// + /// 将前端传入的 expression 转成 Roslyn 可执行的成员访问表达式。 + /// 例如 trade.StockEqvNotional 会转成 ((YLErp.DBModels.trade)DataMap["trade"]).StockEqvNotional。 + /// 数值类型会自动包一层 Convert.ToDecimal,便于与 decimal 阈值比较。 + /// + private static string BuildMemberAccessExpression(string expression, string variableType) + { + var parts = expression.Split('.'); + if (parts.Length < 2) + { + throw new ArgumentException($"expression 格式不正确:{expression}"); + } + + string prefix = parts[0].Trim().ToLower(); + if (!VariableTypeMap.TryGetValue(prefix, out string typeFullName)) + { + throw new ArgumentException($"未知前缀:{prefix}"); + } + + // 除前缀外,其余部分都视为成员访问路径,便于后续扩展多级属性访问 + string memberAccess = string.Join(".", parts.Skip(1).Select(p => p.Trim())); + string objectExpr = $"(({typeFullName})DataMap[\"{prefix}\"]).{memberAccess}"; + + if (variableType == "numeric" || variableType == "number") + { + return $"Convert.ToDecimal({objectExpr})"; + } + + return objectExpr; + } + + /// + /// 用 Roslyn 编译 C# 脚本代码为可执行委托 + /// + private static Func CompileScript(string scriptCode) + { + // 配置编译选项:引用必要的程序集 + var options = ScriptOptions.Default + .WithReferences( + typeof(RiskContext).Assembly, // YLErpDAL + typeof(YLErp.DBModels.trade).Assembly // Model 所在程序集 + ) + .WithImports("System"); + + // 创建脚本(尚未执行,仅编译) + var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); + + // 编译(提前发现语法错误) + //后续编译需提供接口,返回前端编译信息,包含编译错误列表 + var compilation = script.GetCompilation(); + var diagnostics = compilation.GetDiagnostics(); + var errors = diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).ToList(); + if (errors.Any()) + { + string errorMsg = string.Join("; ", errors.Select(e => e.GetMessage())); + _logger.Error($"规则编译失败:{errorMsg}\n脚本代码:{scriptCode}"); + throw new InvalidOperationException($"脚本编译失败:{errorMsg}"); + } + + // 生成可调用委托 + var runner = script.CreateDelegate(); + + // 包装为同步的 Func + return ctx => + { + var globals = new ScriptGlobals { DataMap = ctx.DataMap }; + try + { + return runner(globals).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + // 脚本执行异常(如空引用、类型转换失败)视为规则不触发 + _logger.Error($"规则执行异常:{ex.Message}\n脚本代码:{scriptCode}"); + return false; + } + }; + } + + /// + /// 格式化阈值为 C# 字面量 + /// + private static string FormatValueLiteral(object value, string variableType) + { + if (value == null) return "null"; + + string strValue = value.ToString(); + + // 数值类型统一加 m 后缀(decimal) + if (variableType == "numeric" || variableType == "number") + { + if (decimal.TryParse(strValue, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal d)) + { + return d.ToString(CultureInfo.InvariantCulture) + "m"; + } + } + + // 布尔 + if (variableType == "boolean" || variableType == "bool") + { + if (bool.TryParse(strValue, out bool b)) + { + return b ? "true" : "false"; + } + } + + // 日期 + if (variableType == "date" && DateTime.TryParse(strValue, out DateTime dt)) + { + return $"DateTime.Parse(\"{dt:yyyy-MM-dd}\")"; + } + + // 字符串(兜底) + return $"\"{strValue.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; + } + + /// + /// 将操作符统一为 C# 操作符 + /// + private static string FormatOperator(string op) + { + return op?.Trim() switch + { + // 数值型 C# 符号 + "=" or "==" => "==", + "!=" or "≠" => "!=", + ">" => ">", + "<" => "<", + ">=" => ">=", + "<=" => "<=", + // 日期型中文语义 → C# 符号 + "早于" => "<", + "晚于" => ">", + "等于" => "==", + "不早于" => ">=", + "不晚于" => "<=", + _ => throw new ArgumentException($"不支持的操作符:{op}") + }; + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs new file mode 100644 index 00000000..0aed2a39 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/BatchOperationResult.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.RiskEngine.Dto +{ + public class BatchOperationResult + { + public bool Success { get; set; } + public int TotalCount { get; set; } + public int SuccessCount { get; set; } + public string ErrorMessage { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs new file mode 100644 index 00000000..65e939c4 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskApplicationReq.cs @@ -0,0 +1,16 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class CreateRiskApplicationReq + { + public string RuleIds { get; set; } + public RiskControlStrategy ControlStrategy { get; set; } + public string TriggerPoints { get; set; } + public string ScopeAssetBookIds { get; set; } + public string ScopeClientIds { get; set; } + public string ScopeUnderlyingTypes { get; set; } + public string ScopeTradeTypes { get; set; } + public int ScopeIsGlobal { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs new file mode 100644 index 00000000..a6d69dda --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskRuleReq.cs @@ -0,0 +1,10 @@ +namespace YLErp.Modules.RiskEngine.Dto +{ + public class CreateRiskRuleReq + { + public string RuleName { get; set; } + public string RuleText { get; set; } + public string ConditionJson { get; set; } + public string RuleExpr { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskVariableReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskVariableReq.cs new file mode 100644 index 00000000..707db27b --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/CreateRiskVariableReq.cs @@ -0,0 +1,16 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class CreateRiskVariableReq + { + public string VariableName { get; set; } + public RiskVariableCategory Category { get; set; } + public RiskVariableDataType DataType { get; set; } + public string Unit { get; set; } + public string ValueDomain { get; set; } + public string Description { get; set; } + public string VariableExpr { get; set; } + public int SortOrder { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs new file mode 100644 index 00000000..eec45ca7 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskApplicationReq.cs @@ -0,0 +1,17 @@ +using BaseOUDAL; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class QueryRiskApplicationReq : BaseSearchReq + { + public string RuleName { get; set; } + public RiskRuleStatus? Status { get; set; } + public RiskControlStrategy? Strategy { get; set; } + public string TriggerPoint { get; set; } + public string ScopeClientIds { get; set; } + public string ScopeUnderlyingTypes { get; set; } + public string ScopeTradeTypes { get; set; } + public string ScopeAssetBookIds { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs new file mode 100644 index 00000000..690712fa --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskAuditLogReq.cs @@ -0,0 +1,14 @@ +using System; +using BaseOUDAL; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class QueryRiskAuditLogReq : BaseSearchReq + { + public string OperationType { get; set; } + public string TargetType { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public string Keyword { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskRuleReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskRuleReq.cs new file mode 100644 index 00000000..df7e1300 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskRuleReq.cs @@ -0,0 +1,12 @@ +using BaseOUDAL; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class QueryRiskRuleReq : BaseSearchReq + { + public string Keyword { get; set; } + public RiskRuleStatus? Status { get; set; } + public long? VariableId { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs new file mode 100644 index 00000000..9b8f39bd --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/QueryRiskVariableReq.cs @@ -0,0 +1,12 @@ +using BaseOUDAL; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class QueryRiskVariableReq : BaseSearchReq + { + public RiskVariableCategory? Category { get; set; } + public RiskVariableDataType? DataType { get; set; } + public string VariableName { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs new file mode 100644 index 00000000..cb76b20c --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationDetail.cs @@ -0,0 +1,24 @@ +using System; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskApplicationDetail + { + public long Id { get; set; } + public string RuleIds { get; set; } + public RiskRuleStatus Status { get; set; } + public RiskControlStrategy ControlStrategy { get; set; } + public string TriggerPoints { get; set; } + public string ScopeAssetBookIds { get; set; } + public string ScopeClientIds { get; set; } + public string ScopeUnderlyingTypes { get; set; } + public string ScopeTradeTypes { get; set; } + public int ScopeIsGlobal { get; set; } + public int Version { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + public string UpdateOptName { get; set; } + public DateTime UpdateDate { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs new file mode 100644 index 00000000..466b1d94 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskApplicationListItem.cs @@ -0,0 +1,24 @@ +using System; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskApplicationListItem + { + public long Id { get; set; } + public string RuleIds { get; set; } + public int Status { get; set; } + public RiskControlStrategy ControlStrategy { get; set; } + public string TriggerPoints { get; set; } + public string ScopeAssetBookIds { get; set; } + public string ScopeClientIds { get; set; } + public string ScopeUnderlyingTypes { get; set; } + public string ScopeTradeTypes { get; set; } + public int ScopeIsGlobal { get; set; } + public int Version { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + public string UpdateOptName { get; set; } + public DateTime UpdateDate { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogDetail.cs new file mode 100644 index 00000000..a4f52acf --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogDetail.cs @@ -0,0 +1,19 @@ +using System; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskAuditLogDetail + { + public long Id { get; set; } + public string OperationType { get; set; } + public string TargetType { get; set; } + public long TargetId { get; set; } + public string TargetName { get; set; } + public string OperationDetail { get; set; } + public string Result { get; set; } + public string SnapshotData { get; set; } + public long OptId { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogListItem.cs new file mode 100644 index 00000000..487f5f3f --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskAuditLogListItem.cs @@ -0,0 +1,17 @@ +using System; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskAuditLogListItem + { + public long Id { get; set; } + public string OperationType { get; set; } + public string TargetType { get; set; } + public long TargetId { get; set; } + public string TargetName { get; set; } + public string OperationDetail { get; set; } + public string Result { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleApplicationSummary.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleApplicationSummary.cs new file mode 100644 index 00000000..23c6e0e2 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleApplicationSummary.cs @@ -0,0 +1,12 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskRuleApplicationSummary + { + public long Id { get; set; } + public RiskRuleStatus Status { get; set; } + public RiskControlStrategy ControlStrategy { get; set; } + public string TriggerPoints { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs new file mode 100644 index 00000000..6abe752d --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleDetail.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskRuleDetail + { + public long Id { get; set; } + public string RuleName { get; set; } + public string RuleText { get; set; } + public string ConditionJson { get; set; } + public string RuleExpr { get; set; } + public RiskRuleStatus Status { get; set; } + public int Version { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + public string UpdateOptName { get; set; } + public DateTime UpdateDate { get; set; } + public List Applications { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs new file mode 100644 index 00000000..988f55c4 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleListItem.cs @@ -0,0 +1,21 @@ +using System; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskRuleListItem + { + public long Id { get; set; } + public string RuleName { get; set; } + public string RuleText { get; set; } + public string ConditionJson { get; set; } + public string RuleExpr { get; set; } + public RiskRuleStatus Status { get; set; } + public int Version { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + public string UpdateOptName { get; set; } + public DateTime UpdateDate { get; set; } + public int ApplicationCount { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs new file mode 100644 index 00000000..aea6a343 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleSimpleItem.cs @@ -0,0 +1,9 @@ +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskRuleSimpleItem + { + public long Id { get; set; } + public string RuleName { get; set; } + public string RuleText { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleVersionItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleVersionItem.cs new file mode 100644 index 00000000..b0842d6e --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskRuleVersionItem.cs @@ -0,0 +1,13 @@ +using System; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskRuleVersionItem + { + public int Version { get; set; } + public string OperationType { get; set; } + public string OperationDetail { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs new file mode 100644 index 00000000..bbadd4ce --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableDetail.cs @@ -0,0 +1,24 @@ +using System; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskVariableDetail + { + public long Id { get; set; } + public string VariableName { get; set; } + public RiskVariableCategory Category { get; set; } + public RiskVariableDataType DataType { get; set; } + public string Unit { get; set; } + public string ValueDomain { get; set; } + public string Description { get; set; } + public string VariableExpr { get; set; } + public int Version { get; set; } + public int SortOrder { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + public string UpdateOptName { get; set; } + public DateTime UpdateDate { get; set; } + public int ReferenceCount { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs new file mode 100644 index 00000000..c4ddf647 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableListItem.cs @@ -0,0 +1,18 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskVariableListItem + { + public long Id { get; set; } + public string VariableName { get; set; } + public RiskVariableCategory Category { get; set; } + public RiskVariableDataType DataType { get; set; } + public string Unit { get; set; } + public string ValueDomain { get; set; } + public string Description { get; set; } + public string VariableExpr { get; set; } + public int Version { get; set; } + public int SortOrder { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs new file mode 100644 index 00000000..8585564d --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RiskVariableSimpleItem.cs @@ -0,0 +1,14 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RiskVariableSimpleItem + { + public long Id { get; set; } + public string VariableName { get; set; } + public RiskVariableCategory Category { get; set; } + public RiskVariableDataType DataType { get; set; } + public string Unit { get; set; } + public string VariableExpr { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs b/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs new file mode 100644 index 00000000..d1e459ff --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs @@ -0,0 +1,14 @@ +namespace YLErp.Modules.RiskEngine.Dto +{ + public class RuleCondition + { + public long VariableId { get; set; } + public string Operator { get; set; } + public string ThresholdType { get; set; } + public object Value { get; set; } + public long? ThresholdVariableId { get; set; } + // 介于/不介于 的边界包含控制,默认 true + public bool IncludeLowerBound { get; set; } = true; + public bool IncludeUpperBound { get; set; } = true; + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs new file mode 100644 index 00000000..aa290529 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskApplicationReq.cs @@ -0,0 +1,17 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class UpdateRiskApplicationReq + { + public string RuleIds { get; set; } + public RiskControlStrategy ControlStrategy { get; set; } + public string TriggerPoints { get; set; } + public string ScopeAssetBookIds { get; set; } + public string ScopeClientIds { get; set; } + public string ScopeUnderlyingTypes { get; set; } + public string ScopeTradeTypes { get; set; } + public int ScopeIsGlobal { get; set; } + public int Version { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs new file mode 100644 index 00000000..85521bac --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskRuleReq.cs @@ -0,0 +1,11 @@ +namespace YLErp.Modules.RiskEngine.Dto +{ + public class UpdateRiskRuleReq + { + public string RuleName { get; set; } + public string RuleText { get; set; } + public string ConditionJson { get; set; } + public string RuleExpr { get; set; } + public int Version { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskVariableReq.cs b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskVariableReq.cs new file mode 100644 index 00000000..2990b7c1 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/Dto/UpdateRiskVariableReq.cs @@ -0,0 +1,17 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine.Dto +{ + public class UpdateRiskVariableReq + { + public string VariableName { get; set; } + public RiskVariableCategory Category { get; set; } + public RiskVariableDataType DataType { get; set; } + public string Unit { get; set; } + public string ValueDomain { get; set; } + public string Description { get; set; } + public string VariableExpr { get; set; } + public int SortOrder { get; set; } + public int Version { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/RiskContext.cs b/YLErpDAL/Modules/RiskEngine/RiskContext.cs new file mode 100644 index 00000000..7e9e550e --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskContext.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控上下文:封装一次风控检查所需的全部数据上下文 + /// + public class RiskContext + { + /// + /// 当前交易ID + /// + public int TradeId { get; set; } + + /// + /// 当前触发时点 + /// + public string TriggerPoint { get; set; } + + /// + /// 数据字典:key=表名/前缀,value=对应数据对象 + /// + public Dictionary DataMap { get; set; } = new Dictionary(); + } +} diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs new file mode 100644 index 00000000..2e0e68a8 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -0,0 +1,638 @@ +using Newtonsoft.Json; +using Qdp.Foundation.Utilities; +using System.Reflection; +using YLErp.BLL; +using YLErp.Model; + +/* +================================================================================ + 风控引擎服务 — RiskEngineService 技术方案与当前实现说明 +================================================================================ + +【项目背景】 + 当前 TRS 系统已在 QuotaMonitorService 中接入第一版风控引擎,用于在交易关键 + 时点执行可配置规则判断。当前目标不是一次性做成最终版,而是在最小可运行链路 + 跑通后,逐步演进为可由前端配置、后端预编译、执行期直接命中的正式版本。 + +【当前实现口径】(以本注释和实际代码为准) + + 核心概念: + - RiskRule(风控规则):规则定义本体,当前重点字段包括 Id、RuleName、 + RuleText、RuleExpr、Version、CompiledScript。 + - RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、 + 适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。 + - RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, + 例如直接访问 DataMap["trade"] 后做数值或日期比较。 + - ConditionJson:当前已在规则定义中保留,但不参与主执行链路。 + - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DataMap。 + - RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func。 + + 当前编译流程: + 1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式 + 2. 调用 RuleCompiler.ValidateAndCompileRule(rule) + 3. 内部使用 Roslyn 编译 RuleExpr,生成 Func + 4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache + 5. 执行阶段优先从 RuleCompiledCache 取委托执行 + + 当前执行流程(EvaluateRisk): + 1. 从数据库加载规则列表和应用列表 + 2. 按应用状态、TriggerPoints 过滤有效应用 + 3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型) + 4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则 + 5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 + 6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings + 7. 返回 RiskResult + + 当前维度匹配规则: + - 全局:ScopeIsGlobal=true 时直接命中 + - 同一维度内多选:并集(OR) + - 不同维度之间:交集(AND) + - 某维度留空:视为该维度不限制 + +【与早期方案的主要差异】 + - 当前不是 Content 解析或表达式树主导,而是 RuleExpr 直编译主导 + - RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并 + - 编译器文件已放入 RiskEngine/Compile 目录下 + - 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存 + - Rule2 这类“左值与右值都来自对象字段”的规则,当前通过 RuleExpr 直接表达 + +【当前代码结构】 + 风控引擎层:YLErpDAL/Modules/RiskEngine/ + - RiskEngineService.cs:执行入口,负责规则筛选、应用过滤、委托执行、结果聚合 + - RiskRule.cs:规则定义模型,含 CompiledScript 运行时字段 + - RiskRuleApplication.cs:规则应用模型,承载策略、触发点和适用范围 + - RiskContext.cs / RiskResult.cs:执行上下文与结果模型 + + 编译相关:YLErpDAL/Modules/RiskEngine/Compile/ + - RuleCompiler.cs:Roslyn 编译入口,提供 ValidateAndCompileFormula / ValidateAndCompileRule + - RuleCompileResult.cs:编译结果模型 + - RuleCompiledCache.cs:编译结果缓存 + + 当前集成点: + - QuotaMonitorService.cs:构造 RiskContext,并将 trade 对象放入 DataMap["trade"] + +【当前进度】(截至 2026-06-24) + ✅ 已完成: + 1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler + 2. 风控上下文通过 DataMap 传入 trade 对象,支持规则脚本直接访问交易字段 + 3. RuleExpr 直编译方案已接入,支持数值比较和日期比较 + 4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型 + 5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND + 6. RuleCompiler 已支持校验 + 编译 + 写缓存 + 7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录 + 8. RiskEngineService 已改为执行时优先从 RuleCompiledCache 读取委托 + +【待办事项 / TODO】 + ⬜ 1. 接入真实规则来源 + - 由数据库或前端提交替换当前 LoadRules / LoadApplications 的规则来源 + ⬜ 2. 增加接口层 + - 提供前端提交 RuleExpr 后的校验接口 + - 提供规则保存 / 发布后预编译并写缓存的入口 + ⬜ 3. 启动预热与缓存刷新 + - 服务启动时批量加载有效规则并预编译 + - 支持规则更新后的缓存刷新 / 删除 + ⬜ 4. 完善执行期策略 + - 当前保留“缓存未命中时兜底编译”逻辑 + - 后续可收紧为“执行期只读缓存,未命中按发布失败处理” + ⬜ 5. 补测试样例 + - 数值比较、日期比较、维度过滤、非法公式、边界值 + ⬜ 6. 完善 RuleExpr 配套能力 + - 提供前端可用的表达式编辑、校验与错误提示 + - 约束可用变量、类型转换和脚本安全边界 + +【注意事项】 + 1. 当前 RuleExpr 必须是 Roslyn 最终可执行的 C# bool 表达式,不是业务语义短句 + 2. DataMap 中的值由宿主业务代码准备,编译器与执行器本身不负责查库补数 + 3. 当前缓存的是 Func,这是规则判断函数,不是事件处理器 + 4. 业务可预期失败优先走结果返回,不要把高频校验失败都设计成异常 +================================================================================ +*/ + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控引擎服务(第一版 — 骨架版,先跑通) + /// + public class RiskEngineService : YLBaseService + { + IYcLogger _logger = LogFactory.GetLogger("RiskEngineService"); + + private static readonly Lazy _instance = + new Lazy(() => new RiskEngineService()); + + public static RiskEngineService GetInstance() => _instance.Value; + + private RiskEngineService() : base(OptUserInfo.SystemUser) + { + } + + public RiskEngineService(OptUserInfo userInfo) : base(userInfo) + { + } + + public RiskEngineService(YLBaseService baseService) : base(baseService) + { + } + + public RiskEngineService(OptUserInfo optUser, YLContext dbContext) : base(optUser, dbContext) + { + } + + /// + /// 规则内存缓存(启动预热写入,EvaluateRisk 读取) + /// + private static volatile List _cachedRules; + private static volatile List _cachedApplications; + private static readonly object _cacheLock = new object(); + + /// + /// 预热:加载规则与应用到内存,并预编译所有规则到 RuleCompiledCache。 + /// 项目启动时调用一次;规则/应用更新后调用 RefreshCache 刷新。 + /// + public void Preload() + { + lock (_cacheLock) + { + _logger.Info("[风控引擎] Preload 开始 - 加载规则与应用并预编译"); + + var rules = LoadRulesFromDb(); + var applications = LoadApplicationsFromDb(); + List res = new List(); + foreach(var i in applications) + { + if(i.Status!= RiskRuleStatus.Active) + { + res.Add(i.Id); + } + } + // 预编译所有规则到 RuleCompiledCache + foreach (var rule in rules) + { + var ruleId = rule.Id.ToString(); + if (RuleCompiledCache.TryGet(ruleId, out _)) + { + continue; + } + + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + _logger.Info($"[风控引擎] 规则预编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + } + } + + _cachedRules = rules; + _cachedApplications = applications; + + _logger.Info($"[风控引擎] Preload 完成 - 规则数: {rules.Count}, 应用数: {applications.Count}"); + } + } + + /// + /// 刷新缓存:清空已编译委托与内存缓存后重新预加载。 + /// 规则/应用配置更新后调用。 + /// + public void RefreshCache() + { + _logger.Info("[风控引擎] RefreshCache 被调用 - 清空缓存并重新预加载"); + lock (_cacheLock) + { + _cachedRules = null; + _cachedApplications = null; + RuleCompiledCache.Clear(); + } + + Preload(); + } + + /// + /// 从内存缓存获取规则列表;缓存为空时兜底加载并填充缓存。 + /// + private List GetRules() + { + var rules = _cachedRules; + if (rules != null) + { + return rules; + } + + lock (_cacheLock) + { + if (_cachedRules != null) + { + return _cachedRules; + } + + var loaded = LoadRulesFromDb(); + _cachedRules = loaded; + return loaded; + } + } + + /// + /// 从内存缓存获取应用列表;缓存为空时兜底加载并填充缓存。 + /// + private List GetApplications() + { + var applications = _cachedApplications; + if (applications != null) + { + return applications; + } + + lock (_cacheLock) + { + if (_cachedApplications != null) + { + return _cachedApplications; + } + + var loaded = LoadApplicationsFromDb(); + _cachedApplications = loaded; + return loaded; + } + } + + /// + /// 执行风控检查(最终设计版:预编译委托 + 规则筛选 + 策略判定) + /// + /// 风控上下文 + /// 触发时点,如 BOOK_CONFIRM + /// 风控结果 + public RiskResult EvaluateRisk(RiskContext context, string triggerPoint) + { + var result = new RiskResult(); + + try + { + _logger.Info($"[风控引擎] EvaluateRisk 开始 - TradeId: {context?.TradeId}, TriggerPoint: {triggerPoint}"); + + // ============================================================ + // Step 1: 从内存缓存读取规则定义和规则应用(启动时已预热) + // ============================================================ + var rules = GetRules(); + var applications = GetApplications(); + _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); + + // ============================================================ + // Step 2: 先按 Application 过滤 Active 状态和 TriggerPoint 匹配的应用 + // ============================================================ + var activeApps = applications + .Where(a => a.Status == RiskRuleStatus.Active) + .ToList(); + var apps = activeApps + .Where(a => !string.IsNullOrEmpty(a.TriggerPoints)) + .ToList(); + var triggerMatchedApps = apps + .Where(a => a.TriggerPoints.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Contains(triggerPoint)) + .ToList(); + + // 再按应用范围过滤。 + // 统一走通用维度匹配: + // 1. 全局命中时直接通过; + // 2. 同一维度内多选按并集处理; + // 3. 不同维度之间按交集处理; + // 4. 某维度留空表示该维度不限制。 + var trade = context?.DataMap != null && context.DataMap.ContainsKey("trade") + ? context.DataMap["trade"] as YLErp.DBModels.trade + : null; + + var matchedApplications = triggerMatchedApps + .Where(a => IsApplicationMatched(a, trade)) + .ToList(); + + _logger.Info($"[风控引擎] 匹配 TriggerPoint 的应用数: {matchedApplications.Count}"); + + // ============================================================ + // Step 3: 遍历匹配的应用,通过 RuleIds 关联规则并执行预编译委托 + // ============================================================ + foreach (var application in matchedApplications) + { + // ------------------------------------------------------------ + // 3.1 通过 RuleIds 关联规则定义 + // ------------------------------------------------------------ + var applicationRuleIds = ParseRuleIds(application.RuleIds); + if (!applicationRuleIds.Any()) + { + _logger.Info($"[风控引擎] 应用未配置有效规则 - RuleIds: {application.RuleIds}"); + continue; + } + + var applicationRules = rules + .Where(r => applicationRuleIds.Contains(r.Id) && r.Id == 3) + .ToList(); + + var missingRuleIds = applicationRuleIds + .Where(ruleId => applicationRules.All(r => r.Id != ruleId)) + .ToList(); + + foreach (var missingRuleId in missingRuleIds) + { + _logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {missingRuleId}, ApplicationRuleIds: {application.RuleIds}"); + } + + foreach (var rule in applicationRules) + { + var ruleId = rule.Id.ToString(); + + // ------------------------------------------------------------ + // 3.2 优先从编译缓存读取规则委托 + // ------------------------------------------------------------ + if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) + { + _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); + + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + _logger.Info($"[风控引擎] 规则编译失败 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + continue; + } + + compiledScript = compileResult.CompiledScript; + } + + // ------------------------------------------------------------ + // 3.3 执行预编译委托 + // ------------------------------------------------------------ + bool triggered = false; + try + { + triggered = compiledScript(context); + _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] 规则执行异常 - RuleId: {rule.Id}, Error: {ex.Message}"); + continue; + } + + // ------------------------------------------------------------ + // 3.4 命中后按 Application 的控制策略聚合结果 + // ------------------------------------------------------------ + if (triggered) + { + _logger.Info($"[风控引擎] 规则触发 - RuleId: {rule.Id}, Strategy: {application.ControlStrategy}"); + + switch (application.ControlStrategy) + { + case RiskControlStrategy.Block: + break; + result.Blocked = true; + result.Passed = false; + result.TriggeredRules.Add(new TriggeredRuleInfo + { + RuleId = rule.Id.ToString(), + RuleName = rule.RuleName, + ControlStrategy = RiskControlStrategy.Block, + RuleText = rule.RuleText, + Message = $"规则[{rule.RuleName}]触发:禁止" + }); + break; + + case RiskControlStrategy.Approval: + result.NeedApproval = true; + result.Passed = false; + result.TriggeredRules.Add(new TriggeredRuleInfo + { + RuleId = ruleId, + RuleName = rule.RuleName, + ControlStrategy = RiskControlStrategy.Approval, + RuleText = rule.RuleText, + Message = $"规则[{rule.RuleName}]触发:需审批" + }); + break; + + case RiskControlStrategy.ShowTip: + result.ShowTip = true; + result.TriggeredRules.Add(new TriggeredRuleInfo + { + RuleId = ruleId, + RuleName = rule.RuleName, + ControlStrategy = RiskControlStrategy.ShowTip, + RuleText = rule.RuleText, + Message = $"规则[{rule.RuleName}]触发:提示" + }); + break; + + default: + _logger.Info($"[风控引擎] 未知策略类型 - ControlStrategy: {application.ControlStrategy}"); + break; + } + } + else + { + _logger.Info($"[风控引擎] 规则未触发 - RuleId: {rule.Id}, TriggerPoint: {triggerPoint}"); + } + } + } + + // ============================================================ + // Step 4: 聚合最终结果 + // ============================================================ + if (!result.Blocked && !result.NeedApproval) + { + result.Passed = true; + } + else + { + result.Passed = false; + } + + _logger.Info($"[风控引擎] EvaluateRisk 完成 - TradeId: {context?.TradeId}, Passed: {result.Passed}, Blocked: {result.Blocked}, NeedApproval: {result.NeedApproval}, TriggeredRules: {result.TriggeredRules.Count}"); + } + catch (Exception ex) + { + result.NeedApproval = true; + result.Passed = false; + result.TriggeredRules.Add(new TriggeredRuleInfo + { + RuleId = "ENGINE_ERROR", + RuleName = "风控引擎执行异常", + RuleText = ex.Message, + Message = $"风控引擎异常:{ex.Message}" + }); + _logger.Error($"[风控引擎] EvaluateRisk 异常 - TradeId: {context?.TradeId}, Error: {ex.Message}"); + } + + return result; + } + + /// + /// 从数据库加载规则列表 + /// + private List LoadRulesFromDb() + { + return DbContext.glms_risk_rule + .AsNoTracking() + .Where(r => r.Status != RiskRuleStatus.Deleted) + .OrderByDescending(r => r.UpdateDate ?? r.OptDate) + .Select(r => new RiskRule + { + Id = r.id, + RuleName = r.RuleName, + RuleText = r.RuleText, + ConditionJson = r.ConditionJson, + RuleExpr = r.RuleExpr, + Version = r.Version, + IsDeleted = r.Status == RiskRuleStatus.Deleted, + OptId = r.OptId ?? 0, + OptName = r.OptName, + OptDate = r.OptDate ?? DateTime.MinValue, + UpdateOptId = r.UpdateOptId ?? 0, + UpdateOptName = r.UpdateOptName, + UpdateDate = r.UpdateDate ?? r.OptDate ?? DateTime.MinValue + }) + .ToList(); + } + /// + /// 从数据库加载应用配置 + /// + private List LoadApplicationsFromDb() + { + return DbContext.glms_risk_rule_application + .AsNoTracking() + .Where(a => a.Status != RiskRuleStatus.Deleted) + .OrderByDescending(a => a.UpdateDate ?? a.OptDate) + .Select(a => new RiskRuleApplication + { + Id = a.id, + RuleIds = a.RuleIds, + Status = a.Status, + ControlStrategy = a.ControlStrategy, + TriggerPoints = a.TriggerPoints, + ScopeIsGlobal = a.ScopeIsGlobal, + ScopeAssetBookIds = a.ScopeAssetBookIds, + ScopeClientIds = a.ScopeClientIds, + ScopeUnderlyingTypes = a.ScopeUnderlyingTypes, + ScopeTradeTypes = a.ScopeTradeTypes, + Version = a.Version, + OptId = a.OptId ?? 0, + OptName = a.OptName, + OptDate = a.OptDate ?? DateTime.MinValue, + UpdateOptId = a.UpdateOptId ?? 0, + UpdateOptName = a.UpdateOptName, + UpdateDate = a.UpdateDate ?? a.OptDate ?? DateTime.MinValue + }) + .ToList(); + } + + /// + /// 判断应用配置是否命中当前交易。 + /// 匹配规则遵循设计文档: + /// 1. 全局命中时直接返回 true; + /// 2. 同一维度内多选按并集处理; + /// 3. 不同维度之间按交集处理; + /// 4. 某维度留空表示该维度不限制。 + /// + private bool IsApplicationMatched(RiskRuleApplication application, YLErp.DBModels.trade trade) + { + if (application == null) + { + return false; + } + + if (application.ScopeIsGlobal == 1) + { + return true; + } + + if (trade == null) + { + return false; + } + + var accountMatched = IsScopeEmpty(application.ScopeAssetBookIds) || IsValueMatched(application.ScopeAssetBookIds, GetTradeAssetBookId(trade)); + var clientMatched = IsScopeEmpty(application.ScopeClientIds) || IsValueMatched(application.ScopeClientIds, trade.ClientId); + var underlyingTypeMatched = IsScopeEmpty(application.ScopeUnderlyingTypes) || IsValueMatched(application.ScopeUnderlyingTypes, GetTradeUnderlyingType(trade)); + var tradeTypeMatched = IsScopeEmpty(application.ScopeTradeTypes) || IsValueMatched(application.ScopeTradeTypes, trade.TradeType); + + return accountMatched + && clientMatched + && underlyingTypeMatched + && tradeTypeMatched; + } + + /// + /// 解析应用配置中的规则ID列表。 + /// 多个规则ID使用逗号分隔,返回去空格后的 long 集合。 + /// + private List ParseRuleIds(string ruleIds) + { + if (string.IsNullOrWhiteSpace(ruleIds)) + { + return new List(); + } + + var ids = new List(); + foreach (var part in ruleIds.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (long.TryParse(part.Trim(), out long id)) + { + ids.Add(id); + } + } + + return ids; + } + + /// + /// 判断某个范围字段是否为空。 + /// 为空表示该维度不限制。 + /// + private bool IsScopeEmpty(string scopeValue) + { + return string.IsNullOrWhiteSpace(scopeValue); + } + + /// + /// 判断单个值是否命中逗号分隔的范围配置。 + /// 同一维度内多选按并集处理,只要命中任一值即返回 true。 + /// + private bool IsValueMatched(string scopeValue, object currentValue) + { + if (string.IsNullOrWhiteSpace(scopeValue)) + { + return true; + } + + if (currentValue == null) + { + return false; + } + + var currentText = currentValue.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(currentText)) + { + return false; + } + + return scopeValue + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Any(s => string.Equals(s, currentText, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 读取交易的资产簿账户ID。 + /// 当前先按 BookId 取值;如果后续真实字段不是 BookId,再统一调整这里即可。 + /// + private object GetTradeAssetBookId(YLErp.DBModels.trade trade) + { + var property = trade.GetType().GetProperty("BookId"); + return property?.GetValue(trade); + } + + /// + /// 读取交易的标的类型。 + /// 当前先按 UnderlyingInstrumentType 取值;如果后续真实字段名不同,再统一调整这里即可。 + /// + private object GetTradeUnderlyingType(YLErp.DBModels.trade trade) + { + var property = trade.GetType().GetProperty("UnderlyingInstrumentType"); + return property?.GetValue(trade); + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/RiskResult.cs b/YLErpDAL/Modules/RiskEngine/RiskResult.cs new file mode 100644 index 00000000..69edbd57 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskResult.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控结果 + /// + public class RiskResult + { + /// + /// 是否全部通过 + /// + public bool Passed { get; set; } = true; + + /// + /// 是否被禁止 + /// + public bool Blocked { get; set; } + + /// + /// 是否需要审批 + /// + public bool NeedApproval { get; set; } + + /// + /// 是否存在提示 + /// + public bool ShowTip { get; set; } + + /// + /// 触发的规则列表 + /// + public List TriggeredRules { get; set; } = new List(); + } + + /// + /// 触发规则详情 + /// + public class TriggeredRuleInfo + { + public string RuleId { get; set; } + public string RuleName { get; set; } + public RiskControlStrategy ControlStrategy { get; set; } + public string RuleText { get; set; } + public string Message { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/RiskRule.cs b/YLErpDAL/Modules/RiskEngine/RiskRule.cs new file mode 100644 index 00000000..bc6c6910 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskRule.cs @@ -0,0 +1,50 @@ +using System; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控规则定义。 + /// 只描述“规则是什么”,不包含状态、策略、触发时点、适用范围等应用配置。 + /// 这些执行维度由 RiskRuleApplication 承载。 + /// + public class RiskRule + { + // === 基础信息 === + public long Id { get; set; } + public string RuleName { get; set; } + public string RuleText { get; set; } + + /// + /// 【核心】条件JSON(结构化模式的条件列表存储),如 + /// {"conditions":[{"variableId":1,"operator":">","value":100000000}]} + /// 由 RuleCompiler 解析并编译为 CompiledScript + /// + public string ConditionJson { get; set; } + + /// + /// 【核心】规则表达式(自由文本模式,类C#表达式) + /// 由 RuleCompiler 编译为 CompiledScript + /// + public string RuleExpr { get; set; } + + // === 生命周期 === + public int Version { get; set; } + public bool IsDeleted { get; set; } + + // === 审计字段 === + public int OptId { get; set; } + public string OptName { get; set; } + public DateTime OptDate { get; set; } + public int UpdateOptId { get; set; } + public string UpdateOptName { get; set; } + public DateTime UpdateDate { get; set; } + + // === 运行时内存字段(不持久化到数据库)=== + /// + /// 【核心】预编译的可执行委托 + /// 在规则创建/加载时由 RuleCompiler.Compile(this) 生成 + /// 判风控时直接调用:rule.CompiledScript(context) → bool + /// + public Func CompiledScript { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs new file mode 100644 index 00000000..68219350 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleApplication.cs @@ -0,0 +1,142 @@ +using System; +using YLErp.DBModels; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 风控规则应用配置。 + /// + /// 设计原因: + /// 依据设计文档,风控规则需要区分“规则定义”和“规则应用”两个层面。 + /// RiskRule 负责定义“规则是什么”,例如规则名称、规则编码、公式表达式、描述、版本等; + /// RiskRuleApplication 负责定义“规则在什么场景下如何执行”,例如启用状态、校验策略、触发时点、适用范围。 + /// + /// 拆分后的好处是: + /// 1. 同一条规则可以被多个应用配置复用,避免重复维护相同的公式定义。 + /// 2. 不同业务场景可以对同一条规则配置不同的执行策略,例如在簿记确认时禁止,在平仓审核时提示。 + /// 3. 调整触发时点、适用范围、控制策略时,不需要修改规则本身,降低配置耦合。 + /// 4. 后续如果要做规则管理页面、规则启停、按场景灰度投放,也更容易扩展。 + /// + /// 当前文件先定义第一版最小模型,用于支撑基础流程跑通和示例数据加载。 + /// + public class RiskRuleApplication + { + /// + /// 应用配置主键ID。 + /// 用于唯一标识一条规则应用记录。 + /// + public long Id { get; set; } + + /// + /// 关联的规则ID列表。 + /// 通过该字段与 RiskRule.Id 关联,多个规则ID使用逗号分隔。 + /// + + public string RuleIds { get; set; } + + /// + /// 应用状态。 + /// 约定: + /// 1 = Active(已生效) + /// 2 = Disabled(已停用) + /// 3 = Deleted(已删除) + /// 在实际执行时,通常只加载 Active 状态的应用配置。 + /// + public RiskRuleStatus Status { get; set; } + + /// + /// 校验策略。 + /// 约定: + /// 1 = 禁止 + /// 2 = 审批 + /// 3 = 提示 + /// 当规则命中时,由该字段决定最终处理方式。 + /// + public RiskControlStrategy ControlStrategy { get; set; } + + /// + /// 触发时点。 + /// 多个时点使用逗号分隔,例如: + /// BOOK_CONFIRM,CLOSE_REVIEW + /// 表示该应用配置会在这些业务时点参与风控判断。 + /// + public string TriggerPoints { get; set; } + + /// + /// 是否全局适用。 + /// true 表示对所有交易都适用,此时通常忽略其他 Scope 字段; + /// false 表示只在指定范围内适用,需要结合下方各 Scope 字段进行过滤。 + /// + public int ScopeIsGlobal { get; set; } + + /// + /// 适用的资产簿记账户范围。 + /// 第一版使用逗号分隔的字符串保存,例如: + /// 123,456,789 + /// 后续执行时可按约定解析为账户ID集合。 + /// + public string ScopeAssetBookIds { get; set; } + + /// + /// 适用的客户范围。 + /// 第一版使用逗号分隔的字符串保存客户ID,例如: + /// 10001,10002 + /// 用于在执行前筛选该规则是否适用于当前客户。 + /// + public string ScopeClientIds { get; set; } + + /// + /// 适用的标的类型范围。 + /// 例如: + /// 股票,ETF,债券 + /// 用于控制规则仅对特定标的类型生效。 + /// + public string ScopeUnderlyingTypes { get; set; } + + /// + /// 适用的交易类型范围。 + /// 例如: + /// 簿记交易,平仓交易 + /// 用于控制规则仅在指定交易类型下生效。 + /// + public string ScopeTradeTypes { get; set; } + + public int Version { get; set; } + + /// + /// 创建人ID。 + /// 记录是谁创建了这条应用配置。 + /// + public int OptId { get; set; } + + /// + /// 创建人名称。 + /// 便于界面展示和审计追踪。 + /// + public string OptName { get; set; } + + /// + /// 创建时间。 + /// 记录应用配置的创建时刻。 + /// + public DateTime OptDate { get; set; } + + /// + /// 最后更新人ID。 + /// 记录最近一次修改该应用配置的操作人。 + /// + public int UpdateOptId { get; set; } + + /// + /// 最后更新人名称。 + /// 便于界面展示和审计追踪。 + /// + public string UpdateOptName { get; set; } + + /// + /// 最后更新时间。 + /// 记录最近一次修改该应用配置的时间。 + /// + public DateTime UpdateDate { get; set; } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs new file mode 100644 index 00000000..7e79f074 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -0,0 +1,1532 @@ +using BaseOUDAL; +using Newtonsoft.Json; +using Qdp.Foundation.Utilities; +using System.Linq.Expressions; +using YLErp.BLL; +using YLErp.DBModels; +using YLErp.Model; +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 字段) │ + │ - 删除保护(有 Active 引用时拒绝删除) │ + │ - 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) 删除规则(强保护:有 Active 应用时拒绝) + EnableRule(long) 启用规则(仅 Disabled → Active) + DisableRule(long) 停用规则(仅 Active → Disabled) + GetRuleVersions(long) 获取规则版本历史(从审计日志 SnapshotData 提取版本号) + BatchDeleteRules(List) 批量删除规则(逐条保护检查,单事务) + GetRuleApplications(long) 获取规则关联的应用配置列表 + GetAllRuleList() 获取所有启用规则(轻量字段,供应用配置下拉) + + ── 应用配置管理(9 个) ─────────────────────────────────────────── + QueryApplicationList(QueryRiskApplicationReq) 查询应用列表(多条件筛选,内存关联规则名称) + GetApplicationDetail(long) 获取应用详情(含关联规则信息) + CreateApplication(CreateRiskApplicationReq) 新建应用(关联规则 Active 校验,触发时点/Scope 校验) + UpdateApplication(long, UpdateRiskApplicationReq) 修改应用(乐观锁校验) + DeleteApplication(long) 删除应用(软删除) + EnableApplication(long) 启用应用(校验关联规则状态) + DisableApplication(long) 停用应用 + BatchEnableApplications(List) 批量启用(逐条校验关联规则) + BatchDisableApplications(List) 批量停用 + + ── 变量管理(6 个) ─────────────────────────────────────────────── + QueryVariableList(QueryRiskVariableReq) 查询变量列表(全量返回,Category/Keyword 筛选) + GetVariableDetail(long) 获取变量详情(含 ReferenceCount 引用统计) + CreateVariable(CreateRiskVariableReq) 新建变量(VariableExpr 长度校验 ≤10000) + UpdateVariable(long, UpdateRiskVariableReq) 修改变量(乐观锁,DataType 变更保护) + DeleteVariable(long) 删除变量(引用保护:有 Active 规则引用时拒绝,硬删除) + GetAllVariableList() 获取所有已实现变量(轻量字段,供规则编辑器下拉) + + ── 审计日志(3 个) ─────────────────────────────────────────────── + QueryAuditLogs(QueryRiskAuditLogReq) 查询审计日志(多条件筛选,分页) + ExportAuditLogs(QueryRiskAuditLogReq) 导出审计日志(占位实现,后续迭代 Excel 导出) + 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 列表格式校验 + TryRefreshCache() 安全调用 RefreshCache(失败仅记日志) + GetRuleOrThrow / GetApplicationOrThrow / GetVariableOrThrow 实体获取快捷方法 + +【关键设计决策】 + 1. 数据访问:直接使用 EF Core DbContext(无独立 Repository,与项目风格一致) + 2. 事务策略:Create* 方法使用 BeginTransaction 包裹双 SaveChanges(实体 + 审计日志同事务) + 3. 并发控制:乐观锁(Version 字段),修改时版本不匹配抛 ServiceException + 4. 删除保护:强保护模式,有 Active 引用时拒绝删除规则/变量 + 5. ConditionJson 校验:变量存在性(按 VariableId)+ 操作符合法性(按 DataType 映射)+ 类型匹配 + 阈值变量引用 + 6. RuleExpr:自由文本模式下前端直接编辑,后端做基础语法校验(括号匹配 + 变量引用校验) + 7. Scope 字段:逗号分隔字符串存储,全局适用时不允许指定其他维度 + 8. 缓存刷新:写操作后调用 TryRefreshCache(),失败仅记日志不影响操作结果 + 9. 批量操作:单事务批量,全部成功或全部失败 + 10. 变量缓存:5 分钟本地 HashSet 缓存,CRUD 后 InvalidateVariableCache() + 11. 审计日志:16 种操作类型,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. 所有写操作后必须调用 TryRefreshCache(),确保引擎缓存与数据库一致 + 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted) + 4. ExportAuditLogs 当前为占位实现,Excel 导出功能待后续迭代 + 5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库 + 6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性) +================================================================================ +*/ + +namespace YLErp.Modules.RiskEngine +{ + public class RiskRuleService : YLBaseService + { + private readonly IYcLogger _logger = LogFactory.GetLogger("RiskRuleService"); + + private HashSet _variableExprCache; + private DateTime _variableCacheUpdateTime; + + private static readonly HashSet ValidTriggerPoints = new HashSet + { + "BOOK_CONFIRM", + "CLOSE_REVIEW", + "UPLOAD_CONFIRMATION", + "EVENT_TRIGGER", + "FUND_PAYMENT" + }; + + private static readonly Dictionary> ValidOperatorsByType = new Dictionary> + { + { RiskVariableDataType.Numeric, new HashSet { ">", "<", ">=", "<=", "=", "≠", "介于", "不介于" } }, + { RiskVariableDataType.Date, new HashSet { "早于", "晚于", "等于", "不早于", "不晚于", "介于", "不介于" } }, + { RiskVariableDataType.Boolean, new HashSet { "是", "否" } } + }; + + public RiskRuleService(OptUserInfo userInfo) : base(userInfo) + { + } + + public RiskRuleService(YLBaseService baseService) : base(baseService) + { + } + + public RiskRuleService(OptUserInfo optUser, YLContext dbContext) : base(optUser, dbContext) + { + } + + #region Private Helpers + + 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); + } + + private HashSet GetVariableExprSet() + { + if (_variableExprCache == null || + DateTime.Now - _variableCacheUpdateTime > TimeSpan.FromMinutes(5)) + { + _variableExprCache = DbContext.glms_risk_variable + .Where(v => v.VariableExpr != null && v.VariableExpr != "") + .Select(v => v.VariableExpr) + .ToHashSet(); + _variableCacheUpdateTime = DateTime.Now; + } + return _variableExprCache; + } + + private void InvalidateVariableCache() + { + _variableExprCache = null; + } + + private void ValidateConditionJson(string conditionJson) + { + List conditions; + try + { + conditions = JsonConvert.DeserializeObject>(conditionJson); + } + catch + { + throw new ServiceException("公式表达式 JSON 格式不合法"); + } + + if (conditions == null || conditions.Count == 0) + throw new ServiceException("公式条件列表不能为空"); + + for (int i = 0; i < conditions.Count; i++) + { + var cond = conditions[i]; + var condLabel = $"条件{i + 1}"; + + var variableDef = DbContext.glms_risk_variable + .FirstOrDefault(v => v.id == cond.VariableId); + if (variableDef == null) + throw new ServiceException($"{condLabel}:变量 ID '{cond.VariableId}' 不存在"); + + var validOperators = ValidOperatorsByType[variableDef.DataType]; + if (!validOperators.Contains(cond.Operator)) + throw new ServiceException($"{condLabel}:操作符 '{cond.Operator}' 不适用于{GetDataTypeName(variableDef.DataType)}类型变量"); + + if (cond.ThresholdType == "Fixed") + { + if (cond.Value == null) + throw new ServiceException($"{condLabel}:固定阈值不能为空"); + + if (cond.Operator == "介于" || cond.Operator == "不介于") + { + // 介于/不介于:Value 必须为双元素数组 [下限, 上限] + if (!(cond.Value is Newtonsoft.Json.Linq.JArray arr && arr.Count == 2)) + throw new ServiceException($"{condLabel}:操作符 '{cond.Operator}' 的阈值必须为双元素数组 [下限, 上限]"); + if (variableDef.DataType == RiskVariableDataType.Numeric) + { + if (!decimal.TryParse(arr[0].ToString(), out _) || !decimal.TryParse(arr[1].ToString(), out _)) + throw new ServiceException($"{condLabel}:数值型介于/不介于的下限和上限必须为数字"); + } + if (variableDef.DataType == RiskVariableDataType.Date) + { + if (!DateTime.TryParse(arr[0].ToString(), out _) || !DateTime.TryParse(arr[1].ToString(), out _)) + throw new ServiceException($"{condLabel}:日期型介于/不介于的起始和结束日期必须为合法日期"); + } + } + else + { + if (variableDef.DataType == RiskVariableDataType.Numeric) + { + if (!decimal.TryParse(cond.Value.ToString(), out _)) + throw new ServiceException($"{condLabel}:数值型变量的阈值必须为数字"); + } + if (variableDef.DataType == RiskVariableDataType.Date) + { + if (!DateTime.TryParse(cond.Value.ToString(), out _)) + throw new ServiceException($"{condLabel}:日期型变量的阈值必须为合法日期"); + } + } + } + else if (cond.ThresholdType == "Variable") + { + if (!cond.ThresholdVariableId.HasValue) + throw new ServiceException($"{condLabel}:变量阈值引用的变量 ID 不能为空"); + var thresholdVarDef = DbContext.glms_risk_variable + .FirstOrDefault(v => v.id == cond.ThresholdVariableId.Value); + if (thresholdVarDef == null) + throw new ServiceException($"{condLabel}:阈值变量 ID '{cond.ThresholdVariableId.Value}' 不存在"); + if (thresholdVarDef.DataType != variableDef.DataType) + throw new ServiceException($"{condLabel}:阈值变量与条件变量的数据类型不一致"); + } + else + { + throw new ServiceException($"{condLabel}:阈值类型 '{cond.ThresholdType}' 不合法,仅支持 Fixed/Variable"); + } + } + } + + private void ValidateRuleExpr(string ruleExpr) + { + if (ruleExpr.Length > 10000) + throw new ServiceException("公式表达式长度不能超过 10000 字符"); + + int parenDepth = 0; + foreach (char c in ruleExpr) + { + if (c == '(') parenDepth++; + else if (c == ')') parenDepth--; + if (parenDepth < 0) + throw new ServiceException("公式表达式括号不匹配:多余的右括号"); + } + if (parenDepth != 0) + throw new ServiceException("公式表达式括号不匹配:缺少右括号"); + } + + private void ValidateTriggerPoints(string triggerPoints) + { + if (string.IsNullOrWhiteSpace(triggerPoints)) + throw new ServiceException("触发时点不能为空"); + + var points = triggerPoints.Split(','); + foreach (var point in points) + { + var trimmed = point.Trim(); + if (!ValidTriggerPoints.Contains(trimmed)) + throw new ServiceException($"触发时点 '{trimmed}' 不合法"); + } + + if (points.All(p => string.IsNullOrWhiteSpace(p))) + throw new ServiceException("至少需要指定一个触发时点"); + } + + private void ValidateScopeFields(CreateRiskApplicationReq req) + { + if (req.ScopeIsGlobal == 1) + { + if (!string.IsNullOrEmpty(req.ScopeAssetBookIds) || + !string.IsNullOrEmpty(req.ScopeClientIds) || + !string.IsNullOrEmpty(req.ScopeUnderlyingTypes) || + !string.IsNullOrEmpty(req.ScopeTradeTypes)) + { + throw new ServiceException("全局适用时,不应指定其他适用范围维度"); + } + return; + } + + if (!string.IsNullOrEmpty(req.ScopeAssetBookIds)) + { + ValidateIdList(req.ScopeAssetBookIds, "适用账户"); + } + + if (!string.IsNullOrEmpty(req.ScopeClientIds)) + { + ValidateIdList(req.ScopeClientIds, "适用对手方"); + } + } + + private void ValidateIdList(string idList, string fieldName) + { + var parts = idList.Split(','); + foreach (var part in parts) + { + if (!int.TryParse(part.Trim(), out _)) + throw new ServiceException($"{fieldName}列表格式不合法,应为逗号分隔的数字 ID"); + } + } + + private void TryRefreshCache() + { + try + { + RiskEngineService.GetInstance().RefreshCache(); + } + catch (Exception ex) + { + _logger.Error($"缓存刷新失败:{ex.Message}", ex); + } + } + + private string GetDataTypeName(RiskVariableDataType dataType) + { + return dataType switch + { + RiskVariableDataType.Numeric => "数值", + RiskVariableDataType.Date => "日期", + RiskVariableDataType.Boolean => "布尔", + _ => "未知" + }; + } + + private glms_risk_rule GetRuleOrThrow(long ruleId) + { + var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status != RiskRuleStatus.Deleted); + if (rule == null) + throw new ServiceException("规则不存在或已删除"); + return rule; + } + + private glms_risk_rule_application GetApplicationOrThrow(long applicationId) + { + var app = DbContext.glms_risk_rule_application.FirstOrDefault(a => a.id == applicationId && a.Status != RiskRuleStatus.Deleted); + if (app == null) + throw new ServiceException("应用配置不存在或已删除"); + return app; + } + + private glms_risk_variable GetVariableOrThrow(long variableId) + { + var variable = DbContext.glms_risk_variable.FirstOrDefault(v => v.id == variableId); + if (variable == null) + throw new ServiceException("变量不存在"); + return variable; + } + + private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr) + { + if (string.IsNullOrWhiteSpace(ruleName)) + throw new ServiceException("规则名称不能为空"); + if (ruleName.Length > 200) + throw new ServiceException("规则名称长度不能超过200字符"); + + if (string.IsNullOrWhiteSpace(ruleExpr)) + throw new ServiceException("规则表达式不能为空"); + + if (!string.IsNullOrWhiteSpace(conditionJson)) + { + ValidateConditionJson(conditionJson); + } + + ValidateRuleExpr(ruleExpr); + } + + private List ParseRuleIds(string ruleIds) + { + if (string.IsNullOrWhiteSpace(ruleIds)) + throw new ServiceException("关联规则 ID 不能为空"); + + var ids = new List(); + foreach (var part in ruleIds.Split(',')) + { + if (long.TryParse(part.Trim(), out long id)) + ids.Add(id); + else + throw new ServiceException($"规则 ID 列表格式不合法:'{part.Trim()}'"); + } + + if (ids.Count == 0) + throw new ServiceException("关联规则 ID 不能为空"); + + return ids; + } + + private void ValidateRuleIdsActive(string ruleIds) + { + 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) + throw new ServiceException("部分关联规则不存在或已删除"); + + foreach (var rule in rules) + { + if (rule.Status == RiskRuleStatus.Disabled) + throw new ServiceException($"关联规则 '{rule.RuleName}' 已停用,无法创建/启用应用配置"); + } + } + + private string ResolveRuleNames(string ruleIds) + { + if (string.IsNullOrWhiteSpace(ruleIds)) + return ""; + + var ids = ruleIds.Split(',') + .Select(s => long.TryParse(s.Trim(), out long id) ? id : (long?)null) + .Where(id => id.HasValue) + .Select(id => id.Value) + .ToList(); + + if (ids.Count == 0) + return ""; + + var names = DbContext.glms_risk_rule + .Where(r => ids.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) + .Select(r => r.RuleName) + .ToList(); + + return string.Join(", ", names); + } + + /// + /// 构建 Scope 字段的 OR 过滤谓词(逗号分隔值精确匹配) + /// + private static IQueryable ApplyScopeFilter( + IQueryable query, + Expression> fieldSelector, + string csvValues) + { + var ids = csvValues.Split(',', StringSplitOptions.RemoveEmptyEntries); + if (ids.Length == 0) + return query; + + Expression> predicate = null; + var param = fieldSelector.Parameters[0]; + var member = fieldSelector.Body; + + foreach (var trimmed in ids.Select(id => id.Trim())) + { + var eq = Expression.Equal(member, Expression.Constant(trimmed)); + var startsExpr = Expression.Call(member, nameof(string.StartsWith), null, + Expression.Constant(trimmed + ",")); + var endsExpr = Expression.Call(member, nameof(string.EndsWith), null, + Expression.Constant("," + trimmed)); + var containsExpr = Expression.Call(member, nameof(string.Contains), null, + Expression.Constant("," + trimmed + ",")); + + var orExpr = Expression.OrElse(Expression.OrElse(eq, startsExpr), + Expression.OrElse(endsExpr, containsExpr)); + var lambda = Expression.Lambda>(orExpr, param); + predicate = predicate == null ? lambda : predicate.Or(lambda); + } + + return predicate != null ? query.Where(predicate) : query; + } + + private static string GetStrategyName(RiskControlStrategy strategy) + { + return strategy switch + { + RiskControlStrategy.Block => "阻断", + RiskControlStrategy.Approval => "审批", + RiskControlStrategy.ShowTip => "提醒", + _ => strategy.ToString() + }; + } + + private static readonly Dictionary TriggerPointCnMap = new Dictionary + { + ["BOOK_CONFIRM"] = "交易录入确认", + ["CLOSE_REVIEW"] = "平仓审核", + ["UPLOAD_CONFIRMATION"] = "上传确认书", + ["EVENT_TRIGGER"] = "事件触发", + ["FUND_PAYMENT"] = "资金支付" + }; + + private static string MapTriggerPointsToChinese(string triggerPoints) + { + if (string.IsNullOrWhiteSpace(triggerPoints)) + return ""; + + var parts = triggerPoints.Split(','); + var translated = parts.Select(p => TriggerPointCnMap.TryGetValue(p.Trim(), out var cn) ? cn : p.Trim()); + return string.Join(", ", translated); + } + + #endregion + + #region Rule Management + + public SearchListResult QueryRuleList(QueryRiskRuleReq req) + { + var query = DbContext.glms_risk_rule + .Where(r => r.Status != RiskRuleStatus.Deleted); + + if (!string.IsNullOrWhiteSpace(req.Keyword)) + { + query = query.Where(r => r.RuleName.Contains(req.Keyword) + || r.RuleText.Contains(req.Keyword)); + } + + if (req.Status.HasValue) + { + query = query.Where(r => r.Status == req.Status.Value); + } + + if (req.VariableId.HasValue) + { + var varIdStr = req.VariableId.Value.ToString(); + query = query.Where(r => (r.ConditionJson != null && r.ConditionJson.Contains(varIdStr)) + || (r.RuleExpr != null && r.RuleExpr.Contains(varIdStr))); + } + + 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, + 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.RuleIds.Contains(r.id.ToString()) && a.Status != RiskRuleStatus.Deleted) + }); + + return result.ToSearchList(req); + } + + public RiskRuleDetail GetRuleDetail(long ruleId) + { + var rule = GetRuleOrThrow(ruleId); + + var ruleIdStr = ruleId.ToString(); + var applications = DbContext.glms_risk_rule_application + .Where(a => a.RuleIds.Contains(ruleIdStr) && a.Status != RiskRuleStatus.Deleted) + .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, + Status = rule.Status, + Version = rule.Version, + OptName = rule.OptName, + OptDate = rule.OptDate.GetValueOrDefault(), + UpdateOptName = rule.UpdateOptName, + UpdateDate = rule.UpdateDate ?? rule.OptDate.GetValueOrDefault(), + Applications = applications + }; + } + + public RiskRuleDetail CreateRule(CreateRiskRuleReq req) + { + ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); + + var entity = new glms_risk_rule + { + RuleName = req.RuleName, + RuleText = req.RuleText, + ConditionJson = req.ConditionJson, + RuleExpr = req.RuleExpr, + Status = RiskRuleStatus.Active, + 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); + DbContext.SaveChanges(); + + WriteAuditLog("RULE_CREATE", "RULE", entity.id, req.RuleName, "创建规则", + snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); + DbContext.SaveChanges(); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + + TryRefreshCache(); + + return GetRuleDetail(entity.id); + } + + 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); + + rule.RuleName = req.RuleName; + rule.RuleText = req.RuleText; + rule.ConditionJson = req.ConditionJson; + rule.RuleExpr = req.RuleExpr; + rule.Version = rule.Version + 1; + rule.UpdateOptId = UserId; + rule.UpdateOptName = UserName; + rule.UpdateDate = DateTime.Now; + + WriteAuditLog("RULE_UPDATE", "RULE", ruleId, req.RuleName, "修改规则"); + DbContext.SaveChanges(); + + TryRefreshCache(); + + return GetRuleDetail(ruleId); + } + + public void DeleteRule(long ruleId) + { + var rule = GetRuleOrThrow(ruleId); + + var ruleIdStr = ruleId.ToString(); + var activeCount = DbContext.glms_risk_rule_application + .Count(a => a.RuleIds.Contains(ruleIdStr) && a.Status == RiskRuleStatus.Active); + if (activeCount > 0) + throw new ServiceException($"该规则仍有 {activeCount} 个生效中的应用配置,请先停用或删除相关应用"); + + 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, "删除规则"); + DbContext.SaveChanges(); + + TryRefreshCache(); + } + + 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("仅已停用的规则可以启用"); + + rule.Status = RiskRuleStatus.Active; + rule.UpdateOptId = UserId; + rule.UpdateOptName = UserName; + rule.UpdateDate = DateTime.Now; + + WriteAuditLog("RULE_ENABLE", "RULE", ruleId, rule.RuleName, "启用规则"); + DbContext.SaveChanges(); + + TryRefreshCache(); + } + + public void DisableRule(long ruleId) + { + var rule = DbContext.glms_risk_rule.FirstOrDefault(r => r.id == ruleId && r.Status == RiskRuleStatus.Active); + if (rule == null) + throw new ServiceException("仅已生效的规则可以停用"); + + rule.Status = RiskRuleStatus.Disabled; + rule.UpdateOptId = UserId; + rule.UpdateOptName = UserName; + rule.UpdateDate = DateTime.Now; + + WriteAuditLog("RULE_DISABLE", "RULE", ruleId, rule.RuleName, "停用规则"); + DbContext.SaveChanges(); + + TryRefreshCache(); + } + + public List GetRuleVersions(long ruleId) + { + GetRuleOrThrow(ruleId); + + var logs = DbContext.glms_risk_rule_audit_log + .Where(l => l.TargetType == "RULE" && l.TargetId == ruleId) + .OrderByDescending(l => l.OptDate) + .ToList(); + + var result = new List(); + foreach (var l in logs) + { + int version = 0; + if (!string.IsNullOrEmpty(l.SnapshotData)) + { + try + { + var snapshot = JsonConvert.DeserializeAnonymousType(l.SnapshotData, new { Version = 0 }); + if (snapshot != null) + version = snapshot.Version; + } + catch { } + } + + result.Add(new RiskRuleVersionItem + { + Version = version, + OperationType = l.OperationType, + OperationDetail = l.OperationDetail, + OptName = l.OptName, + OptDate = l.OptDate + }); + } + + return result; + } + + public BatchOperationResult BatchDeleteRules(List ruleIds) + { + var rules = DbContext.glms_risk_rule + .Where(r => ruleIds.Contains(r.id) && r.Status != RiskRuleStatus.Deleted) + .ToList(); + + if (rules.Count != ruleIds.Count) + throw new ServiceException("部分规则不存在或已删除"); + + foreach (var rule in rules) + { + var ruleIdStr = rule.id.ToString(); + var activeCount = DbContext.glms_risk_rule_application + .Count(a => a.RuleIds.Contains(ruleIdStr) && a.Status == RiskRuleStatus.Active); + if (activeCount > 0) + throw new ServiceException($"规则 '{rule.RuleName}' 仍有 {activeCount} 个生效中的应用配置,请先停用或删除相关应用"); + } + + foreach (var rule in rules) + { + rule.Status = RiskRuleStatus.Deleted; + rule.Version = rule.Version + 1; + rule.UpdateOptId = UserId; + rule.UpdateOptName = UserName; + rule.UpdateDate = DateTime.Now; + + WriteAuditLog("RULE_BATCH_DELETE", "RULE", rule.id, rule.RuleName, "批量删除规则"); + } + + DbContext.SaveChanges(); + TryRefreshCache(); + + return new BatchOperationResult + { + Success = true, + TotalCount = ruleIds.Count, + SuccessCount = ruleIds.Count + }; + } + + public List GetRuleApplications(long ruleId) + { + GetRuleOrThrow(ruleId); + + var ruleIdStr = ruleId.ToString(); + var apps = DbContext.glms_risk_rule_application + .Where(a => a.RuleIds.Contains(ruleIdStr) && a.Status != RiskRuleStatus.Deleted) + .OrderByDescending(a => a.UpdateDate) + .ToList(); + + var result = new List(); + foreach (var app in apps) + { + result.Add(new RiskApplicationListItem + { + Id = app.id, + RuleIds = app.RuleIds, + 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() + }); + } + + return result; + } + + public List GetAllRuleList() + { + return DbContext.glms_risk_rule + .Where(r => r.Status == RiskRuleStatus.Active) + .OrderBy(r => r.id) + .Select(r => new RiskRuleSimpleItem + { + Id = r.id, + RuleName = r.RuleName, + RuleText = r.RuleText + }) + .ToList(); + } + + #endregion + + #region Application Management + + public SearchListResult QueryApplicationList(QueryRiskApplicationReq req) + { + var query = DbContext.glms_risk_rule_application + .Where(a => a.Status != RiskRuleStatus.Deleted); + + if (req.Status.HasValue) + { + query = query.Where(a => a.Status == req.Status.Value); + } + + if (req.Strategy.HasValue) + { + query = query.Where(a => a.ControlStrategy == req.Strategy.Value); + } + + if (!string.IsNullOrWhiteSpace(req.TriggerPoint)) + { + query = query.Where(a => a.TriggerPoints.Contains(req.TriggerPoint)); + } + + if (!string.IsNullOrWhiteSpace(req.RuleName)) + { + query = query.Where(a => DbContext.glms_risk_rule + .Any(r => r.Status != RiskRuleStatus.Deleted + && a.RuleIds.Contains(r.id.ToString()) + && r.RuleName.Contains(req.RuleName))); + } + + 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, + 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); + + return pagedResult; + } + + public RiskApplicationDetail GetApplicationDetail(long applicationId) + { + var app = DbContext.glms_risk_rule_application + .FirstOrDefault(a => a.id == applicationId && a.Status != RiskRuleStatus.Deleted); + if (app == null) + throw new ServiceException("应用配置不存在或已删除"); + + return new RiskApplicationDetail + { + Id = app.id, + RuleIds = app.RuleIds, + 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() + }; + } + + public RiskApplicationDetail CreateApplication(CreateRiskApplicationReq req) + { + ValidateRuleIdsActive(req.RuleIds); + ValidateTriggerPoints(req.TriggerPoints); + ValidateScopeFields(req); + + var entity = new glms_risk_rule_application + { + RuleIds = req.RuleIds, + Status = RiskRuleStatus.Active, + 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); + DbContext.SaveChanges(); + + WriteAuditLog("APP_CREATE", "APPLICATION", entity.id, ResolveRuleNames(req.RuleIds), + $"创建应用配置:策略={GetStrategyName(req.ControlStrategy)}, 触发时点={MapTriggerPointsToChinese(req.TriggerPoints)}", + snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); + DbContext.SaveChanges(); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + + TryRefreshCache(); + + return GetApplicationDetail(entity.id); + } + + public RiskApplicationDetail UpdateApplication(long applicationId, UpdateRiskApplicationReq req) + { + var app = GetApplicationOrThrow(applicationId); + + if (app.Version != req.Version) + throw new ServiceException("应用配置已被其他用户修改,请重新加载后再编辑"); + + if (!string.IsNullOrWhiteSpace(req.RuleIds)) + ValidateRuleIdsActive(req.RuleIds); + + 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 (!string.IsNullOrWhiteSpace(req.RuleIds)) + app.RuleIds = req.RuleIds; + 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)}"); + DbContext.SaveChanges(); + + TryRefreshCache(); + + return GetApplicationDetail(applicationId); + } + + public void DeleteApplication(long applicationId) + { + var app = GetApplicationOrThrow(applicationId); + + app.Status = RiskRuleStatus.Deleted; + app.Version = app.Version + 1; + app.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + + WriteAuditLog("APP_DELETE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "删除应用配置"); + DbContext.SaveChanges(); + + TryRefreshCache(); + } + + 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("仅已停用的应用配置可以启用"); + + ValidateRuleIdsActive(app.RuleIds); + + app.Status = RiskRuleStatus.Active; + app.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + + WriteAuditLog("APP_ENABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "启用应用配置"); + DbContext.SaveChanges(); + + TryRefreshCache(); + } + + 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.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + + WriteAuditLog("APP_DISABLE", "APPLICATION", applicationId, ResolveRuleNames(app.RuleIds), "停用应用配置"); + DbContext.SaveChanges(); + + TryRefreshCache(); + } + + public BatchOperationResult BatchEnableApplications(List applicationIds) + { + var apps = DbContext.glms_risk_rule_application + .Where(a => applicationIds.Contains(a.id) && a.Status == RiskRuleStatus.Disabled) + .ToList(); + + if (apps.Count != applicationIds.Count) + throw new ServiceException("部分应用配置不存在或当前状态不允许启用"); + + foreach (var app in apps) + { + ValidateRuleIdsActive(app.RuleIds); + } + + foreach (var app in apps) + { + app.Status = RiskRuleStatus.Active; + app.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + + WriteAuditLog("APP_BATCH_ENABLE", "APPLICATION", app.id, ResolveRuleNames(app.RuleIds), "批量启用应用配置"); + } + + DbContext.SaveChanges(); + TryRefreshCache(); + + return new BatchOperationResult + { + Success = true, + TotalCount = applicationIds.Count, + SuccessCount = applicationIds.Count + }; + } + + public BatchOperationResult BatchDisableApplications(List applicationIds) + { + var apps = DbContext.glms_risk_rule_application + .Where(a => applicationIds.Contains(a.id) && a.Status == RiskRuleStatus.Active) + .ToList(); + + if (apps.Count != applicationIds.Count) + throw new ServiceException("部分应用配置不存在或当前状态不允许停用"); + + foreach (var app in apps) + { + app.Status = RiskRuleStatus.Disabled; + app.UpdateOptId = UserId; + app.UpdateOptName = UserName; + app.UpdateDate = DateTime.Now; + + WriteAuditLog("APP_BATCH_DISABLE", "APPLICATION", app.id, ResolveRuleNames(app.RuleIds), "批量停用应用配置"); + } + + DbContext.SaveChanges(); + TryRefreshCache(); + + return new BatchOperationResult + { + Success = true, + TotalCount = applicationIds.Count, + SuccessCount = applicationIds.Count + }; + } + + #endregion + + #region Variable Management + + public SearchListResult QueryVariableList(QueryRiskVariableReq req) + { + var query = DbContext.glms_risk_variable.AsQueryable(); + + 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)); + } + + 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 + }); + + return result.ToSearchList(req); + } + + public RiskVariableDetail GetVariableDetail(long variableId) + { + var variable = GetVariableOrThrow(variableId); + + var varExpr = variable.VariableExpr; + var referenceCount = DbContext.glms_risk_rule + .Count(r => r.Status == RiskRuleStatus.Active && + ((r.ConditionJson != null && r.ConditionJson.Contains(variable.id.ToString())) || + (r.RuleExpr != null && varExpr != null && r.RuleExpr.Contains(varExpr)))); + + 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, + OptName = variable.OptName, + OptDate = variable.OptDate.GetValueOrDefault(), + UpdateOptName = variable.UpdateOptName, + UpdateDate = variable.UpdateDate ?? variable.OptDate.GetValueOrDefault(), + ReferenceCount = referenceCount + }; + } + + public RiskVariableDetail CreateVariable(CreateRiskVariableReq req) + { + if (string.IsNullOrWhiteSpace(req.VariableName)) + throw new ServiceException("变量名称不能为空"); + if (string.IsNullOrWhiteSpace(req.VariableExpr)) + throw new ServiceException("取值表达式不能为空"); + if (req.VariableExpr.Length > 10000) + throw new ServiceException("取值表达式长度不能超过 10000 字符"); + + var entity = new glms_risk_variable + { + VariableName = req.VariableName, + Category = req.Category, + DataType = req.DataType, + Unit = req.Unit, + ValueDomain = req.ValueDomain, + Description = req.Description, + VariableExpr = req.VariableExpr, + 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); + DbContext.SaveChanges(); + + WriteAuditLog("VARIABLE_CREATE", "VARIABLE", entity.id, req.VariableName, $"创建变量:{req.VariableName}", + snapshotData: JsonConvert.SerializeObject(new { Version = entity.Version })); + DbContext.SaveChanges(); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + + InvalidateVariableCache(); + TryRefreshCache(); + + return GetVariableDetail(entity.id); + } + + public RiskVariableDetail UpdateVariable(long variableId, UpdateRiskVariableReq req) + { + var variable = GetVariableOrThrow(variableId); + + if (variable.Version != req.Version) + throw new ServiceException("变量已被其他用户修改,请重新加载后再编辑"); + + if (string.IsNullOrWhiteSpace(req.VariableName)) + throw new ServiceException("变量名称不能为空"); + if (string.IsNullOrWhiteSpace(req.VariableExpr)) + throw new ServiceException("取值表达式不能为空"); + + if (req.DataType != variable.DataType) + { + var varExpr = variable.VariableExpr; + var activeRefCount = DbContext.glms_risk_rule + .Count(r => r.Status == RiskRuleStatus.Active && + ((r.ConditionJson != null && r.ConditionJson.Contains(variable.id.ToString())) || + (r.RuleExpr != null && varExpr != null && r.RuleExpr.Contains(varExpr)))); + if (activeRefCount > 0) + throw new ServiceException($"该变量被 {activeRefCount} 个生效中的规则引用,无法修改数据类型"); + } + + 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; + + WriteAuditLog("VAR_UPDATE", "VARIABLE", variableId, req.VariableName, $"修改变量:{variable.VariableName}"); + DbContext.SaveChanges(); + + InvalidateVariableCache(); + TryRefreshCache(); + + return GetVariableDetail(variableId); + } + + public void DeleteVariable(long variableId) + { + var variable = GetVariableOrThrow(variableId); + + var varExpr = variable.VariableExpr; + var activeRefCount = DbContext.glms_risk_rule + .Count(r => r.Status == RiskRuleStatus.Active && + ((r.ConditionJson != null && r.ConditionJson.Contains(variable.id.ToString())) || + (r.RuleExpr != null && varExpr != null && r.RuleExpr.Contains(varExpr)))); + if (activeRefCount > 0) + throw new ServiceException($"该变量被 {activeRefCount} 个生效中的规则引用,无法删除"); + + DbContext.glms_risk_variable.Remove(variable); + + WriteAuditLog("VAR_DELETE", "VARIABLE", variableId, variable.VariableName, $"删除变量:{variable.VariableName}"); + DbContext.SaveChanges(); + + InvalidateVariableCache(); + TryRefreshCache(); + } + + public List GetAllVariableList() + { + var result = DbContext.glms_risk_variable + .Where(v => v.VariableExpr != null && v.VariableExpr != "") + .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 + }) + .ToList(); + + return result; + } + + #endregion + + #region Audit Log + + public SearchListResult QueryAuditLogs(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.TargetType)) + { + query = query.Where(l => l.TargetType == req.TargetType); + } + + 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.ToSearchList(req); + } + + public byte[] ExportAuditLogs(QueryRiskAuditLogReq req) + { + throw new ServiceException("导出功能暂未实现"); + } + + 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 + } +} diff --git a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs index 906f710c..9dbf410b 100644 --- a/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs +++ b/YLErpDAL/Modules/RiskModule/QuotaMonitorService.cs @@ -50,6 +50,7 @@ using YLErp.Modules.TradeDalModule; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; using YLErp.QdpModule; +using YLErp.Modules.RiskEngine; using static YLErp.ConsGlobal; namespace YLErp.Modules.RiskModule @@ -4464,7 +4465,12 @@ namespace YLErp.Modules.RiskModule public bool QuotaCheck(ref TradeOpenResult res) { - if (res == null && res.Trade == null) + return QuotaCheck(ref res, false); + } + + public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning) + { + if (res == null || res.Trade == null) { throw new ArgumentNullException(nameof(res)); } @@ -4481,6 +4487,12 @@ namespace YLErp.Modules.RiskModule res.TrialDataId = quotaObj.id; return false; } + //成功状态下,如果触发TIp检查,提示用户 + if (!string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails)) + { + res.TrialDataId = quotaObj.id; + res.TipMsg = quotaObj.RiskWarningDetails; + } return true; } var lastTrial = trialService.QueryLastQuotaTrial(tradeId, true); @@ -4489,8 +4501,27 @@ namespace YLErp.Modules.RiskModule new TradeRiskCheckLogService(UserInfo).AddLog(quotaObj); } //否则的情况是上次没算,这次是预警,或上次算了,结果是不通过\通过或预警,这次是预警或不通过,提示用户; - res.RetCode = TradeOpenRetCode.QuotaTrialError; res.TrialDataId = quotaObj.id; + //汇总各类检查详情,避免只展示RiskWarningDetails而漏掉其它老风控检查 + var detailParts = new List(); + if (!string.IsNullOrWhiteSpace(quotaObj.FundCheckDetails)) + detailParts.Add($"应付资金检查:{quotaObj.FundCheckDetails}"); + if (!string.IsNullOrWhiteSpace(quotaObj.QuotaCheckDetails)) + detailParts.Add($"限额检查:{quotaObj.QuotaCheckDetails}"); + if (!string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails)) + detailParts.Add($"风险预警:{quotaObj.RiskWarningDetails}"); + if (!string.IsNullOrWhiteSpace(quotaObj.QuotaWarningDetails)) + detailParts.Add($"限额预警:{quotaObj.QuotaWarningDetails}"); + res.ErrorMsg = string.Join("\n", detailParts); + var isRiskApprovalWarning = quotaObj.TrialStatus == QuotaTrialStatusEnum.Warning + && !string.IsNullOrWhiteSpace(quotaObj.RiskWarningDetails); + //需清除ErrorMsg,不然外部调用会认为失败 + if (ignoreRiskWarning && isRiskApprovalWarning) + { + res.ErrorMsg = string.Empty; + return true; + } + res.RetCode = isRiskApprovalWarning ? TradeOpenRetCode.RiskWarning : TradeOpenRetCode.QuotaTrialError; return false; } @@ -4903,7 +4934,7 @@ namespace YLErp.Modules.RiskModule { _logger.Info($"[限额试算] 开始执行 - tradeId: {tradeId}, trialSource: {trialSource}"); var result = new QuotaTrial(); - var tradeObj = DbContext.trade.AsNoTracking().Where(O => O.id == tradeId).FirstOrDefault(); + trade tradeObj = DbContext.trade.AsNoTracking().Where(O => O.id == tradeId).FirstOrDefault(); // 检查交易对象是否存在 if (tradeObj == null) @@ -4913,14 +4944,14 @@ namespace YLErp.Modules.RiskModule } _logger.Info($"[限额试算] 交易信息 - tradeId: {tradeObj.id}, TradeNumber: {tradeObj.TradeNumber}, TradeType: {tradeObj.TradeType}, ClientId: {tradeObj.ClientId}"); - - // 检查关键字段是否为null + + //// 检查关键字段是否为null if (string.IsNullOrEmpty(tradeObj.TradeType)) { _logger.Info($"[限额试算] TradeType 为 null 或空 - tradeId: {tradeId}"); throw new ArgumentNullException(nameof(tradeObj.TradeType), "交易类型不能为空"); } - + var tradeList = new List(); if (tradeObj.TradeType == "结构化交易") { @@ -4936,7 +4967,7 @@ namespace YLErp.Modules.RiskModule _logger.Info($"[限额试算] TradeDate 为 null - tradeId: {tradeId}"); throw new ArgumentNullException(nameof(tradeObj.TradeDate), "交易日期不能为空"); } - + var fundStatus = CheckFund(tradeList, tradeObj.StockEqvNotional, tradeObj.ClientId, out var fundMsg, out var availableMsg); //var riskWarningStatus = CheckRiskWarning(tradeList, out var riskWarningMsg); string riskWarningMsg = string.Empty; @@ -4949,7 +4980,7 @@ namespace YLErp.Modules.RiskModule } string quotaWarningMsg = "", quotaMsg = ""; bool quotaWarningStatus = true, quotaStatus = true; - + var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && x.PosiQuantity > 0 && x.IsInitial && !x.Invalid).ToList(); _logger.Info($"[限额试算] 检查持仓 - positions.Count: {positions?.Count ?? 0}"); quotaStatus = CheckUnderlyingWhiteList(tradeObj.ClientId, positions, out quotaMsg); @@ -4971,7 +5002,7 @@ namespace YLErp.Modules.RiskModule List clientRiskCheckResps = new List(); var floatPosi = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && x.PosiQuantity > 0 && x.IsInitial && !x.Invalid).FirstOrDefault(); _logger.Info($"[限额试算] floatPosi: {(floatPosi == null ? "null" : $"id={floatPosi.id}, UnderlyingCode={floatPosi.UnderlyingCode}")}"); - + if (floatPosi != null) { using var bondDb = new BondOmsDBContext(); @@ -4991,10 +5022,10 @@ namespace YLErp.Modules.RiskModule umCodes.Add(floatPosi.UnderlyingCode); umCodes = umCodes.Distinct().ToList(); _logger.Info($"[限额试算] umCodes.Count: {umCodes?.Count ?? 0}"); - + var ums = DataCacheProvider.GetUnderlyingDataSource().AsQueryable().Where(x => umCodes.Contains(x.UnderlyingCode)); _logger.Info($"[限额试算] ums.Count: {ums?.Count() ?? 0}"); - + List checkPoisiList = new List(); var posiList = GetPosiQuotaMoitors(); _logger.Info($"[限额试算] posiList.Count: {posiList?.Count ?? 0}"); @@ -5004,9 +5035,9 @@ namespace YLErp.Modules.RiskModule decimal vobp = 0; double lastPrice = 0; var um = ums.FirstOrDefault(x => x.UnderlyingCode == item.security_id); - + _logger.Info($"[限额试算] 处理持仓 - security_id: {item.security_id}, um: {(um == null ? "null" : $"id={um.id}, UnderlyingCode={um.UnderlyingCode}")}"); - + if (um == null) { _logger.Info($"[限额试算] 找不到标的 - security_id: {item.security_id}"); @@ -5040,7 +5071,7 @@ namespace YLErp.Modules.RiskModule var bondPrice = EodPriceQueryService.GetBondPrice(dealDate, item.security_id); lastPrice = bondPrice != null ? bondPrice.ClosePrice : (um.Price ?? 0) * Convert.ToDouble(ConsGlobal.bondPriceMultiple); vobp = bondPrice != null ? bondPrice.Vobp ?? 0 : 0; - + // 检查 ExJson 是否为 null 或空 if (string.IsNullOrEmpty(um.ExJson)) { @@ -5109,6 +5140,64 @@ namespace YLErp.Modules.RiskModule result.QuotaWarningDetails = quotaWarningMsg ?? ""; result.AvailableForClient = availableMsg ?? ""; result.TrialSource = trialSource; + + // ===== 新风控引擎接入点(设计文档 4.6.1) ===== + try + { + var riskEngine = new RiskEngineService(this); + var riskContext = new RiskContext + { + TradeId = tradeId, + TriggerPoint = "BOOK_CONFIRM" + }; + // 构造 DataMap(第一版只塞 trade 对象,后续按需扩展) + riskContext.DataMap["trade"] = tradeObj; + var riskResult = riskEngine.EvaluateRisk(riskContext, "BOOK_CONFIRM"); + + _logger.Info($"[风控引擎] 簿记交易确认 - TradeId: {tradeId}, Passed: {riskResult.Passed}, Blocked: {riskResult.Blocked}, NeedApproval: {riskResult.NeedApproval}, ShowTip: {riskResult.ShowTip}"); + + // 按策略映射回 QuotaTrialStatusEnum + if (riskResult.Blocked) + { + result.TrialStatus = QuotaTrialStatusEnum.Error; + result.RiskWarningDetails += "[风控引擎] 规则触发:禁止\n"; + foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Block)) + { + result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; + } + } + if (riskResult.NeedApproval) + { + if (result.TrialStatus != QuotaTrialStatusEnum.Error) + { + result.TrialStatus = QuotaTrialStatusEnum.Warning; + } + result.RiskWarningDetails += "[风控引擎] 规则触发:需审批\n"; + foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Approval)) + { + result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n"; + } + } + if (riskResult.ShowTip) + { + var warningMessages = riskResult.TriggeredRules + .Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip) + .Select(r => r.Message) + .Where(r => !string.IsNullOrWhiteSpace(r)) + .ToList(); + if (warningMessages.Count > 0) + { + result.RiskWarningDetails += "[风控引擎] 提示:" + string.Join(";", warningMessages) + "\n"; + } + } + } + catch (Exception ex) + { + _logger.Error($"[风控引擎] 执行异常 - TradeId: {tradeId}, Error: {ex.Message}"); + // 第一版:异常不阻断原有流程,只记录日志 + } + // ===== 新风控引擎接入结束 ===== + _logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}"); SaveQuotaTrial(result); return result; diff --git a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs index 6055e15c..53ac78c3 100644 --- a/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs +++ b/YLErpDAL/Modules/RiskModule/TradeRiskCheckLogService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using MoreLinq; using OfficeOpenXml; using Snowflake.Core; @@ -146,6 +146,39 @@ namespace YLErp.Modules.RiskModule DbContext.SaveChanges(); } + public void AddWarningDecisionLog(int quotaTrialId, string decision) + { + var quotaTrial = DbContext.quotaTrial.FirstOrDefault(x => x.id == quotaTrialId); + if (quotaTrial == null) + { + return; + } + var td = DbContext.trade.FirstOrDefault(x => x.id == quotaTrial.TradeId); + trade_risk_check_log log = new trade_risk_check_log(); + if (td != null) + { + var client = DataCacheProvider.GetClientDataSource().GetData(td.ClientId); + log.client_number = client?.Number; + } + var worker = new IdWorker(1, 1); + log.id = worker.NextId(); + log.trial_result = (int)quotaTrial.TrialStatus; + log.client_name = quotaTrial.ClientName; + log.trader = td?.TraderName; + log.trade_number = quotaTrial.TradeNumber; + log.risk_warning = string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails) + ? $"[风控预警处理]{decision}" + : quotaTrial.RiskWarningDetails + Environment.NewLine + $"[风控预警处理]{decision}"; + log.limit_warning = quotaTrial.QuotaCheckDetails; + log.remark = string.IsNullOrWhiteSpace(quotaTrial.Remark) + ? $"风控预警处理结果:{decision}" + : quotaTrial.Remark + ";风控预警处理结果:" + decision; + log.create_user = UserId; + log.create_time = DateTime.Now; + DbContext.trade_risk_check_log.Add(log); + DbContext.SaveChanges(); + } + /// /// 回退重置 /// diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs index f6f74962..bedb5873 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeConfirmService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using DocumentFormat.OpenXml.Office2010.Excel; using OfficeOpenXml.Drawing; using Org.BouncyCastle.Ocsp; @@ -46,7 +46,7 @@ namespace YLErp.Modules.TradeModule.DealModule /// /// 2021-02-04改造:批量确认因为耗时太长,用户多页面(交易列表,今日交易)并发确认导致生成多条期权费资金记录 /// - public TradeConfirmResultModel tradeConfirm(IEnumerable tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false) + public TradeConfirmResultModel tradeConfirm(IEnumerable tradeids, bool ignoreMoneyCheck = false, bool isSkipApproval = false, bool ignoreRiskWarning = false) { if (tradeids is null || !tradeids.Any(n => n > 0)) { @@ -76,6 +76,7 @@ namespace YLErp.Modules.TradeModule.DealModule var hasTradeProcess = HasTradeProcess(); var sbError = new StringBuilder(); + var sbTip = new StringBuilder(); var result = new TradeConfirmResultModel { confirmedTradeIds = new List(), @@ -99,7 +100,8 @@ namespace YLErp.Modules.TradeModule.DealModule isSkipApproval: isSkipApproval, confirmedTradeIds: result.confirmedTradeIds, changeConfirmPaths: result.changeConfirmPaths, - continueQuotaCheck: idCache.Contains(pid)); + continueQuotaCheck: idCache.Contains(pid), + ignoreRiskWarning: ignoreRiskWarning); if (pid > 0) { idCache.Add(pid); @@ -114,7 +116,8 @@ namespace YLErp.Modules.TradeModule.DealModule result.LackOfMoney = true; break; } - if (temp.RetCode == TradeOpenRetCode.QuotaTrialError || temp.RetCode == TradeOpenRetCode.FundStatus) + //此处LackOfMoney仅仅代表需要前端二次处理,不代表资金缺乏,延用老逻辑 + if (temp.RetCode == TradeOpenRetCode.QuotaTrialError || temp.RetCode == TradeOpenRetCode.FundStatus || temp.RetCode == TradeOpenRetCode.RiskWarning) { result.LackOfMoney = true; result.TrialDataId = temp.TrialDataId; @@ -128,6 +131,12 @@ namespace YLErp.Modules.TradeModule.DealModule sbError.AppendLine(temp.ErrorMsg); } + if (!string.IsNullOrEmpty(temp.TipMsg)) + { + result.TrialDataId = temp.TrialDataId; + sbTip.AppendLine(temp.TipMsg); + } + Helpers.LockHelper.Release(lockKeyId); } } @@ -135,6 +144,7 @@ namespace YLErp.Modules.TradeModule.DealModule DbContext.SaveChanges(); result.errorMsg = sbError.ToString(); + result.tipMsg = sbTip.ToString(); return result; } @@ -152,7 +162,7 @@ namespace YLErp.Modules.TradeModule.DealModule /// /// private TradeOpenResult ConfirmTrade(int tid, bool isBatch, bool hasTradeProcess, - bool ignoreMoneyCheck, bool isSkipApproval, List confirmedTradeIds, List changeConfirmPaths, bool continueQuotaCheck = false) + bool ignoreMoneyCheck, bool isSkipApproval, List confirmedTradeIds, List changeConfirmPaths, bool continueQuotaCheck = false, bool ignoreRiskWarning = false) { var td = DbContext.trade.FirstOrDefault(t => t.id == tid); var result = new TradeOpenResult(td ?? new trade()); @@ -207,7 +217,7 @@ namespace YLErp.Modules.TradeModule.DealModule } var trialService = new QuotaMonitorService(this); - if (!trialService.QuotaCheck(ref result)) + if (!trialService.QuotaCheck(ref result, ignoreRiskWarning)) { return result; } @@ -223,6 +233,10 @@ namespace YLErp.Modules.TradeModule.DealModule history = "特权账户批量确认自动" + history; } } + else if (ignoreRiskWarning) + { + history = "风控预警确认-" + history; + } //进入交易审批流程 if (hasTradeProcess && !isSkipApproval) { @@ -609,6 +623,7 @@ namespace YLErp.Modules.TradeModule.DealModule public List confirmedTradeIds; public List changeConfirmPaths; public string errorMsg; + public string tipMsg; public string type; } } diff --git a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs index 0e354bf0..448967ec 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/TradeOpenService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using YLErp.BLL; using YLErp.BLL.Eod; using YLErp.Model; @@ -792,6 +792,10 @@ namespace YLErp.Modules.TradeModule.DealModule /// 错误信息 /// public string ErrorMsg; + /// + /// 提示信息 + /// + public string TipMsg; /// /// 变更确认书路径 @@ -831,6 +835,10 @@ namespace YLErp.Modules.TradeModule.DealModule /// FundStatus, /// + /// 风控预警二次确认 + /// + RiskWarning, + /// /// 无审核权限 /// NoRight diff --git a/YLErpDAL/Modules/TradeModule/TradeBLL.cs b/YLErpDAL/Modules/TradeModule/TradeBLL.cs index 5571b14d..993be3a9 100644 --- a/YLErpDAL/Modules/TradeModule/TradeBLL.cs +++ b/YLErpDAL/Modules/TradeModule/TradeBLL.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using Org.BouncyCastle.Ocsp; using System; using System.Drawing; @@ -25,6 +25,8 @@ namespace YLErp.BLL /// public const string LackOfMoney = "LackOfMoney"; + public const string RiskWarningConfirm = "RiskWarningConfirm"; + public const string IgnoreBreak = "IgnoreBreak"; /// diff --git a/YLErpDAL/YLErpDAL.csproj b/YLErpDAL/YLErpDAL.csproj index 83cf8334..e9d409ac 100644 --- a/YLErpDAL/YLErpDAL.csproj +++ b/YLErpDAL/YLErpDAL.csproj @@ -30,6 +30,8 @@ + + @@ -40,7 +42,7 @@ - + diff --git a/YLErpWeb/App/HostedTaskService.cs b/YLErpWeb/App/HostedTaskService.cs index a219fb65..28660c10 100644 --- a/YLErpWeb/App/HostedTaskService.cs +++ b/YLErpWeb/App/HostedTaskService.cs @@ -1,8 +1,9 @@ -using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using YLErp.DBModels.Consts; using YLErp.Modules.AppModule; using YLErp.Modules.EodModule.SettlementModule; +using YLErp.Modules.RiskEngine; using YLErp.Modules.RiskModule; using YLErp.Modules.SuperviseReportModule.SAC.Service; using YLErp.Modules.TradeRiskCalcModule; @@ -123,6 +124,19 @@ namespace YLErp.Web.App { PS.SetConfig(ConsAppConfig.YLErpWebUrlConfig, address); } + + // 预热风控引擎:加载规则与应用到内存,并预编译所有规则 + Task.Run(() => + { + try + { + RiskEngineService.GetInstance().Preload(); + } + catch (Exception ex) + { + LogFactory.GetLogger("HostedTaskService").Error("[风控引擎] 启动预热失败", ex); + } + }); }); return Task.CompletedTask; diff --git a/YLErpWeb/App_Data/FunctionRight.xml b/YLErpWeb/App_Data/FunctionRight.xml index 2f01d359..1e03e280 100644 --- a/YLErpWeb/App_Data/FunctionRight.xml +++ b/YLErpWeb/App_Data/FunctionRight.xml @@ -1,28 +1,29 @@ - + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + @@ -30,13 +31,13 @@ - - - - - - - + + + + + + + @@ -52,14 +53,31 @@ - + - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -71,39 +89,39 @@ - + - - + + - - - + + + - - - + + + - - + + - - - + + + - - + + @@ -111,62 +129,62 @@ - - - - + + + + - - + + - - - - - - + + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - + - + - - - - - - - + + + + + + + @@ -194,28 +212,28 @@ - + - + - - - - - - - + + + + + + + - - + + - + diff --git a/YLErpWeb/App_Data/Menus.txt b/YLErpWeb/App_Data/Menus.txt index a8aea562..7b460406 100644 --- a/YLErpWeb/App_Data/Menus.txt +++ b/YLErpWeb/App_Data/Menus.txt @@ -25,6 +25,7 @@ {Name:"资金监控",Rights:["风险控制-资金监控"],Url:"client/clientRiskMonitor"}, {Name:"市场风险",Rights:["风险控制-市场风险"],Url:"risk/RiskExposureReport"}, {Name:"限额监控",Rights:["风险控制-限额监控"],Url:"risk/quotaMonitor"}, + {Name:"异常交易监控",Rights:["风险控制-异常交易监控"],Url:"v3//risk/risk-engine-config"}, {Name:"白名单券池",Rights:["风险控制-白名单券池"],Url:"v3/data/underlying-pool"}, {Name:"日终持仓风险",Rights:["风险控制-日终持仓风险"],Url:"trade/EodPositionRisks"}, {Name:"日终持仓风险_互换",Rights:["风险控制-日终持仓风险_互换"],Url:"swaptrade2/EodPositionRisks"}, diff --git a/YLErpWeb/Common/BaseController.cs b/YLErpWeb/Common/BaseController.cs index e8224105..be2e8e8a 100644 --- a/YLErpWeb/Common/BaseController.cs +++ b/YLErpWeb/Common/BaseController.cs @@ -180,6 +180,14 @@ namespace YLErp.Web return; } var hasRight = false; + #if DEBUG + if (context.Controller is Controllers.RiskRuleController) + { + hasRight = true; + base.OnActionExecuting(context); + return; + } + #endif var isAjaxRequest = Request.Headers["X-Requested-With"] == "XMLHttpRequest"; var typedHeaders = Request.GetTypedHeaders(); diff --git a/YLErpWeb/Common/UserInfo.cs b/YLErpWeb/Common/UserInfo.cs index d9c507eb..e5071920 100644 --- a/YLErpWeb/Common/UserInfo.cs +++ b/YLErpWeb/Common/UserInfo.cs @@ -1,4 +1,4 @@ -using YLErp.DBModels.Consts; +using YLErp.DBModels.Consts; using YLErp.Model.Enum; using YLErp.Modules.ClientModule; using YLErp.Modules.ClientModule.Models; @@ -280,7 +280,7 @@ namespace YLErp.Web if (user.Roles.Any(n => n.Name == "权限管理员")) { - var rightStrs = new string[] { "部门管理", "系统管理", "角色管理", "角色新增", "角色修改", "用户管理", "用户新增", "用户修改", "用户禁用", "基础缓存" }; + var rightStrs = new string[] { "部门管理", "系统管理", "角色管理", "角色新增", "角色修改", "用户管理", "用户新增", "用户修改", "用户禁用", "基础缓存", "风控规则查看", "风控规则新增", "风控规则编辑", "风控规则删除", "风控规则启停", "风控应用查看", "风控应用新增", "风控应用编辑", "风控应用删除", "风控应用启停", "风控变量查看", "风控变量新增", "风控变量编辑", "风控变量删除", "风控日志查看", "风控日志导出" }; var query1 = db.Functions.Where(o => rightStrs.Contains(o.Name)).Select(n => n.Id); var query2 = from roleFunc in db.RoleFunctions join roleUsr in db.RoleUsers on roleFunc.RoleId equals roleUsr.RoleId diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs new file mode 100644 index 00000000..55ba98e9 --- /dev/null +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -0,0 +1,667 @@ +using Qdp.Foundation.Utilities; +using YLErp.DBModels; +using YLErp.Modules.RiskEngine; +using YLErp.Modules.RiskEngine.Dto; + +namespace YLErp.Web.Controllers +{ + [Route("risk-engine")] + public class RiskRuleController : BaseController + { + private readonly IYcLogger _logger = LogFactory.GetLogger("RiskRuleController"); + + #region Rule Management + + [HttpGet("risk-rules")] + [MyAuthorize("风控规则查看")] + public JsonResult QueryRiskRules(QueryRiskRuleReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.QueryRuleList(req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询风控规则列表"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-rules/{id}")] + [MyAuthorize("风控规则查看")] + public JsonResult GetRiskRule(long id) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.GetRuleDetail(id); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询风控规则详情"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-rules/{id}/versions")] + [MyAuthorize("风控规则查看")] + public JsonResult GetRiskRuleVersions(long id) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.GetRuleVersions(id); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询风控规则版本历史"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-rules")] + [MyAuthorize("风控规则新增")] + public JsonResult CreateRiskRule([FromBody] CreateRiskRuleReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.CreateRule(req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "创建风控规则"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPut("risk-rules/{id}")] + [MyAuthorize("风控规则编辑")] + public JsonResult UpdateRiskRule(long id, [FromBody] UpdateRiskRuleReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.UpdateRule(id, req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "更新风控规则"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpDelete("risk-rules/{id}")] + [MyAuthorize("风控规则删除")] + public JsonResult DeleteRiskRule(long id) + { + try + { + var service = new RiskRuleService(CurUser); + service.DeleteRule(id); + return Json(new { success = true }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "删除风控规则"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpDelete("risk-rules/batch")] + [MyAuthorize("风控规则删除")] + public JsonResult BatchDeleteRiskRules([FromBody] List ids) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.BatchDeleteRules(ids); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "批量删除风控规则"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-rules/{id}/enable")] + [MyAuthorize("风控规则启停")] + public JsonResult EnableRiskRule(long id) + { + try + { + var service = new RiskRuleService(CurUser); + service.EnableRule(id); + return Json(new { success = true }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "启用风控规则"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-rules/{id}/disable")] + [MyAuthorize("风控规则启停")] + public JsonResult DisableRiskRule(long id) + { + try + { + var service = new RiskRuleService(CurUser); + service.DisableRule(id); + return Json(new { success = true }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "停用风控规则"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-rules/{id}/applications")] + [MyAuthorize("风控规则查看")] + public JsonResult GetRuleApplications(long id) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.GetRuleApplications(id); + return Json(new { success = true, data = new { rows = result, total = result.Count, records = result.Count, page = 1 } }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询规则关联应用列表"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-rules/list")] + [MyAuthorize("风控规则查看")] + public JsonResult GetAllRiskRules() + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.GetAllRuleList(); + return Json(new { success = true, data = new { rows = result, total = result.Count, records = result.Count, page = 1 } }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询规则下拉列表"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + #endregion + + #region Application Management + + [HttpGet("risk-applications")] + [MyAuthorize("风控应用查看")] + public JsonResult QueryRiskApplications(QueryRiskApplicationReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.QueryApplicationList(req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询应用配置列表"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-applications/{id}")] + [MyAuthorize("风控应用查看")] + public JsonResult GetRiskApplication(long id) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.GetApplicationDetail(id); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询应用配置详情"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-applications")] + [MyAuthorize("风控应用新增")] + public JsonResult CreateRiskApplication([FromBody] CreateRiskApplicationReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.CreateApplication(req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "创建应用配置"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPut("risk-applications/{id}")] + [MyAuthorize("风控应用编辑")] + public JsonResult UpdateRiskApplication(long id, [FromBody] UpdateRiskApplicationReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.UpdateApplication(id, req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "更新应用配置"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpDelete("risk-applications/{id}")] + [MyAuthorize("风控应用删除")] + public JsonResult DeleteRiskApplication(long id) + { + try + { + var service = new RiskRuleService(CurUser); + service.DeleteApplication(id); + return Json(new { success = true }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "删除应用配置"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-applications/{id}/enable")] + [MyAuthorize("风控应用启停")] + public JsonResult EnableRiskApplication(long id) + { + try + { + var service = new RiskRuleService(CurUser); + service.EnableApplication(id); + return Json(new { success = true }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "启用应用配置"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-applications/{id}/disable")] + [MyAuthorize("风控应用启停")] + public JsonResult DisableRiskApplication(long id) + { + try + { + var service = new RiskRuleService(CurUser); + service.DisableApplication(id); + return Json(new { success = true }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "停用应用配置"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-applications/batch-enable")] + [MyAuthorize("风控应用启停")] + public JsonResult BatchEnableRiskApplications([FromBody] List ids) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.BatchEnableApplications(ids); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "批量启用应用配置"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-applications/batch-disable")] + [MyAuthorize("风控应用启停")] + public JsonResult BatchDisableRiskApplications([FromBody] List ids) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.BatchDisableApplications(ids); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "批量停用应用配置"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + #endregion + + #region Variable Management + + [HttpGet("risk-variables")] + [MyAuthorize("风控变量查看")] + public JsonResult QueryRiskVariables(QueryRiskVariableReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.QueryVariableList(req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询变量列表"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-variables/{id}")] + [MyAuthorize("风控变量查看")] + public JsonResult GetRiskVariable(long id) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.GetVariableDetail(id); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询变量详情"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-variables/list")] + [MyAuthorize("风控变量查看")] + public JsonResult GetAllRiskVariables() + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.GetAllVariableList(); + return Json(new { success = true, data = new { rows = result, total = result.Count, records = result.Count, page = 1 } }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询全部变量列表"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPost("risk-variables")] + [MyAuthorize("风控变量新增")] + public JsonResult CreateRiskVariable([FromBody] CreateRiskVariableReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.CreateVariable(req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "创建变量"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpPut("risk-variables/{id}")] + [MyAuthorize("风控变量编辑")] + public JsonResult UpdateRiskVariable(long id, [FromBody] UpdateRiskVariableReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.UpdateVariable(id, req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "更新变量"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpDelete("risk-variables/{id}")] + [MyAuthorize("风控变量删除")] + public JsonResult DeleteRiskVariable(long id) + { + try + { + var service = new RiskRuleService(CurUser); + service.DeleteVariable(id); + return Json(new { success = true }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "删除变量"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + #endregion + + #region Trade Types + + [HttpGet("trade-types")] + [MyAuthorize("风控应用查看")] + public JsonResult GetTradeTypes() + { + try + { + var rows = ConsTrade.AllTradeTypes.Select(t => new { label = t, value = t }).ToList(); + var total = rows.Count; + return Json(new { + success = true, + data = new { + page = 1, + records = total, + rows = rows, + total = total + } + }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询合约类型列表"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + #endregion + + #region Audit Log + + [HttpGet("risk-audit-logs")] + [MyAuthorize("风控日志查看")] + public JsonResult QueryRiskAuditLogs(QueryRiskAuditLogReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.QueryAuditLogs(req); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询风控操作日志列表"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-audit-logs/{id}")] + [MyAuthorize("风控日志查看")] + public JsonResult GetRiskAuditLogDetail(long id) + { + try + { + var service = new RiskRuleService(CurUser); + var result = service.GetAuditLogDetail(id); + return Json(new { success = true, data = result }); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "查询风控操作日志详情"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + [HttpGet("risk-audit-logs/export")] + [MyAuthorize("风控日志导出")] + public IActionResult ExportRiskAuditLogs(QueryRiskAuditLogReq req) + { + try + { + var service = new RiskRuleService(CurUser); + var bytes = service.ExportAuditLogs(req); + return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "风控操作日志.xlsx"); + } + catch (ServiceException ex) + { + return Json(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.Error(ex, "导出风控操作日志"); + return Json(new { success = false, message = "系统异常,请联系管理员" }); + } + } + + #endregion + } +} diff --git a/YLErpWeb/Controllers/tradeController.cs b/YLErpWeb/Controllers/tradeController.cs index 428a4c66..9959628b 100644 --- a/YLErpWeb/Controllers/tradeController.cs +++ b/YLErpWeb/Controllers/tradeController.cs @@ -2456,18 +2456,35 @@ namespace YLErp.Web.Controllers } var config = valuedateBLL.SystemDate; var ignoreMoneyCheck = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.LackOfMoney; + var ignoreRiskWarning = tradeidArr.Count() == 1 && additionalProcessing == tradeBLL.RiskWarningConfirm; var isSkipApproval = false; if (CurUser.交易管理_特批批量确认 && isSkipCheck) { ignoreMoneyCheck = true; isSkipApproval = true; } - var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval); + var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning); //如果客户缺少资金而操作者有交易特批权限 - if (!ignoreMoneyCheck && result.LackOfMoney) + if (!ignoreMoneyCheck && !ignoreRiskWarning && result.LackOfMoney) { - return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1 ? tradeBLL.LackOfMoney : "", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + if (result.type == TradeOpenRetCode.RiskWarning.ToString()) + { + return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.RiskWarningConfirm, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } + if (result.type == TradeOpenRetCode.FundStatus.ToString()) + { + return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = "", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } + if (result.type == TradeOpenRetCode.LackOfMoney.ToString() || ((PS.Config.ErpElement.Company == Configuration.CompanyEnum.天风 || config.SpecialOperateForTrade == 1) && string.IsNullOrWhiteSpace(result.type))) + { + return JsonSuccessData(new { proccessType = "AdditionalProcessing", type = tradeBLL.LackOfMoney, TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } + //限额试算不通过(老风控检查失败),展示详情但不允许审批 + if (result.type == TradeOpenRetCode.QuotaTrialError.ToString()) + { + return JsonSuccessData(new { proccessType = "QuotaTrialError", TrialDataId = result.TrialDataId, message = result.errorMsg, typecode = result.type }); + } } //生成交易确认书 @@ -2475,7 +2492,7 @@ namespace YLErp.Web.Controllers { new TradeContractGenerateService(CurUser).GenerateContractsAsync(result.confirmedTradeIds, "确认书"); } - + //有ErrorMsg则认为失败 if (!string.IsNullOrEmpty(result.errorMsg)) { if (result.changeConfirmPaths?.Count > 0) @@ -2486,11 +2503,12 @@ namespace YLErp.Web.Controllers } else { + var successMsg = string.IsNullOrWhiteSpace(result.tipMsg) ? "操作完成" : result.tipMsg; if (result.changeConfirmPaths?.Count > 0) { - return JsonSuccess("操作完成", new { generateChangeSuccess = true, url = result.changeConfirmPaths }); + return JsonSuccess(successMsg, new { generateChangeSuccess = true, url = result.changeConfirmPaths, TrialDataId = result.TrialDataId }); } - return JsonSuccess("操作完成"); + return JsonSuccess(successMsg, new { TrialDataId = result.TrialDataId }); } } @@ -8396,6 +8414,20 @@ namespace YLErp.Web.Controllers return JsonSuccess(); } + public JsonResult SaveRiskWarningDecisionLog(int quotaTrialId, string decision) + { + try + { + new TradeRiskCheckLogService(CurUser).AddWarningDecisionLog(quotaTrialId, decision); + } + catch (Exception ex) + { + LogFactory.GetLogger("SaveRiskWarningDecisionLog").Error(ex); + return JsonError("保存风控预警处理结果失败!"); + } + return JsonSuccess(); + } + [MyAuthorize("风险控制-接口审批列表")] public ActionResult InterfaceApproval() { diff --git a/YLErpWeb/Views/trade/quotaTrial.cshtml b/YLErpWeb/Views/trade/quotaTrial.cshtml index f1e7835b..7751409d 100644 --- a/YLErpWeb/Views/trade/quotaTrial.cshtml +++ b/YLErpWeb/Views/trade/quotaTrial.cshtml @@ -1,4 +1,4 @@ -@model QuotaTrial +@model QuotaTrial @{ ViewBag.Title = "交易试算"; Layout = "~/Views/Shared/_InfoLayout.cshtml"; @@ -111,7 +111,7 @@ 客户:@Model.ClientName - @if (Model.TrialStatus != YLErp.Enums.QuotaTrialStatusEnum.Success || !string.IsNullOrWhiteSpace(Model.AvailableForClient)) + @if (Model.TrialStatus != YLErp.Enums.QuotaTrialStatusEnum.Success || !string.IsNullOrWhiteSpace(Model.AvailableForClient) || !string.IsNullOrWhiteSpace(Model.RiskWarningDetails)) {
diff --git a/YLErpWeb/appsettings.dev.json b/YLErpWeb/appsettings.dev.json index 378136e1..f53778f2 100644 --- a/YLErpWeb/appsettings.dev.json +++ b/YLErpWeb/appsettings.dev.json @@ -7,15 +7,15 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "ylcms": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=glms_yltrs_ylcms;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;", - "yladmin": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=glms_yltrs_admin;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;", - "ylclient": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=glms_yltrs_client;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;", - "bondoms": "server=192.168.2.96;uid=DBAdmin;pooling=true;port=3306;pwd=YieldChain$$2025;database=glms_bond_oms;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;" + "ylcms": "server=10.250.202.35;uid=onederiv@OneDeriv#wx_dtpp_test;pooling=true;port=2883;pwd=Onederiv@2026;database=yltrs_ylcms;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;", + "yladmin": "server=10.250.202.35;uid=onederiv@OneDeriv#wx_dtpp_test;pooling=true;port=2883;pwd=Onederiv@2026;database=yltrs_admin;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;", + "ylclient": "server=10.250.202.35;uid=onederiv@OneDeriv#wx_dtpp_test;pooling=true;port=2883;pwd=Onederiv@2026;database=yltrs_client;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;", + "bondoms": "server=10.250.202.35;uid=onederiv@OneDeriv#wx_dtpp_test;pooling=true;port=2883;pwd=Onederiv@2026;database=bond_oms;charset=utf8;Allow User Variables=True;SslMode=none;Connection Timeout=30;IgnoreCommandTransaction=true;" }, "AppSettings": { "VirtualPathRoot": "", "UseRightAligned": "", - "PluginFolder": "D:\\workspace\\glms\\zszq-trs\\Plugins\\YLErp.Plugins.GuoLian\\obj\\Debug\\net6.0" + "PluginFolder": "D:\\workspace\\onederiv\\trs\\Plugins\\YLErp.Plugins.GuoLian\\obj\\Debug\\net6.0" // 改为本地目录地址 }, "LibreOffice": { "ExePath": "", @@ -75,12 +75,15 @@ "SendKafakaCron": "10 0 * * *", "MaxThreadPercent": 0.6, //占系统资源最多60%的线程数量 "oa_confg": { - "BaseUrl": "http://ip:port", - "SubUrl": "", - "UploadUrl": "/api/oa/upload", // OA附件上传接口路径 - "SystemToken": "your-system-token", // OA系统鉴权Token - "SystemName": "OTC_TRADE", // 调用方系统标识 - "IsNextFlow": 0 + "BaseUrl": "http://10.250.202.40:13590", + "SubUrl": "/gateway/oaflow/createOaFlow", + "QueryUrl": "/gateway/oaflow/getRequestData", + "DownloadFileUrl": "/gateway/oaflow/downloadSingleFile", + "SystemToken": "QIkox8sv3rVLtiT1wmI4Dp8wSjX9D84Glkp121vRino9fLobWnDiy8ppmqJcv1676TYuxZsGrYelx0whRCMaPyums2VnzIYrpqCFf5tyc3/x22Elf25xgRmd5pH8tLcnPuVOjMrUf0GsoytQCGFkhnrPajhsKq/mSwYQ4OA=", //OA系统key + "SystemName": "onederiv", + "IsNextFlow": 1, //是否自动提交 默认0 + "FlowId": 779115, + "UploadUrl": "http://10.250.202.40:13590/gateway/oaflow/uploadDoc" }, "GeneralSSO": { "enable": true, //是否启用通用SSO登录 diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js index ed73c2b1..8527190b 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeView.js @@ -1,4 +1,4 @@ -const inputFormatDouble2 = Object.freeze({ precision: 2, append: '' }); +const inputFormatDouble2 = Object.freeze({ precision: 2, append: '' }); function deletetrade(id) { //无效化 main.confirm(page.ConfirmInfo, function () { @@ -59,8 +59,61 @@ var confirmFunc = function (id, additionalProcessing) { main.message(data.msg); return; } + + if (data.obj && data.obj.proccessType === "QuotaTrialError") { + //限额试算不通过(老风控检查失败),展示详情表格但不允许审批 + if (data.obj.TrialDataId) { + var quotaTrialErrorLayerSetting = { + type: 2, + title: "提示", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, + yes: function (index) { + layer.close(layerIndex); + }, + cancel: function () { + if (window.parent && window.parent.reloadtrade) { + window.parent.reloadtrade(); + } + layer.close(layerIndex); + } + }; + quotaTrialErrorLayerSetting.btn = ["关闭"]; + layerIndex = layer.open(quotaTrialErrorLayerSetting); + } + else if (data.obj.message) { + main.message(data.obj.message); + } + return; + } if (data.obj && data.obj.proccessType === "AdditionalProcessing") { if (data.obj.TrialDataId) { + var isRiskWarningConfirm = data.obj.type === "RiskWarningConfirm"; + var additionalProcessingType = isRiskWarningConfirm ? "RiskWarningConfirm" : "LackOfMoney"; + var saveQuotaTrial = function (obj) { + var saveSuccess = true; + if (obj.Data.Remark && obj.Data.Remark.length > 0) { + main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { + if (!res || !res.success) { + saveSuccess = false; + main.message(res.msg); + } + }); + } + return saveSuccess; + }; + var saveRiskWarningDecisionLog = function (quotaTrialId, decision) { + var saveSuccess = true; + main.post("/trade/SaveRiskWarningDecisionLog", { quotaTrialId: quotaTrialId, decision: decision }, { async: false }).done(function (res) { + if (!res || !res.success) { + saveSuccess = false; + main.message(res.msg); + } + }); + return saveSuccess; + }; var layerSetting = { type: 2, title: "提示", @@ -70,19 +123,27 @@ var confirmFunc = function (id, additionalProcessing) { content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, yes: function (index) { var obj = window["layui-layer-iframe" + index].page; - if (obj.Data.Remark && obj.Data.Remark.length > 0) { - main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { - if (!res || !res.success) { - main.message(res.msg); - } - }); - layer.close(layerIndex); - confirmFunc(id, "LackOfMoney"); + if (!obj.Data.Remark || obj.Data.Remark.length <= 0) { + main.message("必须填写说明内容,才可以录入交易"); return; } - main.message("必须填写说明内容,才可以录入交易"); + if (!saveQuotaTrial(obj)) { + return; + } + if (isRiskWarningConfirm) { + if (!saveRiskWarningDecisionLog(obj.Data.id, "确认通过")) { + return; + } + } + layer.close(layerIndex); + confirmFunc(id, additionalProcessingType); }, cancel: function () { + var iframeWindow = window["layui-layer-iframe" + this.index]; + var pageObj = iframeWindow && iframeWindow.page; + if (isRiskWarningConfirm && pageObj && pageObj.Data && pageObj.Data.id) { + saveRiskWarningDecisionLog(pageObj.Data.id, "取消不通过"); + } if (window.parent && window.parent.reloadtrade) { window.parent.reloadtrade(); } @@ -92,8 +153,11 @@ var confirmFunc = function (id, additionalProcessing) { if (data.obj.type === "LackOfMoney") { layerSetting.btn = [page.buttonStr, '取消']; } + else if (isRiskWarningConfirm) { + layerSetting.btn = ["交易特批", '取消']; + } // 只展示“资金状况”的情况 - if (data.obj.typecode === "FundStatus") { + else if (data.obj.typecode === "FundStatus") { layerSetting.btn = ["确认", '取消']; } @@ -106,6 +170,38 @@ var confirmFunc = function (id, additionalProcessing) { } return; } + if (data.msg && data.msg !== "操作完成") { + //新风控 ShowTip 提示:交易已确认成功,但触发提示规则,弹窗展示试算详情 + if (data.obj && data.obj.TrialDataId) { + var showTipLayerSetting = { + type: 2, + title: "提示", + shadeClose: false, + shade: 0.4, + area: ['800px', '500px'], + content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, + yes: function (index) { + layer.close(layerIndex); + closetradeWindow(); + }, + cancel: function () { + if (window.parent && window.parent.reloadtrade) { + window.parent.reloadtrade(); + } + layer.close(layerIndex); + closetradeWindow(); + } + }; + showTipLayerSetting.btn = ["关闭"]; + layerIndex = layer.open(showTipLayerSetting); + } + else { + main.alert(data.msg, function () { + closetradeWindow(); + }); + } + return; + } if (window.parent && window.parent.reloadtrade) { window.parent.reloadtrade(); } @@ -115,6 +211,8 @@ var confirmFunc = function (id, additionalProcessing) { } } closetradeWindow(); + }).fail(function (err) { + console.log("[swapTradeView.tradeConfirm] fail, err=", err); }); }; function confirmTrade(id) { diff --git a/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js b/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js index f95b5078..c1943d3b 100644 --- a/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js +++ b/YLErpWeb/wwwroot/Scripts/app/trade/tradeview.js @@ -1,4 +1,4 @@ -const inputFormatDouble2 = Object.freeze({ precision: 2, append: '' }); +const inputFormatDouble2 = Object.freeze({ precision: 2, append: '' }); function deletetrade(id) { //无效化 main.confirm(page.ConfirmInfo, function () { @@ -85,6 +85,34 @@ var confirmFunc = function (id, additionalProcessing) { } if (data.obj && data.obj.proccessType === "AdditionalProcessing") { if (data.obj.TrialDataId) { + var isRiskWarningConfirm = data.obj.type === "RiskWarningConfirm"; + var additionalProcessingType = isRiskWarningConfirm ? "RiskWarningConfirm" : "LackOfMoney"; + var saveQuotaTrial = function (obj) { + var saveSuccess = true; + if (obj.IsTF && (!obj.Data.Remark || obj.Data.Remark.length <= 0)) { + main.message("必须填写说明内容,才可以录入交易"); + return false; + } + if (obj.Data.Remark && obj.Data.Remark.length > 0) { + main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { + if (!res || !res.success) { + saveSuccess = false; + main.message(res.msg); + } + }); + } + return saveSuccess; + }; + var saveRiskWarningDecisionLog = function (quotaTrialId, decision) { + var saveSuccess = true; + main.post("/trade/SaveRiskWarningDecisionLog", { quotaTrialId: quotaTrialId, decision: decision }, { async: false }).done(function (res) { + if (!res || !res.success) { + saveSuccess = false; + main.message(res.msg); + } + }); + return saveSuccess; + }; var layerSetting = { type: 2, title: "提示", @@ -94,31 +122,23 @@ var confirmFunc = function (id, additionalProcessing) { content: "/trade/showQuotaTrial?id=" + data.obj.TrialDataId, yes: function (index) { var obj = window["layui-layer-iframe" + index].page; - if (obj.IsTF) { - if (obj.Data.Remark && obj.Data.Remark.length > 0) { - main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { - if (!res || !res.success) { - main.message(res.msg); - } - }); - layer.close(layerIndex); - confirmFunc(id, "LackOfMoney"); + if (!saveQuotaTrial(obj)) { + return; + } + if (isRiskWarningConfirm) { + if (!saveRiskWarningDecisionLog(obj.Data.id, "确认通过")) { return; } - main.message("必须填写说明内容,才可以录入交易"); - } else { - if (obj.Data.Remark && obj.Data.Remark.length > 0) { - main.post("/trade/SaveQuotaTrial", obj.Data, { async: false }).done(function (res) { - if (!res || !res.success) { - main.message(res.msg); - } - }); - } - layer.close(layerIndex); - confirmFunc(id, "LackOfMoney"); } + layer.close(layerIndex); + confirmFunc(id, additionalProcessingType); }, cancel: function () { + var iframeWindow = window["layui-layer-iframe" + this.index]; + var pageObj = iframeWindow && iframeWindow.page; + if (isRiskWarningConfirm && pageObj && pageObj.Data && pageObj.Data.id) { + saveRiskWarningDecisionLog(pageObj.Data.id, "取消不通过"); + } if (window.parent && window.parent.reloadtrade) { window.parent.reloadtrade(); } @@ -128,8 +148,10 @@ var confirmFunc = function (id, additionalProcessing) { if (data.obj.type === "LackOfMoney") { layerSetting.btn = [page.buttonStr, '取消']; } - // 只展示“资金状况”的情况 - if (data.obj.typecode === "FundStatus") { + else if (isRiskWarningConfirm) { + layerSetting.btn = ["交易特批", '取消']; + } + else if (data.obj.typecode === "FundStatus") { layerSetting.btn = ["确认", '取消']; } @@ -167,6 +189,14 @@ var confirmFunc = function (id, additionalProcessing) { }); }); } + if (data.msg && data.msg !== "操作完成") { + main.alert(data.msg, function () { + if (blClose) { + closetradeWindow(); + } + }); + return; + } if (blClose) { closetradeWindow(); }