# V37 防刷风险概览与运营规则配置 > 状态:**已完成并通过真实 MySQL/Docker 黑盒验收(PASS=76 / FAIL=0 / SKIP=0)**。 > 功能版本:V37;Flyway:`V36__risk_governance.sql`。 > 基线:OpenAPI 148 paths / 182 operations;V35、V36、V37 待建立联合 Git 检查点。 ## 1. 目标与边界 本模块为 ADMIN 提供确定性、可解释的防刷风险概览与规则配置能力。它将既有拒绝型安全审计事件转为可聚合的风险事件,供运营人员查看趋势、分布、明细及进行人工标记。 本版只做以下内容: - 基于既有 `audit_logs` 的规则型风险检测; - ADMIN 风险概览、趋势、分布、事件列表; - ADMIN 启停或调整阈值、窗口、等级; - ADMIN 对风险事件做无自由文本的人工标记; - 全链路审计、脱敏、幂等扫描与并发保护。 明确不做: - 自动封禁、自动警告、自动限制投递/消息/评分; - AI 风控、设备指纹、地理位置、IP 归因、手机号或邮箱展示; - 修改 V33 举报限流、屏蔽语义,或修改 V35/V36 测评和评分引擎; - 企业端、学生端风险事件查询; - 对历史 `audit_logs.details` 的回填、修复或删除。 ## 2. 现有能力与输入范围 现有 `SecurityAuditService` 已以独立事务写入拒绝型安全审计,当前可安全复用且可关联用户的输入只有: | 审计 action | 来源 | 初始规则用途 | | --- | --- | --- | | `REPORT_RATE_LIMITED` | V33 举报第 11 次及以后被拒绝 | 一次命中即 HIGH 风险 | | `BLOCKED_SEND_PROHIBITED` | V33 屏蔽关系下的新建私聊或发消息被拒绝 | 窗口内多次命中为 MEDIUM 风险 | `SMS_RATE_LIMITED` 当前不具备安全审计中的用户主体,且原始手机号不能进入风险库;本版不纳入。后续如需纳入,必须单独设计不可逆主体哈希和密钥治理。 风险计算只读取 `audit_logs` 的 `id/user_id/action/entity_type/entity_id/request_id/created_at`,不复制 `details/ip_address/user_agent`。 ## 3. 风险规则 规则是确定性阈值条件:在 `windowMinutes` 滑动窗口内,某用户同类安全审计数量达到 `thresholdCount` 后,生成一条风险事件。事件是提醒,不改变业务权限。 初始种子规则: | ruleCode | sourceAction | thresholdCount | windowMinutes | severity | 默认启用 | | --- | --- | ---: | ---: | --- | --- | | `REPORT_RATE_LIMIT_BURST` | `REPORT_RATE_LIMITED` | 1 | 60 | HIGH | true | | `BLOCKED_MESSAGE_BURST` | `BLOCKED_SEND_PROHIBITED` | 3 | 15 | MEDIUM | true | 规则更新只影响后续扫描;不回溯重算、不会删除既有风险事件。若启用后需重新观察历史,应在未来另行设计“回放”操作,避免误产生大量旧告警。 ## 4. 数据模型与 Flyway 迁移文件为 `V36__risk_governance.sql`。V36 是功能版本号,Flyway 当前最新迁移为 V35,因此使用严格连续的 V36。 ### 4.1 `scoring_rule_configs` | 字段 | 约束/用途 | | --- | --- | | `id` | PK | | `rule_code` | `UNIQUE`,稳定规则标识 | | `source_action` | 审计 action 白名单 | | `threshold_count` | 1–100 | | `window_minutes` | 1–1440 | | `severity` | `LOW/MEDIUM/HIGH` | | `enabled` | 是否参与后续扫描 | | `version` | `@Version` 乐观锁 | | `created_at/updated_at` | 审计时间 | ### 4.2 `scoring_risk_events` | 字段 | 约束/用途 | | --- | --- | | `id` | PK;对外只返回此事件 ID | | `rule_config_id` | FK → `scoring_rule_configs` | | `subject_user_id` | 内部关联用户,**所有 API 响应均不返回** | | `source_audit_log_id` | 触发本事件的审计记录;与 `rule_config_id` 联合唯一,保证扫描幂等 | | `source_action` | 冗余快照,支持稳定统计 | | `severity` | 生成时规则等级快照 | | `window_started_at/window_ended_at` | 命中统计窗口 | | `occurrence_count` | 命中数量 | | `status` | `OPEN/ACKNOWLEDGED/DISMISSED` | | `detected_at` | 检测时间 | | `version` | 乐观锁 | 索引:`(detected_at,status)`、`(severity,detected_at)`、`(rule_config_id,detected_at)`、`(subject_user_id,detected_at)`。 ### 4.3 `scoring_risk_actions` 风险事件人工操作历史。为防止运营备注成为新的个人信息载体,本版不提供自由文本 `note`。 | 字段 | 约束/用途 | | --- | --- | | `id` | PK | | `risk_event_id` | FK → `scoring_risk_events` | | `operator_user_id` | ADMIN 操作人,响应不返回 | | `action` | `ACKNOWLEDGE/DISMISS` | | `reason_code` | `MONITORING/CONFIRMED_FALSE_POSITIVE/OUT_OF_BAND_ESCALATED` | | `created_at` | 操作时间 | ### 4.4 `scoring_risk_scan_checkpoints` 持久化扫描游标,解决应用重启、暂停或扫描耗时导致的审计事件遗漏问题。该表只保留一条 `checkpoint_key=RISK_EVENT_DETECTOR` 的记录。 | 字段 | 约束/用途 | | --- | --- | | `id` | PK | | `checkpoint_key` | `UNIQUE`,固定为 `RISK_EVENT_DETECTOR` | | `last_audit_log_id` | 已成功处理的最大 `audit_logs.id`,默认 0 | | `version` | 乐观锁 | | `updated_at` | 最近成功推进时间 | 迁移同时为 `audit_logs` 新增 `(action, user_id, created_at, id)` 复合索引,仅优化风险扫描,不改变任何既有审计语义。 ## 5. 检测与状态机 ### 5.1 扫描器 `RiskEventDetector` 使用 `@Scheduled` 固定延迟扫描(默认 60 秒,可配置 `app.risk.scan.fixed-delay-ms`),其核心 `scanOnce()` 必须可被测试直接调用。每轮: 1. 在一个事务中以悲观锁或等价机制锁定 `RISK_EVENT_DETECTOR` checkpoint; 2. 按 `audit_logs.id > last_audit_log_id`、升序、每批最多 500 条读取新增审计;仅处理 `user_id` 非空且 action 在规则白名单内的记录; 3. 读取全部 `enabled=true` 的规则。已禁用规则对应的新增审计会随游标跳过;重新启用不回放历史; 4. 以“当前审计记录时间 - windowMinutes”为窗口,统计同一用户、同一 action 的命中数; 5. **仅当命中数恰好等于 `thresholdCount`** 时生成事件,避免阈值之后的每次操作都刷出一条风险事件; 6. 按 `(rule_config_id, source_audit_log_id)` 原子插入事件;唯一键冲突视为已处理,不报错; 7. 成功处理整批后,在同一事务推进 `last_audit_log_id`;任何处理失败必须回滚,游标不得前进; 8. 写入 `RISK_EVENT_DETECTED` 审计,但 details 仅包含 `riskEventId/ruleCode/severity/occurrenceCount/requestId`。 扫描器不处理无用户主体的审计,也不会发送通知、修改用户状态或调用评分引擎。首版允许最长约 60 秒可见延迟。 ### 5.2 事件状态机 ```text OPEN ──ACKNOWLEDGE──> ACKNOWLEDGED OPEN ──DISMISS──────> DISMISSED ACKNOWLEDGED ──DISMISS──> DISMISSED ``` `DISMISSED` 为终态;不能重新打开。每次合法状态变更新增一条 `scoring_risk_actions` 记录,并写 `RISK_EVENT_ACTIONED` 审计。 ## 6. 接口契约(仅 ADMIN) 除 `401` 外,所有非 ADMIN 情况统一 `403/2002`。Controller 从 JWT `SecurityContext` 取身份,禁止接受 `adminUserId/operatorUserId/userId` 参数。 | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/admin/scoring/risk/overview` | 时间范围内 OPEN 总数、等级分布、规则分布、最近检测时间 | | GET | `/admin/scoring/risk/trend` | 按 `HOUR/DAY` 聚合的事件趋势 | | GET | `/admin/scoring/risk/distribution` | 按规则和风险等级聚合的事件分布 | | GET | `/admin/scoring/risk/events` | 分页事件列表;可按 `status/severity/ruleCode/from/to` 筛选 | | GET | `/admin/scoring/risk/rules` | 规则列表与当前版本 | | PUT | `/admin/scoring/risk/rules/{ruleCode}` | 更新规则,须提供 `expectedVersion` | | POST | `/admin/scoring/risk/events/{eventId}/actions` | `ACKNOWLEDGE/DISMISS` 人工操作 | 预期 OpenAPI:`141 → 148 paths`,`175 → 182 operations`。 ### 6.1 规则更新请求 ```json { "enabled": true, "thresholdCount": 3, "windowMinutes": 15, "severity": "MEDIUM", "expectedVersion": 2 } ``` 不允许修改 `ruleCode` 或 `sourceAction`,避免配置被用于监听未批准的审计类型。 ### 6.2 事件操作请求 ```json { "action": "ACKNOWLEDGE", "reasonCode": "MONITORING", "expectedVersion": 0 } ``` ### 6.3 事件响应白名单 事件列表或详情只含:`eventId/ruleCode/sourceAction/severity/status/occurrenceCount/windowStartedAt/windowEndedAt/detectedAt/version`。 严禁含:`subjectUserId/phone/email/ipAddress/userAgent/requestId/details/Token/password/设备指纹`。 ## 7. 错误码 新增 99xx 段: | code | 名称 | HTTP | | ---: | --- | ---: | | 9901 | `RISK_RULE_NOT_FOUND` | 404 | | 9902 | `RISK_EVENT_NOT_FOUND` | 404 | | 9903 | `RISK_RULE_CONCURRENT_UPDATE` | 409 | | 9904 | `RISK_EVENT_CONCURRENT_UPDATE` | 409 | | 9905 | `RISK_INVALID_ACTION` | 409 | | 9906 | `RISK_INVALID_FILTER` | 400 | 复用:未登录 `401/2001`、无权限 `403/2002`、分页或时间参数错误 `400/1001`。 ## 8. 查询、脱敏与性能 - 全部 overview/trend/distribution 使用 SQL `GROUP BY` 投影,不在 Java 循环中逐事件查询规则或用户,避免 N+1; - 事件列表分页 `page=0` 起、`size=1–50`;空结果为 `200 + items=[]`; - 默认时间范围为最近 7 天,最大查询范围 90 天;`from > to` 或超出 90 天返回 `9906`; - 对外 DTO 均使用字段白名单;不返回 `subject_user_id`; - 不复制 `audit_logs.details/ip_address/user_agent` 至风险事件; - 规则更新和事件操作均乐观锁;冲突不覆盖对方操作; - 定时扫描使用 checkpoint 断点续扫;事件唯一键保证重复扫描不会重复产生事件,也不会因服务停机而漏掉未处理审计。 ## 9. 审计 | action | 触发点 | details 白名单 | | --- | --- | --- | | `RISK_EVENT_DETECTED` | 系统检测到阈值事件 | `riskEventId/ruleCode/severity/occurrenceCount` | | `RISK_RULE_UPDATED` | ADMIN 更新规则 | `ruleCode/enabled/thresholdCount/windowMinutes/severity/version` | | `RISK_EVENT_ACTIONED` | ADMIN 标记风险事件 | `riskEventId/action/reasonCode/version` | 审计 details 不允许写入用户身份、原始审计 details、IP、浏览器信息、请求正文或任何凭据。 ## 10. 实现与测试计划 新增:4 Entity、4 Repository、`RiskEventDetector`、`RiskGovernanceService`、`AdminRiskController`、请求/响应 DTO、`V36__risk_governance.sql`、错误码与 OpenAPI 文档。 测试: - 单元:阈值、滑动窗口、规则禁用、仅阈值首次命中生成事件、checkpoint 断点续扫、扫描失败游标不前进、唯一键幂等、状态机、乐观锁、脱敏; - MockMvc:ADMIN 成功、STUDENT/COMPANY 403、匿名 401、分页/时间参数、响应不含敏感字段; - H2 持久化:Flyway、四表 FK/UNIQUE/CHECK、扫描重复执行只生成一条事件、checkpoint 并发锁、规则并发更新; - MySQL/Docker 黑盒:插入本轮独立的安全审计夹具 → 等待扫描 → 验证概览/趋势/分布/事件/规则/人工操作;验证无自动封禁、无 `candidate_dimension_scores` 写入、队列排空,并回归 V33/V35/V36。 ## 11. 验收与冻结条件 1. 专项与全量测试全绿; 2. `verify_openapi.py` 静态路径/操作数校验通过; 3. 真实 MySQL Flyway V36 成功,三表/索引/约束可查; 4. Docker 黑盒 `FAIL=0`,关键权限、去重、脱敏、队列、回归项不得 SKIP; 5. 风险事件不会改变用户状态、投递、消息、评分或候选人维度分; 6. 经负责人审阅精确提交范围后,才与 V35/V36 一并建立 Git 检查点。 ## 12. 待负责人确认 建议按本稿锁定以下选择后再编码: 1. 规则输入首版仅限 `REPORT_RATE_LIMITED` 与 `BLOCKED_SEND_PROHIBITED`; 2. 风险事件 API 不返回主体用户 ID,运营通过 `eventId` 处理,不做自动处置; 3. 保留 `POST /admin/scoring/risk/events/{eventId}/actions`,因此接口增量为 +7/+7; 4. 风险事件人工操作仅允许受控 `reasonCode`,不支持自由文本备注; 5. 使用 checkpoint 驱动的定时扫描(默认 60 秒)而非把风险检测同步耦合到业务请求; 6. 使用 `V36__risk_governance.sql` 与 9901–9906 错误码。