diff --git a/backend/app/api/endpoints/ai_models.py b/backend/app/api/endpoints/ai_models.py index 0f88fd4..b2d3c34 100755 --- a/backend/app/api/endpoints/ai_models.py +++ b/backend/app/api/endpoints/ai_models.py @@ -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, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 34844b5..a07b722 100755 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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( diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 6023207..80343ca 100755 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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("✅ 数据库初始化完成") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 75090ad..aa33965 100755 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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)) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 68eeab7..bc76534 100755 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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] diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index a475950..0322385 100755 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -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() diff --git a/digital-avatar-app/backend/database.py b/digital-avatar-app/backend/database.py index 076e2f0..84e7d10 100644 --- a/digital-avatar-app/backend/database.py +++ b/digital-avatar-app/backend/database.py @@ -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 <> ''" + ) diff --git a/digital-avatar-app/backend/embeddings.py b/digital-avatar-app/backend/embeddings.py index 6c54232..0e20019 100644 --- a/digital-avatar-app/backend/embeddings.py +++ b/digital-avatar-app/backend/embeddings.py @@ -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) diff --git a/digital-avatar-app/backend/main.py b/digital-avatar-app/backend/main.py index c34ebac..57beb2a 100644 --- a/digital-avatar-app/backend/main.py +++ b/digital-avatar-app/backend/main.py @@ -8,7 +8,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger 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 @@ -19,6 +19,7 @@ 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__) @@ -56,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( @@ -105,6 +118,7 @@ def seed(): db.add_all(orgs) db.commit() + release_stale_reservations(db) finally: db.close() @@ -143,14 +157,28 @@ def on_startup(): 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, + 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("BOXIM takeover scheduler started (interval=%ss)", poll_interval) + 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}") diff --git a/digital-avatar-app/backend/models.py b/digital-avatar-app/backend/models.py index 38b1484..b4ae291 100644 --- a/digital-avatar-app/backend/models.py +++ b/digital-avatar-app/backend/models.py @@ -1,6 +1,7 @@ import uuid from sqlalchemy import ( + BigInteger, Boolean, Column, DateTime, @@ -259,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="") diff --git a/digital-avatar-app/backend/routers/chat.py b/digital-avatar-app/backend/routers/chat.py index 74233e2..9e5d863 100644 --- a/digital-avatar-app/backend/routers/chat.py +++ b/digital-avatar-app/backend/routers/chat.py @@ -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 diff --git a/digital-avatar-app/backend/routers/huihui_auth.py b/digital-avatar-app/backend/routers/huihui_auth.py index 25bf880..39ee61f 100644 --- a/digital-avatar-app/backend/routers/huihui_auth.py +++ b/digital-avatar-app/backend/routers/huihui_auth.py @@ -29,6 +29,7 @@ _CN_TZ = timezone(timedelta(hours=8)) from database import get_db 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,6 +295,47 @@ def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)): }) +@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: @@ -332,7 +387,13 @@ def _find_or_link_user(db: Session, phone: str, huihui_user_id: str) -> User: return user -def _issue_session(db: Session, phone: str, info: dict): +def _issue_session( + db: Session, + phone: str, + info: dict, + *, + reuse_existing_session: bool = False, +): """建/链本地用户并签发本系统会话 token""" huihui_user_id = info.get("userId", "") user = _find_or_link_user(db, phone, huihui_user_id) @@ -343,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(), @@ -356,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", ""), }, }) diff --git a/digital-avatar-app/backend/routers/tokens.py b/digital-avatar-app/backend/routers/tokens.py index 18871e8..3a447e9 100644 --- a/digital-avatar-app/backend/routers/tokens.py +++ b/digital-avatar-app/backend/routers/tokens.py @@ -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 + ]) diff --git a/digital-avatar-app/backend/services/chat_model_config.py b/digital-avatar-app/backend/services/chat_model_config.py new file mode 100644 index 0000000..a2c71b1 --- /dev/null +++ b/digital-avatar-app/backend/services/chat_model_config.py @@ -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 diff --git a/digital-avatar-app/backend/services/takeover_service.py b/digital-avatar-app/backend/services/takeover_service.py index d803345..9a96f9d 100644 --- a/digital-avatar-app/backend/services/takeover_service.py +++ b/digital-avatar-app/backend/services/takeover_service.py @@ -86,24 +86,34 @@ class TakeoverService: self.reply_delay_seconds = reply_delay_seconds self.now = now self._sessions: dict[str, dict] = {} - self._run_lock = asyncio.Lock() + self._poll_lock = asyncio.Lock() + self._process_lock = asyncio.Lock() async def poll_and_process_messages(self): - """Run one complete cycle; polling always happens before reply dispatch.""" - if self._run_lock.locked(): + """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._run_lock: + 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) - generated = await self._prepare_replies() - if generated: - # Catch a human reply sent while the model was preparing its answer. - 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]: @@ -454,11 +464,19 @@ class TakeoverService: finally: db.close() - generated = 0 - for task_id in task_ids: - if await asyncio.to_thread(self._generate_reply, task_id): - generated += 1 - return generated + 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() @@ -502,7 +520,7 @@ class TakeoverService: from routers.chat import _resolve_reply - result = _resolve_reply(db, avatar, task.prompt, history) + 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": @@ -548,8 +566,8 @@ class TakeoverService: finally: db.close() - for task_id in task_ids: - await self._send_task(task_id) + 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() diff --git a/digital-avatar-app/backend/services/token_billing.py b/digital-avatar-app/backend/services/token_billing.py new file mode 100644 index 0000000..ab763a1 --- /dev/null +++ b/digital-avatar-app/backend/services/token_billing.py @@ -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) diff --git a/digital-avatar-app/backend/tests/conftest.py b/digital-avatar-app/backend/tests/conftest.py index 9d2068f..53d78a8 100644 --- a/digital-avatar-app/backend/tests/conftest.py +++ b/digital-avatar-app/backend/tests/conftest.py @@ -8,6 +8,8 @@ from models import ( TakeoverCursor, TakeoverMessage, TakeoverReplyTask, + TokenAccount, + TokenUsage, User, ) @@ -107,6 +109,13 @@ def authorization_context(): 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 ) diff --git a/digital-avatar-app/backend/tests/test_chat_model_config.py b/digital-avatar-app/backend/tests/test_chat_model_config.py new file mode 100644 index 0000000..43bd77b --- /dev/null +++ b/digital-avatar-app/backend/tests/test_chat_model_config.py @@ -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" diff --git a/digital-avatar-app/backend/tests/test_embeddings.py b/digital-avatar-app/backend/tests/test_embeddings.py index 7520631..eafb1d8 100644 --- a/digital-avatar-app/backend/tests/test_embeddings.py +++ b/digital-avatar-app/backend/tests/test_embeddings.py @@ -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() diff --git a/digital-avatar-app/backend/tests/test_huihui_auth.py b/digital-avatar-app/backend/tests/test_huihui_auth.py index cf0043f..252301f 100644 --- a/digital-avatar-app/backend/tests/test_huihui_auth.py +++ b/digital-avatar-app/backend/tests/test_huihui_auth.py @@ -1,6 +1,7 @@ """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 @@ -9,7 +10,8 @@ from sqlalchemy.pool import StaticPool from database import Base from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User -from routers.huihui_auth import _issue_session +from routers.huihui_auth import _issue_session, token_login +from services.boxim_client import BoxIMError @pytest.fixture @@ -135,3 +137,55 @@ def test_ambiguous_phone_matches_do_not_move_existing_avatars(db): 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 diff --git a/digital-avatar-app/backend/tests/test_takeover_scheduler.py b/digital-avatar-app/backend/tests/test_takeover_scheduler.py index 111c119..fff0bce 100644 --- a/digital-avatar-app/backend/tests/test_takeover_scheduler.py +++ b/digital-avatar-app/backend/tests/test_takeover_scheduler.py @@ -25,7 +25,8 @@ def test_scheduler_uses_boxim_and_restart_safe_service( boxim = MagicMock() mock_boxim_class.return_value = boxim takeover = MagicMock() - takeover.poll_and_process_messages = AsyncMock() + takeover.poll_messages = AsyncMock() + takeover.process_reply_tasks = AsyncMock() mock_takeover_class.return_value = takeover environment = { @@ -46,14 +47,18 @@ def test_scheduler_uses_boxim_and_restart_safe_service( assert config["BOXIM_API_BASE_URL"] == "https://im.example/api" mock_takeover_class.assert_called_once_with(main.SessionLocal, boxim) - scheduler.add_job.assert_called_once() - scheduled_callable = scheduler.add_job.call_args.args[0] - job_options = scheduler.add_job.call_args.kwargs - assert scheduled_callable is takeover.poll_and_process_messages - assert job_options["id"] == "takeover_message_poll" - assert job_options["trigger"].interval.total_seconds() == 1 - assert job_options["max_instances"] == 1 - assert job_options["coalesce"] is True + 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 diff --git a/digital-avatar-app/backend/tests/test_takeover_service.py b/digital-avatar-app/backend/tests/test_takeover_service.py index 2786a25..f8e850f 100644 --- a/digital-avatar-app/backend/tests/test_takeover_service.py +++ b/digital-avatar-app/backend/tests/test_takeover_service.py @@ -1,12 +1,12 @@ """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 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 @@ -58,11 +58,10 @@ class FakeBoxIM: @pytest.fixture -def service_context(): +def service_context(tmp_path): engine = create_engine( - "sqlite://", + f"sqlite:///{tmp_path / 'takeover.db'}", connect_args={"check_same_thread": False}, - poolclass=StaticPool, ) session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) Base.metadata.create_all(engine) @@ -143,6 +142,39 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c db.close() +@pytest.mark.asyncio +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) + + 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() + + 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_read_receipt_failure_does_not_advance_cursor(service_context): session_factory, service, boxim, clock = service_context diff --git a/digital-avatar-app/backend/tests/test_token_billing.py b/digital-avatar-app/backend/tests/test_token_billing.py new file mode 100644 index 0000000..3860a9d --- /dev/null +++ b/digital-avatar-app/backend/tests/test_token_billing.py @@ -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() diff --git a/digital-avatar-app/docker-compose.yml b/digital-avatar-app/docker-compose.yml index ee90f7b..4a9530f 100644 --- a/digital-avatar-app/docker-compose.yml +++ b/digital-avatar-app/docker-compose.yml @@ -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: diff --git a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md new file mode 100644 index 0000000..992b1c6 --- /dev/null +++ b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md @@ -0,0 +1,112 @@ +# 数字分身 H5 生产接入与部署 + +## 1. 接入方式 + +生产会会在用户已登录后打开以下地址: + +```text +https://<数字分身生产域名>/#/avatar/manage?token= +``` + +测试环境示例: + +```text +http://192.168.1.188:8099/#/avatar/manage?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= +HUIHUI_ACCESS_ID= +HUIHUI_ACCESS_SECRET= +HUIHUI_CLIENT_CODE= +BOXIM_TIMEOUT_SECONDS=20 + +DATABASE_URL=sqlite:////data/avatar.db +UPLOAD_DIR=/data/uploads +CHAT_MODEL_CONFIG_URL=http:///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 +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` 返回成功。 + +## 6. 回滚 + +保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。 diff --git a/digital-avatar-app/index.html b/digital-avatar-app/index.html index a9ce14e..1832bf8 100644 --- a/digital-avatar-app/index.html +++ b/digital-avatar-app/index.html @@ -7,8 +7,7 @@ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> 会会数字分身 - - + - diff --git a/digital-avatar-app/src/api/index.ts b/digital-avatar-app/src/api/index.ts index 59f0166..71e011c 100644 --- a/digital-avatar-app/src/api/index.ts +++ b/digital-avatar-app/src/api/index.ts @@ -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('/token/balance') // 获取充值套餐 export const getRechargePlans = () => @@ -143,6 +158,10 @@ export const getRechargePlans = () => export const chargeToken = (planId: string) => request.post<{ balance: number; charged: number }>('/token/charge', { planId }) +// 按分身和使用场景汇总 Token 消耗 +export const getTokenUsage = () => + request.get('/token/usage') + // ==================== 授权管理 API ==================== export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover' @@ -409,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 标记) @@ -424,6 +443,10 @@ export const loginBySms = (phone: string, code: string) => export const loginByPassword = (account: string, password: string) => request.post('/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('/huihui/token/login', { token }) + // 当前登录用户 export const getCurrentUser = () => request.get('/huihui/me') diff --git a/digital-avatar-app/src/main.ts b/digital-avatar-app/src/main.ts index ff22382..c6910e9 100644 --- a/digital-avatar-app/src/main.ts +++ b/digital-avatar-app/src/main.ts @@ -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() diff --git a/digital-avatar-app/src/router/index.ts b/digital-avatar-app/src/router/index.ts index 90d4377..1edac7d 100644 --- a/digital-avatar-app/src/router/index.ts +++ b/digital-avatar-app/src/router/index.ts @@ -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', diff --git a/digital-avatar-app/src/store/avatar.ts b/digital-avatar-app/src/store/avatar.ts index 0af7f4f..ed303a4 100644 --- a/digital-avatar-app/src/store/avatar.ts +++ b/digital-avatar-app/src/store/avatar.ts @@ -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([]) - // 全局 Token 余额(来自后端) + // 当前用户所有分身共享的 Token 账户 const tokenBalance = ref(0) + const tokenConsumed = ref(0) + const tokenUsageByAvatar = ref>({}) // 当前选中分身 id const currentAvatarId = ref(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>((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, diff --git a/digital-avatar-app/src/store/user.ts b/digital-avatar-app/src/store/user.ts index a1786c4..6bf56d5 100644 --- a/digital-avatar-app/src/store/user.ts +++ b/digital-avatar-app/src/store/user.ts @@ -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 + } }) diff --git a/digital-avatar-app/src/types/uni-webview-js.d.ts b/digital-avatar-app/src/types/uni-webview-js.d.ts new file mode 100644 index 0000000..660abf4 --- /dev/null +++ b/digital-avatar-app/src/types/uni-webview-js.d.ts @@ -0,0 +1,4 @@ +declare module '@dcloudio/uni-webview-js' { + const uniWebView: unknown + export default uniWebView +} diff --git a/digital-avatar-app/src/utils/avatar-page-data.d.ts b/digital-avatar-app/src/utils/avatar-page-data.d.ts index 883f30c..32bdb1f 100644 --- a/digital-avatar-app/src/utils/avatar-page-data.d.ts +++ b/digital-avatar-app/src/utils/avatar-page-data.d.ts @@ -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 diff --git a/digital-avatar-app/src/utils/avatar-page-data.js b/digital-avatar-app/src/utils/avatar-page-data.js index 57be97f..d96fdb9 100644 --- a/digital-avatar-app/src/utils/avatar-page-data.js +++ b/digital-avatar-app/src/utils/avatar-page-data.js @@ -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 { diff --git a/digital-avatar-app/src/utils/uniapp-bridge.ts b/digital-avatar-app/src/utils/uniapp-bridge.ts index 5ec2b56..504ca13 100644 --- a/digital-avatar-app/src/utils/uniapp-bridge.ts +++ b/digital-avatar-app/src/utils/uniapp-bridge.ts @@ -1,9 +1,7 @@ // 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具 // 协议详见 uniapp-avatar/README.md // -// 引入方式:在 index.html 中加载 uniapp web-view bridge: -// -// 引入后全局会出现 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): boolean { if (!isInUniWebView()) return false diff --git a/digital-avatar-app/src/views/AuthorizationManage.vue b/digital-avatar-app/src/views/AuthorizationManage.vue index c5095f0..a6510a7 100644 --- a/digital-avatar-app/src/views/AuthorizationManage.vue +++ b/digital-avatar-app/src/views/AuthorizationManage.vue @@ -109,7 +109,7 @@ @@ -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; -} diff --git a/digital-avatar-app/src/views/KnowledgeManage.vue b/digital-avatar-app/src/views/KnowledgeManage.vue index ac7d5ee..0a38319 100644 --- a/digital-avatar-app/src/views/KnowledgeManage.vue +++ b/digital-avatar-app/src/views/KnowledgeManage.vue @@ -79,9 +79,9 @@ @@ -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; } @@ -272,7 +286,12 @@ onMounted(async () => { .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; } @@ -493,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; } diff --git a/digital-avatar-app/src/views/QaPairEdit.vue b/digital-avatar-app/src/views/QaPairEdit.vue index f9252d3..41161dc 100644 --- a/digital-avatar-app/src/views/QaPairEdit.vue +++ b/digital-avatar-app/src/views/QaPairEdit.vue @@ -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() } diff --git a/digital-avatar-app/src/views/SmsLogin.vue b/digital-avatar-app/src/views/SmsLogin.vue index d5f7326..f49a0d2 100644 --- a/digital-avatar-app/src/views/SmsLogin.vue +++ b/digital-avatar-app/src/views/SmsLogin.vue @@ -138,7 +138,7 @@