diff --git a/app/service/public_platform_service.py b/app/service/public_platform_service.py index 45a23a7..496bff6 100644 --- a/app/service/public_platform_service.py +++ b/app/service/public_platform_service.py @@ -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() diff --git a/tools/grant_advisor_role.py b/tools/grant_advisor_role.py index aa0fd13..190b3d8 100644 --- a/tools/grant_advisor_role.py +++ b/tools/grant_advisor_role.py @@ -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", diff --git a/tools/portal.py b/tools/portal.py index c15c7ae..422d689 100644 --- a/tools/portal.py +++ b/tools/portal.py @@ -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 = `
HTTP ${r.status} · ${esc(path)}
HTTP ${r.status}${hint} · ${esc(path)}
${esc(pretty(r.body))}`;
}
diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py
index 2cc727d..d683f68 100644
--- a/tools/seed_test_rbac.py
+++ b/tools/seed_test_rbac.py
@@ -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"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。