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)已修复。
This commit is contained in:
2026-09-08 14:07:38 +08:00
parent 08e831aaf1
commit 8328c2403e
4 changed files with 361 additions and 19 deletions
+153
View File
@@ -335,3 +335,156 @@ def test_chat_jwt_agent_mismatch_denied(env):
headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "customer"},
)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_AGENT_MISMATCH"
# ---------- 方案 B:前端拉侧(会话列表 / 历史消息 / 关闭会话) ----------
RISK_OFFICER = {"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001", "X-Agent-Type": "risk"}
RISK_MANAGER = {"X-Debug-Role": "risk_manager", "X-Debug-Actor": "STAFF-31001", "X-Agent-Type": "risk"}
def test_sessions_list_only_own_and_paged(env):
"""会话列表:仅本人 + 本 Agent 线;created_at 倒序;limit/offset 分页 + total。"""
sid1 = env["client"].post("/api/chat", json={"message": "第一条"}, headers=CUSTOMER).json()["session_id"]
sid2 = env["client"].post("/api/chat", json={"message": "第二条"}, headers=CUSTOMER).json()["session_id"]
# 另一 actor 的会话不应出现在我的列表里
env["client"].post(
"/api/chat", json={"message": "别人的"},
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001", "X-Agent-Type": "customer"},
)
r = env["client"].get("/api/chat/sessions", headers=CUSTOMER)
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert [s["session_id"] for s in body["items"]] == [sid2, sid1] # 倒序
assert all(s["agent_type"] == "customer" and s["status"] == "active" for s in body["items"])
r2 = env["client"].get("/api/chat/sessions?limit=1&offset=1", headers=CUSTOMER)
body2 = r2.json()
assert body2["total"] == 2 and len(body2["items"]) == 1
assert body2["items"][0]["session_id"] == sid1
def test_sessions_list_risk_line_isolated(env):
"""agent_type 隔离:risk 线会话不出现在 customer 线列表(反之亦然)。"""
env["client"].post("/api/chat", json={"message": "客户线"}, headers=CUSTOMER)
r = env["client"].get("/api/chat/sessions", headers=RISK_OFFICER)
assert r.status_code == 200 and r.json()["total"] == 0
def test_messages_page_ascending_and_paged(env):
"""历史消息:seq_no 升序全量分页;has_disclaimer 透出;closed 后仍可读。"""
sid = env["client"].post("/api/chat", json={"message": "第一句"}, headers=CUSTOMER).json()["session_id"]
env["client"].post("/api/chat", json={"message": "第二句", "session_id": sid}, headers=CUSTOMER)
r = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=CUSTOMER)
assert r.status_code == 200
body = r.json()
assert body["total"] == 4
assert [m["role"] for m in body["items"]] == ["user", "assistant", "user", "assistant"]
assert [m["seq_no"] for m in body["items"]] == [1, 2, 3, 4]
assert body["items"][0]["content"] == "第一句"
assert body["items"][1]["has_disclaimer"] is True # customer 线 assistant 带免责声明
r2 = env["client"].get(f"/api/chat/sessions/{sid}/messages?limit=2&offset=2", headers=CUSTOMER)
body2 = r2.json()
assert body2["total"] == 4 and [m["seq_no"] for m in body2["items"]] == [3, 4]
def test_messages_of_other_actor_denied(env):
"""SessionGuard:他人会话历史 403 + 审计留痕(与 POST 同口径)。"""
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
other = {**CUSTOMER, "X-Debug-Actor": "CUST-1001"}
r = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=other)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
assert _rows(env["engine"], "SELECT 1 FROM audit_log WHERE decision = 'forbidden'")
def test_messages_not_found_and_agent_type_mismatch(env):
r = env["client"].get("/api/chat/sessions/sess-nope/messages", headers=CUSTOMER)
assert r.status_code == 404
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
# 会话在 customer 线,advisor 头查 → agent_type 不一致 403
r2 = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=ADVISOR)
assert r2.status_code == 403 and r2.json()["error_code"] == "AUTH_403_SESSION_AGENT"
def test_close_session_then_read_only(env):
"""关闭会话:200 + closed_at 落库;续聊 409;重复关闭 409;历史仍可读。"""
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
r = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=CUSTOMER)
assert r.status_code == 200 and r.json() == {"session_id": sid, "status": "closed"}
row = _rows(env["engine"], "SELECT status, closed_at FROM agent_session WHERE session_id = :s", s=sid)[0]
assert row["status"] == "closed" and row["closed_at"] is not None
r2 = env["client"].post("/api/chat", json={"message": "续聊", "session_id": sid}, headers=CUSTOMER)
assert r2.status_code == 409 and r2.json()["error_code"] == "STATE_CONFLICT"
r3 = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=CUSTOMER)
assert r3.status_code == 409 and r3.json()["error_code"] == "STATE_CONFLICT"
r4 = env["client"].get(f"/api/chat/sessions/{sid}/messages", headers=CUSTOMER)
assert r4.status_code == 200 and r4.json()["total"] == 2
def test_close_other_actor_denied(env):
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
other = {**CUSTOMER, "X-Debug-Actor": "CUST-1001"}
r = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=other)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_SESSION_AGENT"
# 未关闭成功
row = _rows(env["engine"], "SELECT status FROM agent_session WHERE session_id = :s", s=sid)[0]
assert row["status"] == "active"
def test_close_not_found_and_agent_type_mismatch(env):
"""close 端点:会话不存在 404;会话在 customer 线、advisor 头关 → 403(评审 P2)。"""
r = env["client"].post("/api/chat/sessions/sess-nope/close", headers=CUSTOMER)
assert r.status_code == 404
sid = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER).json()["session_id"]
r2 = env["client"].post(f"/api/chat/sessions/{sid}/close", headers=ADVISOR)
assert r2.status_code == 403 and r2.json()["error_code"] == "AUTH_403_SESSION_AGENT"
row = _rows(env["engine"], "SELECT status FROM agent_session WHERE session_id = :s", s=sid)[0]
assert row["status"] == "active"
def test_query_endpoints_limit_clamped(env):
"""分页参数钳制:limit 超上限(sessions 100 / messages 200)由 Query 拦 422(评审 P2)。"""
assert env["client"].get("/api/chat/sessions?limit=101", headers=CUSTOMER).status_code == 422
assert env["client"].get("/api/chat/sessions?offset=-1", headers=CUSTOMER).status_code == 422
r = env["client"].get("/api/chat/sessions?limit=100", headers=CUSTOMER)
assert r.status_code == 200 and r.json()["limit"] == 100
def test_query_endpoints_risk_manager_denied(env):
"""方案 B 拍板:查询/关闭端点与对话线同口径——risk_manager 一律 403 AUTH_403_ROLE。"""
for method, url in (
("get", "/api/chat/sessions"),
("get", "/api/chat/sessions/sess-x/messages"),
("post", "/api/chat/sessions/sess-x/close"),
):
r = getattr(env["client"], method)(url, headers=RISK_MANAGER)
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE"
def test_query_endpoints_missing_agent_type(env):
r = env["client"].get(
"/api/chat/sessions",
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-9527"},
)
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_MISSING_AGENT_TYPE"
def test_sessions_list_jwt_channel(env):
"""JWT 通道:risk_officer 拉自己的 risk 线会话列表。"""
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
headers = {"Authorization": f"Bearer {tok}", "X-Agent-Type": "risk"}
env["client"].post("/api/chat", json={"message": "看下预警"}, headers=headers)
r = env["client"].get("/api/chat/sessions", headers=headers)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1 and body["items"][0]["agent_type"] == "risk"
assert body["items"][0]["actor_id"] == "STAFF-30001"
+4
View File
@@ -65,6 +65,10 @@ def test_all_routers_mounted(client):
"/api/risk/aml/scan",
"/api/simulate/trade",
"/api/chat",
# 方案 B:前端拉侧三端点
"/api/chat/sessions",
"/api/chat/sessions/{session_id}/messages",
"/api/chat/sessions/{session_id}/close",
}