fix(chat,channels): admin session delete, weixin inbound, PG and docs

- List/delete chat sessions for administrator by username (cross-tenant UUID)

- Return 404 when delete does not remove a session; add tests

- Weixin: dispatch lang, inbound attachments, reply persist, native reply timeout

- PG compat scrub and administrator session delete repo path

- RUNBOOK: WhatsApp re-bind with Remove-Item auth; weixin poll diag scripts

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-03 01:36:45 +08:00
parent 39fd1905b6
commit bc1b45a8fd
20 changed files with 1056 additions and 86 deletions

View file

@ -113,6 +113,7 @@ _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
_CHANNEL_DISPATCH_INTERACTION_KEY_PREFIX = "channel.dispatch.interaction_mode."
_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX = "channel.dispatch.specialist."
_CHANNEL_DISPATCH_LANG_KEY_PREFIX = "channel.dispatch.lang."
def _channel_dispatch_interaction_key(channel: str) -> str:
@ -122,6 +123,16 @@ def _channel_dispatch_interaction_key(channel: str) -> str:
def _channel_dispatch_specialist_key(channel: str) -> str:
return f"{_CHANNEL_DISPATCH_SPECIALIST_KEY_PREFIX}{str(channel or '').strip().lower()}"
def _channel_dispatch_lang_key(channel: str) -> str:
return f"{_CHANNEL_DISPATCH_LANG_KEY_PREFIX}{str(channel or '').strip().lower()}"
def _normalize_channel_dispatch_lang(raw: Any) -> str:
v = str(raw or "").strip().lower()
if v in {"auto", "zh", "en"}:
return v
return "auto"
def _normalize_channel_dispatch_channel(raw: Any) -> str:
ch = str(raw or "").strip().lower()
@ -361,11 +372,7 @@ def _chat_username(ctx: dict[str, Any]) -> str:
def _is_administrator_chat_viewer(ctx: dict[str, Any]) -> bool:
"""``administrator`` 账户:在单会话读写/导出等接口上可按租户打开任意会话(供审计与 Session Monitor)。
``GET /chat/sessions`` **不再**使用租户全量列表,避免与普通用户会话混在同一侧边栏;
看全租户会话请用审计页、``GET /admin/api/chat/admin/sessions`` 等专用接口。
"""
"""``administrator`` 账户:可跨租户列出/打开本人名下所有 ``ui_session_owner`` 会话(PG 迁移后历史可能在 smoke/Team 等多租户)。"""
return _chat_username(ctx) == "administrator"
@ -377,9 +384,16 @@ def _require_administrator_chat_viewer(ctx: dict[str, Any]) -> None:
def _resolve_chat_session(store: SqliteStore, ctx: dict[str, Any], session_id: str):
tenant_id = str(ctx.get("tenant_id") or "")
user_id = str(ctx.get("user_id") or "")
sid = str(session_id or "").strip()
if _is_administrator_chat_viewer(ctx):
return store.get_session_in_tenant(session_id=str(session_id or "").strip(), tenant_id=tenant_id)
return store.get_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
sess = store.get_session_for_administrator_username(
session_id=sid,
username=_chat_username(ctx),
)
if sess is not None:
return sess
return store.get_session_in_tenant(session_id=sid, tenant_id=tenant_id)
return store.get_session_for_user(session_id=sid, tenant_id=tenant_id, user_id=user_id)
def _resolve_chat_session_allow_claim_orphan(
@ -875,12 +889,17 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
ctx = resolve_auth(store, authorization)
tenant_id = str(ctx.get("tenant_id") or "")
user_id = str(ctx.get("user_id") or "")
# 含 administrator:侧边栏只列「当前登录用户」名下会话,避免租户内他人会话出现在 /chat。
# 全租户会话列表见审计、GET /admin/api/chat/admin/sessions 等。
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
rows = store.list_sessions_for_user(
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
)
if _is_administrator_chat_viewer(ctx):
uname = _chat_username(ctx)
meta = store.get_sessions_list_meta_for_administrator_username(username=uname)
rows = store.list_sessions_for_administrator_username(
username=uname, limit=limit, offset=offset
)
else:
meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id)
rows = store.list_sessions_for_user(
tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset
)
return {
"ok": True,
"total": int(meta.session_count or 0),
@ -962,12 +981,23 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
if not sess:
raise HTTPException(status_code=404, detail="session_not_found")
if _is_administrator_chat_viewer(ctx):
store.delete_session_in_tenant(session_id=session_id, tenant_id=tenant_id)
deleted = store.delete_session_for_administrator_username(
session_id=session_id, username=_chat_username(ctx)
)
else:
store.delete_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
remaining = store.list_sessions_for_user(
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
)
deleted = store.delete_session_for_user(
session_id=session_id, tenant_id=tenant_id, user_id=user_id
)
if not deleted:
raise HTTPException(status_code=404, detail="session_not_found")
if _is_administrator_chat_viewer(ctx):
remaining = store.list_sessions_for_administrator_username(
username=_chat_username(ctx), limit=1, offset=0
)
else:
remaining = store.list_sessions_for_user(
tenant_id=tenant_id, user_id=user_id, limit=1, offset=0
)
next_id = str(remaining[0].id) if remaining else ""
if not next_id:
lang = _api_lang(store)
@ -1563,12 +1593,16 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
specialist = normalize_requested_specialist(
store.get_setting(_channel_dispatch_specialist_key(ch)) or "generalist"
)
lang = _normalize_channel_dispatch_lang(
store.get_setting(_channel_dispatch_lang_key(ch)) or "auto"
)
specialist = _apply_specialist_flags(store, specialist)
return {
"ok": True,
"channel": ch,
"interaction_mode": interaction_mode,
"specialist": specialist,
"lang": lang,
"available_specialists": [sid for sid in _chat_specialist_ids() if bool(_specialist_flags_with_overrides(store).get(sid, True))],
}
@ -1585,14 +1619,17 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
ch = _normalize_channel_dispatch_channel(channel)
interaction_mode = normalize_interaction_mode(body.get("interaction_mode") or "expert")
specialist = normalize_requested_specialist(body.get("specialist") or "generalist")
lang = _normalize_channel_dispatch_lang(body.get("lang") or "auto")
specialist = _apply_specialist_flags(store, specialist)
store.set_setting(_channel_dispatch_interaction_key(ch), interaction_mode)
store.set_setting(_channel_dispatch_specialist_key(ch), specialist)
store.set_setting(_channel_dispatch_lang_key(ch), lang)
return {
"ok": True,
"channel": ch,
"interaction_mode": interaction_mode,
"specialist": specialist,
"lang": lang,
}
@chat.get("/settings/attachment-limits")

View file

@ -333,6 +333,48 @@ def build_admin_router() -> APIRouter:
return parts[1].strip()
return ""
def _resolve_login_tenant_id(store: SqliteStore, *, username: str, tenant_id: str) -> str:
"""Pick tenant when login body omits tenant_id.
Chat login sends tenant_id=\"\". Using list_tenants(limit=1) alone picks the newest tenant
(often pg-smoke), which has no chat history. For administrator, prefer the Team tenant,
then the tenant with the most owned sessions.
"""
explicit = str(tenant_id or "").strip()
if explicit:
return explicit
uname = str(username or "").strip().lower()
tenants = store.list_tenants(limit=500)
if not tenants:
return ""
if uname == "administrator":
for row in tenants:
tid = str((row or {}).get("id") or "").strip()
if not tid:
continue
if str((row or {}).get("name") or "").strip().lower() != "team":
continue
if store.get_user_by_username(tenant_id=tid, username="administrator"):
return tid
best_tid = ""
best_n = -1
for row in tenants:
tid = str((row or {}).get("id") or "").strip()
if not tid:
continue
user = store.get_user_by_username(tenant_id=tid, username="administrator")
if not user:
continue
uid = str(user.get("id") or "")
meta = store.get_sessions_list_meta_for_user(tenant_id=tid, user_id=uid)
n = int(meta.session_count or 0)
if n > best_n:
best_n = n
best_tid = tid
if best_tid:
return best_tid
return str((tenants[0] or {}).get("id") or "").strip()
def _resolve_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]:
token = _extract_bearer(authorization)
if not token:
@ -3751,11 +3793,6 @@ def build_admin_router() -> APIRouter:
payload = payload or {}
store = get_assistant_store()
_ensure_admin_bootstrap(store)
tenant_id = str(payload.get("tenant_id") or "").strip()
if not tenant_id:
tenants = store.list_tenants(limit=1)
if tenants:
tenant_id = str((tenants[0] or {}).get("id") or "").strip()
purpose = str(payload.get("purpose") or "console").strip().lower()
if purpose not in {"console", "chat"}:
purpose = "console"
@ -3763,6 +3800,11 @@ def build_admin_router() -> APIRouter:
username = str(payload.get("username") or "administrator").strip().lower()
else:
username = str(payload.get("username") or "").strip().lower()
tenant_id = _resolve_login_tenant_id(
store,
username=username,
tenant_id=str(payload.get("tenant_id") or "").strip(),
)
password = str(payload.get("password") or "").strip()
if not tenant_id or not password:
return {"ok": False, "error": "tenant_id, username, password are required"}

View file

@ -1528,20 +1528,28 @@ async function renderStack() {
const createChannelDispatchCard = (channel, title, initial) => {
const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert";
const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist";
const curLang = String((initial && initial.lang) || "auto").trim().toLowerCase() || "auto";
const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) =>
el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }),
));
const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist}` });
const langSel = el("select", { class: "input" }, [
el("option", { value: "auto", text: currentLang === "zh" ? "自动" : "Auto", selected: curLang === "auto" ? "selected" : undefined }),
el("option", { value: "zh", text: currentLang === "zh" ? "中文" : "Chinese", selected: curLang === "zh" ? "selected" : undefined }),
el("option", { value: "en", text: currentLang === "zh" ? "英文" : "English", selected: curLang === "en" ? "selected" : undefined }),
]);
const status = el("div", { class: "muted", text: `mode=${curMode} specialist=${curSpecialist} lang=${curLang}` });
const saveExpertBtn = el("button", {
class: "btn",
text: currentLang === "zh" ? "绑定专家" : "Bind specialist",
onclick: async () => {
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto";
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
interaction_mode: "expert",
specialist,
lang,
});
status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)}`;
status.textContent = `mode=${String(resp.interaction_mode || "expert")} specialist=${String(resp.specialist || specialist)} lang=${String(resp.lang || lang)}`;
},
});
const saveComprehensiveBtn = el("button", {
@ -1549,11 +1557,13 @@ async function renderStack() {
text: currentLang === "zh" ? "综合" : "Comprehensive",
onclick: async () => {
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto";
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
interaction_mode: "comprehensive",
specialist,
lang,
});
status.textContent = `mode=${String(resp.interaction_mode || "comprehensive")} specialist=${String(resp.specialist || specialist)}`;
status.textContent = `mode=${String(resp.interaction_mode || "comprehensive")} specialist=${String(resp.specialist || specialist)} lang=${String(resp.lang || lang)}`;
},
});
return el("div", { class: "card" }, [
@ -1561,6 +1571,8 @@ async function renderStack() {
el("div", { class: "row" }, [
el("label", { text: currentLang === "zh" ? "专家" : "Specialist" }),
specialistSel,
el("label", { text: currentLang === "zh" ? "语言" : "Lang" }),
langSel,
saveExpertBtn,
saveComprehensiveBtn,
]),

View file

@ -49,6 +49,40 @@ def _now_ms() -> int:
return int(time.time() * 1000)
def _native_reply_timeout_sec() -> float:
raw = str(os.getenv("AIA_CHANNEL_NATIVE_REPLY_TIMEOUT_SEC") or "90").strip()
try:
value = float(raw)
except ValueError:
value = 90.0
return max(10.0, min(value, 600.0))
async def _run_native_reply_usecase(payload: dict[str, Any]) -> dict[str, Any]:
timeout = _native_reply_timeout_sec()
try:
return await asyncio.wait_for(
asyncio.to_thread(_process_inbound_payload_usecase, payload),
timeout=timeout,
)
except asyncio.TimeoutError:
channel = _normalize_channel(payload.get("channel"))
chat_id = str(payload.get("chat_id") or payload.get("user_id") or "").strip()
return {
"ok": False,
"error": "native_reply_timeout",
"replies": [
{
"channel": channel,
"chat_id": chat_id,
"text": "处理超时,请稍后再试。",
"attachments": [],
"metadata": {},
}
],
}
def _normalize_channel(raw: Any) -> str:
ch = str(raw or "wechat").strip().lower()
return ch if ch else "wechat"
@ -468,7 +502,7 @@ async def weixin_native_reply(
return {"ok": False, "error": "missing user_id", "replies": []}
if not str(payload.get("account_id") or "").strip():
return {"ok": False, "error": "missing account_id", "replies": []}
out = await asyncio.to_thread(_process_inbound_payload_usecase, payload)
out = await _run_native_reply_usecase(payload)
replies = out.get("replies") if isinstance(out, dict) else []
if not isinstance(replies, list):
replies = []
@ -490,7 +524,7 @@ async def whatsapp_native_reply(
return {"ok": False, "error": "missing user_id", "replies": []}
if not str(payload.get("account_id") or "").strip():
return {"ok": False, "error": "missing account_id", "replies": []}
out = await asyncio.to_thread(_process_inbound_payload_usecase, payload)
out = await _run_native_reply_usecase(payload)
replies = out.get("replies") if isinstance(out, dict) else []
if not isinstance(replies, list):
replies = []