代理人异常行为链识别(RISK-008)落地: - 新增 agent_behavior_service:三条件证据聚合(A 诱导调仓/B AUTH_403_SCOPE 越权试探/C AUTH_403_NOT_OWNER|NOT_ASSIGNED 越权查询),按代理人维度独立出 pattern 单,payload.actor_id 指向代理人,审计仅 INSERT event_type=agent_behavior_detected。 - risk_repository 新增 list_audit_events / find_agent_behavior_alert / merge_agent_behavior_payload(同日同代理人一张单,证据并集)。 - trade_gateway.submit_trade 补 actor_id 透传(代理人发起交易归属发起人,缺省 SYSTEM);simulate 路由传入 auth.actor_id。 - chat_tools 新增 query_agent_behavior 只读 Tool(agent_id 过滤 + 客户脱敏),tool_service 补意图词与摘要。 - scripts/cron/agent_behavior_scan.py 定时扫描脚本。 - 修复 append_alert_event 序列化缺 default=str(C6 evidence 含 datetime 字段)。 - 单测 12 例(_count_induce 边界 / 三条件 / 出单去重 / payload 归属 / Tool 过滤脱敏)。 全量 pytest 482 passed 0 failed(470 基线 + 12 C6)。
213 lines
7.8 KiB
Python
213 lines
7.8 KiB
Python
"""交易网关服务(PRD FR-1 · 架构 §3.1)。
|
||
|
||
submit_trade 为唯一入口:参数校验 → suitability_check(FR-2,落校验日志;
|
||
不匹配→ suitability 预警单 + 阻断响应,交易不落 core_trade)→ 匹配 →
|
||
INSERT core_trade → 同步调规则引擎(FR-3)→ 返回 blocked + trade_id +
|
||
触发规则。阻断/放行全量审计(agent_type='platform',FR-1 §6);
|
||
convert/未知类型属参数校验失败(400),不落审计(PRD 审计口径仅阻断/放行)。
|
||
|
||
引擎异常兜底(架构 §5.3):交易已成立(core_trade 已提交),审计
|
||
decision='risk_engine_error' + logger.exception,响应带 engine_error=true
|
||
供 B9a rebuild_alerts 按 trade_id 补偿重放。
|
||
|
||
鉴权归路由层(T-01/B6 的 get_auth_context:risk_demo 或客户本人);
|
||
trace 由调用方中间件贯通,本层 ensure_trace 兜底(脚本/测试直调场景)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from typing import Any, Callable
|
||
from uuid import uuid4
|
||
|
||
from app.gateway.gateway_repository import GatewayRepository
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.risk.engine import process_trade_event
|
||
from app.service.risk.rules import RiskThresholds
|
||
from app.service.risk.alert_service import record_suitability_alert
|
||
from app.service.suitability import SuitabilityResult, suitability_check
|
||
from app.utils.trace import ensure_trace
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SUPPORTED_TRADE_TYPES = ("subscribe", "redeem")
|
||
CONVERT_MESSAGE = "转换交易暂不支持,请分别发起申购/赎回"
|
||
ADVICE = "请联系持证投资顾问"
|
||
RECORDED_NOTICE = "本次请求已记录"
|
||
|
||
|
||
class UnsupportedTradeType(ValueError):
|
||
"""trade_type 非法(convert 显式 400;未知类型兜底拒绝,PRD FR-1)。"""
|
||
|
||
|
||
def _new_trade_id(now: datetime) -> str:
|
||
return f"TRD-{now:%Y%m%d}-{uuid4().hex[:8].upper()}"
|
||
|
||
|
||
def _audit(
|
||
repo: RiskRepository,
|
||
*,
|
||
decision: str,
|
||
trade_id: str,
|
||
req: dict[str, Any],
|
||
rule_id: str | None = None,
|
||
detail: dict[str, Any] | None = None,
|
||
actor_id: str | None = None,
|
||
) -> None:
|
||
from app.utils.trace import current_trace, new_trace
|
||
|
||
repo.insert_audit_log(
|
||
{
|
||
"trace_id": current_trace() or new_trace(),
|
||
"event_type": "trade_request",
|
||
"agent_type": "platform",
|
||
"actor_id": actor_id or "SYSTEM", # C6 透传:代理人发起交易归属发起人;缺省 SYSTEM 保持现有测试/脚本零改动
|
||
"customer_id": req.get("customer_id"),
|
||
"rule_id": rule_id,
|
||
"input_summary": {
|
||
"trade_id": trade_id,
|
||
"product_id": req.get("product_id"),
|
||
"trade_type": req.get("trade_type"),
|
||
"amount": str(req.get("amount")),
|
||
**(detail or {}),
|
||
},
|
||
"decision": decision,
|
||
"risk_score": None,
|
||
"handler_id": None,
|
||
"handler_result": None,
|
||
"handler_comment": None,
|
||
}
|
||
)
|
||
|
||
|
||
def submit_trade(
|
||
req: dict[str, Any],
|
||
core_ro: CoreReadOnlyRepository | None = None,
|
||
risk_repo: RiskRepository | None = None,
|
||
gateway_repo: GatewayRepository | None = None,
|
||
thresholds: RiskThresholds | None = None,
|
||
now: datetime | None = None,
|
||
trade_id_factory: Callable[[datetime], str] | None = None,
|
||
actor_id: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""处理一笔模拟交易请求(PRD FR-1 流程 ①~⑤)。
|
||
|
||
req:{customer_id, product_id, trade_type, amount};amount 转 Decimal。
|
||
trade_id_factory:测试注入点(架构 §7 约定集成测试交易用 TRD-TEST- 前缀,
|
||
B8 teardown 按前缀清理;缺省 TRD-{date}-{uuid8})。
|
||
返回 FR-1 ⑤ 响应体:阻断 {blocked, trade_id, block_reason, reasons, advice,
|
||
notice};放行 {blocked, trade_id, triggered_rules, alert_ids, aml_hit}
|
||
(引擎异常时附 engine_error=true)。
|
||
"""
|
||
core = core_ro or CoreReadOnlyRepository()
|
||
repo = risk_repo or RiskRepository()
|
||
writer = gateway_repo or GatewayRepository()
|
||
th = thresholds or RiskThresholds.from_settings()
|
||
ensure_trace()
|
||
|
||
trade_type = str(req.get("trade_type", ""))
|
||
if trade_type == "convert":
|
||
raise UnsupportedTradeType(CONVERT_MESSAGE)
|
||
if trade_type not in SUPPORTED_TRADE_TYPES:
|
||
raise UnsupportedTradeType(f"不支持的交易类型: {trade_type}(仅 subscribe/redeem)")
|
||
|
||
now = now or datetime.now()
|
||
trade_id = (trade_id_factory or _new_trade_id)(now)
|
||
amount = Decimal(str(req["amount"]))
|
||
traded_at = now
|
||
|
||
result: SuitabilityResult = suitability_check(
|
||
req["customer_id"], req["product_id"], core_ro=core, risk_repo=repo,
|
||
check_source="r02_trade", actor_id="svc-trade-suitability", request_ref=trade_id,
|
||
)
|
||
if result.blocked:
|
||
record_suitability_alert(
|
||
{
|
||
"trade_id": trade_id,
|
||
"customer_id": req["customer_id"],
|
||
"product_id": req["product_id"],
|
||
"trade_type": trade_type,
|
||
"amount": amount,
|
||
"traded_at": traded_at,
|
||
},
|
||
rule_id=result.rule_id,
|
||
block_reason=result.block_reason,
|
||
risk_repo=repo,
|
||
)
|
||
_audit(
|
||
repo,
|
||
decision="suitability_blocked",
|
||
trade_id=trade_id,
|
||
req=req,
|
||
rule_id=result.rule_id,
|
||
detail={
|
||
"block_reason": result.block_reason,
|
||
"block_response_code": result.block_response_code,
|
||
"reasons": list(result.reasons),
|
||
},
|
||
)
|
||
return {
|
||
"blocked": True,
|
||
"trade_id": trade_id,
|
||
"match_result": result.match_result,
|
||
"mismatch_type": result.mismatch_type,
|
||
"requires_disclosure": result.requires_disclosure,
|
||
"needs_branch_confirm": result.needs_branch_confirm,
|
||
"block_reason": result.block_reason,
|
||
"block_response_code": result.block_response_code,
|
||
"rule_refs": result.rule_refs,
|
||
"reasons": list(result.reasons),
|
||
"advice": ADVICE,
|
||
"notice": RECORDED_NOTICE,
|
||
}
|
||
|
||
writer.insert_trade(
|
||
trade_id, req["customer_id"], req["product_id"], trade_type, amount, traded_at
|
||
)
|
||
try:
|
||
engine_result = process_trade_event(
|
||
{
|
||
"trade_id": trade_id,
|
||
"customer_id": req["customer_id"],
|
||
"product_id": req["product_id"],
|
||
"trade_type": trade_type,
|
||
"amount": amount,
|
||
"trade_status": "confirmed",
|
||
"traded_at": traded_at,
|
||
},
|
||
core_ro=core,
|
||
risk_repo=repo,
|
||
thresholds=th,
|
||
)
|
||
except Exception:
|
||
# 交易已成立(core_trade 已提交):留审计与日志供 B9a rebuild_alerts 补偿
|
||
logger.exception("risk engine failed after trade accepted: %s", trade_id)
|
||
_audit(
|
||
repo,
|
||
decision="risk_engine_error",
|
||
trade_id=trade_id,
|
||
req=req,
|
||
actor_id=actor_id,
|
||
detail={"error_stage": "process_trade_event"},
|
||
)
|
||
return {
|
||
"blocked": False,
|
||
"trade_id": trade_id,
|
||
"triggered_rules": [],
|
||
"alert_ids": [],
|
||
"aml_hit": False,
|
||
"engine_error": True,
|
||
}
|
||
_audit(
|
||
repo,
|
||
decision="trade_accepted",
|
||
trade_id=trade_id,
|
||
req=req,
|
||
actor_id=actor_id,
|
||
detail=dict(engine_result), # 全量输出(评审 P2-3)
|
||
)
|
||
return {"blocked": False, "trade_id": trade_id, **engine_result}
|