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:
2026-09-12 15:37:36 +08:00
parent 14f5078491
commit 615032ab00
4 changed files with 67 additions and 10 deletions
+14
View File
@@ -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()
+6
View File
@@ -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
View File
@@ -500,8 +500,10 @@ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'
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>`;
}
+13
View File
@@ -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"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。