Files
group_xinghuo_jinrong/app/api/chat.py
T
GaoYiYuan_0626 8328c2403e feat(chat): 新增前端拉侧三端点(会话列表/历史消息/关闭会话)
方案 B:补前端对话页「拉」侧能力(chat 此前只有 POST 发消息):
- GET /api/chat/sessions:本人 + 本 Agent 线会话分页列表(created_at 倒序,total 供分页器)
- GET /api/chat/sessions/{id}/messages:历史消息 seq_no 升序分页(closed 会话仍可读)
- POST /api/chat/sessions/{id}/close:active→closed + closed_at;重复/非 active 409

守卫复用:抽取 _resolve_agent_type / _assert_chat_entry / _guard_session,
POST "" 改为复用同套守卫(行为零回归);risk_manager 在对话线数据面保持
同口径 403(PRD 4A.1 冻结);会话仓储新增 list_sessions / list_messages_page /
close_session(条件更新防并发静默写)。

测试:新增 12 例(分页、越权 403+留痕、404、409、manager 拒绝、limit 钳制、
JWT 通道),路由挂载清单同步;全量 pytest 482→494 绿。独立 AI 评审 P0=0,
P1(close 并发 rowcount 静默 200)已修复。
2026-09-08 14:07:38 +08:00

325 lines
13 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.
"""对话接口(T-06 最小闭环 · FLOW §2):四 Agent 统一 chat 入口。
链路:X-Agent-Type 分流 + Agent 准入(deps.assert_agent_access,手册 §5.4)
→ T-03 输入防护(input_guard:注入短语 / 超长,命中即拒 + input_guard_log
留痕,fail-fast 在会话解析前)→ customer 归属固定本人 / 代理人等指定客户走
G-01 归属校验(A-01 语义)→ SessionGuard(会话存在、actor/agent_type 一致
AUTH_403_SESSION_AGENT、active 状态,手册 §9)→ memory_service 窗口 →
agent_service(T-07 图 + T-04 Tool 节点:意图→归属校验→Core RO 只读查询)
→ user/assistant 双消息落 MySQL + Redis 窗口 → 响应 {session_id, reply,
has_disclaimer, trace_id}。
落库:agent_session/agent_message(同 trace_id);agent_tool_call 由 Tool
节点落(T-04,success/blocked/error 全留痕)。审计:鉴权失败/越权经
deps.deny 双写留痕;输入防护拒绝经 T-03 落 input_guard_log。
方案 B(前端拉侧):GET /sessions(本人会话分页列表)、
GET /sessions/{id}/messages(历史消息升序分页)、POST /sessions/{id}/close
(active→closed,重复关闭 409);与 POST "" 共用入口守卫 + SessionGuard。
"""
from __future__ import annotations
import logging
from uuid import uuid4
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from app.api.deps import (
AGENT_TYPES,
AuthContext,
assert_agent_access,
assert_customer_access,
deny,
get_auth_context,
)
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.repository.session_repository import SessionRepository
from app.service import agent_service, input_guard, memory_service
from app.utils.exceptions import ApiError, StateConflict
from app.utils.trace import current_trace, new_trace
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/chat", tags=["chat"])
# Pydantic 硬顶(仅防 DoS 的超宽上限):业务上限 4000 由 input_guard
# (T-03)判定——在 guard 层拦截才能落 input_guard_log 留痕;
# Pydantic 层直接 422 会绕过留痕(F-03「拦截记录」要求)。
MESSAGE_HARD_CEILING = 20000
def _repo() -> RiskRepository:
"""审计仓储(deny 留痕用;测试 monkeypatch 点)。"""
return RiskRepository()
def _session_repo() -> SessionRepository:
"""会话仓储(测试 monkeypatch 点)。"""
return SessionRepository()
def _core_ro() -> CoreReadOnlyRepository:
"""归属校验仓储(测试 monkeypatch 点)。"""
return CoreReadOnlyRepository()
class ChatRequest(BaseModel):
session_id: str | None = Field(None, description="缺省新建会话;传入则续聊")
message: str = Field(..., min_length=1, max_length=MESSAGE_HARD_CEILING)
customer_id: str | None = Field(
None, description="目标客户:customer 角色忽略(强制本人);advisor/risk/analyst 可指定(过归属校验)"
)
title: str | None = Field(None, max_length=256)
def _primary_role(auth: AuthContext) -> str:
"""会话主角色(agent_session.actor_role):按 Agent 边界优先序取(评审 P3-11,
多角色 token 落库稳定)。"""
for role in ("customer", "advisor", "analyst", "risk_officer", "compliance", "ops"):
if role in auth.roles:
return role
return auth.roles[0] if auth.roles else "unknown"
def _resolve_customer_id(
auth: AuthContext, agent_type: str, requested: str | None
) -> str | None:
"""会话关联客户:customer 强制本人;其余角色指定时过 G-01 归属校验。"""
if agent_type == "customer":
if requested and requested != auth.customer_id:
deny(auth, "AUTH_403_NOT_OWNER", _repo(), customer_id=requested, agent_type=agent_type)
return auth.customer_id
if requested:
assert_customer_access(auth, requested, core_ro=_core_ro(), risk_repo=_repo())
return requested
return None
def _resolve_agent_type(request: Request) -> str:
"""X-Agent-Type 解析(POST 与查询/关闭端点共用;debug 通道在此强制)。"""
agent_type = request.headers.get("X-Agent-Type", "").strip()
if not agent_type:
raise ApiError(401, "AUTH_401_MISSING_AGENT_TYPE", "missing X-Agent-Type header")
if agent_type not in AGENT_TYPES:
raise ApiError(400, "BAD_REQUEST", f"invalid X-Agent-Type: {agent_type}")
return agent_type
def _assert_chat_entry(auth: AuthContext, agent_type: str) -> None:
"""对话线入口守卫(四端点共用):矩阵准入 + risk_manager 显式拒绝。
C5 前置(PRD 4A.1):对话线不放行 risk_manager——HTTP 台账才放行,保住
FR-6 冻结口径。矩阵放行解决 HTTP 通道,chat 层显式拒绝兜底(manager 根本
进不了对话线,Tool 层 assert_tool_access 天然 fail-closed)。会话查询/
关闭端点同属对话线数据面,沿用同一口径(方案 B 拍板)。
"""
assert_agent_access(auth, agent_type, risk_repo=_repo())
if agent_type == "risk" and "risk_manager" in auth.roles:
deny(auth, "AUTH_403_ROLE", _repo(), message="对话线仅限 risk_officer,请走 HTTP 台账")
def _guard_session(auth: AuthContext, agent_type: str, session_id: str) -> dict:
"""SessionGuard(手册 §9):会话存在 + actor/agent_type 一致,他人会话 fail-closed。"""
session = _session_repo().get_session(session_id)
if session is None:
raise ApiError(404, "NOT_FOUND", f"session not found: {session_id}")
if session["actor_id"] != auth.actor_id or session["agent_type"] != agent_type:
deny(
auth,
"AUTH_403_SESSION_AGENT",
_repo(),
customer_id=session.get("customer_id"),
message="session belongs to another actor or agent",
agent_type=agent_type,
)
return session
@router.post("")
def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get_auth_context)) -> dict:
agent_type = _resolve_agent_type(request)
_assert_chat_entry(auth, agent_type)
message = req.message.strip()
if not message:
raise ApiError(400, "BAD_REQUEST", "message is blank")
# T-03 限流(actor 级固定窗口,拍板 30 次/分):先于内容防护——计数
# 覆盖全部请求(含将被注入拦截的),重复攻击者快速收敛到 429,不再
# 逐条扫描+留痕;Redis 异常 fail-open(可用性保护非安全边界)。
if not input_guard.check_rate_limit(agent_type, auth.actor_id):
try:
_repo().insert_input_guard_log(
trace_id=current_trace() or new_trace(),
agent_type=agent_type,
actor_id=auth.actor_id,
guard_type=input_guard.GUARD_RATE_LIMIT,
action="blocked",
raw_excerpt=f"rate limited: {auth.actor_id}",
session_id=req.session_id,
)
except Exception:
logger.warning(
"rate limit log failed (degraded): actor=%s", auth.actor_id, exc_info=True
)
raise ApiError(429, "GUARD_RATE_LIMITED", "rate limit exceeded, retry later")
# T-03 输入防护(F-03/G-03):准入后、会话解析前 fail-fast——被拒输入
# 不建会话、不落消息表。命中即拒(拍板:宁可误拒不可漏放);留痕失败
# 降级 warning,拒绝语义优先(与 deps 401/403 留痕降级同口径)。
verdict = input_guard.inspect_message(message)
if verdict.blocked:
try:
_repo().insert_input_guard_log(
trace_id=current_trace() or new_trace(),
agent_type=agent_type,
actor_id=auth.actor_id,
guard_type=verdict.guard_type or input_guard.GUARD_INJECTION,
action="blocked",
raw_excerpt=message[:1024],
session_id=(req.session_id or "")[:64], # P3 评审:未校验字段先截断再落审计库
)
except Exception:
logger.warning(
"input guard log failed (degraded): actor=%s type=%s",
auth.actor_id,
verdict.guard_type,
exc_info=True,
)
code = (
"GUARD_BLOCKED_OVERSIZE"
if verdict.guard_type == input_guard.GUARD_OVERSIZE
else "GUARD_BLOCKED_INJECTION"
)
raise ApiError(400, code, "message rejected by input guard")
customer_id = _resolve_customer_id(auth, agent_type, req.customer_id)
session_repo = _session_repo()
if req.session_id:
session = _guard_session(auth, agent_type, req.session_id)
if session["status"] != "active":
raise ApiError(409, "STATE_CONFLICT", f"session is {session['status']}")
sid = session["session_id"]
else:
sid = f"sess-{uuid4().hex[:16]}"
session_repo.create_session(
session_id=sid,
trace_id=current_trace(),
agent_type=agent_type,
actor_id=auth.actor_id,
actor_role=_primary_role(auth),
customer_id=customer_id,
advisor_id=auth.actor_id if agent_type == "advisor" else None,
title=req.title,
)
history = memory_service.get_recent(agent_type, sid)
result = agent_service.chat(
agent_type,
history,
message,
session_id=sid,
trace_id=current_trace(),
actor={"actor_id": auth.actor_id, "roles": auth.roles, "token_type": auth.token_type},
customer_id=customer_id,
)
# 落盘:user + assistant 同步写(异步化归后续);同 trace_id 贯通
trace_id = current_trace()
seq = session_repo.next_seq_no(sid)
session_repo.insert_message(
session_id=sid, trace_id=trace_id, seq_no=seq, role="user", content=message
)
session_repo.insert_message(
session_id=sid,
trace_id=trace_id,
seq_no=seq + 1,
role="assistant",
content=result["reply"],
has_disclaimer=bool(result["has_disclaimer"]),
)
memory_service.append_window(
agent_type,
sid,
[
{"role": "user", "content": message},
{"role": "assistant", "content": result["reply"]},
],
)
return {
"session_id": sid,
"agent_type": agent_type,
"customer_id": customer_id,
"reply": result["reply"],
"has_disclaimer": result["has_disclaimer"],
"trace_id": trace_id,
}
# ---------- 方案 B:前端「拉」侧只读接口(会话列表 / 历史消息 / 关闭会话) ----------
#
# 前端对话页三件套,与 POST "" 共用同一套入口守卫(_assert_chat_entry:
# 矩阵准入 + risk_manager 显式拒绝)与 SessionGuard(_guard_session:
# 仅本人会话 + agent_type 一致,越权 403 留痕)。纯读/状态流转,不改表、
# 不碰 Tool 契约;manager 与对话线保持同口径 deny(见 _assert_chat_entry)。
@router.get("/sessions")
def list_sessions_api(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
auth: AuthContext = Depends(get_auth_context),
) -> dict:
"""当前登录人的会话列表(created_at 倒序分页;agent_type 经 X-Agent-Type 头指定)。"""
agent_type = _resolve_agent_type(request)
_assert_chat_entry(auth, agent_type)
items, total = _session_repo().list_sessions(
actor_id=auth.actor_id, agent_type=agent_type, limit=limit, offset=offset
)
return {"items": items, "total": total, "limit": limit, "offset": offset}
@router.get("/sessions/{session_id}/messages")
def list_messages_api(
session_id: str,
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
auth: AuthContext = Depends(get_auth_context),
) -> dict:
"""指定会话的历史消息(seq_no 升序分页;closed 会话历史仍可读)。"""
agent_type = _resolve_agent_type(request)
_assert_chat_entry(auth, agent_type)
_guard_session(auth, agent_type, session_id)
items, total = _session_repo().list_messages_page(session_id, limit=limit, offset=offset)
return {
"session_id": session_id,
"items": items,
"total": total,
"limit": limit,
"offset": offset,
}
@router.post("/sessions/{session_id}/close")
def close_session_api(
session_id: str,
request: Request,
auth: AuthContext = Depends(get_auth_context),
) -> dict:
"""关闭会话(active → closed + closed_at);重复关闭/非 active 409。"""
agent_type = _resolve_agent_type(request)
_assert_chat_entry(auth, agent_type)
session = _guard_session(auth, agent_type, session_id)
if session["status"] != "active":
raise ApiError(409, "STATE_CONFLICT", f"session is {session['status']}")
# 条件更新(WHERE status='active'):并发双击时后到者 rowcount=0 —— 不静默
# 返回 200,转 409 与“重复关闭”同语义(评审 P1)。
if not _session_repo().close_session(session_id):
raise ApiError(409, "STATE_CONFLICT", "session is closed")
return {"session_id": session_id, "status": "closed"}