Files
huihuiSquare/docs/superpowers/plans/2026-08-07-avatar-takeover-chat-plan.md
T

1015 lines
31 KiB
Markdown

# 数字分身接管聊天功能 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现数字分身接管主人在会会平台上的聊天,支持立即接管和延迟接管两种模式,优先使用知识库生成回复。
**Architecture:** 在数字分身应用后端新增 takeover 模块,包含消息监听器、接管决策器、回复执行器。通过轮询会会平台推送消息接口获取未读消息,调用现有分身聊天接口生成回复,通过盒子 IM 接口以主人身份发送。
**Tech Stack:** FastAPI (Python), SQLite, Redis, HTTPX, APScheduler, Vue 3 (frontend)
## 全局约束
- 数据库:SQLite (avatar.db),使用 `_try_add_columns` 模式添加新列
- 响应格式:统一使用 `ok()` / `fail()` from `responses.py`
- 认证:`Authorization: Bearer <app_token>` header
- 盒子 IM 接口前缀:`/api/im/box/*`(通过会会平台网关)
- 签名机制:复用 `news_service.py` 中的 `_build_form` / `_make_sign` 模式
- YAGNI:本期只做单聊接管,群聊接管标记为后续扩展
- TDD:每个功能模块先写测试再实现
---
## 文件清单
### 新建文件
| 文件 | 职责 |
|------|------|
| `digital-avatar-app/backend/routers/takeover.py` | 接管配置 API + 内部处理接口 |
| `digital-avatar-app/backend/services/takeover_service.py` | 接管核心逻辑(监听、决策、回复) |
| `digital-avatar-app/backend/services/boxim_client.py` | 盒子 IM 客户端(获取凭证、发送消息) |
| `digital-avatar-app/backend/requirements.txt` | 新增 redis、apscheduler 依赖 |
| `digital-avatar-app/src/api/index.ts` | 新增 takeover 相关 API 函数 |
| `digital-avatar-app/src/views/AuthorizationManage.vue` | 添加接管开关 UI |
### 修改文件
| 文件 | 修改内容 |
|------|---------|
| `digital-avatar-app/backend/models.py` | Authorization 表新增 takeover 字段 |
| `digital-avatar-app/backend/database.py` | `_try_add_columns` 添加 takeover 列 |
| `digital-avatar-app/backend/main.py` | 注册 takeover router,启动定时任务 |
| `digital-avatar-app/backend/requirements.txt` | 添加 redis, apscheduler |
---
## 任务分解
### Task 1: 扩展 Authorization 数据模型
**Files:**
- Modify: `digital-avatar-app/backend/models.py`
- Modify: `digital-avatar-app/backend/database.py`
- Test: `digital-avatar-app/backend/tests/test_takeover_model.py`
**Interfaces:**
- 新增字段:`takeover_enabled`, `takeover_mode`, `takeover_delay_seconds`
- `Authorization.to_dict()` 返回新增字段
- [ ] **Step 1: Write the failing test**
```python
# digital-avatar-app/backend/tests/test_takeover_model.py
from database import SessionLocal
from models import Authorization
def test_authorization_takeover_fields():
db = SessionLocal()
try:
auth = db.query(Authorization).first()
assert auth is not None
# 检查新字段存在且有默认值
assert hasattr(auth, 'takeover_enabled')
assert hasattr(auth, 'takeover_mode')
assert hasattr(auth, 'takeover_delay_seconds')
assert auth.takeover_enabled == False
assert auth.takeover_mode == 'immediate'
assert auth.takeover_delay_seconds == 30
finally:
db.close()
def test_authorization_to_dict_includes_takeover():
db = SessionLocal()
try:
auth = db.query(Authorization).first()
d = auth.to_dict()
assert 'takeover_enabled' in d
assert 'takeover_mode' in d
assert 'takeover_delay_seconds' in d
finally:
db.close()
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_model.py -v
```
Expected: FAIL with "no such column: takeover_enabled"
- [ ] **Step 3: Update Authorization model**
```python
# digital-avatar-app/backend/models.py
# 在 Authorization 类中新增字段(在 created_at 之前)
class Authorization(Base):
# ... 现有字段 ...
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
takeover_mode = Column(String, default="immediate") # immediate | delayed
takeover_delay_seconds = Column(Integer, default=30) # 延迟秒数
created_at = Column(DateTime, server_default=func.now())
def to_dict(self):
return {
# ... 现有字段 ...
"takeoverEnabled": self.takeover_enabled,
"takeoverMode": self.takeover_mode,
"takeoverDelaySeconds": self.takeover_delay_seconds,
"createdAt": _iso(self.created_at),
}
```
- [ ] **Step 4: Add column migration**
```python
# digital-avatar-app/backend/database.py
# 在 _try_add_columns 调用中新增:
_try_add_columns(
# ... 现有列 ...
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"),
)
```
- [ ] **Step 5: Run test to verify it passes**
```bash
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_model.py -v
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
cd /Users/yqq/Works/AiProject/huihuiSquare
git add digital-avatar-app/backend/models.py digital-avatar-app/backend/database.py digital-avatar-app/backend/tests/test_takeover_model.py
git commit -m "feat: add takeover fields to Authorization model"
```
---
### Task 2: 盒子 IM 客户端
**Files:**
- Create: `digital-avatar-app/backend/services/boxim_client.py`
- Test: `digital-avatar-app/backend/tests/test_boxim_client.py`
**Interfaces:**
- `BoxIMClient.get_credentials(user_id: str) -> dict` — 获取网易云信凭证
- `BoxIMClient.send_p2p_message(from_accid: str, to_accid: str, content: str) -> bool` — 发送单聊消息
**Dependencies:**
- 会会平台签名机制(复用 news_service 中的模式)
- HTTPX for async HTTP calls
- 平台配置从环境变量读取
- [ ] **Step 1: Write the failing test**
```python
# digital-avatar-app/backend/tests/test_boxim_client.py
import pytest
from unittest.mock import AsyncMock, patch
from services.boxim_client import BoxIMClient
@pytest.fixture
def mock_config():
return {
"HUIHUI_IM_BASE_URL": "http://192.168.1.200:60040",
"HUIHUI_APP_ID": "test_app",
"HUIHUI_ACCESS_ID": "test_access",
"HUIHUI_ACCESS_SECRET": "test_secret",
}
@patch("services.boxim_client.httpx.AsyncClient")
@pytest.mark.asyncio
async def test_get_credentials(mock_client_class, mock_config):
mock_response = AsyncMock()
mock_response.json.return_value = {"code": 200, "data": {"accid": "user123", "token": "tok_xyz"}}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
client = BoxIMClient(mock_config)
result = await client.get_credentials("user123")
assert result["accid"] == "user123"
assert result["token"] == "tok_xyz"
@patch("services.boxim_client.httpx.AsyncClient")
@pytest.mark.asyncio
async def test_send_p2p_message_success(mock_client_class, mock_config):
mock_response = AsyncMock()
mock_response.json.return_value = {"code": 200}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
client = BoxIMClient(mock_config)
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
assert result is True
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd digital-avatar-app/backend
python -m pytest tests/test_boxim_client.py -v
```
Expected: FAIL with "ModuleNotFoundError: No module named 'services.boxim_client'"
- [ ] **Step 3: Write minimal implementation**
```python
# digital-avatar-app/backend/services/boxim_client.py
"""盒子 IM 客户端 — 封装网易云信 IM 接口调用"""
import hashlib
import random
import string
from datetime import datetime
from typing import Optional
import httpx
class BoxIMClient:
"""盒子 IM 客户端,通过会会平台网关调用网易云信 IM"""
def __init__(self, config: dict):
self.base_url = config.get("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040")
self.app_id = config.get("HUIHUI_APP_ID", "")
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
def _build_sign_params(self, extra: dict) -> dict:
"""构建带签名的请求参数"""
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
timestamp = datetime.now().strftime("%Y%m%d%H%M%S") # 24小时制
params = {
"appId": self.app_id,
"accessId": self.access_id,
"nonce": nonce,
"timestamp": timestamp,
**extra,
}
# 计算签名
keys = sorted(params.keys())
sign_parts = []
for k in keys:
v = params.get(k)
if v and v != "" and v != []:
sign_parts.append(f"{k}={v}")
sign_str = "&".join(sign_parts) + f"&accessSecret={self.access_secret}"
signature = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
params["signature"] = signature
params["signType"] = "MD5"
params["signVersion"] = "1.0"
return params
async def get_credentials(self, user_id: str) -> Optional[dict]:
"""获取用户的网易云信 IM 凭证 (accid, token)"""
params = self._build_sign_params({"userId": user_id})
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base_url}/box/netease",
params=params,
)
data = r.json()
if data.get("code") in (0, 200):
return data.get("data", {})
return None
async def send_p2p_message(
self, from_accid: str, to_accid: str, content: str
) -> bool:
"""发送单聊消息(文本)"""
params = self._build_sign_params({
"from": from_accid,
"to": to_accid,
"msgType": "text",
"content": content,
})
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base_url}/box/message/send/p2p",
params=params,
)
data = r.json()
return data.get("code") in (0, 200)
```
- [ ] **Step 4: Add test dependencies**
```bash
cd digital-avatar-app/backend
pip install pytest pytest-asyncio httpx
```
- [ ] **Step 5: Run test to verify it passes**
```bash
cd digital-avatar-app/backend
python -m pytest tests/test_boxim_client.py -v
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add digital-avatar-app/backend/services/boxim_client.py digital-avatar-app/backend/tests/test_boxim_client.py
git commit -m "feat: add Box IM client for Netease Yunxin integration"
```
---
### Task 3: 接管服务核心逻辑
**Files:**
- Create: `digital-avatar-app/backend/services/takeover_service.py`
- Test: `digital-avatar-app/backend/tests/test_takeover_service.py`
**Interfaces:**
- `TakeoverService.check_takeover_enabled(owner_huihui_id: str, from_user_id: str) -> Authorization | None`
- `TakeoverService.generate_reply(avatar_id: str, message: str) -> str`
- `TakeoverService.execute_takeover(auth: Authorization, message: dict) -> bool`
- `TakeoverService.process_delayed_queue()` — 处理延迟队列中到期的消息
**Dependencies:**
- `BoxIMClient` from Task 2
- Existing chat API: `POST /api/avatar/{id}/chat`
- Redis for delayed queue
- `Authorization` model from Task 1
- [ ] **Step 1: Write the failing test**
```python
# digital-avatar-app/backend/tests/test_takeover_service.py
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from services.takeover_service import TakeoverService
@pytest.fixture
def mock_db():
db = MagicMock()
return db
@pytest.fixture
def mock_boxim():
client = AsyncMock()
client.get_credentials.return_value = {"accid": "owner_acc", "token": "tok"}
client.send_p2p_message.return_value = True
return client
@pytest.fixture
def mock_auth():
auth = MagicMock()
auth.takeover_enabled = True
auth.takeover_mode = "immediate"
auth.takeover_delay_seconds = 30
auth.avatar_id = "avatar_123"
auth.target_id = "target_user_123"
return auth
def test_check_takeover_enabled_returns_auth_when_enabled(mock_db, mock_auth):
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_auth
service = TakeoverService(mock_db, None)
result = service.check_takeover_enabled("owner_123", "target_123")
assert result == mock_auth
def test_check_takeover_enabled_returns_none_when_disabled(mock_db):
disabled_auth = MagicMock()
disabled_auth.takeover_enabled = False
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = disabled_auth
service = TakeoverService(mock_db, None)
result = service.check_takeover_enabled("owner_123", "target_123")
assert result is None
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_service.py -v
```
Expected: FAIL
- [ ] **Step 3: Write minimal implementation**
```python
# digital-avatar-app/backend/services/takeover_service.py
"""分身接管聊天服务 — 消息监听、决策、回复执行"""
import json
import logging
from typing import Optional
from sqlalchemy.orm import Session
import httpx
from models import Authorization, Avatar
from services.boxim_client import BoxIMClient
logger = logging.getLogger(__name__)
class TakeoverService:
"""分身接管服务"""
def __init__(self, db: Session, boxim_client: BoxIMClient, redis_client=None):
self.db = db
self.boxim = boxim_client
self.redis = redis_client
self._chat_api_base = "http://localhost:8000/api" # 分身应用自己的 API
def check_takeover_enabled(
self, owner_huihui_id: str, from_user_id: str
) -> Optional[Authorization]:
"""检查是否有接管授权"""
auth = (
self.db.query(Authorization)
.filter(Authorization.target_id == from_user_id)
.filter(Authorization.takeover_enabled == True)
.first()
)
if auth and auth.takeover_enabled:
return auth
return None
async def generate_reply(self, avatar_id: str, message: str) -> str:
"""调用分身聊天接口生成回复"""
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{self._chat_api_base}/avatar/{avatar_id}/chat",
json={"message": message, "history": []},
)
data = r.json()
if data.get("code") in (0, 200):
return data.get("data", {}).get("answer", "")
logger.warning(f"分身聊天接口返回异常: {data}")
return ""
except Exception as e:
logger.error(f"调用分身聊天接口失败: {e}")
return ""
async def execute_takeover(self, auth: Authorization, message: dict) -> bool:
"""执行接管:生成回复并以主人身份发送"""
try:
# 1. 获取主人的 IM 凭证
owner_huihui_id = auth.owner_id if hasattr(auth, 'owner_id') else ""
credentials = await self.boxim.get_credentials(owner_huihui_id)
if not credentials:
logger.warning(f"无法获取主人 IM 凭证: {owner_huihui_id}")
return False
# 2. 生成回复
reply = await self.generate_reply(auth.avatar_id, message.get("content", ""))
if not reply:
logger.warning("分身未生成回复")
return False
# 3. 发送消息
success = await self.boxim.send_p2p_message(
from_accid=credentials["accid"],
to_accid=message.get("from_accid", ""),
content=reply,
)
if success:
logger.info(f"分身接管回复成功: {reply[:50]}...")
return success
except Exception as e:
logger.error(f"执行接管失败: {e}")
return False
def enqueue_delayed_message(self, auth: Authorization, message: dict):
"""将消息写入 Redis 延迟队列"""
if not self.redis:
logger.warning("Redis 未配置,降级为立即接管")
return
key = f"takeover:delayed:{auth.target_id}:{message.get('msg_id', '')}"
value = json.dumps({
"avatar_id": auth.avatar_id,
"from_accid": message.get("from_accid", ""),
"content": message.get("content", ""),
"owner_huihui_id": auth.owner_id if hasattr(auth, 'owner_id') else "",
})
self.redis.setex(key, auth.takeover_delay_seconds + 10, value)
logger.info(f"消息写入延迟队列: {key}")
async def process_delayed_queue(self):
"""处理延迟队列中到期的消息"""
if not self.redis:
return
# Redis 会自动过期,这里不需要主动处理
# 实际使用时可以用 Redis keyspace notifications 或定期扫描
pass
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_service.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add digital-avatar-app/backend/services/takeover_service.py digital-avatar-app/backend/tests/test_takeover_service.py
git commit -m "feat: add takeover service core logic"
```
---
### Task 4: 接管配置 API
**Files:**
- Create: `digital-avatar-app/backend/routers/takeover.py`
- Modify: `digital-avatar-app/backend/main.py`
**Interfaces:**
- `PUT /api/avatar/{avatar_id}/authorizations/takeover` — 更新接管配置
- 请求体:`{ authorization_id, takeover_enabled, takeover_mode, takeover_delay_seconds }`
**Dependencies:**
- `Authorization` model from Task 1
- `ok()` / `fail()` from `responses.py`
- [ ] **Step 1: Write the failing test**
```python
# digital-avatar-app/backend/tests/test_takeover_api.py
from fastapi.testclient import TestClient
from main import app
from database import SessionLocal, Base, engine
from models import Authorization, Avatar
def setup_test_db():
Base.metadata.create_all(bind=engine)
db = SessionLocal()
avatar = Avatar(name="test", status="active", config={})
db.add(avatar)
db.commit()
auth = Authorization(avatar_id=avatar.id, target_id="user1", target_name="测试用户")
db.add(auth)
db.commit()
return db, auth.id
def test_update_takeover_config():
db, auth_id = setup_test_db()
client = TestClient(app)
response = client.put(
f"/api/avatar/test_auth/avatar_id/authorizations/takeover",
json={
"authorization_id": auth_id,
"takeover_enabled": True,
"takeover_mode": "delayed",
"takeover_delay_seconds": 60,
},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
# 验证数据库已更新
auth = db.query(Authorization).filter(Authorization.id == auth_id).first()
assert auth.takeover_enabled == True
assert auth.takeover_mode == "delayed"
assert auth.takeover_delay_seconds == 60
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_api.py -v
```
Expected: FAIL with 404 (endpoint not found)
- [ ] **Step 3: Write minimal implementation**
```python
# digital-avatar-app/backend/routers/takeover.py
"""分身接管配置 API"""
from fastapi import APIRouter, Depends, Body
from sqlalchemy.orm import Session
from database import get_db
from models import Authorization
from responses import ok, fail
router = APIRouter(tags=["分身接管"])
@router.put("/avatar/{avatar_id}/authorizations/takeover")
def update_takeover_config(
avatar_id: str,
payload: dict = Body(...),
db: Session = Depends(get_db),
):
"""更新分身接管配置"""
auth_id = payload.get("authorization_id")
if not auth_id:
return fail("缺少 authorization_id", 400)
auth = db.query(Authorization).filter(Authorization.id == auth_id).first()
if not auth:
return fail("授权不存在", 404)
# 更新接管配置
if "takeover_enabled" in payload:
auth.takeover_enabled = payload["takeover_enabled"]
if "takeover_mode" in payload:
mode = payload["takeover_mode"]
if mode not in ("immediate", "delayed"):
return fail("takeover_mode 必须是 immediate 或 delayed", 400)
auth.takeover_mode = mode
if "takeover_delay_seconds" in payload:
delay = payload["takeover_delay_seconds"]
if not isinstance(delay, int) or delay < 5:
return fail("takeover_delay_seconds 必须 >= 5", 400)
auth.takeover_delay_seconds = delay
db.commit()
return ok(auth.to_dict())
```
- [ ] **Step 4: Register router in main.py**
```python
# digital-avatar-app/backend/main.py
# 在 import 部分添加:
import routers.takeover
# 在 router 注册部分添加:
app.include_router(routers.takeover.router, prefix="/api")
```
- [ ] **Step 5: Run test to verify it passes**
```bash
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_api.py -v
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add digital-avatar-app/backend/routers/takeover.py digital-avatar-app/backend/main.py digital-avatar-app/backend/tests/test_takeover_api.py
git commit -m "feat: add takeover config API endpoint"
```
---
### Task 5: 定时消息监听任务
**Files:**
- Modify: `digital-avatar-app/backend/main.py`
- Modify: `digital-avatar-app/backend/requirements.txt`
**Interfaces:**
- APScheduler 定时任务:每 10 秒轮询一次未读消息
- 调用 `TakeoverService.check_takeover_enabled` 判断是否接管
- 根据模式调用立即执行或延迟队列
**Dependencies:**
- `TakeoverService` from Task 3
- `BoxIMClient` from Task 2
- `APScheduler` for scheduling
- `redis` for delayed queue
- [ ] **Step 1: Add dependencies**
```
# digital-avatar-app/backend/requirements.txt
# 新增:
redis>=5.0
apscheduler>=3.10
```
- [ ] **Step 2: Add scheduler setup in main.py**
```python
# digital-avatar-app/backend/main.py
# 在 import 部分添加:
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
import redis as redis_lib
import os
# 在 on_startup 函数中添加:
@app.on_event("startup")
def on_startup():
init_db()
seed()
# 初始化 Redis(可选)
redis_client = None
redis_url = os.getenv("REDIS_URL", "")
if redis_url:
try:
redis_client = redis_lib.from_url(redis_url)
redis_client.ping()
except Exception:
logger.warning("Redis 连接失败,延迟接管将降级为立即接管")
# 初始化 Box IM 客户端
boxim_config = {
"HUIHUI_IM_BASE_URL": os.getenv("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040"),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
}
boxim_client = BoxIMClient(boxim_config)
# 初始化接管服务
from services.takeover_service import TakeoverService
takeover_service = TakeoverService(SessionLocal(), boxim_client, redis_client)
# 启动定时任务
scheduler = BackgroundScheduler()
scheduler.add_job(
takeover_service.poll_and_process_messages,
trigger=IntervalTrigger(seconds=10),
id="takeover_message_poll",
)
scheduler.start()
```
- [ ] **Step 3: Add poll method to TakeoverService**
```python
# digital-avatar-app/backend/services/takeover_service.py
# 在 TakeoverService 类中新增:
async def poll_and_process_messages(self):
"""定时轮询未读消息并处理"""
# 这里简化处理,实际需要调用会会平台的消息推送接口
# 伪代码:
# messages = await self.fetch_unread_messages()
# for msg in messages:
# await self.process_message(msg)
pass
async def process_message(self, message: dict):
"""处理单条消息"""
owner_id = message.get("owner_huihui_id", "")
from_id = message.get("from_accid", "")
auth = self.check_takeover_enabled(owner_id, from_id)
if not auth:
return
if auth.takeover_mode == "immediate":
await self.execute_takeover(auth, message)
else:
self.enqueue_delayed_message(auth, message)
```
- [ ] **Step 4: Commit**
```bash
git add digital-avatar-app/backend/main.py digital-avatar-app/backend/requirements.txt digital-avatar-app/backend/services/takeover_service.py
git commit -m "feat: add scheduled message polling for takeover"
```
---
### Task 6: 前端授权管理 UI 增强
**Files:**
- Modify: `digital-avatar-app/src/views/AuthorizationManage.vue`
- Modify: `digital-avatar-app/src/api/index.ts`
**Interfaces:**
- 授权卡片新增接管开关和配置
- 开关开启时显示模式选择和延迟秒数输入
- 调用 `PUT /api/avatar/{id}/authorizations/takeover` 保存配置
**Dependencies:**
- Existing authorization API functions
- Takeover config API from Task 4
- [ ] **Step 1: Add API function**
```typescript
// digital-avatar-app/src/api/index.ts
// 新增:
export const updateTakeoverConfig = (avatarId: string, data: {
authorizationId: string
takeoverEnabled: boolean
takeoverMode?: string
takeoverDelaySeconds?: number
}) => {
return api.put(`/api/avatar/${avatarId}/authorizations/takeover`, data)
}
```
- [ ] **Step 2: Update AuthorizationManage.vue**
```vue
<!-- digital-avatar-app/src/views/AuthorizationManage.vue -->
<!-- 在授权卡片的权限标签后添加接管配置 -->
<div v-if="auth.permissions?.includes('takeover') || auth.takeoverEnabled" class="takeover-config">
<div class="takeover-header">
<span class="takeover-label">分身接管聊天</span>
<button class="toggle-btn" :class="{ active: auth.takeoverEnabled }" @click="toggleTakeover(auth)">
<span class="toggle-dot"></span>
</button>
</div>
<div v-if="auth.takeoverEnabled" class="takeover-options">
<div class="option-row">
<label>接管模式</label>
<select v-model="auth.takeoverMode" @change="saveTakeoverConfig(auth)">
<option value="immediate">立即接管</option>
<option value="delayed">延迟接管</option>
</select>
</div>
<div v-if="auth.takeoverMode === 'delayed'" class="option-row">
<label>延迟秒数</label>
<input type="number" v-model.number="auth.takeoverDelaySeconds"
min="5" max="300" @change="saveTakeoverConfig(auth)" />
</div>
</div>
</div>
```
- [ ] **Step 3: Add methods to AuthorizationManage.vue**
```vue
<script setup lang="ts">
// 新增 import
import { updateTakeoverConfig } from '@/api'
// 新增方法
const toggleTakeover = async (auth: any) => {
auth.takeoverEnabled = !auth.takeoverEnabled
await saveTakeoverConfig(auth)
}
const saveTakeoverConfig = async (auth: any) => {
try {
await updateTakeoverConfig(currentAvatarId.value, {
authorizationId: auth.id,
takeoverEnabled: auth.takeoverEnabled,
takeoverMode: auth.takeoverMode || 'immediate',
takeoverDelaySeconds: auth.takeoverDelaySeconds || 30,
})
} catch (e) {
console.error('保存接管配置失败', e)
}
}
</script>
```
- [ ] **Step 4: Add styles**
```vue
<style scoped>
.takeover-config {
margin-top: 12px;
padding: 10px;
background: rgba(88, 166, 255, 0.05);
border-radius: 8px;
}
.takeover-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.takeover-label {
font-size: 13px;
font-weight: 600;
}
.takeover-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.option-row label {
width: 70px;
flex-shrink: 0;
color: var(--color-text-muted);
}
.option-row select, .option-row input {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text);
}
</style>
```
- [ ] **Step 5: Commit**
```bash
git add digital-avatar-app/src/views/AuthorizationManage.vue digital-avatar-app/src/api/index.ts
git commit -m "feat: add takeover config UI to authorization management"
```
---
### Task 7: 集成测试和部署
**Files:**
- Modify: `docker-compose.yml` (digital-avatar-app)
- Create: `digital-avatar-app/.env.example`
**Interfaces:**
- 添加 Redis 容器(可选,用于延迟队列)
- 添加环境变量配置
- [ ] **Step 1: Add Redis to docker-compose**
```yaml
# digital-avatar-app/docker-compose.yml
services:
avatar-backend:
# ... 现有配置 ...
environment:
- REDIS_URL=redis://avatar-redis:6379
- HUIHUI_IM_BASE_URL=http://192.168.1.200:60040
- HUIHUI_APP_ID=${HUIHUI_APP_ID}
- HUIHUI_ACCESS_ID=${HUIHUI_ACCESS_ID}
- HUIHUI_ACCESS_SECRET=${HUIHUI_ACCESS_SECRET}
depends_on:
- avatar-redis
avatar-redis:
image: redis:7-alpine
container_name: avatar-redis
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- avatar-net
```
- [ ] **Step 2: Create .env.example**
```
# digital-avatar-app/.env.example
HUIHUI_IM_BASE_URL=http://192.168.1.200:60040
HUIHUI_APP_ID=your_app_id
HUIHUI_ACCESS_ID=your_access_id
HUIHUI_ACCESS_SECRET=your_access_secret
REDIS_URL=redis://avatar-redis:6379
```
- [ ] **Step 3: Manual testing checklist**
```markdown
## 测试清单
### 功能测试
- [ ] 授权管理页面显示接管开关
- [ ] 开关开启后显示模式选择
- [ ] 延迟模式显示延迟秒数输入
- [ ] 保存配置后刷新页面配置保留
- [ ] 立即接管:模拟消息到达后分身立即回复
- [ ] 延迟接管:N 秒内主人回复则不分身接管
- [ ] 延迟接管:超时后分身自动回复
### 边界测试
- [ ] 主人有多个分身时使用第一个 active 分身
- [ ] 知识库为空时使用 Qwen 兜底
- [ ] IM 接口异常时记录日志不崩溃
- [ ] Redis 不可用时降级为立即接管
### 性能测试
- [ ] 轮询间隔 10 秒时 CPU 占用正常
- [ ] 延迟队列消息过期自动清理
```
- [ ] **Step 4: Commit**
```bash
git add digital-avatar-app/docker-compose.yml digital-avatar-app/.env.example
git commit -m "feat: add Redis for takeover delayed queue and env config"
```
---
## 执行顺序
1. Task 1 → Task 2 → Task 3 → Task 4 → Task 5 → Task 6 → Task 7
2. 每个 Task 完成后运行测试验证
3. Task 5 和 Task 6 可以并行(后端定时任务和前端 UI 无依赖)
## 风险与缓解
| 风险 | 缓解措施 |
|------|---------|
| 会会平台 IM 接口文档不完整 | 先做接口探索,用 Postman 手动验证 |
| 盒子 IM 替换云信的 API 差异 | 保留云信客户端作为 fallback |
| Redis 延迟队列复杂性 | 第一期可简化为内存队列,Redis 作为优化 |
| 分身聊天接口响应慢 | 设置超时,失败时不阻塞轮询 |