fix: 修 POST /api/v1/conversations 的 MissingGreenlet(会话建不出来导致转人工 404)
- public_platform_service: 创建 ConversationSession 时显式赋值四个 server_default 时间列,
否则 flush() 后需回读数据库生成值,在 async session 里以同步属性访问触发
MissingGreenlet,接口 500,连带转人工一直报会话不存在
- portal: 客服改用 C001 真实建会话;转人工改传 reason_code/reason_detail(原 reason 属额外字段 422)
- portal: 投顾查客户投资目标走 customers/{id} 变体
- seed/grant: 补 investment-goal:{read,write,confirm}:customer 三个动态拼出的权限码
(data_scope=own_customers,投顾只看名下客户)
This commit is contained in:
@@ -79,11 +79,25 @@ class PublicPlatformService:
|
||||
session_id: str | None = None
|
||||
if operation == "create":
|
||||
get_agent_factory().authorize(payload["agent_type"], context)
|
||||
# 这四个时间列在模型里都是 `server_default=CURRENT_TIMESTAMP(6)`。
|
||||
# 不显式赋值的话,`flush()` 之后 SQLAlchemy 需要**回读**这些由数据库生成的
|
||||
# 值,而在 async session 里回读是异步 IO —— 紧接着 `_session_view(row)`
|
||||
# 以同步属性访问去读,就抛 `MissingGreenlet: greenlet_spawn has not been
|
||||
# called`,整个 `POST /api/v1/conversations` 500,连带转人工也做不了
|
||||
# (会话建不出来 ⇒ 后续 404 会话不存在)。显式传 `now` 与同文件
|
||||
# `ConversationFeedback(...)` 的写法一致,也贴合本项目"应用侧赋时间"的约定。
|
||||
row = ConversationSession(
|
||||
session_id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
agent_type=payload["agent_type"],
|
||||
portal=context.portal,
|
||||
status="active",
|
||||
clarification_round=0,
|
||||
message_count=0,
|
||||
started_at=now,
|
||||
last_active_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
|
||||
@@ -80,6 +80,12 @@ ADVISOR_GRANTED_CODES: tuple[str, ...] = (
|
||||
"investment-goal:read:self",
|
||||
"investment-goal:write:self", # 新建投资目标
|
||||
"investment-goal:confirm:self", # 与客户确认目标
|
||||
# 看/建/确认**客户**(而非自己)的投资目标。这三个码是 `investment_goal_service.py`
|
||||
# 按 `customer_id == 自己` 动态拼出来的,`data_scope=own_customers`:
|
||||
# 只有客户在投顾名下才放行 —— 投顾服务的本来就是别人的钱。
|
||||
"investment-goal:read:customer",
|
||||
"investment-goal:write:customer",
|
||||
"investment-goal:confirm:customer",
|
||||
"investment-goal:review",
|
||||
"investment-goal:publish",
|
||||
"portfolio-analysis:read:self",
|
||||
|
||||
+34
-10
@@ -500,8 +500,10 @@ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({'&':'&','<':'
|
||||
const pretty = (o) => { try { return JSON.stringify(o, null, 2); } catch { return String(o); } };
|
||||
|
||||
let ME = null;
|
||||
// 客服对话的会话号(一次对话内保持,用于承接上下文)
|
||||
let SESSION = "portal-" + Math.random().toString(16).slice(2, 10);
|
||||
// 客服会话:必须用 C001 `POST /api/v1/conversations` 真实创建后再用 ——
|
||||
// `agent-runs` 的 session_id 不会替你建会话,转人工时后端会查 `conversation` 表,
|
||||
// 拿一个自己编的 id 去调就会得到 404「会话不存在」。
|
||||
let CONV = null;
|
||||
// 门户会话号:每个标签页一个,存在 sessionStorage;令牌本身始终留在服务端 ——
|
||||
// 于是可以同时开两个窗口分别用客户与管理员身份,互不干扰。
|
||||
let SID = sessionStorage.getItem('portal-sid');
|
||||
@@ -622,6 +624,16 @@ function addMsg(who, text, meta) {
|
||||
return row;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (CONV) return CONV;
|
||||
const r = await jpost('/api/call', { method:'POST', path:'/api/v1/conversations',
|
||||
body:{ agent_type:'customer_service' } });
|
||||
const d = (r.body || {}).data || {};
|
||||
CONV = d.session_id || d.id || null;
|
||||
if (!CONV) { alert('创建会话失败(HTTP ' + r.status + '):' + pretty(r.body)); }
|
||||
return CONV;
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const box = $('msg');
|
||||
const text = box.value.trim();
|
||||
@@ -630,7 +642,9 @@ async function sendChat() {
|
||||
box.value = '';
|
||||
const pending = addMsg('bot', '正在查询资料…');
|
||||
$('chat-meta').textContent = '处理中…';
|
||||
const r = await jpost('/api/chat', { message: text, session_id: SESSION });
|
||||
const sid = await ensureSession();
|
||||
if (!sid) { pending.remove(); $('chat-meta').textContent = ''; return; }
|
||||
const r = await jpost('/api/chat', { message: text, session_id: sid });
|
||||
pending.remove();
|
||||
$('chat-meta').textContent = '';
|
||||
if (!r.ok) { addMsg('bot', '请求失败:' + pretty(r.body)); return; }
|
||||
@@ -676,10 +690,15 @@ async function decide(id, decision) {
|
||||
}
|
||||
|
||||
async function askHandover() {
|
||||
const reason = prompt('转人工原因(会记录进工单):', '需要人工协助');
|
||||
if (reason === null) return;
|
||||
const sid = await ensureSession();
|
||||
if (!sid) return;
|
||||
// 该接口只接受 `reason_code`(固定 user_requested)+ `reason_detail`(可选、≤500 字);
|
||||
// 传 `reason` 会被 `additionalProperties: false` 判为额外字段,直接 422。
|
||||
const detail = prompt('转人工说明(可留空,最多 500 字):', '需要人工协助');
|
||||
if (detail === null) return;
|
||||
const r = await jpost('/api/call', { method:'POST',
|
||||
path: `/api/v1/conversations/${SESSION}/handover-requests`, body: { reason } });
|
||||
path: `/api/v1/conversations/${sid}/handover-requests`,
|
||||
body: { reason_code: 'user_requested', reason_detail: detail.slice(0, 500) } });
|
||||
showExtra('转人工结果', r);
|
||||
}
|
||||
|
||||
@@ -1080,12 +1099,17 @@ function showAdv(title, r) {
|
||||
}
|
||||
|
||||
async function goal() {
|
||||
const id = $('cust')?.value.trim();
|
||||
const path = id ? `/api/v1/advisor/customers/${id}/investment-goals/current`
|
||||
: '/api/v1/advisor/investment-goals/current';
|
||||
// 投顾要看的是**客户**的目标,所以走 `customers/{id}` 变体 —— 服务层会按
|
||||
// `customer_id == 自己` 决定拼 `:self` 还是 `:customer` 权限码,后者要求
|
||||
// `data_scope=own_customers` 且该客户确实在这个投顾名下,否则返回 404。
|
||||
const id = ($('cust')?.value.trim() || '9001');
|
||||
const path = `/api/v1/advisor/customers/${id}/investment-goals/current`;
|
||||
const r = await GET(path);
|
||||
const hint = r.status === 403
|
||||
? ' —— 需要 investment-goal:read:customer(scope=own_customers)'
|
||||
: (r.status === 404 ? ' —— 该客户不在你名下,或还没有投资目标' : '');
|
||||
$('advisor-extra').innerHTML = `<h2>投资目标</h2>
|
||||
<p class="hint">HTTP <b>${r.status}</b> · <code>${esc(path)}</code></p>
|
||||
<p class="hint">HTTP <b>${r.status}</b>${hint} · <code>${esc(path)}</code></p>
|
||||
<pre>${esc(pretty(r.body))}</pre>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,19 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
|
||||
(9055, "financial:nl2sql:read", "financial", "nl2sql", "all"),
|
||||
# 平台验证探针工具(`platform_probe.py`),只给 admin。
|
||||
(9056, "probe:read", "probe", "read", "all"),
|
||||
# ---- 9057-9059:`investment-goal:{action}:customer` 三个变体 ----
|
||||
# 这三个是**动态拼出来的**:`investment_goal_service.py:270-286` 的
|
||||
# `_assert_customer_access` 按 `customer_id == 自己` 决定拼 `:self` 还是 `:customer`,
|
||||
# 所以对账工具抓不到字面量,一直以为权限齐了 —— 实际投顾查/建/确认**客户**的目标
|
||||
# 全部 403。action 取值来自调用点:`write`(L43) / `confirm`(L110) / `read`(L138,155)。
|
||||
#
|
||||
# `data_scope` 必须是 `own_customers`:那段代码在后面还会校一次
|
||||
# `scope != "all" and (scope != "own_customers" or customer_id not in context.customer_ids)`
|
||||
# ⇒ 只有 `own_customers` 且客户确在投顾名下才放行。这是**最小权限**的正确形态:
|
||||
# 投顾只看自己服务的客户,而不是全量客户。
|
||||
(9057, "investment-goal:read:customer", "investment-goal", "read", "own_customers"),
|
||||
(9058, "investment-goal:write:customer", "investment-goal", "write", "own_customers"),
|
||||
(9059, "investment-goal:confirm:customer", "investment-goal", "confirm", "own_customers"),
|
||||
)
|
||||
|
||||
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
|
||||
|
||||
Reference in New Issue
Block a user