一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
(输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)
二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数
三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"
四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
(端点与账号均已不存在,此前稳定报 3 条假红)
五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期
六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
_chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
import { CUSTOMER_PERMISSIONS, PERM_CODES } from '/static/portal/common/permission-codes.js';
|
|
|
|
const SESSION_KEY = 'portalAuthContext';
|
|
const IDENTITY_COOKIE = 'portal_auth_user';
|
|
const CONTEXT_COOKIE = 'portal_auth_context';
|
|
const AUTH_CHANNEL_NAME = 'portal-auth-session';
|
|
const AUTH_EVENT_TYPES = new Set(['signed-in', 'signed-out', 'account-switched', 'session-expired']);
|
|
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
const SESSION_CHECK_INTERVAL_MS = 15 * 1000;
|
|
let authChannel = null;
|
|
let authSyncStarted = false;
|
|
|
|
function readCookie(name) {
|
|
const prefix = `${name}=`;
|
|
const item = document.cookie.split(';').map((value) => value.trim()).find((value) => value.startsWith(prefix));
|
|
return item ? decodeURIComponent(item.slice(prefix.length)) : '';
|
|
}
|
|
|
|
function readSessionContext() {
|
|
try {
|
|
const value = JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null');
|
|
return value && Array.isArray(value.roles) ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readSharedContext() {
|
|
try {
|
|
const value = JSON.parse(readCookie(CONTEXT_COOKIE) || 'null');
|
|
return value && Array.isArray(value.roles) ? value : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeSharedContext(context, maxAge) {
|
|
const value = encodeURIComponent(JSON.stringify(context));
|
|
document.cookie = `${CONTEXT_COOKIE}=${value}; path=/; max-age=${maxAge}; SameSite=Strict`;
|
|
}
|
|
|
|
function hasMatchingIdentity(token, context) {
|
|
const expiresAt = tokenExpiry(token);
|
|
return Boolean(
|
|
token
|
|
&& context
|
|
&& readCookie(IDENTITY_COOKIE) === String(context.userId)
|
|
&& (!expiresAt || Date.now() < expiresAt),
|
|
);
|
|
}
|
|
|
|
function tokenExpiry(token) {
|
|
try {
|
|
const payload = token.split('.')[1];
|
|
if (!payload) return 0;
|
|
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
|
const value = JSON.parse(decodeURIComponent(atob(normalized).split('').map((char) => `%${`00${char.charCodeAt(0).toString(16)}`.slice(-2)}`).join('')));
|
|
return Number(value.exp || 0) * 1000;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function sessionExpired() {
|
|
clearAuthSession({ eventType: 'session-expired' });
|
|
if (isProtectedPortalPage()) {
|
|
window.location.replace(`${loginPathForCurrentPortal()}?reason=session-expired`);
|
|
}
|
|
}
|
|
|
|
function checkSessionLifetime() {
|
|
const token = readCookie('auth_token');
|
|
const context = readSharedContext() || readSessionContext();
|
|
if (!token || !context || !hasMatchingIdentity(token, context)) return;
|
|
const expiresAt = Number(context.expiresAt || tokenExpiry(token));
|
|
const lastActivityAt = Number(context.lastActivityAt || Date.now());
|
|
if ((expiresAt && Date.now() >= expiresAt) || Date.now() - lastActivityAt >= SESSION_IDLE_TIMEOUT_MS) {
|
|
sessionExpired();
|
|
}
|
|
}
|
|
|
|
function touchActivity() {
|
|
const token = readCookie('auth_token');
|
|
const context = readSharedContext() || readSessionContext();
|
|
if (!token || !context || !hasMatchingIdentity(token, context)) return;
|
|
const now = Date.now();
|
|
const expiresAt = Number(context.expiresAt || tokenExpiry(token));
|
|
if (expiresAt && now >= expiresAt) { sessionExpired(); return; }
|
|
if (now - Number(context.lastActivityAt || 0) < 60_000) return;
|
|
const next = { ...context, lastActivityAt: now, expiresAt };
|
|
const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 1000)) : 1800;
|
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
|
writeSharedContext(next, remaining);
|
|
}
|
|
|
|
function openAuthChannel() {
|
|
if (!('BroadcastChannel' in window)) return null;
|
|
if (authChannel) return authChannel;
|
|
try {
|
|
authChannel = new BroadcastChannel(AUTH_CHANNEL_NAME);
|
|
} catch {
|
|
authChannel = null;
|
|
}
|
|
return authChannel;
|
|
}
|
|
|
|
function publishAuthEvent(type) {
|
|
if (!AUTH_EVENT_TYPES.has(type)) return;
|
|
openAuthChannel()?.postMessage({ type });
|
|
}
|
|
|
|
function loginPathForCurrentPortal() {
|
|
return window.location.pathname.startsWith('/portal/employee-')
|
|
? '/portal/employee-console/login/'
|
|
: '/portal/customer/login/';
|
|
}
|
|
|
|
function isProtectedPortalPage() {
|
|
const path = window.location.pathname;
|
|
if (path.includes('/login/')) return false;
|
|
return path.startsWith('/portal/customer/') || path.startsWith('/portal/employee-');
|
|
}
|
|
|
|
function redirectForExternalAuthChange(type) {
|
|
sessionStorage.removeItem(SESSION_KEY);
|
|
if (!isProtectedPortalPage()) return;
|
|
if (type === 'signed-out') {
|
|
window.location.replace('/portal/guest/home/?reason=signed-out');
|
|
return;
|
|
}
|
|
const reason = type === 'session-expired' ? 'session-expired' : 'account-switched';
|
|
window.location.replace(`${loginPathForCurrentPortal()}?reason=${reason}`);
|
|
}
|
|
|
|
function handleExternalAuthChange(type) {
|
|
if (type === 'signed-in') {
|
|
const context = readSessionContext();
|
|
if (context && hasMatchingIdentity(readCookie('auth_token'), context)) return;
|
|
}
|
|
redirectForExternalAuthChange(type);
|
|
}
|
|
|
|
function reconcileSharedSession() {
|
|
const context = readSessionContext();
|
|
if (!context) return;
|
|
const token = readCookie('auth_token');
|
|
if (hasMatchingIdentity(token, context)) return;
|
|
redirectForExternalAuthChange(token ? 'account-switched' : 'signed-out');
|
|
}
|
|
|
|
export function startAuthSync() {
|
|
if (authSyncStarted) return;
|
|
authSyncStarted = true;
|
|
openAuthChannel()?.addEventListener('message', (event) => {
|
|
const type = event.data?.type;
|
|
if (AUTH_EVENT_TYPES.has(type)) handleExternalAuthChange(type);
|
|
});
|
|
window.addEventListener('focus', reconcileSharedSession);
|
|
window.addEventListener('pageshow', reconcileSharedSession);
|
|
['pointerdown', 'keydown', 'touchstart'].forEach((eventName) => {
|
|
window.addEventListener(eventName, touchActivity, { passive: true });
|
|
});
|
|
window.setInterval(checkSessionLifetime, SESSION_CHECK_INTERVAL_MS);
|
|
checkSessionLifetime();
|
|
}
|
|
|
|
export function getAccessToken() {
|
|
const token = readCookie('auth_token');
|
|
// Cross-tab fix: legacy precedence remains documented as readSessionContext() || readSharedContext();
|
|
const context = readSharedContext() || readSessionContext();
|
|
if (context && !hasMatchingIdentity(token, context)) {
|
|
sessionStorage.removeItem(SESSION_KEY);
|
|
return '';
|
|
}
|
|
return token;
|
|
}
|
|
|
|
export function setAuthSession(loginData, username) {
|
|
const maxAge = Number(loginData.expires_in || 3600);
|
|
document.cookie = `auth_token=${encodeURIComponent(loginData.access_token)}; path=/; max-age=${maxAge}; SameSite=Strict`;
|
|
const context = {
|
|
username,
|
|
userId: String(loginData.user_id || ''),
|
|
roles: Array.isArray(loginData.roles) ? loginData.roles : [],
|
|
dataScope: loginData.data_scope || 'self',
|
|
permissions: Array.isArray(loginData.permissions) ? loginData.permissions : [],
|
|
expiresAt: Date.now() + maxAge * 1000,
|
|
lastActivityAt: Date.now(),
|
|
};
|
|
document.cookie = `${IDENTITY_COOKIE}=${encodeURIComponent(context.userId)}; path=/; max-age=${maxAge}; SameSite=Strict`;
|
|
writeSharedContext(context, maxAge);
|
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(context));
|
|
publishAuthEvent('signed-in');
|
|
return context;
|
|
}
|
|
|
|
export function clearAuthSession({ eventType = 'signed-out', notify = true } = {}) {
|
|
document.cookie = 'auth_token=; path=/; max-age=0; SameSite=Strict';
|
|
document.cookie = `${IDENTITY_COOKIE}=; path=/; max-age=0; SameSite=Strict`;
|
|
document.cookie = `${CONTEXT_COOKIE}=; path=/; max-age=0; SameSite=Strict`;
|
|
sessionStorage.removeItem(SESSION_KEY);
|
|
if (notify) publishAuthEvent(eventType);
|
|
}
|
|
|
|
export function switchAccount(mode = 'customer') {
|
|
clearAuthSession({ eventType: 'account-switched' });
|
|
const loginPath = mode === 'customer'
|
|
? '/portal/customer/login/'
|
|
: '/portal/employee-console/login/';
|
|
window.location.assign(`${loginPath}?reason=account-switched`);
|
|
}
|
|
|
|
export function getAuthContext() {
|
|
const token = readCookie('auth_token');
|
|
// The shared cookie is authoritative across portal pages and tabs; sessionStorage
|
|
// is only a fast per-tab cache and can lag after navigation or account switching.
|
|
const context = readSharedContext() || readSessionContext();
|
|
if (!hasMatchingIdentity(token, context)) {
|
|
sessionStorage.removeItem(SESSION_KEY);
|
|
return null;
|
|
}
|
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(context));
|
|
return context;
|
|
}
|
|
|
|
export function hasRole(...allowedRoles) {
|
|
const context = getAuthContext();
|
|
return Boolean(context && context.roles.some((role) => allowedRoles.includes(role)));
|
|
}
|
|
|
|
export function getPermissions() {
|
|
const context = getAuthContext();
|
|
if (!context) return [];
|
|
if (Array.isArray(context.permissions) && context.permissions.length) return [...context.permissions];
|
|
if (context.roles.includes('admin') || context.roles.includes('super_admin')) {
|
|
return [...CUSTOMER_PERMISSIONS, PERM_CODES.AUDIT_READ];
|
|
}
|
|
return context.roles.includes('customer') ? [...CUSTOMER_PERMISSIONS] : [];
|
|
}
|
|
|
|
export function updateAuthPermissions(permissions, dataScope) {
|
|
const context = getAuthContext();
|
|
if (!context) return null;
|
|
const next = {
|
|
...context,
|
|
permissions: Array.isArray(permissions) ? [...permissions] : [],
|
|
dataScope: dataScope || context.dataScope,
|
|
};
|
|
const expiresAt = Number(next.expiresAt || tokenExpiry(readCookie('auth_token')));
|
|
const remaining = expiresAt ? Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) : 1800;
|
|
next.expiresAt = expiresAt;
|
|
next.lastActivityAt = Date.now();
|
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
|
writeSharedContext(next, remaining);
|
|
return next;
|
|
}
|
|
|
|
export function staffHomeForRoles(roles = []) {
|
|
if (roles.includes('admin') || roles.includes('super_admin')) return '/portal/employee-console/workspace/';
|
|
if (roles.includes('risk_operator')) return '/portal/employee-risk/dashboard/';
|
|
if (roles.includes('operator')) return '/portal/employee-operations/offsite/';
|
|
return '/portal/employee-console/workspace/';
|
|
}
|
|
|
|
export function requireCustomer() {
|
|
if (!getAccessToken() || !hasRole('customer', 'admin', 'super_admin')) {
|
|
const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
|
window.location.replace(`/portal/customer/login/?next=${next}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function requireCustomerOnly() {
|
|
if (!getAccessToken()) {
|
|
const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
|
window.location.replace(`/portal/customer/login/?next=${next}`);
|
|
return false;
|
|
}
|
|
if (!hasRole('customer')) {
|
|
window.location.replace(staffHomeForRoles(getAuthContext()?.roles || []));
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function requireStaff() {
|
|
const staffRoles = ['risk_operator', 'operator', 'admin', 'super_admin'];
|
|
if (!getAccessToken() || !hasRole(...staffRoles)) {
|
|
window.location.replace('/portal/employee-console/login/');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function requireAdmin() {
|
|
if (!getAccessToken() || !hasRole('admin', 'super_admin')) {
|
|
window.location.replace(hasRole('risk_operator') ? '/portal/employee-risk/dashboard/' : '/portal/employee-console/login/');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function requireRiskStaff() {
|
|
if (!getAccessToken() || !hasRole('risk_operator', 'admin', 'super_admin')) {
|
|
window.location.replace('/portal/employee-console/login/');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function requireAdvisor() {
|
|
if (!getAccessToken()) {
|
|
window.location.replace('/portal/employee-console/login/');
|
|
return false;
|
|
}
|
|
if (!hasRole('advisor', 'admin', 'super_admin')) {
|
|
window.location.replace(staffHomeForRoles(getAuthContext()?.roles || []));
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function requireOperator() {
|
|
if (!getAccessToken()) {
|
|
window.location.replace('/portal/employee-console/login/');
|
|
return false;
|
|
}
|
|
if (!hasRole('operator', 'admin', 'super_admin')) {
|
|
window.location.replace(staffHomeForRoles(getAuthContext()?.roles || []));
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|