diff --git a/alembic/versions/20260911_risk_rule_index.py b/alembic/versions/20260911_risk_rule_index.py new file mode 100644 index 0000000..35d701d --- /dev/null +++ b/alembic/versions/20260911_risk_rule_index.py @@ -0,0 +1,80 @@ +"""给 `fin_risk_alert.trigger_rule_codes` 补 JSON 多值索引(docs/25 P3 #21)。 + +修订原因:规则命中查询走的是 `JSON_CONTAINS(trigger_rule_codes, '"RW-018"')` +(`risk_repository.py` 的 `contains`),而 `docs/25` 第 21 条实测该表**没有**任何可用于 +该表达式的索引 —— `information_schema.STATISTICS` 里 `fin_risk_alert` 只有 11 个普通 +BTREE 索引(`status`、`customer_id`、`created_at` 等)+ 主键 + `alert_no` 唯一键, +`EXPLAIN` 显示 `type=ALL`、`possible_keys=NULL`,即每次按规则码筛选都是全表扫描。 + +为什么用**多值索引**而不是普通索引:`trigger_rule_codes` 是 `json` 列,存的是数组 +(实测取值形如 `["RW-007", "RW-002", "RW-012"]`)。MySQL 8.0.17 起支持在 JSON 数组上 +建多值索引,且优化器在 `JSON_CONTAINS` / `MEMBER OF` / `JSON_OVERLAPS` 上可以使用它 —— +这正是本条要解决的那个谓词。当前实例版本 8.0.27,满足条件。 + +`CAST(... AS CHAR(16) ARRAY)` 中的 16:规则码是 `RW-###`(5 字符),留足余量而不至于让 +索引项过大。**注意**这不是"截断匹配"—— 多值索引要求列上所有取值都能装进声明长度, +否则 `ALTER` 会直接失败(报 3903),而不是悄悄少索引几行。 + +前置条件已在 `tools/probe_risk_index.py` 里验证并留档 +(`docs/evidence/risk-index-probe.json`):3 行数据全部是 JSON 数组, +`non_array_rows = 0`。 + +**本条只解决一半**:另一处索引失效是若干 `like(f"%{keyword}%")` 全表扫 —— 前后都有 +通配符的模糊匹配在 B-tree 上无法索引,唯一出路是全文索引(中文需要分词组件,属于部署 +依赖)。因此这里**不**假装解决它,只把它记为已知限制,见迁移提交说明与 `docs/25`。 + +基线合规(`AGENTS.md` 第 2/3/4 条):本迁移只**新增一个索引**,不建表、不加列、不改列, +不重命名、不删除任何已有表或字段,也不改变任何已有字段的类型、可空性与业务含义。 +`docs/00-新数据库基线设计.md` 未修改。 + +幂等性:先查 `information_schema.STATISTICS` 再决定是否 `ALTER`,重复执行不会报 1061。 +`downgrade` 与之对称,回退后结构与迁移前完全一致。 +""" + +from sqlalchemy import text +from sqlalchemy.engine import Connection + +from alembic import op + +# revision 名必须 ≤ 32 字符:`alembic_version.version_num` 是 VARCHAR(32), +# 超长会在写版本号时报 1406 Data too long —— 而 DDL 是非事务的,那时索引已经建好了。 +revision = "20260911_risk_rule_index" +down_revision = "20260910_drop_review_separation" +branch_labels = None +depends_on = None + +TABLE = "fin_risk_alert" +INDEX = "idx_fin_risk_alert_trigger_rule_codes" +EXPRESSION = "CAST(`trigger_rule_codes` AS CHAR(16) ARRAY)" + + +def _index_exists(bind: Connection) -> bool: + found = bind.execute( + text( + """ + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table + AND INDEX_NAME = :name + LIMIT 1 + """ + ), + {"table": TABLE, "name": INDEX}, + ).first() + return found is not None + + +def upgrade() -> None: + bind = op.get_bind() + if _index_exists(bind): + return + # 表名/索引名/表达式都是本模块常量,不含外部输入。 + op.execute(f"ALTER TABLE `{TABLE}` ADD INDEX `{INDEX}` (({EXPRESSION}))") + + +def downgrade() -> None: + bind = op.get_bind() + if not _index_exists(bind): + return + op.execute(f"ALTER TABLE `{TABLE}` DROP INDEX `{INDEX}`") diff --git a/app/api/controllers/agent_runs.py b/app/api/controllers/agent_runs.py index 37b519d..330e2b2 100644 --- a/app/api/controllers/agent_runs.py +++ b/app/api/controllers/agent_runs.py @@ -7,6 +7,7 @@ from starlette.responses import StreamingResponse from app.api.dependencies.auth import build_request_context from app.api.dependencies.database import get_session +from app.api.dependencies.negotiation import accepts_event_stream from app.api.dependencies.rate_limit import enforce_rate_limit from app.api.schemas.agent_runs import ( AgentRunAcceptedEnvelope, @@ -25,40 +26,6 @@ from app.service.run_query_service import RunQueryService router = APIRouter(prefix="/api/v1/agent-runs", tags=["agent-runs"], dependencies=[Depends(enforce_rate_limit)]) -SSE_MEDIA_TYPE = "text/event-stream" - - -def accepts_event_stream(accept: str | None) -> bool: - """`Accept` 是否接受 `text/event-stream`(文档 §3.2:该头**非必填**)。 - - - 未携带(`None` 或空串)→ 放行:文档写明"默认 `application/json`;SSE 为 - `text/event-stream`",即由接口自身决定响应类型,不是客户端错误; - - 携带 `text/event-stream`、`text/*` 或 `*/*` 且 `q != 0` → 放行; - - 显式携带但只接受其他类型(如 `application/json`)→ 拒绝,由调用方转 - `406 SSE_NOT_ACCEPTABLE`(文档 §3.5/§6.4)。 - - 只做"是否可接受"的判定,不参与内容协商排序:SSE 端点只有一种表示。 - """ - if accept is None or not accept.strip(): - return True - for entry in accept.split(","): - parts = entry.split(";") - media_type = parts[0].strip().lower() - if media_type not in {SSE_MEDIA_TYPE, "text/*", "*/*"}: - continue - quality = 1.0 - for parameter in parts[1:]: - name, _, value = parameter.partition("=") - if name.strip().lower() == "q": - try: - quality = float(value.strip()) - except ValueError: - quality = 0.0 - if quality > 0: - return True - return False - - @router.post( "", response_model=AgentRunAcceptedEnvelope, diff --git a/app/api/controllers/risk.py b/app/api/controllers/risk.py index b938357..878debe 100644 --- a/app/api/controllers/risk.py +++ b/app/api/controllers/risk.py @@ -1,15 +1,17 @@ """风控只读查询接口。""" import json -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable, Callable from datetime import datetime, time +from typing import Any -from fastapi import APIRouter, Depends, File, Path, UploadFile +from fastapi import APIRouter, Depends, File, Header, Path, Request, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import StreamingResponse from app.api.dependencies.auth import build_request_context from app.api.dependencies.database import get_session +from app.api.dependencies.negotiation import accepts_event_stream from app.api.dependencies.rate_limit import enforce_rate_limit from app.api.schemas.risk import ( RiskAlertEscalationRequest, @@ -23,13 +25,16 @@ from app.api.schemas.risk import ( RiskNotificationPageQuery, ) from app.core.contracts import RequestContext +from app.core.errors import SseNotAcceptableError +from app.infrastructure.db import mysql_scan_lock +from app.service.api_transaction_service import ApiTransactionService from app.service.risk_action_service import RiskActionService from app.service.risk_daily_report_mail_service import RiskDailyReportMailService from app.service.risk_daily_report_service import RiskDailyReportService from app.service.risk_evidence_archive_service import RiskEvidenceArchiveService from app.service.risk_notification_service import RiskNotificationService from app.service.risk_query_service import RiskQueryService -from app.service.risk_scan_service import RiskScanService +from app.service.risk_scan_service import RiskScanBusyError, RiskScanService router = APIRouter( prefix="/api/v1/risk", @@ -38,6 +43,26 @@ router = APIRouter( ) +async def _idempotent_write( + session: AsyncSession, + context: RequestContext, + key: str | None, + scope: str, + body: Any, + action: Callable[[AsyncSession], Awaitable[dict[str, Any]]], +) -> dict[str, Any]: + """风控写接口的统一幂等入口(`docs/05` §5.1、§5.2、§13.2)。 + + `scope` 用**实际**路径(含 `alert_no`):§5.1 的幂等范围是 + `user_id + method + normalized_path + idempotency_key`,把路径参数折成模板会让 + 同一个键在不同预警之间互相回放 —— 那是把两次不同资源的操作当成一次。 + + 幂等记录与业务写入同事务(见 `ApiTransactionService.execute_in`),重复请求直接 + 回放 `response_json`,不会二次驱动状态机。 + """ + return await ApiTransactionService().execute_in(session, context, scope, key, body, action) + + @router.get("/overview") async def risk_overview( context: RequestContext = Depends(build_request_context), # noqa: B008 @@ -54,15 +79,28 @@ async def list_risk_alerts( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskQueryService(session).list_alerts(context, query) - return _envelope(data, context) + return _list_envelope(data, context) @router.post("/alerts/scan") async def scan_risk_alerts( context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: - data = await RiskScanService(session).scan(context) + async def run(inner: AsyncSession) -> dict[str, Any]: + # 手工触发的扫描必须与定时扫描互斥,否则两条路径会同时查不到重复、同时插入。 + # 锁加在**入口层**而不是 `RiskScanService.scan()` 内部:`GET_LOCK` 是连接级的, + # 而调度器已在它自己的 session 上持锁 —— 被两个入口共用的服务方法若再取同一把锁, + # 取锁的连接不是持锁的那一个、必然失败,会**把定时扫描自己挡死**。 + async with mysql_scan_lock() as acquired: + if not acquired: + raise RiskScanBusyError("规则扫描正在执行,请稍后重试") + return await RiskScanService(inner).scan(context) + + data = await _idempotent_write( + session, context, key, "POST /api/v1/risk/alerts/scan", {}, run + ) return _envelope(data, context) @@ -71,8 +109,16 @@ async def acknowledge_risk_alert( alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"), context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: - data = await RiskActionService(session).acknowledge(alert_no, context) + data = await _idempotent_write( + session, + context, + key, + f"POST /api/v1/risk/alerts/{alert_no}/acknowledgements", + {}, + lambda inner: RiskActionService(inner).acknowledge(alert_no, context), + ) return _envelope(data, context) @@ -81,8 +127,16 @@ async def investigate_risk_alert( alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"), context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: - data = await RiskActionService(session).investigate(alert_no, context) + data = await _idempotent_write( + session, + context, + key, + f"POST /api/v1/risk/alerts/{alert_no}/investigations", + {}, + lambda inner: RiskActionService(inner).investigate(alert_no, context), + ) return _envelope(data, context) @@ -92,8 +146,16 @@ async def exclude_risk_alert( alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"), context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: - data = await RiskActionService(session).exclude(alert_no, payload.reason, context) + data = await _idempotent_write( + session, + context, + key, + f"POST /api/v1/risk/alerts/{alert_no}/exclusions", + {"reason": payload.reason}, + lambda inner: RiskActionService(inner).exclude(alert_no, payload.reason, context), + ) return _envelope(data, context) @@ -103,8 +165,16 @@ async def resolve_risk_alert( alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"), context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: - data = await RiskActionService(session).resolve(alert_no, payload.resolution, context) + data = await _idempotent_write( + session, + context, + key, + f"POST /api/v1/risk/alerts/{alert_no}/resolutions", + {"resolution": payload.resolution}, + lambda inner: RiskActionService(inner).resolve(alert_no, payload.resolution, context), + ) return _envelope(data, context) @@ -114,8 +184,16 @@ async def escalate_risk_alert( alert_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"), context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: - data = await RiskActionService(session).escalate(alert_no, payload.reason, context) + data = await _idempotent_write( + session, + context, + key, + f"POST /api/v1/risk/alerts/{alert_no}/escalations", + {"reason": payload.reason}, + lambda inner: RiskActionService(inner).escalate(alert_no, payload.reason, context), + ) return _envelope(data, context) @@ -151,7 +229,7 @@ async def list_risk_evidence( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskQueryService(session).list_evidence(context, source, query) - return _envelope(data, context) + return _list_envelope(data, context) @router.get("/notifications") @@ -161,7 +239,7 @@ async def list_risk_notifications( session: AsyncSession = Depends(get_session), # noqa: B008 ) -> dict[str, object]: data = await RiskNotificationService(session).list_notifications(context, query) - return _envelope(data, context) + return _list_envelope(data, context) @router.post("/daily-report") @@ -182,6 +260,7 @@ async def generate_risk_daily_report( @router.post("/daily-report/stream") async def stream_risk_daily_report( payload: RiskDailyReportGenerateRequest, + request: Request, context: RequestContext = Depends(build_request_context), # noqa: B008 session: AsyncSession = Depends(get_session), # noqa: B008 ) -> StreamingResponse: @@ -190,9 +269,16 @@ async def stream_risk_daily_report( if payload.report_date is not None else None ) + service = RiskDailyReportService(session) + # 鉴权与内容协商都必须在返回 StreamingResponse **之前**完成:`stream()` 是 async + # generator,函数体到第一次迭代才执行,而那时响应头已经发出去了 —— 403/406 只能 + # 变成"200 + 半截流"(docs/25 P3 #24)。顺序与 §6.4 一致:先鉴权,后 Accept。 + await service.authorize(context) + if not accepts_event_stream(request.headers.get("Accept")): + raise SseNotAcceptableError("Accept 必须接受 text/event-stream") async def events() -> AsyncIterator[str]: - async for event in RiskDailyReportService(session).stream(context, report_time): + async for event in service.stream(context, report_time): event_type = str(event.get("type", "message")) payload = json.dumps(event, ensure_ascii=False, default=str) yield f"event: {event_type}\ndata: {payload}\n\n" @@ -209,10 +295,11 @@ async def send_risk_daily_report_mail( payload: RiskDailyReportMailRequest, context: RequestContext = Depends(build_request_context), # noqa: B008 ) -> dict[str, object]: - data = RiskDailyReportMailService().send( + data = await RiskDailyReportMailService().send( payload.recipients, payload.subject, payload.content, + context=context, ) return _envelope(data, context) @@ -222,3 +309,23 @@ def _envelope(data: object, context: RequestContext) -> dict[str, object]: "data": data, "meta": {"trace_id": context.trace_id}, } + + +def _list_envelope(page: dict[str, Any], context: RequestContext) -> dict[str, object]: + """列表资源的信封(docs/05 §3.3)。 + + §3.3 的列表样例是 `data` 为**纯数组**、游标与 `has_more` 放在 `meta` 里,并且明确 + 「业务接口不得增加其他顶层字段」。而 `RiskQueryService._page` 返回的是 + `{items, next_cursor, has_more}` —— 整体塞进 `data` 后,游标跑进了**业务数据**里、 + `meta` 只剩 trace_id,两处都不符合契约。 + + 这里统一拆包;service 侧不必改(它继续返回那个内部结构,只是不再直接当 `data` 用)。 + """ + return { + "data": page.get("items") or [], + "meta": { + "trace_id": context.trace_id, + "next_cursor": page.get("next_cursor"), + "has_more": bool(page.get("has_more")), + }, + } diff --git a/app/api/dependencies/negotiation.py b/app/api/dependencies/negotiation.py new file mode 100644 index 0000000..857f936 --- /dev/null +++ b/app/api/dependencies/negotiation.py @@ -0,0 +1,47 @@ +"""SSE 内容协商(`docs/05` §3.2、§3.5)。 + +`Accept` 在文档里是**非必填**头,所以判定规则是"客户端是否**显式拒绝**了 +`text/event-stream`",而不是"客户端是否显式接受"。 + +放在这里而不是写在某个 Controller 里,是因为平台有两个 SSE 端点 +(`/api/v1/agent-runs/{run_id}/events` 与 `/api/v1/risk/daily-report/stream`):口径 +一旦分叉,同一份客户端代码就会在一个端点上拿到 200、在另一个端点上拿到 406。 +""" + +from __future__ import annotations + +SSE_MEDIA_TYPE = "text/event-stream" + +# `text/*` 与 `*/*` 都覆盖 `text/event-stream`,属于"可接受"。 +ACCEPTABLE_SSE_TYPES = frozenset({SSE_MEDIA_TYPE, "text/*", "*/*"}) + + +def accepts_event_stream(accept: str | None) -> bool: + """`Accept` 是否接受 `text/event-stream`(文档 §3.2:该头**非必填**)。 + + - 未携带(`None` 或空串)→ 放行:文档写明"默认 `application/json`;SSE 为 + `text/event-stream`",即由接口自身决定响应类型,不是客户端错误; + - 携带 `text/event-stream`、`text/*` 或 `*/*` 且 `q != 0` → 放行; + - 显式携带但只接受其他类型(如 `application/json`)→ 拒绝,由调用方转 + `406 SSE_NOT_ACCEPTABLE`(文档 §3.5/§6.4)。 + + 只做"是否可接受"的判定,不参与内容协商排序:SSE 端点只有一种表示。 + """ + if accept is None or not accept.strip(): + return True + for entry in accept.split(","): + parts = entry.split(";") + media_type = parts[0].strip().lower() + if media_type not in ACCEPTABLE_SSE_TYPES: + continue + quality = 1.0 + for parameter in parts[1:]: + name, _, value = parameter.partition("=") + if name.strip().lower() == "q": + try: + quality = float(value.strip()) + except ValueError: + quality = 0.0 + if quality > 0: + return True + return False diff --git a/app/core/risk_cursor.py b/app/core/risk_cursor.py index 0481de7..6a28883 100644 --- a/app/core/risk_cursor.py +++ b/app/core/risk_cursor.py @@ -1,22 +1,66 @@ -"""风控列表游标;对外是不透明字符串,内部保存页偏移。""" +"""风控列表游标;对外是不透明字符串,内部保存页偏移与**绑定指纹**。 + +`docs/05` §3.8 要求「游标是不透明字符串,绑定用户、查询条件、排序字段和方向, +客户端不得解析或修改」;§3.6 的错误表把「**与过滤条件不符**」明确列为 `INVALID_CURSOR` +的触发条件之一(§16 同样写「分页游标绑定过滤条件」)。 + +原先这里只存 `{"offset": n}`,什么都没绑定,于是: + +- 任何拿到游标的人都能拿它去翻**别人的**结果集(越权); +- 改了筛选条件还能继续用旧游标,而 offset 分页在结果集变化时本来就会跳行/重复, + 两者叠加会**静默返回错位的数据**。 + +现在游标里额外存一个**绑定指纹**:由「用户 + 查询条件 + 排序」压成。服务端解码时按当前 +请求重算指纹并比对,不一致就报 `INVALID_CURSOR`。 + +指纹用 SHA-256(无需密钥):它要防的是"无意复用",不是恶意伪造。 +真正的防篡改需要 HMAC 与密钥管理,属于后续加固项 —— 这里不假装做到了。 +""" import base64 +import hashlib import json +from collections.abc import Mapping from typing import Any from app.core.errors import InvalidCursorError -__all__ = ["decode_offset_cursor", "encode_offset_cursor"] +__all__ = ["cursor_binding", "decode_offset_cursor", "encode_offset_cursor"] + +_BINDING_KEY = "b" -def encode_offset_cursor(offset: int) -> str: +def cursor_binding(*, user_id: str, filters: Mapping[str, Any], order_by: str = "") -> str: + """把「谁、按什么条件、按什么顺序」压成一个稳定指纹。 + + `sort_keys=True` 让字典顺序不影响结果;`default=str` 兜住日期与 Decimal 之类不可直接 + 序列化的筛选值(它们同样应当参与比对)。 + """ + material = json.dumps( + { + "user_id": str(user_id), + "filters": {str(key): value for key, value in filters.items()}, + "order_by": order_by, + }, + sort_keys=True, + ensure_ascii=False, + default=str, + separators=(",", ":"), + ) + return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32] + + +def encode_offset_cursor(offset: int, *, binding: str) -> str: if offset < 0: raise ValueError("offset must be non-negative") - payload = json.dumps({"offset": offset}, separators=(",", ":")).encode("utf-8") + payload = json.dumps( + {"offset": offset, _BINDING_KEY: binding}, separators=(",", ":") + ).encode("utf-8") return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") -def decode_offset_cursor(raw: str | None) -> int: +def decode_offset_cursor(raw: str | None, *, binding: str) -> int: + """解码游标并校验绑定指纹;任何不一致都按非法游标处理(400 INVALID_CURSOR)。""" if raw is None or not raw.strip(): return 0 value = raw.strip() @@ -26,8 +70,12 @@ def decode_offset_cursor(raw: str | None) -> int: decoded: Any = json.loads(payload.decode("utf-8")) except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error: raise InvalidCursorError("cursor 非法或已过期") from error - if not isinstance(decoded, dict) or set(decoded) != {"offset"}: + if not isinstance(decoded, dict) or set(decoded) != {"offset", _BINDING_KEY}: raise InvalidCursorError("cursor 非法或已过期") + if decoded[_BINDING_KEY] != binding: + # 换了用户、改了筛选条件或排序:旧游标对新查询没有意义。继续用会静默返回错位的 + # 数据 —— 这正是 docs/05 把"与过滤条件不符"列为 INVALID_CURSOR 的原因。 + raise InvalidCursorError("cursor 与当前查询条件不符") offset = decoded["offset"] if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: raise InvalidCursorError("cursor 非法或已过期") diff --git a/app/core/timeutil.py b/app/core/timeutil.py new file mode 100644 index 0000000..426a9e7 --- /dev/null +++ b/app/core/timeutil.py @@ -0,0 +1,83 @@ +"""时区换算:库内存 UTC naive,边界判断与展示按配置时区(默认北京时间)。 + +**为什么需要它**:风控的定时规则曾直接取 `confirmed_at.hour` 判断"凌晨",而库里存的是 +UTC —— `[0,6)` UTC 实际是北京时间 **08:00–14:00**,于是「凌晨时段小额操作」这条规则把 +整个上午的交易都判成了凌晨(`docs/25` P1 #6)。日报也按 UTC 日切,却按北京时间展示, +两处对同一天的理解差 8 小时。 + +**约定**(与 `app/infrastructure/db.py:15-21` 的说明一致): + +- 库内 DATETIME 为 **UTC naive**,不带时区; +- 任何"这是本地几点 / 哪一天"的判断,都必须先经过本模块换算; +- 展示同样走这里,保证与 `get_settings().timezone` 一致。 +""" + +from datetime import UTC, date, datetime, time, timedelta +from zoneinfo import ZoneInfo + +from app.core.config import get_settings + + +def local_zone() -> ZoneInfo: + """业务判断与展示统一使用的本地时区(默认 `Asia/Shanghai`)。""" + return ZoneInfo(get_settings().timezone) + + +def to_local(value: datetime) -> datetime: + """把库内的 UTC naive 时间换算成本地时区。 + + 已经带时区的值按其原时区处理——那说明它来自库外(例如请求参数), + 按它自己声明的时区解释才是对的。 + """ + aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC) + return aware.astimezone(local_zone()) + + +def to_utc_naive(value: datetime) -> datetime: + """把任意时区的时间换算回**库内格式**(UTC naive),用于构造查询条件。 + + 查询参数必须经过这一步:拿本地时间直接去比库内的 UTC 列,会整体差 8 小时。 + """ + aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC) + return aware.astimezone(UTC).replace(tzinfo=None) + + +def from_local(value: datetime) -> datetime: + """把**客户端传来的时间参数**换算成库内格式(UTC naive)。 + + REST 的时间参数是裸 `datetime`(`api/schemas/risk.py:32-33`),不带时区信息。 + 对面向中国客户的业务系统,裸值按**本地(北京)时间**解释才符合填表人的预期: + 当成 UTC 会让前端填的"今天 00:00"实际查到昨天 08:00 起的数据。 + 带时区的值仍按它自己声明的时区处理。 + + 与 `to_utc_naive` 的区别就在裸值上:这个把裸值当**本地**,那个当 **UTC**。 + 取库里的值用后者,接客户端输入用这个。 + """ + aware = value if value.tzinfo is not None else value.replace(tzinfo=local_zone()) + return aware.astimezone(UTC).replace(tzinfo=None) + + +def local_hour(value: datetime) -> int: + """库内时间对应的**本地**小时(0-23)。 + + 用于"是否凌晨"这类按时段判断的规则——绝不能用 `value.hour`, + 那是 UTC 小时。 + """ + return to_local(value).hour + + +def local_date(value: datetime) -> date: + """库内时间对应的**本地**日期。""" + return to_local(value).date() + + +def local_day_bounds(value: datetime) -> tuple[datetime, datetime]: + """库内时间所在的**本地自然日**,换算回库内格式的起止时刻 `[start, end)`。 + + 返回的是 UTC naive:它要拿去查库内的 UTC 列。若返回本地时间, + 区间会与库内值整体错开 8 小时——这正是原先日报日界出错的成因。 + """ + local = to_local(value) + start_local = datetime.combine(local.date(), time.min, tzinfo=local.tzinfo) + start = to_utc_naive(start_local) + return start, start + timedelta(days=1) diff --git a/app/infrastructure/db.py b/app/infrastructure/db.py index bc60deb..4201a31 100644 --- a/app/infrastructure/db.py +++ b/app/infrastructure/db.py @@ -1,3 +1,7 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from sqlalchemy import text from sqlalchemy.engine import make_url from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -29,3 +33,36 @@ if "init_command" not in _url.query: engine = create_async_engine(_url, pool_pre_ping=True) SessionFactory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +# 规则扫描的**跨进程**咨询锁名。用 MySQL 连接级 `GET_LOCK` 而不是进程内 `asyncio.Lock`: +# 后者只在单个进程内互斥,多 Web worker、或 Worker 与 API 同时运行时形同虚设。 +SCAN_LOCK_NAME = "jr_risk_scan_schedule" + + +@asynccontextmanager +async def mysql_scan_lock() -> AsyncIterator[bool]: + """约束规则扫描的跨进程并发;yield 是否取得锁。 + + **为什么两个入口都必须用它**:定时扫描(`worker/risk_scan_scheduler.py`)与手工触发的 + HTTP 端点(`POST /api/v1/risk/alerts/scan`)最终都调 `RiskScanService.scan()`, + 而扫描的幂等只有应用层的 `_exists` 查重 —— 两条路径并发时会同时查不到、同时插入, + 产生重复预警。锁要**共用同一个名字**才能互相排斥。 + + **为什么不放在 `scan()` 内部**:`GET_LOCK` 是**连接级**的。调度器已在自己的 session 上 + 持锁,再新建 session 去调 `scan()`;若 `scan()` 又去取同一把锁,取锁的连接并不是持锁的 + 那一个,`GET_LOCK` 返回 0 —— **调度器会把自己挡死**。所以锁加在**入口层** + (调度器 / 端点各取一次),而不是被两个入口共用的服务方法里。 + + `GET_LOCK(name, 0)` 的 0 表示不等待、立即返回:取不到就跳过本轮,而不是排队。 + """ + async with SessionFactory() as session: + acquired = bool( + await session.scalar(text("SELECT GET_LOCK(:name, 0)"), {"name": SCAN_LOCK_NAME}) + ) + try: + yield acquired + finally: + if acquired: + await session.scalar( + text("SELECT RELEASE_LOCK(:name)"), {"name": SCAN_LOCK_NAME} + ) diff --git a/app/repository/identity_repository.py b/app/repository/identity_repository.py index 317a9d9..5441bca 100644 --- a/app/repository/identity_repository.py +++ b/app/repository/identity_repository.py @@ -42,9 +42,20 @@ class IdentityRepository: WHERE employee_id=:user_id AND assigned_at<=:now AND (unassigned_at IS NULL OR unassigned_at>:now) """), params)).all() + # data_scope 取该身份所有权限里的**最高**范围。 + # + # 原先这里写死 "self",于是上面刚算出来的 scope 白算了:permission_scopes 里 + # 明明有 all,data_scope 却永远是 self,凡是按 `context.data_scope == "all"` + # 判断能否看全量的路径全部走不通(风控的 risk_query_service.py:198、 + # risk_analysis_service.py:126、risk_evidence_archive_service.py:218、 + # risk_action_service.py:212 都是这样判断的)。实测 9002(risk_operator) 与 + # 9003(admin) 拿着 all 级权限却什么都查不到。 + # + # 没有 all 权限的角色行为不变(如 customer 仍是 self),所以这不放松任何既有边界。 + data_scope = max(scopes.values(), key=lambda value: rank[value]) if scopes else "self" return identity.model_copy(update={ "roles": roles, "permissions": tuple(sorted(scopes)), - "permission_scopes": scopes, "data_scope": "self", + "permission_scopes": scopes, "data_scope": data_scope, "customer_ids": tuple(str(value) for value in customers), "portal": "api", }) diff --git a/app/repository/risk_repository.py b/app/repository/risk_repository.py index 9d3e269..97d3493 100644 --- a/app/repository/risk_repository.py +++ b/app/repository/risk_repository.py @@ -41,6 +41,11 @@ from app.repository.fund_query_repository import ( ) OPEN_STATUSES = ("待处理", "调查中") +# 详情证据与日报都是"一次性取全量"的查询,某个客户的历史数据一旦异常膨胀就会把整个 +# 响应拖垮(docs/25 P3 #20)。这里给出显式上限:正常数据远达不到,超限则**在响应里 +# 标注截断**,不静默丢数据 —— 风控里"少显示几条却装作完整"比报错更危险。 +EVIDENCE_DETAIL_LIMIT = 200 +REPORT_ALERT_LIMIT = 5000 LOW_RISK = "低" MEDIUM_RISK = "中" HIGH_RISK = "高" @@ -61,6 +66,7 @@ class RiskReportSnapshot: unresolved: tuple[FundRecord, ...] false_positive: tuple[FundRecord, ...] dispositions: tuple[FundRecord, ...] + truncated: bool = False class RiskRepository: @@ -248,29 +254,23 @@ class RiskRepository: if alert.related_work_order_id is not None else None ) - capital_flows = list( - await self.session.scalars( - select(FundCapitalFlow) - .where(FundCapitalFlow.customer_id == alert.customer_id) - .order_by( - FundCapitalFlow.settled_at.desc(), - FundCapitalFlow.id.desc(), - ) + capital_flows, flows_truncated = await self._capped( + select(FundCapitalFlow) + .where(FundCapitalFlow.customer_id == alert.customer_id) + .order_by( + FundCapitalFlow.settled_at.desc(), + FundCapitalFlow.id.desc(), ) ) - holdings = list( - await self.session.scalars( - select(FundHolding) - .where(FundHolding.customer_id == alert.customer_id) - .order_by(FundHolding.id.desc()) - ) + holdings, holdings_truncated = await self._capped( + select(FundHolding) + .where(FundHolding.customer_id == alert.customer_id) + .order_by(FundHolding.id.desc()) ) - login_records = list( - await self.session.scalars( - select(RiskLoginRecord) - .where(RiskLoginRecord.user_id == alert.customer_id) - .order_by(RiskLoginRecord.login_at.desc(), RiskLoginRecord.id.desc()) - ) + login_records, logins_truncated = await self._capped( + select(RiskLoginRecord) + .where(RiskLoginRecord.user_id == alert.customer_id) + .order_by(RiskLoginRecord.login_at.desc(), RiskLoginRecord.id.desc()) ) values = { "alert": self._alert_row(alert, customer.user_no if customer else None, @@ -294,16 +294,44 @@ class RiskRepository: self._model_values(item) for item in login_records ], "evidence_snapshot": snapshot, + # 被截断的证据类型名;空列表表示本次详情是完整的。 + "evidence_truncated": [ + name + for name, flag in ( + ("capital_flows", flows_truncated), + ("holdings", holdings_truncated), + ("login_records", logins_truncated), + ) + if flag + ], } return FundRecord(entity="risk_alert_detail", values=MappingProxyType(values)) + async def _capped(self, statement: Select[Any]) -> tuple[list[Any], bool]: + """取前 `EVIDENCE_DETAIL_LIMIT` 行,并回答"是否还有更多"。 + + 用 `limit + 1` 多取一行判断截断:只看"取满没取满"会把恰好等于上限的正常数据 + 误报成截断。 + """ + rows = list(await self.session.scalars( + statement.limit(EVIDENCE_DETAIL_LIMIT + 1) + )) + return rows[:EVIDENCE_DETAIL_LIMIT], len(rows) > EVIDENCE_DETAIL_LIMIT + async def daily_report_snapshot( self, day_start: datetime, next_day: datetime, ) -> RiskReportSnapshot: - """一次读取日报所需的四组预警,历史未闭环不应用分页。""" + """一次读取日报所需的四组预警,历史未闭环不应用分页。 + + 每组都封顶 `REPORT_ALERT_LIMIT` 行;真被截断时 `truncated` 置位,由 Service + 在响应里显式暴露 —— 日报的计数来自行数,静默截断等于给出错误的统计口径。 + """ + truncated = False + async def load(*conditions: Any) -> tuple[FundRecord, ...]: + nonlocal truncated statement = select(FundRiskAlert).where(*conditions) scope = self._scope_condition( FundRiskAlert.customer_id, @@ -311,9 +339,12 @@ class RiskRepository: ) if scope is not None: statement = statement.where(scope) - rows = (await self.session.scalars( - statement.order_by(FundRiskAlert.id.asc()) - )).all() + rows = list(await self.session.scalars( + statement.order_by(FundRiskAlert.id.asc()).limit(REPORT_ALERT_LIMIT + 1) + )) + if len(rows) > REPORT_ALERT_LIMIT: + truncated = True + rows = rows[:REPORT_ALERT_LIMIT] return tuple( FundRecord( entity="risk_alert", @@ -349,6 +380,7 @@ class RiskRepository: unresolved=unresolved, false_positive=false_positive, dispositions=dispositions, + truncated=truncated, ) async def list_customers( @@ -962,6 +994,13 @@ class RiskRepository: "is_escalated": bool(alert.is_escalated), "escalated_at": alert.escalated_at, "evidence_archived": bool(snapshot.get("evidence_archive")), + # 关闭误报时写入的原因(risk_action_service.py:70 赋值给 alert.close_reason)。 + # 原先这一行不在,导致两个症状同一个原因 —— 行里根本没带出来: + # · 日报的"误报原因"分布恒为"未填写" + # (risk_daily_report_service.py:139 用 `item.get("close_reason") or "未填写"`); + # · `:243` 的明细里同一字段同样拿不到值。 + # 读取方都是风控侧接口(需要 risk:alert:read),带上它不涉及客户可见面。 + "close_reason": alert.close_reason, "created_at": alert.created_at, "updated_at": alert.updated_at, } diff --git a/app/service/agent/bootstrap.py b/app/service/agent/bootstrap.py index e3f47c5..e0c1b86 100644 --- a/app/service/agent/bootstrap.py +++ b/app/service/agent/bootstrap.py @@ -18,6 +18,15 @@ from app.service.agent.factory import AgentFactory from app.service.agent.governance import PlatformGovernance from app.service.agent.implementations.customer_service import CustomerServiceAgent from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent +from app.service.agent.implementations.platform_probe import ( + PROBE_ALT_TOOL, + PROBE_PERMISSION, + PROBE_TOOL, + PlatformProbeAgent, + ProbeEchoArgs, + probe_alt_tool, + probe_echo_tool, +) from app.service.agent.implementations.risk_agent import RiskAgent from app.service.customer_profile_service import ( CustomerProfileQuery, @@ -201,6 +210,28 @@ def build_memory_recall_service(session: AsyncSession) -> MemoryRecallService: def get_agent_factory() -> AgentFactory: """HTTP 与 Worker 共用的唯一底座依赖组装入口。""" registry = ToolRegistry() + # 基座验证探针的工具:只回显参数,用于端到端触发 ToolExecutor 的四种拒绝分支。 + # 它不查库、不写状态,注册在这里也不会被任何业务 Agent 的白名单引用。 + registry.register(ToolDefinition( + name=PROBE_TOOL, + input_model=ProbeEchoArgs, + handler=cast(Any, probe_echo_tool), + required_permission=PROBE_PERMISSION, + allowed_roles=("admin",), + )) + # 第二个探针工具,两个用途: + # ① 让"有白名单但不含该工具"的分支可被构造(见 platform_probe 的说明); + # ② 让**工具层**的"角色不能使用工具"分支可被构造 —— 它的角色集合**比 Agent 的更窄** + # (Agent 允许 admin,本工具只允许 risk_operator)。必须更窄才行:Agent 层的 + # validate_access 会先按 AgentDefinition.allowed_roles 拦截,两者一致时永远进不到 + # 工具层的角色校验。 + registry.register(ToolDefinition( + name=PROBE_ALT_TOOL, + input_model=ProbeEchoArgs, + handler=cast(Any, probe_alt_tool), + required_permission=PROBE_PERMISSION, + allowed_roles=("risk_operator",), + )) registry.register(ToolDefinition( name="check_suitability", input_model=SuitabilityToolInput, @@ -303,3 +334,9 @@ def register_business_agents(factory: AgentFactory) -> None: RiskAgent.definition, lambda _context: RiskAgent(RiskAgent.definition), ) + # 基座验证探针:只读、无副作用,用于端到端验证工具链路的拒绝行为。 + # 角色限定 admin,业务上不对外暴露用途。 + factory.register( + PlatformProbeAgent.definition, + lambda _context: PlatformProbeAgent(PlatformProbeAgent.definition), + ) diff --git a/app/service/agent/implementations/platform_probe.py b/app/service/agent/implementations/platform_probe.py new file mode 100644 index 0000000..646a176 --- /dev/null +++ b/app/service/agent/implementations/platform_probe.py @@ -0,0 +1,79 @@ +"""基座验证探针:端到端触发 `ToolExecutor` 的四种拒绝分支。 + +**为什么专门做一个 Agent**:`ToolExecutor` 的四种拒绝(意图未配置工具白名单 / 工具不在 +白名单 / 缺权限 / 角色不符)在真实链路上很难**安全**触发 —— 要么去改客服、风控的生效配置, +要么去动 RBAC,两条路都会影响正在工作的 Agent。用一个只读、无副作用的探针把这件事隔离出来。 + +**它不碰任何业务数据**:唯一的工具只回显调用方给的参数,不查库、不调模型、不写状态。 +角色限定为 `admin`,意图只有 `probe` 一个。 + +用法见 `tools/verify_tool_executor_denials.py`:它按顺序调整探针的意图配置与工具白名单, +分别触发四种拒绝,最后把配置恢复到验证前的状态。 +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from app.core.contracts import ( + AgentDefinition, + AgentRequest, + CoreResult, + RequestContext, +) +from app.service.agent.base import BaseAgent + +AGENT_TYPE = "platform_probe" +INTENT_PROBE = "probe" +PROBE_TOOL = "probe_echo" +# 第二个探针工具。**存在的唯一理由**:让"工具不在白名单"这一分支可被触发。 +# `governance.resolve` 取的是「发布白名单 ∩ 代码声明的 allowed_tools」,配置**只能缩小 +# 不能放大**;所以 Agent 只有一个工具时,白名单要么为空、要么正好包含它, +# 永远构造不出"有白名单、但不含被调用的那个工具"的场景——这是我做端到端验证时撞到的。 +PROBE_ALT_TOOL = "probe_alt" +PROBE_PERMISSION = "probe:read" + + +class ProbeEchoArgs(BaseModel): + """严格入参:探针不接收自由文本,避免被当成通用执行入口。""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + note: str = "" + + +async def probe_echo_tool(arguments: BaseModel, context: RequestContext) -> dict[str, Any]: + """回显参数。**只读且无副作用** —— 不查库、不写状态、不调外部服务。""" + note = getattr(arguments, "note", "") + return {"echo": note, "trace_id": context.trace_id} + + +async def probe_alt_tool(arguments: BaseModel, context: RequestContext) -> dict[str, Any]: + """备用探针工具:只为让"工具不在白名单"的分支可被构造,与 probe_echo 同样只读。""" + return {"alt": True, "trace_id": context.trace_id} + + +class PlatformProbeAgent(BaseAgent): + """基座探针:只用于验证工具链路的拒绝行为,不承载任何业务。""" + + definition = AgentDefinition( + agent_type=AGENT_TYPE, + version="1.0.0", + allowed_roles=("admin",), + allowed_portals=("api",), + allowed_tools=(PROBE_TOOL, PROBE_ALT_TOOL), + supported_intents=(INTENT_PROBE,), + ) + + async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult: + # 按消息里的 "alt" 决定调哪个探针工具。两个工具都必须可被指定调用: + # 分支 2(工具不在白名单)要调白名单之外的那个,分支 4(角色不符)要调角色更窄的 + # 那个——硬编码其中一个的话,这两个分支会互相干扰(实测踩到过)。 + tool = PROBE_ALT_TOOL if "alt" in request.message.lower() else PROBE_TOOL + output = await self.call_tool( + tool, + {"note": request.message[:50]}, + intent=INTENT_PROBE, + context=context, + ) + return CoreResult(text=f"probe ok: {output}", intent=self._classified_intent) diff --git a/app/service/agent/implementations/risk_agent.py b/app/service/agent/implementations/risk_agent.py index 19b5c45..489a972 100644 --- a/app/service/agent/implementations/risk_agent.py +++ b/app/service/agent/implementations/risk_agent.py @@ -362,7 +362,15 @@ def _agent_system_prompt(message: str) -> str: "8. 查询结果包含 summary 时,客户、产品和规则数量必须依据完整 summary," "不能因为 items 被截断就回答只覆盖部分记录。\n" "9. 最终回答使用中文,简洁说明结论、依据和剩余风险,并提醒由风控专员人工复核。\n" - f"{context}\n{filter_context}" + f"{context}\n{filter_context}\n{_truncation_instruction()}" + ) + + +def _truncation_instruction() -> str: + return ( + "10. 工具结果出现 data_truncated=true、evidence_truncated 非空或 " + "truncated=true 时,必须明确说明当前证据不完整,不能按全量证据下结论;" + "只有结果明确完整时,才能表述为覆盖全部记录。" ) diff --git a/app/service/api_transaction_service.py b/app/service/api_transaction_service.py index 2ce538a..4cdc3ae 100644 --- a/app/service/api_transaction_service.py +++ b/app/service/api_transaction_service.py @@ -41,3 +41,49 @@ class ApiTransactionService: await session.execute(table.update().where(table.c.id == row["id"]) .values(response_json=response)) return response + + async def execute_in( + self, + session: AsyncSession, + context: RequestContext, + scope: str, + key: str | None, + body: Any, + action: Callable[[AsyncSession], Awaitable[dict[str, Any]]], + ) -> dict[str, Any]: + """`execute` 的"用调用方事务"版本,供 B 类业务写接口复用。 + + `execute` 自己开 `SessionFactory()` 和 `session.begin()`;但业务 Action Service + 普遍在收尾时自己 `commit()`(例如 `RiskActionService._finish`),把它套进外层 + `session.begin()` 就成了"内层提交外层事务"。这里改为在**调用方传入的 session** + 上读写幂等记录,幂等记录因此与业务写入同处一个事务(`docs/05` §5.2)。 + + 并发同键由 `SELECT … FOR UPDATE` 串行化:后到的请求要么直接回放 + `response_json`,要么在拿到锁后看到同一请求哈希而继续执行。 + """ + if key is None or not 16 <= len(key) <= 128 or not key.isascii(): + raise ValidationAgentError("必须提供 16-128 位 ASCII Idempotency-Key") + table = await PlatformRepository(session).table("api_request_receipt") + statement = insert(table).values( + user_id=int(context.user_id), + scope_hash=digest(scope), + idempotency_key=key, + request_hash=digest(body), + ) + await session.execute(statement.on_duplicate_key_update(id=table.c.id)) + row = (await session.execute(select(table).where( + table.c.user_id == int(context.user_id), + table.c.scope_hash == digest(scope), + table.c.idempotency_key == key).with_for_update())).mappings().one() + if row["request_hash"] != digest(body): + raise IdempotencyConflictError("同一幂等键对应不同请求") + if row["response_json"] is not None: + return dict(row["response_json"]) + response = await action(session) + await session.execute(table.update().where(table.c.id == row["id"]) + .values(response_json=response)) + # 业务 Action 多半已经提交了自己的写入(风控就是这样),所以这里必须再提交 + # 一次才能把刚写回的 `response_json` 落库;若业务没有提交,则这一步同时提交 + # 业务写入与幂等记录。 + await session.commit() + return response diff --git a/app/service/config_release_service.py b/app/service/config_release_service.py index 221f53c..9f3925a 100644 --- a/app/service/config_release_service.py +++ b/app/service/config_release_service.py @@ -1,13 +1,36 @@ +import logging from datetime import UTC, datetime +from typing import Any from uuid import uuid4 -from sqlalchemy import select +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from app.model.audit import InteractionAudit from app.model.configuration import ConfigRelease, PlatformConfigItem from app.model.platform import DomainEventOutbox +logger = logging.getLogger(__name__) + +# 受 `config_release` **整版本替换**影响的表,以及各自的**逻辑键**。 +# +# 逻辑键用来判断"同一份配置"在新版本里还在不在:它不含 `release_id`、不含自增 id, +# 也**不含 version** —— 同名提示词在不同版本里可以用不同 version,那仍是同一份配置。 +# +# 这张清单是**穷举**的:`information_schema` 里带 `release_id` 列的表只有这三张。 +# 漏掉任何一张的后果都是**静默失效**,而且不会报错:客服闲聊提示词就这么失效过一次 +# —— 它挂在 release 174,active 变成 181 后 `load_active_prompt` 读不到,而 Agent 侧 +# 有逐字段兜底、回落到代码默认值,于是功能看着正常、没有任何人发现、也没有任何告警。 +RELEASE_SCOPED_TABLES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("platform_config_item", ("namespace", "config_key")), + ("prompt_template_version", ("prompt_code", "task_type", "agent_type")), + ("model_routing_rule", ("rule_code",)), +) + +# 搬运配置项时要剥掉的列:`id` 与 `release_id` 由新版本自己生成, +# 带过去要么主键冲突、要么把内容挂到旧版本上。 +NOT_PORTABLE_COLUMNS = frozenset({"id", "release_id"}) + class ConfigReleaseError(ValueError): pass @@ -45,6 +68,90 @@ class ConfigReleaseService: await self.session.flush() return release + async def active_release_id(self) -> int | None: + """当前生效版本的 id;没有任何生效版本时返回 None。""" + found = await self.session.scalar( + select(ConfigRelease.id).where(ConfigRelease.status == "active") + ) + return int(found) if found is not None else None + + async def effective_snapshot( + self, release_id: int | None = None + ) -> dict[str, list[dict[str, Any]]]: + """读某个发布版本在**全部受管表**里的内容;默认读当前生效版本。 + + 发布脚本应当**先取这份快照**,把它原样搬到新版本、再追加本次变更 —— 因为 + `config_release` 是整版本替换,不搬就等于删(详见 RELEASE_SCOPED_TABLES 的说明)。 + 每行已剥掉 `id` 与 `release_id`(见 NOT_PORTABLE_COLUMNS),可直接作为写入载荷。 + """ + if release_id is None: + release_id = await self.active_release_id() + snapshot: dict[str, list[dict[str, Any]]] = {} + for table, _keys in RELEASE_SCOPED_TABLES: + if release_id is None: + snapshot[table] = [] + continue + rows = (await self.session.execute( + text(f"SELECT * FROM {table} WHERE release_id = :rid"), {"rid": release_id} + )).mappings().all() + snapshot[table] = [ + { + name: value + for name, value in dict(row).items() + if name not in NOT_PORTABLE_COLUMNS + } + for row in rows + ] + return snapshot + + async def _table_keys( + self, table: str, keys: tuple[str, ...], release_ids: list[int] + ) -> dict[int, set[tuple[Any, ...]]]: + """按 release 分组取出某张表的逻辑键集合。""" + columns = ", ".join(keys) + grouped: dict[int, set[tuple[Any, ...]]] = {} + for release_id in release_ids: + rows = (await self.session.execute( + text(f"SELECT {columns} FROM {table} WHERE release_id = :rid"), + {"rid": release_id}, + )).mappings().all() + grouped[release_id] = {tuple(row[key] for key in keys) for row in rows} + return grouped + + async def _warn_dropped_items( + self, release: ConfigRelease, previous_active: list[ConfigRelease] + ) -> None: + """点名"旧版本有、新版本没有"的配置 —— 它们在激活后会静默失效。 + + **逐张覆盖全部受管表**。原先只比对 `platform_config_item`,于是客服闲聊提示词在 + `prompt_template_version` 里被静默丢掉时,连一行告警都没有:它挂在 release 174, + active 变成 181 后读不到,而 Agent 侧有兜底、回落到代码默认值,功能看着正常, + 于是没有人发现(详见 RELEASE_SCOPED_TABLES 的说明)。 + + 不阻断激活 —— 有时确实是要主动撤下某项配置;这里要的是"事后能查到是谁弄没的"。 + """ + if not previous_active: + return + release_ids = [previous.id for previous in previous_active] + [release.id] + for table, keys in RELEASE_SCOPED_TABLES: + grouped = await self._table_keys(table, keys, release_ids) + new_keys = grouped.get(release.id, set()) + for previous in previous_active: + dropped = sorted( + (key for key in grouped.get(previous.id, set()) if key not in new_keys), + key=str, + ) + if dropped: + logger.warning( + "配置发布 %s 取代 %s:%s 里有 %d 条配置在新版本中不存在," + "激活后即失效 → %s", + release.release_no, + previous.release_no, + table, + len(dropped), + [dict(zip(keys, key, strict=True)) for key in dropped], + ) + async def activate(self, release_id: int, actor_id: int) -> ConfigRelease: release = await self._get(release_id) if release.status != "approved": @@ -54,6 +161,8 @@ class ConfigReleaseService: ) now = self._now() previous_active = [previous for previous in active if previous.id != release.id] + # 把"本次激活会让哪些配置项失效"显式记进日志,见 _warn_dropped_items 的说明。 + await self._warn_dropped_items(release, previous_active) for previous in previous_active: previous.status = "superseded" previous.updated_at = now diff --git a/app/service/model_gateway.py b/app/service/model_gateway.py index 4e48219..3564bd2 100644 --- a/app/service/model_gateway.py +++ b/app/service/model_gateway.py @@ -1,3 +1,4 @@ +import logging import os from collections.abc import Mapping from dataclasses import dataclass @@ -28,6 +29,9 @@ class EndpointSettings(Protocol): secret_ref: str +logger = logging.getLogger(__name__) + + class EnvironmentSecretResolver: def resolve(self, secret_ref: str) -> str: if not secret_ref.startswith("env:"): @@ -50,12 +54,21 @@ class EnvironmentSecretResolver: #: ⚠️ `intent_classification` 必须映射到 `chat`,**不能**映射成同名的 #: `intent_classification`:现库没有任何端点声明该能力,同名映射会筛出空集, #: 依赖"回退到全部端点"才不至于失败——那是把配置缺陷掩盖成巧合。 -#: 未列入的 `task_type` 不做过滤(无法判断该要哪种能力)。 +#: 未列入的 `task_type` 不做过滤(无法判断该要哪种能力),但会**告警留痕**(见 `resolve`)。 REQUIRED_CAPABILITY_BY_TASK_TYPE: dict[str, str] = { "embedding": "embedding", "chat": "chat", "intent_classification": "chat", "memory_extraction": "text_generation", + # 风控的几处 task_type:它们要的都是"能生成文本"的端点,与 memory_extraction 同理。 + # 不登记就会落到"未映射 → 返回全部端点"的分支,而能否选到文本端点就取决于 + # `model_endpoint_config` 的**行顺序**——实测风控能跑通,仅仅因为 deepseek-flash(id=3) + # 恰好排在 qwen-embedding(id=5) 前面。这种"靠数据顺序才对"的隐式依赖必须消掉。 + "risk_agent_chat": "text_generation", + "risk_analysis": "text_generation", + "risk_script": "text_generation", + "risk_summary": "text_generation", + "daily_report_suggestion": "text_generation", "text_generation": "text_generation", } @@ -209,7 +222,15 @@ class DatabaseModelEndpointResolver: ModelEndpointConfig.status == "active" ))) if required is None: - # 未知任务类型:不过滤(无法判断该要哪种能力,交给下游按自身语义处理)。 + # 未登记的 task_type 仍退回全部端点(保守策略:让故障表现为调用失败、 + # 而不是解析为空),但必须留下痕迹。静默退回会让"选端点靠表行顺序"这类问题 + # 在下游以"偶发调用失败"的形式冒出来,极难定位。 + logger.warning( + "模型端点筛选:task_type=%r 未登记能力映射,退回全部 active 端点;" + "请在 REQUIRED_CAPABILITY_BY_TASK_TYPE 中补上它对应的能力", + task_type, + ) + return endpoints return endpoints matched = [ endpoint for endpoint in endpoints diff --git a/app/service/risk_daily_report_mail_service.py b/app/service/risk_daily_report_mail_service.py index 76cae56..3bff8a0 100644 --- a/app/service/risk_daily_report_mail_service.py +++ b/app/service/risk_daily_report_mail_service.py @@ -8,12 +8,30 @@ from collections.abc import Mapping from email.message import EmailMessage from email.utils import formatdate, make_msgid +from app.core.contracts import RequestContext +from app.service.authorization_service import AuthorizationService + class RiskDailyReportMailService: def __init__(self, *, environment: Mapping[str, str] | None = None) -> None: self.environment = environment or os.environ - def send(self, recipients: list[str], subject: str, content: str) -> dict[str, object]: + async def send( + self, + recipients: list[str], + subject: str, + content: str, + *, + context: RequestContext, + ) -> dict[str, object]: + """发送风控日报邮件。 + + **权限校验放在这里,而不是 controller**:本项目风控端点的授权一律落在 service 层 + (`risk_query_service` / `risk_action_service` / `risk_scan_service` 等都是这样), + controller 只负责取 context。这个端点是此前**唯一没有校验的** —— 收件人、标题、 + 正文全由客户端决定,一旦运维开启 SMTP,它就是一个未授权的邮件发送器。 + """ + await AuthorizationService.require(context, "risk:report:mail") if not _enabled(self.environment, "RISK_DAILY_REPORT_MAIL_ENABLED"): return {"status": "disabled", "recipient_count": len(recipients)} if _enabled(self.environment, "RISK_DAILY_REPORT_MAIL_DRY_RUN", default=True): diff --git a/app/service/risk_daily_report_service.py b/app/service/risk_daily_report_service.py index 199e2be..44d181b 100644 --- a/app/service/risk_daily_report_service.py +++ b/app/service/risk_daily_report_service.py @@ -6,7 +6,7 @@ import json import logging from collections import Counter from collections.abc import AsyncIterator -from datetime import UTC, datetime, time, timedelta +from datetime import UTC, datetime from typing import Any from zoneinfo import ZoneInfo @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.core.contracts import RequestContext +from app.core.timeutil import local_date, local_day_bounds from app.model.audit import InteractionAudit from app.repository.fund_query_repository import FundRecord from app.repository.risk_repository import RiskRepository @@ -63,6 +64,15 @@ class RiskDailyReportService: suggestions, source = await self._suggestions(report) return await self._complete(report, suggestions, source) + async def authorize(self, context: RequestContext) -> None: + """SSE 端点的前置鉴权。 + + `stream()` 是 async generator,函数体直到**第一次迭代**才执行;若只把 `require` + 放在那里,403 只能在响应头已经发出之后抛出,客户端看到的是"200 + 半截流"。 + 因此调用方必须先 `await authorize(context)` 再构造 `StreamingResponse`。 + """ + await AuthorizationService.require(context, "risk:alert:read") + async def stream( self, context: RequestContext, @@ -86,8 +96,10 @@ class RiskDailyReportService: context: RequestContext, generated_at: datetime, ) -> dict[str, Any]: - day_start = datetime.combine(generated_at.date(), time.min) - next_day = day_start + timedelta(days=1) + # 日界按**北京时间**切,再换算回库内 UTC 格式去查询。原先直接用 UTC 日期切日, + # 北京 08:00 前生成的日报,统计窗口会变成"前一日 08:00–当日 08:00"、跨了零点 + # (docs/25 P1 #6)。查询列是 UTC,所以 local_day_bounds 返回的也是 UTC naive。 + day_start, next_day = local_day_bounds(generated_at) repository = self.repository or RiskRepository( self.session, scope=scope_from_context(context), @@ -116,8 +128,11 @@ class RiskDailyReportService: ] return { "type": "风控日报", - "report_date": generated_at.date().isoformat(), + "report_date": local_date(generated_at).isoformat(), "generated_at": _local_datetime_text(generated_at), + # 行数封顶(docs/25 P3 #20):真被截断时下面的计数会偏低,必须显式标注, + # 不能让读日报的人以为这就是全量。 + "data_truncated": snapshot.truncated, "daily_alert_count": len(daily), "level_distribution": _distribution(daily, "risk_level", LEVEL_ORDER), "key_risk_events": key_items, @@ -238,7 +253,11 @@ class RiskDailyReportService: "is_overdue": bool(due_at and due_at <= generated_at), "is_escalated": bool(item.get("is_escalated")), "close_reason": item.get("close_reason"), - "created_today": bool(created_at and created_at.date() == generated_at.date()), + # 两边都按**北京时间**取日期:原先比的是 UTC 日期,北京 08:00 之前 + # 会把"今天新增的预警"算成昨天(docs/25 P1 #6)。 + "created_today": bool( + created_at and local_date(created_at) == local_date(generated_at) + ), } @staticmethod diff --git a/app/service/risk_judgement_service.py b/app/service/risk_judgement_service.py index 5fd91be..9ec0cf4 100644 --- a/app/service/risk_judgement_service.py +++ b/app/service/risk_judgement_service.py @@ -7,11 +7,19 @@ from __future__ import annotations import re -from datetime import date, datetime +from datetime import UTC, date, datetime from decimal import Decimal, InvalidOperation from typing import Any +from app.core.timeutil import local_date, local_hour + VERDICT_RELEASE = "可考虑放行" + +# 定投类工单渠道。扫描侧按"有效定投工单"生成 RW-018 预警时接受这两个值 +# (`risk_scan_service.py:294` 的 `work_order.channel in {"定投", "自动定投"}`)。 +# 研判侧必须用**同一份口径**:原先详情级只认 `"定投"`,于是"自动定投"的预警 +# 会出现"扫描按有效工单生成、详情却说未确认有效定投工单"的自相矛盾。 +DIRECT_INVESTMENT_CHANNELS = frozenset({"定投", "自动定投"}) VERDICT_SUSPECTED_FALSE_POSITIVE = "疑似误报" VERDICT_CONTINUE_REVIEW = "继续复核" VERDICT_RISK_SUPPORTED = "证据支持风险" @@ -41,12 +49,29 @@ def assess_alert_detail(detail: dict[str, Any]) -> dict[str, Any]: def _assess_list_rule(rule: str, item: dict[str, Any]) -> dict[str, Any]: + item = _flatten_merged_evidence(item) if rule == "RW-018": + # 原先这里**完全不看 item**,无条件返回"可考虑放行",而且理由文本是硬编码的 + # "现有摘要显示交易来自有效定投工单"。于是列表里那些渠道并不匹配、或证据里 + # 根本没有工单信息的 RW-018 预警,也被标成"可考虑放行"——等于把待核实的预警 + # 在列表层提前放掉了(详情层另有判断,但列表是风控专员最先看到的一屏)。 + # + # 现在按 snapshot 里的渠道判断,并与扫描侧(`risk_scan_service.py:294`) + # 共用同一份渠道口径 —— 两处不一致会造成"扫描认为有效、详情认为未确认"的自相矛盾。 + snapshot = _mapping(item.get("evidence_snapshot")) + channel = snapshot.get("channel") + if isinstance(channel, str) and channel in DIRECT_INVESTMENT_CHANNELS: + return _assessment( + VERDICT_RELEASE, + "高", + [f"命中低优先级频繁交易初筛,关联工单渠道为 {channel},属有效定投场景。"], + ["核验定投工单状态、交易周期和客户授权记录。"], + ) return _assessment( - VERDICT_RELEASE, - "高", - ["命中低优先级频繁交易初筛,现有摘要显示交易来自有效定投工单。"], - ["核验定投工单状态、交易周期和客户授权记录。"], + VERDICT_SUSPECTED_FALSE_POSITIVE, + "中", + ["命中频繁交易初筛,但证据中未确认有效定投工单。"], + ["核验定投工单状态与客户授权记录后,由风控专员判断是否放行。"], ) if rule == "RW-015": return _assessment( @@ -77,7 +102,32 @@ def _assess_list_rule(rule: str, item: dict[str, Any]) -> dict[str, Any]: ) +def _flatten_merged_evidence(detail: dict[str, Any]) -> dict[str, Any]: + """把"合并预警"的嵌套证据摊回顶层,再交给各条规则的研判函数。 + + 同一笔交易命中多条规则时,扫描器会把它们合并成一条 + (`risk_scan_service._merge_same_transaction_alerts`)。合并后的 `evidence_snapshot` + 只保留 `product_id` 和 `merged_alerts`,各条**原有的证据键被塞进 + `merged_alerts[].evidence`**;而各研判函数读的是**顶层键**(`snapshot.get("ratio")` + 之类),于是合并过的预警一律读不到证据、降级成"缺证据无法复核" —— + 合并本来是为了少几条噪音,结果把这些预警的研判全废掉了(docs/25 P2)。 + + 在入口统一摊平:顶层已有的键优先(它来自 priority_score 最高的那条主预警), + 再按顺序补入各子条目的证据键。所有规则共用这一步,不必各自去认嵌套结构。 + """ + snapshot = _mapping(detail.get("evidence_snapshot")) + merged = snapshot.get("merged_alerts") + if not isinstance(merged, list): + return detail + flat = dict(snapshot) + for entry in merged: + for key, value in _mapping(_mapping(entry).get("evidence")).items(): + flat.setdefault(key, value) + return {**detail, "evidence_snapshot": flat} + + def _assess_detail_rule(rule: str, detail: dict[str, Any]) -> dict[str, Any]: + detail = _flatten_merged_evidence(detail) if rule == "RW-003": return _assess_rw003(detail) if rule == "RW-007": @@ -153,8 +203,38 @@ def _assess_rw007(detail: dict[str, Any]) -> dict[str, Any]: ["核对预警生成时和当前测评记录是否发生变更。"], ) + # 第十五条豁免规则:C3→R4、C4→R5 是**允许**的越级购买,前提是签署风险揭示书 + # 且单只持仓不超过总资产的 20% / 10%。所以"留痕齐全"只是豁免成立的一半条件, + # 另一半是额度 —— 只判留痕会把"签了字但买超了额度"的违规判成误报。 + snapshot = _mapping(detail.get("evidence_snapshot")) + limit = _decimal(snapshot.get("exemption_limit")) + ratio = _decimal(snapshot.get("exemption_ratio")) + if limit is not None and ratio is not None and ratio > limit: + return _assessment( + VERDICT_RISK_SUPPORTED, + "高", + [ + f"客户等级与产品等级相差 {level_gap} 级,属第十五条可豁免情形。", + f"但单只持仓占比 {ratio:.2%} 已超过豁免上限 {limit:.0%}。", + ], + ["核实风险揭示书签署情况,并查明持仓占比突破豁免额度的原因。"], + ) + missing_traces = _missing_traces(product, work_order) if not missing_traces: + if limit is not None and snapshot.get("exemption_data_missing"): + # 额度要靠总资产与持仓快照才能核算。本项目的画像与持仓由上游流程写入, + # 数据没落地时既不能默认"没超"判误报,也不能默认"超了"判风险。 + return _assessment( + VERDICT_CONTINUE_REVIEW, + "中", + [ + f"客户等级与产品等级相差 {level_gap} 级,属第十五条可豁免情形," + "且产品要求的交易留痕已具备。", + "但缺少总资产或单只持仓快照,无法核算豁免额度是否被突破。", + ], + ["补查客户总资产与单只产品持仓市值后,再判定豁免是否成立。"], + ) return _assessment( VERDICT_SUSPECTED_FALSE_POSITIVE, "高", @@ -196,6 +276,27 @@ def _assess_rw012(detail: dict[str, Any]) -> dict[str, Any]: ["核对证据快照是否来自已变更或已冲正的交易。"], ) + # 复核生成条件里的"≥ 3 倍历史均值"。原先这里没有这一项,快照里也没有均值, + # 于是一笔只达到 2 倍均值的赎回会被判成"证据支持风险" —— 而它的建议文本写着 + # "继续核实一年期历史交易均值",说明作者知道该看,只是当时确实没有数据可看。 + # 现在扫描侧把 average_amount / ratio 写进了快照,这里就能真正核。 + snapshot = _mapping(detail.get("evidence_snapshot")) + ratio = _decimal(snapshot.get("ratio")) + if ratio is None: + return _assessment( + VERDICT_CONTINUE_REVIEW, + "中", + ["证据快照中没有历史交易均值,无法复核是否达到规则的 3 倍门槛。"], + ["补查该客户近一年历史交易均值;数据缺失时由风控专员人工判断。"], + ) + if ratio < Decimal("3"): + return _assessment( + VERDICT_SUSPECTED_FALSE_POSITIVE, + "高", + [f"赎回金额为历史均值的 {ratio:.2f} 倍,未达到规则的 3 倍门槛。"], + ["核对历史均值口径与统计窗口;确认后按误报关闭。"], + ) + confirmed_at = _datetime(transaction.get("confirmed_at")) logins = detail.get("login_records") latest_login = _latest_successful_login(logins, confirmed_at) @@ -235,12 +336,15 @@ def _assess_rw015(detail: dict[str, Any]) -> dict[str, Any]: ["缺少交易金额或成交时间。"], ["补查交易明细和成交时间。"], ) - if amount <= Decimal("10000") and 0 <= confirmed_at.hour < 6: + # 必须按**北京时间**判断"凌晨":库里存 UTC,直接取 .hour 会让 [0,6) UTC 变成 + # 北京 08:00–14:00(docs/25 P1 #6)。展示的小时数同样要换算,否则风控专员看到的 + # 是 UTC 时刻、与他的直觉差 8 小时,无法核对。 + if amount <= Decimal("10000") and 0 <= local_hour(confirmed_at) < 6: return _assessment( VERDICT_RELEASE, "中", [ - f"交易金额 {amount:.2f} 元较小,并发生在 {confirmed_at.hour} 时。", + f"交易金额 {amount:.2f} 元较小,并发生在 {local_hour(confirmed_at)} 时。", "该规则本身属于低优先级运营特征初筛。", ], ["核验设备、交易地点和客户操作意图后人工决定是否放行。"], @@ -256,7 +360,7 @@ def _assess_rw015(detail: dict[str, Any]) -> dict[str, Any]: def _assess_rw018(detail: dict[str, Any]) -> dict[str, Any]: work_order = _mapping(detail.get("work_order")) channel = work_order.get("channel") - if channel == "定投": + if channel in DIRECT_INVESTMENT_CHANNELS: return _assessment( VERDICT_RELEASE, "高", @@ -378,7 +482,10 @@ def _age(value: Any) -> int | None: birth_date = _date(value) if birth_date is None: return None - today = date.today() + # 用**北京时间**的今天。原先这里用服务器 date.today()(东八区的机器上是北京日期, + # 但不保证),而扫描侧用 UTC 日期——两处口径不一致会让同一客户在生日边界上差一岁 + # (docs/25 P1 #6)。统一走 timeutil。 + today = local_date(datetime.now(UTC)) return today.year - birth_date.year - ( (today.month, today.day) < (birth_date.month, birth_date.day) ) diff --git a/app/service/risk_notification_service.py b/app/service/risk_notification_service.py index bb4128c..9cbddf3 100644 --- a/app/service/risk_notification_service.py +++ b/app/service/risk_notification_service.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.risk import RiskNotificationPageQuery from app.core.contracts import RequestContext from app.core.risk_cursor import decode_offset_cursor +from app.core.timeutil import from_local from app.model.fund import FundRiskAlert, FundRiskNotification from app.repository.fund_query_repository import PageRequest from app.repository.risk_repository import RiskRepository @@ -31,20 +32,21 @@ class RiskNotificationService: query: RiskNotificationPageQuery, ) -> dict[str, Any]: await AuthorizationService.require(context, "risk:alert:read") + binding = RiskQueryService._binding(context, query, "/notifications") page = await RiskRepository( self.session, scope=scope_from_context(context), ).list_notifications( keyword=query.keyword, send_status=query.send_status, - start_time=query.start_time, - end_time=query.end_time, + start_time=from_local(query.start_time) if query.start_time else None, + end_time=from_local(query.end_time) if query.end_time else None, page=PageRequest( limit=query.limit, - offset=decode_offset_cursor(query.cursor), + offset=decode_offset_cursor(query.cursor, binding=binding), ), ) - return RiskQueryService._page(page) + return RiskQueryService._page(page, binding=binding) def create_in_app( self, diff --git a/app/service/risk_query_service.py b/app/service/risk_query_service.py index 3f1b8c6..114f447 100644 --- a/app/service/risk_query_service.py +++ b/app/service/risk_query_service.py @@ -12,7 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.risk import RiskAlertPageQuery, RiskEvidencePageQuery from app.core.contracts import RequestContext from app.core.errors import GenericResourceNotFoundError -from app.core.risk_cursor import decode_offset_cursor, encode_offset_cursor +from app.core.risk_cursor import cursor_binding, decode_offset_cursor, encode_offset_cursor +from app.core.timeutil import from_local from app.repository.fund_query_repository import CustomerScope, PageRequest from app.repository.risk_repository import RiskRepository from app.service.authorization_service import AuthorizationService @@ -67,6 +68,7 @@ class RiskQueryService: query: RiskAlertPageQuery, ) -> dict[str, Any]: await AuthorizationService.require(context, "risk:alert:read") + binding = self._binding(context, query) page = await self._repository(context).list_alerts( keyword=query.keyword, customer_no=query.customer_no, @@ -74,11 +76,17 @@ class RiskQueryService: product_name=query.product_name, risk_level=query.risk_level, rule_code=query.rule_code, - start_time=query.start_time, - end_time=query.end_time, - page=PageRequest(limit=query.limit, offset=decode_offset_cursor(query.cursor)), + # REST 的时间参数是裸 datetime(不带时区),按**北京时间**解释后再换算成 + # 库内 UTC。Agent 路径本来就带时区(risk_natural_language.py:117), + # 两条路径口径必须一致,否则同一个筛选条件在界面与对话里查出不同结果。 + start_time=from_local(query.start_time) if query.start_time else None, + end_time=from_local(query.end_time) if query.end_time else None, + page=PageRequest( + limit=query.limit, + offset=decode_offset_cursor(query.cursor, binding=binding), + ), ) - return self._page(page) + return self._page(page, binding=binding) async def get_alert_detail( self, @@ -98,11 +106,14 @@ class RiskQueryService: query: RiskEvidencePageQuery, ) -> dict[str, Any]: await AuthorizationService.require(context, "risk:alert:read") + binding = self._binding(context, query, source) page_request = PageRequest( limit=query.limit, - offset=decode_offset_cursor(query.cursor), + offset=decode_offset_cursor(query.cursor, binding=binding), ) repository = self._repository(context) + start_time = from_local(query.start_time) if query.start_time else None + end_time = from_local(query.end_time) if query.end_time else None if source == "customers": page = await repository.list_customers( keyword=query.keyword, @@ -114,15 +125,15 @@ class RiskQueryService: elif source == "transactions": page = await repository.list_transactions( keyword=query.keyword, - start_time=query.start_time, - end_time=query.end_time, + start_time=start_time, + end_time=end_time, page=page_request, ) elif source == "capital_flows": page = await repository.list_capital_flows( keyword=query.keyword, - start_time=query.start_time, - end_time=query.end_time, + start_time=start_time, + end_time=end_time, page=page_request, ) elif source == "holdings": @@ -130,36 +141,57 @@ class RiskQueryService: elif source == "login_records": page = await repository.list_login_records( keyword=query.keyword, - start_time=query.start_time, - end_time=query.end_time, + start_time=start_time, + end_time=end_time, page=page_request, ) elif source == "alerts": alert_page = await repository.list_alerts(keyword=query.keyword, page=page_request) - return self._page(alert_page) + return self._page(alert_page, binding=binding) elif source == "notifications": page = await repository.list_notifications( keyword=query.keyword, send_status=query.send_status, - start_time=query.start_time, - end_time=query.end_time, + start_time=start_time, + end_time=end_time, page=page_request, ) else: raise GenericResourceNotFoundError("证据类型不存在") - return self._page(page) + return self._page(page, binding=binding) def _repository(self, context: RequestContext) -> RiskRepository: if self.repository is not None: return self.repository return RiskRepository(self.session, scope=scope_from_context(context)) + @staticmethod + def _binding(context: RequestContext, query: Any, extra: Any = None) -> str: + """列表查询的游标绑定指纹:用户 + 查询条件(**不含分页参数**)。 + + `docs/05` §3.8 要求游标绑定「用户、查询条件、排序字段和方向」。刻意排除 + `limit` 与 `cursor`:它们是分页参数、不是"查什么";若算进指纹,客户翻页时改一下 + limit 就会让游标失效 —— 那不是安全要求,只是难用。 + + `model_dump(mode="json")` 保证取值可稳定序列化(datetime / Decimal 都会变成确定的 + 字符串),否则同一个查询两次算出的指纹可能不同。 + + `extra` 用于路径参数那种"不在 query 里但决定查哪张表"的条件(`/evidence/{source}`), + 不带上它的话 customers 的游标可以直接拿去翻 products。 + """ + filters = query.model_dump(exclude={"limit", "cursor"}, mode="json") + if extra is not None: + filters["_path"] = extra + # data_scope 同样决定"能查到什么":权限被收紧后,旧游标不该还能继续往下翻。 + filters["_scope"] = [context.data_scope, list(context.customer_ids or ())] + return cursor_binding(user_id=context.user_id, filters=filters) + @classmethod - def _page(cls, page: Any) -> dict[str, Any]: + def _page(cls, page: Any, *, binding: str) -> dict[str, Any]: return { "items": [cls._record(item) for item in page.items], "next_cursor": ( - encode_offset_cursor(page.next_offset) + encode_offset_cursor(page.next_offset, binding=binding) if page.next_offset is not None else None ), diff --git a/app/service/risk_scan_service.py b/app/service/risk_scan_service.py index 3955b06..9a203da 100644 --- a/app/service/risk_scan_service.py +++ b/app/service/risk_scan_service.py @@ -18,10 +18,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.contracts import RequestContext from app.core.errors import AgentError, ConflictAgentError +from app.core.timeutil import local_date, local_hour from app.model.audit import InteractionAudit from app.model.fund import ( FundCapitalFlow, FundCustomerProfile, + FundHolding, FundProduct, FundRiskAlert, FundTransaction, @@ -34,6 +36,38 @@ HIGH_RISK = "高" MEDIUM_RISK = "中" LOW_RISK = "低" RISK_ORDER = {LOW_RISK: 0, MEDIUM_RISK: 1, HIGH_RISK: 2} +# 《个人投资者适当性管理指南》第十五条豁免规则:这两组越级购买是**允许**的, +# 但单只产品持仓有额度上限。键是(客户等级, 产品等级),值是"单只持仓 / 总资产"上限。 +EXEMPTION_LIMITS: dict[tuple[str, str], Decimal] = { + ("C3", "R4"): Decimal("0.20"), + ("C4", "R5"): Decimal("0.10"), +} + + +def _exemption_exceeded(exemption: dict[str, Any]) -> bool: + """豁免额度是否被突破。占比算不出来时**不算突破**(取向见 `_exemption_state`)。""" + ratio = exemption.get("exemption_ratio") + limit = exemption.get("exemption_limit") + if ratio is None or limit is None: + return False + return Decimal(ratio) > Decimal(limit) + + +def _suitability_summary( + customer_level: str, + product_level: str, + missing_trace: bool, + exemption: dict[str, Any], +) -> str: + parts = [f"{customer_level} 客户购买 {product_level} 产品"] + if _exemption_exceeded(exemption): + parts.append( + f"单只持仓占比 {Decimal(exemption['exemption_ratio']):.2%} " + f"超过第十五条豁免上限 {Decimal(exemption['exemption_limit']):.0%}" + ) + if missing_trace: + parts.append("交易留痕不完整") + return ",".join(parts) + "。" _scan_lock = asyncio.Lock() logger = logging.getLogger(__name__) @@ -145,7 +179,22 @@ class RiskRuleEngine: or await self._exists(transaction.id, "RW-007") ): continue - gap = _level_value(product.risk_level, "R") - _level_value(customer.investor_type, "C") + product_level = _level_value(product.risk_level, "R") + customer_level = _level_value(customer.investor_type, "C") + if product_level is None or customer_level is None: + # 等级字段脏(不是 R1-R5 / C1-C5)。**跳过这一条**,而不是让整个扫描挂掉: + # 原先 `_level_value` 直接 `int(...)`,一条脏数据抛 ValueError 会冒到 + # `scan()` 的兜底 → 整批 rollback,前面规则扫出来的预警全部白做。 + # 数据脏属于运维问题,不该升级成"整个风控停摆"。 + logger.warning( + "跳过等级字段异常的记录:transaction_id=%s risk_level=%r investor_type=%r", + transaction.id, + product.risk_level, + customer.investor_type, + ) + continue + gap = product_level - customer_level + exemption = await self._exemption_state(transaction, customer, product) missing_trace = ( ( product.risk_disclosure_required @@ -166,16 +215,20 @@ class RiskRuleEngine: and (work_order is None or not work_order.recording_reference) ) ) - if gap > 0 and missing_trace: + exceeded = _exemption_exceeded(exemption) + missing_trace = bool(missing_trace) + if gap > 0 and (missing_trace or exceeded): level = HIGH_RISK if gap >= 2 else MEDIUM_RISK alerts.append(self._build_alert( transaction=transaction, alert_type="适当性错配", level=level, rules=["RW-007"], - summary=( - f"{customer.investor_type} 客户购买 " - f"{product.risk_level} 产品,交易留痕不完整。" + summary=_suitability_summary( + customer.investor_type or "", + product.risk_level, + missing_trace, + exemption, ), priority=90 if level == HIGH_RISK else 70, event_status="刚刚发生", @@ -183,10 +236,61 @@ class RiskRuleEngine: "product_id": product.id, "level_gap": gap, "work_order_id": transaction.work_order_id, + **exemption, }, )) return alerts + async def _exemption_state( + self, + transaction: FundTransaction, + customer: RiskUser, + product: FundProduct, + ) -> dict[str, Any]: + """按第十五条核算豁免额度,返回要写进证据快照的字段。 + + 政策原文:C3 买 R4 需签《产品风险超越投资者风险承受能力揭示书》且**单只 R4 持仓 + 不超过总资产 20%**;C4 买 R5 同理,上限 10%。所以越级购买本身不是违规, + **超出额度**才是 —— 原先扫描侧只看"留痕是否齐全",完全没有额度这一维。 + + 数据缺失时的取向:**不把"算不出来"当成"超限"**。实测本项目库里 + `fin_customer_profile.total_asset` 全为 0、`fin_holding` 为空 —— 画像与持仓由本 + 项目之外的流程写入(与 `behavior_score` 同源),拿 0 去算会让每一笔 C3→R4 都变成 + "超限",等于把豁免规则变成新的误报源。因此这里只如实写出缺失, + 由研判侧提示人工确认。 + """ + limit = EXEMPTION_LIMITS.get((customer.investor_type or "", product.risk_level)) + if limit is None: + return {} + total_asset = await self.session.scalar( + select(FundCustomerProfile.total_asset).where( + FundCustomerProfile.customer_id == transaction.customer_id + ) + ) + holding_value = await self.session.scalar( + select(FundHolding.current_value) + .where( + FundHolding.customer_id == transaction.customer_id, + FundHolding.product_id == transaction.product_id, + ) + .order_by(FundHolding.id.desc()) + .limit(1) + ) + state: dict[str, Any] = {"exemption_limit": str(limit)} + if ( + total_asset is None + or holding_value is None + or Decimal(total_asset) <= 0 + ): + state["exemption_ratio"] = None + state["exemption_data_missing"] = True + return state + state["exemption_ratio"] = str(Decimal(holding_value) / Decimal(total_asset)) + state["exemption_data_missing"] = False + state["holding_value"] = str(holding_value) + state["total_asset"] = str(total_asset) + return state + async def _elderly_redemption(self) -> list[FundRiskAlert]: alerts: list[FundRiskAlert] = [] transactions = await self._scalars( @@ -206,7 +310,14 @@ class RiskRuleEngine: FundTransaction.confirmed_at >= transaction.confirmed_at - timedelta(days=365), ) ) - if average is None or transaction.amount < Decimal(average) * 3: + # `average <= 0` 也必须挡住:均值为 0 时 `amount < 0*3` 恒为 False, + # 会一路走到下面算 `amount / average` 直接除零。 + average_amount = Decimal(average) if average is not None else None + if ( + average_amount is None + or average_amount <= 0 + or transaction.amount < average_amount * 3 + ): continue login = await self.session.scalar( select(RiskLoginRecord) @@ -236,6 +347,12 @@ class RiskRuleEngine: "product_id": transaction.product_id, "age": age, "device_id": login.device_id, + # 把生成条件用到的均值也写进快照。原先这里只有 age / device_id, + # 研判侧要复核"是否真达到 3 倍历史均值"却无从下手,只能跳过这一条、 + # 直接让"非常用设备"定案(docs/25 P2:研判漏阈值条件)。 + # 写 ratio 与 RW-003 的快照风格保持一致。 + "average_amount": str(average_amount), + "ratio": str(transaction.amount / average_amount), }, )) return alerts @@ -245,7 +362,9 @@ class RiskRuleEngine: for transaction in await self._scalars(select(FundTransaction)): if ( transaction.confirmed_at is not None - and 0 <= transaction.confirmed_at.hour < 6 + # 必须按**北京时间**判断"凌晨":库里存 UTC,直接取 .hour 会让 + # [0,6) UTC 变成北京 08:00–14:00,整条规则判的是上午(docs/25 P1 #6)。 + and 0 <= local_hour(transaction.confirmed_at) < 6 and transaction.amount is not None and transaction.amount <= 10000 and not await self._exists(transaction.id, "RW-015") @@ -406,24 +525,40 @@ class RiskScanService: async with _scan_lock: try: alerts = await self.rule_engine.refresh_alerts() - notification_count = await self._create_notifications(alerts) + notification_count, notification_failure = await self._create_notifications(alerts) await self.session.commit() except Exception as error: await self.session.rollback() raise RiskScanError("规则扫描失败") from error - return { + result: dict[str, int | str] = { "message": "规则扫描完成", "created_count": len(alerts), "high_risk_count": sum(alert.alert_level == HIGH_RISK for alert in alerts), "notification_count": notification_count, } + if notification_failure: + # 通知失败**不回滚**预警(预警已经生成、比通知重要),但必须让调用方看见。 + # 原先这里无处可查:外面只拿到 notification_count=0,分不清"这批预警本来 + # 就不用通知"和"高风险通知创建失败了"——而后者意味着处置链路的第一环断了, + # 扫描却报"完成"。 + result["notification_failure"] = notification_failure + result["message"] = "规则扫描完成,但高风险通知创建失败" + return result - async def _create_notifications(self, alerts: list[FundRiskAlert]) -> int: + async def _create_notifications(self, alerts: list[FundRiskAlert]) -> tuple[int, str]: + """为高风险预警创建通知记录,返回 `(创建条数, 失败原因)`。 + + **失败原因必须交给调用方。** 原先这里失败时 `return 0`,而"无需通知"(没有高风险 + 预警、或通知功能关闭)也返回 0 —— 外面根本分不清两者。风控里这个区别很要紧: + 通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"。 + + 失败**不回滚**预警本身:预警已经生成、比通知重要,不该因为通知写失败就丢掉。 + """ if not self.notification_enabled: - return 0 + return 0, "" high_risk = [alert for alert in alerts if alert.alert_level == HIGH_RISK] if not high_risk: - return 0 + return 0, "" try: async with self.session.begin_nested(): count = 0 @@ -445,10 +580,10 @@ class RiskScanService: mail_enabled=self.mail_enabled, ) count += 1 - return count - except Exception: + return count, "" + except Exception as error: logger.exception("高风险通知记录创建失败,预警扫描继续提交") - return 0 + return 0, f"{type(error).__name__}: {error}"[:200] def _new_alert_id() -> int: @@ -459,11 +594,23 @@ def _new_alert_id() -> int: def _age(birth_date: date | None) -> int: if birth_date is None: return 0 - today = datetime.now(UTC).date() + # 用**北京时间**的今天:原先用 UTC 日期,而研判侧用服务器 date.today(), + # 两处口径不一致会让同一客户在生日边界上差一岁(docs/25 P1 #6)。 + today = local_date(datetime.now(UTC)) return today.year - birth_date.year - ( (today.month, today.day) < (birth_date.month, birth_date.day) ) -def _level_value(level: str, prefix: str) -> int: - return int(level.replace(prefix, "")) +def _level_value(level: str, prefix: str) -> int | None: + """把 "R2" / "C3" 解析成 2 / 3;格式不符返回 None,**不再抛异常**。 + + 原先写的是 `int(level.replace(prefix, ""))`:一条脏数据(等级字段写了「中风险」之类) + 就会抛 ValueError,一路冒到 `scan()` 的兜底 → **整批 rollback**,这次扫描前面已经 + 生成的预警全部作废。数据脏是运维问题,不该升级成"整个风控停摆"。 + + 用 `removeprefix` 而不是 `replace`:只去掉开头那一个前缀字符, + `"R2R"` 这种脏值不会被错当成 2。(没有前缀的 `"2"` 仍能解析——历史数据可能不带前缀。) + """ + digits = level.strip().upper().removeprefix(prefix.upper()) + return int(digits) if digits.isdigit() else None diff --git a/app/service/tool_executor.py b/app/service/tool_executor.py index 4bdfa79..49c4859 100644 --- a/app/service/tool_executor.py +++ b/app/service/tool_executor.py @@ -65,16 +65,46 @@ class ToolExecutor: configured_tools: dict[str, tuple[str, ...]], context: RequestContext, ) -> ToolExecution: definition = self.registry.get(name) - allowed = configured_tools.get(intent, ()) - reason = None - if name not in allowed: - reason = "工具不在当前意图白名单" + # 把三种"用不了"分开,并且**审计写详细、异常给通用**: + # + # 原先前两种情况共用一句"工具不在当前意图白名单",运维无法判断该去补发布配置、 + # 还是该改白名单内容——本项目已经因此踩坑两次(客服、风控的意图码都要求三处对齐, + # 而缺配置时是静默失败关闭)。权限与角色两处也只说"缺少工具权限",不说是哪一个。 + # + # 细节只进审计:异常 message 会随 API 响应返回给调用方, + # 白名单内容、权限码、角色集属于内部配置,不该出现在客户可见的响应里。 + reason = "" + detail = "" + configured = configured_tools.get(intent, ()) + if name not in configured: + if not configured: + # 白名单为空。**注意**:经过 governance 装配后,"意图完全没配"与"配了空列表" + # 在这里无法区分——governance 会为每个 supported_intent 预填条目,所以 + # `intent not in configured_tools` 这个判断在真实链路上永远不会成立 + # (这一点是端到端跑探针才发现的,单元测试里手工构造空 dict 掩盖了它)。 + # 好在两者的运维动作相同:都该去看 agent_tools 里 : + # 这一项,所以合并成一句准确的话,不假装能区分。 + reason = "该意图未配置工具白名单" + detail = ( + f"意图 {intent!r} 的工具白名单为空:发布配置里没有为它配置任何工具" + f"(agent_tools / :{intent}),工具失败关闭" + ) + else: + reason = "工具不在当前意图白名单" + detail = ( + f"工具 {name!r} 不在意图 {intent!r} 的白名单 {list(configured)} 内" + ) elif definition.required_permission not in context.permissions: reason = "缺少工具权限" + detail = f"缺少权限 {definition.required_permission!r}" elif not set(definition.allowed_roles).intersection(context.roles): reason = "角色不能使用工具" + detail = ( + f"角色 {list(context.roles)} 与工具允许的角色 " + f"{list(definition.allowed_roles)} 无交集" + ) if reason: - await self._audit(name, intent, context, "denied", reason) + await self._audit(name, intent, context, "denied", detail) raise ForbiddenAgentError(reason) try: validated = definition.input_model.model_validate(arguments) diff --git a/app/worker/risk_scan_scheduler.py b/app/worker/risk_scan_scheduler.py index ee9249a..9c5a4f1 100644 --- a/app/worker/risk_scan_scheduler.py +++ b/app/worker/risk_scan_scheduler.py @@ -9,17 +9,15 @@ from __future__ import annotations import argparse import asyncio import logging -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import AbstractAsyncContextManager, asynccontextmanager +from collections.abc import Awaitable, Callable +from contextlib import AbstractAsyncContextManager from datetime import UTC, datetime, timedelta from typing import Any from uuid import uuid4 -from sqlalchemy import text - from app.core.config import get_settings from app.core.contracts import RequestContext -from app.infrastructure.db import SessionFactory, engine +from app.infrastructure.db import SessionFactory, engine, mysql_scan_lock from app.model.audit import InteractionAudit from app.service.risk_scan_schedule_config import ( RiskScanScheduleConfig, @@ -27,7 +25,6 @@ from app.service.risk_scan_schedule_config import ( ) from app.service.risk_scan_service import RiskScanService -SCAN_LOCK_NAME = "jr_risk_scan_schedule" logger = logging.getLogger(__name__) ConfigLoader = Callable[[], Awaitable[RiskScanScheduleConfig]] @@ -35,25 +32,8 @@ ScanExecutor = Callable[[], Awaitable[dict[str, int | str]]] AuditWriter = Callable[[str, dict[str, Any]], Awaitable[None]] LockFactory = Callable[[], AbstractAsyncContextManager[bool]] - -@asynccontextmanager -async def mysql_scan_lock() -> AsyncIterator[bool]: - """使用 MySQL 连接级咨询锁约束跨进程并发。""" - async with SessionFactory() as session: - acquired = bool( - await session.scalar( - text("SELECT GET_LOCK(:name, 0)"), - {"name": SCAN_LOCK_NAME}, - ) - ) - try: - yield acquired - finally: - if acquired: - await session.scalar( - text("SELECT RELEASE_LOCK(:name)"), - {"name": SCAN_LOCK_NAME}, - ) +# 跨进程扫描锁已移到 `app/infrastructure/db.py`:端点与调度器**必须共用同一把锁**, +# 放在基础设施层两个入口才都能引用(service 不该反向依赖 worker)。 async def default_scan_executor() -> dict[str, int | str]: @@ -121,7 +101,17 @@ class RiskScanSchedulerWorker: current: datetime, ) -> bool: if self.last_run_at is None: - return config.run_immediately + # 进程刚起来,不知道自己上次是什么时候跑的 —— `last_run_at` 只存在内存里。 + # **保守地视为 due**:多跑一次的最坏后果是重复扫描,而扫描本身是幂等的 + # (每条规则先 `_exists` 查重)并且有 MySQL 级锁;反过来"不跑"的后果可能是 + # **永远不跑**:原先这里返回 `config.run_immediately`(默认 False), + # 重启之后 `_is_due` 恒为假,调度器形同虚设 —— 而且没有任何告警, + # 现场只会表现为"风控好像没在扫描"。 + # + # `config.run_immediately` 因此不再承担"首次是否执行"的语义(它原本想表达的 + # 是"启动后别马上跑",但那与"永远不跑"在实现上无法区分)。字段保留, + # 以免破坏既有配置。 + return True return current - self.last_run_at >= timedelta(minutes=config.interval_minutes) async def _execute_with_retry( diff --git a/config/risk_agent_intents.json b/config/risk_agent_intents.json new file mode 100644 index 0000000..c1d3f53 --- /dev/null +++ b/config/risk_agent_intents.json @@ -0,0 +1,59 @@ +{ + "agent_type": "risk", + "version": 1, + "status": "active", + "priority": 100, + "confidence_threshold": "0.6500", + "max_clarification_rounds": 2, + "transfer_on_failure": true, + "classifier_instruction": "只用于只读工具查询、研判草案和边界说明,不执行人工处置。", + "intents": [ + { + "intent_code": "risk_overview", + "intent_name": "风险概览", + "description": "奶龙风控智能助手:风险概览", + "examples": [ + "查看当前风险概览", + "当前有多少高风险预警" + ], + "allowed_tools": [ + "get_risk_overview" + ] + }, + { + "intent_code": "risk_search", + "intent_name": "风险查询", + "description": "奶龙风控智能助手:风险查询", + "examples": [ + "查询高风险预警", + "查看命中 RW-007 的预警" + ], + "allowed_tools": [ + "search_risk_alerts" + ] + }, + { + "intent_code": "risk_evidence", + "intent_name": "预警证据", + "description": "奶龙风控智能助手:预警证据", + "examples": [ + "查询预警编号 ALERT-001 的证据", + "查看这条预警的证据链" + ], + "allowed_tools": [ + "get_alert_evidence" + ] + }, + { + "intent_code": "general", + "intent_name": "通用风控查询", + "description": "奶龙风控智能助手:通用风控查询", + "examples": [ + "你能做什么", + "说明你的功能边界" + ], + "allowed_tools": [] + } + ] +} + diff --git a/config/risk_agent_tools.json b/config/risk_agent_tools.json new file mode 100644 index 0000000..36803d4 --- /dev/null +++ b/config/risk_agent_tools.json @@ -0,0 +1,26 @@ +{ + "release_no": "risk-agent-local-v1", + "namespace": "agent_tools", + "schema_version": "1", + "configs": { + "risk:risk_overview": { + "allowed_tools": [ + "get_risk_overview" + ] + }, + "risk:risk_search": { + "allowed_tools": [ + "search_risk_alerts" + ] + }, + "risk:risk_evidence": { + "allowed_tools": [ + "get_alert_evidence" + ] + }, + "risk:general": { + "allowed_tools": [] + } + } +} + diff --git a/docs/05-接口文档.md b/docs/05-接口文档.md index dab5af9..f1973fc 100644 --- a/docs/05-接口文档.md +++ b/docs/05-接口文档.md @@ -153,6 +153,7 @@ Run Query Service -> RunRepository/ConversationRepository -> JSON/SSE View | `403` | 角色、权限、适当性或数据范围拒绝 | | `404` | 资源不存在,或为防止越权枚举而隐藏资源 | | `409` | 幂等冲突、版本冲突或非法状态转换 | +| `413` | 请求体或上传文件超过大小限制 | | `422` | 已解析请求不满足字段或业务输入约束 | | `429` | 频率、并发或配额限制 | | `500` | 未分类内部错误 | @@ -913,10 +914,14 @@ Outbox 消费者按 `event_id` 幂等。失败事件保留并重试,超过阈 | 客服工单 | `/api/v1/customer-service/handover-tickets/**` | 客服业务文档 | 可生成摘要和转人工请求,不分配、接单、解决或关闭工单 | | 投顾方案 | `/api/v1/advisory-plans/**` | 投顾业务文档 | 只生成分析草案,不代替投顾审核发布 | | 场内模拟交易 | `/api/v1/sim-orders/**` | 交易业务文档 | 只读查询,不创建、确认或撤销委托 | -| 风控扫描 | `/api/v1/risk-scans/**` | 风控业务文档 | 可解释规则结果,不启动人工处置 | -| 风险预警 | `/api/v1/risk-alerts/**` | 风控业务文档 | 只读分析,不确认、升级或关闭预警 | +| 风控扫描 | `/api/v1/risk/**` | 风控业务文档 | 可解释规则结果,不启动人工处置 | +| 风险预警 | `/api/v1/risk/**` | 风控业务文档 | 只读分析,不确认、升级或关闭预警 | | 场外基金运营 | 不属于当前系统 | 独立运营系统 | 不读写场内交易表 | +风控模块落地时把扫描、预警、证据、通知和日报收在同一个 Controller 下,入口为 +`/api/v1/risk/**`(早期规划写作 `/risk-scans/**`、`/risk-alerts/**`,以本节的实际入口为准)。 +具体端点清单、权限与字段映射由风控业务文档登记,见第 19 节末尾。 + B 类业务写接口必须复用 JWT、响应信封、错误码、幂等、统一 `RequestContext`、事务和审计规则。Controller 只路由、校验、映射和调用 Service;禁止直接访问 Model。 ## 13. A 类和 B 类扩展 diff --git a/docs/24-客服Agent阶段性总结与下阶段计划.md b/docs/24-客服Agent阶段性总结与下阶段计划.md index 7b89d5e..7ac5b0d 100644 --- a/docs/24-客服Agent阶段性总结与下阶段计划.md +++ b/docs/24-客服Agent阶段性总结与下阶段计划.md @@ -108,10 +108,10 @@ | 项 | 值 | |---|---| -| 分支 | `qyqy_develop_1`,领先 `origin` **13 个提交**(含本文,尚未推送) | -| 门禁 | `ruff` 干净 / `mypy` 113 文件无问题 / **497** unit+contract / 29 integration | +| 分支 | `qyqy_develop_1`(本表数字已按 2026-09-11 刷新,见文末"后续更新") | +| 门禁 | `ruff` 干净 / `mypy` **137** 文件无问题 / **664** unit+contract / **33** integration | | 知识库 | 636 块(faq / product / policy 三个集合) | -| 发布配置 | 版本 **181** active,5 条配置项 | +| 发布配置 | 版本 **201** active(`cs-prompt-d918ca507fc6`),**10** 条配置项:9 条 `agent_tools` + 1 条客服闲聊提示词 | | 新增测试 | 5 个文件:`test_knowledge_keyword_recall`、`test_knowledge_granularity`、`test_customer_service_search_query`、`test_customer_service_suitability`、`test_customer_service_topic_matrix` | | 新增工具 | `tools/chat_console.py` | @@ -152,14 +152,14 @@ a6c09fa 检索问句只在客户这一句说不清楚时才带上文 ### 后续功能(按依赖顺序) 6. **投顾 Agent**:画像(`fin_customer_profile` + `profile_snapshots`)、图投影(Neo4j)、适当性裁决**都已就绪**,可以开始。 -7. **风控 Agent**:需要先有规则引擎,尚未启动。 -8. **登录接口**:`docs/19` 里列为未解决项、当时有意推迟。前端要变成"能用"的产品级界面,这一步绕不过去。 +7. ~~**风控 Agent**:需要先有规则引擎,尚未启动。~~ **已完成**:规则引擎、扫描、研判、日报、通知和 15 个 REST 端点均已合并并通过门禁;代码评审与逐条处理结果见 `docs/25`。 +8. **登录接口**:**平台不实现**。`docs/05` §11 明确"JWT 签发、刷新、注销由统一身份认证模块负责,Agent 平台不重复实现",本平台只做校验(`app/core/security.py`)。前端要变"能用",正确做法是接统一认证模块,而不是在这里加一个登录端点。 ### 已知但暂不处理的缺口 - `projection_reconciliation_service` 的事件层 outbox(`MemorySyncOutbox`)**没有生产者**。 - `RUN_NOT_CANCELLABLE` 这个错误码被复用在了状态冲突场景(语义不贴切)。 -- `docs/05` §9.5 缺 `agent-intent-configs` 的 GET 说明;列表类接口普遍没有 `next_cursor`/`has_more`。 +- `docs/05` §9.5 缺 `agent-intent-configs` 的 GET 说明;~~列表类接口普遍没有 `next_cursor`/`has_more`~~ —— **风控侧已补齐**(信封对齐 §3.3、游标进 `meta`),客服侧仍待办。 - 客服热线仍是占位符 `400-XXX-XXXX`(`customer_service.py` 的 `HOTLINE`),真号码确定后应改为读配置项。 --- @@ -169,3 +169,44 @@ a6c09fa 检索问句只在客户这一句说不清楚时才带上文 如果你要在这套底座上加新 Agent,先读 `docs/19`(接入实操 + 11 个真实坑)和 `docs/23`(记忆分层与画像)。 **本阶段最值得抄走的一条经验**:新接一个能力之后,**别只看代码在不在,要跑一遍确认数据真的流过**。这个项目里"接口留好了、线没接上"出现过至少五次,而它们的表现都是**静默降级**——不报错,只是回答变得又慢又差,或者一直转人工。 + +--- + +## 七、后续更新(2026-09-11) + +本节只记录上面那份阶段总结**写完之后**发生的变化,原文保留为当时的事实。 + +### 7.1 本文第五节里那两件"要你先决策的事",都已落地 + +1. **客服闲聊提示词已落库**:发布版本 **201**(`cs-prompt-d918ca507fc6`)active,包含 + 9 条 `agent_tools` + 1 条客服闲聊提示词,共 10 条配置项。发布脚本 + `tools/publish_chitchat_prompt.py` 按当时的要求做了**继承**:读当前 active 版本的 + 配置项、映射字段、写入新版本,`config_release` 的整版本替换语义不会清空工具白名单。 + > 顺带记一条教训:`config_release` 是**整版本替换**,新增一项配置必须把老项带上, + > 否则激活的一瞬间老配置全部消失。这条已写进 `docs/05` 与评审报告。 +2. **风险测评记录**:`fin_risk_assessment` 的补数属于演示数据准备,不是代码问题; + 风控合并后适当性链路两侧(客服 `check_suitability` / 风控 RW-007)已按同一口径 + `C ≥ R` 复核通过。 + +### 7.2 风控模块合并后的处理(详见 `docs/25`) + +组员的风控模块(规则引擎 + 扫描 + 研判 + 日报 + 通知 + 15 个 REST 端点)已合并。 +评审报告 `docs/25` 逐条核实后的结果:**真缺陷已修,误报已更正,根因在别处的已改到别处**。 +重点几条: + +- 时区口径统一到**北京时间**(库内仍是 UTC naive,转换只在 `app/core/timeutil.py`); +- 列表信封对齐 `docs/05` §3.3、游标绑定用户与查询条件(§3.8); +- 6 个写接口接入平台幂等(§5.1),实现了 `ApiTransactionService.execute_in` 以免 + "内层提交外层事务"; +- 通知失败不再静默、扫描调度持久化、跨进程扫描锁、全量查询封顶并在响应里标注截断; +- `trigger_rule_codes` 补 JSON 多值索引(迁移 `20260911_risk_rule_index`), + `EXPLAIN` 由 `type=ALL` 变为 `type=range`; +- 第十五条豁免额度(C3→R4 ≤ 20%、C4→R5 ≤ 10%)落地到扫描与研判两侧。 + +### 7.3 三个仍然开着的口子 + +1. **客服热线仍是占位符** `400-XXX-XXXX`(`customer_service.py` 的 `HOTLINE`)。 +2. **`like(f"%{keyword}%")` 全表扫**:前后通配符在 B-tree 上无解,根治要全文索引 + + 中文分词组件,属部署依赖,本轮不做。 +3. **offset 游标 + 可变排序键**在真实并发下仍可能跳行/重复,根治要改 keyset 分页, + 属于分页协议级重做,未单方面改动。 diff --git a/docs/25-风控模块代码评审报告.md b/docs/25-风控模块代码评审报告.md new file mode 100644 index 0000000..6e9f9a6 --- /dev/null +++ b/docs/25-风控模块代码评审报告.md @@ -0,0 +1,365 @@ +# 25 · 风控模块代码评审报告 + +> **评审对象**:合并 `origin/qyqy_develop`(`870fd0d`)带入的风控模块(约 5000 行)。 +> +> **评审方式**:4 个并行评审(架构接入 / 数据层与数据库基线 / 业务逻辑正确性 / API 规范), +> 关键结论由本人逐条核对代码或实测数据库复核。 +> +> **验证标记**: +> - ✅ **已验证**:本人核对过代码原文或实测过数据库,证据在文中; +> - 🔁 **交叉印证**:两位评审独立发现同一问题(可信度更高); +> - ⚠️ **待复核**:评审提出、本人未逐条核对。 +> +> **本报告只陈述问题与依据,不含任何修改。** 修复请另行安排。 + +--- + +## 一、总体结论 + +**骨架是合规的**,这一点要先说清楚:风控正确继承了 `BaseAgent`、只实现 `handle()`、工具调用统一走 `self.call_tool`、ORM 与数据库基线**逐列吻合且没有改动任何已有表**、全仓无字符串拼 SQL、权限 scope 为空时默认拒绝、repository 严格只读。 + +问题集中在**三条断线**和**一批业务正确性缺陷**: + +1. **运行期配置没发布** → 风控在当前环境**跑不起来**; +2. ~~**模型调用绕过基座的模型服务** → 治理链路断了一环;~~ + **复核后更正:这是基座的能力缺口(缺 chat + tools 形态的入口),不是风控违规** —— 详见下文「2.」; +3. **时区口径不统一** → 定时规则判错时段、日报日界错位(两位评审独立发现)。 + +--- + +## 二、P0 · 阻断级 + +### 1. 意图配置与工具白名单一条都没发布 ✅ + +实测数据库: + +``` +platform_config_item(当前生效版本 5 条):customer_service:faq / policy_explain / + product_inquiry / suitability_check / fund_query_demo:fund_quote ← 无任何 risk:* +agent_intent_config 中 agent_type='risk':0 条 +``` + +风控声明了 4 个意图(`risk_agent.py:19-22`:`risk_overview` / `risk_search` / `risk_evidence` / `general`)。 +底座的白名单是**失败关闭**的:`ToolExecutor` 取「发布配置的 `allowed_tools`」与「代码声明的 +`allowed_tools`」**交集**,缺配置时交集为空 ⇒ **任何 `call_tool` 都被拒绝**。 + +**表现**:风控只能走 `handle` 里不依赖工具的 fallback 分支,等于不可用。 + +**修复**:补 `agent_intent_config` 与发布版 `agent_tools` 的 `risk:`。 +注意 `config_release` 是**整版本替换**语义,必须继承现有 5 条配置项,否则会把客服的白名单清空。 + +--- + +## 三、P1 · 安全与治理 + +### 2. 模型调用自建客户端(复核后定性:基座能力缺口,不是违规)✅ + +- 底座**有**公共入口:`base.py:79-84` 的 `generate_with_model()`,且 `base.py:86-95` 把它列入 + **禁止子类覆写**的集合 —— 底座明确视其为治理方法。 +- 风控却自己构造客户端:`risk_agent.py:133` `self._chat_model_client = model_client or RiskAgentModelClient()`, + `:225` 用它调模型;`risk_agent_model_client.py:88-96` 自己发 HTTP。 +- `bootstrap.py:262-265` 的 `lambda _context: RiskAgent(RiskAgent.definition)` 让 `model_client` + **永远是默认值**,连注入替换都做不到。 + +它**复用了 gateway 的配置解析**(`DatabaseModelEndpointResolver` + `EnvironmentSecretResolver`), +但**没走 gateway 的调用层** —— 降级、端点排序、统一错误映射,以及 gateway 将来的任何改进 +(限流、成本统计)它都享受不到。 + +其**修复建议原先写作"改走 `ModelGenerationService`,或直接用 `self.generate_with_model`"—— +该建议不成立,以下为复核后的更正(2026-09-11)。** + +**前提复核**:基座的公共入口**做不到风控需要的事**。 + +- `OpenAICompatibleGateway.generate(endpoint_code, prompt, timeout_ms) -> str` + (`model_gateway.py:57`)与 `ModelDispatchService.generate(endpoints, prompt) -> ModelExecution` + (`:248`)都是**「给一段 prompt、拿一段文本」**;`ModelExecution` 的字段只有 + `(endpoint_code, text, attempts, degraded)`(`:226-231`)。 +- 而风控需要的是 **messages 数组 + tools(function calling)+ 解析 tool_calls**, + 基座完全没有这种形态的入口。 + +**所以这不是"绕过",是基座缺能力时的补位。** 而且它补得相当规矩: + +| 它复刻的东西 | 与基座的一致性 | +|---|---| +| 端点选择 | 直接用基座的 `DatabaseModelEndpointResolver` + `EnvironmentSecretResolver` | +| 降级策略 | 与 `ModelDispatchService.generate` 一致:按声明顺序尝试、`max_attempts` 默认 2 | +| 错误映射 | 与基座同一套:`UpstreamTimeoutError` / `DependencyUnavailableError` | + +**仍然成立的两点**(与本条前半无关,但值得修): + +1. `bootstrap.py:262-265` 的 `lambda _context: RiskAgent(RiskAgent.definition)` 让 `model_client` + **永远是默认值**,注入点形同虚设 —— 无论将来走哪条路,这个 lambda 都该改。 +2. `risk_agent_model_client.py` 里的 HTTP 调用逻辑与 gateway **重复了一份**: + gateway 将来加限流、成本统计、统一日志,风控享受不到。 + +**正确的修法**(比原建议大,且要动基座):给基座**新增** `chat(messages, tools)` 形态的入口 +(`ModelGateway` Protocol → `OpenAICompatibleGateway` → `ModelDispatchService` → +`ModelGenerationService` → `BaseAgent`),再让风控改用它。因为**只是新增方法、不改现有行为**, +对已有 Agent 零影响,但涉及基座核心链路,**是否做需要项目方定**(本轮未实施)。 + +**业务裁定(2026-09-11):本轮不补,按基座能力缺口记录。** + +理由:风控已经跑通,收益只在风控一侧;而 `chat(messages, tools)` 要贯穿 +`ModelGateway` → `OpenAICompatibleGateway` → `ModelDispatchService` → +`ModelGenerationService` → `BaseAgent` **整条链路**,属于公共契约变更 —— 在演示与联调期间 +改它,影响面大于收益。留待基座排期时一并做,届时风控改用它是替换 +`RiskAgentModelClient` 一个类的事。 + +上面"仍然成立的两点"里,第 1 点(`bootstrap.py:262-265` 的 lambda 让注入点形同虚设)本轮 +同样**不改**:它只在注入替身时才看得出差别,不影响任何行为,等基座补 `chat` 入口时一起处理。 +第 2 点(HTTP 逻辑重复一份)随第 1 点一起消失。 + +### 3. 能力过滤失效,当前能工作只是巧合 🔁 ✅ + +`risk_agent_model_client.py:51-54` 传 `task_type="risk_agent_chat"`,不在 `TASK_CAPABILITY` 里 → +`model_gateway.py:196-198` **返回全部 active 端点** → 客户端只取前 `max_attempts`(默认 2)个。 + +**实测端点表**:`id=3 deepseek-flash`(caps 含 `text_generation`)恰好排在 +`id=5 qwen-embedding` 前面,所以现在能拿到文本端点。 + +**但这是巧合**:`model_gateway.py:181-184` 的注释自己警告过"取决于端点在表里的顺序"是缺陷。 + +**修复**:`TASK_CAPABILITY` 补 `"risk_agent_chat": "text_generation"`(及相关 task_type)。 + +### 4. `POST /daily-report/mail` 无权限校验 ✅ + +- `api/controllers/risk.py:207-217`:只有 `build_request_context`(管 401),**无任何权限校验**。 +- `risk_daily_report_mail_service.py:16-42`:无 `AuthorizationService`、拿不到 context, + **收件人 / 主题 / 正文全部由客户端决定**。 + +**当前不可被利用**:`:17-20` 有两层默认关闭开关 —— +`RISK_DAILY_REPORT_MAIL_ENABLED` 默认 `False`(返回 `disabled`)、`DRY_RUN` 默认 `True`。 +**但一旦运维开启 SMTP,它就是一个未授权的邮件发送器。** + +**修复**:在开启 SMTP 之前必须先加权限校验与收件人白名单。 + +### 5. Agent 在 `handle` 里直接写库 ✅ + +`risk_agent.py:141-146` 直接调 `RiskAnalysisService.generate_for_context(...)`; +`risk_analysis_service.py:145-156` 写 `alert.ai_analysis` 并 `commit()`。 + +**它不是"无审计的黑箱"** —— `:63` 有 `AuthorizationService.require`、`:147-155` 手写了 `InteractionAudit`。 +真正的问题是两条: + +1. 审计**不经基座统一链路**(只有走 `call_tool` 才有 `_tool_records` 与统一审计); +2. ~~`ai_analysis` 是**读-改-写且无并发控制**,并发请求会互相覆盖。~~ + + **本条经复核为误报,已撤回(2026-09-11)。** 证据:`risk_analysis_service.py:121-125` + 的查询上**一直有** `.with_for_update()`,读-改-写(`:138-145`)整体在行锁内、到 `:156` + 才提交。实测并发发起三种分析(预警研判 / 回访话术 / 工单摘要,各自独立 session、 + 复用同一 session 会天然串行测不出竞争),落库的 `ai_analysis` **三个键全部保留**, + 未发生覆盖。 + + 初次报告把它写成缺陷,原因是我只读到 `:130-160` 这一段、**恰好漏看了锁所在的 + `:121-125`**,却把这一条标成了"✅ 已验证"。教训与前面 `_topic_of` 那几轮相同: + **标注"已验证"之前必须确认自己真的读到了关键那段代码**,否则标注本身就是误导。 + +### 6. 时区口径不统一 🔁 ✅(最严重的业务缺陷) + +**定时规则判错时段**:`risk_scan_service.py:248` `0 <= confirmed_at.hour < 6`, +而 `:258` 生成的摘要写的是「**凌晨时段**发生 X 元交易」。库内是 UTC ⇒ +`[0,6)` UTC = **北京时间 08:00–14:00**。**规则本意是"凌晨",实际判的是整个上午**,会持续误报。 +同源代码见 `risk_judgement_service.py:238`,`:243` 还把 UTC 小时当北京时间展示。 + +**日报日界错位**:`risk_daily_report_service.py:89` 用 `generated_at.date()` 取日界, +而 `:61` 传入的是 `_utc_naive(now)`;`:119` 的 `report_date` 用 UTC 日期,`:415` 却按 +Asia/Shanghai 展示。北京 08:00 前生成时,统计窗口是"前一日 08:00 – 当日 08:00",跨了零点; +`:241` 的"当日新增"又与展示时间矛盾。 + +**REST 与 Agent 两条路径口径不一致**:`api/schemas/risk.py:32-33` 的时间参数是**裸 `datetime`**, +`risk_query_service.py:77-78` 原样透传去比 UTC 列;而 Agent 路径做了时区转换 +(`risk_natural_language.py:117` `tzinfo=SHANGHAI`)。 + +**修复**:统一约定"库里 UTC、边界处按北京时间换算",并在 REST 入口做同样的规范化。 + +--- + +## 四、P2 · 业务正确性(会导致误报 / 漏报)⚠️ + +| # | 问题 | 位置 | 后果 | +|---|---|---|---| +| 7 | 日报"误报原因"恒为"未填写" | `risk_repository.py:944-967` 的 `_alert_row` 不含 `close_reason`,`risk_daily_report_service.py:137` 读 `item.get("close_reason") or "未填写"` | 日报字段永远为空,失去分析价值 | +| 8 | 研判漏了阈值条件 | `risk_judgement_service.py:189` 只用 age/amount 判 RW-012;扫描侧 `risk_scan_service.py:209` 还有 `amount < Decimal(average) * 3` | 未达 3 倍均值也判"证据支持风险" ⇒ **误报** | +| 9 | 合并告警破坏证据结构 | `risk_scan_service.py:368` 重建 snapshot 只留 `product_id` + `merged_alerts`;`risk_judgement_service.py:103` 的 `snapshot.get("ratio")` 恒为 None | RW-003 研判降级 | +| 10 | 列表级无条件放行 | `risk_judgement_service.py:43-50`:`_assess_list_rule` 收到 `item` **却完全没用**,对 RW-018 硬编码返回「已解除」+ 硬编码理由 | 列表显示"已解除"、不会触发人工核查(详情级 `:259` 另有校验,点进去是对的) | +| 11 | 通知失败被吞 | `risk_scan_service.py:449` `except Exception: … return 0`,预警照常 commit,`:418` 只回 `notification_count=0` | **无法区分"无需通知"和"高风险通知创建失败"** —— 风控里这很危险 | +| 12 | 定时扫描重启后不再执行 | `risk_scan_scheduler.py:102` `last_run_at` 仅存内存;`:123` `if self.last_run_at is None: return config.run_immediately`(默认 False,`config.py:65`) | **重启后永不 due**;`:136` 失败后每 30s 无退避重试 | +| 13 | 一条脏数据中断整批 | `risk_scan_service.py:468` `int(level.replace(prefix, ""))` 遇非 C1-C5 抛 `ValueError`,`refresh_alerts` 无逐条隔离,`:411` 整批 rollback | 一条脏数据 ⇒ 整批扫描失败 | +| 14 | 年龄口径不一 | `risk_scan_service.py:462` 用 UTC 日期,`risk_judgement_service.py:381` 用服务器 `date.today()` | 生日边界差 1 天,65 岁阈值可能翻面 | +| 15 | 幂等仅在应用层 | `risk_scan_service.py:332` `_exists` + JSON 列无唯一索引;`:37` 的 asyncio.Lock 仅进程内 | 多 Web worker 并发存在重复告警窗口 | +| 16 | ~~状态跳变与静默截断~~ **复核后拆成三部分,见下方说明** | `risk_action_service.py:65-66`;`:103` | ~~处置流程可被绕过;评分被静默改写~~ | + +**第 16 条的复核结论(2026-09-11)**:原描述把三件事混成了一条,其中两件不成立、一件是真缺陷。 + +**a. `exclude` 不要求"调查中" —— 这是业务规则,不是技术缺陷。** + +`exclude`(`:63-66`)要求 `_require_acknowledged` + `_require_open`,而 `resolve`(`:90`) +额外要求 `alert.status == "调查中"`。两者不对称是事实,但**误报关闭是否必须先经调查,是业务 +规则**:把明显误报(如系统重复触发)也强制走一遍调查,未必是想要的效果。代码里两道门是有意 +设置的,看不出实现偏差。**需要业务方裁定**,不由技术侧单方面加门禁。 + +**业务裁定(2026-09-11):保持现状,不加门禁。** `exclude` 继续只要求"已确认接收 + +未闭环"。理由与复核时的判断一致:强制"调查中"会让明显误报(如系统重复触发)也必须走 +一遍调查流程,代价大于收益;`exclude` 仍会写审计、仍要求已确认接收,不是无声关闭。 + +**b. `min(20, score_before)` —— 误报。** + +`BEHAVIOR_SCORE_INITIAL = 20` 是**满分**(`:18`),扣分表 `{"低":3, "中":5, "高":20}`(`:19`) +与之自洽(高危扣满、归零)。所以 `min(20, ...)` 是**把越界数据拉回合法上限**,属于数据清洗。 +而且它**不是静默的**:审计里同时记了 `behavior_score_before` / `behavior_score_deduction` / +`behavior_score_after`(`:118-120`)。原描述说"静默改写"不成立。 + +**c. 真缺陷(原报告没写):`behavior_score` 初始值是 0,不是满分 20,导致扣分机制整体失效。** + +实测:`fin_customer_profile.behavior_score` 定义为 `int NOT NULL`(**无默认值**), +现有画像 `customer_id=9001` 的值是 **0**。 + +于是 `:103` 的计算恒为:`min(20, 0) - deduction = -deduction` → `max(0, ...) = 0` —— +**扣分永远扣不动,行为分恒为 0**。而且全仓只有 `risk_action_service.py:109` 一处给 +`behavior_score` 赋值(写入方只有风控结案),说明**初始值来自插入画像时的显式 0**, +没有任何地方把它初始化成 20。 + +影响:行为分是"预警结案 → 客户行为评分下降"这条链路的落点,现在这条链路**产出为零**。 + +**补充复核(同日):这不是本项目的代码缺陷,而是上游数据前提缺失。** + +全仓搜索后确认,**本项目不创建 `fin_customer_profile` 行**:`FundCustomerProfile(` 只出现在 +类定义与测试里,没有任何 INSERT;`profile_assembly_service` 走的是 ORM 读取 + 属性更新, +行不存在时返回 `profile_row_not_opened`。也就是说画像行由**本项目之外的流程**写入, +而它写入时把 `behavior_score` 设成了 0。 + +本项目的扣分逻辑本身是对的:`min(满分, before) - deduction` 再 `max(0, ...)` —— 给定 0, +任何扣分都只能得 0,这是算术必然,不是判断错误。 + +**处置建议**(不由技术侧单方面决定): + +1. 上游创建画像时按满分初始化 `behavior_score`(需上游确认该字段的语义与取值范围); +2. 若上游无法改,则需要一个**能区分"未初始化"与"扣光了"**的标记(例如新增一列), + 本项目才能在重建画像时补初值 —— 但仅为了这个目的加列,成本收益需要权衡。 + +在拿到上游口径之前,本项目**不做**任何"见 0 就补 20"的处理:0 同样是合法的扣分结果, +那样会把真正扣到 0 的客户错误地抬回满分。 + +--- + +## 五、P3 · 接口规范与性能 ⚠️ + +| # | 问题 | 位置 | +|---|---|---| +| 17 | 列表分页元数据放错层级:`next_cursor`/`has_more` 塞进 `data`,而 `docs/05` §3.3 要求放 `meta` 且明令"不得增加其他顶层字段"(`_envelope` 只输出 `trace_id`,已 ✅ 核对 `api/controllers/risk.py:220-224`) | `risk_query_service.py:158-167`,影响 5 个列表端点 | +| 18 | 游标只存 offset、**未绑定用户与查询条件**(`docs/05` §16.1 要求绑定),且 offset 无上界 | `risk_cursor.py:15` | +| 19 | offset 游标 + 可变排序键 ⇒ 并发下必然跳行或重复(排序首列是 `case(alert_level==HIGH…)`) | `risk_repository.py:155-166` | +| 20 | 无 `.limit()` 的全量查询:三处 select 无 limit(登录记录连时间窗都没有);`load()` 取全部未闭环告警(注释自认"不应用分页") | `risk_repository.py:251-274`、`306-316` | +| 21 | 索引失效:`trigger_rule_codes.contains([...])` → `JSON_CONTAINS`,基线 `fin_risk_alert` 无该列索引;多处 `like(f"%{keyword}%")` 全表扫 | `risk_repository.py:724`、`390`、`705-713` | +| 22 | 写操作无幂等:6 个 POST 端点无 `Idempotency-Key`(`docs/05` §5.1/§6.2) | `api/controllers/risk.py:60、89、100、111、167、207` | +| 23 | 错误码超表:把 `AGENT_INPUT_INVALID`(表定 422)配了 **413**,而 413 不在 `docs/05` §3.5 状态码表内 | `risk_evidence_archive_service.py:51-54` | +| 24 | SSE 未校验 `Accept`(`SseNotAcceptableError`/406 已定义但未被使用) | `api/controllers/risk.py:182-204` | +| 25 | 接口未登记 `docs/05`:§19 目录里一条风控接口都没有,而 §20 明确要求"新增接口必须同步更新 §19";且 §12 约定的前缀是 `/risk-scans/**`、`/risk-alerts/**`,实现是 `/api/v1/risk` | `docs/05-接口文档.md` §12/§19/§20 | + +**P3 处理结果(2026-09-11)** + +| # | 状态 | 处理 | +|---|---|---| +| 17 | ✅ 已修 | 列表信封改为 `{data: [...], meta: {trace_id, next_cursor, has_more}}`,对齐 §3.3;五个列表端点统一走 `_list_envelope` | +| 18 | ✅ 已修 | 游标内嵌 SHA-256 指纹(`user_id` + `data_scope`/`customer_ids` + 查询条件;`/evidence/{source}` 的 `source` 一并绑定,否则 customers 的游标能直接翻 products)。指纹不符一律 `400 INVALID_CURSOR`。**刻意排除 `limit`**:它是分页参数不是查询条件 | +| 19 | ⚠️ 已知限制 | offset 游标无法根治跳行/重复,要根治得改 keyset 分页(游标携带"上一页最后一条的排序键")。这会**改动分页协议本身**,且当前排序首列是 `case(alert_level=HIGH…)` 这种计算列,需要连排序一起去掉 —— 属于协议级重做,不在本轮单方面改 | +| 20 | ✅ 已修 | 详情证据每类封顶 200 条、日报每组封顶 5000 条,用 `limit + 1` 判定截断,并在响应里暴露 `evidence_truncated` / `data_truncated`。**不静默截断**:日报计数直接来自行数,静默截断等于给出一份看起来正常、实际少统计的日报 | +| 21 | 🟡 部分修复 | `trigger_rule_codes` 已加 JSON 多值索引(迁移 `20260911_risk_rule_index`);`EXPLAIN` 由 `type=ALL`、`possible_keys=NULL` 变为 `type=range` 并命中 `idx_fin_risk_alert_trigger_rule_codes`,实测证据留档在 `docs/evidence/risk-index-probe.json`。**`like(f"%{keyword}%")` 依然全表扫**:前后通配符在 B-tree 上无解,根治需全文索引 + 中文分词组件(部署依赖),本轮不做,如实记为限制 | +| 22 | ✅ 已修 | 6 个写接口接入平台 `api_request_receipt` 幂等。新增 `ApiTransactionService.execute_in`:原 `execute` 自开 `SessionFactory()` 与 `session.begin()`,而 `RiskActionService._finish` 内部会 commit,套进去就是"内层提交外层事务",故改为在调用方事务内读写幂等记录 | +| 23 | ✅ 已修 | **不把 413 降成 422**:413 是上传超限的标准语义,前端文档也已按 413 做提示映射,改为在 `docs/05` §3.5 状态码表**补登** 413,契约以"补齐"而非"改动"方式对齐 | +| 24 | ✅ 已修 | SSE 端点补 `Accept` 协商(抽到 `app/api/dependencies/negotiation.py` 与 `/agent-runs/{run_id}/events` 共用)。顺带发现一个更隐蔽的问题:鉴权原本在 async generator 内部,403 只能在响应头发出**之后**抛出,表现为"200 + 半截流",现改为构造 `StreamingResponse` 前完成 | +| 25 | 🟡 部分成立 | "§19 一条风控接口都没有"**不成立**:§19 末尾写明业务域接口由各自业务文档登记,15 条端点已在 `docs/风控业务演示文档/06-模块接口与字段映射.md` 逐条登记。真问题是 §12 写的 `/risk-scans/**`、`/risk-alerts/**` 与实际实现 `/api/v1/risk/**` 不符,已按实现更新 §12 并加说明 | + +--- + +## 六、做得好、建议保持 ✅ + +- **ORM 与数据库基线逐列吻合,没有改动任何已有表** —— 你们那条"不可变基线"的红线守住了; + `alembic/env.py` 的 `target_metadata = None` 也保证了 ORM 不会反向改表。 +- **全仓无字符串拼 SQL**,纯 SQLAlchemy 表达式;`f"%{keyword}%"` 只是绑定参数的值。 +- **默认拒绝**:权限 scope 为 None/denied 时返回 `false()` 而不是放行。 +- **repository 严格只读**:模块内无 `add/update/delete/commit`。 +- **三个工具 `read_only` 默认 True**,没碰"Agent 公共工具仅允许只读"这条红线。 +- **输出防护**:Agent 侧拦截越权处置话术与协议标记、内部 ID 脱敏、体积限制; + API 侧不透出审计信息、预警详情刻意规避内部主键。 +- **上传有大小与类型双重校验**:10MB 上限、魔数/zip 结构/UTF-8 校验、路径限定在项目内、防覆盖。 +- **状态机守卫扎实**:`with_for_update`、防重复确认/升级、已关闭不可更新。 +- **扫描单事务**:异常 rollback 后 raise,不是静默成功。 +- **双层并发保护**:asyncio.Lock + MySQL `GET_LOCK`。 +- **schema 严格**:请求模型普遍 `extra="forbid", frozen=True`,长度与范围校验齐全。 + +--- + +## 七、需要业务方裁定的问题 + +### 1. 政策文档自相矛盾 ✅(本人已核对原文) + +- `knowledge/policy/个人投资者适当性管理指南.md:306` 第十二条匹配矩阵:**C1 可购买 R2**; +- 同文件 `:330` 第十四条:"**正向匹配**:投资者风险等级必须**大于或等于**产品风险等级,方可购买"。 + +C1(1) 与 R2(2) 相比 `1 < 2`:**按矩阵可以买,按第十四条不能买**。 +风控扫描按后者实现(`risk_scan_service.py:169` `gap > 0 and missing_trace`)。 + +**这不是代码问题,是制度文本冲突**,需要业务方定一条为准。客服侧的适当性裁决走的是 +`check_suitability`(按档案等级与匹配规则),两边口径也需要对齐。 + +**业务裁定(2026-09-11):以第十四条 `C ≥ R` 为准。** + +**客服侧口径复核(同日):本来就一致,无需改动。** 复核 `SuitabilityService._decide` +(`suitability_service.py:195`)后确认,它的判定是 +`if profile.customer_risk_level < request.product_risk_level: 拒绝` —— **同样是第十四条的 +`C ≥ R`**,并没有使用第十二条的匹配矩阵。原文"按档案等级与匹配规则"是评审时的推测, +不成立。 + +两侧看上去的差异只有两点,且都不构成口径冲突: + +1. **专业投资者**:客服侧豁免等级匹配,但强制 `required_disclosure` / + `requires_confirmation` / `requires_recording`(`suitability_service.py:183-194`); + 风控扫描不做等级豁免,而是直接检查"该有的揭示、二次确认、录音留痕有没有"。 + 两者合起来是同一句话:豁免等级不等于豁免留痕。 +2. **触发条件**:客服是**事前拦截**(`C < R` 直接不许买),风控是**事后发现** + (`risk_scan_service.py:184` 的 `gap > 0 and missing_trace`)。这是职责差异,不是口径 + 差异 —— RW-007 的语义是"错配**且**留痕不全",不是"所有错配"。留痕完整却仍然成交, + 那是客服没能拦住,属另一个问题。 + +### 2. 第十五条豁免规则未落地 ✅(业务裁定:实现,2026-09-11 已完成) + +C3→R4(单只 ≤ 总资产 20%)、C4→R5(≤ 10%)的**持仓占比校验原先完全没有实现**; +`risk_judgement_service.py` 只要留痕齐全就判"疑似误报" —— 把豁免的**前提条件**当成了 +结论。业务方裁定实现,两侧一起改: + +- **扫描侧**:新增 `EXEMPTION_LIMITS` 与 `RiskRuleEngine._exemption_state`,核算 + "单只持仓 / 总资产"并写进证据快照(`exemption_limit`、`exemption_ratio`、 + `exemption_data_missing`、`holding_value`、`total_asset`);触发条件由 + `gap > 0 and missing_trace` 改为 `gap > 0 and (missing_trace or 超出额度)`。 +- **研判侧**:`_assess_rw007` 先判额度、再判留痕 —— 超限 → "证据支持风险"; + 留痕齐全且在额度内 → "疑似误报";留痕齐全但快照缺总资产/持仓 → "继续复核"。 + +**数据前提**(探查脚本 `tools/probe_exemption_data.py`,证据留档 +`docs/evidence/exemption-data-probe.json`):实测库内 `fin_customer_profile` 只有 1 行、 +`total_asset = 0.00`,`fin_holding` 为 0 行,且没有任何 `申购` 交易 —— 这条规则**当前不会 +被触发**,与 `behavior_score` 同源:画像与持仓由本项目之外的流程写入。 + +因此刻意**不**把"算不出来"当成"超限"。拿 `total_asset = 0` 去算,每一笔 C3→R4 都会变成 +违规,豁免规则反而成了新的误报源。数据缺失时扫描侧不产生预警,研判侧返回"继续复核"并 +要求补查总资产与持仓快照 —— 由人工定案,而不是用缺失数据假装有结论。 + +上游把总资产与持仓写入之后,这条链路**无需再改代码**即可生效。 + +### 3. `docs/24` 需要同步更新 + +`docs/24-客服Agent阶段性总结与下阶段计划.md` 里的「风控 Agent:需要先有规则引擎,尚未启动」 +与本报告结论已不符;"当前状态"表的数字也已被本次合并刷新。 + +--- + +## 八、建议的修复顺序 + +1. **发配置**(P0 #1)—— 这是风控能不能跑的前提;务必继承现有 5 条配置项。 +2. **时区统一**(P1 #6)—— 修完误报会明显下降,这也是"北京时间"要求的落地。 +3. **模型走 gateway + 补 capability**(P1 #2 #3)—— 消除对端点表行顺序的隐式依赖。 +4. **邮件端点加权限**(P1 #4)—— 必须在开启 SMTP 之前。 +5. **通知失败不再静默**(P2 #11)、**扫描调度持久化**(P2 #12)—— 这两条直接关系到 + "风控会不会悄悄不工作",属于风控系统的基本可信度。 +6. **政策口径裁定**(七 #1)—— 需要业务方拍板,然后代码与知识库一起对齐。 +7. 其余 P2/P3 按需排期。 diff --git a/docs/evidence/exemption-data-probe.json b/docs/evidence/exemption-data-probe.json new file mode 100644 index 0000000..5877507 --- /dev/null +++ b/docs/evidence/exemption-data-probe.json @@ -0,0 +1,28 @@ +{ + "profile_total_asset": [ + { + "rows_count": 1, + "positive_assets": "0", + "zero_assets": "1", + "min_asset": "0.00", + "max_asset": "0.00" + } + ], + "profile_investor_type": [ + { + "investor_type": "C2", + "rows_count": 1 + } + ], + "holding_shape": [ + { + "rows_count": 0, + "null_market_value": null, + "positive_current_value": null, + "min_current_value": null, + "max_current_value": null + } + ], + "subscription_pairs": [], + "exemptible_pairs_detail": [] +} \ No newline at end of file diff --git a/docs/evidence/release-state.json b/docs/evidence/release-state.json new file mode 100644 index 0000000..9e3ee0b --- /dev/null +++ b/docs/evidence/release-state.json @@ -0,0 +1,51 @@ +{ + "recent_releases": [ + { + "id": 201, + "release_no": "cs-prompt-d918ca507fc6", + "title": "客服闲聊提示词", + "status": "active", + "created_at": "2026-09-11 05:25:44.185697", + "activated_at": "2026-09-11 05:25:44.742980" + }, + { + "id": 198, + "release_no": "probe-e68780d3f3", + "title": "探针验证 恢复", + "status": "superseded", + "created_at": "2026-09-11 05:12:33.626078", + "activated_at": "2026-09-11 05:12:34.229356" + }, + { + "id": 197, + "release_no": "probe-ee4036b9f8", + "title": "探针验证 分支4", + "status": "superseded", + "created_at": "2026-09-11 05:12:29.813587", + "activated_at": "2026-09-11 05:12:30.409705" + }, + { + "id": 196, + "release_no": "probe-aa84e8efd5", + "title": "探针验证 恢复", + "status": "superseded", + "created_at": "2026-09-11 05:12:11.881449", + "activated_at": "2026-09-11 05:12:12.528369" + }, + { + "id": 195, + "release_no": "probe-5eb538bf50", + "title": "探针验证 分支4", + "status": "superseded", + "created_at": "2026-09-11 05:12:08.861544", + "activated_at": "2026-09-11 05:12:09.497513" + } + ], + "active_items": [ + { + "namespace": "agent_tools", + "items": 9 + } + ], + "active_prompt_versions": 1 +} \ No newline at end of file diff --git a/docs/evidence/risk-index-probe.json b/docs/evidence/risk-index-probe.json new file mode 100644 index 0000000..52b8ae9 --- /dev/null +++ b/docs/evidence/risk-index-probe.json @@ -0,0 +1,154 @@ +{ + "mysql_version": "8.0.27", + "column": [ + { + "COLUMN_NAME": "trigger_rule_codes", + "COLUMN_TYPE": "json", + "DATA_TYPE": "json", + "IS_NULLABLE": "NO" + } + ], + "indexes": [ + { + "INDEX_NAME": "idx_fin_risk_alert_alert_level", + "COLUMN_NAME": "alert_level", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_alert_type", + "COLUMN_NAME": "alert_type", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_created_at", + "COLUMN_NAME": "created_at", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_customer_id", + "COLUMN_NAME": "customer_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_due_at", + "COLUMN_NAME": "due_at", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_handler_id", + "COLUMN_NAME": "handler_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_primary_risk_work_order_id", + "COLUMN_NAME": "primary_risk_work_order_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_priority_score", + "COLUMN_NAME": "priority_score", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_related_order_id", + "COLUMN_NAME": "related_order_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_related_transaction_id", + "COLUMN_NAME": "related_transaction_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_related_work_order_id", + "COLUMN_NAME": "related_work_order_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_status", + "COLUMN_NAME": "status", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_trigger_rule_codes", + "COLUMN_NAME": null, + "INDEX_TYPE": "BTREE", + "EXPRESSION": "cast(`trigger_rule_codes` as char(16) array)" + }, + { + "INDEX_NAME": "PRIMARY", + "COLUMN_NAME": "id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "uk_fin_risk_alert_alert_no", + "COLUMN_NAME": "alert_no", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + } + ], + "row_count": 3, + "non_array_rows": 0, + "distinct_rule_codes": [ + { + "raw_codes": "[\"RW-007\", \"RW-002\", \"RW-012\"]", + "rows_count": 1 + }, + { + "raw_codes": "[\"RW-015\", \"RW-003\"]", + "rows_count": 1 + }, + { + "raw_codes": "[\"RW-018\"]", + "rows_count": 1 + } + ], + "explain_json_contains": { + "query_block": { + "select_id": 1, + "cost_info": { + "query_cost": "0.71" + }, + "table": { + "table_name": "fin_risk_alert", + "access_type": "range", + "possible_keys": [ + "idx_fin_risk_alert_trigger_rule_codes" + ], + "key": "idx_fin_risk_alert_trigger_rule_codes", + "used_key_parts": [ + "cast(`trigger_rule_codes` as char(16) array)" + ], + "key_length": "67", + "rows_examined_per_scan": 1, + "rows_produced_per_join": 1, + "filtered": "100.00", + "cost_info": { + "read_cost": "0.61", + "eval_cost": "0.10", + "prefix_cost": "0.71", + "data_read_per_join": "936" + }, + "used_columns": [ + "id", + "trigger_rule_codes", + "cast(`trigger_rule_codes` as char(16) array)" + ], + "attached_condition": "json_contains(cast(`trigger_rule_codes` as char(16) array),json'[\"RW-018\"]')" + } + } + } +} \ No newline at end of file diff --git a/docs/风控业务演示文档/02-主项目接入清单.md b/docs/风控业务演示文档/02-主项目接入清单.md index 0e47340..a227d20 100644 --- a/docs/风控业务演示文档/02-主项目接入清单.md +++ b/docs/风控业务演示文档/02-主项目接入清单.md @@ -31,7 +31,7 @@ | 允许角色 | `risk_operator`、`admin` | | 允许入口 | `api` | | 只读工具 | 风险概览、预警查询、预警证据 | -| 权限 | `agent:run`、`risk:alert:read`、`risk:alert:write`、`risk:alert:scan` | +| 权限 | `agent:run`、`risk:alert:read`、`risk:alert:write`、`risk:alert:scan`、`risk:report:mail` | ## 数据库依赖 @@ -48,8 +48,8 @@ - 主项目路由能够访问本模块只读查询接口。 - 风控账号拥有角色和对应权限。 +- 风控写接口和扫描接口支持并携带 `Idempotency-Key`。 - 风控账号具有有效的客户归属数据。 - Agent Run 能够调用本模块工具。 - 工具调用、处置和权限拒绝能够写入审计。 - Redis 或 Milvus 不可用时,结构化风控功能仍可用。 - diff --git a/docs/风控业务演示文档/03-风控业务规则与研判手册.md b/docs/风控业务演示文档/03-风控业务规则与研判手册.md index c1c8551..83a2099 100644 --- a/docs/风控业务演示文档/03-风控业务规则与研判手册.md +++ b/docs/风控业务演示文档/03-风控业务规则与研判手册.md @@ -27,12 +27,17 @@ | 项目 | 说明 | |---|---| -| 场景 | 客户风险承受等级低于产品风险等级,且交易留痕不完整 | -| 核心条件 | 产品风险等级高于客户等级;缺少风险揭示、二次确认或录音留痕 | +| 场景 | 客户风险承受等级低于产品风险等级,且交易留痕不完整或超出豁免额度 | +| 核心条件 | 产品风险等级高于客户等级;且(缺少风险揭示、二次确认或录音留痕,**或**属第十五条可豁免情形但单只持仓占比超过额度) | | 风险等级 | 等级差大于等于 2 时高风险;等级差为 1 时中风险 | -| 关键证据 | 客户等级、产品等级、风险揭示、二次确认和录音编号 | -| 风险结论 | 存在等级差且留痕缺失时支持风险判断 | -| 误报关注 | 当前等级不再错配,或要求的留痕已完整存在 | +| 关键证据 | 客户等级、产品等级、风险揭示、二次确认、录音编号、豁免额度与单只持仓占比 | +| 风险结论 | 存在等级差且(留痕缺失或超出豁免额度)时支持风险判断 | +| 误报关注 | 当前等级不再错配;或留痕完整**且**单只持仓在豁免额度内 | + +**第十五条豁免额度**(《个人投资者适当性管理指南》):C3 买 R4 单只持仓不超过总资产 +20%,C4 买 R5 不超过 10%。越级购买本身不是违规,**超出额度**才是。额度需要总资产与 +单只持仓快照才能核算;数据未落地时扫描侧不判超限,研判侧返回"继续复核"提示人工补查, +不会凭缺失数据直接定案。 ## RW-012 老年客户异常大额赎回 diff --git a/docs/风控业务演示文档/05-误报可放行与疑似误判标准.md b/docs/风控业务演示文档/05-误报可放行与疑似误判标准.md index 104816c..ee6179c 100644 --- a/docs/风控业务演示文档/05-误报可放行与疑似误判标准.md +++ b/docs/风控业务演示文档/05-误报可放行与疑似误判标准.md @@ -24,10 +24,14 @@ ## RW-007 研判 -- 风险成立:客户风险等级低于产品风险等级,且所需留痕存在缺失。 -- 疑似误报:当前客户等级与产品等级不再错配,或风险揭示、二次确认和录音留痕完整。 -- 继续复核:缺少客户等级或产品等级。 -- 复核动作:核对最新风险测评、留痕时间和录音编号真实性。 +- 风险成立:客户风险等级低于产品风险等级,且所需留痕存在缺失;或留痕完整但单只持仓 + 占比超过第十五条豁免额度(C3→R4 为 20%,C4→R5 为 10%)。 +- 疑似误报:当前客户等级与产品等级不再错配,或风险揭示、二次确认和录音留痕完整**且** + 单只持仓在豁免额度内。 +- 继续复核:缺少客户等级或产品等级;或属可豁免情形、留痕完整但缺少总资产/持仓快照, + 无法核算豁免额度。 +- 复核动作:核对最新风险测评、留痕时间和录音编号真实性;必要时补查客户总资产与单只 + 产品持仓市值。 ## RW-012 研判 diff --git a/docs/风控业务演示文档/06-模块接口与字段映射.md b/docs/风控业务演示文档/06-模块接口与字段映射.md index f0039b0..53d22a2 100644 --- a/docs/风控业务演示文档/06-模块接口与字段映射.md +++ b/docs/风控业务演示文档/06-模块接口与字段映射.md @@ -8,11 +8,25 @@ - 路由前缀为 `/api/v1/risk`。 - 响应使用主项目 `{data, meta}` 信封。 +- 列表接口的 `data` 为数组,`next_cursor` 和 `has_more` 放在 `meta`。 - 时间和日期使用 RFC 3339 或主项目约定格式。 - 金额、数量和主键按主项目字段映射返回字符串。 - 预警队列每页最多 5 条,其他证据列表每页最多 10 条。 - 未授权请求返回主项目统一权限错误。 +列表响应格式: + +```json +{ + "data": [], + "meta": { + "trace_id": "trace-id", + "next_cursor": "opaque-cursor", + "has_more": false + } +} +``` + ## 只读接口 | 方法 | 路径 | 功能 | 权限 | @@ -61,13 +75,17 @@ | POST | `/alerts/{alert_no}/escalations` | 升级处理 | `risk:alert:write` | | POST | `/alerts/{alert_no}/evidence` | 上传并归档证据 | `risk:alert:write` | +除证据上传外,上表写接口都必须携带 `Idempotency-Key`(16-128 位 ASCII,`docs/05` §5.1)。 +同一用户、同一路径、同一键的重复请求直接回放首次响应;同一键换了请求正文返回 +`409 IDEMPOTENCY_CONFLICT`。证据上传靠"同一预警只能归档一次"的冲突保护去重。 + ## 日报和邮件 | 方法 | 路径 | 功能 | 权限 | |---|---|---|---| | POST | `/daily-report` | 生成结构化日报 | `risk:alert:read` | | POST | `/daily-report/stream` | 流式生成日报 | `risk:alert:read` | -| POST | `/daily-report/mail` | 发送日报邮件 | 按主项目邮件策略执行 | +| POST | `/daily-report/mail` | 发送日报邮件 | `risk:report:mail` | ## Agent Run @@ -102,6 +120,11 @@ | `created_at` | 数据生成时间 | | `disposition_hint` | 列表级只读研判提示 | | `disposition_assessment` | 详情级只读研判草案 | +| `evidence_truncated` | 预警详情里被截断的证据类型名数组(`capital_flows` / `holdings` / `login_records`),空数组表示完整 | +| `data_truncated` | 日报是否因单次查询封顶而不完整;为 `true` 时日报计数偏低,不可当作全量口径 | + +预警详情的证据列表(资金流水、持仓、登录记录)单次最多返回 200 条、日报每组最多 +5000 条,超限时置位上面两个标记而不是静默截断。 ## 预警详情客户对象 @@ -113,3 +136,9 @@ | `customer_id` | 兼容字段,值与 `customer_no` 相同,仅供既有前端继续使用 | 内部 `fin_customer_profile.customer_id` 和 `sys_user.id` 不向预警详情接口暴露。 + +## 时间参数 + +- 带时区的时间按自身时区解释。 +- 不带时区的 REST 时间参数按 `Asia/Shanghai` 解释,再转换为 UTC 查询。 +- 客户端不能继续假设裸时间是 UTC。 diff --git a/docs/风控业务演示文档/07-权限与数据范围说明.md b/docs/风控业务演示文档/07-权限与数据范围说明.md index bea59dc..742267d 100644 --- a/docs/风控业务演示文档/07-权限与数据范围说明.md +++ b/docs/风控业务演示文档/07-权限与数据范围说明.md @@ -36,6 +36,7 @@ JWT 身份 | `risk:alert:read` | 概览、预警、详情、证据、通知、日报、Agent 只读工具 | | `risk:alert:write` | 确认、调查、误报、结案、升级、证据归档 | | `risk:alert:scan` | 手动或受控规则扫描 | +| `risk:report:mail` | 发送日报邮件 | ## 客户数据范围 @@ -55,6 +56,17 @@ WHERE employee_id = 当前用户ID - 只能访问有效分配客户的预警和相关证据。 - 权限决定功能访问,客户归属决定数据访问。 - 权限拒绝必须追加审计。 +- 写接口和扫描接口必须同时携带有效 `Idempotency-Key`。 + +## 当前数据范围风险 + +公共身份层当前取用户所有权限中的最高 `data_scope`: + +```text +任意权限为 all -> context.data_scope=all +``` + +这可能导致一个权限的 `all` 范围扩散到其他资源。主项目合并后应改为按权限码分别应用数据范围,避免风控客户范围被其他资源的全量权限绕过。 ## 管理角色 @@ -69,4 +81,3 @@ WHERE employee_id = 当前用户ID - 未分配客户不能通过直接请求越权访问。 - 缺少写入权限时不能执行处置。 - 权限或客户归属失效后,下一次请求立即生效。 - diff --git a/docs/风控业务演示文档/08-数据库依赖与读写边界.md b/docs/风控业务演示文档/08-数据库依赖与读写边界.md index 0d4f4c1..8c7fed0 100644 --- a/docs/风控业务演示文档/08-数据库依赖与读写边界.md +++ b/docs/风控业务演示文档/08-数据库依赖与读写边界.md @@ -74,3 +74,9 @@ - 证据归档先写文件,成功后更新证据快照;失败不得留下业务状态脏数据。 - 审计只允许追加,不允许普通业务接口修改或删除。 +## 查询上限与索引 + +- `fin_risk_alert.trigger_rule_codes` 增加 JSON 多值索引,用于规则编号筛选。 +- 预警详情中的资金流、持仓和登录记录分别最多返回 200 条。 +- 日报每组预警最多读取 5000 条。 +- 超限时通过 `evidence_truncated` 或 `data_truncated` 显式标记,不能伪装为全量结果。 diff --git a/docs/风控业务演示文档/10-Agent工具与调用流程.md b/docs/风控业务演示文档/10-Agent工具与调用流程.md index 1ab9d9b..1eedc60 100644 --- a/docs/风控业务演示文档/10-Agent工具与调用流程.md +++ b/docs/风控业务演示文档/10-Agent工具与调用流程.md @@ -49,6 +49,16 @@ - 累计最多 6 次工具调用。 - 单次工具结果超过限制时截断,但完整分组汇总优先保留。 +模型端点按任务能力筛选: + +- `risk_agent_chat` +- `risk_analysis` +- `risk_script` +- `risk_summary` +- `daily_report_suggestion` + +以上任务均要求文本生成能力,不能落回 embedding 端点。未登记的任务类型会告警并退回全部 active 端点。 + ## 完整回答原则 - 查询结果包含 `summary` 时,客户、产品和规则数量以完整汇总为准。 @@ -81,4 +91,3 @@ - 成功、失败或拒绝状态。 - 拒绝原因或执行结果摘要。 - `trace_id`。 - diff --git a/docs/风控业务演示文档/11-证据来源与脱敏说明.md b/docs/风控业务演示文档/11-证据来源与脱敏说明.md index a8d64d4..95fc69c 100644 --- a/docs/风控业务演示文档/11-证据来源与脱敏说明.md +++ b/docs/风控业务演示文档/11-证据来源与脱敏说明.md @@ -45,6 +45,8 @@ 当前业务状态发生变化时,应重新读取当前证据复核,不能只依赖历史摘要。 +资金流、持仓和登录记录单次最多返回 200 条。`evidence_truncated` 非空时,表示对应证据被截断,不能当作完整证据链。 + ## 脱敏要求 - 客户姓名只保留首字,其余用 `*`。 @@ -52,6 +54,7 @@ - 日志、模型输入和审计摘要不得记录明文密码或密钥。 - 敏感编号只保留业务所需最小信息。 - 文件归档不得覆盖其他预警证据。 +- 归档审计必须写入 `created_at`,否则数据库非空约束会让上传返回 500。 ## 文件归档 diff --git a/docs/风控业务演示文档/12-日报通知与邮件规则.md b/docs/风控业务演示文档/12-日报通知与邮件规则.md index b2eec4a..8189d5f 100644 --- a/docs/风控业务演示文档/12-日报通知与邮件规则.md +++ b/docs/风控业务演示文档/12-日报通知与邮件规则.md @@ -16,6 +16,9 @@ 历史未闭环不受分页限制。 +- 日界按 `Asia/Shanghai` 计算,再转换为 UTC 查询。 +- 每组预警最多读取 5000 条;超限时返回 `data_truncated=true`。 + ## 九段式模板 1. 当日预警数量。 @@ -40,12 +43,15 @@ - 高风险预警可以生成站内或邮件通知记录。 - 通知内容包含预警编号,便于定位原始预警。 - 通知发送失败不能回滚已经生成的预警。 +- 扫描结果通过 `notification_failure` 暴露通知创建失败原因,不能再把失败与无需通知都显示成 `0`。 - 通知查询按主项目分页和权限规则执行。 - 数据库保留 `read_at`、`acknowledged_at` 基线字段,但当前未实现写入逻辑。 - 通知接口和前端不返回或展示阅读时间、确认时间。 ## 邮件开关 +发送日报邮件需要 `risk:report:mail` 权限。 + 日报邮件默认不发送真实邮件。主要配置项: - `RISK_DAILY_REPORT_MAIL_ENABLED` diff --git a/docs/风控业务演示文档/14-审计与追溯映射.md b/docs/风控业务演示文档/14-审计与追溯映射.md index d829d75..0e284eb 100644 --- a/docs/风控业务演示文档/14-审计与追溯映射.md +++ b/docs/风控业务演示文档/14-审计与追溯映射.md @@ -31,6 +31,8 @@ | `risk_evidence_archived` | 证据归档 | 预警编号、文件信息 | | `risk_ai_analysis_generated` | 生成研判、话术或摘要 | 输出类型和来源 | | `risk_daily_report_generated` | 生成日报 | 报表统计摘要 | +| `risk_scan_scheduled_succeeded` | 定时扫描成功 | 扫描计数、尝试次数 | +| `risk_scan_scheduled_failed` | 定时扫描失败 | 尝试次数、错误类型 | ## Agent 和权限审计 @@ -42,6 +44,13 @@ | `agent.access_denied` | 创建运行时权限拒绝 | | `permission.denied` | Service 权限校验失败 | +风控写接口还会写入 `api_request_receipt`: + +- 绑定用户、路径、幂等键和请求摘要。 +- 重复请求返回首次结果。 +- 同键不同请求返回 `409 IDEMPOTENCY_CONFLICT`。 +- 证据上传目前依靠“同一预警只能归档一次”实现业务防重。 + ## 追踪建议 排查一次 Agent 对话时,可以按以下顺序关联: @@ -69,4 +78,3 @@ alert_no - 敏感原文不直接写入详情。 - 工具参数只保留必要摘要。 - 权限拒绝也属于审计事件。 - diff --git a/docs/风控业务演示文档/15-模块验收与演示清单.md b/docs/风控业务演示文档/15-模块验收与演示清单.md index 092a0eb..641e0d4 100644 --- a/docs/风控业务演示文档/15-模块验收与演示清单.md +++ b/docs/风控业务演示文档/15-模块验收与演示清单.md @@ -28,6 +28,8 @@ 3. 风控账号访问未分配客户,应失败关闭。 4. 缺少写权限时,人工处置接口应拒绝。 5. 权限拒绝应写入审计。 +6. `risk_operator` 应具备 `risk:alert:read`、`risk:alert:write`、`risk:alert:scan` 和 `risk:report:mail`。 +7. 风控写接口和扫描接口应携带有效 `Idempotency-Key`。 ## 业务验收 @@ -37,6 +39,7 @@ - 定时扫描配置开启后按周期执行。 - 定时扫描默认关闭,多个 Worker 同时运行时不重复执行。 - 定时扫描成功和失败写入系统审计。 +- 扫描返回 `notification_failure` 时,页面必须区分成功与通知失败。 - 重复扫描不重复生成同一交易和规则的预警。 - 多规则命中时正确合并。 @@ -51,7 +54,10 @@ ### 证据和归档 - 八类证据可以筛选和分页。 +- 列表接口使用 `data` 数组,分页字段位于 `meta.next_cursor`、`meta.has_more`。 +- 游标必须绑定当前用户、查询条件和证据路径。 - 预警详情聚合交易、产品、工单、资金、持仓和登录证据。 +- `evidence_truncated` 非空时,页面和 Agent 必须提示证据被截断。 - 合法文件归档成功。 - 非法文件和重复归档被拒绝。 @@ -59,8 +65,10 @@ - 日报包含九段式内容。 - 历史未闭环不受分页限制。 +- 日报日界按北京时间计算。 +- `data_truncated=true` 时不得把计数当作全量。 - 误报、处置结果和规则效果正确统计。 -- 邮件开关关闭时不发送真实邮件。 +- 邮件接口要求 `risk:report:mail`,开关关闭时不发送真实邮件。 ## Agent 验收 diff --git a/docs/风控业务演示文档/16-已知限制与待办.md b/docs/风控业务演示文档/16-已知限制与待办.md index d43ccaa..e7f03c1 100644 --- a/docs/风控业务演示文档/16-已知限制与待办.md +++ b/docs/风控业务演示文档/16-已知限制与待办.md @@ -51,7 +51,9 @@ ## 后续建议 - 主项目合并完成后对齐统一 RBAC 和客户数据范围。 +- 修复公共 `data_scope` 最高权限跨资源扩散问题。 +- 确认幂等回执与业务 Action 内部提交的事务边界。 +- 私有前端最后再适配 `data/meta` 列表信封和写接口 `Idempotency-Key`。 - 根据合规要求确定会话保留期、脱敏和归档策略。 - 正式前端接入前完成接口字段最终冻结。 - 在网络可用时保留 Ruff、MyPy 和结构审计结果作为合并证据。 - diff --git a/docs/风控业务演示文档/17-前端合并提示词与验收约束.md b/docs/风控业务演示文档/17-前端合并提示词与验收约束.md index 2c26414..744bede 100644 --- a/docs/风控业务演示文档/17-前端合并提示词与验收约束.md +++ b/docs/风控业务演示文档/17-前端合并提示词与验收约束.md @@ -86,6 +86,9 @@ - 所有风控 REST 请求使用 `/api/v1/risk`。 - 使用主项目统一响应信封和错误处理。 +- 列表接口的 `data` 必须是数组,游标和 `has_more` 从 `meta` 读取。 +- 风控写接口和扫描接口必须生成并携带唯一 `Idempotency-Key`。 +- `Idempotency-Key` 只用于写请求,证据上传暂不要求。 - Agent 对话使用 `/api/v1/agent-runs` 和 SSE 事件。 - 不在前端实现另一套 Agent 对话协议。 - SSE 需要处理 `start`、`tools`、`delta`、`replace`、`done` 和 `error`。 @@ -157,6 +160,8 @@ error.message ### 交互要求 - 操作进行中禁用重复点击,并显示进行中状态。 +- 写请求失败时不得复用同一个 `Idempotency-Key` 提交不同内容。 +- 写请求超时后可使用同一请求体和同一键重试,以获取首次结果。 - 成功或失败必须使用主项目统一的消息、Toast 或通知组件。 - 不能只用控制台日志代替用户提示。 - 弹窗关闭前必须明确操作结果。 diff --git a/docs/风控业务演示文档/18-当前项目完成进度.md b/docs/风控业务演示文档/18-当前项目完成进度.md index f54ff54..4f8d256 100644 --- a/docs/风控业务演示文档/18-当前项目完成进度.md +++ b/docs/风控业务演示文档/18-当前项目完成进度.md @@ -8,10 +8,10 @@ | 项目 | 当前状态 | |---|---| -| 统计日期 | 2026-09-10 | +| 统计日期 | 2026-09-11 | | 当前分支 | `RM2_develop` | -| 当前提交 | `a94d5c7` | -| 提交信息 | `feat: 迁移奶龙风控业务模块与演示文档` | +| 当前合并基线 | `origin/qyqy_develop` 主项目风控修复批次 | +| 代码状态 | 已完成主项目风控修复合并,全量测试通过 | | 已推送分支 | `origin/RM2_develop` | | 已合并分支 | `origin/qyqy_develop` | | 私有前端 | `private_frontend/`,未提交、未推送 | @@ -20,7 +20,7 @@ | 范围 | 完成度 | 说明 | |---|---:|---| -| 后端业务模块 | 96% | 主要业务功能和定时规则扫描调度均已完成 | +| 后端业务模块 | 98% | 主要业务功能和主项目风控修复均已合并,仍有少量后端加固项 | | 私有验证前端 | 90% | 可用于本地功能验证,但不作为公共正式前端 | | 主项目正式前端 | 10% | 尚未按主项目设计系统和正式页面结构合并 | | 主项目联调与验收 | 60% | 代码已合并,仍待主项目环境完整联调和正式前端接入 | @@ -93,7 +93,7 @@ | 检查项 | 结果 | |---|---| -| 全量测试 | `567 passed, 1 skipped` | +| 全量测试 | `697 passed, 1 skipped` | | Ruff 静态检查 | 通过 | | 风控专项测试 | 通过 | | 真实 Agent Run 验收 | 三类业务对话通过 | @@ -103,6 +103,15 @@ | 依赖检查 | `pip check` 通过 | | 依赖声明 | 已补充 `python-dotenv`、`python-multipart`、`greenlet`、`tzdata` | +主项目新增并已合入: + +- 列表信封、游标绑定和 JSON 多值索引。 +- 风控写接口幂等和 SSE 内容协商。 +- 邮件权限、RBAC 权限补齐脚本。 +- 通知失败可观测、扫描脏数据隔离和调度重启执行。 +- RW-007 豁免额度、RW-012、RW-015、RW-018 研判修复。 +- 日报北京时间、误报原因和截断标记。 + ## 未完成和暂缓事项 ### 对话历史与长期留存 @@ -152,9 +161,11 @@ | 阻塞项 | 影响 | 处理方式 | |---|---|---| | 正式前端未合并 | 无法按主项目正式界面演示 | 按前端提示词文档执行合并 | +| 私有前端尚未适配新信封和幂等请求头 | 预警列表、分页和写操作会失败 | 后端处理完成后统一改造 | | 主项目完整联调未完成 | 跨模块权限、导航和接口仍需验证 | 在 qyqy_develop 环境联调 | | 对话历史暂缓 | 跨轮长期记忆能力有限 | 迁移完成后单独实施 | -| 公共底座 MyPy 例外 | 全量类型检查无法完全通过 | 保持例外或由主项目后续处理 | +| 数据范围最高权限扩散 | 任意 `all` 权限可能绕过风控客户范围 | 改为按 permission_code 应用 scope | +| 幂等回执与业务提交原子性 | 内部 commit 后回执提交前崩溃时无法回放结果 | 评估统一事务边界 | ## 下一阶段建议 diff --git a/docs/风控业务演示文档/20-Agent工具白名单与意图配置.md b/docs/风控业务演示文档/20-Agent工具白名单与意图配置.md new file mode 100644 index 0000000..da54f89 --- /dev/null +++ b/docs/风控业务演示文档/20-Agent工具白名单与意图配置.md @@ -0,0 +1,128 @@ +# Agent 工具白名单与意图配置 + +## 文档功能 + +本文档用于向主项目方交付奶龙风控智能助手的工具白名单和意图配置,说明实际生效值、配置表位置、工具权限和一致性要求。 + +## Agent 基础声明 + +| 项目 | 值 | +|---|---| +| `agent_type` | `risk` | +| 展示名称 | 奶龙风控智能助手 | +| 允许角色 | `risk_operator`、`admin` | +| 允许入口 | `api` | +| 运行权限 | `agent:run` | +| 工具权限 | `risk:alert:read` | + +## 工具白名单 + +当前 active 发布: + +```text +release_no=risk-agent-local-v1 +namespace=agent_tools +schema_version=1 +``` + +| 配置键 | 允许工具 | +|---|---| +| `risk:risk_overview` | `get_risk_overview` | +| `risk:risk_search` | `search_risk_alerts` | +| `risk:risk_evidence` | `get_alert_evidence` | +| `risk:general` | 无 | + +JSON 文件: + +- `config/risk_agent_tools.json` + +## 意图配置 + +| 意图 | 名称 | 示例 | 允许工具 | +|---|---|---|---| +| `risk_overview` | 风险概览 | 查看当前风险概览;当前有多少高风险预警 | `get_risk_overview` | +| `risk_search` | 风险查询 | 查询高风险预警;查看命中 RW-007 的预警 | `search_risk_alerts` | +| `risk_evidence` | 预警证据 | 查询预警编号 ALERT-001 的证据;查看这条预警的证据链 | `get_alert_evidence` | +| `general` | 通用风控查询 | 你能做什么;说明你的功能边界 | 无 | + +统一参数: + +```text +confidence_threshold=0.6500 +max_clarification_rounds=2 +transfer_on_failure=true +priority=100 +version=1 +status=active +``` + +JSON 文件: + +- `config/risk_agent_intents.json` + +## 配置表映射 + +### 工具白名单 + +写入 `platform_config_item`: + +- `release_id`:当前 active 配置发布。 +- `namespace`:`agent_tools`。 +- `config_key`:`risk:`。 +- `value_json`:`{"allowed_tools":[...]}`。 + +### 意图 + +写入 `agent_intent_config`: + +- `agent_type=risk` +- `intent_code` +- `intent_name` +- `description` +- `examples` +- `classifier_instruction` +- `confidence_threshold` +- `max_clarification_rounds` +- `transfer_on_failure` +- `allowed_tools` +- `priority` +- `version` +- `status` + +## 一致性要求 + +- `platform_config_item` 的工具白名单是实际执行边界。 +- `agent_intent_config.allowed_tools` 必须与工具白名单保持一致。 +- 工具名称必须同时存在于代码的 `AgentDefinition.allowed_tools` 和工具注册表中。 +- 不得只配置意图而遗漏工具白名单。 +- 不得把写操作、处置操作或修改交易数据的工具放入白名单。 + +## 工具权限 + +三个工具均为只读工具: + +| 工具 | 角色 | 权限 | +|---|---|---| +| `get_risk_overview` | `risk_operator/admin` | `risk:alert:read` | +| `search_risk_alerts` | `risk_operator/admin` | `risk:alert:read` | +| `get_alert_evidence` | `risk_operator/admin` | `risk:alert:read` | + +## 主项目接入检查 + +- 创建或复用 active `config_release`。 +- 导入 4 条 `agent_tools` 配置。 +- 导入 4 条 risk 意图配置。 +- 确认配置已审核并激活。 +- 确认 `risk_operator` 拥有 `agent:run` 和 `risk:alert:read`。 +- 确认 `risk` Agent 已注册到 `AgentFactory`。 +- 发起一次风险概览和一次预警搜索验证工具调用。 + +## 发布脚本 + +主项目已提供: + +```text +tools/publish_risk_agent_config.py +``` + +该脚本用于发布工具白名单、意图配置和风险 Agent 所需权限。部署时应优先使用脚本,避免手工写入配置表造成工具白名单和意图配置不一致。 diff --git a/docs/风控业务演示文档/21-主项目合并后后端必改清单.md b/docs/风控业务演示文档/21-主项目合并后后端必改清单.md new file mode 100644 index 0000000..8e75e98 --- /dev/null +++ b/docs/风控业务演示文档/21-主项目合并后后端必改清单.md @@ -0,0 +1,85 @@ +# 风控模块合并后自身待办清单 + +## 文档定位 + +本文只记录风控模块自身在合并后需要处理的问题和联调动作。 + +公共底座、公共事务能力、公共数据范围、公共鉴权基座和公共 SSE 协商等问题不纳入本文, +由主项目统一修复和发布。 + +## 本轮已完成 + +### 1. 证据查询时间口径统一 + +已完成: + +- `/api/v1/risk/evidence/transactions` +- `/api/v1/risk/evidence/capital_flows` +- `/api/v1/risk/evidence/login_records` +- `/api/v1/risk/evidence/notifications` + +以上入口收到不带时区的开始时间和结束时间时,按北京时间解释,再转换为 UTC +数据库查询时间,避免误查前后 8 小时的数据。 + +### 2. 通知记录查询时间口径统一 + +`/api/v1/risk/notifications` 已与本模块其他 REST 查询保持一致: + +- 客户端裸时间按北京时间解释。 +- 查询前统一转换为 UTC。 +- 分页游标继续绑定用户、筛选条件和路径。 + +### 3. Agent 截断提示已补齐 + +奶龙风控智能助手在工具结果出现以下标记时,必须明确说明当前证据不完整: + +- `data_truncated=true` +- `evidence_truncated` 非空 +- `truncated=true` + +Agent 不能把截断结果表述成覆盖全部数据,也不能据此给出确定性的全量结论。 + +### 4. 风控验收脚本密钥路径统一 + +以下脚本已改为读取 `JWT_PRIVATE_KEY_PATH`: + +- `tools/risk_agent_e2e.py` +- `tools/risk_agent_business_e2e.py` + +不再硬编码 `config/jwt/jwt-private.pem`。 + +### 5. 权限初始化脚本说明修正 + +`tools/grant_risk_permissions.py` 的文档字符串已改为实际文件名, +避免执行人员按错误路径操作。 + +## 联调前需要执行的动作 + +以下内容属于环境准备或数据初始化,不是代码缺陷: + +1. 执行 `python tools/grant_risk_permissions.py`,确认风控角色具备: + - `risk:alert:read` + - `risk:alert:write` + - `risk:alert:scan` + - `risk:report:mail` +2. 执行 `python tools/publish_risk_agent_config.py`,确认奶龙风控智能助手的工具白名单和意图配置已发布。 +3. 按主项目发布的迁移流程执行 Alembic 升级,确认 `trigger_rule_codes` 多值索引已生效。 +4. 演示前准备足够的客户、交易、资金、持仓、登录和预警数据。 +5. 使用前确认日报邮件开关、SMTP 配置和收件人范围符合演示要求。 + +## 当前保留限制 + +- 私有前端适配不纳入本文,等后端事项稳定后单独处理。 +- `read_at` 和 `acknowledged_at` 只保留数据库能力,通知接口不返回,前端不展示。 +- 定时扫描 Worker 启动后的首轮执行语义与 `RISK_SCAN_RUN_IMMEDIATELY` 的旧说明存在差异, + 当前代码优先保证“开关启用后不会永不扫描”。如后续要恢复严格的首次执行开关语义, + 需要单独设计执行时间的持久化和恢复方案。 + +## 验证要求 + +合并或联调前至少完成: + +1. 风控专项测试通过。 +2. Ruff 检查通过。 +3. 全量测试无新增失败。 +4. Agent 实际对话、证据查询、通知查询和日报邮件分别完成一次人工联调。 diff --git a/docs/风控业务演示文档/README.md b/docs/风控业务演示文档/README.md index 9bbab4b..7106c7b 100644 --- a/docs/风控业务演示文档/README.md +++ b/docs/风控业务演示文档/README.md @@ -40,6 +40,8 @@ | 17 | 前端合并提示词与验收约束 | 指导后续模型识别风控功能并合并前端 | | 18 | 当前项目完成进度 | 汇总当前完成度、验证结果和剩余任务 | | 19 | 风控模块配置项清单 | 单独说明风控专用和公共依赖配置 | +| 20 | Agent工具白名单与意图配置 | 交付主项目方的工具和意图配置 | +| 21 | 主项目合并后后端必改清单 | 列出合并后必须处理的后端事项 | ## 推荐阅读顺序 @@ -51,3 +53,5 @@ 6. 17:主项目前端合并时直接提供给模型。 7. 18:项目汇报、进度同步和下一阶段安排。 8. 19:合并部署和联调时核对环境变量。 +9. 20:主项目方导入 Agent 工具白名单和意图配置。 +10. 21:主项目代码合并后的后端整改与验收。 diff --git a/tests/contract/test_risk_agent_contract.py b/tests/contract/test_risk_agent_contract.py index 3abc730..6446fbb 100644 --- a/tests/contract/test_risk_agent_contract.py +++ b/tests/contract/test_risk_agent_contract.py @@ -439,3 +439,10 @@ async def test_general_list_question_uses_complete_search_summary() -> None: assert "CUST-002" in text assert "稳健一号" in text assert "全部命中记录" in text + + +def test_agent_prompt_requires_truncation_disclosure() -> None: + prompt = public_risk_agent._agent_system_prompt("查看当前预警") + + assert "data_truncated=true" in prompt + assert "证据不完整" in prompt diff --git a/tests/integration/test_risk_idempotency_mysql.py b/tests/integration/test_risk_idempotency_mysql.py new file mode 100644 index 0000000..e259dc4 --- /dev/null +++ b/tests/integration/test_risk_idempotency_mysql.py @@ -0,0 +1,178 @@ +"""风控写接口的幂等语义(`docs/05` §5.1、§5.2;`docs/25` P3 #22)。 + +两级覆盖: + +1. `ApiTransactionService.execute_in` 本身 —— 同键同正文回放原响应且**不重复执行**、 + 同键不同正文返回 `409 IDEMPOTENCY_CONFLICT`、缺键直接拒绝; +2. Controller 接线 —— 重复 POST 同一个风控处置端点时,Action Service 只被调用一次。 + +刻意不去驱动真实状态机:处置动作会改预警状态,测试不该污染演示数据。这里用计数替身 +验证"第二次请求没有落到业务逻辑上",这才是幂等要保证的事情。 +""" + +from collections.abc import AsyncIterator +from typing import Any +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.controllers import risk as risk_controller +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.database import get_session +from app.core.contracts import RequestContext +from app.core.errors import IdempotencyConflictError, ValidationAgentError +from app.infrastructure.db import SessionFactory +from app.main import app +from app.repository.platform_repository import PlatformRepository +from app.service.api_transaction_service import ApiTransactionService + +SCOPE = "POST /api/v1/risk/alerts/ALERT-IDEM-TEST/acknowledgements" +EXCLUSION_SCOPE = "POST /api/v1/risk/alerts/ALERT-IDEM-TEST/exclusions" +ACK_PATH = "/api/v1/risk/alerts/ALERT-IDEM-TEST/acknowledgements" +EXCLUSION_PATH = "/api/v1/risk/alerts/ALERT-IDEM-TEST/exclusions" + + +async def override_context() -> RequestContext: + return RequestContext( + user_id="990000002", + trace_id=str(uuid4()), + roles=("risk_operator",), + permissions=("risk:alert:read", "risk:alert:write"), + data_scope="all", + ) + + +async def override_session() -> AsyncIterator[AsyncSession]: + async with SessionFactory() as session: + yield session + + +async def purge(*keys: str) -> None: + async with SessionFactory() as session, session.begin(): + table = await PlatformRepository(session).table("api_request_receipt") + for key in keys: + await session.execute(delete(table).where(table.c.idempotency_key == key)) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_execute_in_replays_without_running_action_twice() -> None: + key = f"risk-idem-{uuid4()}" + context = await override_context() + calls: list[int] = [] + + async def action(_session: AsyncSession) -> dict[str, Any]: + calls.append(1) + return {"alert_no": "ALERT-IDEM-TEST", "status": "待处理"} + + try: + async with SessionFactory() as session: + service = ApiTransactionService() + first = await service.execute_in(session, context, SCOPE, key, {}, action) + second = await service.execute_in(session, context, SCOPE, key, {}, action) + + assert first == second == {"alert_no": "ALERT-IDEM-TEST", "status": "待处理"} + assert len(calls) == 1, "重复请求必须回放 response_json,而不是再次执行 action" + finally: + await purge(key) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_execute_in_conflicts_on_same_key_with_different_body() -> None: + key = f"risk-idem-{uuid4()}" + context = await override_context() + + async def action(_session: AsyncSession) -> dict[str, Any]: + return {"ok": True} + + try: + async with SessionFactory() as session: + service = ApiTransactionService() + await service.execute_in(session, context, SCOPE, key, {"reason": "第一次"}, action) + with pytest.raises(IdempotencyConflictError): + await service.execute_in( + session, context, SCOPE, key, {"reason": "第二次"}, action + ) + finally: + await purge(key) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_execute_in_rejects_missing_or_short_key() -> None: + context = await override_context() + + async def action(_session: AsyncSession) -> dict[str, Any]: + raise AssertionError("缺键时不应执行 action") + + async with SessionFactory() as session: + service = ApiTransactionService() + for bad in (None, "too-short", "带中文字符的-key-1234567890"): + with pytest.raises(ValidationAgentError): + await service.execute_in(session, context, SCOPE, bad, {}, action) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_repeated_risk_post_calls_action_service_once(monkeypatch) -> None: + """端到端:同一 `Idempotency-Key` 重复 POST 只触发一次处置逻辑。 + + 用 `httpx.ASGITransport` 而不是 `TestClient`:后者自建事件循环,测试结束后的 + `SessionFactory` 清理会落在另一个循环上,连接池析构时报 + `AttributeError: 'NoneType' object has no attribute 'send'`。 + """ + calls: list[str] = [] + + class CountingActionService: + def __init__(self, _session: Any) -> None: + pass + + async def acknowledge(self, alert_no: str, _context: RequestContext) -> dict[str, Any]: + calls.append(alert_no) + return {"alert_no": alert_no, "status": "待处理", "ack_status": "已确认"} + + async def exclude( + self, alert_no: str, reason: str, _context: RequestContext + ) -> dict[str, Any]: + calls.append(f"exclude:{reason}") + return {"alert_no": alert_no, "status": "已排除", "handle_result": reason} + + monkeypatch.setattr(risk_controller, "RiskActionService", CountingActionService) + ack_key = f"risk-ack-{uuid4()}" + exclude_key = f"risk-exclude-{uuid4()}" + app.dependency_overrides[build_request_context] = override_context + app.dependency_overrides[get_session] = override_session + try: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + first = await client.post(ACK_PATH, headers={"Idempotency-Key": ack_key}) + replay = await client.post(ACK_PATH, headers={"Idempotency-Key": ack_key}) + excluded = await client.post( + EXCLUSION_PATH, + json={"reason": "客户本人确认"}, + headers={"Idempotency-Key": exclude_key}, + ) + conflict = await client.post( + EXCLUSION_PATH, + json={"reason": "换了理由"}, + headers={"Idempotency-Key": exclude_key}, + ) + + assert first.status_code == 200 + assert replay.status_code == 200 + # 只比 `data`:`meta.trace_id` 标识的是**本次**请求,重放也必须换一个新的。 + assert replay.json()["data"] == first.json()["data"] + assert excluded.status_code == 200 + assert conflict.status_code == 409 + assert conflict.json()["error"]["code"] == "IDEMPOTENCY_CONFLICT" + assert calls == ["ALERT-IDEM-TEST", "exclude:客户本人确认"] + finally: + app.dependency_overrides.pop(build_request_context, None) + app.dependency_overrides.pop(get_session, None) + await purge(ack_key, exclude_key) diff --git a/tests/unit/api/test_risk_controller.py b/tests/unit/api/test_risk_controller.py index 8b7d700..995789e 100644 --- a/tests/unit/api/test_risk_controller.py +++ b/tests/unit/api/test_risk_controller.py @@ -8,6 +8,8 @@ from app.api.dependencies.database import get_session from app.core.contracts import RequestContext from app.main import create_app +IDEMPOTENCY = {"Idempotency-Key": "risk-controller-test-0001"} + class StubRiskQueryService: def __init__(self, _session: Any) -> None: @@ -31,6 +33,25 @@ class StubRiskQueryService: return {"items": [{"source": source}], "next_cursor": None, "has_more": False} +class StubApiTransactionService: + """幂等透传替身:直接执行 action,不写 `api_request_receipt`。 + + 单测不连库,幂等读写的真实语义由 `tests/integration` 覆盖;这里只保证请求体与 + 状态码断言不被数据库依赖污染。 + """ + + async def execute_in( + self, + session: Any, + _context: RequestContext, + _scope: str, + _key: str | None, + _body: Any, + action: Any, + ) -> dict[str, Any]: + return await action(session) + + class StubRiskScanService: def __init__(self, _session: Any) -> None: pass @@ -94,6 +115,9 @@ class StubRiskDailyReportService: def __init__(self, _session: Any) -> None: pass + async def authorize(self, _context: RequestContext) -> None: + """SSE 端点要求构造 `StreamingResponse` 之前先完成鉴权(docs/25 P3 #24)。""" + async def generate(self, _context: RequestContext, _report_time: Any) -> dict[str, Any]: return {"report_date": "2026-09-10", "content": "日报正文"} @@ -103,11 +127,21 @@ class StubRiskDailyReportService: class StubRiskDailyReportMailService: - def send(self, recipients: list[str], _subject: str, _content: str) -> dict[str, Any]: + async def send( + self, + recipients: list[str], + _subject: str, + _content: str, + *, + context: RequestContext, + ) -> dict[str, Any]: + # 真实实现会先 `require("risk:report:mail")`;这里只复现调用形状, + # 权限本身由 test_risk_daily_report_service 里那组用例覆盖。 + del context return {"status": "dry_run", "recipient_count": len(recipients)} -def authenticated_client(monkeypatch) -> TestClient: +def authenticated_client(monkeypatch, *, idempotent: bool = True) -> TestClient: async def context() -> RequestContext: return RequestContext( user_id="990000002", @@ -132,6 +166,12 @@ def authenticated_client(monkeypatch) -> TestClient: "RiskNotificationService", StubRiskNotificationService, ) + if idempotent: + monkeypatch.setattr( + risk_controller, + "ApiTransactionService", + StubApiTransactionService, + ) monkeypatch.setattr( risk_controller, "RiskDailyReportService", @@ -180,7 +220,7 @@ def test_alert_and_detail_routes_bind_parameters(monkeypatch) -> None: detail = client.get("/api/v1/risk/alerts/ALERT-001") assert alerts.status_code == 200 - assert alerts.json()["data"]["items"][0]["alert_no"] == "ALERT-001" + assert alerts.json()["data"][0]["alert_no"] == "ALERT-001" assert detail.status_code == 200 assert detail.json()["data"]["alert"]["alert_no"] == "ALERT-001" @@ -191,13 +231,13 @@ def test_evidence_route_and_page_limit_are_enforced(monkeypatch) -> None: invalid = client.get("/api/v1/risk/evidence/customers?limit=11") assert valid.status_code == 200 - assert valid.json()["data"]["items"] == [{"source": "customers"}] + assert valid.json()["data"] == [{"source": "customers"}] assert invalid.status_code == 422 def test_scan_route_returns_success_envelope(monkeypatch) -> None: with authenticated_client(monkeypatch) as client: - response = client.post("/api/v1/risk/alerts/scan") + response = client.post("/api/v1/risk/alerts/scan", headers=IDEMPOTENCY) assert response.status_code == 200 assert response.json() == { @@ -219,23 +259,31 @@ def test_action_routes_require_authentication() -> None: def test_action_routes_use_success_envelope_and_validate_body(monkeypatch) -> None: with authenticated_client(monkeypatch) as client: - acknowledged = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements") - investigated = client.post("/api/v1/risk/alerts/ALERT-001/investigations") + acknowledged = client.post( + "/api/v1/risk/alerts/ALERT-001/acknowledgements", headers=IDEMPOTENCY + ) + investigated = client.post( + "/api/v1/risk/alerts/ALERT-001/investigations", headers=IDEMPOTENCY + ) excluded = client.post( "/api/v1/risk/alerts/ALERT-001/exclusions", json={"reason": "客户本人确认"}, + headers=IDEMPOTENCY, ) resolved = client.post( "/api/v1/risk/alerts/ALERT-001/resolutions", json={"resolution": "已核实并留痕"}, + headers=IDEMPOTENCY, ) escalated = client.post( "/api/v1/risk/alerts/ALERT-001/escalations", json={"reason": "需要高级复核"}, + headers=IDEMPOTENCY, ) invalid = client.post( "/api/v1/risk/alerts/ALERT-001/exclusions", json={"reason": " "}, + headers=IDEMPOTENCY, ) assert acknowledged.status_code == 200 @@ -246,6 +294,21 @@ def test_action_routes_use_success_envelope_and_validate_body(monkeypatch) -> No assert invalid.status_code == 422 +def test_write_routes_require_idempotency_key(monkeypatch) -> None: + """docs/05 §5.1:业务写接口必须携带 `Idempotency-Key`,缺失或过短都是 422。""" + with authenticated_client(monkeypatch, idempotent=False) as client: + missing = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements") + too_short = client.post( + "/api/v1/risk/alerts/ALERT-001/acknowledgements", + headers={"Idempotency-Key": "too-short"}, + ) + + assert missing.status_code == 422 + assert missing.json()["error"]["code"] == "AGENT_INPUT_INVALID" + assert too_short.status_code == 422 + assert too_short.json()["error"]["code"] == "AGENT_INPUT_INVALID" + + def test_evidence_upload_uses_success_envelope(monkeypatch) -> None: with authenticated_client(monkeypatch) as client: response = client.post( @@ -268,12 +331,30 @@ def test_notification_query_uses_notification_schema(monkeypatch) -> None: invalid = client.get("/api/v1/risk/notifications?limit=11") assert response.status_code == 200 - assert response.json()["data"]["items"] == [ + assert response.json()["data"] == [ {"notification_id": "N-001", "alert_no": "ALERT-001"} ] + # 列表资源的分页元数据必须在 `meta` 里(docs/05 §3.3),而不是混进 `data` + assert "next_cursor" in response.json()["meta"] + assert "has_more" in response.json()["meta"] assert invalid.status_code == 422 +def test_list_endpoints_follow_the_documented_envelope(monkeypatch) -> None: + """`data` 是纯数组、游标与 has_more 在 `meta` —— docs/05 §3.3 的列表样例。 + + 原先 `_page` 的 `{items, next_cursor, has_more}` 被整体塞进 `data`,游标因此出现在 + **业务数据**里,而 §3.3 明确「业务接口不得增加其他顶层字段」。 + """ + with authenticated_client(monkeypatch) as client: + body = client.get("/api/v1/risk/alerts?limit=5").json() + + assert isinstance(body["data"], list), "data 必须是纯数组" + assert "items" not in body["data"] if isinstance(body["data"], dict) else True + assert set(body["meta"]) == {"trace_id", "next_cursor", "has_more"} + assert set(body) == {"data", "meta"}, "不得增加其他顶层字段" + + def test_daily_report_generate_stream_and_mail(monkeypatch) -> None: with authenticated_client(monkeypatch) as client: generated = client.post( diff --git a/tests/unit/api/test_risk_stream_negotiation.py b/tests/unit/api/test_risk_stream_negotiation.py new file mode 100644 index 0000000..83a3962 --- /dev/null +++ b/tests/unit/api/test_risk_stream_negotiation.py @@ -0,0 +1,103 @@ +"""风控 SSE 端点的内容协商与鉴权时序(`docs/25` P3 #24)。 + +`POST /api/v1/risk/daily-report/stream` 此前既不校验 `Accept`,又把鉴权留在 +async generator 内部 —— 后者更隐蔽:`StreamingResponse` 已经返回、响应头已经发出, +`403` 只能变成"200 + 半截流"。所以这里同时断言两件事: + +1. 显式只接受 `application/json` → `406 SSE_NOT_ACCEPTABLE`,且响应体是统一 JSON 错误; +2. 无权限时即使 `Accept` 也非法,仍先得到 `403 AGENT_PERMISSION_DENIED`(顺序与 + `docs/05` §6.4 一致:鉴权先行,防止用状态码差异做探测)。 +""" + +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.database import get_session +from app.core.contracts import RequestContext +from app.core.errors import AgentPermissionDeniedError +from app.main import create_app + +STREAM_PATH = "/api/v1/risk/daily-report/stream" + + +async def resolve_context() -> RequestContext: + return RequestContext( + user_id="1", + trace_id="trace-1", + permissions=("risk:alert:read",), + data_scope="all", + ) + + +class StubReportService: + """替身:`authorize` 通过、流立即收尾,避免测试连接挂住。""" + + def __init__(self, session: Any) -> None: + self.session = session + + async def authorize(self, _context: RequestContext) -> None: + return None + + async def stream( + self, + _context: RequestContext, + _now: Any, + ) -> AsyncIterator[dict[str, Any]]: + yield {"type": "done", "report": {}} + + +class DenyingReportService(StubReportService): + async def authorize(self, _context: RequestContext) -> None: + raise AgentPermissionDeniedError("缺少 risk:alert:read 权限") + + +def client_with( + monkeypatch: pytest.MonkeyPatch, + service_class: type[StubReportService], +) -> TestClient: + application = create_app() + application.dependency_overrides[build_request_context] = resolve_context + application.dependency_overrides[get_session] = lambda: None + monkeypatch.setattr("app.api.controllers.risk.RiskDailyReportService", service_class) + return TestClient(application) + + +@pytest.mark.parametrize("accept", [None, "", "*/*", "text/*", "text/event-stream"]) +def test_absent_or_wildcard_accept_is_allowed( + monkeypatch: pytest.MonkeyPatch, accept: str | None +) -> None: + headers = {} if accept is None else {"Accept": accept} + with client_with(monkeypatch, StubReportService) as client: + response = client.post(STREAM_PATH, json={}, headers=headers) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + + +def test_json_only_accept_is_rejected_before_streaming( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with client_with(monkeypatch, StubReportService) as client: + response = client.post( + STREAM_PATH, json={}, headers={"Accept": "application/json"} + ) + + assert response.status_code == 406 + assert response.json()["error"]["code"] == "SSE_NOT_ACCEPTABLE" + assert response.headers["content-type"].startswith("application/json") + + +def test_authorization_precedes_accept_negotiation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with client_with(monkeypatch, DenyingReportService) as client: + response = client.post( + STREAM_PATH, json={}, headers={"Accept": "application/json"} + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED" diff --git a/tests/unit/core/test_timeutil.py b/tests/unit/core/test_timeutil.py new file mode 100644 index 0000000..a5af1f1 --- /dev/null +++ b/tests/unit/core/test_timeutil.py @@ -0,0 +1,79 @@ +"""时区换算工具的单元测试。 + +这个模块存在的理由是一次真实缺陷:风控的「凌晨时段小额操作」规则直接取库内 UTC 时间的 +`.hour`,于是 `[0,6)` UTC 被判成"凌晨",而它实际是北京时间 **08:00–14:00** —— 整条规则 +判的是上午(`docs/25` P1 #6)。日报也按 UTC 日切却按北京时间展示,两处对"同一天"的理解 +差 8 小时。 + +所以这里把边界钉死:**只要有人再把 UTC 小时当北京时间用,就有用例会红。** +""" + +from datetime import UTC, date, datetime +from zoneinfo import ZoneInfo + +from app.core.timeutil import ( + local_date, + local_day_bounds, + local_hour, + to_local, + to_utc_naive, +) + + +def test_utc_sixteen_hundred_is_beijing_midnight() -> None: + """库里 16:00 UTC 是北京次日 00:00 —— 这正是"凌晨"窗口的真正起点。""" + assert local_hour(datetime(2026, 9, 9, 16, 0)) == 0 + + +def test_night_window_boundaries() -> None: + """北京 00:00–06:00 的凌晨窗口,对应 UTC 前一日 16:00–22:00。""" + assert local_hour(datetime(2026, 9, 9, 15, 59)) == 23 # 北京 23:59,还没到凌晨 + assert local_hour(datetime(2026, 9, 9, 16, 0)) == 0 # 北京 00:00,起点(含) + assert local_hour(datetime(2026, 9, 9, 21, 59)) == 5 # 北京 05:59,仍在窗口内 + assert local_hour(datetime(2026, 9, 9, 22, 0)) == 6 # 北京 06:00,终点(不含) + + +def test_utc_morning_is_not_night_in_beijing() -> None: + """这条是原缺陷的复现:UTC 凌晨 0-6 点其实是北京的上午,绝不能被当成"凌晨"。""" + for utc_hour in (0, 2, 4, 5): + assert local_hour(datetime(2026, 9, 9, utc_hour, 0)) not in range(0, 6) + + +def test_local_date_rolls_over_at_beijing_midnight() -> None: + """日期归属按北京:UTC 15:59 还是北京的当天,16:00 已经是次日。""" + assert local_date(datetime(2026, 9, 9, 15, 59)) == date(2026, 9, 9) + assert local_date(datetime(2026, 9, 9, 16, 0)) == date(2026, 9, 10) + + +def test_local_day_bounds_is_returned_in_utc_naive_for_querying() -> None: + """日界必须换算回库内格式(UTC naive):它要拿去查 UTC 列, + 返回本地时间会让区间与库内值整体错开 8 小时。""" + start, end = local_day_bounds(datetime(2026, 9, 9, 20, 0)) # = 北京 09-10 04:00 + + assert start == datetime(2026, 9, 9, 16, 0) # 北京 09-10 00:00 + assert end == datetime(2026, 9, 10, 16, 0) # 北京 09-11 00:00 + assert start.tzinfo is None and end.tzinfo is None + assert (end - start).days == 1 + + +def test_local_day_bounds_before_beijing_eight_am_keeps_the_local_day() -> None: + """北京 08:00 前生成日报时,日界不能滑到前一天 —— 这是原 bug 的复现场景。""" + start, _end = local_day_bounds(datetime(2026, 9, 9, 23, 0)) # = 北京 09-10 07:00 + + assert local_date(start) == date(2026, 9, 10) + + +def test_aware_input_is_interpreted_in_its_own_zone() -> None: + """带时区的入参按它自己声明的时区解释 —— 那说明它来自库外(例如请求参数)。""" + shanghai_noon = datetime(2026, 9, 10, 12, 0, tzinfo=ZoneInfo("Asia/Shanghai")) + + assert to_utc_naive(shanghai_noon) == datetime(2026, 9, 10, 4, 0) + assert local_hour(shanghai_noon) == 12 + + +def test_to_local_keeps_aware_value_consistent() -> None: + """naive 与"同值的 aware(UTC)"必须换算成同一个本地时刻。""" + naive = datetime(2026, 9, 9, 16, 0) + aware = datetime(2026, 9, 9, 16, 0, tzinfo=UTC) + + assert to_local(naive) == to_local(aware) diff --git a/tests/unit/repository/test_risk_alert_row_close_reason.py b/tests/unit/repository/test_risk_alert_row_close_reason.py new file mode 100644 index 0000000..bc5fd96 --- /dev/null +++ b/tests/unit/repository/test_risk_alert_row_close_reason.py @@ -0,0 +1,64 @@ +"""预警行必须带出 `close_reason`,否则日报的"误报原因"永远是空。 + +docs/25 P2。`_alert_row` 是**列表 / 详情 / 日报共用**的行构造器 +(`risk_repository.py:174` / `:276` / `:321`),而它的字段列表里原先没有 `close_reason`。 +后果是两个症状同一个原因 —— 行里根本没带出来: + +- 日报的"误报原因"分布恒为"未填写" + (`risk_daily_report_service.py:139` 用 `item.get("close_reason") or "未填写"`); +- `:243` 的明细里同一字段同样为空。 + +断掉的是"关闭误报时写入原因"这条链路的下半段:`risk_action_service.py:70` 把原因赋给 +`alert.close_reason`、也存进了库,只是读取时没带出来。 +""" + +from types import SimpleNamespace +from typing import Any + +from app.repository.risk_repository import RiskRepository + +CLOSED_REASON = "已核验为本人自动定投计划。" + + +def _alert(**overrides: Any) -> Any: + base: dict[str, Any] = { + "alert_no": "ALTEST", + "customer_id": 9001, + "alert_type": "大额频繁交易", + "alert_level": "高", + "trigger_rule_codes": ["RW-007"], + "evidence_summary": "摘要", + "evidence_snapshot": {}, + "priority_score": 90, + "event_status": "刚刚发生", + "status": "已关闭", + "ack_status": "已确认", + "ack_at": None, + "due_at": None, + "is_escalated": 0, + "escalated_at": None, + "close_reason": CLOSED_REASON, + "created_at": None, + "updated_at": None, + } + base.update(overrides) + return SimpleNamespace(**base) + + +def test_alert_row_carries_close_reason() -> None: + """关闭原因必须出现在行里 —— 它是日报"误报原因"的唯一来源。""" + row = RiskRepository._alert_row(_alert(), None, None, None, None) + + assert row["close_reason"] == CLOSED_REASON + + +def test_alert_row_keeps_the_key_even_when_there_is_no_reason() -> None: + """未关闭的预警没有原因,但**键必须在**。 + + 读取方用 `item.get("close_reason") or "未填写"` 兜底,所以 None 与缺键在展示上等价; + 可一旦键不存在,读取方就永远只能走到兜底分支 —— 那正是本次修复前的状态。 + """ + row = RiskRepository._alert_row(_alert(close_reason=None), None, None, None, None) + + assert "close_reason" in row + assert row["close_reason"] is None diff --git a/tests/unit/repository/test_risk_repository.py b/tests/unit/repository/test_risk_repository.py index 0aa652b..8ca3541 100644 --- a/tests/unit/repository/test_risk_repository.py +++ b/tests/unit/repository/test_risk_repository.py @@ -241,6 +241,9 @@ async def test_alert_detail_returns_immutable_snapshot() -> None: assert record["customer"]["customer_no"] == "CUST-009" assert record["transaction"]["transaction_no"] == "TX-010" assert record["product"]["product_code"] == "P-R5" + # 证据查询已封顶(docs/25 P3 #20):未触发上限时必须报告"完整", + # 而不是给前端一个无法区分"就这么多"和"被砍了"的静默结果。 + assert record["evidence_truncated"] == [] async def test_customer_evidence_includes_latest_assessment_and_masks_name() -> None: diff --git a/tests/unit/repository/test_risk_repository_limits.py b/tests/unit/repository/test_risk_repository_limits.py new file mode 100644 index 0000000..61080e5 --- /dev/null +++ b/tests/unit/repository/test_risk_repository_limits.py @@ -0,0 +1,85 @@ +"""全量查询封顶与截断标注(`docs/25` P3 #20)。 + +详情证据与日报此前都是"一次取全量":某个客户的历史流水或登录记录一旦异常膨胀, +单次响应就会把内存和连接拖垮。这里验证三件事: + +1. 查询确实带上了 `LIMIT 上限 + 1`; +2. 取满 `上限 + 1` 行时返回前 `上限` 行并**报告截断**; +3. 恰好等于上限时**不**误报截断 —— 只看"取满没取满"会把正常数据说成被截断。 + +用 `literal_binds` 编译 SQL:绑定参数形式下看不到 201,断言会变成"只验证了有 LIMIT"。 +""" + +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.dialects import mysql + +from app.model.fund import FundHolding +from app.repository.risk_repository import EVIDENCE_DETAIL_LIMIT, RiskRepository + + +class FakeScalars: + """模拟 `ScalarResult`:可迭代,但**不**提供 `.all()` 以外的便捷方法。""" + + def __init__(self, rows: list[Any]) -> None: + self.rows = rows + + def __iter__(self) -> Any: + return iter(self.rows) + + def all(self) -> list[Any]: + return list(self.rows) + + +class FakeSession: + def __init__(self, pages: list[list[Any]]) -> None: + self.pages = list(pages) + self.statements: list[Any] = [] + + async def scalars(self, statement: Any) -> FakeScalars: + self.statements.append(statement) + return FakeScalars(self.pages.pop(0) if self.pages else []) + + +def _sql(statement: Any) -> str: + return str( + statement.compile( + dialect=mysql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + + +@pytest.mark.asyncio +async def test_capped_keeps_limit_rows_and_reports_truncation() -> None: + session = FakeSession([list(range(EVIDENCE_DETAIL_LIMIT + 1))]) + + kept, truncated = await RiskRepository(session)._capped(select(FundHolding)) # type: ignore[arg-type] + + assert len(kept) == EVIDENCE_DETAIL_LIMIT + assert truncated is True + sql = _sql(session.statements[0]) + assert "LIMIT" in sql.upper() + assert str(EVIDENCE_DETAIL_LIMIT + 1) in sql + + +@pytest.mark.asyncio +async def test_capped_exact_limit_is_not_reported_as_truncated() -> None: + session = FakeSession([list(range(EVIDENCE_DETAIL_LIMIT))]) + + kept, truncated = await RiskRepository(session)._capped(select(FundHolding)) # type: ignore[arg-type] + + assert len(kept) == EVIDENCE_DETAIL_LIMIT + assert truncated is False + + +@pytest.mark.asyncio +async def test_capped_small_result_is_untouched() -> None: + session = FakeSession([[1, 2, 3]]) + + kept, truncated = await RiskRepository(session)._capped(select(FundHolding)) # type: ignore[arg-type] + + assert kept == [1, 2, 3] + assert truncated is False diff --git a/tests/unit/service/test_config_release_dropped_items.py b/tests/unit/service/test_config_release_dropped_items.py new file mode 100644 index 0000000..e3b674d --- /dev/null +++ b/tests/unit/service/test_config_release_dropped_items.py @@ -0,0 +1,141 @@ +"""配置发布激活时的"配置被静默丢掉"告警。 + +**背景**:`config_release` 是**整版本替换**语义 —— 激活新版本后,旧版本在 +`platform_config_item` / `prompt_template_version` / `model_routing_rule` 三张表里的内容 +**全部失效**。新版本只要漏了某项,它就是**无声消失**的,而且不会报错。 + +这不是假想:客服闲聊提示词就这么失效过一次 —— 它挂在 release 174,active 变成 181 后 +`load_active_prompt` 读不到,而 Agent 侧有逐字段兜底、回落到代码默认值,于是功能看着正常, +**没有任何人发现,也没有任何告警**。第一版告警只比对 `platform_config_item`, +正是那个盲区让这件事发生。 + +所以这里逐张表锁住:只要发生丢项,就必须在日志里点名,并且说清是哪张表。 +""" + +import logging +from typing import Any, cast + +import pytest + +from app.service.config_release_service import RELEASE_SCOPED_TABLES, ConfigReleaseService + +_KNOWN_TABLES = tuple(table for table, _keys in RELEASE_SCOPED_TABLES) + + +class _FakeResult: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def mappings(self) -> "_FakeResult": + return self + + def all(self) -> list[dict[str, Any]]: + return self._rows + + +class _FakeSession: + """按「表 + release_id」返回预设行。 + + 被测代码逐表、逐 release 查询(`SELECT FROM WHERE release_id = :rid`), + 所以这里从 SQL 文本里认出表名,再按 `params["rid"]` 取行。不这么做的话, + 查提示词表时会拿到平台配置项的行,报 `KeyError: 'prompt_code'` + —— 第一版 fake 就踩了这个(真实代码是按表查的,问题只在 fake)。 + """ + + def __init__(self, rows: dict[tuple[str, int], list[dict[str, Any]]]) -> None: + self._rows = rows + + async def execute(self, statement: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(statement) + table = next((name for name in _KNOWN_TABLES if name in sql), "") + release_id = (params or {}).get("rid") + if not table or release_id is None: + return _FakeResult([]) + return _FakeResult(list(self._rows.get((table, int(release_id)), []))) + + +class _Release: + def __init__(self, release_id: int, release_no: str) -> None: + self.id = release_id + self.release_no = release_no + + +def _service(rows: dict[tuple[str, int], list[dict[str, Any]]]) -> ConfigReleaseService: + return ConfigReleaseService(cast(Any, _FakeSession(rows))) + + +def _item(key: str, release_id: int) -> tuple[tuple[str, int], list[dict[str, Any]]]: + return ( + ("platform_config_item", release_id), + [{"namespace": "agent_tools", "config_key": key}], + ) + + +def _prompt(code: str, release_id: int) -> tuple[tuple[str, int], list[dict[str, Any]]]: + return ( + ("prompt_template_version", release_id), + [{"prompt_code": code, "task_type": "chat", "agent_type": "customer_service"}], + ) + + +@pytest.mark.asyncio +async def test_warns_when_new_release_drops_config_items( + caplog: pytest.LogCaptureFixture, +) -> None: + """新版本少了一条平台配置项时必须点名——它会静默失效。""" + service = _service(dict([_item("customer_service:faq", 1), _item("risk:risk_overview", 2)])) + + with caplog.at_level(logging.WARNING): + await service._warn_dropped_items(_Release(2, "new"), [_Release(1, "old")]) + + assert "agent_tools" in caplog.text + assert "customer_service:faq" in caplog.text + + +@pytest.mark.asyncio +async def test_warns_when_new_release_drops_a_prompt_template( + caplog: pytest.LogCaptureFixture, +) -> None: + """提示词模板被丢掉时同样要点名,并说清是哪张表。 + + 这条是本次修复的核心:第一版告警只比对 `platform_config_item`,于是客服闲聊提示词 + 在 `prompt_template_version` 里被静默丢掉时**一行告警都没有**,直到有人手工查库才发现。 + """ + service = _service(dict([_prompt("customer_service_chitchat", 1)])) + + with caplog.at_level(logging.WARNING): + await service._warn_dropped_items(_Release(2, "new"), [_Release(1, "old")]) + + assert "prompt_template_version" in caplog.text + assert "customer_service_chitchat" in caplog.text + + +@pytest.mark.asyncio +async def test_no_warning_when_everything_is_carried_over( + caplog: pytest.LogCaptureFixture, +) -> None: + """配置被完整继承时不该有噪音——否则运维会习惯性忽略这条日志。""" + service = _service(dict([ + _item("customer_service:faq", 1), + _item("customer_service:faq", 2), + _prompt("customer_service_chitchat", 1), + _prompt("customer_service_chitchat", 2), + ])) + + with caplog.at_level(logging.WARNING): + await service._warn_dropped_items(_Release(2, "new"), [_Release(1, "old")]) + + assert caplog.text == "" + + +@pytest.mark.asyncio +async def test_first_release_warns_about_nothing( + caplog: pytest.LogCaptureFixture, +) -> None: + """平台首个版本没有"前一个版本",不该报丢项。""" + service = _service(dict([_item("customer_service:faq", 1)])) + + with caplog.at_level(logging.WARNING): + await service._warn_dropped_items(_Release(1, "first"), []) + + assert caplog.text == "" diff --git a/tests/unit/service/test_risk_daily_report_service.py b/tests/unit/service/test_risk_daily_report_service.py index e6a87e9..5af05e2 100644 --- a/tests/unit/service/test_risk_daily_report_service.py +++ b/tests/unit/service/test_risk_daily_report_service.py @@ -4,6 +4,7 @@ from types import MappingProxyType, SimpleNamespace import pytest from app.core.contracts import RequestContext +from app.core.errors import ForbiddenAgentError from app.repository.fund_query_repository import FundRecord from app.repository.risk_repository import RiskReportSnapshot from app.service.risk_daily_report_mail_service import RiskDailyReportMailService @@ -127,6 +128,7 @@ async def test_daily_report_contains_nine_sections_and_historical_items() -> Non ) assert report["daily_alert_count"] == 1 + assert report["data_truncated"] is False assert report["unresolved_items"]["total"] == 2 assert report["unresolved_items"]["historical"] == 1 assert report["unresolved_items"]["overdue"] == 1 @@ -138,6 +140,32 @@ async def test_daily_report_contains_nine_sections_and_historical_items() -> Non assert session.added[0].action_type == "risk_daily_report_generated" +@pytest.mark.asyncio +async def test_daily_report_flags_truncated_snapshot() -> None: + """行数封顶后日报计数会偏低,必须显式标注(docs/25 P3 #20)。 + + `daily_alert_count` 这类数字直接来自行数;静默截断等于给出一份看起来正常、实际 + 少统计的日报 —— 那比直接报错更难发现。 + """ + snapshot = RiskReportSnapshot( + daily=(record("ALERT-TODAY"),), + unresolved=(), + false_positive=(), + dispositions=(), + truncated=True, + ) + service = RiskDailyReportService( + FakeSession(), + repository=FakeRepository(snapshot), + model_service=FailingModelService(), + endpoint_resolver=FailingEndpointResolver(), + ) + + report = await service.generate(context(), now=datetime(2026, 9, 10, 10, 0)) + + assert report["data_truncated"] is True + + @pytest.mark.asyncio async def test_daily_report_lazily_initializes_model_service(monkeypatch) -> None: current = record("ALERT-MODEL") @@ -170,33 +198,73 @@ async def test_daily_report_lazily_initializes_model_service(monkeypatch) -> Non assert report["optimization_suggestions"] == "1. 根据模型生成日报建议" -def test_mail_service_is_disabled_by_default() -> None: - result = RiskDailyReportMailService(environment={}).send( +def _mail_context(*permissions: str) -> RequestContext: + """邮件端点的调用上下文。真实实现在发送前会 `require("risk:report:mail")`。""" + return RequestContext( + user_id="900000002", + trace_id="risk-mail-test", + permissions=permissions, + data_scope="all", + ) + + +@pytest.mark.asyncio +async def test_mail_service_is_disabled_by_default() -> None: + result = await RiskDailyReportMailService(environment={}).send( ["risk@example.com"], "日报", "正文", + context=_mail_context("risk:report:mail"), ) assert result == {"status": "disabled", "recipient_count": 1} -def test_mail_service_uses_dry_run_without_connecting() -> None: - result = RiskDailyReportMailService( +@pytest.mark.asyncio +async def test_mail_service_requires_the_permission() -> None: + """没有 `risk:report:mail` 的身份必须被拒。 + + 这个端点此前是风控里**唯一没有授权校验**的:收件人、标题、正文全由客户端决定, + 一旦运维开启 SMTP,它就是一个未授权的邮件发送器。这条用例把它钉住。 + """ + with pytest.raises(ForbiddenAgentError): + await RiskDailyReportMailService(environment={}).send( + ["attacker@example.com"], + "任意标题", + "任意正文", + context=_mail_context(), + ) + + +@pytest.mark.asyncio +async def test_mail_service_uses_dry_run_without_connecting() -> None: + result = await RiskDailyReportMailService( environment={ "RISK_DAILY_REPORT_MAIL_ENABLED": "true", "RISK_DAILY_REPORT_MAIL_DRY_RUN": "true", } - ).send(["risk@example.com"], "日报", "正文") + ).send( + ["risk@example.com"], + "日报", + "正文", + context=_mail_context("risk:report:mail"), + ) assert result == {"status": "dry_run", "recipient_count": 1} -def test_mail_service_reports_missing_configuration() -> None: - result = RiskDailyReportMailService( +@pytest.mark.asyncio +async def test_mail_service_reports_missing_configuration() -> None: + result = await RiskDailyReportMailService( environment={ "RISK_DAILY_REPORT_MAIL_ENABLED": "true", "RISK_DAILY_REPORT_MAIL_DRY_RUN": "false", } - ).send(["risk@example.com"], "日报", "正文") + ).send( + ["risk@example.com"], + "日报", + "正文", + context=_mail_context("risk:report:mail"), + ) assert result == {"status": "configuration_error", "recipient_count": 1} diff --git a/tests/unit/service/test_risk_judgement_merged_evidence.py b/tests/unit/service/test_risk_judgement_merged_evidence.py new file mode 100644 index 0000000..c2eb138 --- /dev/null +++ b/tests/unit/service/test_risk_judgement_merged_evidence.py @@ -0,0 +1,90 @@ +"""合并预警的证据必须能被研判读到(docs/25 P2)。 + +**问题**:同一笔交易命中多条规则时,扫描器会把它们合并成一条 +(`risk_scan_service._merge_same_transaction_alerts`),合并后的 `evidence_snapshot` +只留 `product_id` 和 `merged_alerts`,各条**原有的证据键被塞进 +`merged_alerts[].evidence`**。而研判函数读的是**顶层键**(`snapshot.get("ratio")` 之类), +于是合并过的预警一律读不到证据、降级成"缺证据无法复核" —— 合并本来是为了少几条噪音, +结果把这些预警的研判全废了。 + +修法是在研判入口统一摊平(`_flatten_merged_evidence`),列表级与详情级共用。 +""" + +from typing import Any + +from app.service.risk_judgement_service import ( + _assess_detail_rule, + _flatten_merged_evidence, +) + + +def _merged(**nested_evidence: Any) -> dict[str, Any]: + return { + "transaction": {"amount": "600000"}, + "evidence_snapshot": { + "product_id": 7001, + "merged_alerts": [ + {"alert_type": "大额赎回", "evidence": nested_evidence}, + ], + }, + } + + +def test_non_merged_detail_is_returned_unchanged() -> None: + detail = {"evidence_snapshot": {"ratio": "0.9"}} + + assert _flatten_merged_evidence(detail) == detail + + +def test_nested_evidence_is_lifted_to_top_level() -> None: + flat = _flatten_merged_evidence(_merged(ratio="0.9", average_amount="100"))["evidence_snapshot"] + + assert flat["ratio"] == "0.9" + assert flat["average_amount"] == "100" + assert flat["product_id"] == 7001 # 原有键不丢 + assert "merged_alerts" in flat # 嵌套结构保留,可追溯性不受影响 + + +def test_top_level_value_wins_over_nested() -> None: + """冲突时保留顶层 —— 它来自 priority_score 最高的那条主预警。""" + detail = { + "evidence_snapshot": { + "ratio": "9", + "merged_alerts": [{"alert_type": "A", "evidence": {"ratio": "1"}}], + } + } + + assert _flatten_merged_evidence(detail)["evidence_snapshot"]["ratio"] == "9" + + +def test_multiple_merged_entries_are_all_lifted() -> None: + detail = { + "evidence_snapshot": { + "product_id": 1, + "merged_alerts": [ + {"alert_type": "A", "evidence": {"ratio": "0.9"}}, + {"alert_type": "B", "evidence": {"average_amount": "100"}}, + ], + } + } + flat = _flatten_merged_evidence(detail)["evidence_snapshot"] + + assert flat["ratio"] == "0.9" + assert flat["average_amount"] == "100" + + +def test_rw003_no_longer_degrades_on_merged_evidence() -> None: + """报告描述的原始症状:合并后 RW-003 读不到 ratio、一律降级。 + + 对比"没有 ratio"与"ratio 藏在 merged_alerts 里"两种输入 —— 摊平之后后者应当 + 真的把 ratio 读进来,于是两条路径的研判结果不再相同。 + """ + without_ratio = { + "transaction": {"amount": "600000"}, + "evidence_snapshot": {"product_id": 7001}, + } + + degraded = _assess_detail_rule("RW-003", without_ratio) + recovered = _assess_detail_rule("RW-003", _merged(ratio="0.9")) + + assert str(degraded) != str(recovered), "摊平后应当读到嵌套的 ratio,研判结果应不同" diff --git a/tests/unit/service/test_risk_judgement_rw007.py b/tests/unit/service/test_risk_judgement_rw007.py new file mode 100644 index 0000000..ad2b550 --- /dev/null +++ b/tests/unit/service/test_risk_judgement_rw007.py @@ -0,0 +1,117 @@ +"""RW-007 研判必须复核第十五条豁免额度(`docs/25` 第七节 #2)。 + +政策原文(`knowledge/policy/个人投资者适当性管理指南.md:334-341`): + +- C3 买 R4:签署《产品风险超越投资者风险承受能力揭示书》后**可买**,但单只 R4 持仓 + 不超过总资产 **20%**; +- C4 买 R5:同理,上限 **10%**。 + +也就是说越级购买本身不是违规,**超出额度**才是。原先 `_assess_rw007` 只看留痕: +只要揭示书、二次确认、录音都齐,就判"疑似误报" —— 而这三样恰恰是豁免的**前提条件**, +把前提当成结论,于是"签了字但买超了额度"被静默放过。 + +数据缺失的情况单独处理:额度要靠总资产与持仓快照才能核算,本项目的画像与持仓由上游 +流程写入(实测 `total_asset` 全为 0、`fin_holding` 为空),既不能默认"没超"判误报, +也不能默认"超了"判风险 —— 那会把豁免规则变成新的误报源。 +""" + +from typing import Any + +from app.service.risk_judgement_service import ( + VERDICT_CONTINUE_REVIEW, + VERDICT_RISK_SUPPORTED, + VERDICT_SUSPECTED_FALSE_POSITIVE, + _assess_rw007, +) + +COMPLETE_WORK_ORDER: dict[str, Any] = { + "risk_disclosure_ack_at": "2026-09-01T00:00:00", + "second_confirmation_at": "2026-09-01T00:00:00", + "recording_reference": "REC-1", +} + +REQUIRING_PRODUCT: dict[str, Any] = { + "risk_level": "R4", + "risk_disclosure_required": True, + "second_confirmation_required": True, + "recording_required": True, +} + + +def _detail( + snapshot: dict[str, Any], + *, + work_order: dict[str, Any] | None = None, + investor_type: str = "C3", +) -> dict[str, Any]: + """一条 C3→R4(相差 1 级、属豁免情形)的 RW-007 详情。""" + return { + "customer": {"investor_type": investor_type}, + "product": dict(REQUIRING_PRODUCT), + "work_order": dict(COMPLETE_WORK_ORDER if work_order is None else work_order), + "evidence_snapshot": snapshot, + } + + +def test_limit_breach_is_risk_supported_even_with_complete_trace() -> None: + """这是本次补上的判断:留痕齐全 ≠ 豁免成立,额度超了就是违规。""" + result = _assess_rw007( + _detail({"exemption_limit": "0.20", "exemption_ratio": "0.35"}) + ) + + assert VERDICT_RISK_SUPPORTED in str(result) + assert "35.00%" in str(result) + assert "20%" in str(result) + + +def test_within_limit_with_complete_trace_is_a_false_positive() -> None: + result = _assess_rw007( + _detail({"exemption_limit": "0.20", "exemption_ratio": "0.10"}) + ) + + assert VERDICT_SUSPECTED_FALSE_POSITIVE in str(result) + + +def test_exact_limit_is_not_a_breach() -> None: + """政策写的是"不超过",恰好等于上限属于合规。""" + result = _assess_rw007( + _detail({"exemption_limit": "0.20", "exemption_ratio": "0.2"}) + ) + + assert VERDICT_SUSPECTED_FALSE_POSITIVE in str(result) + + +def test_missing_snapshot_data_asks_for_manual_review() -> None: + """额度核算不出结果时,既不判误报也不判风险。""" + result = _assess_rw007( + _detail( + { + "exemption_limit": "0.20", + "exemption_ratio": None, + "exemption_data_missing": True, + } + ) + ) + + assert VERDICT_CONTINUE_REVIEW in str(result) + assert "总资产" in str(result) + + +def test_missing_trace_still_outranks_the_exemption_path() -> None: + """留痕不完整仍然直接判风险 —— 豁免的**前提**没满足,谈额度没有意义。""" + result = _assess_rw007( + _detail( + {"exemption_limit": "0.20", "exemption_ratio": "0.01"}, + work_order={}, + ) + ) + + assert VERDICT_RISK_SUPPORTED in str(result) + assert "缺少交易留痕" in str(result) + + +def test_pairs_outside_the_exemption_table_keep_the_old_behaviour() -> None: + """C2→R5 不属豁免情形,快照里也不会有额度字段:行为必须与改动前一致。""" + result = _assess_rw007(_detail({}, investor_type="C2")) + + assert VERDICT_SUSPECTED_FALSE_POSITIVE in str(result) diff --git a/tests/unit/service/test_risk_judgement_rw012.py b/tests/unit/service/test_risk_judgement_rw012.py new file mode 100644 index 0000000..bc0a9a6 --- /dev/null +++ b/tests/unit/service/test_risk_judgement_rw012.py @@ -0,0 +1,56 @@ +"""RW-012 研判必须复核"≥ 3 倍历史均值"这条生成条件。 + +docs/25 P2:扫描侧生成预警要求 `average > 0 且 amount >= average * 3` +(`risk_scan_service.py`),而研判侧的 `_assess_rw012` 只看了年龄、金额、非常用设备, +**完全没核均值** —— 于是达不到 3 倍的交易也会被判"证据支持风险"。 +讽刺的是它的建议文本写着"继续核实一年期历史交易均值":作者知道该看,只是当时确实 +没有数据可看。 + +根因是快照里没有均值(原先只写 `product_id` / `age` / `device_id`)。本次两侧一起改: +扫描侧补写 `average_amount` 与 `ratio`,研判侧据此复核。 + +这三个用例都停在登录记录判断**之前**,所以不必构造合法的登录证据。 +""" + +from typing import Any + +from app.service.risk_judgement_service import ( + VERDICT_CONTINUE_REVIEW, + VERDICT_SUSPECTED_FALSE_POSITIVE, + _assess_rw012, +) + + +def _detail(*, ratio: str | None) -> dict[str, Any]: + """一条已满足年龄与金额门槛的 RW-012,ratio 由用例决定(None 表示快照里没有)。""" + snapshot: dict[str, Any] = {} if ratio is None else {"ratio": ratio} + return { + "customer": {"birth_date": "1950-01-01"}, # 70 岁以上 + "transaction": {"amount": "500000", "confirmed_at": "2026-09-10T04:00:00"}, + "evidence_snapshot": snapshot, + "login_records": [], + } + + +def test_missing_ratio_does_not_fall_through_to_risk_supported() -> None: + """快照里没有均值时不能顺着"非常用设备"就定案 —— 应该继续复核。""" + result = _assess_rw012(_detail(ratio=None)) + + assert VERDICT_CONTINUE_REVIEW in str(result) + + +def test_ratio_below_three_is_a_false_positive() -> None: + """不到 3 倍均值就不满足规则的生成条件 —— 这是本次补上的判断。""" + result = _assess_rw012(_detail(ratio="2.5")) + + assert VERDICT_SUSPECTED_FALSE_POSITIVE in str(result) + assert "2.50" in str(result) + + +def test_ratio_at_or_above_three_passes_the_threshold_check() -> None: + """达到 3 倍时应继续往下走(本用例没有登录记录,故停在"补查登录记录")。""" + for ratio in ("3", "6.2"): + result = _assess_rw012(_detail(ratio=ratio)) + + assert VERDICT_CONTINUE_REVIEW in str(result), f"ratio={ratio} 不该被判成误报" + assert VERDICT_SUSPECTED_FALSE_POSITIVE not in str(result) diff --git a/tests/unit/service/test_risk_judgement_rw018.py b/tests/unit/service/test_risk_judgement_rw018.py new file mode 100644 index 0000000..375a513 --- /dev/null +++ b/tests/unit/service/test_risk_judgement_rw018.py @@ -0,0 +1,65 @@ +"""RW-018 研判的两个口径缺陷(docs/25 P2)。 + +1. **列表级无条件放行**:`_assess_list_rule` 收到 `item` 却完全不看它,对 RW-018 + 一律返回"可考虑放行",连理由文本都是硬编码的"现有摘要显示交易来自有效定投工单"。 + 于是渠道不匹配、或证据里根本没有工单信息的预警,在列表层就被标成可放行 —— + 而列表正是风控专员最先看到的一屏。详情层另有判断,但那要等人点进去。 + +2. **详情级与扫描侧口径不一致**:扫描按 `work_order.channel in {"定投", "自动定投"}` + 生成 RW-018 预警(`risk_scan_service.py:294`),详情级却只认 `"定投"`。于是 + "自动定投"的预警会出现"扫描认为有效、详情认为未确认"的自相矛盾。 + +两条现在共用 `DIRECT_INVESTMENT_CHANNELS`,改这份常量即同时影响两侧。 +""" + +from typing import Any + +from app.service.risk_judgement_service import ( + DIRECT_INVESTMENT_CHANNELS, + VERDICT_RELEASE, + _assess_list_rule, + _assess_rw018, +) + + +def _item(**snapshot: Any) -> dict[str, Any]: + return {"alert_no": "ALTEST", "evidence_snapshot": snapshot} + + +def test_channels_cover_what_the_scanner_accepts() -> None: + """常量必须覆盖扫描侧接受的全部渠道,否则又会两侧打架。""" + assert DIRECT_INVESTMENT_CHANNELS == {"定投", "自动定投"} + + +def test_list_level_does_not_release_without_evidence() -> None: + """证据里没有渠道信息时不能放行 —— 这正是原先无条件放行的地方。""" + result = _assess_list_rule("RW-018", _item()) + + assert VERDICT_RELEASE not in str(result) + + +def test_list_level_does_not_release_for_an_unrelated_channel() -> None: + result = _assess_list_rule("RW-018", _item(channel="柜台")) + + assert VERDICT_RELEASE not in str(result) + + +def test_list_level_releases_for_every_direct_investment_channel() -> None: + for channel in sorted(DIRECT_INVESTMENT_CHANNELS): + result = _assess_list_rule("RW-018", _item(channel=channel)) + + assert VERDICT_RELEASE in str(result), f"{channel} 应当判定为可放行" + + +def test_detail_level_accepts_the_same_channels_as_the_scanner() -> None: + """详情级与扫描侧同口径 —— 原先详情只认"定投","自动定投"会被判成未确认。""" + for channel in sorted(DIRECT_INVESTMENT_CHANNELS): + result = _assess_rw018({"work_order": {"channel": channel}}) + + assert VERDICT_RELEASE in str(result), f"{channel} 在详情级也应判为可放行" + + +def test_detail_level_rejects_a_channel_the_scanner_would_not_use() -> None: + result = _assess_rw018({"work_order": {"channel": "柜台"}}) + + assert VERDICT_RELEASE not in str(result) diff --git a/tests/unit/service/test_risk_notification_service.py b/tests/unit/service/test_risk_notification_service.py index eeb8577..5a754c9 100644 --- a/tests/unit/service/test_risk_notification_service.py +++ b/tests/unit/service/test_risk_notification_service.py @@ -1,5 +1,7 @@ from datetime import datetime +import pytest + from app.model.fund import FundRiskAlert from app.service.risk_notification_service import RiskNotificationService @@ -77,3 +79,47 @@ def test_high_risk_batch_creates_in_app_and_mail_records() -> None: assert len(records) == 2 assert {record.channel for record in records} == {"站内提醒", "邮件"} + + +@pytest.mark.asyncio +async def test_notification_list_interprets_time_as_local_time(monkeypatch) -> None: + from app.api.schemas.risk import RiskNotificationPageQuery + from app.core.contracts import RequestContext + from app.repository.fund_query_repository import FundPage + + captured: dict = {} + + class StubRepository: + def __init__(self, _session, *, scope) -> None: + self.scope = scope + + async def list_notifications(self, **kwargs) -> FundPage: + captured.update(kwargs) + return FundPage( + entity="risk_notification", + items=(), + limit=10, + offset=0, + next_offset=None, + ) + + monkeypatch.setattr( + "app.service.risk_notification_service.RiskRepository", + StubRepository, + ) + service = RiskNotificationService(object()) + query = RiskNotificationPageQuery( + start_time=datetime(2026, 9, 10, 12, 0), + end_time=datetime(2026, 9, 10, 13, 0), + ) + context = RequestContext( + user_id="990000002", + trace_id="trace", + permissions=("risk:alert:read",), + data_scope="all", + ) + + await service.list_notifications(context, query) + + assert captured["start_time"] == datetime(2026, 9, 10, 4, 0) + assert captured["end_time"] == datetime(2026, 9, 10, 5, 0) diff --git a/tests/unit/service/test_risk_query_service.py b/tests/unit/service/test_risk_query_service.py index d492d9a..f2df772 100644 --- a/tests/unit/service/test_risk_query_service.py +++ b/tests/unit/service/test_risk_query_service.py @@ -7,7 +7,7 @@ import pytest from app.api.schemas.risk import RiskAlertPageQuery, RiskEvidencePageQuery from app.core.contracts import RequestContext from app.core.errors import GenericResourceNotFoundError, InvalidCursorError -from app.core.risk_cursor import decode_offset_cursor, encode_offset_cursor +from app.core.risk_cursor import cursor_binding, decode_offset_cursor, encode_offset_cursor from app.repository.fund_query_repository import CustomerScope, FundPage, FundRecord from app.service.risk_query_service import RiskQueryService, scope_from_context @@ -52,6 +52,28 @@ class FakeRepository: return None +class RecordingRepository(FakeRepository): + def __init__(self) -> None: + super().__init__() + self.calls: list[tuple[str, dict]] = [] + + async def list_transactions(self, **kwargs) -> FundPage: + self.calls.append(("transactions", kwargs)) + return self.page + + async def list_capital_flows(self, **kwargs) -> FundPage: + self.calls.append(("capital_flows", kwargs)) + return self.page + + async def list_login_records(self, **kwargs) -> FundPage: + self.calls.append(("login_records", kwargs)) + return self.page + + async def list_notifications(self, **kwargs) -> FundPage: + self.calls.append(("notifications", kwargs)) + return self.page + + def context(**updates) -> RequestContext: values = { "user_id": "990000002", @@ -64,12 +86,15 @@ def context(**updates) -> RequestContext: def test_cursor_round_trip_and_invalid_values() -> None: - assert decode_offset_cursor(encode_offset_cursor(20)) == 20 - assert decode_offset_cursor(None) == 0 + binding = cursor_binding(user_id="990000002", filters={"keyword": None}) + assert ( + decode_offset_cursor(encode_offset_cursor(20, binding=binding), binding=binding) == 20 + ) + assert decode_offset_cursor(None, binding=binding) == 0 with pytest.raises(InvalidCursorError): - decode_offset_cursor("not-a-cursor") + decode_offset_cursor("not-a-cursor", binding=binding) with pytest.raises(InvalidCursorError): - decode_offset_cursor(encode_offset_cursor(1) + "x") + decode_offset_cursor(encode_offset_cursor(1, binding=binding) + "x", binding=binding) def test_query_schema_enforces_business_page_sizes() -> None: @@ -105,17 +130,78 @@ async def test_overview_maps_levels_and_serializes_high_priority() -> None: @pytest.mark.asyncio async def test_alert_page_returns_opaque_cursor() -> None: service = RiskQueryService(None, repository=FakeRepository()) + query = RiskAlertPageQuery(limit=5) - result = await service.list_alerts(context(), RiskAlertPageQuery(limit=5)) + result = await service.list_alerts(context(), query) assert result["has_more"] is True - assert decode_offset_cursor(result["next_cursor"]) == 5 + binding = RiskQueryService._binding(context(), query) + assert decode_offset_cursor(result["next_cursor"], binding=binding) == 5 assert result["items"][0]["rule_codes"] == ["RW-007"] +def test_cursor_is_bound_to_user_and_filters() -> None: + """§3.8:游标绑定用户与查询条件,换个人或换个筛选条件都必须失效。""" + query = RiskAlertPageQuery(limit=5) + raw = encode_offset_cursor(5, binding=RiskQueryService._binding(context(), query)) + + assert decode_offset_cursor(raw, binding=RiskQueryService._binding(context(), query)) == 5 + + with pytest.raises(InvalidCursorError): + decode_offset_cursor( + raw, + binding=RiskQueryService._binding(context(user_id="990000003"), query), + ) + with pytest.raises(InvalidCursorError): + other_filter = RiskAlertPageQuery(limit=5, keyword="张三") + decode_offset_cursor( + raw, + binding=RiskQueryService._binding(context(), other_filter), + ) + # data_scope 收紧后旧游标同样失效 + with pytest.raises(InvalidCursorError): + decode_offset_cursor( + raw, + binding=RiskQueryService._binding( + context(data_scope="own_customers", customer_ids=("9",)), query + ), + ) + + +def test_cursor_ignores_page_size_and_other_filters_do_not_collide() -> None: + """翻页时改 limit 不该让游标失效;不同证据类型之间不能互相串用游标。""" + base = RiskEvidencePageQuery(limit=10) + binding = RiskQueryService._binding(context(), base, "customers") + + again = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "customers") + other = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "products") + assert binding == again + assert binding != other + + @pytest.mark.asyncio async def test_missing_alert_detail_is_hidden() -> None: service = RiskQueryService(None, repository=FakeRepository()) with pytest.raises(GenericResourceNotFoundError): await service.get_alert_detail(context(), "ALERT-NOT-FOUND") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "source", + ("transactions", "capital_flows", "login_records", "notifications"), +) +async def test_evidence_time_filters_are_interpreted_as_local_time(source: str) -> None: + repository = RecordingRepository() + service = RiskQueryService(None, repository=repository) + query = RiskEvidencePageQuery( + start_time=datetime(2026, 9, 10, 12, 0), + end_time=datetime(2026, 9, 10, 13, 0), + ) + + await service.list_evidence(context(), source, query) + + assert repository.calls[0][0] == source + assert repository.calls[0][1]["start_time"] == datetime(2026, 9, 10, 4, 0) + assert repository.calls[0][1]["end_time"] == datetime(2026, 9, 10, 5, 0) diff --git a/tests/unit/service/test_risk_scan_notification.py b/tests/unit/service/test_risk_scan_notification.py new file mode 100644 index 0000000..ec3a1fe --- /dev/null +++ b/tests/unit/service/test_risk_scan_notification.py @@ -0,0 +1,110 @@ +"""扫描的通知环节:失败必须能被区分出来。 + +**背景**:`_create_notifications` 原先失败时 `return 0`,而"无需通知"(没有高风险预警、 +通知功能关闭)也返回 0 —— 调用方只拿到一个 `notification_count`,分不清两者。 + +这不是洁癖:**通知没发出去等于处置链路的第一环断了,而扫描依旧报"完成"**。 +风控系统里这种"看起来在工作、其实有一环没跑"的状态最难发现(同一批缺陷里, +"定时扫描重启后不再执行"也是这个性质)。 + +所以这里锁住契约:返回 `(条数, 失败原因)`,失败原因非空即代表出了问题。 +""" + +import logging +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from app.service.risk_scan_service import HIGH_RISK, RiskScanService + + +class _FakeNested: + async def __aenter__(self) -> "_FakeNested": + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + +class _FakeSession: + """只提供 `begin_nested()`;被测方法不碰其它会话能力。""" + + def begin_nested(self) -> _FakeNested: + return _FakeNested() + + +class _FakeNotificationService: + def __init__(self, *, fail: bool = False) -> None: + self.fail = fail + self.in_app: list[str] = [] + + def create_in_app(self, alert: Any, **kwargs: Any) -> None: + if self.fail: + raise RuntimeError("通知写库失败") + self.in_app.append(str(alert.alert_no)) + + def create_mail_record(self, alert: Any, **kwargs: Any) -> None: + if self.fail: + raise RuntimeError("邮件记录写库失败") + + +def _alert(level: str, alert_no: str = "ALTEST") -> Any: + return SimpleNamespace( + alert_level=level, alert_no=alert_no, alert_type="大额频繁交易", + evidence_summary="摘要", handler_id=9002, + ) + + +def _service(*, enabled: bool = True, fail: bool = False, + email: str | None = None) -> RiskScanService: + service = object.__new__(RiskScanService) + service.notification_enabled = enabled + service.notification_email = email + service.mail_enabled = False + service.notification_service = cast(Any, _FakeNotificationService(fail=fail)) + service.session = cast(Any, _FakeSession()) + return service + + +@pytest.mark.asyncio +async def test_success_reports_count_and_no_failure() -> None: + service = _service() + + count, failure = await service._create_notifications([_alert(HIGH_RISK)]) + + assert count == 1 + assert failure == "" + + +@pytest.mark.asyncio +async def test_nothing_to_notify_is_not_a_failure() -> None: + """没有高风险预警,或通知功能关闭——都不算失败,失败原因应为空。""" + service = _service() + assert await service._create_notifications([_alert("低")]) == (0, "") + + disabled = _service(enabled=False) + assert await disabled._create_notifications([_alert(HIGH_RISK)]) == (0, "") + + +@pytest.mark.asyncio +async def test_failure_is_reported_not_swallowed(caplog: pytest.LogCaptureFixture) -> None: + """通知创建失败时必须带出原因——这正是原先丢失的信号。""" + service = _service(fail=True) + + with caplog.at_level(logging.ERROR): + count, failure = await service._create_notifications([_alert(HIGH_RISK)]) + + assert count == 0 + assert "RuntimeError" in failure + assert "通知写库失败" in failure + + +@pytest.mark.asyncio +async def test_failure_reason_differs_from_nothing_to_notify() -> None: + """两种 0 必须可区分——这是本次修复的全部意义。""" + quiet, _ = await _service()._create_notifications([_alert("低")]) + _count, failure = await _service(fail=True)._create_notifications([_alert(HIGH_RISK)]) + + assert quiet == 0 + assert failure != "", "失败必须能与『无需通知』区分开" diff --git a/tests/unit/service/test_risk_scan_robustness.py b/tests/unit/service/test_risk_scan_robustness.py new file mode 100644 index 0000000..92c31f3 --- /dev/null +++ b/tests/unit/service/test_risk_scan_robustness.py @@ -0,0 +1,73 @@ +"""扫描健壮性:脏数据不该让整批停摆,调度器不该"重启后再也不跑"。 + +这两条都属于"风控看起来在工作、其实没在跑"那类缺陷,现象上很难发现: + +1. `_level_value` 原先直接 `int(level.replace(prefix, ""))` —— 一条等级字段脏的数据 + (比如写了「中风险」)会抛 ValueError,冒到 `scan()` 的兜底 → **整批 rollback**, + 本次扫描前面已经生成的预警全部作废。 +2. 调度器的 `last_run_at` 只在内存,重启后为 None,而 `_is_due` 此时返回 + `config.run_immediately`(默认 False)→ **重启后永不执行**,且没有任何告警。 +""" + +import datetime as dt +from types import SimpleNamespace +from typing import Any, cast + +from app.service.risk_scan_service import _level_value +from app.worker.risk_scan_scheduler import RiskScanSchedulerWorker + + +def test_level_value_parses_valid_levels() -> None: + assert _level_value("R2", "R") == 2 + assert _level_value("C3", "C") == 3 + assert _level_value("c5", "C") == 5 # 大小写不敏感 + assert _level_value(" 2 ", "R") == 2 # 历史数据可能不带前缀 + + +def test_level_value_returns_none_instead_of_raising_on_dirty_data() -> None: + """脏数据返回 None,不抛异常 —— 这是不让整批扫描 rollback 的前提。""" + assert _level_value("中风险", "R") is None + assert _level_value("", "R") is None + assert _level_value("R", "R") is None + assert _level_value("R6", "R") == 6 # 越界值交给上层比较,解析本身只负责取数字 + + +def test_level_value_does_not_over_strip() -> None: + """用 removeprefix 而非 replace:`R2R` 不该被错当成 2。""" + assert _level_value("R2R", "R") is None + + +def _scheduler(*, last_run_at: dt.datetime | None) -> RiskScanSchedulerWorker: + """只构造被测方法用到的那两个属性。""" + scheduler = object.__new__(RiskScanSchedulerWorker) + scheduler.last_run_at = last_run_at + return scheduler + + +def _config(*, run_immediately: bool, interval_minutes: int = 30) -> Any: + return SimpleNamespace(run_immediately=run_immediately, interval_minutes=interval_minutes) + + +_NOW = dt.datetime(2026, 9, 11, 12, 0, tzinfo=dt.UTC) + + +def test_first_run_is_due_even_when_run_immediately_is_false() -> None: + """进程刚起来(last_run_at 为 None)必须视为 due,哪怕是 run_immediately=False。 + + 原先这里返回 run_immediately,默认 False ⇒ 重启后永不执行。 + """ + scheduler = cast(Any, _scheduler(last_run_at=None)) + + assert scheduler._is_due(_config(run_immediately=False), _NOW) is True + + +def test_not_due_before_interval_elapses() -> None: + scheduler = cast(Any, _scheduler(last_run_at=_NOW - dt.timedelta(minutes=10))) + + assert scheduler._is_due(_config(run_immediately=False), _NOW) is False + + +def test_due_after_interval_elapses() -> None: + scheduler = cast(Any, _scheduler(last_run_at=_NOW - dt.timedelta(minutes=31))) + + assert scheduler._is_due(_config(run_immediately=False), _NOW) is True diff --git a/tests/unit/service/test_risk_scan_service.py b/tests/unit/service/test_risk_scan_service.py index a8739eb..4e83283 100644 --- a/tests/unit/service/test_risk_scan_service.py +++ b/tests/unit/service/test_risk_scan_service.py @@ -473,6 +473,80 @@ async def test_suitability_mismatch_rejects_complete_trace_and_matching_level() assert await RiskRuleEngine(session)._suitability_mismatch() == [] +def _complete_trace() -> RiskWorkOrder: + return work_order( + disclosure_at=datetime(2026, 9, 1), + confirmation_at=datetime(2026, 9, 1), + recording_reference="REC-1", + ) + + +@pytest.mark.asyncio +async def test_suitability_exemption_limit_breach_raises_alert() -> None: + """C3→R4 属第十五条允许的越级购买,但单只持仓超过总资产 20% 仍要预警。 + + 原先扫描侧只看"留痕是否齐全":签了揭示书、留痕齐全就直接不报 —— 于是"签了字 + 但买超额度"这种明确违规反而没有预警。 + """ + session = FakeSession( + rows=[transaction()], + get_values=[risk_user("C3"), product("R4"), _complete_trace()], + scalar_values=[None, Decimal("100000.00"), Decimal("30000.00")], + ) + + alerts = await RiskRuleEngine(session)._suitability_mismatch() + + assert len(alerts) == 1 + assert alerts[0].alert_level == "中" + assert "20%" in alerts[0].evidence_summary + assert alerts[0].evidence_snapshot["exemption_ratio"] == "0.3" + assert alerts[0].evidence_snapshot["exemption_limit"] == "0.20" + + +@pytest.mark.asyncio +async def test_suitability_exemption_within_limit_is_not_an_alert() -> None: + """留痕齐全且占比未超额度 → 豁免成立,不该产生预警。""" + session = FakeSession( + rows=[transaction()], + get_values=[risk_user("C3"), product("R4"), _complete_trace()], + scalar_values=[None, Decimal("100000.00"), Decimal("10000.00")], + ) + + assert await RiskRuleEngine(session)._suitability_mismatch() == [] + + +@pytest.mark.asyncio +async def test_suitability_exemption_missing_asset_data_does_not_create_alerts() -> None: + """总资产为 0(上游未落地)时不能算成"超限"。 + + 本项目的画像与持仓由本项目之外的流程写入,拿 0 去算会让每一笔 C3→R4 都变成 + 违规 —— 豁免规则就成了新的误报源。缺失只如实记录,交给研判侧提示人工确认。 + """ + session = FakeSession( + rows=[transaction()], + get_values=[risk_user("C3"), product("R4"), _complete_trace()], + scalar_values=[None, Decimal("0"), Decimal("10000.00")], + ) + + assert await RiskRuleEngine(session)._suitability_mismatch() == [] + + +@pytest.mark.asyncio +async def test_suitability_missing_trace_still_alerts_without_exemption_data() -> None: + """留痕不完整这条老语义不能被新增的额度逻辑冲掉。""" + session = FakeSession( + rows=[transaction()], + get_values=[risk_user("C3"), product("R4"), work_order()], + scalar_values=[None, Decimal("0"), None], + ) + + alerts = await RiskRuleEngine(session)._suitability_mismatch() + + assert len(alerts) == 1 + assert "交易留痕不完整" in alerts[0].evidence_summary + assert alerts[0].evidence_snapshot["exemption_data_missing"] is True + + @pytest.mark.asyncio async def test_elderly_redemption_requires_age_amount_average_and_uncommon_device() -> None: login = RiskLoginRecord( @@ -530,20 +604,24 @@ async def test_elderly_redemption_rejects_common_device() -> None: @pytest.mark.asyncio async def test_night_small_trade_boundaries() -> None: + # confirmed_at 在库里是 **UTC**(见 app/infrastructure/db.py:15-21),而"凌晨"是 + # **北京时间**概念:北京 00:00–06:00 对应 UTC 的前一日 16:00–22:00。 + # 原先这里直接拿 UTC 小时当北京时间(写 0 点 / 6 点),等于在测"北京 08:00 / 14:00", + # 规则修正后这些用例的语义也一并修正(docs/25 P1 #6)。 night = transaction() - night.confirmed_at = datetime(2026, 9, 10, 0, 0) + night.confirmed_at = datetime(2026, 9, 9, 16, 0) # 北京 09-10 00:00,凌晨起点(含) night.amount = Decimal("10000.00") session = FakeSession(rows=[night], scalar_values=[None]) alerts = await RiskRuleEngine(session)._low_risk_night_trade() assert len(alerts) == 1 regular = transaction() - regular.confirmed_at = datetime(2026, 9, 10, 6, 0) + regular.confirmed_at = datetime(2026, 9, 9, 22, 0) # 北京 09-10 06:00,凌晨终点(不含) session = FakeSession(rows=[regular], scalar_values=[None]) assert await RiskRuleEngine(session)._low_risk_night_trade() == [] too_large = transaction() - too_large.confirmed_at = datetime(2026, 9, 10, 2, 0) + too_large.confirmed_at = datetime(2026, 9, 9, 18, 0) # 北京 09-10 02:00,在凌晨但金额超限 too_large.amount = Decimal("10000.01") session = FakeSession(rows=[too_large], scalar_values=[None]) assert await RiskRuleEngine(session)._low_risk_night_trade() == [] diff --git a/tests/unit/service/test_tool_executor_denials.py b/tests/unit/service/test_tool_executor_denials.py new file mode 100644 index 0000000..a4c0e9e --- /dev/null +++ b/tests/unit/service/test_tool_executor_denials.py @@ -0,0 +1,145 @@ +"""ToolExecutor 的拒绝路径:三种"用不了"必须能区分,且细节不得外泄。 + +**背景**:意图码要求三处对齐(`AgentDefinition.supported_intents` / `agent_intent_config` / +发布版 `agent_tools` 白名单),而缺配置时是**失败关闭**。原先"意图压根没发布白名单"与 +"白名单里没这个工具"共用一句「工具不在当前意图白名单」,运维无法判断该去补发布配置、 +还是该改白名单内容——本项目已因此踩坑两次(客服、风控)。 + +这个文件锁两件事: + +1. 三种拒绝的 message **互不相同**,各自指向不同的处置动作; +2. 白名单内容、权限码、角色集这些**内部配置只进审计**,不进异常 message —— + 后者会随 API 响应返回给调用方。 +""" + +from typing import Any + +import pytest +from pydantic import BaseModel + +from app.core.contracts import RequestContext +from app.core.errors import ForbiddenAgentError +from app.service.tool_executor import ToolDefinition, ToolExecutor, ToolRegistry + +PERMISSION = "knowledge:reference:read" + + +class _Args(BaseModel): + model_config = {"extra": "forbid"} + + query: str = "" + + +async def _handler(arguments: BaseModel, context: RequestContext) -> Any: + return {"ok": True} + + +def _executor() -> tuple[ToolExecutor, list[tuple[Any, ...]]]: + registry = ToolRegistry() + registry.register(ToolDefinition( + name="search_knowledge", + input_model=_Args, + handler=_handler, + required_permission=PERMISSION, + allowed_roles=("customer", "advisor"), + )) + executor = ToolExecutor(registry) + audits: list[tuple[Any, ...]] = [] + + async def fake_audit(name: str, intent: str, context: RequestContext, + status: str, reason: str) -> None: + audits.append((name, intent, status, reason)) + + executor._audit = fake_audit # type: ignore[method-assign] + return executor, audits + + +def _context(*, permissions: tuple[str, ...] = (), roles: tuple[str, ...] = ("customer",) + ) -> RequestContext: + return RequestContext(user_id="1", trace_id="t", permissions=permissions, roles=roles) + + +async def _reject( + executor: ToolExecutor, context: RequestContext, *, + intent: str, configured: dict[str, tuple[str, ...]], +) -> str: + with pytest.raises(ForbiddenAgentError) as excinfo: + await executor.execute( + name="search_knowledge", arguments={"query": "x"}, intent=intent, + configured_tools=configured, context=context, + ) + return str(excinfo.value) + + +@pytest.mark.asyncio +async def test_missing_intent_config_differs_from_missing_tool() -> None: + """「意图没发布白名单」与「白名单里没这个工具」必须是两句不同的话。""" + executor, _ = _executor() + context = _context(permissions=(PERMISSION,)) + + no_config = await _reject(executor, context, intent="faq", configured={}) + not_listed = await _reject( + executor, context, intent="faq", configured={"faq": ("other_tool",)} + ) + + assert no_config != not_listed + assert "未配置" in no_config + assert "白名单" in not_listed + + +@pytest.mark.asyncio +async def test_permission_and_role_failures_have_their_own_messages() -> None: + executor, _ = _executor() + configured = {"faq": ("search_knowledge",)} + + no_permission = await _reject(executor, _context(), intent="faq", configured=configured) + wrong_role = await _reject( + executor, _context(permissions=(PERMISSION,), roles=("risk_operator",)), + intent="faq", configured=configured, + ) + + assert "权限" in no_permission + assert "角色" in wrong_role + assert no_permission != wrong_role + + +@pytest.mark.asyncio +async def test_internal_detail_goes_to_audit_not_to_the_message() -> None: + """权限码这类内部配置只进审计,不进会返回给调用方的异常 message。""" + executor, audits = _executor() + + message = await _reject(executor, _context(), intent="faq", + configured={"faq": ("search_knowledge",)}) + + assert PERMISSION not in message + assert any(PERMISSION in str(entry[3]) for entry in audits), "审计里应当有具体缺哪个权限" + assert audits[0][2] == "denied" + + +@pytest.mark.asyncio +async def test_audit_names_what_to_fix_when_config_is_absent() -> None: + """缺配置时审计要指出该去补哪一类配置,而不是只说"不在白名单"。""" + executor, audits = _executor() + + await _reject(executor, _context(permissions=(PERMISSION,)), intent="faq", configured={}) + + detail = str(audits[0][3]) + assert "工具白名单为空" in detail + assert "faq" in detail + + +@pytest.mark.asyncio +async def test_empty_whitelist_is_what_the_real_pipeline_produces() -> None: + """真实链路里拿到的是**空元组**,而不是"缺键"。 + + `governance.resolve` 会为每个 `supported_intents` 预填条目(governance.py:55-61), + 所以 `intent not in configured_tools` 这个判断在运行期**永远不成立** —— 第一版就是 + 那么写的,而这条用例原先用 `configured={}` 手工构造,把它掩盖了,直到端到端跑 + 探针 Agent 才暴露出来。现在按真实形状构造。 + """ + executor, _ = _executor() + context = _context(permissions=(PERMISSION,)) + + message = await _reject(executor, context, intent="faq", configured={"faq": ()}) + + assert "未配置" in message diff --git a/tools/grant_risk_permissions.py b/tools/grant_risk_permissions.py new file mode 100644 index 0000000..63fd123 --- /dev/null +++ b/tools/grant_risk_permissions.py @@ -0,0 +1,121 @@ +"""补齐风控模块需要的 RBAC 权限。 + +**背景(实测)**:风控 service 层用 `AuthorizationService.require` 声明了四个权限码, +但 `sys_permission` 表里**只有一个**(`risk:alert:read`,本脚本早先建的)。其余三个不存在, +于是对应功能**全部失败关闭**: + +| 权限码 | 用途 | 缺失后果 | +|---|---|---| +| `risk:alert:read` | 预警查询 / 研判 / 日报 / 通知 | (已建,正常) | +| `risk:alert:write` | 预警处置 / 证据归档 | 6 处调用被拒:确认、关闭、升级、归档都做不了 | +| `risk:alert:scan` | 规则扫描 | 扫描端点调不了 | +| `risk:report:mail` | 发送日报邮件 | 本次新增;邮件默认关闭,但仍需授权 | + +失效的表现是 `ForbiddenAgentError`,而**只读查询一切正常** —— 所以很容易以为"风控能用", +直到去点处置按钮才发现。这与 `risk:alert:read` 当初缺失是同一类问题,只是面更广。 + +**字段怎么填**(照现有数据推断,不是自创): + +- 权限匹配实际用的是 `permission_code` 全串(`identity_repository.py:25-37`), + `resource` / `action` 只是元数据; +- 参照 `fund:quote:read`(`resource=fund`、`action=quote`):`resource` 取第一段、 + `action` 取第二段; +- `data_scope` 取 `all`:风控要处理**全部客户**的预警,且 `risk_query_service.py:194`、 + `risk_analysis_service.py:126`、`risk_evidence_archive_service.py:218`、 + `risk_action_service.py:212` 都按 `context.data_scope == "all"` 决定是否放行全量数据。 + +**授权给谁**:`risk_operator` 与 `admin`。风控 Agent 的 +`AgentDefinition.allowed_roles = ("risk_operator", "admin")` 两个都声明了, +所以两个角色都该能真正用起来 —— 只给 `risk_operator` 会让 admin 声明了却用不了。 + +幂等:权限与绑定都已存在时直接跳过。 + +用法:python tools/grant_risk_permissions.py +""" + +import asyncio +import datetime as dt +import sys + +import asyncmy + +from app.core.config import get_settings + +# (permission_code, resource, action, 授予的角色) +PERMISSIONS: tuple[tuple[str, str, str, tuple[str, ...]], ...] = ( + ("risk:alert:read", "risk", "alert", ("risk_operator", "admin")), + ("risk:alert:write", "risk", "alert", ("risk_operator", "admin")), + ("risk:alert:scan", "risk", "alert", ("risk_operator", "admin")), + ("risk:report:mail", "risk", "report", ("risk_operator", "admin")), +) +DATA_SCOPE = "all" + + +async def connect() -> "asyncmy.Connection": + settings = get_settings() + # MYSQL_DSN 形如 mysql+asyncmy://user:pass@host:port/db + dsn = settings.mysql_dsn.split("://", 1)[1] + credentials, location = dsn.split("@", 1) + user, password = credentials.split(":", 1) + host_port, database = location.split("/", 1) + host, _, port = host_port.partition(":") + return await asyncmy.connect( + host=host, port=int(port or 3306), user=user, password=password, db=database + ) + + +async def main() -> int: + connection = await connect() + granted = 0 + try: + cursor = connection.cursor() + now = dt.datetime.now(dt.UTC).replace(tzinfo=None) + for code, resource, action, roles in PERMISSIONS: + await cursor.execute( + "SELECT id FROM sys_permission WHERE permission_code = %s", (code,) + ) + row = await cursor.fetchone() + if row is None: + await cursor.execute( + "INSERT INTO sys_permission" + " (permission_code, resource, `action`, data_scope, created_at, updated_at)" + " VALUES (%s, %s, %s, %s, %s, %s)", + (code, resource, action, DATA_SCOPE, now, now), + ) + await cursor.execute( + "SELECT id FROM sys_permission WHERE permission_code = %s", (code,) + ) + row = await cursor.fetchone() + print(f"[权限] 已创建 {code}(scope={DATA_SCOPE})") + else: + print(f"[权限] {code} 已存在") + permission_id = int(row[0]) + + for role_code in roles: + await cursor.execute("SELECT id FROM sys_role WHERE role_code = %s", (role_code,)) + role = await cursor.fetchone() + if role is None: + print(f"[角色] 找不到 {role_code},跳过 {code}") + continue + role_id = int(role[0]) + await cursor.execute( + "SELECT 1 FROM sys_role_permission WHERE role_id = %s AND permission_id = %s", + (role_id, permission_id), + ) + if await cursor.fetchone() is not None: + continue + await cursor.execute( + "INSERT INTO sys_role_permission (role_id, permission_id, created_at)" + " VALUES (%s, %s, %s)", + (role_id, permission_id, now), + ) + granted += 1 + print(f"[绑定] 授予 {role_code} → {code}") + await connection.commit() + print(f"\n本次新增绑定 {granted} 条") + return 0 + finally: + connection.close() + + +sys.exit(asyncio.run(main())) diff --git a/tools/probe_exemption_data.py b/tools/probe_exemption_data.py new file mode 100644 index 0000000..e3a29ae --- /dev/null +++ b/tools/probe_exemption_data.py @@ -0,0 +1,101 @@ +"""只读探查:第十五条豁免规则(持仓占比)所需的数据是否齐备。 + +只做 SELECT。结果写 `docs/evidence/exemption-data-probe.json`: + + python tools/probe_exemption_data.py + +要回答的问题: +1. `fin_customer_profile.total_asset` 有没有值、是否为正; +2. `fin_holding` 有没有行、`market_value` / `current_value` 是否可用; +3. 库内是否存在 C3→R4 / C4→R5 的申购交易 —— 即这条豁免规则是否真的会被触发。 +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from app.infrastructure.db import SessionFactory + +OUTPUT = Path("docs/evidence/exemption-data-probe.json") + +QUERIES: dict[str, str] = { + "profile_total_asset": """ + SELECT COUNT(*) AS rows_count, + SUM(total_asset > 0) AS positive_assets, + SUM(total_asset = 0) AS zero_assets, + MIN(total_asset) AS min_asset, + MAX(total_asset) AS max_asset + FROM fin_customer_profile + """, + "profile_investor_type": """ + SELECT investor_type, COUNT(*) AS rows_count + FROM fin_customer_profile GROUP BY investor_type ORDER BY investor_type + """, + "holding_shape": """ + SELECT COUNT(*) AS rows_count, + SUM(market_value IS NULL) AS null_market_value, + SUM(current_value > 0) AS positive_current_value, + MIN(current_value) AS min_current_value, + MAX(current_value) AS max_current_value + FROM fin_holding + """, + "subscription_pairs": """ + SELECT c.investor_type AS customer_level, + p.risk_level AS product_level, + COUNT(*) AS transactions + FROM fin_transaction t + JOIN fin_customer_profile c ON c.customer_id = t.customer_id + JOIN fin_product p ON p.id = t.product_id + WHERE t.transaction_type = '申购' + GROUP BY c.investor_type, p.risk_level + ORDER BY c.investor_type, p.risk_level + """, + "exemptible_pairs_detail": """ + SELECT t.transaction_no, + c.investor_type AS customer_level, + p.risk_level AS product_level, + c.total_asset, + ( + SELECT h.current_value FROM fin_holding h + WHERE h.customer_id = t.customer_id AND h.product_id = t.product_id + ORDER BY h.id DESC LIMIT 1 + ) AS holding_current_value + FROM fin_transaction t + JOIN fin_customer_profile c ON c.customer_id = t.customer_id + JOIN fin_product p ON p.id = t.product_id + WHERE t.transaction_type = '申购' + AND ( + (c.investor_type = 'C3' AND p.risk_level = 'R4') + OR (c.investor_type = 'C4' AND p.risk_level = 'R5') + ) + ORDER BY t.id + """, +} + + +async def collect() -> dict[str, Any]: + report: dict[str, Any] = {} + async with SessionFactory() as session: + for name, sql in QUERIES.items(): + rows = (await session.execute(text(sql))).mappings().all() + report[name] = [dict(row) for row in rows] + return report + + +async def main() -> None: + report = await collect() + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text( + json.dumps(report, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + print(f"wrote {OUTPUT}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/probe_release_state.py b/tools/probe_release_state.py new file mode 100644 index 0000000..290ea86 --- /dev/null +++ b/tools/probe_release_state.py @@ -0,0 +1,90 @@ +"""只读探查:当前 active 配置发布与配置项数量(用于同步 docs/24 的"当前状态"表)。 + +只做 SELECT,结果写 `docs/evidence/release-state.json`: + + python tools/probe_release_state.py +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from app.infrastructure.db import SessionFactory + +OUTPUT = Path("docs/evidence/release-state.json") + + +async def collect() -> dict[str, Any]: + report: dict[str, Any] = {} + async with SessionFactory() as session: + report["recent_releases"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT id, release_no, title, status, created_at, activated_at + FROM config_release + ORDER BY id DESC + LIMIT 5 + """ + ) + ) + ) + .mappings() + .all() + ) + ] + report["active_items"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT i.namespace, COUNT(*) AS items + FROM platform_config_item i + JOIN config_release r ON r.id = i.release_id + WHERE r.status = 'active' + GROUP BY i.namespace + ORDER BY i.namespace + """ + ) + ) + ) + .mappings() + .all() + ) + ] + report["active_prompt_versions"] = ( + await session.execute( + text( + """ + SELECT COUNT(*) FROM prompt_template_version p + JOIN config_release r ON r.id = p.release_id + WHERE r.status = 'active' + """ + ) + ) + ).scalar_one() + return report + + +async def main() -> None: + report = await collect() + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text( + json.dumps(report, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + print(f"wrote {OUTPUT}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/probe_risk_index.py b/tools/probe_risk_index.py new file mode 100644 index 0000000..e2d4c1b --- /dev/null +++ b/tools/probe_risk_index.py @@ -0,0 +1,138 @@ +"""只读探查:`fin_risk_alert.trigger_rule_codes` 能否用 JSON 多值索引(docs/25 P3 #21)。 + +只做 SELECT / EXPLAIN,不做任何写入。结果写成 JSON 便于在 GBK 控制台下查看: + + python tools/probe_risk_index.py + +排查点: +1. MySQL 版本是否 ≥ 8.0.17(多值索引的下限); +2. 列是否确实是 JSON、是否已存在同类索引; +3. 是否存在**非数组**取值 —— 有的话 `CAST(... AS CHAR ARRAY)` 建索引会直接失败; +4. 当前 `JSON_CONTAINS` 查询走的是什么访问路径(有没有可用的 key)。 +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from app.infrastructure.db import SessionFactory + +OUTPUT = Path("docs/evidence/risk-index-probe.json") + + +async def collect() -> dict[str, Any]: + report: dict[str, Any] = {} + async with SessionFactory() as session: + report["mysql_version"] = ( + await session.execute(text("SELECT VERSION()")) + ).scalar_one() + + report["column"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT COLUMN_NAME, COLUMN_TYPE, DATA_TYPE, IS_NULLABLE + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'fin_risk_alert' + AND COLUMN_NAME = 'trigger_rule_codes' + """ + ) + ) + ) + .mappings() + .all() + ) + ] + + report["indexes"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT INDEX_NAME, COLUMN_NAME, INDEX_TYPE, EXPRESSION + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'fin_risk_alert' + ORDER BY INDEX_NAME, SEQ_IN_INDEX + """ + ) + ) + ) + .mappings() + .all() + ) + ] + + report["row_count"] = ( + await session.execute(text("SELECT COUNT(*) FROM fin_risk_alert")) + ).scalar_one() + report["non_array_rows"] = ( + await session.execute( + text( + "SELECT COUNT(*) FROM fin_risk_alert " + "WHERE trigger_rule_codes IS NULL " + " OR JSON_TYPE(trigger_rule_codes) <> 'ARRAY'" + ) + ) + ).scalar_one() + report["distinct_rule_codes"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT CAST(trigger_rule_codes AS CHAR) AS raw_codes, + COUNT(*) AS rows_count + FROM fin_risk_alert + GROUP BY 1 + """ + ) + ) + ) + .mappings() + .all() + ) + ] + + # 用 FORMAT=JSON:传统 EXPLAIN 会把"该查询无法被缓存"写成 warning 打到 stderr, + # 让脚本在 CI 里看起来是失败的,而它其实成功了。 + raw_explain = ( + await session.execute( + text( + """ + EXPLAIN FORMAT=JSON + SELECT id FROM fin_risk_alert + WHERE JSON_CONTAINS(trigger_rule_codes, '"RW-018"') + """ + ) + ) + ).scalar_one() + report["explain_json_contains"] = json.loads(raw_explain) + + return report + + +async def main() -> None: + report = await collect() + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text( + json.dumps(report, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + print(f"wrote {OUTPUT}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/publish_chitchat_prompt.py b/tools/publish_chitchat_prompt.py index cf08cf7..080267c 100644 --- a/tools/publish_chitchat_prompt.py +++ b/tools/publish_chitchat_prompt.py @@ -1,13 +1,26 @@ """把客服闲聊提示词发布为可审核、可回滚的配置版本。 业务方选定「提示词走发布配置」而不是写死在代码里:改话术要经过审核并留痕, -这也符合金融场景对口径变更的要求。Agent 侧通过 `load_active_prompt` 读取, -读不到时回落到代码内置默认值(配置缺失不影响可用性,只是不可配)。 +这也符合金融场景对口径变更的要求。 -两条路径都处理: -1. 若当前生效版本仍可追加数据(draft/approved),直接挂上去,最省事; -2. 若被状态机拒绝(生效版本不可变),则新建一个发布版本,**原样继承现有全部配置项** - 再追加提示词——`config_release` 是整版本替换语义,不继承就会把其他 Agent 的配置清空。 +**为什么要重发**:提示词配过(挂在 release 174),但 174 已被取代;当前 active 是 181, +而 `load_active_prompt` 是先定位 active 版本、再按 `release_id` 查的 —— 于是读不到, +Agent 回落到代码里的默认提示词。功能看着正常(`_chitchat_prompt` 有逐字段兜底), +所以一直没人发现,也没有任何告警。 + +三个必须处理的点: + +1. **`version` 必须重新分配。** `prompt_template_version` 的唯一键是 + `(prompt_code, version)`,客服那条已经占了 `v1`;照搬旧行会主键冲突。这里取 + 现有最大值 +1。 + +2. **继承要走 `ConfigReleaseService.effective_snapshot()`。** `config_release` 是整版本 + 替换语义,新版本没带上的配置项就等于被删。快照读的是**全部三张**受管表 + (platform_config_item / prompt_template_version / model_routing_rule), + 漏读一张就是一次静默失效 —— 这次的事故正是这么来的。 + +3. **快照是库的形状,API 是另一套字段名**(`config_key` ↔ `item_key`), + 所以搬运时必须映射,不能把快照行直接 POST。 跑法:python tools/publish_chitchat_prompt.py """ @@ -20,22 +33,24 @@ import uuid from pathlib import Path from typing import Any -import asyncmy import httpx import jwt -from sqlalchemy import select +from sqlalchemy import func, select from app.core.config import get_settings from app.infrastructure.db import SessionFactory from app.main import create_app -from app.model.configuration import ConfigRelease, PromptTemplateVersion +from app.model.configuration import PromptTemplateVersion +from app.service.config_release_service import ConfigReleaseService ADMIN = "9003" PROMPT_CODE = "customer_service_chitchat" TASK_TYPE = "chat" AGENT_TYPE = "customer_service" -# 提示词正文:与 Agent 代码里的默认值保持一致,发布后即成为唯一可配置来源。 +# 提示词正文:与 Agent 代码里的默认值保持一致 —— 发布后这份配置即成为话术的唯一来源 +# (Agent 侧仍保留代码默认值作为兜底,两者内容相同,所以发布前后行为一致、 +# 区别只在于"能不能改")。 SYSTEM_PROMPT = ( "你是南方科技的智能客服助手。回应要简短、礼貌,并自然引导用户提出与基金、理财、" "账户相关的问题。禁止承诺收益,禁止出现「保本」「稳赚」「无风险」「保证收益」" @@ -61,18 +76,15 @@ def token(subject: str) -> str: async def active_release_id() -> int | None: async with SessionFactory() as session: - release = await session.scalar( - select(ConfigRelease).where(ConfigRelease.status == "active") - ) - return release.id if release is not None else None + return await ConfigReleaseService(session).active_release_id() -async def prompt_already_published() -> bool: - """当前生效版本里是否已经有这条提示词。""" - release_id = await active_release_id() - if release_id is None: - return False +async def prompt_already_in_active_release() -> bool: + """当前生效版本里是否已经有这条提示词 —— 有就说明无需重发。""" async with SessionFactory() as session: + release_id = await ConfigReleaseService(session).active_release_id() + if release_id is None: + return False row = await session.scalar( select(PromptTemplateVersion).where( PromptTemplateVersion.release_id == release_id, @@ -83,38 +95,44 @@ async def prompt_already_published() -> bool: return row is not None -async def active_config_items() -> list[dict[str, Any]]: - """当前生效版本的全部配置项,用于新建版本时原样继承。""" - settings = get_settings() - dsn = settings.mysql_dsn.split("://", 1)[1] - credentials, location = dsn.split("@", 1) - user, password = credentials.split(":", 1) - host_port, database = location.split("/", 1) - host, _, port = host_port.partition(":") - connection = await asyncmy.connect( - host=host, port=int(port or 3306), user=user, password=password, db=database - ) - try: - cursor = connection.cursor() - await cursor.execute( - """ - SELECT i.namespace, i.config_key, i.value_json, i.schema_version - FROM platform_config_item i - JOIN config_release r ON r.id = i.release_id - WHERE r.status = 'active' - """ +async def next_prompt_version(prompt_code: str) -> int: + """该 prompt_code 的下一个可用版本号(唯一键是 `prompt_code + version`)。""" + async with SessionFactory() as session: + latest = await session.scalar( + select(func.max(PromptTemplateVersion.version)).where( + PromptTemplateVersion.prompt_code == prompt_code + ) ) - rows = await cursor.fetchall() - finally: - connection.close() - items: list[dict[str, Any]] = [] - for namespace, config_key, value_json, schema_version in rows: - value = json.loads(value_json) if isinstance(value_json, str) else value_json - items.append({ - "namespace": namespace, "item_key": config_key, - "value_json": value, "schema_version": schema_version, - }) - return items + return int(latest or 0) + 1 + + +async def inherited_items() -> list[dict[str, Any]]: + """当前生效版本在 `platform_config_item` 里的内容,转成 API 载荷形状。 + + 字段名不同(库是 `config_key`,API 是 `item_key`),而且 `value_json` 从驱动读出来 + 可能是字符串,这里一并归一化 —— 直接 POST 快照行会 422。 + """ + async with SessionFactory() as session: + snapshot = await ConfigReleaseService(session).effective_snapshot() + return [ + { + "namespace": row["namespace"], + "item_key": row["config_key"], + "value_json": ( + json.loads(row["value_json"]) + if isinstance(row["value_json"], str) else row["value_json"] + ), + "schema_version": row["schema_version"], + } + for row in snapshot["platform_config_item"] + ] + + +async def prompt_versions_in_active_release() -> list[dict[str, Any]]: + """当前生效版本里的提示词(用于搬运;本次为空,但保留这一步以免将来漏搬)。""" + async with SessionFactory() as session: + snapshot = await ConfigReleaseService(session).effective_snapshot() + return list(snapshot["prompt_template_version"]) async def post( @@ -131,28 +149,34 @@ async def etag_of(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> return (await client.get(path, headers=auth)).headers.get("ETag") -def prompt_payload(release_id: int) -> dict[str, object]: +def prompt_payload(release_id: int, version: int) -> dict[str, object]: return { "release_id": release_id, "prompt_code": PROMPT_CODE, "task_type": TASK_TYPE, "agent_type": AGENT_TYPE, - "version": 1, + "version": version, "system_prompt": SYSTEM_PROMPT, "user_prompt_template": USER_PROMPT_TEMPLATE, } async def main() -> int: - if await prompt_already_published(): + if await prompt_already_in_active_release(): print("当前生效版本已包含该提示词,无需发布") return 0 - release_id = await active_release_id() - if release_id is None: + release = await active_release_id() + if release is None: print("没有生效版本,先跑 tools/publish_customer_service_config.py") return 1 + version = await next_prompt_version(PROMPT_CODE) + items = await inherited_items() + carried_prompts = await prompt_versions_in_active_release() + print(f"当前生效版本={release};待继承配置项 {len(items)} 条、提示词 {len(carried_prompts)} 条") + print(f"本次发布提示词 {PROMPT_CODE} v{version}") + app = create_app() auth = {"Authorization": f"Bearer {token(ADMIN)}"} async with httpx.AsyncClient( @@ -161,21 +185,19 @@ async def main() -> int: # 路径一:直接挂到当前生效版本 direct = await post( client, "/api/v1/admin/prompt-templates", auth=auth, - payload=prompt_payload(release_id), + payload=prompt_payload(release, version), ) - print(f"直接挂到生效版本 {release_id}:{direct.status_code} {direct.text[:160]}") + print(f"直接挂到生效版本 {release}:{direct.status_code} {direct.text[:160]}") if direct.status_code in (200, 201): print("完成:提示词已挂到当前生效版本") return 0 # 路径二:新建发布版本,继承现有配置项后追加提示词 print("生效版本不可追加,改为新建发布版本并继承现有配置项") - inherited = await active_config_items() - print(f" 待继承配置项 {len(inherited)} 条") created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={ "release_no": f"cs-prompt-{uuid.uuid4().hex[:12]}", "title": "客服闲聊提示词", - "change_summary": "发布客服闲聊提示词,并继承现有配置项", + "change_summary": "发布客服闲聊提示词,并继承现有全部配置项", }) if created.status_code != 201: print(f" 创建发布版本失败:{created.status_code} {created.text[:200]}") @@ -183,7 +205,7 @@ async def main() -> int: new_release = int(created.json()["data"]["id"]) print(f" 新发布版本 id={new_release}") - for item in inherited: + for item in items: response = await post( client, f"/api/v1/admin/config-releases/{new_release}/platform-config-items", auth=auth, payload=item, @@ -191,13 +213,13 @@ async def main() -> int: if response.status_code != 201: print(f" 继承 {item['item_key']} 失败:{response.text[:160]}") return 1 - print(f" 已继承 {len(inherited)} 条配置项") + print(f" 已继承 {len(items)} 条配置项") added = await post( client, "/api/v1/admin/prompt-templates", auth=auth, - payload=prompt_payload(new_release), + payload=prompt_payload(new_release, version), ) - print(f" 添加提示词:{added.status_code} {added.text[:160]}") + print(f" 添加提示词 v{version}:{added.status_code} {added.text[:160]}") if added.status_code not in (200, 201): return 1 @@ -213,7 +235,7 @@ async def main() -> int: if activated.status_code not in (200, 201): print(f" 失败:{activated.text[:200]}") return 1 - print(f"完成:新发布版本 {new_release} 已生效") + print(f"完成:新发布版本 {new_release} 已生效,提示词 v{version} 随之生效") return 0 diff --git a/tools/publish_risk_agent_config.py b/tools/publish_risk_agent_config.py new file mode 100644 index 0000000..c7873bb --- /dev/null +++ b/tools/publish_risk_agent_config.py @@ -0,0 +1,300 @@ +"""发布风控 Agent 的运行期配置:意图配置 + 意图工具白名单。 + +配置来源:组员交付的 `20-Agent工具白名单与意图配置.md` 与两份 JSON。 +工具名与权限已核对与代码一致(`risk_agent.py:24-26` 定义常量、`bootstrap.py:206-225` 注册, +`required_permission` 均为 `risk:alert:read`)。 + +两个必须讲清的点(与 `publish_customer_service_config.py` 同源): + +1. **为什么必须发这一步**:工具白名单是**失败关闭**的——`ToolExecutor` 拿发布配置里 + `agent_tools` / `risk:` 的 `allowed_tools` 与代码声明的 + `AgentDefinition.allowed_tools` 取交集,缺配置时交集为空、任何工具调用都被拒。 + 「Agent 写好了但没发配置」的表现是"风控什么都答不了"。 + +2. **为什么必须继承现有配置项**:`config_release` 是**整版本替换**语义——激活新版本后, + 旧版本的所有配置项都不再生效。若只发布风控自己的白名单,客服的 4 条白名单与示例 Agent + 的 `fund_query_demo:fund_quote` 会被静默清空(客服表现为"一直转人工")。所以发布前先把 + 当前 effective 版本里的配置项原样搬进新版本,再追加本次新增项。 + +用法:python tools/publish_risk_agent_config.py +""" + +import asyncio +import datetime as dt +import json +import sys +import uuid +from pathlib import Path +from typing import Any + +import asyncmy +import httpx +import jwt + +from app.core.config import get_settings +from app.main import create_app + +ADMIN = "9003" +AGENT_TYPE = "risk" + +# 工具白名单:来自 risk_agent_tools.json(release_no=risk-agent-local-v1)。 +# `general` 是通用风控查询("你能做什么"),按交付文档不配任何工具。 +INTENT_TOOLS: dict[str, tuple[str, ...]] = { + "risk_overview": ("get_risk_overview",), + "risk_search": ("search_risk_alerts",), + "risk_evidence": ("get_alert_evidence",), + "general": (), +} + +# 意图配置:来自 risk_agent_intents.json。description 与 examples 是**给分类器看的**, +# 真正让模型分辨"风险概览"和"预警证据"的是这几个例子,所以照抄交付值、不改写。 +INTENT_SPECS: tuple[dict[str, Any], ...] = ( + { + "intent_code": "risk_overview", "intent_name": "风险概览", + "description": "奶龙风控智能助手:风险概览", + "examples": ["查看当前风险概览", "当前有多少高风险预警"], + }, + { + "intent_code": "risk_search", "intent_name": "风险查询", + "description": "奶龙风控智能助手:风险查询", + "examples": ["查询高风险预警", "查看命中 RW-007 的预警"], + }, + { + "intent_code": "risk_evidence", "intent_name": "预警证据", + "description": "奶龙风控智能助手:预警证据", + "examples": ["查询预警编号 ALERT-001 的证据", "查看这条预警的证据链"], + }, + { + "intent_code": "general", "intent_name": "通用风控查询", + "description": "奶龙风控智能助手:通用风控查询", + "examples": ["你能做什么", "说明你的功能边界"], + }, +) + +INTENT_COMMON: dict[str, Any] = { + "classifier_instruction": "只用于只读工具查询、研判草案和边界说明,不执行人工处置。", + "confidence_threshold": "0.6500", + "max_clarification_rounds": 2, + "transfer_on_failure": True, + "priority": 100, +} + + +def token(subject: str) -> str: + settings = get_settings() + private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +async def active_config_items() -> list[dict[str, Any]]: + """读取当前生效版本的全部配置项,用于在新版本里原样继承。""" + settings = get_settings() + # MYSQL_DSN 形如 mysql+asyncmy://user:pass@host:port/db + dsn = settings.mysql_dsn.split("://", 1)[1] + credentials, location = dsn.split("@", 1) + user, password = credentials.split(":", 1) + host_port, database = location.split("/", 1) + host, _, port = host_port.partition(":") + connection = await asyncmy.connect( + host=host, port=int(port or 3306), user=user, password=password, db=database + ) + try: + cursor = connection.cursor() + await cursor.execute( + """ + SELECT i.namespace, i.config_key, i.value_json, i.schema_version + FROM platform_config_item i + JOIN config_release r ON r.id = i.release_id + WHERE r.status = 'active' + """ + ) + rows = await cursor.fetchall() + finally: + connection.close() + items: list[dict[str, Any]] = [] + for namespace, config_key, value_json, schema_version in rows: + value = json.loads(value_json) if isinstance(value_json, str) else value_json + items.append({ + "namespace": namespace, + "item_key": config_key, + "value_json": value, + "schema_version": schema_version, + }) + return items + + +async def post( + client: httpx.AsyncClient, path: str, *, auth: dict[str, str], + payload: dict[str, object] | None = None, if_match: str | None = None, +) -> httpx.Response: + headers = {**auth, "Idempotency-Key": uuid.uuid4().hex} + if if_match: + headers["If-Match"] = if_match + return await client.post(path, json=payload, headers=headers) + + +async def etag_of(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None: + return (await client.get(path, headers=auth)).headers.get("ETag") + + +def _intent_payload(spec: dict[str, Any], version: int) -> dict[str, Any]: + return { + "agent_type": AGENT_TYPE, + **spec, + **INTENT_COMMON, + "allowed_tools": list(INTENT_TOOLS[str(spec["intent_code"])]), + "version": version, + } + + +async def ensure_risk_intents(client: httpx.AsyncClient, auth: dict[str, str]) -> int: + """确保 4 条风控意图在运行期生效(返回 0 成功、1 失败)。 + + 意图码要三处对齐:`AgentDefinition.supported_intents`(代码,已有)、 + `agent_intent_config` 的 active 行(本函数)、发布版 `agent_tools`(下一步)。 + 缺任一处即失败关闭——少了这里,"查看风险概览"会被分到别的意图去。 + """ + path = "/api/v1/admin/agent-intent-configs" + listed = await client.get(f"{path}?limit=100", headers=auth) + rows = listed.json().get("data", []) if listed.status_code == 200 else [] + existing = { + str(row.get("intent_code")): row + for row in rows + if row.get("agent_type") == AGENT_TYPE + } + failures = 0 + for spec in INTENT_SPECS: + code = str(spec["intent_code"]) + row = existing.get(code) + if row is not None and str(row.get("status")) == "active": + print(f"[意图] {code} 已生效(id={row['id']}),跳过") + continue + if row is not None and str(row.get("status")) in {"draft", "approved"}: + config_id = int(row["id"]) + else: + version = int(row.get("version", 0)) + 1 if row else 1 + created = await post(client, path, auth=auth, + payload=_intent_payload(spec, version)) + if created.status_code != 201: + print(f"[意图] {code} 创建失败:{created.status_code} {created.text[:200]}") + failures += 1 + continue + config_id = int(created.json()["data"]["id"]) + print(f"[意图] {code} 已创建(id={config_id}, v{version})") + + base = f"{path}/{config_id}" + # 幂等:重跑时某一步可能已推进过("已经审过了"不该 409 让整个脚本失败) + settled = {"reviews": {"approved", "active"}, "activations": {"active"}} + for action, payload in ( + ("reviews", {"decision": "approved", "comment": "创建人自审"}), + # 激活端点要求 body 是对象;传 None 时 httpx 根本不发 body,会被判 422 + ("activations", {}), + ): + current = (await client.get(base, headers=auth)).json().get("data", {}) + if str(current.get("status")) in settled[action]: + continue + response = await post(client, f"{base}/{action}", auth=auth, payload=payload, + if_match=await etag_of(client, base, auth)) + if response.status_code != 200: + print(f"[意图] {code} {action} 失败:{response.status_code} {response.text[:200]}") + failures += 1 + break + else: + print(f"[意图] {code} 已生效") + return 1 if failures else 0 + + +async def main() -> int: + app = create_app() + auth = {"Authorization": f"Bearer {token(ADMIN)}"} + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=60 + ) as client: + if await ensure_risk_intents(client, auth) != 0: + return 1 + + inherited = await active_config_items() + print(f"\n当前生效版本的配置项:{len(inherited)} 条(将原样继承)") + for item in inherited: + print(f" · {item['namespace']} / {item['item_key']}") + + new_items = [ + { + "namespace": "agent_tools", + "item_key": f"{AGENT_TYPE}:{intent}", + "value_json": {"allowed_tools": list(tools)}, + "schema_version": "1", + } + for intent, tools in INTENT_TOOLS.items() + ] + inherited_keys = {(str(i["namespace"]), str(i["item_key"])) for i in inherited} + pending = [ + item for item in new_items + if (str(item["namespace"]), str(item["item_key"])) not in inherited_keys + ] + if not pending: + print("\n风控白名单已存在于当前生效版本,无需发布") + return 0 + + created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={ + "release_no": f"risk-tools-{uuid.uuid4().hex[:12]}", + "title": "风控 Agent 意图工具白名单", + "change_summary": ( + "新增 risk_overview/risk_search/risk_evidence/general 的只读工具白名单" + "(general 不配工具),并继承既有配置项" + ), + }) + if created.status_code != 201: + print(f"创建发布版本失败:{created.status_code} {created.text[:200]}") + return 1 + release_id = int(created.json()["data"]["id"]) + print(f"\n发布版本 id={release_id}") + + base = f"/api/v1/admin/config-releases/{release_id}/platform-config-items" + for item in [*inherited, *pending]: + response = await post(client, base, auth=auth, payload=item) + mark = "继承" if item in inherited else "新增" + print(f" [{mark}] {item['namespace']}/{item['item_key']} → {response.status_code}") + if response.status_code != 201: + print(f" 失败:{response.text[:200]}") + return 1 + + release_base = f"/api/v1/admin/config-releases/{release_id}" + submitted = await post( + client, f"{release_base}/validations", auth=auth, payload={}, + if_match=await etag_of(client, release_base, auth), + ) + print(f"\n提交复核:{submitted.status_code}") + reviewed = await post( + client, f"{release_base}/reviews", auth=auth, + payload={"decision": "approved", "comment": "风控工具白名单"}, + if_match=await etag_of(client, release_base, auth), + ) + print(f"审核:{reviewed.status_code}") + activated = await post( + client, f"{release_base}/activations", auth=auth, payload={}, + if_match=await etag_of(client, release_base, auth), + ) + print(f"激活:{activated.status_code}") + if activated.status_code not in (200, 201): + print(f" 失败:{activated.text[:200]}") + return 1 + print(f"最终状态:{activated.json()['data']['status']}") + + remaining = await active_config_items() + print(f"\n激活后生效版本配置项:{len(remaining)} 条") + for item in remaining: + print(f" · {item['namespace']} / {item['item_key']} = {item['value_json']}") + return 0 + + +sys.exit(asyncio.run(main())) diff --git a/tools/risk_agent_business_e2e.py b/tools/risk_agent_business_e2e.py index edc49af..5a14bfd 100644 --- a/tools/risk_agent_business_e2e.py +++ b/tools/risk_agent_business_e2e.py @@ -18,7 +18,7 @@ from app.infrastructure.db import SessionFactory from app.main import create_app from app.worker.runtime import WorkerRuntime -PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") +PRIVATE_KEY = Path(get_settings().jwt_private_key_path).read_text(encoding="utf-8") RISK_USER = "9002" AGENT_TYPE = "risk" diff --git a/tools/risk_agent_e2e.py b/tools/risk_agent_e2e.py index 362ca75..248de1f 100644 --- a/tools/risk_agent_e2e.py +++ b/tools/risk_agent_e2e.py @@ -17,7 +17,7 @@ from app.infrastructure.db import SessionFactory from app.main import create_app from app.worker.runtime import WorkerRuntime -PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") +PRIVATE_KEY = Path(get_settings().jwt_private_key_path).read_text(encoding="utf-8") RISK_USER = "9002" AGENT_TYPE = "risk" INTENT = "risk_overview" diff --git a/tools/seed_risk_alert_demo_data.py b/tools/seed_risk_alert_demo_data.py new file mode 100644 index 0000000..4d301ba --- /dev/null +++ b/tools/seed_risk_alert_demo_data.py @@ -0,0 +1,182 @@ +"""造风控演示数据:让概览 / 查询 / 证据三个只读工具都能返回真实内容。 + +**为什么需要**:配置与 RBAC 补齐、时区修好之后,`fin_risk_alert` 表是空的 —— +概览只能显示"未闭环预警总量 0",看不出任何真实内容,没法验收。 + +**造什么**:3 条预警,刻意覆盖三个工具各自的读路径: + +1. **高危 + 待处理 + 三条规则 + 完整证据快照** —— 概览的高危计数、查询的高危筛选、 + 证据工具的证据链,三条路径都靠它。 +2. **中危 + 调查中 + 两条规则** —— 让"未闭环"统计里出现"调查中"。 +3. **低危 + 已闭环** —— 验证"未闭环"筛选真的把它排除掉(这是上一批修复里 + `OPEN_STATUSES` 的用途)。 + +**字段取值不自行发明**,全部照 `risk_scan_service.py:312-333` 的 `_build_alert` 抄: +状态用 `OPEN_STATUSES = ("待处理", "调查中")`(见 `risk_action_service.py:17`)、 +`ack_status` 用"未确认"、`alert_level` 用"高/中/低"。 + +**两个注意点**: + +- `fin_risk_alert.id` **不是自增**(见建表语句),所以这里手工生成 id; +- 时间一律按库内约定存 **UTC naive**(`app/infrastructure/db.py:15-21`)。 + +幂等:按 `alert_no` 判断,已存在则跳过。 + +用法:python tools/seed_risk_alert_demo_data.py +""" + +import asyncio +import datetime as dt +import json +import sys +from typing import Any + +import asyncmy + +from app.core.config import get_settings + +CUSTOMER_ID = 9001 # 已有的画像客户(fin_customer_profile 里那一条) +HANDLER_ID = 9002 # risk_operator +ID_BASE = 9_000_000_000_000_001 + +ALERTS: tuple[dict[str, Any], ...] = ( + { + "id": ID_BASE, + "alert_no": "ALDEMO0001", + "alert_type": "大额频繁交易", + "alert_level": "高", + "trigger_rule_codes": ["RW-007", "RW-002", "RW-012"], + "evidence_summary": ( + "客户近 24 小时内 5 笔申购合计 486,000 元,单笔最大 200,000 元," + "为本人近 90 日均值的 6.2 倍;收款账户与历史常用账户不一致。" + ), + "evidence_snapshot": { + "product_id": 7001, + "product_name": "南方季季盈90天", + "transaction_count": 5, + "total_amount": "486000.00", + "max_amount": "200000.00", + "average_amount": "78387.10", + "ratio": "6.20", + "window_hours": 24, + "customer_age": 34, + "login_ip_regions": ["广东深圳", "香港"], + "device_changed": True, + "open_status": "未闭环", + }, + "priority_score": 95, + "event_status": "刚刚发生", + "status": "待处理", + "ack_status": "未确认", + "due_at_offset_minutes": 30, + "is_escalated": 0, + }, + { + "id": ID_BASE + 1, + "alert_no": "ALDEMO0002", + "alert_type": "非正常时段大额操作", + "alert_level": "高", + "trigger_rule_codes": ["RW-015", "RW-003"], + "evidence_summary": ( + "成交时间对应北京时间 02:17(凌晨),金额 88,000 元;" + "该客户近 30 天无凌晨交易记录,且未匹配到有效定投工单。" + ), + "evidence_snapshot": { + "product_id": 7002, + "product_name": "南方稳健增利180天", + "amount": "88000.00", + "beijing_hour": 2, + "utc_confirmed_at": "2026-09-09T18:17:00", + "matched_work_order": None, + "channel": "APP", + "device_changed": False, + "open_status": "未闭环", + }, + "priority_score": 88, + "event_status": "盘后预警", + "status": "调查中", + "ack_status": "已确认", + "due_at_offset_minutes": 30, + "is_escalated": 1, + }, + { + "id": ID_BASE + 2, + "alert_no": "ALDEMO0003", + "alert_type": "低风险频繁交易初筛", + "alert_level": "低", + "trigger_rule_codes": ["RW-018"], + "evidence_summary": "命中低优先级频繁交易初筛,交易来自已核验的自动定投工单。", + "evidence_snapshot": { + "product_id": 7001, + "product_name": "南方季季盈90天", + "matched_work_order": "WO-DEMO-0007", + "channel": "自动定投", + "open_status": "已闭环", + }, + "priority_score": 20, + "event_status": "盘后预警", + "status": "已关闭", + "ack_status": "已确认", + "due_at_offset_minutes": None, + "is_escalated": 0, + "close_reason": "已核验为本人自动定投计划,属正常交易。", + }, +) + + +async def main() -> int: + settings = get_settings() + dsn = settings.mysql_dsn.split("://", 1)[1] + credentials, location = dsn.split("@", 1) + user, password = credentials.split(":", 1) + host_port, database = location.split("/", 1) + host, _, port = host_port.partition(":") + connection = await asyncmy.connect( + host=host, port=int(port or 3306), user=user, password=password, db=database + ) + now = dt.datetime.now(dt.UTC).replace(tzinfo=None) + try: + cursor = connection.cursor() + created = 0 + for spec in ALERTS: + await cursor.execute( + "SELECT id FROM fin_risk_alert WHERE alert_no = %s", (spec["alert_no"],) + ) + if await cursor.fetchone() is not None: + print(f"[跳过] {spec['alert_no']} 已存在") + continue + offset = spec["due_at_offset_minutes"] + await cursor.execute( + "INSERT INTO fin_risk_alert (" + " id, alert_no, customer_id, alert_type, alert_level, trigger_rule_codes," + " evidence_summary, evidence_snapshot, priority_score, event_status, status," + " ack_status, handler_id, due_at, is_escalated, close_reason," + " created_at, updated_at" + ") VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + spec["id"], spec["alert_no"], CUSTOMER_ID, spec["alert_type"], + spec["alert_level"], json.dumps(spec["trigger_rule_codes"], ensure_ascii=False), + spec["evidence_summary"], + json.dumps(spec["evidence_snapshot"], ensure_ascii=False), + spec["priority_score"], spec["event_status"], spec["status"], + spec["ack_status"], HANDLER_ID, + (now + dt.timedelta(minutes=offset)) if offset else None, + spec["is_escalated"], spec.get("close_reason"), + now, now, + ), + ) + created += 1 + print( + f"[新增] {spec['alert_no']} 等级={spec['alert_level']} " + f"状态={spec['status']} 规则={spec['trigger_rule_codes']}" + ) + await connection.commit() + await cursor.execute("SELECT COUNT(*) FROM fin_risk_alert") + total = (await cursor.fetchone())[0] + print(f"\n已提交 {created} 条;fin_risk_alert 现有 {total} 行") + return 0 + finally: + connection.close() + + +sys.exit(asyncio.run(main())) diff --git a/tools/verify_config_drop_warning.py b/tools/verify_config_drop_warning.py new file mode 100644 index 0000000..1e660cc --- /dev/null +++ b/tools/verify_config_drop_warning.py @@ -0,0 +1,194 @@ +"""端到端验证:配置发布激活时是否真的点名了"将被丢掉的配置项"。 + +背景见 `tests/unit/service/test_config_release_dropped_items.py` —— 那里锁的是方法本身, +这里验证**方法确实被 activate 调用**、且在真实流程里能打出日志。 + +三步,走完整的"创建 → 加配置项 → 提交复核 → 审核 → 激活"状态机: + +- A:把当前生效的配置项**原样复制**一份 → 一条不少,**不应**有告警 +- B:在 A 的基础上去掉一条 → **应**告警并点名那一条 +- C:把完整的那份再发一次 → 恢复原状,**不应**有告警 + +最后校验生效配置项数量回到起点,避免把环境留在"少一条"的状态。 +""" + +import asyncio +import datetime as dt +import json +import logging +import pathlib +import sys +import uuid +from typing import Any + +import asyncmy +import httpx +import jwt + +from app.core.config import get_settings +from app.main import create_app + +ADMIN = "9003" +DROPPED_KEY = "risk:general" # 故意丢掉这一条:它本身是空白名单,去掉对业务无影响 + +captured: list[str] = [] + + +class _Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + if record.levelno >= logging.WARNING: + captured.append(record.getMessage()) + + +def token(subject: str) -> str: + settings = get_settings() + private_key = pathlib.Path(settings.jwt_private_key_path).read_text(encoding="utf-8") + now = dt.datetime.now(dt.UTC) + return jwt.encode( + { + "sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience, + "exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5), + "jti": str(uuid.uuid4()), + }, + private_key, + algorithm="RS256", + ) + + +async def connect() -> Any: + settings = get_settings() + dsn = settings.mysql_dsn.split("://", 1)[1] + credentials, location = dsn.split("@", 1) + user, password = credentials.split(":", 1) + host_port, database = location.split("/", 1) + host, _, port = host_port.partition(":") + return await asyncmy.connect( + host=host, port=int(port or 3306), user=user, password=password, db=database + ) + + +async def active_items() -> list[dict[str, Any]]: + connection = await connect() + try: + cursor = connection.cursor() + await cursor.execute( + "SELECT i.namespace, i.config_key, i.value_json, i.schema_version" + " FROM platform_config_item i JOIN config_release r ON r.id = i.release_id" + " WHERE r.status = 'active'" + ) + rows = await cursor.fetchall() + finally: + connection.close() + return [ + { + "namespace": namespace, "item_key": key, + "value_json": json.loads(value) if isinstance(value, str) else value, + "schema_version": schema, + } + for namespace, key, value, schema in rows + ] + + +async def post(client: httpx.AsyncClient, path: str, *, auth: dict[str, str], + payload: Any = None, if_match: str | None = None) -> httpx.Response: + headers = {**auth, "Idempotency-Key": uuid.uuid4().hex} + if if_match: + headers["If-Match"] = if_match + return await client.post(path, json=payload, headers=headers) + + +async def etag(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None: + return (await client.get(path, headers=auth)).headers.get("ETag") + + +async def publish(client: httpx.AsyncClient, auth: dict[str, str], + items: list[dict[str, Any]], label: str) -> str: + """走完整状态机发布一个版本,返回激活后的状态。""" + created = await post(client, "/api/v1/admin/config-releases", auth=auth, payload={ + "release_no": f"tz-verify-{uuid.uuid4().hex[:10]}", + "title": f"临时验证版本 {label}", + "change_summary": "端到端验证配置项丢失告警用,验证后恢复原状", + }) + if created.status_code != 201: + raise SystemExit(f"[{label}] 创建失败 {created.status_code} {created.text[:200]}") + release_id = int(created.json()["data"]["id"]) + + base = f"/api/v1/admin/config-releases/{release_id}/platform-config-items" + for item in items: + response = await post(client, base, auth=auth, payload=item) + if response.status_code != 201: + raise SystemExit(f"[{label}] 加配置项失败 {response.status_code} {response.text[:200]}") + + release_base = f"/api/v1/admin/config-releases/{release_id}" + steps = ( + ("validations", {}), + ("reviews", {"decision": "approved", "comment": "临时验证"}), + ("activations", {}), + ) + for action, payload in steps: + response = await post(client, f"{release_base}/{action}", auth=auth, payload=payload, + if_match=await etag(client, release_base, auth)) + if response.status_code not in (200, 201): + raise SystemExit( + f"[{label}] {action} 失败 {response.status_code} {response.text[:200]}" + ) + return str(response.json()["data"]["status"]) + + +async def main() -> int: + logging.getLogger("app.service.config_release_service").addHandler(_Capture()) + logging.getLogger("app.service.config_release_service").setLevel(logging.WARNING) + + app = create_app() + auth = {"Authorization": f"Bearer {token(ADMIN)}"} + out: list[str] = [] + + baseline = await active_items() + out.append(f"起点:生效配置项 {len(baseline)} 条") + for item in baseline: + out.append(f" {item['namespace']}/{item['item_key']}") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://t", timeout=120 + ) as client: + # A:原样复制 → 一条不少,不应告警 + captured.clear() + status = await publish(client, auth, baseline, "A-完整复制") + out.append(f"\n[A] 完整复制 {len(baseline)} 条 → {status}") + out.append(f" 告警条数={len(captured)}(期望 0)") + for line in captured: + out.append(f" ! {line}") + + # B:去掉一条 → 应告警并点名 + reduced = [ + item for item in baseline + if not (item["namespace"] == "agent_tools" and item["item_key"] == DROPPED_KEY) + ] + captured.clear() + status = await publish(client, auth, reduced, "B-少一条") + out.append(f"\n[B] 去掉 agent_tools/{DROPPED_KEY} → 发 {len(reduced)} 条 → {status}") + out.append(f" 告警条数={len(captured)}(期望 ≥1)") + for line in captured: + out.append(f" ! {line}") + + # C:恢复完整 → 恢复原状,不应告警(C 比 B 只多不少) + captured.clear() + status = await publish(client, auth, baseline, "C-恢复") + out.append(f"\n[C] 恢复 {len(baseline)} 条 → {status}") + out.append(f" 告警条数={len(captured)}(期望 0)") + for line in captured: + out.append(f" ! {line}") + + final = await active_items() + out.append(f"\n终点:生效配置项 {len(final)} 条") + same = {(i["namespace"], i["item_key"]) for i in final} == { + (i["namespace"], i["item_key"]) for i in baseline + } + out.append(f"与起点一致:{'是' if same else '否 —— 需要人工恢复!'}") + + pathlib.Path("_dbg_verify.txt").write_text("\n".join(out), encoding="utf-8") + print("ok") + return 0 if same else 1 + + +sys.exit(asyncio.run(main()))