Compare commits

...
Author SHA1 Message Date
stefanfeng 6794e88d53 fix(avatar): normalize embedding API endpoint 2026-08-28 16:59:22 +08:00
stefanfeng 7884430b3d Merge pull request 'feat(avatar): 优化会会 H5 嵌入管理流程' (#8) from codex/avatar-h5-embedded-layout-20260827 into main
Reviewed-on: #8
2026-08-27 11:41:21 +08:00
stefanfeng 46d42b7d98 fix(avatar): prevent takeover loops and isolate settings 2026-08-27 11:30:12 +08:00
stefanfeng ef58c5f2d2 feat(avatar): optimize embedded H5 management flow 2026-08-27 09:28:05 +08:00
stefanfeng 6e3fe5a616 Merge pull request 'feat(avatar): 接入会会支付并统一积分展示' (#7) from codex/avatar-token-copy-to-points-20260826 into main
Reviewed-on: #7
2026-08-26 14:53:58 +08:00
stefanfeng 67b6bd1b48 Merge pull request 'fix(avatar): 部署重启后自动恢复 BOXIM 接管' (#6) from codex/avatar-takeover-restart-safe-20260826 into main
Reviewed-on: #6
2026-08-26 14:53:49 +08:00
stefanfeng 0752001d85 Merge pull request 'feat(avatar): 数字分身自动跟随用户语言回答' (#5) from codex/avatar-auto-reply-language-20260826 into main
Reviewed-on: #5
2026-08-26 14:53:40 +08:00
stefanfeng c37294be17 feat(avatar): integrate Huihui payments 2026-08-26 14:41:21 +08:00
stefanfeng 5360cac8ad fix(avatar): normalize legacy plan copy 2026-08-26 14:02:38 +08:00
stefanfeng ea932f27fb feat(avatar): rename Token display to points 2026-08-26 14:01:12 +08:00
deploy bf0183bef4 add wechat mini-program domain verification file 2026-08-26 13:44:56 +08:00
stefanfeng 0c6419f37e fix(avatar): restore BOXIM takeover after restart 2026-08-26 13:25:16 +08:00
stefanfeng f768e7648f fix(avatar): prevent inferred reply scenarios 2026-08-26 11:56:54 +08:00
stefanfeng e30ab2b889 feat(avatar): follow user language in replies 2026-08-26 11:52:32 +08:00
stefanfeng 730f586784 Merge pull request 'docs(avatar): 配置 digital.99hui.com 生产域名' (#4) from codex/avatar-multi-management-integrated-20260825 into main
Reviewed-on: #4
2026-08-26 10:17:40 +08:00
stefanfeng ef2b1c6dd6 chore(avatar): add WeChat verification file 2026-08-26 09:51:13 +08:00
stefanfeng c693899b12 docs(avatar): set production H5 domain 2026-08-26 09:44:22 +08:00
stefanfeng d274ccb5e2 Merge pull request 'feat: 完成数字分身多分身管理与生产 H5 接入' (#3) from codex/avatar-multi-management-integrated-20260825 into main
Reviewed-on: #3
2026-08-26 09:43:52 +08:00
stefanfeng 5d19992f00 fix(avatar): remove SSO token from router state 2026-08-26 09:25:07 +08:00
stefanfeng 81aec1c63a feat(avatar): support production H5 token SSO 2026-08-26 09:17:41 +08:00
stefanfeng 4029c31ed7 fix(avatar): bundle uni bridge and add favicon 2026-08-25 17:19:30 +08:00
stefanfeng e24e89d326 fix(deploy): serialize model migration and pin nginx 2026-08-25 16:59:15 +08:00
stefanfeng dc34a03357 feat(ai): add dedicated digital avatar model config 2026-08-25 16:50:33 +08:00
stefanfeng 3f7ff9329a fix(avatar): align QA cards to the left 2026-08-25 15:36:49 +08:00
stefanfeng 699bbbde57 feat(avatar): add user token accounting 2026-08-25 13:24:02 +08:00
stefanfeng 7a0199e685 feat(avatar): improve multi-avatar management 2026-08-25 11:44:27 +08:00
stefanfeng 9e86cc64ac Merge pull request 'Codex/avatar integrated 20260819' (#2) from codex/avatar-integrated-20260819 into main
Reviewed-on: #2
2026-08-21 09:31:43 +08:00
66 changed files with 3986 additions and 840 deletions
+52 -4
View File
@@ -1,8 +1,11 @@
"""AI模型配置接口""" """AI模型配置接口"""
from fastapi import APIRouter, Depends, HTTPException import secrets
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import select, update from sqlalchemy import select, update
from app.core.database import get_db from app.core.database import get_db
from app.core.config import settings
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
from app.models import AIModelConfig from app.models import AIModelConfig
from app.utils.crypto import encrypt, decrypt from app.utils.crypto import encrypt, decrypt
@@ -22,10 +25,15 @@ async def list_models(db=Depends(get_db)):
@router.post("") @router.post("")
async def create_model(req: AIModelCreateRequest, db=Depends(get_db)): async def create_model(req: AIModelCreateRequest, db=Depends(get_db)):
if req.is_default: 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 = AIModelConfig(
model_name=req.model_name, model_name=req.model_name,
provider=req.provider, provider=req.provider,
usage_scope=req.usage_scope,
api_base_url=req.api_base_url, api_base_url=req.api_base_url,
api_key_enc=encrypt(req.api_key) if req.api_key else None, api_key_enc=encrypt(req.api_key) if req.api_key else None,
model_version=req.model_version, 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() model = result.scalar_one_or_none()
if not model: if not model:
raise HTTPException(status_code=404, detail="模型不存在") raise HTTPException(status_code=404, detail="模型不存在")
if req.is_default: target_scope = req.usage_scope or model.usage_scope
await db.execute(update(AIModelConfig).where(AIModelConfig.id != model_id).values(is_default=0)) 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(): for field, val in req.model_dump(exclude_none=True).items():
if field == "api_key": if field == "api_key":
model.api_key_enc = encrypt(val) if val else None 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="更新成功") 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}") @router.delete("/{model_id}")
async def delete_model(model_id: int, db=Depends(get_db)): async def delete_model(model_id: int, db=Depends(get_db)):
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id)) 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: def _format_model(m: AIModelConfig) -> dict:
return { return {
"id": m.id, "model_name": m.model_name, "provider": m.provider, "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), "api_base_url": m.api_base_url, "has_api_key": bool(m.api_key_enc),
"model_version": m.model_version, "temperature": m.temperature, "model_version": m.model_version, "temperature": m.temperature,
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds, "max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
+1
View File
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
# 安全 # 安全
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod") 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!") 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( NEWS_PLATFORM_BASE_URL: str = os.getenv(
+17
View File
@@ -1,6 +1,7 @@
"""数据库连接管理""" """数据库连接管理"""
import asyncio import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings from app.core.config import settings
from app.core.logger import logger from app.core.logger import logger
@@ -64,6 +65,22 @@ async def init_db():
VirtualUser, UserPersonality, InteractionRecord, VirtualUser, UserPersonality, InteractionRecord,
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog 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("✅ 数据库模型注册成功")
logger.info("✅ 数据库初始化完成") logger.info("✅ 数据库初始化完成")
+1
View File
@@ -122,6 +122,7 @@ class AIModelConfig(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
model_name: Mapped[str] = mapped_column(String(64), nullable=False) model_name: Mapped[str] = mapped_column(String(64), nullable=False)
provider: Mapped[str] = mapped_column(String(32), 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_base_url: Mapped[str | None] = mapped_column(String(256))
api_key_enc: Mapped[str | None] = mapped_column(String(512)) api_key_enc: Mapped[str | None] = mapped_column(String(512))
model_version: Mapped[str | None] = mapped_column(String(64)) model_version: Mapped[str | None] = mapped_column(String(64))
+4
View File
@@ -154,6 +154,7 @@ class InteractionResponse(BaseModel):
class AIModelCreateRequest(BaseModel): class AIModelCreateRequest(BaseModel):
model_name: str = Field(..., min_length=1, max_length=64) model_name: str = Field(..., min_length=1, max_length=64)
provider: str = Field(..., pattern="^(openai|zhipu|wenxin|qianwen|local)$") 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_base_url: Optional[str] = None
api_key: Optional[str] = None api_key: Optional[str] = None
model_version: Optional[str] = None model_version: Optional[str] = None
@@ -165,6 +166,8 @@ class AIModelCreateRequest(BaseModel):
class AIModelUpdateRequest(BaseModel): class AIModelUpdateRequest(BaseModel):
model_name: Optional[str] = None 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_base_url: Optional[str] = None
api_key: Optional[str] = None api_key: Optional[str] = None
model_version: Optional[str] = None model_version: Optional[str] = None
@@ -179,6 +182,7 @@ class AIModelResponse(BaseModel):
id: int id: int
model_name: str model_name: str
provider: str provider: str
usage_scope: str
api_base_url: Optional[str] api_base_url: Optional[str]
has_api_key: bool has_api_key: bool
model_version: Optional[str] model_version: Optional[str]
+3 -1
View File
@@ -28,7 +28,9 @@ class AIService:
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]: async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
result = await db.execute( result = await db.execute(
select(AIModelConfig).where( 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() return result.scalar_one_or_none()
+25 -1
View File
@@ -38,10 +38,17 @@ def init_db():
("avatars", "owner_id", "VARCHAR DEFAULT ''"), ("avatars", "owner_id", "VARCHAR DEFAULT ''"),
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"), ("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"), ("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"), ("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 180"),
("avatars", "share_token", "VARCHAR DEFAULT NULL"), ("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() _normalize_optional_unique_values()
_normalize_takeover_delays()
_create_token_indexes()
def _try_add_columns(*cols): def _try_add_columns(*cols):
@@ -58,3 +65,20 @@ def _try_add_columns(*cols):
def _normalize_optional_unique_values(): def _normalize_optional_unique_values():
with engine.begin() as conn: with engine.begin() as conn:
conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''") conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''")
def _normalize_takeover_delays():
with engine.begin() as conn:
# The old 30-second column default was never wired into the scheduler.
conn.exec_driver_sql(
"UPDATE authorizations SET takeover_delay_seconds = 180 "
"WHERE takeover_delay_seconds IS NULL OR takeover_delay_seconds = 30"
)
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 <> ''"
)
+9 -1
View File
@@ -18,6 +18,14 @@ EMBED_DIM = 256
MODEL = os.getenv("EMBEDDING_MODEL", "mock-hash-embed-v1") MODEL = os.getenv("EMBEDDING_MODEL", "mock-hash-embed-v1")
def _embedding_endpoint(api_url):
"""Accept either an OpenAI-compatible base URL or its full endpoint."""
api_url = (api_url or "").strip().rstrip("/")
if not api_url or api_url.endswith("/embeddings"):
return api_url
return f"{api_url}/embeddings"
def _tokenize(text): def _tokenize(text):
text = (text or "").lower() text = (text or "").lower()
# 英文/数字按词,CJK 逐字(中文无空格,需拆到字级才能命中子词) # 英文/数字按词,CJK 逐字(中文无空格,需拆到字级才能命中子词)
@@ -47,7 +55,7 @@ def embed(texts):
"""返回 list[list[float]],与输入顺序一致。""" """返回 list[list[float]],与输入顺序一致。"""
if not texts: if not texts:
return [] return []
api_url = os.getenv("EMBEDDING_API_URL") api_url = _embedding_endpoint(os.getenv("EMBEDDING_API_URL"))
if api_url: if api_url:
api_key = os.getenv("EMBEDDING_API_KEY", "") api_key = os.getenv("EMBEDDING_API_KEY", "")
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
+25 -11
View File
@@ -8,7 +8,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from database import init_db, SessionLocal 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 from fastapi.staticfiles import StaticFiles
import routers.avatars import routers.avatars
import routers.tokens import routers.tokens
@@ -19,6 +19,7 @@ import routers.huihui_auth
import routers.chat import routers.chat
import routers.takeover import routers.takeover
from responses import ok from responses import ok
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -56,17 +57,29 @@ def health():
def seed(): def seed():
db = SessionLocal() db = SessionLocal()
try: try:
if db.query(TokenAccount).first() is None: plan_specs = [
db.add(TokenAccount(balance=1250)) {"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
]
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: for user in db.query(User).all():
plans = [ account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"), if account is None:
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"), db.add(TokenAccount(
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"), user_id=user.id,
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"), balance=DEFAULT_TOKEN_GRANT,
] total_granted=DEFAULT_TOKEN_GRANT,
db.add_all(plans) total_consumed=0,
))
if db.query(Avatar).count() == 0: if db.query(Avatar).count() == 0:
avatar = Avatar( avatar = Avatar(
@@ -105,6 +118,7 @@ def seed():
db.add_all(orgs) db.add_all(orgs)
db.commit() db.commit()
release_stale_reservations(db)
finally: finally:
db.close() db.close()
+74 -4
View File
@@ -1,6 +1,7 @@
import uuid import uuid
from sqlalchemy import ( from sqlalchemy import (
BigInteger,
Boolean, Boolean,
Column, Column,
DateTime, DateTime,
@@ -66,7 +67,7 @@ class Authorization(Base):
status = Column(String, default="active") # active | inactive status = Column(String, default="active") # active | inactive
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管 takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
takeover_mode = Column(String, default="immediate") # immediate | delayed takeover_mode = Column(String, default="immediate") # immediate | delayed
takeover_delay_seconds = Column(Integer, default=30) # 延迟秒数 takeover_delay_seconds = Column(Integer, default=180) # 延迟秒数,默认 3 分钟
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
def to_dict(self): def to_dict(self):
@@ -187,7 +188,7 @@ class KnowledgeDoc(Base):
file_type = Column(String, default="") # pdf | doc | docx | xlsx file_type = Column(String, default="") # pdf | doc | docx | xlsx
file_size = Column(Integer, default=0) file_size = Column(Integer, default=0)
file_url = Column(String, default="") file_url = Column(String, default="")
status = Column(String, default="uploaded") # uploaded | parsing | ready status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
vectorized = Column(Boolean, default=False) # 是否已向量化 vectorized = Column(Boolean, default=False) # 是否已向量化
embedding_model = Column(String, default="") # 向量模型标识 embedding_model = Column(String, default="") # 向量模型标识
chunk_count = Column(Integer, default=0) # 切片数量 chunk_count = Column(Integer, default=0) # 切片数量
@@ -259,14 +260,42 @@ class KnowledgeChunk(Base):
class TokenAccount(Base): class TokenAccount(Base):
__tablename__ = "token_account" __tablename__ = "token_account"
id = Column(Integer, primary_key=True) 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): class TokenPlan(Base):
__tablename__ = "token_plans" __tablename__ = "token_plans"
id = Column(String, primary_key=True) id = Column(String, primary_key=True)
name = Column(String, default="") name = Column(String, default="")
amount = Column(Integer, default=0) amount = Column(BigInteger, default=0)
price = Column(Float, default=0) price = Column(Float, default=0)
badge = Column(String, default="") badge = Column(String, default="")
desc = Column(String, default="") desc = Column(String, default="")
@@ -282,6 +311,47 @@ class TokenPlan(Base):
} }
class TokenPaymentOrder(Base):
__tablename__ = "token_payment_orders"
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
order_no = Column(String, nullable=False, unique=True, index=True)
user_id = Column(String, nullable=False, index=True)
plan_id = Column(String, nullable=False)
payment_method = Column(String, nullable=False)
pay_type = Column(String, nullable=False)
pay_way = Column(String, nullable=False)
points_amount = Column(BigInteger, nullable=False)
price_cents = Column(Integer, nullable=False)
status = Column(String, nullable=False, default="pending", index=True)
provider_order_id = Column(String, default="")
provider_order_no = Column(String, default="")
provider_status = Column(String, default="")
pay_message = Column(Text, default="")
failure_reason = Column(String, default="")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
paid_at = Column(DateTime)
def to_dict(self):
return {
"id": self.id,
"orderNo": self.order_no,
"planId": self.plan_id,
"paymentMethod": self.payment_method,
"payType": self.pay_type,
"payWay": self.pay_way,
"pointsAmount": self.points_amount,
"price": self.price_cents / 100,
"status": self.status,
"providerStatus": self.provider_status,
"payMessage": self.pay_message,
"failureReason": self.failure_reason,
"createdAt": _iso(self.created_at),
"paidAt": _iso(self.paid_at),
}
class User(Base): class User(Base):
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)""" """会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import Authorization, TakeoverCursor, TakeoverReplyTask from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask
from responses import fail, ok from responses import fail, ok
from routers.avatars import _require_owned_avatar from routers.avatars import _require_owned_avatar
@@ -14,6 +14,10 @@ ALLOWED_PERMISSIONS = set(PERMISSION_ORDER)
AVATAR_PERMISSION_ORDER = PERMISSION_ORDER AVATAR_PERMISSION_ORDER = PERMISSION_ORDER
AVATAR_PERMISSION_KEY = "authorizationPermissions" AVATAR_PERMISSION_KEY = "authorizationPermissions"
DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"] DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"]
TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds"
DEFAULT_TAKEOVER_DELAY_SECONDS = 180
MIN_TAKEOVER_DELAY_SECONDS = 3
MAX_TAKEOVER_DELAY_SECONDS = 86_400
LEGACY_PERMISSION_MAP = { LEGACY_PERMISSION_MAP = {
"read": "browse", "read": "browse",
"reply": "chat", "reply": "chat",
@@ -91,9 +95,62 @@ def _permission_settings_payload(avatar) -> dict:
return { return {
"avatarId": avatar.id, "avatarId": avatar.id,
"permissions": _stored_avatar_permissions(avatar), "permissions": _stored_avatar_permissions(avatar),
"takeoverReplyDelaySeconds": _stored_takeover_delay(avatar),
} }
def _stored_takeover_delay(avatar) -> int:
raw = (avatar.config or {}).get(TAKEOVER_DELAY_KEY, DEFAULT_TAKEOVER_DELAY_SECONDS)
if isinstance(raw, bool):
return DEFAULT_TAKEOVER_DELAY_SECONDS
try:
delay = int(raw)
except (TypeError, ValueError):
return DEFAULT_TAKEOVER_DELAY_SECONDS
if not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS:
return DEFAULT_TAKEOVER_DELAY_SECONDS
return delay
def _validate_takeover_delay(value) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError("自动回复等待时间必须是整数秒")
if not MIN_TAKEOVER_DELAY_SECONDS <= value <= MAX_TAKEOVER_DELAY_SECONDS:
raise ValueError("自动回复等待时间需在 3 秒到 24 小时之间")
return value
def _disable_other_takeovers(db: Session, avatar) -> list[str]:
disabled_ids = []
others = (
db.query(Avatar)
.filter(Avatar.owner_id == avatar.owner_id, Avatar.id != avatar.id)
.all()
)
for other in others:
permissions = _stored_avatar_permissions(other)
if "takeover" not in permissions:
continue
other.config = {
**(other.config or {}),
AVATAR_PERMISSION_KEY: [item for item in permissions if item != "takeover"],
}
disabled_ids.append(other.id)
tasks = (
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.avatar_id == other.id,
TakeoverReplyTask.status.in_(("pending", "generating", "ready", "sending")),
)
.all()
)
for task in tasks:
task.status = "cancelled"
task.cancel_reason = "another_avatar_takeover_enabled"
task.locked_at = None
return disabled_ids
def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization: def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization:
authorization = ( authorization = (
db.query(Authorization) db.query(Authorization)
@@ -144,10 +201,19 @@ def update_permission_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
avatar = _require_owned_avatar(db, avatar_id, authorization) avatar = _require_owned_avatar(db, avatar_id, authorization)
if "permissions" not in payload: if "permissions" not in payload and TAKEOVER_DELAY_KEY not in payload:
return fail("缺少 permissions", 400) return fail("缺少授权设置", 400)
try: try:
permissions = _normalize_avatar_permissions(payload["permissions"]) permissions = (
_normalize_avatar_permissions(payload["permissions"])
if "permissions" in payload
else _stored_avatar_permissions(avatar)
)
takeover_delay = (
_validate_takeover_delay(payload[TAKEOVER_DELAY_KEY])
if TAKEOVER_DELAY_KEY in payload
else _stored_takeover_delay(avatar)
)
except ValueError as exc: except ValueError as exc:
return fail(str(exc), 400) return fail(str(exc), 400)
@@ -155,7 +221,9 @@ def update_permission_settings(
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
AVATAR_PERMISSION_KEY: permissions, AVATAR_PERMISSION_KEY: permissions,
TAKEOVER_DELAY_KEY: takeover_delay,
} }
disabled_avatar_ids = _disable_other_takeovers(db, avatar) if "takeover" in permissions else []
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first() cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
if cursor and "takeover" in permissions and "takeover" not in previous_permissions: if cursor and "takeover" in permissions and "takeover" not in previous_permissions:
cursor.initialized = False cursor.initialized = False
@@ -179,7 +247,9 @@ def update_permission_settings(
task.locked_at = None task.locked_at = None
db.commit() db.commit()
db.refresh(avatar) db.refresh(avatar)
return ok(_permission_settings_payload(avatar), "授权设置已保存") response = _permission_settings_payload(avatar)
response["disabledAvatarIds"] = disabled_avatar_ids
return ok(response, "授权设置已保存")
@router.get("/avatar/{avatar_id}/authorizations") @router.get("/avatar/{avatar_id}/authorizations")
@@ -240,7 +310,7 @@ def create_auth(
status="active", status="active",
takeover_enabled=False, takeover_enabled=False,
takeover_mode="immediate", takeover_mode="immediate",
takeover_delay_seconds=30, takeover_delay_seconds=DEFAULT_TAKEOVER_DELAY_SECONDS,
) )
db.add(item) db.add(item)
db.commit() db.commit()
+42 -16
View File
@@ -6,7 +6,17 @@ from sqlalchemy.orm import Session
from database import get_db from database import get_db
from routers.knowledge import UPLOAD_DIR from routers.knowledge import UPLOAD_DIR
from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User from models import (
Authorization,
Avatar,
KnowledgeChunk,
KnowledgeDoc,
QAPair,
TakeoverCursor,
TakeoverMessage,
TakeoverReplyTask,
User,
)
from responses import ok, fail from responses import ok, fail
router = APIRouter(tags=["分身"]) router = APIRouter(tags=["分身"])
@@ -74,18 +84,21 @@ def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(Non
@router.get("/avatar/{avatar_id}") @router.get("/avatar/{avatar_id}")
def get_avatar(avatar_id: str, db: Session = Depends(get_db)): def get_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: authorization: str = Header(None),
return fail("分身不存在", 404) db: Session = Depends(get_db),
return ok(a.to_dict()) ):
return ok(_require_owned_avatar(db, avatar_id, authorization).to_dict())
@router.post("/avatar") @router.post("/avatar")
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
user = _resolve_user(authorization, db) user = _resolve_user(authorization, db)
if not user:
raise HTTPException(status_code=401, detail="未登录")
a = Avatar( a = Avatar(
owner_id=user.huihui_user_id if user else "", owner_id=user.huihui_user_id,
name=payload.get("name", "未命名分身"), name=payload.get("name", "未命名分身"),
display_name=payload.get("displayName", "") or payload.get("display_name", ""), display_name=payload.get("displayName", "") or payload.get("display_name", ""),
description=payload.get("description", ""), description=payload.get("description", ""),
@@ -102,10 +115,13 @@ def create_avatar(payload: dict = Body(...), authorization: str = Header(None),
@router.put("/avatar/{avatar_id}") @router.put("/avatar/{avatar_id}")
def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)): def update_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: payload: dict = Body(...),
return fail("分身不存在", 404) authorization: str = Header(None),
db: Session = Depends(get_db),
):
a = _require_owned_avatar(db, avatar_id, authorization)
mapping = { mapping = {
"displayName": "display_name", "displayName": "display_name",
"photoUrl": "photo_url", "photoUrl": "photo_url",
@@ -114,22 +130,32 @@ def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depen
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"): for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
if key in payload: if key in payload:
col = mapping.get(key, key) col = mapping.get(key, key)
setattr(a, col, payload[key]) value = payload[key]
if key == "config":
if not isinstance(value, dict):
return fail("分身配置格式不正确", 400)
value = {**(a.config or {}), **value}
setattr(a, col, value)
db.commit() db.commit()
db.refresh(a) db.refresh(a)
return ok(a.to_dict()) return ok(a.to_dict())
@router.delete("/avatar/{avatar_id}") @router.delete("/avatar/{avatar_id}")
def delete_avatar(avatar_id: str, db: Session = Depends(get_db)): def delete_avatar(
a = db.query(Avatar).filter(Avatar.id == avatar_id).first() avatar_id: str,
if not a: authorization: str = Header(None),
return fail("分身不存在", 404) db: Session = Depends(get_db),
):
a = _require_owned_avatar(db, avatar_id, authorization)
# 级联清理关联数据,避免孤儿记录 # 级联清理关联数据,避免孤儿记录
db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete() db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete()
db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete() db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete()
db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete() db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete()
db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete() db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete()
db.query(TakeoverReplyTask).filter(TakeoverReplyTask.avatar_id == avatar_id).delete()
db.query(TakeoverMessage).filter(TakeoverMessage.avatar_id == avatar_id).delete()
db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).delete()
db.delete(a) db.delete(a)
db.commit() db.commit()
return ok({"success": True}) return ok({"success": True})
+263 -41
View File
@@ -16,12 +16,17 @@ import embeddings
from database import get_db from database import get_db
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
from responses import ok, fail 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=["数字分身聊天"]) 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_MESSAGE_LENGTH = 4000
MAX_HISTORY_MESSAGES = 10 MAX_HISTORY_MESSAGES = 10
QA_LEXICAL_THRESHOLD = 0.72 QA_LEXICAL_THRESHOLD = 0.72
@@ -29,6 +34,19 @@ QA_SEMANTIC_THRESHOLD = 0.72
QA_MATCH_MARGIN = 0.06 QA_MATCH_MARGIN = 0.06
KNOWLEDGE_MIN_SCORE = float(os.getenv("KNOWLEDGE_MIN_SCORE", "0.42")) KNOWLEDGE_MIN_SCORE = float(os.getenv("KNOWLEDGE_MIN_SCORE", "0.42"))
_WRITING_SYSTEM_PATTERNS = {
"han": re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]"),
"latin": re.compile(r"[A-Za-z\u00c0-\u024f]"),
"cyrillic": re.compile(r"[\u0400-\u052f]"),
"arabic": re.compile(r"[\u0600-\u06ff]"),
"hebrew": re.compile(r"[\u0590-\u05ff]"),
"devanagari": re.compile(r"[\u0900-\u097f]"),
"thai": re.compile(r"[\u0e00-\u0e7f]"),
"greek": re.compile(r"[\u0370-\u03ff]"),
}
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
class ChatMessage(BaseModel): class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$") role: str = Field(pattern="^(user|assistant)$")
@@ -65,6 +83,30 @@ def _normalize_question(value: str) -> str:
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》")) return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
def _dominant_writing_system(value: str) -> str:
value = value or ""
if _JAPANESE_KANA.search(value):
return "japanese"
if _KOREAN_HANGUL.search(value):
return "korean"
counts = {
name: len(pattern.findall(value))
for name, pattern in _WRITING_SYSTEM_PATTERNS.items()
}
name, count = max(counts.items(), key=lambda item: item[1])
return name if count else "unknown"
def _qa_requires_language_adaptation(question: str, answer: str) -> bool:
question_system = _dominant_writing_system(question)
answer_system = _dominant_writing_system(answer)
return (
question_system != "unknown"
and answer_system != "unknown"
and question_system != answer_system
)
def _canonicalize_question(value: str) -> str: def _canonicalize_question(value: str) -> str:
value = _normalize_question(value) value = _normalize_question(value)
replacements = ( replacements = (
@@ -184,7 +226,14 @@ def _config(avatar: Avatar) -> dict:
} }
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]: def _build_prompt(
avatar: Avatar,
history: list[Any],
question: str,
knowledge_hits: list[dict],
*,
standard_answer: str = "",
) -> list[dict]:
config = _config(avatar) config = _config(avatar)
description = (getattr(avatar, "description", "") or "").strip() description = (getattr(avatar, "description", "") or "").strip()
knowledge = "\n".join( knowledge = "\n".join(
@@ -205,7 +254,7 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
profile = ";".join(f"{label}:{value}" for label, value in profile_items) profile = ";".join(f"{label}:{value}" for label, value in profile_items)
system = ( system = (
f"你的专业或服务范围是:「{description or '未设置'}」。" f"你的专业或服务范围是:「{description or '未设置'}」。"
"请基于已提供的知识库回答,不要编造事实;" "请基于已提供的可靠资料回答,不要编造事实;"
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;" f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。" f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
) )
@@ -216,7 +265,13 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
) )
if config["systemPrompt"]: if config["systemPrompt"]:
system += f"\n额外系统提示词:{config['systemPrompt']}" system += f"\n额外系统提示词:{config['systemPrompt']}"
if knowledge: if standard_answer:
system += (
f"\n以下是本次问题命中的已确认标准答案:\n{standard_answer.strip()}"
"\n必须保持标准答案中的事实、数字、专有名词和结论不变,只允许为匹配用户当前语言进行忠实转换"
"和必要的自然表达,不得补充、删减或改写其含义。不要提及标准答案或转换过程。"
)
elif knowledge:
system += ( system += (
f"\n以下是本次问题检索到的可靠资料:\n{knowledge}" f"\n以下是本次问题检索到的可靠资料:\n{knowledge}"
"\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文" "\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文"
@@ -226,7 +281,8 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
system += ( system += (
"\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、" "\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、"
"专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能" "专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能"
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。" "补充更具体的问题;不要提及知识库、检索、模型或系统限制。对方询问你能做什么时,只能概括"
"专业或服务范围和本人资料中明确写出的内容,不得推断服务对象、工作场所、现实案例或从业经历。"
) )
system += ( system += (
"\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、" "\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、"
@@ -244,6 +300,14 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
"只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。" "只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。"
"不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。" "不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。"
) )
system += (
"\n回答语言规则(始终优先):先识别当前最后一条用户消息所使用的主要自然语言,并且只使用该语言回答。"
"用户用中文就用中文,使用 English 就用 English,使用日语、韩语、法语、西班牙语或其他语言时,"
"也必须使用对应语言。消息混用多种语言时跟随占主导的语言;用户明确指定回答语言时服从其指定。"
"历史消息、本人资料、标准答案和知识库使用的语言都不能覆盖当前用户消息的语言。"
"专有名词、品牌、地址、代码和必要缩写可保留原文。不要解释语言识别或翻译过程。"
"改变回答语言只改变表达语言,绝不能因此增加资料中没有的场景、身份、经历或事实。"
)
messages = [{"role": "system", "content": system}] messages = [{"role": "system", "content": system}]
for item in history[-MAX_HISTORY_MESSAGES:]: for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item) messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
@@ -279,21 +343,25 @@ def _search_knowledge(db: Session, avatar_id: str, question: str, top_k: int = 5
return results return results
def _call_qwen(messages: list[dict], temperature: float) -> str: def _call_qwen(
if not CHAT_API_KEY: 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") raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions" url = f"{model_config.api_base_url}/chat/completions"
payload = { payload = {
"model": CHAT_MODEL, "model": model_config.model,
"messages": messages, "messages": messages,
"temperature": temperature, "temperature": temperature,
"max_tokens": model_config.max_tokens,
} }
try: try:
response = httpx.post( response = httpx.post(
url, url,
headers={"Authorization": f"Bearer {CHAT_API_KEY}"}, headers={"Authorization": f"Bearer {model_config.api_key}"},
json=payload, json=payload,
timeout=30, timeout=model_config.timeout_seconds,
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
@@ -302,17 +370,33 @@ def _call_qwen(messages: list[dict], temperature: float) -> str:
raise RuntimeError("Qwen 模型服务暂时不可用") from exc raise RuntimeError("Qwen 模型服务暂时不可用") from exc
if not isinstance(answer, str) or not answer.strip(): if not isinstance(answer, str) or not answer.strip():
raise RuntimeError("Qwen 模型没有返回有效回答") 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 分片原样转为文本增量。""" """将 OpenAI 兼容接口的 SSE 分片原样转为文本增量。"""
if not CHAT_API_KEY: model_config = model_config or get_chat_model_config()
if not model_config.api_key:
raise RuntimeError("模型服务未配置") raise RuntimeError("模型服务未配置")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions" url = f"{model_config.api_base_url}/chat/completions"
payload = {"model": CHAT_MODEL, "messages": messages, "temperature": temperature, "stream": True} payload = {
"model": model_config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": model_config.max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
try: 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() response.raise_for_status()
for raw_line in response.iter_lines(): for raw_line in response.iter_lines():
line = raw_line.decode() if isinstance(raw_line, bytes) else raw_line line = raw_line.decode() if isinstance(raw_line, bytes) else raw_line
@@ -322,11 +406,15 @@ def _iter_qwen_stream(messages: list[dict], temperature: float):
if data == "[DONE]": if data == "[DONE]":
return return
try: try:
delta = json.loads(data).get("choices", [{}])[0].get("delta", {}).get("content") parsed = json.loads(data)
except (ValueError, IndexError, AttributeError): except (ValueError, IndexError, AttributeError):
continue 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: if delta:
yield delta yield {"content": delta}
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise RuntimeError("模型服务暂时不可用") from exc raise RuntimeError("模型服务暂时不可用") from exc
@@ -350,49 +438,165 @@ def _resolve_reply(
qa_pairs: list[Any] | None = None, qa_pairs: list[Any] | None = None,
search_fn: Callable[..., list[dict]] | None = None, search_fn: Callable[..., list[dict]] | None = None,
model_client: Callable[..., str] | None = None, model_client: Callable[..., str] | None = None,
usage_source: str = "chat",
) -> dict: ) -> dict:
if qa_pairs is None: if qa_pairs is None:
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all() qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs) matched = _match_standard_qa(question, qa_pairs)
if matched: adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
)
if matched and not adapt_qa_language:
return {"answer": matched.answer, "source": "qa", "references": []} return {"answer": matched.answer, "source": "qa", "references": []}
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query)) if matched:
hits = search_fn(question, avatar.id) hits = []
messages = _build_prompt(avatar, history, question, hits) messages = _build_prompt(
avatar,
history,
question,
hits,
standard_answer=matched.answer,
)
else:
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
hits = search_fn(question, avatar.id)
messages = _build_prompt(avatar, history, question, hits)
config = _config(avatar) config = _config(avatar)
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6) temperature = 0.0 if matched else min(
model_client = model_client or _call_qwen 0.45 if hits else 0.25,
answer = model_client(messages=messages, temperature=temperature) 0.2 + config["creativity"] / 100 * 0.6,
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, "answer": answer,
"source": "knowledge" if hits else "qwen", "source": "qa" if matched else ("knowledge" if hits else "qwen"),
"references": hits, "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() qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs) matched = _match_standard_qa(question, qa_pairs)
if matched: adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
)
messages, reservation = [], None
if matched and not adapt_qa_language:
source, references, chunks = "qa", [], _iter_text_chunks(matched.answer) source, references, chunks = "qa", [], _iter_text_chunks(matched.answer)
else: else:
references = _search_knowledge(db, avatar.id, question) if matched:
source = "knowledge" if references else "qwen" references = []
source = "qa"
messages = _build_prompt(
avatar,
history,
question,
references,
standard_answer=matched.answer,
)
else:
references = _search_knowledge(db, avatar.id, question)
source = "knowledge" if references else "qwen"
messages = _build_prompt(avatar, history, question, references)
config = _config(avatar) config = _config(avatar)
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6) temperature = 0.0 if matched else min(
chunks = _iter_qwen_stream(_build_prompt(avatar, history, question, references), temperature) 0.45 if references else 0.25,
0.2 + config["creativity"] / 100 * 0.6,
)
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 public: if public:
source, references = "public", [] source, references = "public", []
def generate(): def generate():
output_parts = []
provider_usage = None
settled = False
try: try:
yield _sse("meta", {"source": source, "references": references}) 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("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: except RuntimeError as exc:
yield _sse("error", {"message": str(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( return StreamingResponse(
generate(), generate(),
@@ -441,11 +645,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)): def public_chat(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
avatar = _require_shared_avatar(db, share_token) avatar = _require_shared_avatar(db, share_token)
try: 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["references"] = []
result["source"] = "public" result["source"] = "public"
result.pop("tokenUsage", None)
return ok(result) return ok(result)
except InsufficientTokensError as exc:
return fail(str(exc), code=402)
except RuntimeError as exc: except RuntimeError as exc:
return fail(str(exc), code=502) return fail(str(exc), code=502)
@@ -455,15 +662,30 @@ def chat(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(N
avatar = _require_owned_avatar(db, avatar_id, authorization) avatar = _require_owned_avatar(db, avatar_id, authorization)
try: try:
return ok(_resolve_reply(db, avatar, body.message, body.history)) return ok(_resolve_reply(db, avatar, body.message, body.history))
except InsufficientTokensError as exc:
return fail(str(exc), code=402)
except RuntimeError as exc: except RuntimeError as exc:
return fail(str(exc), code=502) return fail(str(exc), code=502)
@router.post("/avatar/{avatar_id}/chat/stream") @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)): 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") @router.post("/public/avatar/{share_token}/chat/stream")
def public_chat_stream(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)): 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
@@ -29,6 +29,7 @@ _CN_TZ = timezone(timedelta(hours=8))
from database import get_db from database import get_db
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from responses import ok, fail from responses import ok, fail
from services.boxim_client import BoxIMClient, BoxIMError
router = APIRouter(tags=["会会账号"]) 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) 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): def _call_huihui(path: str, params: dict, as_query: bool = False):
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)""" """调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
url = f"{AUTH_BASE_URL}{path}" 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: 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.""" """Move one user's avatar-owned data to a replacement Huihui identity."""
if not old_owner_id or old_owner_id == new_owner_id: 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 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""" """建/链本地用户并签发本系统会话 token"""
huihui_user_id = info.get("userId", "") huihui_user_id = info.get("userId", "")
user = _find_or_link_user(db, phone, huihui_user_id) 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"): if info.get("avatarUrl"):
user.avatar_url = info["avatarUrl"] user.avatar_url = info["avatarUrl"]
user.huihui_token = info.get("token", "") 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() user.last_login_at = datetime.now()
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
from services.token_billing import get_or_create_account
get_or_create_account(db, user.id)
return ok({ return ok({
"token": user.app_token, "token": user.app_token,
"user": user.to_dict(), "user": user.to_dict(),
@@ -356,7 +421,6 @@ def _issue_session(db: Session, phone: str, info: dict):
"userId": huihui_user_id, "userId": huihui_user_id,
"nickname": info.get("nickname", ""), "nickname": info.get("nickname", ""),
"avatarUrl": info.get("avatarUrl", ""), "avatarUrl": info.get("avatarUrl", ""),
"token": info.get("token", ""),
}, },
}) })
+44 -24
View File
@@ -1,5 +1,6 @@
import os import os
import json import json
import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -13,6 +14,7 @@ from responses import ok, fail
import embeddings import embeddings
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))) UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
@@ -69,6 +71,15 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
.order_by(KnowledgeDoc.created_at.desc()) .order_by(KnowledgeDoc.created_at.desc())
.all() .all()
) )
# Older synchronous uploads could be interrupted after persisting "parsing".
# New uploads are committed only after indexing finishes, so these rows are stale.
stale_docs = [doc for doc in docs if doc.status == "parsing"]
if stale_docs:
for doc in stale_docs:
doc.status = "failed"
doc.vectorized = False
doc.chunk_count = 0
db.commit()
return ok([_doc_payload(d) for d in docs]) return ok([_doc_payload(d) for d in docs])
@@ -88,6 +99,7 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(content) f.write(content)
doc = KnowledgeDoc( doc = KnowledgeDoc(
id=uuid.uuid4().hex,
avatar_id=avatar_id, avatar_id=avatar_id,
filename=file.filename, filename=file.filename,
file_type=ext.lstrip("."), file_type=ext.lstrip("."),
@@ -95,39 +107,47 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
file_url=f"/api/files/{avatar_id}/{stored}", file_url=f"/api/files/{avatar_id}/{stored}",
status="parsing", status="parsing",
) )
db.add(doc)
db.commit()
db.refresh(doc)
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片 # Complete extraction and embedding before the first database commit so a
# process restart cannot leave a permanent "parsing" row behind.
try: try:
text = embeddings.extract_text(path, ext) text = embeddings.extract_text(path, ext)
chunks = embeddings.chunk_text(text) chunks = embeddings.chunk_text(text)
if chunks: if not chunks:
vectors = embeddings.embed(chunks) raise ValueError("文档没有可建立索引的文字内容")
for i, (c, v) in enumerate(zip(chunks, vectors)): vectors = embeddings.embed(chunks)
db.add( if len(vectors) != len(chunks):
KnowledgeChunk( raise ValueError("向量服务返回数量与文档分段不一致")
doc_id=doc.id, doc.vectorized = True
avatar_id=avatar_id, doc.embedding_model = embeddings.MODEL
content=c, doc.chunk_count = len(chunks)
vector=json.dumps(v), doc.vectorized_at = datetime.now(timezone.utc)
chunk_index=i,
embedding_model=embeddings.MODEL,
)
)
doc.vectorized = True
doc.embedding_model = embeddings.MODEL
doc.chunk_count = len(chunks)
doc.vectorized_at = datetime.now(timezone.utc)
doc.status = "ready" doc.status = "ready"
db.add(doc)
for i, (chunk, vector) in enumerate(zip(chunks, vectors)):
db.add(
KnowledgeChunk(
doc_id=doc.id,
avatar_id=avatar_id,
content=chunk,
vector=json.dumps(vector),
chunk_index=i,
embedding_model=embeddings.MODEL,
)
)
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
except Exception as e: except Exception as exc:
print("vectorize failed:", e) db.rollback()
doc.status = "ready" # 上传成功但向量化失败,仍可展示 doc.status = "failed"
doc.vectorized = False
doc.embedding_model = ""
doc.chunk_count = 0
doc.vectorized_at = None
db.add(doc)
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
logger.exception("knowledge vectorization failed for %s: %s", doc.id, exc)
return ok(_doc_payload(doc)) return ok(_doc_payload(doc))
+26 -5
View File
@@ -8,13 +8,25 @@ from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import TakeoverCursor, TakeoverReplyTask, User from models import TakeoverCursor, TakeoverReplyTask, User
from responses import fail, ok from responses import fail, ok
from routers.authorizations import _require_authorization from routers.authorizations import (
DEFAULT_TAKEOVER_DELAY_SECONDS,
MAX_TAKEOVER_DELAY_SECONDS,
MIN_TAKEOVER_DELAY_SECONDS,
_require_authorization,
_stored_takeover_delay,
)
from routers.avatars import _require_owned_avatar from routers.avatars import _require_owned_avatar
router = APIRouter(tags=["分身接管"]) router = APIRouter(tags=["分身接管"])
BOXIM_STATUS_FRESH_SECONDS = 60 BOXIM_STATUS_FRESH_SECONDS = 60
def _delay_label(seconds: int) -> str:
if seconds % 60 == 0:
return f"{seconds // 60} 分钟"
return f"{seconds} 秒"
@router.get("/avatar/{avatar_id}/takeover/status") @router.get("/avatar/{avatar_id}/takeover/status")
def get_takeover_status( def get_takeover_status(
avatar_id: str, avatar_id: str,
@@ -24,6 +36,7 @@ def get_takeover_status(
avatar = _require_owned_avatar(db, avatar_id, authorization) avatar = _require_owned_avatar(db, avatar_id, authorization)
permissions = (avatar.config or {}).get("authorizationPermissions", []) permissions = (avatar.config or {}).get("authorizationPermissions", [])
enabled = isinstance(permissions, list) and "takeover" in permissions enabled = isinstance(permissions, list) and "takeover" in permissions
reply_delay_seconds = _stored_takeover_delay(avatar)
user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first() user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first() cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
pending_count = ( pending_count = (
@@ -49,7 +62,10 @@ def get_takeover_status(
and cursor.last_polled_at and cursor.last_polled_at
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS) >= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
): ):
status, message = "ready", "BOXIM 已连接,收到私聊消息 3 秒后自动回复" status, message = (
"ready",
f"BOXIM 已连接,收到私聊消息 {_delay_label(reply_delay_seconds)}后自动回复",
)
else: else:
status, message = "connecting", "正在连接 BOXIM" status, message = "connecting", "正在连接 BOXIM"
@@ -59,6 +75,7 @@ def get_takeover_status(
"status": status, "status": status,
"message": message, "message": message,
"pendingCount": pending_count, "pendingCount": pending_count,
"takeoverReplyDelaySeconds": reply_delay_seconds,
"lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None, "lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None,
} }
) )
@@ -91,7 +108,7 @@ def update_takeover_config(
auth = _require_authorization(db, avatar_id, str(auth_id)) auth = _require_authorization(db, avatar_id, str(auth_id))
enabled = bool(auth.takeover_enabled) enabled = bool(auth.takeover_enabled)
mode = auth.takeover_mode or "immediate" mode = auth.takeover_mode or "immediate"
delay = auth.takeover_delay_seconds or 30 delay = auth.takeover_delay_seconds or DEFAULT_TAKEOVER_DELAY_SECONDS
if _has(payload, "takeoverEnabled", "takeover_enabled"): if _has(payload, "takeoverEnabled", "takeover_enabled"):
raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled") raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled")
@@ -106,8 +123,12 @@ def update_takeover_config(
if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"): if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"):
delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds") delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds")
if isinstance(delay, bool) or not isinstance(delay, int) or not 5 <= delay <= 3600: if (
return fail("延迟时间需在 5 到 3600 秒之间", 400) isinstance(delay, bool)
or not isinstance(delay, int)
or not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS
):
return fail("延迟时间需在 3 秒到 24 小时之间", 400)
if enabled and auth.target_type != "user": if enabled and auth.target_type != "user":
return fail("本期仅支持对会会用户开启单聊接管", 400) return fail("本期仅支持对会会用户开启单聊接管", 400)
+327 -18
View File
@@ -1,37 +1,346 @@
from fastapi import APIRouter, Depends, Body import hashlib
import hmac
import json
import os
import uuid
from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import parse_qs
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import TokenAccount, TokenPlan from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
from responses import ok, fail from responses import fail, ok
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
from services.token_billing import DEFAULT_TOKEN_GRANT, get_or_create_account
router = APIRouter(tags=["Token"]) router = APIRouter(tags=["Token"])
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
PAYMENT_SCENES = {"APP", "LITE", "JSAPI"}
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
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
def _payment_client() -> HuihuiPaymentClient:
return HuihuiPaymentClient({
"HUIHUI_PAYMENT_BASE_URL": os.getenv(
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
"HUIHUI_PAYMENT_TIMEOUT_SECONDS": os.getenv("HUIHUI_PAYMENT_TIMEOUT_SECONDS", "30"),
})
def _callback_url(order_no: str) -> str:
base = os.getenv(
"HUIHUI_PAYMENT_CALLBACK_BASE_URL", "https://digital.99hui.com"
).rstrip("/")
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
if len(secret) < 16:
raise HuihuiPaymentError("会会支付回调密钥未配置")
signature = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
return f"{base}/api/token/payment/callback/{order_no}/{signature}"
def _price_cents(price: float) -> int:
return int(
(Decimal(str(price)) * Decimal("100")).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
def _payment_payload(order: TokenPaymentOrder, account: TokenAccount) -> dict:
return {**order.to_dict(), "balance": account.balance}
def _nested_payload(value):
if isinstance(value, str):
text = value.strip()
if text[:1] in ("{", "["):
try:
return _nested_payload(json.loads(text))
except (TypeError, ValueError):
return value
return value
if isinstance(value, list):
return [_nested_payload(item) for item in value]
if isinstance(value, dict):
return {key: _nested_payload(item) for key, item in value.items()}
return value
def _find_value(payload, *names):
expected = {name.lower() for name in names}
if isinstance(payload, dict):
for key, value in payload.items():
if key.lower() in expected and value not in (None, ""):
return value
for value in payload.values():
found = _find_value(value, *names)
if found not in (None, ""):
return found
elif isinstance(payload, list):
for value in payload:
found = _find_value(value, *names)
if found not in (None, ""):
return found
return None
def _callback_amount_cents(payload) -> int | None:
value = _find_value(
payload,
"actualAmt",
"payAmt",
"masterOrderAmt",
"orderAmt",
"amount",
"totalAmount",
)
if value in (None, ""):
return None
try:
return int(
(Decimal(str(value)) * Decimal("100")).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
except (InvalidOperation, TypeError, ValueError):
return None
@router.get("/token/balance") @router.get("/token/balance")
def balance(db: Session = Depends(get_db)): def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
acc = db.query(TokenAccount).first() user = _require_user(authorization, db)
return ok({"balance": acc.balance if acc else 0}) 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") @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() items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
return ok([p.to_dict() for p in items]) return ok([p.to_dict() for p in items])
# 积分只会在会会支付回调确认成功后到账。
@router.post("/token/charge") @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)):
plan_id = payload.get("planId") user = _require_user(authorization, db)
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first() plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
if not plan: if not plan:
return fail("套餐不存在", 404) return fail("套餐不存在", 404)
acc = db.query(TokenAccount).first()
if not acc: payment_method = str(payload.get("paymentMethod") or "").lower()
acc = TokenAccount(balance=0) pay_type = PAYMENT_METHODS.get(payment_method)
db.add(acc) if not pay_type:
db.commit() return fail("请选择正确的支付方式", 400)
db.refresh(acc) pay_way = str(payload.get("payScene") or "APP").upper()
acc.balance += plan.amount if pay_way not in PAYMENT_SCENES:
return fail("当前支付场景不受支持", 400)
cents = _price_cents(plan.price)
order = TokenPaymentOrder(
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
user_id=user.id,
plan_id=plan.id,
payment_method=payment_method,
pay_type=pay_type,
pay_way=pay_way,
points_amount=plan.amount,
price_cents=cents,
status="pending",
)
db.add(order)
db.commit() db.commit()
return ok({"balance": acc.balance, "charged": plan.amount})
try:
callback_url = _callback_url(order.order_no)
except HuihuiPaymentError as exc:
order.status = "failed"
order.failure_reason = str(exc)
db.commit()
return fail(str(exc), 503)
try:
result = _payment_client().create_payment(
huihui_token=user.huihui_token,
huihui_user_id=user.huihui_user_id,
real_name=user.nickname,
order_no=order.order_no,
amount=f"{cents / 100:.2f}",
points_amount=plan.amount,
pay_type=pay_type,
pay_way=pay_way,
callback_url=callback_url,
)
except HuihuiPaymentError as exc:
order.status = "failed"
order.failure_reason = str(exc)[:500]
db.commit()
return fail(str(exc), 502)
db.refresh(order)
if order.status != "paid":
order.provider_order_id = str(result.get("orderId") or "")
order.provider_order_no = str(result.get("orderNo") or "")
order.provider_status = str(result.get("status") or "pending")
message = result.get("payMessage") or ""
order.pay_message = (
json.dumps(message, ensure_ascii=False)
if isinstance(message, (dict, list))
else str(message)
)
if order.provider_status.upper() in FAILED_STATUSES:
order.status = "failed"
order.failure_reason = str(result.get("bankReturnMsg") or "支付下单失败")[:500]
db.commit()
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.get("/token/payment/{order_id}")
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
order = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order_id,
TokenPaymentOrder.user_id == user.id,
).first()
if not order:
return fail("支付订单不存在", 404)
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
@router.post("/token/payment/callback/{order_no}/{callback_signature}")
async def payment_callback(
order_no: str,
callback_signature: str,
request: Request,
db: Session = Depends(get_db),
):
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
expected = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
if len(secret) < 16 or not hmac.compare_digest(callback_signature, expected):
raise HTTPException(status_code=404, detail="Not found")
content_type = request.headers.get("content-type", "").lower()
if "application/json" in content_type:
try:
payload = await request.json()
except ValueError:
return fail("支付回调格式不正确", 400)
else:
raw = (await request.body()).decode("utf-8", errors="replace")
payload = {key: values[-1] for key, values in parse_qs(raw).items()}
payload = _nested_payload(payload)
payload_order_no = str(_find_value(
payload,
"masterOrderNo",
"master_order_no",
"orderNo",
"order_no",
"bizOrderNo",
) or "").strip()
if payload_order_no and payload_order_no != order_no:
return fail("支付回调订单号不匹配", 422)
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
if not order:
return fail("支付订单不存在", 404)
if order.status == "paid":
return ok({"received": True, "duplicate": True})
provider_status = str(_find_value(
payload, "status", "payStatus", "tradeStatus", "paymentStatus"
) or "").upper()
order.provider_status = provider_status
if provider_status not in SUCCESS_STATUSES:
if provider_status in FAILED_STATUSES:
order.status = "failed"
order.failure_reason = str(
_find_value(payload, "message", "errorMsg", "failReason") or "支付失败"
)[:500]
db.commit()
return ok({"received": True, "paid": False})
paid_cents = _callback_amount_cents(payload)
if paid_cents is None or paid_cents != order.price_cents:
order.failure_reason = "支付回调金额不匹配"
db.commit()
return fail("支付金额不匹配", 422)
updated = db.query(TokenPaymentOrder).filter(
TokenPaymentOrder.id == order.id,
TokenPaymentOrder.status != "paid",
).update({
TokenPaymentOrder.status: "paid",
TokenPaymentOrder.provider_status: provider_status,
TokenPaymentOrder.paid_at: datetime.utcnow(),
TokenPaymentOrder.failure_reason: "",
}, synchronize_session=False)
if updated:
account = db.query(TokenAccount).filter(TokenAccount.user_id == order.user_id).first()
if account is None:
account = TokenAccount(
user_id=order.user_id,
balance=DEFAULT_TOKEN_GRANT,
total_granted=DEFAULT_TOKEN_GRANT,
total_consumed=0,
)
db.add(account)
db.flush()
account.balance = int(account.balance or 0) + order.points_amount
account.total_granted = int(account.total_granted or 0) + order.points_amount
db.commit()
return ok({"received": True, "paid": True})
@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
])
@@ -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
@@ -0,0 +1,124 @@
"""Signed client for Huihui's production payment-v3 service."""
import hashlib
import random
import string
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
_CN_TZ = timezone(timedelta(hours=8))
class HuihuiPaymentError(RuntimeError):
pass
class HuihuiPaymentClient:
def __init__(self, config: dict):
self.base_url = config.get(
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
).rstrip("/")
self.app_id = config.get("HUIHUI_APP_ID", "")
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
self.timeout = float(config.get("HUIHUI_PAYMENT_TIMEOUT_SECONDS", 30))
@property
def configured(self) -> bool:
return bool(self.base_url and self.app_id and self.access_id and self.access_secret)
def _signed_params(self, user_id: str) -> dict:
params = {
"appId": self.app_id,
"accessId": self.access_id,
"nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
"timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"),
"signType": "MD5",
"signVersion": "1.0",
"userId": user_id,
}
source = "&".join(
f"{key}={params[key]}"
for key in sorted(params)
if params[key] not in (None, "", [])
)
source += f"&accessSecret={self.access_secret}"
params["signature"] = hashlib.md5(source.encode("utf-8")).hexdigest().upper()
return params
@staticmethod
def _json(response: httpx.Response) -> dict:
try:
payload = response.json()
except ValueError as exc:
raise HuihuiPaymentError("会会支付返回了无效响应") from exc
if not isinstance(payload, dict):
raise HuihuiPaymentError("会会支付返回格式不正确")
return payload
def create_payment(
self,
*,
huihui_token: str,
huihui_user_id: str,
real_name: str,
order_no: str,
amount: str,
points_amount: int,
pay_type: str,
pay_way: str,
callback_url: str,
) -> dict[str, Any]:
if not self.configured:
raise HuihuiPaymentError("会会支付服务未配置")
if not huihui_token or not huihui_user_id:
raise HuihuiPaymentError("当前会会登录凭证无法发起支付")
now = datetime.now(_CN_TZ)
body = {
"appId": self.app_id,
"callbackUrl": callback_url,
"chargeType": 4,
"currency": "cny",
"description": f"充值 {points_amount} 积分",
"expend": {},
"masterOrderAmt": amount,
"masterOrderNo": order_no,
"memberId": huihui_user_id,
"orderDesc": "数字分身积分充值",
"orderTime": now.isoformat(),
"orderTitle": "数字分身积分充值",
"payAmt": float(amount),
"payType": pay_type,
"payWay": pay_way,
"realName": real_name or "会会用户",
"timeExpire": (now + timedelta(hours=2)).strftime("%Y%m%d%H%M%S"),
}
headers = {
"Authorization": f"Bearer {huihui_token}",
"appId": self.app_id,
"windowAppId": self.app_id,
}
try:
response = httpx.post(
f"{self.base_url}/payment/pay",
headers=headers,
params=self._signed_params(huihui_user_id),
json=body,
timeout=self.timeout,
follow_redirects=True,
)
except httpx.HTTPError as exc:
raise HuihuiPaymentError("会会支付连接失败,请稍后重试") from exc
payload = self._json(response)
code = payload.get("code")
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
raise HuihuiPaymentError(payload.get("message") or "会会支付下单失败")
data = payload.get("data") or {}
if not isinstance(data, dict):
raise HuihuiPaymentError("会会支付未返回订单信息")
return data
@@ -28,6 +28,14 @@ MAX_PROMPT_LENGTH = 4000
MAX_STALE_SECONDS = 120 MAX_STALE_SECONDS = 120
STUCK_LOCK_SECONDS = 90 STUCK_LOCK_SECONDS = 90
TAKEOVER_PERMISSION = "takeover" TAKEOVER_PERMISSION = "takeover"
TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds"
DEFAULT_REPLY_DELAY_SECONDS = 180
MIN_REPLY_DELAY_SECONDS = 3
MAX_REPLY_DELAY_SECONDS = 86_400
HUMAN_PAUSE_SECONDS = 600
RATE_LIMIT_WINDOW_SECONDS = 300
RATE_LIMIT_MAX_REPLIES = 5
AVATAR_LOCAL_ID_PREFIX = "880"
def _utcnow() -> datetime: def _utcnow() -> datetime:
@@ -70,15 +78,43 @@ def _plain_text_reply(value: str) -> str:
return "\n".join(line for line in lines if line).strip() return "\n".join(line for line in lines if line).strip()
def _avatar_local_id(owner_id: str, trigger_message_id: str) -> str:
"""Build a deterministic BOXIM idempotency key that also marks avatar traffic."""
digest = hashlib.sha256(f"{owner_id}:{trigger_message_id}".encode("utf-8")).digest()
suffix = int.from_bytes(digest[:8], "big") % (10**15)
return f"{AVATAR_LOCAL_ID_PREFIX}{suffix:015d}"
def _is_avatar_local_id(value: str | None) -> bool:
local_id = str(value or "").strip()
return len(local_id) == 18 and local_id.isdigit() and local_id.startswith(AVATAR_LOCAL_ID_PREFIX)
def _configured_reply_delay(avatar: Avatar, fallback: int | None = None) -> int:
raw = (avatar.config or {}).get(
TAKEOVER_DELAY_KEY,
fallback if fallback is not None else DEFAULT_REPLY_DELAY_SECONDS,
)
if isinstance(raw, bool):
return DEFAULT_REPLY_DELAY_SECONDS
try:
delay = int(raw)
except (TypeError, ValueError):
return DEFAULT_REPLY_DELAY_SECONDS
if not MIN_REPLY_DELAY_SECONDS <= delay <= MAX_REPLY_DELAY_SECONDS:
return DEFAULT_REPLY_DELAY_SECONDS
return delay
class TakeoverService: class TakeoverService:
"""Poll BOXIM, prepare replies during the grace period, then send at +3s.""" """Poll BOXIM, honor the owner grace period, then generate and send one reply."""
def __init__( def __init__(
self, self,
session_factory: Callable[[], Session], session_factory: Callable[[], Session],
boxim_client: BoxIMClient, boxim_client: BoxIMClient,
*, *,
reply_delay_seconds: int = 3, reply_delay_seconds: int | None = None,
now: Callable[[], datetime] = _utcnow, now: Callable[[], datetime] = _utcnow,
): ):
self.session_factory = session_factory self.session_factory = session_factory
@@ -119,11 +155,17 @@ class TakeoverService:
def _enabled_avatar_ids(self) -> list[str]: def _enabled_avatar_ids(self) -> list[str]:
db = self.session_factory() db = self.session_factory()
try: try:
return [ avatars = (
avatar.id db.query(Avatar)
for avatar in db.query(Avatar).filter(Avatar.status == "active").all() .filter(Avatar.status == "active")
if _takeover_enabled(avatar) .order_by(Avatar.updated_at.desc(), Avatar.created_at.desc())
] .all()
)
selected = {}
for avatar in avatars:
if _takeover_enabled(avatar) and avatar.owner_id not in selected:
selected[avatar.owner_id] = avatar.id
return list(selected.values())
finally: finally:
db.close() db.close()
@@ -201,13 +243,20 @@ class TakeoverService:
def _forget_boxim_session(self, user_id: str): def _forget_boxim_session(self, user_id: str):
self._sessions.pop(user_id, None) self._sessions.pop(user_id, None)
def _disable_after_connection_failure( def _record_connection_failure(
self, self,
db: Session, db: Session,
avatar: Avatar, avatar: Avatar,
cursor: TakeoverCursor, cursor: TakeoverCursor,
message: str, message: str,
*,
disable_takeover: bool,
): ):
cursor.last_error = message
cursor.last_polled_at = self.now()
if not disable_takeover:
return
permissions = (avatar.config or {}).get("authorizationPermissions", []) permissions = (avatar.config or {}).get("authorizationPermissions", [])
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
@@ -217,8 +266,6 @@ class TakeoverService:
if permission != TAKEOVER_PERMISSION if permission != TAKEOVER_PERMISSION
], ],
} }
cursor.last_error = message
cursor.last_polled_at = self.now()
tasks = ( tasks = (
db.query(TakeoverReplyTask) db.query(TakeoverReplyTask)
.filter( .filter(
@@ -245,11 +292,12 @@ class TakeoverService:
db.add(cursor) db.add(cursor)
db.flush() db.flush()
if not user or not user.huihui_token: if not user or not user.huihui_token:
self._disable_after_connection_failure( self._record_connection_failure(
db, db,
avatar, avatar,
cursor, cursor,
"请重新登录会会生产账号后再开启主动接管", "请重新登录会会生产账号后再开启主动接管",
disable_takeover=True,
) )
db.commit() db.commit()
return False return False
@@ -268,11 +316,24 @@ class TakeoverService:
if isinstance(exc, BoxIMError) and exc.auth_error: if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id) self._forget_boxim_session(user.id)
message = "BOXIM 授权已失效,请重新登录会会生产账号" message = "BOXIM 授权已失效,请重新登录会会生产账号"
disable_takeover = True
else: else:
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}" message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
self._disable_after_connection_failure(db, avatar, cursor, message) disable_takeover = False
self._record_connection_failure(
db,
avatar,
cursor,
message,
disable_takeover=disable_takeover,
)
db.commit() db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc) logger.warning(
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
avatar.id,
not disable_takeover,
exc,
)
return False return False
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0)) messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
@@ -350,13 +411,21 @@ class TakeoverService:
now = self.now() now = self.now()
send_time = _boxim_time(message.get("sendTime"), now) send_time = _boxim_time(message.get("sendTime"), now)
is_avatar = False is_avatar = _is_avatar_local_id(local_id)
if direction == "outgoing" and local_id: if not is_avatar and local_id:
is_avatar = bool( is_avatar = bool(
db.query(TakeoverReplyTask) db.query(TakeoverReplyTask)
.filter( .filter(
TakeoverReplyTask.owner_id == avatar.owner_id,
TakeoverReplyTask.boxim_local_id == local_id, TakeoverReplyTask.boxim_local_id == local_id,
TakeoverReplyTask.status.in_(("ready", "sending", "sent")),
)
.first()
)
if not is_avatar:
is_avatar = bool(
db.query(TakeoverReplyTask)
.filter(
TakeoverReplyTask.boxim_sent_message_id == message_id,
TakeoverReplyTask.status == "sent", TakeoverReplyTask.status == "sent",
) )
.first() .first()
@@ -385,8 +454,52 @@ class TakeoverService:
return return
if (now - send_time).total_seconds() > MAX_STALE_SECONDS: if (now - send_time).total_seconds() > MAX_STALE_SECONDS:
return return
if is_avatar:
self._cancel_conversation(db, avatar.owner_id, peer_id, "peer_avatar_message")
return
if self._human_pause_active(db, avatar.owner_id, peer_id, now):
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_active")
return
if self._conversation_rate_limited(db, avatar.owner_id, peer_id, now):
self._cancel_conversation(db, avatar.owner_id, peer_id, "rate_limited")
return
self._schedule_reply(db, avatar, event) self._schedule_reply(db, avatar, event)
@staticmethod
def _human_pause_active(db: Session, owner_id: str, peer_id: str, now: datetime) -> bool:
threshold = now - timedelta(seconds=HUMAN_PAUSE_SECONDS)
return bool(
db.query(TakeoverMessage.id)
.filter(
TakeoverMessage.owner_id == owner_id,
TakeoverMessage.peer_id == peer_id,
TakeoverMessage.direction == "outgoing",
TakeoverMessage.is_avatar.is_(False),
TakeoverMessage.send_time >= threshold,
)
.first()
)
@staticmethod
def _conversation_rate_limited(
db: Session,
owner_id: str,
peer_id: str,
now: datetime,
) -> bool:
threshold = now - timedelta(seconds=RATE_LIMIT_WINDOW_SECONDS)
return (
db.query(TakeoverReplyTask.id)
.filter(
TakeoverReplyTask.owner_id == owner_id,
TakeoverReplyTask.peer_id == peer_id,
TakeoverReplyTask.status == "sent",
TakeoverReplyTask.sent_at >= threshold,
)
.count()
>= RATE_LIMIT_MAX_REPLIES
)
@staticmethod @staticmethod
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str): def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
tasks = ( tasks = (
@@ -427,9 +540,11 @@ class TakeoverService:
prompt_parts.append(event.content.strip()) prompt_parts.append(event.content.strip())
source_ids.append(event.boxim_message_id) source_ids.append(event.boxim_message_id)
prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:] prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:]
due_at = event.send_time + timedelta(seconds=self.reply_delay_seconds) due_at = event.send_time + timedelta(
seconds=_configured_reply_delay(avatar, self.reply_delay_seconds)
)
task_id = secrets.token_hex(16) task_id = secrets.token_hex(16)
local_id = int(time.time() * 1000) * 1000 + secrets.randbelow(1000) local_id = _avatar_local_id(avatar.owner_id, event.boxim_message_id)
db.add( db.add(
TakeoverReplyTask( TakeoverReplyTask(
id=task_id, id=task_id,
@@ -455,6 +570,7 @@ class TakeoverService:
.filter( .filter(
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES), TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
TakeoverReplyTask.response_text == "", TakeoverReplyTask.response_text == "",
TakeoverReplyTask.scheduled_at <= self.now(),
) )
.order_by(TakeoverReplyTask.created_at.asc()) .order_by(TakeoverReplyTask.created_at.asc())
.limit(10) .limit(10)
@@ -501,6 +617,7 @@ class TakeoverService:
.filter( .filter(
TakeoverMessage.owner_id == task.owner_id, TakeoverMessage.owner_id == task.owner_id,
TakeoverMessage.peer_id == task.peer_id, TakeoverMessage.peer_id == task.peer_id,
TakeoverMessage.avatar_id == task.avatar_id,
) )
.order_by(TakeoverMessage.send_time.desc()) .order_by(TakeoverMessage.send_time.desc())
.limit(30) .limit(30)
@@ -510,6 +627,8 @@ class TakeoverService:
for event in reversed(events): for event in reversed(events):
if event.boxim_message_id in excluded_ids or not event.content.strip(): if event.boxim_message_id in excluded_ids or not event.content.strip():
continue continue
if event.direction == "incoming" and event.is_avatar:
continue
history.append( history.append(
{ {
"role": "user" if event.direction == "incoming" else "assistant", "role": "user" if event.direction == "incoming" else "assistant",
@@ -520,7 +639,7 @@ class TakeoverService:
from routers.chat import _resolve_reply 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", "")) answer = _plain_text_reply(result.get("answer", ""))
db.refresh(task) db.refresh(task)
if task.status != "generating": if task.status != "generating":
@@ -587,6 +706,15 @@ class TakeoverService:
task.cancel_reason = "stale_reply" task.cancel_reason = "stale_reply"
db.commit() db.commit()
return False return False
cursor = (
db.query(TakeoverCursor)
.filter(TakeoverCursor.avatar_id == task.avatar_id)
.first()
)
if not cursor or not cursor.last_polled_at or cursor.last_polled_at < task.scheduled_at:
# Do not race the owner's final seconds of the grace period. A
# completed poll at/after the due time must confirm no human reply.
return False
user = db.query(User).filter(User.huihui_user_id == task.owner_id).first() user = db.query(User).filter(User.huihui_user_id == task.owner_id).first()
if not user or not user.huihui_token: if not user or not user.huihui_token:
raise BoxIMError("缺少会会登录凭证", auth_error=True) raise BoxIMError("缺少会会登录凭证", auth_error=True)
@@ -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("分身尚未关联有效用户,暂时无法使用积分")
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("积分余额不足,请充值后继续")
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("积分账户不存在")
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)
@@ -8,6 +8,9 @@ from models import (
TakeoverCursor, TakeoverCursor,
TakeoverMessage, TakeoverMessage,
TakeoverReplyTask, TakeoverReplyTask,
TokenAccount,
TokenPaymentOrder,
TokenUsage,
User, User,
) )
@@ -107,6 +110,16 @@ def authorization_context():
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete( db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
synchronize_session=False synchronize_session=False
) )
user_ids = [owner.id, other.id]
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id.in_(user_ids)).delete(
synchronize_session=False
)
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( db.query(User).filter(User.id.in_([owner.id, other.id])).delete(
synchronize_session=False synchronize_session=False
) )
@@ -103,6 +103,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
assert initial["data"] == { assert initial["data"] == {
"avatarId": context["avatar"].id, "avatarId": context["avatar"].id,
"permissions": ["friend", "chat"], "permissions": ["friend", "chat"],
"takeoverReplyDelaySeconds": 180,
} }
updated = client.put( updated = client.put(
@@ -115,6 +116,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
reloaded = client.get(endpoint, headers=context["owner_headers"]).json() reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"] assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
assert reloaded["data"]["takeoverReplyDelaySeconds"] == 180
def test_avatar_permission_settings_allow_all_disabled(authorization_context): def test_avatar_permission_settings_allow_all_disabled(authorization_context):
@@ -156,3 +158,57 @@ def test_avatar_permission_settings_validate_owner_and_permissions(authorization
unauthenticated = client.get(endpoint) unauthenticated = client.get(endpoint)
assert unauthenticated.status_code == 401 assert unauthenticated.status_code == 401
def test_takeover_delay_minimum_and_single_active_avatar_per_owner(authorization_context):
from database import SessionLocal
from models import Avatar
context = authorization_context
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
invalid = client.put(
endpoint,
headers=context["owner_headers"],
json={"permissions": ["chat"], "takeoverReplyDelaySeconds": 2},
).json()
assert invalid["code"] == 400
second_avatar_id = f"second-{context['suffix']}"
db = SessionLocal()
try:
db.add(
Avatar(
id=second_avatar_id,
owner_id=context["owner"].huihui_user_id,
name="第二个分身",
status="active",
config={"authorizationPermissions": ["chat", "takeover"]},
)
)
db.commit()
finally:
db.close()
try:
updated = client.put(
endpoint,
headers=context["owner_headers"],
json={"permissions": ["chat", "takeover"], "takeoverReplyDelaySeconds": 3},
).json()
assert updated["code"] == 200
assert updated["data"]["takeoverReplyDelaySeconds"] == 3
assert updated["data"]["disabledAvatarIds"] == [second_avatar_id]
db = SessionLocal()
try:
second = db.query(Avatar).filter(Avatar.id == second_avatar_id).one()
assert "takeover" not in second.config["authorizationPermissions"]
finally:
db.close()
finally:
db = SessionLocal()
try:
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
db.commit()
finally:
db.close()
@@ -0,0 +1,93 @@
"""Ownership and configuration-isolation tests for digital avatars."""
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app
from models import Avatar
client = TestClient(app)
def test_avatar_detail_and_update_require_the_owner(authorization_context):
context = authorization_context
avatar_id = context["avatar"].id
assert client.get(f"/api/avatar/{avatar_id}").status_code == 401
assert client.get(
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
).status_code == 403
updated = client.put(
f"/api/avatar/{avatar_id}",
headers=context["owner_headers"],
json={
"description": "独立描述",
"config": {"replyStyle": "concise"},
},
)
assert updated.status_code == 200
assert updated.json()["data"]["description"] == "独立描述"
forbidden = client.put(
f"/api/avatar/{avatar_id}",
headers=context["other_headers"],
json={"description": "越权修改"},
)
assert forbidden.status_code == 403
def test_avatar_config_updates_do_not_erase_takeover_or_knowledge_scope(authorization_context):
context = authorization_context
avatar_id = context["avatar"].id
db = SessionLocal()
try:
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).one()
avatar.config = {
"authorizationPermissions": ["chat", "takeover"],
"takeoverReplyDelaySeconds": 180,
}
db.commit()
finally:
db.close()
response = client.put(
f"/api/avatar/{avatar_id}",
headers=context["owner_headers"],
json={"config": {"replyStyle": "warm", "creativity": 25}},
).json()
config = response["data"]["config"]
assert config["replyStyle"] == "warm"
assert config["creativity"] == 25
assert config["authorizationPermissions"] == ["chat", "takeover"]
assert config["takeoverReplyDelaySeconds"] == 180
def test_avatar_create_and_delete_require_login_and_ownership(authorization_context):
context = authorization_context
assert client.post("/api/avatar", json={"name": "匿名分身"}).status_code == 401
created = client.post(
"/api/avatar",
headers=context["owner_headers"],
json={"name": "待删除分身"},
)
assert created.status_code == 200
avatar_id = created.json()["data"]["id"]
try:
assert client.delete(
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
).status_code == 403
deleted = client.delete(
f"/api/avatar/{avatar_id}", headers=context["owner_headers"]
).json()
assert deleted["code"] == 200
finally:
db = SessionLocal()
try:
db.query(Avatar).filter(Avatar.id == avatar_id).delete()
db.commit()
finally:
db.close()
@@ -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"
@@ -5,7 +5,15 @@ from unittest.mock import Mock
from fastapi import HTTPException from fastapi import HTTPException
from models import Avatar, User from models import Avatar, User
from routers.chat import _build_prompt, _iter_text_chunks, _match_standard_qa, _public_avatar_payload, _require_owned_avatar, _resolve_reply from routers.chat import (
_build_prompt,
_iter_text_chunks,
_match_standard_qa,
_public_avatar_payload,
_qa_requires_language_adaptation,
_require_owned_avatar,
_resolve_reply,
)
class ChatOrchestrationTests(unittest.TestCase): class ChatOrchestrationTests(unittest.TestCase):
@@ -50,6 +58,34 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertEqual(result["answer"], "标准地址") self.assertEqual(result["answer"], "标准地址")
fake_model.assert_not_called() fake_model.assert_not_called()
def test_cross_language_qa_is_faithfully_adapted_by_model(self):
fake_model = Mock(return_value="Our address is Test Road 1.")
fake_search = Mock(return_value=[])
result = _resolve_reply(
None,
self.avatar,
"Where is your office?",
[],
qa_pairs=[SimpleNamespace(question="Where is your office?", answer="地址是测试路1号。", enabled=True)],
search_fn=fake_search,
model_client=fake_model,
)
self.assertEqual(result["source"], "qa")
self.assertEqual(result["answer"], "Our address is Test Road 1.")
self.assertEqual(fake_model.call_args.kwargs["temperature"], 0.0)
system = fake_model.call_args.kwargs["messages"][0]["content"]
self.assertIn("已确认标准答案", system)
self.assertIn("地址是测试路1号", system)
self.assertIn("只使用该语言回答", system)
fake_search.assert_not_called()
def test_qa_language_adaptation_detects_common_writing_system_changes(self):
self.assertTrue(_qa_requires_language_adaptation("Hello", "你好"))
self.assertTrue(_qa_requires_language_adaptation("こんにちは", "你好"))
self.assertTrue(_qa_requires_language_adaptation("안녕하세요", "你好"))
self.assertFalse(_qa_requires_language_adaptation("你好", "您好"))
def test_conversational_paraphrase_matches_standard_qa(self): def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"): for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
with self.subTest(question=question): with self.subTest(question=question):
@@ -106,6 +142,9 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"]) self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
self.assertIn("不隶属于任何机构", messages[0]["content"]) self.assertIn("不隶属于任何机构", messages[0]["content"])
self.assertIn("不要连续输出空行", messages[0]["content"]) self.assertIn("不要连续输出空行", messages[0]["content"])
self.assertIn("回答语言规则", messages[0]["content"])
self.assertIn("当前最后一条用户消息", messages[0]["content"])
self.assertIn("历史消息", messages[0]["content"])
def test_prompt_blocks_ungrounded_factual_answers(self): def test_prompt_blocks_ungrounded_factual_answers(self):
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", []) messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
@@ -113,6 +152,8 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertIn("没有检索到可靠资料", system) self.assertIn("没有检索到可靠资料", system)
self.assertIn("不要凭通用知识", system) self.assertIn("不要凭通用知识", system)
self.assertIn("不要提及知识库", system) self.assertIn("不要提及知识库", system)
self.assertIn("不得推断服务对象", system)
self.assertIn("工作场所", system)
def test_public_avatar_payload_excludes_internal_configuration(self): def test_public_avatar_payload_excludes_internal_configuration(self):
payload = _public_avatar_payload(self.avatar) payload = _public_avatar_payload(self.avatar)
@@ -48,9 +48,11 @@ class RemoteEmbeddingTests(unittest.TestCase):
def test_large_input_is_split_into_provider_safe_batches(self): def test_large_input_is_split_into_provider_safe_batches(self):
texts = [f"chunk-{index}" for index in range(14)] texts = [f"chunk-{index}" for index in range(14)]
batch_sizes = [] batch_sizes = []
requested_urls = []
def fake_urlopen(request, timeout): def fake_urlopen(request, timeout):
self.assertEqual(timeout, 30) self.assertEqual(timeout, 30)
requested_urls.append(request.full_url)
payload = json.loads(request.data.decode("utf-8")) payload = json.loads(request.data.decode("utf-8"))
batch_sizes.append(len(payload["input"])) batch_sizes.append(len(payload["input"]))
return FakeResponse({ return FakeResponse({
@@ -61,7 +63,7 @@ class RemoteEmbeddingTests(unittest.TestCase):
}) })
with patch.dict(os.environ, { with patch.dict(os.environ, {
"EMBEDDING_API_URL": "https://embedding.example/v1/embeddings", "EMBEDDING_API_URL": "https://embedding.example/v1",
"EMBEDDING_API_KEY": "test-key", "EMBEDDING_API_KEY": "test-key",
"EMBEDDING_MODEL": "text-embedding-v4", "EMBEDDING_MODEL": "text-embedding-v4",
"EMBEDDING_BATCH_SIZE": "10", "EMBEDDING_BATCH_SIZE": "10",
@@ -69,8 +71,18 @@ class RemoteEmbeddingTests(unittest.TestCase):
result = embeddings.embed(texts) result = embeddings.embed(texts)
self.assertEqual(batch_sizes, [10, 4]) self.assertEqual(batch_sizes, [10, 4])
self.assertEqual(requested_urls, [
"https://embedding.example/v1/embeddings",
"https://embedding.example/v1/embeddings",
])
self.assertEqual(result, [[float(index)] for index in range(14)]) self.assertEqual(result, [[float(index)] for index in range(14)])
def test_full_embedding_endpoint_is_not_modified(self):
self.assertEqual(
embeddings._embedding_endpoint("https://embedding.example/v1/embeddings/"),
"https://embedding.example/v1/embeddings",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -1,6 +1,7 @@
"""Tests for preserving local avatar ownership when Huihui IDs change.""" """Tests for preserving local avatar ownership when Huihui IDs change."""
from datetime import datetime from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -9,7 +10,8 @@ from sqlalchemy.pool import StaticPool
from database import Base from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from routers.huihui_auth import _issue_session from routers.huihui_auth import _issue_session, token_login
from services.boxim_client import BoxIMError
@pytest.fixture @pytest.fixture
@@ -135,3 +137,55 @@ def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
assert db.query(User).count() == 3 assert db.query(User).count() == 3
_assert_avatar_data_owner(db, first_avatar.id, "fat-1") _assert_avatar_data_owner(db, first_avatar.id, "fat-1")
_assert_avatar_data_owner(db, second_avatar.id, "fat-2") _assert_avatar_data_owner(db, second_avatar.id, "fat-2")
@pytest.mark.asyncio
async def test_token_login_uses_huihui_user_id_and_keeps_upstream_token_server_side(db):
existing = User(
id="existing-local",
huihui_user_id="huihui-user-88",
app_token="existing-app-session",
)
db.add(existing)
db.commit()
client = AsyncMock()
client.exchange_access_token.return_value = {"accessToken": "boxim-token"}
client.get_self.return_value = {
"id": 998877,
"huihuiUserId": "huihui-user-88",
"nickName": "会会用户",
"headImage": "https://cdn.example/avatar.jpg",
}
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
"routers.huihui_auth._create_boxim_client", return_value=client
):
response = await token_login({"token": "production-huihui-token"}, db)
assert response["code"] == 200
assert response["data"]["token"] == "existing-app-session"
assert "token" not in response["data"]["huihui"]
user = db.query(User).one()
assert user.huihui_user_id == "huihui-user-88"
assert user.huihui_user_id != "998877"
assert user.huihui_token == "production-huihui-token"
assert user.nickname == "会会用户"
assert user.avatar_url == "https://cdn.example/avatar.jpg"
client.exchange_access_token.assert_awaited_once_with("production-huihui-token")
client.get_self.assert_awaited_once_with("boxim-token")
@pytest.mark.asyncio
async def test_token_login_rejects_expired_huihui_token_without_creating_user(db):
client = AsyncMock()
client.exchange_access_token.side_effect = BoxIMError(
"expired", auth_error=True
)
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
"routers.huihui_auth._create_boxim_client", return_value=client
):
response = await token_login({"token": "expired-token"}, db)
assert response["code"] == 401
assert response["message"] == "会会登录凭证无效或已过期"
assert db.query(User).count() == 0
@@ -0,0 +1,50 @@
from unittest.mock import Mock, patch
from services.huihui_payment import HuihuiPaymentClient
def test_create_payment_uses_huihui_payment_v3_contract():
client = HuihuiPaymentClient({
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
"HUIHUI_APP_ID": "app-id",
"HUIHUI_ACCESS_ID": "access-id",
"HUIHUI_ACCESS_SECRET": "access-secret",
})
response = Mock()
response.status_code = 200
response.json.return_value = {
"code": 0,
"data": {"orderId": "provider-id", "status": "pending", "payMessage": "mock"},
}
with patch("services.huihui_payment.httpx.post", return_value=response) as post:
result = client.create_payment(
huihui_token="user-token",
huihui_user_id="user-id",
real_name="测试用户",
order_no="AV202608260001",
amount="10.00",
points_amount=2_000_000,
pay_type="WECHAT",
pay_way="APP",
callback_url="https://digital.example/api/token/payment/callback/secret",
)
assert result["orderId"] == "provider-id"
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/pay"
assert post.call_args.kwargs["headers"] == {
"Authorization": "Bearer user-token",
"appId": "app-id",
"windowAppId": "app-id",
}
params = post.call_args.kwargs["params"]
assert params["appId"] == "app-id"
assert params["accessId"] == "access-id"
assert params["userId"] == "user-id"
assert params["signature"]
assert "accessSecret" not in params
body = post.call_args.kwargs["json"]
assert body["payType"] == "WECHAT"
assert body["payWay"] == "APP"
assert body["masterOrderAmt"] == "10.00"
assert body["payAmt"] == 10.0
@@ -2,9 +2,17 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair
from routers.knowledge import _doc_payload from routers.knowledge import _doc_payload
client = TestClient(app)
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path): def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
avatar_id = "avatar-1" avatar_id = "avatar-1"
stored_name = "knowledge.md" stored_name = "knowledge.md"
@@ -21,3 +29,157 @@ def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
assert _doc_payload(doc)["filePresent"] is False assert _doc_payload(doc)["filePresent"] is False
stored_file.write_text("knowledge", encoding="utf-8") stored_file.write_text("knowledge", encoding="utf-8")
assert _doc_payload(doc)["filePresent"] is True assert _doc_payload(doc)["filePresent"] is True
def test_upload_marks_vectorization_failure_instead_of_staying_processing(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.embeddings.embed", side_effect=RuntimeError("provider unavailable")),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
)
payload = response.json()["data"]
assert payload["status"] == "failed"
assert payload["vectorized"] is False
assert payload["chunkCount"] == 0
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "failed"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
db.delete(stored)
db.commit()
finally:
db.close()
def test_markdown_upload_commits_ready_document_and_chunks_together(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.embeddings.embed", return_value=[[1.0, 0.0]]),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
)
payload = response.json()["data"]
assert payload["status"] == "ready"
assert payload["vectorized"] is True
assert payload["chunkCount"] == 1
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "ready"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 1
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
db.delete(stored)
db.commit()
finally:
db.close()
def test_each_avatar_has_an_independent_document_and_qa_scope(authorization_context):
context = authorization_context
first_avatar_id = context["avatar"].id
second_avatar_id = f"knowledge-second-{context['suffix']}"
first_doc_id = f"knowledge-first-doc-{context['suffix']}"
second_doc_id = f"knowledge-second-doc-{context['suffix']}"
first_qa_id = f"knowledge-first-qa-{context['suffix']}"
second_qa_id = f"knowledge-second-qa-{context['suffix']}"
db = SessionLocal()
try:
db.add_all(
[
Avatar(
id=second_avatar_id,
owner_id=context["owner"].huihui_user_id,
name="独立知识库分身",
status="active",
config={},
),
KnowledgeDoc(
id=first_doc_id,
avatar_id=first_avatar_id,
filename="first.md",
status="ready",
vectorized=True,
),
KnowledgeDoc(
id=second_doc_id,
avatar_id=second_avatar_id,
filename="second.md",
status="ready",
vectorized=True,
),
QAPair(
id=first_qa_id,
avatar_id=first_avatar_id,
question="第一个分身问题",
answer="第一个分身答案",
),
QAPair(
id=second_qa_id,
avatar_id=second_avatar_id,
question="第二个分身问题",
answer="第二个分身答案",
),
]
)
db.commit()
finally:
db.close()
try:
first_docs = client.get(
f"/api/avatar/{first_avatar_id}/knowledge/docs",
headers=context["owner_headers"],
).json()["data"]
second_docs = client.get(
f"/api/avatar/{second_avatar_id}/knowledge/docs",
headers=context["owner_headers"],
).json()["data"]
first_qa = client.get(
f"/api/avatar/{first_avatar_id}/knowledge/qa",
headers=context["owner_headers"],
).json()["data"]
second_qa = client.get(
f"/api/avatar/{second_avatar_id}/knowledge/qa",
headers=context["owner_headers"],
).json()["data"]
assert [item["id"] for item in first_docs if item["id"] == first_doc_id] == [first_doc_id]
assert second_doc_id not in {item["id"] for item in first_docs}
assert [item["id"] for item in second_docs] == [second_doc_id]
assert first_qa_id in {item["id"] for item in first_qa}
assert second_qa_id not in {item["id"] for item in first_qa}
assert [item["id"] for item in second_qa] == [second_qa_id]
finally:
db = SessionLocal()
try:
db.query(QAPair).filter(QAPair.id.in_([first_qa_id, second_qa_id])).delete(
synchronize_session=False
)
db.query(KnowledgeDoc).filter(
KnowledgeDoc.id.in_([first_doc_id, second_doc_id])
).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
db.commit()
finally:
db.close()
@@ -13,7 +13,7 @@ def test_authorization_takeover_fields():
assert hasattr(auth, 'takeover_delay_seconds') assert hasattr(auth, 'takeover_delay_seconds')
assert auth.takeover_enabled == False assert auth.takeover_enabled == False
assert auth.takeover_mode == 'immediate' assert auth.takeover_mode == 'immediate'
assert auth.takeover_delay_seconds == 30 assert auth.takeover_delay_seconds == 180
finally: finally:
db.close() db.close()
@@ -7,12 +7,16 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from database import Base from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from services.boxim_client import BoxIMError from services.boxim_client import BoxIMError
from services.takeover_service import TakeoverService, _plain_text_reply from services.takeover_service import (
AVATAR_LOCAL_ID_PREFIX,
TakeoverService,
_avatar_local_id,
_plain_text_reply,
)
class Clock: class Clock:
@@ -59,11 +63,10 @@ class FakeBoxIM:
@pytest.fixture @pytest.fixture
def service_context(): def service_context(tmp_path):
engine = create_engine( engine = create_engine(
"sqlite://", f"sqlite:///{tmp_path / 'takeover.db'}",
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
poolclass=StaticPool,
) )
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
@@ -79,7 +82,10 @@ def service_context():
owner_id=user.huihui_user_id, owner_id=user.huihui_user_id,
name="分身", name="分身",
status="active", status="active",
config={"authorizationPermissions": ["chat", "takeover"]}, config={
"authorizationPermissions": ["chat", "takeover"],
"takeoverReplyDelaySeconds": 3,
},
) )
db.add_all([user, avatar]) db.add_all([user, avatar])
db.commit() db.commit()
@@ -122,8 +128,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"} {"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
) )
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}): await service.poll_and_process_messages()
await service.poll_and_process_messages()
assert boxim.sent == [] assert boxim.sent == []
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}] assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
@@ -132,7 +137,8 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
assert boxim.sent == [] assert boxim.sent == []
clock.advance(1) clock.advance(1)
await service.poll_and_process_messages() with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
await service.poll_and_process_messages()
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}] assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
db = session_factory() db = session_factory()
@@ -144,6 +150,131 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
db.close() db.close()
@pytest.mark.asyncio
async def test_default_reply_delay_is_three_minutes(service_context):
session_factory, service, boxim, clock = service_context
db = session_factory()
try:
avatar = db.query(Avatar).one()
avatar.config = {"authorizationPermissions": ["chat", "takeover"]}
db.commit()
finally:
db.close()
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 12, "localId": 12, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "三分钟后回复"}
)
await service.poll_and_process_messages()
db = session_factory()
try:
task = db.query(TakeoverReplyTask).one()
assert task.scheduled_at == clock.now() + timedelta(seconds=180)
finally:
db.close()
clock.advance(179)
await service.process_reply_tasks()
assert boxim.sent == []
clock.advance(1)
with patch("routers.chat._resolve_reply", return_value={"answer": "好的"}):
await service.poll_and_process_messages()
assert [item["content"] for item in boxim.sent] == ["好的"]
@pytest.mark.asyncio
async def test_avatar_origin_message_never_schedules_a_reply(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
local_id = _avatar_local_id("peer-owner", "peer-trigger")
boxim.messages.append(
{"id": 15, "localId": local_id, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "另一端分身回复"}
)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
db = session_factory()
try:
event = db.query(TakeoverMessage).filter(TakeoverMessage.boxim_message_id == "15").one()
assert event.is_avatar is True
assert db.query(TakeoverReplyTask).count() == 0
finally:
db.close()
@pytest.mark.asyncio
async def test_peer_avatar_messages_are_excluded_from_later_human_context(service_context):
_session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{
"id": 16,
"localId": _avatar_local_id("peer-owner", "peer-trigger"),
"sendId": 200,
"recvId": 100,
"sendTime": clock.millis(),
"type": 0,
"content": "分身生成的夸张长文",
}
)
await service.poll_and_process_messages()
clock.advance(1)
boxim.messages.append(
{
"id": 17,
"localId": 17,
"sendId": 200,
"recvId": 100,
"sendTime": clock.millis(),
"type": 0,
"content": "真人的新问题",
}
)
await service.poll_and_process_messages()
clock.advance(3)
with patch("routers.chat._resolve_reply", return_value={"answer": "正常回复"}) as resolver:
await service.poll_and_process_messages()
assert resolver.call_args.args[3] == []
@pytest.mark.asyncio
async def test_owner_message_pauses_future_takeover_for_ten_minutes(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 18, "localId": 18, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我先来回复"}
)
await service.poll_and_process_messages()
clock.advance(30)
boxim.messages.append(
{"id": 19, "localId": 19, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "收到"}
)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
db = session_factory()
try:
assert db.query(TakeoverReplyTask).count() == 0
finally:
db.close()
def test_avatar_local_id_is_deterministic_and_self_describing():
first = _avatar_local_id("owner", "message-1")
assert first == _avatar_local_id("owner", "message-1")
assert first != _avatar_local_id("owner", "message-2")
assert first.startswith(AVATAR_LOCAL_ID_PREFIX)
assert len(first) == 18
assert first.isdigit()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_different_contacts_generate_without_blocking_each_other(service_context): async def test_different_contacts_generate_without_blocking_each_other(service_context):
session_factory, service, boxim, clock = service_context session_factory, service, boxim, clock = service_context
@@ -156,15 +287,15 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
) )
both_generating = Barrier(2, timeout=2) both_generating = Barrier(2, timeout=2)
def resolve(_db, _avatar, prompt, _history): def resolve(_db, _avatar, prompt, _history, **_kwargs):
both_generating.wait() both_generating.wait()
return {"answer": f"回复{prompt[-1]}"} return {"answer": f"回复{prompt[-1]}"}
with patch("routers.chat._resolve_reply", side_effect=resolve): await service.poll_and_process_messages()
await service.poll_and_process_messages()
clock.advance(3) clock.advance(3)
await service.process_reply_tasks() with patch("routers.chat._resolve_reply", side_effect=resolve):
await service.poll_and_process_messages()
assert {(item["peerId"], item["content"]) for item in boxim.sent} == { assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
("200", "回复甲"), ("200", "回复甲"),
("300", "回复乙"), ("300", "回复乙"),
@@ -239,6 +370,34 @@ async def test_owner_message_cancels_pending_reply(service_context):
db.close() db.close()
@pytest.mark.asyncio
async def test_owner_message_in_final_second_wins_before_generation(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.append(
{"id": 23, "localId": 23, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "还在吗"}
)
await service.poll_and_process_messages()
clock.advance(2)
boxim.messages.append(
{"id": 24, "localId": 24, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来处理"}
)
clock.advance(1)
with patch("routers.chat._resolve_reply") as resolver:
await service.poll_and_process_messages()
resolver.assert_not_called()
assert boxim.sent == []
db = session_factory()
try:
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "23").one()
assert task.status == "cancelled"
assert task.cancel_reason == "owner_replied"
finally:
db.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context): async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
session_factory, service, boxim, clock = service_context session_factory, service, boxim, clock = service_context
@@ -246,19 +405,18 @@ async def test_quick_successive_messages_are_coalesced_into_one_reply(service_co
boxim.messages.append( boxim.messages.append(
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"} {"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
) )
with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}): await service.poll_and_process_messages()
await service.poll_and_process_messages()
clock.advance(1) clock.advance(1)
boxim.messages.append( boxim.messages.append(
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"} {"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"}
) )
await service.poll_and_process_messages()
clock.advance(3)
with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver: with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
await service.poll_and_process_messages() await service.poll_and_process_messages()
assert resolver.call_args.args[2] == "第一句\n第二句" assert resolver.call_args.args[2] == "第一句\n第二句"
clock.advance(3)
await service.poll_and_process_messages()
assert [item["content"] for item in boxim.sent] == ["合并回复"] assert [item["content"] for item in boxim.sent] == ["合并回复"]
db = session_factory() db = session_factory()
@@ -293,5 +451,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token") boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
@pytest.mark.asyncio
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
session_factory, service, boxim, _ = service_context
boxim.exchange_access_token = AsyncMock(
side_effect=[
BoxIMError("连接超时"),
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
]
)
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is False
assert "暂时连接失败" in cursor.last_error
finally:
db.close()
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is True
assert cursor.last_error == ""
finally:
db.close()
assert boxim.exchange_access_token.await_count == 2
def test_plain_text_reply_removes_markdown_and_empty_lines(): def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医" assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
@@ -0,0 +1,430 @@
import uuid
import os
import hashlib
import hmac
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import Mock, patch
import pytest
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app, seed
from models import Avatar, TokenAccount, TokenPaymentOrder, 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 _enable_huihui_payment_login(context):
db = SessionLocal()
try:
user = db.query(User).filter(User.id == context["owner"].id).one()
user.huihui_token = f"huihui-payment-{context['suffix']}"
db.commit()
finally:
db.close()
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_charge_creates_huihui_order_without_early_points(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {
"orderId": "huihui-payment-id",
"orderNo": "huihui-payment-no",
"payMessage": {"mock": "payment-params"},
"payType": "WECHAT",
"paySubType": "APP",
"status": "pending",
}
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": "test-callback-secret-123456",
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
response = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
)
assert response.status_code == 200
result = response.json()["data"]
assert result["status"] == "pending"
assert result["payType"] == "WECHAT"
assert result["payWay"] == "APP"
assert result["balance"] == DEFAULT_TOKEN_GRANT
assert payment_client.create_payment.call_args.kwargs["amount"] == "10.00"
callback_url = payment_client.create_payment.call_args.kwargs["callback_url"]
assert callback_url.startswith("https://digital.example/api/token/payment/callback/AV")
assert "test-callback-secret-123456" not in callback_url
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {
"orderId": "huihui-payment-id",
"orderNo": "huihui-payment-no",
"payMessage": "payment-message",
"status": "pending",
}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
).json()["data"]
callback_body = {
"data": {
"masterOrderNo": created["orderNo"],
"status": "succeeded",
"payAmt": "10.00",
}
}
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback_path = f"/api/token/payment/callback/{created['orderNo']}/{signature}"
first = client.post(callback_path, json=callback_body)
second = client.post(callback_path, json=callback_body)
assert first.json()["data"] == {"received": True, "paid": True}
assert second.json()["data"] == {"received": True, "duplicate": True}
status = client.get(
f"/api/token/payment/{created['id']}", headers=context["owner_headers"]
).json()["data"]
assert status["status"] == "paid"
assert status["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000
assert client.get(
f"/api/token/payment/{created['id']}", headers=context["other_headers"]
).json()["code"] == 404
def test_callback_amount_mismatch_never_credits_points(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
).json()["data"]
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback = client.post(
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
json={
"masterOrderNo": created["orderNo"],
"status": "success",
"actualAmt": "9.99",
},
)
assert callback.json()["code"] == 422
db = SessionLocal()
try:
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.id == created["id"]).one()
account = get_or_create_account(db, context["owner"].id)
assert order.status == "pending"
assert account.balance == DEFAULT_TOKEN_GRANT
finally:
db.close()
def test_payment_callback_creates_missing_account_in_same_settlement(authorization_context):
context = authorization_context
_enable_huihui_payment_login(context)
payment_client = Mock()
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
secret = "test-callback-secret-123456"
env = {
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
}
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
created = client.post(
"/api/token/charge",
headers=context["owner_headers"],
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
).json()["data"]
db = SessionLocal()
try:
db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).delete()
db.commit()
finally:
db.close()
signature = hmac.new(
secret.encode(), created["orderNo"].encode(), hashlib.sha256
).hexdigest()
callback = client.post(
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
json={
"masterOrderNo": created["orderNo"],
"status": "success",
"payAmt": "10.00",
},
)
assert callback.json()["data"] == {"received": True, "paid": True}
db = SessionLocal()
try:
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
assert account.total_granted == DEFAULT_TOKEN_GRANT + 2_000_000
finally:
db.close()
def test_multiple_avatars_share_owner_balance_and_usage_is_itemized():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"token-user-{suffix}", huihui_user_id=f"token-huihui-{suffix}")
first = Avatar(id=f"token-avatar-a-{suffix}", owner_id=user.huihui_user_id, name="甲")
second = Avatar(id=f"token-avatar-b-{suffix}", owner_id=user.huihui_user_id, name="乙")
db.add_all([user, first, second])
db.commit()
try:
first_reservation = reserve_avatar_tokens(db, first, "chat", "qwen-test", [{"content": "问题一"}], 128)
settle_reservation(
db,
first_reservation,
{"prompt_tokens": 60, "completion_tokens": 40, "total_tokens": 100},
fallback_total=999,
)
second_reservation = reserve_avatar_tokens(db, second, "takeover", "qwen-test", [{"content": "问题二"}], 128)
settle_reservation(
db,
second_reservation,
{"prompt_tokens": 120, "completion_tokens": 80, "total_tokens": 200},
fallback_total=999,
)
account = get_or_create_account(db, user.id)
assert account.balance == DEFAULT_TOKEN_GRANT - 300
assert account.total_consumed == 300
usages = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).order_by(TokenUsage.total_tokens).all()
assert [(row.avatar_id, row.source, row.total_tokens) for row in usages] == [
(first.id, "chat", 100),
(second.id, "takeover", 200),
]
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id.in_([first.id, second.id])).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_concurrent_settlements_do_not_overwrite_each_other():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"concurrent-user-{suffix}", huihui_user_id=f"concurrent-huihui-{suffix}")
avatar = Avatar(id=f"concurrent-avatar-{suffix}", owner_id=user.huihui_user_id, name="并发测试")
db.add_all([user, avatar])
db.commit()
first = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "甲"}], 128)
second = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "乙"}], 128)
db.close()
barrier = Barrier(2, timeout=3)
def settle(reservation, total):
thread_db = SessionLocal()
try:
barrier.wait()
settle_reservation(
thread_db,
reservation,
{"prompt_tokens": total - 20, "completion_tokens": 20, "total_tokens": total},
fallback_total=999,
)
finally:
thread_db.close()
with ThreadPoolExecutor(max_workers=2) as pool:
list(pool.map(lambda args: settle(*args), [(first, 100), (second, 200)]))
db = SessionLocal()
try:
account = get_or_create_account(db, user.id)
assert account.balance == DEFAULT_TOKEN_GRANT - 300
assert account.total_consumed == 300
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_failed_model_request_returns_the_full_reservation():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"refund-user-{suffix}", huihui_user_id=f"refund-huihui-{suffix}")
avatar = Avatar(id=f"refund-avatar-{suffix}", owner_id=user.huihui_user_id, name="退款测试")
db.add_all([user, avatar])
db.commit()
try:
reservation = reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
release_reservation(db, reservation, "provider error")
account = get_or_create_account(db, user.id)
usage = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).one()
assert account.balance == DEFAULT_TOKEN_GRANT
assert account.total_consumed == 0
assert usage.status == "failed"
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_insufficient_balance_rejects_before_model_usage_is_created():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"empty-user-{suffix}", huihui_user_id=f"empty-huihui-{suffix}")
avatar = Avatar(id=f"empty-avatar-{suffix}", owner_id=user.huihui_user_id, name="余额不足")
db.add_all([user, avatar])
db.commit()
try:
account = get_or_create_account(db, user.id)
account.balance = 1
db.commit()
with pytest.raises(InsufficientTokensError):
reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
db.refresh(account)
assert account.balance == 1
assert db.query(TokenUsage).filter(TokenUsage.user_id == user.id).count() == 0
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_chat_settles_from_provider_usage_not_fallback_estimate():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"chat-user-{suffix}", huihui_user_id=f"chat-huihui-{suffix}")
avatar = Avatar(id=f"chat-avatar-{suffix}", owner_id=user.huihui_user_id, name="聊天测试", config={})
db.add_all([user, avatar])
db.commit()
try:
with patch(
"routers.chat._call_qwen",
return_value={
"answer": "测试回答",
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "total_tokens": 100},
},
):
result = _resolve_reply(
db,
avatar,
"测试问题",
[],
qa_pairs=[],
search_fn=lambda *_args: [],
)
assert result["tokenUsage"]["totalTokens"] == 100
assert result["tokenUsage"]["balance"] == DEFAULT_TOKEN_GRANT - 100
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
@pytest.mark.asyncio
async def test_streaming_chat_settles_final_provider_usage():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"stream-user-{suffix}", huihui_user_id=f"stream-huihui-{suffix}")
avatar = Avatar(id=f"stream-avatar-{suffix}", owner_id=user.huihui_user_id, name="流式测试", config={})
db.add_all([user, avatar])
db.commit()
try:
chunks = iter([
{"content": "流式"},
{"content": "回答"},
{"usage": {"prompt_tokens": 90, "completion_tokens": 10, "total_tokens": 100}},
])
with patch("routers.chat._iter_qwen_stream", return_value=chunks):
response = _stream_reply(db, avatar, "测试问题", [])
body = []
async for chunk in response.body_iterator:
body.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
assert "流式" in "".join(body)
account = get_or_create_account(db, user.id)
usage = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).one()
assert account.balance == DEFAULT_TOKEN_GRANT - 100
assert usage.source == "chat_stream"
assert usage.total_tokens == 100
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
+3
View File
@@ -10,6 +10,9 @@ services:
environment: environment:
DATABASE_URL: sqlite:////data/avatar.db DATABASE_URL: sqlite:////data/avatar.db
UPLOAD_DIR: /data/uploads 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: volumes:
- avatar-data:/data - avatar-data:/data
expose: expose:
@@ -0,0 +1,129 @@
# 数字分身 H5 生产接入与部署
## 1. 接入方式
生产会会在用户已登录后打开以下地址:
```text
https://digital.99hui.com/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
```
测试环境示例:
```text
http://192.168.1.188:8099/#/avatar/manage?token=<encodeURIComponent(token)>
```
兼容参数位于域名查询串的形式,但生产统一使用上面的 hash 路由形式。必须对 token 调用 `encodeURIComponent`,不能拼接用户 ID 代替 token。
免登录流程如下:
1. H5 在页面渲染前读取 `token`,立即通过 `history.replaceState` 从地址栏和浏览器历史中移除。
2. H5 调用 `POST /api/huihui/token/login`,不会把会会 token 当作数字分身接口 token 直接使用。
3. 后端通过会会生产接口 `/im/box/netease` 换取 BOXIM 凭证,再调用 BOXIM `/user/self` 校验用户身份。
4. 后端以返回的 `huihuiUserId` 绑定本地用户,保存会会凭证供 BOXIM 接管功能使用,并签发本系统 `app_token`。
5. 浏览器只保存 `app_token` 和非敏感用户资料。会会原始 token 不返回浏览器存储。
6. token 无效、过期或上游校验失败时清除旧会话并进入登录页,不会沿用上一位用户的缓存身份。
## 2. 生产配置
后端 `.env` 至少配置以下内容,密钥由部署平台注入,禁止提交 Git:
```dotenv
HUIHUI_DEV_MOCK=false
HUIHUI_AUTH_BASE_URL=https://99hui.com/api/usercenter
HUIHUI_PLATFORM_BASE_URL=https://open.99hui.com/api
BOXIM_API_BASE_URL=https://im.99hui.com/api
HUIHUI_APP_ID=<production-app-id>
HUIHUI_ACCESS_ID=<production-access-id>
HUIHUI_ACCESS_SECRET=<production-access-secret>
HUIHUI_CLIENT_CODE=<production-client-code>
BOXIM_TIMEOUT_SECONDS=20
HUIHUI_PAYMENT_BASE_URL=https://open.99hui.com/api/payment-v3
HUIHUI_PAYMENT_CALLBACK_BASE_URL=https://digital.99hui.com
HUIHUI_PAYMENT_CALLBACK_SECRET=<至少32位随机密钥>
HUIHUI_PAYMENT_TIMEOUT_SECONDS=30
DATABASE_URL=sqlite:////data/avatar.db
UPLOAD_DIR=/data/uploads
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
EMBEDDING_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EMBEDDING_API_KEY=<production-embedding-api-key>
EMBEDDING_MODEL=text-embedding-v3
EMBEDDING_BATCH_SIZE=10
```
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`,端内支付场景为 `APP`,微信内 H5 使用 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
## 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
docker compose exec avatar-backend python -c 'import embeddings; v=embeddings.embed(["部署向量探针"]); print(len(v), len(v[0]))'
```
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
## 4. 网关要求
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
```nginx
server_name digital.99hui.com;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://avatar-backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
client_max_body_size 20m;
}
```
`proxy_buffering off` 用于数字分身 SSE 流式吐字,`client_max_body_size` 用于知识库文件上传。网关和应用日志必须关闭完整 URL 查询参数记录,任何异常日志都不得输出 token、Authorization 或平台密钥。建议同时设置严格的 `Referrer-Policy: no-referrer`。
## 5. 发布验收
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和积分余额均存在。
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
## 6. 回滚
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
+3 -4
View File
@@ -7,10 +7,9 @@
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<title>会会数字分身</title> <title>会会数字分身</title>
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 --> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script> <!-- digital.99hui.com 同域部署保持 apiBase 为空,接口使用 /api。
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。 仅跨域部署时才显式配置后端公网地址。 -->
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
<script type="text/javascript"> <script type="text/javascript">
window.__APP_CONFIG__ = { apiBase: '' } window.__APP_CONFIG__ = { apiBase: '' }
</script> </script>
+7
View File
@@ -8,6 +8,7 @@
"name": "digital-avatar-app", "name": "digital-avatar-app",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dcloudio/uni-webview-js": "^0.0.3",
"axios": "^1.6.0", "axios": "^1.6.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.3.0", "vue": "^3.3.0",
@@ -66,6 +67,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dcloudio/uni-webview-js": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/@dcloudio/uni-webview-js/-/uni-webview-js-0.0.3.tgz",
"integrity": "sha512-3XDnLXg22QKC/aTLtGim6zbOK+euiDYoRf98BjGDE0lqMm1U4SLFHAvOlAHa0qck91phaceKwo73ScQQgSojQg==",
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dcloudio/uni-webview-js": "^0.0.3",
"axios": "^1.6.0", "axios": "^1.6.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.3.0", "vue": "^3.3.0",
+1
View File
@@ -0,0 +1 @@
8f54097b01d5b0433f8aa5ea70ca930b
@@ -0,0 +1 @@
8f54097b01d5b0433f8aa5ea70ca930b
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="avatar-gradient" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop stop-color="#fb923c"/>
<stop offset="1" stop-color="#f97316"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#avatar-gradient)"/>
<circle cx="32" cy="24" r="10" fill="#fff"/>
<path d="M15 52c1.8-10 8-15 17-15s15.2 5 17 15" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

+1 -108
View File
@@ -1,73 +1,10 @@
<template> <template>
<div id="app"> <div id="app">
<router-view /> <router-view />
<!-- 底部导航栏 --> <!-- 底部菜单暂不展示,多分身能力统一从分身卡片进入。 -->
<nav class="bottom-nav" v-if="showNav">
<button
class="nav-item"
:class="{ active: currentRoute === '/' || currentRoute === '/avatar/manage' }"
@click="navigateTo('/avatar/manage')"
>
<span class="nav-icon">🤖</span>
<span class="nav-label">我的分身</span>
</button>
<button
class="nav-item"
:class="{ active: currentRoute === '/authorization' }"
@click="navigateTo('/authorization')"
>
<span class="nav-icon">🔑</span>
<span class="nav-label">授权管理</span>
</button>
<button
class="nav-item"
:class="{ active: currentRoute === '/token/charge' }"
@click="navigateTo('/token/charge')"
>
<span class="nav-icon">💰</span>
<span class="nav-label">Token</span>
</button>
</nav>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const currentRoute = ref<string>(route.path)
const showNav = ref<boolean>(shouldShowNav(route.path))
function shouldShowNav(path: string) {
return path !== '/'
&& path !== '/authorization'
&& path !== '/avatar/create'
&& path !== '/login/sms'
&& !path.startsWith('/avatar/edit')
&& !path.startsWith('/avatar/chat')
&& !path.startsWith('/share/')
}
// 监听路由变化
watch(() => route.path, (newPath) => {
currentRoute.value = newPath
showNav.value = shouldShowNav(newPath)
})
// 导航
const navigateTo = (path: string) => {
router.push(path)
}
onMounted(() => {
currentRoute.value = route.path
showNav.value = shouldShowNav(route.path)
})
</script>
<style> <style>
* { * {
margin: 0; margin: 0;
@@ -81,48 +18,4 @@ onMounted(() => {
background: #F8F9FA; background: #F8F9FA;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
/* 底部导航栏 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: white;
border-top: 1px solid #EDEEF1;
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 0;
background: none;
border: none;
cursor: pointer;
transition: color 0.2s;
}
.nav-icon {
font-size: 20px;
}
.nav-label {
font-size: 11px;
color: #9398AE;
font-weight: 500;
}
.nav-item.active .nav-label {
color: #F97316;
}
.nav-item.active .nav-icon {
filter: none;
}
</style> </style>
+63 -9
View File
@@ -9,8 +9,8 @@ interface ApiClient {
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api' // API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000) // - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
// - web-view 内(混合架构):需配置为后端公网地址,例如 'https://geo.99hui.com/api' // - digital.99hui.com 同域部署:保持 '/api'
// - 同域部署的构建产物:可保持 '/api' // - 仅跨域部署时配置后端公网地址
function resolveBaseURL(): string { function resolveBaseURL(): string {
const cfg = (window as any).__APP_CONFIG__ const cfg = (window as any).__APP_CONFIG__
if (cfg && cfg.apiBase) return cfg.apiBase as string if (cfg && cfg.apiBase) return cfg.apiBase as string
@@ -131,17 +131,58 @@ export const deleteAvatar = (id: string) =>
// ==================== Token 管理 API ==================== // ==================== 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
}
export interface TokenPaymentOrder {
id: string
orderNo: string
planId: string
paymentMethod: 'wechat' | 'alipay'
payType: 'WECHAT' | 'ALIPAY'
payWay: 'APP' | 'LITE' | 'JSAPI'
pointsAmount: number
price: number
status: 'pending' | 'paid' | 'failed'
providerStatus: string
payMessage: string
failureReason: string
balance: number
}
// 获取 Token 余额 // 获取 Token 余额
export const getTokenBalance = () => export const getTokenBalance = () =>
request.get<{ balance: number }>('/token/balance') request.get<TokenBalance>('/token/balance')
// 获取充值套餐 // 获取充值套餐
export const getRechargePlans = () => export const getRechargePlans = () =>
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans') request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
// 执行充值 // 创建会会支付订单;支付回调成功后积分到账。
export const chargeToken = (planId: string) => export const chargeToken = (
request.post<{ balance: number; charged: number }>('/token/charge', { planId }) planId: string,
paymentMethod: 'wechat' | 'alipay',
payScene: 'APP' | 'LITE' | 'JSAPI'
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
export const getTokenPaymentStatus = (orderId: string) =>
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
// 按分身和使用场景汇总 Token 消耗
export const getTokenUsage = () =>
request.get<TokenUsageSummary[]>('/token/usage')
// ==================== 授权管理 API ==================== // ==================== 授权管理 API ====================
@@ -150,19 +191,28 @@ export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'inter
export interface AvatarPermissionSettings { export interface AvatarPermissionSettings {
avatarId: string avatarId: string
permissions: AvatarPermission[] permissions: AvatarPermission[]
takeoverReplyDelaySeconds: number
disabledAvatarIds?: string[]
} }
export const getAvatarPermissionSettings = (avatarId: string) => export const getAvatarPermissionSettings = (avatarId: string) =>
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`) request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) => export const updateAvatarPermissionSettings = (
request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, { permissions }) avatarId: string,
permissions: AvatarPermission[],
takeoverReplyDelaySeconds: number
) => request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, {
permissions,
takeoverReplyDelaySeconds,
})
export interface TakeoverStatus { export interface TakeoverStatus {
enabled: boolean enabled: boolean
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error' status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
message: string message: string
pendingCount: number pendingCount: number
takeoverReplyDelaySeconds: number
lastPolledAt: string | null lastPolledAt: string | null
} }
@@ -409,7 +459,7 @@ export const getUserProfile = () =>
export interface SmsLoginResult { export interface SmsLoginResult {
token: string token: string
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: 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 标记) // 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
@@ -424,6 +474,10 @@ export const loginBySms = (phone: string, code: string) =>
export const loginByPassword = (account: string, password: string) => export const loginByPassword = (account: string, password: string) =>
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password }) request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
// Validate a production Huihui access token and exchange it for an app session.
export const loginByHuihuiToken = (token: string) =>
request.post<SmsLoginResult>('/huihui/token/login', { token })
// 当前登录用户 // 当前登录用户
export const getCurrentUser = () => export const getCurrentUser = () =>
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me') request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
+4
View File
@@ -0,0 +1,4 @@
<svg fill="#1677FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>支付宝</title>
<path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

@@ -0,0 +1,4 @@
<svg fill="#07C160" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>微信支付</title>
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+46 -20
View File
@@ -1,14 +1,16 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import uniWebView from '@dcloudio/uni-webview-js'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import pinia from './store' 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 { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user' 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) const app = createApp(App)
app.use(router)
app.use(pinia) app.use(pinia)
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 —— // —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
@@ -16,10 +18,8 @@ const params = getLaunchParams()
const avatarStore = useAvatarStore(pinia) const avatarStore = useAvatarStore(pinia)
const userStore = useUserStore(pinia) const userStore = useUserStore(pinia)
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户) function syncAvatarProfile() {
userStore.loadFromStorage() if (!userStore.isLogin || !userStore.user) return
if (userStore.isLogin && userStore.user) {
setAuthToken(userStore.token)
avatarStore.setNativeProfile({ avatarStore.setNativeProfile({
userId: (userStore.user as any).huihuiUserId || '', userId: (userStore.user as any).huihuiUserId || '',
nickname: userStore.user.nickname || '', nickname: userStore.user.nickname || '',
@@ -27,29 +27,55 @@ if (userStore.isLogin && userStore.user) {
}) })
} }
if (params.token) { async function exchangeInjectedToken(token: string) {
setAuthToken(params.token) stripLaunchToken()
} userStore.clearSession()
if (params.userId || params.nickname || params.avatar) { await userStore.loginByToken(token)
avatarStore.setNativeProfile({ syncAvatarProfile()
userId: params.userId || '',
nickname: params.nickname || '',
avatarUrl: params.avatar || ''
})
} }
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用) // 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
onNativeMessage((msg) => { onNativeMessage((msg) => {
if (!msg || !msg.type) return if (!msg || !msg.type) return
if (msg.type === 'tokenRefresh' && msg.token) { 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) { if (msg.type === 'userUpdate' && msg.user) {
avatarStore.setNativeProfile(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 已就绪 if (params.userId || params.nickname || params.avatar) {
UniEvents.ready() avatarStore.setNativeProfile({
userId: params.userId || '',
nickname: params.nickname || '',
avatarUrl: params.avatar || ''
})
}
app.use(router)
app.mount('#app')
// 通知原生壳:H5 已就绪
UniEvents.ready()
}
void bootstrap()
+25 -1
View File
@@ -45,11 +45,17 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/AuthorizationManage.vue'), component: () => import('@/views/AuthorizationManage.vue'),
meta: { title: '授权管理', requiresAuth: true } meta: { title: '授权管理', requiresAuth: true }
}, },
{
path: '/avatar/:avatarId/authorization',
name: 'AvatarAuthorizationManage',
component: () => import('@/views/AuthorizationManage.vue'),
meta: { title: '授权管理', requiresAuth: true }
},
{ {
path: '/token/charge', path: '/token/charge',
name: 'TokenCharge', name: 'TokenCharge',
component: () => import('@/views/TokenCharge.vue'), component: () => import('@/views/TokenCharge.vue'),
meta: { title: 'Token充值', requiresAuth: true } meta: { title: '积分充值', requiresAuth: true }
}, },
{ {
path: '/avatar/card', path: '/avatar/card',
@@ -81,6 +87,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/KnowledgeManage.vue'), component: () => import('@/views/KnowledgeManage.vue'),
meta: { title: '知识库管理', requiresAuth: true } meta: { title: '知识库管理', requiresAuth: true }
}, },
{
path: '/avatar/:avatarId/knowledge',
name: 'AvatarKnowledgeManage',
component: () => import('@/views/KnowledgeManage.vue'),
meta: { title: '知识库管理', requiresAuth: true }
},
{ {
path: '/knowledge/qa/create', path: '/knowledge/qa/create',
name: 'QaPairCreate', name: 'QaPairCreate',
@@ -93,6 +105,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/QaPairEdit.vue'), component: () => import('@/views/QaPairEdit.vue'),
meta: { title: '编辑问答对', requiresAuth: true } 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', path: '/login/sms',
name: 'SmsLogin', name: 'SmsLogin',
+20 -2
View File
@@ -1,13 +1,15 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' 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' import { unwrapListData } from '@/utils/avatar-page-data'
export const useAvatarStore = defineStore('avatar', () => { export const useAvatarStore = defineStore('avatar', () => {
// 已创建的分身列表(来自后端) // 已创建的分身列表(来自后端)
const avatars = ref<any[]>([]) const avatars = ref<any[]>([])
// 全局 Token 余额(来自后端) // 当前用户所有分身共享的 Token 账户
const tokenBalance = ref<number>(0) const tokenBalance = ref<number>(0)
const tokenConsumed = ref<number>(0)
const tokenUsageByAvatar = ref<Record<string, number>>({})
// 当前选中分身 id // 当前选中分身 id
const currentAvatarId = ref<string | null>(null) const currentAvatarId = ref<string | null>(null)
// 会会用户资料(头像/昵称,来自会会接口) // 会会用户资料(头像/昵称,来自会会接口)
@@ -29,11 +31,24 @@ export const useAvatarStore = defineStore('avatar', () => {
try { try {
const res = await getTokenBalance() const res = await getTokenBalance()
tokenBalance.value = (res as any)?.balance ?? 0 tokenBalance.value = (res as any)?.balance ?? 0
tokenConsumed.value = (res as any)?.totalConsumed ?? 0
} catch (e) { } catch (e) {
console.error('加载余额失败', e) console.error('加载余额失败', e)
} }
} }
const loadTokenUsage = async () => {
try {
const rows = await getTokenUsage()
tokenUsageByAvatar.value = rows.reduce<Record<string, number>>((result, row) => {
result[row.avatarId] = (result[row.avatarId] || 0) + row.totalTokens
return result
}, {})
} catch (e) {
console.error('加载 Token 用量失败', e)
}
}
// 拉取会会用户资料(头像/昵称) // 拉取会会用户资料(头像/昵称)
const loadUserProfile = async () => { const loadUserProfile = async () => {
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock // 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
@@ -82,10 +97,13 @@ export const useAvatarStore = defineStore('avatar', () => {
return { return {
avatars, avatars,
tokenBalance, tokenBalance,
tokenConsumed,
tokenUsageByAvatar,
currentAvatarId, currentAvatarId,
userProfile, userProfile,
loadAvatars, loadAvatars,
loadTokenBalance, loadTokenBalance,
loadTokenUsage,
loadUserProfile, loadUserProfile,
setNativeProfile, setNativeProfile,
addAvatar, addAvatar,
+54 -21
View File
@@ -1,6 +1,16 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' 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'
import { clearHuihuiEmbeddedMode, markHuihuiEmbeddedMode } from '@/utils/embed-mode'
const TOKEN_KEY = 'hh_app_token' const TOKEN_KEY = 'hh_app_token'
const USER_KEY = 'hh_app_user' const USER_KEY = 'hh_app_user'
@@ -37,6 +47,23 @@ export const useUserStore = defineStore('smsuser', () => {
localStorage.removeItem(USER_KEY) 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) // 发送验证码(返回结果,演示模式含 devCode)
const sendCode = async (phone: string) => { const sendCode = async (phone: string) => {
return await sendSmsCode(phone) return await sendSmsCode(phone)
@@ -44,24 +71,22 @@ export const useUserStore = defineStore('smsuser', () => {
// 短信登录 // 短信登录
const login = async (phone: string, code: string) => { const login = async (phone: string, code: string) => {
const res: any = await loginBySms(phone, code) const result = await loginBySms(phone, code)
token.value = res.token clearHuihuiEmbeddedMode()
user.value = { ...(res.user || {}), ...(res.huihui || {}) } return acceptLogin(result)
isLogin.value = true
setAuthToken(res.token)
persist()
return res
} }
// 账号密码登录 // 账号密码登录
const loginByPwd = async (account: string, password: string) => { const loginByPwd = async (account: string, password: string) => {
const res: any = await loginByPassword(account, password) const result = await loginByPassword(account, password)
token.value = res.token clearHuihuiEmbeddedMode()
user.value = { ...(res.user || {}), ...(res.huihui || {}) } return acceptLogin(result)
isLogin.value = true }
setAuthToken(res.token)
persist() const loginByToken = async (huihuiToken: string) => {
return res const result = await loginByHuihuiToken(huihuiToken)
markHuihuiEmbeddedMode()
return acceptLogin(result)
} }
// 退出 // 退出
@@ -71,12 +96,20 @@ export const useUserStore = defineStore('smsuser', () => {
} catch { } catch {
/* 忽略网络错误,本地清除即可 */ /* 忽略网络错误,本地清除即可 */
} }
token.value = '' clearHuihuiEmbeddedMode()
user.value = null clearSession()
isLogin.value = false
setAuthToken(null)
clearLocal()
} }
return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout } return {
token,
user,
isLogin,
loadFromStorage,
clearSession,
sendCode,
login,
loginByPwd,
loginByToken,
logout
}
}) })
+4
View File
@@ -0,0 +1,4 @@
declare module '@dcloudio/uni-webview-js' {
const uniWebView: unknown
export default uniWebView
}
+5
View File
@@ -41,5 +41,10 @@ export function pickAvatarId(
currentAvatarId: string | null | undefined, currentAvatarId: string | null | undefined,
avatars?: AvatarPageRecord[] avatars?: AvatarPageRecord[]
): string | null ): 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 normalizeAvatarEditForm(avatar?: AvatarPageRecord): AvatarEditForm
export function buildAvatarUpdatePayload(form: AvatarEditForm): AvatarUpdatePayload export function buildAvatarUpdatePayload(form: AvatarEditForm): AvatarUpdatePayload
@@ -8,6 +8,11 @@ export function pickAvatarId(currentAvatarId, avatars) {
return currentAvatarId || avatars?.[0]?.id || null 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 = {}) { export function normalizeAvatarEditForm(avatar = {}) {
const config = avatar.config || {} const config = avatar.config || {}
return { return {
@@ -0,0 +1,13 @@
const HUIHUI_EMBED_MODE_KEY = 'hh_huihui_embed_mode'
export function markHuihuiEmbeddedMode(): void {
sessionStorage.setItem(HUIHUI_EMBED_MODE_KEY, '1')
}
export function clearHuihuiEmbeddedMode(): void {
sessionStorage.removeItem(HUIHUI_EMBED_MODE_KEY)
}
export function isHuihuiEmbeddedMode(): boolean {
return sessionStorage.getItem(HUIHUI_EMBED_MODE_KEY) === '1'
}
+60 -20
View File
@@ -1,11 +1,10 @@
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具 // 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
// 协议详见 uniapp-avatar/README.md // 协议详见 uniapp-avatar/README.md
// //
// 引入方式:在 index.html 中加载 uniapp web-view bridge: // uni-webview bridge is bundled by main.ts; no external CDN is required.
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
const BRIDGE_HANDLER = '__uniBridgeHandle__' const BRIDGE_HANDLER = '__uniBridgeHandle__'
const nativeMessageHandlers = new Set<(message: any) => void>()
export interface UniLaunchParams { export interface UniLaunchParams {
token?: string token?: string
@@ -15,28 +14,64 @@ export interface UniLaunchParams {
ts?: string 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 环境中 // 是否运行在 uniapp web-view 环境中
export function isInUniWebView(): boolean { export function isInUniWebView(): boolean {
return !!(window as any).uni?.webView const runtime = window as any
const userAgent = navigator.userAgent
const isDCloudApp = Boolean(
runtime.plus ||
runtime.__dcloud_weex_postMessage ||
runtime.__dcloud_weex_ ||
/uni-app|Html5Plus/i.test(userAgent)
)
const isMiniProgram = Boolean(
(/miniProgram/i.test(userAgent) && (runtime.wx?.miniProgram || runtime.qq?.miniProgram)) ||
runtime.my ||
runtime.swan?.webView ||
runtime.tt?.miniProgram
)
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram))
} }
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户) // 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
export function getLaunchParams(): UniLaunchParams { export function getLaunchParams(): UniLaunchParams {
const sp = new URLSearchParams(window.location.search)
const params: UniLaunchParams = {} const params: UniLaunchParams = {}
const token = sp.get('token') readParams(window.location.search, params)
const userId = sp.get('userId') const hashQueryIndex = window.location.hash.indexOf('?')
const nickname = sp.get('nickname') if (hashQueryIndex >= 0) {
const avatar = sp.get('avatar') readParams(window.location.hash.slice(hashQueryIndex + 1), params)
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
return 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) // H5 → 原生:发送事件(需引入 uniapp web-view bridge)
export function postToNative(message: Record<string, any>): boolean { export function postToNative(message: Record<string, any>): boolean {
if (!isInUniWebView()) return false if (!isInUniWebView()) return false
@@ -45,14 +80,18 @@ export function postToNative(message: Record<string, any>): boolean {
} }
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__) // 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
export function onNativeMessage(handler: (message: any) => void): void { export function onNativeMessage(handler: (message: any) => void): () => void {
nativeMessageHandlers.add(handler)
;(window as any)[BRIDGE_HANDLER] = (message: any) => { ;(window as any)[BRIDGE_HANDLER] = (message: any) => {
try { for (const listener of nativeMessageHandlers) {
handler(message) try {
} catch (e) { listener(message)
console.error('[uniBridge] handler error', e) } catch (e) {
console.error('[uniBridge] handler error', e)
}
} }
} }
return () => nativeMessageHandlers.delete(handler)
} }
// 便捷事件 // 便捷事件
@@ -61,5 +100,6 @@ export const UniEvents = {
needLogin: () => postToNative({ type: 'needLogin' }), needLogin: () => postToNative({ type: 'needLogin' }),
setTitle: (title: string) => postToNative({ type: 'setTitle', title }), setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
navigate: (path: string) => postToNative({ type: 'navigate', path }), navigate: (path: string) => postToNative({ type: 'navigate', path }),
payment: (payment: Record<string, any>) => postToNative({ type: 'payment', payment }),
back: () => postToNative({ type: 'back' }) back: () => postToNative({ type: 'back' })
} }
@@ -1,6 +1,6 @@
<template> <template>
<div class="authorization-page"> <div class="authorization-page" :class="{ embedded: isEmbedded }">
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack"> <button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m15 18-6-6 6-6" /> <path d="m15 18-6-6 6-6" />
@@ -64,7 +64,7 @@
<span class="permission-copy"> <span class="permission-copy">
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<small> <small>
{{ item.description }} {{ item.key === 'takeover' ? takeoverDescription : item.description }}
<span <span
v-if="item.key === 'takeover' && takeoverConnectionLabel" v-if="item.key === 'takeover' && takeoverConnectionLabel"
class="connection-state" class="connection-state"
@@ -79,6 +79,33 @@
</button> </button>
</section> </section>
<section v-if="permissionState.takeover" class="takeover-delay-card" aria-label="自动回复等待时间">
<div class="delay-heading">
<div>
<strong>自动回复等待时间</strong>
<small>等待期间主人发言会取消本次回复,最短 3 秒</small>
</div>
<span>{{ formattedTakeoverDelay }}</span>
</div>
<div class="delay-control">
<input
v-model.number="takeoverDelayValue"
type="number"
inputmode="numeric"
step="1"
:min="takeoverDelayUnit === 'minutes' ? 1 : 3"
:max="takeoverDelayUnit === 'minutes' ? 1440 : 86400"
aria-label="等待时间"
:disabled="loading || saving"
@blur="normalizeTakeoverDelay"
/>
<select v-model="takeoverDelayUnit" aria-label="等待时间单位" :disabled="loading || saving">
<option value="seconds">秒</option>
<option value="minutes">分钟</option>
</select>
</div>
</section>
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
</template> </template>
@@ -95,6 +122,9 @@
</main> </main>
<footer v-if="activeAvatarId" class="save-area"> <footer v-if="activeAvatarId" class="save-area">
<button v-if="isEmbedded" class="footer-back-button" type="button" :disabled="saving" @click="goBack">
返回
</button>
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()"> <button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span> <span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
{{ saving ? '保存中...' : '保存授权设置' }} {{ saving ? '保存中...' : '保存授权设置' }}
@@ -109,7 +139,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
getAvatarPermissionSettings, getAvatarPermissionSettings,
getTakeoverStatus, getTakeoverStatus,
@@ -118,12 +148,16 @@ import {
type TakeoverStatus, type TakeoverStatus,
} from '@/api' } from '@/api'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { pickAvatarId } from '@/utils/avatar-page-data.js' import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
type PermissionState = Record<AvatarPermission, boolean> type PermissionState = Record<AvatarPermission, boolean>
const router = useRouter() const router = useRouter()
const route = useRoute()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
const isEmbedded = isHuihuiEmbeddedMode()
const permissionItems: Array<{ const permissionItems: Array<{
key: AvatarPermission key: AvatarPermission
@@ -164,7 +198,7 @@ const permissionItems: Array<{
{ {
key: 'takeover', key: 'takeover',
title: '分身主动接管聊天回复', title: '分身主动接管聊天回复',
description: '收到私聊消息 3 秒后回复,主人发言时暂停', description: '收到私聊消息后按设定时间回复,主人发言时暂停',
tone: 'cyan', tone: 'cyan',
}, },
] ]
@@ -183,6 +217,8 @@ const saving = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const toastMessage = ref('') const toastMessage = ref('')
const takeoverStatus = ref<TakeoverStatus | null>(null) const takeoverStatus = ref<TakeoverStatus | null>(null)
const takeoverDelayValue = ref(3)
const takeoverDelayUnit = ref<'seconds' | 'minutes'>('minutes')
let toastTimer: number | undefined let toastTimer: number | undefined
let takeoverStatusTimer: number | undefined let takeoverStatusTimer: number | undefined
@@ -203,6 +239,38 @@ const takeoverConnectionTone = computed(() => {
return 'connecting' return 'connecting'
}) })
const takeoverDelaySeconds = computed(() => {
const value = Math.trunc(Number(takeoverDelayValue.value) || 0)
return takeoverDelayUnit.value === 'minutes' ? value * 60 : value
})
const formattedTakeoverDelay = computed(() => {
const seconds = takeoverDelaySeconds.value
if (seconds > 0 && seconds % 60 === 0) return `${seconds / 60} 分钟`
return `${seconds} 秒`
})
const takeoverDescription = computed(() =>
`收到私聊消息 ${formattedTakeoverDelay.value}后回复,主人发言时暂停`
)
const applyTakeoverDelay = (seconds: number) => {
const normalized = Number.isFinite(seconds) && seconds >= 3 ? Math.trunc(seconds) : 180
if (normalized % 60 === 0) {
takeoverDelayUnit.value = 'minutes'
takeoverDelayValue.value = normalized / 60
} else {
takeoverDelayUnit.value = 'seconds'
takeoverDelayValue.value = normalized
}
}
const normalizeTakeoverDelay = () => {
const min = takeoverDelayUnit.value === 'minutes' ? 1 : 3
const max = takeoverDelayUnit.value === 'minutes' ? 1440 : 86400
takeoverDelayValue.value = Math.min(max, Math.max(min, Math.trunc(Number(takeoverDelayValue.value) || min)))
}
const setPermissions = (permissions: AvatarPermission[]) => { const setPermissions = (permissions: AvatarPermission[]) => {
const enabled = new Set(permissions) const enabled = new Set(permissions)
for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key) for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key)
@@ -258,6 +326,7 @@ const loadSettings = async () => {
try { try {
const settings = await getAvatarPermissionSettings(activeAvatarId.value) const settings = await getAvatarPermissionSettings(activeAvatarId.value)
setPermissions(settings.permissions || []) setPermissions(settings.permissions || [])
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
await loadTakeoverStatus() await loadTakeoverStatus()
scheduleTakeoverStatusRefresh() scheduleTakeoverStatusRefresh()
} catch (error: any) { } catch (error: any) {
@@ -284,8 +353,18 @@ const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
saving.value = true saving.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const settings = await updateAvatarPermissionSettings(activeAvatarId.value, selectedPermissions()) normalizeTakeoverDelay()
if (takeoverDelaySeconds.value < 3 || takeoverDelaySeconds.value > 86400) {
errorMessage.value = '自动回复等待时间需在 3 秒到 24 小时之间'
return false
}
const settings = await updateAvatarPermissionSettings(
activeAvatarId.value,
selectedPermissions(),
takeoverDelaySeconds.value,
)
setPermissions(settings.permissions || []) setPermissions(settings.permissions || [])
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
await loadTakeoverStatus() await loadTakeoverStatus()
scheduleTakeoverStatusRefresh() scheduleTakeoverStatusRefresh()
if (takeoverToggle) { if (takeoverToggle) {
@@ -307,7 +386,8 @@ const goCreate = () => router.push('/avatar/create')
onMounted(async () => { onMounted(async () => {
if (!avatarStore.avatars.length) await avatarStore.loadAvatars() if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
activeAvatarId.value = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars) || '' activeAvatarId.value = avatarId.value || ''
if (activeAvatarId.value) avatarStore.currentAvatarId = activeAvatarId.value
await loadSettings() await loadSettings()
}) })
@@ -391,6 +471,10 @@ svg {
padding: 0 20px; padding: 0 20px;
} }
.authorization-page.embedded .page-content {
padding-top: 16px;
}
.permission-intro { .permission-intro {
min-height: 96px; min-height: 96px;
padding: 15px 16px 14px; padding: 15px 16px 14px;
@@ -456,6 +540,75 @@ svg {
min-height: 76px; min-height: 76px;
} }
.takeover-delay-card {
margin-top: 12px;
padding: 16px;
border: 1px solid #dff1ef;
border-radius: 15px;
background: linear-gradient(135deg, #f5fcfb 0%, #fff 100%);
box-shadow: 0 8px 24px rgba(53, 166, 162, .06);
}
.delay-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.delay-heading strong,
.delay-heading small {
display: block;
}
.delay-heading strong {
font-size: 14px;
line-height: 1.4;
}
.delay-heading small {
margin-top: 5px;
color: #8c929f;
font-size: 11px;
line-height: 1.55;
}
.delay-heading > span {
flex: none;
padding: 4px 8px;
border-radius: 999px;
color: #258e8a;
background: #e8f8f6;
font-size: 11px;
font-weight: 700;
}
.delay-control {
margin-top: 14px;
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 10px;
}
.delay-control input,
.delay-control select {
min-width: 0;
height: 42px;
padding: 0 12px;
border: 1px solid #dfe5e8;
border-radius: 11px;
outline: none;
color: #222528;
background: #fff;
font: inherit;
}
.delay-control input:focus,
.delay-control select:focus {
border-color: #35a6a2;
box-shadow: 0 0 0 3px rgba(53, 166, 162, .1);
}
.permission-icon { .permission-icon {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -596,12 +749,15 @@ svg {
bottom: 0; bottom: 0;
width: min(100%, 390px); width: min(100%, 390px);
padding: 12px 20px calc(20px + env(safe-area-inset-bottom)); padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
display: flex;
gap: 10px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%); background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
transform: translateX(-50%); transform: translateX(-50%);
} }
.save-button { .save-button {
width: 100%; min-width: 0;
flex: 1;
height: 48px; height: 48px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -617,6 +773,20 @@ svg {
cursor: pointer; cursor: pointer;
} }
.footer-back-button {
flex: 0 0 96px;
height: 48px;
border: 1px solid #eadfd6;
border-radius: 24px;
color: #6f665f;
background: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.footer-back-button:disabled { opacity: .58; }
.save-button:disabled { .save-button:disabled {
opacity: .68; opacity: .68;
} }
+56 -15
View File
@@ -1,12 +1,10 @@
<template> <template>
<div class="edit-avatar-page"> <div class="edit-avatar-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">分身微调</h1> <h1 class="page-title">分身微调</h1>
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges"> <span class="header-spacer" aria-hidden="true"></span>
{{ saving ? '保存中...' : '保存' }}
</button>
</header> </header>
<div v-if="loading" class="status-banner">加载中...</div> <div v-if="loading" class="status-banner">加载中...</div>
@@ -159,6 +157,13 @@
{{ deleting ? '删除中...' : '删除数字分身' }} {{ deleting ? '删除中...' : '删除数字分身' }}
</button> </button>
</section> </section>
<footer class="edit-action-bar">
<button class="action-back-btn" type="button" :disabled="saving" @click="goBack">返回</button>
<button class="action-save-btn" type="button" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
{{ saving ? '保存中...' : '保存修改' }}
</button>
</footer>
</div> </div>
</template> </template>
@@ -168,11 +173,13 @@ import { useRoute, useRouter } from 'vue-router'
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api' import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js' import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const avatarId = route.params.id as string const avatarId = route.params.id as string
const isEmbedded = isHuihuiEmbeddedMode()
// 表单数据 // 表单数据
const formData = reactive({ const formData = reactive({
@@ -288,7 +295,7 @@ onMounted(async () => {
.edit-avatar-page { .edit-avatar-page {
min-height: 100vh; min-height: 100vh;
background: #F8F9FA; background: #F8F9FA;
padding-bottom: 40px; padding-bottom: calc(104px + env(safe-area-inset-bottom));
} }
/* 顶部导航 */ /* 顶部导航 */
@@ -331,16 +338,7 @@ onMounted(async () => {
color: #B91C1C; color: #B91C1C;
} }
.save-btn { .header-spacer { width: 40px; }
background: #F97316;
color: white;
border: none;
padding: 8px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
/* 头像上传 */ /* 头像上传 */
.photo-section { .photo-section {
@@ -580,4 +578,47 @@ onMounted(async () => {
background: #EF4444; background: #EF4444;
color: white; color: white;
} }
.edit-action-bar {
position: fixed;
z-index: 30;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 12px;
padding: 12px 20px calc(14px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(229, 231, 235, .9);
background: rgba(248, 249, 250, .96);
box-shadow: 0 -8px 24px rgba(56, 38, 24, .06);
backdrop-filter: blur(12px);
}
.action-back-btn,
.action-save-btn {
height: 48px;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.action-back-btn {
flex: 0 0 104px;
border: 1px solid #E4E0DC;
color: #655E58;
background: #fff;
}
.action-save-btn {
min-width: 0;
flex: 1;
border: 0;
color: #fff;
background: linear-gradient(105deg, #F79A38, #F97316);
box-shadow: 0 8px 18px rgba(249, 115, 22, .18);
}
.action-back-btn:disabled,
.action-save-btn:disabled { opacity: .6; cursor: not-allowed; }
</style> </style>
+118 -408
View File
@@ -1,15 +1,11 @@
<template> <template>
<div class="avatar-manage-page"> <div class="avatar-manage-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<div class="header-left"> <div class="header-left">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">数字分身管理</h1> <h1 class="page-title">数字分身管理</h1>
</div> </div>
<!-- 右上角创建入口 -->
<div class="header-right">
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
</div>
</header> </header>
<!-- 用户资料头(会会登录账号的头像 / 昵称) --> <!-- 用户资料头(会会登录账号的头像 / 昵称) -->
@@ -24,12 +20,13 @@
</div> </div>
</section> </section>
<!-- Token 余额条 --> <!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
<section class="token-section"> <section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
<div class="token-card"> <div class="token-card">
<div class="token-info"> <div class="token-info">
<span class="token-label">Token 余额</span> <span class="token-label">积分余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span> <span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div> </div>
<button class="recharge-btn" @click="goToRecharge">充值</button> <button class="recharge-btn" @click="goToRecharge">充值</button>
</div> </div>
@@ -38,12 +35,20 @@
<!-- 数字分身列表(只放分身相关) --> <!-- 数字分身列表(只放分身相关) -->
<section class="avatar-list-section"> <section class="avatar-list-section">
<div class="section-head"> <div class="section-head">
<h3 class="section-title">我的数字分身</h3> <div class="section-heading-copy">
<span class="count-badge">{{ avatars.length }}</span> <h3 class="section-title">我的数字分身</h3>
<span class="count-badge">{{ avatars.length }}</span>
</div>
<button class="section-create-btn" type="button" @click="goCreate">
<span aria-hidden="true">+</span> 添加分身
</button>
</div> </div>
<div v-if="avatars.length" class="avatar-list"> <div v-if="avatars.length" class="avatar-list">
<div class="avatar-card" v-for="a in avatars" :key="a.id"> <div class="avatar-card" v-for="a in avatars" :key="a.id">
<button class="card-edit" @click="goToEdit(a.id)" :aria-label="`编辑${a.displayName || a.name}`">
<span aria-hidden="true">✎</span> 编辑
</button>
<div class="avatar-card-main"> <div class="avatar-card-main">
<div class="avatar-photo"> <div class="avatar-photo">
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" /> <img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
@@ -52,13 +57,14 @@
<div class="avatar-details"> <div class="avatar-details">
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div> <div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p> <p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} 积分</span>
</div> </div>
</div> </div>
<div class="avatar-actions"> <div class="avatar-actions">
<button class="chat-link" @click="goToChat(a.id)"><span>💬</span> 对话</button> <button class="chat-link" @click="goToChat(a.id)"><span>💬</span> 对话</button>
<button class="knowledge-link" @click="goToKnowledge(a.id)"><span>📚</span> 知识库</button>
<button class="authorization-link" @click="goToAuthorization(a.id)"><span>🔑</span> 授权</button>
<button class="share-link" @click="shareAvatar(a)"><span>↗</span> 分享</button> <button class="share-link" @click="shareAvatar(a)"><span>↗</span> 分享</button>
<button class="edit-link" @click="goToEdit(a.id)">编辑</button>
<button class="del-link" @click="askDelete(a)" aria-label="删除分身">删除</button>
</div> </div>
</div> </div>
</div> </div>
@@ -72,88 +78,7 @@
<p v-if="shareToast" class="share-toast">{{ shareToast }}</p> <p v-if="shareToast" class="share-toast">{{ shareToast }}</p>
<!-- 分身工具入口 --> <!-- 分身工具和分身动态暂不展示,入口统一收拢到每张分身卡片。 -->
<section class="tools-section">
<h3 class="section-title">分身工具</h3>
<div class="tools-grid">
<div class="tool-card" @click="goToKnowledge">
<div class="tool-icon">📚</div>
<div class="tool-info">
<span class="tool-name">知识库管理</span>
<span class="tool-desc">上传文档与标准问答</span>
</div>
<span class="tool-arrow">›</span>
</div>
<div class="tool-card" @click="goToAvatarCard">
<div class="tool-icon">🪪</div>
<div class="tool-info">
<span class="tool-name">分身名片</span>
<span class="tool-desc">生成并分享名片</span>
</div>
<span class="tool-arrow">›</span>
</div>
<div class="tool-card" @click="goToAvatarContacts">
<div class="tool-icon">🤝</div>
<div class="tool-info">
<span class="tool-name">分身人脉</span>
<span class="tool-desc">管理社交关系</span>
</div>
<span class="tool-arrow">›</span>
</div>
<div class="tool-card" @click="goToMyProjects">
<div class="tool-icon">📁</div>
<div class="tool-info">
<span class="tool-name">我的项目</span>
<span class="tool-desc">查看参与项目</span>
</div>
<span class="tool-arrow">›</span>
</div>
<div class="tool-card" @click="goToCreateOrg">
<div class="tool-icon">🏢</div>
<div class="tool-info">
<span class="tool-name">创建组织</span>
<span class="tool-desc">新建组织团队</span>
</div>
<span class="tool-arrow">›</span>
</div>
</div>
</section>
<!-- 分身动态列表 -->
<section class="activities-section">
<h3 class="section-title">分身动态</h3>
<div class="activity-list" v-if="activities.length > 0">
<div class="activity-item" v-for="activity in activities" :key="activity.id">
<div class="activity-icon" :class="activity.type">{{ activityIcon(activity.type) }}</div>
<div class="activity-content">
<p class="activity-text">{{ activity.text }}</p>
<span class="activity-time">{{ formatTime(activity.createdAt) }}</span>
</div>
</div>
</div>
<div class="empty-state" v-else>
<span class="empty-icon">📭</span>
<p class="empty-text">暂无动态</p>
</div>
</section>
<!-- 删除确认弹窗 -->
<div v-if="showDelete" class="modal-mask" @click.self="cancelDelete">
<div class="modal">
<div class="modal-icon">⚠️</div>
<h3 class="modal-title">删除数字分身</h3>
<p class="modal-text">
确认删除「{{ pendingDelete?.displayName || pendingDelete?.name }}」?<br />
其知识库、问答对、授权等关联数据将一并清除,且<b>不可恢复</b>。
</p>
<div class="modal-actions">
<button class="modal-cancel" @click="cancelDelete">取消</button>
<button class="modal-confirm" :disabled="deleting" @click="confirmDelete">
{{ deleting ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -163,30 +88,27 @@ import { useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { createAvatarShareLink } from '@/api' import { createAvatarShareLink } from '@/api'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter() const router = useRouter()
const avatarStore = useAvatarStore() const avatarStore = useAvatarStore()
const userStore = useUserStore() const userStore = useUserStore()
const isEmbedded = isHuihuiEmbeddedMode()
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = false
// 当前登录会会用户的资料(头像 / 昵称) // 当前登录会会用户的资料(头像 / 昵称)
const me = computed(() => userStore.user) const me = computed(() => userStore.user)
// 状态(来自 store / 后端) // 状态(来自 store / 后端)
const tokenBalance = computed(() => avatarStore.tokenBalance) const tokenBalance = computed(() => avatarStore.tokenBalance)
const tokenConsumed = computed(() => avatarStore.tokenConsumed)
const avatars = computed(() => avatarStore.avatars) const avatars = computed(() => avatarStore.avatars)
const avatarTokenUsage = (id: string) => avatarStore.tokenUsageByAvatar[id] || 0
// 删除确认弹窗状态
const showDelete = ref(false)
const pendingDelete = ref<any>(null)
const deleting = ref(false)
const shareToast = ref('') const shareToast = ref('')
const activities = ref<Array<{ id: string; type: string; text: string; createdAt: string }>>([
{ id: '1', type: 'create', text: '数字分身创建成功', createdAt: new Date(Date.now() - 86400000).toISOString() },
{ id: '2', type: 'edit', text: '更新了分身描述', createdAt: new Date(Date.now() - 43200000).toISOString() },
{ id: '3', type: 'authorize', text: '授权微信小程序访问', createdAt: new Date(Date.now() - 3600000).toISOString() }
])
// 状态文本 // 状态文本
const statusText = (status: string) => { const statusText = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -197,55 +119,6 @@ const statusText = (status: string) => {
return map[status] || status return map[status] || status
} }
// 活动图标
const activityIcon = (type: string) => {
const map: Record<string, string> = {
'create': '✨',
'edit': '✏️',
'authorize': '🔑',
'interact': '💬'
}
return map[type] || '📌'
}
// 格式化时间
const formatTime = (time: string) => {
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
return `${days}天前`
}
// 删除流程
const askDelete = (a: any) => {
pendingDelete.value = a
showDelete.value = true
}
const cancelDelete = () => {
if (deleting.value) return
showDelete.value = false
pendingDelete.value = null
}
const confirmDelete = async () => {
if (!pendingDelete.value || deleting.value) return
deleting.value = true
try {
await avatarStore.removeAvatar(pendingDelete.value.id)
showDelete.value = false
pendingDelete.value = null
} catch (e: any) {
alert(e?.message || '删除失败,请稍后重试')
} finally {
deleting.value = false
}
}
// 导航 // 导航
const goBack = () => { const goBack = () => {
router.back() router.back()
@@ -259,8 +132,12 @@ const goCreate = () => {
router.push('/avatar/create') router.push('/avatar/create')
} }
const goToKnowledge = () => { const goToKnowledge = (id: string) => {
router.push('/knowledge') router.push({ name: 'AvatarKnowledgeManage', params: { avatarId: id } })
}
const goToAuthorization = (id: string) => {
router.push({ name: 'AvatarAuthorizationManage', params: { avatarId: id } })
} }
const goToEdit = (id: string) => { const goToEdit = (id: string) => {
@@ -303,26 +180,11 @@ const shareAvatar = async (avatar: any) => {
} }
} }
const goToAvatarCard = () => {
router.push('/avatar/card')
}
const goToAvatarContacts = () => {
router.push('/avatar/contacts')
}
const goToMyProjects = () => {
router.push('/avatar/projects')
}
const goToCreateOrg = () => {
router.push('/avatar/org/create')
}
onMounted(() => { onMounted(() => {
userStore.loadFromStorage() userStore.loadFromStorage()
avatarStore.loadAvatars() avatarStore.loadAvatars()
avatarStore.loadTokenBalance() avatarStore.loadTokenBalance()
avatarStore.loadTokenUsage()
}) })
</script> </script>
@@ -330,7 +192,7 @@ onMounted(() => {
.avatar-manage-page { .avatar-manage-page {
min-height: 100vh; min-height: 100vh;
background: #F8F9FA; background: #F8F9FA;
padding-bottom: 80px; padding-bottom: calc(28px + env(safe-area-inset-bottom));
} }
/* 顶部导航 */ /* 顶部导航 */
@@ -433,7 +295,7 @@ onMounted(() => {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* Token 余额条 */ /* 积分余额条 */
.token-section { .token-section {
padding: 16px 20px; padding: 16px 20px;
} }
@@ -465,6 +327,12 @@ onMounted(() => {
color: #F97316; color: #F97316;
} }
.token-used {
margin-top: 3px;
color: #A0A5B4;
font-size: 11px;
}
.recharge-btn { .recharge-btn {
padding: 8px 16px; padding: 8px 16px;
background: #F97316; background: #F97316;
@@ -489,10 +357,36 @@ onMounted(() => {
.section-head { .section-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
gap: 12px;
margin: 8px 0 12px; margin: 8px 0 12px;
} }
.section-heading-copy {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.section-create-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 8px 12px;
border: 1px solid #FED7B5;
border-radius: 999px;
color: #E9650C;
background: #FFF7ED;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.section-create-btn span { font-size: 17px; line-height: 1; }
.section-create-btn:active { background: #FFEDD5; }
.section-title { .section-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@@ -516,13 +410,32 @@ onMounted(() => {
} }
.avatar-card { .avatar-card {
position: relative;
padding: 16px; padding: 16px;
background: white; background: white;
border: 1px solid #F4E5D9; border: 1px solid #F4E5D9;
border-radius: 18px; border-radius: 18px;
box-shadow: 0 8px 22px rgba(112, 62, 22, .07); 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 { .avatar-photo {
width: 66px; width: 66px;
@@ -570,6 +483,13 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.avatar-token-usage {
display: inline-block;
margin-top: 5px;
color: #A0A5B4;
font-size: 10px;
}
.avatar-status { .avatar-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -598,56 +518,39 @@ onMounted(() => {
} }
.avatar-actions { .avatar-actions {
display: flex; display: grid;
align-items: center; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px; gap: 8px;
margin-top: 16px; 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 { .chat-link {
flex: 1;
padding: 10px 8px;
background: linear-gradient(135deg, #F97316, #FB923C); background: linear-gradient(135deg, #F97316, #FB923C);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 10px;
font-size: 13px;
cursor: pointer;
} }
.chat-link span, .share-link span { margin-right: 3px; } .knowledge-link { border: 1px solid #F5D7B8; color: #A85418; background: #FFF8F1; }
.share-link { flex: 1; padding: 10px 8px; border: 1px solid #FFD5AF; border-radius: 10px; color: #C15F18; background: #FFF8F1; font-size: 13px; cursor: pointer; } .authorization-link { border: 1px solid #DCE5F2; color: #47627D; background: #F6F9FC; }
.share-link { border: 1px solid #E5E1DD; color: #675E58; background: #FAF8F6; }
.edit-link { .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; }
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;
}
/* 空状态 */ /* 空状态 */
.empty-state { .empty-state {
@@ -684,197 +587,4 @@ onMounted(() => {
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3); box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
} }
/* 分身工具入口 */
.tools-section {
padding: 8px 20px 16px;
}
.tools-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.tool-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.tool-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.tool-icon {
font-size: 24px;
flex-shrink: 0;
}
.tool-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tool-name {
font-size: 14px;
font-weight: 600;
color: #18191C;
}
.tool-desc {
font-size: 12px;
color: #9398AE;
}
.tool-arrow {
font-size: 18px;
color: #C9CDD2;
flex-shrink: 0;
}
/* 分身动态列表 */
.activities-section {
padding: 0 20px;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.activity-icon {
font-size: 20px;
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #FFF0E6;
}
.activity-content {
flex: 1;
min-width: 0;
}
.activity-text {
font-size: 14px;
color: #18191C;
margin: 0 0 4px;
line-height: 1.4;
}
.activity-time {
font-size: 12px;
color: #9398AE;
}
/* 删除确认弹窗 */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 50;
animation: fade 0.2s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.modal {
width: 100%;
max-width: 320px;
background: white;
border-radius: 18px;
padding: 24px 22px 18px;
text-align: center;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
animation: pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes pop { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: none; } }
.modal-icon {
font-size: 36px;
margin-bottom: 8px;
}
.modal-title {
font-size: 17px;
font-weight: 700;
color: #18191C;
margin: 0 0 10px;
}
.modal-text {
font-size: 13px;
line-height: 1.6;
color: #6B7280;
margin: 0 0 20px;
}
.modal-actions {
display: flex;
gap: 12px;
}
.modal-cancel {
flex: 1;
padding: 12px;
background: #F3F4F6;
color: #6B7280;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.modal-confirm {
flex: 1;
padding: 12px;
background: #EF4444;
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.modal-confirm:hover {
opacity: 0.92;
}
.modal-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>
@@ -1,7 +1,7 @@
<template> <template>
<div class="knowledge-page"> <div class="knowledge-page" :class="{ embedded: isEmbedded }">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header v-if="!isEmbedded" class="page-header">
<div class="header-left"> <div class="header-left">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">知识库管理</h1> <h1 class="page-title">知识库管理</h1>
@@ -23,7 +23,7 @@
<div class="upload-section"> <div class="upload-section">
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop"> <div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
<div class="upload-icon">📥</div> <div class="upload-icon">📥</div>
<p class="upload-title">拖拽文件到此处,或<span class="upload-link">点击上传</span></p> <p class="upload-title"><span class="upload-link">点击上传</span></p>
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p> <p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" /> <input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
</div> </div>
@@ -37,10 +37,10 @@
<div class="card-content"> <div class="card-content">
<div class="card-title-row"> <div class="card-title-row">
<strong>{{ doc.filename }}</strong> <strong>{{ doc.filename }}</strong>
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span> <span class="status-pill" :class="documentState(doc).tone">{{ documentState(doc).label }}</span>
</div> </div>
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p> <p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p> <p class="card-detail">{{ documentState(doc).detail }}</p>
</div> </div>
<button class="card-delete" @click="removeDoc(doc.id)">删除</button> <button class="card-delete" @click="removeDoc(doc.id)">删除</button>
</article> </article>
@@ -79,9 +79,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js' import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
import { import {
getKnowledgeDocs, getKnowledgeDocs,
uploadKnowledgeDoc, uploadKnowledgeDoc,
@@ -93,9 +94,11 @@ import {
} from '@/api' } from '@/api'
const router = useRouter() const router = useRouter()
const route = useRoute()
const store = useAvatarStore() const store = useAvatarStore()
const isEmbedded = isHuihuiEmbeddedMode()
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars)) const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
const activeTab = ref<'docs' | 'qa'>('docs') const activeTab = ref<'docs' | 'qa'>('docs')
const docs = ref<any[]>([]) const docs = ref<any[]>([])
@@ -110,6 +113,19 @@ const searching = ref(false)
const searched = ref(false) const searched = ref(false)
const searchResults = ref<any[]>([]) const searchResults = ref<any[]>([])
const documentState = (doc: any) => {
if (doc.filePresent === false) {
return { tone: 'missing', label: '文件缺失', detail: '原文件不可用,请删除后重新上传' }
}
if (doc.vectorized) {
return { tone: 'ready', label: '已入库', detail: `已切分 ${doc.chunkCount || 0} 段,可用于对话` }
}
if (['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())) {
return { tone: 'pending', label: '处理中', detail: '正在解析并建立知识索引' }
}
return { tone: 'failed', label: '处理失败', detail: '未能建立知识索引,请删除后重新上传' }
}
const loadDocs = async () => { const loadDocs = async () => {
if (!avatarId.value) return if (!avatarId.value) return
try { try {
@@ -200,9 +216,21 @@ const toggleQa = async (qa: any, e: Event) => {
} }
} }
const goAddQa = () => router.push('/knowledge/qa/create') const goAddQa = () => {
if (route.params.avatarId && avatarId.value) {
router.push({ name: 'AvatarQaPairCreate', params: { avatarId: avatarId.value } })
return
}
router.push('/knowledge/qa/create')
}
const goEditQa = (qa: any) => router.push(`/knowledge/qa/${qa.id}/edit`) const goEditQa = (qa: any) => {
if (route.params.avatarId && avatarId.value) {
router.push({ name: 'AvatarQaPairEdit', params: { avatarId: avatarId.value, qaId: qa.id } })
return
}
router.push(`/knowledge/qa/${qa.id}/edit`)
}
const removeQa = async (id: string) => { const removeQa = async (id: string) => {
if (!avatarId.value) return if (!avatarId.value) return
@@ -226,6 +254,7 @@ onMounted(async () => {
if (!store.avatars.length) { if (!store.avatars.length) {
await store.loadAvatars() await store.loadAvatars()
} }
if (avatarId.value) store.currentAvatarId = avatarId.value
await Promise.all([loadDocs(), loadQA()]) await Promise.all([loadDocs(), loadQA()])
}) })
</script> </script>
@@ -234,7 +263,7 @@ onMounted(async () => {
.knowledge-page { .knowledge-page {
min-height: 100vh; min-height: 100vh;
background: #F8F9FA; background: #F8F9FA;
padding-bottom: 80px; padding-bottom: calc(28px + env(safe-area-inset-bottom));
overflow-x: hidden; overflow-x: hidden;
} }
@@ -269,10 +298,16 @@ onMounted(async () => {
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; } .card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; } .status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
.status-pill.missing { color: #B91C1C; background: #FEF2F2; } .status-pill.missing { color: #B91C1C; background: #FEF2F2; }
.status-pill.failed { color: #B91C1C; background: #FEF2F2; }
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; } .card-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-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; } .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-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-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; } .qa-card-actions { display: flex; gap: 8px; margin-top: 11px; }
@@ -493,6 +528,8 @@ onMounted(async () => {
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; } .knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
.card-content { grid-column: 2; } .card-content { grid-column: 2; }
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; } .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; } .card-title-row { align-items: flex-start; flex-wrap: wrap; gap: 5px 7px; }
.status-pill { order: 2; } .status-pill { order: 2; }
.search-bar { gap: 8px; } .search-bar { gap: 8px; }
+30 -7
View File
@@ -10,10 +10,12 @@
<section class="form-section"> <section class="form-section">
<label class="field-label">问题</label> <label class="field-label">问题</label>
<textarea <textarea
ref="questionInput"
v-model="form.question" v-model="form.question"
class="field-input" class="field-input question-input"
rows="3" rows="1"
placeholder="例如:你们的退款政策是什么?" placeholder="例如:你们的退款政策是什么?"
@input="resizeQuestion"
></textarea> ></textarea>
<label class="field-label">标准答案</label> <label class="field-label">标准答案</label>
@@ -46,23 +48,31 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, nextTick, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAvatarStore } from '@/store/avatar' 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' import { getQAPairs, createQAPair, updateQAPair } from '@/api'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const store = useAvatarStore() 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 qaId = computed(() => (route.params.qaId as string) || null)
const isEdit = computed(() => !!qaId.value) const isEdit = computed(() => !!qaId.value)
const form = reactive({ question: '', answer: '', enabled: true }) const form = reactive({ question: '', answer: '', enabled: true })
const saving = ref(false) const saving = ref(false)
const error = ref('') const error = ref('')
const questionInput = ref<HTMLTextAreaElement | null>(null)
const resizeQuestion = (event?: Event) => {
const element = (event?.target as HTMLTextAreaElement | null) || questionInput.value
if (!element) return
element.style.height = 'auto'
element.style.height = `${element.scrollHeight}px`
}
const goBack = () => router.back() const goBack = () => router.back()
@@ -104,8 +114,11 @@ const save = async () => {
} else { } else {
await createQAPair(avatarId.value, payload) await createQAPair(avatarId.value, payload)
} }
// 保存成功返回知识库管理页 if (route.params.avatarId) {
router.replace('/knowledge') router.replace({ name: 'AvatarKnowledgeManage', params: { avatarId: avatarId.value } })
} else {
router.replace('/knowledge')
}
} catch (e: any) { } catch (e: any) {
error.value = e?.message || '保存失败' error.value = e?.message || '保存失败'
} finally { } finally {
@@ -117,9 +130,12 @@ onMounted(async () => {
if (!store.avatars.length) { if (!store.avatars.length) {
await store.loadAvatars() await store.loadAvatars()
} }
if (avatarId.value) store.currentAvatarId = avatarId.value
if (isEdit.value) { if (isEdit.value) {
await loadForEdit() await loadForEdit()
} }
await nextTick()
resizeQuestion()
}) })
</script> </script>
@@ -190,6 +206,13 @@ onMounted(async () => {
border-color: #F97316; border-color: #F97316;
} }
.question-input {
min-height: 44px;
overflow: hidden;
resize: none;
line-height: 1.55;
}
.switch-row { .switch-row {
display: flex; display: flex;
align-items: center; align-items: center;
+9 -1
View File
@@ -138,7 +138,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, computed, onUnmounted } from 'vue' import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
@@ -161,6 +161,14 @@ const password = ref('')
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
onMounted(() => {
const ssoError = sessionStorage.getItem('hh_sso_error')
if (ssoError) {
errorMsg.value = ssoError
sessionStorage.removeItem('hh_sso_error')
}
})
const counting = ref(false) const counting = ref(false)
const countdown = ref(60) const countdown = ref(60)
let timer: any = null let timer: any = null
+237 -22
View File
@@ -3,7 +3,7 @@
<!-- 顶部导航 --> <!-- 顶部导航 -->
<header class="page-header"> <header class="page-header">
<button class="back-btn" @click="goBack">‹</button> <button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">Token 充值</h1> <h1 class="page-title">积分充值</h1>
<div class="header-right"></div> <div class="header-right"></div>
</header> </header>
@@ -12,7 +12,8 @@
<div class="balance-card"> <div class="balance-card">
<span class="balance-label">当前余额</span> <span class="balance-label">当前余额</span>
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span> <span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
<span class="balance-unit">Token</span> <span class="balance-unit">积分</span>
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} 积分</span>
</div> </div>
</section> </section>
@@ -28,10 +29,10 @@
@click="selectedPlan = plan" @click="selectedPlan = plan"
> >
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div> <div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div> <div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
<div class="plan-unit">Token</div> <div class="plan-unit">积分</div>
<div class="plan-price">¥{{ plan.price }}</div> <div class="plan-price">¥{{ plan.price }}</div>
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div> <div class="plan-desc" v-if="plan.desc">{{ formatPointsCopy(plan.desc) }}</div>
</div> </div>
</div> </div>
</section> </section>
@@ -45,7 +46,7 @@
:class="{ selected: paymentMethod === 'wechat' }" :class="{ selected: paymentMethod === 'wechat' }"
@click="paymentMethod = 'wechat'" @click="paymentMethod = 'wechat'"
> >
<span class="payment-icon">💚</span> <span class="payment-icon wechat-icon"><img :src="wechatPayIcon" alt="" /></span>
<span class="payment-name">微信支付</span> <span class="payment-name">微信支付</span>
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span> <span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
</div> </div>
@@ -54,36 +55,47 @@
:class="{ selected: paymentMethod === 'alipay' }" :class="{ selected: paymentMethod === 'alipay' }"
@click="paymentMethod = 'alipay'" @click="paymentMethod = 'alipay'"
> >
<span class="payment-icon">💙</span> <span class="payment-icon alipay-icon"><img :src="alipayIcon" alt="" /></span>
<span class="payment-name">支付宝</span> <span class="payment-name">支付宝</span>
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span> <span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
</div> </div>
</div> </div>
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
</section> </section>
<!-- 充值按钮 --> <!-- 充值按钮 -->
<section class="checkout-section"> <section class="checkout-section">
<button <button
class="checkout-btn" class="checkout-btn"
:class="{ disabled: !selectedPlan }" :class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
:disabled="!selectedPlan" :disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
@click="doCharge" @click="doCharge"
> >
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }} {{ checkoutLabel }}
</button> </button>
</section> </section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api' import {
chargeToken,
getRechargePlans,
getTokenBalance,
getTokenPaymentStatus,
type TokenPaymentOrder
} from '@/api'
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
import wechatPayIcon from '@/assets/wechat-pay.svg'
import alipayIcon from '@/assets/alipay.svg'
const router = useRouter() const router = useRouter()
// 当前余额 // 当前余额
const currentBalance = ref<number>(1250) const currentBalance = ref<number>(0)
const totalConsumed = ref<number>(0)
// 充值套餐 // 充值套餐
const plans = ref<Array<{ const plans = ref<Array<{
@@ -99,12 +111,19 @@ const selectedPlan = ref<any>(null)
// 支付方式 // 支付方式
const paymentMethod = ref<'wechat' | 'alipay'>('wechat') const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
const paymentNotice = ref('')
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
let pollTimer: number | undefined
let pollDeadline = 0
let removeNativeListener: (() => void) | undefined
// 从后端加载余额与套餐 // 从后端加载余额与套餐
const loadData = async () => { const loadData = async () => {
try { try {
const b: any = await getTokenBalance() const b: any = await getTokenBalance()
currentBalance.value = b?.balance ?? 0 currentBalance.value = b?.balance ?? 0
totalConsumed.value = b?.totalConsumed ?? 0
} catch (e) { } catch (e) {
console.error('加载余额失败', e) console.error('加载余额失败', e)
} }
@@ -116,18 +135,144 @@ const loadData = async () => {
} }
} }
// 执行充值(写入后端) // 会会支付订单创建与到账确认
const charging = ref(false) const charging = ref(false)
const formatTokenAmount = (amount: number) => {
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
return amount.toLocaleString()
}
const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分')
const checkoutLabel = computed(() => {
if (!selectedPlan.value) return '请选择充值套餐'
if (charging.value) return '正在创建支付订单…'
if (pendingOrderId.value) return '等待支付结果…'
return `立即支付 ¥${selectedPlan.value.price}`
})
const payScene = () => {
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'APP' as const
}
const parsePayMessage = (message: string) => {
const text = (message || '').trim()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
const findPaymentUrl = (value: any): string => {
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
if (!value || typeof value !== 'object') return ''
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
}
return ''
}
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
const invoke = () => {
const bridge = (window as any).WeixinJSBridge
if (!bridge?.invoke) {
reject(new Error('当前微信环境无法拉起支付'))
return
}
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
const message = String(result?.err_msg || result?.errMsg || '')
if (/\bok\b/i.test(message)) resolve()
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
})
}
if ((window as any).WeixinJSBridge) invoke()
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
})
const launchPayment = async (order: TokenPaymentOrder) => {
const parsed = parsePayMessage(order.payMessage)
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
await invokeWechatJsapi(parsed)
return
}
if (isInUniWebView() && UniEvents.payment({
orderId: order.id,
orderNo: order.orderNo,
paymentMethod: order.paymentMethod,
payType: order.payType,
payWay: order.payWay,
payMessage: order.payMessage,
paymentParams: parsed
})) return
const paymentUrl = findPaymentUrl(parsed)
if (paymentUrl) {
window.location.assign(paymentUrl)
return
}
throw new Error('请在会会 App 内完成支付')
}
const clearPendingOrder = () => {
pendingOrderId.value = ''
sessionStorage.removeItem('hh_pending_payment_order')
if (pollTimer) window.clearTimeout(pollTimer)
pollTimer = undefined
}
const pollPayment = async () => {
if (!pendingOrderId.value) return
try {
const order = await getTokenPaymentStatus(pendingOrderId.value)
if (order.status === 'paid') {
currentBalance.value = order.balance
paymentNoticeTone.value = 'success'
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
clearPendingOrder()
return
}
if (order.status === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
clearPendingOrder()
return
}
} catch (error) {
console.error('查询支付结果失败', error)
}
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
else {
paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
clearPendingOrder()
}
}
const startPolling = (orderId: string) => {
pendingOrderId.value = orderId
sessionStorage.setItem('hh_pending_payment_order', orderId)
pollDeadline = Date.now() + 2 * 60 * 1000
if (pollTimer) window.clearTimeout(pollTimer)
void pollPayment()
}
const doCharge = async () => { const doCharge = async () => {
if (!selectedPlan.value || charging.value) return if (!selectedPlan.value || charging.value) return
charging.value = true charging.value = true
paymentNotice.value = ''
try { try {
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝' const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
const res: any = await chargeToken(selectedPlan.value.id) if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
currentBalance.value = res?.balance ?? currentBalance.value paymentNoticeTone.value = 'pending'
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`) paymentNotice.value = '支付订单已创建,正在等待付款结果'
} catch (e) { startPolling(order.id)
alert('充值失败,请重试') await launchPayment(order)
} catch (error: any) {
paymentNoticeTone.value = 'error'
paymentNotice.value = error?.message || '充值失败,请重试'
clearPendingOrder()
} finally { } finally {
charging.value = false charging.value = false
} }
@@ -139,7 +284,33 @@ const goBack = () => {
} }
onMounted(() => { onMounted(() => {
loadData() void loadData()
removeNativeListener = onNativeMessage((message) => {
if (message?.type !== 'paymentResult') return
if (message.orderId && message.orderId !== pendingOrderId.value) return
if (['cancelled', 'canceled'].includes(String(message.status).toLowerCase())) {
paymentNoticeTone.value = 'error'
paymentNotice.value = '支付已取消'
clearPendingOrder()
return
}
if (String(message.status).toLowerCase() === 'failed') {
paymentNoticeTone.value = 'error'
paymentNotice.value = message.message || '支付未完成,请重新发起'
clearPendingOrder()
return
}
void pollPayment()
})
if (pendingOrderId.value) {
paymentNotice.value = '正在确认上一笔支付结果'
startPolling(pendingOrderId.value)
}
})
onUnmounted(() => {
if (pollTimer) window.clearTimeout(pollTimer)
removeNativeListener?.()
}) })
</script> </script>
@@ -187,6 +358,7 @@ onMounted(() => {
.balance-card { .balance-card {
display: flex; display: flex;
flex-wrap: wrap;
align-items: baseline; align-items: baseline;
gap: 8px; gap: 8px;
padding: 20px; padding: 20px;
@@ -211,6 +383,12 @@ onMounted(() => {
opacity: 0.9; opacity: 0.9;
} }
.balance-used {
flex-basis: 100%;
font-size: 12px;
opacity: 0.82;
}
/* 充值套餐 */ /* 充值套餐 */
.plans-section { .plans-section {
padding: 0 20px 20px; padding: 0 20px 20px;
@@ -319,7 +497,24 @@ onMounted(() => {
} }
.payment-icon { .payment-icon {
font-size: 24px; width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 11px;
}
.payment-icon img {
width: 25px;
height: 25px;
}
.wechat-icon {
background: #EAF9F0;
}
.alipay-icon {
background: #EAF3FF;
} }
.payment-name { .payment-name {
@@ -342,6 +537,26 @@ onMounted(() => {
font-weight: 600; font-weight: 600;
} }
.payment-notice {
margin: 12px 2px 0;
padding: 10px 12px;
border-radius: 10px;
color: #9A5B16;
background: #FFF5E8;
font-size: 13px;
line-height: 1.5;
}
.payment-notice.success {
color: #08783E;
background: #EAF9F0;
}
.payment-notice.error {
color: #B42318;
background: #FFF0EE;
}
/* 充值按钮 */ /* 充值按钮 */
.checkout-section { .checkout-section {
padding: 0 20px; padding: 0 20px;
+1
View File
@@ -17,6 +17,7 @@ services:
- REDIS_PORT=6379 - REDIS_PORT=6379
- SECRET_KEY=your-secret-key-change-in-production - SECRET_KEY=your-secret-key-change-in-production
- AES_KEY=your-aes-key-32-chars-change-now! - AES_KEY=your-aes-key-32-chars-change-now!
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- AVATAR_DB_PATH=/app/avatar.db - AVATAR_DB_PATH=/app/avatar.db
volumes: volumes:
+1
View File
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS `ai_model_configs` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`model_name` varchar(64) NOT NULL COMMENT '模型名称', `model_name` varchar(64) NOT NULL COMMENT '模型名称',
`provider` varchar(32) NOT NULL COMMENT 'openai/zhipu/wenxin/qianwen/local', `provider` varchar(32) NOT NULL COMMENT 'openai/zhipu/wenxin/qianwen/local',
`usage_scope` varchar(16) NOT NULL DEFAULT 'general' COMMENT '用途:general/digital_avatar',
`api_base_url` varchar(256) DEFAULT NULL COMMENT 'API地址', `api_base_url` varchar(256) DEFAULT NULL COMMENT 'API地址',
`api_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key', `api_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key',
`model_version` varchar(64) DEFAULT NULL COMMENT '模型版本', `model_version` varchar(64) DEFAULT NULL COMMENT '模型版本',
+2 -1
View File
@@ -6,7 +6,8 @@ RUN npm install
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM nginx:alpine # Nginx 1.31 uses syscalls that are blocked by the test server's legacy kernel.
FROM nginx:1.28.3-alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
+16 -4
View File
@@ -15,6 +15,9 @@
<span class="model-title">{{ m.model_name }}</span> <span class="model-title">{{ m.model_name }}</span>
</div> </div>
<div style="display:flex;gap:6px;align-items:center"> <div style="display:flex;gap:6px;align-items:center">
<el-tag :type="m.usage_scope === 'digital_avatar' ? 'warning' : 'info'" size="small">
{{ scopeLabels[m.usage_scope] || '通用业务' }}
</el-tag>
<el-tag v-if="m.is_default" type="success" size="small">默认</el-tag> <el-tag v-if="m.is_default" type="success" size="small">默认</el-tag>
<el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag> <el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag>
</div> </div>
@@ -49,6 +52,13 @@
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" /> <el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="使用场景" prop="usage_scope">
<el-radio-group v-model="form.usage_scope">
<el-radio-button value="general">通用业务</el-radio-button>
<el-radio-button value="digital_avatar">数字分身专用</el-radio-button>
</el-radio-group>
<div class="scope-tip">数字分身专用模型仅用于分身对话和主动接管回复</div>
</el-form-item>
<el-form-item label="API地址"> <el-form-item label="API地址">
<el-input v-model="form.api_base_url" placeholder="留空使用默认地址" /> <el-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
</el-form-item> </el-form-item>
@@ -131,8 +141,9 @@ const testResult = ref(null)
const testing = ref(false) const testing = ref(false)
const providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' } const providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' }
const form = reactive({ model_name: '', provider: 'openai', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 }) const scopeLabels = { general: '通用业务', digital_avatar: '数字分身专用' }
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }] } const form = reactive({ model_name: '', provider: 'openai', usage_scope: 'general', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }], usage_scope: [{ required: true }] }
async function load() { async function load() {
const res = await getAIModels() const res = await getAIModels()
@@ -155,13 +166,13 @@ function onProviderChange(provider) {
function openCreate() { function openCreate() {
editModel.value = null editModel.value = null
Object.assign(form, { model_name: '', provider: 'openai', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 }) Object.assign(form, { model_name: '', provider: 'openai', usage_scope: 'general', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
dialogVisible.value = true dialogVisible.value = true
} }
function openEdit(m) { function openEdit(m) {
editModel.value = m editModel.value = m
Object.assign(form, { model_name: m.model_name, provider: m.provider, api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default }) Object.assign(form, { model_name: m.model_name, provider: m.provider, usage_scope: m.usage_scope || 'general', api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -236,4 +247,5 @@ onMounted(load)
.result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } .result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; } .result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
.empty-state { grid-column: 1/-1; padding: 40px; } .empty-state { grid-column: 1/-1; padding: 40px; }
.scope-tip { margin-top: 6px; color: var(--color-text-muted); font-size: 12px; line-height: 1.5; }
</style> </style>
+4
View File
@@ -54,6 +54,7 @@ H5 引入 uniapp web-view bridge 后调用:
| `needLogin` | — | token 失效,请求壳重新登录 | | `needLogin` | — | token 失效,请求壳重新登录 |
| `setTitle` | `title` | 设置原生导航栏标题 | | `setTitle` | `title` | 设置原生导航栏标题 |
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) | | `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
| `payment` | `payment` | 拉起会会原生支付;包含 `orderId/orderNo/payType/payWay/payMessage/paymentParams` |
| `back` | — | 请求原生返回 | | `back` | — | 请求原生返回 |
### 2.3 原生 → H5(壳主动推送) ### 2.3 原生 → H5(壳主动推送)
@@ -63,9 +64,12 @@ H5 引入 uniapp web-view bridge 后调用:
| `context` | `platform, version` | 注入运行环境信息 | | `context` | `platform, version` | 注入运行环境信息 |
| `tokenRefresh` | `token` | 登录刷新后下发新 token | | `tokenRefresh` | `token` | 登录刷新后下发新 token |
| `userUpdate` | `user` | 会会资料变更 | | `userUpdate` | `user` | 会会资料变更 |
| `paymentResult` | `orderId,status` | 原生支付结束通知;`status` 为 `success/cancelled/failed` |
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。 > H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
壳收到 `payment` 后应调用会会 App 已有的微信/支付宝支付能力(或 `uni.requestPayment`),把 `payMessage/paymentParams` 原样交给对应渠道。原生 SDK 返回后再发送 `paymentResult`;H5 不以原生返回作为到账依据,只会轮询本地订单,最终由会会服务端支付回调确认并增加积分。
--- ---
## 3. 项目结构(uni CLI / src 布局,已验证可编译) ## 3. 项目结构(uni CLI / src 布局,已验证可编译)