From 6e11b17d9877465a547afe3f20ff9272907d68ed Mon Sep 17 00:00:00 2001 From: ruisu Date: Fri, 24 Jul 2026 13:41:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat=EF=BC=9A=E4=BF=AE=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpDAL/Modules/RiskEngine/测试用例.md | 1081 ++++++++++++++++++++++- 1 file changed, 1076 insertions(+), 5 deletions(-) diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index 00050f5c..7d29142a 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1313,7 +1313,987 @@ ignoreRiskRuleIds = A --- -## 12. 规则 12 查询结果排查 SQL +## 12. 规则 1 查询结果排查 SQL + +规则 1:挂钩标的集中度(本地)。用于核对同一标的在本笔及所有存续/审批中交易中的总名义本金占该标的发行余额的比例。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade,取 UnderlyingId、UnderlyingCode。 +2. 查询 trade 表中同一 UnderlyingId 的有效交易。 +3. 限定 ParentTradeId = 0,且交易状态为存续状态或“审批中”。 +4. 汇总这些交易的 StockEqvNotional,作为同一标的在本笔及所有存续/审批中交易中的总名义本金。 +5. 根据当前交易 UnderlyingCode 查 underlying_manager.ExJson 中债券 IssueSize。 +6. IssueSize 库内单位为亿,乘以 100000000 还原为元,作为该标的发行余额。 +7. 计算 总名义本金 ÷ 发行余额 × 100%,大于 30% 时命中。 +``` + +规则公式: + +```text +同一标的在本笔及所有存续/审批中交易中的总名义本金 ÷ 该标的发行余额 × 100% > 30% +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000001, +// RuleName = "挂钩标的集中度校验(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易;分子查询 trade 表同一标的存续/审批中交易 StockEqvNotional 汇总;分母查询 underlying_manager.ExJson 中债券 IssueSize(亿)。计算逻辑:同一标的总名义本金 ÷ 发行量 × 100%,发行量乘 100000000 还原为元,结果大于 30% 时触发审批。", +// RuleExpr = "Convert.ToDecimal(DbContext.trade.Where(t => t.ValidState != \"InValid\" && t.UnderlyingId == DbContext.trade.First(x => x.id == TradeId).UnderlyingId && t.ParentTradeId == 0 && (ConsTrade.NeedMarginTradeStatusList.Contains(t.TradeStatus) || t.TradeStatus == \"审批中\")).Sum(t => (double?)t.StockEqvNotional) ?? 0d) / (JsonConvert.DeserializeObject(DbContext.underlying_manager.Where(u => u.UnderlyingCode == DbContext.trade.First(x => x.id == TradeId).UnderlyingCode).Select(u => u.ExJson).FirstOrDefault()).IssueSize.Value * 100000000m) * 100m > 30m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + cur.id AS CurrentTradeId, + cur.UnderlyingId, + cur.UnderlyingCode, + + SUM(IFNULL(t.StockEqvNotional, 0)) AS TotalStockEqvNotional, + CAST(JSON_UNQUOTE(JSON_EXTRACT(um.ExJson, '$.IssueSize')) AS DECIMAL(30, 10)) AS IssueSizeYi, + CAST(JSON_UNQUOTE(JSON_EXTRACT(um.ExJson, '$.IssueSize')) AS DECIMAL(30, 10)) * 100000000 AS IssueSizeAmount, + + SUM(IFNULL(t.StockEqvNotional, 0)) + / (CAST(JSON_UNQUOTE(JSON_EXTRACT(um.ExJson, '$.IssueSize')) AS DECIMAL(30, 10)) * 100000000) + * 100 AS ConcentrationPercent, + CASE + WHEN SUM(IFNULL(t.StockEqvNotional, 0)) + / (CAST(JSON_UNQUOTE(JSON_EXTRACT(um.ExJson, '$.IssueSize')) AS DECIMAL(30, 10)) * 100000000) + * 100 > 30 THEN 1 + ELSE 0 + END AS IsGreaterThan30 +FROM trade cur +INNER JOIN trade t + ON t.UnderlyingId = cur.UnderlyingId + AND t.ValidState <> 'InValid' + AND t.ParentTradeId = 0 + AND t.TradeStatus IN ('确认成交', '平仓待复核', '提前终止拒绝', '行权待复核', '互换待复核', '审批中') +LEFT JOIN underlying_manager um + ON um.UnderlyingCode = cur.UnderlyingCode +WHERE cur.id = @TradeId +GROUP BY + cur.id, + cur.UnderlyingId, + cur.UnderlyingCode, + um.ExJson; +``` + +交易明细 SQL:用于核对分子中纳入汇总的交易明细;是否超限以汇总 SQL 的 `ConcentrationPercent` 为准。 + +```sql +SET @TradeId = 3001699; + +SELECT + cur.id AS CurrentTradeId, + cur.UnderlyingId, + cur.UnderlyingCode, + + t.id AS RelatedTradeId, + t.TradeStatus, + t.ValidState, + t.ParentTradeId, + t.StockEqvNotional +FROM trade cur +INNER JOIN trade t + ON t.UnderlyingId = cur.UnderlyingId + AND t.ValidState <> 'InValid' + AND t.ParentTradeId = 0 + AND t.TradeStatus IN ('确认成交', '平仓待复核', '提前终止拒绝', '行权待复核', '互换待复核', '审批中') +WHERE cur.id = @TradeId +ORDER BY + t.id; +``` + +--- + +## 13. 规则 2 查询结果排查 SQL + +规则 2:挂钩标的到期日小于合约到期日(本地)。用于核对当前交易的合约到期日是否晚于挂钩标的到期日。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade,取 UnderlyingCode、ExerciseDate。 +2. 根据 trade.UnderlyingCode 关联 underlying_manager。 +3. 从 underlying_manager 取 MaturityDate,作为挂钩标的到期日。 +4. 比较 underlying_manager.MaturityDate 和 trade.ExerciseDate。 +5. 当挂钩标的到期日 < 合约到期日时命中。 +``` + +规则公式: + +```text +挂钩标的到期日 < 合约到期日 +``` + +规则字段口径: + +```text +挂钩标的到期日:underlying_manager.MaturityDate +合约到期日:trade.ExerciseDate +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000002, +// RuleName = "挂钩标的到期日小于合约到期日(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 UnderlyingCode 和 ExerciseDate,ExerciseDate 对应合约到期日;通过 DbContext.underlying_manager 按 UnderlyingCode 取 MaturityDate,MaturityDate 对应挂钩标的到期日。计算逻辑:挂钩标的到期日小于合约到期日时触发禁止。", +// RuleExpr = "DbContext.underlying_manager.First(u => u.UnderlyingCode == DbContext.trade.First(t => t.id == TradeId).UnderlyingCode).MaturityDate.Value < DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.TradeType, + t.UnderlyingCode, + t.ExerciseDate AS ContractExerciseDate, + + um.id AS UnderlyingManagerId, + um.UnderlyingCode AS UnderlyingManagerCode, + um.UnderlyingName, + um.MaturityDate AS UnderlyingMaturityDate, + + CASE + WHEN um.MaturityDate < t.ExerciseDate THEN 1 + ELSE 0 + END AS IsUnderlyingMaturityLessThanContractExerciseDate +FROM trade t +LEFT JOIN underlying_manager um + ON um.UnderlyingCode = t.UnderlyingCode +WHERE t.id = @TradeId; +``` + +--- + +## 14. 规则 3 查询结果排查 SQL + +规则 3:名义本金超阈值(本地)。用于核对当前交易的开仓名义本金是否超过阈值。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade。 +2. 取 trade.OriginalStockEqvNotional,作为开仓名义本金。 +3. 当 OriginalStockEqvNotional > 100000000 时命中。 +``` + +规则公式: + +```text +开仓名义本金 > 100000000 +``` + +规则字段口径: + +```text +开仓名义本金:trade.OriginalStockEqvNotional +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000003, +// RuleName = "名义本金超阈值(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional,对应 trade 表开仓名义本金字段。计算逻辑:OriginalStockEqvNotional 大于 100000000 时触发审批。", +// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).OriginalStockEqvNotional > 100000000", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.TradeType, + t.OriginalStockEqvNotional, + CASE + WHEN t.OriginalStockEqvNotional > 100000000 THEN 1 + ELSE 0 + END AS IsOriginalStockEqvNotionalGreaterThan100Million +FROM trade t +WHERE t.id = @TradeId; +``` + +--- + +## 15. 规则 4 查询结果排查 SQL + +规则 4:保证金支付比例超阈值(本地)。用于核对当前交易预付金区域中支付方向的保证金金额占开仓名义本金的比例是否超过阈值。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade,取 OriginalStockEqvNotional,作为开仓名义本金。 +2. 查询 swap_position 中当前交易的预付金行。 +3. 预付金行口径:SwapTradeId = TradeId、IsInitial = 1、Invalid = 0、InterestMode in (5, 6)。 +4. 支付方向口径:InterestDirection = 2。 +5. 汇总 InterestPrincipalFix,作为保证金支付金额。 +6. 计算 保证金支付金额 ÷ 开仓名义本金,大于 0.5 时命中。 +``` + +规则公式: + +```text +保证金支付金额 ÷ 开仓名义本金 > 50% +``` + +规则字段口径: + +```text +保证金金额:swap_position.InterestPrincipalFix +保证金方向:swap_position.InterestDirection,1=收取,2=支付 +保证金类型:swap_position.InterestMode,5=初始预付金,6=追加预付金 +开仓名义本金:trade.OriginalStockEqvNotional +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000004, +// RuleName = "保证金支付比例超阈值(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional,作为开仓名义本金;通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 且 InterestDirection=2 的预付金支付方向记录,汇总 InterestPrincipalFix 作为保证金支付金额。计算逻辑:保证金支付金额 ÷ 开仓名义本金 大于 50% 时触发审批。", +// RuleExpr = "DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 2).Sum(p => (decimal?)p.InterestPrincipalFix).Value / Convert.ToDecimal(DbContext.trade.First(t => t.id == TradeId).OriginalStockEqvNotional) > 0.5m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +汇总 SQL: + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.OriginalStockEqvNotional, + + SUM(IFNULL(sp.InterestPrincipalFix, 0)) AS PayMarginAmount, + SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional AS PayMarginRate, + CASE + WHEN SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional > 0.5 THEN 1 + ELSE 0 + END AS IsGreaterThan50Percent +FROM trade t +LEFT JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) + AND sp.InterestDirection = 2 +WHERE t.id = @TradeId +GROUP BY + t.id, + t.OriginalStockEqvNotional; +``` + +明细 SQL:用于查看纳入保证金支付金额汇总的预付金明细;是否超限以汇总 SQL 的 `PayMarginRate` 为准。 + +```sql +SET @TradeId = 3001699; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.InterestMode, + sp.InterestDirection, + sp.InterestPrincipalFix, + sp.InterestRateDefault, + sp.HappenDate +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) + AND sp.InterestDirection = 2 +ORDER BY + sp.HappenDate, + sp.id; +``` + +--- + +## 16. 规则 5 查询结果排查 SQL + +规则 5:保证金利率偏离(本地)。用于核对当前交易预付金区域中的预付金返息率是否偏离基准值。 + +取数流程: + +```text +1. 查询 swap_position 中当前交易的预付金行。 +2. 预付金行口径:SwapTradeId = TradeId、IsInitial = 1、Invalid = 0、InterestMode in (5, 6)。 +3. 取 swap_position.InterestRateDefault,作为预付金返息率数据库原值。 +4. 数据库保存的是原值,公式计算时先和 1 比较,再乘以 100 还原为百分比偏离值。 +5. 计算 ABS((InterestRateDefault - 1) * 100),大于阈值时命中。 +``` + +规则公式: + +```text +ABS((预付金返息率 - 1) * 100) > 阈值 +``` + +规则字段口径: + +```text +预付金返息率:swap_position.InterestRateDefault +保证金类型:swap_position.InterestMode,5=初始预付金,6=追加预付金 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000005, +// RuleName = "保证金利率偏离(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 的预付金记录,取 InterestRateDefault 作为预付金返息率数据库原值。计算逻辑:数据库保存的是原值,公式中先和 1 比较,再乘以 100 还原为百分比偏离值,即 ABS((预付金返息率 - 1) * 100),结果大于阈值时触发审批。", +// RuleExpr = "Math.Abs((Convert.ToDecimal(DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6)).InterestRateDefault) - 1m) * 100m) > 阈值", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +汇总 SQL:用于查看当前交易预付金返息率偏离情况;执行前将 `@Threshold` 替换为实际阈值。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 0.5; + +SELECT + sp.SwapTradeId AS TradeId, + MAX(ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100)) AS MaxInterestRateDeviation, + CASE + WHEN MAX(ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100)) > @Threshold THEN 1 + ELSE 0 + END AS IsInterestRateDeviationGreaterThanThreshold +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) +GROUP BY + sp.SwapTradeId; +``` + +明细 SQL:用于查看每条预付金记录的返息率原值、乘以 100 后的页面口径及偏离值。 + +```sql +SET @TradeId = 3001699; +SET @Threshold = 0.5; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.InterestMode, + sp.InterestDirection, + sp.InterestPrincipalFix, + sp.InterestRateDefault AS InterestRateRawValue, + sp.InterestRateDefault * 100 AS InterestRatePercentValue, + ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100) AS InterestRateDeviation, + CASE + WHEN ABS((IFNULL(sp.InterestRateDefault, 0) - 1) * 100) > @Threshold THEN 1 + ELSE 0 + END AS IsGreaterThanThreshold, + sp.HappenDate +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) +ORDER BY + sp.HappenDate, + sp.id; +``` + +--- + +## 17. 规则 6 查询结果排查 SQL + +规则 6:保证金收取比例低于最低标准(本地)。用于核对当前交易预付金区域中收取方向的保证金金额占开仓名义本金的比例是否低于最低标准。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade,取 OriginalStockEqvNotional,作为开仓名义本金。 +2. 查询 swap_position 中当前交易的预付金行。 +3. 预付金行口径:SwapTradeId = TradeId、IsInitial = 1、Invalid = 0、InterestMode in (5, 6)。 +4. 收取方向口径:InterestDirection = 1。 +5. 汇总 InterestPrincipalFix,作为保证金收取金额。 +6. 计算 保证金收取金额 ÷ 开仓名义本金,小于 0.2 时命中。 +``` + +规则公式: + +```text +保证金收取金额 ÷ 开仓名义本金 < 20% +``` + +规则字段口径: + +```text +保证金金额:swap_position.InterestPrincipalFix +保证金方向:swap_position.InterestDirection,1=收取,2=支付 +保证金类型:swap_position.InterestMode,5=初始预付金,6=追加预付金 +开仓名义本金:trade.OriginalStockEqvNotional +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000006, +// RuleName = "保证金收取比例低于最低标准(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 OriginalStockEqvNotional,作为开仓名义本金;通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、InterestMode 为 5 或 6 且 InterestDirection=1 的预付金收取方向记录,汇总 InterestPrincipalFix 作为保证金收取金额。计算逻辑:保证金收取金额 ÷ 开仓名义本金 小于 20% 时触发审批。", +// RuleExpr = "DbContext.swap_position.Where(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && (p.InterestMode == 5 || p.InterestMode == 6) && p.InterestDirection == 1).Sum(p => (decimal?)p.InterestPrincipalFix).Value / Convert.ToDecimal(DbContext.trade.First(t => t.id == TradeId).OriginalStockEqvNotional) < 0.2m", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +汇总 SQL: + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.OriginalStockEqvNotional, + + SUM(IFNULL(sp.InterestPrincipalFix, 0)) AS ReceiveMarginAmount, + SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional AS ReceiveMarginRate, + CASE + WHEN SUM(IFNULL(sp.InterestPrincipalFix, 0)) / t.OriginalStockEqvNotional < 0.2 THEN 1 + ELSE 0 + END AS IsLessThan20Percent +FROM trade t +LEFT JOIN swap_position sp + ON sp.SwapTradeId = t.id + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) + AND sp.InterestDirection = 1 +WHERE t.id = @TradeId +GROUP BY + t.id, + t.OriginalStockEqvNotional; +``` + +明细 SQL:用于查看纳入保证金收取金额汇总的预付金明细;是否低于最低标准以汇总 SQL 的 `ReceiveMarginRate` 为准。 + +```sql +SET @TradeId = 3001699; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.InterestMode, + sp.InterestDirection, + sp.InterestPrincipalFix, + sp.InterestRateDefault, + sp.HappenDate +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.InterestMode IN (5, 6) + AND sp.InterestDirection = 1 +ORDER BY + sp.HappenDate, + sp.id; +``` + +--- + +## 18. 规则 7 查询结果排查 SQL + +规则 7:起息日早于当前日期(本地)。用于核对互换交易界面的开始日期是否早于当前日期。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade。 +2. 取 trade.StartDate,作为互换交易界面的开始日期,即起息日。 +3. 取系统当前日期 DateTime.Today。 +4. 当 StartDate 有值且 StartDate.Date < DateTime.Today 时命中。 +``` + +规则公式: + +```text +起息日 < 当前日期 +``` + +规则字段口径: + +```text +起息日:trade.StartDate,对应互换交易界面的开始日期 +当前日期:DateTime.Today +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000007, +// RuleName = "起息日早于当前日期(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate,StartDate 对应互换交易界面的开始日期,即起息日。计算逻辑:StartDate 有值且日期早于系统当天 DateTime.Today 时触发审批。", +// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date < DateTime.Today", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.TradeType, + t.TradeDate, + t.StartDate AS ValueDate, + CURDATE() AS CurrentDate, + CASE + WHEN t.StartDate IS NOT NULL AND DATE(t.StartDate) < CURDATE() THEN 1 + ELSE 0 + END AS IsStartDateLessThanCurrentDate +FROM trade t +WHERE t.id = @TradeId; +``` + +--- + +## 19. 规则 8 查询结果排查 SQL + +规则 8:支付日为银行间交易日(本地)。用于核对交易平仓窗口的支付日期是否为银行间交易日;若支付日期未落在 IB 日历的非交易日列表中,则命中。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_flow_event。 +2. 限定 SwapTradeId = TradeId、EventType = 2、DataState <> 0,取平仓事件。 +3. 取 swap_flow_event.PayDate,作为交易平仓窗口的支付日期。 +4. 根据 PayDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +5. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +6. 若 HolidayJson 不包含支付日期对应的 yyyy,MM,dd,则说明支付日期是银行间交易日,命中规则。 +``` + +规则公式: + +```text +支付日期 ∉ IB 日历非交易日 +``` + +规则字段口径: + +```text +支付日期:swap_flow_event.PayDate,对应交易平仓窗口的支付日期 +平仓事件:swap_flow_event.EventType = 2 +银行间日历:calendar.Country = 'IB' +日历年份:calendar.Year = swap_flow_event.PayDate.Year +非交易日:calendar.HolidayJson +``` + +参考页面字段: + +```cshtml + + +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000008, +// RuleName = "支付日为银行间交易日(本地)", +// RuleText = "取值字段:通过 DbContext.swap_flow_event 按 SwapTradeId=TradeId、EventType=2、DataState<>0 取平仓事件的 PayDate,PayDate 对应交易平仓窗口的支付日期;通过 DbContext.calendar 按 Country='IB' 且 Year=PayDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若支付日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明支付日期是银行间交易日,触发审批。", +// RuleExpr = "DbContext.swap_flow_event.Any(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue) && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue).OrderByDescending(e => e.id).First().PayDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.PayDate.HasValue).OrderByDescending(e => e.id).First().PayDate.Value.ToString(\"yyyy,MM,dd\"))", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + sfe.id AS SwapFlowEventId, + sfe.EventType, + sfe.DataState, + sfe.PayDate, + c.Country AS CalendarCountry, + c.Year AS CalendarYear, + DATE_FORMAT(sfe.PayDate, '%Y,%m,%d') AS PayDateText, + CASE + WHEN sfe.PayDate IS NOT NULL + AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(sfe.PayDate, '%Y,%m,%d'), '%') THEN 1 + ELSE 0 + END AS IsPayDateIBTradingDay +FROM trade t +LEFT JOIN ( + SELECT * + FROM swap_flow_event + WHERE SwapTradeId = @TradeId + AND EventType = 2 + AND DataState <> 0 + ORDER BY id DESC + LIMIT 1 +) sfe ON sfe.SwapTradeId = t.id +LEFT JOIN calendar c + ON c.Country = 'IB' + AND c.Year = YEAR(sfe.PayDate) + AND c.ValidState <> 'InValid' +WHERE t.id = @TradeId; +``` + +--- + +## 20. 规则 9 查询结果排查 SQL + +规则 9:到期日为银行间交易日(本地)。用于核对互换交易界面的到期日是否为银行间交易日;若到期日未落在 IB 日历的非交易日列表中,则命中。 + +取数流程: + +```text +1. 根据 TradeId 查当前 trade。 +2. 取 trade.ExerciseDate,作为互换交易界面的到期日。 +3. 根据 ExerciseDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +4. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +5. 若 HolidayJson 不包含到期日对应的 yyyy,MM,dd,则说明到期日是银行间交易日,命中规则。 +``` + +规则公式: + +```text +到期日 ∉ IB 日历非交易日 +``` + +规则字段口径: + +```text +到期日:trade.ExerciseDate,对应互换交易界面的到期日 +银行间日历:calendar.Country = 'IB' +日历年份:calendar.Year = trade.ExerciseDate.Year +非交易日:calendar.HolidayJson +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000009, +// RuleName = "到期日为银行间交易日(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 ExerciseDate,ExerciseDate 对应互换交易界面的到期日;通过 DbContext.calendar 按 Country='IB' 且 Year=ExerciseDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若到期日对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明到期日是银行间交易日,触发审批。", +// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.ToString(\"yyyy,MM,dd\"))", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + t.TradeType, + t.ExerciseDate AS MaturityDate, + c.Country AS CalendarCountry, + c.Year AS CalendarYear, + DATE_FORMAT(t.ExerciseDate, '%Y,%m,%d') AS MaturityDateText, + CASE + WHEN t.ExerciseDate IS NOT NULL + AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(t.ExerciseDate, '%Y,%m,%d'), '%') THEN 1 + ELSE 0 + END AS IsMaturityDateIBTradingDay +FROM trade t +LEFT JOIN calendar c + ON c.Country = 'IB' + AND c.Year = YEAR(t.ExerciseDate) + AND c.ValidState <> 'InValid' +WHERE t.id = @TradeId; +``` + +--- + +## 21. 规则 10 查询结果排查 SQL + +规则 10:平仓日为银行间交易日(本地)。用于核对交易平仓窗口的平仓日期是否为银行间交易日;若平仓日期未落在 IB 日历的非交易日列表中,则命中。 + +取数流程: + +```text +1. 根据 TradeId 查 swap_flow_event。 +2. 限定 SwapTradeId = TradeId、EventType = 2、DataState <> 0,取平仓事件。 +3. 取 swap_flow_event.UnwindDate,作为交易平仓窗口的平仓日期。 +4. 根据 UnwindDate.Value.Year 查询 calendar 表中 Country = 'IB' 的银行间日历。 +5. calendar.HolidayJson 存储该年非交易日,格式为 yyyy,MM,dd。 +6. 若 HolidayJson 不包含平仓日期对应的 yyyy,MM,dd,则说明平仓日期是银行间交易日,命中规则。 +``` + +规则公式: + +```text +平仓日期 ∉ IB 日历非交易日 +``` + +规则字段口径: + +```text +平仓日期:swap_flow_event.UnwindDate,对应交易平仓窗口的平仓日期 +平仓事件:swap_flow_event.EventType = 2 +银行间日历:calendar.Country = 'IB' +日历年份:calendar.Year = swap_flow_event.UnwindDate.Year +非交易日:calendar.HolidayJson +``` + +参考页面字段: + +```cshtml + + +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000010, +// RuleName = "平仓日为银行间交易日(本地)", +// RuleText = "取值字段:通过 DbContext.swap_flow_event 按 SwapTradeId=TradeId、EventType=2、DataState<>0 取平仓事件的 UnwindDate,UnwindDate 对应交易平仓窗口的平仓日期;通过 DbContext.calendar 按 Country='IB' 且 Year=UnwindDate.Year 取银行间日历,HolidayJson 存储该年非交易日。计算逻辑:若平仓日期对应的 yyyy,MM,dd 不存在于 IB 日历 HolidayJson 中,则说明平仓日期是银行间交易日,触发审批。", +// RuleExpr = "DbContext.swap_flow_event.Any(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue) && !DbContext.calendar.First(c => c.Country == \"IB\" && c.Year == DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue).OrderByDescending(e => e.id).First().UnwindDate.Value.Year && c.ValidState != \"InValid\").HolidayJson.Contains(DbContext.swap_flow_event.Where(e => e.SwapTradeId == TradeId && e.EventType == 2 && e.DataState != 0 && e.UnwindDate.HasValue).OrderByDescending(e => e.id).First().UnwindDate.Value.ToString(\"yyyy,MM,dd\"))", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + t.id AS TradeId, + sfe.id AS SwapFlowEventId, + sfe.EventType, + sfe.DataState, + sfe.UnwindDate, + c.Country AS CalendarCountry, + c.Year AS CalendarYear, + DATE_FORMAT(sfe.UnwindDate, '%Y,%m,%d') AS UnwindDateText, + CASE + WHEN sfe.UnwindDate IS NOT NULL + AND c.HolidayJson NOT LIKE CONCAT('%', DATE_FORMAT(sfe.UnwindDate, '%Y,%m,%d'), '%') THEN 1 + ELSE 0 + END AS IsUnwindDateIBTradingDay +FROM trade t +LEFT JOIN ( + SELECT * + FROM swap_flow_event + WHERE SwapTradeId = @TradeId + AND EventType = 2 + AND DataState <> 0 + ORDER BY id DESC + LIMIT 1 +) sfe ON sfe.SwapTradeId = t.id +LEFT JOIN calendar c + ON c.Country = 'IB' + AND c.Year = YEAR(sfe.UnwindDate) + AND c.ValidState <> 'InValid' +WHERE t.id = @TradeId; +``` + +--- + +## 22. 规则 11 查询结果排查 SQL + +规则 11:合约期限超阈值(本地)。用于核对互换交易合约期限;合约期限按起始日与到期日之间的自然日天数计算,并根据计息方式决定是否算头、算尾,超过阈值时命中。 + +取数流程: + +```text +1. 根据 TradeId 查 trade 表,取 StartDate 作为起始日,取 ExerciseDate 作为到期日。 +2. 根据 TradeId 查 trade_extend 表,取 ExtendJson 中的 InterestCalcMode 作为计息方式。 +3. InterestCalcMode 口径:00=不算头不算尾,01=不算头算尾,10=算头不算尾,11=算头算尾,缺省值=11。 +4. 先计算自然日基础天数:(到期日.Date - 起始日.Date).Days。 +5. 基础天数按“不算头算尾”口径理解;若算头则 +1,若不算尾则 -1。 +6. 计算结果大于阈值时命中。 +``` + +规则公式: + +```text +(到期日.Date - 起始日.Date).Days + 是否算头 - 是否不算尾 > 阈值 +``` + +规则字段口径: + +```text +起始日:trade.StartDate,对应互换交易界面的开始日期 +到期日:trade.ExerciseDate,对应互换交易界面的到期日期 +计息方式:trade_extend.ExtendJson.InterestCalcMode +计息方式枚举:00=不算头不算尾,01=不算头算尾,10=算头不算尾,11=算头算尾 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000011, +// RuleName = "合约期限超阈值(本地)", +// RuleText = "取值字段:通过 DbContext.trade 按 TradeId 取当前交易的 StartDate 和 ExerciseDate;通过 DbContext.trade_extend 按 TradeId 取 ExtendJson 中的 InterestCalcMode,InterestCalcMode 口径为 00=不算头不算尾、01=不算头算尾、10=算头不算尾、11=算头算尾。计算逻辑:先计算 (ExerciseDate.Date-StartDate.Date).Days 作为自然日基础天数,基础天数按不算头算尾口径理解;若 InterestCalcMode 首位为 1 则算头加 1,若末位为 0 则不算尾减 1,最终合约期限大于阈值时触发审批。", +// RuleExpr = "DbContext.trade.First(t => t.id == TradeId).StartDate.HasValue && DbContext.trade.First(t => t.id == TradeId).ExerciseDate.HasValue && ((DbContext.trade.First(t => t.id == TradeId).ExerciseDate.Value.Date - DbContext.trade.First(t => t.id == TradeId).StartDate.Value.Date).Days + (DbContext.trade_extend.First(e => e.TradeId == TradeId).ExtendObj.InterestCalcMode.StartsWith(\"1\") ? 1 : 0) - (DbContext.trade_extend.First(e => e.TradeId == TradeId).ExtendObj.InterestCalcMode.EndsWith(\"1\") ? 0 : 1)) > 阈值", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; +SET @Threshold = 365; + +SELECT + t.id AS TradeId, + t.StartDate, + t.ExerciseDate, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11') AS InterestCalcMode, + DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) AS BaseNaturalDays, + CASE + WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 + ELSE 0 + END AS CalcFirstDays, + CASE + WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 + ELSE -1 + END AS NotCalcLastDays, + DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) + + CASE + WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 + ELSE 0 + END + + CASE + WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 + ELSE -1 + END AS ContractNaturalDays, + CASE + WHEN DATEDIFF(DATE(t.ExerciseDate), DATE(t.StartDate)) + + CASE + WHEN LEFT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 1 + ELSE 0 + END + + CASE + WHEN RIGHT(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(te.ExtendJson, '$.InterestCalcMode')), '11'), 1) = '1' THEN 0 + ELSE -1 + END > @Threshold THEN 1 + ELSE 0 + END AS IsContractTermGreaterThanThreshold +FROM trade t +LEFT JOIN trade_extend te + ON te.TradeId = t.id +WHERE t.id = @TradeId; +``` + +--- + +## 23. 规则 12 查询结果排查 SQL 规则 12:债券类净价偏离(本地)。用于核对浮动支付端期初交割净价和交易日前中债估值净价;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 @@ -1403,7 +2383,7 @@ LIMIT 1; --- -## 13. 规则 13 查询结果排查 SQL +## 24. 规则 13 查询结果排查 SQL 规则 13:债券类收益率偏离(本地)。用于核对浮动支付端期初成交收益率和交易日前中债估值收益率;同一标的同一估值日存在不同可信度时,优先取 credibility=1。 @@ -1493,7 +2473,7 @@ LIMIT 1; --- -## 14. 规则 14 查询结果排查 SQL +## 22. 规则 14 查询结果排查 SQL 规则 14:非债券类价格偏离(本地)。用于核对普通收益互换页面填写的期初标的价格和交易日前上一日收盘价。 @@ -1581,7 +2561,7 @@ LIMIT 1; --- -## 15. 规则 15 查询结果排查 SQL +## 23. 规则 15 查询结果排查 SQL 规则 15:单一交易对手累计标的数量超阈值(本地)。用于核对同一交易对手实时存续持仓下的去重标的数量是否超过 10 个。 @@ -1690,7 +2670,7 @@ ORDER BY --- -## 16. 规则 16 查询结果排查 SQL +## 24. 规则 16 查询结果排查 SQL 规则 16:多头支付固定端利率偏离(本地)。用于核对利息端收入固定利息方向的点差百分比绝对值是否低于阈值。 @@ -1752,3 +2732,94 @@ FROM swap_position sp WHERE sp.SwapTradeId = @TradeId AND sp.InterestDirection = 1; ``` + +--- + +## 28. 规则 18 查询结果排查 SQL + +规则 18:账户授权收支方向不匹配(本地)。用于核对账户授权的收支方向与交易浮动端多空方向是否匹配;不符合允许场景时命中。 + +校验内容: + +```text +不符合以下场景则不通过: +1. 利息端 = 收取,且浮动端 = 空头 +2. 利息端 = 支付,且浮动端 = 多头 +``` + +取数流程: + +```text +1. 根据 TradeId 查 swap_position。 +2. 限定 IsInitial=1、Invalid=0、PosiDirection>0,取浮动端初始持仓记录。 +3. 取 swap_position.InterestDirection 作为利息端收支方向,1=收取,2=支付。 +4. 取 swap_position.PositionType 作为浮动端多空方向,1=多头,2=空头。 +5. 若不是“利息端收取且浮动端空头”,也不是“利息端支付且浮动端多头”,则命中。 +``` + +规则公式: + +```text +NOT ((InterestDirection = 1 AND PositionType = 2) OR (InterestDirection = 2 AND PositionType = 1)) +``` + +规则字段口径: + +```text +利息端收支方向:swap_position.InterestDirection,1=收取,2=支付 +浮动端多空方向:swap_position.PositionType,1=多头,2=空头 +浮动端记录:swap_position.IsInitial=1、Invalid=0、PosiDirection>0 +``` + +注释规则定义: + +```csharp +//rules.Add(new RiskRule +//{ +// Id = 1000018, +// RuleName = "账户授权收支方向不匹配(本地)", +// RuleText = "取值字段:通过 DbContext.swap_position 按 TradeId 取 IsInitial=true、Invalid=false、PosiDirection>0 的浮动端初始持仓记录;InterestDirection 表示利息端收支方向,1=收取、2=支付;PositionType 表示浮动端多空方向,1=多头、2=空头。计算逻辑:仅允许利息端=收取且浮动端=空头,或利息端=支付且浮动端=多头;其他组合触发审批。", +// RuleExpr = "!(((DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).InterestDirection == 1) && (DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).PositionType == 2)) || ((DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).InterestDirection == 2) && (DbContext.swap_position.First(p => p.SwapTradeId == TradeId && p.IsInitial && !p.Invalid && p.PosiDirection > 0).PositionType == 1)))", +// Version = 1, +// Status = RiskRuleStatus.Active, +// OptId = 0, +// OptName = "system", +// OptDate = DateTime.Now, +// UpdateOptId = 0, +// UpdateOptName = "system", +// UpdateDate = DateTime.Now +//}); +``` + +```sql +SET @TradeId = 3001699; + +SELECT + sp.id AS SwapPositionId, + sp.SwapTradeId, + sp.IsInitial, + sp.Invalid, + sp.PosiDirection, + sp.InterestDirection, + CASE sp.InterestDirection + WHEN 1 THEN '收取' + WHEN 2 THEN '支付' + ELSE '未知' + END AS InterestDirectionText, + sp.PositionType, + CASE sp.PositionType + WHEN 1 THEN '多头' + WHEN 2 THEN '空头' + ELSE '未知' + END AS PositionTypeText, + CASE + WHEN (sp.InterestDirection = 1 AND sp.PositionType = 2) + OR (sp.InterestDirection = 2 AND sp.PositionType = 1) THEN 0 + ELSE 1 + END AS IsDirectionMismatch +FROM swap_position sp +WHERE sp.SwapTradeId = @TradeId + AND sp.IsInitial = 1 + AND sp.Invalid = 0 + AND sp.PosiDirection > 0; +``` From 2977f658f7c3f5d2b5a516d998ed70107f7cb95f Mon Sep 17 00:00:00 2001 From: ruisu Date: Mon, 27 Jul 2026 15:01:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=E6=94=AF=E6=8C=81=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E4=B8=BA=E5=A4=8D=E6=9D=82=E8=A1=A8=E8=BE=BE=E5=BC=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RiskEngine/Compile/RuleCompiler.cs | 360 +++++++++++++- .../Modules/RiskEngine/RiskEngineService.cs | 167 +++++-- .../Modules/RiskEngine/RiskRuleService.cs | 116 +++-- .../RiskEngine/StructuredRuleExecutor.cs | 461 ++++++++++++++++++ YLErpDAL/Modules/RiskEngine/测试用例.md | 28 ++ 5 files changed, 1030 insertions(+), 102 deletions(-) create mode 100644 YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs diff --git a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs index e9797395..5d7be9d3 100644 --- a/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs +++ b/YLErpDAL/Modules/RiskEngine/Compile/RuleCompiler.cs @@ -1,4 +1,7 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Scripting; using Newtonsoft.Json; using System; @@ -46,7 +49,119 @@ namespace YLErp.Modules.RiskEngine /// public static class RuleCompiler { - static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); + private static IYcLogger _logger = LogFactory.GetLogger("RuleCompiler"); + + /// + /// 高风险方法黑名单。 + /// 这里按方法名做语法层拦截,覆盖数据库写入、原生 SQL、反射、文件、进程、服务定位等入口。 + /// + private static readonly HashSet ForbiddenInvocationNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "SaveChanges", + "SaveChangesAsync", + "Add", + "AddAsync", + "AddRange", + "AddRangeAsync", + "AddOrUpdate", + "Update", + "UpdateRange", + "Remove", + "RemoveRange", + "Attach", + "AttachRange", + "Set", + "Entry", + "ExecuteSqlRaw", + "ExecuteSqlRawAsync", + "ExecuteSqlInterpolated", + "ExecuteSqlInterpolatedAsync", + "ExecuteSql", + "ExecuteSqlAsync", + "ExecuteSqlCommand", + "ExecuteSqlCommandAsync", + "ExecuteStoreCommand", + "ExecuteStoreQuery", + "ExecuteDelete", + "ExecuteDeleteAsync", + "ExecuteUpdate", + "ExecuteUpdateAsync", + "FromSqlRaw", + "FromSqlRawAsync", + "FromSqlInterpolated", + "FromSqlInterpolatedAsync", + "BulkInsert", + "BulkUpdate", + "BulkDelete", + "BulkMerge", + "GetType", + "Invoke", + "InvokeMember", + "GetMethod", + "GetProperty", + "GetField", + "CreateInstance", + "Load", + "LoadFrom", + "ReadAllText", + "ReadAllLines", + "ReadAllBytes", + "WriteAllText", + "WriteAllLines", + "WriteAllBytes", + "AppendAllText", + "Delete", + "Move", + "Copy", + "Open", + "OpenRead", + "OpenWrite", + "Create", + "CreateText", + "Start", + "Kill", + "Exit", + "GetEnvironmentVariable", + "SetEnvironmentVariable", + "GetService", + "GetRequiredService", + "CreateScope", + "Sleep" + }; + + /// + /// 高风险成员黑名单。 + /// DbContext.Database、ChangeTracker 等成员会绕过只读查询约束或暴露底层状态,禁止脚本访问。 + /// + private static readonly HashSet ForbiddenMemberNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Database", + "ChangeTracker", + "Assembly", + "AppDomain" + }; + + /// + /// 高风险类型或标识符黑名单。 + /// 用于拦截 File、Process、Environment 等直接作为类型或变量名出现的绕过方式。 + /// + private static readonly HashSet ForbiddenTypeOrIdentifierNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Activator", + "Assembly", + "AppDomain", + "Environment", + "File", + "Directory", + "Path", + "Process", + "HttpClient", + "WebClient", + "WebRequest", + "Socket", + "Thread", + "GC" + }; /// /// 校验并编译规则表达式。 @@ -79,6 +194,109 @@ namespace YLErp.Modules.RiskEngine } } + /// + /// 校验变量取值表达式。 + /// 变量表达式只要求能编译为 object,不在保存时执行,避免依赖真实交易和数据库数据。 + /// + public static RuleCompileResult ValidateVariableExpression(string variableExpr) + { + if (string.IsNullOrWhiteSpace(variableExpr)) + return RuleCompileResult.Fail("变量取值表达式不能为空"); + + var compiled = CompileVariableExpression(variableExpr, out var compileErrorMessage); + if (compiled == null) + return RuleCompileResult.Fail(compileErrorMessage ?? "变量取值表达式编译失败"); + + return RuleCompileResult.Ok(null); + } + + /// + /// 编译变量取值表达式。 + /// 结构化规则执行时使用,表达式返回值由执行器按变量 DataType 统一转换。 + /// + public static Func CompileValueExpression(string variableExpr, out string errorMessage) + { + if (string.IsNullOrWhiteSpace(variableExpr)) + { + errorMessage = "变量取值表达式不能为空"; + return null; + } + + return CompileVariableExpression(variableExpr, out errorMessage); + } + + /// + /// 编译变量取值表达式。优先兼容原有单表达式写法;失败后再按多语句脚本编译。 + /// 多语句脚本支持用 return 表达最终值,编译前会转换为 Roslyn Script 稳定支持的末尾表达式。 + /// + private static Func CompileVariableExpression(string variableExpr, out string errorMessage) + { + if (IsLikelyVariableScript(variableExpr)) + { + var normalizedScript = NormalizeVariableScript(variableExpr); + var scriptCompiled = CompileScript(null, normalizedScript, out errorMessage); + return scriptCompiled; + } + + // 原有变量表达式大多是单个查询表达式,包成 object 后可直接作为脚本返回值。 + var expressionScript = $"(object)({variableExpr})"; + var compiled = CompileScript(null, expressionScript, out var expressionErrorMessage); + if (compiled != null) + { + errorMessage = null; + return compiled; + } + + // 单表达式编译失败时,再按多语句脚本兜底,兼容包含局部变量、if、throw 的复杂变量。 + var normalizedFallbackScript = NormalizeVariableScript(variableExpr); + compiled = CompileScript(null, normalizedFallbackScript, out var scriptErrorMessage); + if (compiled != null) + { + errorMessage = null; + return compiled; + } + + errorMessage = $"单表达式编译失败:{expressionErrorMessage};多语句脚本编译失败:{scriptErrorMessage}"; + return null; + } + + /// + /// 粗略判断变量表达式是否更像多语句脚本。 + /// 多语句脚本不能再包成 (object)(...),需要直接按 C# Script 编译。 + /// + private static bool IsLikelyVariableScript(string variableExpr) + { + return variableExpr.IndexOf(';') >= 0 || + variableExpr.IndexOf("return", StringComparison.OrdinalIgnoreCase) >= 0 || + variableExpr.IndexOf("throw", StringComparison.OrdinalIgnoreCase) >= 0 || + variableExpr.IndexOf("if", StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// 规范化多语句变量脚本。 + /// Roslyn Script 更稳定的返回方式是“最后一行表达式”,这里把末尾 return 表达式转换为末尾表达式。 + /// + private static string NormalizeVariableScript(string variableExpr) + { + var syntaxTree = CSharpSyntaxTree.ParseText(variableExpr, new CSharpParseOptions(kind: SourceCodeKind.Script)); + var root = syntaxTree.GetCompilationUnitRoot(); + var lastStatement = root.Members + .OfType() + .Select(statement => statement.Statement) + .LastOrDefault(); + + if (lastStatement is ReturnStatementSyntax returnStatement && returnStatement.Expression != null) + { + var returnText = returnStatement.ToFullString(); + var expressionText = returnStatement.Expression.ToFullString(); + var index = variableExpr.LastIndexOf(returnText, StringComparison.Ordinal); + if (index >= 0) + return variableExpr.Substring(0, index) + expressionText; + } + + return variableExpr; + } + /// /// 校验并编译规则,编译成功后同时写入规则对象和内存缓存。 /// @@ -115,13 +333,28 @@ namespace YLErp.Modules.RiskEngine } /// - /// 用 Roslyn 编译 C# 脚本代码为可执行委托 + /// 用 Roslyn 编译 C# 布尔表达式为可执行委托。 /// private static Func CompileScript(long? ruleId, string scriptCode, out string errorMessage) + { + return CompileScript(ruleId, scriptCode, out errorMessage); + } + + /// + /// 用 Roslyn 编译 C# 脚本代码为指定返回类型的可执行委托。 + /// 规则公式编译为 bool,变量取值表达式编译为 object。 + /// + private static Func CompileScript(long? ruleId, string scriptCode, out string errorMessage) { errorMessage = null; var ruleIdText = ruleId.HasValue ? ruleId.Value.ToString() : "未落库"; + if (!ValidateScriptSafety(scriptCode, out errorMessage)) + { + _logger.Error($"规则脚本安全校验失败 - RuleId: {ruleIdText}, Error: {errorMessage}\n脚本代码:{scriptCode}"); + return null; + } + // 配置编译选项:引用必要的程序集 var options = ScriptOptions.Default .WithReferences( @@ -136,7 +369,7 @@ namespace YLErp.Modules.RiskEngine .WithImports("System", "System.Linq", "Newtonsoft.Json", "YLErp.DBModels", "YLErp.QdpModule"); // 创建脚本(尚未执行,仅编译) - var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); + var script = CSharpScript.Create(scriptCode, options, globalsType: typeof(ScriptGlobals)); // 编译(提前发现语法错误) //后续编译需提供接口,返回前端编译信息,包含编译错误列表 @@ -153,7 +386,7 @@ namespace YLErp.Modules.RiskEngine // 生成可调用委托 var runner = script.CreateDelegate(); - // 包装为同步的 Func + // 包装为同步的 Func return ctx => { try @@ -174,6 +407,125 @@ namespace YLErp.Modules.RiskEngine } }; } + + /// + /// 脚本安全校验。 + /// 在 Roslyn 编译前先扫描语法树,禁止写库、反射、文件、网络、进程等高风险语法入口。 + /// + private static bool ValidateScriptSafety(string scriptCode, out string errorMessage) + { + var tree = CSharpSyntaxTree.ParseText(scriptCode, new CSharpParseOptions(kind: SourceCodeKind.Script)); + var root = tree.GetRoot(); + + // 允许变量声明初始化(如 decimal a = ...),但禁止后续赋值或复合赋值,避免脚本修改对象状态。 + if (root.DescendantNodes().OfType().Any()) + { + errorMessage = "规则表达式不允许包含赋值语句"; + return false; + } + + if (root.DescendantNodes().OfType().Any(IsIncrementOrDecrement) || + root.DescendantNodes().OfType().Any(IsIncrementOrDecrement)) + { + errorMessage = "规则表达式不允许包含自增或自减语句"; + return false; + } + + foreach (var memberAccess in root.DescendantNodes().OfType()) + { + var memberName = memberAccess.Name.Identifier.ValueText; + if (!string.IsNullOrWhiteSpace(memberName) && ForbiddenMemberNames.Contains(memberName)) + { + errorMessage = $"规则表达式不允许访问高风险成员:{memberName}"; + return false; + } + } + + // 只拦截独立标识符,避免把实体字段 p.File、p.Path 误判为高风险类型名。 + foreach (var identifier in root.DescendantNodes().OfType()) + { + var identifierName = identifier.Identifier.ValueText; + if (!IsMemberAccessName(identifier) && !string.IsNullOrWhiteSpace(identifierName) && ForbiddenTypeOrIdentifierNames.Contains(identifierName)) + { + errorMessage = $"规则表达式不允许访问高风险类型或标识符:{identifierName}"; + return false; + } + } + + foreach (var objectCreation in root.DescendantNodes().OfType()) + { + var typeName = GetTypeName(objectCreation.Type); + if (!string.IsNullOrWhiteSpace(typeName) && ForbiddenTypeOrIdentifierNames.Contains(typeName)) + { + errorMessage = $"规则表达式不允许创建高风险类型:{typeName}"; + return false; + } + } + + foreach (var invocation in root.DescendantNodes().OfType()) + { + var methodName = GetInvocationName(invocation.Expression); + if (!string.IsNullOrWhiteSpace(methodName) && ForbiddenInvocationNames.Contains(methodName)) + { + errorMessage = $"规则表达式不允许调用高风险方法:{methodName}"; + return false; + } + } + + errorMessage = null; + return true; + } + + /// + /// 判断前缀一元表达式是否为自增/自减。 + /// + private static bool IsIncrementOrDecrement(PrefixUnaryExpressionSyntax expression) + { + return expression.IsKind(SyntaxKind.PreIncrementExpression) || expression.IsKind(SyntaxKind.PreDecrementExpression); + } + + /// + /// 判断后缀一元表达式是否为自增/自减。 + /// + private static bool IsIncrementOrDecrement(PostfixUnaryExpressionSyntax expression) + { + return expression.IsKind(SyntaxKind.PostIncrementExpression) || expression.IsKind(SyntaxKind.PostDecrementExpression); + } + + /// + /// 判断标识符是否是成员访问右侧名称,例如 p.Path 中的 Path。 + /// + private static bool IsMemberAccessName(IdentifierNameSyntax identifier) + { + return identifier.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Name == identifier; + } + + /// + /// 从调用表达式中提取方法名,兼容 SaveChanges() 和 DbContext.SaveChanges() 两种写法。 + /// + private static string GetInvocationName(ExpressionSyntax expression) + { + return expression switch + { + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText, + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + _ => null + }; + } + + /// + /// 从类型语法中提取类型短名,用于匹配高风险类型黑名单。 + /// + private static string GetTypeName(TypeSyntax type) + { + return type switch + { + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText, + AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText, + _ => type?.ToString() + }; + } } public class BuildMemberAccessResult diff --git a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs index d16a4911..5bf02e41 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskEngineService.cs @@ -21,26 +21,26 @@ using YLErp.Model; RuleText、RuleExpr、Version、CompiledScript。 - RiskRuleApplication(规则应用):与规则分离,承载启用状态、控制策略、触发时点、 适用范围(全局 / 账户 / 客户 / 标的类型 / 合约类型)。 - - RuleExpr:当前唯一主编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, + - RuleExpr:自由文本规则的编译入口。要求内容是 Roslyn 可直接执行的 C# bool 表达式, 例如通过 DbContext 和 TradeId 查询数据库后做数值或日期比较。 - - ConditionJson:当前已在规则定义中保留,但不参与主执行链路。 + - ConditionJson:结构化规则的主执行依据;有值时优先由结构化执行器执行。 - RiskContext:一次风控检查的数据上下文,包含 TradeId、TriggerPoint 和 DbContext。 - - RuleCompiledCache:进程内编译结果缓存,按规则 Id 缓存 Func。 + - RuleCompiledCache:进程内编译结果缓存,仅用于自由文本 RuleExpr 规则,按规则 Id 缓存 Func。 当前编译流程: - 1. 外部准备好规则对象,在 RuleExpr 中直接写最终可执行表达式 - 2. 调用 RuleCompiler.ValidateAndCompileRule(rule) - 3. 内部使用 Roslyn 编译 RuleExpr,生成 Func - 4. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache - 5. 执行阶段优先从 RuleCompiledCache 取委托执行 + 1. 结构化规则以 ConditionJson 为执行依据,预热时只做 JSON 结构解析校验 + 2. 自由文本规则在 RuleExpr 中直接写最终可执行表达式 + 3. 调用 RuleCompiler.ValidateAndCompileRule(rule) + 4. 内部使用 Roslyn 编译 RuleExpr,生成 Func + 5. 编译成功后写入 rule.CompiledScript,并同步写入 RuleCompiledCache 当前执行流程(EvaluateRisk): 1. 从数据库加载规则列表和应用列表 2. 按应用状态、TriggerPoints 过滤有效应用 3. 按应用范围过滤交易是否命中(全局 / 账户 / 客户 / 标的类型 / 合约类型) 4. 根据应用配置中的 RuleIds 找到对应规则 Id 并关联规则 - 5. 优先从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 - 6. 执行规则委托,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings + 5. ConditionJson 有值时走结构化执行器;否则从 RuleCompiledCache 读取已编译委托,未命中时兜底编译一次 + 6. 执行规则判断,按 ControlStrategy 聚合为 Blocked / NeedApproval / Warnings 7. 返回 RiskResult 当前维度匹配规则: @@ -50,7 +50,7 @@ using YLErp.Model; - 某维度留空:视为该维度不限制 【与早期方案的主要差异】 - - 当前不是 Content 解析或表达式树主导,而是 RuleExpr 直编译主导 + - 结构化规则已改为 ConditionJson 执行主导,自由文本规则继续保留 RuleExpr 直编译 - RiskRule 与 RiskRuleApplication 当前仍是分离模型,没有合并 - 编译器文件已放入 RiskEngine/Compile 目录下 - 当前已引入 RuleCompileResult、RuleCompiledCache,用于校验结果与进程内缓存 @@ -75,12 +75,12 @@ using YLErp.Model; ✅ 已完成: 1. RiskEngine 第一版执行链路已跑通:QuotaMonitorService -> RiskEngineService -> RuleCompiler 2. 风控上下文提供 DbContext 和 TradeId,支持规则脚本直接查询数据库 - 3. RuleExpr 直编译方案已接入,支持数值比较和日期比较 + 3. 结构化规则已支持 ConditionJson 执行,自由文本规则保留 RuleExpr 直编译 4. Application 通用维度匹配已支持:全局 / 账户 / 客户 / 标的类型 / 合约类型 5. 维度组合逻辑已按文档确认:同维度 OR,不同维度 AND 6. RuleCompiler 已支持校验 + 编译 + 写缓存 7. RuleCompiledCache / RuleCompileResult 已落地到 Compile 目录 - 8. RiskEngineService 已改为执行时优先从 RuleCompiledCache 读取委托 + 8. RiskEngineService 已改为结构化规则优先执行 ConditionJson,自由文本规则再读取 RuleCompiledCache 【待办事项 / TODO】 ⬜ 1. 接入真实规则来源 @@ -146,8 +146,8 @@ namespace YLErp.Modules.RiskEngine private static readonly object _cacheLock = new object(); /// - /// 预热:加载规则与应用到内存,并预编译所有规则到 RuleCompiledCache。 - /// 项目启动时调用一次;规则/应用更新后调用 RefreshCache 刷新。 + /// 预热:加载规则与应用到内存,并预编译自由文本规则到 RuleCompiledCache。 + /// 结构化规则运行时直接执行 ConditionJson,预热时只做轻量结构校验,不再预编译 RuleExpr。 /// public void Preload() { @@ -157,7 +157,7 @@ namespace YLErp.Modules.RiskEngine var rules = LoadRulesFromDb(); var applications = LoadApplicationsFromDb(); - // 预编译所有规则到 RuleCompiledCache + // 仅预编译自由文本规则;结构化规则运行时由 ConditionJson 执行。 foreach (var rule in rules) { var ruleId = rule.Id.ToString(); @@ -169,6 +169,20 @@ namespace YLErp.Modules.RiskEngine continue; } + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) + { + RuleCompiledCache.Remove(rule.Id.ToString()); + try + { + RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + _logger.Info($"[风控引擎] 结构化规则预热校验成功,跳过 RuleExpr 预编译 - RuleId: {rule.Id}"); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] 结构化规则预热校验失败 - RuleId: {rule.Id}, Error: {ex.Message}"); + } + continue; + } var compileResult = RuleCompiler.ValidateAndCompileRule(rule); if (compileResult.Success) @@ -231,6 +245,22 @@ namespace YLErp.Modules.RiskEngine return RuleCompileResult.Fail("规则不存在或非活跃状态"); } + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) + { + RuleCompiledCache.Remove(ruleId.ToString()); + try + { + RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + _logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验成功,跳过 RuleExpr 编译 - RuleId: {ruleId}"); + return RuleCompileResult.Ok(null); + } + catch (Exception ex) + { + _logger.Info($"[风控引擎] RefreshOneRuleCache 结构化规则校验失败 - RuleId: {ruleId}, Error: {ex.Message}"); + return RuleCompileResult.Fail(ex.Message); + } + } + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); _logger.Info($"[风控引擎] RefreshOneRuleCache 完成 - RuleId: {ruleId}, Success: {compileResult.Success}, Error: {compileResult.ErrorMessage}"); return compileResult; @@ -323,6 +353,7 @@ namespace YLErp.Modules.RiskEngine // ============================================================ var rules = GetRules(); var applications = GetApplications(); + var variables = new Dictionary(); _logger.Info($"[风控引擎] 加载规则数: {rules.Count}, 应用数: {applications.Count}"); // ============================================================ @@ -395,40 +426,88 @@ namespace YLErp.Modules.RiskEngine { var ruleId = rule.Id.ToString(); - // ------------------------------------------------------------ - // 3.2 优先从编译缓存读取规则委托 - // ------------------------------------------------------------ - if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) + bool triggered = false; + if (!string.IsNullOrWhiteSpace(rule.ConditionJson)) { - _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); - - var compileResult = RuleCompiler.ValidateAndCompileRule(rule); - if (!compileResult.Success) + // 结构化规则优先使用 ConditionJson 执行,避免继续把条件整体拼成 Roslyn bool 公式。 + try { - var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}"; - _logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); - AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage); + var conditions = RuleConditionExpressionBuilder.DeserializeConditions(rule.ConditionJson); + var referencedVariableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions); + var missingVariableIds = referencedVariableIds + .Where(id => !variables.ContainsKey(id)) + .ToList(); + if (missingVariableIds.Any()) + { + // 按本次结构化规则实际引用的变量懒加载,避免每次风控检查全量读取变量池。 + var loadedVariables = ruleDbContext.glms_risk_variable + .AsNoTracking() + .Where(v => missingVariableIds.Contains(v.id)) + .ToDictionary(v => v.id); + foreach (var variable in loadedVariables) + variables[variable.Key] = variable.Value; + } + + var ruleVariables = referencedVariableIds + .Where(variables.ContainsKey) + .ToDictionary(id => id, id => variables[id]); + + var executeResult = StructuredRuleExecutor.Execute(conditions, ruleVariables, context); + if (!executeResult.Success) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{executeResult.ErrorMessage}"; + _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {executeResult.ErrorMessage}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + continue; + } + + triggered = executeResult.Triggered; + _logger.Info($"[风控引擎] 结构化规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); + } + catch (Exception ex) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; + _logger.Error($"[风控引擎] 结构化规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); continue; } - - compiledScript = compileResult.CompiledScript; } + else + { + // ------------------------------------------------------------ + // 3.2 优先从编译缓存读取规则委托 + // ------------------------------------------------------------ + if (!RuleCompiledCache.TryGet(ruleId, out var compiledScript)) + { + _logger.Info($"[风控引擎] 缓存中未命中已编译规则,执行兜底编译 - RuleId: {rule.Id}"); - // ------------------------------------------------------------ - // 3.3 执行预编译委托 - // ------------------------------------------------------------ - bool triggered = false; - try - { - triggered = compiledScript(context); - _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); - } - catch (Exception ex) - { - var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; - _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); - AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); - continue; + var compileResult = RuleCompiler.ValidateAndCompileRule(rule); + if (!compileResult.Success) + { + var errorMessage = $"规则[{rule.RuleName}]编译失败,已按阻断处理,请检查规则表达式配置:{compileResult.ErrorMessage}"; + _logger.Info($"[风控引擎] 规则编译失败,按阻断处理 - RuleId: {rule.Id}, Error: {compileResult.ErrorMessage}"); + AddBlockError(result, rule.Id.ToString(), rule.RuleName, rule.RuleText, errorMessage); + continue; + } + + compiledScript = compileResult.CompiledScript; + } + + // ------------------------------------------------------------ + // 3.3 执行预编译委托 + // ------------------------------------------------------------ + try + { + triggered = compiledScript(context); + _logger.Info($"[风控引擎] 规则执行 - RuleId: {rule.Id}, Triggered: {triggered}"); + } + catch (Exception ex) + { + var errorMessage = $"规则[{rule.RuleName}]执行异常:{ex.Message}"; + _logger.Error($"[风控引擎] 规则执行异常,按阻断处理 - RuleId: {rule.Id}, Error: {ex.Message}"); + AddBlockError(result, ruleId, rule.RuleName, rule.RuleText, errorMessage); + continue; + } } // ------------------------------------------------------------ diff --git a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs index 92ade91b..bad1b920 100644 --- a/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs +++ b/YLErpDAL/Modules/RiskEngine/RiskRuleService.cs @@ -180,10 +180,10 @@ using YLErp.Modules.RiskEngine.Dto; 【注意事项】 1. RiskEngineService 由另一个团队开发实现,本服务通过 GetInstance() 获取单例引用 - 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量变更不触发引擎缓存刷新 + 2. 规则变更后调用 RefreshOneRuleCache(ruleId),应用变更后调用 RefreshApplication(),变量定义变更后刷新引用规则缓存 3. 变量删除为硬删除(物理删除),规则/应用删除为软删除(Status=Deleted) 4. ExportAuditLogs 按查询条件导出,并生成包含查询条件的文件名 - 5. VariableExpr 当前仅做基础长度校验(≤10000),完整编译校验待引入 Roslyn 库 + 5. VariableExpr 保存时校验长度,并通过 Roslyn 做编译与安全校验 6. ValidateScopeFields 的 ID 存在性校验已跳过(前端下拉选择器保证有效性) ================================================================================ */ @@ -353,11 +353,12 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验 ConditionJson 并生成服务端期望的简洁 RuleExpr。 + /// 校验 ConditionJson 结构和引用变量是否存在。 + /// 结构化规则运行时以 ConditionJson 为准,RuleExpr 只作为兼容/展示字段。 /// - private string ValidateConditionJson(string conditionJson) + private void ValidateConditionJson(string conditionJson) { - return BuildRuleExprFromConditionJson(conditionJson); + ValidateConditionJsonVariables(conditionJson); } /// /// 校验 RuleExpr(自由文本模式:括号匹配) @@ -511,7 +512,8 @@ namespace YLErp.Modules.RiskEngine } /// - /// 校验规则参数(名称 + ConditionJson + RuleExpr) + /// 校验规则参数(名称 + ConditionJson / RuleExpr)。 + /// 自由文本规则继续使用 RuleExpr;结构化规则允许保留 RuleExpr 作为兼容/展示字段,运行时以 ConditionJson 为准。 /// private void ValidateRuleParams(string ruleName, string conditionJson, string ruleExpr, long? ruleId = null) { @@ -520,22 +522,31 @@ namespace YLErp.Modules.RiskEngine if (ruleName.Length > 200) throw new ServiceException("规则名称长度不能超过200字符"); + if (!string.IsNullOrWhiteSpace(conditionJson)) + { + ValidateConditionJson(conditionJson); + return; + } + if (string.IsNullOrWhiteSpace(ruleExpr)) throw new ServiceException("规则表达式不能为空"); - if (!string.IsNullOrWhiteSpace(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); } + /// + /// 校验规则编译:结构化规则运行时以 ConditionJson 为准,只有自由文本规则必须编译 RuleExpr。 + /// + private void ValidateRuleCompile(long? ruleId, string conditionJson, string ruleExpr) + { + if (!string.IsNullOrWhiteSpace(conditionJson)) + return; + + var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, ruleExpr); + if (!compileResult.Success) + throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + } + /// /// 解析逗号分隔的规则 ID 字符串为 List /// @@ -893,10 +904,7 @@ namespace YLErp.Modules.RiskEngine public RiskRuleDetail CreateRule(CreateRiskRuleReq req) { ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr); - - var compileResult = RuleCompiler.ValidateAndCompileFormula(null, req.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(null, req.ConditionJson, req.RuleExpr); var entity = new glms_risk_rule { @@ -950,10 +958,7 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("规则已被其他用户修改,请重新加载后再编辑"); ValidateRuleParams(req.RuleName, req.ConditionJson, req.RuleExpr, ruleId); - - var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, req.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(ruleId, req.ConditionJson, req.RuleExpr); rule.RuleName = req.RuleName; rule.RuleText = req.RuleText; @@ -1007,9 +1012,7 @@ namespace YLErp.Modules.RiskEngine if (rule == null) throw new ServiceException("仅已停用的规则可以启用"); - var compileResult = RuleCompiler.ValidateAndCompileFormula(ruleId, rule.RuleExpr); - if (!compileResult.Success) - throw new ServiceException($"规则表达式编译失败:{compileResult.ErrorMessage}"); + ValidateRuleCompile(ruleId, rule.ConditionJson, rule.RuleExpr); rule.Status = RiskRuleStatus.Active; @@ -1198,11 +1201,13 @@ namespace YLErp.Modules.RiskEngine continue; } - var compileResult = RuleCompiler.ValidateAndCompileFormula(rule.id, rule.RuleExpr); - if (!compileResult.Success) + try { - items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", - $"规则表达式编译失败:{compileResult.ErrorMessage}")); + ValidateRuleCompile(rule.id, rule.ConditionJson, rule.RuleExpr); + } + catch (ServiceException ex) + { + items.Add(BatchItem(id, BatchOperationItemStatus.Failed, "COMPILE_FAILED", ex.Message)); continue; } @@ -1807,23 +1812,36 @@ namespace YLErp.Modules.RiskEngine } /// - /// 根据结构化 ConditionJson 和当前变量池定义重新生成简洁 RuleExpr。 + /// 校验结构化 ConditionJson 引用的变量是否存在。 /// - private string BuildRuleExprFromConditionJson(string conditionJson) + private List ValidateConditionJsonVariables(string conditionJson) { var conditions = RuleConditionExpressionBuilder.DeserializeConditions(conditionJson); var variableIds = RuleConditionExpressionBuilder.GetReferencedVariableIds(conditions).ToList(); var variables = DbContext.glms_risk_variable .Where(variable => variableIds.Contains(variable.id)) - .ToList() - .ToDictionary(variable => (long)variable.id); + .ToList(); - var missingVariableIds = variableIds.Where(id => !variables.ContainsKey(id)).ToList(); + var variableMap = variables.ToDictionary(variable => (long)variable.id); + var missingVariableIds = variableIds.Where(id => !variableMap.ContainsKey(id)).ToList(); if (missingVariableIds.Any()) throw new ServiceException($"公式引用的变量不存在:{string.Join(",", missingVariableIds)}"); - return RuleConditionExpressionBuilder.Build(conditions, variables); + return variables; } + + + /// + /// 校验变量取值表达式是否可编译且不包含写库等危险调用。 + /// 这里只做静态编译与安全检查,不执行表达式,避免依赖真实交易数据。 + /// + private void ValidateVariableExpr(string variableExpr) + { + var compileResult = RuleCompiler.ValidateVariableExpression(variableExpr); + if (!compileResult.Success) + throw new ServiceException($"变量取值表达式校验失败:{compileResult.ErrorMessage}"); + } + #region Variable Management /// @@ -1907,6 +1925,7 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("取值表达式不能为空"); if (req.VariableExpr.Length > 10000) throw new ServiceException("取值表达式长度不能超过 10000 字符"); + ValidateVariableExpr(req.VariableExpr); var entity = new glms_risk_variable { @@ -1965,6 +1984,9 @@ namespace YLErp.Modules.RiskEngine throw new ServiceException("变量名称不能为空"); if (string.IsNullOrWhiteSpace(req.VariableExpr)) throw new ServiceException("取值表达式不能为空"); + if (req.VariableExpr.Length > 10000) + throw new ServiceException("取值表达式长度不能超过 10000 字符"); + ValidateVariableExpr(req.VariableExpr); if (req.DataType != variable.DataType) { @@ -2000,34 +2022,20 @@ namespace YLErp.Modules.RiskEngine { try { - // 在同一事务内完成引用发现、公式重建、编译和持久化;任一规则失败时整体回滚。 + // 结构化规则运行时直接按 ConditionJson 读取变量定义,不再把变量表达式展开回 RuleExpr。 + // 因此变量定义变化时只标记引用规则受影响,避免多语句变量表达式被拼成非法公式。 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]; rule.Version = rule.Version + 1; rule.UpdateOptId = UserId; rule.UpdateOptName = UserName; rule.UpdateDate = DateTime.Now; WriteAuditLog("VAR_CASCADE_UPDATE_RULE", "RULE", rule.id, rule.RuleName, - $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,规则表达式被级联更新"); + $"因变量'{variable.VariableName}'(ID:{variableId})定义变更,结构化规则已标记更新"); } SaveChangesWithConcurrencyCheck(); diff --git a/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs new file mode 100644 index 00000000..af9e7171 --- /dev/null +++ b/YLErpDAL/Modules/RiskEngine/StructuredRuleExecutor.cs @@ -0,0 +1,461 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using YLErp.Commons; +using YLErp.DBModels; +using YLErp.Modules.RiskEngine.Dto; + +namespace YLErp.Modules.RiskEngine +{ + /// + /// 结构化规则执行器。 + /// 用于执行现有 ConditionJson:变量取值、按 DataType 转换并比较阈值;复杂业务计算放在变量表达式中完成。 + /// + public static class StructuredRuleExecutor + { + private static readonly ConcurrentDictionary> VariableCompiledCache = new ConcurrentDictionary>(); + + 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" + }; + + public static StructuredRuleExecuteResult Execute( + IReadOnlyList conditions, + IReadOnlyDictionary variables, + RiskContext context) + { + try + { + if (conditions == null || conditions.Count == 0) + return StructuredRuleExecuteResult.Fail("公式条件列表不能为空"); + if (variables == null) + return StructuredRuleExecuteResult.Fail("变量列表不能为空"); + + for (var i = 0; i < conditions.Count; i++) + { + var conditionResult = ExecuteCondition(conditions[i], variables, context, i + 1); + if (!conditionResult.Success) + return conditionResult; + if (!conditionResult.Triggered) + return StructuredRuleExecuteResult.Ok(false); + } + + return StructuredRuleExecuteResult.Ok(true); + } + catch (Exception ex) + { + return StructuredRuleExecuteResult.Fail(ex.Message); + } + } + + private static StructuredRuleExecuteResult ExecuteCondition( + RuleCondition condition, + IReadOnlyDictionary variables, + RiskContext context, + int conditionIndex) + { + var label = $"条件{conditionIndex}"; + if (condition == null) + return StructuredRuleExecuteResult.Fail($"{label}不能为空"); + if (!variables.TryGetValue(condition.VariableId, out var variable)) + return StructuredRuleExecuteResult.Fail($"{label}:变量 ID '{condition.VariableId}' 不存在"); + + if (variable.DataType == RiskVariableDataType.Boolean) + return ExecuteBooleanCondition(condition, variable, context, label); + + if (variable.DataType != RiskVariableDataType.Numeric && variable.DataType != RiskVariableDataType.Date) + return StructuredRuleExecuteResult.Fail($"{label}:不支持的数据类型"); + if (!ComparableOperators.Contains(condition.Operator)) + return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于{GetDataTypeName(variable.DataType)}类型变量"); + + var left = GetVariableValue(variable, variable.DataType, context, label, "条件变量"); + if (!left.Success) + return StructuredRuleExecuteResult.Fail(left.ErrorMessage); + + if (condition.Operator == "between" || condition.Operator == "notBetween") + return ExecuteRangeCondition(condition, variable, left.Value, variables, context, label); + + var rangeFieldCheck = EnsureNoRangeFields(condition, label); + if (!rangeFieldCheck.Success) + return rangeFieldCheck; + + var right = GetThresholdValue( + condition.ThresholdType, + condition.Value, + condition.ThresholdVariableId, + variable, + variables, + context, + label, + "阈值"); + if (!right.Success) + return StructuredRuleExecuteResult.Fail(right.ErrorMessage); + + var compareResult = Compare(left.Value, right.Value, variable.DataType, condition.Operator, label); + if (!compareResult.Success) + return compareResult; + + return StructuredRuleExecuteResult.Ok(compareResult.Triggered); + } + + private static StructuredRuleExecuteResult ExecuteBooleanCondition( + RuleCondition condition, + glms_risk_variable variable, + RiskContext context, + string label) + { + if (!BooleanOperators.Contains(condition.Operator)) + return StructuredRuleExecuteResult.Fail($"{label}:操作符 '{condition.Operator}' 不适用于布尔类型变量"); + var thresholdFieldCheck = EnsureNoThresholdFields(condition, label); + if (!thresholdFieldCheck.Success) + return thresholdFieldCheck; + + var value = GetVariableValue(variable, RiskVariableDataType.Boolean, context, label, "条件变量"); + if (!value.Success) + return StructuredRuleExecuteResult.Fail(value.ErrorMessage); + + var expected = condition.Operator == "isTrue"; + return StructuredRuleExecuteResult.Ok((bool)value.Value == expected); + } + + private static StructuredRuleExecuteResult ExecuteRangeCondition( + RuleCondition condition, + glms_risk_variable variable, + object leftValue, + IReadOnlyDictionary variables, + RiskContext context, + string label) + { + if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue) + return StructuredRuleExecuteResult.Fail($"{label}:区间条件不得携带普通阈值字段"); + if (!condition.IncludeLower.HasValue || !condition.IncludeUpper.HasValue) + return StructuredRuleExecuteResult.Fail($"{label}:区间开闭配置不完整"); + + var fixedRangeCheck = ValidateFixedRangeOrder(condition, variable.DataType, label); + if (!fixedRangeCheck.Success) + return fixedRangeCheck; + + var lower = GetThresholdValue(condition.LowerThresholdType, condition.LowerValue, condition.LowerThresholdVariableId, variable, variables, context, label, "下限"); + if (!lower.Success) + return StructuredRuleExecuteResult.Fail(lower.ErrorMessage); + + var upper = GetThresholdValue(condition.UpperThresholdType, condition.UpperValue, condition.UpperThresholdVariableId, variable, variables, context, label, "上限"); + if (!upper.Success) + return StructuredRuleExecuteResult.Fail(upper.ErrorMessage); + + var lowerOperator = condition.IncludeLower == true ? "gte" : "gt"; + var upperOperator = condition.IncludeUpper == true ? "lte" : "lt"; + var lowerCompare = Compare(leftValue, lower.Value, variable.DataType, lowerOperator, label); + if (!lowerCompare.Success) + return lowerCompare; + var upperCompare = Compare(leftValue, upper.Value, variable.DataType, upperOperator, label); + if (!upperCompare.Success) + return upperCompare; + + var inRange = lowerCompare.Triggered && upperCompare.Triggered; + return StructuredRuleExecuteResult.Ok(condition.Operator == "notBetween" ? !inRange : inRange); + } + + private static ValueExecuteResult GetThresholdValue( + string thresholdType, + object fixedValue, + long? thresholdVariableId, + glms_risk_variable conditionVariable, + IReadOnlyDictionary variables, + RiskContext context, + string label, + string thresholdLabel) + { + if (string.Equals(thresholdType, "Fixed", StringComparison.Ordinal)) + { + if (thresholdVariableId.HasValue) + return ValueExecuteResult.Fail($"{label}:固定{thresholdLabel}不得携带变量 ID"); + var fixedResult = ConvertValue(fixedValue, conditionVariable.DataType, label, $"固定{thresholdLabel}"); + if (!fixedResult.Success) + return ValueExecuteResult.Fail($"{fixedResult.ErrorMessage},原始值:{FormatRawValue(fixedValue)}"); + return fixedResult; + } + + if (string.Equals(thresholdType, "Variable", StringComparison.Ordinal)) + { + if (HasValue(fixedValue)) + return ValueExecuteResult.Fail($"{label}:变量{thresholdLabel}不得携带固定值"); + if (!thresholdVariableId.HasValue) + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID 不能为空"); + if (!variables.TryGetValue(thresholdVariableId.Value, out var thresholdVariable)) + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量 ID '{thresholdVariableId.Value}' 不存在"); + if (thresholdVariable.DataType != conditionVariable.DataType) + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的数据类型不一致"); + if (conditionVariable.DataType == RiskVariableDataType.Numeric && !UnitsMatch(conditionVariable.Unit, thresholdVariable.Unit)) + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}变量与条件变量的单位不一致"); + + return GetVariableValue(thresholdVariable, conditionVariable.DataType, context, label, thresholdLabel); + } + + return ValueExecuteResult.Fail($"{label}:{thresholdLabel}类型 '{thresholdType}' 不合法,仅支持 Fixed/Variable"); + } + + private static ValueExecuteResult GetVariableValue( + glms_risk_variable variable, + RiskVariableDataType dataType, + RiskContext context, + string label, + string valueLabel) + { + if (string.IsNullOrWhiteSpace(variable.VariableExpr)) + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})的取值表达式为空"); + + var compiled = GetCompiledVariableExpression(variable, out var errorMessage); + if (compiled == null) + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})编译失败:{errorMessage}"); + + object rawValue; + try + { + rawValue = compiled(context); + } + catch (Exception ex) + { + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id})执行失败:{ex.Message}"); + } + + var converted = ConvertValue(rawValue, dataType, label, valueLabel); + if (!converted.Success) + return ValueExecuteResult.Fail($"{label}:变量'{variable.VariableName}'(ID:{variable.id}){converted.ErrorMessage},原始值:{FormatRawValue(rawValue)}"); + + return converted; + } + + private static Func GetCompiledVariableExpression(glms_risk_variable variable, out string errorMessage) + { + var cacheKey = $"{variable.id}:{variable.Version}:{variable.UpdateDate?.Ticks ?? 0}:{StringComparer.Ordinal.GetHashCode(variable.VariableExpr ?? string.Empty)}"; + if (VariableCompiledCache.TryGetValue(cacheKey, out var compiled)) + { + errorMessage = null; + return compiled; + } + + // 变量表达式只负责取原始值,类型转换统一在结构化执行器中处理;这里缓存编译结果,避免每次执行重复 Roslyn 编译。 + compiled = RuleCompiler.CompileValueExpression(variable.VariableExpr, out errorMessage); + if (compiled != null) + VariableCompiledCache[cacheKey] = compiled; + return compiled; + } + + private static ValueExecuteResult ConvertValue(object value, RiskVariableDataType dataType, string label, string valueLabel) + { + if (!HasValue(value)) + return ValueExecuteResult.Fail($"{valueLabel}为空"); + + if (dataType == RiskVariableDataType.Numeric) + { + if (value is decimal decimalValue) + return ValueExecuteResult.Ok(decimalValue); + if (decimal.TryParse(GetRawValue(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var numericValue)) + return ValueExecuteResult.Ok(numericValue); + return ValueExecuteResult.Fail($"{valueLabel}必须为数字"); + } + + if (dataType == RiskVariableDataType.Date) + { + if (value is DateTime dateValue) + return ValueExecuteResult.Ok(dateValue.Date); + if (DateTime.TryParseExact(GetRawValue(value), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var fixedDate)) + return ValueExecuteResult.Ok(fixedDate.Date); + return ValueExecuteResult.Fail($"{valueLabel}必须为 yyyy-MM-dd 格式的合法日期"); + } + + if (dataType == RiskVariableDataType.Boolean) + { + if (value is bool boolValue) + return ValueExecuteResult.Ok(boolValue); + if (bool.TryParse(GetRawValue(value), out var parsedBool)) + return ValueExecuteResult.Ok(parsedBool); + return ValueExecuteResult.Fail($"{valueLabel}必须为布尔值"); + } + + return ValueExecuteResult.Fail($"{label}:不支持的数据类型"); + } + + private static StructuredRuleExecuteResult Compare(object left, object right, RiskVariableDataType dataType, string ruleOperator, string label) + { + var compare = dataType switch + { + RiskVariableDataType.Numeric => ((decimal)left).CompareTo((decimal)right), + RiskVariableDataType.Date => ((DateTime)left).CompareTo((DateTime)right), + _ => throw new ServiceException($"{label}:不支持的数据类型") + }; + + var result = ruleOperator switch + { + "gt" => compare > 0, + "lt" => compare < 0, + "gte" => compare >= 0, + "lte" => compare <= 0, + "eq" => compare == 0, + "ne" => compare != 0, + _ => throw new ServiceException($"{label}:不支持的操作符'{ruleOperator}'") + }; + + return StructuredRuleExecuteResult.Ok(result); + } + + private static StructuredRuleExecuteResult EnsureNoRangeFields(RuleCondition condition, string label) + { + if (condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue || + condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue || + condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue) + { + return StructuredRuleExecuteResult.Fail($"{label}:非区间条件不得携带区间字段"); + } + + return StructuredRuleExecuteResult.Ok(false); + } + + private static StructuredRuleExecuteResult EnsureNoThresholdFields(RuleCondition condition, string label) + { + if (condition.ThresholdType != null || HasValue(condition.Value) || condition.ThresholdVariableId.HasValue || + condition.LowerThresholdType != null || HasValue(condition.LowerValue) || condition.LowerThresholdVariableId.HasValue || + condition.UpperThresholdType != null || HasValue(condition.UpperValue) || condition.UpperThresholdVariableId.HasValue || + condition.IncludeLower.HasValue || condition.IncludeUpper.HasValue) + { + return StructuredRuleExecuteResult.Fail($"{label}:布尔条件不得携带阈值字段"); + } + + return StructuredRuleExecuteResult.Ok(false); + } + + private static StructuredRuleExecuteResult ValidateFixedRangeOrder(RuleCondition condition, RiskVariableDataType dataType, string label) + { + if (condition.LowerThresholdType != "Fixed" || condition.UpperThresholdType != "Fixed") + return StructuredRuleExecuteResult.Ok(false); + + var lower = GetRawValue(condition.LowerValue); + var upper = GetRawValue(condition.UpperValue); + if (dataType == RiskVariableDataType.Numeric && + decimal.TryParse(lower, NumberStyles.Float, CultureInfo.InvariantCulture, out var lowerNumber) && + decimal.TryParse(upper, NumberStyles.Float, CultureInfo.InvariantCulture, out var upperNumber) && + lowerNumber > upperNumber) + { + return StructuredRuleExecuteResult.Fail($"{label}:下限不能大于上限"); + } + + if (dataType == RiskVariableDataType.Date && + DateTime.TryParseExact(lower, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var lowerDate) && + DateTime.TryParseExact(upper, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var upperDate) && + lowerDate > upperDate) + { + return StructuredRuleExecuteResult.Fail($"{label}:下限不能晚于上限"); + } + + return StructuredRuleExecuteResult.Ok(false); + } + + 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 string FormatRawValue(object value) + { + if (value == null) + return ""; + var rawValue = GetRawValue(value); + return string.IsNullOrWhiteSpace(rawValue) ? "" : rawValue; + } + + private static bool UnitsMatch(string left, string right) + { + return string.Equals(left?.Trim() ?? string.Empty, right?.Trim() ?? string.Empty, StringComparison.Ordinal); + } + + private static string GetDataTypeName(RiskVariableDataType dataType) + { + return dataType switch + { + RiskVariableDataType.Numeric => "数值", + RiskVariableDataType.Date => "日期", + RiskVariableDataType.Boolean => "布尔", + _ => "未知" + }; + } + } + + public class StructuredRuleExecuteResult + { + public bool Success { get; set; } + + public bool Triggered { get; set; } + + public string ErrorMessage { get; set; } + + public static StructuredRuleExecuteResult Ok(bool triggered) + { + return new StructuredRuleExecuteResult + { + Success = true, + Triggered = triggered + }; + } + + public static StructuredRuleExecuteResult Fail(string errorMessage) + { + return new StructuredRuleExecuteResult + { + Success = false, + Triggered = false, + ErrorMessage = errorMessage + }; + } + } + + internal class ValueExecuteResult + { + public bool Success { get; set; } + + public object Value { get; set; } + + public string ErrorMessage { get; set; } + + public static ValueExecuteResult Ok(object value) + { + return new ValueExecuteResult + { + Success = true, + Value = value + }; + } + + public static ValueExecuteResult Fail(string errorMessage) + { + return new ValueExecuteResult + { + Success = false, + ErrorMessage = errorMessage + }; + } + } +} diff --git a/YLErpDAL/Modules/RiskEngine/测试用例.md b/YLErpDAL/Modules/RiskEngine/测试用例.md index 7d29142a..a319dcf0 100644 --- a/YLErpDAL/Modules/RiskEngine/测试用例.md +++ b/YLErpDAL/Modules/RiskEngine/测试用例.md @@ -1575,6 +1575,34 @@ WHERE t.id = @TradeId; 保证金支付金额 ÷ 开仓名义本金 > 50% ``` +变量 Roslyn 示例:变量名为“保证金支付比例”,DataType 为 Numeric;规则前端仍配置“保证金支付比例 > 0.5”。字段类型需为 decimal / decimal?,否则需要字段层调整或显式类型转换。 + +```csharp +decimal marginPaymentAmount = DbContext.swap_position + .Where(p => p.SwapTradeId == TradeId + && p.IsInitial + && !p.Invalid + && (p.InterestMode == 5 || p.InterestMode == 6) + && p.InterestDirection == 2) + .Select(p => p.InterestPrincipalFix) + .Sum(); + +double? openingNotionalRaw = DbContext.trade + .Where(t => t.id == TradeId) + .Select(t => t.OriginalStockEqvNotional) + .FirstOrDefault(); + +if (!openingNotionalRaw.HasValue) + throw new Exception("开仓名义本金为空"); + +decimal openingNotional = (decimal)openingNotionalRaw.Value; + +if (openingNotional == 0m) + throw new Exception("开仓名义本金为0,不能作为除数"); + +return marginPaymentAmount / openingNotional; +``` + 规则字段口径: ```text