Files
group_xinghuo_jinrong/app/gateway/trade_gateway.py
T
GaoYiYuan_0626 66ec7eba08 feat: C6 FR-10 代理人行为链 RISK-008
代理人异常行为链识别(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)。
2026-09-07 19:53:39 +08:00

213 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""交易网关服务(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}