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:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user