!23 feature/p132_74-risk-engine 风控引擎二期
Merge pull request !23 from feature/p132_74-risk-engine
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace YLErp.DBModels
|
||||
@@ -19,8 +20,9 @@ namespace YLErp.DBModels
|
||||
/// <summary>规则说明</summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>规则状态</summary>
|
||||
public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active;
|
||||
public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Disabled;
|
||||
/// <summary>版本号(乐观锁)</summary>
|
||||
[ConcurrencyCheck]
|
||||
public int Version { get; set; } = 1;
|
||||
/// <summary>最后更新人工号</summary>
|
||||
public int? UpdateOptId { get; set; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace YLErp.DBModels
|
||||
@@ -13,7 +14,7 @@ namespace YLErp.DBModels
|
||||
/// <summary>应用说明</summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>应用配置状态</summary>
|
||||
public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active;
|
||||
public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Disabled;
|
||||
/// <summary>风控策略(预警/阻断)</summary>
|
||||
public RiskControlStrategy ControlStrategy { get; set; }
|
||||
/// <summary>触发点列表(JSON 数组)</summary>
|
||||
@@ -29,6 +30,7 @@ namespace YLErp.DBModels
|
||||
/// <summary>是否全局生效(0:否 1:是)</summary>
|
||||
public int ScopeIsGlobal { get; set; }
|
||||
/// <summary>版本号(乐观锁)</summary>
|
||||
[ConcurrencyCheck]
|
||||
public int Version { get; set; } = 1;
|
||||
/// <summary>最后更新人工号</summary>
|
||||
public int? UpdateOptId { get; set; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace YLErp.DBModels
|
||||
@@ -22,7 +23,10 @@ namespace YLErp.DBModels
|
||||
public string Description { get; set; }
|
||||
/// <summary>取值表达式(C# 表达式,用于计算变量值)</summary>
|
||||
public string VariableExpr { get; set; }
|
||||
/// <summary>状态: 1=Active, 2=Disabled, 3=Deleted</summary>
|
||||
public RiskRuleStatus Status { get; set; } = RiskRuleStatus.Active;
|
||||
/// <summary>版本号(乐观锁)</summary>
|
||||
[ConcurrencyCheck]
|
||||
public int Version { get; set; } = 1;
|
||||
/// <summary>排序序号</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ============================================================
|
||||
-- 增量迁移:为 glms_risk_variable 表增加 Status 字段
|
||||
-- 目的:将变量删除由硬删除改造为软删除,与 glms_risk_rule / glms_risk_rule_application 保持一致
|
||||
-- 状态:1=Active, 2=Disabled, 3=Deleted
|
||||
-- 默认值:1(Active),存量数据自动标记为可用
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `yltrs_ylcms`.`glms_risk_variable`
|
||||
ADD COLUMN `Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted' AFTER `VariableExpr`;
|
||||
|
||||
ALTER TABLE `yltrs_ylcms`.`glms_risk_variable`
|
||||
ADD INDEX `idx_status`(`Status` ASC) USING BTREE;
|
||||
+8
-10
@@ -1,6 +1,4 @@
|
||||
SET FOREIGN_KEY_CHECKS=0;
|
||||
|
||||
CREATE TABLE `glms_risk_rule` (
|
||||
CREATE TABLE `yltrs_ylcms`.`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 '规则文本',
|
||||
@@ -19,7 +17,7 @@ CREATE TABLE `glms_risk_rule` (
|
||||
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` (
|
||||
CREATE TABLE `yltrs_ylcms`.`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列表(逗号分隔)',
|
||||
`Description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '描述',
|
||||
@@ -42,7 +40,7 @@ CREATE TABLE `glms_risk_rule_application` (
|
||||
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` (
|
||||
CREATE TABLE `yltrs_ylcms`.`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)',
|
||||
@@ -61,7 +59,7 @@ CREATE TABLE `glms_risk_rule_audit_log` (
|
||||
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` (
|
||||
CREATE TABLE `yltrs_ylcms`.`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',
|
||||
@@ -70,6 +68,7 @@ CREATE TABLE `glms_risk_variable` (
|
||||
`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#表达式)',
|
||||
`Status` tinyint NOT NULL DEFAULT 1 COMMENT '状态: 1=Active, 2=Disabled, 3=Deleted',
|
||||
`Version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)',
|
||||
`SortOrder` int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`OptId` int NULL DEFAULT NULL COMMENT '创建人Id',
|
||||
@@ -78,7 +77,6 @@ CREATE TABLE `glms_risk_variable` (
|
||||
`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;
|
||||
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;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- 异常交易监控页面 - 操作权限(按钮)数据
|
||||
-- 依赖:风险控制目录(name='风险控制',type=0)必须已存在
|
||||
-- 对应 FunctionRight.xml 风险控制下风控相关操作权限(共16个按钮)
|
||||
-- type: 0=目录 1=菜单 2=按钮
|
||||
-- 注意:以下 sys_menu 和 sys_role_menu 两个 INSERT 依赖用户变量 @menu_id,必须在同一个 Session 中执行
|
||||
-- ============================================================
|
||||
|
||||
-- ----------------------------
|
||||
-- 3.1. sys_menu 页面+按钮(type=1,2)
|
||||
-- ----------------------------
|
||||
SELECT IFNULL(MAX(id), 0) INTO @menu_id FROM `yltrs_admin`.`sys_menu`;
|
||||
SELECT id INTO @risk_control_id FROM `yltrs_admin`.`sys_menu` WHERE `name` = '风险控制' AND `type` = 0 LIMIT 1;
|
||||
|
||||
INSERT INTO `yltrs_admin`.`sys_menu` (`id`, `pid`, `pids`, `name`, `code`, `type`, `icon`, `router`, `component`, `permission`, `application`, `open_type`, `visible`, `link`, `redirect`, `weight`, `sort`, `remark`, `status`, `create_time`, `create_user`, `update_time`, `update_user`) VALUES
|
||||
(@menu_id + 1, @risk_control_id, NULL, '异常交易监控', NULL, 1, NULL, NULL, NULL, '异常交易监控', NULL, NULL, NULL, NULL, NULL, NULL, 0, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 2, @menu_id + 1, NULL, '风控规则查看', NULL, 2, NULL, NULL, NULL, '风控规则查看', NULL, NULL, NULL, NULL, NULL, NULL, 1, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 3, @menu_id + 1, NULL, '风控规则新增', NULL, 2, NULL, NULL, NULL, '风控规则新增', NULL, NULL, NULL, NULL, NULL, NULL, 2, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 4, @menu_id + 1, NULL, '风控规则编辑', NULL, 2, NULL, NULL, NULL, '风控规则编辑', NULL, NULL, NULL, NULL, NULL, NULL, 3, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 5, @menu_id + 1, NULL, '风控规则删除', NULL, 2, NULL, NULL, NULL, '风控规则删除', NULL, NULL, NULL, NULL, NULL, NULL, 4, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 6, @menu_id + 1, NULL, '风控规则启停', NULL, 2, NULL, NULL, NULL, '风控规则启停', NULL, NULL, NULL, NULL, NULL, NULL, 5, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 7, @menu_id + 1, NULL, '风控应用查看', NULL, 2, NULL, NULL, NULL, '风控应用查看', NULL, NULL, NULL, NULL, NULL, NULL, 6, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 8, @menu_id + 1, NULL, '风控应用新增', NULL, 2, NULL, NULL, NULL, '风控应用新增', NULL, NULL, NULL, NULL, NULL, NULL, 7, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 9, @menu_id + 1, NULL, '风控应用编辑', NULL, 2, NULL, NULL, NULL, '风控应用编辑', NULL, NULL, NULL, NULL, NULL, NULL, 8, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 10, @menu_id + 1, NULL, '风控应用删除', NULL, 2, NULL, NULL, NULL, '风控应用删除', NULL, NULL, NULL, NULL, NULL, NULL, 9, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 11, @menu_id + 1, NULL, '风控应用启停', NULL, 2, NULL, NULL, NULL, '风控应用启停', NULL, NULL, NULL, NULL, NULL, NULL, 10, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 12, @menu_id + 1, NULL, '风控变量查看', NULL, 2, NULL, NULL, NULL, '风控变量查看', NULL, NULL, NULL, NULL, NULL, NULL, 11, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 13, @menu_id + 1, NULL, '风控变量新增', NULL, 2, NULL, NULL, NULL, '风控变量新增', NULL, NULL, NULL, NULL, NULL, NULL, 12, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 14, @menu_id + 1, NULL, '风控变量编辑', NULL, 2, NULL, NULL, NULL, '风控变量编辑', NULL, NULL, NULL, NULL, NULL, NULL, 13, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 15, @menu_id + 1, NULL, '风控变量删除', NULL, 2, NULL, NULL, NULL, '风控变量删除', NULL, NULL, NULL, NULL, NULL, NULL, 14, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 16, @menu_id + 1, NULL, '风控日志查看', NULL, 2, NULL, NULL, NULL, '风控日志查看', NULL, NULL, NULL, NULL, NULL, NULL, 15, '', 0, NULL, NULL, NULL, NULL),
|
||||
(@menu_id + 17, @menu_id + 1, NULL, '风控日志导出', NULL, 2, NULL, NULL, NULL, '风控日志导出', NULL, NULL, NULL, NULL, NULL, NULL, 16, '', 0, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- ----------------------------
|
||||
-- 3.2. sys_role_menu 角色1的按钮权限(menu_id 引用上方 sys_menu 自增 id)
|
||||
-- ----------------------------
|
||||
SELECT IFNULL(MAX(id), 0) INTO @role_menu_id FROM `yltrs_admin`.`sys_role_menu`;
|
||||
|
||||
INSERT INTO `yltrs_admin`.`sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES
|
||||
(@role_menu_id + 1, 1, @menu_id + 1),
|
||||
(@role_menu_id + 2, 1, @menu_id + 2),
|
||||
(@role_menu_id + 3, 1, @menu_id + 3),
|
||||
(@role_menu_id + 4, 1, @menu_id + 4),
|
||||
(@role_menu_id + 5, 1, @menu_id + 5),
|
||||
(@role_menu_id + 6, 1, @menu_id + 6),
|
||||
(@role_menu_id + 7, 1, @menu_id + 7),
|
||||
(@role_menu_id + 8, 1, @menu_id + 8),
|
||||
(@role_menu_id + 9, 1, @menu_id + 9),
|
||||
(@role_menu_id + 10, 1, @menu_id + 10),
|
||||
(@role_menu_id + 11, 1, @menu_id + 11),
|
||||
(@role_menu_id + 12, 1, @menu_id + 12),
|
||||
(@role_menu_id + 13, 1, @menu_id + 13),
|
||||
(@role_menu_id + 14, 1, @menu_id + 14),
|
||||
(@role_menu_id + 15, 1, @menu_id + 15),
|
||||
(@role_menu_id + 16, 1, @menu_id + 16),
|
||||
(@role_menu_id + 17, 1, @menu_id + 17);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ConditionJson 操作符一次性迁移(基于当前生产 seed 数据)
|
||||
-- 原因:ConditionJson 改用稳定 token,避免 >、< 在接口传输中发生 HTML 转义。
|
||||
-- 仅修改规则 3、14、15、16 的 ConditionJson;RuleExpr、RuleText 和自由文本规则不变。
|
||||
|
||||
USE `yltrs_ylcms`;
|
||||
|
||||
-- 执行前确认当前值,并保存查询结果作为人工备份。
|
||||
SELECT id, RuleName, ConditionJson, RuleExpr
|
||||
FROM glms_risk_rule
|
||||
WHERE id IN (3, 14, 15, 16)
|
||||
ORDER BY id;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
UPDATE glms_risk_rule
|
||||
SET ConditionJson = REPLACE(ConditionJson, '"Operator":">"', '"Operator":"gt"')
|
||||
WHERE id IN (3, 14, 15, 16)
|
||||
AND ConditionJson LIKE '%"Operator":">"%';
|
||||
|
||||
UPDATE glms_risk_rule
|
||||
SET ConditionJson = REPLACE(ConditionJson, '"Operator":"<"', '"Operator":"lt"')
|
||||
WHERE id IN (3, 14, 15, 16)
|
||||
AND ConditionJson LIKE '%"Operator":"<"%';
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 执行后确认:ConditionJson 使用 token,RuleExpr 保持原简洁公式。
|
||||
SELECT id, RuleName, ConditionJson, RuleExpr
|
||||
FROM glms_risk_rule
|
||||
WHERE id IN (3, 14, 15, 16)
|
||||
ORDER BY id;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- ConditionJson 操作符迁移回滚(仅用于应用版本整体回退)
|
||||
-- 只恢复规则 3、14、15、16 的 ConditionJson 操作符,不修改 RuleExpr。
|
||||
|
||||
USE `yltrs_ylcms`;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
UPDATE glms_risk_rule
|
||||
SET ConditionJson = REPLACE(ConditionJson, '"Operator":"gt"', '"Operator":">"')
|
||||
WHERE id IN (3, 14, 15, 16)
|
||||
AND ConditionJson LIKE '%"Operator":"gt"%';
|
||||
|
||||
UPDATE glms_risk_rule
|
||||
SET ConditionJson = REPLACE(ConditionJson, '"Operator":"lt"', '"Operator":"<"')
|
||||
WHERE id IN (3, 14, 15, 16)
|
||||
AND ConditionJson LIKE '%"Operator":"lt"%';
|
||||
|
||||
COMMIT;
|
||||
|
||||
SELECT id, RuleName, ConditionJson, RuleExpr
|
||||
FROM glms_risk_rule
|
||||
WHERE id IN (3, 14, 15, 16)
|
||||
ORDER BY id;
|
||||
@@ -1,135 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 规则应用初始数据(对应设计文档 §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 = '接近/触发敲入敲出价';
|
||||
@@ -1,50 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 异常交易监控页面 - 操作权限(按钮)数据
|
||||
-- 依赖:异常交易监控菜单 id=1077 (type=1) 需先插入
|
||||
-- 对应 FunctionRight.xml 风险控制下风控相关操作权限(共16个按钮)
|
||||
-- type: 0=目录 1=菜单 2=按钮
|
||||
-- ============================================================
|
||||
|
||||
-- ----------------------------
|
||||
-- 1. sys_menu 页面+按钮(type=1,2)
|
||||
-- ----------------------------
|
||||
INSERT INTO `yltrs_admin`.`sys_menu` (`id`, `pid`, `pids`, `name`, `code`, `type`, `icon`, `router`, `component`, `permission`, `application`, `open_type`, `visible`, `link`, `redirect`, `weight`, `sort`, `remark`, `status`, `create_time`, `create_user`, `update_time`, `update_user`) VALUES
|
||||
(1077, 99, NULL, '异常交易监控', NULL, 1, NULL, NULL, NULL, '异常交易监控', NULL, NULL, NULL, NULL, NULL, NULL, 0, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1078, 1077, NULL, '风控规则查看', NULL, 2, NULL, NULL, NULL, '风控规则查看', NULL, NULL, NULL, NULL, NULL, NULL, 1, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1079, 1077, NULL, '风控规则新增', NULL, 2, NULL, NULL, NULL, '风控规则新增', NULL, NULL, NULL, NULL, NULL, NULL, 2, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1080, 1077, NULL, '风控规则编辑', NULL, 2, NULL, NULL, NULL, '风控规则编辑', NULL, NULL, NULL, NULL, NULL, NULL, 3, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1081, 1077, NULL, '风控规则删除', NULL, 2, NULL, NULL, NULL, '风控规则删除', NULL, NULL, NULL, NULL, NULL, NULL, 4, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1082, 1077, NULL, '风控规则启停', NULL, 2, NULL, NULL, NULL, '风控规则启停', NULL, NULL, NULL, NULL, NULL, NULL, 5, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1083, 1077, NULL, '风控应用查看', NULL, 2, NULL, NULL, NULL, '风控应用查看', NULL, NULL, NULL, NULL, NULL, NULL, 6, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1084, 1077, NULL, '风控应用新增', NULL, 2, NULL, NULL, NULL, '风控应用新增', NULL, NULL, NULL, NULL, NULL, NULL, 7, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1085, 1077, NULL, '风控应用编辑', NULL, 2, NULL, NULL, NULL, '风控应用编辑', NULL, NULL, NULL, NULL, NULL, NULL, 8, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1086, 1077, NULL, '风控应用删除', NULL, 2, NULL, NULL, NULL, '风控应用删除', NULL, NULL, NULL, NULL, NULL, NULL, 9, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1087, 1077, NULL, '风控应用启停', NULL, 2, NULL, NULL, NULL, '风控应用启停', NULL, NULL, NULL, NULL, NULL, NULL, 10, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1088, 1077, NULL, '风控变量查看', NULL, 2, NULL, NULL, NULL, '风控变量查看', NULL, NULL, NULL, NULL, NULL, NULL, 11, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1089, 1077, NULL, '风控变量新增', NULL, 2, NULL, NULL, NULL, '风控变量新增', NULL, NULL, NULL, NULL, NULL, NULL, 12, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1090, 1077, NULL, '风控变量编辑', NULL, 2, NULL, NULL, NULL, '风控变量编辑', NULL, NULL, NULL, NULL, NULL, NULL, 13, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1091, 1077, NULL, '风控变量删除', NULL, 2, NULL, NULL, NULL, '风控变量删除', NULL, NULL, NULL, NULL, NULL, NULL, 14, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1092, 1077, NULL, '风控日志查看', NULL, 2, NULL, NULL, NULL, '风控日志查看', NULL, NULL, NULL, NULL, NULL, NULL, 15, '', 0, NULL, NULL, NULL, NULL),
|
||||
(1093, 1077, NULL, '风控日志导出', NULL, 2, NULL, NULL, NULL, '风控日志导出', NULL, NULL, NULL, NULL, NULL, NULL, 16, '', 0, NULL, NULL, NULL, NULL);
|
||||
|
||||
-- ----------------------------
|
||||
-- 2. sys_role_menu 角色1的按钮权限
|
||||
-- ----------------------------
|
||||
INSERT INTO `yltrs_admin`.`sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES
|
||||
(1097, 1, 1077),
|
||||
(1098, 1, 1078),
|
||||
(1099, 1, 1079),
|
||||
(1100, 1, 1080),
|
||||
(1101, 1, 1081),
|
||||
(1102, 1, 1082),
|
||||
(1103, 1, 1083),
|
||||
(1104, 1, 1084),
|
||||
(1105, 1, 1085),
|
||||
(1106, 1, 1086),
|
||||
(1107, 1, 1087),
|
||||
(1108, 1, 1088),
|
||||
(1109, 1, 1089),
|
||||
(1110, 1, 1090),
|
||||
(1111, 1, 1091),
|
||||
(1112, 1, 1092),
|
||||
(1113, 1, 1093);
|
||||
@@ -0,0 +1,121 @@
|
||||
-- ============================================================
|
||||
-- 规则应用初始数据(对应当前 glms_risk_rule_application 数据库内容)
|
||||
-- 依赖:seed_risk_engine_rules.sql(规则数据需先插入)
|
||||
--
|
||||
-- 本次仅同步依赖脚本文件名,不修改应用业务数据和 RuleIds。
|
||||
-- 每条记录对应一条应用配置,定义"何时、对谁、怎么处理"
|
||||
-- ControlStrategy: 1=Block(禁止), 2=Approval(审批), 3=Warning(提示)
|
||||
-- TriggerPoints: BOOK_CONFIRM=簿记交易确认
|
||||
-- ScopeIsGlobal: 1=全局适用, 0=按维度配置(Scope字段为空字符串表示"全部")
|
||||
-- ============================================================
|
||||
|
||||
-- 插入当前数据库中的 6 条规则应用,保留原始 ID 和 RuleIds
|
||||
INSERT INTO `yltrs_ylcms`.`glms_risk_rule_application` (`id`, `RuleIds`, `Description`, `Status`, `ControlStrategy`, `TriggerPoints`, `ScopeAssetBookIds`, `ScopeClientIds`, `ScopeUnderlyingTypes`, `ScopeTradeTypes`, `ScopeIsGlobal`, `Version`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES
|
||||
(23,
|
||||
'3',
|
||||
'',
|
||||
2,
|
||||
2,
|
||||
'BOOK_CONFIRM',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
8,
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-13 13:24:03',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:49:39'),
|
||||
(24,
|
||||
'12',
|
||||
'',
|
||||
2,
|
||||
2,
|
||||
'BOOK_CONFIRM',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
10,
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-14 15:46:51',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:49:42'),
|
||||
(25,
|
||||
'13',
|
||||
NULL,
|
||||
2,
|
||||
2,
|
||||
'BOOK_CONFIRM',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-15 14:06:10',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:50:00'),
|
||||
(26,
|
||||
'14',
|
||||
'',
|
||||
2,
|
||||
2,
|
||||
'BOOK_CONFIRM',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-15 15:19:30',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:50:05'),
|
||||
(27,
|
||||
'16',
|
||||
NULL,
|
||||
2,
|
||||
2,
|
||||
'BOOK_CONFIRM',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
4,
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-15 17:44:02',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:54:11'),
|
||||
(28,
|
||||
'15',
|
||||
'',
|
||||
2,
|
||||
2,
|
||||
'BOOK_CONFIRM',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-15 17:49:56',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:50:03');
|
||||
@@ -0,0 +1,121 @@
|
||||
-- ============================================================
|
||||
-- 规则初始数据(对应当前 glms_risk_rule 数据库内容)
|
||||
-- 依赖:seed_risk_engine_variables.sql(变量池数据需先插入)
|
||||
--
|
||||
-- 变量 ID 映射(seed_risk_engine_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;
|
||||
-- 区间比较使用 Lower/UpperThresholdType、Lower/UpperValue、Lower/UpperThresholdVariableId、IncludeLower/IncludeUpper。
|
||||
-- 修改原因:Operator 使用 gt/lt 等稳定 token,避免 >、< 在接口传输中发生 HTML 转义。
|
||||
-- RuleExpr 保持现有简洁 C# 公式;编译前类型转换由 RiskEngineCompiler 负责。
|
||||
-- 展示字段(VariableName/VariableType/Unit/ThresholdVariableName)从变量接口关联获取
|
||||
-- ============================================================
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 插入当前数据库中的 6 条规则,保留原始 ID 以匹配规则应用表 RuleIds
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO `yltrs_ylcms`.`glms_risk_rule` (`id`, `RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Description`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES
|
||||
(3,
|
||||
'名义本金超阈值',
|
||||
'合约名义本金 > 1亿',
|
||||
'[{"VariableId":1,"Operator":"gt","ThresholdType":"Fixed","Value":100000000}]',
|
||||
'DbContext.trade.First(t => t.id == TradeId).StockEqvNotional > 100000000',
|
||||
'',
|
||||
2,
|
||||
14,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:50:33'),
|
||||
(12,
|
||||
'债券类净价偏离',
|
||||
'期初净价与上一收盘日中债估值净价绝对价差大于5元时触发审批',
|
||||
'',
|
||||
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().net_price.Value) > 5m',
|
||||
NULL,
|
||||
2,
|
||||
9,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:50:29'),
|
||||
(13,
|
||||
'债券类收益率偏离',
|
||||
'',
|
||||
'',
|
||||
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100m - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value) > 0.4m',
|
||||
'',
|
||||
2,
|
||||
10,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:50:21'),
|
||||
(14,
|
||||
'非债券类价格偏离',
|
||||
'TRS非债券价格偏离绝对值 > 5元',
|
||||
'[{"VariableId":47,"Operator":"gt","ThresholdType":"Fixed","Value":5}]',
|
||||
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100 - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice)) > 5',
|
||||
'',
|
||||
2,
|
||||
8,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:50:37'),
|
||||
(15,
|
||||
'单一交易对手累计标的数量超阈值',
|
||||
'对手方累计标的数量 > 10个',
|
||||
'[{"VariableId":32,"Operator":"gt","ThresholdType":"Fixed","Value":10}]',
|
||||
'DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).Distinct().Count() > 10',
|
||||
'',
|
||||
2,
|
||||
8,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:50:18'),
|
||||
(16,
|
||||
'多头支付固定端利率偏离',
|
||||
'利息端利率与FR007偏离度 > 3%',
|
||||
'[{"VariableId":48,"Operator":"gt","ThresholdType":"Fixed","Value":3}]',
|
||||
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m > 3',
|
||||
'',
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 14:54:18');
|
||||
@@ -0,0 +1,118 @@
|
||||
-- ============================================================
|
||||
-- 变量池初始数据(对应当前 glms_risk_variable 数据库内容)
|
||||
-- ============================================================
|
||||
|
||||
-- 4.4.1 簿记要素类(Category=1)
|
||||
-- 4.4.2 行情类(Category=2,统一取上一交易日收盘价)
|
||||
-- 4.4.3 系统计算值类(Category=3)
|
||||
-- 新增变量(ID 45~53):
|
||||
-- 45=期初净价偏离度 46=期初收益率偏离度 47=期初价格偏离度
|
||||
-- 48=利息端利率与FR007偏离度 49=利息端利率与借贷加权费率偏离度
|
||||
-- 50=执行价偏离度 51=执行价 52=客户品种最低保证金率 53=参考价格
|
||||
-- 4.4.4 布尔判断类(Category=4)
|
||||
-- 说明:结构化 RuleExpr 的固定数值不追加 m;本文件 VariableExpr 为人工维护的 C# 表达式,原有 m 后缀保持不变。
|
||||
|
||||
-- 插入当前数据库中的 6 条变量,保留原始 ID 以匹配规则 ConditionJson 中的 VariableId
|
||||
INSERT INTO `yltrs_ylcms`.`glms_risk_variable` (`id`, `VariableName`, `Category`, `DataType`, `Unit`, `ValueDomain`, `Description`, `VariableExpr`, `Status`, `Version`, `SortOrder`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES
|
||||
(1,
|
||||
'合约名义本金',
|
||||
1,
|
||||
1,
|
||||
'元',
|
||||
'≥ 0',
|
||||
'',
|
||||
'DbContext.trade.First(t => t.id == TradeId).StockEqvNotional',
|
||||
1,
|
||||
18,
|
||||
0,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-23 20:13:16',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 10:02:27'),
|
||||
(32,
|
||||
'对手方累计标的数量',
|
||||
3,
|
||||
1,
|
||||
'个',
|
||||
'≥ 0',
|
||||
'当前交易对手方所有存续交易涉及的标的数量(去重)合计(含本笔)',
|
||||
'DbContext.swap_position.Where(p => !string.IsNullOrEmpty(p.UnderlyingCode) && !p.IsInitial && p.PosiQuantity > 0 && !p.Invalid && p.PosiDirection > 0 && DbContext.trade.Any(t => t.id == p.SwapTradeId && t.ValidState != "InValid" && t.ClientId == DbContext.trade.First(x => x.id == TradeId).ClientId)).Select(p => p.UnderlyingCode).Distinct().Count()',
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-23 20:13:16',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 11:20:06'),
|
||||
(45,
|
||||
'TRS债券净价偏离绝对值',
|
||||
3,
|
||||
1,
|
||||
'%',
|
||||
'≥ 0',
|
||||
'abs(期初标的交割净价% - 上一收盘日中债估值净价)',
|
||||
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiNetNoFeePrice.Value * 100 - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(v => v.valuation_date).First().net_price.Value)',
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 13:28:12'),
|
||||
(46,
|
||||
'TRS债券收益率偏离绝对值',
|
||||
3,
|
||||
1,
|
||||
'%',
|
||||
'≥ 0',
|
||||
'abs(期初标的成交收益率% - 上一收盘日中债估值收益率)',
|
||||
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).InitYtm.Value * 100 - DbContext.china_bond_valuation.Where(v => v.bond_id == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && v.valuation_date < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderBy(v => v.credibility).ThenByDescending(v => v.valuation_date).First().yield.Value)',
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 13:28:30'),
|
||||
(47,
|
||||
'TRS非债券价格偏离绝对值',
|
||||
3,
|
||||
1,
|
||||
'元',
|
||||
'≥ 0',
|
||||
'abs(期初标的交割全价% - 上一日标的收盘价)',
|
||||
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).PosiGrossPrice * 100 - Convert.ToDecimal(DbContext.eod_commodity_future_price.Where(e => e.UnderlyingCode == DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection == 2 && !string.IsNullOrEmpty(p.UnderlyingCode)).UnderlyingCode && e.ValueDate < DbContext.trade.First(t => t.id == TradeId).TradeDate.Value.Date).OrderByDescending(e => e.ValueDate).First().ClosePrice))',
|
||||
1,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-17 13:29:43'),
|
||||
(48,
|
||||
'利息端利率与FR007偏离度',
|
||||
3,
|
||||
1,
|
||||
'%',
|
||||
'≥ 0',
|
||||
'ABS(利息端利率-FR007)/FR007*100',
|
||||
'Math.Abs(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.InterestDirection == 1).InterestRateDefault)*100m',
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
'SYSTEM',
|
||||
'2026-06-24 15:51:47',
|
||||
1,
|
||||
'初始用户',
|
||||
'2026-07-16 19:23:05');
|
||||
@@ -1,292 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 规则初始数据(对应设计文档 §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());
|
||||
@@ -1,73 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 变量池初始数据(对应设计文档 §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());
|
||||
@@ -0,0 +1,36 @@
|
||||
using YLErp.Modules.RiskEngine.Dto;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
[TestClass]
|
||||
public class RiskApplicationQueryContractTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void QueryRequest_UsesFrontendParameterNames()
|
||||
{
|
||||
var request = new QueryRiskApplicationReq
|
||||
{
|
||||
page = 1,
|
||||
rows = 20,
|
||||
Keyword = "本金",
|
||||
ControlStrategy = (RiskControlStrategy)2,
|
||||
Status = (RiskRuleStatus)1,
|
||||
ScopeAssetBookIds = "2000041",
|
||||
ScopeClientIds = "1000017",
|
||||
ScopeUnderlyingTypes = "深交所债券ETF",
|
||||
ScopeTradeTypes = "收益互换"
|
||||
};
|
||||
|
||||
Assert.AreEqual("本金", request.Keyword);
|
||||
Assert.AreEqual(RiskControlStrategy.Approval, request.ControlStrategy);
|
||||
Assert.AreEqual(RiskRuleStatus.Active, request.Status);
|
||||
Assert.AreEqual("2000041", request.ScopeAssetBookIds);
|
||||
Assert.AreEqual("1000017", request.ScopeClientIds);
|
||||
Assert.AreEqual("深交所债券ETF", request.ScopeUnderlyingTypes);
|
||||
Assert.AreEqual("收益互换", request.ScopeTradeTypes);
|
||||
|
||||
Assert.IsNull(typeof(QueryRiskApplicationReq).GetProperty("RuleName"));
|
||||
Assert.IsNull(typeof(QueryRiskApplicationReq).GetProperty("Strategy"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Reflection;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
[TestClass]
|
||||
public class RiskConcurrencyTokenTest
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow(typeof(glms_risk_rule))]
|
||||
[DataRow(typeof(glms_risk_rule_application))]
|
||||
[DataRow(typeof(glms_risk_variable))]
|
||||
public void Version_IsConfiguredAsConcurrencyToken(Type entityType)
|
||||
{
|
||||
var versionProperty = entityType.GetProperty("Version", BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.IsNotNull(versionProperty, $"{entityType.Name} 缺少 Version 属性");
|
||||
Assert.IsNotNull(
|
||||
versionProperty.GetCustomAttribute<ConcurrencyCheckAttribute>(),
|
||||
$"{entityType.Name}.Version 未配置为 EF concurrency token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.Modules.RiskEngine.Dto;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
[TestClass]
|
||||
public class RiskRuleApplicationStatusDecoupleContractTest
|
||||
{
|
||||
private static readonly string[] AvailabilityPropertyNames =
|
||||
{
|
||||
"AssociatedRuleCount",
|
||||
"EffectiveRuleCount",
|
||||
"CanEnable",
|
||||
"CanDisable",
|
||||
"EnableButtonTooltip",
|
||||
"DisableButtonTooltip"
|
||||
};
|
||||
|
||||
[TestMethod]
|
||||
public void DisableRuleResult_UsesDecoupledContract()
|
||||
{
|
||||
AssertProperty<DisableRuleResult>("RuleId", typeof(long));
|
||||
AssertProperty<DisableRuleResult>("AffectedActiveAppCount", typeof(int));
|
||||
Assert.IsNull(typeof(DisableRuleResult).GetProperty("CascadedApplicationIds"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteRuleResult_ReportsAffectedApplications()
|
||||
{
|
||||
AssertProperty<DeleteRuleResult>("RuleId", typeof(long));
|
||||
AssertProperty<DeleteRuleResult>("AffectedActiveAppCount", typeof(int));
|
||||
AssertProperty<DeleteRuleResult>("CausedNoAvailableRuleAppIds", typeof(List<long>));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ApplicationDtos_ExposeAvailabilityFields()
|
||||
{
|
||||
AssertAvailabilityProperties<RiskApplicationListItem>();
|
||||
AssertAvailabilityProperties<RiskApplicationDetail>();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BatchOperationResult_ExposesDecoupledApplicationImpactFields()
|
||||
{
|
||||
AssertProperty<BatchOperationItemResult>("AffectedApplicationIds", typeof(List<long>));
|
||||
AssertProperty<BatchOperationItemResult>("CausedNoAvailableRuleApplicationIds", typeof(List<long>));
|
||||
AssertProperty<BatchOperationResult>("AffectedApplicationIds", typeof(List<long>));
|
||||
AssertProperty<BatchOperationResult>("CausedNoAvailableRuleApplicationIds", typeof(List<long>));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VariableDeleteReferenceMessage_ListsDistinctSortedRuleIds()
|
||||
{
|
||||
var method = typeof(RiskRuleService).GetMethod(
|
||||
"BuildVariableDeleteReferenceMessage", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.IsNotNull(method);
|
||||
|
||||
var rules = new List<glms_risk_rule>
|
||||
{
|
||||
new glms_risk_rule { id = 12 },
|
||||
new glms_risk_rule { id = 10 },
|
||||
new glms_risk_rule { id = 12 }
|
||||
};
|
||||
|
||||
var message = (string)method.Invoke(null, new object[] { rules });
|
||||
|
||||
Assert.AreEqual("该变量被 2 个规则引用(规则ID:10、12),无法删除", message);
|
||||
}
|
||||
|
||||
private static void AssertAvailabilityProperties<T>()
|
||||
{
|
||||
foreach (var propertyName in AvailabilityPropertyNames)
|
||||
{
|
||||
var expectedType = propertyName.EndsWith("Tooltip", StringComparison.Ordinal)
|
||||
? typeof(string)
|
||||
: propertyName.StartsWith("Can", StringComparison.Ordinal)
|
||||
? typeof(bool)
|
||||
: typeof(int);
|
||||
|
||||
AssertProperty<T>(propertyName, expectedType);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertProperty<T>(string propertyName, Type expectedType)
|
||||
{
|
||||
var property = typeof(T).GetProperty(propertyName);
|
||||
Assert.IsNotNull(property, $"{typeof(T).Name}.{propertyName} should exist.");
|
||||
Assert.AreEqual(expectedType, property.PropertyType, $"{typeof(T).Name}.{propertyName} type mismatch.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
[TestClass]
|
||||
public class RiskRuleDefaultStatusTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void NewRiskRule_DefaultStatus_IsDisabled()
|
||||
{
|
||||
var rule = new glms_risk_rule();
|
||||
|
||||
Assert.AreEqual(RiskRuleStatus.Disabled, rule.Status);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NewRiskRuleApplication_DefaultStatus_IsDisabled()
|
||||
{
|
||||
var application = new glms_risk_rule_application();
|
||||
|
||||
Assert.AreEqual(RiskRuleStatus.Disabled, application.Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using YLErp.Commons;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.Modules.RiskEngine.Dto;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
[TestClass]
|
||||
public class RuleConditionExpressionBuilderTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void Build_NumericFixedValue_UsesNormalizedLiteralWithoutSuffix()
|
||||
{
|
||||
var expression = Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "gt",
|
||||
ThresholdType = "Fixed",
|
||||
Value = "123.4500"
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Numeric, "trade.Amount"));
|
||||
|
||||
Assert.AreEqual(
|
||||
"trade.Amount > 123.45",
|
||||
expression);
|
||||
Assert.IsFalse(expression.Contains("123.45m"));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("0", "0")]
|
||||
[DataRow("-0", "0")]
|
||||
[DataRow("123.4500", "123.45")]
|
||||
[DataRow("1e3", "1000")]
|
||||
[DataRow("1e-28", "0.0000000000000000000000000001")]
|
||||
public void Build_NumericFixedValue_NormalizesLikeFrontend(string input, string expectedValue)
|
||||
{
|
||||
var expression = Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "eq",
|
||||
ThresholdType = "Fixed",
|
||||
Value = input
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Numeric, "amount"));
|
||||
|
||||
Assert.AreEqual(
|
||||
$"amount == {expectedValue}",
|
||||
expression);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_NumericFixedValue_RejectsThousandsSeparator()
|
||||
{
|
||||
Assert.ThrowsException<ServiceException>(() => Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "eq",
|
||||
ThresholdType = "Fixed",
|
||||
Value = "1,000"
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Numeric, "amount")));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("gt", ">")]
|
||||
[DataRow("lt", "<")]
|
||||
[DataRow("gte", ">=")]
|
||||
[DataRow("lte", "<=")]
|
||||
[DataRow("eq", "==")]
|
||||
[DataRow("ne", "!=")]
|
||||
public void Build_ComparisonTokens_MapToCSharpOperators(string token, string expectedOperator)
|
||||
{
|
||||
var expression = Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = token,
|
||||
ThresholdType = "Fixed",
|
||||
Value = "0"
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Numeric, "amount"));
|
||||
|
||||
StringAssert.Contains(expression, $" {expectedOperator} ");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_NumericVariableThreshold_UsesStoredExpressions()
|
||||
{
|
||||
var expression = Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "lte",
|
||||
ThresholdType = "Variable",
|
||||
ThresholdVariableId = 2
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Numeric, "trade.Amount", "元"),
|
||||
Variable(2, RiskVariableDataType.Numeric, "limit.Amount", "元"));
|
||||
|
||||
Assert.AreEqual(
|
||||
"trade.Amount <= limit.Amount",
|
||||
expression);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_NumericVariableThreshold_RejectsDifferentUnit()
|
||||
{
|
||||
var exception = Assert.ThrowsException<ServiceException>(() => Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "lte",
|
||||
ThresholdType = "Variable",
|
||||
ThresholdVariableId = 2
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Numeric, "trade.Amount", "元"),
|
||||
Variable(2, RiskVariableDataType.Numeric, "trade.Count", "笔")));
|
||||
|
||||
StringAssert.Contains(exception.Message, "单位不一致");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_NumericRangeVariableBound_RejectsDifferentUnit()
|
||||
{
|
||||
var exception = Assert.ThrowsException<ServiceException>(() => Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "between",
|
||||
LowerThresholdType = "Fixed",
|
||||
LowerValue = "0",
|
||||
UpperThresholdType = "Variable",
|
||||
UpperThresholdVariableId = 2,
|
||||
IncludeLower = true,
|
||||
IncludeUpper = true
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Numeric, "trade.Amount", "元"),
|
||||
Variable(2, RiskVariableDataType.Numeric, "trade.Count", "笔")));
|
||||
|
||||
StringAssert.Contains(exception.Message, "单位不一致");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_FixedDate_UsesDateConstructorTemplate()
|
||||
{
|
||||
var expression = Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "eq",
|
||||
ThresholdType = "Fixed",
|
||||
Value = "2026-07-21"
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Date, "trade.TradeDate"));
|
||||
|
||||
Assert.AreEqual("trade.TradeDate == new DateTime(2026, 7, 21)", expression);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("isTrue", "trade.IsConfirmed == true")]
|
||||
[DataRow("isFalse", "trade.IsConfirmed == false")]
|
||||
public void Build_BooleanToken_UsesBooleanLiteral(string token, string expected)
|
||||
{
|
||||
var expression = Build(
|
||||
new RuleCondition { VariableId = 1, Operator = token },
|
||||
Variable(1, RiskVariableDataType.Boolean, "trade.IsConfirmed"));
|
||||
|
||||
Assert.AreEqual(expected, expression);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_Between_AllowsMixedFixedAndVariableBounds()
|
||||
{
|
||||
var expression = Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "between",
|
||||
LowerThresholdType = "Fixed",
|
||||
LowerValue = "-1.25",
|
||||
UpperThresholdType = "Variable",
|
||||
UpperThresholdVariableId = 2,
|
||||
IncludeLower = false,
|
||||
IncludeUpper = true
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Numeric, "trade.Amount"),
|
||||
Variable(2, RiskVariableDataType.Numeric, "limit.Upper"));
|
||||
|
||||
Assert.AreEqual(
|
||||
"trade.Amount > -1.25 && trade.Amount <= limit.Upper",
|
||||
expression);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_NotBetween_NegatesCompleteRangeExpression()
|
||||
{
|
||||
var expression = Build(
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "notBetween",
|
||||
LowerThresholdType = "Fixed",
|
||||
LowerValue = "2026-01-01",
|
||||
UpperThresholdType = "Fixed",
|
||||
UpperValue = "2026-12-31",
|
||||
IncludeLower = true,
|
||||
IncludeUpper = false
|
||||
},
|
||||
Variable(1, RiskVariableDataType.Date, "trade.TradeDate"));
|
||||
|
||||
Assert.AreEqual(
|
||||
"!(trade.TradeDate >= new DateTime(2026, 1, 1) && trade.TradeDate < new DateTime(2026, 12, 31))",
|
||||
expression);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_MultipleConditions_UsesExactAndSeparator()
|
||||
{
|
||||
var conditions = new[]
|
||||
{
|
||||
new RuleCondition { VariableId = 1, Operator = "gt", ThresholdType = "Fixed", Value = "0" },
|
||||
new RuleCondition { VariableId = 2, Operator = "isTrue" }
|
||||
};
|
||||
var variables = Variables(
|
||||
Variable(1, RiskVariableDataType.Numeric, "amount"),
|
||||
Variable(2, RiskVariableDataType.Boolean, "isValid"));
|
||||
|
||||
var expression = RuleConditionExpressionBuilder.Build(conditions, variables);
|
||||
|
||||
Assert.AreEqual(
|
||||
"amount > 0 && isValid == true",
|
||||
expression);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_RejectsNonRangeConditionWithRangeFields()
|
||||
{
|
||||
var condition = new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
Operator = "gt",
|
||||
ThresholdType = "Fixed",
|
||||
Value = "0",
|
||||
IncludeLower = true
|
||||
};
|
||||
|
||||
Assert.ThrowsException<ServiceException>(() => Build(
|
||||
condition,
|
||||
Variable(1, RiskVariableDataType.Numeric, "amount")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReferencedVariableIds_IgnoreNullConditionItems()
|
||||
{
|
||||
var conditions = new RuleCondition[]
|
||||
{
|
||||
null,
|
||||
new RuleCondition { VariableId = 7 }
|
||||
};
|
||||
|
||||
CollectionAssert.AreEqual(
|
||||
new long[] { 7 },
|
||||
RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReferencedVariableIds_IncludeAllFourFields()
|
||||
{
|
||||
var conditions = new[]
|
||||
{
|
||||
new RuleCondition
|
||||
{
|
||||
VariableId = 1,
|
||||
ThresholdVariableId = 2,
|
||||
LowerThresholdVariableId = 3,
|
||||
UpperThresholdVariableId = 4
|
||||
}
|
||||
};
|
||||
|
||||
CollectionAssert.AreEquivalent(
|
||||
new long[] { 1, 2, 3, 4 },
|
||||
RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToArray());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsVariableReferenced_MatchesAllFieldsWithoutIdPrefixCollision()
|
||||
{
|
||||
const string json = "[{\"VariableId\":10,\"ThresholdVariableId\":20,\"LowerThresholdVariableId\":30,\"UpperThresholdVariableId\":40}]";
|
||||
|
||||
Assert.IsTrue(RuleConditionExpressionBuilder.IsVariableReferenced(json, 10));
|
||||
Assert.IsTrue(RuleConditionExpressionBuilder.IsVariableReferenced(json, 20));
|
||||
Assert.IsTrue(RuleConditionExpressionBuilder.IsVariableReferenced(json, 30));
|
||||
Assert.IsTrue(RuleConditionExpressionBuilder.IsVariableReferenced(json, 40));
|
||||
Assert.IsFalse(RuleConditionExpressionBuilder.IsVariableReferenced(json, 1));
|
||||
Assert.IsFalse(RuleConditionExpressionBuilder.IsVariableReferenced(json, 4));
|
||||
}
|
||||
|
||||
private static string Build(RuleCondition condition, params glms_risk_variable[] variables)
|
||||
{
|
||||
return RuleConditionExpressionBuilder.Build(new[] { condition }, Variables(variables));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<long, glms_risk_variable> Variables(params glms_risk_variable[] variables)
|
||||
{
|
||||
return variables.ToDictionary(variable => (long)variable.id);
|
||||
}
|
||||
|
||||
private static glms_risk_variable Variable(long id, RiskVariableDataType dataType, string expression, string unit = null)
|
||||
{
|
||||
return new glms_risk_variable
|
||||
{
|
||||
id = (int)id,
|
||||
VariableName = $"V{id}",
|
||||
DataType = dataType,
|
||||
VariableExpr = expression,
|
||||
Unit = unit
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ namespace YLErp
|
||||
[ModuleInitializer]
|
||||
internal static void M1()
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("YLErp_UNIT_TEST_SKIP_INITIALIZATION"), "1", StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
Console.WriteLine("version--" + AppManager.Version);
|
||||
|
||||
var logger = NLog.LogManager.Setup().GetCurrentClassLogger();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
@@ -46,7 +49,119 @@ namespace YLErp.Modules.RiskEngine
|
||||
/// </summary>
|
||||
public static class RuleCompiler
|
||||
{
|
||||
static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler");
|
||||
private static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler");
|
||||
|
||||
/// <summary>
|
||||
/// 高风险方法黑名单。
|
||||
/// 这里按方法名做语法层拦截,覆盖数据库写入、原生 SQL、反射、文件、进程、服务定位等入口。
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ForbiddenInvocationNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"SaveChanges",
|
||||
"SaveChangesAsync",
|
||||
"Add",
|
||||
"AddAsync",
|
||||
"AddRange",
|
||||
"AddRangeAsync",
|
||||
"AddOrUpdate",
|
||||
"Update",
|
||||
"UpdateRange",
|
||||
"Remove",
|
||||
"RemoveRange",
|
||||
"Attach",
|
||||
"AttachRange",
|
||||
"Set",
|
||||
"Entry",
|
||||
"ExecuteSqlRaw",
|
||||
"ExecuteSqlRawAsync",
|
||||
"ExecuteSqlInterpolated",
|
||||
"ExecuteSqlInterpolatedAsync",
|
||||
"ExecuteSql",
|
||||
"ExecuteSqlAsync",
|
||||
"ExecuteSqlCommand",
|
||||
"ExecuteSqlCommandAsync",
|
||||
"ExecuteStoreCommand",
|
||||
"ExecuteStoreQuery",
|
||||
"ExecuteDelete",
|
||||
"ExecuteDeleteAsync",
|
||||
"ExecuteUpdate",
|
||||
"ExecuteUpdateAsync",
|
||||
"FromSqlRaw",
|
||||
"FromSqlRawAsync",
|
||||
"FromSqlInterpolated",
|
||||
"FromSqlInterpolatedAsync",
|
||||
"BulkInsert",
|
||||
"BulkUpdate",
|
||||
"BulkDelete",
|
||||
"BulkMerge",
|
||||
"GetType",
|
||||
"Invoke",
|
||||
"InvokeMember",
|
||||
"GetMethod",
|
||||
"GetProperty",
|
||||
"GetField",
|
||||
"CreateInstance",
|
||||
"Load",
|
||||
"LoadFrom",
|
||||
"ReadAllText",
|
||||
"ReadAllLines",
|
||||
"ReadAllBytes",
|
||||
"WriteAllText",
|
||||
"WriteAllLines",
|
||||
"WriteAllBytes",
|
||||
"AppendAllText",
|
||||
"Delete",
|
||||
"Move",
|
||||
"Copy",
|
||||
"Open",
|
||||
"OpenRead",
|
||||
"OpenWrite",
|
||||
"Create",
|
||||
"CreateText",
|
||||
"Start",
|
||||
"Kill",
|
||||
"Exit",
|
||||
"GetEnvironmentVariable",
|
||||
"SetEnvironmentVariable",
|
||||
"GetService",
|
||||
"GetRequiredService",
|
||||
"CreateScope",
|
||||
"Sleep"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 高风险成员黑名单。
|
||||
/// DbContext.Database、ChangeTracker 等成员会绕过只读查询约束或暴露底层状态,禁止脚本访问。
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ForbiddenMemberNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Database",
|
||||
"ChangeTracker",
|
||||
"Assembly",
|
||||
"AppDomain"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 高风险类型或标识符黑名单。
|
||||
/// 用于拦截 File、Process、Environment 等直接作为类型或变量名出现的绕过方式。
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ForbiddenTypeOrIdentifierNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Activator",
|
||||
"Assembly",
|
||||
"AppDomain",
|
||||
"Environment",
|
||||
"File",
|
||||
"Directory",
|
||||
"Path",
|
||||
"Process",
|
||||
"HttpClient",
|
||||
"WebClient",
|
||||
"WebRequest",
|
||||
"Socket",
|
||||
"Thread",
|
||||
"GC"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 校验并编译规则表达式。
|
||||
@@ -79,6 +194,109 @@ namespace YLErp.Modules.RiskEngine
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验变量取值表达式。
|
||||
/// 变量表达式只要求能编译为 object,不在保存时执行,避免依赖真实交易和数据库数据。
|
||||
/// </summary>
|
||||
public static RuleCompileResult ValidateVariableExpression(string variableExpr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableExpr))
|
||||
return RuleCompileResult.Fail("变量取值表达式不能为空");
|
||||
|
||||
var compiled = CompileVariableExpression(variableExpr, out var compileErrorMessage);
|
||||
if (compiled == null)
|
||||
return RuleCompileResult.Fail(compileErrorMessage ?? "变量取值表达式编译失败");
|
||||
|
||||
return RuleCompileResult.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编译变量取值表达式。
|
||||
/// 结构化规则执行时使用,表达式返回值由执行器按变量 DataType 统一转换。
|
||||
/// </summary>
|
||||
public static Func<RiskContext, object> CompileValueExpression(string variableExpr, out string errorMessage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableExpr))
|
||||
{
|
||||
errorMessage = "变量取值表达式不能为空";
|
||||
return null;
|
||||
}
|
||||
|
||||
return CompileVariableExpression(variableExpr, out errorMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编译变量取值表达式。优先兼容原有单表达式写法;失败后再按多语句脚本编译。
|
||||
/// 多语句脚本支持用 return 表达最终值,编译前会转换为 Roslyn Script 稳定支持的末尾表达式。
|
||||
/// </summary>
|
||||
private static Func<RiskContext, object> CompileVariableExpression(string variableExpr, out string errorMessage)
|
||||
{
|
||||
if (IsLikelyVariableScript(variableExpr))
|
||||
{
|
||||
var normalizedScript = NormalizeVariableScript(variableExpr);
|
||||
var scriptCompiled = CompileScript<object>(null, normalizedScript, out errorMessage);
|
||||
return scriptCompiled;
|
||||
}
|
||||
|
||||
// 原有变量表达式大多是单个查询表达式,包成 object 后可直接作为脚本返回值。
|
||||
var expressionScript = $"(object)({variableExpr})";
|
||||
var compiled = CompileScript<object>(null, expressionScript, out var expressionErrorMessage);
|
||||
if (compiled != null)
|
||||
{
|
||||
errorMessage = null;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
// 单表达式编译失败时,再按多语句脚本兜底,兼容包含局部变量、if、throw 的复杂变量。
|
||||
var normalizedFallbackScript = NormalizeVariableScript(variableExpr);
|
||||
compiled = CompileScript<object>(null, normalizedFallbackScript, out var scriptErrorMessage);
|
||||
if (compiled != null)
|
||||
{
|
||||
errorMessage = null;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
errorMessage = $"单表达式编译失败:{expressionErrorMessage};多语句脚本编译失败:{scriptErrorMessage}";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 粗略判断变量表达式是否更像多语句脚本。
|
||||
/// 多语句脚本不能再包成 (object)(...),需要直接按 C# Script 编译。
|
||||
/// </summary>
|
||||
private static bool IsLikelyVariableScript(string variableExpr)
|
||||
{
|
||||
return variableExpr.IndexOf(';') >= 0 ||
|
||||
variableExpr.IndexOf("return", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
variableExpr.IndexOf("throw", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
variableExpr.IndexOf("if", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 规范化多语句变量脚本。
|
||||
/// Roslyn Script 更稳定的返回方式是“最后一行表达式”,这里把末尾 return 表达式转换为末尾表达式。
|
||||
/// </summary>
|
||||
private static string NormalizeVariableScript(string variableExpr)
|
||||
{
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(variableExpr, new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
var root = syntaxTree.GetCompilationUnitRoot();
|
||||
var lastStatement = root.Members
|
||||
.OfType<GlobalStatementSyntax>()
|
||||
.Select(statement => statement.Statement)
|
||||
.LastOrDefault();
|
||||
|
||||
if (lastStatement is ReturnStatementSyntax returnStatement && returnStatement.Expression != null)
|
||||
{
|
||||
var returnText = returnStatement.ToFullString();
|
||||
var expressionText = returnStatement.Expression.ToFullString();
|
||||
var index = variableExpr.LastIndexOf(returnText, StringComparison.Ordinal);
|
||||
if (index >= 0)
|
||||
return variableExpr.Substring(0, index) + expressionText;
|
||||
}
|
||||
|
||||
return variableExpr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验并编译规则,编译成功后同时写入规则对象和内存缓存。
|
||||
/// </summary>
|
||||
@@ -115,17 +333,34 @@ namespace YLErp.Modules.RiskEngine
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用 Roslyn 编译 C# 脚本代码为可执行委托
|
||||
/// 用 Roslyn 编译 C# 布尔表达式为可执行委托。
|
||||
/// </summary>
|
||||
private static Func<RiskContext, bool> CompileScript(long? ruleId, string scriptCode, out string errorMessage)
|
||||
{
|
||||
return CompileScript<bool>(ruleId, scriptCode, out errorMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用 Roslyn 编译 C# 脚本代码为指定返回类型的可执行委托。
|
||||
/// 规则公式编译为 bool,变量取值表达式编译为 object。
|
||||
/// </summary>
|
||||
private static Func<RiskContext, T> CompileScript<T>(long? ruleId, string scriptCode, out string errorMessage)
|
||||
{
|
||||
errorMessage = null;
|
||||
var ruleIdText = ruleId.HasValue ? ruleId.Value.ToString() : "未落库";
|
||||
|
||||
if (!ValidateScriptSafety(scriptCode, out errorMessage))
|
||||
{
|
||||
_logger.Error($"规则脚本安全校验失败 - RuleId: {ruleIdText}, Error: {errorMessage}\n脚本代码:{scriptCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 配置编译选项:引用必要的程序集
|
||||
var options = ScriptOptions.Default
|
||||
.WithReferences(
|
||||
typeof(RiskContext).Assembly,
|
||||
typeof(RiskCalendarHelper).Assembly,
|
||||
typeof(RiskMarketDeviationHelper).Assembly,
|
||||
typeof(YLContext).Assembly,
|
||||
typeof(YLErp.DBModels.trade).Assembly,
|
||||
typeof(QdpCalendarHelper).Assembly,
|
||||
@@ -133,10 +368,10 @@ namespace YLErp.Modules.RiskEngine
|
||||
typeof(Microsoft.EntityFrameworkCore.DbContext).Assembly,
|
||||
typeof(Queryable).Assembly
|
||||
)
|
||||
.WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule");
|
||||
.WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule", "YLErp.Modules.RiskEngine");
|
||||
|
||||
// 创建脚本(尚未执行,仅编译)
|
||||
var script = CSharpScript.Create<bool>(scriptCode, options, globalsType: typeof(ScriptGlobals));
|
||||
var script = CSharpScript.Create<T>(scriptCode, options, globalsType: typeof(ScriptGlobals));
|
||||
|
||||
// 编译(提前发现语法错误)
|
||||
//后续编译需提供接口,返回前端编译信息,包含编译错误列表
|
||||
@@ -153,7 +388,7 @@ namespace YLErp.Modules.RiskEngine
|
||||
// 生成可调用委托
|
||||
var runner = script.CreateDelegate();
|
||||
|
||||
// 包装为同步的 Func<RiskContext, bool>
|
||||
// 包装为同步的 Func<RiskContext, T>
|
||||
return ctx =>
|
||||
{
|
||||
try
|
||||
@@ -174,6 +409,125 @@ namespace YLErp.Modules.RiskEngine
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 脚本安全校验。
|
||||
/// 在 Roslyn 编译前先扫描语法树,禁止写库、反射、文件、网络、进程等高风险语法入口。
|
||||
/// </summary>
|
||||
private static bool ValidateScriptSafety(string scriptCode, out string errorMessage)
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(scriptCode, new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
var root = tree.GetRoot();
|
||||
|
||||
// 允许变量声明初始化(如 decimal a = ...),但禁止后续赋值或复合赋值,避免脚本修改对象状态。
|
||||
if (root.DescendantNodes().OfType<AssignmentExpressionSyntax>().Any())
|
||||
{
|
||||
errorMessage = "规则表达式不允许包含赋值语句";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (root.DescendantNodes().OfType<PrefixUnaryExpressionSyntax>().Any(IsIncrementOrDecrement) ||
|
||||
root.DescendantNodes().OfType<PostfixUnaryExpressionSyntax>().Any(IsIncrementOrDecrement))
|
||||
{
|
||||
errorMessage = "规则表达式不允许包含自增或自减语句";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var memberAccess in root.DescendantNodes().OfType<MemberAccessExpressionSyntax>())
|
||||
{
|
||||
var memberName = memberAccess.Name.Identifier.ValueText;
|
||||
if (!string.IsNullOrWhiteSpace(memberName) && ForbiddenMemberNames.Contains(memberName))
|
||||
{
|
||||
errorMessage = $"规则表达式不允许访问高风险成员:{memberName}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 只拦截独立标识符,避免把实体字段 p.File、p.Path 误判为高风险类型名。
|
||||
foreach (var identifier in root.DescendantNodes().OfType<IdentifierNameSyntax>())
|
||||
{
|
||||
var identifierName = identifier.Identifier.ValueText;
|
||||
if (!IsMemberAccessName(identifier) && !string.IsNullOrWhiteSpace(identifierName) && ForbiddenTypeOrIdentifierNames.Contains(identifierName))
|
||||
{
|
||||
errorMessage = $"规则表达式不允许访问高风险类型或标识符:{identifierName}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var objectCreation in root.DescendantNodes().OfType<ObjectCreationExpressionSyntax>())
|
||||
{
|
||||
var typeName = GetTypeName(objectCreation.Type);
|
||||
if (!string.IsNullOrWhiteSpace(typeName) && ForbiddenTypeOrIdentifierNames.Contains(typeName))
|
||||
{
|
||||
errorMessage = $"规则表达式不允许创建高风险类型:{typeName}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var invocation in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
{
|
||||
var methodName = GetInvocationName(invocation.Expression);
|
||||
if (!string.IsNullOrWhiteSpace(methodName) && ForbiddenInvocationNames.Contains(methodName))
|
||||
{
|
||||
errorMessage = $"规则表达式不允许调用高风险方法:{methodName}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断前缀一元表达式是否为自增/自减。
|
||||
/// </summary>
|
||||
private static bool IsIncrementOrDecrement(PrefixUnaryExpressionSyntax expression)
|
||||
{
|
||||
return expression.IsKind(SyntaxKind.PreIncrementExpression) || expression.IsKind(SyntaxKind.PreDecrementExpression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断后缀一元表达式是否为自增/自减。
|
||||
/// </summary>
|
||||
private static bool IsIncrementOrDecrement(PostfixUnaryExpressionSyntax expression)
|
||||
{
|
||||
return expression.IsKind(SyntaxKind.PostIncrementExpression) || expression.IsKind(SyntaxKind.PostDecrementExpression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断标识符是否是成员访问右侧名称,例如 p.Path 中的 Path。
|
||||
/// </summary>
|
||||
private static bool IsMemberAccessName(IdentifierNameSyntax identifier)
|
||||
{
|
||||
return identifier.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Name == identifier;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从调用表达式中提取方法名,兼容 SaveChanges() 和 DbContext.SaveChanges() 两种写法。
|
||||
/// </summary>
|
||||
private static string GetInvocationName(ExpressionSyntax expression)
|
||||
{
|
||||
return expression switch
|
||||
{
|
||||
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText,
|
||||
IdentifierNameSyntax identifier => identifier.Identifier.ValueText,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从类型语法中提取类型短名,用于匹配高风险类型黑名单。
|
||||
/// </summary>
|
||||
private static string GetTypeName(TypeSyntax type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
IdentifierNameSyntax identifier => identifier.Identifier.ValueText,
|
||||
QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText,
|
||||
AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText,
|
||||
_ => type?.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class BuildMemberAccessResult
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine.Dto
|
||||
{
|
||||
/// <summary>
|
||||
/// 批量停用规则结果
|
||||
/// </summary>
|
||||
public class BatchDisableRulesResult
|
||||
{
|
||||
/// <summary>是否全部成功</summary>
|
||||
public bool Success { get; set; }
|
||||
/// <summary>总数量</summary>
|
||||
public int TotalCount { get; set; }
|
||||
/// <summary>成功数量</summary>
|
||||
public int SuccessCount { get; set; }
|
||||
/// <summary>错误信息</summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
/// <summary>级联停用的应用 ID 列表</summary>
|
||||
public List<long> CascadedApplicationIds { get; set; } = new List<long>();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine.Dto
|
||||
{
|
||||
/// <summary>
|
||||
/// 批量操作结果
|
||||
/// </summary>
|
||||
public class BatchOperationResult
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum BatchOperationOutcome
|
||||
{
|
||||
/// <summary>是否全部成功</summary>
|
||||
public bool Success { get; set; }
|
||||
/// <summary>总数量</summary>
|
||||
public int TotalCount { get; set; }
|
||||
/// <summary>成功数量</summary>
|
||||
public int SuccessCount { get; set; }
|
||||
/// <summary>错误信息</summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
[EnumMember(Value = "AllSucceeded")]
|
||||
AllSucceeded,
|
||||
[EnumMember(Value = "PartiallySucceeded")]
|
||||
PartiallySucceeded,
|
||||
[EnumMember(Value = "AllFailed")]
|
||||
AllFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量删除变量结果
|
||||
/// </summary>
|
||||
public class BatchDeleteVariablesResult : BatchOperationResult
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum BatchOperationItemStatus
|
||||
{
|
||||
/// <summary>成功删除的变量 ID</summary>
|
||||
public List<long> DeletedIds { get; set; }
|
||||
/// <summary>不存在的变量 ID</summary>
|
||||
public List<long> MissingIds { get; set; }
|
||||
/// <summary>因被生效规则引用而跳过的变量 ID</summary>
|
||||
public List<long> BlockedIds { get; set; }
|
||||
/// <summary>被跳过变量的原因,Key 为变量 ID</summary>
|
||||
public Dictionary<long, string> BlockedReasons { get; set; }
|
||||
[EnumMember(Value = "Succeeded")]
|
||||
Succeeded,
|
||||
[EnumMember(Value = "Unchanged")]
|
||||
Unchanged,
|
||||
[EnumMember(Value = "Failed")]
|
||||
Failed
|
||||
}
|
||||
|
||||
public class BatchOperationItemResult
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public BatchOperationItemStatus Status { get; set; }
|
||||
public string Code { get; set; }
|
||||
public string Message { get; set; }
|
||||
/// <summary>操作影响到的应用 ID(不代表应用状态发生变化)</summary>
|
||||
public List<long> AffectedApplicationIds { get; set; } = new List<long>();
|
||||
/// <summary>操作后不再包含任何启用规则的应用 ID</summary>
|
||||
public List<long> CausedNoAvailableRuleApplicationIds { get; set; } = new List<long>();
|
||||
/// <summary>旧版级联字段,解耦后始终为空,仅为兼容旧客户端保留</summary>
|
||||
public List<long> CascadedApplicationIds { get; set; } = new List<long>();
|
||||
}
|
||||
|
||||
public class BatchOperationResult
|
||||
{
|
||||
public BatchOperationOutcome Outcome { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
public int SucceededCount { get; set; }
|
||||
public int UnchangedCount { get; set; }
|
||||
public int FailedCount { get; set; }
|
||||
public bool HasChanges { get; set; }
|
||||
public List<BatchOperationItemResult> Items { get; set; } = new List<BatchOperationItemResult>();
|
||||
/// <summary>所有请求项影响到的应用 ID 去重并集</summary>
|
||||
public List<long> AffectedApplicationIds { get; set; } = new List<long>();
|
||||
/// <summary>操作后不再包含任何启用规则的应用 ID 去重并集</summary>
|
||||
public List<long> CausedNoAvailableRuleApplicationIds { get; set; } = new List<long>();
|
||||
/// <summary>旧版级联字段,解耦后始终为空,仅为兼容旧客户端保留</summary>
|
||||
public List<long> CascadedApplicationIds { get; set; } = new List<long>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine.Dto
|
||||
{
|
||||
/// <summary>
|
||||
/// 删除规则结果
|
||||
/// </summary>
|
||||
public class DeleteRuleResult
|
||||
{
|
||||
/// <summary>规则 ID</summary>
|
||||
public long RuleId { get; set; }
|
||||
|
||||
/// <summary>删除前引用该规则的启用中应用数量</summary>
|
||||
public int AffectedActiveAppCount { get; set; }
|
||||
|
||||
/// <summary>删除后不再包含任何启用规则的应用 ID</summary>
|
||||
public List<long> CausedNoAvailableRuleAppIds { get; set; } = new List<long>();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine.Dto
|
||||
{
|
||||
/// <summary>
|
||||
@@ -7,7 +5,10 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
/// </summary>
|
||||
public class DisableRuleResult
|
||||
{
|
||||
/// <summary>级联停用的应用 ID 列表</summary>
|
||||
public List<long> CascadedApplicationIds { get; set; } = new List<long>();
|
||||
/// <summary>规则 ID</summary>
|
||||
public long RuleId { get; set; }
|
||||
|
||||
/// <summary>引用该规则的启用中应用数量</summary>
|
||||
public int AffectedActiveAppCount { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
/// </summary>
|
||||
public class QueryRiskApplicationReq : BaseSearchReq
|
||||
{
|
||||
/// <summary>规则名称</summary>
|
||||
public string RuleName { get; set; }
|
||||
/// <summary>规则名称关键词</summary>
|
||||
public string Keyword { get; set; }
|
||||
/// <summary>应用配置状态</summary>
|
||||
public RiskRuleStatus? Status { get; set; }
|
||||
/// <summary>风控策略</summary>
|
||||
public RiskControlStrategy? Strategy { get; set; }
|
||||
public RiskControlStrategy? ControlStrategy { get; set; }
|
||||
/// <summary>触发点</summary>
|
||||
public string TriggerPoint { get; set; }
|
||||
/// <summary>适用范围-客户 ID 列表</summary>
|
||||
|
||||
@@ -14,5 +14,7 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
public RiskVariableDataType? DataType { get; set; }
|
||||
/// <summary>变量名称</summary>
|
||||
public string VariableName { get; set; }
|
||||
/// <summary>状态筛选(不传则返回非 Deleted 的全部)</summary>
|
||||
public RiskRuleStatus? Status { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,18 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
public string RuleIds { get; set; }
|
||||
/// <summary>应用说明</summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>关联规则数(不含已删除规则)</summary>
|
||||
public int AssociatedRuleCount { get; set; }
|
||||
/// <summary>生效规则数(关联规则中已启用的数量)</summary>
|
||||
public int EffectiveRuleCount { get; set; }
|
||||
/// <summary>当前状态下是否允许启用</summary>
|
||||
public bool CanEnable { get; set; }
|
||||
/// <summary>当前状态下是否允许停用</summary>
|
||||
public bool CanDisable { get; set; }
|
||||
/// <summary>启用按钮不可用时的提示</summary>
|
||||
public string EnableButtonTooltip { get; set; }
|
||||
/// <summary>停用按钮不可用时的提示</summary>
|
||||
public string DisableButtonTooltip { get; set; }
|
||||
/// <summary>应用配置状态</summary>
|
||||
public RiskRuleStatus Status { get; set; }
|
||||
/// <summary>风控策略</summary>
|
||||
|
||||
@@ -14,6 +14,18 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
public string RuleIds { get; set; }
|
||||
/// <summary>应用说明</summary>
|
||||
public string Description { get; set; }
|
||||
/// <summary>关联规则数(不含已删除规则)</summary>
|
||||
public int AssociatedRuleCount { get; set; }
|
||||
/// <summary>生效规则数(关联规则中已启用的数量)</summary>
|
||||
public int EffectiveRuleCount { get; set; }
|
||||
/// <summary>当前状态下是否允许启用</summary>
|
||||
public bool CanEnable { get; set; }
|
||||
/// <summary>当前状态下是否允许停用</summary>
|
||||
public bool CanDisable { get; set; }
|
||||
/// <summary>启用按钮不可用时的提示</summary>
|
||||
public string EnableButtonTooltip { get; set; }
|
||||
/// <summary>停用按钮不可用时的提示</summary>
|
||||
public string DisableButtonTooltip { get; set; }
|
||||
/// <summary>应用配置状态</summary>
|
||||
public int Status { get; set; }
|
||||
/// <summary>风控策略</summary>
|
||||
|
||||
@@ -28,6 +28,8 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
public int Version { get; set; }
|
||||
/// <summary>排序序号</summary>
|
||||
public int SortOrder { get; set; }
|
||||
/// <summary>状态</summary>
|
||||
public RiskRuleStatus Status { get; set; }
|
||||
/// <summary>创建人姓名</summary>
|
||||
public string OptName { get; set; }
|
||||
/// <summary>创建时间</summary>
|
||||
|
||||
@@ -27,5 +27,7 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
public int Version { get; set; }
|
||||
/// <summary>排序序号</summary>
|
||||
public int SortOrder { get; set; }
|
||||
/// <summary>状态</summary>
|
||||
public RiskRuleStatus Status { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,7 @@ namespace YLErp.Modules.RiskEngine.Dto
|
||||
public string Unit { get; set; }
|
||||
/// <summary>取值表达式</summary>
|
||||
public string VariableExpr { get; set; }
|
||||
/// <summary>状态</summary>
|
||||
public RiskRuleStatus Status { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
namespace YLErp.Modules.RiskEngine.Dto
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则条件项
|
||||
/// 结构化规则条件项。Operator 使用稳定语义 token,不存储 UI 文案或 C# 操作符。
|
||||
/// </summary>
|
||||
public class RuleCondition
|
||||
{
|
||||
/// <summary>条件变量 ID</summary>
|
||||
public long VariableId { get; set; }
|
||||
/// <summary>操作符(如 Equal、GreaterThan、Between 等)</summary>
|
||||
/// <summary>操作符 token:gt/lt/gte/lte/eq/ne/between/notBetween/isTrue/isFalse</summary>
|
||||
public string Operator { get; set; }
|
||||
/// <summary>阈值类型(FixedValue / VariableRef)</summary>
|
||||
|
||||
/// <summary>普通比较阈值类型:Fixed / Variable</summary>
|
||||
public string ThresholdType { get; set; }
|
||||
/// <summary>阈值(固定值或变量引用)</summary>
|
||||
/// <summary>普通比较固定阈值</summary>
|
||||
public object Value { get; set; }
|
||||
/// <summary>阈值变量 ID(当 ThresholdType 为 VariableRef 时)</summary>
|
||||
/// <summary>普通比较阈值变量 ID</summary>
|
||||
public long? ThresholdVariableId { get; set; }
|
||||
/// <summary>介于/不介于的下边界是否包含,默认 true</summary>
|
||||
public bool IncludeLowerBound { get; set; } = true;
|
||||
/// <summary>介于/不介于的上边界是否包含,默认 true</summary>
|
||||
public bool IncludeUpperBound { get; set; } = true;
|
||||
|
||||
/// <summary>区间下限阈值类型:Fixed / Variable</summary>
|
||||
public string LowerThresholdType { get; set; }
|
||||
/// <summary>区间固定下限</summary>
|
||||
public object LowerValue { get; set; }
|
||||
/// <summary>区间下限变量 ID</summary>
|
||||
public long? LowerThresholdVariableId { get; set; }
|
||||
/// <summary>区间上限阈值类型:Fixed / Variable</summary>
|
||||
public string UpperThresholdType { get; set; }
|
||||
/// <summary>区间固定上限</summary>
|
||||
public object UpperValue { get; set; }
|
||||
/// <summary>区间上限变量 ID</summary>
|
||||
public long? UpperThresholdVariableId { get; set; }
|
||||
/// <summary>是否包含下限</summary>
|
||||
public bool? IncludeLower { get; set; }
|
||||
/// <summary>是否包含上限</summary>
|
||||
public bool? IncludeUpper { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using YLErp.BLL;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// 风控规则专用日历辅助类。
|
||||
/// 当前主要用于债券类规则按银行间日历确认“上一收盘日”,避免简单按估值表倒序取最近日期导致口径偏差。
|
||||
/// </summary>
|
||||
public static class RiskCalendarHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定日期的上一银行间交易日。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">当前风控执行使用的数据库上下文。</param>
|
||||
/// <param name="date">当前交易日或业务基准日。</param>
|
||||
/// <returns>严格早于入参日期的上一银行间交易日。</returns>
|
||||
/// <exception cref="ArgumentNullException">数据库上下文为空。</exception>
|
||||
/// <exception cref="Exception">缺少银行间日历、日历内容异常或在保护范围内找不到上一交易日。</exception>
|
||||
public static DateTime GetPreviousInterbankTradingDay(YLContext dbContext, DateTime date)
|
||||
{
|
||||
if (dbContext == null)
|
||||
throw new ArgumentNullException(nameof(dbContext));
|
||||
|
||||
var holidayCache = new Dictionary<int, HashSet<string>>();
|
||||
var currentDate = date.Date.AddDays(-1);
|
||||
|
||||
// 最多向前查 370 天,既覆盖跨年和长假场景,也避免日历配置异常时出现无限循环。
|
||||
for (var i = 0; i < 370; i++)
|
||||
{
|
||||
var holidays = GetInterbankHolidays(dbContext, currentDate.Year, holidayCache);
|
||||
var currentDateText = currentDate.ToString("yyyy,MM,dd", CultureInfo.InvariantCulture);
|
||||
|
||||
// calendar.HolidayJson 存的是非交易日;不在非交易日集合内,即认为是银行间交易日。
|
||||
if (!holidays.Contains(currentDateText))
|
||||
return currentDate;
|
||||
|
||||
currentDate = currentDate.AddDays(-1);
|
||||
}
|
||||
|
||||
throw new Exception($"未找到{date:yyyy-MM-dd}的上一银行间交易日");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定年份的银行间非交易日集合。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">当前风控执行使用的数据库上下文。</param>
|
||||
/// <param name="year">日历年份。</param>
|
||||
/// <param name="holidayCache">单次查询过程内的年份级缓存,跨年查找时避免重复读取同一年日历。</param>
|
||||
/// <returns>格式为 yyyy,MM,dd 的非交易日集合。</returns>
|
||||
private static HashSet<string> GetInterbankHolidays(YLContext dbContext, int year, Dictionary<int, HashSet<string>> holidayCache)
|
||||
{
|
||||
if (holidayCache.TryGetValue(year, out var holidays))
|
||||
return holidays;
|
||||
|
||||
// 同一年可能存在多种市场日历;规则 12 明确使用 Country=IB 的银行间日历。
|
||||
var calendar = dbContext.calendar
|
||||
.Where(c => c.Year == year && (c.ValidState == null || c.ValidState != ConsGlobal.InValid))
|
||||
.ToList()
|
||||
.FirstOrDefault(c => string.Equals(c.Country, "IB", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (calendar == null)
|
||||
throw new Exception($"未找到{year}年银行间日历");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(calendar.HolidayJson))
|
||||
throw new Exception($"{year}年银行间日历HolidayJson为空");
|
||||
|
||||
List<string> holidayList;
|
||||
try
|
||||
{
|
||||
holidayList = JsonConvert.DeserializeObject<List<string>>(calendar.HolidayJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"{year}年银行间日历HolidayJson解析失败", ex);
|
||||
}
|
||||
|
||||
holidays = new HashSet<string>(holidayList ?? new List<string>());
|
||||
holidayCache[year] = holidays;
|
||||
return holidays;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using YLErp.BLL;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// 风控行情偏离类变量辅助方法,统一封装债券中债估值偏离和非债券行情价格偏离的取数、计算和命中明细生成逻辑。
|
||||
/// </summary>
|
||||
public static class RiskMarketDeviationHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 计算当前交易所有浮动支付端的债券类净价偏离值,返回最大偏离值用于规则比较。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">数据库上下文。</param>
|
||||
/// <param name="tradeId">当前交易ID。</param>
|
||||
/// <returns>包含最大净价偏离值和逐笔偏离明细的变量返回值。</returns>
|
||||
public static RiskVariableValueDetail GetBondNetPriceDeviation(YLContext dbContext, int tradeId)
|
||||
{
|
||||
return GetBondValuationDeviation(
|
||||
dbContext,
|
||||
tradeId,
|
||||
"债券类净价偏离",
|
||||
"期初交割净价",
|
||||
"中债估值净价",
|
||||
p => p.PosiNetNoFeePrice,
|
||||
v => v.net_price);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算当前交易所有浮动支付端的债券类收益率偏离值,返回最大偏离值用于规则比较。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">数据库上下文。</param>
|
||||
/// <param name="tradeId">当前交易ID。</param>
|
||||
/// <returns>包含最大收益率偏离值和逐笔偏离明细的变量返回值。</returns>
|
||||
public static RiskVariableValueDetail GetBondYieldDeviation(YLContext dbContext, int tradeId)
|
||||
{
|
||||
return GetBondValuationDeviation(
|
||||
dbContext,
|
||||
tradeId,
|
||||
"债券类收益率偏离",
|
||||
"期初成交收益率",
|
||||
"中债估值收益率",
|
||||
p => p.InitYtm,
|
||||
v => v.yield);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算当前交易所有浮动支付端的非债券类价格偏离值,返回最大偏离值用于规则比较。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">数据库上下文。</param>
|
||||
/// <param name="tradeId">当前交易ID。</param>
|
||||
/// <returns>包含最大价格偏离值和逐笔偏离明细的变量返回值。</returns>
|
||||
public static RiskVariableValueDetail GetNonBondPriceDeviation(YLContext dbContext, int tradeId)
|
||||
{
|
||||
if (dbContext == null)
|
||||
throw new ArgumentNullException(nameof(dbContext));
|
||||
|
||||
var tradeDate = GetTradeDate(dbContext, tradeId);
|
||||
var floatingPositions = GetFloatingPaymentPositions(dbContext, tradeId)
|
||||
.Select(p => new
|
||||
{
|
||||
p.id,
|
||||
p.UnderlyingCode,
|
||||
p.PosiGrossPrice
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (!floatingPositions.Any())
|
||||
throw new Exception("浮动支付端记录不存在");
|
||||
|
||||
var underlyingCodes = floatingPositions
|
||||
.Select(p => p.UnderlyingCode)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// 非债券类取交易日前最近一条行情,不使用银行间日历,也不要求行情日等于上一银行间交易日。
|
||||
var eodRows = dbContext.eod_commodity_future_price
|
||||
.Where(e => underlyingCodes.Contains(e.UnderlyingCode)
|
||||
&& e.ValueDate < tradeDate)
|
||||
.Select(e => new
|
||||
{
|
||||
e.id,
|
||||
e.UnderlyingCode,
|
||||
e.ValueDate,
|
||||
e.ClosePrice
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 先按标的批量查出行情,再在内存中分组取最近日,避免每条浮动支付端单独访问数据库。
|
||||
var eodByUnderlyingCode = eodRows
|
||||
.GroupBy(e => e.UnderlyingCode)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.OrderByDescending(e => e.ValueDate).ThenBy(e => e.id).First());
|
||||
|
||||
var valuationItems = floatingPositions
|
||||
.Select(p => new
|
||||
{
|
||||
Position = p,
|
||||
Eod = eodByUnderlyingCode.ContainsKey(p.UnderlyingCode) ? eodByUnderlyingCode[p.UnderlyingCode] : null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var missingEodItems = valuationItems
|
||||
.Where(x => x.Eod == null)
|
||||
.Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}")
|
||||
.ToList();
|
||||
|
||||
if (missingEodItems.Any())
|
||||
throw new Exception($"未找到交易日前行情收盘价:" + string.Join(";", missingEodItems));
|
||||
|
||||
var diffItems = valuationItems
|
||||
.Select(x => new
|
||||
{
|
||||
PositionId = x.Position.id,
|
||||
UnderlyingCode = x.Position.UnderlyingCode,
|
||||
PositionPrice = x.Position.PosiGrossPrice * 100m,
|
||||
MarketDate = x.Eod.ValueDate,
|
||||
MarketPrice = Convert.ToDecimal(x.Eod.ClosePrice),
|
||||
DiffAbs = Math.Abs(x.Position.PosiGrossPrice * 100m - Convert.ToDecimal(x.Eod.ClosePrice))
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return BuildDeviationDetail(
|
||||
diffItems.Select(x => new DeviationItem
|
||||
{
|
||||
PositionId = x.PositionId,
|
||||
UnderlyingCode = x.UnderlyingCode,
|
||||
PositionValue = x.PositionPrice,
|
||||
MarketDate = x.MarketDate,
|
||||
MarketValue = x.MarketPrice,
|
||||
DiffAbs = x.DiffAbs
|
||||
}).ToList(),
|
||||
"非债券类价格偏离",
|
||||
"期初标的价格",
|
||||
"上一行情收盘价");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 债券类中债估值偏离的公共计算入口,净价偏离和收益率偏离仅通过字段选择器区分取值字段。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">数据库上下文。</param>
|
||||
/// <param name="tradeId">当前交易ID。</param>
|
||||
/// <param name="deviationName">偏离规则名称,用于生成命中明细。</param>
|
||||
/// <param name="positionValueName">交易侧取值名称,用于生成命中明细。</param>
|
||||
/// <param name="marketValueName">市场估值取值名称,用于生成命中明细。</param>
|
||||
/// <param name="positionValueSelector">交易侧字段选择器。</param>
|
||||
/// <param name="valuationValueSelector">中债估值字段选择器。</param>
|
||||
/// <returns>包含最大偏离值和逐笔偏离明细的变量返回值。</returns>
|
||||
private static RiskVariableValueDetail GetBondValuationDeviation(
|
||||
YLContext dbContext,
|
||||
int tradeId,
|
||||
string deviationName,
|
||||
string positionValueName,
|
||||
string marketValueName,
|
||||
Func<YLErp.DBModels.swap_position, decimal?> positionValueSelector,
|
||||
Func<YLErp.DBModels.ChinaBondValuation, decimal?> valuationValueSelector)
|
||||
{
|
||||
if (dbContext == null)
|
||||
throw new ArgumentNullException(nameof(dbContext));
|
||||
|
||||
var tradeDate = GetTradeDate(dbContext, tradeId);
|
||||
var previousTradingDay = RiskCalendarHelper.GetPreviousInterbankTradingDay(dbContext, tradeDate);
|
||||
var nextTradingDate = previousTradingDay.AddDays(1);
|
||||
var floatingPositions = GetFloatingPaymentPositions(dbContext, tradeId).ToList();
|
||||
|
||||
if (!floatingPositions.Any())
|
||||
throw new Exception("浮动支付端记录不存在");
|
||||
|
||||
var positionItems = floatingPositions
|
||||
.Select(p => new
|
||||
{
|
||||
p.id,
|
||||
p.UnderlyingCode,
|
||||
PositionValue = positionValueSelector(p)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var missingPositionValueIds = positionItems
|
||||
.Where(p => !p.PositionValue.HasValue)
|
||||
.Select(p => p.id.ToString())
|
||||
.ToList();
|
||||
|
||||
if (missingPositionValueIds.Any())
|
||||
throw new Exception($"浮动支付端{positionValueName}为空,记录ID:" + string.Join("、", missingPositionValueIds));
|
||||
|
||||
var underlyingCodes = positionItems
|
||||
.Select(p => p.UnderlyingCode)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// 债券类必须严格匹配上一银行间交易日当天的中债估值,不能简单取交易日前最近估值日。
|
||||
var valuationRows = dbContext.china_bond_valuation
|
||||
.Where(v => underlyingCodes.Contains(v.bond_id)
|
||||
&& v.valuation_date >= previousTradingDay
|
||||
&& v.valuation_date < nextTradingDate)
|
||||
.ToList()
|
||||
.Select(v => new
|
||||
{
|
||||
v.id,
|
||||
v.bond_id,
|
||||
v.valuation_date,
|
||||
v.credibility,
|
||||
ValuationValue = valuationValueSelector(v)
|
||||
})
|
||||
.Where(v => v.ValuationValue.HasValue)
|
||||
.ToList();
|
||||
|
||||
// 同一标的同一估值日可能有多条来源,按可信度优先,ID兜底稳定排序。
|
||||
var valuationByBondId = valuationRows
|
||||
.GroupBy(v => v.bond_id)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.OrderBy(v => v.credibility).ThenBy(v => v.id).First());
|
||||
|
||||
var valuationItems = positionItems
|
||||
.Select(p => new
|
||||
{
|
||||
Position = p,
|
||||
Valuation = valuationByBondId.ContainsKey(p.UnderlyingCode) ? valuationByBondId[p.UnderlyingCode] : null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var missingValuationItems = valuationItems
|
||||
.Where(x => x.Valuation == null)
|
||||
.Select(x => $"记录ID {x.Position.id},标的{x.Position.UnderlyingCode}")
|
||||
.ToList();
|
||||
|
||||
if (missingValuationItems.Any())
|
||||
throw new Exception($"未找到上一银行间交易日{previousTradingDay:yyyy-MM-dd}的{marketValueName}:" + string.Join(";", missingValuationItems));
|
||||
|
||||
var diffItems = valuationItems
|
||||
.Select(x => new DeviationItem
|
||||
{
|
||||
PositionId = x.Position.id,
|
||||
UnderlyingCode = x.Position.UnderlyingCode,
|
||||
PositionValue = x.Position.PositionValue.Value * 100m,
|
||||
MarketDate = x.Valuation.valuation_date,
|
||||
MarketValue = x.Valuation.ValuationValue.Value,
|
||||
DiffAbs = Math.Abs(x.Position.PositionValue.Value * 100m - x.Valuation.ValuationValue.Value)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return BuildDeviationDetail(diffItems, deviationName, positionValueName, marketValueName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前交易的交易日,所有行情偏离规则都以交易日作为市场数据取数基准。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">数据库上下文。</param>
|
||||
/// <param name="tradeId">当前交易ID。</param>
|
||||
/// <returns>交易日日期部分。</returns>
|
||||
private static DateTime GetTradeDate(YLContext dbContext, int tradeId)
|
||||
{
|
||||
var tradeDate = dbContext.trade
|
||||
.Where(t => t.id == tradeId)
|
||||
.Select(t => t.TradeDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!tradeDate.HasValue)
|
||||
throw new Exception("交易日为空");
|
||||
|
||||
return tradeDate.Value.Date;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前交易下全部浮动支付端记录,行情偏离类规则需要遍历同一TradeId下所有浮动支付端。
|
||||
/// </summary>
|
||||
/// <param name="dbContext">数据库上下文。</param>
|
||||
/// <param name="tradeId">当前交易ID。</param>
|
||||
/// <returns>浮动支付端记录查询对象。</returns>
|
||||
private static IQueryable<YLErp.DBModels.swap_position> GetFloatingPaymentPositions(YLContext dbContext, int tradeId)
|
||||
{
|
||||
return dbContext.swap_position
|
||||
.Where(p => p.SwapTradeId == tradeId
|
||||
&& p.IsInitial
|
||||
&& !p.Invalid
|
||||
&& p.PosiDirection == 2
|
||||
&& !string.IsNullOrEmpty(p.UnderlyingCode));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一生成行情偏离类变量返回值,变量值取最大偏离值,命中说明保留逐笔偏离明细。
|
||||
/// </summary>
|
||||
/// <param name="diffItems">逐笔偏离结果。</param>
|
||||
/// <param name="deviationName">偏离规则名称。</param>
|
||||
/// <param name="positionValueName">交易侧取值名称。</param>
|
||||
/// <param name="marketValueName">市场侧取值名称。</param>
|
||||
/// <returns>包含最大偏离值和逐笔偏离明细的变量返回值。</returns>
|
||||
private static RiskVariableValueDetail BuildDeviationDetail(
|
||||
List<DeviationItem> diffItems,
|
||||
string deviationName,
|
||||
string positionValueName,
|
||||
string marketValueName)
|
||||
{
|
||||
var maxDiffItem = diffItems
|
||||
.OrderByDescending(x => x.DiffAbs)
|
||||
.ThenBy(x => x.PositionId)
|
||||
.First();
|
||||
|
||||
var deviatedItems = diffItems
|
||||
.Where(x => x.DiffAbs > 0m)
|
||||
.OrderByDescending(x => x.DiffAbs)
|
||||
.ThenBy(x => x.PositionId)
|
||||
.Select(x => $"记录ID {x.PositionId},标的{x.UnderlyingCode}:{positionValueName}{FormatDecimal(x.PositionValue)},{x.MarketDate:yyyy-MM-dd}{marketValueName}{FormatDecimal(x.MarketValue)},偏离{FormatDecimal(x.DiffAbs)}")
|
||||
.ToList();
|
||||
|
||||
string diffMessage = deviatedItems.Any()
|
||||
? $"存在{deviationName}的浮动支付端记录:" + string.Join(";", deviatedItems)
|
||||
: $"未发现{deviationName}记录";
|
||||
|
||||
return new RiskVariableValueDetail(maxDiffItem.DiffAbs, diffMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化风控命中说明中的数值,避免展示过长小数。
|
||||
/// </summary>
|
||||
/// <param name="value">待格式化数值。</param>
|
||||
/// <returns>最多9位小数的展示文本。</returns>
|
||||
private static string FormatDecimal(decimal value)
|
||||
{
|
||||
return value.ToString("0.#########");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 行情偏离计算的中间结果模型,用于把债券和非债券两类计算结果统一交给明细构建逻辑。
|
||||
/// </summary>
|
||||
private class DeviationItem
|
||||
{
|
||||
public long PositionId { get; set; }
|
||||
public string UnderlyingCode { get; set; }
|
||||
public decimal PositionValue { get; set; }
|
||||
public DateTime MarketDate { get; set; }
|
||||
public decimal MarketValue { get; set; }
|
||||
public decimal DiffAbs { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,26 +21,26 @@ using YLErp.Model;
|
||||
RuleText、RuleExpr、Version、CompiledScript。
|
||||
- RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、
|
||||
适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。
|
||||
- RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式,
|
||||
- RuleExpr:自由文本规则的编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式,
|
||||
例如通过 DbContext 和 TradeId 查询数据库后做数值或日期比较。
|
||||
- ConditionJson:当前已在规则定义中保留,但不参与主执行链路。
|
||||
- ConditionJson:结构化规则的主执行依据;有值时优先由结构化执行器执行。
|
||||
- RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DbContext。
|
||||
- RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func<RiskContext, bool>。
|
||||
- RuleCompiledCache:进程内编译结果缓存,仅用于自由文本 RuleExpr 规则,按规则 Id 缓存 Func<RiskContext, bool>。
|
||||
|
||||
当前编译流程:
|
||||
1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式
|
||||
2. 调用 RuleCompiler.ValidateAndCompileRule(rule)
|
||||
3. 内部使用 Roslyn 编译 RuleExpr,生成 Func<RiskContext, bool>
|
||||
4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache
|
||||
5. 执行阶段优先从 RuleCompiledCache 取委托执行
|
||||
1. 结构化规则以 ConditionJson 为执行依据,预热时只做 JSON 结构解析校验
|
||||
2. 自由文本规则在 RuleExpr 中直接写最终可执行表达式
|
||||
3. 调用 RuleCompiler.ValidateAndCompileRule(rule)
|
||||
4. 内部使用 Roslyn 编译 RuleExpr,生成 Func<RiskContext, bool>
|
||||
5. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache
|
||||
|
||||
当前执行流程(EvaluateRisk):
|
||||
1. 从数据库加载规则列表和应用列表
|
||||
2. 按应用状态、TriggerPoints 过滤有效应用
|
||||
3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型)
|
||||
4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则
|
||||
5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次
|
||||
6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings
|
||||
5. ConditionJson 有值时走结构化执行器;否则从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次
|
||||
6. 执行规则判断,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings
|
||||
7. 返回 RiskResult
|
||||
|
||||
当前维度匹配规则:
|
||||
@@ -50,7 +50,7 @@ using YLErp.Model;
|
||||
- 某维度留空:视为该维度不限制
|
||||
|
||||
【与早期方案的主要差异】
|
||||
- 当前不是 Content 解析或表达式树主导,而是 RuleExpr 直编译主导
|
||||
- 结构化规则已改为 ConditionJson 执行主导,自由文本规则继续保留 RuleExpr 直编译
|
||||
- RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并
|
||||
- 编译器文件已放入 RiskEngine/Compile 目录下
|
||||
- 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存
|
||||
@@ -75,12 +75,12 @@ using YLErp.Model;
|
||||
✅ 已完成:
|
||||
1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler
|
||||
2. 风控上下文提供 DbContext 和 TradeId,支持规则脚本直接查询数据库
|
||||
3. RuleExpr 直编译方案已接入,支持数值比较和日期比较
|
||||
3. 结构化规则已支持 ConditionJson 执行,自由文本规则保留 RuleExpr 直编译
|
||||
4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型
|
||||
5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND
|
||||
6. RuleCompiler 已支持校验 + 编译 + 写缓存
|
||||
7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录
|
||||
8. RiskEngineService 已改为执行时优先从 RuleCompiledCache 读取委托
|
||||
8. RiskEngineService 已改为结构化规则优先执行 ConditionJson,自由文本规则再读取 RuleCompiledCache
|
||||
|
||||
【待办事项 / TODO】
|
||||
⬜ 1. 接入真实规则来源
|
||||
@@ -146,8 +146,8 @@ namespace YLErp.Modules.RiskEngine
|
||||
private static readonly object _cacheLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// 预热:加载规则与应用到内存,并预编译所有规则到 RuleCompiledCache。
|
||||
/// 项目启动时调用一次;规则/应用更新后调用 RefreshCache 刷新。
|
||||
/// 预热:加载规则与应用到内存,并预编译自由文本规则到 RuleCompiledCache。
|
||||
/// 结构化规则运行时直接执行 ConditionJson,预热时只做轻量结构校验,不再预编译 RuleExpr。
|
||||
/// </summary>
|
||||
public void Preload()
|
||||
{
|
||||
@@ -157,7 +157,7 @@ namespace YLErp.Modules.RiskEngine
|
||||
|
||||
var rules = LoadRulesFromDb();
|
||||
var applications = LoadApplicationsFromDb();
|
||||
// 预编译所有规则到 RuleCompiledCache
|
||||
// 仅预编译自由文本规则;结构化规则运行时由 ConditionJson 执行。
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
var ruleId = rule.Id.ToString();
|
||||
@@ -169,6 +169,22 @@ namespace YLErp.Modules.RiskEngine
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.ConditionJson))
|
||||
{
|
||||
RuleCompiledCache.Remove(rule.Id.ToString());
|
||||
try
|
||||
{
|
||||
// 结构化条件在加载阶段已解析到 ParsedConditions;这里仅确认缓存存在,运行时不再反序列化 ConditionJson。
|
||||
if (rule.ParsedConditions == null || rule.ParsedConditions.Count == 0)
|
||||
rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
|
||||
_logger.Info($"[风控引擎] 结构化规则预热校验成功,跳过 RuleExpr 预编译 - RuleId: {rule.Id}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Info($"[风控引擎] 结构化规则预热校验失败 - RuleId: {rule.Id}, Error: {ex.Message}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var compileResult = RuleCompiler.ValidateAndCompileRule(rule);
|
||||
if (compileResult.Success)
|
||||
@@ -200,6 +216,7 @@ namespace YLErp.Modules.RiskEngine
|
||||
_cachedRules = null;
|
||||
_cachedApplications = null;
|
||||
RuleCompiledCache.Clear();
|
||||
StructuredRuleExecutor.ClearVariableCompiledCache();
|
||||
}
|
||||
|
||||
Preload();
|
||||
@@ -231,6 +248,23 @@ namespace YLErp.Modules.RiskEngine
|
||||
return RuleCompileResult.Fail("规则不存在或非活跃状态");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.ConditionJson))
|
||||
{
|
||||
RuleCompiledCache.Remove(ruleId.ToString());
|
||||
try
|
||||
{
|
||||
// 单规则刷新时同步刷新结构化条件缓存,避免后续执行继续解析旧 JSON 或重复反序列化。
|
||||
rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
|
||||
_logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验成功,跳过 RuleExpr 编译 - RuleId: {ruleId}");
|
||||
return RuleCompileResult.Ok(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验失败 - RuleId: {ruleId}, Error: {ex.Message}");
|
||||
return RuleCompileResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var compileResult = RuleCompiler.ValidateAndCompileRule(rule);
|
||||
_logger.Info($"[风控引擎] RefreshOneRuleCache 完成 - RuleId: {ruleId}, Success: {compileResult.Success}, Error: {compileResult.ErrorMessage}");
|
||||
return compileResult;
|
||||
@@ -323,6 +357,9 @@ namespace YLErp.Modules.RiskEngine
|
||||
// ============================================================
|
||||
var rules = GetRules();
|
||||
var applications = GetApplications();
|
||||
// 规则列表在本次执行内转为字典,应用按 RuleIds 关联规则时直接按 ID 查找,避免每个应用重复扫描规则列表。
|
||||
var ruleMap = rules.ToDictionary(r => r.Id);
|
||||
var variables = new Dictionary<long, glms_risk_variable>();
|
||||
_logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}");
|
||||
|
||||
// ============================================================
|
||||
@@ -371,64 +408,116 @@ namespace YLErp.Modules.RiskEngine
|
||||
continue;
|
||||
}
|
||||
|
||||
var applicationRules = rules
|
||||
.Where(r => applicationRuleIds.Contains(r.Id) && r.Status == RiskRuleStatus.Active)
|
||||
.ToList();
|
||||
|
||||
// 区分"规则不存在"与"规则非活跃"两种情况,分别记录日志
|
||||
var ruleDict = rules.Where(r => applicationRuleIds.Contains(r.Id))
|
||||
.ToDictionary(r => r.Id);
|
||||
|
||||
var applicationRules = new List<RiskRule>();
|
||||
foreach (var ruleId in applicationRuleIds)
|
||||
{
|
||||
if (!ruleDict.TryGetValue(ruleId, out var ruleDef))
|
||||
// 通过本次执行预构建的规则字典按 ID 查找,避免每个应用都 Where 扫描全量规则列表。
|
||||
if (!ruleMap.TryGetValue(ruleId, out var ruleDef))
|
||||
{
|
||||
_logger.Info($"[风控引擎] 未找到对应规则定义 - RuleId: {ruleId}, ApplicationRuleIds: {application.RuleIds}");
|
||||
continue;
|
||||
}
|
||||
else if (ruleDef.Status != RiskRuleStatus.Active)
|
||||
|
||||
if (ruleDef.Status != RiskRuleStatus.Active)
|
||||
{
|
||||
_logger.Info($"[风控引擎] 规则非活跃,跳过执行 - RuleId: {ruleId}, Status: {ruleDef.Status}, ApplicationRuleIds: {application.RuleIds}");
|
||||
continue;
|
||||
}
|
||||
|
||||
applicationRules.Add(ruleDef);
|
||||
}
|
||||
|
||||
foreach (var rule in applicationRules)
|
||||
{
|
||||
var ruleId = rule.Id.ToString();
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3.2 优先从编译缓存读取规则委托
|
||||
// ------------------------------------------------------------
|
||||
if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript))
|
||||
bool triggered = false;
|
||||
StructuredRuleExecuteResult executeResult = null;
|
||||
if (!string.IsNullOrWhiteSpace(rule.ConditionJson))
|
||||
{
|
||||
_logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}");
|
||||
|
||||
var compileResult = RuleCompiler.ValidateAndCompileRule(rule);
|
||||
if (!compileResult.Success)
|
||||
// 结构化规则优先使用 ConditionJson 执行,避免继续把条件整体拼成 Roslyn bool 公式。
|
||||
try
|
||||
{
|
||||
var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}";
|
||||
_logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}");
|
||||
AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage);
|
||||
// 结构化条件在规则加载/刷新时已解析到内存缓存,运行时直接复用,避免每次执行反序列化 ConditionJson。
|
||||
var conditions = rule.ParsedConditions;
|
||||
if (conditions == null || conditions.Count == 0)
|
||||
throw new InvalidOperationException("结构化规则条件缓存为空");
|
||||
|
||||
var referencedVariableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions);
|
||||
var missingVariableIds = referencedVariableIds
|
||||
.Where(id => !variables.ContainsKey(id))
|
||||
.ToList();
|
||||
if (missingVariableIds.Any())
|
||||
{
|
||||
// 按本次结构化规则实际引用的变量懒加载,避免每次风控检查全量读取变量池。
|
||||
var loadedVariables = ruleDbContext.glms_risk_variable
|
||||
.AsNoTracking()
|
||||
.Where(v => missingVariableIds.Contains(v.id))
|
||||
.ToDictionary(v => v.id);
|
||||
foreach (var variable in loadedVariables)
|
||||
variables[variable.Key] = variable.Value;
|
||||
}
|
||||
|
||||
var ruleVariables = referencedVariableIds
|
||||
.Where(variables.ContainsKey)
|
||||
.ToDictionary(id => id, id => variables[id]);
|
||||
|
||||
executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context);
|
||||
if (!executeResult.Success)
|
||||
{
|
||||
var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}";
|
||||
_logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {executeResult.ErrorMessage}");
|
||||
AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
triggered = executeResult.Triggered;
|
||||
_logger.Info($"[风控引擎] 结构化规则执行 - RuleId: {rule.Id}, Triggered: {triggered}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}";
|
||||
_logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}");
|
||||
AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
compiledScript = compileResult.CompiledScript;
|
||||
}
|
||||
else
|
||||
{
|
||||
// ------------------------------------------------------------
|
||||
// 3.2 优先从编译缓存读取规则委托
|
||||
// ------------------------------------------------------------
|
||||
if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript))
|
||||
{
|
||||
_logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}");
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3.3 执行预编译委托
|
||||
// ------------------------------------------------------------
|
||||
bool triggered = false;
|
||||
try
|
||||
{
|
||||
triggered = compiledScript(context);
|
||||
_logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}";
|
||||
_logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}");
|
||||
AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage);
|
||||
continue;
|
||||
var compileResult = RuleCompiler.ValidateAndCompileRule(rule);
|
||||
if (!compileResult.Success)
|
||||
{
|
||||
var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}";
|
||||
_logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}");
|
||||
AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
compiledScript = compileResult.CompiledScript;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3.3 执行预编译委托
|
||||
// ------------------------------------------------------------
|
||||
try
|
||||
{
|
||||
triggered = compiledScript(context);
|
||||
_logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}";
|
||||
_logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}");
|
||||
AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
@@ -437,6 +526,8 @@ namespace YLErp.Modules.RiskEngine
|
||||
if (triggered)
|
||||
{
|
||||
_logger.Info($"[风控引擎] 规则触发 - RuleId: {rule.Id}, Strategy: {application.ControlStrategy}");
|
||||
// 结构化规则执行器会返回实际值、阈值和变量计算明细;纯文本规则没有该信息时保持原提示。
|
||||
var triggerMessageDetail = executeResult?.Message;
|
||||
|
||||
switch (application.ControlStrategy)
|
||||
{
|
||||
@@ -449,7 +540,7 @@ namespace YLErp.Modules.RiskEngine
|
||||
RuleName = rule.RuleName,
|
||||
ControlStrategy = RiskControlStrategy.Block,
|
||||
RuleText = rule.RuleText,
|
||||
Message = $"规则[{rule.RuleName}]触发:禁止"
|
||||
Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:禁止", triggerMessageDetail)
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -462,7 +553,7 @@ namespace YLErp.Modules.RiskEngine
|
||||
RuleName = rule.RuleName,
|
||||
ControlStrategy = RiskControlStrategy.Approval,
|
||||
RuleText = rule.RuleText,
|
||||
Message = $"规则[{rule.RuleName}]触发:需审批"
|
||||
Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:需审批", triggerMessageDetail)
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -474,7 +565,7 @@ namespace YLErp.Modules.RiskEngine
|
||||
RuleName = rule.RuleName,
|
||||
ControlStrategy = RiskControlStrategy.ShowTip,
|
||||
RuleText = rule.RuleText,
|
||||
Message = $"规则[{rule.RuleName}]触发:提示"
|
||||
Message = BuildTriggeredMessage($"规则[{rule.RuleName}]触发:提示", triggerMessageDetail)
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -514,6 +605,13 @@ namespace YLErp.Modules.RiskEngine
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string BuildTriggeredMessage(string baseMessage, string detailMessage)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(detailMessage)
|
||||
? baseMessage
|
||||
: $"{baseMessage}。{detailMessage}";
|
||||
}
|
||||
|
||||
private static void AddBlockError(RiskResult result, string ruleId, string ruleName, string ruleText, string errorMessage)
|
||||
{
|
||||
result.Blocked = true;
|
||||
@@ -556,7 +654,22 @@ namespace YLErp.Modules.RiskEngine
|
||||
UpdateOptId = r.UpdateOptId ?? 0,
|
||||
UpdateOptName = r.UpdateOptName,
|
||||
UpdateDate = r.UpdateDate ?? r.OptDate ?? DateTime.MinValue
|
||||
}).ToList() ;
|
||||
}).ToList();
|
||||
|
||||
// 结构化规则的 ConditionJson 在加载阶段解析一次并缓存到规则对象,运行时直接复用 ParsedConditions。
|
||||
// 如果历史脏数据解析失败,不中断整批规则加载,后续执行该规则时仍按单条规则异常处理。
|
||||
foreach (var rule in rules.Where(r => !string.IsNullOrWhiteSpace(r.ConditionJson)))
|
||||
{
|
||||
try
|
||||
{
|
||||
rule.ParsedConditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Info($"[风控引擎] 结构化规则条件解析失败 - RuleId: {rule.Id}, Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
//#region 测试本地规则
|
||||
//rules.Add(new RiskRule
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using YLErp.Modules.RiskEngine.Dto;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
@@ -47,5 +49,11 @@ namespace YLErp.Modules.RiskEngine
|
||||
/// 判风控时直接调用:rule.CompiledScript(context) → bool
|
||||
/// </summary>
|
||||
public Func<RiskContext, bool> CompiledScript { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结构化规则已解析条件缓存。
|
||||
/// 规则加载/刷新时由 ConditionJson 解析一次,运行时直接复用,避免每次风控执行反序列化 JSON。
|
||||
/// </summary>
|
||||
public IReadOnlyList<RuleCondition> ParsedConditions { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using YLErp.Commons;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.Modules.RiskEngine.Dto;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// ConditionJson 契约校验和简洁 RuleExpr 生成器。
|
||||
/// </summary>
|
||||
public static class RuleConditionExpressionBuilder
|
||||
{
|
||||
private static readonly HashSet<string> ComparableOperators = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"gt", "lt", "gte", "lte", "eq", "ne", "between", "notBetween"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> BooleanOperators = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"isTrue", "isFalse"
|
||||
};
|
||||
|
||||
private static readonly string[] VariableReferenceFields =
|
||||
{
|
||||
"VariableId", "ThresholdVariableId", "LowerThresholdVariableId", "UpperThresholdVariableId"
|
||||
};
|
||||
|
||||
public static List<RuleCondition> DeserializeConditions(string conditionJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var conditions = JsonConvert.DeserializeObject<List<RuleCondition>>(conditionJson);
|
||||
if (conditions == null || conditions.Count == 0)
|
||||
throw new ServiceException("公式条件列表不能为空");
|
||||
return conditions;
|
||||
}
|
||||
catch (ServiceException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new ServiceException("公式表达式 JSON 格式不合法");
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyCollection<long> GetReferencedVariableIds(IEnumerable<RuleCondition> conditions)
|
||||
{
|
||||
return conditions
|
||||
.SelectMany(condition => condition == null
|
||||
? Array.Empty<long?>()
|
||||
: new long?[]
|
||||
{
|
||||
condition.VariableId,
|
||||
condition.ThresholdVariableId,
|
||||
condition.LowerThresholdVariableId,
|
||||
condition.UpperThresholdVariableId
|
||||
})
|
||||
.Where(id => id.HasValue && id.Value > 0)
|
||||
.Select(id => id.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool IsVariableReferenced(string conditionJson, long variableId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(conditionJson) || variableId <= 0)
|
||||
return false;
|
||||
|
||||
var idText = variableId.ToString(CultureInfo.InvariantCulture);
|
||||
return VariableReferenceFields.Any(field =>
|
||||
Regex.IsMatch(conditionJson, $@"""{field}""\s*:\s*{idText}\b", RegexOptions.CultureInvariant));
|
||||
}
|
||||
|
||||
public static string Build(
|
||||
IReadOnlyList<RuleCondition> conditions,
|
||||
IReadOnlyDictionary<long, glms_risk_variable> variables)
|
||||
{
|
||||
if (conditions == null || conditions.Count == 0)
|
||||
throw new ServiceException("公式条件列表不能为空");
|
||||
|
||||
var expressions = conditions
|
||||
.Select((condition, index) => BuildConditionExpression(condition, variables, index + 1));
|
||||
return string.Join(" && ", expressions);
|
||||
}
|
||||
|
||||
private static string BuildConditionExpression(
|
||||
RuleCondition condition,
|
||||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||||
int conditionIndex)
|
||||
{
|
||||
var label = $"条件{conditionIndex}";
|
||||
if (condition == null)
|
||||
throw new ServiceException($"{label}不能为空");
|
||||
if (!variables.TryGetValue(condition.VariableId, out var variable))
|
||||
throw new ServiceException($"{label}:变量 ID '{condition.VariableId}' 不存在");
|
||||
if (string.IsNullOrWhiteSpace(variable.VariableExpr))
|
||||
throw new ServiceException($"{label}:变量'{variable.VariableName}'的取值表达式为空");
|
||||
|
||||
if (variable.DataType == RiskVariableDataType.Boolean)
|
||||
return BuildBooleanExpression(condition, variable, label);
|
||||
|
||||
if (variable.DataType != RiskVariableDataType.Numeric && variable.DataType != RiskVariableDataType.Date)
|
||||
throw new ServiceException($"{label}:不支持的数据类型");
|
||||
if (!ComparableOperators.Contains(condition.Operator))
|
||||
throw new ServiceException($"{label}:操作符 '{condition.Operator}' 不适用于{GetDataTypeName(variable.DataType)}类型变量");
|
||||
|
||||
var leftExpression = BuildComparableExpression(variable, label);
|
||||
if (condition.Operator == "between" || condition.Operator == "notBetween")
|
||||
return BuildRangeExpression(condition, variable, leftExpression, variables, label);
|
||||
|
||||
EnsureNoRangeFields(condition, label);
|
||||
var rightExpression = BuildThresholdExpression(
|
||||
condition.ThresholdType,
|
||||
condition.Value,
|
||||
condition.ThresholdVariableId,
|
||||
variable,
|
||||
variables,
|
||||
label,
|
||||
"阈值");
|
||||
var comparisonOperator = GetComparisonOperator(condition.Operator, label);
|
||||
return $"{leftExpression} {comparisonOperator} {rightExpression}";
|
||||
}
|
||||
|
||||
private static string BuildBooleanExpression(RuleCondition condition, glms_risk_variable variable, string label)
|
||||
{
|
||||
if (!BooleanOperators.Contains(condition.Operator))
|
||||
throw new ServiceException($"{label}:操作符 '{condition.Operator}' 不适用于布尔类型变量");
|
||||
EnsureNoThresholdFields(condition, label);
|
||||
return $"{variable.VariableExpr} == {(condition.Operator == "isTrue" ? "true" : "false")}";
|
||||
}
|
||||
|
||||
private static string BuildRangeExpression(
|
||||
RuleCondition condition,
|
||||
glms_risk_variable conditionVariable,
|
||||
string leftExpression,
|
||||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||||
string label)
|
||||
{
|
||||
if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue)
|
||||
throw new ServiceException($"{label}:区间条件不得携带普通阈值字段");
|
||||
if (!condition.IncludeLower.HasValue || !condition.IncludeUpper.HasValue)
|
||||
throw new ServiceException($"{label}:区间开闭配置不完整");
|
||||
|
||||
var lowerExpression = BuildThresholdExpression(
|
||||
condition.LowerThresholdType,
|
||||
condition.LowerValue,
|
||||
condition.LowerThresholdVariableId,
|
||||
conditionVariable,
|
||||
variables,
|
||||
label,
|
||||
"下限");
|
||||
var upperExpression = BuildThresholdExpression(
|
||||
condition.UpperThresholdType,
|
||||
condition.UpperValue,
|
||||
condition.UpperThresholdVariableId,
|
||||
conditionVariable,
|
||||
variables,
|
||||
label,
|
||||
"上限");
|
||||
|
||||
ValidateFixedRangeOrder(condition, conditionVariable.DataType, label);
|
||||
|
||||
var lowerOperator = condition.IncludeLower.Value ? ">=" : ">";
|
||||
var upperOperator = condition.IncludeUpper.Value ? "<=" : "<";
|
||||
var rangeExpression = $"{leftExpression} {lowerOperator} {lowerExpression} && {leftExpression} {upperOperator} {upperExpression}";
|
||||
return condition.Operator == "notBetween" ? $"!({rangeExpression})" : rangeExpression;
|
||||
}
|
||||
|
||||
private static string BuildThresholdExpression(
|
||||
string thresholdType,
|
||||
object fixedValue,
|
||||
long? thresholdVariableId,
|
||||
glms_risk_variable conditionVariable,
|
||||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||||
string label,
|
||||
string thresholdLabel)
|
||||
{
|
||||
var dataType = conditionVariable.DataType;
|
||||
if (string.Equals(thresholdType, "Fixed", StringComparison.Ordinal))
|
||||
{
|
||||
if (thresholdVariableId.HasValue)
|
||||
throw new ServiceException($"{label}:固定{thresholdLabel}不得携带变量 ID");
|
||||
return BuildFixedValueExpression(fixedValue, dataType, label, thresholdLabel);
|
||||
}
|
||||
|
||||
if (string.Equals(thresholdType, "Variable", StringComparison.Ordinal))
|
||||
{
|
||||
if (HasValue(fixedValue))
|
||||
throw new ServiceException($"{label}:变量{thresholdLabel}不得携带固定值");
|
||||
if (!thresholdVariableId.HasValue)
|
||||
throw new ServiceException($"{label}:{thresholdLabel}变量 ID 不能为空");
|
||||
if (!variables.TryGetValue(thresholdVariableId.Value, out var thresholdVariable))
|
||||
throw new ServiceException($"{label}:{thresholdLabel}变量 ID '{thresholdVariableId.Value}' 不存在");
|
||||
if (thresholdVariable.DataType != dataType)
|
||||
throw new ServiceException($"{label}:{thresholdLabel}变量与条件变量的数据类型不一致");
|
||||
if (dataType == RiskVariableDataType.Numeric && !UnitsMatch(conditionVariable.Unit, thresholdVariable.Unit))
|
||||
throw new ServiceException($"{label}:{thresholdLabel}变量与条件变量的单位不一致");
|
||||
return BuildComparableExpression(thresholdVariable, label);
|
||||
}
|
||||
|
||||
throw new ServiceException($"{label}:{thresholdLabel}类型 '{thresholdType}' 不合法,仅支持 Fixed/Variable");
|
||||
}
|
||||
|
||||
private static string BuildComparableExpression(glms_risk_variable variable, string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variable.VariableExpr))
|
||||
throw new ServiceException($"{label}:变量'{variable.VariableName}'的取值表达式为空");
|
||||
return variable.VariableExpr;
|
||||
}
|
||||
|
||||
private static string BuildFixedValueExpression(
|
||||
object value,
|
||||
RiskVariableDataType dataType,
|
||||
string label,
|
||||
string thresholdLabel)
|
||||
{
|
||||
if (!HasValue(value))
|
||||
throw new ServiceException($"{label}:固定{thresholdLabel}不能为空");
|
||||
|
||||
var rawValue = GetRawValue(value);
|
||||
if (dataType == RiskVariableDataType.Numeric)
|
||||
{
|
||||
if (!decimal.TryParse(rawValue, NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var numericValue))
|
||||
{
|
||||
throw new ServiceException($"{label}:数值型{thresholdLabel}必须为数字");
|
||||
}
|
||||
|
||||
return FormatFixedDecimal(numericValue);
|
||||
}
|
||||
|
||||
if (dataType == RiskVariableDataType.Date)
|
||||
{
|
||||
if (!DateTime.TryParseExact(rawValue, "yyyy-MM-dd", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var dateValue))
|
||||
{
|
||||
throw new ServiceException($"{label}:日期型{thresholdLabel}必须为 yyyy-MM-dd 格式的合法日期");
|
||||
}
|
||||
|
||||
return $"new DateTime({dateValue.Year}, {dateValue.Month}, {dateValue.Day})";
|
||||
}
|
||||
|
||||
throw new ServiceException($"{label}:不支持的数据类型");
|
||||
}
|
||||
|
||||
private static string FormatFixedDecimal(decimal value)
|
||||
{
|
||||
return value.ToString("0.############################", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void ValidateFixedRangeOrder(RuleCondition condition, RiskVariableDataType dataType, string label)
|
||||
{
|
||||
if (condition.LowerThresholdType != "Fixed" || condition.UpperThresholdType != "Fixed")
|
||||
return;
|
||||
|
||||
var lower = GetRawValue(condition.LowerValue);
|
||||
var upper = GetRawValue(condition.UpperValue);
|
||||
if (dataType == RiskVariableDataType.Numeric &&
|
||||
decimal.TryParse(lower, NumberStyles.Float, CultureInfo.InvariantCulture, out var lowerNumber) &&
|
||||
decimal.TryParse(upper, NumberStyles.Float, CultureInfo.InvariantCulture, out var upperNumber) &&
|
||||
lowerNumber > upperNumber)
|
||||
{
|
||||
throw new ServiceException($"{label}:下限不能大于上限");
|
||||
}
|
||||
|
||||
if (dataType == RiskVariableDataType.Date &&
|
||||
DateTime.TryParseExact(lower, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var lowerDate) &&
|
||||
DateTime.TryParseExact(upper, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var upperDate) &&
|
||||
lowerDate > upperDate)
|
||||
{
|
||||
throw new ServiceException($"{label}:下限不能晚于上限");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureNoRangeFields(RuleCondition condition, string label)
|
||||
{
|
||||
if (condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue ||
|
||||
condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue ||
|
||||
condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue)
|
||||
{
|
||||
throw new ServiceException($"{label}:非区间条件不得携带区间字段");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureNoThresholdFields(RuleCondition condition, string label)
|
||||
{
|
||||
if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue ||
|
||||
condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue ||
|
||||
condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue ||
|
||||
condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue)
|
||||
{
|
||||
throw new ServiceException($"{label}:布尔条件不得携带阈值字段");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasValue(object value)
|
||||
{
|
||||
if (value == null) return false;
|
||||
if (value is JValue jsonValue)
|
||||
{
|
||||
if (jsonValue.Type == JTokenType.Null || jsonValue.Type == JTokenType.Undefined) return false;
|
||||
if (jsonValue.Type == JTokenType.String) return !string.IsNullOrWhiteSpace(jsonValue.Value<string>());
|
||||
return true;
|
||||
}
|
||||
return value is not string text || !string.IsNullOrWhiteSpace(text);
|
||||
}
|
||||
|
||||
private static string GetRawValue(object value)
|
||||
{
|
||||
return value is JValue jsonValue
|
||||
? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture)
|
||||
: Convert.ToString(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static bool UnitsMatch(string left, string right)
|
||||
{
|
||||
return string.Equals(left?.Trim() ?? string.Empty, right?.Trim() ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string GetComparisonOperator(string ruleOperator, string label)
|
||||
{
|
||||
return ruleOperator switch
|
||||
{
|
||||
"gt" => ">",
|
||||
"lt" => "<",
|
||||
"gte" => ">=",
|
||||
"lte" => "<=",
|
||||
"eq" => "==",
|
||||
"ne" => "!=",
|
||||
_ => throw new ServiceException($"{label}:不支持的操作符'{ruleOperator}'")
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDataTypeName(RiskVariableDataType dataType)
|
||||
{
|
||||
return dataType switch
|
||||
{
|
||||
RiskVariableDataType.Numeric => "数值",
|
||||
RiskVariableDataType.Date => "日期",
|
||||
RiskVariableDataType.Boolean => "布尔",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using YLErp.Commons;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.Modules.RiskEngine.Dto;
|
||||
|
||||
namespace YLErp.Modules.RiskEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// 结构化规则执行器。
|
||||
/// 用于执行现有 ConditionJson:变量取值、按 DataType 转换并比较阈值;复杂业务计算放在变量表达式中完成。
|
||||
/// </summary>
|
||||
public static class StructuredRuleExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// 变量表达式编译缓存。Key 包含变量 ID、版本、更新时间和表达式哈希,避免变量变更后复用旧委托。
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, Func<RiskContext, object>> VariableCompiledCache = new ConcurrentDictionary<string, Func<RiskContext, object>>();
|
||||
|
||||
/// <summary>
|
||||
/// 按变量 ID 增量移除变量表达式编译缓存。
|
||||
/// </summary>
|
||||
public static void RemoveVariableCompiledCache(long variableId)
|
||||
{
|
||||
var keyPrefix = variableId + ":";
|
||||
// 缓存 Key 以变量 ID 开头,变量修改/删除时只清理该变量相关的编译结果。
|
||||
foreach (var key in VariableCompiledCache.Keys.Where(k => k.StartsWith(keyPrefix, StringComparison.Ordinal)))
|
||||
{
|
||||
VariableCompiledCache.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按变量 ID 集合批量移除变量表达式编译缓存。
|
||||
/// </summary>
|
||||
public static void RemoveVariableCompiledCache(IEnumerable<long> variableIds)
|
||||
{
|
||||
if (variableIds == null)
|
||||
return;
|
||||
|
||||
var idSet = new HashSet<long>(variableIds.Distinct());
|
||||
if (idSet.Count == 0)
|
||||
return;
|
||||
|
||||
// 批量移除时只扫描一次缓存 Key,避免变量数较多时重复遍历整个缓存。
|
||||
foreach (var key in VariableCompiledCache.Keys)
|
||||
{
|
||||
var separatorIndex = key.IndexOf(':');
|
||||
if (separatorIndex <= 0)
|
||||
continue;
|
||||
if (long.TryParse(key.Substring(0, separatorIndex), NumberStyles.None, CultureInfo.InvariantCulture, out var variableId) && idSet.Contains(variableId))
|
||||
{
|
||||
VariableCompiledCache.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空全部变量表达式编译缓存。仅用于风控缓存全量刷新场景。
|
||||
/// </summary>
|
||||
public static void ClearVariableCompiledCache()
|
||||
{
|
||||
VariableCompiledCache.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数值和日期类型支持的比较操作符。
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ComparableOperators = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"gt", "lt", "gte", "lte", "eq", "ne", "between", "notBetween"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 布尔类型支持的判断操作符。
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> BooleanOperators = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"isTrue", "isFalse"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 执行一组结构化条件。当前规则条件之间按 AND 关系处理,任一条件不满足则整条规则不触发。
|
||||
/// </summary>
|
||||
public static StructuredRuleExecuteResult Execute(
|
||||
IReadOnlyList<RuleCondition> conditions,
|
||||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||||
RiskContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (conditions == null || conditions.Count == 0)
|
||||
return StructuredRuleExecuteResult.Fail("公式条件列表不能为空");
|
||||
if (variables == null)
|
||||
return StructuredRuleExecuteResult.Fail("变量列表不能为空");
|
||||
|
||||
var triggerMessages = new List<string>();
|
||||
for (var i = 0; i < conditions.Count; i++)
|
||||
{
|
||||
var conditionResult = ExecuteCondition(conditions[i], variables, context, i + 1);
|
||||
if (!conditionResult.Success)
|
||||
return conditionResult;
|
||||
// 结构化规则当前按 AND 执行,任一条件不命中即可短路返回,避免后续变量表达式继续查库。
|
||||
if (!conditionResult.Triggered)
|
||||
return StructuredRuleExecuteResult.Ok(false);
|
||||
if (!string.IsNullOrWhiteSpace(conditionResult.Message))
|
||||
triggerMessages.Add(conditionResult.Message);
|
||||
}
|
||||
|
||||
// 所有条件都命中时,合并每个条件的实际值/阈值说明,供最终风控结果展示。
|
||||
return StructuredRuleExecuteResult.Ok(true, string.Join(";", triggerMessages));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StructuredRuleExecuteResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行单个结构化条件,负责按变量数据类型分派到布尔、普通比较或区间比较逻辑。
|
||||
/// </summary>
|
||||
private static StructuredRuleExecuteResult ExecuteCondition(
|
||||
RuleCondition condition,
|
||||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||||
RiskContext context,
|
||||
int conditionIndex)
|
||||
{
|
||||
var label = $"条件{conditionIndex}";
|
||||
if (condition == null)
|
||||
return StructuredRuleExecuteResult.Fail($"{label}不能为空");
|
||||
if (!variables.TryGetValue(condition.VariableId, out var variable))
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:变量 ID '{condition.VariableId}' 不存在");
|
||||
|
||||
// 布尔变量没有阈值概念,只能走 isTrue/isFalse 分支,避免被后续数值/日期比较逻辑误处理。
|
||||
if (variable.DataType == RiskVariableDataType.Boolean)
|
||||
return ExecuteBooleanCondition(condition, variable, context, label);
|
||||
|
||||
if (variable.DataType != RiskVariableDataType.Numeric && variable.DataType != RiskVariableDataType.Date)
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:不支持的数据类型");
|
||||
if (!ComparableOperators.Contains(condition.Operator))
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于{GetDataTypeName(variable.DataType)}类型变量");
|
||||
|
||||
// 左侧条件变量统一先执行并转换成声明的数据类型;后续普通比较和区间比较都复用这个结果。
|
||||
var left = GetVariableValue(variable, variable.DataType, context, label, "条件变量");
|
||||
if (!left.Success)
|
||||
return StructuredRuleExecuteResult.Fail(left.ErrorMessage);
|
||||
|
||||
// 区间操作符使用下限/上限字段,不能继续走普通阈值字段。
|
||||
if (condition.Operator == "between" || condition.Operator == "notBetween")
|
||||
return ExecuteRangeCondition(condition, variable, left, variables, context, label);
|
||||
|
||||
var rangeFieldCheck = EnsureNoRangeFields(condition, label);
|
||||
if (!rangeFieldCheck.Success)
|
||||
return rangeFieldCheck;
|
||||
|
||||
var right = GetThresholdValue(
|
||||
condition.ThresholdType,
|
||||
condition.Value,
|
||||
condition.ThresholdVariableId,
|
||||
variable,
|
||||
variables,
|
||||
context,
|
||||
label,
|
||||
"阈值");
|
||||
if (!right.Success)
|
||||
return StructuredRuleExecuteResult.Fail(right.ErrorMessage);
|
||||
|
||||
var compareResult = Compare(left.Value, right.Value, variable.DataType, condition.Operator, label);
|
||||
if (!compareResult.Success)
|
||||
return compareResult;
|
||||
|
||||
return StructuredRuleExecuteResult.Ok(
|
||||
compareResult.Triggered,
|
||||
// 未命中时不构造提示,减少字符串拼接开销;只有真正触发才展示实际值和阈值。
|
||||
compareResult.Triggered ? BuildCompareMessage(variable, left, right, condition.Operator) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行布尔条件。布尔变量只允许 isTrue/isFalse,不允许配置固定值、变量阈值或区间字段。
|
||||
/// </summary>
|
||||
private static StructuredRuleExecuteResult ExecuteBooleanCondition(
|
||||
RuleCondition condition,
|
||||
glms_risk_variable variable,
|
||||
RiskContext context,
|
||||
string label)
|
||||
{
|
||||
if (!BooleanOperators.Contains(condition.Operator))
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于布尔类型变量");
|
||||
var thresholdFieldCheck = EnsureNoThresholdFields(condition, label);
|
||||
if (!thresholdFieldCheck.Success)
|
||||
return thresholdFieldCheck;
|
||||
|
||||
var value = GetVariableValue(variable, RiskVariableDataType.Boolean, context, label, "条件变量");
|
||||
if (!value.Success)
|
||||
return StructuredRuleExecuteResult.Fail(value.ErrorMessage);
|
||||
|
||||
var expected = condition.Operator == "isTrue";
|
||||
var triggered = (bool)value.Value == expected;
|
||||
return StructuredRuleExecuteResult.Ok(
|
||||
triggered,
|
||||
triggered ? BuildBooleanMessage(variable, value, expected) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行区间条件。下限和上限可以分别来自固定值或变量,并根据开闭区间转换为对应比较操作符。
|
||||
/// </summary>
|
||||
private static StructuredRuleExecuteResult ExecuteRangeCondition(
|
||||
RuleCondition condition,
|
||||
glms_risk_variable variable,
|
||||
ValueExecuteResult left,
|
||||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||||
RiskContext context,
|
||||
string label)
|
||||
{
|
||||
if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue)
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:区间条件不得携带普通阈值字段");
|
||||
if (!condition.IncludeLower.HasValue || !condition.IncludeUpper.HasValue)
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:区间开闭配置不完整");
|
||||
|
||||
// 固定上下限可以提前比较顺序;变量上下限依赖运行时查询结果,留到后面按实际值比较。
|
||||
var fixedRangeCheck = ValidateFixedRangeOrder(condition, variable.DataType, label);
|
||||
if (!fixedRangeCheck.Success)
|
||||
return fixedRangeCheck;
|
||||
|
||||
var lower = GetThresholdValue(condition.LowerThresholdType, condition.LowerValue, condition.LowerThresholdVariableId, variable, variables, context, label, "下限");
|
||||
if (!lower.Success)
|
||||
return StructuredRuleExecuteResult.Fail(lower.ErrorMessage);
|
||||
|
||||
var upper = GetThresholdValue(condition.UpperThresholdType, condition.UpperValue, condition.UpperThresholdVariableId, variable, variables, context, label, "上限");
|
||||
if (!upper.Success)
|
||||
return StructuredRuleExecuteResult.Fail(upper.ErrorMessage);
|
||||
|
||||
// 将区间开闭配置转换为普通比较:闭区间使用 >=/<=,开区间使用 >/<。
|
||||
var lowerOperator = condition.IncludeLower == true ? "gte" : "gt";
|
||||
var upperOperator = condition.IncludeUpper == true ? "lte" : "lt";
|
||||
var lowerCompare = Compare(left.Value, lower.Value, variable.DataType, lowerOperator, label);
|
||||
if (!lowerCompare.Success)
|
||||
return lowerCompare;
|
||||
var upperCompare = Compare(left.Value, upper.Value, variable.DataType, upperOperator, label);
|
||||
if (!upperCompare.Success)
|
||||
return upperCompare;
|
||||
|
||||
var inRange = lowerCompare.Triggered && upperCompare.Triggered;
|
||||
// between 要求落在区间内,notBetween 则取反;提示同样只在命中时构造。
|
||||
var triggered = condition.Operator == "notBetween" ? !inRange : inRange;
|
||||
return StructuredRuleExecuteResult.Ok(
|
||||
triggered,
|
||||
triggered ? BuildRangeMessage(variable, left, lower, upper, condition.IncludeLower == true, condition.IncludeUpper == true, condition.Operator) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取阈值。固定阈值直接转换;变量阈值先校验类型和单位,再执行阈值变量表达式。
|
||||
/// </summary>
|
||||
private static ValueExecuteResult GetThresholdValue(
|
||||
string thresholdType,
|
||||
object fixedValue,
|
||||
long? thresholdVariableId,
|
||||
glms_risk_variable conditionVariable,
|
||||
IReadOnlyDictionary<long, glms_risk_variable> variables,
|
||||
RiskContext context,
|
||||
string label,
|
||||
string thresholdLabel)
|
||||
{
|
||||
if (string.Equals(thresholdType, "Fixed", StringComparison.Ordinal))
|
||||
{
|
||||
if (thresholdVariableId.HasValue)
|
||||
return ValueExecuteResult.Fail($"{label}:固定{thresholdLabel}不得携带变量 ID");
|
||||
var fixedResult = ConvertValue(fixedValue, conditionVariable.DataType, label, $"固定{thresholdLabel}");
|
||||
if (!fixedResult.Success)
|
||||
return ValueExecuteResult.Fail($"{fixedResult.ErrorMessage},原始值:{FormatRawValue(fixedValue)}");
|
||||
return fixedResult;
|
||||
}
|
||||
|
||||
if (string.Equals(thresholdType, "Variable", StringComparison.Ordinal))
|
||||
{
|
||||
if (HasValue(fixedValue))
|
||||
return ValueExecuteResult.Fail($"{label}:变量{thresholdLabel}不得携带固定值");
|
||||
if (!thresholdVariableId.HasValue)
|
||||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID 不能为空");
|
||||
if (!variables.TryGetValue(thresholdVariableId.Value, out var thresholdVariable))
|
||||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID '{thresholdVariableId.Value}' 不存在");
|
||||
if (thresholdVariable.DataType != conditionVariable.DataType)
|
||||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的数据类型不一致");
|
||||
// 数值变量允许配置单位;变量阈值必须与条件变量单位一致,避免“金额”和“比例”等不同口径被直接比较。
|
||||
if (conditionVariable.DataType == RiskVariableDataType.Numeric && !UnitsMatch(conditionVariable.Unit, thresholdVariable.Unit))
|
||||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的单位不一致");
|
||||
|
||||
return GetVariableValue(thresholdVariable, conditionVariable.DataType, context, label, thresholdLabel);
|
||||
}
|
||||
|
||||
return ValueExecuteResult.Fail($"{label}:{thresholdLabel}类型 '{thresholdType}' 不合法,仅支持 Fixed/Variable");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行变量取值表达式,并将表达式返回值转换为变量声明的数据类型。
|
||||
/// </summary>
|
||||
private static ValueExecuteResult GetVariableValue(
|
||||
glms_risk_variable variable,
|
||||
RiskVariableDataType dataType,
|
||||
RiskContext context,
|
||||
string label,
|
||||
string valueLabel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variable.VariableExpr))
|
||||
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})的取值表达式为空");
|
||||
|
||||
var compiled = GetCompiledVariableExpression(variable, out var errorMessage);
|
||||
if (compiled == null)
|
||||
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})编译失败:{errorMessage}");
|
||||
|
||||
object rawValue;
|
||||
try
|
||||
{
|
||||
rawValue = compiled(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})执行失败:{ex.Message}");
|
||||
}
|
||||
|
||||
// 变量表达式可选择返回 RiskVariableValueDetail:Value 参与比较,Message 只用于命中说明。
|
||||
// 普通变量表达式仍返回原始值,不需要修改历史变量配置。
|
||||
var detail = rawValue as RiskVariableValueDetail;
|
||||
var actualRawValue = detail?.Value ?? rawValue;
|
||||
var converted = ConvertValue(actualRawValue, dataType, label, valueLabel);
|
||||
if (!converted.Success)
|
||||
return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(actualRawValue)}");
|
||||
|
||||
converted.DetailMessage = detail?.Message;
|
||||
return converted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取变量表达式的编译委托。缓存 Key 绑定变量版本信息,变量内容变化后会自然生成新 Key。
|
||||
/// </summary>
|
||||
private static Func<RiskContext, object> GetCompiledVariableExpression(glms_risk_variable variable, out string errorMessage)
|
||||
{
|
||||
var cacheKey = $"{variable.id}:{variable.Version}:{variable.UpdateDate?.Ticks ?? 0}:{StringComparer.Ordinal.GetHashCode(variable.VariableExpr ?? string.Empty)}";
|
||||
if (VariableCompiledCache.TryGetValue(cacheKey, out var compiled))
|
||||
{
|
||||
errorMessage = null;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
// 变量表达式只负责取原始值,类型转换统一在结构化执行器中处理;这里缓存编译结果,避免每次执行重复 Roslyn 编译。
|
||||
compiled = RuleCompiler.CompileValueExpression(variable.VariableExpr, out errorMessage);
|
||||
if (compiled != null)
|
||||
VariableCompiledCache[cacheKey] = compiled;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按变量数据类型统一转换值。日期固定值只接受 yyyy-MM-dd,避免不同区域格式导致比较结果不一致。
|
||||
/// </summary>
|
||||
private static ValueExecuteResult ConvertValue(object value, RiskVariableDataType dataType, string label, string valueLabel)
|
||||
{
|
||||
if (!HasValue(value))
|
||||
return ValueExecuteResult.Fail($"{valueLabel}为空");
|
||||
|
||||
if (dataType == RiskVariableDataType.Numeric)
|
||||
{
|
||||
if (value is decimal decimalValue)
|
||||
return ValueExecuteResult.Ok(decimalValue);
|
||||
// 数据库字段可能返回 double/int/string/JValue,统一按 InvariantCulture 转 decimal,避免服务器区域设置影响小数点解析。
|
||||
if (decimal.TryParse(GetRawValue(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var numericValue))
|
||||
return ValueExecuteResult.Ok(numericValue);
|
||||
return ValueExecuteResult.Fail($"{valueLabel}必须为数字");
|
||||
}
|
||||
|
||||
if (dataType == RiskVariableDataType.Date)
|
||||
{
|
||||
if (value is DateTime dateValue)
|
||||
return ValueExecuteResult.Ok(dateValue.Date);
|
||||
if (DateTime.TryParseExact(GetRawValue(value), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fixedDate))
|
||||
return ValueExecuteResult.Ok(fixedDate.Date);
|
||||
return ValueExecuteResult.Fail($"{valueLabel}必须为 yyyy-MM-dd 格式的合法日期");
|
||||
}
|
||||
|
||||
if (dataType == RiskVariableDataType.Boolean)
|
||||
{
|
||||
if (value is bool boolValue)
|
||||
return ValueExecuteResult.Ok(boolValue);
|
||||
if (bool.TryParse(GetRawValue(value), out var parsedBool))
|
||||
return ValueExecuteResult.Ok(parsedBool);
|
||||
return ValueExecuteResult.Fail($"{valueLabel}必须为布尔值");
|
||||
}
|
||||
|
||||
return ValueExecuteResult.Fail($"{label}:不支持的数据类型");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 比较两个已转换为同一数据类型的值,并应用结构化规则操作符。
|
||||
/// </summary>
|
||||
private static StructuredRuleExecuteResult Compare(object left, object right, RiskVariableDataType dataType, string ruleOperator, string label)
|
||||
{
|
||||
// 前置 ConvertValue 已保证左右值类型一致,这里只做最终大小关系计算。
|
||||
var compare = dataType switch
|
||||
{
|
||||
RiskVariableDataType.Numeric => ((decimal)left).CompareTo((decimal)right),
|
||||
RiskVariableDataType.Date => ((DateTime)left).CompareTo((DateTime)right),
|
||||
_ => throw new ServiceException($"{label}:不支持的数据类型")
|
||||
};
|
||||
|
||||
var result = ruleOperator switch
|
||||
{
|
||||
"gt" => compare > 0,
|
||||
"lt" => compare < 0,
|
||||
"gte" => compare >= 0,
|
||||
"lte" => compare <= 0,
|
||||
"eq" => compare == 0,
|
||||
"ne" => compare != 0,
|
||||
_ => throw new ServiceException($"{label}:不支持的操作符'{ruleOperator}'")
|
||||
};
|
||||
|
||||
return StructuredRuleExecuteResult.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建普通比较条件的命中说明。变量表达式如果返回 RiskVariableValueDetail,会优先带出自定义明细。
|
||||
/// </summary>
|
||||
private static string BuildCompareMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult right, string ruleOperator)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
AddDetail(parts, left.DetailMessage);
|
||||
parts.Add($"{variable.VariableName}为{FormatDisplayValue(left.Value)}");
|
||||
AddDetail(parts, right.DetailMessage);
|
||||
parts.Add($"阈值为{FormatDisplayValue(right.Value)}");
|
||||
parts.Add($"比较关系:{GetOperatorName(ruleOperator)}");
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建区间条件的命中说明,包含实际值、上下限和区间开闭配置。
|
||||
/// </summary>
|
||||
private static string BuildRangeMessage(glms_risk_variable variable, ValueExecuteResult left, ValueExecuteResult lower, ValueExecuteResult upper, bool includeLower, bool includeUpper, string ruleOperator)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
AddDetail(parts, left.DetailMessage);
|
||||
parts.Add($"{variable.VariableName}为{FormatDisplayValue(left.Value)}");
|
||||
AddDetail(parts, lower.DetailMessage);
|
||||
AddDetail(parts, upper.DetailMessage);
|
||||
parts.Add($"阈值区间为{(includeLower ? "[" : "(")}{FormatDisplayValue(lower.Value)}, {FormatDisplayValue(upper.Value)}{(includeUpper ? "]" : ")")}");
|
||||
parts.Add(ruleOperator == "notBetween" ? "要求不在区间内" : "要求在区间内");
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建布尔条件的命中说明。
|
||||
/// </summary>
|
||||
private static string BuildBooleanMessage(glms_risk_variable variable, ValueExecuteResult value, bool expected)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
AddDetail(parts, value.DetailMessage);
|
||||
parts.Add($"{variable.VariableName}为{FormatDisplayValue(value.Value)}");
|
||||
parts.Add($"期望为{FormatDisplayValue(expected)}");
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加变量表达式返回的自定义明细,避免空明细污染最终提示。
|
||||
/// </summary>
|
||||
private static void AddDetail(List<string> parts, string detailMessage)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(detailMessage))
|
||||
parts.Add(detailMessage.Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验非区间条件没有携带区间字段,避免前端残留字段影响规则语义。
|
||||
/// </summary>
|
||||
private static StructuredRuleExecuteResult EnsureNoRangeFields(RuleCondition condition, string label)
|
||||
{
|
||||
if (condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue ||
|
||||
condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue ||
|
||||
condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue)
|
||||
{
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:非区间条件不得携带区间字段");
|
||||
}
|
||||
|
||||
return StructuredRuleExecuteResult.Ok(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验布尔条件没有携带阈值或区间字段,布尔判断只由变量值和 isTrue/isFalse 决定。
|
||||
/// </summary>
|
||||
private static StructuredRuleExecuteResult EnsureNoThresholdFields(RuleCondition condition, string label)
|
||||
{
|
||||
if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue ||
|
||||
condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue ||
|
||||
condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue ||
|
||||
condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue)
|
||||
{
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:布尔条件不得携带阈值字段");
|
||||
}
|
||||
|
||||
return StructuredRuleExecuteResult.Ok(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当区间上下限都是固定值时,提前校验下限不能大于上限;变量上下限留到运行时按实际值比较。
|
||||
/// </summary>
|
||||
private static StructuredRuleExecuteResult ValidateFixedRangeOrder(RuleCondition condition, RiskVariableDataType dataType, string label)
|
||||
{
|
||||
if (condition.LowerThresholdType != "Fixed" || condition.UpperThresholdType != "Fixed")
|
||||
return StructuredRuleExecuteResult.Ok(false);
|
||||
|
||||
var lower = GetRawValue(condition.LowerValue);
|
||||
var upper = GetRawValue(condition.UpperValue);
|
||||
if (dataType == RiskVariableDataType.Numeric &&
|
||||
decimal.TryParse(lower, NumberStyles.Float, CultureInfo.InvariantCulture, out var lowerNumber) &&
|
||||
decimal.TryParse(upper, NumberStyles.Float, CultureInfo.InvariantCulture, out var upperNumber) &&
|
||||
lowerNumber > upperNumber)
|
||||
{
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:下限不能大于上限");
|
||||
}
|
||||
|
||||
if (dataType == RiskVariableDataType.Date &&
|
||||
DateTime.TryParseExact(lower, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var lowerDate) &&
|
||||
DateTime.TryParseExact(upper, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var upperDate) &&
|
||||
lowerDate > upperDate)
|
||||
{
|
||||
return StructuredRuleExecuteResult.Fail($"{label}:下限不能晚于上限");
|
||||
}
|
||||
|
||||
return StructuredRuleExecuteResult.Ok(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断普通对象或 JObject/JValue 字段是否有有效值,空字符串、null、undefined 都视为无值。
|
||||
/// </summary>
|
||||
private static bool HasValue(object value)
|
||||
{
|
||||
if (value == null) return false;
|
||||
if (value is JValue jsonValue)
|
||||
{
|
||||
if (jsonValue.Type == JTokenType.Null || jsonValue.Type == JTokenType.Undefined) return false;
|
||||
if (jsonValue.Type == JTokenType.String) return !string.IsNullOrWhiteSpace(jsonValue.Value<string>());
|
||||
return true;
|
||||
}
|
||||
return value is not string text || !string.IsNullOrWhiteSpace(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用固定区域性取出原始字符串,保证数字和日期解析不受服务器区域设置影响。
|
||||
/// </summary>
|
||||
private static string GetRawValue(object value)
|
||||
{
|
||||
return value is JValue jsonValue
|
||||
? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture)
|
||||
: Convert.ToString(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化错误信息中的原始值,便于区分 null、空字符串和普通值。
|
||||
/// </summary>
|
||||
private static string FormatRawValue(object value)
|
||||
{
|
||||
if (value == null)
|
||||
return "<null>";
|
||||
var rawValue = GetRawValue(value);
|
||||
return string.IsNullOrWhiteSpace(rawValue) ? "<empty>" : rawValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化命中说明中的值,日期只展示日期部分,布尔值转为中文。
|
||||
/// </summary>
|
||||
private static string FormatDisplayValue(object value)
|
||||
{
|
||||
if (value == null)
|
||||
return "<null>";
|
||||
if (value is DateTime dateValue)
|
||||
return dateValue.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
if (value is bool boolValue)
|
||||
return boolValue ? "是" : "否";
|
||||
if (value is decimal decimalValue)
|
||||
return decimalValue.ToString("0.#########", CultureInfo.InvariantCulture);
|
||||
if (value is double doubleValue)
|
||||
return doubleValue.ToString("0.#########", CultureInfo.InvariantCulture);
|
||||
if (value is float floatValue)
|
||||
return floatValue.ToString("0.#########", CultureInfo.InvariantCulture);
|
||||
return Convert.ToString(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取操作符中文说明,用于正常命中时解释实际值与阈值的关系。
|
||||
/// </summary>
|
||||
private static string GetOperatorName(string ruleOperator)
|
||||
{
|
||||
return ruleOperator switch
|
||||
{
|
||||
"gt" => "大于阈值",
|
||||
"lt" => "小于阈值",
|
||||
"gte" => "大于等于阈值",
|
||||
"lte" => "小于等于阈值",
|
||||
"eq" => "等于阈值",
|
||||
"ne" => "不等于阈值",
|
||||
_ => ruleOperator
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 比较数值变量单位。空单位按空字符串处理,前后空格不参与比较。
|
||||
/// </summary>
|
||||
private static bool UnitsMatch(string left, string right)
|
||||
{
|
||||
return string.Equals(left?.Trim() ?? string.Empty, right?.Trim() ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取数据类型中文名称,用于拼接用户可读的错误信息。
|
||||
/// </summary>
|
||||
private static string GetDataTypeName(RiskVariableDataType dataType)
|
||||
{
|
||||
return dataType switch
|
||||
{
|
||||
RiskVariableDataType.Numeric => "数值",
|
||||
RiskVariableDataType.Date => "日期",
|
||||
RiskVariableDataType.Boolean => "布尔",
|
||||
_ => "未知"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 结构化规则执行结果。Success 表示执行过程是否成功,Triggered 表示规则条件是否命中。
|
||||
/// </summary>
|
||||
public class StructuredRuleExecuteResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public bool Triggered { get; set; }
|
||||
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 正常命中时的可读说明,用于展示实际值、阈值和变量表达式返回的计算明细。
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
public static StructuredRuleExecuteResult Ok(bool triggered, string message = null)
|
||||
{
|
||||
return new StructuredRuleExecuteResult
|
||||
{
|
||||
Success = true,
|
||||
Triggered = triggered,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
public static StructuredRuleExecuteResult Fail(string errorMessage)
|
||||
{
|
||||
return new StructuredRuleExecuteResult
|
||||
{
|
||||
Success = false,
|
||||
Triggered = false,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 变量表达式可选返回模型。Value 参与规则比较,Message 用于正常命中时展示计算明细。
|
||||
/// </summary>
|
||||
public class RiskVariableValueDetail
|
||||
{
|
||||
public RiskVariableValueDetail(object value, string message)
|
||||
{
|
||||
Value = value;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
public object Value { get; }
|
||||
|
||||
public string Message { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单个值取值或转换结果,用于在内部传递转换后的值和错误信息。
|
||||
/// </summary>
|
||||
internal class ValueExecuteResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public object Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变量表达式返回的计算明细,仅用于正常命中提示,不参与比较。
|
||||
/// </summary>
|
||||
public string DetailMessage { get; set; }
|
||||
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
public static ValueExecuteResult Ok(object value)
|
||||
{
|
||||
return new ValueExecuteResult
|
||||
{
|
||||
Success = true,
|
||||
Value = value
|
||||
};
|
||||
}
|
||||
|
||||
public static ValueExecuteResult Fail(string errorMessage)
|
||||
{
|
||||
return new ValueExecuteResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+1460
-17
File diff suppressed because it is too large
Load Diff
@@ -4465,12 +4465,20 @@ namespace YLErp.Modules.RiskModule
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool QuotaCheck(ref TradeOpenResult res)
|
||||
/// <summary>
|
||||
/// 交易确认风控检查入口:执行完整风控试算(老风控 + 新风控),并把试算结果转换为交易确认结果。
|
||||
/// 返回 true 表示确认流程可以继续;返回 false 表示需要阻断、提示或审批。
|
||||
/// </summary>
|
||||
public bool CheckTradeConfirmRisk(ref TradeOpenResult res)
|
||||
{
|
||||
return QuotaCheck(ref res, false);
|
||||
return CheckTradeConfirmRisk(ref res, false);
|
||||
}
|
||||
|
||||
public bool QuotaCheck(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable<string> ignoreRiskRuleIds = null)
|
||||
/// <summary>
|
||||
/// 交易确认风控检查入口:执行完整风控试算(老风控 + 新风控)。
|
||||
/// ignoreRiskWarning/ignoreRiskRuleIds 只用于新风控“需审批”规则的二次确认,不影响老风控结果。
|
||||
/// </summary>
|
||||
public bool CheckTradeConfirmRisk(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable<string> ignoreRiskRuleIds = null)
|
||||
{
|
||||
if (res == null)
|
||||
{
|
||||
@@ -4483,9 +4491,39 @@ namespace YLErp.Modules.RiskModule
|
||||
}
|
||||
|
||||
var tradeId = (res.Trade.ParentTradeId == 0 || res.Trade.IsGroup != 0) ? res.Trade.id : res.Trade.ParentTradeId;
|
||||
var tradeNumber = DbContext.trade.Where(O => O.id == tradeId).Select(O => O.TradeNumber).FirstOrDefault();
|
||||
var trialService = new QuotaMonitorService(this);
|
||||
var quotaObj = trialService.QuotaCheck(tradeId, 1);
|
||||
var quotaObj = trialService.RunQuotaTrial(tradeId, 1);
|
||||
return ApplyQuotaCheckResult(ref res, quotaObj, ignoreRiskWarning, ignoreRiskRuleIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易确认新风控检查入口:只执行新风控试算。
|
||||
/// 用于老风控已被特批放行(ignoreMoneyCheck = true)的场景,避免老风控特批误跳过新风控。
|
||||
/// </summary>
|
||||
public bool CheckTradeConfirmNewRisk(ref TradeOpenResult res, bool ignoreRiskWarning, IEnumerable<string> ignoreRiskRuleIds = null)
|
||||
{
|
||||
if (res == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(res));
|
||||
}
|
||||
|
||||
if (res.Trade == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(res.Trade));
|
||||
}
|
||||
|
||||
var tradeId = (res.Trade.ParentTradeId == 0 || res.Trade.IsGroup != 0) ? res.Trade.id : res.Trade.ParentTradeId;
|
||||
var trialService = new QuotaMonitorService(this);
|
||||
var quotaObj = trialService.RunNewRiskTrial(tradeId, 1);
|
||||
return ApplyQuotaCheckResult(ref res, quotaObj, ignoreRiskWarning, ignoreRiskRuleIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将风控试算结果转换为交易确认结果。
|
||||
/// 这里不重新执行风控,只负责设置 RetCode、ErrorMsg、TipMsg、TrialDataId 等返回给确认流程的字段。
|
||||
/// </summary>
|
||||
private bool ApplyQuotaCheckResult(ref TradeOpenResult res, QuotaTrial quotaObj, bool ignoreRiskWarning, IEnumerable<string> ignoreRiskRuleIds = null)
|
||||
{
|
||||
if (quotaObj.TrialStatus == QuotaTrialStatusEnum.Success)//这次计算结果是通过,直接通过
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(quotaObj.AvailableForClient)) // 返回用户资金情况
|
||||
@@ -4979,7 +5017,7 @@ namespace YLErp.Modules.RiskModule
|
||||
}
|
||||
riskWarning += "风险预警: " + quotaTrial.RiskWarningDetails;
|
||||
}
|
||||
log.risk_warning = riskWarning;
|
||||
log.risk_warning = TruncateRiskCheckLogText(riskWarning);
|
||||
log.limit_warning = quotaTrial.QuotaCheckDetails;
|
||||
log.remark = RiskCheckTriggerRemark;
|
||||
if (isOldRiskErrorSpecialApproval)
|
||||
@@ -5026,12 +5064,13 @@ namespace YLErp.Modules.RiskModule
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易试算
|
||||
/// 完整风控试算:先执行老风控资金/限额试算,再叠加新风控试算结果。
|
||||
/// 后续老风控下线时,调用方可以保留该入口,内部改为直接调用 RunNewRiskTrial。
|
||||
/// </summary>
|
||||
/// <param name="tradeId">交易Id,结构化交易请传主交易Id</param>
|
||||
/// <param name="trialSource">试算来源,0:手动点击试算按钮;1:确认成交时自动调用;</param>
|
||||
/// <returns></returns>
|
||||
public QuotaTrial QuotaCheck(int tradeId, int trialSource)
|
||||
public QuotaTrial RunQuotaTrial(int tradeId, int trialSource)
|
||||
{
|
||||
_logger.Info($"[限额试算] 开始执行 - tradeId: {tradeId}, trialSource: {trialSource}");
|
||||
var result = new QuotaTrial();
|
||||
@@ -5076,8 +5115,20 @@ namespace YLErp.Modules.RiskModule
|
||||
tradeList = tradeList.Where(O => !string.IsNullOrEmpty(O.UnderlyingCode) && O.TradeType != "结构化交易" && O.TradeType != "现金流交易").ToList();
|
||||
if (tradeList.Count == 0)
|
||||
{
|
||||
// 老风控没有可试算的标的时,老逻辑认为试算通过;这里仍需继续执行新风控,避免普通确认漏掉新风控检查。
|
||||
//("当前交易不需要试算");
|
||||
return new QuotaTrial() { TrialStatus = QuotaTrialStatusEnum.Success };
|
||||
result.TrialStatus = QuotaTrialStatusEnum.Success;
|
||||
result.TradeId = tradeObj.id;
|
||||
result.TradeNumber = tradeObj.TradeNumber ?? "";
|
||||
result.ClientName = tradeObj.ClientName ?? "";
|
||||
result.FundCheckDetails = fundMsg ?? "";
|
||||
result.RiskWarningDetails = riskWarningMsg ?? "";
|
||||
result.AvailableForClient = availableMsg ?? "";
|
||||
result.TrialSource = trialSource;
|
||||
result.OldRiskTrialStatus = result.TrialStatus;
|
||||
result = ApplyNewRiskTrial(tradeId, result);
|
||||
SaveQuotaTrial(result);
|
||||
return result;
|
||||
}
|
||||
string quotaWarningMsg = "", quotaMsg = "";
|
||||
bool quotaWarningStatus = true, quotaStatus = true;
|
||||
@@ -5243,8 +5294,42 @@ namespace YLErp.Modules.RiskModule
|
||||
result.AvailableForClient = availableMsg ?? "";
|
||||
result.TrialSource = trialSource;
|
||||
result.OldRiskTrialStatus = result.TrialStatus;
|
||||
_logger.Info($"[限额试算] 试算完成 - tradeId: {tradeId}, TrialStatus: {result.TrialStatus}, FundCheck: {fundStatus}, QuotaCheck: {quotaStatus}");
|
||||
// 保留老风控原始状态,随后叠加新风控结果;这样二次特批时可以区分是老风控失败还是新风控需审批。
|
||||
result = ApplyNewRiskTrial(tradeId, result);
|
||||
SaveQuotaTrial(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新风控独立试算:不执行老风控资金/限额检查,只调用新风控引擎。
|
||||
/// 用于老风控特批后的确认流程,也可作为后续老风控下线后的主试算入口。
|
||||
/// </summary>
|
||||
public QuotaTrial RunNewRiskTrial(int tradeId, int trialSource)
|
||||
{
|
||||
var result = new QuotaTrial
|
||||
{
|
||||
TradeId = tradeId,
|
||||
TrialSource = trialSource,
|
||||
TrialStatus = QuotaTrialStatusEnum.Success,
|
||||
OldRiskTrialStatus = QuotaTrialStatusEnum.Success
|
||||
};
|
||||
var tradeObj = DbContext.trade.AsNoTracking().Where(O => O.id == tradeId).FirstOrDefault();
|
||||
if (tradeObj != null)
|
||||
{
|
||||
result.TradeNumber = tradeObj.TradeNumber ?? "";
|
||||
result.ClientName = tradeObj.ClientName ?? "";
|
||||
}
|
||||
result = ApplyNewRiskTrial(tradeId, result);
|
||||
SaveQuotaTrial(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将新风控引擎结果合并到 QuotaTrial。
|
||||
/// 如果传入的是完整试算结果,则是在老风控结果基础上叠加新风控;如果传入的是新建结果,则表示只跑新风控。
|
||||
/// </summary>
|
||||
private QuotaTrial ApplyNewRiskTrial(int tradeId, QuotaTrial result)
|
||||
{
|
||||
// ===== 新风控引擎接入点(设计文档 4.6.1) =====
|
||||
try
|
||||
{
|
||||
@@ -5265,12 +5350,7 @@ namespace YLErp.Modules.RiskModule
|
||||
result.RiskWarningDetails += "[风控引擎] 规则触发:禁止\n";
|
||||
foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.Block))
|
||||
{
|
||||
result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}";
|
||||
if (!string.IsNullOrWhiteSpace(triggeredRule.Message))
|
||||
{
|
||||
result.RiskWarningDetails += $";信息:{triggeredRule.Message}";
|
||||
}
|
||||
result.RiskWarningDetails += "\n";
|
||||
result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule);
|
||||
}
|
||||
}
|
||||
if (riskResult.NeedApproval)
|
||||
@@ -5295,19 +5375,15 @@ namespace YLErp.Modules.RiskModule
|
||||
result.ApprovalRuleIds = processedRiskRuleIds;
|
||||
foreach (var triggeredRule in approvalTriggeredRules)
|
||||
{
|
||||
result.RiskWarningDetails += $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}\n";
|
||||
result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule);
|
||||
}
|
||||
}
|
||||
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 += "[风控引擎] 规则触发:提示\n";
|
||||
foreach (var triggeredRule in riskResult.TriggeredRules.Where(r => r.ControlStrategy == RiskControlStrategy.ShowTip))
|
||||
{
|
||||
result.RiskWarningDetails += "[风控引擎] 提示:" + string.Join(";", warningMessages) + "\n";
|
||||
result.RiskWarningDetails += BuildTriggeredRuleDetail(triggeredRule);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5318,9 +5394,34 @@ namespace YLErp.Modules.RiskModule
|
||||
}
|
||||
// ===== 新风控引擎接入结束 =====
|
||||
|
||||
SaveQuotaTrial(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建新风控触发规则明细。禁止、审批、提示三类规则都可能带结构化命中明细,统一格式避免不同策略展示字段不一致。
|
||||
/// </summary>
|
||||
private static string BuildTriggeredRuleDetail(TriggeredRuleInfo triggeredRule)
|
||||
{
|
||||
var detail = $"规则ID:{triggeredRule.RuleId};规则名称:{triggeredRule.RuleName};规则说明:{triggeredRule.RuleText}";
|
||||
if (!string.IsNullOrWhiteSpace(triggeredRule.Message))
|
||||
{
|
||||
detail += $";信息:{triggeredRule.Message}";
|
||||
}
|
||||
|
||||
return detail + "\n";
|
||||
}
|
||||
|
||||
private const int RiskCheckLogTextMaxLength = 500;
|
||||
|
||||
private static string TruncateRiskCheckLogText(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || text.Length <= RiskCheckLogTextMaxLength)
|
||||
return text;
|
||||
|
||||
const string suffix = "……(日志内容过长已截断)";
|
||||
return text.Substring(0, RiskCheckLogTextMaxLength - suffix.Length) + suffix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验标的白名单
|
||||
/// </summary>
|
||||
|
||||
@@ -22,6 +22,16 @@ namespace YLErp.Modules.RiskModule
|
||||
{
|
||||
}
|
||||
|
||||
private const int RiskCheckLogTextMaxLength = 500;
|
||||
|
||||
private static string TruncateRiskCheckLogText(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || text.Length <= RiskCheckLogTextMaxLength)
|
||||
return text;
|
||||
|
||||
const string suffix = "……(日志内容过长已截断)";
|
||||
return text.Substring(0, RiskCheckLogTextMaxLength - suffix.Length) + suffix;
|
||||
}
|
||||
|
||||
public SearchListResult<trade_risk_check_log> Search(SearchTradeRiskCheckLogRequest req)
|
||||
{
|
||||
@@ -153,7 +163,7 @@ namespace YLErp.Modules.RiskModule
|
||||
}
|
||||
riskWarning += "风控预警: " + quotaTrial.RiskWarningDetails;
|
||||
}
|
||||
log.risk_warning = riskWarning;
|
||||
log.risk_warning = TruncateRiskCheckLogText(riskWarning);
|
||||
log.limit_warning = quotaTrial.QuotaCheckDetails;
|
||||
log.remark = quotaTrial.Remark;
|
||||
log.create_user = UserId;
|
||||
@@ -202,9 +212,10 @@ namespace YLErp.Modules.RiskModule
|
||||
log.client_name = quotaTrial.ClientName;
|
||||
log.trader = td?.TraderName;
|
||||
log.trade_number = quotaTrial.TradeNumber;
|
||||
log.risk_warning = string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails)
|
||||
var riskWarning = string.IsNullOrWhiteSpace(quotaTrial.RiskWarningDetails)
|
||||
? $"[风控预警处理]{decision}"
|
||||
: quotaTrial.RiskWarningDetails + Environment.NewLine + $"[风控预警处理]{decision}";
|
||||
log.risk_warning = TruncateRiskCheckLogText(riskWarning);
|
||||
log.limit_warning = quotaTrial.QuotaCheckDetails;
|
||||
log.remark = string.IsNullOrWhiteSpace(quotaTrial.Remark)
|
||||
? $"风控预警处理结果:{decision}"
|
||||
|
||||
@@ -203,26 +203,38 @@ namespace YLErp.Modules.TradeModule.DealModule
|
||||
result.ErrorMsg = $"未找到客户信息:{td.ClientName}";
|
||||
return result;
|
||||
}
|
||||
if (!ignoreMoneyCheck && !continueQuotaCheck)
|
||||
if (!continueQuotaCheck)
|
||||
{
|
||||
result = CheckTradeConfirmRule(result, td, isSkipApproval);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ErrorMsg))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = CheckClientRating(result, client, td);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ErrorMsg))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var trialService = new QuotaMonitorService(this);
|
||||
if (!trialService.QuotaCheck(ref result, ignoreRiskWarning, ignoreRiskRuleIds))
|
||||
if (ignoreMoneyCheck)
|
||||
{
|
||||
return result;
|
||||
// ignoreMoneyCheck 表示老风控资金/限额检查已被特批放行,只跳过老风控;新风控仍需独立执行。
|
||||
if (!trialService.CheckTradeConfirmNewRisk(ref result, ignoreRiskWarning, ignoreRiskRuleIds))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 正常确认流程:先执行原有交易确认规则和客户评级检查,再执行完整风控(老风控 + 新风控)。
|
||||
result = CheckTradeConfirmRule(result, td, isSkipApproval);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ErrorMsg))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = CheckClientRating(result, client, td);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ErrorMsg))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!trialService.CheckTradeConfirmRisk(ref result, ignoreRiskWarning, ignoreRiskRuleIds))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
var history = "确认交易";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Qdp.Foundation.Utilities;
|
||||
using Qdp.Foundation.Utilities;
|
||||
using System.Threading.Tasks;
|
||||
using YLErp.DBModels;
|
||||
using YLErp.Modules.RiskEngine;
|
||||
@@ -14,6 +14,17 @@ namespace YLErp.Web.Controllers
|
||||
|
||||
private RiskRuleService GetRiskRuleService() => new RiskRuleService(CurUser);
|
||||
|
||||
/// <summary>
|
||||
/// 格式化批量请求上下文,限制 ID 数量以避免异常日志过长。
|
||||
/// </summary>
|
||||
private static string FormatBatchRequestContext(BatchIdsReq req)
|
||||
{
|
||||
var ids = req?.Ids?.Distinct().ToList() ?? new List<long>();
|
||||
var displayedIds = string.Join(",", ids.Take(20));
|
||||
var truncated = ids.Count > 20 ? ",...(已截断)" : string.Empty;
|
||||
return $"Count={ids.Count}, Ids={displayedIds}{truncated}";
|
||||
}
|
||||
|
||||
#region Rule Management
|
||||
|
||||
[HttpGet("risk-rules")]
|
||||
@@ -134,8 +145,8 @@ namespace YLErp.Web.Controllers
|
||||
try
|
||||
{
|
||||
var service = GetRiskRuleService();
|
||||
await Task.Run(() => service.DeleteRule(id));
|
||||
return Json(new { success = true });
|
||||
var result = await Task.Run(() => service.DeleteRule(id));
|
||||
return Json(new { success = true, data = result });
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
{
|
||||
@@ -157,8 +168,6 @@ namespace YLErp.Web.Controllers
|
||||
{
|
||||
var service = GetRiskRuleService();
|
||||
var result = await Task.Run(() => service.BatchDeleteRules(req?.Ids));
|
||||
if (!result.Success)
|
||||
return Json(new { success = false, message = result.ErrorMessage });
|
||||
return Json(new { success = true, data = result });
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
@@ -167,7 +176,7 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "批量删除风控规则");
|
||||
_logger.Error(ex, $"批量删除风控规则异常,{FormatBatchRequestContext(req)}");
|
||||
return Json(new { success = false, message = "系统异常,请联系管理员" });
|
||||
}
|
||||
}
|
||||
@@ -206,8 +215,7 @@ namespace YLErp.Web.Controllers
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
cascadedAppIds = result.CascadedApplicationIds,
|
||||
cascadedAppCount = result.CascadedApplicationIds.Count
|
||||
affectedActiveAppCount = result.AffectedActiveAppCount
|
||||
});
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
@@ -230,8 +238,6 @@ namespace YLErp.Web.Controllers
|
||||
{
|
||||
var service = GetRiskRuleService();
|
||||
var result = await Task.Run(() => service.BatchEnableRules(req?.Ids));
|
||||
if (!result.Success)
|
||||
return Json(new { success = false, message = result.ErrorMessage });
|
||||
return Json(new { success = true, data = result });
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
@@ -240,7 +246,7 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "批量启用风控规则");
|
||||
_logger.Error(ex, $"批量启用风控规则异常,{FormatBatchRequestContext(req)}");
|
||||
return Json(new { success = false, message = "系统异常,请联系管理员" });
|
||||
}
|
||||
}
|
||||
@@ -254,15 +260,7 @@ namespace YLErp.Web.Controllers
|
||||
{
|
||||
var service = GetRiskRuleService();
|
||||
var result = await Task.Run(() => service.BatchDisableRules(req?.Ids));
|
||||
if (!result.Success)
|
||||
return Json(new { success = false, message = result.ErrorMessage });
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
data = result,
|
||||
cascadedAppIds = result.CascadedApplicationIds,
|
||||
cascadedAppCount = result.CascadedApplicationIds.Count
|
||||
});
|
||||
return Json(new { success = true, data = result });
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
{
|
||||
@@ -270,7 +268,7 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "批量停用风控规则");
|
||||
_logger.Error(ex, $"批量停用风控规则异常,{FormatBatchRequestContext(req)}");
|
||||
return Json(new { success = false, message = "系统异常,请联系管理员" });
|
||||
}
|
||||
}
|
||||
@@ -486,8 +484,6 @@ namespace YLErp.Web.Controllers
|
||||
{
|
||||
var service = GetRiskRuleService();
|
||||
var result = await Task.Run(() => service.BatchEnableApplications(req?.Ids));
|
||||
if (!result.Success)
|
||||
return Json(new { success = false, message = result.ErrorMessage });
|
||||
return Json(new { success = true, data = result });
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
@@ -496,7 +492,7 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "批量启用应用配置");
|
||||
_logger.Error(ex, $"批量启用应用配置异常,{FormatBatchRequestContext(req)}");
|
||||
return Json(new { success = false, message = "系统异常,请联系管理员" });
|
||||
}
|
||||
}
|
||||
@@ -510,8 +506,6 @@ namespace YLErp.Web.Controllers
|
||||
{
|
||||
var service = GetRiskRuleService();
|
||||
var result = await Task.Run(() => service.BatchDisableApplications(req?.Ids));
|
||||
if (!result.Success)
|
||||
return Json(new { success = false, message = result.ErrorMessage });
|
||||
return Json(new { success = true, data = result });
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
@@ -520,7 +514,7 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "批量停用应用配置");
|
||||
_logger.Error(ex, $"批量停用应用配置异常,{FormatBatchRequestContext(req)}");
|
||||
return Json(new { success = false, message = "系统异常,请联系管理员" });
|
||||
}
|
||||
}
|
||||
@@ -534,8 +528,6 @@ namespace YLErp.Web.Controllers
|
||||
{
|
||||
var service = GetRiskRuleService();
|
||||
var result = await Task.Run(() => service.BatchDeleteApplications(req?.Ids));
|
||||
if (!result.Success)
|
||||
return Json(new { success = false, message = result.ErrorMessage });
|
||||
return Json(new { success = true, data = result });
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
@@ -544,7 +536,7 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "批量删除应用配置");
|
||||
_logger.Error(ex, $"批量删除应用配置异常,{FormatBatchRequestContext(req)}");
|
||||
return Json(new { success = false, message = "系统异常,请联系管理员" });
|
||||
}
|
||||
}
|
||||
@@ -665,7 +657,7 @@ namespace YLErp.Web.Controllers
|
||||
|
||||
[HttpDelete("risk-variables/{id}")]
|
||||
[MyAuthorize("风险控制-风控变量删除")]
|
||||
/// <summary>删除变量</summary>
|
||||
/// <summary>删除变量(软删除)</summary>
|
||||
public async Task<JsonResult> DeleteRiskVariable(long id)
|
||||
{
|
||||
try
|
||||
@@ -687,7 +679,7 @@ namespace YLErp.Web.Controllers
|
||||
|
||||
[HttpDelete("risk-variables/batch")]
|
||||
[MyAuthorize("风险控制-风控变量删除")]
|
||||
/// <summary>批量删除变量</summary>
|
||||
/// <summary>批量删除变量(软删除)</summary>
|
||||
public async Task<JsonResult> BatchDeleteRiskVariables([FromBody] BatchIdsReq req)
|
||||
{
|
||||
try
|
||||
@@ -702,7 +694,7 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "批量删除变量");
|
||||
_logger.Error(ex, $"批量删除变量异常,{FormatBatchRequestContext(req)}");
|
||||
return Json(new { success = false, message = "系统异常,请联系管理员" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2493,8 +2493,8 @@ namespace YLErp.Web.Controllers
|
||||
}
|
||||
var result = new TradeConfirmService(CurUser).tradeConfirm(tradeidArr, ignoreMoneyCheck, isSkipApproval, ignoreRiskWarning, ignoreRiskRuleIdArr);
|
||||
|
||||
//如果客户缺少资金而操作者有交易特批权限
|
||||
if (!ignoreMoneyCheck && !ignoreRiskWarning && result.LackOfMoney)
|
||||
//如果需要前端确认信息,触发新老风控
|
||||
if (result.LackOfMoney)
|
||||
{
|
||||
if (result.type == TradeOpenRetCode.RiskWarning.ToString())
|
||||
{
|
||||
@@ -8431,7 +8431,7 @@ namespace YLErp.Web.Controllers
|
||||
/// <returns></returns>
|
||||
public ActionResult quotaTrial(int id, int trialSource = 0)
|
||||
{
|
||||
var quotaTrial = new QuotaMonitorService(CurUser).QuotaCheck(id, trialSource);
|
||||
var quotaTrial = new QuotaMonitorService(CurUser).RunQuotaTrial(id, trialSource);
|
||||
return View(quotaTrial);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user