Compare commits

..
Author SHA1 Message Date
stefanfeng ef2b1c6dd6 chore(avatar): add WeChat verification file 2026-08-26 09:51:13 +08:00
stefanfeng c693899b12 docs(avatar): set production H5 domain 2026-08-26 09:44:22 +08:00
stefanfeng 5d19992f00 fix(avatar): remove SSO token from router state 2026-08-26 09:25:07 +08:00
stefanfeng 81aec1c63a feat(avatar): support production H5 token SSO 2026-08-26 09:17:41 +08:00
stefanfeng 4029c31ed7 fix(avatar): bundle uni bridge and add favicon 2026-08-25 17:19:30 +08:00
stefanfeng e24e89d326 fix(deploy): serialize model migration and pin nginx 2026-08-25 16:59:15 +08:00
stefanfeng dc34a03357 feat(ai): add dedicated digital avatar model config 2026-08-25 16:50:33 +08:00
stefanfeng 3f7ff9329a fix(avatar): align QA cards to the left 2026-08-25 15:36:49 +08:00
stefanfeng 699bbbde57 feat(avatar): add user token accounting 2026-08-25 13:24:02 +08:00
stefanfeng 7a0199e685 feat(avatar): improve multi-avatar management 2026-08-25 11:44:27 +08:00
stefanfeng 672019830d fix(avatar): prevent takeover replies blocking across chats 2026-08-21 16:21:00 +08:00
stefanfeng e720baa21e fix(avatar): batch knowledge embedding requests 2026-08-21 13:21:05 +08:00
stefanfeng e2b928273c fix(avatar): acknowledge BOXIM messages as read 2026-08-20 17:10:54 +08:00
stefanfeng 64b7680ec4 fix(avatar): keep BOXIM status ready during long polls 2026-08-20 15:58:07 +08:00
stefanfeng 89f52963b7 fix(avatar): persist takeover toggle immediately 2026-08-20 15:55:12 +08:00
stefanfeng 76bd22c24b fix(avatar): preserve ownership across Huihui environments 2026-08-20 15:27:29 +08:00
stefanfeng 51a317ccd9 fix(avatar): fail closed on BOXIM connection errors 2026-08-20 14:47:13 +08:00
stefanfeng 08590bf9ea fix(avatar): refresh BOXIM takeover status 2026-08-19 18:03:59 +08:00
stefanfeng 25fb8fbee5 feat(avatar): add BOXIM chat takeover 2026-08-19 17:56:57 +08:00
stefanfeng cfcfe7146e feat(avatar): align authorization page with design 2026-08-19 17:05:22 +08:00
stefanfeng 2e2adeb9e2 feat(avatar): complete authorization management 2026-08-19 16:21:19 +08:00
stefanfeng 25d2494616 fix: surface missing knowledge files 2026-08-19 15:13:22 +08:00
59 changed files with 4849 additions and 2104 deletions
+52 -4
View File
@@ -1,8 +1,11 @@
"""AI模型配置接口"""
from fastapi import APIRouter, Depends, HTTPException
import secrets
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import select, update
from app.core.database import get_db
from app.core.config import settings
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
from app.models import AIModelConfig
from app.utils.crypto import encrypt, decrypt
@@ -22,10 +25,15 @@ async def list_models(db=Depends(get_db)):
@router.post("")
async def create_model(req: AIModelCreateRequest, db=Depends(get_db)):
if req.is_default:
await db.execute(update(AIModelConfig).values(is_default=0))
await db.execute(
update(AIModelConfig)
.where(AIModelConfig.usage_scope == req.usage_scope)
.values(is_default=0)
)
model = AIModelConfig(
model_name=req.model_name,
provider=req.provider,
usage_scope=req.usage_scope,
api_base_url=req.api_base_url,
api_key_enc=encrypt(req.api_key) if req.api_key else None,
model_version=req.model_version,
@@ -47,8 +55,16 @@ async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
if req.is_default:
await db.execute(update(AIModelConfig).where(AIModelConfig.id != model_id).values(is_default=0))
target_scope = req.usage_scope or model.usage_scope
if req.is_default or (req.usage_scope and model.is_default):
await db.execute(
update(AIModelConfig)
.where(
AIModelConfig.id != model_id,
AIModelConfig.usage_scope == target_scope,
)
.values(is_default=0)
)
for field, val in req.model_dump(exclude_none=True).items():
if field == "api_key":
model.api_key_enc = encrypt(val) if val else None
@@ -59,6 +75,37 @@ async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_
return ApiResponse(data=_format_model(model), message="更新成功")
@router.get("/runtime/digital-avatar")
async def get_digital_avatar_runtime_model(
x_avatar_config_token: str | None = Header(default=None),
db=Depends(get_db),
):
expected = settings.AVATAR_MODEL_CONFIG_TOKEN
if not expected:
raise HTTPException(status_code=503, detail="数字分身模型配置服务未启用")
if not x_avatar_config_token or not secrets.compare_digest(x_avatar_config_token, expected):
raise HTTPException(status_code=401, detail="无权读取数字分身模型配置")
result = await db.execute(
select(AIModelConfig).where(
AIModelConfig.usage_scope == "digital_avatar",
AIModelConfig.is_default == 1,
AIModelConfig.is_enabled == 1,
)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="尚未配置启用的数字分身专用模型")
return ApiResponse(data={
"api_base_url": model.api_base_url or "https://api.openai.com/v1",
"api_key": decrypt(model.api_key_enc) if model.api_key_enc else "",
"model": model.model_version or model.model_name,
"temperature": model.temperature,
"max_tokens": model.max_tokens,
"timeout_seconds": model.timeout_seconds,
})
@router.delete("/{model_id}")
async def delete_model(model_id: int, db=Depends(get_db)):
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
@@ -79,6 +126,7 @@ async def test_model(req: AIModelTestRequest, db=Depends(get_db)):
def _format_model(m: AIModelConfig) -> dict:
return {
"id": m.id, "model_name": m.model_name, "provider": m.provider,
"usage_scope": m.usage_scope,
"api_base_url": m.api_base_url, "has_api_key": bool(m.api_key_enc),
"model_version": m.model_version, "temperature": m.temperature,
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
+1
View File
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
# 安全
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
AES_KEY: str = os.getenv("AES_KEY", "your-aes-key-32-chars-change-now!")
AVATAR_MODEL_CONFIG_TOKEN: str = os.getenv("AVATAR_MODEL_CONFIG_TOKEN", "")
# 新闻平台
NEWS_PLATFORM_BASE_URL: str = os.getenv(
+17
View File
@@ -1,6 +1,7 @@
"""数据库连接管理"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
from app.core.logger import logger
@@ -64,6 +65,22 @@ async def init_db():
VirtualUser, UserPersonality, InteractionRecord,
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog
)
async with engine.begin() as conn:
await conn.execute(text("SELECT GET_LOCK('ai_model_usage_scope_migration', 30)"))
try:
result = await conn.execute(text(
"SELECT COUNT(*) FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ai_model_configs' "
"AND COLUMN_NAME = 'usage_scope'"
))
if result.scalar_one() == 0:
await conn.execute(text(
"ALTER TABLE ai_model_configs ADD COLUMN usage_scope "
"VARCHAR(16) NOT NULL DEFAULT 'general' AFTER provider"
))
logger.info("AI模型配置表已增加 usage_scope 字段")
finally:
await conn.execute(text("SELECT RELEASE_LOCK('ai_model_usage_scope_migration')"))
logger.info("✅ 数据库模型注册成功")
logger.info("✅ 数据库初始化完成")
+1
View File
@@ -122,6 +122,7 @@ class AIModelConfig(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
model_name: Mapped[str] = mapped_column(String(64), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
usage_scope: Mapped[str] = mapped_column(String(16), nullable=False, default="general")
api_base_url: Mapped[str | None] = mapped_column(String(256))
api_key_enc: Mapped[str | None] = mapped_column(String(512))
model_version: Mapped[str | None] = mapped_column(String(64))
+4
View File
@@ -154,6 +154,7 @@ class InteractionResponse(BaseModel):
class AIModelCreateRequest(BaseModel):
model_name: str = Field(..., min_length=1, max_length=64)
provider: str = Field(..., pattern="^(openai|zhipu|wenxin|qianwen|local)$")
usage_scope: str = Field(default="general", pattern="^(general|digital_avatar)$")
api_base_url: Optional[str] = None
api_key: Optional[str] = None
model_version: Optional[str] = None
@@ -165,6 +166,8 @@ class AIModelCreateRequest(BaseModel):
class AIModelUpdateRequest(BaseModel):
model_name: Optional[str] = None
provider: Optional[str] = Field(None, pattern="^(openai|zhipu|wenxin|qianwen|local)$")
usage_scope: Optional[str] = Field(None, pattern="^(general|digital_avatar)$")
api_base_url: Optional[str] = None
api_key: Optional[str] = None
model_version: Optional[str] = None
@@ -179,6 +182,7 @@ class AIModelResponse(BaseModel):
id: int
model_name: str
provider: str
usage_scope: str
api_base_url: Optional[str]
has_api_key: bool
model_version: Optional[str]
+3 -1
View File
@@ -28,7 +28,9 @@ class AIService:
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
result = await db.execute(
select(AIModelConfig).where(
AIModelConfig.is_default == 1, AIModelConfig.is_enabled == 1
AIModelConfig.usage_scope == "general",
AIModelConfig.is_default == 1,
AIModelConfig.is_enabled == 1,
)
)
return result.scalar_one_or_none()
+14
View File
@@ -40,8 +40,14 @@ def init_db():
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"),
("avatars", "share_token", "VARCHAR DEFAULT NULL"),
("token_account", "user_id", "VARCHAR DEFAULT ''"),
("token_account", "total_granted", "BIGINT DEFAULT 0"),
("token_account", "total_consumed", "BIGINT DEFAULT 0"),
("token_account", "created_at", "TIMESTAMP"),
("token_account", "updated_at", "TIMESTAMP"),
)
_normalize_optional_unique_values()
_create_token_indexes()
def _try_add_columns(*cols):
@@ -58,3 +64,11 @@ def _try_add_columns(*cols):
def _normalize_optional_unique_values():
with engine.begin() as conn:
conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''")
def _create_token_indexes():
with engine.begin() as conn:
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_token_account_user_id "
"ON token_account(user_id) WHERE user_id <> ''"
)
+26 -16
View File
@@ -51,22 +51,32 @@ def embed(texts):
if api_url:
api_key = os.getenv("EMBEDDING_API_KEY", "")
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
payload = json.dumps({"input": texts, "model": model}).encode("utf-8")
req = urllib.request.Request(
api_url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}" if api_key else "",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
items = data["data"]
if items and "index" in items[0]:
items = sorted(items, key=lambda x: x["index"])
return [item["embedding"] for item in items]
try:
batch_size = max(1, int(os.getenv("EMBEDDING_BATCH_SIZE", "10")))
except ValueError:
batch_size = 10
embeddings = []
for start in range(0, len(texts), batch_size):
batch = texts[start:start + batch_size]
payload = json.dumps({"input": batch, "model": model}).encode("utf-8")
req = urllib.request.Request(
api_url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}" if api_key else "",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
items = data["data"]
if items and "index" in items[0]:
items = sorted(items, key=lambda x: x["index"])
if len(items) != len(batch):
raise ValueError("embedding response count does not match request")
embeddings.extend(item["embedding"] for item in items)
return embeddings
return _hash_embedding(texts)
+56 -41
View File
@@ -6,10 +6,9 @@ import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
import redis as redis_lib
from database import init_db, SessionLocal
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
from fastapi.staticfiles import StaticFiles
import routers.avatars
import routers.tokens
@@ -20,11 +19,11 @@ import routers.huihui_auth
import routers.chat
import routers.takeover
from responses import ok
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
logger = logging.getLogger(__name__)
takeover_scheduler = None
takeover_db = None
app = FastAPI(title="会会数字分身 API", version="1.0.0")
@@ -58,17 +57,29 @@ def health():
def seed():
db = SessionLocal()
try:
if db.query(TokenAccount).first() is None:
db.add(TokenAccount(balance=1250))
plan_specs = [
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M Token"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M Token"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M Token"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M Token"},
]
for spec in plan_specs:
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
if plan is None:
db.add(TokenPlan(**spec))
else:
for key, value in spec.items():
setattr(plan, key, value)
if db.query(TokenPlan).count() == 0:
plans = [
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"),
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"),
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"),
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"),
]
db.add_all(plans)
for user in db.query(User).all():
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
if account is None:
db.add(TokenAccount(
user_id=user.id,
balance=DEFAULT_TOKEN_GRANT,
total_granted=DEFAULT_TOKEN_GRANT,
total_consumed=0,
))
if db.query(Avatar).count() == 0:
avatar = Avatar(
@@ -107,13 +118,14 @@ def seed():
db.add_all(orgs)
db.commit()
release_stale_reservations(db)
finally:
db.close()
@app.on_event("startup")
def on_startup():
global takeover_scheduler, takeover_db
global takeover_scheduler
init_db()
seed()
@@ -123,47 +135,57 @@ def on_startup():
# --- Takeover scheduler ---
try:
# Initialize Redis (optional)
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 as e:
logger.warning(f"Redis connection failed, delayed takeover will degrade to immediate: {e}")
# Initialize Box IM client
# BOXIM production endpoints are intentionally separate from the login API.
from services.boxim_client import BoxIMClient
boxim_config = {
"HUIHUI_IM_BASE_URL": os.getenv("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040"),
"HUIHUI_PLATFORM_BASE_URL": os.getenv(
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
),
"BOXIM_API_BASE_URL": os.getenv(
"BOXIM_API_BASE_URL", "https://im.99hui.com/api"
),
"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_TIMEOUT_SECONDS": os.getenv("BOXIM_TIMEOUT_SECONDS", "20"),
}
boxim_client = BoxIMClient(boxim_config)
# Initialize takeover service
from services.takeover_service import TakeoverService
takeover_db = SessionLocal()
takeover_service = TakeoverService(takeover_db, boxim_client, redis_client)
takeover_service = TakeoverService(SessionLocal, boxim_client)
# AsyncIOScheduler awaits the service coroutine instead of dropping it.
poll_interval = max(0.5, float(os.getenv("BOXIM_POLL_INTERVAL_SECONDS", "1")))
takeover_scheduler = AsyncIOScheduler()
takeover_scheduler.add_job(
takeover_service.poll_and_process_messages,
trigger=IntervalTrigger(seconds=10),
takeover_service.poll_messages,
trigger=IntervalTrigger(seconds=poll_interval),
id="takeover_message_poll",
max_instances=1,
coalesce=True,
)
process_interval = max(
0.25, float(os.getenv("TAKEOVER_PROCESS_INTERVAL_SECONDS", "0.5"))
)
takeover_scheduler.add_job(
takeover_service.process_reply_tasks,
trigger=IntervalTrigger(seconds=process_interval),
id="takeover_reply_process",
max_instances=1,
coalesce=True,
)
takeover_scheduler.start()
logger.info("Takeover message polling scheduler started (interval=10s)")
logger.info(
"BOXIM takeover scheduler started (poll=%ss, process=%ss)",
poll_interval,
process_interval,
)
except Exception as e:
stop_takeover_scheduler()
logger.warning(f"Failed to initialize takeover scheduler, app will continue without it: {e}")
def stop_takeover_scheduler():
global takeover_scheduler, takeover_db
global takeover_scheduler
if takeover_scheduler is not None:
try:
@@ -174,13 +196,6 @@ def stop_takeover_scheduler():
finally:
takeover_scheduler = None
if takeover_db is not None:
try:
takeover_db.close()
finally:
takeover_db = None
@app.on_event("shutdown")
def on_shutdown():
stop_takeover_scheduler()
+113 -3
View File
@@ -1,6 +1,18 @@
import uuid
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, JSON, Boolean
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
Float,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.sql import func
from database import Base
@@ -74,6 +86,76 @@ class Authorization(Base):
}
class TakeoverCursor(Base):
"""Durable BOXIM polling cursor for one avatar owner."""
__tablename__ = "takeover_cursors"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
avatar_id = Column(String, nullable=False, unique=True, index=True)
owner_id = Column(String, nullable=False, default="", index=True)
boxim_owner_id = Column(String, default="")
last_message_id = Column(String, default="0")
initialized = Column(Boolean, default=False)
last_polled_at = Column(DateTime)
last_error = Column(Text, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class TakeoverMessage(Base):
"""BOXIM message receipt used for audit, deduplication, and chat context."""
__tablename__ = "takeover_messages"
__table_args__ = (
UniqueConstraint("owner_id", "boxim_message_id", name="uq_takeover_message_owner_boxim"),
Index("ix_takeover_message_conversation", "owner_id", "peer_id", "send_time"),
)
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
avatar_id = Column(String, nullable=False, index=True)
owner_id = Column(String, nullable=False, index=True)
boxim_message_id = Column(String, nullable=False)
boxim_local_id = Column(String, nullable=True)
peer_id = Column(String, nullable=False, index=True)
direction = Column(String, nullable=False) # incoming | outgoing
message_type = Column(Integer, default=0)
content = Column(Text, default="")
is_avatar = Column(Boolean, default=False)
send_time = Column(DateTime, nullable=False)
created_at = Column(DateTime, server_default=func.now())
class TakeoverReplyTask(Base):
"""Restart-safe three-second BOXIM reply task."""
__tablename__ = "takeover_reply_tasks"
__table_args__ = (
UniqueConstraint("owner_id", "trigger_message_id", name="uq_takeover_task_owner_trigger"),
Index("ix_takeover_task_due", "status", "scheduled_at"),
Index("ix_takeover_task_conversation", "owner_id", "peer_id", "status"),
)
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
avatar_id = Column(String, nullable=False, index=True)
owner_id = Column(String, nullable=False, index=True)
peer_id = Column(String, nullable=False, index=True)
trigger_message_id = Column(String, nullable=False)
source_message_ids = Column(JSON, default=list)
prompt = Column(Text, default="")
response_text = Column(Text, default="")
status = Column(String, default="pending")
scheduled_at = Column(DateTime, nullable=False)
locked_at = Column(DateTime)
sent_at = Column(DateTime)
attempts = Column(Integer, default=0)
last_error = Column(Text, default="")
cancel_reason = Column(String, default="")
boxim_local_id = Column(String, nullable=False)
boxim_sent_message_id = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class Organization(Base):
__tablename__ = "organizations"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
@@ -178,14 +260,42 @@ class KnowledgeChunk(Base):
class TokenAccount(Base):
__tablename__ = "token_account"
id = Column(Integer, primary_key=True)
balance = Column(Integer, default=1250)
user_id = Column(String, nullable=False, default="", index=True)
balance = Column(BigInteger, default=1_000_000)
total_granted = Column(BigInteger, default=1_000_000)
total_consumed = Column(BigInteger, default=0)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class TokenUsage(Base):
__tablename__ = "token_usage"
__table_args__ = (
Index("ix_token_usage_user_created", "user_id", "created_at"),
Index("ix_token_usage_avatar_created", "avatar_id", "created_at"),
)
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
user_id = Column(String, nullable=False, index=True)
avatar_id = Column(String, nullable=False, default="", index=True)
source = Column(String, nullable=False, default="chat")
model = Column(String, default="")
status = Column(String, nullable=False, default="reserved")
reserved_tokens = Column(BigInteger, default=0)
prompt_tokens = Column(BigInteger, default=0)
completion_tokens = Column(BigInteger, default=0)
total_tokens = Column(BigInteger, default=0)
balance_after = Column(BigInteger, default=0)
failure_reason = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
settled_at = Column(DateTime)
class TokenPlan(Base):
__tablename__ = "token_plans"
id = Column(String, primary_key=True)
name = Column(String, default="")
amount = Column(Integer, default=0)
amount = Column(BigInteger, default=0)
price = Column(Float, default=0)
badge = Column(String, default="")
desc = Column(String, default="")
@@ -7,5 +7,4 @@ httpx
pypdf
python-docx
openpyxl
redis>=5.0
apscheduler>=3.10
@@ -1,32 +1,334 @@
from fastapi import APIRouter, Depends, Body
from fastapi import APIRouter, Body, Depends, Header, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from models import Authorization
from responses import ok, fail
from models import Authorization, TakeoverCursor, TakeoverReplyTask
from responses import fail, ok
from routers.avatars import _require_owned_avatar
router = APIRouter(tags=["授权"])
TARGET_TYPES = {"user", "organization", "application"}
PERMISSION_ORDER = ("friend", "chat", "publish", "browse", "interact", "takeover")
ALLOWED_PERMISSIONS = set(PERMISSION_ORDER)
AVATAR_PERMISSION_ORDER = PERMISSION_ORDER
AVATAR_PERMISSION_KEY = "authorizationPermissions"
DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"]
LEGACY_PERMISSION_MAP = {
"read": "browse",
"reply": "chat",
"write": "publish",
"edit": "publish",
}
def _read(payload: dict, camel_key: str, snake_key: str | None = None, default=None):
if camel_key in payload:
return payload[camel_key]
if snake_key and snake_key in payload:
return payload[snake_key]
return default
def _clean_text(value, field_name: str, *, max_length: int) -> str:
text = str(value or "").strip()
if not text:
raise ValueError(f"{field_name}不能为空")
if len(text) > max_length:
raise ValueError(f"{field_name}不能超过 {max_length} 个字符")
return text
def _normalize_permissions(value) -> list[str]:
if not isinstance(value, list):
raise ValueError("权限格式不正确")
normalized = []
for raw in value:
permission = LEGACY_PERMISSION_MAP.get(str(raw).strip(), str(raw).strip())
if permission not in ALLOWED_PERMISSIONS:
raise ValueError(f"不支持的权限:{raw}")
if permission not in normalized:
normalized.append(permission)
if not [item for item in normalized if item != "takeover"]:
raise ValueError("请至少选择一项权限")
return sorted(normalized, key=PERMISSION_ORDER.index)
def _normalize_avatar_permissions(value) -> list[str]:
if not isinstance(value, list):
raise ValueError("权限格式不正确")
normalized = []
for raw in value:
permission = LEGACY_PERMISSION_MAP.get(str(raw).strip(), str(raw).strip())
if permission not in AVATAR_PERMISSION_ORDER:
raise ValueError(f"不支持的权限:{raw}")
if permission not in normalized:
normalized.append(permission)
return sorted(normalized, key=AVATAR_PERMISSION_ORDER.index)
def _stored_avatar_permissions(avatar) -> list[str]:
config = avatar.config or {}
if AVATAR_PERMISSION_KEY not in config:
return list(DEFAULT_AVATAR_PERMISSIONS)
stored = config.get(AVATAR_PERMISSION_KEY)
if not isinstance(stored, list):
return list(DEFAULT_AVATAR_PERMISSIONS)
permissions = []
for raw in stored:
permission = LEGACY_PERMISSION_MAP.get(str(raw).strip(), str(raw).strip())
if permission in AVATAR_PERMISSION_ORDER and permission not in permissions:
permissions.append(permission)
return sorted(permissions, key=AVATAR_PERMISSION_ORDER.index)
def _permission_settings_payload(avatar) -> dict:
return {
"avatarId": avatar.id,
"permissions": _stored_avatar_permissions(avatar),
}
def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization:
authorization = (
db.query(Authorization)
.filter(
Authorization.id == authorization_id,
Authorization.avatar_id == avatar_id,
)
.first()
)
if not authorization:
raise HTTPException(status_code=404, detail="授权不存在")
return authorization
def _duplicate_target(
db: Session,
avatar_id: str,
target_type: str,
target_id: str,
*,
exclude_id: str | None = None,
):
query = db.query(Authorization).filter(
Authorization.avatar_id == avatar_id,
Authorization.target_type == target_type,
Authorization.target_id == target_id,
)
if exclude_id:
query = query.filter(Authorization.id != exclude_id)
return query.first()
@router.get("/avatar/{avatar_id}/permission-settings")
def get_permission_settings(
avatar_id: str,
authorization: str = Header(None),
db: Session = Depends(get_db),
):
avatar = _require_owned_avatar(db, avatar_id, authorization)
return ok(_permission_settings_payload(avatar))
@router.put("/avatar/{avatar_id}/permission-settings")
def update_permission_settings(
avatar_id: str,
payload: dict = Body(...),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
avatar = _require_owned_avatar(db, avatar_id, authorization)
if "permissions" not in payload:
return fail("缺少 permissions", 400)
try:
permissions = _normalize_avatar_permissions(payload["permissions"])
except ValueError as exc:
return fail(str(exc), 400)
previous_permissions = _stored_avatar_permissions(avatar)
avatar.config = {
**(avatar.config or {}),
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.refresh(avatar)
return ok(_permission_settings_payload(avatar), "授权设置已保存")
@router.get("/avatar/{avatar_id}/authorizations")
def list_auth(avatar_id: str, db: Session = Depends(get_db)):
# demo:返回全部授权(忽略具体 avatar 绑定,便于联调)
items = db.query(Authorization).order_by(Authorization.created_at.desc()).all()
return ok([a.to_dict() for a in items])
def list_auth(
avatar_id: str,
authorization: str = Header(None),
db: Session = Depends(get_db),
):
_require_owned_avatar(db, avatar_id, authorization)
items = (
db.query(Authorization)
.filter(Authorization.avatar_id == avatar_id)
.order_by(Authorization.created_at.desc())
.all()
)
return ok([item.to_dict() for item in items])
@router.post("/avatar/{avatar_id}/authorizations")
def create_auth(
avatar_id: str,
payload: dict = Body(...),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
_require_owned_avatar(db, avatar_id, authorization)
try:
target_type = _clean_text(
_read(payload, "targetType", "target_type", "user"),
"授权类型",
max_length=24,
)
if target_type not in TARGET_TYPES:
return fail("授权类型不正确", 400)
target_id = _clean_text(
_read(payload, "targetId", "target_id"),
"对象标识",
max_length=120,
)
target_name = _clean_text(
_read(payload, "targetName", "target_name"),
"对象名称",
max_length=50,
)
permissions = _normalize_permissions(payload.get("permissions", []))
except ValueError as exc:
return fail(str(exc), 400)
if _duplicate_target(db, avatar_id, target_type, target_id):
return fail("该对象已在授权列表中,可直接编辑现有授权", 409)
item = Authorization(
avatar_id=avatar_id,
target_type=target_type,
target_id=target_id,
target_name=target_name,
permissions=permissions,
status="active",
takeover_enabled=False,
takeover_mode="immediate",
takeover_delay_seconds=30,
)
db.add(item)
db.commit()
db.refresh(item)
return ok(item.to_dict(), "授权已添加")
@router.put("/avatar/{avatar_id}/authorizations")
def update_auth(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
auth_id = payload.get("id")
def update_auth(
avatar_id: str,
payload: dict = Body(...),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
_require_owned_avatar(db, avatar_id, authorization)
auth_id = payload.get("id") or _read(payload, "authorizationId", "authorization_id")
if not auth_id:
return fail("缺少授权 id", 400)
a = db.query(Authorization).filter(Authorization.id == auth_id).first()
if not a:
return fail("授权不存在", 404)
item = _require_authorization(db, avatar_id, str(auth_id))
try:
target_type = item.target_type
target_id = item.target_id
if "targetType" in payload or "target_type" in payload:
target_type = _clean_text(
_read(payload, "targetType", "target_type"),
"授权类型",
max_length=24,
)
if target_type not in TARGET_TYPES:
return fail("授权类型不正确", 400)
if "targetId" in payload or "target_id" in payload:
target_id = _clean_text(
_read(payload, "targetId", "target_id"),
"对象标识",
max_length=120,
)
if "targetName" in payload or "target_name" in payload:
item.target_name = _clean_text(
_read(payload, "targetName", "target_name"),
"对象名称",
max_length=50,
)
if "permissions" in payload:
item.permissions = _normalize_permissions(payload["permissions"])
except ValueError as exc:
return fail(str(exc), 400)
if _duplicate_target(
db,
avatar_id,
target_type,
target_id,
exclude_id=item.id,
):
return fail("该对象已在授权列表中", 409)
if "status" in payload:
a.status = payload["status"]
if "permissions" in payload:
a.permissions = payload["permissions"]
status = str(payload["status"] or "")
if status not in ("active", "inactive"):
return fail("授权状态不正确", 400)
item.status = status
item.target_type = target_type
item.target_id = target_id
permissions = list(item.permissions or [])
chat_allowed = "chat" in permissions or "reply" in permissions
if item.status != "active" or item.target_type != "user" or not chat_allowed:
item.takeover_enabled = False
item.permissions = [permission for permission in permissions if permission != "takeover"]
elif item.takeover_enabled and "takeover" not in permissions:
item.permissions = permissions + ["takeover"]
db.commit()
items = db.query(Authorization).order_by(Authorization.created_at.desc()).all()
return ok([x.to_dict() for x in items])
db.refresh(item)
return ok(item.to_dict(), "授权已更新")
@router.delete("/avatar/{avatar_id}/authorizations/{authorization_id}")
def delete_auth(
avatar_id: str,
authorization_id: str,
authorization: str = Header(None),
db: Session = Depends(get_db),
):
_require_owned_avatar(db, avatar_id, authorization)
item = _require_authorization(db, avatar_id, authorization_id)
db.delete(item)
db.commit()
return ok({"id": authorization_id}, "授权已删除")
+158 -27
View File
@@ -16,12 +16,17 @@ import embeddings
from database import get_db
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
from responses import ok, fail
from services.token_billing import (
InsufficientTokensError,
estimate_fallback_usage,
release_reservation,
reserve_avatar_tokens,
settle_reservation,
)
from services.chat_model_config import ChatModelConfig, get_chat_model_config
router = APIRouter(tags=["数字分身聊天"])
CHAT_API_URL = os.getenv("CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
CHAT_API_KEY = os.getenv("CHAT_API_KEY", "")
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen-plus")
MAX_MESSAGE_LENGTH = 4000
MAX_HISTORY_MESSAGES = 10
QA_LEXICAL_THRESHOLD = 0.72
@@ -279,21 +284,25 @@ def _search_knowledge(db: Session, avatar_id: str, question: str, top_k: int = 5
return results
def _call_qwen(messages: list[dict], temperature: float) -> str:
if not CHAT_API_KEY:
def _call_qwen(
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
) -> dict:
model_config = model_config or get_chat_model_config()
if not model_config.api_key:
raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
url = f"{model_config.api_base_url}/chat/completions"
payload = {
"model": CHAT_MODEL,
"model": model_config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": model_config.max_tokens,
}
try:
response = httpx.post(
url,
headers={"Authorization": f"Bearer {CHAT_API_KEY}"},
headers={"Authorization": f"Bearer {model_config.api_key}"},
json=payload,
timeout=30,
timeout=model_config.timeout_seconds,
)
response.raise_for_status()
data = response.json()
@@ -302,17 +311,33 @@ def _call_qwen(messages: list[dict], temperature: float) -> str:
raise RuntimeError("Qwen 模型服务暂时不可用") from exc
if not isinstance(answer, str) or not answer.strip():
raise RuntimeError("Qwen 模型没有返回有效回答")
return answer.strip()
return {"answer": answer.strip(), "usage": data.get("usage") or {}}
def _iter_qwen_stream(messages: list[dict], temperature: float):
def _iter_qwen_stream(
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
):
"""将 OpenAI 兼容接口的 SSE 分片原样转为文本增量。"""
if not CHAT_API_KEY:
model_config = model_config or get_chat_model_config()
if not model_config.api_key:
raise RuntimeError("模型服务未配置")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
payload = {"model": CHAT_MODEL, "messages": messages, "temperature": temperature, "stream": True}
url = f"{model_config.api_base_url}/chat/completions"
payload = {
"model": model_config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": model_config.max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
try:
with httpx.stream("POST", url, headers={"Authorization": f"Bearer {CHAT_API_KEY}"}, json=payload, timeout=45) as response:
with httpx.stream(
"POST",
url,
headers={"Authorization": f"Bearer {model_config.api_key}"},
json=payload,
timeout=max(45, model_config.timeout_seconds),
) as response:
response.raise_for_status()
for raw_line in response.iter_lines():
line = raw_line.decode() if isinstance(raw_line, bytes) else raw_line
@@ -322,11 +347,15 @@ def _iter_qwen_stream(messages: list[dict], temperature: float):
if data == "[DONE]":
return
try:
delta = json.loads(data).get("choices", [{}])[0].get("delta", {}).get("content")
parsed = json.loads(data)
except (ValueError, IndexError, AttributeError):
continue
if parsed.get("usage"):
yield {"usage": parsed["usage"]}
choices = parsed.get("choices") or []
delta = choices[0].get("delta", {}).get("content") if choices else None
if delta:
yield delta
yield {"content": delta}
except httpx.HTTPError as exc:
raise RuntimeError("模型服务暂时不可用") from exc
@@ -350,6 +379,7 @@ def _resolve_reply(
qa_pairs: list[Any] | None = None,
search_fn: Callable[..., list[dict]] | None = None,
model_client: Callable[..., str] | None = None,
usage_source: str = "chat",
) -> dict:
if qa_pairs is None:
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
@@ -362,16 +392,54 @@ def _resolve_reply(
messages = _build_prompt(avatar, history, question, hits)
config = _config(avatar)
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
model_client = model_client or _call_qwen
answer = model_client(messages=messages, temperature=temperature)
return {
token_usage = None
if model_client is not None:
answer = model_client(messages=messages, temperature=temperature)
else:
model_config = get_chat_model_config()
reservation = reserve_avatar_tokens(
db,
avatar,
usage_source,
model_config.model,
messages,
model_config.max_tokens,
)
try:
model_result = _call_qwen(
messages=messages,
temperature=temperature,
model_config=model_config,
)
answer = model_result["answer"]
token_usage = settle_reservation(
db,
reservation,
model_result.get("usage"),
fallback_total=estimate_fallback_usage(messages, answer),
)
except Exception as exc:
release_reservation(db, reservation, str(exc))
raise
result = {
"answer": answer,
"source": "knowledge" if hits else "qwen",
"references": hits,
}
if token_usage:
result["tokenUsage"] = token_usage
return result
def _stream_reply(db: Session, avatar: Avatar, question: str, history: list[Any], *, public: bool = False):
def _stream_reply(
db: Session,
avatar: Avatar,
question: str,
history: list[Any],
*,
public: bool = False,
usage_source: str = "chat_stream",
):
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
if matched:
@@ -381,18 +449,63 @@ def _stream_reply(db: Session, avatar: Avatar, question: str, history: list[Any]
source = "knowledge" if references else "qwen"
config = _config(avatar)
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
chunks = _iter_qwen_stream(_build_prompt(avatar, history, question, references), temperature)
messages = _build_prompt(avatar, history, question, references)
model_config = get_chat_model_config()
reservation = reserve_avatar_tokens(
db,
avatar,
usage_source,
model_config.model,
messages,
model_config.max_tokens,
)
chunks = _iter_qwen_stream(messages, temperature, model_config)
if matched:
messages, reservation = [], None
if public:
source, references = "public", []
def generate():
output_parts = []
provider_usage = None
settled = False
try:
yield _sse("meta", {"source": source, "references": references})
for content in chunks:
for chunk in chunks:
if reservation is None:
content = chunk
else:
provider_usage = chunk.get("usage") or provider_usage
content = chunk.get("content")
if not content:
continue
output_parts.append(content)
yield _sse("delta", {"content": content})
yield _sse("done", {})
token_usage = None
if reservation is not None:
answer = "".join(output_parts)
token_usage = settle_reservation(
db,
reservation,
provider_usage,
fallback_total=estimate_fallback_usage(messages, answer),
)
settled = True
yield _sse("done", {} if public else {"tokenUsage": token_usage})
except RuntimeError as exc:
yield _sse("error", {"message": str(exc)})
finally:
if reservation is not None and not settled:
answer = "".join(output_parts)
if answer:
settle_reservation(
db,
reservation,
provider_usage,
fallback_total=estimate_fallback_usage(messages, answer),
)
else:
release_reservation(db, reservation, "stream_ended_without_output")
return StreamingResponse(
generate(),
@@ -441,11 +554,14 @@ def get_shared_avatar(share_token: str, db: Session = Depends(get_db)):
def public_chat(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
avatar = _require_shared_avatar(db, share_token)
try:
result = _resolve_reply(db, avatar, body.message, body.history)
result = _resolve_reply(db, avatar, body.message, body.history, usage_source="public_chat")
# 公开访客无需获知知识文件名、检索分数或内部答复来源。
result["references"] = []
result["source"] = "public"
result.pop("tokenUsage", None)
return ok(result)
except InsufficientTokensError as exc:
return fail(str(exc), code=402)
except RuntimeError as exc:
return fail(str(exc), code=502)
@@ -455,15 +571,30 @@ def chat(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(N
avatar = _require_owned_avatar(db, avatar_id, authorization)
try:
return ok(_resolve_reply(db, avatar, body.message, body.history))
except InsufficientTokensError as exc:
return fail(str(exc), code=402)
except RuntimeError as exc:
return fail(str(exc), code=502)
@router.post("/avatar/{avatar_id}/chat/stream")
def chat_stream(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
return _stream_reply(db, _require_owned_avatar(db, avatar_id, authorization), body.message, body.history)
try:
return _stream_reply(db, _require_owned_avatar(db, avatar_id, authorization), body.message, body.history)
except InsufficientTokensError as exc:
raise HTTPException(status_code=402, detail=str(exc)) from exc
@router.post("/public/avatar/{share_token}/chat/stream")
def public_chat_stream(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
return _stream_reply(db, _require_shared_avatar(db, share_token), body.message, body.history, public=True)
try:
return _stream_reply(
db,
_require_shared_avatar(db, share_token),
body.message,
body.history,
public=True,
usage_source="public_chat_stream",
)
except InsufficientTokensError as exc:
raise HTTPException(status_code=402, detail=str(exc)) from exc
@@ -27,8 +27,9 @@ from sqlalchemy.orm import Session
_CN_TZ = timezone(timedelta(hours=8))
from database import get_db
from models import User
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from responses import ok, fail
from services.boxim_client import BoxIMClient, BoxIMError
router = APIRouter(tags=["会会账号"])
@@ -111,6 +112,19 @@ def _cfg_ready() -> bool:
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
def _create_boxim_client() -> BoxIMClient:
return BoxIMClient({
"HUIHUI_PLATFORM_BASE_URL": os.getenv(
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
),
"BOXIM_API_BASE_URL": os.getenv("BOXIM_API_BASE_URL", "https://im.99hui.com/api"),
"HUIHUI_APP_ID": APP_ID,
"HUIHUI_ACCESS_ID": ACCESS_ID,
"HUIHUI_ACCESS_SECRET": ACCESS_SECRET,
"BOXIM_TIMEOUT_SECONDS": os.getenv("BOXIM_TIMEOUT_SECONDS", "20"),
})
def _call_huihui(path: str, params: dict, as_query: bool = False):
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
url = f"{AUTH_BASE_URL}{path}"
@@ -281,12 +295,108 @@ def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
})
def _issue_session(db: Session, phone: str, info: dict):
@router.post("/huihui/token/login")
async def token_login(body: dict = Body(...), db: Session = Depends(get_db)):
"""Validate a production Huihui token through BOXIM and issue an app session."""
huihui_token = (body.get("token") or "").strip()
if not huihui_token or len(huihui_token) > 8192:
return fail("会会登录凭证无效或已过期", 401)
if not _cfg_ready():
return fail("会会登录服务未配置", 500)
client = _create_boxim_client()
try:
token_data = await client.exchange_access_token(huihui_token)
profile = await client.get_self(token_data["accessToken"])
except BoxIMError as exc:
if exc.auth_error:
return fail("会会登录凭证无效或已过期", 401)
return fail("会会登录服务暂时不可用,请稍后重试", 502)
# BOXIM's id is its internal IM id. Account ownership must use huihuiUserId.
huihui_user_id = str(profile.get("huihuiUserId") or "").strip()
if not huihui_user_id:
return fail("会会未返回用户标识", 502)
phone = str(_pick(profile, "mobile", "phone", default="")).strip()
nickname = str(_pick(profile, "nickName", "nickname", "name", "userName", default="")).strip()
avatar_url = str(
_pick(profile, "headImage", "headImageThumb", "avatar", "avatarUrl", default="")
).strip()
return _issue_session(
db,
phone,
{
"userId": huihui_user_id,
"nickname": nickname,
"avatarUrl": avatar_url,
"token": huihui_token,
},
reuse_existing_session=True,
)
def _transfer_avatar_ownership(db: Session, old_owner_id: str, new_owner_id: str) -> int:
"""Move one user's avatar-owned data to a replacement Huihui identity."""
if not old_owner_id or old_owner_id == new_owner_id:
return 0
avatar_ids = [
avatar_id
for (avatar_id,) in db.query(Avatar.id).filter(Avatar.owner_id == old_owner_id).all()
]
if not avatar_ids:
return 0
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).update(
{Avatar.owner_id: new_owner_id}, synchronize_session="fetch"
)
for model in (TakeoverCursor, TakeoverMessage, TakeoverReplyTask):
db.query(model).filter(model.avatar_id.in_(avatar_ids)).update(
{model.owner_id: new_owner_id}, synchronize_session="fetch"
)
return len(avatar_ids)
def _find_or_link_user(db: Session, phone: str, huihui_user_id: str) -> User:
"""Resolve an account and safely retain avatars across Huihui environments."""
user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first()
if not phone:
return user or User(huihui_user_id=huihui_user_id)
same_phone_users = db.query(User).filter(User.phone == phone).all()
if user is None:
# A unique verified-phone match is the same person whose upstream ID changed.
if len(same_phone_users) == 1:
user = same_phone_users[0]
old_owner_id = user.huihui_user_id
_transfer_avatar_ownership(db, old_owner_id, huihui_user_id)
user.huihui_user_id = huihui_user_id
return user
return User(huihui_user_id=huihui_user_id)
legacy_users = [candidate for candidate in same_phone_users if candidate.id != user.id]
current_avatar_count = db.query(Avatar).filter(Avatar.owner_id == huihui_user_id).count()
if len(legacy_users) == 1 and current_avatar_count == 0:
legacy_user = legacy_users[0]
_transfer_avatar_ownership(db, legacy_user.huihui_user_id, huihui_user_id)
legacy_user.app_token = ""
legacy_user.huihui_token = ""
db.add(legacy_user)
return user
def _issue_session(
db: Session,
phone: str,
info: dict,
*,
reuse_existing_session: bool = False,
):
"""建/链本地用户并签发本系统会话 token"""
huihui_user_id = info.get("userId", "")
user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first()
if not user:
user = User(huihui_user_id=huihui_user_id)
user = _find_or_link_user(db, phone, huihui_user_id)
if phone:
user.phone = phone
if info.get("nickname"):
@@ -294,12 +404,16 @@ def _issue_session(db: Session, phone: str, info: dict):
if info.get("avatarUrl"):
user.avatar_url = info["avatarUrl"]
user.huihui_token = info.get("token", "")
user.app_token = uuid.uuid4().hex
if not reuse_existing_session or not user.app_token:
user.app_token = uuid.uuid4().hex
user.last_login_at = datetime.now()
db.add(user)
db.commit()
db.refresh(user)
from services.token_billing import get_or_create_account
get_or_create_account(db, user.id)
return ok({
"token": user.app_token,
"user": user.to_dict(),
@@ -307,7 +421,6 @@ def _issue_session(db: Session, phone: str, info: dict):
"userId": huihui_user_id,
"nickname": info.get("nickname", ""),
"avatarUrl": info.get("avatarUrl", ""),
"token": info.get("token", ""),
},
})
@@ -32,6 +32,14 @@ class EnabledIn(BaseModel):
enabled: bool = True
def _doc_payload(doc: KnowledgeDoc) -> dict:
payload = doc.to_dict()
stored_name = os.path.basename(doc.file_url or "")
stored_path = os.path.join(UPLOAD_DIR, doc.avatar_id, stored_name)
payload["filePresent"] = bool(stored_name and os.path.isfile(stored_path))
return payload
def _resolve_user(authorization: str | None, db: Session):
if not authorization:
return None
@@ -61,7 +69,7 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
.order_by(KnowledgeDoc.created_at.desc())
.all()
)
return ok([d.to_dict() for d in docs])
return ok([_doc_payload(d) for d in docs])
@router.post("/avatar/{avatar_id}/knowledge/docs")
@@ -121,7 +129,7 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
db.commit()
db.refresh(doc)
return ok(doc.to_dict())
return ok(_doc_payload(doc))
@router.delete("/avatar/{avatar_id}/knowledge/docs/{doc_id}")
+111 -20
View File
@@ -1,41 +1,132 @@
"""分身接管配置 API"""
from fastapi import APIRouter, Depends, Body
"""数字分身 BOXIM 单聊接管 API。"""
from datetime import datetime, timedelta
from fastapi import APIRouter, Body, Depends, Header
from sqlalchemy.orm import Session
from database import get_db
from models import Authorization
from responses import ok, fail
from models import TakeoverCursor, TakeoverReplyTask, User
from responses import fail, ok
from routers.authorizations import _require_authorization
from routers.avatars import _require_owned_avatar
router = APIRouter(tags=["分身接管"])
BOXIM_STATUS_FRESH_SECONDS = 60
@router.get("/avatar/{avatar_id}/takeover/status")
def get_takeover_status(
avatar_id: str,
authorization: str = Header(None),
db: Session = Depends(get_db),
):
avatar = _require_owned_avatar(db, avatar_id, authorization)
permissions = (avatar.config or {}).get("authorizationPermissions", [])
enabled = isinstance(permissions, list) and "takeover" in permissions
user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
pending_count = (
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.avatar_id == avatar.id,
TakeoverReplyTask.status.in_(("pending", "generating", "ready", "sending")),
)
.count()
)
if cursor and cursor.last_error:
status, message = "error", cursor.last_error
elif not enabled:
status, message = "disabled", "主动接管未开启"
elif not user or not user.huihui_token:
status, message = "needs_login", "请重新登录会会生产账号以连接 BOXIM"
elif (
cursor
and cursor.initialized
and cursor.last_polled_at
# BOXIM offline-message reads can long-poll for about 20 seconds.
and cursor.last_polled_at
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
):
status, message = "ready", "BOXIM 已连接,收到私聊消息 3 秒后自动回复"
else:
status, message = "connecting", "正在连接 BOXIM"
return ok(
{
"enabled": enabled,
"status": status,
"message": message,
"pendingCount": pending_count,
"lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None,
}
)
def _has(payload: dict, camel_key: str, snake_key: str) -> bool:
return camel_key in payload or snake_key in payload
def _read(payload: dict, camel_key: str, snake_key: str, default=None):
if camel_key in payload:
return payload[camel_key]
if snake_key in payload:
return payload[snake_key]
return default
@router.put("/avatar/{avatar_id}/authorizations/takeover")
def update_takeover_config(
avatar_id: str,
payload: dict = Body(...),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
"""更新分身接管配置"""
auth_id = payload.get("authorizationId") or payload.get("authorization_id")
_require_owned_avatar(db, avatar_id, authorization)
auth_id = _read(payload, "authorizationId", "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)
auth = _require_authorization(db, avatar_id, str(auth_id))
enabled = bool(auth.takeover_enabled)
mode = auth.takeover_mode or "immediate"
delay = auth.takeover_delay_seconds or 30
if "takeover_enabled" in payload:
auth.takeover_enabled = payload["takeover_enabled"]
if "takeover_mode" in payload:
mode = payload["takeover_mode"]
if _has(payload, "takeoverEnabled", "takeover_enabled"):
raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled")
if not isinstance(raw_enabled, bool):
return fail("takeover_enabled 必须是布尔值", 400)
enabled = raw_enabled
if _has(payload, "takeoverMode", "takeover_mode"):
mode = _read(payload, "takeoverMode", "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
if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"):
delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds")
if isinstance(delay, bool) or not isinstance(delay, int) or not 5 <= delay <= 3600:
return fail("延迟时间需在 5 到 3600 秒之间", 400)
if enabled and auth.target_type != "user":
return fail("本期仅支持对会会用户开启单聊接管", 400)
if enabled and auth.status != "active":
return fail("请先启用该授权,再开启聊天接管", 400)
permissions = list(auth.permissions or [])
if enabled:
if "chat" not in permissions and "reply" not in permissions:
permissions.append("chat")
if "takeover" not in permissions:
permissions.append("takeover")
else:
permissions = [permission for permission in permissions if permission != "takeover"]
auth.permissions = permissions
auth.takeover_enabled = enabled
auth.takeover_mode = mode
auth.takeover_delay_seconds = delay
db.commit()
return ok(auth.to_dict())
db.refresh(auth)
return ok(auth.to_dict(), "接管配置已保存")
+57 -13
View File
@@ -1,37 +1,81 @@
from fastapi import APIRouter, Depends, Body
from fastapi import APIRouter, Depends, Body, Header, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from database import get_db
from models import TokenAccount, TokenPlan
from models import TokenAccount, TokenPlan, TokenUsage, User
from responses import ok, fail
from services.token_billing import get_or_create_account
router = APIRouter(tags=["Token"])
def _require_user(authorization: str | None, db: Session) -> User:
if not authorization:
raise HTTPException(status_code=401, detail="未登录")
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
user = db.query(User).filter(User.app_token == token).first()
if not user:
raise HTTPException(status_code=401, detail="会话无效或已过期")
return user
@router.get("/token/balance")
def balance(db: Session = Depends(get_db)):
acc = db.query(TokenAccount).first()
return ok({"balance": acc.balance if acc else 0})
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
acc = get_or_create_account(db, user.id)
return ok({
"balance": acc.balance,
"totalGranted": acc.total_granted,
"totalConsumed": acc.total_consumed,
})
@router.get("/token/plans")
def plans(db: Session = Depends(get_db)):
def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
_require_user(authorization, db)
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
return ok([p.to_dict() for p in items])
@router.post("/token/charge")
def charge(payload: dict = Body(...), db: Session = Depends(get_db)):
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
plan_id = payload.get("planId")
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
if not plan:
return fail("套餐不存在", 404)
acc = db.query(TokenAccount).first()
if not acc:
acc = TokenAccount(balance=0)
db.add(acc)
db.commit()
db.refresh(acc)
acc = get_or_create_account(db, user.id)
acc.balance += plan.amount
acc.total_granted = int(acc.total_granted or 0) + plan.amount
db.commit()
return ok({"balance": acc.balance, "charged": plan.amount})
@router.get("/token/usage")
def usage(authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
rows = (
db.query(
TokenUsage.avatar_id,
TokenUsage.source,
func.sum(TokenUsage.prompt_tokens),
func.sum(TokenUsage.completion_tokens),
func.sum(TokenUsage.total_tokens),
func.count(TokenUsage.id),
)
.filter(TokenUsage.user_id == user.id, TokenUsage.status == "completed")
.group_by(TokenUsage.avatar_id, TokenUsage.source)
.all()
)
return ok([
{
"avatarId": avatar_id,
"source": source,
"promptTokens": int(prompt_tokens or 0),
"completionTokens": int(completion_tokens or 0),
"totalTokens": int(total_tokens or 0),
"requestCount": int(request_count or 0),
}
for avatar_id, source, prompt_tokens, completion_tokens, total_tokens, request_count in rows
])
@@ -1,76 +1,210 @@
"""盒子 IM 客户端 — 封装网易云信 IM 接口调用"""
"""Client for Huihui's self-hosted BOXIM production APIs."""
import hashlib
import random
import secrets
import string
from datetime import datetime
from typing import Optional
import time
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
_CN_TZ = timezone(timedelta(hours=8))
class BoxIMError(RuntimeError):
def __init__(self, message: str, *, code: Any = None, auth_error: bool = False):
super().__init__(message)
self.code = code
self.auth_error = auth_error
class BoxIMClient:
"""盒子 IM 客户端,通过会会平台网关调用网易云信 IM"""
"""Exchange Huihui credentials and call BOXIM's private-message API."""
def __init__(self, config: dict):
self.base_url = config.get("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040")
self.platform_base_url = config.get(
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
).rstrip("/")
self.im_base_url = config.get(
"BOXIM_API_BASE_URL", "https://im.99hui.com/api"
).rstrip("/")
self.app_id = config.get("HUIHUI_APP_ID", "")
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
self.timeout = float(config.get("BOXIM_TIMEOUT_SECONDS", 20))
def _build_sign_params(self, extra: dict) -> dict:
"""构建带签名的请求参数(复用 news_service 签名模式)"""
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
timestamp = datetime.now().strftime("%Y%m%d%H%M%S") # 24小时制
def _build_sign_params(self, extra: dict | None = None) -> dict:
"""Build the same signed form used by Huihui's current production app."""
params = {
"appId": self.app_id,
"accessId": self.access_id,
"nonce": nonce,
"timestamp": timestamp,
**extra,
"nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
"timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"),
"signType": "MD5",
"signVersion": "1.0",
**(extra or {}),
}
# 计算签名 — 排序 key, 过滤空值, 拼接后加 accessSecret, MD5 大写
keys = sorted(params.keys())
params.pop("accessSecret", None)
params.pop("signature", None)
sign_parts = []
for k in keys:
if k in ("signature", "accessSecret"):
for key in sorted(params):
value = params[key]
if value in (None, "", []):
continue
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"
if isinstance(value, list):
continue
sign_parts.append(f"{key}={value}")
sign_source = "&".join(sign_parts) + f"&accessSecret={self.access_secret}"
params["signature"] = hashlib.md5(sign_source.encode("utf-8")).hexdigest().upper()
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
@staticmethod
def _response_payload(response: httpx.Response) -> dict:
try:
payload = response.json()
except ValueError as exc:
raise BoxIMError("BOXIM 返回了无效响应") from exc
if not isinstance(payload, dict):
raise BoxIMError("BOXIM 返回格式不正确")
return payload
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,
async def exchange_access_token(self, huihui_token: str) -> dict:
"""Exchange a production Huihui token for a BOXIM access token."""
if not huihui_token:
raise BoxIMError("缺少会会登录凭证", auth_error=True)
if not (self.app_id and self.access_id and self.access_secret):
raise BoxIMError("会会开放平台凭证未配置", auth_error=True)
headers = {
"Authorization": f"Bearer {huihui_token}",
"appId": self.app_id,
"windowAppId": self.app_id,
}
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
response = await client.post(
f"{self.platform_base_url}/im/box/netease",
headers=headers,
data=self._build_sign_params(),
)
data = r.json()
return data.get("code") in (0, 200)
payload = self._response_payload(response)
data = payload.get("data") or {}
code = payload.get("code")
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
raise BoxIMError(
payload.get("message") or "BOXIM 授权失败",
code=code or response.status_code,
auth_error=response.status_code in (400, 401, 403)
or code in (
400,
401,
40100,
40101,
403,
"400",
"401",
"40100",
"40101",
"403",
),
)
if not data.get("accessToken"):
raise BoxIMError("会会未返回 BOXIM 访问凭证", auth_error=True)
return data
async def _request(
self,
method: str,
path: str,
access_token: str,
*,
params: dict | None = None,
json: dict | None = None,
) -> Any:
headers = {"accessToken": access_token}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method,
f"{self.im_base_url}{path}",
headers=headers,
params=params,
json=json,
)
payload = self._response_payload(response)
code = payload.get("code")
if response.status_code >= 400 or code not in (200, "200"):
raise BoxIMError(
payload.get("message") or "BOXIM 请求失败",
code=code or response.status_code,
auth_error=response.status_code in (400, 401, 403)
or code in (400, 401, 40100, 40101, 403, "400", "401", "40100", "40101", "403"),
)
return payload.get("data")
async def get_self(self, access_token: str) -> dict:
data = await self._request("GET", "/user/self", access_token)
if not isinstance(data, dict) or data.get("id") is None:
raise BoxIMError("BOXIM 未返回当前用户信息")
return data
async def fetch_private_messages(self, access_token: str, min_id: str = "0") -> list[dict]:
data = await self._request(
"GET",
"/message/private/loadOfflineMessage",
access_token,
params={"minId": str(min_id or "0")},
)
if data is None:
return []
if not isinstance(data, list):
raise BoxIMError("BOXIM 私聊消息格式不正确")
return [item for item in data if isinstance(item, dict)]
async def mark_private_messages_read(
self,
access_token: str,
friend_id: int | str,
message_id: int | str,
) -> None:
"""Mark one private conversation read through its latest received message."""
friend_id_text = str(friend_id).strip()
message_id_text = str(message_id).strip()
if not friend_id_text.isdigit() or not message_id_text.isdigit():
raise BoxIMError("BOXIM 已读回执参数不正确")
await self._request(
"PUT",
"/message/private/readed",
access_token,
params={
"friendId": int(friend_id_text),
"messageId": int(message_id_text),
},
)
async def send_private_message(
self,
access_token: str,
peer_id: str,
content: str,
*,
local_id: int | str | None = None,
) -> dict:
local_id = int(local_id or (int(time.time() * 1000) * 1000 + secrets.randbelow(1000)))
data = await self._request(
"POST",
"/message/private/send",
access_token,
json={
"localId": local_id,
"recvId": int(peer_id) if str(peer_id).isdigit() else peer_id,
"content": content,
"type": 0,
"receipt": False,
"atUserIds": [],
},
)
if not isinstance(data, dict):
raise BoxIMError("BOXIM 未返回发送结果")
return data
@@ -0,0 +1,93 @@
import logging
import os
import threading
import time
from dataclasses import dataclass
import httpx
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ChatModelConfig:
api_base_url: str
api_key: str
model: str
max_tokens: int
timeout_seconds: float
source: str
_cache_lock = threading.Lock()
_cached_config: ChatModelConfig | None = None
_cache_expires_at = 0.0
def _environment_config() -> ChatModelConfig:
return ChatModelConfig(
api_base_url=os.getenv(
"CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"
).rstrip("/"),
api_key=os.getenv("CHAT_API_KEY", ""),
model=os.getenv("CHAT_MODEL", "qwen-plus"),
max_tokens=max(128, int(os.getenv("CHAT_MAX_OUTPUT_TOKENS", "1024"))),
timeout_seconds=max(5.0, float(os.getenv("CHAT_TIMEOUT_SECONDS", "30"))),
source="environment",
)
def _fetch_runtime_config() -> ChatModelConfig | None:
url = os.getenv("CHAT_MODEL_CONFIG_URL", "").strip()
token = os.getenv("AVATAR_MODEL_CONFIG_TOKEN", "").strip()
if not url or not token:
return None
response = httpx.get(
url,
headers={"X-Avatar-Config-Token": token},
timeout=max(2.0, float(os.getenv("CHAT_MODEL_CONFIG_TIMEOUT_SECONDS", "5"))),
)
response.raise_for_status()
payload = response.json().get("data") or {}
api_base_url = str(payload.get("api_base_url") or "").rstrip("/")
api_key = str(payload.get("api_key") or "")
model = str(payload.get("model") or "")
if not api_base_url or not api_key or not model:
raise ValueError("数字分身专用模型配置不完整")
return ChatModelConfig(
api_base_url=api_base_url,
api_key=api_key,
model=model,
max_tokens=max(128, int(payload.get("max_tokens") or 1024)),
timeout_seconds=max(5.0, float(payload.get("timeout_seconds") or 30)),
source="admin",
)
def get_chat_model_config(*, force_refresh: bool = False) -> ChatModelConfig:
global _cached_config, _cache_expires_at
now = time.monotonic()
if not force_refresh and _cached_config is not None and now < _cache_expires_at:
return _cached_config
with _cache_lock:
now = time.monotonic()
if not force_refresh and _cached_config is not None and now < _cache_expires_at:
return _cached_config
try:
config = _fetch_runtime_config() or _environment_config()
except (httpx.HTTPError, ValueError, TypeError) as exc:
logger.warning("读取数字分身专用模型配置失败,暂时使用环境变量配置: %s", exc)
config = _environment_config()
_cached_config = config
ttl = max(5, int(os.getenv("CHAT_MODEL_CONFIG_CACHE_SECONDS", "60")))
_cache_expires_at = now + ttl
return config
def clear_chat_model_config_cache() -> None:
global _cached_config, _cache_expires_at
with _cache_lock:
_cached_config = None
_cache_expires_at = 0.0
@@ -1,174 +1,631 @@
"""Takeover service — message listening, decision, reply execution."""
import json
"""Restart-safe automatic replies over Huihui's self-hosted BOXIM."""
import asyncio
import hashlib
import logging
import os
from typing import Optional
import re
import secrets
import time
from datetime import datetime, timedelta
from typing import Callable
import httpx
from sqlalchemy.orm import Session
from models import Avatar, Authorization
from services.boxim_client import BoxIMClient
from models import (
Avatar,
TakeoverCursor,
TakeoverMessage,
TakeoverReplyTask,
User,
)
from services.boxim_client import BoxIMClient, BoxIMError
logger = logging.getLogger(__name__)
ACTIVE_TASK_STATUSES = ("pending", "generating", "ready", "sending")
GENERATABLE_TASK_STATUSES = ("pending",)
MAX_PROMPT_LENGTH = 4000
MAX_STALE_SECONDS = 120
STUCK_LOCK_SECONDS = 90
TAKEOVER_PERMISSION = "takeover"
def _utcnow() -> datetime:
return datetime.utcnow()
def _takeover_enabled(avatar: Avatar | None) -> bool:
if not avatar or avatar.status != "active":
return False
permissions = (avatar.config or {}).get("authorizationPermissions", [])
return isinstance(permissions, list) and TAKEOVER_PERMISSION in permissions
def _boxim_time(value, fallback: datetime) -> datetime:
try:
timestamp = float(value)
if timestamp > 10_000_000_000:
timestamp /= 1000
return datetime.utcfromtimestamp(timestamp)
except (TypeError, ValueError, OSError, OverflowError):
return fallback
def _numeric_id(value) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _plain_text_reply(value: str) -> str:
"""BOXIM is plain text, so remove Markdown markers without damaging paragraphs."""
text = (value or "").replace("\r\n", "\n").replace("\r", "\n")
text = re.sub(r"```(?:\w+)?\n?(.*?)```", r"\1", text, flags=re.S)
text = re.sub(r"\*\*(.*?)\*\*|__(.*?)__", lambda m: m.group(1) or m.group(2), text)
text = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M)
lines = [line.strip() for line in text.split("\n")]
return "\n".join(line for line in lines if line).strip()
class TakeoverService:
"""Service for handling avatar takeover — generating replies and sending them via IM."""
"""Poll BOXIM, prepare replies during the grace period, then send at +3s."""
def __init__(
self,
db: Session,
session_factory: Callable[[], Session],
boxim_client: BoxIMClient,
redis_client=None,
*,
reply_delay_seconds: int = 3,
now: Callable[[], datetime] = _utcnow,
):
self.db = db
self.session_factory = session_factory
self.boxim = boxim_client
self.redis = redis_client
self._chat_api_base = os.getenv(
"TAKEOVER_CHAT_API_BASE", "http://localhost:8000/api"
)
def check_takeover_enabled(
self, owner_huihui_id: str, from_user_id: str
) -> Optional[Authorization]:
"""Check whether takeover is enabled for the given target user."""
avatar = self.db.query(Avatar).filter(Avatar.owner_id == owner_huihui_id).first()
if not avatar:
return None
auth = (
self.db.query(Authorization)
.filter(Authorization.avatar_id == avatar.id)
.filter(Authorization.target_id == from_user_id)
.filter(Authorization.takeover_enabled == True)
.first()
)
return auth if auth and auth.takeover_enabled else None
async def generate_reply(self, avatar_id: str, message: str) -> str:
"""Call the avatar chat endpoint to generate a reply."""
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"Avatar chat API returned error code: {data}")
return ""
except Exception as e:
logger.error(f"Failed to call avatar chat API: {e}")
return ""
async def execute_takeover(self, auth: Authorization, message: dict) -> bool:
"""Execute takeover: generate a reply and send it as the owner via IM."""
try:
# Resolve owner through Avatar model
avatar = self.db.query(Avatar).filter(Avatar.id == auth.avatar_id).first()
if not avatar:
logger.warning(f"Avatar not found: {auth.avatar_id}")
return False
owner_huihui_id = avatar.owner_id
credentials = await self.boxim.get_credentials(owner_huihui_id)
if not credentials:
logger.warning(f"Cannot obtain IM credentials for owner: {owner_huihui_id}")
return False
reply = await self.generate_reply(auth.avatar_id, message.get("content", ""))
if not reply:
logger.warning("Avatar did not generate a reply")
return False
success = await self.boxim.send_p2p_message(
from_accid=credentials["accid"],
to_accid=message.get("from_accid", ""),
content=reply,
)
if success:
logger.info(f"Takeover reply sent successfully: {reply[:50]}...")
return success
except Exception as e:
logger.error(f"Takeover execution failed: {e}")
return False
def enqueue_delayed_message(self, auth: Authorization, message: dict):
"""Write a message into the Redis delayed queue (TTL = delay + 10s buffer)."""
if not self.redis:
logger.warning("Redis not configured, degrading to immediate takeover")
return
avatar = self.db.query(Avatar).filter(Avatar.id == auth.avatar_id).first()
owner_huihui_id = avatar.owner_id if avatar else ""
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": owner_huihui_id,
})
self.redis.setex(key, auth.takeover_delay_seconds + 10, value)
logger.info(f"Message enqueued to delayed queue: {key}")
async def process_delayed_queue(self):
"""Process expired messages from the delayed queue.
Scans Redis keys matching the takeover:delayed: pattern and dispatches
each to execute_takeover after resolving the Authorization.
"""
if not self.redis:
return
try:
pattern = "takeover:delayed:*"
keys = self.redis.keys(pattern)
for key in keys:
raw = self.redis.get(key)
if not raw:
continue
data = json.loads(raw)
auth = (
self.db.query(Authorization)
.filter(Authorization.target_id == key.split(":")[2])
.first()
)
if auth:
message = {
"msg_id": key.split(":")[-1],
"from_accid": data.get("from_accid", ""),
"content": data.get("content", ""),
}
await self.execute_takeover(auth, message)
self.redis.delete(key)
except Exception as e:
logger.error(f"Failed to process delayed queue: {e}")
self.reply_delay_seconds = reply_delay_seconds
self.now = now
self._sessions: dict[str, dict] = {}
self._poll_lock = asyncio.Lock()
self._process_lock = asyncio.Lock()
async def poll_and_process_messages(self):
"""Periodic polling job: fetch unread messages and process each."""
"""Run one complete cycle for callers that do not use the split scheduler."""
await self.poll_messages()
await self.process_reply_tasks()
async def poll_messages(self):
"""Fetch BOXIM events without blocking reply generation and dispatch."""
if self._poll_lock.locked():
return
async with self._poll_lock:
self._recover_stuck_tasks()
avatar_ids = self._enabled_avatar_ids()
self._cancel_disabled_tasks(set(avatar_ids))
for avatar_id in avatar_ids:
await self._sync_avatar(avatar_id)
async def process_reply_tasks(self):
"""Generate and send replies independently from BOXIM's long poll."""
if self._process_lock.locked():
return
async with self._process_lock:
self._recover_stuck_tasks()
avatar_ids = set(self._enabled_avatar_ids())
self._cancel_disabled_tasks(avatar_ids)
await self._prepare_replies()
await self._dispatch_ready_replies()
def _enabled_avatar_ids(self) -> list[str]:
db = self.session_factory()
try:
messages = await self.fetch_unread_messages()
for msg in messages:
await self.process_message(msg)
except Exception as e:
logger.error(f"poll_and_process_messages failed: {e}")
return [
avatar.id
for avatar in db.query(Avatar).filter(Avatar.status == "active").all()
if _takeover_enabled(avatar)
]
finally:
db.close()
async def fetch_unread_messages(self) -> list:
"""Fetch unread messages from Box IM. Stub — replace with real API call."""
logger.debug("fetch_unread_messages: no real API wired yet")
return []
def _cancel_disabled_tasks(self, enabled_avatar_ids: set[str]):
db = self.session_factory()
try:
tasks = (
db.query(TakeoverReplyTask)
.filter(TakeoverReplyTask.status.in_(ACTIVE_TASK_STATUSES))
.all()
)
changed = False
for task in tasks:
if task.avatar_id not in enabled_avatar_ids:
task.status = "cancelled"
task.cancel_reason = "takeover_disabled"
task.locked_at = None
changed = True
if changed:
db.commit()
finally:
db.close()
async def process_message(self, message: dict):
"""Process a single message: check takeover, dispatch immediate or delayed."""
owner_id = message.get("owner_huihui_id", "")
from_id = message.get("from_accid", "")
def _recover_stuck_tasks(self):
db = self.session_factory()
try:
threshold = self.now() - timedelta(seconds=STUCK_LOCK_SECONDS)
tasks = (
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.status.in_(("generating", "sending")),
TakeoverReplyTask.locked_at.isnot(None),
TakeoverReplyTask.locked_at < threshold,
)
.all()
)
for task in tasks:
task.status = "pending" if task.status == "generating" else "ready"
task.locked_at = None
task.last_error = "上次处理意外中断,已自动恢复"
if tasks:
db.commit()
finally:
db.close()
auth = self.check_takeover_enabled(owner_id, from_id)
if not auth:
async def _boxim_session(self, user: User) -> dict:
token_fingerprint = hashlib.sha256((user.huihui_token or "").encode()).hexdigest()
cached = self._sessions.get(user.id)
if (
cached
and cached["expires_at"] > time.monotonic()
and cached["token_fingerprint"] == token_fingerprint
):
return cached
token_data = await self.boxim.exchange_access_token(user.huihui_token)
access_token = token_data["accessToken"]
profile = await self.boxim.get_self(access_token)
try:
expires_in = int(token_data.get("accessTokenExpiresIn") or 3600)
except (TypeError, ValueError):
expires_in = 3600
if expires_in > 86_400:
expires_in //= 1000
cache_for = max(60, min(expires_in - 60, 3600))
cached = {
"access_token": access_token,
"boxim_owner_id": str(profile["id"]),
"expires_at": time.monotonic() + cache_for,
"token_fingerprint": token_fingerprint,
}
self._sessions[user.id] = cached
return cached
def _forget_boxim_session(self, user_id: str):
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:
db = self.session_factory()
try:
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
if not _takeover_enabled(avatar):
return False
user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
if not cursor:
cursor = TakeoverCursor(avatar_id=avatar.id, owner_id=avatar.owner_id)
db.add(cursor)
db.flush()
if not user or not user.huihui_token:
self._disable_after_connection_failure(
db,
avatar,
cursor,
"请重新登录会会生产账号后再开启主动接管",
)
db.commit()
return False
try:
session = await self._boxim_session(user)
owner_boxim_id = session["boxim_owner_id"]
if cursor.boxim_owner_id and cursor.boxim_owner_id != owner_boxim_id:
cursor.initialized = False
cursor.last_message_id = "0"
cursor.boxim_owner_id = owner_boxim_id
messages = await self.boxim.fetch_private_messages(
session["access_token"], cursor.last_message_id or "0"
)
except Exception as exc:
if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id)
message = "BOXIM 授权已失效,请重新登录会会生产账号"
else:
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
self._disable_after_connection_failure(db, avatar, cursor, message)
db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc)
return False
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
priming = not bool(cursor.initialized)
max_message_id = _numeric_id(cursor.last_message_id)
read_receipts: dict[str, int] = {}
for message in messages:
self._record_message(
db,
avatar,
cursor.boxim_owner_id,
message,
schedule_reply=not priming,
)
message_id = _numeric_id(message.get("id"))
max_message_id = max(max_message_id, message_id)
send_id = str(message.get("sendId") or "")
recv_id = str(message.get("recvId") or "")
if recv_id == cursor.boxim_owner_id and send_id and message_id:
read_receipts[send_id] = max(read_receipts.get(send_id, 0), message_id)
# BOXIM publishes this HTTP state change to connected socket clients.
# Do it before advancing the cursor so a failed receipt is retried.
for peer_id, message_id in read_receipts.items():
await self.boxim.mark_private_messages_read(
session["access_token"], peer_id, message_id
)
cursor.last_message_id = str(max_message_id)
cursor.initialized = True
cursor.last_polled_at = self.now()
cursor.last_error = ""
db.commit()
return True
except Exception:
db.rollback()
logger.exception("Failed to persist BOXIM messages for avatar %s", avatar_id)
return False
finally:
db.close()
def _record_message(
self,
db: Session,
avatar: Avatar,
boxim_owner_id: str,
message: dict,
*,
schedule_reply: bool,
):
message_id = str(message.get("id") or "").strip()
if not message_id:
return
local_id = str(message.get("localId") or "").strip() or None
if (
db.query(TakeoverMessage)
.filter(
TakeoverMessage.owner_id == avatar.owner_id,
TakeoverMessage.boxim_message_id == message_id,
)
.first()
):
return
if auth.takeover_mode == "immediate":
await self.execute_takeover(auth, message)
send_id = str(message.get("sendId") or "")
recv_id = str(message.get("recvId") or "")
if send_id == boxim_owner_id:
direction, peer_id = "outgoing", recv_id
elif recv_id == boxim_owner_id:
direction, peer_id = "incoming", send_id
else:
self.enqueue_delayed_message(auth, message)
return
if not peer_id:
return
now = self.now()
send_time = _boxim_time(message.get("sendTime"), now)
is_avatar = False
if direction == "outgoing" and local_id:
is_avatar = bool(
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.owner_id == avatar.owner_id,
TakeoverReplyTask.boxim_local_id == local_id,
TakeoverReplyTask.status == "sent",
)
.first()
)
event = TakeoverMessage(
avatar_id=avatar.id,
owner_id=avatar.owner_id,
boxim_message_id=message_id,
boxim_local_id=local_id,
peer_id=peer_id,
direction=direction,
message_type=int(message.get("type") or 0),
content=str(message.get("content") or ""),
is_avatar=is_avatar,
send_time=send_time,
)
db.add(event)
db.flush()
if direction == "outgoing":
if not is_avatar:
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_replied")
return
if not schedule_reply or event.message_type != 0 or not event.content.strip():
return
if (now - send_time).total_seconds() > MAX_STALE_SECONDS:
return
self._schedule_reply(db, avatar, event)
@staticmethod
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
tasks = (
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.owner_id == owner_id,
TakeoverReplyTask.peer_id == peer_id,
TakeoverReplyTask.status.in_(ACTIVE_TASK_STATUSES),
)
.all()
)
for task in tasks:
task.status = "cancelled"
task.cancel_reason = reason
task.locked_at = None
def _schedule_reply(self, db: Session, avatar: Avatar, event: TakeoverMessage):
active_tasks = (
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.owner_id == avatar.owner_id,
TakeoverReplyTask.peer_id == event.peer_id,
TakeoverReplyTask.status.in_(("pending", "generating", "ready")),
)
.order_by(TakeoverReplyTask.created_at.desc())
.all()
)
prompt_parts = []
source_ids = []
if active_tasks:
latest = active_tasks[0]
prompt_parts.append(latest.prompt)
source_ids.extend(latest.source_message_ids or [])
for task in active_tasks:
task.status = "cancelled"
task.cancel_reason = "newer_incoming_message"
task.locked_at = None
prompt_parts.append(event.content.strip())
source_ids.append(event.boxim_message_id)
prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:]
due_at = event.send_time + timedelta(seconds=self.reply_delay_seconds)
task_id = secrets.token_hex(16)
local_id = int(time.time() * 1000) * 1000 + secrets.randbelow(1000)
db.add(
TakeoverReplyTask(
id=task_id,
avatar_id=avatar.id,
owner_id=avatar.owner_id,
peer_id=event.peer_id,
trigger_message_id=event.boxim_message_id,
source_message_ids=source_ids,
prompt=prompt,
status="pending",
scheduled_at=due_at,
boxim_local_id=str(local_id),
)
)
async def _prepare_replies(self) -> int:
db = self.session_factory()
try:
task_ids = [
row[0]
for row in (
db.query(TakeoverReplyTask.id)
.filter(
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
TakeoverReplyTask.response_text == "",
)
.order_by(TakeoverReplyTask.created_at.asc())
.limit(10)
.all()
)
]
finally:
db.close()
if not task_ids:
return 0
# Each conversation owns its task, so unrelated contacts can generate in
# parallel instead of one slow model response delaying every other peer.
semaphore = asyncio.Semaphore(4)
async def generate(task_id: str) -> bool:
async with semaphore:
return await asyncio.to_thread(self._generate_reply, task_id)
results = await asyncio.gather(*(generate(task_id) for task_id in task_ids))
return sum(bool(result) for result in results)
def _generate_reply(self, task_id: str) -> bool:
db = self.session_factory()
try:
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.id == task_id).first()
if not task or task.status != "pending":
return False
avatar = db.query(Avatar).filter(Avatar.id == task.avatar_id).first()
if not _takeover_enabled(avatar):
task.status = "cancelled"
task.cancel_reason = "takeover_disabled"
db.commit()
return False
task.status = "generating"
task.locked_at = self.now()
db.commit()
excluded_ids = set(task.source_message_ids or [])
events = (
db.query(TakeoverMessage)
.filter(
TakeoverMessage.owner_id == task.owner_id,
TakeoverMessage.peer_id == task.peer_id,
)
.order_by(TakeoverMessage.send_time.desc())
.limit(30)
.all()
)
history = []
for event in reversed(events):
if event.boxim_message_id in excluded_ids or not event.content.strip():
continue
history.append(
{
"role": "user" if event.direction == "incoming" else "assistant",
"content": event.content.strip(),
}
)
history = history[-10:]
from routers.chat import _resolve_reply
result = _resolve_reply(db, avatar, task.prompt, history, usage_source="takeover")
answer = _plain_text_reply(result.get("answer", ""))
db.refresh(task)
if task.status != "generating":
return False
if not answer:
raise RuntimeError("分身没有生成有效回复")
task.response_text = answer
task.status = "ready"
task.locked_at = None
task.last_error = ""
db.commit()
return True
except Exception as exc:
db.rollback()
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.id == task_id).first()
if task and task.status in ("pending", "generating"):
task.attempts = (task.attempts or 0) + 1
task.status = "pending" if task.attempts < 3 else "failed"
task.locked_at = None
task.last_error = str(exc)[:300]
db.commit()
logger.warning("Failed to prepare takeover reply %s: %s", task_id, exc)
return False
finally:
db.close()
async def _dispatch_ready_replies(self):
db = self.session_factory()
try:
task_ids = [
row[0]
for row in (
db.query(TakeoverReplyTask.id)
.filter(
TakeoverReplyTask.status == "ready",
TakeoverReplyTask.scheduled_at <= self.now(),
)
.order_by(TakeoverReplyTask.scheduled_at.asc())
.limit(10)
.all()
)
]
finally:
db.close()
if task_ids:
await asyncio.gather(*(self._send_task(task_id) for task_id in task_ids))
async def _send_task(self, task_id: str) -> bool:
db = self.session_factory()
user = None
try:
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.id == task_id).first()
if not task or task.status != "ready":
return False
avatar = db.query(Avatar).filter(Avatar.id == task.avatar_id).first()
if not _takeover_enabled(avatar):
task.status = "cancelled"
task.cancel_reason = "takeover_disabled"
db.commit()
return False
if (self.now() - task.scheduled_at).total_seconds() > MAX_STALE_SECONDS:
task.status = "cancelled"
task.cancel_reason = "stale_reply"
db.commit()
return False
user = db.query(User).filter(User.huihui_user_id == task.owner_id).first()
if not user or not user.huihui_token:
raise BoxIMError("缺少会会登录凭证", auth_error=True)
task.status = "sending"
task.locked_at = self.now()
db.commit()
session = await self._boxim_session(user)
result = await self.boxim.send_private_message(
session["access_token"],
task.peer_id,
task.response_text,
local_id=task.boxim_local_id,
)
db.refresh(task)
if task.status != "sending":
return False
task.status = "sent"
task.sent_at = self.now()
task.locked_at = None
task.last_error = ""
task.boxim_sent_message_id = str(result.get("id") or "")
db.commit()
logger.info("BOXIM takeover reply sent for task %s", task.id)
return True
except Exception as exc:
db.rollback()
if user and isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id)
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.id == task_id).first()
if task and task.status in ("ready", "sending"):
task.attempts = (task.attempts or 0) + 1
task.status = "ready" if task.attempts < 3 else "failed"
task.locked_at = None
task.last_error = str(exc)[:300]
if task.status == "ready":
task.scheduled_at = self.now() + timedelta(seconds=2 ** task.attempts)
db.commit()
logger.warning("Failed to send takeover reply %s: %s", task_id, exc)
return False
finally:
db.close()
@@ -0,0 +1,198 @@
"""User-scoped token accounting for every avatar model request."""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from models import Avatar, TokenAccount, TokenUsage, User
DEFAULT_TOKEN_GRANT = 1_000_000
class InsufficientTokensError(RuntimeError):
pass
@dataclass(frozen=True)
class TokenReservation:
usage_id: str
user_id: str
reserved_tokens: int
def get_or_create_account(db: Session, user_id: str) -> TokenAccount:
account = db.query(TokenAccount).filter(TokenAccount.user_id == user_id).first()
if account:
return account
account = TokenAccount(
user_id=user_id,
balance=DEFAULT_TOKEN_GRANT,
total_granted=DEFAULT_TOKEN_GRANT,
total_consumed=0,
)
db.add(account)
try:
db.commit()
except IntegrityError:
# A concurrent first request may have created the same user account.
db.rollback()
account = db.query(TokenAccount).filter(TokenAccount.user_id == user_id).first()
if account is None:
raise
db.refresh(account)
return account
def avatar_owner_user(db: Session, avatar: Avatar) -> User | None:
owner_id = (avatar.owner_id or "").strip()
if not owner_id:
return None
return db.query(User).filter(User.huihui_user_id == owner_id).first()
def estimate_request_tokens(messages: list[dict], max_output_tokens: int) -> int:
# UTF-8 bytes / 2 deliberately overestimates mixed Chinese/English prompts;
# the unused reservation is returned after provider usage is received.
content_bytes = sum(
len(str(item.get("content", "")).encode("utf-8"))
for item in messages
)
prompt_reserve = max(1, math.ceil(content_bytes / 2) + len(messages) * 6)
return prompt_reserve + max(1, int(max_output_tokens))
def estimate_fallback_usage(messages: list[dict], output: str) -> int:
content_bytes = sum(
len(str(item.get("content", "")).encode("utf-8"))
for item in messages
) + len((output or "").encode("utf-8"))
return max(1, math.ceil(content_bytes / 3) + len(messages) * 4)
def reserve_avatar_tokens(
db: Session,
avatar: Avatar,
source: str,
model: str,
messages: list[dict],
max_output_tokens: int,
) -> TokenReservation:
user = avatar_owner_user(db, avatar)
if not user:
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用 Token")
account = get_or_create_account(db, user.id)
reserved = estimate_request_tokens(messages, max_output_tokens)
updated = (
db.query(TokenAccount)
.filter(TokenAccount.id == account.id, TokenAccount.balance >= reserved)
.update(
{TokenAccount.balance: TokenAccount.balance - reserved},
synchronize_session=False,
)
)
if updated != 1:
db.rollback()
raise InsufficientTokensError("Token 余额不足,请充值后继续")
db.refresh(account)
usage = TokenUsage(
user_id=user.id,
avatar_id=avatar.id,
source=source,
model=model,
status="reserved",
reserved_tokens=reserved,
)
db.add(usage)
db.flush()
usage.balance_after = account.balance
db.commit()
return TokenReservation(usage.id, user.id, reserved)
def settle_reservation(
db: Session,
reservation: TokenReservation,
usage: dict | None,
*,
fallback_total: int,
) -> dict:
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
if not record or record.status != "reserved":
return {}
provider_usage = usage or {}
prompt_tokens = max(0, int(provider_usage.get("prompt_tokens") or 0))
completion_tokens = max(0, int(provider_usage.get("completion_tokens") or 0))
provider_total = max(
int(provider_usage.get("total_tokens") or 0),
prompt_tokens + completion_tokens,
)
total_tokens = max(1, provider_total or int(fallback_total or 0))
updated = (
db.query(TokenAccount)
.filter(TokenAccount.user_id == reservation.user_id)
.update(
{
TokenAccount.balance: TokenAccount.balance + reservation.reserved_tokens - total_tokens,
TokenAccount.total_consumed: TokenAccount.total_consumed + total_tokens,
},
synchronize_session=False,
)
)
if updated != 1:
raise RuntimeError("Token 账户不存在")
db.expire_all()
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
record.prompt_tokens = prompt_tokens
record.completion_tokens = completion_tokens
record.total_tokens = total_tokens
record.balance_after = account.balance
record.status = "completed"
record.settled_at = datetime.utcnow()
db.commit()
return {
"promptTokens": prompt_tokens,
"completionTokens": completion_tokens,
"totalTokens": total_tokens,
"balance": account.balance,
}
def release_reservation(db: Session, reservation: TokenReservation, reason: str = "") -> None:
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
if not record or record.status != "reserved":
return
updated = (
db.query(TokenAccount)
.filter(TokenAccount.user_id == reservation.user_id)
.update(
{TokenAccount.balance: TokenAccount.balance + reservation.reserved_tokens},
synchronize_session=False,
)
)
if updated:
db.expire_all()
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
record.balance_after = account.balance
record.status = "failed"
record.failure_reason = (reason or "model_request_failed")[:255]
record.settled_at = datetime.utcnow()
db.commit()
def release_stale_reservations(db: Session, older_than_minutes: int = 10) -> int:
cutoff = datetime.utcnow() - timedelta(minutes=older_than_minutes)
stale = db.query(TokenUsage).filter(
TokenUsage.status == "reserved",
TokenUsage.created_at < cutoff,
).all()
for record in stale:
release_reservation(
db,
TokenReservation(record.id, record.user_id, int(record.reserved_tokens or 0)),
"stale_reservation_recovered",
)
return len(stale)
+98 -1
View File
@@ -1,6 +1,17 @@
import uuid
import pytest
from database import init_db, SessionLocal
from models import Authorization
from models import (
Authorization,
Avatar,
TakeoverCursor,
TakeoverMessage,
TakeoverReplyTask,
TokenAccount,
TokenUsage,
User,
)
@pytest.fixture(scope="session", autouse=True)
@@ -24,3 +35,89 @@ def setup_database():
db.commit()
finally:
db.close()
@pytest.fixture
def authorization_context():
"""Create isolated users, avatars, and one authorization for API tests."""
suffix = uuid.uuid4().hex
owner = User(
id=f"owner-{suffix}",
huihui_user_id=f"huihui-owner-{suffix}",
nickname="授权测试用户",
app_token=f"owner-token-{suffix}",
)
other = User(
id=f"other-{suffix}",
huihui_user_id=f"huihui-other-{suffix}",
nickname="其他用户",
app_token=f"other-token-{suffix}",
)
avatar = Avatar(
id=f"avatar-{suffix}",
owner_id=owner.huihui_user_id,
name="授权测试分身",
status="active",
config={},
)
other_avatar = Avatar(
id=f"other-avatar-{suffix}",
owner_id=other.huihui_user_id,
name="其他分身",
status="active",
config={},
)
authorization = Authorization(
id=f"authorization-{suffix}",
avatar_id=avatar.id,
target_type="user",
target_id=f"contact-{suffix}",
target_name="测试联系人",
permissions=["chat", "browse"],
status="active",
)
db = SessionLocal()
try:
db.add_all([owner, other, avatar, other_avatar, authorization])
db.commit()
yield {
"owner": owner,
"other": other,
"avatar": avatar,
"other_avatar": other_avatar,
"authorization": authorization,
"owner_headers": {"Authorization": f"Bearer {owner.app_token}"},
"other_headers": {"Authorization": f"Bearer {other.app_token}"},
"suffix": suffix,
}
finally:
db.rollback()
avatar_ids = [avatar.id, other_avatar.id]
db.query(TakeoverReplyTask).filter(
TakeoverReplyTask.avatar_id.in_(avatar_ids)
).delete(synchronize_session=False)
db.query(TakeoverMessage).filter(
TakeoverMessage.avatar_id.in_(avatar_ids)
).delete(synchronize_session=False)
db.query(TakeoverCursor).filter(
TakeoverCursor.avatar_id.in_(avatar_ids)
).delete(synchronize_session=False)
db.query(Authorization).filter(
Authorization.avatar_id.in_(avatar_ids)
).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
synchronize_session=False
)
user_ids = [owner.id, other.id]
db.query(TokenUsage).filter(TokenUsage.user_id.in_(user_ids)).delete(
synchronize_session=False
)
db.query(TokenAccount).filter(TokenAccount.user_id.in_(user_ids)).delete(
synchronize_session=False
)
db.query(User).filter(User.id.in_([owner.id, other.id])).delete(
synchronize_session=False
)
db.commit()
db.close()
@@ -0,0 +1,158 @@
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_authorization_list_is_scoped_to_owned_avatar(authorization_context):
context = authorization_context
response = client.get(
f"/api/avatar/{context['avatar'].id}/authorizations",
headers=context["owner_headers"],
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 200
assert [item["id"] for item in payload["data"]] == [context["authorization"].id]
forbidden = client.get(
f"/api/avatar/{context['other_avatar'].id}/authorizations",
headers=context["owner_headers"],
)
assert forbidden.status_code == 403
def test_create_update_and_delete_authorization(authorization_context):
context = authorization_context
avatar_id = context["avatar"].id
target_id = f"new-contact-{context['suffix']}"
created = client.post(
f"/api/avatar/{avatar_id}/authorizations",
headers=context["owner_headers"],
json={
"targetType": "user",
"targetId": target_id,
"targetName": "新联系人",
"permissions": ["friend", "chat", "browse"],
},
).json()
assert created["code"] == 200
authorization_id = created["data"]["id"]
assert created["data"]["permissions"] == ["friend", "chat", "browse"]
duplicate = client.post(
f"/api/avatar/{avatar_id}/authorizations",
headers=context["owner_headers"],
json={
"targetType": "user",
"targetId": target_id,
"targetName": "重复联系人",
"permissions": ["chat"],
},
).json()
assert duplicate["code"] == 409
updated = client.put(
f"/api/avatar/{avatar_id}/authorizations",
headers=context["owner_headers"],
json={
"id": authorization_id,
"targetName": "联系人新名称",
"permissions": ["interact", "publish"],
},
).json()
assert updated["code"] == 200
assert updated["data"]["targetName"] == "联系人新名称"
assert updated["data"]["permissions"] == ["publish", "interact"]
deleted = client.delete(
f"/api/avatar/{avatar_id}/authorizations/{authorization_id}",
headers=context["owner_headers"],
).json()
assert deleted["code"] == 200
assert deleted["data"]["id"] == authorization_id
def test_authorization_requires_login_and_rejects_unknown_permissions(authorization_context):
context = authorization_context
avatar_id = context["avatar"].id
no_session = client.get(f"/api/avatar/{avatar_id}/authorizations")
assert no_session.status_code == 401
invalid = client.post(
f"/api/avatar/{avatar_id}/authorizations",
headers=context["owner_headers"],
json={
"targetType": "user",
"targetId": "invalid-target",
"targetName": "无效权限",
"permissions": ["admin"],
},
).json()
assert invalid["code"] == 400
def test_avatar_permission_settings_default_and_persist(authorization_context):
context = authorization_context
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
initial = client.get(endpoint, headers=context["owner_headers"]).json()
assert initial["code"] == 200
assert initial["data"] == {
"avatarId": context["avatar"].id,
"permissions": ["friend", "chat"],
}
updated = client.put(
endpoint,
headers=context["owner_headers"],
json={"permissions": ["interact", "takeover", "publish", "friend", "friend"]},
).json()
assert updated["code"] == 200
assert updated["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
def test_avatar_permission_settings_allow_all_disabled(authorization_context):
context = authorization_context
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
response = client.put(
endpoint,
headers=context["owner_headers"],
json={"permissions": []},
).json()
assert response["code"] == 200
assert response["data"]["permissions"] == []
def test_avatar_permission_settings_validate_owner_and_permissions(authorization_context):
context = authorization_context
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
invalid = client.put(
endpoint,
headers=context["owner_headers"],
json={"permissions": ["admin"]},
).json()
assert invalid["code"] == 400
missing = client.put(
endpoint,
headers=context["owner_headers"],
json={},
).json()
assert missing["code"] == 400
forbidden = client.get(
f"/api/avatar/{context['other_avatar'].id}/permission-settings",
headers=context["owner_headers"],
)
assert forbidden.status_code == 403
unauthenticated = client.get(endpoint)
assert unauthenticated.status_code == 401
@@ -1,134 +1,130 @@
"""Tests for the Box IM client (Netease Yunxin gateway wrapper)."""
import pytest
"""Contract tests for the self-hosted BOXIM client."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from services.boxim_client import BoxIMClient, BoxIMError
@pytest.fixture
def mock_config():
def config():
return {
"HUIHUI_IM_BASE_URL": "http://192.168.1.200:60040",
"HUIHUI_PLATFORM_BASE_URL": "https://open.example/api",
"BOXIM_API_BASE_URL": "https://im.example/api",
"HUIHUI_APP_ID": "test_app",
"HUIHUI_ACCESS_ID": "test_access",
"HUIHUI_ACCESS_SECRET": "test_secret",
}
def _make_mock_response(json_data: dict):
"""Create a properly configured mock for httpx.Response."""
mock_response = MagicMock()
mock_response.json.return_value = json_data
return mock_response
def _response(payload: dict, status_code: int = 200):
response = MagicMock()
response.status_code = status_code
response.json.return_value = payload
return response
def _patch_httpx_client(json_data: dict):
"""Patch httpx.AsyncClient so that `async with httpx.AsyncClient() as c: await c.post(...)` returns json_data."""
mock_client = AsyncMock()
mock_client.post.return_value = _make_mock_response(json_data)
mock_cm = AsyncMock()
mock_cm.__aenter__.return_value = mock_client
mock_cm.__aexit__.return_value = None
return patch("httpx.AsyncClient", return_value=mock_cm)
def _client_patch(*, post_payload=None, request_payload=None, status_code=200):
client = AsyncMock()
if post_payload is not None:
client.post.return_value = _response(post_payload, status_code)
if request_payload is not None:
client.request.return_value = _response(request_payload, status_code)
context = AsyncMock()
context.__aenter__.return_value = client
context.__aexit__.return_value = None
return patch("services.boxim_client.httpx.AsyncClient", return_value=context), client
@pytest.mark.asyncio
async def test_get_credentials(mock_config):
"""get_credentials should return accid and token from the gateway response."""
with _patch_httpx_client({"code": 200, "data": {"accid": "user123", "token": "tok_xyz"}}):
from services.boxim_client import BoxIMClient
async def test_exchange_access_token_uses_huihui_bearer_and_signed_form(config):
mocked, client = _client_patch(
post_payload={"code": 0, "data": {"accessToken": "box-token", "accessTokenExpiresIn": 3600}}
)
with mocked:
result = await BoxIMClient(config).exchange_access_token("huihui-token")
client = BoxIMClient(mock_config)
result = await client.get_credentials("user123")
assert result["accid"] == "user123"
assert result["token"] == "tok_xyz"
assert result["accessToken"] == "box-token"
call = client.post.await_args
assert call.args[0] == "https://open.example/api/im/box/netease"
assert call.kwargs["headers"]["Authorization"] == "Bearer huihui-token"
assert call.kwargs["data"]["appId"] == "test_app"
assert len(call.kwargs["data"]["signature"]) == 32
@pytest.mark.asyncio
async def test_send_p2p_message_success(mock_config):
"""send_p2p_message should return True when the gateway responds with code 200."""
with _patch_httpx_client({"code": 200}):
from services.boxim_client import BoxIMClient
async def test_get_self_and_incremental_private_messages_use_boxim_header(config):
client_instance = BoxIMClient(config)
mocked, client = _client_patch(
request_payload={"code": 200, "data": {"id": 42, "nickName": "Owner"}}
)
with mocked:
profile = await client_instance.get_self("box-token")
assert profile["id"] == 42
assert client.request.await_args.kwargs["headers"] == {"accessToken": "box-token"}
client = BoxIMClient(mock_config)
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
assert result is True
mocked, client = _client_patch(
request_payload={"code": 200, "data": [{"id": 101, "sendId": 7, "recvId": 42}]}
)
with mocked:
messages = await client_instance.fetch_private_messages("box-token", "100")
assert messages[0]["id"] == 101
assert client.request.await_args.kwargs["params"] == {"minId": "100"}
@pytest.mark.asyncio
async def test_send_p2p_message_failure(mock_config):
"""send_p2p_message should return False when the gateway responds with a non-200 code."""
with _patch_httpx_client({"code": 500, "message": "error"}):
from services.boxim_client import BoxIMClient
async def test_send_private_message_matches_boxim_payload(config):
mocked, client = _client_patch(
request_payload={"code": 200, "data": {"id": 88, "localId": 12345}}
)
with mocked:
result = await BoxIMClient(config).send_private_message(
"box-token", "77", "你好", local_id="12345"
)
client = BoxIMClient(mock_config)
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
assert result is False
assert result["id"] == 88
call = client.request.await_args
assert call.args[:2] == ("POST", "https://im.example/api/message/private/send")
assert call.kwargs["json"] == {
"localId": 12345,
"recvId": 77,
"content": "你好",
"type": 0,
"receipt": False,
"atUserIds": [],
}
@pytest.mark.asyncio
async def test_get_credentials_returns_none_on_error(mock_config):
"""get_credentials should return None when the gateway responds with an error code."""
with _patch_httpx_client({"code": 500, "message": "user not found"}):
from services.boxim_client import BoxIMClient
async def test_mark_private_messages_read_uses_latest_message_id(config):
mocked, client = _client_patch(request_payload={"code": 200, "data": None})
with mocked:
await BoxIMClient(config).mark_private_messages_read("box-token", "77", "101")
client = BoxIMClient(mock_config)
result = await client.get_credentials("nonexistent")
assert result is None
call = client.request.await_args
assert call.args[:2] == ("PUT", "https://im.example/api/message/private/readed")
assert call.kwargs["headers"] == {"accessToken": "box-token"}
assert call.kwargs["params"] == {"friendId": 77, "messageId": 101}
def test_build_sign_params_contains_required_fields(mock_config):
"""_build_sign_params should produce appId, accessId, nonce, timestamp, signature, signType, signVersion."""
from services.boxim_client import BoxIMClient
@pytest.mark.asyncio
async def test_boxim_auth_error_is_explicit(config):
mocked, _ = _client_patch(
request_payload={"code": 400, "message": "未登录"}, status_code=200
)
with mocked, pytest.raises(BoxIMError) as exc_info:
await BoxIMClient(config).get_self("expired")
assert exc_info.value.auth_error is True
client = BoxIMClient(mock_config)
params = client._build_sign_params({"userId": "u1"})
assert "appId" in params
assert "accessId" in params
assert "nonce" in params
assert "timestamp" in params
assert "signature" in params
def test_sign_params_include_production_required_fields(config):
params = BoxIMClient(config)._build_sign_params()
assert params["appId"] == "test_app"
assert params["accessId"] == "test_access"
assert params["signType"] == "MD5"
assert params["signVersion"] == "1.0"
assert len(params["nonce"]) == 12
def test_build_sign_params_excludes_signature_and_accessSecret_from_signing_string(mock_config):
"""signature and accessSecret must be excluded from the signing string to match news_service.py."""
from services.boxim_client import BoxIMClient
client = BoxIMClient(mock_config)
# Pass params that already contain a stale "signature" value
params_with_stale_sig = client._build_sign_params({
"userId": "u1",
"signature": "OLD_STALE_SIG",
})
# The returned signature must be freshly computed (32-char MD5 uppercase),
# NOT the stale value we passed in.
assert params_with_stale_sig["signature"] != "OLD_STALE_SIG"
assert len(params_with_stale_sig["signature"]) == 32
# Calling with the same extra params but no stale signature should also work.
params_clean = client._build_sign_params({"userId": "u1"})
assert len(params_clean["signature"]) == 32
def test_build_sign_params_signature_is_deterministic(mock_config):
"""Same inputs should produce valid MD5 signatures."""
from services.boxim_client import BoxIMClient
client = BoxIMClient(mock_config)
params1 = client._build_sign_params({"userId": "u1"})
params2 = client._build_sign_params({"userId": "u1"})
assert params1["signature"] is not None
assert params2["signature"] is not None
assert len(params1["signature"]) == 32 # MD5 hex length
assert len(params["timestamp"]) == 14
assert len(params["signature"]) == 32
assert "accessSecret" not in params
@@ -0,0 +1,79 @@
from unittest.mock import Mock, patch
import httpx
from services.chat_model_config import (
clear_chat_model_config_cache,
get_chat_model_config,
)
def setup_function():
clear_chat_model_config_cache()
def teardown_function():
clear_chat_model_config_cache()
def test_admin_runtime_config_takes_priority(monkeypatch):
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "http://config.test/runtime")
monkeypatch.setenv("AVATAR_MODEL_CONFIG_TOKEN", "shared-secret")
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {
"data": {
"api_base_url": "https://model.test/v1/",
"api_key": "runtime-key",
"model": "avatar-model",
"max_tokens": 2048,
"timeout_seconds": 42,
}
}
with patch("services.chat_model_config.httpx.get", return_value=response) as request:
config = get_chat_model_config()
assert config.source == "admin"
assert config.api_base_url == "https://model.test/v1"
assert config.model == "avatar-model"
assert config.max_tokens == 2048
request.assert_called_once_with(
"http://config.test/runtime",
headers={"X-Avatar-Config-Token": "shared-secret"},
timeout=5.0,
)
def test_runtime_failure_falls_back_to_environment(monkeypatch):
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "http://config.test/runtime")
monkeypatch.setenv("AVATAR_MODEL_CONFIG_TOKEN", "shared-secret")
monkeypatch.setenv("CHAT_API_URL", "https://fallback.test/v1/")
monkeypatch.setenv("CHAT_API_KEY", "fallback-key")
monkeypatch.setenv("CHAT_MODEL", "fallback-model")
monkeypatch.setenv("CHAT_MAX_OUTPUT_TOKENS", "1536")
request = httpx.Request("GET", "http://config.test/runtime")
with patch(
"services.chat_model_config.httpx.get",
side_effect=httpx.ConnectError("offline", request=request),
):
config = get_chat_model_config()
assert config.source == "environment"
assert config.api_base_url == "https://fallback.test/v1"
assert config.api_key == "fallback-key"
assert config.model == "fallback-model"
assert config.max_tokens == 1536
def test_runtime_config_is_cached(monkeypatch):
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "")
monkeypatch.setenv("CHAT_MODEL", "first-model")
first = get_chat_model_config()
monkeypatch.setenv("CHAT_MODEL", "second-model")
second = get_chat_model_config()
assert first is second
assert second.model == "first-model"
@@ -1,10 +1,26 @@
import json
import os
import tempfile
import unittest
from unittest.mock import patch
import embeddings
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, *_):
return None
def read(self):
return json.dumps(self.payload).encode("utf-8")
class TextExtractionTests(unittest.TestCase):
def write_text(self, suffix, content):
handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
@@ -28,5 +44,33 @@ class TextExtractionTests(unittest.TestCase):
embeddings.extract_text(path, ".csv")
class RemoteEmbeddingTests(unittest.TestCase):
def test_large_input_is_split_into_provider_safe_batches(self):
texts = [f"chunk-{index}" for index in range(14)]
batch_sizes = []
def fake_urlopen(request, timeout):
self.assertEqual(timeout, 30)
payload = json.loads(request.data.decode("utf-8"))
batch_sizes.append(len(payload["input"]))
return FakeResponse({
"data": [
{"index": index, "embedding": [float(text.split("-")[1])]}
for index, text in enumerate(payload["input"])
]
})
with patch.dict(os.environ, {
"EMBEDDING_API_URL": "https://embedding.example/v1/embeddings",
"EMBEDDING_API_KEY": "test-key",
"EMBEDDING_MODEL": "text-embedding-v4",
"EMBEDDING_BATCH_SIZE": "10",
}), patch("embeddings.urllib.request.urlopen", side_effect=fake_urlopen):
result = embeddings.embed(texts)
self.assertEqual(batch_sizes, [10, 4])
self.assertEqual(result, [[float(index)] for index in range(14)])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,191 @@
"""Tests for preserving local avatar ownership when Huihui IDs change."""
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from routers.huihui_auth import _issue_session, token_login
from services.boxim_client import BoxIMError
@pytest.fixture
def db():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
try:
yield session
finally:
session.close()
def _add_avatar_data(db, owner_id: str, suffix: str = "1") -> Avatar:
avatar = Avatar(id=f"avatar-{suffix}", owner_id=owner_id, name="冯医生")
db.add_all(
[
avatar,
TakeoverCursor(id=f"cursor-{suffix}", avatar_id=avatar.id, owner_id=owner_id),
TakeoverMessage(
id=f"message-{suffix}",
avatar_id=avatar.id,
owner_id=owner_id,
boxim_message_id=f"box-{suffix}",
peer_id="peer",
direction="incoming",
send_time=datetime(2026, 8, 20, 12, 0, 0),
),
TakeoverReplyTask(
id=f"task-{suffix}",
avatar_id=avatar.id,
owner_id=owner_id,
peer_id="peer",
trigger_message_id=f"trigger-{suffix}",
scheduled_at=datetime(2026, 8, 20, 12, 0, 3),
boxim_local_id=f"local-{suffix}",
),
]
)
db.commit()
return avatar
def _assert_avatar_data_owner(db, avatar_id: str, owner_id: str):
assert db.query(Avatar).filter_by(id=avatar_id).one().owner_id == owner_id
assert db.query(TakeoverCursor).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
assert db.query(TakeoverMessage).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
assert db.query(TakeoverReplyTask).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
def test_unique_phone_user_is_reused_when_huihui_id_changes(db):
legacy = User(
id="legacy-local",
huihui_user_id="fat-user-id",
phone="18500000000",
app_token="old-session",
)
db.add(legacy)
db.commit()
avatar = _add_avatar_data(db, legacy.huihui_user_id)
response = _issue_session(
db,
"18500000000",
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
)
users = db.query(User).all()
assert len(users) == 1
assert users[0].id == "legacy-local"
assert users[0].huihui_user_id == "prod-user-id"
assert response["data"]["token"] == users[0].app_token
_assert_avatar_data_owner(db, avatar.id, "prod-user-id")
def test_existing_production_user_claims_one_legacy_phone_account(db):
current = User(
id="prod-local",
huihui_user_id="prod-user-id",
phone="18500000000",
)
legacy = User(
id="legacy-local",
huihui_user_id="fat-user-id",
phone="18500000000",
app_token="old-session",
huihui_token="fat-token",
)
db.add_all([current, legacy])
db.commit()
avatar = _add_avatar_data(db, legacy.huihui_user_id)
_issue_session(
db,
"18500000000",
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
)
db.refresh(legacy)
assert legacy.app_token == ""
assert legacy.huihui_token == ""
_assert_avatar_data_owner(db, avatar.id, "prod-user-id")
def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
first = User(id="first", huihui_user_id="fat-1", phone="18500000000")
second = User(id="second", huihui_user_id="fat-2", phone="18500000000")
db.add_all([first, second])
db.commit()
first_avatar = _add_avatar_data(db, first.huihui_user_id, "1")
second_avatar = _add_avatar_data(db, second.huihui_user_id, "2")
_issue_session(
db,
"18500000000",
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
)
assert db.query(User).count() == 3
_assert_avatar_data_owner(db, first_avatar.id, "fat-1")
_assert_avatar_data_owner(db, second_avatar.id, "fat-2")
@pytest.mark.asyncio
async def test_token_login_uses_huihui_user_id_and_keeps_upstream_token_server_side(db):
existing = User(
id="existing-local",
huihui_user_id="huihui-user-88",
app_token="existing-app-session",
)
db.add(existing)
db.commit()
client = AsyncMock()
client.exchange_access_token.return_value = {"accessToken": "boxim-token"}
client.get_self.return_value = {
"id": 998877,
"huihuiUserId": "huihui-user-88",
"nickName": "会会用户",
"headImage": "https://cdn.example/avatar.jpg",
}
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
"routers.huihui_auth._create_boxim_client", return_value=client
):
response = await token_login({"token": "production-huihui-token"}, db)
assert response["code"] == 200
assert response["data"]["token"] == "existing-app-session"
assert "token" not in response["data"]["huihui"]
user = db.query(User).one()
assert user.huihui_user_id == "huihui-user-88"
assert user.huihui_user_id != "998877"
assert user.huihui_token == "production-huihui-token"
assert user.nickname == "会会用户"
assert user.avatar_url == "https://cdn.example/avatar.jpg"
client.exchange_access_token.assert_awaited_once_with("production-huihui-token")
client.get_self.assert_awaited_once_with("boxim-token")
@pytest.mark.asyncio
async def test_token_login_rejects_expired_huihui_token_without_creating_user(db):
client = AsyncMock()
client.exchange_access_token.side_effect = BoxIMError(
"expired", auth_error=True
)
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
"routers.huihui_auth._create_boxim_client", return_value=client
):
response = await token_login({"token": "expired-token"}, db)
assert response["code"] == 401
assert response["message"] == "会会登录凭证无效或已过期"
assert db.query(User).count() == 0
@@ -0,0 +1,23 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from routers.knowledge import _doc_payload
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
avatar_id = "avatar-1"
stored_name = "knowledge.md"
doc = SimpleNamespace(
avatar_id=avatar_id,
file_url=f"/api/files/{avatar_id}/{stored_name}",
to_dict=lambda: {"id": "doc-1", "fileUrl": f"/api/files/{avatar_id}/{stored_name}"},
)
stored_dir = tmp_path / avatar_id
stored_dir.mkdir()
stored_file = stored_dir / stored_name
with patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)):
assert _doc_payload(doc)["filePresent"] is False
stored_file.write_text("knowledge", encoding="utf-8")
assert _doc_payload(doc)["filePresent"] is True
@@ -1,105 +1,253 @@
"""Tests for PUT /api/avatar/{avatar_id}/authorizations/takeover endpoint."""
"""Tests for takeover configuration and BOXIM connection status."""
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app
from database import SessionLocal, Base, engine
from models import Authorization, Avatar
from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask, User
def setup_test_db():
Base.metadata.create_all(bind=engine)
client = TestClient(app)
def test_update_takeover_accepts_camel_case_and_persists(authorization_context):
context = authorization_context
response = client.put(
f"/api/avatar/{context['avatar'].id}/authorizations/takeover",
headers=context["owner_headers"],
json={
"authorizationId": context["authorization"].id,
"takeoverEnabled": True,
"takeoverMode": "delayed",
"takeoverDelaySeconds": 60,
},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 200
assert payload["data"]["takeoverEnabled"] is True
assert payload["data"]["takeoverMode"] == "delayed"
assert payload["data"]["takeoverDelaySeconds"] == 60
assert "takeover" in payload["data"]["permissions"]
db = SessionLocal()
avatar = Avatar(name="test", status="active", config={})
db.add(avatar)
db.commit()
db.refresh(avatar)
auth = Authorization(avatar_id=avatar.id, target_id="user1", target_name="测试用户")
db.add(auth)
db.commit()
db.refresh(auth)
return db, auth.id
def test_update_takeover_config():
db, auth_id = setup_test_db()
try:
client = TestClient(app)
response = client.put(
f"/api/avatar/test_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
assert data["data"]["takeoverEnabled"] is True
assert data["data"]["takeoverMode"] == "delayed"
assert data["data"]["takeoverDelaySeconds"] == 60
# 验证数据库已更新
auth = db.query(Authorization).filter(Authorization.id == auth_id).first()
assert auth.takeover_enabled is True
assert auth.takeover_mode == "delayed"
assert auth.takeover_delay_seconds == 60
stored = db.query(Authorization).filter(
Authorization.id == context["authorization"].id
).first()
assert stored.takeover_enabled is True
assert stored.takeover_mode == "delayed"
assert stored.takeover_delay_seconds == 60
finally:
db.close()
def test_update_takeover_invalid_mode():
db, auth_id = setup_test_db()
try:
client = TestClient(app)
response = client.put(
f"/api/avatar/test/authorizations/takeover",
json={
"authorization_id": auth_id,
"takeover_mode": "invalid_mode",
},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 400
finally:
db.close()
def test_update_takeover_invalid_delay():
db, auth_id = setup_test_db()
try:
client = TestClient(app)
response = client.put(
f"/api/avatar/test/authorizations/takeover",
json={
"authorization_id": auth_id,
"takeover_delay_seconds": 2,
},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 400
finally:
db.close()
def test_update_takeover_missing_auth_id():
client = TestClient(app)
response = client.put(
f"/api/avatar/test/authorizations/takeover",
json={"takeover_enabled": True},
def test_disabling_authorization_also_disables_takeover(authorization_context):
context = authorization_context
endpoint = f"/api/avatar/{context['avatar'].id}/authorizations/takeover"
client.put(
endpoint,
headers=context["owner_headers"],
json={
"authorizationId": context["authorization"].id,
"takeoverEnabled": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 400
updated = client.put(
f"/api/avatar/{context['avatar'].id}/authorizations",
headers=context["owner_headers"],
json={"id": context["authorization"].id, "status": "inactive"},
).json()
assert updated["code"] == 200
assert updated["data"]["status"] == "inactive"
assert updated["data"]["takeoverEnabled"] is False
assert "takeover" not in updated["data"]["permissions"]
def test_update_takeover_not_found():
client = TestClient(app)
response = client.put(
f"/api/avatar/test/authorizations/takeover",
json={"authorization_id": "nonexistent"},
def test_takeover_rejects_invalid_values_and_cross_avatar_access(authorization_context):
context = authorization_context
endpoint = f"/api/avatar/{context['avatar'].id}/authorizations/takeover"
invalid_mode = client.put(
endpoint,
headers=context["owner_headers"],
json={
"authorization_id": context["authorization"].id,
"takeover_mode": "invalid",
},
).json()
assert invalid_mode["code"] == 400
invalid_delay = client.put(
endpoint,
headers=context["owner_headers"],
json={
"authorization_id": context["authorization"].id,
"takeover_delay_seconds": 2,
},
).json()
assert invalid_delay["code"] == 400
forbidden = client.put(
endpoint,
headers=context["other_headers"],
json={
"authorizationId": context["authorization"].id,
"takeoverEnabled": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 404
assert forbidden.status_code == 403
def test_takeover_is_limited_to_active_user_authorizations(authorization_context):
context = authorization_context
avatar_id = context["avatar"].id
created = client.post(
f"/api/avatar/{avatar_id}/authorizations",
headers=context["owner_headers"],
json={
"targetType": "organization",
"targetId": f"org-{context['suffix']}",
"targetName": "测试组织",
"permissions": ["chat"],
},
).json()
response = client.put(
f"/api/avatar/{avatar_id}/authorizations/takeover",
headers=context["owner_headers"],
json={
"authorizationId": created["data"]["id"],
"takeoverEnabled": True,
},
).json()
assert response["code"] == 400
assert "单聊接管" in response["message"]
def test_takeover_status_reports_disabled_and_requires_owner_login(authorization_context):
context = authorization_context
endpoint = f"/api/avatar/{context['avatar'].id}/takeover/status"
disabled = client.get(endpoint, headers=context["owner_headers"])
assert disabled.status_code == 200
assert disabled.json()["data"]["status"] == "disabled"
client.put(
f"/api/avatar/{context['avatar'].id}/permission-settings",
headers=context["owner_headers"],
json={"permissions": ["chat", "takeover"]},
)
needs_login = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
assert needs_login["enabled"] is True
assert needs_login["status"] == "needs_login"
assert "BOXIM" in needs_login["message"]
assert client.get(endpoint).status_code == 401
assert client.get(endpoint, headers=context["other_headers"]).status_code == 403
def test_takeover_status_reports_ready_pending_count_and_errors(authorization_context):
context = authorization_context
avatar_id = context["avatar"].id
endpoint = f"/api/avatar/{avatar_id}/takeover/status"
client.put(
f"/api/avatar/{avatar_id}/permission-settings",
headers=context["owner_headers"],
json={"permissions": ["chat", "takeover"]},
)
db = SessionLocal()
try:
owner = db.query(User).filter(User.id == context["owner"].id).one()
owner.huihui_token = "production-login-token"
cursor = TakeoverCursor(
avatar_id=avatar_id,
owner_id=owner.huihui_user_id,
boxim_owner_id="100",
last_message_id="10",
initialized=True,
last_polled_at=datetime.utcnow(),
)
task = TakeoverReplyTask(
avatar_id=avatar_id,
owner_id=owner.huihui_user_id,
peer_id="200",
trigger_message_id="11",
source_message_ids=["11"],
prompt="你好",
status="pending",
scheduled_at=datetime.utcnow(),
boxim_local_id="123",
)
db.add_all([cursor, task])
db.commit()
finally:
db.close()
ready = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
assert ready["status"] == "ready"
assert ready["pendingCount"] == 1
assert ready["lastPolledAt"]
db = SessionLocal()
try:
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
cursor.last_polled_at = datetime.utcnow() - timedelta(seconds=30)
db.commit()
finally:
db.close()
long_polling = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
assert long_polling["status"] == "ready"
db = SessionLocal()
try:
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
cursor.last_polled_at = datetime.utcnow() - timedelta(seconds=61)
db.commit()
finally:
db.close()
stale = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
assert stale["status"] == "connecting"
db = SessionLocal()
try:
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
cursor.last_error = "BOXIM 暂时不可用"
db.commit()
finally:
db.close()
failed = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
assert failed["status"] == "error"
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,221 +1,88 @@
"""Tests for the scheduled takeover message polling."""
import json
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
"""Tests for the BOXIM takeover scheduler lifecycle."""
from unittest.mock import AsyncMock, MagicMock, patch
def test_app_has_startup_event():
"""Verify the app has a startup event configured."""
def test_app_has_startup_and_shutdown_events():
from main import app
startup_handlers = [handler for handler in app.router.on_startup]
assert len(startup_handlers) > 0
@patch("services.takeover_service.TakeoverService")
@patch("services.boxim_client.BoxIMClient")
@patch("main.redis_lib.from_url")
@patch("main.AsyncIOScheduler")
def test_scheduler_initialized_with_redis(mock_scheduler_class, mock_redis_from_url, mock_boxim_cls, mock_takeover_cls):
"""Verify scheduler is initialized when Redis is available."""
mock_redis = MagicMock()
mock_redis.ping.return_value = None
mock_redis_from_url.return_value = mock_redis
mock_boxim = MagicMock()
mock_boxim_cls.return_value = mock_boxim
mock_takeover = MagicMock()
mock_takeover.poll_and_process_messages = AsyncMock()
mock_takeover_cls.return_value = mock_takeover
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": "redis://localhost:6379"}):
from main import on_startup
on_startup()
mock_scheduler_class.return_value.add_job.assert_called_once()
scheduled_callable = mock_scheduler_class.return_value.add_job.call_args.args[0]
call_kwargs = mock_scheduler_class.return_value.add_job.call_args[1]
assert scheduled_callable is mock_takeover.poll_and_process_messages
assert call_kwargs["id"] == "takeover_message_poll"
mock_scheduler_class.return_value.start.assert_called_once_with()
assert app.router.on_startup
assert app.router.on_shutdown
@patch("services.takeover_service.TakeoverService")
@patch("services.boxim_client.BoxIMClient")
@patch("main.AsyncIOScheduler")
def test_scheduler_starts_without_redis(mock_scheduler_class, mock_boxim_cls, mock_takeover_cls):
"""App should start even when REDIS_URL is not set."""
mock_boxim = MagicMock()
mock_boxim_cls.return_value = mock_boxim
mock_takeover = MagicMock()
mock_takeover.poll_and_process_messages = AsyncMock()
mock_takeover_cls.return_value = mock_takeover
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": ""}, clear=False):
from main import on_startup
on_startup()
mock_scheduler_class.return_value.add_job.assert_called_once()
@patch("services.takeover_service.TakeoverService")
@patch("services.boxim_client.BoxIMClient")
@patch("main.redis_lib.from_url")
@patch("main.AsyncIOScheduler")
def test_scheduler_starts_when_redis_fails(mock_scheduler_class, mock_redis_from_url, mock_boxim_cls, mock_takeover_cls):
"""App should start even when Redis ping fails."""
mock_redis_from_url.side_effect = ConnectionError("Connection refused")
mock_boxim = MagicMock()
mock_boxim_cls.return_value = mock_boxim
mock_takeover = MagicMock()
mock_takeover.poll_and_process_messages = AsyncMock()
mock_takeover_cls.return_value = mock_takeover
with patch("main.init_db"), patch("main.seed"), patch.dict("os.environ", {"REDIS_URL": "redis://badhost:6379"}):
from main import on_startup
on_startup()
mock_scheduler_class.return_value.add_job.assert_called_once()
@patch("main.AsyncIOScheduler")
def test_scheduler_fails_gracefully(mock_scheduler_class):
"""If scheduler init raises, the app should still start (exception caught)."""
mock_scheduler_class.side_effect = RuntimeError("Scheduler crash")
with patch("main.init_db"), patch("main.seed"):
from main import on_startup
on_startup()
# No exception should propagate
def test_scheduler_shutdown_releases_resources():
"""Shutdown should stop polling and close its dedicated database session."""
def test_scheduler_uses_boxim_and_restart_safe_service(
mock_scheduler_class,
mock_boxim_class,
mock_takeover_class,
):
import main
mock_scheduler = MagicMock()
mock_scheduler.running = True
mock_db = MagicMock()
main.takeover_scheduler = mock_scheduler
main.takeover_db = mock_db
scheduler = MagicMock()
mock_scheduler_class.return_value = scheduler
boxim = MagicMock()
mock_boxim_class.return_value = boxim
takeover = MagicMock()
takeover.poll_messages = AsyncMock()
takeover.process_reply_tasks = AsyncMock()
mock_takeover_class.return_value = takeover
environment = {
"HUIHUI_PLATFORM_BASE_URL": "https://open.example/api",
"BOXIM_API_BASE_URL": "https://im.example/api",
"HUIHUI_APP_ID": "app-id",
"HUIHUI_ACCESS_ID": "access-id",
"HUIHUI_ACCESS_SECRET": "secret",
"BOXIM_POLL_INTERVAL_SECONDS": "1",
}
with patch("main.init_db"), patch("main.seed"), patch.dict(
"os.environ", environment, clear=False
):
main.on_startup()
config = mock_boxim_class.call_args.args[0]
assert config["HUIHUI_PLATFORM_BASE_URL"] == "https://open.example/api"
assert config["BOXIM_API_BASE_URL"] == "https://im.example/api"
mock_takeover_class.assert_called_once_with(main.SessionLocal, boxim)
assert scheduler.add_job.call_count == 2
poll_call, process_call = scheduler.add_job.call_args_list
assert poll_call.args[0] is takeover.poll_messages
assert poll_call.kwargs["id"] == "takeover_message_poll"
assert poll_call.kwargs["trigger"].interval.total_seconds() == 1
assert poll_call.kwargs["max_instances"] == 1
assert poll_call.kwargs["coalesce"] is True
assert process_call.args[0] is takeover.process_reply_tasks
assert process_call.kwargs["id"] == "takeover_reply_process"
assert process_call.kwargs["trigger"].interval.total_seconds() == 0.5
assert process_call.kwargs["max_instances"] == 1
assert process_call.kwargs["coalesce"] is True
scheduler.start.assert_called_once_with()
main.takeover_scheduler = None
@patch("main.AsyncIOScheduler")
def test_scheduler_failure_does_not_stop_the_api(mock_scheduler_class):
import main
mock_scheduler_class.side_effect = RuntimeError("scheduler crash")
with patch("main.init_db"), patch("main.seed"):
main.on_startup()
assert main.takeover_scheduler is None
def test_shutdown_stops_only_the_scheduler():
import main
scheduler = MagicMock()
scheduler.running = True
main.takeover_scheduler = scheduler
main.on_shutdown()
mock_scheduler.shutdown.assert_called_once_with(wait=False)
mock_db.close.assert_called_once_with()
scheduler.shutdown.assert_called_once_with(wait=False)
assert main.takeover_scheduler is None
assert main.takeover_db is None
# --- poll_and_process_messages ---
@pytest.fixture
def mock_db():
return MagicMock()
@pytest.fixture
def mock_boxim():
return AsyncMock()
@pytest.mark.asyncio
async def test_poll_and_process_messages_calls_fetch_and_process(mock_db, mock_boxim):
"""poll_and_process_messages should fetch messages and process each."""
from services.takeover_service import TakeoverService
service = TakeoverService(mock_db, mock_boxim)
service.fetch_unread_messages = AsyncMock(return_value=[
{"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"},
{"owner_huihui_id": "owner_2", "from_accid": "user_2", "content": "hello"},
])
service.process_message = AsyncMock()
await service.poll_and_process_messages()
service.fetch_unread_messages.assert_awaited_once()
assert service.process_message.await_count == 2
@pytest.mark.asyncio
async def test_poll_and_process_messages_handles_errors(mock_db, mock_boxim):
"""poll_and_process_messages should not crash on fetch failure."""
from services.takeover_service import TakeoverService
service = TakeoverService(mock_db, mock_boxim)
service.fetch_unread_messages = AsyncMock(side_effect=ConnectionError("Box IM down"))
await service.poll_and_process_messages()
# No exception should propagate
# --- process_message ---
@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
@pytest.mark.asyncio
async def test_process_message_immediate_mode(mock_db, mock_boxim, mock_auth):
"""When takeover_mode is 'immediate', execute_takeover should be called."""
from services.takeover_service import TakeoverService
service = TakeoverService(mock_db, mock_boxim)
service.check_takeover_enabled = MagicMock(return_value=mock_auth)
service.execute_takeover = AsyncMock(return_value=True)
service.enqueue_delayed_message = MagicMock()
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
await service.process_message(message)
service.execute_takeover.assert_awaited_once_with(mock_auth, message)
service.enqueue_delayed_message.assert_not_called()
@pytest.mark.asyncio
async def test_process_message_delayed_mode(mock_db, mock_boxim, mock_auth):
"""When takeover_mode is not 'immediate', message should be enqueued."""
from services.takeover_service import TakeoverService
mock_auth.takeover_mode = "delayed"
service = TakeoverService(mock_db, mock_boxim)
service.check_takeover_enabled = MagicMock(return_value=mock_auth)
service.execute_takeover = AsyncMock()
service.enqueue_delayed_message = MagicMock()
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
await service.process_message(message)
service.enqueue_delayed_message.assert_called_once_with(mock_auth, message)
service.execute_takeover.assert_not_awaited()
@pytest.mark.asyncio
async def test_process_message_no_takeover(mock_db, mock_boxim):
"""When takeover is not enabled, nothing should happen."""
from services.takeover_service import TakeoverService
service = TakeoverService(mock_db, mock_boxim)
service.check_takeover_enabled = MagicMock(return_value=None)
service.execute_takeover = AsyncMock()
service.enqueue_delayed_message = MagicMock()
message = {"owner_huihui_id": "owner_1", "from_accid": "user_1", "content": "hi"}
await service.process_message(message)
service.execute_takeover.assert_not_awaited()
service.enqueue_delayed_message.assert_not_called()
@@ -1,318 +1,295 @@
"""Tests for the TakeoverService — message listening, decision, reply execution."""
"""End-to-end service tests for BOXIM takeover timing and human priority."""
from datetime import datetime, timedelta, timezone
from threading import Barrier
from unittest.mock import AsyncMock, patch
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from services.takeover_service import TakeoverService
from models import Authorization, Avatar
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from services.boxim_client import BoxIMError
from services.takeover_service import TakeoverService, _plain_text_reply
class Clock:
def __init__(self):
self.value = datetime(2026, 8, 19, 10, 0, 0)
def now(self):
return self.value
def advance(self, seconds: int):
self.value += timedelta(seconds=seconds)
def millis(self):
return int(self.value.replace(tzinfo=timezone.utc).timestamp() * 1000)
class FakeBoxIM:
def __init__(self):
self.messages = []
self.sent = []
self.read_receipts = []
async def exchange_access_token(self, huihui_token):
assert huihui_token == "prod-huihui-token"
return {"accessToken": "box-token", "accessTokenExpiresIn": 3600}
async def get_self(self, access_token):
assert access_token == "box-token"
return {"id": 100}
async def fetch_private_messages(self, access_token, min_id="0"):
assert access_token == "box-token"
return [item.copy() for item in self.messages if int(item["id"]) > int(min_id)]
async def mark_private_messages_read(self, access_token, friend_id, message_id):
assert access_token == "box-token"
self.read_receipts.append(
{"friendId": str(friend_id), "messageId": str(message_id)}
)
async def send_private_message(self, access_token, peer_id, content, *, local_id=None):
self.sent.append({"peerId": str(peer_id), "content": content, "localId": str(local_id)})
return {"id": 900 + len(self.sent), "localId": int(local_id)}
@pytest.fixture
def mock_db():
db = MagicMock()
return db
def service_context(tmp_path):
engine = create_engine(
f"sqlite:///{tmp_path / 'takeover.db'}",
connect_args={"check_same_thread": False},
)
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
Base.metadata.create_all(engine)
db = session_factory()
user = User(
id="owner-local",
huihui_user_id="owner-huihui",
huihui_token="prod-huihui-token",
app_token="app-token",
)
avatar = Avatar(
id="avatar-1",
owner_id=user.huihui_user_id,
name="分身",
status="active",
config={"authorizationPermissions": ["chat", "takeover"]},
)
db.add_all([user, avatar])
db.commit()
db.close()
@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(spec=Authorization)
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
@pytest.fixture
def mock_avatar():
avatar = MagicMock(spec=Avatar)
avatar.id = "avatar_123"
avatar.owner_id = "owner_huihui_123"
return avatar
# --- check_takeover_enabled ---
def test_check_takeover_enabled_returns_auth_when_enabled(mock_db, mock_auth, mock_boxim, mock_avatar):
avatar_filter = MagicMock()
avatar_filter.first.return_value = mock_avatar
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
auth_filter = MagicMock()
auth_filter.filter.return_value = auth_filter
auth_filter.first.return_value = mock_auth
def query_side_effect(model):
if model == Avatar:
return avatar_query
return auth_filter
mock_db.query.side_effect = query_side_effect
service = TakeoverService(mock_db, mock_boxim)
result = service.check_takeover_enabled("owner_huihui_123", "target_user_123")
assert result == mock_auth
def test_check_takeover_enabled_returns_none_when_no_avatar(mock_db, mock_boxim):
avatar_filter = MagicMock()
avatar_filter.first.return_value = None
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
mock_db.query.return_value = avatar_query
service = TakeoverService(mock_db, mock_boxim)
result = service.check_takeover_enabled("owner_123", "target_123")
assert result is None
def test_check_takeover_enabled_returns_none_when_disabled(mock_db, mock_boxim, mock_avatar):
avatar_filter = MagicMock()
avatar_filter.first.return_value = mock_avatar
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
disabled_auth = MagicMock(spec=Authorization)
disabled_auth.takeover_enabled = False
auth_filter = MagicMock()
auth_filter.filter.return_value = auth_filter
auth_filter.first.return_value = disabled_auth
def query_side_effect(model):
if model == Avatar:
return avatar_query
return auth_filter
mock_db.query.side_effect = query_side_effect
service = TakeoverService(mock_db, mock_boxim)
result = service.check_takeover_enabled("owner_123", "target_123")
assert result is None
def test_check_takeover_enabled_filters_by_owner_and_target(mock_db, mock_boxim, mock_avatar, mock_auth):
"""Verify that queries use the correct filter arguments."""
avatar_filter = MagicMock()
avatar_filter.first.return_value = mock_avatar
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
auth_filter = MagicMock()
auth_filter.filter.return_value = auth_filter
auth_filter.first.return_value = mock_auth
call_order = []
def query_side_effect(model):
if model == Avatar:
call_order.append("Avatar")
return avatar_query
call_order.append("Authorization")
return auth_filter
mock_db.query.side_effect = query_side_effect
service = TakeoverService(mock_db, mock_boxim)
service.check_takeover_enabled("owner_huihui_123", "target_user_123")
assert "Avatar" in call_order
assert "Authorization" in call_order
# --- generate_reply ---
clock = Clock()
boxim = FakeBoxIM()
service = TakeoverService(session_factory, boxim, now=clock.now)
return session_factory, service, boxim, clock
@pytest.mark.asyncio
async def test_generate_reply_returns_answer(mock_boxim):
mock_db = MagicMock()
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
async def test_first_sync_primes_cursor_without_replying_to_history(service_context):
session_factory, service, boxim, clock = service_context
boxim.messages = [
{"id": 10, "localId": 1, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "旧消息"}
]
service = TakeoverService(mock_db, mock_boxim)
result = await service.generate_reply("avatar_123", "Hello")
assert result == "Hello back"
with patch("routers.chat._resolve_reply", return_value={"answer": "不应发送"}):
await service.poll_and_process_messages()
db = session_factory()
try:
cursor = db.query(TakeoverCursor).one()
assert cursor.initialized is True
assert cursor.last_message_id == "10"
assert db.query(TakeoverMessage).count() == 1
assert db.query(TakeoverReplyTask).count() == 0
assert boxim.sent == []
assert boxim.read_receipts == [{"friendId": "200", "messageId": "10"}]
finally:
db.close()
@pytest.mark.asyncio
async def test_generate_reply_handles_empty_answer(mock_boxim):
"""generate_reply should return empty string when answer is missing."""
mock_db = MagicMock()
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.json.return_value = {"code": 200, "data": {}}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
)
service = TakeoverService(mock_db, mock_boxim)
result = await service.generate_reply("avatar_123", "Hello")
assert result == ""
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
await service.poll_and_process_messages()
assert boxim.sent == []
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
clock.advance(2)
await service.poll_and_process_messages()
assert boxim.sent == []
clock.advance(1)
await service.poll_and_process_messages()
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
db = session_factory()
try:
task = db.query(TakeoverReplyTask).one()
assert task.status == "sent"
assert task.sent_at == clock.now()
finally:
db.close()
@pytest.mark.asyncio
async def test_generate_reply_handles_error_code(mock_boxim):
"""generate_reply should return empty string when API returns error code."""
mock_db = MagicMock()
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.json.return_value = {"code": 500, "message": "Internal error"}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
async def test_different_contacts_generate_without_blocking_each_other(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.extend(
[
{"id": 13, "localId": 31, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "联系人甲"},
{"id": 14, "localId": 32, "sendId": 300, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "联系人乙"},
]
)
both_generating = Barrier(2, timeout=2)
service = TakeoverService(mock_db, mock_boxim)
result = await service.generate_reply("avatar_123", "Hello")
assert result == ""
def resolve(_db, _avatar, prompt, _history, **_kwargs):
both_generating.wait()
return {"answer": f"回复{prompt[-1]}"}
with patch("routers.chat._resolve_reply", side_effect=resolve):
await service.poll_and_process_messages()
# --- execute_takeover ---
clock.advance(3)
await service.process_reply_tasks()
assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
("200", "回复甲"),
("300", "回复乙"),
}
db = session_factory()
try:
assert {task.status for task in db.query(TakeoverReplyTask).all()} == {"sent"}
finally:
db.close()
@pytest.mark.asyncio
async def test_execute_takeover_success(mock_db, mock_boxim, mock_auth, mock_avatar):
avatar_filter = MagicMock()
avatar_filter.first.return_value = mock_avatar
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
mock_db.query.return_value = avatar_query
async def test_read_receipt_failure_does_not_advance_cursor(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 12, "localId": 3, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "未读消息"}
)
boxim.mark_private_messages_read = AsyncMock(side_effect=BoxIMError("回执失败"))
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
with patch("routers.chat._resolve_reply", return_value={"answer": "稍后回复"}):
await service.poll_and_process_messages()
service = TakeoverService(mock_db, mock_boxim)
message = {"from_accid": "user_acc", "content": "Hello"}
db = session_factory()
try:
cursor = db.query(TakeoverCursor).one()
assert cursor.last_message_id == "0"
assert db.query(TakeoverMessage).count() == 0
assert db.query(TakeoverReplyTask).count() == 0
finally:
db.close()
result = await service.execute_takeover(mock_auth, message)
boxim.mark_private_messages_read = AsyncMock(return_value=None)
with patch("routers.chat._resolve_reply", return_value={"answer": "稍后回复"}):
await service.poll_and_process_messages()
assert result is True
mock_boxim.get_credentials.assert_called_once_with("owner_huihui_123")
mock_boxim.send_p2p_message.assert_called_once()
db = session_factory()
try:
assert db.query(TakeoverCursor).one().last_message_id == "12"
assert db.query(TakeoverMessage).count() == 1
assert db.query(TakeoverReplyTask).count() == 1
finally:
db.close()
@pytest.mark.asyncio
async def test_execute_takeover_fails_when_avatar_not_found(mock_db, mock_boxim, mock_auth):
"""execute_takeover should return False when Avatar is not found."""
avatar_filter = MagicMock()
avatar_filter.first.return_value = None
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
mock_db.query.return_value = avatar_query
async def test_owner_message_cancels_pending_reply(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 21, "localId": 3, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "在吗"}
)
with patch("routers.chat._resolve_reply", return_value={"answer": "在的"}):
await service.poll_and_process_messages()
service = TakeoverService(mock_db, mock_boxim)
message = {"from_accid": "user_acc", "content": "Hello"}
clock.advance(2)
boxim.messages.append(
{"id": 22, "localId": 4, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来回复"}
)
await service.poll_and_process_messages()
clock.advance(2)
await service.poll_and_process_messages()
result = await service.execute_takeover(mock_auth, message)
assert result is False
mock_boxim.get_credentials.assert_not_called()
db = session_factory()
try:
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "21").one()
assert task.status == "cancelled"
assert task.cancel_reason == "owner_replied"
assert boxim.sent == []
finally:
db.close()
@pytest.mark.asyncio
async def test_execute_takeover_fails_when_no_credentials(mock_db, mock_boxim, mock_auth, mock_avatar):
"""execute_takeover should return False when boxim.get_credentials returns None."""
avatar_filter = MagicMock()
avatar_filter.first.return_value = mock_avatar
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
mock_db.query.return_value = avatar_query
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
)
with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}):
await service.poll_and_process_messages()
mock_boxim.get_credentials.return_value = None
service = TakeoverService(mock_db, mock_boxim)
message = {"from_accid": "user_acc", "content": "Hello"}
clock.advance(1)
boxim.messages.append(
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"}
)
with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
await service.poll_and_process_messages()
assert resolver.call_args.args[2] == "第一句\n第二句"
result = await service.execute_takeover(mock_auth, message)
clock.advance(3)
await service.poll_and_process_messages()
assert [item["content"] for item in boxim.sent] == ["合并回复"]
assert result is False
# --- enqueue_delayed_message ---
def test_enqueue_delayed_message_with_redis(mock_db, mock_boxim, mock_auth, mock_avatar):
mock_redis = MagicMock()
avatar_filter = MagicMock()
avatar_filter.first.return_value = mock_avatar
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
mock_db.query.return_value = avatar_query
service = TakeoverService(mock_db, mock_boxim, mock_redis)
message = {"msg_id": "msg_1", "from_accid": "user_acc", "content": "Hello"}
service.enqueue_delayed_message(mock_auth, message)
mock_redis.setex.assert_called_once()
call_args = mock_redis.setex.call_args
value = call_args[0][1]
import json
payload = json.loads(call_args[0][2])
assert payload["owner_huihui_id"] == "owner_huihui_123"
def test_enqueue_delayed_message_without_redis_logs_warning(mock_db, mock_boxim, mock_auth, mock_avatar):
"""When Redis is not configured, enqueue_delayed_message should log a warning and not crash."""
avatar_filter = MagicMock()
avatar_filter.first.return_value = mock_avatar
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
mock_db.query.return_value = avatar_query
service = TakeoverService(mock_db, mock_boxim)
message = {"msg_id": "msg_1", "from_accid": "user_acc", "content": "Hello"}
service.enqueue_delayed_message(mock_auth, message)
# --- process_delayed_queue ---
db = session_factory()
try:
tasks = db.query(TakeoverReplyTask).order_by(TakeoverReplyTask.created_at).all()
assert [task.status for task in tasks] == ["cancelled", "sent"]
assert tasks[0].cancel_reason == "newer_incoming_message"
finally:
db.close()
@pytest.mark.asyncio
async def test_process_delayed_queue_no_redis(mock_db, mock_boxim):
"""process_delayed_queue should return immediately without Redis."""
service = TakeoverService(mock_db, mock_boxim)
await service.process_delayed_queue()
mock_db.query.assert_not_called()
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")
@pytest.mark.asyncio
async def test_process_delayed_queue_processes_messages(mock_db, mock_boxim, mock_auth, mock_avatar):
"""process_delayed_queue should read from Redis, resolve auth, and execute takeover."""
mock_redis = MagicMock()
mock_redis.keys.return_value = ["takeover:delayed:target_user_123:msg_1"]
mock_redis.get.return_value = '{"from_accid": "user_acc", "content": "Hello"}'
avatar_filter = MagicMock()
avatar_filter.first.return_value = mock_avatar
avatar_query = MagicMock()
avatar_query.filter.return_value = avatar_filter
auth_filter = MagicMock()
auth_filter.filter.return_value = auth_filter
auth_filter.first.return_value = mock_auth
def query_side_effect(model):
if model == Avatar:
return avatar_query
return auth_filter
mock_db.query.side_effect = query_side_effect
with patch("services.takeover_service.httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.json.return_value = {"code": 200, "data": {"answer": "Hello back"}}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
service = TakeoverService(mock_db, mock_boxim, mock_redis)
await service.process_delayed_queue()
mock_boxim.send_p2p_message.assert_called_once()
mock_redis.delete.assert_called_once()
def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
@@ -0,0 +1,252 @@
import uuid
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app, seed
from models import Avatar, TokenAccount, TokenPlan, TokenUsage, User
from routers.chat import _resolve_reply, _stream_reply
from services.token_billing import (
DEFAULT_TOKEN_GRANT,
InsufficientTokensError,
get_or_create_account,
release_reservation,
reserve_avatar_tokens,
settle_reservation,
)
client = TestClient(app)
def test_balance_is_user_scoped_and_defaults_to_one_million(authorization_context):
context = authorization_context
owner = client.get("/api/token/balance", headers=context["owner_headers"])
other = client.get("/api/token/balance", headers=context["other_headers"])
assert owner.status_code == 200
assert owner.json()["data"] == {
"balance": DEFAULT_TOKEN_GRANT,
"totalGranted": DEFAULT_TOKEN_GRANT,
"totalConsumed": 0,
}
assert other.json()["data"]["balance"] == DEFAULT_TOKEN_GRANT
assert client.get("/api/token/balance").status_code == 401
def test_seed_synchronizes_requested_recharge_plans():
seed()
db = SessionLocal()
try:
plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
assert [(plan.price, plan.amount) for plan in plans] == [
(10, 2_000_000),
(100, 20_000_000),
(1000, 250_000_000),
(10000, 2_500_000_000),
]
finally:
db.close()
def test_multiple_avatars_share_owner_balance_and_usage_is_itemized():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"token-user-{suffix}", huihui_user_id=f"token-huihui-{suffix}")
first = Avatar(id=f"token-avatar-a-{suffix}", owner_id=user.huihui_user_id, name="甲")
second = Avatar(id=f"token-avatar-b-{suffix}", owner_id=user.huihui_user_id, name="乙")
db.add_all([user, first, second])
db.commit()
try:
first_reservation = reserve_avatar_tokens(db, first, "chat", "qwen-test", [{"content": "问题一"}], 128)
settle_reservation(
db,
first_reservation,
{"prompt_tokens": 60, "completion_tokens": 40, "total_tokens": 100},
fallback_total=999,
)
second_reservation = reserve_avatar_tokens(db, second, "takeover", "qwen-test", [{"content": "问题二"}], 128)
settle_reservation(
db,
second_reservation,
{"prompt_tokens": 120, "completion_tokens": 80, "total_tokens": 200},
fallback_total=999,
)
account = get_or_create_account(db, user.id)
assert account.balance == DEFAULT_TOKEN_GRANT - 300
assert account.total_consumed == 300
usages = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).order_by(TokenUsage.total_tokens).all()
assert [(row.avatar_id, row.source, row.total_tokens) for row in usages] == [
(first.id, "chat", 100),
(second.id, "takeover", 200),
]
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id.in_([first.id, second.id])).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_concurrent_settlements_do_not_overwrite_each_other():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"concurrent-user-{suffix}", huihui_user_id=f"concurrent-huihui-{suffix}")
avatar = Avatar(id=f"concurrent-avatar-{suffix}", owner_id=user.huihui_user_id, name="并发测试")
db.add_all([user, avatar])
db.commit()
first = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "甲"}], 128)
second = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "乙"}], 128)
db.close()
barrier = Barrier(2, timeout=3)
def settle(reservation, total):
thread_db = SessionLocal()
try:
barrier.wait()
settle_reservation(
thread_db,
reservation,
{"prompt_tokens": total - 20, "completion_tokens": 20, "total_tokens": total},
fallback_total=999,
)
finally:
thread_db.close()
with ThreadPoolExecutor(max_workers=2) as pool:
list(pool.map(lambda args: settle(*args), [(first, 100), (second, 200)]))
db = SessionLocal()
try:
account = get_or_create_account(db, user.id)
assert account.balance == DEFAULT_TOKEN_GRANT - 300
assert account.total_consumed == 300
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_failed_model_request_returns_the_full_reservation():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"refund-user-{suffix}", huihui_user_id=f"refund-huihui-{suffix}")
avatar = Avatar(id=f"refund-avatar-{suffix}", owner_id=user.huihui_user_id, name="退款测试")
db.add_all([user, avatar])
db.commit()
try:
reservation = reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
release_reservation(db, reservation, "provider error")
account = get_or_create_account(db, user.id)
usage = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).one()
assert account.balance == DEFAULT_TOKEN_GRANT
assert account.total_consumed == 0
assert usage.status == "failed"
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_insufficient_balance_rejects_before_model_usage_is_created():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"empty-user-{suffix}", huihui_user_id=f"empty-huihui-{suffix}")
avatar = Avatar(id=f"empty-avatar-{suffix}", owner_id=user.huihui_user_id, name="余额不足")
db.add_all([user, avatar])
db.commit()
try:
account = get_or_create_account(db, user.id)
account.balance = 1
db.commit()
with pytest.raises(InsufficientTokensError):
reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
db.refresh(account)
assert account.balance == 1
assert db.query(TokenUsage).filter(TokenUsage.user_id == user.id).count() == 0
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_chat_settles_from_provider_usage_not_fallback_estimate():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"chat-user-{suffix}", huihui_user_id=f"chat-huihui-{suffix}")
avatar = Avatar(id=f"chat-avatar-{suffix}", owner_id=user.huihui_user_id, name="聊天测试", config={})
db.add_all([user, avatar])
db.commit()
try:
with patch(
"routers.chat._call_qwen",
return_value={
"answer": "测试回答",
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "total_tokens": 100},
},
):
result = _resolve_reply(
db,
avatar,
"测试问题",
[],
qa_pairs=[],
search_fn=lambda *_args: [],
)
assert result["tokenUsage"]["totalTokens"] == 100
assert result["tokenUsage"]["balance"] == DEFAULT_TOKEN_GRANT - 100
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
@pytest.mark.asyncio
async def test_streaming_chat_settles_final_provider_usage():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"stream-user-{suffix}", huihui_user_id=f"stream-huihui-{suffix}")
avatar = Avatar(id=f"stream-avatar-{suffix}", owner_id=user.huihui_user_id, name="流式测试", config={})
db.add_all([user, avatar])
db.commit()
try:
chunks = iter([
{"content": "流式"},
{"content": "回答"},
{"usage": {"prompt_tokens": 90, "completion_tokens": 10, "total_tokens": 100}},
])
with patch("routers.chat._iter_qwen_stream", return_value=chunks):
response = _stream_reply(db, avatar, "测试问题", [])
body = []
async for chunk in response.body_iterator:
body.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
assert "流式" in "".join(body)
account = get_or_create_account(db, user.id)
usage = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).one()
assert account.balance == DEFAULT_TOKEN_GRANT - 100
assert usage.source == "chat_stream"
assert usage.total_tokens == 100
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
+3
View File
@@ -10,6 +10,9 @@ services:
environment:
DATABASE_URL: sqlite:////data/avatar.db
UPLOAD_DIR: /data/uploads
CHAT_MODEL_CONFIG_URL: http://host.docker.internal:8000/api/ai-models/runtime/digital-avatar
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- avatar-data:/data
expose:
@@ -0,0 +1,115 @@
# 数字分身 H5 生产接入与部署
## 1. 接入方式
生产会会在用户已登录后打开以下地址:
```text
https://digital.99hui.com/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
```
测试环境示例:
```text
http://192.168.1.188:8099/#/avatar/manage?token=<encodeURIComponent(token)>
```
兼容参数位于域名查询串的形式,但生产统一使用上面的 hash 路由形式。必须对 token 调用 `encodeURIComponent`,不能拼接用户 ID 代替 token。
免登录流程如下:
1. H5 在页面渲染前读取 `token`,立即通过 `history.replaceState` 从地址栏和浏览器历史中移除。
2. H5 调用 `POST /api/huihui/token/login`,不会把会会 token 当作数字分身接口 token 直接使用。
3. 后端通过会会生产接口 `/im/box/netease` 换取 BOXIM 凭证,再调用 BOXIM `/user/self` 校验用户身份。
4. 后端以返回的 `huihuiUserId` 绑定本地用户,保存会会凭证供 BOXIM 接管功能使用,并签发本系统 `app_token`。
5. 浏览器只保存 `app_token` 和非敏感用户资料。会会原始 token 不返回浏览器存储。
6. token 无效、过期或上游校验失败时清除旧会话并进入登录页,不会沿用上一位用户的缓存身份。
## 2. 生产配置
后端 `.env` 至少配置以下内容,密钥由部署平台注入,禁止提交 Git:
```dotenv
HUIHUI_DEV_MOCK=false
HUIHUI_AUTH_BASE_URL=https://99hui.com/api/usercenter
HUIHUI_PLATFORM_BASE_URL=https://open.99hui.com/api
BOXIM_API_BASE_URL=https://im.99hui.com/api
HUIHUI_APP_ID=<production-app-id>
HUIHUI_ACCESS_ID=<production-access-id>
HUIHUI_ACCESS_SECRET=<production-access-secret>
HUIHUI_CLIENT_CODE=<production-client-code>
BOXIM_TIMEOUT_SECONDS=20
DATABASE_URL=sqlite:////data/avatar.db
UPLOAD_DIR=/data/uploads
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
```
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
## 3. 构建与发布
首次发布前备份数据:
```bash
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp /srv/digital-avatar/data/avatar.db "$BACKUP_DIR/"
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
```
在发布目录执行:
```bash
git fetch origin
git checkout <已验收的提交SHA>
cd digital-avatar-app
docker compose build --pull avatar-backend avatar-frontend
docker compose up -d avatar-backend avatar-frontend
docker compose ps
curl -fsS http://127.0.0.1:8099/api/health
```
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
## 4. 网关要求
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
```nginx
server_name digital.99hui.com;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://avatar-backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
client_max_body_size 20m;
}
```
`proxy_buffering off` 用于数字分身 SSE 流式吐字,`client_max_body_size` 用于知识库文件上传。网关和应用日志必须关闭完整 URL 查询参数记录,任何异常日志都不得输出 token、Authorization 或平台密钥。建议同时设置严格的 `Referrer-Policy: no-referrer`。
## 5. 发布验收
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和 Token 余额均存在。
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
## 6. 回滚
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
+3 -4
View File
@@ -7,10 +7,9 @@
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>会会数字分身</title>
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 -->
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- digital.99hui.com 同域部署保持 apiBase 为空,接口使用 /api。
仅跨域部署时才显式配置后端公网地址。 -->
<script type="text/javascript">
window.__APP_CONFIG__ = { apiBase: '' }
</script>
+7
View File
@@ -8,6 +8,7 @@
"name": "digital-avatar-app",
"version": "1.0.0",
"dependencies": {
"@dcloudio/uni-webview-js": "^0.0.3",
"axios": "^1.6.0",
"pinia": "^2.1.0",
"vue": "^3.3.0",
@@ -66,6 +67,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@dcloudio/uni-webview-js": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/@dcloudio/uni-webview-js/-/uni-webview-js-0.0.3.tgz",
"integrity": "sha512-3XDnLXg22QKC/aTLtGim6zbOK+euiDYoRf98BjGDE0lqMm1U4SLFHAvOlAHa0qck91phaceKwo73ScQQgSojQg==",
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@dcloudio/uni-webview-js": "^0.0.3",
"axios": "^1.6.0",
"pinia": "^2.1.0",
"vue": "^3.3.0",
+1
View File
@@ -0,0 +1 @@
8f54097b01d5b0433f8aa5ea70ca930b
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="avatar-gradient" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop stop-color="#fb923c"/>
<stop offset="1" stop-color="#f97316"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#avatar-gradient)"/>
<circle cx="32" cy="24" r="10" fill="#fff"/>
<path d="M15 52c1.8-10 8-15 17-15s15.2 5 17 15" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

+1 -107
View File
@@ -1,72 +1,10 @@
<template>
<div id="app">
<router-view />
<!-- 底部导航栏 -->
<nav class="bottom-nav" v-if="showNav">
<button
class="nav-item"
:class="{ active: currentRoute === '/' || currentRoute === '/avatar/manage' }"
@click="navigateTo('/avatar/manage')"
>
<span class="nav-icon">🤖</span>
<span class="nav-label">我的分身</span>
</button>
<button
class="nav-item"
:class="{ active: currentRoute === '/authorization' }"
@click="navigateTo('/authorization')"
>
<span class="nav-icon">🔑</span>
<span class="nav-label">授权管理</span>
</button>
<button
class="nav-item"
:class="{ active: currentRoute === '/token/charge' }"
@click="navigateTo('/token/charge')"
>
<span class="nav-icon">💰</span>
<span class="nav-label">Token</span>
</button>
</nav>
<!-- 底部菜单暂不展示,多分身能力统一从分身卡片进入。 -->
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const currentRoute = ref<string>(route.path)
const showNav = ref<boolean>(shouldShowNav(route.path))
function shouldShowNav(path: string) {
return path !== '/'
&& path !== '/avatar/create'
&& path !== '/login/sms'
&& !path.startsWith('/avatar/edit')
&& !path.startsWith('/avatar/chat')
&& !path.startsWith('/share/')
}
// 监听路由变化
watch(() => route.path, (newPath) => {
currentRoute.value = newPath
showNav.value = shouldShowNav(newPath)
})
// 导航
const navigateTo = (path: string) => {
router.push(path)
}
onMounted(() => {
currentRoute.value = route.path
showNav.value = shouldShowNav(route.path)
})
</script>
<style>
* {
margin: 0;
@@ -80,48 +18,4 @@ onMounted(() => {
background: #F8F9FA;
padding-bottom: env(safe-area-inset-bottom);
}
/* 底部导航栏 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: white;
border-top: 1px solid #EDEEF1;
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 0;
background: none;
border: none;
cursor: pointer;
transition: color 0.2s;
}
.nav-icon {
font-size: 20px;
}
.nav-label {
font-size: 11px;
color: #9398AE;
font-weight: 500;
}
.nav-item.active .nav-label {
color: #F97316;
}
.nav-item.active .nav-icon {
filter: none;
}
</style>
+79 -15
View File
@@ -9,8 +9,8 @@ interface ApiClient {
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
// - web-view 内(混合架构):需配置为后端公网地址,例如 'https://geo.99hui.com/api'
// - 同域部署的构建产物:可保持 '/api'
// - digital.99hui.com 同域部署:保持 '/api'
// - 仅跨域部署时配置后端公网地址
function resolveBaseURL(): string {
const cfg = (window as any).__APP_CONFIG__
if (cfg && cfg.apiBase) return cfg.apiBase as string
@@ -131,9 +131,24 @@ export const deleteAvatar = (id: string) =>
// ==================== Token 管理 API ====================
export interface TokenBalance {
balance: number
totalGranted: number
totalConsumed: number
}
export interface TokenUsageSummary {
avatarId: string
source: string
promptTokens: number
completionTokens: number
totalTokens: number
requestCount: number
}
// 获取 Token 余额
export const getTokenBalance = () =>
request.get<{ balance: number }>('/token/balance')
request.get<TokenBalance>('/token/balance')
// 获取充值套餐
export const getRechargePlans = () =>
@@ -143,8 +158,36 @@ export const getRechargePlans = () =>
export const chargeToken = (planId: string) =>
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
// 按分身和使用场景汇总 Token 消耗
export const getTokenUsage = () =>
request.get<TokenUsageSummary[]>('/token/usage')
// ==================== 授权管理 API ====================
export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover'
export interface AvatarPermissionSettings {
avatarId: string
permissions: AvatarPermission[]
}
export const getAvatarPermissionSettings = (avatarId: string) =>
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) =>
request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, { permissions })
export interface TakeoverStatus {
enabled: boolean
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
message: string
pendingCount: number
lastPolledAt: string | null
}
export const getTakeoverStatus = (avatarId: string) =>
request.get<TakeoverStatus>(`/avatar/${avatarId}/takeover/status`)
export interface Authorization {
id: string
avatarId: string
@@ -153,16 +196,41 @@ export interface Authorization {
targetName: string
permissions: string[]
status: 'active' | 'inactive'
takeoverEnabled: boolean
takeoverMode: 'immediate' | 'delayed'
takeoverDelaySeconds: number
createdAt: string
}
export type AuthorizationInput = Pick<
Authorization,
'targetType' | 'targetId' | 'targetName' | 'permissions'
>
// 获取授权列表
export const getAuthorizationList = (avatarId: string) =>
request.get<Authorization[]>(`/avatar/${avatarId}/authorizations`)
// 添加授权
export const createAuthorization = (avatarId: string, data: AuthorizationInput) =>
request.post<Authorization>(`/avatar/${avatarId}/authorizations`, data)
// 更新授权
export const updateAuthorization = (avatarId: string, data: Partial<Authorization>) =>
request.put(`/avatar/${avatarId}/authorizations`, data)
export const updateAuthorization = (avatarId: string, data: Partial<Authorization> & { id: string }) =>
request.put<Authorization>(`/avatar/${avatarId}/authorizations`, data)
// 删除授权
export const deleteAuthorization = (avatarId: string, authorizationId: string) =>
request.delete<{ id: string }>(`/avatar/${avatarId}/authorizations/${authorizationId}`)
// 更新单聊接管配置
export const updateTakeoverConfig = (avatarId: string, data: {
authorizationId: string
takeoverEnabled: boolean
takeoverMode?: 'immediate' | 'delayed'
takeoverDelaySeconds?: number
}) =>
request.put<Authorization>(`/avatar/${avatarId}/authorizations/takeover`, data)
// ==================== 组织管理 API ====================
@@ -202,6 +270,7 @@ export interface KnowledgeDoc {
fileSize: number
fileUrl: string
status: string
filePresent?: boolean
vectorized?: boolean
embeddingModel?: string
chunkCount?: number
@@ -359,7 +428,7 @@ export const getUserProfile = () =>
export interface SmsLoginResult {
token: string
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
huihui: { userId: string; nickname: string; avatarUrl: string; token: string }
huihui: { userId: string; nickname: string; avatarUrl: string }
}
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
@@ -374,6 +443,10 @@ export const loginBySms = (phone: string, code: string) =>
export const loginByPassword = (account: string, password: string) =>
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
// Validate a production Huihui access token and exchange it for an app session.
export const loginByHuihuiToken = (token: string) =>
request.post<SmsLoginResult>('/huihui/token/login', { token })
// 当前登录用户
export const getCurrentUser = () =>
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
@@ -382,13 +455,4 @@ export const getCurrentUser = () =>
export const logoutUser = () =>
request.post('/huihui/logout')
// 更新接管配置
export const updateTakeoverConfig = (avatarId: string, data: {
authorizationId: string
takeoverEnabled: boolean
takeoverMode?: 'immediate' | 'delayed'
takeoverDelaySeconds?: number
}) =>
request.put(`/avatar/${avatarId}/authorizations/takeover`, data)
export default request
+46 -20
View File
@@ -1,14 +1,16 @@
import { createApp } from 'vue'
import uniWebView from '@dcloudio/uni-webview-js'
import App from './App.vue'
import router from './router'
import pinia from './store'
import { getLaunchParams, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
import { getLaunchParams, onNativeMessage, stripLaunchToken, UniEvents } from '@/utils/uniapp-bridge'
import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user'
import { setAuthToken } from '@/api'
// Bundle the bridge locally so the H5 does not depend on an external CDN.
;(window as any).uni = (window as any).uni || uniWebView
const app = createApp(App)
app.use(router)
app.use(pinia)
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
@@ -16,10 +18,8 @@ const params = getLaunchParams()
const avatarStore = useAvatarStore(pinia)
const userStore = useUserStore(pinia)
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户)
userStore.loadFromStorage()
if (userStore.isLogin && userStore.user) {
setAuthToken(userStore.token)
function syncAvatarProfile() {
if (!userStore.isLogin || !userStore.user) return
avatarStore.setNativeProfile({
userId: (userStore.user as any).huihuiUserId || '',
nickname: userStore.user.nickname || '',
@@ -27,29 +27,55 @@ if (userStore.isLogin && userStore.user) {
})
}
if (params.token) {
setAuthToken(params.token)
}
if (params.userId || params.nickname || params.avatar) {
avatarStore.setNativeProfile({
userId: params.userId || '',
nickname: params.nickname || '',
avatarUrl: params.avatar || ''
})
async function exchangeInjectedToken(token: string) {
stripLaunchToken()
userStore.clearSession()
await userStore.loginByToken(token)
syncAvatarProfile()
}
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
onNativeMessage((msg) => {
if (!msg || !msg.type) return
if (msg.type === 'tokenRefresh' && msg.token) {
setAuthToken(msg.token)
void exchangeInjectedToken(msg.token).catch(() => {
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
void router.replace('/login/sms')
})
}
if (msg.type === 'userUpdate' && msg.user) {
avatarStore.setNativeProfile(msg.user)
}
})
app.mount('#app')
async function bootstrap() {
// A URL token represents the current production user and must override stale storage.
if (params.token) {
try {
await exchangeInjectedToken(params.token)
await router.replace(window.location.hash.slice(1) || '/avatar/manage')
} catch {
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
await router.replace('/login/sms')
}
} else {
userStore.loadFromStorage()
syncAvatarProfile()
}
// 通知原生壳:H5 已就绪
UniEvents.ready()
if (params.userId || params.nickname || params.avatar) {
avatarStore.setNativeProfile({
userId: params.userId || '',
nickname: params.nickname || '',
avatarUrl: params.avatar || ''
})
}
app.use(router)
app.mount('#app')
// 通知原生壳:H5 已就绪
UniEvents.ready()
}
void bootstrap()
+24
View File
@@ -45,6 +45,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/AuthorizationManage.vue'),
meta: { title: '授权管理', requiresAuth: true }
},
{
path: '/avatar/:avatarId/authorization',
name: 'AvatarAuthorizationManage',
component: () => import('@/views/AuthorizationManage.vue'),
meta: { title: '授权管理', requiresAuth: true }
},
{
path: '/token/charge',
name: 'TokenCharge',
@@ -81,6 +87,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/KnowledgeManage.vue'),
meta: { title: '知识库管理', requiresAuth: true }
},
{
path: '/avatar/:avatarId/knowledge',
name: 'AvatarKnowledgeManage',
component: () => import('@/views/KnowledgeManage.vue'),
meta: { title: '知识库管理', requiresAuth: true }
},
{
path: '/knowledge/qa/create',
name: 'QaPairCreate',
@@ -93,6 +105,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/QaPairEdit.vue'),
meta: { title: '编辑问答对', requiresAuth: true }
},
{
path: '/avatar/:avatarId/knowledge/qa/create',
name: 'AvatarQaPairCreate',
component: () => import('@/views/QaPairEdit.vue'),
meta: { title: '添加问答对', requiresAuth: true }
},
{
path: '/avatar/:avatarId/knowledge/qa/:qaId/edit',
name: 'AvatarQaPairEdit',
component: () => import('@/views/QaPairEdit.vue'),
meta: { title: '编辑问答对', requiresAuth: true }
},
{
path: '/login/sms',
name: 'SmsLogin',
+20 -2
View File
@@ -1,13 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getUserProfile } from '@/api'
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getTokenUsage, getUserProfile } from '@/api'
import { unwrapListData } from '@/utils/avatar-page-data'
export const useAvatarStore = defineStore('avatar', () => {
// 已创建的分身列表(来自后端)
const avatars = ref<any[]>([])
// 全局 Token 余额(来自后端)
// 当前用户所有分身共享的 Token 账户
const tokenBalance = ref<number>(0)
const tokenConsumed = ref<number>(0)
const tokenUsageByAvatar = ref<Record<string, number>>({})
// 当前选中分身 id
const currentAvatarId = ref<string | null>(null)
// 会会用户资料(头像/昵称,来自会会接口)
@@ -29,11 +31,24 @@ export const useAvatarStore = defineStore('avatar', () => {
try {
const res = await getTokenBalance()
tokenBalance.value = (res as any)?.balance ?? 0
tokenConsumed.value = (res as any)?.totalConsumed ?? 0
} catch (e) {
console.error('加载余额失败', e)
}
}
const loadTokenUsage = async () => {
try {
const rows = await getTokenUsage()
tokenUsageByAvatar.value = rows.reduce<Record<string, number>>((result, row) => {
result[row.avatarId] = (result[row.avatarId] || 0) + row.totalTokens
return result
}, {})
} catch (e) {
console.error('加载 Token 用量失败', e)
}
}
// 拉取会会用户资料(头像/昵称)
const loadUserProfile = async () => {
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
@@ -82,10 +97,13 @@ export const useAvatarStore = defineStore('avatar', () => {
return {
avatars,
tokenBalance,
tokenConsumed,
tokenUsageByAvatar,
currentAvatarId,
userProfile,
loadAvatars,
loadTokenBalance,
loadTokenUsage,
loadUserProfile,
setNativeProfile,
addAvatar,
+45 -21
View File
@@ -1,6 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { setAuthToken, sendSmsCode, loginBySms, loginByPassword, logoutUser, type UserProfile } from '@/api'
import {
setAuthToken,
sendSmsCode,
loginBySms,
loginByPassword,
loginByHuihuiToken,
logoutUser,
type SmsLoginResult,
type UserProfile
} from '@/api'
const TOKEN_KEY = 'hh_app_token'
const USER_KEY = 'hh_app_user'
@@ -37,6 +46,23 @@ export const useUserStore = defineStore('smsuser', () => {
localStorage.removeItem(USER_KEY)
}
const clearSession = () => {
token.value = ''
user.value = null
isLogin.value = false
setAuthToken(null)
clearLocal()
}
const acceptLogin = (res: SmsLoginResult) => {
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
}
// 发送验证码(返回结果,演示模式含 devCode)
const sendCode = async (phone: string) => {
return await sendSmsCode(phone)
@@ -44,26 +70,17 @@ export const useUserStore = defineStore('smsuser', () => {
// 短信登录
const login = async (phone: string, code: string) => {
const res: any = await loginBySms(phone, code)
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
return acceptLogin(await loginBySms(phone, code))
}
// 账号密码登录
const loginByPwd = async (account: string, password: string) => {
const res: any = await loginByPassword(account, password)
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
return acceptLogin(await loginByPassword(account, password))
}
const loginByToken = async (huihuiToken: string) =>
acceptLogin(await loginByHuihuiToken(huihuiToken))
// 退出
const logout = async () => {
try {
@@ -71,12 +88,19 @@ export const useUserStore = defineStore('smsuser', () => {
} catch {
/* 忽略网络错误,本地清除即可 */
}
token.value = ''
user.value = null
isLogin.value = false
setAuthToken(null)
clearLocal()
clearSession()
}
return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout }
return {
token,
user,
isLogin,
loadFromStorage,
clearSession,
sendCode,
login,
loginByPwd,
loginByToken,
logout
}
})
+4
View File
@@ -0,0 +1,4 @@
declare module '@dcloudio/uni-webview-js' {
const uniWebView: unknown
export default uniWebView
}
+5
View File
@@ -41,5 +41,10 @@ export function pickAvatarId(
currentAvatarId: string | null | undefined,
avatars?: AvatarPageRecord[]
): string | null
export function pickScopedAvatarId(
routeAvatarId: string | string[] | null | undefined,
currentAvatarId: string | null | undefined,
avatars?: AvatarPageRecord[]
): string | null
export function normalizeAvatarEditForm(avatar?: AvatarPageRecord): AvatarEditForm
export function buildAvatarUpdatePayload(form: AvatarEditForm): AvatarUpdatePayload
@@ -8,6 +8,11 @@ export function pickAvatarId(currentAvatarId, avatars) {
return currentAvatarId || avatars?.[0]?.id || null
}
export function pickScopedAvatarId(routeAvatarId, currentAvatarId, avatars) {
const requested = Array.isArray(routeAvatarId) ? routeAvatarId[0] : routeAvatarId
return requested ? String(requested) : pickAvatarId(currentAvatarId, avatars)
}
export function normalizeAvatarEditForm(avatar = {}) {
const config = avatar.config || {}
return {
+34 -14
View File
@@ -1,9 +1,7 @@
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
// 协议详见 uniapp-avatar/README.md
//
// 引入方式:在 index.html 中加载 uniapp web-view bridge:
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
// uni-webview bridge is bundled by main.ts; no external CDN is required.
const BRIDGE_HANDLER = '__uniBridgeHandle__'
@@ -15,6 +13,16 @@ export interface UniLaunchParams {
ts?: string
}
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts']
function readParams(search: string, target: UniLaunchParams): void {
const sp = new URLSearchParams(search)
for (const key of PARAM_KEYS) {
const value = sp.get(key)
if (value) target[key] = value
}
}
// 是否运行在 uniapp web-view 环境中
export function isInUniWebView(): boolean {
return !!(window as any).uni?.webView
@@ -22,21 +30,33 @@ export function isInUniWebView(): boolean {
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
export function getLaunchParams(): UniLaunchParams {
const sp = new URLSearchParams(window.location.search)
const params: UniLaunchParams = {}
const token = sp.get('token')
const userId = sp.get('userId')
const nickname = sp.get('nickname')
const avatar = sp.get('avatar')
const ts = sp.get('ts')
if (token) params.token = token
if (userId) params.userId = userId
if (nickname) params.nickname = decodeURIComponent(nickname)
if (avatar) params.avatar = decodeURIComponent(avatar)
if (ts) params.ts = ts
readParams(window.location.search, params)
const hashQueryIndex = window.location.hash.indexOf('?')
if (hashQueryIndex >= 0) {
readParams(window.location.hash.slice(hashQueryIndex + 1), params)
}
return params
}
// Remove the one-time login credential before any route is rendered or logged.
export function stripLaunchToken(): void {
const url = new URL(window.location.href)
url.searchParams.delete('token')
const hash = url.hash.slice(1)
const queryIndex = hash.indexOf('?')
if (queryIndex >= 0) {
const path = hash.slice(0, queryIndex)
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1))
hashParams.delete('token')
const query = hashParams.toString()
url.hash = `${path}${query ? `?${query}` : ''}`
}
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
}
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
export function postToNative(message: Record<string, any>): boolean {
if (!isInUniWebView()) return false
File diff suppressed because it is too large Load Diff
+74 -396
View File
@@ -30,6 +30,7 @@
<div class="token-info">
<span class="token-label">Token 余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div>
<button class="recharge-btn" @click="goToRecharge">充值</button>
</div>
@@ -44,6 +45,9 @@
<div v-if="avatars.length" class="avatar-list">
<div class="avatar-card" v-for="a in avatars" :key="a.id">
<button class="card-edit" @click="goToEdit(a.id)" :aria-label="`编辑${a.displayName || a.name}`">
<span aria-hidden="true">✎</span> 编辑
</button>
<div class="avatar-card-main">
<div class="avatar-photo">
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
@@ -52,13 +56,14 @@
<div class="avatar-details">
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token</span>
</div>
</div>
<div class="avatar-actions">
<button class="chat-link" @click="goToChat(a.id)"><span>💬</span> 对话</button>
<button class="knowledge-link" @click="goToKnowledge(a.id)"><span>📚</span> 知识库</button>
<button class="authorization-link" @click="goToAuthorization(a.id)"><span>🔑</span> 授权</button>
<button class="share-link" @click="shareAvatar(a)"><span>↗</span> 分享</button>
<button class="edit-link" @click="goToEdit(a.id)">编辑</button>
<button class="del-link" @click="askDelete(a)" aria-label="删除分身">删除</button>
</div>
</div>
</div>
@@ -72,88 +77,7 @@
<p v-if="shareToast" class="share-toast">{{ shareToast }}</p>
<!-- 分身工具入口 -->
<section class="tools-section">
<h3 class="section-title">分身工具</h3>
<div class="tools-grid">
<div class="tool-card" @click="goToKnowledge">
<div class="tool-icon">📚</div>
<div class="tool-info">
<span class="tool-name">知识库管理</span>
<span class="tool-desc">上传文档与标准问答</span>
</div>
<span class="tool-arrow">›</span>
</div>
<div class="tool-card" @click="goToAvatarCard">
<div class="tool-icon">🪪</div>
<div class="tool-info">
<span class="tool-name">分身名片</span>
<span class="tool-desc">生成并分享名片</span>
</div>
<span class="tool-arrow">›</span>
</div>
<div class="tool-card" @click="goToAvatarContacts">
<div class="tool-icon">🤝</div>
<div class="tool-info">
<span class="tool-name">分身人脉</span>
<span class="tool-desc">管理社交关系</span>
</div>
<span class="tool-arrow">›</span>
</div>
<div class="tool-card" @click="goToMyProjects">
<div class="tool-icon">📁</div>
<div class="tool-info">
<span class="tool-name">我的项目</span>
<span class="tool-desc">查看参与项目</span>
</div>
<span class="tool-arrow">›</span>
</div>
<div class="tool-card" @click="goToCreateOrg">
<div class="tool-icon">🏢</div>
<div class="tool-info">
<span class="tool-name">创建组织</span>
<span class="tool-desc">新建组织团队</span>
</div>
<span class="tool-arrow">›</span>
</div>
</div>
</section>
<!-- 分身动态列表 -->
<section class="activities-section">
<h3 class="section-title">分身动态</h3>
<div class="activity-list" v-if="activities.length > 0">
<div class="activity-item" v-for="activity in activities" :key="activity.id">
<div class="activity-icon" :class="activity.type">{{ activityIcon(activity.type) }}</div>
<div class="activity-content">
<p class="activity-text">{{ activity.text }}</p>
<span class="activity-time">{{ formatTime(activity.createdAt) }}</span>
</div>
</div>
</div>
<div class="empty-state" v-else>
<span class="empty-icon">📭</span>
<p class="empty-text">暂无动态</p>
</div>
</section>
<!-- 删除确认弹窗 -->
<div v-if="showDelete" class="modal-mask" @click.self="cancelDelete">
<div class="modal">
<div class="modal-icon">⚠️</div>
<h3 class="modal-title">删除数字分身</h3>
<p class="modal-text">
确认删除「{{ pendingDelete?.displayName || pendingDelete?.name }}」?<br />
其知识库、问答对、授权等关联数据将一并清除,且<b>不可恢复</b>。
</p>
<div class="modal-actions">
<button class="modal-cancel" @click="cancelDelete">取消</button>
<button class="modal-confirm" :disabled="deleting" @click="confirmDelete">
{{ deleting ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
<!-- 分身工具和分身动态暂不展示,入口统一收拢到每张分身卡片。 -->
</div>
</template>
@@ -173,20 +97,12 @@ const me = computed(() => userStore.user)
// 状态(来自 store / 后端)
const tokenBalance = computed(() => avatarStore.tokenBalance)
const tokenConsumed = computed(() => avatarStore.tokenConsumed)
const avatars = computed(() => avatarStore.avatars)
const avatarTokenUsage = (id: string) => avatarStore.tokenUsageByAvatar[id] || 0
// 删除确认弹窗状态
const showDelete = ref(false)
const pendingDelete = ref<any>(null)
const deleting = ref(false)
const shareToast = ref('')
const activities = ref<Array<{ id: string; type: string; text: string; createdAt: string }>>([
{ id: '1', type: 'create', text: '数字分身创建成功', createdAt: new Date(Date.now() - 86400000).toISOString() },
{ id: '2', type: 'edit', text: '更新了分身描述', createdAt: new Date(Date.now() - 43200000).toISOString() },
{ id: '3', type: 'authorize', text: '授权微信小程序访问', createdAt: new Date(Date.now() - 3600000).toISOString() }
])
// 状态文本
const statusText = (status: string) => {
const map: Record<string, string> = {
@@ -197,55 +113,6 @@ const statusText = (status: string) => {
return map[status] || status
}
// 活动图标
const activityIcon = (type: string) => {
const map: Record<string, string> = {
'create': '✨',
'edit': '✏️',
'authorize': '🔑',
'interact': '💬'
}
return map[type] || '📌'
}
// 格式化时间
const formatTime = (time: string) => {
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
return `${days}天前`
}
// 删除流程
const askDelete = (a: any) => {
pendingDelete.value = a
showDelete.value = true
}
const cancelDelete = () => {
if (deleting.value) return
showDelete.value = false
pendingDelete.value = null
}
const confirmDelete = async () => {
if (!pendingDelete.value || deleting.value) return
deleting.value = true
try {
await avatarStore.removeAvatar(pendingDelete.value.id)
showDelete.value = false
pendingDelete.value = null
} catch (e: any) {
alert(e?.message || '删除失败,请稍后重试')
} finally {
deleting.value = false
}
}
// 导航
const goBack = () => {
router.back()
@@ -259,8 +126,12 @@ const goCreate = () => {
router.push('/avatar/create')
}
const goToKnowledge = () => {
router.push('/knowledge')
const goToKnowledge = (id: string) => {
router.push({ name: 'AvatarKnowledgeManage', params: { avatarId: id } })
}
const goToAuthorization = (id: string) => {
router.push({ name: 'AvatarAuthorizationManage', params: { avatarId: id } })
}
const goToEdit = (id: string) => {
@@ -303,26 +174,11 @@ const shareAvatar = async (avatar: any) => {
}
}
const goToAvatarCard = () => {
router.push('/avatar/card')
}
const goToAvatarContacts = () => {
router.push('/avatar/contacts')
}
const goToMyProjects = () => {
router.push('/avatar/projects')
}
const goToCreateOrg = () => {
router.push('/avatar/org/create')
}
onMounted(() => {
userStore.loadFromStorage()
avatarStore.loadAvatars()
avatarStore.loadTokenBalance()
avatarStore.loadTokenUsage()
})
</script>
@@ -330,7 +186,7 @@ onMounted(() => {
.avatar-manage-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: 80px;
padding-bottom: calc(28px + env(safe-area-inset-bottom));
}
/* 顶部导航 */
@@ -465,6 +321,12 @@ onMounted(() => {
color: #F97316;
}
.token-used {
margin-top: 3px;
color: #A0A5B4;
font-size: 11px;
}
.recharge-btn {
padding: 8px 16px;
background: #F97316;
@@ -516,13 +378,32 @@ onMounted(() => {
}
.avatar-card {
position: relative;
padding: 16px;
background: white;
border: 1px solid #F4E5D9;
border-radius: 18px;
box-shadow: 0 8px 22px rgba(112, 62, 22, .07);
}
.avatar-card-main { display: flex; align-items: center; gap: 14px; }
.avatar-card-main { display: flex; align-items: center; gap: 14px; padding: 4px 68px 2px 0; }
.card-edit {
position: absolute;
top: 12px;
right: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 7px 10px;
border: 1px solid #E9E2DC;
border-radius: 999px;
color: #6B625C;
background: #FAF8F6;
font-size: 12px;
cursor: pointer;
}
.card-edit:active { background: #F2EDE8; }
.avatar-photo {
width: 66px;
@@ -570,6 +451,13 @@ onMounted(() => {
white-space: nowrap;
}
.avatar-token-usage {
display: inline-block;
margin-top: 5px;
color: #A0A5B4;
font-size: 10px;
}
.avatar-status {
display: inline-flex;
align-items: center;
@@ -598,56 +486,39 @@ onMounted(() => {
}
.avatar-actions {
display: flex;
align-items: center;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 16px;
}
.avatar-actions button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 0;
min-height: 58px;
padding: 8px 3px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.avatar-actions button span { font-size: 17px; line-height: 1; }
.chat-link {
flex: 1;
padding: 10px 8px;
background: linear-gradient(135deg, #F97316, #FB923C);
color: #fff;
border: none;
border-radius: 10px;
font-size: 13px;
cursor: pointer;
}
.chat-link span, .share-link span { margin-right: 3px; }
.share-link { flex: 1; padding: 10px 8px; border: 1px solid #FFD5AF; border-radius: 10px; color: #C15F18; background: #FFF8F1; font-size: 13px; cursor: pointer; }
.knowledge-link { border: 1px solid #F5D7B8; color: #A85418; background: #FFF8F1; }
.authorization-link { border: 1px solid #DCE5F2; color: #47627D; background: #F6F9FC; }
.share-link { border: 1px solid #E5E1DD; color: #675E58; background: #FAF8F6; }
.edit-link {
padding: 10px 10px;
background: #F3F4F6;
color: #6B7280;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.edit-link:hover {
background: #E5E7EB;
}
.del-link {
padding: 10px 2px;
background: transparent;
color: #B6BCC8;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.share-toast { position: fixed; left: 50%; bottom: 92px; z-index: 300; max-width: calc(100vw - 48px); transform: translateX(-50%); padding: 10px 14px; border-radius: 10px; color: white; background: rgba(39, 32, 28, .88); font-size: 13px; text-align: center; }
.del-link:hover {
background: #FEE2E2;
}
.share-toast { position: fixed; left: 50%; bottom: 24px; z-index: 300; max-width: calc(100vw - 48px); transform: translateX(-50%); padding: 10px 14px; border-radius: 10px; color: white; background: rgba(39, 32, 28, .88); font-size: 13px; text-align: center; }
/* 空状态 */
.empty-state {
@@ -684,197 +555,4 @@ onMounted(() => {
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
}
/* 分身工具入口 */
.tools-section {
padding: 8px 20px 16px;
}
.tools-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.tool-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.tool-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.tool-icon {
font-size: 24px;
flex-shrink: 0;
}
.tool-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tool-name {
font-size: 14px;
font-weight: 600;
color: #18191C;
}
.tool-desc {
font-size: 12px;
color: #9398AE;
}
.tool-arrow {
font-size: 18px;
color: #C9CDD2;
flex-shrink: 0;
}
/* 分身动态列表 */
.activities-section {
padding: 0 20px;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.activity-icon {
font-size: 20px;
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #FFF0E6;
}
.activity-content {
flex: 1;
min-width: 0;
}
.activity-text {
font-size: 14px;
color: #18191C;
margin: 0 0 4px;
line-height: 1.4;
}
.activity-time {
font-size: 12px;
color: #9398AE;
}
/* 删除确认弹窗 */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 50;
animation: fade 0.2s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.modal {
width: 100%;
max-width: 320px;
background: white;
border-radius: 18px;
padding: 24px 22px 18px;
text-align: center;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
animation: pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes pop { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: none; } }
.modal-icon {
font-size: 36px;
margin-bottom: 8px;
}
.modal-title {
font-size: 17px;
font-weight: 700;
color: #18191C;
margin: 0 0 10px;
}
.modal-text {
font-size: 13px;
line-height: 1.6;
color: #6B7280;
margin: 0 0 20px;
}
.modal-actions {
display: flex;
gap: 12px;
}
.modal-cancel {
flex: 1;
padding: 12px;
background: #F3F4F6;
color: #6B7280;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.modal-confirm {
flex: 1;
padding: 12px;
background: #EF4444;
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.modal-confirm:hover {
opacity: 0.92;
}
.modal-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -37,10 +37,10 @@
<div class="card-content">
<div class="card-title-row">
<strong>{{ doc.filename }}</strong>
<span class="status-pill" :class="{ pending: !doc.vectorized }">{{ doc.vectorized ? '已入库' : '处理中' }}</span>
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span>
</div>
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
<p class="card-detail">{{ doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引' }}</p>
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p>
</div>
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
</article>
@@ -79,9 +79,9 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import {
getKnowledgeDocs,
uploadKnowledgeDoc,
@@ -93,9 +93,10 @@ import {
} from '@/api'
const router = useRouter()
const route = useRoute()
const store = useAvatarStore()
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
const activeTab = ref<'docs' | 'qa'>('docs')
const docs = ref<any[]>([])
@@ -200,9 +201,21 @@ const toggleQa = async (qa: any, e: Event) => {
}
}
const goAddQa = () => router.push('/knowledge/qa/create')
const goAddQa = () => {
if (route.params.avatarId && avatarId.value) {
router.push({ name: 'AvatarQaPairCreate', params: { avatarId: avatarId.value } })
return
}
router.push('/knowledge/qa/create')
}
const goEditQa = (qa: any) => router.push(`/knowledge/qa/${qa.id}/edit`)
const goEditQa = (qa: any) => {
if (route.params.avatarId && avatarId.value) {
router.push({ name: 'AvatarQaPairEdit', params: { avatarId: avatarId.value, qaId: qa.id } })
return
}
router.push(`/knowledge/qa/${qa.id}/edit`)
}
const removeQa = async (id: string) => {
if (!avatarId.value) return
@@ -226,6 +239,7 @@ onMounted(async () => {
if (!store.avatars.length) {
await store.loadAvatars()
}
if (avatarId.value) store.currentAvatarId = avatarId.value
await Promise.all([loadDocs(), loadQA()])
})
</script>
@@ -234,7 +248,7 @@ onMounted(async () => {
.knowledge-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: 80px;
padding-bottom: calc(28px + env(safe-area-inset-bottom));
overflow-x: hidden;
}
@@ -268,10 +282,16 @@ onMounted(async () => {
.card-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
.status-pill.missing { color: #B91C1C; background: #FEF2F2; }
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
.qa-card { align-items: stretch; }.qa-card.qa-disabled { opacity: .58; }
.qa-card { align-items: stretch; text-align: left; }.qa-card.qa-disabled { opacity: .58; }
.qa-card .card-content,
.qa-card .qa-question,
.qa-card .qa-answer,
.qa-card .card-meta,
.qa-card .qa-card-actions { text-align: left; }
.qa-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }.qa-label { color: #C15F18; font-size: 11px; font-weight: 700; }
.qa-question { display: block; color: #27201C; font-size: 15px; line-height: 1.5; }.qa-answer { display: -webkit-box; margin: 7px 0 0; overflow: hidden; color: #6B7280; font-size: 13px; line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.qa-card-actions { display: flex; gap: 8px; margin-top: 11px; }
@@ -492,6 +512,8 @@ onMounted(async () => {
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
.card-content { grid-column: 2; }
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; }
.qa-card { display: block; }
.qa-card .card-content { width: 100%; grid-column: 1; }
.card-title-row { align-items: flex-start; flex-wrap: wrap; gap: 5px 7px; }
.status-pill { order: 2; }
.search-bar { gap: 8px; }
+8 -4
View File
@@ -49,14 +49,14 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import { getQAPairs, createQAPair, updateQAPair } from '@/api'
const router = useRouter()
const route = useRoute()
const store = useAvatarStore()
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
const qaId = computed(() => (route.params.qaId as string) || null)
const isEdit = computed(() => !!qaId.value)
@@ -104,8 +104,11 @@ const save = async () => {
} else {
await createQAPair(avatarId.value, payload)
}
// 保存成功返回知识库管理页
router.replace('/knowledge')
if (route.params.avatarId) {
router.replace({ name: 'AvatarKnowledgeManage', params: { avatarId: avatarId.value } })
} else {
router.replace('/knowledge')
}
} catch (e: any) {
error.value = e?.message || '保存失败'
} finally {
@@ -117,6 +120,7 @@ onMounted(async () => {
if (!store.avatars.length) {
await store.loadAvatars()
}
if (avatarId.value) store.currentAvatarId = avatarId.value
if (isEdit.value) {
await loadForEdit()
}
+9 -1
View File
@@ -138,7 +138,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, computed, onUnmounted } from 'vue'
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useAvatarStore } from '@/store/avatar'
@@ -161,6 +161,14 @@ const password = ref('')
const loading = ref(false)
const errorMsg = ref('')
onMounted(() => {
const ssoError = sessionStorage.getItem('hh_sso_error')
if (ssoError) {
errorMsg.value = ssoError
sessionStorage.removeItem('hh_sso_error')
}
})
const counting = ref(false)
const countdown = ref(60)
let timer: any = null
+16 -2
View File
@@ -13,6 +13,7 @@
<span class="balance-label">当前余额</span>
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
<span class="balance-unit">Token</span>
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} Token</span>
</div>
</section>
@@ -28,7 +29,7 @@
@click="selectedPlan = plan"
>
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div>
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
<div class="plan-unit">Token</div>
<div class="plan-price">¥{{ plan.price }}</div>
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
@@ -83,7 +84,8 @@ import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
const router = useRouter()
// 当前余额
const currentBalance = ref<number>(1250)
const currentBalance = ref<number>(0)
const totalConsumed = ref<number>(0)
// 充值套餐
const plans = ref<Array<{
@@ -105,6 +107,7 @@ const loadData = async () => {
try {
const b: any = await getTokenBalance()
currentBalance.value = b?.balance ?? 0
totalConsumed.value = b?.totalConsumed ?? 0
} catch (e) {
console.error('加载余额失败', e)
}
@@ -118,6 +121,10 @@ const loadData = async () => {
// 执行充值(写入后端)
const charging = ref(false)
const formatTokenAmount = (amount: number) => {
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
return amount.toLocaleString()
}
const doCharge = async () => {
if (!selectedPlan.value || charging.value) return
charging.value = true
@@ -187,6 +194,7 @@ onMounted(() => {
.balance-card {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
padding: 20px;
@@ -211,6 +219,12 @@ onMounted(() => {
opacity: 0.9;
}
.balance-used {
flex-basis: 100%;
font-size: 12px;
opacity: 0.82;
}
/* 充值套餐 */
.plans-section {
padding: 0 20px 20px;
+1
View File
@@ -17,6 +17,7 @@ services:
- REDIS_PORT=6379
- SECRET_KEY=your-secret-key-change-in-production
- AES_KEY=your-aes-key-32-chars-change-now!
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
- TZ=Asia/Shanghai
- AVATAR_DB_PATH=/app/avatar.db
volumes:
+1
View File
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS `ai_model_configs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`model_name` varchar(64) NOT NULL COMMENT '模型名称',
`provider` varchar(32) NOT NULL COMMENT 'openai/zhipu/wenxin/qianwen/local',
`usage_scope` varchar(16) NOT NULL DEFAULT 'general' COMMENT '用途:general/digital_avatar',
`api_base_url` varchar(256) DEFAULT NULL COMMENT 'API地址',
`api_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key',
`model_version` varchar(64) DEFAULT NULL COMMENT '模型版本',
+2 -1
View File
@@ -6,7 +6,8 @@ RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
# Nginx 1.31 uses syscalls that are blocked by the test server's legacy kernel.
FROM nginx:1.28.3-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+16 -4
View File
@@ -15,6 +15,9 @@
<span class="model-title">{{ m.model_name }}</span>
</div>
<div style="display:flex;gap:6px;align-items:center">
<el-tag :type="m.usage_scope === 'digital_avatar' ? 'warning' : 'info'" size="small">
{{ scopeLabels[m.usage_scope] || '通用业务' }}
</el-tag>
<el-tag v-if="m.is_default" type="success" size="small">默认</el-tag>
<el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag>
</div>
@@ -49,6 +52,13 @@
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
</el-select>
</el-form-item>
<el-form-item label="使用场景" prop="usage_scope">
<el-radio-group v-model="form.usage_scope">
<el-radio-button value="general">通用业务</el-radio-button>
<el-radio-button value="digital_avatar">数字分身专用</el-radio-button>
</el-radio-group>
<div class="scope-tip">数字分身专用模型仅用于分身对话和主动接管回复</div>
</el-form-item>
<el-form-item label="API地址">
<el-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
</el-form-item>
@@ -131,8 +141,9 @@ const testResult = ref(null)
const testing = ref(false)
const providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' }
const form = reactive({ model_name: '', provider: 'openai', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }] }
const scopeLabels = { general: '通用业务', digital_avatar: '数字分身专用' }
const form = reactive({ model_name: '', provider: 'openai', usage_scope: 'general', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }], usage_scope: [{ required: true }] }
async function load() {
const res = await getAIModels()
@@ -155,13 +166,13 @@ function onProviderChange(provider) {
function openCreate() {
editModel.value = null
Object.assign(form, { model_name: '', provider: 'openai', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
Object.assign(form, { model_name: '', provider: 'openai', usage_scope: 'general', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
dialogVisible.value = true
}
function openEdit(m) {
editModel.value = m
Object.assign(form, { model_name: m.model_name, provider: m.provider, api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
Object.assign(form, { model_name: m.model_name, provider: m.provider, usage_scope: m.usage_scope || 'general', api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
dialogVisible.value = true
}
@@ -236,4 +247,5 @@ onMounted(load)
.result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
.empty-state { grid-column: 1/-1; padding: 40px; }
.scope-tip { margin-top: 6px; color: var(--color-text-muted); font-size: 12px; line-height: 1.5; }
</style>