fix(avatar): fail closed on BOXIM connection errors

This commit is contained in:
stefanfeng
2026-08-20 14:47:13 +08:00
parent 08590bf9ea
commit 51a317ccd9
7 changed files with 173 additions and 17 deletions
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import Authorization from models import Authorization, TakeoverCursor, TakeoverReplyTask
from responses import fail, ok from responses import fail, ok
from routers.avatars import _require_owned_avatar from routers.avatars import _require_owned_avatar
@@ -151,10 +151,32 @@ def update_permission_settings(
except ValueError as exc: except ValueError as exc:
return fail(str(exc), 400) return fail(str(exc), 400)
previous_permissions = _stored_avatar_permissions(avatar)
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
AVATAR_PERMISSION_KEY: permissions, AVATAR_PERMISSION_KEY: permissions,
} }
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
if cursor and "takeover" in permissions and "takeover" not in previous_permissions:
cursor.initialized = False
cursor.last_message_id = "0"
cursor.last_error = ""
elif cursor and "takeover" not in permissions:
cursor.last_error = ""
if "takeover" not in permissions:
tasks = (
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.avatar_id == avatar.id,
TakeoverReplyTask.status.in_(("pending", "generating", "ready", "sending")),
)
.all()
)
for task in tasks:
task.status = "cancelled"
task.cancel_reason = "takeover_disabled"
task.locked_at = None
db.commit() db.commit()
db.refresh(avatar) db.refresh(avatar)
return ok(_permission_settings_payload(avatar), "授权设置已保存") return ok(_permission_settings_payload(avatar), "授权设置已保存")
@@ -34,12 +34,12 @@ def get_takeover_status(
.count() .count()
) )
if not enabled: if cursor and cursor.last_error:
status, message = "error", cursor.last_error
elif not enabled:
status, message = "disabled", "主动接管未开启" status, message = "disabled", "主动接管未开启"
elif not user or not user.huihui_token: elif not user or not user.huihui_token:
status, message = "needs_login", "请重新登录会会生产账号以连接 BOXIM" status, message = "needs_login", "请重新登录会会生产账号以连接 BOXIM"
elif cursor and cursor.last_error:
status, message = "error", cursor.last_error
elif ( elif (
cursor cursor
and cursor.initialized and cursor.initialized
@@ -97,7 +97,18 @@ class BoxIMClient:
payload.get("message") or "BOXIM 授权失败", payload.get("message") or "BOXIM 授权失败",
code=code or response.status_code, code=code or response.status_code,
auth_error=response.status_code in (400, 401, 403) auth_error=response.status_code in (400, 401, 403)
or code in (400, 401, 40100, 403, "400", "401", "40100", "403"), or code in (
400,
401,
40100,
40101,
403,
"400",
"401",
"40100",
"40101",
"403",
),
) )
if not data.get("accessToken"): if not data.get("accessToken"):
raise BoxIMError("会会未返回 BOXIM 访问凭证", auth_error=True) raise BoxIMError("会会未返回 BOXIM 访问凭证", auth_error=True)
@@ -128,7 +139,7 @@ class BoxIMClient:
payload.get("message") or "BOXIM 请求失败", payload.get("message") or "BOXIM 请求失败",
code=code or response.status_code, code=code or response.status_code,
auth_error=response.status_code in (400, 401, 403) auth_error=response.status_code in (400, 401, 403)
or code in (400, 401, 403, "400", "401", "403"), or code in (400, 401, 40100, 40101, 403, "400", "401", "40100", "40101", "403"),
) )
return payload.get("data") return payload.get("data")
@@ -191,6 +191,37 @@ class TakeoverService:
def _forget_boxim_session(self, user_id: str): def _forget_boxim_session(self, user_id: str):
self._sessions.pop(user_id, None) self._sessions.pop(user_id, None)
def _disable_after_connection_failure(
self,
db: Session,
avatar: Avatar,
cursor: TakeoverCursor,
message: str,
):
permissions = (avatar.config or {}).get("authorizationPermissions", [])
avatar.config = {
**(avatar.config or {}),
"authorizationPermissions": [
permission
for permission in permissions
if permission != TAKEOVER_PERMISSION
],
}
cursor.last_error = message
cursor.last_polled_at = self.now()
tasks = (
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.avatar_id == avatar.id,
TakeoverReplyTask.status.in_(ACTIVE_TASK_STATUSES),
)
.all()
)
for task in tasks:
task.status = "cancelled"
task.cancel_reason = "connection_failed"
task.locked_at = None
async def _sync_avatar(self, avatar_id: str) -> bool: async def _sync_avatar(self, avatar_id: str) -> bool:
db = self.session_factory() db = self.session_factory()
try: try:
@@ -204,8 +235,12 @@ class TakeoverService:
db.add(cursor) db.add(cursor)
db.flush() db.flush()
if not user or not user.huihui_token: if not user or not user.huihui_token:
cursor.last_error = "请重新登录会会生产账号后再开启主动接管" self._disable_after_connection_failure(
cursor.last_polled_at = self.now() db,
avatar,
cursor,
"请重新登录会会生产账号后再开启主动接管",
)
db.commit() db.commit()
return False return False
@@ -222,10 +257,10 @@ class TakeoverService:
except Exception as exc: except Exception as exc:
if isinstance(exc, BoxIMError) and exc.auth_error: if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id) self._forget_boxim_session(user.id)
cursor.last_error = "BOXIM 授权已失效,请重新登录会会生产账号" message = "BOXIM 授权已失效,请重新登录会会生产账号"
else: else:
cursor.last_error = f"BOXIM 暂时连接失败:{str(exc)[:160]}" message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
cursor.last_polled_at = self.now() self._disable_after_connection_failure(db, avatar, cursor, message)
db.commit() db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc) logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc)
return False return False
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
from database import SessionLocal from database import SessionLocal
from main import app from main import app
from models import Authorization, TakeoverCursor, TakeoverReplyTask, User from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask, User
client = TestClient(app) client = TestClient(app)
@@ -203,3 +203,29 @@ def test_takeover_status_reports_ready_pending_count_and_errors(authorization_co
failed = client.get(endpoint, headers=context["owner_headers"]).json()["data"] failed = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
assert failed["status"] == "error" assert failed["status"] == "error"
assert failed["message"] == "BOXIM 暂时不可用" assert failed["message"] == "BOXIM 暂时不可用"
db = SessionLocal()
try:
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).one()
avatar.config = {"authorizationPermissions": ["chat"]}
db.commit()
finally:
db.close()
auto_disabled = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
assert auto_disabled["enabled"] is False
assert auto_disabled["status"] == "error"
client.put(
f"/api/avatar/{avatar_id}/permission-settings",
headers=context["owner_headers"],
json={"permissions": ["chat", "takeover"]},
)
db = SessionLocal()
try:
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
assert cursor.initialized is False
assert cursor.last_message_id == "0"
assert cursor.last_error == ""
finally:
db.close()
@@ -1,7 +1,7 @@
"""End-to-end service tests for BOXIM takeover timing and human priority.""" """End-to-end service tests for BOXIM takeover timing and human priority."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -10,6 +10,7 @@ from sqlalchemy.pool import StaticPool
from database import Base from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from services.boxim_client import BoxIMError
from services.takeover_service import TakeoverService, _plain_text_reply from services.takeover_service import TakeoverService, _plain_text_reply
@@ -192,5 +193,28 @@ async def test_quick_successive_messages_are_coalesced_into_one_reply(service_co
db.close() db.close()
@pytest.mark.asyncio
async def test_connection_failure_disables_takeover_and_stops_retrying(service_context):
session_factory, service, boxim, _ = service_context
boxim.exchange_access_token = AsyncMock(
side_effect=BoxIMError("无效的访问令牌", code=40101, auth_error=True)
)
await service.poll_and_process_messages()
await service.poll_and_process_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" not in avatar.config["authorizationPermissions"]
assert cursor.initialized is False
assert "重新登录" in cursor.last_error
assert db.query(TakeoverReplyTask).count() == 0
finally:
db.close()
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
def test_plain_text_reply_removes_markdown_and_empty_lines(): def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医" assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
@@ -63,7 +63,14 @@
<span class="permission-copy"> <span class="permission-copy">
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<small>{{ item.key === 'takeover' ? takeoverDescription : item.description }}</small> <small>
{{ item.description }}
<span
v-if="item.key === 'takeover' && takeoverConnectionLabel"
class="connection-state"
:class="takeoverConnectionTone"
> · {{ takeoverConnectionLabel }}</span>
</small>
</span> </span>
<span class="permission-switch" :class="{ active: permissionState[item.key] }" aria-hidden="true"> <span class="permission-switch" :class="{ active: permissionState[item.key] }" aria-hidden="true">
@@ -179,9 +186,21 @@ const takeoverStatus = ref<TakeoverStatus | null>(null)
let toastTimer: number | undefined let toastTimer: number | undefined
let takeoverStatusTimer: number | undefined let takeoverStatusTimer: number | undefined
const takeoverDescription = computed(() => { const takeoverConnectionLabel = computed(() => {
if (!permissionState.takeover) return '收到私聊消息 3 秒后回复,主人发言时暂停' if (takeoverStatus.value?.status === 'error' || takeoverStatus.value?.status === 'needs_login') {
return takeoverStatus.value?.message || '正在连接 BOXIM' return '连接失败'
}
if (takeoverStatus.value?.status === 'ready') return '已连接'
if (permissionState.takeover) return '正在连接'
return ''
})
const takeoverConnectionTone = computed(() => {
if (takeoverStatus.value?.status === 'ready') return 'ready'
if (takeoverStatus.value?.status === 'error' || takeoverStatus.value?.status === 'needs_login') {
return 'failed'
}
return 'connecting'
}) })
const setPermissions = (permissions: AvatarPermission[]) => { const setPermissions = (permissions: AvatarPermission[]) => {
@@ -249,6 +268,9 @@ const loadTakeoverStatus = async () => {
if (!activeAvatarId.value) return if (!activeAvatarId.value) return
try { try {
takeoverStatus.value = await getTakeoverStatus(activeAvatarId.value) takeoverStatus.value = await getTakeoverStatus(activeAvatarId.value)
if (!takeoverStatus.value.enabled && permissionState.takeover) {
permissionState.takeover = false
}
} catch { } catch {
takeoverStatus.value = null takeoverStatus.value = null
} }
@@ -476,6 +498,22 @@ svg {
display: block; display: block;
} }
.connection-state {
font-weight: 600;
}
.connection-state.connecting {
color: #d78532;
}
.connection-state.ready {
color: #31966b;
}
.connection-state.failed {
color: #d95849;
}
.permission-copy strong { .permission-copy strong {
color: #26272b; color: #26272b;
font-size: 14px; font-size: 14px;