!23 feature/p132_74-risk-engine 风控引擎二期

Merge pull request !23 from feature/p132_74-risk-engine
This commit is contained in:
尹峰
2026-08-03 09:34:54 +00:00
committed by Gitee
48 changed files with 5468 additions and 1586 deletions
@@ -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;
@@ -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=12
-- ----------------------------
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 的 ConditionJsonRuleExpr、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 使用 tokenRuleExpr 保持原简洁公式。
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=12
-- ----------------------------
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) > 1000000001亿元)
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
};
}
}
}
+3
View File
@@ -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>操作符 tokengt/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; }
}
}
}
+171 -58
View File
@@ -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
+8
View File
@@ -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}");
}
// 变量表达式可选择返回 RiskVariableValueDetailValue 参与比较,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
};
}
}
}
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 = "确认交易";
+25 -33
View File
@@ -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 = "系统异常,请联系管理员" });
}
}
+3 -3
View File
@@ -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);
}