diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql similarity index 95% rename from Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql rename to Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql index b971458f..07c2d87c 100644 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/prod.sql +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/create_risk_engine_table.sql @@ -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', @@ -79,6 +77,4 @@ CREATE TABLE `glms_risk_variable` ( `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; +) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '风控变量池定义表' ROW_FORMAT = Dynamic; \ No newline at end of file diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_appconfig.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_appconfig.sql similarity index 100% rename from Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_appconfig.sql rename to Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_appconfig.sql diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_sys_menu.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_sys_menu.sql new file mode 100644 index 00000000..b7dd81ce --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/insert_risk_engine_sys_menu.sql @@ -0,0 +1,55 @@ +-- 异常交易监控页面 - 操作权限(按钮)数据 +-- 依赖:风险控制目录(name='风险控制',type=0)必须已存在 +-- 对应 FunctionRight.xml 风险控制下风控相关操作权限(共16个按钮) +-- type: 0=目录 1=菜单 2=按钮 +-- 注意:以下 sys_menu 和 sys_role_menu 两个 INSERT 依赖用户变量 @menu_id,必须在同一个 Session 中执行 +-- ============================================================ + +-- ---------------------------- +-- 3.1. sys_menu 页面+按钮(type=1,2) +-- ---------------------------- +SELECT IFNULL(MAX(id), 0) INTO @menu_id FROM `yltrs_admin`.`sys_menu`; +SELECT id INTO @risk_control_id FROM `yltrs_admin`.`sys_menu` WHERE `name` = '风险控制' AND `type` = 0 LIMIT 1; + +INSERT INTO `yltrs_admin`.`sys_menu` (`id`, `pid`, `pids`, `name`, `code`, `type`, `icon`, `router`, `component`, `permission`, `application`, `open_type`, `visible`, `link`, `redirect`, `weight`, `sort`, `remark`, `status`, `create_time`, `create_user`, `update_time`, `update_user`) VALUES +(@menu_id + 1, @risk_control_id, NULL, '异常交易监控', NULL, 1, NULL, NULL, NULL, '异常交易监控', NULL, NULL, NULL, NULL, NULL, NULL, 0, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 2, @menu_id + 1, NULL, '风控规则查看', NULL, 2, NULL, NULL, NULL, '风控规则查看', NULL, NULL, NULL, NULL, NULL, NULL, 1, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 3, @menu_id + 1, NULL, '风控规则新增', NULL, 2, NULL, NULL, NULL, '风控规则新增', NULL, NULL, NULL, NULL, NULL, NULL, 2, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 4, @menu_id + 1, NULL, '风控规则编辑', NULL, 2, NULL, NULL, NULL, '风控规则编辑', NULL, NULL, NULL, NULL, NULL, NULL, 3, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 5, @menu_id + 1, NULL, '风控规则删除', NULL, 2, NULL, NULL, NULL, '风控规则删除', NULL, NULL, NULL, NULL, NULL, NULL, 4, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 6, @menu_id + 1, NULL, '风控规则启停', NULL, 2, NULL, NULL, NULL, '风控规则启停', NULL, NULL, NULL, NULL, NULL, NULL, 5, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 7, @menu_id + 1, NULL, '风控应用查看', NULL, 2, NULL, NULL, NULL, '风控应用查看', NULL, NULL, NULL, NULL, NULL, NULL, 6, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 8, @menu_id + 1, NULL, '风控应用新增', NULL, 2, NULL, NULL, NULL, '风控应用新增', NULL, NULL, NULL, NULL, NULL, NULL, 7, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 9, @menu_id + 1, NULL, '风控应用编辑', NULL, 2, NULL, NULL, NULL, '风控应用编辑', NULL, NULL, NULL, NULL, NULL, NULL, 8, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 10, @menu_id + 1, NULL, '风控应用删除', NULL, 2, NULL, NULL, NULL, '风控应用删除', NULL, NULL, NULL, NULL, NULL, NULL, 9, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 11, @menu_id + 1, NULL, '风控应用启停', NULL, 2, NULL, NULL, NULL, '风控应用启停', NULL, NULL, NULL, NULL, NULL, NULL, 10, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 12, @menu_id + 1, NULL, '风控变量查看', NULL, 2, NULL, NULL, NULL, '风控变量查看', NULL, NULL, NULL, NULL, NULL, NULL, 11, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 13, @menu_id + 1, NULL, '风控变量新增', NULL, 2, NULL, NULL, NULL, '风控变量新增', NULL, NULL, NULL, NULL, NULL, NULL, 12, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 14, @menu_id + 1, NULL, '风控变量编辑', NULL, 2, NULL, NULL, NULL, '风控变量编辑', NULL, NULL, NULL, NULL, NULL, NULL, 13, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 15, @menu_id + 1, NULL, '风控变量删除', NULL, 2, NULL, NULL, NULL, '风控变量删除', NULL, NULL, NULL, NULL, NULL, NULL, 14, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 16, @menu_id + 1, NULL, '风控日志查看', NULL, 2, NULL, NULL, NULL, '风控日志查看', NULL, NULL, NULL, NULL, NULL, NULL, 15, '', 0, NULL, NULL, NULL, NULL), +(@menu_id + 17, @menu_id + 1, NULL, '风控日志导出', NULL, 2, NULL, NULL, NULL, '风控日志导出', NULL, NULL, NULL, NULL, NULL, NULL, 16, '', 0, NULL, NULL, NULL, NULL); + +-- ---------------------------- +-- 3.2. sys_role_menu 角色1的按钮权限(menu_id 引用上方 sys_menu 自增 id) +-- ---------------------------- +SELECT IFNULL(MAX(id), 0) INTO @role_menu_id FROM `yltrs_admin`.`sys_role_menu`; + +INSERT INTO `yltrs_admin`.`sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES +(@role_menu_id + 1, 1, @menu_id + 1), +(@role_menu_id + 2, 1, @menu_id + 2), +(@role_menu_id + 3, 1, @menu_id + 3), +(@role_menu_id + 4, 1, @menu_id + 4), +(@role_menu_id + 5, 1, @menu_id + 5), +(@role_menu_id + 6, 1, @menu_id + 6), +(@role_menu_id + 7, 1, @menu_id + 7), +(@role_menu_id + 8, 1, @menu_id + 8), +(@role_menu_id + 9, 1, @menu_id + 9), +(@role_menu_id + 10, 1, @menu_id + 10), +(@role_menu_id + 11, 1, @menu_id + 11), +(@role_menu_id + 12, 1, @menu_id + 12), +(@role_menu_id + 13, 1, @menu_id + 13), +(@role_menu_id + 14, 1, @menu_id + 14), +(@role_menu_id + 15, 1, @menu_id + 15), +(@role_menu_id + 16, 1, @menu_id + 16), +(@role_menu_id + 17, 1, @menu_id + 17); \ No newline at end of file diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/migrate_condition_operator_tokens.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/migrate_condition_operator_tokens.sql new file mode 100644 index 00000000..7d8e479f --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/migrate_condition_operator_tokens.sql @@ -0,0 +1,31 @@ +-- ConditionJson 操作符一次性迁移(基于当前生产 seed 数据) +-- 原因:ConditionJson 改用稳定 token,避免 >、< 在接口传输中发生 HTML 转义。 +-- 仅修改规则 3、14、15、16 的 ConditionJson;RuleExpr、RuleText 和自由文本规则不变。 + +USE `yltrs_ylcms`; + +-- 执行前确认当前值,并保存查询结果作为人工备份。 +SELECT id, RuleName, ConditionJson, RuleExpr +FROM glms_risk_rule +WHERE id IN (3, 14, 15, 16) +ORDER BY id; + +START TRANSACTION; + +UPDATE glms_risk_rule +SET ConditionJson = REPLACE(ConditionJson, '"Operator":">"', '"Operator":"gt"') +WHERE id IN (3, 14, 15, 16) + AND ConditionJson LIKE '%"Operator":">"%'; + +UPDATE glms_risk_rule +SET ConditionJson = REPLACE(ConditionJson, '"Operator":"<"', '"Operator":"lt"') +WHERE id IN (3, 14, 15, 16) + AND ConditionJson LIKE '%"Operator":"<"%'; + +COMMIT; + +-- 执行后确认:ConditionJson 使用 token,RuleExpr 保持原简洁公式。 +SELECT id, RuleName, ConditionJson, RuleExpr +FROM glms_risk_rule +WHERE id IN (3, 14, 15, 16) +ORDER BY id; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/rollback_condition_operator_tokens.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/rollback_condition_operator_tokens.sql new file mode 100644 index 00000000..9655026a --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/rollback_condition_operator_tokens.sql @@ -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; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql deleted file mode 100644 index 4573b7d0..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_applications.sql +++ /dev/null @@ -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 = '接近/触发敲入敲出价'; diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_menu_risk_alert.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_menu_risk_alert.sql deleted file mode 100644 index 68a57b0d..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_menu_risk_alert.sql +++ /dev/null @@ -1,50 +0,0 @@ --- ============================================================ --- 异常交易监控页面 - 操作权限(按钮)数据 --- 依赖:异常交易监控菜单 id=1077 (type=1) 需先插入 --- 对应 FunctionRight.xml 风险控制下风控相关操作权限(共16个按钮) --- type: 0=目录 1=菜单 2=按钮 --- ============================================================ - --- ---------------------------- --- 1. sys_menu 页面+按钮(type=1,2) --- ---------------------------- -INSERT INTO `yltrs_admin`.`sys_menu` (`id`, `pid`, `pids`, `name`, `code`, `type`, `icon`, `router`, `component`, `permission`, `application`, `open_type`, `visible`, `link`, `redirect`, `weight`, `sort`, `remark`, `status`, `create_time`, `create_user`, `update_time`, `update_user`) VALUES -(1077, 99, NULL, '异常交易监控', NULL, 1, NULL, NULL, NULL, '异常交易监控', NULL, NULL, NULL, NULL, NULL, NULL, 0, '', 0, NULL, NULL, NULL, NULL), -(1078, 1077, NULL, '风控规则查看', NULL, 2, NULL, NULL, NULL, '风控规则查看', NULL, NULL, NULL, NULL, NULL, NULL, 1, '', 0, NULL, NULL, NULL, NULL), -(1079, 1077, NULL, '风控规则新增', NULL, 2, NULL, NULL, NULL, '风控规则新增', NULL, NULL, NULL, NULL, NULL, NULL, 2, '', 0, NULL, NULL, NULL, NULL), -(1080, 1077, NULL, '风控规则编辑', NULL, 2, NULL, NULL, NULL, '风控规则编辑', NULL, NULL, NULL, NULL, NULL, NULL, 3, '', 0, NULL, NULL, NULL, NULL), -(1081, 1077, NULL, '风控规则删除', NULL, 2, NULL, NULL, NULL, '风控规则删除', NULL, NULL, NULL, NULL, NULL, NULL, 4, '', 0, NULL, NULL, NULL, NULL), -(1082, 1077, NULL, '风控规则启停', NULL, 2, NULL, NULL, NULL, '风控规则启停', NULL, NULL, NULL, NULL, NULL, NULL, 5, '', 0, NULL, NULL, NULL, NULL), -(1083, 1077, NULL, '风控应用查看', NULL, 2, NULL, NULL, NULL, '风控应用查看', NULL, NULL, NULL, NULL, NULL, NULL, 6, '', 0, NULL, NULL, NULL, NULL), -(1084, 1077, NULL, '风控应用新增', NULL, 2, NULL, NULL, NULL, '风控应用新增', NULL, NULL, NULL, NULL, NULL, NULL, 7, '', 0, NULL, NULL, NULL, NULL), -(1085, 1077, NULL, '风控应用编辑', NULL, 2, NULL, NULL, NULL, '风控应用编辑', NULL, NULL, NULL, NULL, NULL, NULL, 8, '', 0, NULL, NULL, NULL, NULL), -(1086, 1077, NULL, '风控应用删除', NULL, 2, NULL, NULL, NULL, '风控应用删除', NULL, NULL, NULL, NULL, NULL, NULL, 9, '', 0, NULL, NULL, NULL, NULL), -(1087, 1077, NULL, '风控应用启停', NULL, 2, NULL, NULL, NULL, '风控应用启停', NULL, NULL, NULL, NULL, NULL, NULL, 10, '', 0, NULL, NULL, NULL, NULL), -(1088, 1077, NULL, '风控变量查看', NULL, 2, NULL, NULL, NULL, '风控变量查看', NULL, NULL, NULL, NULL, NULL, NULL, 11, '', 0, NULL, NULL, NULL, NULL), -(1089, 1077, NULL, '风控变量新增', NULL, 2, NULL, NULL, NULL, '风控变量新增', NULL, NULL, NULL, NULL, NULL, NULL, 12, '', 0, NULL, NULL, NULL, NULL), -(1090, 1077, NULL, '风控变量编辑', NULL, 2, NULL, NULL, NULL, '风控变量编辑', NULL, NULL, NULL, NULL, NULL, NULL, 13, '', 0, NULL, NULL, NULL, NULL), -(1091, 1077, NULL, '风控变量删除', NULL, 2, NULL, NULL, NULL, '风控变量删除', NULL, NULL, NULL, NULL, NULL, NULL, 14, '', 0, NULL, NULL, NULL, NULL), -(1092, 1077, NULL, '风控日志查看', NULL, 2, NULL, NULL, NULL, '风控日志查看', NULL, NULL, NULL, NULL, NULL, NULL, 15, '', 0, NULL, NULL, NULL, NULL), -(1093, 1077, NULL, '风控日志导出', NULL, 2, NULL, NULL, NULL, '风控日志导出', NULL, NULL, NULL, NULL, NULL, NULL, 16, '', 0, NULL, NULL, NULL, NULL); - --- ---------------------------- --- 2. sys_role_menu 角色1的按钮权限 --- ---------------------------- -INSERT INTO `yltrs_admin`.`sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES -(1097, 1, 1077), -(1098, 1, 1078), -(1099, 1, 1079), -(1100, 1, 1080), -(1101, 1, 1081), -(1102, 1, 1082), -(1103, 1, 1083), -(1104, 1, 1084), -(1105, 1, 1085), -(1106, 1, 1086), -(1107, 1, 1087), -(1108, 1, 1088), -(1109, 1, 1089), -(1110, 1, 1090), -(1111, 1, 1091), -(1112, 1, 1092), -(1113, 1, 1093); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_applications.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_applications.sql new file mode 100644 index 00000000..c5d396c1 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_applications.sql @@ -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'); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_rules.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_rules.sql new file mode 100644 index 00000000..a657c326 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_rules.sql @@ -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'); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql new file mode 100644 index 00000000..0801f0d0 --- /dev/null +++ b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_risk_engine_variables.sql @@ -0,0 +1,112 @@ +-- ============================================================ +-- 变量池初始数据(对应当前 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`, `Version`, `SortOrder`, `OptId`, `OptName`, `OptDate`, `UpdateOptId`, `UpdateOptName`, `UpdateDate`) VALUES +(1, + '合约名义本金', + 1, + 1, + '元', + '≥ 0', + '', + 'DbContext.trade.First(t => t.id == TradeId).StockEqvNotional', + 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()', + 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)', + 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)', + 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))', + 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', + 2, + 0, + 0, + 'SYSTEM', + '2026-06-24 15:51:47', + 1, + '初始用户', + '2026-07-16 19:23:05'); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql deleted file mode 100644 index 9751a1d9..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_rules.sql +++ /dev/null @@ -1,292 +0,0 @@ --- ============================================================ --- 规则初始数据(对应设计文档 §4.9.1 通用规则预置参考) --- 依赖:seed_variables.sql(变量池数据需先插入,ID 1~44) --- --- 变量 ID 映射(seed_variables.sql 自增): --- 1=合约名义本金 2=合约起息日 3=合约到期日 4=合约平仓日 --- 5=合约支付日 6=期初净价 7=期初全价 8=期初收益率 --- 9=期初价格 10=期末全价 11=期末价格 12=保证金利率 --- 13=保证金比例 14=客户授信额度 --- 15=上一收盘日中债估值净价 16=上一收盘日中债估值全价 17=上一收盘日中债估值收益率 --- 18=上一日收盘价 19=借贷加权费率 20=FR007 21=当前日期 --- 22=挂钩标的到期日 23=标的发行余额 --- 24=挂钩标的集中度 25=授信占用率 26=合约期限 27=Delta --- 28=Gamma 29=Vega 30=Theta 31=利息端利率 --- 32=对手方累计标的数量 33=同一标的累计名义本金 34=同一客户累计名义本金 --- 35=总持仓名义本金 --- 36=到期日是否银行间交易日 37=平仓日是否银行间交易日 38=支付日是否银行间交易日 --- 39=利息端/浮动端方向是否同向 40=关键业务要素是否一致 --- 41=多空方向为多头 42=多空方向为空头 --- 43=保证金收支方向为支付 44=保证金收支方向为收取 --- 45=期初净价偏离度 46=期初收益率偏离度 47=期初价格偏离度 --- 48=利息端利率与FR007偏离度 49=利息端利率与借贷加权费率偏离度 --- 50=执行价偏离度 51=执行价 52=客户品种最低保证金率 53=参考价格 --- --- ConditionJson 精简设计:只存 VariableId/Operator/ThresholdType/Value/ThresholdVariableId --- 展示字段(VariableName/VariableType/Unit/ThresholdVariableName)从变量接口关联获取 --- ============================================================ - - --- ============================================================ --- 插入 21 条通用规则 --- ============================================================ - --- 规则1:挂钩标的集中度超阈值(审批,全局) --- 条件:挂钩标的集中度(ID=24) > 30% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('挂钩标的集中度超阈值', - '挂钩标的集中度超过阈值(默认30%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 24, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 30 - )), - 'calc.UnderlyingConcentration > 30', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则2:挂钩标的到期日小于合约到期日(禁止,全局) --- 条件:挂钩标的到期日(ID=22) < 合约到期日(ID=3) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('挂钩标的到期日小于合约到期日', - '挂钩标的到期日早于合约到期日时禁止交易', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 22, - 'Operator', '早于', 'ThresholdType', 'Variable', - 'ThresholdVariableId', 3 - )), - 'market.UnderlyingMaturityDate < trade.ExerciseDate', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则3:名义本金超阈值(审批,账户/合约类型) --- 条件:合约名义本金(ID=1) > 100000000(1亿元) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('名义本金超阈值', - '合约名义本金超过阈值(默认1亿元)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 1, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 100000000 - )), - 'trade.StockEqvNotional > 100000000', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则4:保证金支付比例超阈值(审批,账户/标的类型) --- 条件:保证金比例(ID=13) > 50% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('保证金支付比例超阈值', - '保证金比例超过阈值(默认50%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 13, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 50 - )), - 'trade.MarginRate > 50', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则5:保证金利率偏离(审批,账户) --- 条件:保证金利率(ID=12) 不介于 [2%, 5%] -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('保证金利率偏离', - '保证金利率不在配置区间内(默认2%~5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 12, - 'Operator', '不介于', 'ThresholdType', 'Fixed', 'Value', JSON_ARRAY(2, 5), - 'IncludeLowerBound', true, 'IncludeUpperBound', true - )), - '!(client_marginrate.InitMarginRebateRate >= 2 && client_marginrate.InitMarginRebateRate <= 5)', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则6:保证金收取比例低于最低标准(审批,账户/标的类型) --- 条件:保证金比例(ID=13) < 客户品种最低保证金率(ID=52) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('保证金收取比例低于最低标准', - '保证金比例低于客户品种最低保证金率时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 13, - 'Operator', '<', 'ThresholdType', 'Variable', - 'ThresholdVariableId', 52 - )), - 'trade.MarginRate < config.MinMarginRate', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则7:起息日早于当前日期(审批,全局) --- 条件:合约起息日(ID=2) < 当前日期(ID=21) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('起息日早于当前日期', - '合约起息日早于当前日期时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 2, - 'Operator', '早于', 'ThresholdType', 'Variable', - 'ThresholdVariableId', 21 - )), - 'trade.StartDate < sys.CurrentDate', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则8:支付日为银行间交易日(审批,全局) --- 条件:支付日是否银行间交易日(ID=38) = 是 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('支付日为银行间交易日', - '支付日为银行间交易日时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 38, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - )), - 'calc.IsSettlementDateTradingDay == true', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则9:到期日为银行间交易日(审批,全局) --- 条件:到期日是否银行间交易日(ID=36) = 是 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('到期日为银行间交易日', - '到期日为银行间交易日时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 36, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - )), - 'calc.IsExerciseDateTradingDay == true', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则10:平仓日为银行间交易日(审批,全局) --- 条件:平仓日是否银行间交易日(ID=37) = 是 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('平仓日为银行间交易日', - '平仓日为银行间交易日时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 37, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - )), - 'calc.IsUnwindDateTradingDay == true', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则11:合约期限超阈值(审批,账户) --- 条件:合约期限(ID=26) > 365天 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('合约期限超阈值', - '合约期限超过阈值(默认365天)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 26, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 365 - )), - 'calc.MaturityDays > 365', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则12:债券类净价偏离(审批,全局) --- 条件:期初净价偏离度(ID=45) > 5% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('债券类净价偏离', - '期初净价与上一收盘日中债估值净价偏离度超阈值(默认5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 45, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 - )), - 'Math.Abs(swap_position.PosiNetNoFeePrice - market.CBValuationNetPrice) / market.CBValuationNetPrice * 100 > 5', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则13:债券类收益率偏离(审批,全局) --- 条件:期初收益率偏离度(ID=46) > 5% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('债券类收益率偏离', - '期初收益率与上一收盘日中债估值收益率偏离度超阈值(默认5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 46, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 - )), - 'Math.Abs(trade.InitYtm - market.CBValuationYtm) / market.CBValuationYtm * 100 > 5', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则14:非债券类价格偏离(审批,全局) --- 条件:期初价格偏离度(ID=47) > 5% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('非债券类价格偏离', - '期初价格与上一日收盘价偏离度超阈值(默认5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 47, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 - )), - 'Math.Abs(trade.SpotPrice - market.LastClosePrice) / market.LastClosePrice * 100 > 5', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则15:单一交易对手累计标的数量超阈值(审批,对手方) --- 条件:对手方累计标的数量(ID=32) > 10个 -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('单一交易对手累计标的数量超阈值', - '对手方累计标的数量超过阈值(默认10个)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 32, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 10 - )), - 'calc.CounterpartyUnderlyingCount > 10', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则16:多头支付固定端利率偏离(审批,账户) --- 条件:多空方向为多头(ID=41) AND 利息端利率与FR007偏离度(ID=48) > 3% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('多头支付固定端利率偏离', - '多头方向支付固定端场景下,利息端利率与FR007偏离度超阈值(默认3%)时触发审批', - JSON_ARRAY( - JSON_OBJECT( - 'VariableId', 41, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - ), - JSON_OBJECT( - 'VariableId', 48, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 3 - ) - ), - 'calc.IsLongDirection == true && Math.Abs(calc.InterestRate - market.FR007) / market.FR007 * 100 > 3', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则17:空头利率减点借贷加权偏离(审批,账户) --- 条件:多空方向为空头(ID=42) AND 利息端利率与借贷加权费率偏离度(ID=49) > 2% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('空头利率减点借贷加权偏离', - '空头方向场景下,利息端利率与借贷加权费率偏离度超阈值(默认2%)时触发审批', - JSON_ARRAY( - JSON_OBJECT( - 'VariableId', 42, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - ), - JSON_OBJECT( - 'VariableId', 49, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 2 - ) - ), - 'calc.IsShortDirection == true && Math.Abs(calc.InterestRate - market.BondLendingRate) / market.BondLendingRate * 100 > 2', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则18:账户授权收支方向不匹配(禁止,账户) --- 条件:保证金收支方向为支付(ID=43)(实际需结合账户授权方向判断,此处简化) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('账户授权收支方向不匹配', - '保证金收支方向与账户授权方向不匹配时禁止交易', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 43, - 'Operator', '是', 'ThresholdType', 'Fixed', 'Value', true - )), - 'calc.IsMarginPay == true', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则19:执行价偏离超阈值(审批,账户) --- 条件:执行价偏离度(ID=50) > 5% -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('执行价偏离超阈值', - '执行价与参考价格偏离度超阈值(默认5%)时触发审批', - JSON_ARRAY(JSON_OBJECT( - 'VariableId', 50, - 'Operator', '>', 'ThresholdType', 'Fixed', 'Value', 5 - )), - 'Math.Abs(trade.StrikePrice - market.ReferencePrice) / market.ReferencePrice * 100 > 5', - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则20:希腊字母限额超阈值(提示,预留接口,一期不纳入) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('希腊字母限额超阈值', - 'Delta/Gamma/Vega/Theta任一超阈值时提示(预留接口,一期不纳入)', - NULL, - NULL, - 1, 1, 0, 'SYSTEM', NOW()); - --- 规则21:接近/触发敲入敲出价(提示,预留接口,需确认具体判断逻辑) -INSERT INTO `glms_risk_rule` (`RuleName`, `RuleText`, `ConditionJson`, `RuleExpr`, `Status`, `Version`, `OptId`, `OptName`, `OptDate`) VALUES -('接近/触发敲入敲出价', - '标的价格接近敲入/敲出价时提示(预留接口,需确认具体判断逻辑)', - NULL, - NULL, - 1, 1, 0, 'SYSTEM', NOW()); diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql deleted file mode 100644 index 934a3a27..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.6.0/seed_variables.sql +++ /dev/null @@ -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()); diff --git a/UnitTestProject/Modules/RiskEngine/RuleConditionExpressionBuilderTest.cs b/UnitTestProject/Modules/RiskEngine/RuleConditionExpressionBuilderTest.cs new file mode 100644 index 00000000..e083b39f --- /dev/null +++ b/UnitTestProject/Modules/RiskEngine/RuleConditionExpressionBuilderTest.cs @@ -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(() => 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(() => 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(() => 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(() => 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 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 + }; + } + } +} diff --git a/UnitTestProject/Program.cs b/UnitTestProject/Program.cs index 557a48aa..0f768067 100644 --- a/UnitTestProject/Program.cs +++ b/UnitTestProject/Program.cs @@ -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(); diff --git a/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs b/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs index 071be483..7a5ee98e 100644 --- a/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs +++ b/YLErpDAL/Modules/RiskEngine/Dto/RuleCondition.cs @@ -1,23 +1,37 @@ namespace YLErp.Modules.RiskEngine.Dto { /// - /// 规则条件项 + /// 结构化规则条件项。Operator 使用稳定语义 token,不存储 UI 文案或 C# 操作符。 /// public class RuleCondition { /// 条件变量 ID public long VariableId { get; set; } - /// 操作符(如 Equal、GreaterThan、Between 等) + /// 操作符 token:gt/lt/gte/lte/eq/ne/between/notBetween/isTrue/isFalse public string Operator { get; set; } - /// 阈值类型(FixedValue / VariableRef) + + /// 普通比较阈值类型:Fixed / Variable public string ThresholdType { get; set; } - /// 阈值(固定值或变量引用) + /// 普通比较固定阈值 public object Value { get; set; } - /// 阈值变量 ID(当 ThresholdType 为 VariableRef 时) + /// 普通比较阈值变量 ID public long? ThresholdVariableId { get; set; } - /// 介于/不介于的下边界是否包含,默认 true - public bool IncludeLowerBound { get; set; } = true; - /// 介于/不介于的上边界是否包含,默认 true - public bool IncludeUpperBound { get; set; } = true; + + /// 区间下限阈值类型:Fixed / Variable + public string LowerThresholdType { get; set; } + /// 区间固定下限 + public object LowerValue { get; set; } + /// 区间下限变量 ID + public long? LowerThresholdVariableId { get; set; } + /// 区间上限阈值类型:Fixed / Variable + public string UpperThresholdType { get; set; } + /// 区间固定上限 + public object UpperValue { get; set; } + /// 区间上限变量 ID + public long? UpperThresholdVariableId { get; set; } + /// 是否包含下限 + public bool? IncludeLower { get; set; } + /// 是否包含上限 + public bool? IncludeUpper { get; set; } } } diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index d0abff89..92ade91b 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -1,8 +1,6 @@ using BaseOUDAL; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; @@ -208,12 +206,6 @@ namespace YLErp.Modules.RiskEngine "FUND_PAYMENT" }; - private static readonly Dictionary> ValidOperatorsByType = new Dictionary> - { - { RiskVariableDataType.Numeric, new HashSet { ">", "<", ">=", "<=", "=", "≠", "介于", "不介于" } }, - { RiskVariableDataType.Date, new HashSet { "早于", "晚于", "等于", "不早于", "不晚于", "介于", "不介于" } }, - { RiskVariableDataType.Boolean, new HashSet { "是", "否" } } - }; private static readonly Dictionary AuditOperationTypeNames = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -361,90 +353,12 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验 ConditionJson(结构化模式:变量存在性、操作符、类型匹配) + /// 校验 ConditionJson 并生成服务端期望的简洁 RuleExpr。 /// - private void ValidateConditionJson(string conditionJson) + private string ValidateConditionJson(string conditionJson) { - List conditions; - try - { - conditions = JsonConvert.DeserializeObject>(conditionJson); - } - catch - { - throw new ServiceException("公式表达式 JSON 格式不合法"); - } - - if (conditions == null || conditions.Count == 0) - throw new ServiceException("公式条件列表不能为空"); - - for (int i = 0; i < conditions.Count; i++) - { - var cond = conditions[i]; - var condLabel = $"条件{i + 1}"; - - var variableDef = DbContext.glms_risk_variable - .FirstOrDefault(v => v.id == cond.VariableId); - if (variableDef == null) - throw new ServiceException($"{condLabel}:变量 ID '{cond.VariableId}' 不存在"); - - var validOperators = ValidOperatorsByType[variableDef.DataType]; - if (!validOperators.Contains(cond.Operator)) - throw new ServiceException($"{condLabel}:操作符 '{cond.Operator}' 不适用于{GetDataTypeName(variableDef.DataType)}类型变量"); - - if (cond.ThresholdType == "Fixed") - { - if (cond.Value == null) - throw new ServiceException($"{condLabel}:固定阈值不能为空"); - - if (cond.Operator == "介于" || cond.Operator == "不介于") - { - // 介于/不介于:Value 必须为双元素数组 [下限, 上限] - if (!(cond.Value is Newtonsoft.Json.Linq.JArray arr && arr.Count == 2)) - throw new ServiceException($"{condLabel}:操作符 '{cond.Operator}' 的阈值必须为双元素数组 [下限, 上限]"); - if (variableDef.DataType == RiskVariableDataType.Numeric) - { - if (!decimal.TryParse(arr[0].ToString(), out _) || !decimal.TryParse(arr[1].ToString(), out _)) - throw new ServiceException($"{condLabel}:数值型介于/不介于的下限和上限必须为数字"); - } - if (variableDef.DataType == RiskVariableDataType.Date) - { - if (!DateTime.TryParse(arr[0].ToString(), out _) || !DateTime.TryParse(arr[1].ToString(), out _)) - throw new ServiceException($"{condLabel}:日期型介于/不介于的起始和结束日期必须为合法日期"); - } - } - else - { - if (variableDef.DataType == RiskVariableDataType.Numeric) - { - if (!decimal.TryParse(cond.Value.ToString(), out _)) - throw new ServiceException($"{condLabel}:数值型变量的阈值必须为数字"); - } - if (variableDef.DataType == RiskVariableDataType.Date) - { - if (!DateTime.TryParse(cond.Value.ToString(), out _)) - throw new ServiceException($"{condLabel}:日期型变量的阈值必须为合法日期"); - } - } - } - else if (cond.ThresholdType == "Variable") - { - if (!cond.ThresholdVariableId.HasValue) - throw new ServiceException($"{condLabel}:变量阈值引用的变量 ID 不能为空"); - var thresholdVarDef = DbContext.glms_risk_variable - .FirstOrDefault(v => v.id == cond.ThresholdVariableId.Value); - if (thresholdVarDef == null) - throw new ServiceException($"{condLabel}:阈值变量 ID '{cond.ThresholdVariableId.Value}' 不存在"); - if (thresholdVarDef.DataType != variableDef.DataType) - throw new ServiceException($"{condLabel}:阈值变量与条件变量的数据类型不一致"); - } - else - { - throw new ServiceException($"{condLabel}:阈值类型 '{cond.ThresholdType}' 不合法,仅支持 Fixed/Variable"); - } - } + return BuildRuleExprFromConditionJson(conditionJson); } - /// /// 校验 RuleExpr(自由文本模式:括号匹配) /// @@ -599,7 +513,7 @@ namespace YLErp.Modules.RiskEngine /// /// 校验规则参数(名称 + ConditionJson + RuleExpr) /// - private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr) + private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr, long? ruleId = null) { if (string.IsNullOrWhiteSpace(ruleName)) throw new ServiceException("规则名称不能为空"); @@ -611,7 +525,12 @@ namespace YLErp.Modules.RiskEngine if (!string.IsNullOrWhiteSpace(conditionJson)) { - ValidateConditionJson(conditionJson); + var expectedRuleExpr = ValidateConditionJson(conditionJson); + if (!string.Equals(expectedRuleExpr, ruleExpr, StringComparison.Ordinal)) + { + _logger.Info($"结构化规则公式一致性校验失败。RuleId={ruleId?.ToString() ?? "NEW"}; Expected={expectedRuleExpr}; Actual={ruleExpr}"); + throw new ServiceException("公式与结构化条件不一致,请刷新后重试"); + } } ValidateRuleExpr(ruleExpr); @@ -816,6 +735,9 @@ namespace YLErp.Modules.RiskEngine }; } + /// + /// 汇总逐项结果。业务失败彼此隔离,只有 Succeeded 表示实体已实际变更。 + /// private static BatchOperationResult BuildBatchResult(List items) { var result = new BatchOperationResult @@ -836,6 +758,39 @@ namespace YLErp.Modules.RiskEngine return result; } + /// + /// 记录批量业务结果。预期业务失败使用 Info,避免触发生产 Error 告警。 + /// + private void LogBatchResult(string operationName, BatchOperationResult result) + { + const int maxFailedItems = 20; + var failedItems = result.Items + .Where(item => item.Status == BatchOperationItemStatus.Failed) + .Take(maxFailedItems) + .Select(item => $"{item.Id}:{item.Code}") + .ToList(); + var failedSummary = failedItems.Any() ? string.Join(",", failedItems) : "无"; + var truncated = result.FailedCount > maxFailedItems ? ",...(已截断)" : string.Empty; + + _logger.Info($"[{operationName}] 批量操作完成。Total={result.TotalCount}, Succeeded={result.SucceededCount}, Unchanged={result.UnchangedCount}, Failed={result.FailedCount}, FailedItems={failedSummary}{truncated}"); + } + + /// + /// 数据库提交完成后刷新缓存;刷新失败仅记录 Error 告警,不将已提交操作返回为失败。 + /// + private void TryRefreshCache(Action refreshAction, string cacheName, IEnumerable affectedIds) + { + try + { + refreshAction(); + } + catch (Exception ex) + { + var ids = affectedIds?.Distinct().Take(20).ToList() ?? new List(); + _logger.Error($"{cacheName}刷新失败,AffectedIds={string.Join(",", ids)}", ex); + } + } + #endregion #region Rule Management @@ -994,7 +949,7 @@ namespace YLErp.Modules.RiskEngine if (rule.Version != req.Version) throw new ServiceException("规则已被其他用户修改,请重新加载后再编辑"); - ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); + ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr, ruleId); var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, req.RuleExpr); if (!compileResult.Success) @@ -1025,7 +980,6 @@ namespace YLErp.Modules.RiskEngine { var rule = GetRuleOrThrow(ruleId); - var activeCount = DbContext.glms_risk_rule_application .Where(a => a.Status == RiskRuleStatus.Active) .Count(RuleIdsMatchExpr(ruleId)); @@ -1206,12 +1160,16 @@ namespace YLErp.Modules.RiskEngine if (changedIds.Any()) { + // 所有成功项一次原子提交;提交完成后,缓存失败仅告警,不改变数据库成功结果。 SaveChangesWithConcurrencyCheck(); - foreach (var id in changedIds) - RiskEngineService.GetInstance().RefreshOneRuleCache(id); + TryRefreshCache( + () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), + "风控规则缓存", changedIds); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量删除规则", result); + return result; } /// @@ -1260,12 +1218,16 @@ namespace YLErp.Modules.RiskEngine if (changedIds.Any()) { + // 仅成功启用项参与同一次提交和审计,编译失败不影响其他规则。 SaveChangesWithConcurrencyCheck(); - foreach (var id in changedIds) - RiskEngineService.GetInstance().RefreshOneRuleCache(id); + TryRefreshCache( + () => changedIds.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), + "风控规则缓存", changedIds); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量启用规则", result); + return result; } /// @@ -1309,6 +1271,7 @@ namespace YLErp.Modules.RiskEngine items.Add(BatchItem(id, BatchOperationItemStatus.Succeeded, "DISABLED", "规则已停用", cascadesByRule[id])); } + // 多条规则可能关联同一应用;实际状态修改、审计和顶层级联结果均按应用 ID 去重。 var cascadedIds = items.SelectMany(i => i.CascadedApplicationIds).Distinct().ToHashSet(); foreach (var app in activeApps.Where(a => cascadedIds.Contains(a.id))) { @@ -1323,14 +1286,19 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { + // 规则及其级联应用在同一次数据库提交中生效。 SaveChangesWithConcurrencyCheck(); - foreach (var id in activeRules) - RiskEngineService.GetInstance().RefreshOneRuleCache(id); + TryRefreshCache( + () => activeRules.ForEach(id => RiskEngineService.GetInstance().RefreshOneRuleCache(id)), + "风控规则缓存", activeRules); if (cascadedIds.Any()) - RiskEngineService.GetInstance().RefreshApplication(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", cascadedIds); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量停用规则", result); + return result; } /// @@ -1659,9 +1627,12 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshApplication(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量删除应用配置", result); + return result; } /// @@ -1730,6 +1701,7 @@ namespace YLErp.Modules.RiskEngine items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "NOT_FOUND", "应用配置不存在")); continue; } + // Deleted 是终态,不能通过批量启用恢复,避免已删除配置重新参与风控执行。 if (app.Status == RiskRuleStatus.Deleted) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "DELETED_ENTITY", "已删除应用不能启用")); @@ -1761,9 +1733,12 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshApplication(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量启用应用配置", result); + return result; } /// @@ -1807,210 +1782,47 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { SaveChangesWithConcurrencyCheck(); - RiskEngineService.GetInstance().RefreshApplication(); + TryRefreshCache(() => RiskEngineService.GetInstance().RefreshApplication(), + "风控应用缓存", items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量停用应用配置", result); + return result; } #endregion /// - /// 查询 ConditionJson 中引用了指定变量 ID 的规则(仅结构化模式) - /// 精确匹配 "VariableId":{id} 或 "ThresholdVariableId":{id},\b 防止 id=1 误匹配 id=10 + /// 查询 ConditionJson 中四类变量引用字段命中指定变量 ID 的规则(仅结构化模式)。 /// private List GetRulesByVariableId(long variableId) { var allRules = DbContext.glms_risk_rule - .Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null) + .Where(r => r.Status != RiskRuleStatus.Deleted && r.ConditionJson != null && r.ConditionJson != "") .ToList(); - if (allRules.Count == 0) return allRules; - - var idPattern = $@"""VariableId""\s*:\s*{variableId}\b"; - var thresholdPattern = $@"""ThresholdVariableId""\s*:\s*{variableId}\b"; - - return allRules.Where(r => - Regex.IsMatch(r.ConditionJson, idPattern) || - Regex.IsMatch(r.ConditionJson, thresholdPattern) - ).ToList(); + return allRules + .Where(rule => RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, variableId)) + .ToList(); } /// - /// 根据结构化 ConditionJson 和当前变量池定义重新生成可执行 RuleExpr。 - /// ConditionJson 是结构化规则的事实来源,避免通过字符串 Replace 级联修改表达式。 + /// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。 /// private string BuildRuleExprFromConditionJson(string conditionJson) { - List conditions; - try - { - conditions = JsonConvert.DeserializeObject>(conditionJson); - } - catch - { - throw new ServiceException("公式表达式 JSON 格式不合法"); - } - - if (conditions == null || conditions.Count == 0) - throw new ServiceException("公式条件列表不能为空"); - - var variableIds = conditions - .Select(c => c.VariableId) - .Concat(conditions.Where(c => c.ThresholdVariableId.HasValue) - .Select(c => c.ThresholdVariableId.Value)) - .Distinct() - .ToList(); - + var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); + var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList(); var variables = DbContext.glms_risk_variable - .Where(v => variableIds.Contains(v.id)) + .Where(variable => variableIds.Contains(variable.id)) .ToList() - .ToDictionary(v => (long)v.id); + .ToDictionary(variable => (long)variable.id); var missingVariableIds = variableIds.Where(id => !variables.ContainsKey(id)).ToList(); if (missingVariableIds.Any()) throw new ServiceException($"公式引用的变量不存在:{string.Join(",", missingVariableIds)}"); - var conditionExpressions = conditions - .Select((condition, index) => BuildConditionExpression(condition, variables, index + 1)) - .ToList(); - - return string.Join(" && ", conditionExpressions.Select(expr => $"({expr})")); - } - - private string BuildConditionExpression( - RuleCondition condition, - IReadOnlyDictionary variables, - int conditionIndex) - { - var variable = variables[condition.VariableId]; - if (string.IsNullOrWhiteSpace(variable.VariableExpr)) - throw new ServiceException($"条件{conditionIndex}:变量'{variable.VariableName}'的取值表达式为空"); - - if (!ValidOperatorsByType.TryGetValue(variable.DataType, out var validOperators) || - !validOperators.Contains(condition.Operator)) - { - throw new ServiceException($"条件{conditionIndex}:操作符'{condition.Operator}'不适用于{GetDataTypeName(variable.DataType)}类型变量"); - } - - if (variable.DataType == RiskVariableDataType.Boolean) - { - return condition.Operator switch - { - "是" => $"({variable.VariableExpr}) == true", - "否" => $"({variable.VariableExpr}) == false", - _ => throw new ServiceException($"条件{conditionIndex}:不支持的布尔操作符'{condition.Operator}'") - }; - } - - var leftExpression = BuildComparableExpression(variable); - if (condition.Operator == "介于" || condition.Operator == "不介于") - { - if (!string.Equals(condition.ThresholdType, "Fixed", StringComparison.Ordinal)) - throw new ServiceException($"条件{conditionIndex}:介于/不介于暂只支持固定上下限"); - if (condition.Value is not JArray range || range.Count != 2) - throw new ServiceException($"条件{conditionIndex}:介于/不介于的阈值必须为双元素数组"); - - var lowerExpression = BuildFixedValueExpression(range[0], variable.DataType, conditionIndex); - var upperExpression = BuildFixedValueExpression(range[1], variable.DataType, conditionIndex); - var lowerOperator = condition.IncludeLowerBound ? ">=" : ">"; - var upperOperator = condition.IncludeUpperBound ? "<=" : "<"; - var rangeExpression = $"(({leftExpression}) {lowerOperator} ({lowerExpression}) && ({leftExpression}) {upperOperator} ({upperExpression}))"; - - return condition.Operator == "不介于" ? $"!{rangeExpression}" : rangeExpression; - } - - var rightExpression = BuildThresholdExpression(condition, variable.DataType, variables, conditionIndex); - var comparisonOperator = GetComparisonOperator(condition.Operator, conditionIndex); - return $"({leftExpression}) {comparisonOperator} ({rightExpression})"; - } - - private static string BuildComparableExpression(glms_risk_variable variable) - { - return variable.DataType == RiskVariableDataType.Numeric - ? $"Convert.ToDecimal(({variable.VariableExpr}))" - : variable.VariableExpr; - } - - private string BuildThresholdExpression( - RuleCondition condition, - RiskVariableDataType dataType, - IReadOnlyDictionary variables, - int conditionIndex) - { - if (string.Equals(condition.ThresholdType, "Fixed", StringComparison.Ordinal)) - return BuildFixedValueExpression(condition.Value, dataType, conditionIndex); - - if (string.Equals(condition.ThresholdType, "Variable", StringComparison.Ordinal)) - { - if (!condition.ThresholdVariableId.HasValue || - !variables.TryGetValue(condition.ThresholdVariableId.Value, out var thresholdVariable)) - { - throw new ServiceException($"条件{conditionIndex}:阈值变量不存在"); - } - - if (thresholdVariable.DataType != dataType) - throw new ServiceException($"条件{conditionIndex}:阈值变量与条件变量的数据类型不一致"); - if (string.IsNullOrWhiteSpace(thresholdVariable.VariableExpr)) - throw new ServiceException($"条件{conditionIndex}:阈值变量'{thresholdVariable.VariableName}'的取值表达式为空"); - - return BuildComparableExpression(thresholdVariable); - } - - throw new ServiceException($"条件{conditionIndex}:阈值类型'{condition.ThresholdType}'不合法,仅支持 Fixed/Variable"); - } - - private static string BuildFixedValueExpression(object value, RiskVariableDataType dataType, int conditionIndex) - { - if (value == null) - throw new ServiceException($"条件{conditionIndex}:固定阈值不能为空"); - - var rawValue = value is JValue jsonValue - ? Convert.ToString(jsonValue.Value, CultureInfo.InvariantCulture) - : Convert.ToString(value, CultureInfo.InvariantCulture); - - if (dataType == RiskVariableDataType.Numeric) - { - if (!decimal.TryParse(rawValue, NumberStyles.Number | NumberStyles.AllowExponent, - CultureInfo.InvariantCulture, out var numericValue)) - { - throw new ServiceException($"条件{conditionIndex}:数值型阈值必须为数字"); - } - - return numericValue.ToString(CultureInfo.InvariantCulture) + "m"; - } - - if (dataType == RiskVariableDataType.Date) - { - if (!DateTime.TryParse(rawValue, CultureInfo.InvariantCulture, - DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.RoundtripKind, out var dateValue) && - !DateTime.TryParse(rawValue, out dateValue)) - { - throw new ServiceException($"条件{conditionIndex}:日期型阈值必须为合法日期"); - } - - return $"new DateTime({dateValue.Ticks}L, DateTimeKind.{dateValue.Kind})"; - } - - throw new ServiceException($"条件{conditionIndex}:不支持的数据类型"); - } - - private static string GetComparisonOperator(string ruleOperator, int conditionIndex) - { - return ruleOperator switch - { - ">" => ">", - "<" => "<", - ">=" => ">=", - "<=" => "<=", - "=" => "==", - "≠" => "!=", - "早于" => "<", - "晚于" => ">", - "等于" => "==", - "不早于" => ">=", - "不晚于" => "<=", - _ => throw new ServiceException($"条件{conditionIndex}:不支持的操作符'{ruleOperator}'") - }; + return RuleConditionExpressionBuilder.Build(conditions, variables); } #region Variable Management @@ -2163,6 +1975,7 @@ namespace YLErp.Modules.RiskEngine } var oldVariableExpr = variable.VariableExpr; + var oldDataType = variable.DataType; variable.VariableName = req.VariableName; variable.Category = req.Category; @@ -2177,32 +1990,34 @@ namespace YLErp.Modules.RiskEngine variable.UpdateOptName = UserName; variable.UpdateDate = DateTime.Now; - var variableExprChanged = oldVariableExpr != req.VariableExpr; + var variableDefinitionChanged = + oldVariableExpr != req.VariableExpr || oldDataType != req.DataType; - if (variableExprChanged) + if (variableDefinitionChanged) { - // 结构化模式:通过 ConditionJson 中的 VariableId 找到受影响的规则 - var affectedRules = GetRulesByVariableId(variableId) - .Where(r => r.RuleExpr != null) - .ToList(); - - var rebuiltRuleExpressions = new Dictionary(); - foreach (var rule in affectedRules) - { - var rebuiltRuleExpr = BuildRuleExprFromConditionJson(rule.ConditionJson); - ValidateRuleExpr(rebuiltRuleExpr); - - var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rebuiltRuleExpr); - if (!compileResult.Success) - throw new ServiceException($"变量表达式变更导致规则'{rule.RuleName}'(ID:{rule.id})编译失败:{compileResult.ErrorMessage}"); - - rebuiltRuleExpressions[rule.id] = rebuiltRuleExpr; - } - + List affectedRules; using (var transaction = DbContext.Database.BeginTransaction()) { try { + // 在同一事务内完成引用发现、公式重建、编译和持久化;任一规则失败时整体回滚。 + affectedRules = GetRulesByVariableId(variableId) + .Where(r => r.RuleExpr != null) + .ToList(); + + var rebuiltRuleExpressions = new Dictionary(); + foreach (var rule in affectedRules) + { + var rebuiltRuleExpr = BuildRuleExprFromConditionJson(rule.ConditionJson); + ValidateRuleExpr(rebuiltRuleExpr); + + var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rebuiltRuleExpr); + if (!compileResult.Success) + throw new ServiceException($"变量定义变更导致规则'{rule.RuleName}'(ID:{rule.id})编译失败:{compileResult.ErrorMessage}"); + + rebuiltRuleExpressions[rule.id] = rebuiltRuleExpr; + } + foreach (var rule in affectedRules) { rule.RuleExpr = rebuiltRuleExpressions[rule.id]; @@ -2212,7 +2027,7 @@ namespace YLErp.Modules.RiskEngine rule.UpdateDate = DateTime.Now; WriteAuditLog("VAR_CASCADE_UPDATE_RULE", "RULE", rule.id, rule.RuleName, - $"因变量'{variable.VariableName}'(ID:{variableId})表达式变更,规则表达式被级联更新"); + $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,规则表达式被级联更新"); } SaveChangesWithConcurrencyCheck(); @@ -2288,9 +2103,8 @@ namespace YLErp.Modules.RiskEngine continue; } - var idPattern = $@"""VariableId""\s*:\s*{id}\b"; - var thresholdPattern = $@"""ThresholdVariableId""\s*:\s*{id}\b"; - var activeRefCount = activeRules.Count(r => Regex.IsMatch(r.ConditionJson, idPattern) || Regex.IsMatch(r.ConditionJson, thresholdPattern)); + var activeRefCount = activeRules.Count(rule => + RuleConditionExpressionBuilder.IsVariableReferenced(rule.ConditionJson, id)); if (activeRefCount > 0) { items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "ACTIVE_RULE_REFERENCE", @@ -2305,10 +2119,14 @@ namespace YLErp.Modules.RiskEngine if (items.Any(i => i.Status == BatchOperationItemStatus.Succeeded)) { + // 只有实际删除项写审计并参与一次提交;缺失和引用阻塞项不影响其他变量。 SaveChangesWithConcurrencyCheck(); - InvalidateVariableCache(); + TryRefreshCache(InvalidateVariableCache, "风控变量缓存", + items.Where(i => i.Status == BatchOperationItemStatus.Succeeded).Select(i => i.Id)); } - return BuildBatchResult(items); + var result = BuildBatchResult(items); + LogBatchResult("批量删除变量", result); + return result; } /// diff --git a/YLErpDAL/Modules/RiskEngine/RuleConditionExpressionBuilder.cs b/YLErpDAL/Modules/RiskEngine/RuleConditionExpressionBuilder.cs new file mode 100644 index 00000000..46945ca0 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/RuleConditionExpressionBuilder.cs @@ -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 +{ + /// + /// ConditionJson 契约校验和简洁 RuleExpr 生成器。 + /// + public static class RuleConditionExpressionBuilder + { + private static readonly HashSet ComparableOperators = new HashSet(StringComparer.Ordinal) + { + "gt", "lt", "gte", "lte", "eq", "ne", "between", "notBetween" + }; + + private static readonly HashSet BooleanOperators = new HashSet(StringComparer.Ordinal) + { + "isTrue", "isFalse" + }; + + private static readonly string[] VariableReferenceFields = + { + "VariableId", "ThresholdVariableId", "LowerThresholdVariableId", "UpperThresholdVariableId" + }; + + public static List DeserializeConditions(string conditionJson) + { + try + { + var conditions = JsonConvert.DeserializeObject>(conditionJson); + if (conditions == null || conditions.Count == 0) + throw new ServiceException("公式条件列表不能为空"); + return conditions; + } + catch (ServiceException) + { + throw; + } + catch + { + throw new ServiceException("公式表达式 JSON 格式不合法"); + } + } + + public static IReadOnlyCollection GetReferencedVariableIds(IEnumerable conditions) + { + return conditions + .SelectMany(condition => condition == null + ? Array.Empty() + : 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 conditions, + IReadOnlyDictionary 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 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 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 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()); + 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 => "布尔", + _ => "未知" + }; + } + } +} diff --git a/YLErpWeb/Controllers/RiskRuleController.cs b/YLErpWeb/Controllers/RiskRuleController.cs index e90a2087..b5730fd6 100644 --- a/YLErpWeb/Controllers/RiskRuleController.cs +++ b/YLErpWeb/Controllers/RiskRuleController.cs @@ -14,6 +14,17 @@ namespace YLErp.Web.Controllers private RiskRuleService GetRiskRuleService() => new RiskRuleService(CurUser); + /// + /// 格式化批量请求上下文,限制 ID 数量以避免异常日志过长。 + /// + private static string FormatBatchRequestContext(BatchIdsReq req) + { + var ids = req?.Ids?.Distinct().ToList() ?? new List(); + 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")] @@ -165,7 +176,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量删除风控规则"); + _logger.Error(ex, $"批量删除风控规则异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -236,7 +247,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量启用风控规则"); + _logger.Error(ex, $"批量启用风控规则异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -258,7 +269,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量停用风控规则"); + _logger.Error(ex, $"批量停用风控规则异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -482,7 +493,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量启用应用配置"); + _logger.Error(ex, $"批量启用应用配置异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -504,7 +515,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量停用应用配置"); + _logger.Error(ex, $"批量停用应用配置异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -526,7 +537,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量删除应用配置"); + _logger.Error(ex, $"批量删除应用配置异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } } @@ -684,7 +695,7 @@ namespace YLErp.Web.Controllers } catch (Exception ex) { - _logger.Error(ex, "批量删除变量"); + _logger.Error(ex, $"批量删除变量异常,{FormatBatchRequestContext(req)}"); return Json(new { success = false, message = "系统异常,请联系管理员" }); } }