Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c6419f37e | ||
|
|
730f586784 | ||
|
|
ef2b1c6dd6 | ||
|
|
c693899b12 |
@@ -201,13 +201,20 @@ 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(
|
def _record_connection_failure(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
avatar: Avatar,
|
avatar: Avatar,
|
||||||
cursor: TakeoverCursor,
|
cursor: TakeoverCursor,
|
||||||
message: str,
|
message: str,
|
||||||
|
*,
|
||||||
|
disable_takeover: bool,
|
||||||
):
|
):
|
||||||
|
cursor.last_error = message
|
||||||
|
cursor.last_polled_at = self.now()
|
||||||
|
if not disable_takeover:
|
||||||
|
return
|
||||||
|
|
||||||
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
||||||
avatar.config = {
|
avatar.config = {
|
||||||
**(avatar.config or {}),
|
**(avatar.config or {}),
|
||||||
@@ -217,8 +224,6 @@ class TakeoverService:
|
|||||||
if permission != TAKEOVER_PERMISSION
|
if permission != TAKEOVER_PERMISSION
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
cursor.last_error = message
|
|
||||||
cursor.last_polled_at = self.now()
|
|
||||||
tasks = (
|
tasks = (
|
||||||
db.query(TakeoverReplyTask)
|
db.query(TakeoverReplyTask)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -245,11 +250,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:
|
||||||
self._disable_after_connection_failure(
|
self._record_connection_failure(
|
||||||
db,
|
db,
|
||||||
avatar,
|
avatar,
|
||||||
cursor,
|
cursor,
|
||||||
"请重新登录会会生产账号后再开启主动接管",
|
"请重新登录会会生产账号后再开启主动接管",
|
||||||
|
disable_takeover=True,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return False
|
return False
|
||||||
@@ -268,11 +274,24 @@ class TakeoverService:
|
|||||||
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)
|
||||||
message = "BOXIM 授权已失效,请重新登录会会生产账号"
|
message = "BOXIM 授权已失效,请重新登录会会生产账号"
|
||||||
|
disable_takeover = True
|
||||||
else:
|
else:
|
||||||
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
|
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
|
||||||
self._disable_after_connection_failure(db, avatar, cursor, message)
|
disable_takeover = False
|
||||||
|
self._record_connection_failure(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
cursor,
|
||||||
|
message,
|
||||||
|
disable_takeover=disable_takeover,
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc)
|
logger.warning(
|
||||||
|
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
|
||||||
|
avatar.id,
|
||||||
|
not disable_takeover,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
|
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
|
||||||
|
|||||||
@@ -291,5 +291,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
|
|||||||
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
|
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
|
||||||
|
session_factory, service, boxim, _ = service_context
|
||||||
|
boxim.exchange_access_token = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
BoxIMError("连接超时"),
|
||||||
|
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
avatar = db.query(Avatar).one()
|
||||||
|
cursor = db.query(TakeoverCursor).one()
|
||||||
|
assert "takeover" in avatar.config["authorizationPermissions"]
|
||||||
|
assert cursor.initialized is False
|
||||||
|
assert "暂时连接失败" in cursor.last_error
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
avatar = db.query(Avatar).one()
|
||||||
|
cursor = db.query(TakeoverCursor).one()
|
||||||
|
assert "takeover" in avatar.config["authorizationPermissions"]
|
||||||
|
assert cursor.initialized is True
|
||||||
|
assert cursor.last_error == ""
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
assert boxim.exchange_access_token.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
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必要时就医"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
生产会会在用户已登录后打开以下地址:
|
生产会会在用户已登录后打开以下地址:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://<数字分身生产域名>/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
|
https://digital.99hui.com/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
|
||||||
```
|
```
|
||||||
|
|
||||||
测试环境示例:
|
测试环境示例:
|
||||||
@@ -77,6 +77,8 @@ curl -fsS http://127.0.0.1:8099/api/health
|
|||||||
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
|
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
|
server_name digital.99hui.com;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
@@ -106,6 +108,7 @@ location /api/ {
|
|||||||
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。
|
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。
|
||||||
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
|
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
|
||||||
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
|
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
|
||||||
|
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
|
||||||
|
|
||||||
## 6. 回滚
|
## 6. 回滚
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
/>
|
/>
|
||||||
<title>会会数字分身</title>
|
<title>会会数字分身</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
|
<!-- digital.99hui.com 同域部署保持 apiBase 为空,接口使用 /api。
|
||||||
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
|
仅跨域部署时才显式配置后端公网地址。 -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.__APP_CONFIG__ = { apiBase: '' }
|
window.__APP_CONFIG__ = { apiBase: '' }
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
8f54097b01d5b0433f8aa5ea70ca930b
|
||||||
@@ -9,8 +9,8 @@ interface ApiClient {
|
|||||||
|
|
||||||
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
|
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
|
||||||
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
|
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
|
||||||
// - web-view 内(混合架构):需配置为后端公网地址,例如 'https://geo.99hui.com/api'
|
// - digital.99hui.com 同域部署:保持 '/api'
|
||||||
// - 同域部署的构建产物:可保持 '/api'
|
// - 仅跨域部署时配置后端公网地址
|
||||||
function resolveBaseURL(): string {
|
function resolveBaseURL(): string {
|
||||||
const cfg = (window as any).__APP_CONFIG__
|
const cfg = (window as any).__APP_CONFIG__
|
||||||
if (cfg && cfg.apiBase) return cfg.apiBase as string
|
if (cfg && cfg.apiBase) return cfg.apiBase as string
|
||||||
|
|||||||
Reference in New Issue
Block a user