风控策略决定“同一组信号如何处置”,评估事实回答“当时为什么这样处置”。前者允许按租户变化,后者必须在策略更新后仍保持历史原貌。
1. 单项 JSON 整体覆盖
RiskControlSettingDefinitions 注册租户级设置 riskcontrol.policy。它不是五个分散阈值,而是一个 JSON 原子值,包含完整 ScoreRanges 和 ChallengeMap。
{ "scoreRanges": { "Low": { "min": 0, "max": 30 }, "Medium": { "min": 31, "max": 50 }, "High": { "min": 51, "max": 75 }, "Critical": { "min": 76, "max": 90 }, "Block": { "min": 91, "max": 100 } }, "challengeMap": { "Low": "None", "Medium": "Captcha", "High": "MFA", "Critical": "StepUp", "Block": "Block" }}Settings 子系统先解析租户覆盖,再回退全局值和代码默认值;RiskControl 在读取结果之上还执行自己的 JSON 与业务校验。
2. 策略不变量
RiskPolicyConfig.IsValid 和领域 RiskPolicy 都要求:
- 恰好包含 Low、Medium、High、Critical、Block;
- 每个 Level 恰好有一个 Challenge;
- 区间在 0–100 内,Min 不大于 Max;
- 第一个区间从 0 开始,最后一个到 100;
- 相邻闭区间满足前 Max + 1 = 后 Min,无空洞、无重叠;
- Challenge 名称可以解析为已知 SmartEnum。
public static class RiskControlErrors{ public static readonly Error PolicyInvalid = Error.Validation("RiskControl.Policy.Invalid", "Risk policy is invalid.");}
var config = JsonSerializer.Deserialize( command.Json, RiskControlApplicationJsonSerializerContext.Default.RiskPolicyConfig);
// 保存入口先拒绝非法配置,不能依赖读取时静默回退。if (config is null || !config.IsValid()){ return Result.Failure(RiskControlErrors.PolicyInvalid);}
// 领域构造再做一次不变量校验,防止程序内直接构造绕过。_ = config.ToPolicy();return await settings.SetAsync( SettingsRiskPolicyProvider.PolicyKey, config.ToJson(), currentTenant.Tenant.EffectiveTenantId, cancellationToken);仓库当前没有 RiskControl 专属策略编辑 Endpoint;实际管理可以复用 Settings 管理能力,但应提供预览、审批和审计,而不是让运营直接编辑无校验 JSON。
3. 读取失败为何回退默认
SettingsRiskPolicyProvider.ResolveAsync 对 Settings Failure、JSON null、非法配置、转换异常都记录 Warning 并返回 RiskPolicy.Default。调用方取消单独传播。
这是一种 fail-safe,而不是 fail-closed:系统仍有一套已知策略,不会完全关闭风险控制。风险在于租户原本配置更严格时,故障会降低到默认阈值。建议记录:
- tenantId 与策略来源(tenant/global/default/fallback);
- 当前策略版本或哈希;
- fallback 原因和持续时间;
- 严格租户是否要求 fallback → MFA。
4. RiskAssessment 是不可变历史事实
表 SysRiskAssessment 是租户 + 软删除统一聚合,主要列包括:
| 列 | 语义 |
|---|---|
| TenantId | 评估归属租户 |
| UserId | 用户解析前评估时可空 |
| RiskScore | 0–100 总分 |
| Level | 当时 Level 名称 |
| Challenge | 当时 RequiredChallenge |
| FactorsJson | 命中因子与 Evidence 的 AOT JSON 快照 |
| AssessedAt | UTC 评估时间 |
Create 深复制 Evidence,防止调用方随后修改字典改写历史。Store 只接受占位 Id "0",再用基础设施 Id Generator 分配最终标识。
Restore 会拒绝未知 Level/Challenge、损坏 JSON、嵌套 Evidence、重复 JSON 属性和权重总和不一致。它不会按新策略重新算旧 Level。
5. 独立事务时序
RiskAssessmentPersistencePipelineBehavior 必须位于 TransactionPipelineBehavior 外层:
使用 CancellationToken.None 是为了客户端断开后仍尽力保存安全事实。它也意味着数据库故障时操作不受原请求超时约束,底层连接与命令必须有独立超时。
6. “最多一次捕获”不是持久化幂等
RiskAssessmentExecutionContext.Capture 在同一 Scoped 请求已有 pending 时抛错,Take 读取后清空。这能防同一请求内重复记录,却不是跨重试幂等:
- HTTP 重试会重新评估并生成新 AssessmentId;
- 消息重放也会生成另一条事实;
- 持久化提交成功但响应丢失时没有业务 Correlation 唯一键去重。
若运营需要“一次登录尝试一条评估”,模型应保存 AttemptId/CorrelationId,并建立 TenantId + AttemptId 唯一约束。
7. 持久化失败不会改变登录结果
独立 Begin、Record 或 Commit 失败时,Pipeline 尝试 Rollback,记录两类 Error Log,然后吞掉异常。这样原登录成功不会因为审计库抖动变失败,原业务异常也不会被覆盖。
8. QueryShape 读模型
RiskAssessmentStore.SearchAsync:
- 将 page index 最小化为 1,page size 限制 1–100;
- 显式 Predicate 包含可信 TenantId、User、Level、From、To;
- 默认按 AssessedAt desc、AssessmentId desc 稳定排序;
- 只投影持久化标量,再调用 Restore;
- 当前 ORM 通过 QueryShape 执行分页,不手写 EF/SqlSugar 分支;
- 损坏快照返回
RiskControl.Assessment.InvalidPersistenceState。
private static Expression<Func<RiskAssessment, bool>> BuildPredicate( string trustedTenantId, RiskAssessmentFilter filter){ var levelName = filter.Level?.Name; // TenantId 单独来自认证上下文,不属于可选 filter。 // User、Level 与时间范围只在可信租户基线内缩小结果集。 return assessment => assessment.TenantId == trustedTenantId && (filter.UserId == null || assessment.UserId == filter.UserId) && (levelName == null || assessment.LevelName == levelName) && (filter.From == null || assessment.AssessedAt >= filter.From) && (filter.To == null || assessment.AssessedAt <= filter.To);}9. 隐私与保留
当前 FactorsJson 会存原始 IP、设备指纹、完整业务时间;查询 DTO 又返回完整 Factors。生产需要:
- 明确合法处理目的与保留期;
- IP 截断/哈希、设备别名化,避免持久化 Raw Fingerprint;
- 列表默认返回 Code/Weight,敏感 Evidence 使用更高权限的详情端口;
- 导出和审计记录访问原因;
- 软删除之外提供真实保留清理与法务 Hold。
10. 测试与审查
# 验证 Pipeline 注册顺序;Risk 必须在 Transaction 之前。rg -n "RiskAssessmentPersistencePipelineBehavior|TransactionPipelineBehavior" \ src/Hosts/BitzOrcas.Api/Composition/ApiPipelineRegistration.cs
# 审查策略 key、默认值、回退和 JSON 元数据。rg -n "riskcontrol.policy|RiskPolicyConfig|RiskPolicy.Default|JsonSerializer" \ src/Platform/RiskControl -g '*.cs'
# 检查评估事实是否已增加 Attempt/Correlation 幂等字段;当前没有。rg -n "AttemptId|CorrelationId|TraceId" \ src/Platform/RiskControl -g '*.cs'