Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cac96356d | ||
|
|
03c32309a8 | ||
|
|
0fc43908ae | ||
|
|
3d999f9472 | ||
|
|
540edb58c4 | ||
|
|
a7eb6ac2a5 | ||
|
|
016bc22c05 | ||
|
|
094f8cd40f | ||
|
|
6794e88d53 | ||
|
|
7884430b3d | ||
|
|
46d42b7d98 | ||
|
|
ef58c5f2d2 | ||
|
|
6e3fe5a616 | ||
|
|
67b6bd1b48 | ||
|
|
0752001d85 | ||
|
|
c37294be17 | ||
|
|
5360cac8ad | ||
|
|
ea932f27fb | ||
|
|
bf0183bef4 | ||
|
|
0c6419f37e | ||
|
|
f768e7648f | ||
|
|
e30ab2b889 | ||
|
|
730f586784 | ||
|
|
ef2b1c6dd6 | ||
|
|
c693899b12 | ||
|
|
d274ccb5e2 | ||
|
|
5d19992f00 | ||
|
|
81aec1c63a | ||
|
|
4029c31ed7 | ||
|
|
e24e89d326 | ||
|
|
dc34a03357 | ||
|
|
3f7ff9329a | ||
|
|
699bbbde57 | ||
|
|
7a0199e685 | ||
|
|
672019830d | ||
|
|
e720baa21e | ||
|
|
9e86cc64ac | ||
|
|
e2b928273c | ||
|
|
64b7680ec4 | ||
|
|
89f52963b7 | ||
|
|
76bd22c24b | ||
|
|
51a317ccd9 | ||
|
|
08590bf9ea | ||
|
|
25fb8fbee5 | ||
|
|
cfcfe7146e | ||
|
|
2e2adeb9e2 | ||
|
|
25d2494616 | ||
|
|
bd5f64d000 | ||
|
|
350df1d119 | ||
|
|
64462fac92 | ||
|
|
6bf446f889 | ||
|
|
68ea87e1b2 | ||
|
|
c5bfa47a23 | ||
|
|
4ab7732da9 | ||
|
|
4a2d788e85 | ||
|
|
07d4a21379 | ||
|
|
efc419e301 | ||
|
|
c748950402 | ||
|
|
f13d488f82 | ||
|
|
43f2ad9edb | ||
|
|
8e365e63e1 | ||
|
|
dbd668e451 | ||
|
|
190ec48d9c | ||
|
|
0cc43a0d8c | ||
|
|
43bb57eacf | ||
|
|
7327939b4d | ||
|
|
76f0730b57 | ||
|
|
6f6655e791 | ||
|
|
e3cce871b0 | ||
|
|
b7f8ac203b | ||
|
|
a5f7c86a4b | ||
|
|
6026b1279e | ||
|
|
57af1c7db1 | ||
|
|
bbbc97eb4a | ||
|
|
1da5f7f10d | ||
|
|
cc939b6298 | ||
|
|
e3bda469bb | ||
|
|
2ef58a44b8 | ||
|
|
2d9f26a6f0 | ||
|
|
501f548bcc | ||
|
|
cc8af846d5 |
@@ -1,6 +1,6 @@
|
||||
"""API路由汇总"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs
|
||||
from app.api.endpoints import users, interactions, ai_models, dashboard, system, logs, avatars
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -10,3 +10,4 @@ router.include_router(ai_models.router, prefix="/ai-models", tags=["AI模型配
|
||||
router.include_router(dashboard.router, prefix="/dashboard", tags=["数据看板"])
|
||||
router.include_router(system.router, prefix="/system", tags=["系统设置"])
|
||||
router.include_router(logs.router, prefix="/logs", tags=["日志管理"])
|
||||
router.include_router(avatars.router, prefix="/avatars", tags=["数字分身管理"])
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""AI模型配置接口"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
|
||||
from app.models import AIModelConfig
|
||||
from app.utils.crypto import encrypt, decrypt
|
||||
@@ -22,13 +25,20 @@ async def list_models(db=Depends(get_db)):
|
||||
@router.post("")
|
||||
async def create_model(req: AIModelCreateRequest, db=Depends(get_db)):
|
||||
if req.is_default:
|
||||
await db.execute(update(AIModelConfig).values(is_default=0))
|
||||
await db.execute(
|
||||
update(AIModelConfig)
|
||||
.where(AIModelConfig.usage_scope == req.usage_scope)
|
||||
.values(is_default=0)
|
||||
)
|
||||
model = AIModelConfig(
|
||||
model_name=req.model_name,
|
||||
provider=req.provider,
|
||||
usage_scope=req.usage_scope,
|
||||
api_base_url=req.api_base_url,
|
||||
api_key_enc=encrypt(req.api_key) if req.api_key else None,
|
||||
model_version=req.model_version,
|
||||
vision_model_version=req.vision_model_version,
|
||||
ocr_model_version=req.ocr_model_version,
|
||||
temperature=req.temperature,
|
||||
max_tokens=req.max_tokens,
|
||||
timeout_seconds=req.timeout_seconds,
|
||||
@@ -47,8 +57,16 @@ async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
if req.is_default:
|
||||
await db.execute(update(AIModelConfig).where(AIModelConfig.id != model_id).values(is_default=0))
|
||||
target_scope = req.usage_scope or model.usage_scope
|
||||
if req.is_default or (req.usage_scope and model.is_default):
|
||||
await db.execute(
|
||||
update(AIModelConfig)
|
||||
.where(
|
||||
AIModelConfig.id != model_id,
|
||||
AIModelConfig.usage_scope == target_scope,
|
||||
)
|
||||
.values(is_default=0)
|
||||
)
|
||||
for field, val in req.model_dump(exclude_none=True).items():
|
||||
if field == "api_key":
|
||||
model.api_key_enc = encrypt(val) if val else None
|
||||
@@ -59,6 +77,39 @@ async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_
|
||||
return ApiResponse(data=_format_model(model), message="更新成功")
|
||||
|
||||
|
||||
@router.get("/runtime/digital-avatar")
|
||||
async def get_digital_avatar_runtime_model(
|
||||
x_avatar_config_token: str | None = Header(default=None),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
expected = settings.AVATAR_MODEL_CONFIG_TOKEN
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="数字分身模型配置服务未启用")
|
||||
if not x_avatar_config_token or not secrets.compare_digest(x_avatar_config_token, expected):
|
||||
raise HTTPException(status_code=401, detail="无权读取数字分身模型配置")
|
||||
|
||||
result = await db.execute(
|
||||
select(AIModelConfig).where(
|
||||
AIModelConfig.usage_scope == "digital_avatar",
|
||||
AIModelConfig.is_default == 1,
|
||||
AIModelConfig.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="尚未配置启用的数字分身专用模型")
|
||||
return ApiResponse(data={
|
||||
"api_base_url": model.api_base_url or "https://api.openai.com/v1",
|
||||
"api_key": decrypt(model.api_key_enc) if model.api_key_enc else "",
|
||||
"model": model.model_version or model.model_name,
|
||||
"vision_model": model.vision_model_version or "qwen3.6-flash",
|
||||
"ocr_model": model.ocr_model_version or "qwen-vl-ocr",
|
||||
"temperature": model.temperature,
|
||||
"max_tokens": model.max_tokens,
|
||||
"timeout_seconds": model.timeout_seconds,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/{model_id}")
|
||||
async def delete_model(model_id: int, db=Depends(get_db)):
|
||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||
@@ -79,8 +130,11 @@ async def test_model(req: AIModelTestRequest, db=Depends(get_db)):
|
||||
def _format_model(m: AIModelConfig) -> dict:
|
||||
return {
|
||||
"id": m.id, "model_name": m.model_name, "provider": m.provider,
|
||||
"usage_scope": m.usage_scope,
|
||||
"api_base_url": m.api_base_url, "has_api_key": bool(m.api_key_enc),
|
||||
"model_version": m.model_version, "temperature": m.temperature,
|
||||
"vision_model_version": m.vision_model_version,
|
||||
"ocr_model_version": m.ocr_model_version,
|
||||
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
|
||||
"is_default": m.is_default, "is_enabled": m.is_enabled,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""数字分身管理 API 端点"""
|
||||
from typing import Optional
|
||||
import mimetypes
|
||||
import uuid
|
||||
from fastapi import APIRouter, Query, HTTPException, UploadFile, File
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.schemas import ApiResponse
|
||||
from app.services.avatar_service import avatar_service, get_session, is_available
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_avatars(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
keyword: str = Query(default=None),
|
||||
status: str = Query(default=None),
|
||||
):
|
||||
"""分页查询所有数字分身"""
|
||||
if not is_available():
|
||||
return ApiResponse(data={"total": 0, "page": page, "page_size": page_size, "items": []})
|
||||
db = get_session()
|
||||
try:
|
||||
total, items = avatar_service.list_avatars(
|
||||
db, page, page_size, keyword, status
|
||||
)
|
||||
return ApiResponse(
|
||||
data={
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{avatar_id}")
|
||||
def get_avatar(avatar_id: str):
|
||||
"""获取单个数字分身详情"""
|
||||
if not is_available():
|
||||
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
|
||||
db = get_session()
|
||||
try:
|
||||
data = avatar_service.get_avatar(db, avatar_id)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="数字分身不存在")
|
||||
return ApiResponse(data=data)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{avatar_id}/status")
|
||||
def update_avatar_status(avatar_id: str, body: dict):
|
||||
"""更新数字分身状态(开机/关机)"""
|
||||
status = body.get("status")
|
||||
if status not in ("active", "inactive", "training"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="status 必须是 active、inactive 或 training",
|
||||
)
|
||||
if not is_available():
|
||||
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
|
||||
db = get_session()
|
||||
try:
|
||||
data = avatar_service.update_status(db, avatar_id, status)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="数字分身不存在")
|
||||
msg = "已开机" if status == "active" else "已关机"
|
||||
return ApiResponse(data=data, message=msg)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _upload_to_filecenter(file_bytes: bytes, filename: str) -> str:
|
||||
"""利用会会平台 filecenter 上传头像,返回 URL"""
|
||||
import httpx
|
||||
import hashlib
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 读取平台配置
|
||||
from sqlalchemy import select
|
||||
from app.models import SystemConfig
|
||||
result = await db.execute(select(SystemConfig))
|
||||
configs = {row.config_key: row.config_value for row in result.scalars().all()}
|
||||
|
||||
cfg = {
|
||||
"appId": configs.get("platform_app_id", ""),
|
||||
"accessId": configs.get("platform_access_id", ""),
|
||||
"accessSecret": configs.get("platform_access_secret", ""),
|
||||
}
|
||||
biz_url = configs.get("news_platform_base_url", "http://192.168.1.200:63120")
|
||||
|
||||
if not cfg["accessSecret"]:
|
||||
raise RuntimeError("平台 accessSecret 未配置")
|
||||
|
||||
# 构建签名参数
|
||||
nonce = str(random.random())[2:][: random.randint(8, 12)]
|
||||
timestamp = datetime.now().strftime("%Y%m%d%I%M%S")
|
||||
sign_params = {
|
||||
"appId": cfg["appId"],
|
||||
"accessId": cfg["accessId"],
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"module": "userInfo",
|
||||
"service": "kccloud",
|
||||
}
|
||||
|
||||
# 计算签名
|
||||
keys = sorted(sign_params.keys())
|
||||
sign_parts = []
|
||||
for k in keys:
|
||||
v = sign_params.get(k)
|
||||
if v and v != "" and v != []:
|
||||
sign_parts.append(f"{k}={v}")
|
||||
sign_str = "&".join(sign_parts) + f"&accessSecret={cfg['accessSecret']}"
|
||||
signature = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
# 构建 filecenter URL
|
||||
filecenter_url = biz_url.replace("/huihuibusiness", "/filecenter")
|
||||
if "/api/" in filecenter_url:
|
||||
filecenter_url = filecenter_url.split("/api/", 1)[0] + "/api/filecenter"
|
||||
else:
|
||||
filecenter_url = filecenter_url.rstrip("/") + "/filecenter"
|
||||
|
||||
# 确定 MIME 类型
|
||||
mime = mimetypes.guess_type(filename)[0] or "image/jpeg"
|
||||
files = {"file": (filename, file_bytes, mime)}
|
||||
|
||||
# 发送请求
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{filecenter_url}/fileUpload",
|
||||
files=files,
|
||||
data={**sign_params, "signature": signature},
|
||||
)
|
||||
d = r.json()
|
||||
if d.get("code") in [0, 200]:
|
||||
url = d.get("data") or d.get("url") or ""
|
||||
if isinstance(url, dict):
|
||||
url = url.get("url") or url.get("path") or ""
|
||||
if not url:
|
||||
raise RuntimeError(f"filecenter 返回空 URL: {d}")
|
||||
return url
|
||||
raise RuntimeError(f"filecenter 上传失败: {d.get('message', '未知错误')}")
|
||||
|
||||
|
||||
@router.post("/{avatar_id}/upload-photo")
|
||||
async def upload_avatar_photo(
|
||||
avatar_id: str,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""上传数字分身头像(通过会会平台 filecenter)"""
|
||||
if not is_available():
|
||||
raise HTTPException(status_code=503, detail="数字分身数据库尚未初始化")
|
||||
|
||||
# 验证文件
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="仅支持图片文件")
|
||||
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > 5 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="头像文件不能超过5MB")
|
||||
|
||||
# 确保文件扩展名正确
|
||||
filename = file.filename or "avatar.jpg"
|
||||
ext = filename.split(".")[-1].lower() if "." in filename else "jpg"
|
||||
if ext not in ("jpg", "jpeg", "png", "gif", "webp"):
|
||||
filename = f"avatar.{ext}"
|
||||
|
||||
try:
|
||||
photo_url = await _upload_to_filecenter(file_bytes, filename)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"头像上传失败: {str(e)}")
|
||||
|
||||
# 更新数据库
|
||||
db = get_session()
|
||||
try:
|
||||
data = avatar_service.get_avatar(db, avatar_id)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="数字分身不存在")
|
||||
|
||||
from sqlalchemy import text
|
||||
db.execute(
|
||||
text("UPDATE avatars SET photo_url = :url, updated_at = datetime('now') WHERE id = :id"),
|
||||
{"url": photo_url, "id": avatar_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 刷新数据
|
||||
updated = avatar_service.get_avatar(db, avatar_id)
|
||||
return ApiResponse(data=updated, message="头像上传成功")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -52,20 +52,45 @@ async def retry_interaction(record_id: int, db=Depends(get_db)):
|
||||
if not user or user.status != 2:
|
||||
raise HTTPException(status_code=400, detail="用户未登录,无法重试")
|
||||
|
||||
success, err = False, "未知类型"
|
||||
success, err, platform_record_id = False, "未知类型", ""
|
||||
if record.interact_type == "comment" and record.content:
|
||||
success, err = await news_service.post_comment(db, user, record.article_id, record.article_title or "", record.content)
|
||||
success, err, platform_record_id = await news_service.post_comment_with_record_id(
|
||||
db, user, record.article_id, record.article_title or "", record.content
|
||||
)
|
||||
elif record.interact_type == "like":
|
||||
success, err = await news_service.like_news(db, user, record.article_id, org_id="", title=record.article_title or "")
|
||||
elif record.interact_type == "collect":
|
||||
success, err = await news_service.collect_news(db, user, record.article_id, title=record.article_title or "")
|
||||
elif record.interact_type == "forward":
|
||||
success, err = await news_service.forward_news(db, user, record.article_id)
|
||||
elif record.interact_type == "reply" and record.content:
|
||||
parent_comment = None
|
||||
if record.parent_comment_id:
|
||||
comments = await news_service.get_comments(db, user, record.article_id or "")
|
||||
parent_comment = next(
|
||||
(
|
||||
c for c in comments
|
||||
if str(c.get("id") or c.get("commentId") or "") == str(record.parent_comment_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not parent_comment:
|
||||
success, err, platform_record_id = False, "未找到被回复的原评论,无法重试回复", ""
|
||||
else:
|
||||
success, err, platform_record_id = await news_service.post_reply_with_record_id(
|
||||
db, user,
|
||||
record.article_id or "",
|
||||
record.parent_comment_id or "",
|
||||
record.content,
|
||||
parent_comment=parent_comment,
|
||||
article_title=record.article_title or "",
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=1 if success else 2,
|
||||
error_msg=None if success else err,
|
||||
platform_record_id=platform_record_id or record.platform_record_id,
|
||||
retry_count=record.retry_count + 1,
|
||||
)
|
||||
)
|
||||
@@ -147,15 +172,69 @@ async def cancel_interaction(record_id: int, db=Depends(get_db)):
|
||||
news_id=record.article_id or "",
|
||||
title=record.article_title or "",
|
||||
)
|
||||
elif record.interact_type in ("comment", "reply"):
|
||||
elif record.interact_type == "comment":
|
||||
comment_id = record.platform_record_id or ""
|
||||
if not comment_id:
|
||||
return ApiResponse(code=400, message="缺少评论ID,无法删除")
|
||||
comment_id, comment_count = await news_service.find_comment_id(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
content=record.content or "",
|
||||
)
|
||||
if comment_id:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
platform_record_id=comment_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
elif comment_count == 0:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=3,
|
||||
error_msg="平台未查询到评论,已标记取消",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="平台未查询到评论,已标记取消")
|
||||
else:
|
||||
return ApiResponse(code=400, message="缺少评论ID,且未能从平台评论列表反查到该评论")
|
||||
ok, err = await news_service.cancel_comment(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
comment_id=comment_id,
|
||||
)
|
||||
elif record.interact_type == "reply":
|
||||
reply_id = record.platform_record_id or ""
|
||||
if not reply_id:
|
||||
reply_id, reply_count = await news_service.find_reply_id(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
content=record.content or "",
|
||||
parent_comment_id=record.parent_comment_id or "",
|
||||
)
|
||||
if reply_id:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
platform_record_id=reply_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
elif reply_count == 0:
|
||||
await db.execute(
|
||||
update(InteractionRecord).where(InteractionRecord.id == record_id).values(
|
||||
status=3,
|
||||
error_msg="平台未查询到回复,已标记取消",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ApiResponse(message="平台未查询到回复,已标记取消")
|
||||
else:
|
||||
return ApiResponse(code=400, message="缺少回复ID,且未能从平台回复列表反查到该回复")
|
||||
ok, err = await news_service.cancel_reply(
|
||||
db, user,
|
||||
news_id=record.article_id or "",
|
||||
reply_id=reply_id,
|
||||
)
|
||||
|
||||
if ok:
|
||||
# 更新状态为手动取消(status=3)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""虚拟用户管理接口"""
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import mimetypes
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import io
|
||||
@@ -9,6 +12,21 @@ from app.schemas import ApiResponse, UserCreateRequest, UserUpdateRequest, UserB
|
||||
from app.services.user_service import user_service
|
||||
|
||||
router = APIRouter()
|
||||
_UPLOADS_DIR = Path(__file__).resolve().parents[2] / "uploads" / "avatars"
|
||||
_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def _save_local_avatar(file_bytes: bytes, filename: str, content_type: str | None) -> str:
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
guessed_ext = mimetypes.guess_extension(content_type or "") or ".jpg"
|
||||
ext = ".jpg" if guessed_ext == ".jpe" else guessed_ext
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
ext = ".jpg"
|
||||
|
||||
target = _UPLOADS_DIR / f"{uuid.uuid4().hex}{ext}"
|
||||
target.write_bytes(file_bytes)
|
||||
return f"/api/uploads/avatars/{target.name}"
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -268,13 +286,20 @@ async def sync_all_profiles(db=Depends(get_db)):
|
||||
sess = await get_session(uid)
|
||||
if not sess:
|
||||
return False
|
||||
ur = await s.execute(select(_VU).where(_VU.id == uid))
|
||||
user = ur.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
platform_uid = sess.get("platform_uid", "")
|
||||
# 登录成功时 session 里已存有用户信息
|
||||
vals = {}
|
||||
if platform_uid: vals["platform_uid"] = platform_uid
|
||||
# session 里的字段(登录时写入)
|
||||
if sess.get("nickname"): vals["nickname"] = sess["nickname"]
|
||||
if sess.get("real_name"): vals["real_name"] = sess["real_name"]
|
||||
sync_nickname = sess.get("nickname", "")
|
||||
sync_real_name = sess.get("real_name", "")
|
||||
resolved_nickname = news_service._resolve_synced_nickname(user, sync_nickname, sync_real_name)
|
||||
if resolved_nickname: vals["nickname"] = resolved_nickname
|
||||
if sync_real_name: vals["real_name"] = sync_real_name
|
||||
if sess.get("sex"): vals["sex"] = int(sess["sex"])
|
||||
if sess.get("avatar"): vals["avatar_url"] = sess["avatar"]
|
||||
if vals:
|
||||
@@ -327,18 +352,19 @@ async def upload_avatar(
|
||||
else:
|
||||
return ApiResponse(code=500, message=f"头像上传到平台失败: {result}")
|
||||
else:
|
||||
# 仅本地存储(转 base64 或存储到本地)
|
||||
import base64
|
||||
avatar_url = f"data:{file.content_type};base64,{base64.b64encode(file_bytes).decode()}"
|
||||
# 未登录用户本地落盘,避免 base64 超过 avatar_url 字段长度
|
||||
avatar_url = await _save_local_avatar(file_bytes, file.filename or "", file.content_type)
|
||||
|
||||
# 更新数据库
|
||||
# 已登录用户必须同时写入会会当前资料和“TA 的主页”。
|
||||
# 任一接口失败都不得返回“头像更新成功”。
|
||||
if sync_to_platform and user.status == 2 and avatar_url:
|
||||
ok, err = await news_service.update_user_profile(db, user, avatar=avatar_url)
|
||||
if not ok:
|
||||
return ApiResponse(code=502, message=f"头像已上传,但同步到会会失败: {err}")
|
||||
else:
|
||||
await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url))
|
||||
await db.commit()
|
||||
|
||||
# 如果已同步到平台,再调用 update_user_profile 更新头像字段
|
||||
if sync_to_platform and user.status == 2 and avatar_url:
|
||||
await news_service.update_user_profile(db, user, avatar=avatar_url)
|
||||
|
||||
return ApiResponse(data={"avatar_url": avatar_url}, message="头像更新成功")
|
||||
@router.post("/logout-all")
|
||||
async def batch_logout_all(db=Depends(get_db)):
|
||||
|
||||
@@ -19,12 +19,19 @@ class Settings(BaseSettings):
|
||||
# 安全
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||
AES_KEY: str = os.getenv("AES_KEY", "your-aes-key-32-chars-change-now!")
|
||||
AVATAR_MODEL_CONFIG_TOKEN: str = os.getenv("AVATAR_MODEL_CONFIG_TOKEN", "")
|
||||
|
||||
# 新闻平台
|
||||
NEWS_PLATFORM_BASE_URL: str = os.getenv(
|
||||
"NEWS_PLATFORM_BASE_URL", "http://192.168.1.200:63120"
|
||||
)
|
||||
|
||||
# 数字分身 SQLite 数据库路径(直连数字分身应用的 SQLite)
|
||||
AVATAR_DB_PATH: str = os.getenv("AVATAR_DB_PATH", "")
|
||||
|
||||
# 数字分身后端服务地址(用于拼接头像等文件 URL)
|
||||
AVATAR_BACKEND_URL: str = os.getenv("AVATAR_BACKEND_URL", "")
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR: str = "/app/logs"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""数据库连接管理"""
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
from app.core.logger import logger
|
||||
@@ -62,8 +63,39 @@ async def init_db():
|
||||
# 导入所有模型类,确保 SQLAlchemy ORM 元数据注册
|
||||
from app.models import (
|
||||
VirtualUser, UserPersonality, InteractionRecord,
|
||||
TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("SELECT GET_LOCK('ai_model_config_migration', 30)"))
|
||||
try:
|
||||
columns = (
|
||||
(
|
||||
"usage_scope",
|
||||
"ALTER TABLE ai_model_configs ADD COLUMN usage_scope "
|
||||
"VARCHAR(16) NOT NULL DEFAULT 'general' AFTER provider",
|
||||
),
|
||||
(
|
||||
"vision_model_version",
|
||||
"ALTER TABLE ai_model_configs ADD COLUMN vision_model_version "
|
||||
"VARCHAR(64) NULL AFTER model_version",
|
||||
),
|
||||
(
|
||||
"ocr_model_version",
|
||||
"ALTER TABLE ai_model_configs ADD COLUMN ocr_model_version "
|
||||
"VARCHAR(64) NULL AFTER vision_model_version",
|
||||
),
|
||||
)
|
||||
for column_name, ddl in columns:
|
||||
result = await conn.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ai_model_configs' "
|
||||
"AND COLUMN_NAME = :column_name"
|
||||
), {"column_name": column_name})
|
||||
if result.scalar_one() == 0:
|
||||
await conn.execute(text(ddl))
|
||||
logger.info("AI模型配置表已增加 %s 字段", column_name)
|
||||
finally:
|
||||
await conn.execute(text("SELECT RELEASE_LOCK('ai_model_config_migration')"))
|
||||
logger.info("✅ 数据库模型注册成功")
|
||||
logger.info("✅ 数据库初始化完成")
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import re as _re
|
||||
from pathlib import Path
|
||||
|
||||
# 修复 Pydantic v2 把 naive datetime 序列化为 +00:00 的问题
|
||||
# 数据库存的是北京时间,应标记为 +08:00
|
||||
@@ -89,6 +91,10 @@ app.add_middleware(
|
||||
# 注册路由
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
uploads_dir = Path(__file__).resolve().parent / "uploads"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/api/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
||||
@@ -75,6 +75,33 @@ class InteractionRecord(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class PendingReplyTask(Base):
|
||||
__tablename__ = "pending_reply_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
actor_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
next_actor_user_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
||||
news_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
news_title: Mapped[str | None] = mapped_column(String(256))
|
||||
article_org_id: Mapped[str | None] = mapped_column(String(64))
|
||||
parent_comment_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
parent_comment: Mapped[dict | None] = mapped_column(JSON)
|
||||
reply_to: Mapped[dict | None] = mapped_column(JSON)
|
||||
root_content: Mapped[str | None] = mapped_column(Text)
|
||||
context: Mapped[str | None] = mapped_column(Text)
|
||||
next_probability: Mapped[float] = mapped_column(Float, default=0.3)
|
||||
next_delay_min_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
next_delay_max_seconds: Mapped[int] = mapped_column(Integer, default=7200)
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0, index=True) # 0待发送 1发送中 2已发送 3失败
|
||||
attempts: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
last_error: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TokenStat(Base):
|
||||
__tablename__ = "token_stats"
|
||||
|
||||
@@ -95,9 +122,12 @@ class AIModelConfig(Base):
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
model_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
usage_scope: Mapped[str] = mapped_column(String(16), nullable=False, default="general")
|
||||
api_base_url: Mapped[str | None] = mapped_column(String(256))
|
||||
api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
||||
model_version: Mapped[str | None] = mapped_column(String(64))
|
||||
vision_model_version: Mapped[str | None] = mapped_column(String(64))
|
||||
ocr_model_version: Mapped[str | None] = mapped_column(String(64))
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.7)
|
||||
max_tokens: Mapped[int] = mapped_column(Integer, default=1000)
|
||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
|
||||
@@ -154,9 +154,12 @@ class InteractionResponse(BaseModel):
|
||||
class AIModelCreateRequest(BaseModel):
|
||||
model_name: str = Field(..., min_length=1, max_length=64)
|
||||
provider: str = Field(..., pattern="^(openai|zhipu|wenxin|qianwen|local)$")
|
||||
usage_scope: str = Field(default="general", pattern="^(general|digital_avatar)$")
|
||||
api_base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model_version: Optional[str] = None
|
||||
vision_model_version: Optional[str] = Field(None, max_length=64)
|
||||
ocr_model_version: Optional[str] = Field(None, max_length=64)
|
||||
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
||||
max_tokens: int = Field(default=1000, ge=1, le=32000)
|
||||
timeout_seconds: int = Field(default=30, ge=5, le=300)
|
||||
@@ -165,9 +168,13 @@ class AIModelCreateRequest(BaseModel):
|
||||
|
||||
class AIModelUpdateRequest(BaseModel):
|
||||
model_name: Optional[str] = None
|
||||
provider: Optional[str] = Field(None, pattern="^(openai|zhipu|wenxin|qianwen|local)$")
|
||||
usage_scope: Optional[str] = Field(None, pattern="^(general|digital_avatar)$")
|
||||
api_base_url: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model_version: Optional[str] = None
|
||||
vision_model_version: Optional[str] = Field(None, max_length=64)
|
||||
ocr_model_version: Optional[str] = Field(None, max_length=64)
|
||||
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
|
||||
max_tokens: Optional[int] = Field(None, ge=1, le=32000)
|
||||
timeout_seconds: Optional[int] = Field(None, ge=5, le=300)
|
||||
@@ -179,9 +186,12 @@ class AIModelResponse(BaseModel):
|
||||
id: int
|
||||
model_name: str
|
||||
provider: str
|
||||
usage_scope: str
|
||||
api_base_url: Optional[str]
|
||||
has_api_key: bool
|
||||
model_version: Optional[str]
|
||||
vision_model_version: Optional[str]
|
||||
ocr_model_version: Optional[str]
|
||||
temperature: float
|
||||
max_tokens: int
|
||||
timeout_seconds: int
|
||||
|
||||
@@ -28,7 +28,9 @@ class AIService:
|
||||
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
|
||||
result = await db.execute(
|
||||
select(AIModelConfig).where(
|
||||
AIModelConfig.is_default == 1, AIModelConfig.is_enabled == 1
|
||||
AIModelConfig.usage_scope == "general",
|
||||
AIModelConfig.is_default == 1,
|
||||
AIModelConfig.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -190,6 +192,32 @@ class AIService:
|
||||
请对上面的评论写一条友善自然的回复,{word_min}~{word_max}字,直接输出回复内容。"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=150)
|
||||
|
||||
async def generate_thread_reply(
|
||||
self, db: AsyncSession, article_title: str, root_comment: str,
|
||||
reply_context: str, personality_prompt: str,
|
||||
word_min: int = 15, word_max: int = 60
|
||||
) -> tuple[str, int]:
|
||||
"""结合文章、原评论和上下文生成评论回复链中的下一句。"""
|
||||
system_prompt = f"""你是一名真实的社区用户。{personality_prompt}
|
||||
|
||||
重要规则:
|
||||
- 回复必须积极正面、文明友善,不含任何敏感违规内容
|
||||
- 要自然接住对方的话,不要机械复述
|
||||
- 不要透露自己是AI或虚拟用户"""
|
||||
prompt = f"""文章标题:{article_title}
|
||||
原评论:{root_comment}
|
||||
当前对话上下文:{reply_context}
|
||||
|
||||
请结合文章、原评论和当前对话,写一条自然的后续回复。
|
||||
要求:
|
||||
1. 字数 {word_min}~{word_max} 字
|
||||
2. 语气像真实用户交流,可以认同、补充或追问
|
||||
3. 必须围绕文章和评论内容,不要跑题
|
||||
4. 只输出回复正文,不要加任何前缀或解释
|
||||
|
||||
回复:"""
|
||||
return await self._call_api(db, prompt, system_prompt, max_tokens=180)
|
||||
|
||||
async def test_model(self, db: AsyncSession, model_id: int, test_prompt: str) -> dict:
|
||||
"""测试模型可用性"""
|
||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""数字分身管理服务层 — 同步连接数字分身应用的 SQLite 数据库"""
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
_engine = None
|
||||
_SessionLocal: Optional[sessionmaker] = None
|
||||
|
||||
|
||||
def _get_engine_and_session():
|
||||
global _engine, _SessionLocal
|
||||
if _engine is None:
|
||||
db_path = settings.AVATAR_DB_PATH
|
||||
if not db_path:
|
||||
# 默认路径:从 backend/app/core/ 向上三级
|
||||
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
db_path = os.path.join(base, "digital-avatar-app", "backend", "avatar.db")
|
||||
if not os.path.isabs(db_path):
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
# 数据库不存在时返回 None,由调用方处理
|
||||
return None, None
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
_SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False)
|
||||
return _engine, _SessionLocal()
|
||||
|
||||
|
||||
def get_session() -> Optional[Session]:
|
||||
_, session = _get_engine_and_session()
|
||||
return session
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""检查数字分身数据库是否可用"""
|
||||
engine, _ = _get_engine_and_session()
|
||||
return engine is not None
|
||||
|
||||
|
||||
def _resolve_photo_url(photo_url: str) -> str:
|
||||
"""将相对路径的头像 URL 补全为绝对路径"""
|
||||
if not photo_url:
|
||||
return ""
|
||||
if photo_url.startswith(("http://", "https://")):
|
||||
return photo_url
|
||||
base = settings.AVATAR_BACKEND_URL
|
||||
if base:
|
||||
base = base.rstrip("/")
|
||||
return f"{base}{photo_url}"
|
||||
return photo_url
|
||||
|
||||
|
||||
def _get_global_token_balance(db: Session) -> int:
|
||||
"""获取全局 token_account 余额(单行表)"""
|
||||
try:
|
||||
result = db.execute(text("SELECT balance FROM token_account LIMIT 1")).fetchone()
|
||||
return result.balance if result and result.balance else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class AvatarService:
|
||||
|
||||
@staticmethod
|
||||
def list_avatars(
|
||||
db: Session,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> Tuple[int, list]:
|
||||
"""分页查询所有数字分身(含归属用户信息)"""
|
||||
where_clauses = []
|
||||
params = {}
|
||||
|
||||
if keyword:
|
||||
where_clauses.append(
|
||||
"(a.name LIKE :kw OR a.display_name LIKE :kw)"
|
||||
)
|
||||
params["kw"] = f"%{keyword}%"
|
||||
|
||||
if status:
|
||||
where_clauses.append("a.status = :status")
|
||||
params["status"] = status
|
||||
|
||||
where_sql = ""
|
||||
if where_clauses:
|
||||
where_sql = "WHERE " + " AND ".join(where_clauses)
|
||||
|
||||
# 计数
|
||||
count_sql = f"SELECT COUNT(*) FROM avatars a {where_sql}"
|
||||
total = db.execute(text(count_sql), params).scalar() or 0
|
||||
|
||||
# 分页查询
|
||||
offset = (page - 1) * page_size
|
||||
params["limit"] = page_size
|
||||
params["offset"] = offset
|
||||
|
||||
query = text(f"""
|
||||
SELECT
|
||||
a.id, a.name, a.display_name, a.description,
|
||||
a.photo_url, a.emoji, a.status, a.token_balance,
|
||||
a.config, a.created_at, a.updated_at,
|
||||
u.nickname AS owner_nickname,
|
||||
u.phone AS owner_phone
|
||||
FROM avatars a
|
||||
LEFT JOIN users u ON a.owner_id = u.huihui_user_id
|
||||
{where_sql}
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
rows = db.execute(query, params).fetchall()
|
||||
|
||||
items = []
|
||||
global_token = _get_global_token_balance(db)
|
||||
for row in rows:
|
||||
config = {}
|
||||
if row.config:
|
||||
if isinstance(row.config, str):
|
||||
import json
|
||||
try:
|
||||
config = json.loads(row.config)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
config = {}
|
||||
elif isinstance(row.config, dict):
|
||||
config = row.config
|
||||
items.append({
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"display_name": row.display_name,
|
||||
"description": row.description or "",
|
||||
"photo_url": _resolve_photo_url(row.photo_url),
|
||||
"emoji": row.emoji or "🤖",
|
||||
"status": row.status or "active",
|
||||
"token_balance": global_token or (row.token_balance or 0),
|
||||
"config": config,
|
||||
"owner_nickname": row.owner_nickname or "",
|
||||
"owner_phone": row.owner_phone or "",
|
||||
"created_at": str(row.created_at) if row.created_at else "",
|
||||
"updated_at": str(row.updated_at) if row.updated_at else "",
|
||||
})
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def get_avatar(db: Session, avatar_id: str) -> Optional[dict]:
|
||||
"""获取单个数字分身详情"""
|
||||
query = text("""
|
||||
SELECT
|
||||
a.id, a.name, a.display_name, a.description,
|
||||
a.photo_url, a.emoji, a.status, a.token_balance,
|
||||
a.config, a.created_at, a.updated_at,
|
||||
u.nickname AS owner_nickname,
|
||||
u.phone AS owner_phone
|
||||
FROM avatars a
|
||||
LEFT JOIN users u ON a.owner_id = u.huihui_user_id
|
||||
WHERE a.id = :avatar_id
|
||||
""")
|
||||
row = db.execute(query, {"avatar_id": avatar_id}).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
config = {}
|
||||
if row.config:
|
||||
if isinstance(row.config, str):
|
||||
import json
|
||||
try:
|
||||
config = json.loads(row.config)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
config = {}
|
||||
elif isinstance(row.config, dict):
|
||||
config = row.config
|
||||
|
||||
global_token = _get_global_token_balance(db)
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"display_name": row.display_name,
|
||||
"description": row.description or "",
|
||||
"photo_url": _resolve_photo_url(row.photo_url),
|
||||
"emoji": row.emoji or "🤖",
|
||||
"status": row.status or "active",
|
||||
"token_balance": global_token or (row.token_balance or 0),
|
||||
"config": config,
|
||||
"owner_nickname": row.owner_nickname or "",
|
||||
"owner_phone": row.owner_phone or "",
|
||||
"created_at": str(row.created_at) if row.created_at else "",
|
||||
"updated_at": str(row.updated_at) if row.updated_at else "",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_status(db: Session, avatar_id: str, status: str) -> Optional[dict]:
|
||||
"""更新数字分身状态(开关机)"""
|
||||
query = text("""
|
||||
UPDATE avatars SET status = :status, updated_at = datetime('now')
|
||||
WHERE id = :avatar_id
|
||||
""")
|
||||
result = db.execute(query, {"status": status, "avatar_id": avatar_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
return AvatarService.get_avatar(db, avatar_id)
|
||||
|
||||
|
||||
avatar_service = AvatarService()
|
||||
@@ -7,7 +7,7 @@ import uuid
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
@@ -45,6 +45,31 @@ class NewsPlatformService:
|
||||
"orgId": await self._cfg(db, "platform_org_id", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _api_root(base_url: str) -> str:
|
||||
if "/api/" in base_url:
|
||||
return base_url.split("/api/", 1)[0] + "/api"
|
||||
return base_url.rstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_synced_nickname(user: VirtualUser, sync_nickname: str, sync_real_name: str) -> str:
|
||||
"""避免登录同步把有意义的本地昵称覆盖回手机号。"""
|
||||
current_nickname = (user.nickname or "").strip()
|
||||
current_real_name = (user.real_name or "").strip()
|
||||
sync_nickname = (sync_nickname or "").strip()
|
||||
sync_real_name = (sync_real_name or "").strip()
|
||||
account = (user.account or "").strip()
|
||||
|
||||
if current_nickname and current_nickname != account:
|
||||
return current_nickname
|
||||
if sync_nickname and sync_nickname != account:
|
||||
return sync_nickname
|
||||
if sync_real_name:
|
||||
return sync_real_name
|
||||
if current_real_name:
|
||||
return current_real_name
|
||||
return sync_nickname or current_nickname or account
|
||||
|
||||
# ─── 签名(完全对应 sign.js 逻辑) ─────────────────────────
|
||||
@staticmethod
|
||||
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
|
||||
@@ -188,6 +213,7 @@ class NewsPlatformService:
|
||||
sync_real_name = user_info.get("realName") or ""
|
||||
sync_sex = int(user_info.get("sex") or 0)
|
||||
sync_avatar = user_info.get("avatar") or ""
|
||||
preferred_nickname = self._resolve_synced_nickname(user, sync_nickname, sync_real_name)
|
||||
|
||||
await set_session(user.id, {
|
||||
"token": access_token,
|
||||
@@ -196,12 +222,46 @@ class NewsPlatformService:
|
||||
"org_id": org_id or cfg.get("orgId", ""),
|
||||
"login_time": datetime.now().isoformat(),
|
||||
# 缓存用户信息供 sync 使用
|
||||
"nickname": sync_nickname,
|
||||
"nickname": preferred_nickname,
|
||||
"real_name": sync_real_name,
|
||||
"sex": sync_sex,
|
||||
"avatar": sync_avatar,
|
||||
}, expire=86400)
|
||||
|
||||
# 导入用户的昵称/头像先保存在本地;首次或后续登录时,
|
||||
# 如果会会端仍是旧值,必须补写“当前资料 + TA 的主页”。
|
||||
desired_nickname = preferred_nickname
|
||||
desired_real_name = (user.real_name or desired_nickname or "").strip()
|
||||
desired_avatar = (user.avatar_url or sync_avatar or "").strip()
|
||||
needs_profile_sync = any([
|
||||
desired_nickname and desired_nickname != sync_nickname,
|
||||
desired_real_name and desired_real_name != sync_real_name,
|
||||
desired_avatar and desired_avatar != sync_avatar,
|
||||
])
|
||||
# usercenter 与 App 的“TA 的主页”不是同一数据源。即使
|
||||
# usercenter 已一致,也必须检查 huihuiuserextend 的主页资料。
|
||||
if not needs_profile_sync:
|
||||
home_ok, home_data = await self.get_huihui_user_home(db, user)
|
||||
needs_profile_sync = (
|
||||
not home_ok
|
||||
or (desired_nickname and home_data.get("name") != desired_nickname)
|
||||
or (desired_avatar and home_data.get("avatar") != desired_avatar)
|
||||
)
|
||||
if needs_profile_sync:
|
||||
ok, err = await self.update_user_profile(
|
||||
db,
|
||||
user,
|
||||
nick_name=desired_nickname or None,
|
||||
real_name=desired_real_name or None,
|
||||
avatar=desired_avatar or None,
|
||||
)
|
||||
if not ok:
|
||||
await delete_session(user.id)
|
||||
raise ValueError(f"会会用户资料同步失败: {err}")
|
||||
sync_nickname = desired_nickname
|
||||
sync_real_name = desired_real_name
|
||||
sync_avatar = desired_avatar
|
||||
|
||||
# 更新本地数据库,同步平台用户信息
|
||||
update_vals = dict(
|
||||
status=2, session_token=access_token,
|
||||
@@ -209,7 +269,7 @@ class NewsPlatformService:
|
||||
last_login_at=datetime.now(),
|
||||
platform_uid=platform_uid,
|
||||
)
|
||||
if sync_nickname: update_vals["nickname"] = sync_nickname
|
||||
if preferred_nickname: update_vals["nickname"] = preferred_nickname
|
||||
if sync_real_name: update_vals["real_name"] = sync_real_name
|
||||
if sync_sex: update_vals["sex"] = sync_sex
|
||||
if sync_avatar: update_vals["avatar_url"] = sync_avatar
|
||||
@@ -719,51 +779,287 @@ class NewsPlatformService:
|
||||
return False
|
||||
|
||||
async def post_comment(self, db, user, news_id, news_title, content, news_author_id="", org_id="") -> tuple[bool, str]:
|
||||
success, err, _ = await self.post_comment_with_record_id(
|
||||
db, user, news_id, news_title, content,
|
||||
news_author_id=news_author_id, org_id=org_id,
|
||||
)
|
||||
return success, err
|
||||
|
||||
async def post_comment_with_record_id(
|
||||
self, db, user, news_id, news_title, content, news_author_id="", org_id=""
|
||||
) -> tuple[bool, str, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
return False, "未登录", ""
|
||||
biz = await self._biz_url(db)
|
||||
cfg = await self._client(db)
|
||||
uid = sess.get("platform_uid", "")
|
||||
# org_id 优先取文章自带的(从广场数据获取),否则取 session/配置
|
||||
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
|
||||
if not final_org_id:
|
||||
final_org_id = await self._get_article_org_id(db, sess["token"], news_id)
|
||||
body = {
|
||||
"module": "news", "topicId": news_id, "title": news_title,
|
||||
"content": content, "orgId": final_org_id,
|
||||
"toUserId": news_author_id or uid, "userId": uid,
|
||||
"userName": user.nickname, "avatar": user.avatar_url or "",
|
||||
}
|
||||
return await self._json_post(f"{biz}/message/comment", self._bearer(sess["token"]), body)
|
||||
success, err, record_id = await self._json_post_with_record_id(
|
||||
f"{biz}/message/comment", self._bearer(sess["token"]), body
|
||||
)
|
||||
if success and not record_id:
|
||||
record_id, _ = await self.find_comment_id(db, user, news_id, content)
|
||||
return success, err, record_id
|
||||
|
||||
async def post_reply(self, db, user, news_id, comment_id, content) -> tuple[bool, str]:
|
||||
async def post_reply(
|
||||
self, db, user, news_id, comment_id, content,
|
||||
parent_comment: dict | None = None,
|
||||
reply_to: dict | None = None,
|
||||
article_title: str = "",
|
||||
org_id: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
success, err, _ = await self.post_reply_with_record_id(
|
||||
db, user, news_id, comment_id, content,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
article_title=article_title,
|
||||
org_id=org_id,
|
||||
)
|
||||
return success, err
|
||||
|
||||
async def post_reply_with_record_id(
|
||||
self, db, user, news_id, comment_id, content,
|
||||
parent_comment: dict | None = None,
|
||||
reply_to: dict | None = None,
|
||||
article_title: str = "",
|
||||
org_id: str = "",
|
||||
) -> tuple[bool, str, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
return False, "未登录", ""
|
||||
biz = await self._biz_url(db)
|
||||
uid = sess.get("platform_uid", "")
|
||||
cfg = await self._client(db)
|
||||
parent_comment = parent_comment or {}
|
||||
reply_to = reply_to or None
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or comment_id or "")
|
||||
parent_user_id = str(
|
||||
parent_comment.get("createUser")
|
||||
or parent_comment.get("userId")
|
||||
or parent_comment.get("commentUserId")
|
||||
or parent_comment.get("fromUserId")
|
||||
or ""
|
||||
)
|
||||
parent_user_name = (
|
||||
parent_comment.get("userName")
|
||||
or parent_comment.get("fromUserName")
|
||||
or parent_comment.get("nickName")
|
||||
or ""
|
||||
)
|
||||
reply_target = reply_to or parent_comment
|
||||
reply_target_id = str(
|
||||
reply_target.get("id")
|
||||
or reply_target.get("replyId")
|
||||
or reply_target.get("commentId")
|
||||
or parent_comment_id
|
||||
)
|
||||
reply_target_user_id = str(
|
||||
reply_target.get("createUser")
|
||||
or reply_target.get("fromUserId")
|
||||
or reply_target.get("userId")
|
||||
or reply_target.get("commentUserId")
|
||||
or parent_user_id
|
||||
or ""
|
||||
)
|
||||
reply_target_user_name = (
|
||||
reply_target.get("fromUserName")
|
||||
or reply_target.get("userName")
|
||||
or reply_target.get("nickName")
|
||||
or parent_user_name
|
||||
or ""
|
||||
)
|
||||
from_user_name = (
|
||||
(user.real_name or "").strip()
|
||||
or (user.nickname or "").strip()
|
||||
or (user.account or "").strip()
|
||||
)
|
||||
final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or ""
|
||||
if not final_org_id:
|
||||
final_org_id = await self._get_article_org_id(db, sess["token"], news_id)
|
||||
body = {
|
||||
"module": "news", "topicId": news_id, "commentId": comment_id,
|
||||
"commentUserId": uid, "content": content,
|
||||
"fromUserName": user.nickname, "avatar": user.avatar_url or "",
|
||||
"module": "news",
|
||||
"modules": "news",
|
||||
"topicId": news_id,
|
||||
"commentId": parent_comment_id,
|
||||
"commentUserId": parent_user_id,
|
||||
"replyId": reply_target_id,
|
||||
"toUserId": reply_target_user_id,
|
||||
"replyType": "2" if reply_to else "1",
|
||||
"toUserName": reply_target_user_name,
|
||||
"content": content,
|
||||
"title": article_title or parent_comment.get("title") or "",
|
||||
"orgId": final_org_id,
|
||||
"fromUserName": from_user_name,
|
||||
"avatar": user.avatar_url or "",
|
||||
}
|
||||
return await self._json_post(f"{biz}/message/comment/reply", self._bearer(sess["token"]), body)
|
||||
success, err, reply_id = await self._json_post_with_record_id(
|
||||
f"{biz}/message/comment/reply", self._bearer(sess["token"]), body
|
||||
)
|
||||
if success and not reply_id:
|
||||
reply_id, _ = await self.find_reply_id(
|
||||
db, user, news_id,
|
||||
content=content,
|
||||
parent_comment_id=parent_comment_id,
|
||||
)
|
||||
return success, err, reply_id
|
||||
|
||||
async def _get_article_org_id(self, db, token: str, news_id: str) -> str:
|
||||
"""从文章详情补齐 H5 评论/回复所需的组织上下文。"""
|
||||
if not news_id:
|
||||
return ""
|
||||
biz = await self._biz_url(db)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as c:
|
||||
r = await c.get(f"{biz}/news/{news_id}", headers=self._bearer(token))
|
||||
if r.status_code != 200:
|
||||
return ""
|
||||
data = (r.json().get("data") or {})
|
||||
return str(
|
||||
data.get("orgId")
|
||||
or data.get("alumnusId")
|
||||
or data.get("publishOrgId")
|
||||
or data.get("initialPublishOrgId")
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
async def get_comments(self, db, user, news_id) -> list:
|
||||
data = await self.get_comments_payload(db, user, news_id)
|
||||
if isinstance(data, dict):
|
||||
rows = data.get("data") or data.get("records") or data.get("list") or data.get("rows")
|
||||
return rows if isinstance(rows, list) else []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def get_comments_payload(self, db, user, news_id) -> Any:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return []
|
||||
biz = await self._biz_url(db)
|
||||
cfg = await self._client(db)
|
||||
try:
|
||||
params = self._build_form({"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}, cfg)
|
||||
params = {"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{biz}/message/comment", headers=self._bearer(sess["token"]), params=params)
|
||||
r = await c.get(
|
||||
f"{self._api_root(biz)}/interaction/open/selectInteractionCommentList",
|
||||
headers=self._bearer(sess["token"]),
|
||||
params=params,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("data", {}).get("data") or []
|
||||
return r.json().get("data") or []
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def find_comment_id(self, db, user, news_id: str, content: str = "") -> tuple[str, int | None]:
|
||||
"""Best-effort lookup for old records created before platform IDs were saved."""
|
||||
sess = await get_session(user.id)
|
||||
my_uid = str(sess.get("platform_uid", "")) if sess else ""
|
||||
payload = await self.get_comments_payload(db, user, news_id)
|
||||
comment_count = None
|
||||
comments = payload if isinstance(payload, list) else []
|
||||
if isinstance(payload, dict):
|
||||
try:
|
||||
comment_count = int(payload.get("commentCount"))
|
||||
except (TypeError, ValueError):
|
||||
comment_count = None
|
||||
comments = (
|
||||
payload.get("data")
|
||||
or payload.get("records")
|
||||
or payload.get("list")
|
||||
or payload.get("rows")
|
||||
or []
|
||||
)
|
||||
if not isinstance(comments, list):
|
||||
comments = []
|
||||
|
||||
target_content = (content or "").strip()
|
||||
for item in comments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
author_id = str(item.get("userId") or item.get("createUser") or item.get("commentUserId") or "")
|
||||
item_content = (item.get("content") or "").strip()
|
||||
if my_uid and author_id and author_id != my_uid:
|
||||
continue
|
||||
if target_content and item_content != target_content:
|
||||
continue
|
||||
comment_id = str(item.get("id") or item.get("commentId") or item.get("messageId") or "")
|
||||
if comment_id:
|
||||
return comment_id, comment_count
|
||||
return "", comment_count
|
||||
|
||||
async def find_reply_id(
|
||||
self, db, user, news_id: str, content: str = "", parent_comment_id: str = ""
|
||||
) -> tuple[str, int | None]:
|
||||
"""Best-effort lookup for reply records created before platform IDs were saved."""
|
||||
sess = await get_session(user.id)
|
||||
my_uid = str(sess.get("platform_uid", "")) if sess else ""
|
||||
payload = await self.get_comments_payload(db, user, news_id)
|
||||
reply_count = None
|
||||
comments = payload if isinstance(payload, list) else []
|
||||
if isinstance(payload, dict):
|
||||
for count_key in ("replyCount", "commentCount", "total"):
|
||||
try:
|
||||
if payload.get(count_key) is not None:
|
||||
reply_count = int(payload.get(count_key))
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
reply_count = None
|
||||
comments = (
|
||||
payload.get("data")
|
||||
or payload.get("records")
|
||||
or payload.get("list")
|
||||
or payload.get("rows")
|
||||
or []
|
||||
)
|
||||
if not isinstance(comments, list):
|
||||
comments = []
|
||||
|
||||
target_content = (content or "").strip()
|
||||
target_parent_id = str(parent_comment_id or "")
|
||||
for item in comments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
current_parent_id = str(item.get("id") or item.get("commentId") or "")
|
||||
if target_parent_id and current_parent_id != target_parent_id:
|
||||
continue
|
||||
replies = (
|
||||
item.get("respList")
|
||||
or item.get("replyList")
|
||||
or item.get("children")
|
||||
or item.get("replies")
|
||||
or []
|
||||
)
|
||||
if not isinstance(replies, list):
|
||||
continue
|
||||
for reply in replies:
|
||||
if not isinstance(reply, dict):
|
||||
continue
|
||||
author_id = str(
|
||||
reply.get("fromUserId")
|
||||
or reply.get("createUser")
|
||||
or reply.get("userId")
|
||||
or reply.get("commentUserId")
|
||||
or ""
|
||||
)
|
||||
reply_content = (reply.get("content") or "").strip()
|
||||
if my_uid and author_id and author_id != my_uid:
|
||||
continue
|
||||
if target_content and reply_content != target_content:
|
||||
continue
|
||||
reply_id = str(reply.get("id") or reply.get("replyId") or reply.get("commentId") or "")
|
||||
if reply_id:
|
||||
return reply_id, reply_count
|
||||
return "", reply_count
|
||||
|
||||
async def like_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]:
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
@@ -825,13 +1121,41 @@ class NewsPlatformService:
|
||||
return True, ""
|
||||
return False, f"HTTP {resp.status_code}"
|
||||
|
||||
@staticmethod
|
||||
def _extract_record_id(data: Any) -> str:
|
||||
if data is None:
|
||||
return ""
|
||||
if isinstance(data, (str, int)):
|
||||
return str(data)
|
||||
if isinstance(data, dict):
|
||||
for key in ("id", "replyId", "commentId", "messageId", "recordId"):
|
||||
if data.get(key):
|
||||
return str(data[key])
|
||||
for key in ("data", "record", "reply", "comment", "message"):
|
||||
nested = NewsPlatformService._extract_record_id(data.get(key))
|
||||
if nested:
|
||||
return nested
|
||||
return ""
|
||||
|
||||
async def _json_post(self, url, headers, body) -> tuple[bool, str]:
|
||||
success, err, _ = await self._json_post_with_record_id(url, headers, body)
|
||||
return success, err
|
||||
|
||||
async def _json_post_with_record_id(self, url, headers, body) -> tuple[bool, str, str]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.post(url, json=body, headers=headers)
|
||||
return self._ok(r)
|
||||
if r.status_code not in [200, 201]:
|
||||
return False, f"HTTP {r.status_code}", ""
|
||||
try:
|
||||
d = r.json()
|
||||
except Exception:
|
||||
return True, "", ""
|
||||
if d.get("code") in [0, 200]:
|
||||
return True, "", self._extract_record_id(d.get("data"))
|
||||
return False, d.get("message") or "业务失败", ""
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return False, str(e), ""
|
||||
|
||||
async def _write_login_log(self, db, user, action, session_id=None, error_msg=None):
|
||||
try:
|
||||
@@ -898,6 +1222,25 @@ class NewsPlatformService:
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def cancel_reply(self, db, user, news_id: str, reply_id: str) -> tuple[bool, str]:
|
||||
"""DELETE /message/comment/reply/{topicId}/{id} 删除评论回复"""
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "未登录"
|
||||
biz = await self._biz_url(db)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.delete(
|
||||
f"{biz}/message/comment/reply/{news_id}/{reply_id}",
|
||||
headers=self._bearer(sess["token"]),
|
||||
)
|
||||
d = r.json()
|
||||
if d.get("code") in [0, 200]:
|
||||
return True, ""
|
||||
return False, d.get("message", "删除回复失败")
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def cancel_collect(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]:
|
||||
"""取消收藏(复用取消点赞接口)"""
|
||||
return await self.cancel_like(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title)
|
||||
@@ -938,7 +1281,11 @@ class NewsPlatformService:
|
||||
if description is not None: body["description"] = description
|
||||
if email is not None: body["email"] = email
|
||||
|
||||
# 使用 PATCH /v2/users/current 接口(支持修改昵称)
|
||||
# 会会实际有三份用户资料:
|
||||
# 1. /v2/users/current 更新当前用户资料;
|
||||
# 2. /users/page/{id} 更新 usercenter 公开资料;
|
||||
# 3. /huihuiuserextend/user 更新 App“TA 的主页”。
|
||||
# 三处都成功且 App 主页回读一致后才允许标记为成功。
|
||||
headers = dict(self._bearer(token))
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
@@ -950,27 +1297,193 @@ class NewsPlatformService:
|
||||
headers=headers,
|
||||
)
|
||||
d = r.json()
|
||||
if d.get("code") in [0, 200]:
|
||||
# 同步到本地数据库
|
||||
if d.get("code") not in [0, 200]:
|
||||
err = d.get("message") or f"code={d.get('code')}"
|
||||
logger.warning(f"[修改用户信息] {user.account} 失败: {err} body={r.text[:200]}")
|
||||
return False, err
|
||||
|
||||
page_ok, page_err = await self.update_public_user_page(
|
||||
db,
|
||||
user,
|
||||
nick_name=nick_name,
|
||||
avatar=avatar,
|
||||
)
|
||||
if not page_ok:
|
||||
logger.warning(f"[同步TA的主页] {user.account} 失败: {page_err}")
|
||||
return False, f"TA的主页同步失败: {page_err}"
|
||||
|
||||
home_ok, home_err = await self.update_huihui_user_home(
|
||||
db,
|
||||
user,
|
||||
name=nick_name,
|
||||
avatar=avatar,
|
||||
)
|
||||
if not home_ok:
|
||||
logger.warning(f"[同步App用户主页] {user.account} 失败: {home_err}")
|
||||
return False, f"App用户主页同步失败: {home_err}"
|
||||
|
||||
# 三套会会资料均成功并通过 App 主页回读后再同步本地数据库。
|
||||
local_vals = {}
|
||||
if nick_name is not None: local_vals["nickname"] = nick_name
|
||||
if real_name is not None: local_vals["real_name"] = real_name
|
||||
if sex is not None: local_vals["sex"] = sex
|
||||
if avatar is not None: local_vals["avatar_url"] = avatar
|
||||
if local_vals:
|
||||
from sqlalchemy import update
|
||||
await db.execute(update(VirtualUser).where(
|
||||
VirtualUser.id == user.id).values(**local_vals))
|
||||
await db.commit()
|
||||
logger.info(f"✅ 用户 {user.account} 信息已同步到目标系统")
|
||||
logger.info(f"✅ 用户 {user.account} 三套资料与App用户主页均已同步")
|
||||
return True, ""
|
||||
err = d.get("message") or f"code={d.get('code')}"
|
||||
logger.warning(f"[修改用户信息] {user.account} 失败: {err} body={r.text[:200]}")
|
||||
return False, err
|
||||
except Exception as e:
|
||||
logger.warning(f"[修改用户信息] {user.account} 异常: {e}")
|
||||
return False, str(e)
|
||||
|
||||
async def update_public_user_page(
|
||||
self, db: AsyncSession, user: VirtualUser,
|
||||
nick_name: str = None, avatar: str = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""同步会会 App“TA 的主页”展示的公开昵称和头像。"""
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "用户未登录,请先登录"
|
||||
|
||||
platform_uid = sess.get("platform_uid") or user.platform_uid or ""
|
||||
if not platform_uid:
|
||||
return False, "缺少平台用户ID,请重新登录"
|
||||
|
||||
cfg = await self._client(db)
|
||||
auth = await self._auth_url(db)
|
||||
params = {"userId": platform_uid}
|
||||
if nick_name is not None:
|
||||
params["nickName"] = nick_name
|
||||
if avatar is not None:
|
||||
params["icon"] = avatar
|
||||
signed_params = self._build_form(params, cfg)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.patch(
|
||||
f"{auth}/users/page/{platform_uid}",
|
||||
params=signed_params,
|
||||
headers=self._bearer(sess.get("token", "")),
|
||||
)
|
||||
d = r.json()
|
||||
if r.status_code == 200 and d.get("code") in [0, 200] and d.get("data") is not False:
|
||||
return True, ""
|
||||
return False, d.get("message") or f"HTTP={r.status_code}, code={d.get('code')}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def get_public_user_profile(
|
||||
self, db: AsyncSession, user: VirtualUser,
|
||||
) -> tuple[bool, dict | str]:
|
||||
"""通过公开用户详情接口回读“TA 的主页”数据。"""
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "用户未登录"
|
||||
platform_uid = sess.get("platform_uid") or user.platform_uid or ""
|
||||
if not platform_uid:
|
||||
return False, "缺少平台用户ID"
|
||||
cfg = await self._client(db)
|
||||
auth = await self._auth_url(db)
|
||||
params = self._build_form({"userId": platform_uid}, cfg)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(
|
||||
f"{auth}/users/{platform_uid}",
|
||||
params=params,
|
||||
headers=self._bearer(sess.get("token", "")),
|
||||
)
|
||||
d = r.json()
|
||||
if r.status_code == 200 and d.get("code") in [0, 200] and isinstance(d.get("data"), dict):
|
||||
return True, d["data"]
|
||||
return False, d.get("message") or f"HTTP={r.status_code}, code={d.get('code')}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def get_huihui_user_home(
|
||||
self, db: AsyncSession, user: VirtualUser,
|
||||
) -> tuple[bool, dict | str]:
|
||||
"""回读会会 App `/otherIndex` 实际使用的“TA 的主页”资料。"""
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "用户未登录"
|
||||
platform_uid = sess.get("platform_uid") or user.platform_uid or ""
|
||||
if not platform_uid:
|
||||
return False, "缺少平台用户ID"
|
||||
|
||||
cfg = await self._client(db)
|
||||
api_root = self._api_root(await self._biz_url(db))
|
||||
extra = {"userId": platform_uid}
|
||||
org_id = sess.get("org_id") or cfg.get("orgId") or ""
|
||||
if org_id:
|
||||
extra["orgId"] = org_id
|
||||
params = self._build_form(extra, cfg)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.get(
|
||||
f"{api_root}/huihuiuserextend/user/home/{platform_uid}",
|
||||
params=params,
|
||||
headers=self._bearer(sess.get("token", "")),
|
||||
)
|
||||
d = r.json()
|
||||
if r.status_code == 200 and d.get("code") in [0, 200] and isinstance(d.get("data"), dict):
|
||||
return True, d["data"]
|
||||
return False, d.get("message") or f"HTTP={r.status_code}, code={d.get('code')}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def update_huihui_user_home(
|
||||
self, db: AsyncSession, user: VirtualUser,
|
||||
name: str = None, avatar: str = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""写入并回读验证会会 App 真正使用的用户扩展主页资料。"""
|
||||
sess = await get_session(user.id)
|
||||
if not sess:
|
||||
return False, "用户未登录,请先登录"
|
||||
platform_uid = sess.get("platform_uid") or user.platform_uid or ""
|
||||
if not platform_uid:
|
||||
return False, "缺少平台用户ID,请重新登录"
|
||||
|
||||
current_ok, current = await self.get_huihui_user_home(db, user)
|
||||
if not current_ok:
|
||||
return False, f"主页资料回读失败: {current}"
|
||||
extend_id = current.get("id")
|
||||
if not extend_id:
|
||||
return False, "会会用户扩展资料缺少记录ID"
|
||||
|
||||
desired_name = name if name is not None else (user.nickname or "")
|
||||
desired_avatar = avatar if avatar is not None else (user.avatar_url or "")
|
||||
body = {
|
||||
"id": extend_id,
|
||||
"userId": platform_uid,
|
||||
"name": desired_name,
|
||||
"avatar": desired_avatar,
|
||||
}
|
||||
cfg = await self._client(db)
|
||||
api_root = self._api_root(await self._biz_url(db))
|
||||
params = self._build_form({"userId": platform_uid}, cfg)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as c:
|
||||
r = await c.patch(
|
||||
f"{api_root}/huihuiuserextend/user",
|
||||
params=params,
|
||||
json=body,
|
||||
headers={**self._bearer(sess.get("token", "")), "Content-Type": "application/json"},
|
||||
)
|
||||
d = r.json()
|
||||
if r.status_code != 200 or d.get("code") not in [0, 200]:
|
||||
return False, d.get("message") or f"HTTP={r.status_code}, code={d.get('code')}"
|
||||
|
||||
verify_ok, verified = await self.get_huihui_user_home(db, user)
|
||||
if not verify_ok:
|
||||
return False, f"写入后回读失败: {verified}"
|
||||
if verified.get("name") != desired_name or verified.get("avatar") != desired_avatar:
|
||||
return False, "写入后App用户主页昵称或头像不一致"
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
async def upload_avatar(
|
||||
self, db: AsyncSession, user: VirtualUser, file_bytes: bytes, filename: str
|
||||
) -> tuple[bool, str]:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""调度服务 - 定时自动互动、会话校验"""
|
||||
import random
|
||||
import asyncio
|
||||
from datetime import datetime, date
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select, update, func
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.logger import logger
|
||||
from app.models import VirtualUser, UserPersonality, InteractionRecord, SystemConfig
|
||||
from app.models import VirtualUser, UserPersonality, InteractionRecord, PendingReplyTask, SystemConfig
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
@@ -23,6 +23,12 @@ class SchedulerService:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
logger.info("⚡ 立即触发互动任务")
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5"))
|
||||
except (TypeError, ValueError):
|
||||
max_concurrent = 5
|
||||
max_concurrent = max(1, max_concurrent)
|
||||
|
||||
result_r = await session.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.status == 2,
|
||||
@@ -31,13 +37,62 @@ class SchedulerService:
|
||||
)
|
||||
users = result_r.scalars().all()
|
||||
if not users:
|
||||
return {"message": "没有已登录的用户", "triggered": 0}
|
||||
return {
|
||||
"message": "没有已登录的用户",
|
||||
"requested_concurrency": max_concurrent,
|
||||
"attempted_count": 0,
|
||||
"success_count": 0,
|
||||
"skipped_count": 0,
|
||||
"failed_count": 0,
|
||||
"triggered": 0,
|
||||
"users": [],
|
||||
"results": [],
|
||||
}
|
||||
import random
|
||||
selected = random.sample(users, min(5, len(users)))
|
||||
selected = random.sample(users, min(max_concurrent, len(users)))
|
||||
import asyncio
|
||||
tasks = [self._execute_user_interaction(u.id) for u in selected]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return {"triggered": len(selected), "users": [u.account for u in selected]}
|
||||
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
results = []
|
||||
success_count = skipped_count = failed_count = 0
|
||||
for user, outcome in zip(selected, raw_results):
|
||||
if isinstance(outcome, Exception):
|
||||
item = {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "failed",
|
||||
"reason": str(outcome),
|
||||
"interactions": [],
|
||||
}
|
||||
failed_count += 1
|
||||
else:
|
||||
item = outcome or {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_result",
|
||||
"interactions": [],
|
||||
}
|
||||
status = item.get("status")
|
||||
if status == "success":
|
||||
success_count += 1
|
||||
elif status == "failed":
|
||||
failed_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
results.append(item)
|
||||
|
||||
return {
|
||||
"requested_concurrency": max_concurrent,
|
||||
"attempted_count": len(selected),
|
||||
"success_count": success_count,
|
||||
"skipped_count": skipped_count,
|
||||
"failed_count": failed_count,
|
||||
"triggered": len(selected),
|
||||
"users": [u.account for u in selected],
|
||||
"results": results,
|
||||
}
|
||||
|
||||
async def start(self):
|
||||
if self._running:
|
||||
@@ -52,6 +107,11 @@ class SchedulerService:
|
||||
self._run_interactions, IntervalTrigger(minutes=5),
|
||||
id="run_interactions", replace_existing=True
|
||||
)
|
||||
# 待发送回复队列:持久化延迟回复,后端重启后可继续发送
|
||||
self.scheduler.add_job(
|
||||
self._process_pending_reply_tasks, IntervalTrigger(seconds=30),
|
||||
id="process_pending_reply_tasks", replace_existing=True
|
||||
)
|
||||
# 每日零点重置计数
|
||||
self.scheduler.add_job(
|
||||
self._daily_reset, "cron", hour=16, minute=0, # 北京时间 00:00 = UTC 16:00
|
||||
@@ -221,7 +281,13 @@ class SchedulerService:
|
||||
user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user or user.status != 2:
|
||||
return
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"account": getattr(user, "account", ""),
|
||||
"status": "skipped",
|
||||
"reason": "user_not_logged_in",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# 检查今日评论限额
|
||||
can_comment = True
|
||||
@@ -255,7 +321,13 @@ class SchedulerService:
|
||||
f"用户 {user.account} 获取新闻列表为空 "
|
||||
f"(orgId={await news_service._cfg(db, 'platform_org_id', '')})"
|
||||
)
|
||||
return
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_articles",
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
# ── 文章去重 + 热度加权选取 ─────────────────────────────────
|
||||
# 查询今日已互动过的文章(所有类型),避免重复互动同一篇
|
||||
@@ -307,7 +379,14 @@ class SchedulerService:
|
||||
article_org_id = str(article.get("orgId") or "")
|
||||
|
||||
if not news_id:
|
||||
return
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "missing_news_id",
|
||||
"interactions": [],
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
# 读取互动概率
|
||||
comment_prob = float(await self._get_config_from_db(db, "comment_probability", "0.4"))
|
||||
@@ -317,6 +396,7 @@ class SchedulerService:
|
||||
forward_prob = float(await self._get_config_from_db(db, "forward_probability", "0.15"))
|
||||
|
||||
interactions_done = []
|
||||
action_failures = []
|
||||
|
||||
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
|
||||
await news_service.read_news(db, user, news_id)
|
||||
@@ -331,6 +411,8 @@ class SchedulerService:
|
||||
if success:
|
||||
interactions_done.append("like")
|
||||
await self._incr_total(db, user_id)
|
||||
else:
|
||||
action_failures.append({"type": "like", "error": err})
|
||||
|
||||
# ③ 收藏(每篇文章每用户每天只收藏一次)
|
||||
if "collect" not in done_on_this and random.random() < collect_prob:
|
||||
@@ -338,6 +420,8 @@ class SchedulerService:
|
||||
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
|
||||
if success:
|
||||
interactions_done.append("collect")
|
||||
else:
|
||||
action_failures.append({"type": "collect", "error": err})
|
||||
|
||||
# ④ 转发(每篇文章每用户每天只转发一次)
|
||||
if "forward" not in done_on_this and random.random() < forward_prob:
|
||||
@@ -346,61 +430,45 @@ class SchedulerService:
|
||||
if success:
|
||||
interactions_done.append("forward")
|
||||
await self._incr_total(db, user_id)
|
||||
else:
|
||||
action_failures.append({"type": "forward", "error": err})
|
||||
|
||||
# ⑤ 评论/回复逻辑(去重:已评论过的文章改为回复他人评论)
|
||||
# ⑤ 评论/回复逻辑:评论和回复互相独立,未评论过文章也可以回复他人评论
|
||||
if can_comment and personality:
|
||||
style_prompt = personality.comment_style_prompt or ""
|
||||
safe_word_max = min(personality.word_count_max, 80)
|
||||
|
||||
if already_commented_this:
|
||||
# 已评论过此文章 → 改为回复其他用户的评论(虚拟用户互动)
|
||||
if random.random() < reply_prob:
|
||||
existing = await news_service.get_comments(db, user, news_id)
|
||||
if existing:
|
||||
# 优先回复虚拟用户的评论(促进互动),过滤掉自己的评论
|
||||
from app.core.redis_client import get_session as _gs
|
||||
my_sess = await _gs(user.id)
|
||||
my_uid = my_sess.get("platform_uid", "") if my_sess else ""
|
||||
others = [c for c in existing
|
||||
if str(c.get("userId") or c.get("createUser") or "") != my_uid]
|
||||
if others:
|
||||
target = random.choice(others)
|
||||
cid = str(target.get("id") or target.get("commentId") or "")
|
||||
parent_content = target.get("content") or ""
|
||||
if cid:
|
||||
reply_text, r_tokens = await ai_service.generate_reply(
|
||||
db, news_title, parent_content,
|
||||
style_prompt,
|
||||
personality.word_count_min,
|
||||
safe_word_max
|
||||
reply_actions, reply_failures = await self._run_reply_interaction_chain(
|
||||
db=db,
|
||||
starter=user,
|
||||
starter_personality=personality,
|
||||
news_service=news_service,
|
||||
ai_service=ai_service,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
style_prompt=style_prompt,
|
||||
safe_word_max=safe_word_max,
|
||||
)
|
||||
if reply_text:
|
||||
r_ok, r_err = await news_service.post_reply(
|
||||
db, user, news_id, cid, reply_text
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "reply",
|
||||
reply_text, r_tokens, r_ok, r_err,
|
||||
parent_comment_id=cid
|
||||
)
|
||||
if r_ok:
|
||||
interactions_done.append("reply")
|
||||
logger.info(f"💬 {user.account} 回复了已评论文章的评论(去重逻辑)")
|
||||
else:
|
||||
# 未评论过此文章 → 正常发评论
|
||||
if random.random() < comment_prob:
|
||||
interactions_done.extend(reply_actions)
|
||||
action_failures.extend(reply_failures)
|
||||
|
||||
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
|
||||
if not already_commented_this and random.random() < comment_prob:
|
||||
comment_text, tokens = await ai_service.generate_comment(
|
||||
db, news_title, news_content,
|
||||
style_prompt, personality.word_count_min, safe_word_max
|
||||
)
|
||||
if comment_text:
|
||||
success, err = await news_service.post_comment(
|
||||
success, err, comment_record_id = await news_service.post_comment_with_record_id(
|
||||
db, user, news_id, news_title, comment_text,
|
||||
news_author_id=news_author, org_id=article_org_id
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "comment",
|
||||
comment_text, tokens, success, err
|
||||
comment_text, tokens, success, err,
|
||||
platform_record_id=comment_record_id,
|
||||
)
|
||||
if success:
|
||||
interactions_done.append("comment")
|
||||
@@ -411,44 +479,384 @@ class SchedulerService:
|
||||
last_interact_at=datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
# ⑥ 评论成功后,随机回复其他用户的评论(互动链)
|
||||
if random.random() < reply_prob:
|
||||
existing = await news_service.get_comments(db, user, news_id)
|
||||
if existing:
|
||||
from app.core.redis_client import get_session as _gs2
|
||||
my_sess2 = await _gs2(user.id)
|
||||
my_uid2 = my_sess2.get("platform_uid", "") if my_sess2 else ""
|
||||
others2 = [c for c in existing
|
||||
if str(c.get("userId") or c.get("createUser") or "") != my_uid2]
|
||||
if others2:
|
||||
target2 = random.choice(others2)
|
||||
cid2 = str(target2.get("id") or target2.get("commentId") or "")
|
||||
parent_content2 = target2.get("content") or ""
|
||||
if cid2:
|
||||
reply_text2, r_tokens2 = await ai_service.generate_reply(
|
||||
db, news_title, parent_content2,
|
||||
style_prompt,
|
||||
personality.word_count_min,
|
||||
safe_word_max
|
||||
)
|
||||
if reply_text2:
|
||||
r_ok2, r_err2 = await news_service.post_reply(
|
||||
db, user, news_id, cid2, reply_text2
|
||||
)
|
||||
await self._save_record(
|
||||
db, user, news_id, news_title, "reply",
|
||||
reply_text2, r_tokens2, r_ok2, r_err2,
|
||||
parent_comment_id=cid2
|
||||
)
|
||||
if r_ok2:
|
||||
interactions_done.append("reply")
|
||||
else:
|
||||
action_failures.append({"type": "comment", "error": err})
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"👤 {user.account} 互动完成: {interactions_done} [新闻: {news_title[:20]}]")
|
||||
if interactions_done:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "success",
|
||||
"reason": "",
|
||||
"interactions": interactions_done,
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
if action_failures:
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "failed",
|
||||
"reason": "; ".join(
|
||||
f"{item['type']}:{item['error'] or 'unknown'}" for item in action_failures
|
||||
),
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"account": user.account,
|
||||
"status": "skipped",
|
||||
"reason": "no_actions_triggered",
|
||||
"interactions": [],
|
||||
"article_id": news_id,
|
||||
"article_title": news_title,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户 {user_id} 互动异常: {e}")
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"account": "",
|
||||
"status": "failed",
|
||||
"reason": str(e),
|
||||
"interactions": [],
|
||||
}
|
||||
|
||||
async def _run_reply_interaction_chain(
|
||||
self,
|
||||
db,
|
||||
starter: VirtualUser,
|
||||
starter_personality,
|
||||
news_service,
|
||||
ai_service,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
style_prompt: str,
|
||||
safe_word_max: int,
|
||||
) -> tuple[list[str], list[dict]]:
|
||||
"""主动回复评论,并按概率安排后续延迟回应。"""
|
||||
from app.core.redis_client import get_session
|
||||
|
||||
actions: list[str] = []
|
||||
failures: list[dict] = []
|
||||
starter_sess = await get_session(starter.id)
|
||||
starter_uid = str(starter_sess.get("platform_uid") or "") if starter_sess else ""
|
||||
comments = await news_service.get_comments(db, starter, news_id)
|
||||
if not comments:
|
||||
return actions, failures
|
||||
|
||||
candidates = [
|
||||
c for c in comments
|
||||
if str(c.get("id") or c.get("commentId") or "")
|
||||
and (c.get("content") or "").strip()
|
||||
and str(c.get("userId") or c.get("createUser") or "") != starter_uid
|
||||
]
|
||||
if not candidates:
|
||||
return actions, failures
|
||||
|
||||
parent_comment = random.choice(candidates)
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
root_content = (parent_comment.get("content") or "").strip()
|
||||
context = f"准备回复这条评论:{root_content}"
|
||||
reply_text, reply_tokens = await ai_service.generate_thread_reply(
|
||||
db, news_title, root_content, context,
|
||||
style_prompt,
|
||||
starter_personality.word_count_min,
|
||||
safe_word_max,
|
||||
)
|
||||
if not reply_text:
|
||||
return actions, failures
|
||||
|
||||
ok, err, reply_id = await news_service.post_reply_with_record_id(
|
||||
db, starter, news_id, parent_comment_id, reply_text,
|
||||
parent_comment=parent_comment,
|
||||
article_title=news_title,
|
||||
org_id=article_org_id,
|
||||
)
|
||||
await self._save_record(
|
||||
db, starter, news_id, news_title, "reply",
|
||||
reply_text, reply_tokens, ok, err,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=reply_id,
|
||||
)
|
||||
if not ok:
|
||||
failures.append({"type": "reply", "error": err})
|
||||
return actions, failures
|
||||
|
||||
actions.append("reply")
|
||||
await self._incr_total(db, starter.id)
|
||||
logger.info(f"💬 {starter.account} 回复了文章评论")
|
||||
|
||||
starter_reply = {
|
||||
"id": reply_id,
|
||||
"content": reply_text,
|
||||
"createUser": starter_uid,
|
||||
"fromUserName": starter.real_name or starter.nickname or starter.account,
|
||||
}
|
||||
|
||||
chain_probability = await self._get_float_config(db, "reply_chain_probability", 0.3)
|
||||
delay_min = await self._get_int_config(db, "reply_chain_delay_min_seconds", 30)
|
||||
delay_max = await self._get_int_config(db, "reply_chain_delay_max_seconds", 7200)
|
||||
if delay_max < delay_min:
|
||||
delay_max = delay_min
|
||||
|
||||
# 评论作者如果也是当前系统里的已登录虚拟用户,按概率安排稍后回复这条回复。
|
||||
parent_author_uid = str(parent_comment.get("createUser") or parent_comment.get("userId") or "")
|
||||
parent_author = await self._get_logged_in_user_by_platform_uid(db, parent_author_uid)
|
||||
if (
|
||||
parent_author
|
||||
and parent_author.id != starter.id
|
||||
and random.random() < chain_probability
|
||||
):
|
||||
delay_seconds = random.randint(delay_min, delay_max)
|
||||
await self._enqueue_pending_reply_task(
|
||||
db=db,
|
||||
delay_seconds=delay_seconds,
|
||||
actor_id=parent_author.id,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=starter_reply,
|
||||
root_content=root_content,
|
||||
context=f"对方刚回复了你的评论:{reply_text}",
|
||||
next_actor_id=starter.id,
|
||||
next_probability=chain_probability,
|
||||
next_delay_min=delay_min,
|
||||
next_delay_max=delay_max,
|
||||
)
|
||||
logger.info(
|
||||
f"⏳ {parent_author.account} 已进入待发送回复队列,延迟 {delay_seconds}s 后发送"
|
||||
)
|
||||
|
||||
return actions, failures
|
||||
|
||||
async def _enqueue_pending_reply_task(
|
||||
self,
|
||||
db,
|
||||
delay_seconds: int,
|
||||
actor_id: int,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
parent_comment: dict,
|
||||
reply_to: dict,
|
||||
root_content: str,
|
||||
context: str,
|
||||
next_actor_id: int | None = None,
|
||||
next_probability: float = 0.3,
|
||||
next_delay_min: int = 30,
|
||||
next_delay_max: int = 7200,
|
||||
):
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
db.add(PendingReplyTask(
|
||||
actor_user_id=actor_id,
|
||||
next_actor_user_id=next_actor_id,
|
||||
news_id=news_id,
|
||||
news_title=news_title,
|
||||
article_org_id=article_org_id,
|
||||
parent_comment_id=parent_comment_id,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
root_content=root_content,
|
||||
context=context,
|
||||
next_probability=next_probability,
|
||||
next_delay_min_seconds=next_delay_min,
|
||||
next_delay_max_seconds=next_delay_max,
|
||||
status=0,
|
||||
attempts=0,
|
||||
scheduled_at=datetime.now() + timedelta(seconds=max(0, delay_seconds)),
|
||||
))
|
||||
|
||||
async def _process_pending_reply_tasks(self):
|
||||
from app.services.news_service import news_service
|
||||
from app.services.ai_service import ai_service
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
update(PendingReplyTask)
|
||||
.where(
|
||||
PendingReplyTask.status == 1,
|
||||
PendingReplyTask.locked_at < now - timedelta(minutes=10),
|
||||
)
|
||||
.values(status=0, last_error="发送中超时,重新入队")
|
||||
)
|
||||
result = await db.execute(
|
||||
select(PendingReplyTask)
|
||||
.where(
|
||||
PendingReplyTask.status == 0,
|
||||
PendingReplyTask.scheduled_at <= now,
|
||||
)
|
||||
.order_by(PendingReplyTask.scheduled_at.asc())
|
||||
.limit(10)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
for task in tasks:
|
||||
await self._process_pending_reply_task(db, task, news_service, ai_service)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"待发送回复队列处理异常: {e}")
|
||||
|
||||
async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service):
|
||||
task.status = 1
|
||||
task.locked_at = datetime.now()
|
||||
task.attempts = (task.attempts or 0) + 1
|
||||
await db.flush()
|
||||
|
||||
actor = await self._get_user_by_id(db, task.actor_user_id)
|
||||
if not actor or actor.status != 2 or actor.is_enabled != 1:
|
||||
task.status = 3
|
||||
task.last_error = "用户未登录或已禁用"
|
||||
return
|
||||
|
||||
reply_result = await self._post_contextual_reply(
|
||||
db=db,
|
||||
actor=actor,
|
||||
news_service=news_service,
|
||||
ai_service=ai_service,
|
||||
news_id=task.news_id,
|
||||
news_title=task.news_title or "",
|
||||
article_org_id=task.article_org_id or "",
|
||||
parent_comment=task.parent_comment or {},
|
||||
reply_to=task.reply_to or {},
|
||||
root_content=task.root_content or "",
|
||||
context=task.context or "",
|
||||
)
|
||||
if not reply_result["ok"]:
|
||||
task.status = 3 if task.attempts >= 3 else 0
|
||||
task.last_error = reply_result["error"] or "生成或发送回复失败"
|
||||
if task.status == 0:
|
||||
task.scheduled_at = datetime.now() + timedelta(minutes=5)
|
||||
logger.warning(f"待发送回复失败 task_id={task.id} user={actor.account}: {task.last_error}")
|
||||
return
|
||||
|
||||
task.status = 2
|
||||
task.sent_at = datetime.now()
|
||||
task.last_error = None
|
||||
await self._incr_total(db, actor.id)
|
||||
logger.info(f"💬 {actor.account} 发送了待发送回复 task_id={task.id}")
|
||||
|
||||
if task.next_actor_user_id and random.random() < float(task.next_probability or 0.3):
|
||||
delay_min = int(task.next_delay_min_seconds or 30)
|
||||
delay_max = max(delay_min, int(task.next_delay_max_seconds or 7200))
|
||||
next_delay = random.randint(delay_min, delay_max)
|
||||
await self._enqueue_pending_reply_task(
|
||||
db=db,
|
||||
delay_seconds=next_delay,
|
||||
actor_id=task.next_actor_user_id,
|
||||
news_id=task.news_id,
|
||||
news_title=task.news_title or "",
|
||||
article_org_id=task.article_org_id or "",
|
||||
parent_comment=task.parent_comment or {},
|
||||
reply_to=reply_result["reply"],
|
||||
root_content=task.root_content or "",
|
||||
context=(
|
||||
f"原评论:{task.root_content or ''}\n"
|
||||
f"上一条回复:{(task.reply_to or {}).get('content') or ''}\n"
|
||||
f"对方回应:{reply_result['content']}"
|
||||
),
|
||||
next_actor_id=None,
|
||||
next_probability=float(task.next_probability or 0.3),
|
||||
next_delay_min=delay_min,
|
||||
next_delay_max=delay_max,
|
||||
)
|
||||
logger.info(f"⏳ 已入队继续回复,延迟 {next_delay}s 后发送")
|
||||
|
||||
async def _post_contextual_reply(
|
||||
self,
|
||||
db,
|
||||
actor: VirtualUser,
|
||||
news_service,
|
||||
ai_service,
|
||||
news_id: str,
|
||||
news_title: str,
|
||||
article_org_id: str,
|
||||
parent_comment: dict,
|
||||
reply_to: dict,
|
||||
root_content: str,
|
||||
context: str,
|
||||
) -> dict:
|
||||
personality = await self._get_user_personality(db, actor.id)
|
||||
style_prompt = personality.comment_style_prompt if personality else ""
|
||||
word_min = personality.word_count_min if personality else 15
|
||||
word_max = min(personality.word_count_max, 80) if personality else 60
|
||||
content, tokens = await ai_service.generate_thread_reply(
|
||||
db, news_title, root_content, context,
|
||||
style_prompt, word_min, word_max,
|
||||
)
|
||||
if not content:
|
||||
return {"ok": False, "error": "", "reply": {}, "content": ""}
|
||||
|
||||
parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "")
|
||||
ok, err, reply_id = await news_service.post_reply_with_record_id(
|
||||
db, actor, news_id, parent_comment_id, content,
|
||||
parent_comment=parent_comment,
|
||||
reply_to=reply_to,
|
||||
article_title=news_title,
|
||||
org_id=article_org_id,
|
||||
)
|
||||
await self._save_record(
|
||||
db, actor, news_id, news_title, "reply",
|
||||
content, tokens, ok, err,
|
||||
parent_comment_id=parent_comment_id,
|
||||
platform_record_id=reply_id,
|
||||
)
|
||||
from app.core.redis_client import get_session
|
||||
sess = await get_session(actor.id)
|
||||
actor_uid = str(sess.get("platform_uid") or "") if sess else ""
|
||||
return {
|
||||
"ok": ok,
|
||||
"error": "" if ok else err,
|
||||
"content": content,
|
||||
"reply": {
|
||||
"id": reply_id,
|
||||
"content": content,
|
||||
"createUser": actor_uid,
|
||||
"fromUserName": actor.real_name or actor.nickname or actor.account,
|
||||
},
|
||||
}
|
||||
|
||||
async def _get_logged_in_user_by_platform_uid(self, db, platform_uid: str) -> VirtualUser | None:
|
||||
if not platform_uid:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(VirtualUser).where(
|
||||
VirtualUser.platform_uid == platform_uid,
|
||||
VirtualUser.status == 2,
|
||||
VirtualUser.is_enabled == 1,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_by_id(self, db, user_id: int) -> VirtualUser | None:
|
||||
result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_personality(self, db, user_id: int):
|
||||
result = await db.execute(
|
||||
select(UserPersonality).where(UserPersonality.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_float_config(self, db, key: str, default: float) -> float:
|
||||
try:
|
||||
return float(await self._get_config_from_db(db, key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def _get_int_config(self, db, key: str, default: int) -> int:
|
||||
try:
|
||||
return int(float(await self._get_config_from_db(db, key, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
async def _incr_total(self, db, user_id: int):
|
||||
await db.execute(
|
||||
|
||||
@@ -230,7 +230,7 @@ class StatsService:
|
||||
"comment": "评论", "reply": "回复", "like": "点赞",
|
||||
"collect": "收藏", "forward": "转发"
|
||||
}
|
||||
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败"}
|
||||
STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败", 3: "已取消"}
|
||||
|
||||
items = []
|
||||
for r in records:
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services.news_service import NewsPlatformService
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.text = str(payload)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _Client:
|
||||
responses = []
|
||||
calls = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def patch(self, url, **kwargs):
|
||||
self.__class__.calls.append(("PATCH", url, kwargs))
|
||||
return self.__class__.responses.pop(0)
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
self.__class__.calls.append(("GET", url, kwargs))
|
||||
return self.__class__.responses.pop(0)
|
||||
|
||||
|
||||
class HuihuiProfileSyncTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.service = NewsPlatformService()
|
||||
self.service._auth_url = AsyncMock(return_value="https://99hui.com/api/usercenter")
|
||||
self.service._biz_url = AsyncMock(return_value="https://99hui.com/api/huihuibusiness")
|
||||
self.service._client = AsyncMock(return_value={
|
||||
"appId": "app", "accessId": "access", "accessSecret": "secret",
|
||||
"clientCode": "", "orgId": "",
|
||||
})
|
||||
self.db = SimpleNamespace(execute=AsyncMock(), commit=AsyncMock())
|
||||
self.user = SimpleNamespace(
|
||||
id=51, account="13721560046", platform_uid="platform-51",
|
||||
nickname="黎佳怡", real_name="黎佳怡", sex=0, avatar_url="https://img/avatar.jpg",
|
||||
)
|
||||
_Client.calls = []
|
||||
|
||||
async def test_updates_all_profiles_and_verifies_app_home(self):
|
||||
_Client.responses = [
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 0, "data": {
|
||||
"id": "extend-51", "userId": "platform-51", "name": "", "avatar": "",
|
||||
}}),
|
||||
_Response({"code": 0, "data": None}),
|
||||
_Response({"code": 0, "data": {
|
||||
"id": "extend-51", "userId": "platform-51", "name": "黎佳怡",
|
||||
"avatar": "https://img/avatar.jpg",
|
||||
}}),
|
||||
]
|
||||
session = {"token": "token", "platform_uid": "platform-51", "org_id": "org-1"}
|
||||
with patch("app.services.news_service.get_session", AsyncMock(return_value=session)), \
|
||||
patch("app.services.news_service.httpx.AsyncClient", _Client):
|
||||
ok, err = await self.service.update_user_profile(
|
||||
self.db, self.user,
|
||||
nick_name="黎佳怡", real_name="黎佳怡",
|
||||
avatar="https://img/avatar.jpg",
|
||||
)
|
||||
|
||||
self.assertTrue(ok, err)
|
||||
self.assertEqual(_Client.calls[0][1], "https://99hui.com/api/usercenter/v2/users/current")
|
||||
self.assertEqual(_Client.calls[1][1], "https://99hui.com/api/usercenter/users/page/platform-51")
|
||||
public_params = _Client.calls[1][2]["params"]
|
||||
self.assertEqual(public_params["nickName"], "黎佳怡")
|
||||
self.assertEqual(public_params["icon"], "https://img/avatar.jpg")
|
||||
self.assertEqual(_Client.calls[2][0], "GET")
|
||||
self.assertEqual(
|
||||
_Client.calls[2][1],
|
||||
"https://99hui.com/api/huihuiuserextend/user/home/platform-51",
|
||||
)
|
||||
self.assertEqual(_Client.calls[3][0], "PATCH")
|
||||
self.assertEqual(
|
||||
_Client.calls[3][1],
|
||||
"https://99hui.com/api/huihuiuserextend/user",
|
||||
)
|
||||
self.assertEqual(_Client.calls[3][2]["json"]["id"], "extend-51")
|
||||
self.assertEqual(_Client.calls[3][2]["json"]["name"], "黎佳怡")
|
||||
self.assertEqual(_Client.calls[3][2]["json"]["avatar"], "https://img/avatar.jpg")
|
||||
self.assertEqual(_Client.calls[4][0], "GET")
|
||||
self.db.commit.assert_awaited_once()
|
||||
|
||||
async def test_public_page_failure_is_not_reported_as_success(self):
|
||||
_Client.responses = [
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 500, "message": "page update failed"}),
|
||||
]
|
||||
session = {"token": "token", "platform_uid": "platform-51"}
|
||||
with patch("app.services.news_service.get_session", AsyncMock(return_value=session)), \
|
||||
patch("app.services.news_service.httpx.AsyncClient", _Client):
|
||||
ok, err = await self.service.update_user_profile(
|
||||
self.db, self.user,
|
||||
nick_name="黎佳怡", avatar="https://img/avatar.jpg",
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("TA的主页同步失败", err)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_app_home_mismatch_is_not_reported_as_success(self):
|
||||
_Client.responses = [
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 0, "data": True}),
|
||||
_Response({"code": 0, "data": {
|
||||
"id": "extend-51", "userId": "platform-51", "name": "", "avatar": "",
|
||||
}}),
|
||||
_Response({"code": 0, "data": None}),
|
||||
_Response({"code": 0, "data": {
|
||||
"id": "extend-51", "userId": "platform-51", "name": "", "avatar": "",
|
||||
}}),
|
||||
]
|
||||
session = {"token": "token", "platform_uid": "platform-51", "org_id": "org-1"}
|
||||
with patch("app.services.news_service.get_session", AsyncMock(return_value=session)), \
|
||||
patch("app.services.news_service.httpx.AsyncClient", _Client):
|
||||
ok, err = await self.service.update_user_profile(
|
||||
self.db, self.user,
|
||||
nick_name="黎佳怡", avatar="https://img/avatar.jpg",
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("App用户主页同步失败", err)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
.env
|
||||
*.log
|
||||
backend/
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
backend/avatar.db
|
||||
backend/routers/uploads/
|
||||
@@ -0,0 +1,21 @@
|
||||
# 构建阶段:安装依赖并打包 H5
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
|
||||
# 锁定 1.28-alpine:测试服务器 Docker 的 seccomp 拦截 pwrite 系统调用,
|
||||
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 后端依赖(fastapi/uvicorn/sqlalchemy/pypdf/python-docx/openpyxl 等)均为纯 Python wheel,
|
||||
# 无需 gcc 等编译链,故跳过 apt 安装以加快构建并减小镜像体积。
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_FILE = os.path.join(BASE_DIR, "avatar.db")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_FILE}")
|
||||
|
||||
IS_SQLITE = DATABASE_URL.startswith("sqlite:")
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False, "timeout": 30} if IS_SQLITE else {},
|
||||
)
|
||||
|
||||
|
||||
if IS_SQLITE:
|
||||
@event.listens_for(engine, "connect")
|
||||
def _configure_sqlite_connection(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA busy_timeout=30000")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
import models
|
||||
|
||||
if IS_SQLITE:
|
||||
with engine.connect() as conn:
|
||||
conn.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
conn.commit()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 轻量迁移:为已存在的表补充新列(SQLite 不支持自动 ALTER,逐列尝试)
|
||||
_try_add_columns(
|
||||
("qa_pairs", "enabled", "BOOLEAN DEFAULT 1"),
|
||||
("knowledge_docs", "vectorized", "BOOLEAN DEFAULT 0"),
|
||||
("knowledge_docs", "embedding_model", "VARCHAR DEFAULT ''"),
|
||||
("knowledge_docs", "chunk_count", "INTEGER DEFAULT 0"),
|
||||
("knowledge_docs", "vectorized_at", "TIMESTAMP"),
|
||||
("avatars", "owner_id", "VARCHAR DEFAULT ''"),
|
||||
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
|
||||
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
|
||||
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 180"),
|
||||
("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"),
|
||||
("takeover_messages", "attachment_id", "VARCHAR DEFAULT NULL"),
|
||||
)
|
||||
_normalize_optional_unique_values()
|
||||
_normalize_takeover_delays()
|
||||
_create_token_indexes()
|
||||
|
||||
|
||||
def _try_add_columns(*cols):
|
||||
with engine.connect() as conn:
|
||||
for table, col, ddl in cols:
|
||||
try:
|
||||
conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# 列已存在(或全新库由 create_all 建好)则忽略
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_optional_unique_values():
|
||||
with engine.begin() as conn:
|
||||
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 <> ''"
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
向量化服务:对文档/查询文本生成向量。
|
||||
|
||||
优先级:
|
||||
1. 若配置了环境变量 EMBEDDING_API_URL,则调用第三方「OpenAI 兼容」的 /embeddings 接口
|
||||
(需配置 EMBEDDING_API_KEY、EMBEDDING_MODEL,默认 text-embedding-3-small)。
|
||||
2. 否则使用本地「哈希 TF 嵌入」兜底,使向量检索在无外部依赖时也能端到端跑通,
|
||||
且相似文本(共享词汇)会得到更高余弦相似度,便于演示召回效果。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.request
|
||||
|
||||
EMBED_DIM = 256
|
||||
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):
|
||||
text = (text or "").lower()
|
||||
# 英文/数字按词,CJK 逐字(中文无空格,需拆到字级才能命中子词)
|
||||
tokens = re.findall(r"[a-z0-9]+", text)
|
||||
tokens += re.findall(r"[一-鿿]", text)
|
||||
return tokens
|
||||
|
||||
|
||||
def _hash_embedding(texts, dim=EMBED_DIM):
|
||||
vecs = []
|
||||
for text in texts:
|
||||
vec = [0.0] * dim
|
||||
tokens = _tokenize(text)
|
||||
if not tokens:
|
||||
tokens = list(text or "")
|
||||
for tok in tokens:
|
||||
h = int(hashlib.md5(tok.encode("utf-8")).hexdigest(), 16)
|
||||
vec[h % dim] += 1.0
|
||||
norm = math.sqrt(sum(v * v for v in vec))
|
||||
if norm > 0:
|
||||
vec = [v / norm for v in vec]
|
||||
vecs.append(vec)
|
||||
return vecs
|
||||
|
||||
|
||||
def embed(texts):
|
||||
"""返回 list[list[float]],与输入顺序一致。"""
|
||||
if not texts:
|
||||
return []
|
||||
api_url = _embedding_endpoint(os.getenv("EMBEDDING_API_URL"))
|
||||
if api_url:
|
||||
api_key = os.getenv("EMBEDDING_API_KEY", "")
|
||||
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
try:
|
||||
batch_size = max(1, int(os.getenv("EMBEDDING_BATCH_SIZE", "10")))
|
||||
except ValueError:
|
||||
batch_size = 10
|
||||
embeddings = []
|
||||
for start in range(0, len(texts), batch_size):
|
||||
batch = texts[start:start + batch_size]
|
||||
payload = json.dumps({"input": batch, "model": model}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
api_url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}" if api_key else "",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
items = data["data"]
|
||||
if items and "index" in items[0]:
|
||||
items = sorted(items, key=lambda x: x["index"])
|
||||
if len(items) != len(batch):
|
||||
raise ValueError("embedding response count does not match request")
|
||||
embeddings.extend(item["embedding"] for item in items)
|
||||
return embeddings
|
||||
return _hash_embedding(texts)
|
||||
|
||||
|
||||
def cosine(a, b):
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
if na == 0 or nb == 0:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def chunk_text(text, size=400, overlap=50):
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= size:
|
||||
return [text]
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(start + size, len(text))
|
||||
chunks.append(text[start:end])
|
||||
if end == len(text):
|
||||
break
|
||||
start = end - overlap
|
||||
return chunks
|
||||
|
||||
|
||||
def extract_text(path, ext):
|
||||
"""抽取文档纯文本;未知格式拒绝,已知格式解析失败时保留占位文本。"""
|
||||
if ext not in {".txt", ".md", ".docx", ".xlsx", ".pdf", ".doc"}:
|
||||
raise ValueError(f"unsupported file extension: {ext}")
|
||||
try:
|
||||
if ext in {".txt", ".md"}:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
if ext == ".docx":
|
||||
from docx import Document
|
||||
|
||||
doc = Document(path)
|
||||
return "\n".join(p.text for p in doc.paragraphs)
|
||||
if ext == ".xlsx":
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
|
||||
rows = []
|
||||
for ws in wb.worksheets:
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
cells = [str(c) for c in row if c is not None]
|
||||
if cells:
|
||||
rows.append(" ".join(cells))
|
||||
return "\n".join(rows)
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
from PyPDF2 import PdfReader
|
||||
reader = PdfReader(path)
|
||||
return "\n".join((p.extract_text() or "") for p in reader.pages)
|
||||
if ext == ".doc":
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read().decode("utf-8", errors="ignore")
|
||||
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]+", " ", raw)
|
||||
except Exception as e: # 解析失败时回退
|
||||
print("extract_text failed:", e)
|
||||
return f"文档:{os.path.basename(path)} 类型 {ext}"
|
||||
@@ -0,0 +1,264 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from database import init_db, SessionLocal
|
||||
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import routers.avatars
|
||||
import routers.tokens
|
||||
import routers.authorizations
|
||||
import routers.organizations
|
||||
import routers.knowledge
|
||||
import routers.huihui_auth
|
||||
import routers.chat
|
||||
import routers.takeover
|
||||
from responses import ok
|
||||
from services.chat_attachment_service import purge_expired_chat_attachments
|
||||
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
takeover_scheduler = None
|
||||
maintenance_scheduler = None
|
||||
|
||||
app = FastAPI(title="会会数字分身 API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(routers.avatars.router, prefix="/api")
|
||||
app.include_router(routers.tokens.router, prefix="/api")
|
||||
app.include_router(routers.authorizations.router, prefix="/api")
|
||||
app.include_router(routers.organizations.router, prefix="/api")
|
||||
app.include_router(routers.knowledge.router, prefix="/api")
|
||||
app.include_router(routers.huihui_auth.router, prefix="/api")
|
||||
app.include_router(routers.chat.router, prefix="/api")
|
||||
app.include_router(routers.takeover.router, prefix="/api")
|
||||
|
||||
UPLOAD_DIR = routers.knowledge.UPLOAD_DIR
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
app.mount("/api/files", StaticFiles(directory=UPLOAD_DIR), name="knowledge-files")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return ok({"status": "ok"})
|
||||
|
||||
|
||||
def seed():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
plan_specs = [
|
||||
{"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)
|
||||
|
||||
for user in db.query(User).all():
|
||||
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
|
||||
if account is None:
|
||||
db.add(TokenAccount(
|
||||
user_id=user.id,
|
||||
balance=DEFAULT_TOKEN_GRANT,
|
||||
total_granted=DEFAULT_TOKEN_GRANT,
|
||||
total_consumed=0,
|
||||
))
|
||||
|
||||
if db.query(Avatar).count() == 0:
|
||||
avatar = Avatar(
|
||||
name="我的数字分身",
|
||||
display_name="会会助手",
|
||||
description="我是您的AI数字分身,可以帮您管理日程、回复消息、处理任务。",
|
||||
emoji="🤖",
|
||||
status="active",
|
||||
token_balance=0,
|
||||
config={
|
||||
"replyStyle": "professional",
|
||||
"creativity": 50,
|
||||
"rigor": 50,
|
||||
"humor": 30,
|
||||
"responseLength": "medium",
|
||||
"systemPrompt": "",
|
||||
},
|
||||
)
|
||||
db.add(avatar)
|
||||
db.commit()
|
||||
db.refresh(avatar)
|
||||
|
||||
if db.query(Authorization).count() == 0:
|
||||
auths = [
|
||||
Authorization(avatar_id=avatar.id, target_type="application", target_name="微信小程序", permissions=["read", "reply"], status="active"),
|
||||
Authorization(avatar_id=avatar.id, target_type="user", target_name="张三", permissions=["read"], status="active"),
|
||||
Authorization(avatar_id=avatar.id, target_type="organization", target_name="产品团队", permissions=["read", "edit"], status="inactive"),
|
||||
]
|
||||
db.add_all(auths)
|
||||
|
||||
if db.query(Organization).count() == 0:
|
||||
orgs = [
|
||||
Organization(name="会会增长团队", description="负责会会产品的增长与运营", emoji="🚀", org_type="team", member_count=12),
|
||||
Organization(name="AI 实验室", description="探索前沿 AI 能力", emoji="💡", org_type="company", member_count=8),
|
||||
]
|
||||
db.add_all(orgs)
|
||||
|
||||
db.commit()
|
||||
release_stale_reservations(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
global takeover_scheduler
|
||||
|
||||
init_db()
|
||||
seed()
|
||||
|
||||
# Release stale resources when startup is invoked again by a reload/test.
|
||||
stop_takeover_scheduler()
|
||||
stop_maintenance_scheduler()
|
||||
try:
|
||||
start_maintenance_scheduler()
|
||||
except Exception as exc:
|
||||
stop_maintenance_scheduler()
|
||||
logger.warning(
|
||||
"Failed to initialize chat attachment cleanup, app will continue: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
# --- Takeover scheduler ---
|
||||
try:
|
||||
# BOXIM production endpoints are intentionally separate from the login API.
|
||||
from services.boxim_client import BoxIMClient
|
||||
boxim_config = {
|
||||
"HUIHUI_PLATFORM_BASE_URL": os.getenv(
|
||||
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
|
||||
),
|
||||
"BOXIM_API_BASE_URL": os.getenv(
|
||||
"BOXIM_API_BASE_URL", "https://im.99hui.com/api"
|
||||
),
|
||||
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
|
||||
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
|
||||
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
|
||||
"BOXIM_TIMEOUT_SECONDS": os.getenv("BOXIM_TIMEOUT_SECONDS", "20"),
|
||||
}
|
||||
boxim_client = BoxIMClient(boxim_config)
|
||||
|
||||
from services.takeover_service import TakeoverService
|
||||
takeover_service = TakeoverService(
|
||||
SessionLocal,
|
||||
boxim_client,
|
||||
poll_concurrency=int(os.getenv("BOXIM_POLL_CONCURRENCY", "8")),
|
||||
max_message_age_seconds=int(
|
||||
os.getenv("BOXIM_MAX_MESSAGE_AGE_SECONDS", "600")
|
||||
),
|
||||
)
|
||||
|
||||
poll_interval = max(0.5, float(os.getenv("BOXIM_POLL_INTERVAL_SECONDS", "1")))
|
||||
takeover_scheduler = AsyncIOScheduler()
|
||||
takeover_scheduler.add_job(
|
||||
takeover_service.poll_messages,
|
||||
trigger=IntervalTrigger(seconds=poll_interval),
|
||||
id="takeover_message_poll",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
process_interval = max(
|
||||
0.25, float(os.getenv("TAKEOVER_PROCESS_INTERVAL_SECONDS", "0.5"))
|
||||
)
|
||||
takeover_scheduler.add_job(
|
||||
takeover_service.process_reply_tasks,
|
||||
trigger=IntervalTrigger(seconds=process_interval),
|
||||
id="takeover_reply_process",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
takeover_scheduler.start()
|
||||
logger.info(
|
||||
"BOXIM takeover scheduler started (poll=%ss, process=%ss)",
|
||||
poll_interval,
|
||||
process_interval,
|
||||
)
|
||||
except Exception as e:
|
||||
stop_takeover_scheduler()
|
||||
logger.warning(f"Failed to initialize takeover scheduler, app will continue without it: {e}")
|
||||
|
||||
|
||||
def stop_takeover_scheduler():
|
||||
global takeover_scheduler
|
||||
|
||||
if takeover_scheduler is not None:
|
||||
try:
|
||||
if takeover_scheduler.running:
|
||||
takeover_scheduler.shutdown(wait=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to stop takeover scheduler cleanly: {e}")
|
||||
finally:
|
||||
takeover_scheduler = None
|
||||
|
||||
|
||||
def purge_expired_chat_attachments_job():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = purge_expired_chat_attachments(db)
|
||||
if count:
|
||||
logger.info("Purged %s expired chat image attachment(s)", count)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.warning("Failed to purge expired chat image attachments: %s", exc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def start_maintenance_scheduler():
|
||||
global maintenance_scheduler
|
||||
|
||||
purge_expired_chat_attachments_job()
|
||||
interval_minutes = max(
|
||||
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
|
||||
)
|
||||
maintenance_scheduler = AsyncIOScheduler()
|
||||
maintenance_scheduler.add_job(
|
||||
purge_expired_chat_attachments_job,
|
||||
trigger=IntervalTrigger(minutes=interval_minutes),
|
||||
id="chat_attachment_cleanup",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
maintenance_scheduler.start()
|
||||
|
||||
|
||||
def stop_maintenance_scheduler():
|
||||
global maintenance_scheduler
|
||||
|
||||
if maintenance_scheduler is not None:
|
||||
try:
|
||||
if maintenance_scheduler.running:
|
||||
maintenance_scheduler.shutdown(wait=False)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop maintenance scheduler cleanly: %s", exc)
|
||||
finally:
|
||||
maintenance_scheduler = None
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def on_shutdown():
|
||||
stop_takeover_scheduler()
|
||||
stop_maintenance_scheduler()
|
||||
@@ -0,0 +1,418 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
return dt.isoformat() if dt else None
|
||||
|
||||
|
||||
class Avatar(Base):
|
||||
__tablename__ = "avatars"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
owner_id = Column(String, default="", index=True) # 归属用户(会会 huihui_user_id);空=未归属/种子数据
|
||||
name = Column(String, nullable=False)
|
||||
display_name = Column(String, default="")
|
||||
description = Column(Text, default="")
|
||||
photo_url = Column(String, default="")
|
||||
emoji = Column(String, default="🤖")
|
||||
status = Column(String, default="active") # active | inactive | training
|
||||
share_token = Column(String, nullable=True, default=None, unique=True, index=True) # 对外分享使用的不可猜测令牌
|
||||
token_balance = Column(Integer, default=0)
|
||||
config = Column(JSON, default=dict)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"ownerId": self.owner_id,
|
||||
"name": self.name,
|
||||
"displayName": self.display_name,
|
||||
"description": self.description,
|
||||
"photoUrl": self.photo_url,
|
||||
"emoji": self.emoji,
|
||||
"status": self.status,
|
||||
"shareToken": self.share_token or "",
|
||||
"tokenBalance": self.token_balance,
|
||||
"config": self.config or {},
|
||||
"createdAt": _iso(self.created_at),
|
||||
"updatedAt": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class Authorization(Base):
|
||||
__tablename__ = "authorizations"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
target_type = Column(String, default="user") # user | organization | application
|
||||
target_id = Column(String, default="")
|
||||
target_name = Column(String, default="")
|
||||
permissions = Column(JSON, default=list)
|
||||
status = Column(String, default="active") # active | inactive
|
||||
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
|
||||
takeover_mode = Column(String, default="immediate") # immediate | delayed
|
||||
takeover_delay_seconds = Column(Integer, default=180) # 延迟秒数,默认 3 分钟
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"targetType": self.target_type,
|
||||
"targetId": self.target_id,
|
||||
"targetName": self.target_name,
|
||||
"permissions": self.permissions or [],
|
||||
"status": self.status,
|
||||
"takeoverEnabled": self.takeover_enabled,
|
||||
"takeoverMode": self.takeover_mode,
|
||||
"takeoverDelaySeconds": self.takeover_delay_seconds,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class TakeoverCursor(Base):
|
||||
"""Durable BOXIM polling cursor for one avatar owner."""
|
||||
|
||||
__tablename__ = "takeover_cursors"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, unique=True, index=True)
|
||||
owner_id = Column(String, nullable=False, default="", index=True)
|
||||
boxim_owner_id = Column(String, default="")
|
||||
last_message_id = Column(String, default="0")
|
||||
initialized = Column(Boolean, default=False)
|
||||
last_polled_at = Column(DateTime)
|
||||
last_error = Column(Text, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TakeoverMessage(Base):
|
||||
"""BOXIM message receipt used for audit, deduplication, and chat context."""
|
||||
|
||||
__tablename__ = "takeover_messages"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "boxim_message_id", name="uq_takeover_message_owner_boxim"),
|
||||
Index("ix_takeover_message_conversation", "owner_id", "peer_id", "send_time"),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, index=True)
|
||||
owner_id = Column(String, nullable=False, index=True)
|
||||
boxim_message_id = Column(String, nullable=False)
|
||||
boxim_local_id = Column(String, nullable=True)
|
||||
peer_id = Column(String, nullable=False, index=True)
|
||||
direction = Column(String, nullable=False) # incoming | outgoing
|
||||
message_type = Column(Integer, default=0)
|
||||
content = Column(Text, default="")
|
||||
attachment_id = Column(String, nullable=True)
|
||||
is_avatar = Column(Boolean, default=False)
|
||||
send_time = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class TakeoverReplyTask(Base):
|
||||
"""Restart-safe delayed BOXIM reply task."""
|
||||
|
||||
__tablename__ = "takeover_reply_tasks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "trigger_message_id", name="uq_takeover_task_owner_trigger"),
|
||||
Index("ix_takeover_task_due", "status", "scheduled_at"),
|
||||
Index("ix_takeover_task_conversation", "owner_id", "peer_id", "status"),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, index=True)
|
||||
owner_id = Column(String, nullable=False, index=True)
|
||||
peer_id = Column(String, nullable=False, index=True)
|
||||
trigger_message_id = Column(String, nullable=False)
|
||||
source_message_ids = Column(JSON, default=list)
|
||||
prompt = Column(Text, default="")
|
||||
response_text = Column(Text, default="")
|
||||
status = Column(String, default="pending")
|
||||
scheduled_at = Column(DateTime, nullable=False)
|
||||
locked_at = Column(DateTime)
|
||||
sent_at = Column(DateTime)
|
||||
attempts = Column(Integer, default=0)
|
||||
last_error = Column(Text, default="")
|
||||
cancel_reason = Column(String, default="")
|
||||
boxim_local_id = Column(String, nullable=False)
|
||||
boxim_sent_message_id = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, default="")
|
||||
emoji = Column(String, default="🏢")
|
||||
org_type = Column(String, default="team") # team | company | community
|
||||
role = Column(String, default="admin") # admin | member | viewer
|
||||
member_count = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"emoji": self.emoji,
|
||||
"type": self.org_type,
|
||||
"role": self.role,
|
||||
"memberCount": self.member_count,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeDoc(Base):
|
||||
__tablename__ = "knowledge_docs"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
filename = Column(String, default="")
|
||||
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
||||
file_size = Column(Integer, default=0)
|
||||
file_url = Column(String, default="")
|
||||
status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
|
||||
vectorized = Column(Boolean, default=False) # 是否已向量化
|
||||
embedding_model = Column(String, default="") # 向量模型标识
|
||||
chunk_count = Column(Integer, default=0) # 切片数量
|
||||
vectorized_at = Column(DateTime) # 向量化时间
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"filename": self.filename,
|
||||
"fileType": self.file_type,
|
||||
"fileSize": self.file_size,
|
||||
"fileUrl": self.file_url,
|
||||
"status": self.status,
|
||||
"vectorized": bool(self.vectorized),
|
||||
"embeddingModel": self.embedding_model,
|
||||
"chunkCount": self.chunk_count,
|
||||
"vectorizedAt": _iso(self.vectorized_at),
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class QAPair(Base):
|
||||
__tablename__ = "qa_pairs"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="")
|
||||
question = Column(Text, default="")
|
||||
answer = Column(Text, default="")
|
||||
enabled = Column(Boolean, default=True) # 是否启用(关闭后不参与作答)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"question": self.question,
|
||||
"answer": self.answer,
|
||||
"enabled": bool(self.enabled),
|
||||
"createdAt": _iso(self.created_at),
|
||||
"updatedAt": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeChunk(Base):
|
||||
__tablename__ = "knowledge_chunks"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
doc_id = Column(String, default="") # 关联 KnowledgeDoc.id
|
||||
avatar_id = Column(String, default="")
|
||||
content = Column(Text, default="") # 切片文本
|
||||
vector = Column(Text, default="") # JSON 编码的向量
|
||||
chunk_index = Column(Integer, default=0)
|
||||
embedding_model = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"docId": self.doc_id,
|
||||
"avatarId": self.avatar_id,
|
||||
"content": self.content,
|
||||
"chunkIndex": self.chunk_index,
|
||||
"embeddingModel": self.embedding_model,
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class ChatAttachment(Base):
|
||||
"""Private, avatar-scoped result of one chat image analysis."""
|
||||
|
||||
__tablename__ = "chat_attachments"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
avatar_id = Column(String, nullable=False, default="", index=True)
|
||||
uploader_kind = Column(String, default="owner") # owner | public | boxim
|
||||
filename = Column(String, default="")
|
||||
mime_type = Column(String, default="")
|
||||
file_size = Column(Integer, default=0)
|
||||
status = Column(String, default="processing") # processing | ready | failed
|
||||
category = Column(String, default="general_image")
|
||||
summary = Column(Text, default="")
|
||||
extracted_text = Column(Text, default="")
|
||||
structured_data = Column(JSON, default=dict)
|
||||
warning = Column(Text, default="")
|
||||
vision_model = Column(String, default="")
|
||||
ocr_model = Column(String, default="")
|
||||
used_at = Column(DateTime)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"avatarId": self.avatar_id,
|
||||
"filename": self.filename,
|
||||
"mimeType": self.mime_type,
|
||||
"fileSize": self.file_size,
|
||||
"status": self.status,
|
||||
"category": self.category,
|
||||
"summary": self.summary,
|
||||
"warning": self.warning,
|
||||
"expiresAt": _iso(self.expires_at),
|
||||
"createdAt": _iso(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class TokenAccount(Base):
|
||||
__tablename__ = "token_account"
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(String, nullable=False, default="", index=True)
|
||||
balance = Column(BigInteger, default=1_000_000)
|
||||
total_granted = Column(BigInteger, default=1_000_000)
|
||||
total_consumed = Column(BigInteger, default=0)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TokenUsage(Base):
|
||||
__tablename__ = "token_usage"
|
||||
__table_args__ = (
|
||||
Index("ix_token_usage_user_created", "user_id", "created_at"),
|
||||
Index("ix_token_usage_avatar_created", "avatar_id", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
avatar_id = Column(String, nullable=False, default="", index=True)
|
||||
source = Column(String, nullable=False, default="chat")
|
||||
model = Column(String, default="")
|
||||
status = Column(String, nullable=False, default="reserved")
|
||||
reserved_tokens = Column(BigInteger, default=0)
|
||||
prompt_tokens = Column(BigInteger, default=0)
|
||||
completion_tokens = Column(BigInteger, default=0)
|
||||
total_tokens = Column(BigInteger, default=0)
|
||||
balance_after = Column(BigInteger, default=0)
|
||||
failure_reason = Column(String, default="")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
settled_at = Column(DateTime)
|
||||
|
||||
|
||||
class TokenPlan(Base):
|
||||
__tablename__ = "token_plans"
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, default="")
|
||||
amount = Column(BigInteger, default=0)
|
||||
price = Column(Float, default=0)
|
||||
badge = Column(String, default="")
|
||||
desc = Column(String, default="")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"amount": self.amount,
|
||||
"price": self.price,
|
||||
"badge": self.badge,
|
||||
"desc": self.desc,
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
|
||||
|
||||
__tablename__ = "users"
|
||||
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
huihui_user_id = Column(String, default="", index=True) # 会会 userId(唯一标识)
|
||||
phone = Column(String, default="", index=True)
|
||||
nickname = Column(String, default="")
|
||||
avatar_url = Column(String, default="")
|
||||
huihui_token = Column(String, default="") # 会会 access_token
|
||||
app_token = Column(String, default="") # 本系统会话 token
|
||||
last_login_at = Column(DateTime)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"huihuiUserId": self.huihui_user_id,
|
||||
"phone": self.phone,
|
||||
"nickname": self.nickname,
|
||||
"avatarUrl": self.avatar_url,
|
||||
"createdAt": _iso(self.created_at),
|
||||
"lastLoginAt": _iso(self.last_login_at),
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
python-multipart
|
||||
httpx
|
||||
pypdf
|
||||
python-docx
|
||||
openpyxl
|
||||
apscheduler>=3.10
|
||||
Pillow>=10.4
|
||||
@@ -0,0 +1,6 @@
|
||||
def ok(data=None, message="success"):
|
||||
return {"code": 200, "message": message, "data": data}
|
||||
|
||||
|
||||
def fail(message="error", code=400):
|
||||
return {"code": code, "message": message, "data": None}
|
||||
@@ -0,0 +1,404 @@
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask
|
||||
from responses import fail, ok
|
||||
from routers.avatars import _require_owned_avatar
|
||||
|
||||
router = APIRouter(tags=["授权"])
|
||||
|
||||
TARGET_TYPES = {"user", "organization", "application"}
|
||||
PERMISSION_ORDER = ("friend", "chat", "publish", "browse", "interact", "takeover")
|
||||
ALLOWED_PERMISSIONS = set(PERMISSION_ORDER)
|
||||
AVATAR_PERMISSION_ORDER = PERMISSION_ORDER
|
||||
AVATAR_PERMISSION_KEY = "authorizationPermissions"
|
||||
DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"]
|
||||
TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds"
|
||||
DEFAULT_TAKEOVER_DELAY_SECONDS = 180
|
||||
MIN_TAKEOVER_DELAY_SECONDS = 3
|
||||
MAX_TAKEOVER_DELAY_SECONDS = 86_400
|
||||
LEGACY_PERMISSION_MAP = {
|
||||
"read": "browse",
|
||||
"reply": "chat",
|
||||
"write": "publish",
|
||||
"edit": "publish",
|
||||
}
|
||||
|
||||
|
||||
def _read(payload: dict, camel_key: str, snake_key: str | None = None, default=None):
|
||||
if camel_key in payload:
|
||||
return payload[camel_key]
|
||||
if snake_key and snake_key in payload:
|
||||
return payload[snake_key]
|
||||
return default
|
||||
|
||||
|
||||
def _clean_text(value, field_name: str, *, max_length: int) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError(f"{field_name}不能为空")
|
||||
if len(text) > max_length:
|
||||
raise ValueError(f"{field_name}不能超过 {max_length} 个字符")
|
||||
return text
|
||||
|
||||
|
||||
def _normalize_permissions(value) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("权限格式不正确")
|
||||
|
||||
normalized = []
|
||||
for raw in value:
|
||||
permission = LEGACY_PERMISSION_MAP.get(str(raw).strip(), str(raw).strip())
|
||||
if permission not in ALLOWED_PERMISSIONS:
|
||||
raise ValueError(f"不支持的权限:{raw}")
|
||||
if permission not in normalized:
|
||||
normalized.append(permission)
|
||||
|
||||
if not [item for item in normalized if item != "takeover"]:
|
||||
raise ValueError("请至少选择一项权限")
|
||||
return sorted(normalized, key=PERMISSION_ORDER.index)
|
||||
|
||||
|
||||
def _normalize_avatar_permissions(value) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("权限格式不正确")
|
||||
|
||||
normalized = []
|
||||
for raw in value:
|
||||
permission = LEGACY_PERMISSION_MAP.get(str(raw).strip(), str(raw).strip())
|
||||
if permission not in AVATAR_PERMISSION_ORDER:
|
||||
raise ValueError(f"不支持的权限:{raw}")
|
||||
if permission not in normalized:
|
||||
normalized.append(permission)
|
||||
return sorted(normalized, key=AVATAR_PERMISSION_ORDER.index)
|
||||
|
||||
|
||||
def _stored_avatar_permissions(avatar) -> list[str]:
|
||||
config = avatar.config or {}
|
||||
if AVATAR_PERMISSION_KEY not in config:
|
||||
return list(DEFAULT_AVATAR_PERMISSIONS)
|
||||
|
||||
stored = config.get(AVATAR_PERMISSION_KEY)
|
||||
if not isinstance(stored, list):
|
||||
return list(DEFAULT_AVATAR_PERMISSIONS)
|
||||
|
||||
permissions = []
|
||||
for raw in stored:
|
||||
permission = LEGACY_PERMISSION_MAP.get(str(raw).strip(), str(raw).strip())
|
||||
if permission in AVATAR_PERMISSION_ORDER and permission not in permissions:
|
||||
permissions.append(permission)
|
||||
return sorted(permissions, key=AVATAR_PERMISSION_ORDER.index)
|
||||
|
||||
|
||||
def _permission_settings_payload(avatar) -> dict:
|
||||
return {
|
||||
"avatarId": avatar.id,
|
||||
"permissions": _stored_avatar_permissions(avatar),
|
||||
"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:
|
||||
authorization = (
|
||||
db.query(Authorization)
|
||||
.filter(
|
||||
Authorization.id == authorization_id,
|
||||
Authorization.avatar_id == avatar_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=404, detail="授权不存在")
|
||||
return authorization
|
||||
|
||||
|
||||
def _duplicate_target(
|
||||
db: Session,
|
||||
avatar_id: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
*,
|
||||
exclude_id: str | None = None,
|
||||
):
|
||||
query = db.query(Authorization).filter(
|
||||
Authorization.avatar_id == avatar_id,
|
||||
Authorization.target_type == target_type,
|
||||
Authorization.target_id == target_id,
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.filter(Authorization.id != exclude_id)
|
||||
return query.first()
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}/permission-settings")
|
||||
def get_permission_settings(
|
||||
avatar_id: str,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||
return ok(_permission_settings_payload(avatar))
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/permission-settings")
|
||||
def update_permission_settings(
|
||||
avatar_id: str,
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||
if "permissions" not in payload and TAKEOVER_DELAY_KEY not in payload:
|
||||
return fail("缺少授权设置", 400)
|
||||
try:
|
||||
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:
|
||||
return fail(str(exc), 400)
|
||||
|
||||
previous_permissions = _stored_avatar_permissions(avatar)
|
||||
avatar.config = {
|
||||
**(avatar.config or {}),
|
||||
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()
|
||||
if cursor and "takeover" in permissions and "takeover" not in previous_permissions:
|
||||
cursor.initialized = False
|
||||
cursor.last_message_id = "0"
|
||||
cursor.last_error = ""
|
||||
elif cursor and "takeover" not in permissions:
|
||||
cursor.last_error = ""
|
||||
|
||||
if "takeover" not in permissions:
|
||||
tasks = (
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.avatar_id == avatar.id,
|
||||
TakeoverReplyTask.status.in_(("pending", "generating", "ready", "sending")),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for task in tasks:
|
||||
task.status = "cancelled"
|
||||
task.cancel_reason = "takeover_disabled"
|
||||
task.locked_at = None
|
||||
db.commit()
|
||||
db.refresh(avatar)
|
||||
response = _permission_settings_payload(avatar)
|
||||
response["disabledAvatarIds"] = disabled_avatar_ids
|
||||
return ok(response, "授权设置已保存")
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}/authorizations")
|
||||
def list_auth(
|
||||
avatar_id: str,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
items = (
|
||||
db.query(Authorization)
|
||||
.filter(Authorization.avatar_id == avatar_id)
|
||||
.order_by(Authorization.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return ok([item.to_dict() for item in items])
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/authorizations")
|
||||
def create_auth(
|
||||
avatar_id: str,
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
try:
|
||||
target_type = _clean_text(
|
||||
_read(payload, "targetType", "target_type", "user"),
|
||||
"授权类型",
|
||||
max_length=24,
|
||||
)
|
||||
if target_type not in TARGET_TYPES:
|
||||
return fail("授权类型不正确", 400)
|
||||
target_id = _clean_text(
|
||||
_read(payload, "targetId", "target_id"),
|
||||
"对象标识",
|
||||
max_length=120,
|
||||
)
|
||||
target_name = _clean_text(
|
||||
_read(payload, "targetName", "target_name"),
|
||||
"对象名称",
|
||||
max_length=50,
|
||||
)
|
||||
permissions = _normalize_permissions(payload.get("permissions", []))
|
||||
except ValueError as exc:
|
||||
return fail(str(exc), 400)
|
||||
|
||||
if _duplicate_target(db, avatar_id, target_type, target_id):
|
||||
return fail("该对象已在授权列表中,可直接编辑现有授权", 409)
|
||||
|
||||
item = Authorization(
|
||||
avatar_id=avatar_id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
target_name=target_name,
|
||||
permissions=permissions,
|
||||
status="active",
|
||||
takeover_enabled=False,
|
||||
takeover_mode="immediate",
|
||||
takeover_delay_seconds=DEFAULT_TAKEOVER_DELAY_SECONDS,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return ok(item.to_dict(), "授权已添加")
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/authorizations")
|
||||
def update_auth(
|
||||
avatar_id: str,
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
auth_id = payload.get("id") or _read(payload, "authorizationId", "authorization_id")
|
||||
if not auth_id:
|
||||
return fail("缺少授权 id", 400)
|
||||
|
||||
item = _require_authorization(db, avatar_id, str(auth_id))
|
||||
try:
|
||||
target_type = item.target_type
|
||||
target_id = item.target_id
|
||||
if "targetType" in payload or "target_type" in payload:
|
||||
target_type = _clean_text(
|
||||
_read(payload, "targetType", "target_type"),
|
||||
"授权类型",
|
||||
max_length=24,
|
||||
)
|
||||
if target_type not in TARGET_TYPES:
|
||||
return fail("授权类型不正确", 400)
|
||||
if "targetId" in payload or "target_id" in payload:
|
||||
target_id = _clean_text(
|
||||
_read(payload, "targetId", "target_id"),
|
||||
"对象标识",
|
||||
max_length=120,
|
||||
)
|
||||
if "targetName" in payload or "target_name" in payload:
|
||||
item.target_name = _clean_text(
|
||||
_read(payload, "targetName", "target_name"),
|
||||
"对象名称",
|
||||
max_length=50,
|
||||
)
|
||||
if "permissions" in payload:
|
||||
item.permissions = _normalize_permissions(payload["permissions"])
|
||||
except ValueError as exc:
|
||||
return fail(str(exc), 400)
|
||||
|
||||
if _duplicate_target(
|
||||
db,
|
||||
avatar_id,
|
||||
target_type,
|
||||
target_id,
|
||||
exclude_id=item.id,
|
||||
):
|
||||
return fail("该对象已在授权列表中", 409)
|
||||
|
||||
if "status" in payload:
|
||||
status = str(payload["status"] or "")
|
||||
if status not in ("active", "inactive"):
|
||||
return fail("授权状态不正确", 400)
|
||||
item.status = status
|
||||
|
||||
item.target_type = target_type
|
||||
item.target_id = target_id
|
||||
|
||||
permissions = list(item.permissions or [])
|
||||
chat_allowed = "chat" in permissions or "reply" in permissions
|
||||
if item.status != "active" or item.target_type != "user" or not chat_allowed:
|
||||
item.takeover_enabled = False
|
||||
item.permissions = [permission for permission in permissions if permission != "takeover"]
|
||||
elif item.takeover_enabled and "takeover" not in permissions:
|
||||
item.permissions = permissions + ["takeover"]
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return ok(item.to_dict(), "授权已更新")
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}/authorizations/{authorization_id}")
|
||||
def delete_auth(
|
||||
avatar_id: str,
|
||||
authorization_id: str,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
item = _require_authorization(db, avatar_id, authorization_id)
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return ok({"id": authorization_id}, "授权已删除")
|
||||
@@ -0,0 +1,161 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Body, Header, UploadFile, File, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from routers.knowledge import UPLOAD_DIR
|
||||
from models import (
|
||||
Authorization,
|
||||
Avatar,
|
||||
KnowledgeChunk,
|
||||
KnowledgeDoc,
|
||||
QAPair,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
User,
|
||||
)
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["分身"])
|
||||
ALLOWED_AVATAR_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
MAX_AVATAR_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
"""从 Authorization: Bearer <app_token> 解析当前登录用户"""
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not avatar:
|
||||
raise HTTPException(status_code=404, detail="分身不存在")
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该分身")
|
||||
return avatar
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/photo")
|
||||
async def upload_avatar_photo(
|
||||
avatar_id: str,
|
||||
file: UploadFile = File(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
extension = os.path.splitext(file.filename or "")[1].lower()
|
||||
if extension not in ALLOWED_AVATAR_EXTENSIONS or not (file.content_type or "").startswith("image/"):
|
||||
return fail("仅支持 JPG、PNG、WebP 或 GIF 图片", code=400)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_AVATAR_BYTES:
|
||||
return fail("头像图片不能超过 5MB", code=400)
|
||||
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||
os.makedirs(avatar_dir, exist_ok=True)
|
||||
stored_name = f"avatar-{uuid.uuid4().hex}{extension}"
|
||||
with open(os.path.join(avatar_dir, stored_name), "wb") as stream:
|
||||
stream.write(content)
|
||||
return ok({"photoUrl": f"/api/files/{avatar_id}/{stored_name}"})
|
||||
|
||||
|
||||
@router.get("/avatar")
|
||||
def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
# 仅返回当前登录用户自己的分身;未登录返回空,避免看到种子/他人数据
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
return ok({"data": [], "total": 0})
|
||||
q = db.query(Avatar).filter(Avatar.owner_id == user.huihui_user_id)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(Avatar.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return ok({"data": [a.to_dict() for a in items], "total": total})
|
||||
|
||||
|
||||
@router.get("/avatar/{avatar_id}")
|
||||
def get_avatar(
|
||||
avatar_id: str,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return ok(_require_owned_avatar(db, avatar_id, authorization).to_dict())
|
||||
|
||||
|
||||
@router.post("/avatar")
|
||||
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
a = Avatar(
|
||||
owner_id=user.huihui_user_id,
|
||||
name=payload.get("name", "未命名分身"),
|
||||
display_name=payload.get("displayName", "") or payload.get("display_name", ""),
|
||||
description=payload.get("description", ""),
|
||||
photo_url=payload.get("photoUrl", "") or payload.get("photo_url", ""),
|
||||
emoji=payload.get("emoji", "🤖"),
|
||||
status=payload.get("status", "active"),
|
||||
token_balance=payload.get("tokenBalance", 0),
|
||||
config=payload.get("config", {}) or {},
|
||||
)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}")
|
||||
def update_avatar(
|
||||
avatar_id: str,
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
a = _require_owned_avatar(db, avatar_id, authorization)
|
||||
mapping = {
|
||||
"displayName": "display_name",
|
||||
"photoUrl": "photo_url",
|
||||
"tokenBalance": "token_balance",
|
||||
}
|
||||
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
|
||||
if key in payload:
|
||||
col = mapping.get(key, 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.refresh(a)
|
||||
return ok(a.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}")
|
||||
def delete_avatar(
|
||||
avatar_id: str,
|
||||
authorization: str = Header(None),
|
||||
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(KnowledgeChunk).filter(KnowledgeChunk.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(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.commit()
|
||||
return ok({"success": True})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
会会短信验证码登录代理(真实开放平台对接)
|
||||
──────────────────────────────────────────────
|
||||
严格按会会开放平台 sign.js 签名范式:
|
||||
- 公共字段 appId/accessId/timestamp(12小时制hh)/signType/signVersion/accessSecret/nonce 合并业务参数
|
||||
- 过滤空值 → 字典序排序 → k=v& 拼接 → 末尾追加 accessSecret=secretKey → MD5/SHA256 大写
|
||||
- 认证服务基址 / appId / accessId / accessSecret / clientCode 走环境变量
|
||||
真实端点(来自 fat-open 网关 usercenter 的 Swagger):
|
||||
- 发送验证码:POST {BASE}{HUIHUI_SMS_SEND_PATH} 默认 /open/mobile/sms/code (query 参数)
|
||||
- 短信登录: POST {BASE}{HUIHUI_SMS_LOGIN_PATH} 默认 /open/login/token,loginType=code
|
||||
登录成功后建/链本地 users 表(按会会 userId 唯一),签发本系统 app_token 作为会话。
|
||||
无真实凭证时仍可走 DEV_MOCK 兜底联调。
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import random
|
||||
import string
|
||||
import hashlib
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# 会会网关按北京时间(Asia/Shanghai, UTC+8)校验时间戳,容器默认 UTC 会导致签名被拒。
|
||||
# 用固定 +8 偏移(不依赖 tzdata,slim 镜像缺 IANA 库时会抛 ZoneInfoNotFoundError)。
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
from database import get_db
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from responses import ok, fail
|
||||
from services.boxim_client import BoxIMClient, BoxIMError
|
||||
|
||||
router = APIRouter(tags=["会会账号"])
|
||||
|
||||
# ── 会会开放平台配置(环境变量)──
|
||||
AUTH_BASE_URL = os.getenv("HUIHUI_AUTH_BASE_URL", "https://fat-open.99hui.com/api/usercenter")
|
||||
APP_ID = os.getenv("HUIHUI_APP_ID", "")
|
||||
ACCESS_ID = os.getenv("HUIHUI_ACCESS_ID", "")
|
||||
ACCESS_SECRET = os.getenv("HUIHUI_ACCESS_SECRET", "")
|
||||
CLIENT_CODE = os.getenv("HUIHUI_CLIENT_CODE", "")
|
||||
SMS_SEND_PATH = os.getenv("HUIHUI_SMS_SEND_PATH", "/open/mobile/sms/code")
|
||||
SMS_LOGIN_PATH = os.getenv("HUIHUI_SMS_LOGIN_PATH", "/open/login/token")
|
||||
|
||||
# 临时开发态:无真实会会凭证时,本地模拟短信收发,便于端到端联调。
|
||||
DEV_MOCK = os.getenv("HUIHUI_DEV_MOCK", "false").lower() in ("1", "true", "yes")
|
||||
_mock_codes: dict[str, tuple[str, float]] = {}
|
||||
_MOCK_TTL = 300
|
||||
|
||||
|
||||
# ── 签名体系(完全对应 sign.js)──
|
||||
def _get_nonce() -> str:
|
||||
# 与会会 sign.js 一致:base36 随机串
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
||||
|
||||
|
||||
def _get_timestamp() -> str:
|
||||
# 与会会 sign.js 一致:yyyyMMddHHmmss(24 小时制大写 HH)。
|
||||
# 实测:会会 common.format 走 24 小时制,用 12 小时制(%I)会被网关判"签名验证失败"。
|
||||
# 必须用北京时间,否则容器(UTC)生成的时间戳与会会网关校验窗口偏差 8h 被拒。
|
||||
return datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str:
|
||||
SIGN_KEY = "signature"
|
||||
SECRET_KEY = "accessSecret"
|
||||
keys = sorted(params.keys())
|
||||
parts = []
|
||||
for k in keys:
|
||||
if k in (SIGN_KEY, SECRET_KEY):
|
||||
continue
|
||||
v = params.get(k)
|
||||
if v is None or v == "" or v == []:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
continue
|
||||
parts.append(f"{k}={v}")
|
||||
sign_str = "&".join(parts) + f"&{SECRET_KEY}={secret_key}"
|
||||
if sign_type.upper() == "SHA256":
|
||||
return hashlib.sha256(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def _build_form(extra: dict) -> dict:
|
||||
sign_type = "MD5"
|
||||
sign_version = "1.0"
|
||||
secret = ACCESS_SECRET
|
||||
base = {
|
||||
"appId": APP_ID,
|
||||
"accessId": ACCESS_ID,
|
||||
"timestamp": _get_timestamp(),
|
||||
"signType": sign_type,
|
||||
"signVersion": sign_version,
|
||||
"accessSecret": secret,
|
||||
"nonce": _get_nonce(),
|
||||
}
|
||||
base.update(extra)
|
||||
signature = _make_sign(base, secret, sign_type) if secret else ""
|
||||
base["signature"] = signature
|
||||
base.pop("accessSecret", None) # 不发送密钥
|
||||
return base
|
||||
|
||||
|
||||
def _pick(d: dict, *keys, default=""):
|
||||
for k in keys:
|
||||
if d.get(k) not in (None, ""):
|
||||
return d[k]
|
||||
return default
|
||||
|
||||
|
||||
def _cfg_ready() -> bool:
|
||||
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
|
||||
|
||||
|
||||
def _create_boxim_client() -> BoxIMClient:
|
||||
return BoxIMClient({
|
||||
"HUIHUI_PLATFORM_BASE_URL": os.getenv(
|
||||
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
|
||||
),
|
||||
"BOXIM_API_BASE_URL": os.getenv("BOXIM_API_BASE_URL", "https://im.99hui.com/api"),
|
||||
"HUIHUI_APP_ID": APP_ID,
|
||||
"HUIHUI_ACCESS_ID": ACCESS_ID,
|
||||
"HUIHUI_ACCESS_SECRET": ACCESS_SECRET,
|
||||
"BOXIM_TIMEOUT_SECONDS": os.getenv("BOXIM_TIMEOUT_SECONDS", "20"),
|
||||
})
|
||||
|
||||
|
||||
def _call_huihui(path: str, params: dict, as_query: bool = False):
|
||||
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
|
||||
url = f"{AUTH_BASE_URL}{path}"
|
||||
with httpx.Client(timeout=30, follow_redirects=True) as c:
|
||||
if as_query:
|
||||
resp = c.post(url, params=params)
|
||||
else:
|
||||
resp = c.post(url, data=params)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return False, {"message": f"会会返回非JSON: {resp.text[:200]}"}, resp.status_code
|
||||
# 会会统一包装 {code, message, data}
|
||||
code = data.get("code")
|
||||
if resp.status_code == 200 and code in (0, 200, "0", "200"):
|
||||
return True, data, resp.status_code
|
||||
return False, data, resp.status_code
|
||||
|
||||
|
||||
@router.post("/huihui/sms/send")
|
||||
def sms_send(body: dict = Body(...)):
|
||||
"""请求会会发送短信验证码(真实开放平台 /open/mobile/sms/code)"""
|
||||
phone = (body.get("phone") or "").strip()
|
||||
if not phone or not phone.isdigit() or len(phone) != 11:
|
||||
return fail("请输入正确的 11 位手机号", 400)
|
||||
|
||||
# 临时开发态:本地模拟发码
|
||||
if DEV_MOCK and not _cfg_ready():
|
||||
code = str(random.randint(100000, 999999))
|
||||
_mock_codes[phone] = (code, datetime.now().timestamp() + _MOCK_TTL)
|
||||
return ok({"sent": True, "devCode": code, "dev": True})
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会短信服务未配置(缺少 HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET)", 500)
|
||||
|
||||
# /open/mobile/sms/code:mobile 与公共字段均走 query
|
||||
form = _build_form({"mobile": phone})
|
||||
ok_flag, data, status = _call_huihui(SMS_SEND_PATH, form, as_query=True)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"发送失败(HTTP {status})", 502)
|
||||
return ok({"sent": True})
|
||||
|
||||
|
||||
@router.post("/huihui/sms/login")
|
||||
def sms_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""短信验证码登录:调会会 /open/login/token(loginType=code) 换取 access_token + userId,落库并签发本系统会话"""
|
||||
phone = (body.get("phone") or "").strip()
|
||||
code = (body.get("code") or "").strip()
|
||||
if not phone or not code:
|
||||
return fail("手机号或验证码缺失", 400)
|
||||
|
||||
# 临时开发态:本地校验模拟码
|
||||
if DEV_MOCK and not _cfg_ready():
|
||||
rec = _mock_codes.get(phone)
|
||||
if not rec or rec[1] < datetime.now().timestamp():
|
||||
return fail("验证码已失效,请重新获取", 401)
|
||||
if rec[0] != code:
|
||||
return fail("验证码错误", 401)
|
||||
_mock_codes.pop(phone, None)
|
||||
return _issue_session(db, phone, {
|
||||
"userId": f"dev_{phone}",
|
||||
"nickname": f"会会用户{phone[-4:]}",
|
||||
"avatarUrl": "",
|
||||
"token": f"dev_token_{phone}",
|
||||
})
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
extra = {
|
||||
"username": phone,
|
||||
"password": code,
|
||||
"loginType": "code",
|
||||
"grantType": "password",
|
||||
"isRegister": "true",
|
||||
}
|
||||
if CLIENT_CODE:
|
||||
extra["clientCode"] = CLIENT_CODE
|
||||
form = _build_form(extra)
|
||||
ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"登录失败(HTTP {status})", 401)
|
||||
|
||||
raw = data.get("data") or {}
|
||||
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||
huihui_token = (
|
||||
_pick(raw, "accessToken", "access_token", "token")
|
||||
or _pick(user_info, "accessToken", "access_token", "token")
|
||||
)
|
||||
huihui_user_id = (
|
||||
_pick(raw, "openid", "userId", "uid", "openId", "id")
|
||||
or _pick(user_info, "openid", "userId", "uid", "openId", "id")
|
||||
)
|
||||
nickname = (
|
||||
_pick(raw, "nickName", "nickname", "name", "userName")
|
||||
or _pick(user_info, "nickName", "nickname", "name", "userName")
|
||||
)
|
||||
avatar_url = (
|
||||
_pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
)
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
return _issue_session(db, phone, {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/huihui/pwd/login")
|
||||
def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""账号密码登录:调会会 /open/login/token(loginType=password) 换取 access_token + userId,落库并签发本系统会话。
|
||||
与 news_service 既有对接完全一致:username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false。
|
||||
"""
|
||||
account = (body.get("account") or "").strip()
|
||||
password = (body.get("password") or "").strip()
|
||||
if not account or not password:
|
||||
return fail("账号或密码缺失", 400)
|
||||
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
extra = {
|
||||
"username": account,
|
||||
"password": password,
|
||||
"loginType": "password",
|
||||
"grantType": "password",
|
||||
"isRegister": "false",
|
||||
}
|
||||
if CLIENT_CODE:
|
||||
extra["clientCode"] = CLIENT_CODE
|
||||
form = _build_form(extra)
|
||||
ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False)
|
||||
if not ok_flag:
|
||||
return fail(data.get("message") or f"登录失败(HTTP {status})", 401)
|
||||
|
||||
raw = data.get("data") or {}
|
||||
user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {}
|
||||
huihui_token = (
|
||||
_pick(raw, "accessToken", "access_token", "token")
|
||||
or _pick(user_info, "accessToken", "access_token", "token")
|
||||
)
|
||||
huihui_user_id = (
|
||||
_pick(raw, "openid", "userId", "uid", "openId", "id")
|
||||
or _pick(user_info, "openid", "userId", "uid", "openId", "id")
|
||||
)
|
||||
nickname = (
|
||||
_pick(raw, "nickName", "nickname", "name", "userName")
|
||||
or _pick(user_info, "nickName", "nickname", "name", "userName")
|
||||
)
|
||||
avatar_url = (
|
||||
_pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl")
|
||||
)
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
# 账号即手机号时记录,便于资料展示;非手机号(用户名)则不覆盖已有 phone
|
||||
phone = account if (account.isdigit() and len(account) == 11) else ""
|
||||
return _issue_session(db, phone, {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/huihui/token/login")
|
||||
async def token_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||
"""Validate a production Huihui token through BOXIM and issue an app session."""
|
||||
huihui_token = (body.get("token") or "").strip()
|
||||
if not huihui_token or len(huihui_token) > 8192:
|
||||
return fail("会会登录凭证无效或已过期", 401)
|
||||
if not _cfg_ready():
|
||||
return fail("会会登录服务未配置", 500)
|
||||
|
||||
client = _create_boxim_client()
|
||||
try:
|
||||
token_data = await client.exchange_access_token(huihui_token)
|
||||
profile = await client.get_self(token_data["accessToken"])
|
||||
except BoxIMError as exc:
|
||||
if exc.auth_error:
|
||||
return fail("会会登录凭证无效或已过期", 401)
|
||||
return fail("会会登录服务暂时不可用,请稍后重试", 502)
|
||||
|
||||
# BOXIM's id is its internal IM id. Account ownership must use huihuiUserId.
|
||||
huihui_user_id = str(profile.get("huihuiUserId") or "").strip()
|
||||
if not huihui_user_id:
|
||||
return fail("会会未返回用户标识", 502)
|
||||
|
||||
phone = str(_pick(profile, "mobile", "phone", default="")).strip()
|
||||
nickname = str(_pick(profile, "nickName", "nickname", "name", "userName", default="")).strip()
|
||||
avatar_url = str(
|
||||
_pick(profile, "headImage", "headImageThumb", "avatar", "avatarUrl", default="")
|
||||
).strip()
|
||||
return _issue_session(
|
||||
db,
|
||||
phone,
|
||||
{
|
||||
"userId": huihui_user_id,
|
||||
"nickname": nickname,
|
||||
"avatarUrl": avatar_url,
|
||||
"token": huihui_token,
|
||||
},
|
||||
reuse_existing_session=True,
|
||||
)
|
||||
|
||||
|
||||
def _transfer_avatar_ownership(db: Session, old_owner_id: str, new_owner_id: str) -> int:
|
||||
"""Move one user's avatar-owned data to a replacement Huihui identity."""
|
||||
if not old_owner_id or old_owner_id == new_owner_id:
|
||||
return 0
|
||||
|
||||
avatar_ids = [
|
||||
avatar_id
|
||||
for (avatar_id,) in db.query(Avatar.id).filter(Avatar.owner_id == old_owner_id).all()
|
||||
]
|
||||
if not avatar_ids:
|
||||
return 0
|
||||
|
||||
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).update(
|
||||
{Avatar.owner_id: new_owner_id}, synchronize_session="fetch"
|
||||
)
|
||||
for model in (TakeoverCursor, TakeoverMessage, TakeoverReplyTask):
|
||||
db.query(model).filter(model.avatar_id.in_(avatar_ids)).update(
|
||||
{model.owner_id: new_owner_id}, synchronize_session="fetch"
|
||||
)
|
||||
return len(avatar_ids)
|
||||
|
||||
|
||||
def _find_or_link_user(db: Session, phone: str, huihui_user_id: str) -> User:
|
||||
"""Resolve an account and safely retain avatars across Huihui environments."""
|
||||
user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first()
|
||||
if not phone:
|
||||
return user or User(huihui_user_id=huihui_user_id)
|
||||
|
||||
same_phone_users = db.query(User).filter(User.phone == phone).all()
|
||||
|
||||
if user is None:
|
||||
# A unique verified-phone match is the same person whose upstream ID changed.
|
||||
if len(same_phone_users) == 1:
|
||||
user = same_phone_users[0]
|
||||
old_owner_id = user.huihui_user_id
|
||||
_transfer_avatar_ownership(db, old_owner_id, huihui_user_id)
|
||||
user.huihui_user_id = huihui_user_id
|
||||
return user
|
||||
return User(huihui_user_id=huihui_user_id)
|
||||
|
||||
legacy_users = [candidate for candidate in same_phone_users if candidate.id != user.id]
|
||||
current_avatar_count = db.query(Avatar).filter(Avatar.owner_id == huihui_user_id).count()
|
||||
if len(legacy_users) == 1 and current_avatar_count == 0:
|
||||
legacy_user = legacy_users[0]
|
||||
_transfer_avatar_ownership(db, legacy_user.huihui_user_id, huihui_user_id)
|
||||
legacy_user.app_token = ""
|
||||
legacy_user.huihui_token = ""
|
||||
db.add(legacy_user)
|
||||
return user
|
||||
|
||||
|
||||
def _issue_session(
|
||||
db: Session,
|
||||
phone: str,
|
||||
info: dict,
|
||||
*,
|
||||
reuse_existing_session: bool = False,
|
||||
):
|
||||
"""建/链本地用户并签发本系统会话 token"""
|
||||
huihui_user_id = info.get("userId", "")
|
||||
user = _find_or_link_user(db, phone, huihui_user_id)
|
||||
if phone:
|
||||
user.phone = phone
|
||||
if info.get("nickname"):
|
||||
user.nickname = info["nickname"]
|
||||
if info.get("avatarUrl"):
|
||||
user.avatar_url = info["avatarUrl"]
|
||||
user.huihui_token = info.get("token", "")
|
||||
if not reuse_existing_session or not user.app_token:
|
||||
user.app_token = uuid.uuid4().hex
|
||||
user.last_login_at = datetime.now()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
from services.token_billing import get_or_create_account
|
||||
get_or_create_account(db, user.id)
|
||||
|
||||
return ok({
|
||||
"token": user.app_token,
|
||||
"user": user.to_dict(),
|
||||
"huihui": {
|
||||
"userId": huihui_user_id,
|
||||
"nickname": info.get("nickname", ""),
|
||||
"avatarUrl": info.get("avatarUrl", ""),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.get("/huihui/me")
|
||||
def me(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""当前登录用户信息(Bearer app_token)"""
|
||||
if not authorization:
|
||||
return fail("未登录", 401)
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if not user:
|
||||
return fail("会话无效或已过期", 401)
|
||||
return ok(user.to_dict())
|
||||
|
||||
|
||||
@router.post("/huihui/logout")
|
||||
def logout(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
"""退出登录(作废 app_token)"""
|
||||
if authorization:
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
user = db.query(User).filter(User.app_token == token).first()
|
||||
if user:
|
||||
user.app_token = ""
|
||||
db.commit()
|
||||
return ok({"success": True})
|
||||
@@ -0,0 +1,306 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import KnowledgeDoc, QAPair, KnowledgeChunk, Avatar, User
|
||||
from responses import ok, fail
|
||||
import embeddings
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ALLOWED_EXT = {".md", ".txt", ".pdf", ".doc", ".docx", ".xlsx"}
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
class QAIn(BaseModel):
|
||||
question: str = ""
|
||||
answer: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class EnabledIn(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
def _doc_payload(doc: KnowledgeDoc) -> dict:
|
||||
payload = doc.to_dict()
|
||||
stored_name = os.path.basename(doc.file_url or "")
|
||||
stored_path = os.path.join(UPLOAD_DIR, doc.avatar_id, stored_name)
|
||||
payload["filePresent"] = bool(stored_name and os.path.isfile(stored_path))
|
||||
return payload
|
||||
|
||||
|
||||
def _resolve_user(authorization: str | None, db: Session):
|
||||
if not authorization:
|
||||
return None
|
||||
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||
return db.query(User).filter(User.app_token == token).first()
|
||||
|
||||
|
||||
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
||||
if not avatar:
|
||||
raise HTTPException(status_code=404, detail="avatar not found")
|
||||
user = _resolve_user(authorization, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该分身")
|
||||
return avatar
|
||||
|
||||
|
||||
# ---------------- Documents ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/docs")
|
||||
def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
docs = (
|
||||
db.query(KnowledgeDoc)
|
||||
.filter(KnowledgeDoc.avatar_id == avatar_id)
|
||||
.order_by(KnowledgeDoc.created_at.desc())
|
||||
.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])
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/docs")
|
||||
async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
return fail(f"不支持的文件类型:{ext or '空'},仅支持 md/txt/pdf/doc/docx/xlsx", code=400)
|
||||
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||
os.makedirs(avatar_dir, exist_ok=True)
|
||||
stored = f"{uuid.uuid4().hex}{ext}"
|
||||
path = os.path.join(avatar_dir, stored)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
return fail("文件不能超过 10MB", code=400)
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
doc = KnowledgeDoc(
|
||||
id=uuid.uuid4().hex,
|
||||
avatar_id=avatar_id,
|
||||
filename=file.filename,
|
||||
file_type=ext.lstrip("."),
|
||||
file_size=len(content),
|
||||
file_url=f"/api/files/{avatar_id}/{stored}",
|
||||
status="parsing",
|
||||
)
|
||||
|
||||
# Complete extraction and embedding before the first database commit so a
|
||||
# process restart cannot leave a permanent "parsing" row behind.
|
||||
try:
|
||||
text = embeddings.extract_text(path, ext)
|
||||
chunks = embeddings.chunk_text(text)
|
||||
if not chunks:
|
||||
raise ValueError("文档没有可建立索引的文字内容")
|
||||
vectors = embeddings.embed(chunks)
|
||||
if len(vectors) != len(chunks):
|
||||
raise ValueError("向量服务返回数量与文档分段不一致")
|
||||
doc.vectorized = True
|
||||
doc.embedding_model = embeddings.MODEL
|
||||
doc.chunk_count = len(chunks)
|
||||
doc.vectorized_at = datetime.now(timezone.utc)
|
||||
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.refresh(doc)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
doc.status = "failed"
|
||||
doc.vectorized = False
|
||||
doc.embedding_model = ""
|
||||
doc.chunk_count = 0
|
||||
doc.vectorized_at = None
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
logger.exception("knowledge vectorization failed for %s: %s", doc.id, exc)
|
||||
|
||||
return ok(_doc_payload(doc))
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}/knowledge/docs/{doc_id}")
|
||||
def delete_doc(avatar_id: str, doc_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
doc = (
|
||||
db.query(KnowledgeDoc)
|
||||
.filter(KnowledgeDoc.id == doc_id, KnowledgeDoc.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not doc:
|
||||
return fail("文档不存在", code=404)
|
||||
# 级联删除切片
|
||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc_id).delete()
|
||||
try:
|
||||
fp = os.path.join(UPLOAD_DIR, avatar_id, os.path.basename(doc.file_url))
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
except Exception:
|
||||
pass
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
return ok({"id": doc_id})
|
||||
|
||||
|
||||
# ---------------- 向量检索 ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/search")
|
||||
def search_knowledge(avatar_id: str, q: str = "", top_k: int = 5, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (q or "").strip()
|
||||
if not q:
|
||||
return ok([])
|
||||
chunks = (
|
||||
db.query(KnowledgeChunk)
|
||||
.filter(KnowledgeChunk.avatar_id == avatar_id)
|
||||
.all()
|
||||
)
|
||||
if not chunks:
|
||||
return ok([])
|
||||
qvec = embeddings.embed([q])[0]
|
||||
scored = []
|
||||
for c in chunks:
|
||||
try:
|
||||
vec = json.loads(c.vector)
|
||||
except Exception:
|
||||
continue
|
||||
scored.append((embeddings.cosine(qvec, vec), c))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = []
|
||||
for score, c in scored[: max(1, top_k)]:
|
||||
doc = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == c.doc_id).first()
|
||||
snippet = c.content[:120] + ("…" if len(c.content) > 120 else "")
|
||||
results.append(
|
||||
{
|
||||
"docId": c.doc_id,
|
||||
"filename": doc.filename if doc else "",
|
||||
"fileType": doc.file_type if doc else "",
|
||||
"snippet": snippet,
|
||||
"score": round(score, 4),
|
||||
}
|
||||
)
|
||||
return ok(results)
|
||||
|
||||
|
||||
# ---------------- Standard Q&A pairs ----------------
|
||||
@router.get("/avatar/{avatar_id}/knowledge/qa")
|
||||
def list_qa(avatar_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
items = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.avatar_id == avatar_id)
|
||||
.order_by(QAPair.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return ok([q.to_dict() for q in items])
|
||||
|
||||
|
||||
@router.post("/avatar/{avatar_id}/knowledge/qa")
|
||||
def create_qa(avatar_id: str, body: QAIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = QAPair(
|
||||
avatar_id=avatar_id,
|
||||
question=body.question,
|
||||
answer=body.answer,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
db.add(q)
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/knowledge/qa/{qa_id}")
|
||||
def update_qa(avatar_id: str, qa_id: str, body: QAIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
q.question = body.question
|
||||
q.answer = body.answer
|
||||
q.enabled = body.enabled
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/knowledge/qa/{qa_id}/enabled")
|
||||
def set_qa_enabled(avatar_id: str, qa_id: str, body: EnabledIn, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
q.enabled = bool(body.enabled)
|
||||
db.commit()
|
||||
db.refresh(q)
|
||||
return ok(q.to_dict())
|
||||
|
||||
|
||||
@router.delete("/avatar/{avatar_id}/knowledge/qa/{qa_id}")
|
||||
def delete_qa(avatar_id: str, qa_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
q = (
|
||||
db.query(QAPair)
|
||||
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
|
||||
.first()
|
||||
)
|
||||
if not q:
|
||||
return fail("问答对不存在", code=404)
|
||||
db.delete(q)
|
||||
db.commit()
|
||||
return ok({"id": qa_id})
|
||||
|
||||
|
||||
# ---------------- HuiHui user profile (mock; plug real interface via HUIHUI_USER_API) ----------------
|
||||
@router.get("/user/profile")
|
||||
def user_profile():
|
||||
# 接入真实会会接口:设置环境变量 HUIHUI_USER_API 后在此请求并映射字段
|
||||
api = os.getenv("HUIHUI_USER_API")
|
||||
if api:
|
||||
# TODO: 调用会会用户接口,返回 { userId, nickname, avatarUrl }
|
||||
pass
|
||||
return ok({
|
||||
"userId": "hh_10001",
|
||||
"nickname": "会会用户",
|
||||
"avatarUrl": "https://api.dicebear.com/7.x/initials/svg?seed=HuiHui&backgroundColor=F97316",
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Organization
|
||||
from responses import ok, fail
|
||||
|
||||
router = APIRouter(tags=["组织"])
|
||||
|
||||
|
||||
@router.get("/organizations")
|
||||
def list_orgs(page: int = 1, limit: int = 20, db: Session = Depends(get_db)):
|
||||
q = db.query(Organization)
|
||||
total = q.count()
|
||||
items = (
|
||||
q.order_by(Organization.created_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return ok({"data": [o.to_dict() for o in items], "total": total})
|
||||
|
||||
|
||||
@router.post("/organizations")
|
||||
def create_org(payload: dict = Body(...), db: Session = Depends(get_db)):
|
||||
o = Organization(
|
||||
name=payload.get("name", "未命名组织"),
|
||||
description=payload.get("desc", "") or payload.get("description", ""),
|
||||
emoji=payload.get("emoji", "🏢"),
|
||||
org_type=payload.get("type", "") or payload.get("orgType", "team"),
|
||||
)
|
||||
db.add(o)
|
||||
db.commit()
|
||||
db.refresh(o)
|
||||
return ok(o.to_dict())
|
||||
@@ -0,0 +1,153 @@
|
||||
"""数字分身 BOXIM 单聊接管 API。"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import TakeoverCursor, TakeoverReplyTask, User
|
||||
from responses import fail, ok
|
||||
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
|
||||
|
||||
router = APIRouter(tags=["分身接管"])
|
||||
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")
|
||||
def get_takeover_status(
|
||||
avatar_id: str,
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
||||
enabled = isinstance(permissions, list) and "takeover" in permissions
|
||||
reply_delay_seconds = _stored_takeover_delay(avatar)
|
||||
user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
|
||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
|
||||
pending_count = (
|
||||
db.query(TakeoverReplyTask)
|
||||
.filter(
|
||||
TakeoverReplyTask.avatar_id == avatar.id,
|
||||
TakeoverReplyTask.status.in_(("pending", "generating", "ready", "sending")),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
if cursor and cursor.last_error:
|
||||
status, message = "error", cursor.last_error
|
||||
elif not enabled:
|
||||
status, message = "disabled", "主动接管未开启"
|
||||
elif not user or not user.huihui_token:
|
||||
status, message = "needs_login", "请重新登录会会生产账号以连接 BOXIM"
|
||||
elif (
|
||||
cursor
|
||||
and cursor.initialized
|
||||
and cursor.last_polled_at
|
||||
# BOXIM offline-message reads can long-poll for about 20 seconds.
|
||||
and cursor.last_polled_at
|
||||
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
|
||||
):
|
||||
status, message = (
|
||||
"ready",
|
||||
f"BOXIM 已连接,收到私聊消息 {_delay_label(reply_delay_seconds)}后自动回复",
|
||||
)
|
||||
else:
|
||||
status, message = "connecting", "正在连接 BOXIM"
|
||||
|
||||
return ok(
|
||||
{
|
||||
"enabled": enabled,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"pendingCount": pending_count,
|
||||
"takeoverReplyDelaySeconds": reply_delay_seconds,
|
||||
"lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _has(payload: dict, camel_key: str, snake_key: str) -> bool:
|
||||
return camel_key in payload or snake_key in payload
|
||||
|
||||
|
||||
def _read(payload: dict, camel_key: str, snake_key: str, default=None):
|
||||
if camel_key in payload:
|
||||
return payload[camel_key]
|
||||
if snake_key in payload:
|
||||
return payload[snake_key]
|
||||
return default
|
||||
|
||||
|
||||
@router.put("/avatar/{avatar_id}/authorizations/takeover")
|
||||
def update_takeover_config(
|
||||
avatar_id: str,
|
||||
payload: dict = Body(...),
|
||||
authorization: str = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_owned_avatar(db, avatar_id, authorization)
|
||||
auth_id = _read(payload, "authorizationId", "authorization_id")
|
||||
if not auth_id:
|
||||
return fail("缺少 authorization_id", 400)
|
||||
|
||||
auth = _require_authorization(db, avatar_id, str(auth_id))
|
||||
enabled = bool(auth.takeover_enabled)
|
||||
mode = auth.takeover_mode or "immediate"
|
||||
delay = auth.takeover_delay_seconds or DEFAULT_TAKEOVER_DELAY_SECONDS
|
||||
|
||||
if _has(payload, "takeoverEnabled", "takeover_enabled"):
|
||||
raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled")
|
||||
if not isinstance(raw_enabled, bool):
|
||||
return fail("takeover_enabled 必须是布尔值", 400)
|
||||
enabled = raw_enabled
|
||||
|
||||
if _has(payload, "takeoverMode", "takeover_mode"):
|
||||
mode = _read(payload, "takeoverMode", "takeover_mode")
|
||||
if mode not in ("immediate", "delayed"):
|
||||
return fail("takeover_mode 必须是 immediate 或 delayed", 400)
|
||||
|
||||
if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"):
|
||||
delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds")
|
||||
if (
|
||||
isinstance(delay, bool)
|
||||
or not isinstance(delay, int)
|
||||
or not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS
|
||||
):
|
||||
return fail("延迟时间需在 3 秒到 24 小时之间", 400)
|
||||
|
||||
if enabled and auth.target_type != "user":
|
||||
return fail("本期仅支持对会会用户开启单聊接管", 400)
|
||||
if enabled and auth.status != "active":
|
||||
return fail("请先启用该授权,再开启聊天接管", 400)
|
||||
|
||||
permissions = list(auth.permissions or [])
|
||||
if enabled:
|
||||
if "chat" not in permissions and "reply" not in permissions:
|
||||
permissions.append("chat")
|
||||
if "takeover" not in permissions:
|
||||
permissions.append("takeover")
|
||||
else:
|
||||
permissions = [permission for permission in permissions if permission != "takeover"]
|
||||
|
||||
auth.permissions = permissions
|
||||
auth.takeover_enabled = enabled
|
||||
auth.takeover_mode = mode
|
||||
auth.takeover_delay_seconds = delay
|
||||
db.commit()
|
||||
db.refresh(auth)
|
||||
return ok(auth.to_dict(), "接管配置已保存")
|
||||
@@ -0,0 +1,346 @@
|
||||
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 database import get_db
|
||||
from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
|
||||
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"])
|
||||
|
||||
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")
|
||||
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
acc = get_or_create_account(db, user.id)
|
||||
return ok({
|
||||
"balance": acc.balance,
|
||||
"totalGranted": acc.total_granted,
|
||||
"totalConsumed": acc.total_consumed,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/token/plans")
|
||||
def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
_require_user(authorization, db)
|
||||
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
||||
return ok([p.to_dict() for p in items])
|
||||
|
||||
|
||||
# 积分只会在会会支付回调确认成功后到账。
|
||||
@router.post("/token/charge")
|
||||
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||
user = _require_user(authorization, db)
|
||||
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
|
||||
if not plan:
|
||||
return fail("套餐不存在", 404)
|
||||
|
||||
payment_method = str(payload.get("paymentMethod") or "").lower()
|
||||
pay_type = PAYMENT_METHODS.get(payment_method)
|
||||
if not pay_type:
|
||||
return fail("请选择正确的支付方式", 400)
|
||||
pay_way = str(payload.get("payScene") or "APP").upper()
|
||||
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()
|
||||
|
||||
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,210 @@
|
||||
"""Client for Huihui's self-hosted BOXIM production APIs."""
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class BoxIMError(RuntimeError):
|
||||
def __init__(self, message: str, *, code: Any = None, auth_error: bool = False):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.auth_error = auth_error
|
||||
|
||||
|
||||
class BoxIMClient:
|
||||
"""Exchange Huihui credentials and call BOXIM's private-message API."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.platform_base_url = config.get(
|
||||
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
|
||||
).rstrip("/")
|
||||
self.im_base_url = config.get(
|
||||
"BOXIM_API_BASE_URL", "https://im.99hui.com/api"
|
||||
).rstrip("/")
|
||||
self.app_id = config.get("HUIHUI_APP_ID", "")
|
||||
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
|
||||
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
|
||||
self.timeout = float(config.get("BOXIM_TIMEOUT_SECONDS", 20))
|
||||
|
||||
def _build_sign_params(self, extra: dict | None = None) -> dict:
|
||||
"""Build the same signed form used by Huihui's current production app."""
|
||||
params = {
|
||||
"appId": self.app_id,
|
||||
"accessId": self.access_id,
|
||||
"nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
|
||||
"timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"),
|
||||
"signType": "MD5",
|
||||
"signVersion": "1.0",
|
||||
**(extra or {}),
|
||||
}
|
||||
params.pop("accessSecret", None)
|
||||
params.pop("signature", None)
|
||||
sign_parts = []
|
||||
for key in sorted(params):
|
||||
value = params[key]
|
||||
if value in (None, "", []):
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
continue
|
||||
sign_parts.append(f"{key}={value}")
|
||||
sign_source = "&".join(sign_parts) + f"&accessSecret={self.access_secret}"
|
||||
params["signature"] = hashlib.md5(sign_source.encode("utf-8")).hexdigest().upper()
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _response_payload(response: httpx.Response) -> dict:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise BoxIMError("BOXIM 返回了无效响应") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise BoxIMError("BOXIM 返回格式不正确")
|
||||
return payload
|
||||
|
||||
async def exchange_access_token(self, huihui_token: str) -> dict:
|
||||
"""Exchange a production Huihui token for a BOXIM access token."""
|
||||
if not huihui_token:
|
||||
raise BoxIMError("缺少会会登录凭证", auth_error=True)
|
||||
if not (self.app_id and self.access_id and self.access_secret):
|
||||
raise BoxIMError("会会开放平台凭证未配置", auth_error=True)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {huihui_token}",
|
||||
"appId": self.app_id,
|
||||
"windowAppId": self.app_id,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
response = await client.post(
|
||||
f"{self.platform_base_url}/im/box/netease",
|
||||
headers=headers,
|
||||
data=self._build_sign_params(),
|
||||
)
|
||||
payload = self._response_payload(response)
|
||||
data = payload.get("data") or {}
|
||||
code = payload.get("code")
|
||||
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
|
||||
raise BoxIMError(
|
||||
payload.get("message") or "BOXIM 授权失败",
|
||||
code=code or response.status_code,
|
||||
auth_error=response.status_code in (400, 401, 403)
|
||||
or code in (
|
||||
400,
|
||||
401,
|
||||
40100,
|
||||
40101,
|
||||
403,
|
||||
"400",
|
||||
"401",
|
||||
"40100",
|
||||
"40101",
|
||||
"403",
|
||||
),
|
||||
)
|
||||
if not data.get("accessToken"):
|
||||
raise BoxIMError("会会未返回 BOXIM 访问凭证", auth_error=True)
|
||||
return data
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
access_token: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
json: dict | None = None,
|
||||
) -> Any:
|
||||
headers = {"accessToken": access_token}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{self.im_base_url}{path}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
payload = self._response_payload(response)
|
||||
code = payload.get("code")
|
||||
if response.status_code >= 400 or code not in (200, "200"):
|
||||
raise BoxIMError(
|
||||
payload.get("message") or "BOXIM 请求失败",
|
||||
code=code or response.status_code,
|
||||
auth_error=response.status_code in (400, 401, 403)
|
||||
or code in (400, 401, 40100, 40101, 403, "400", "401", "40100", "40101", "403"),
|
||||
)
|
||||
return payload.get("data")
|
||||
|
||||
async def get_self(self, access_token: str) -> dict:
|
||||
data = await self._request("GET", "/user/self", access_token)
|
||||
if not isinstance(data, dict) or data.get("id") is None:
|
||||
raise BoxIMError("BOXIM 未返回当前用户信息")
|
||||
return data
|
||||
|
||||
async def fetch_private_messages(self, access_token: str, min_id: str = "0") -> list[dict]:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/message/private/loadOfflineMessage",
|
||||
access_token,
|
||||
params={"minId": str(min_id or "0")},
|
||||
)
|
||||
if data is None:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
raise BoxIMError("BOXIM 私聊消息格式不正确")
|
||||
return [item for item in data if isinstance(item, dict)]
|
||||
|
||||
async def mark_private_messages_read(
|
||||
self,
|
||||
access_token: str,
|
||||
friend_id: int | str,
|
||||
message_id: int | str,
|
||||
) -> None:
|
||||
"""Mark one private conversation read through its latest received message."""
|
||||
friend_id_text = str(friend_id).strip()
|
||||
message_id_text = str(message_id).strip()
|
||||
if not friend_id_text.isdigit() or not message_id_text.isdigit():
|
||||
raise BoxIMError("BOXIM 已读回执参数不正确")
|
||||
await self._request(
|
||||
"PUT",
|
||||
"/message/private/readed",
|
||||
access_token,
|
||||
params={
|
||||
"friendId": int(friend_id_text),
|
||||
"messageId": int(message_id_text),
|
||||
},
|
||||
)
|
||||
|
||||
async def send_private_message(
|
||||
self,
|
||||
access_token: str,
|
||||
peer_id: str,
|
||||
content: str,
|
||||
*,
|
||||
local_id: int | str | None = None,
|
||||
) -> dict:
|
||||
local_id = int(local_id or (int(time.time() * 1000) * 1000 + secrets.randbelow(1000)))
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/message/private/send",
|
||||
access_token,
|
||||
json={
|
||||
"localId": local_id,
|
||||
"recvId": int(peer_id) if str(peer_id).isdigit() else peer_id,
|
||||
"content": content,
|
||||
"type": 0,
|
||||
"receipt": False,
|
||||
"atUserIds": [],
|
||||
},
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise BoxIMError("BOXIM 未返回发送结果")
|
||||
return data
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Parse and safely download image payloads from BOXIM private messages."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.parse import unquote, urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
MAX_REDIRECTS = 3
|
||||
|
||||
|
||||
class BoxIMImageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadedBoxIMImage:
|
||||
content: bytes
|
||||
filename: str
|
||||
mime_type: str
|
||||
source_url: str
|
||||
|
||||
|
||||
def parse_boxim_image_url(content: str, *, base_url: str = "") -> str:
|
||||
try:
|
||||
payload = json.loads(content or "")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BoxIMImageError("BOXIM 图片消息格式无效") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise BoxIMImageError("BOXIM 图片消息格式无效")
|
||||
|
||||
value = payload.get("originUrl") or payload.get("thumbUrl") or payload.get("url")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise BoxIMImageError("BOXIM 图片消息缺少图片地址")
|
||||
value = value.strip()
|
||||
if value.startswith("/"):
|
||||
if not base_url:
|
||||
raise BoxIMImageError("BOXIM 图片地址不完整")
|
||||
value = urljoin(f"{base_url.rstrip('/')}/", value)
|
||||
return value
|
||||
|
||||
|
||||
def _configured_hosts(name: str) -> set[str]:
|
||||
return {
|
||||
value.strip().lower().rstrip(".")
|
||||
for value in os.getenv(name, "").split(",")
|
||||
if value.strip()
|
||||
}
|
||||
|
||||
|
||||
def _host_matches(host: str, configured: set[str]) -> bool:
|
||||
return any(host == value or host.endswith(f".{value}") for value in configured)
|
||||
|
||||
|
||||
def _resolved_addresses(host: str, port: int) -> set[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
try:
|
||||
return {
|
||||
ipaddress.ip_address(item[4][0])
|
||||
for item in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
}
|
||||
except (OSError, ValueError) as exc:
|
||||
raise BoxIMImageError("BOXIM 图片地址无法解析") from exc
|
||||
|
||||
|
||||
def _is_safe_remote_url(url: str) -> None:
|
||||
parsed = urlsplit(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
allow_http = os.getenv("BOXIM_IMAGE_ALLOW_HTTP", "").lower() in {"1", "true", "yes"}
|
||||
if scheme not in ({"https", "http"} if allow_http else {"https"}):
|
||||
raise BoxIMImageError("BOXIM 图片地址必须使用 HTTPS")
|
||||
if parsed.username or parsed.password or not parsed.hostname:
|
||||
raise BoxIMImageError("BOXIM 图片地址无效")
|
||||
|
||||
host = parsed.hostname.lower().rstrip(".")
|
||||
allowed_hosts = _configured_hosts("BOXIM_IMAGE_ALLOWED_HOSTS")
|
||||
if allowed_hosts and not _host_matches(host, allowed_hosts):
|
||||
raise BoxIMImageError("BOXIM 图片地址不在允许的域名范围内")
|
||||
|
||||
private_hosts = _configured_hosts("BOXIM_IMAGE_PRIVATE_HOSTS")
|
||||
try:
|
||||
addresses = {ipaddress.ip_address(host)}
|
||||
except ValueError:
|
||||
addresses = _resolved_addresses(host, parsed.port or (443 if scheme == "https" else 80))
|
||||
if not addresses:
|
||||
raise BoxIMImageError("BOXIM 图片地址无法解析")
|
||||
if _host_matches(host, private_hosts):
|
||||
return
|
||||
if any(not address.is_global for address in addresses):
|
||||
raise BoxIMImageError("BOXIM 图片地址指向受限网络")
|
||||
|
||||
|
||||
def _filename_from_url(url: str) -> str:
|
||||
value = unquote(PurePosixPath(urlsplit(url).path).name).strip()
|
||||
value = value.replace("\x00", "")
|
||||
return (value or "boxim-image")[:255]
|
||||
|
||||
|
||||
def download_boxim_image(
|
||||
content: str,
|
||||
*,
|
||||
base_url: str = "",
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> DownloadedBoxIMImage:
|
||||
"""Download one BOXIM image without redirects or oversized responses escaping checks."""
|
||||
url = parse_boxim_image_url(content, base_url=base_url)
|
||||
max_bytes = max(1024, int(os.getenv("CHAT_IMAGE_MAX_BYTES", str(8 * 1024 * 1024))))
|
||||
timeout = max(1.0, min(float(os.getenv("BOXIM_IMAGE_TIMEOUT_SECONDS", "15")), 60.0))
|
||||
|
||||
with httpx.Client(
|
||||
timeout=timeout,
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
transport=transport,
|
||||
) as client:
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
_is_safe_remote_url(url)
|
||||
try:
|
||||
with client.stream("GET", url, headers={"Accept": "image/*"}) as response:
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
location = response.headers.get("location", "").strip()
|
||||
if not location:
|
||||
raise BoxIMImageError("BOXIM 图片跳转地址无效")
|
||||
url = urljoin(url, location)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
raw_length = response.headers.get("content-length", "")
|
||||
if raw_length.isdigit() and int(raw_length) > max_bytes:
|
||||
raise BoxIMImageError("BOXIM 图片超过大小限制")
|
||||
chunks = bytearray()
|
||||
for chunk in response.iter_bytes():
|
||||
chunks.extend(chunk)
|
||||
if len(chunks) > max_bytes:
|
||||
raise BoxIMImageError("BOXIM 图片超过大小限制")
|
||||
if not chunks:
|
||||
raise BoxIMImageError("BOXIM 图片内容为空")
|
||||
return DownloadedBoxIMImage(
|
||||
content=bytes(chunks),
|
||||
filename=_filename_from_url(url),
|
||||
mime_type=response.headers.get("content-type", "").split(";", 1)[0][:100],
|
||||
source_url=url,
|
||||
)
|
||||
except BoxIMImageError:
|
||||
raise
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
raise BoxIMImageError("BOXIM 图片下载失败") from exc
|
||||
raise BoxIMImageError("BOXIM 图片跳转次数过多")
|
||||
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import ChatAttachment
|
||||
|
||||
|
||||
def purge_expired_chat_attachments(
|
||||
db: Session,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""Remove expired derived image data; raw image bytes are never persisted."""
|
||||
count = db.query(ChatAttachment).filter(
|
||||
ChatAttachment.expires_at < (now or datetime.utcnow())
|
||||
).delete(synchronize_session=False)
|
||||
if count:
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
return count
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
vision_model: str
|
||||
ocr_model: str
|
||||
vision_max_tokens: int
|
||||
vision_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"))),
|
||||
vision_model=os.getenv("VISION_MODEL", "qwen3.6-flash"),
|
||||
ocr_model=os.getenv("VISION_OCR_MODEL", "qwen-vl-ocr"),
|
||||
vision_max_tokens=max(256, int(os.getenv("VISION_MAX_OUTPUT_TOKENS", "2048"))),
|
||||
vision_timeout_seconds=max(10.0, float(os.getenv("VISION_TIMEOUT_SECONDS", "90"))),
|
||||
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)),
|
||||
vision_model=str(
|
||||
payload.get("vision_model")
|
||||
or os.getenv("VISION_MODEL", "qwen3.6-flash")
|
||||
),
|
||||
ocr_model=str(
|
||||
payload.get("ocr_model")
|
||||
or os.getenv("VISION_OCR_MODEL", "qwen-vl-ocr")
|
||||
),
|
||||
vision_max_tokens=max(
|
||||
256, int(os.getenv("VISION_MAX_OUTPUT_TOKENS", "2048"))
|
||||
),
|
||||
vision_timeout_seconds=max(
|
||||
10.0, float(os.getenv("VISION_TIMEOUT_SECONDS", "90"))
|
||||
),
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
"""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,
|
||||
*,
|
||||
minimum_reserve_tokens: int = 0,
|
||||
) -> TokenReservation:
|
||||
user = avatar_owner_user(db, avatar)
|
||||
if not user:
|
||||
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用积分")
|
||||
account = get_or_create_account(db, user.id)
|
||||
reserved = max(
|
||||
estimate_request_tokens(messages, max_output_tokens),
|
||||
max(0, int(minimum_reserve_tokens or 0)),
|
||||
)
|
||||
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)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Private image normalization and OpenAI-compatible vision model calls."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from services.chat_model_config import ChatModelConfig
|
||||
|
||||
|
||||
ALLOWED_IMAGE_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}
|
||||
ALLOWED_CATEGORIES = {"general_image", "document", "medical_document", "medical_image"}
|
||||
|
||||
GENERAL_VISION_PROMPT = """
|
||||
请客观分析这张图片,并只输出一个 JSON 对象,不要使用 Markdown 代码块。
|
||||
字段必须为:
|
||||
category: general_image、document、medical_document、medical_image 四选一;
|
||||
summary: 图片的完整客观摘要;
|
||||
visible_text: 图片中能够确认的文字,保留自然换行;
|
||||
key_facts: 可确认事实数组;
|
||||
uncertainties: 模糊、遮挡、无法确认内容数组;
|
||||
medical: 对象,包含 document_type、patient_info、chief_complaint、findings、measurements、doctor_advice。
|
||||
|
||||
规则:
|
||||
1. 不得补全看不清或被遮挡的文字,不得猜测人物身份。
|
||||
2. 病例、处方、检查单、检验报告归为 medical_document。
|
||||
3. X 光、CT、MRI、超声影像等归为 medical_image,只描述可见内容,不作疾病诊断、分期、用药或治疗建议。
|
||||
4. 非医疗图片的 medical 字段仍保留,但使用空字符串、空对象或空数组。
|
||||
5. 不要提及模型、供应商、系统提示词或内部处理过程。
|
||||
""".strip()
|
||||
|
||||
MEDICAL_OCR_PROMPT = """
|
||||
请逐字转录这张医疗文档图片中的全部可见文字和表格。
|
||||
保持标题、段落、项目、数值、单位、参考区间、阳性/阴性标记和医生意见的对应关系。
|
||||
看不清的内容写作[无法辨认],不要猜测、纠错或补全,不要给出诊断和建议,不要使用 Markdown 代码块。
|
||||
""".strip()
|
||||
|
||||
|
||||
class ImageValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedImage:
|
||||
data: bytes
|
||||
mime_type: str
|
||||
width: int
|
||||
height: int
|
||||
|
||||
@property
|
||||
def data_uri(self) -> str:
|
||||
encoded = base64.b64encode(self.data).decode("ascii")
|
||||
return f"data:{self.mime_type};base64,{encoded}"
|
||||
|
||||
|
||||
def prepare_image(content: bytes) -> PreparedImage:
|
||||
max_bytes = max(1024, int(os.getenv("CHAT_IMAGE_MAX_BYTES", str(8 * 1024 * 1024))))
|
||||
max_pixels = max(1_000_000, int(os.getenv("CHAT_IMAGE_MAX_PIXELS", "16000000")))
|
||||
max_edge = max(1024, int(os.getenv("CHAT_IMAGE_MAX_EDGE", "4096")))
|
||||
if not content:
|
||||
raise ImageValidationError("图片内容为空")
|
||||
if len(content) > max_bytes:
|
||||
raise ImageValidationError(f"单张图片不能超过 {max_bytes // 1024 // 1024}MB")
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as probe:
|
||||
image_format = str(probe.format or "").upper()
|
||||
width, height = probe.size
|
||||
probe.verify()
|
||||
except (UnidentifiedImageError, OSError, SyntaxError) as exc:
|
||||
raise ImageValidationError("图片格式无效或文件已损坏") from exc
|
||||
|
||||
if image_format not in ALLOWED_IMAGE_FORMATS:
|
||||
raise ImageValidationError("仅支持 JPG、PNG、WebP 图片")
|
||||
if width <= 0 or height <= 0 or width * height > max_pixels:
|
||||
raise ImageValidationError("图片像素过大,请压缩后重新上传")
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as original:
|
||||
image = ImageOps.exif_transpose(original)
|
||||
image.load()
|
||||
if max(image.size) > max_edge:
|
||||
image.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
|
||||
if image.mode in {"RGBA", "LA"}:
|
||||
canvas = Image.new("RGB", image.size, "white")
|
||||
alpha = image.getchannel("A")
|
||||
canvas.paste(image.convert("RGB"), mask=alpha)
|
||||
image = canvas
|
||||
elif image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="JPEG", quality=92, optimize=True)
|
||||
normalized = output.getvalue()
|
||||
normalized_width, normalized_height = image.size
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ImageValidationError("图片解码失败,请重新选择图片") from exc
|
||||
|
||||
return PreparedImage(
|
||||
data=normalized,
|
||||
mime_type="image/jpeg",
|
||||
width=normalized_width,
|
||||
height=normalized_height,
|
||||
)
|
||||
|
||||
|
||||
def call_vision_model(
|
||||
prepared: PreparedImage,
|
||||
model_config: ChatModelConfig,
|
||||
*,
|
||||
model: str,
|
||||
prompt: str,
|
||||
json_output: bool,
|
||||
) -> dict:
|
||||
if not model_config.api_key:
|
||||
raise RuntimeError("视觉模型服务未配置")
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": prepared.data_uri}},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}
|
||||
],
|
||||
"temperature": 0,
|
||||
"max_tokens": model_config.vision_max_tokens,
|
||||
}
|
||||
if json_output:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{model_config.api_base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {model_config.api_key}"},
|
||||
json=payload,
|
||||
timeout=model_config.vision_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
|
||||
raise RuntimeError("图片识别服务暂时不可用") from exc
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise RuntimeError("图片识别服务没有返回有效结果")
|
||||
return {"content": content.strip(), "usage": data.get("usage") or {}}
|
||||
|
||||
|
||||
def parse_vision_analysis(content: str) -> dict:
|
||||
value = (content or "").strip()
|
||||
fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", value, re.DOTALL | re.IGNORECASE)
|
||||
if fenced:
|
||||
value = fenced.group(1).strip()
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("图片识别结果格式无效") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("图片识别结果格式无效")
|
||||
|
||||
category = str(payload.get("category") or "general_image").strip().lower()
|
||||
if category not in ALLOWED_CATEGORIES:
|
||||
category = "general_image"
|
||||
medical = payload.get("medical") if isinstance(payload.get("medical"), dict) else {}
|
||||
return {
|
||||
"category": category,
|
||||
"summary": str(payload.get("summary") or "").strip(),
|
||||
"visible_text": str(payload.get("visible_text") or "").strip(),
|
||||
"key_facts": _string_list(payload.get("key_facts")),
|
||||
"uncertainties": _string_list(payload.get("uncertainties")),
|
||||
"medical": medical,
|
||||
}
|
||||
|
||||
|
||||
def build_attachment_warning(analysis: dict, *, ocr_failed: bool = False) -> str:
|
||||
warnings = list(analysis.get("uncertainties") or [])
|
||||
category = analysis.get("category")
|
||||
if ocr_failed:
|
||||
warnings.append("精确文字识别暂时不可用,请人工核对图片原文")
|
||||
if category == "medical_document":
|
||||
warnings.append("病例识别结果仅供辅助,不能替代医生诊断,请核对原始文档")
|
||||
elif category == "medical_image":
|
||||
warnings.append("医学影像仅作客观描述,不能替代影像报告和医生诊断")
|
||||
return ";".join(dict.fromkeys(item for item in warnings if item))
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from database import init_db, SessionLocal
|
||||
from models import (
|
||||
Authorization,
|
||||
Avatar,
|
||||
ChatAttachment,
|
||||
TakeoverCursor,
|
||||
TakeoverMessage,
|
||||
TakeoverReplyTask,
|
||||
TokenAccount,
|
||||
TokenPaymentOrder,
|
||||
TokenUsage,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database():
|
||||
"""Initialize DB tables and seed an Authorization row."""
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.query(Authorization).first()
|
||||
if existing is None:
|
||||
auth = Authorization(
|
||||
id="test-auth-1",
|
||||
avatar_id="test-avatar-1",
|
||||
target_type="user",
|
||||
target_id="test-user-1",
|
||||
target_name="Test User",
|
||||
permissions=["read", "write"],
|
||||
status="active",
|
||||
)
|
||||
db.add(auth)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authorization_context():
|
||||
"""Create isolated users, avatars, and one authorization for API tests."""
|
||||
suffix = uuid.uuid4().hex
|
||||
owner = User(
|
||||
id=f"owner-{suffix}",
|
||||
huihui_user_id=f"huihui-owner-{suffix}",
|
||||
nickname="授权测试用户",
|
||||
app_token=f"owner-token-{suffix}",
|
||||
)
|
||||
other = User(
|
||||
id=f"other-{suffix}",
|
||||
huihui_user_id=f"huihui-other-{suffix}",
|
||||
nickname="其他用户",
|
||||
app_token=f"other-token-{suffix}",
|
||||
)
|
||||
avatar = Avatar(
|
||||
id=f"avatar-{suffix}",
|
||||
owner_id=owner.huihui_user_id,
|
||||
name="授权测试分身",
|
||||
status="active",
|
||||
config={},
|
||||
)
|
||||
other_avatar = Avatar(
|
||||
id=f"other-avatar-{suffix}",
|
||||
owner_id=other.huihui_user_id,
|
||||
name="其他分身",
|
||||
status="active",
|
||||
config={},
|
||||
)
|
||||
authorization = Authorization(
|
||||
id=f"authorization-{suffix}",
|
||||
avatar_id=avatar.id,
|
||||
target_type="user",
|
||||
target_id=f"contact-{suffix}",
|
||||
target_name="测试联系人",
|
||||
permissions=["chat", "browse"],
|
||||
status="active",
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all([owner, other, avatar, other_avatar, authorization])
|
||||
db.commit()
|
||||
yield {
|
||||
"owner": owner,
|
||||
"other": other,
|
||||
"avatar": avatar,
|
||||
"other_avatar": other_avatar,
|
||||
"authorization": authorization,
|
||||
"owner_headers": {"Authorization": f"Bearer {owner.app_token}"},
|
||||
"other_headers": {"Authorization": f"Bearer {other.app_token}"},
|
||||
"suffix": suffix,
|
||||
}
|
||||
finally:
|
||||
db.rollback()
|
||||
avatar_ids = [avatar.id, other_avatar.id]
|
||||
db.query(ChatAttachment).filter(
|
||||
ChatAttachment.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(TakeoverReplyTask).filter(
|
||||
TakeoverReplyTask.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(TakeoverMessage).filter(
|
||||
TakeoverMessage.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(TakeoverCursor).filter(
|
||||
TakeoverCursor.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Authorization).filter(
|
||||
Authorization.avatar_id.in_(avatar_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
user_ids = [owner.id, other.id]
|
||||
db.query(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(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -0,0 +1,214 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_authorization_list_is_scoped_to_owned_avatar(authorization_context):
|
||||
context = authorization_context
|
||||
response = client.get(
|
||||
f"/api/avatar/{context['avatar'].id}/authorizations",
|
||||
headers=context["owner_headers"],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["code"] == 200
|
||||
assert [item["id"] for item in payload["data"]] == [context["authorization"].id]
|
||||
|
||||
forbidden = client.get(
|
||||
f"/api/avatar/{context['other_avatar'].id}/authorizations",
|
||||
headers=context["owner_headers"],
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
def test_create_update_and_delete_authorization(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
target_id = f"new-contact-{context['suffix']}"
|
||||
created = client.post(
|
||||
f"/api/avatar/{avatar_id}/authorizations",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"targetType": "user",
|
||||
"targetId": target_id,
|
||||
"targetName": "新联系人",
|
||||
"permissions": ["friend", "chat", "browse"],
|
||||
},
|
||||
).json()
|
||||
assert created["code"] == 200
|
||||
authorization_id = created["data"]["id"]
|
||||
assert created["data"]["permissions"] == ["friend", "chat", "browse"]
|
||||
|
||||
duplicate = client.post(
|
||||
f"/api/avatar/{avatar_id}/authorizations",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"targetType": "user",
|
||||
"targetId": target_id,
|
||||
"targetName": "重复联系人",
|
||||
"permissions": ["chat"],
|
||||
},
|
||||
).json()
|
||||
assert duplicate["code"] == 409
|
||||
|
||||
updated = client.put(
|
||||
f"/api/avatar/{avatar_id}/authorizations",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"id": authorization_id,
|
||||
"targetName": "联系人新名称",
|
||||
"permissions": ["interact", "publish"],
|
||||
},
|
||||
).json()
|
||||
assert updated["code"] == 200
|
||||
assert updated["data"]["targetName"] == "联系人新名称"
|
||||
assert updated["data"]["permissions"] == ["publish", "interact"]
|
||||
|
||||
deleted = client.delete(
|
||||
f"/api/avatar/{avatar_id}/authorizations/{authorization_id}",
|
||||
headers=context["owner_headers"],
|
||||
).json()
|
||||
assert deleted["code"] == 200
|
||||
assert deleted["data"]["id"] == authorization_id
|
||||
|
||||
|
||||
def test_authorization_requires_login_and_rejects_unknown_permissions(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
no_session = client.get(f"/api/avatar/{avatar_id}/authorizations")
|
||||
assert no_session.status_code == 401
|
||||
|
||||
invalid = client.post(
|
||||
f"/api/avatar/{avatar_id}/authorizations",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"targetType": "user",
|
||||
"targetId": "invalid-target",
|
||||
"targetName": "无效权限",
|
||||
"permissions": ["admin"],
|
||||
},
|
||||
).json()
|
||||
assert invalid["code"] == 400
|
||||
|
||||
|
||||
def test_avatar_permission_settings_default_and_persist(authorization_context):
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
|
||||
|
||||
initial = client.get(endpoint, headers=context["owner_headers"]).json()
|
||||
assert initial["code"] == 200
|
||||
assert initial["data"] == {
|
||||
"avatarId": context["avatar"].id,
|
||||
"permissions": ["friend", "chat"],
|
||||
"takeoverReplyDelaySeconds": 180,
|
||||
}
|
||||
|
||||
updated = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["interact", "takeover", "publish", "friend", "friend"]},
|
||||
).json()
|
||||
assert updated["code"] == 200
|
||||
assert updated["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
|
||||
|
||||
reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
|
||||
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
|
||||
assert reloaded["data"]["takeoverReplyDelaySeconds"] == 180
|
||||
|
||||
|
||||
def test_avatar_permission_settings_allow_all_disabled(authorization_context):
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
|
||||
|
||||
response = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": []},
|
||||
).json()
|
||||
assert response["code"] == 200
|
||||
assert response["data"]["permissions"] == []
|
||||
|
||||
|
||||
def test_avatar_permission_settings_validate_owner_and_permissions(authorization_context):
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
|
||||
|
||||
invalid = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["admin"]},
|
||||
).json()
|
||||
assert invalid["code"] == 400
|
||||
|
||||
missing = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={},
|
||||
).json()
|
||||
assert missing["code"] == 400
|
||||
|
||||
forbidden = client.get(
|
||||
f"/api/avatar/{context['other_avatar'].id}/permission-settings",
|
||||
headers=context["owner_headers"],
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
unauthenticated = client.get(endpoint)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
|
||||
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,130 @@
|
||||
"""Contract tests for the self-hosted BOXIM client."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.boxim_client import BoxIMClient, BoxIMError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return {
|
||||
"HUIHUI_PLATFORM_BASE_URL": "https://open.example/api",
|
||||
"BOXIM_API_BASE_URL": "https://im.example/api",
|
||||
"HUIHUI_APP_ID": "test_app",
|
||||
"HUIHUI_ACCESS_ID": "test_access",
|
||||
"HUIHUI_ACCESS_SECRET": "test_secret",
|
||||
}
|
||||
|
||||
|
||||
def _response(payload: dict, status_code: int = 200):
|
||||
response = MagicMock()
|
||||
response.status_code = status_code
|
||||
response.json.return_value = payload
|
||||
return response
|
||||
|
||||
|
||||
def _client_patch(*, post_payload=None, request_payload=None, status_code=200):
|
||||
client = AsyncMock()
|
||||
if post_payload is not None:
|
||||
client.post.return_value = _response(post_payload, status_code)
|
||||
if request_payload is not None:
|
||||
client.request.return_value = _response(request_payload, status_code)
|
||||
context = AsyncMock()
|
||||
context.__aenter__.return_value = client
|
||||
context.__aexit__.return_value = None
|
||||
return patch("services.boxim_client.httpx.AsyncClient", return_value=context), client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exchange_access_token_uses_huihui_bearer_and_signed_form(config):
|
||||
mocked, client = _client_patch(
|
||||
post_payload={"code": 0, "data": {"accessToken": "box-token", "accessTokenExpiresIn": 3600}}
|
||||
)
|
||||
with mocked:
|
||||
result = await BoxIMClient(config).exchange_access_token("huihui-token")
|
||||
|
||||
assert result["accessToken"] == "box-token"
|
||||
call = client.post.await_args
|
||||
assert call.args[0] == "https://open.example/api/im/box/netease"
|
||||
assert call.kwargs["headers"]["Authorization"] == "Bearer huihui-token"
|
||||
assert call.kwargs["data"]["appId"] == "test_app"
|
||||
assert len(call.kwargs["data"]["signature"]) == 32
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_self_and_incremental_private_messages_use_boxim_header(config):
|
||||
client_instance = BoxIMClient(config)
|
||||
mocked, client = _client_patch(
|
||||
request_payload={"code": 200, "data": {"id": 42, "nickName": "Owner"}}
|
||||
)
|
||||
with mocked:
|
||||
profile = await client_instance.get_self("box-token")
|
||||
assert profile["id"] == 42
|
||||
assert client.request.await_args.kwargs["headers"] == {"accessToken": "box-token"}
|
||||
|
||||
mocked, client = _client_patch(
|
||||
request_payload={"code": 200, "data": [{"id": 101, "sendId": 7, "recvId": 42}]}
|
||||
)
|
||||
with mocked:
|
||||
messages = await client_instance.fetch_private_messages("box-token", "100")
|
||||
assert messages[0]["id"] == 101
|
||||
assert client.request.await_args.kwargs["params"] == {"minId": "100"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_private_message_matches_boxim_payload(config):
|
||||
mocked, client = _client_patch(
|
||||
request_payload={"code": 200, "data": {"id": 88, "localId": 12345}}
|
||||
)
|
||||
with mocked:
|
||||
result = await BoxIMClient(config).send_private_message(
|
||||
"box-token", "77", "你好", local_id="12345"
|
||||
)
|
||||
|
||||
assert result["id"] == 88
|
||||
call = client.request.await_args
|
||||
assert call.args[:2] == ("POST", "https://im.example/api/message/private/send")
|
||||
assert call.kwargs["json"] == {
|
||||
"localId": 12345,
|
||||
"recvId": 77,
|
||||
"content": "你好",
|
||||
"type": 0,
|
||||
"receipt": False,
|
||||
"atUserIds": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_private_messages_read_uses_latest_message_id(config):
|
||||
mocked, client = _client_patch(request_payload={"code": 200, "data": None})
|
||||
with mocked:
|
||||
await BoxIMClient(config).mark_private_messages_read("box-token", "77", "101")
|
||||
|
||||
call = client.request.await_args
|
||||
assert call.args[:2] == ("PUT", "https://im.example/api/message/private/readed")
|
||||
assert call.kwargs["headers"] == {"accessToken": "box-token"}
|
||||
assert call.kwargs["params"] == {"friendId": 77, "messageId": 101}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_boxim_auth_error_is_explicit(config):
|
||||
mocked, _ = _client_patch(
|
||||
request_payload={"code": 400, "message": "未登录"}, status_code=200
|
||||
)
|
||||
with mocked, pytest.raises(BoxIMError) as exc_info:
|
||||
await BoxIMClient(config).get_self("expired")
|
||||
assert exc_info.value.auth_error is True
|
||||
|
||||
|
||||
def test_sign_params_include_production_required_fields(config):
|
||||
params = BoxIMClient(config)._build_sign_params()
|
||||
assert params["appId"] == "test_app"
|
||||
assert params["accessId"] == "test_access"
|
||||
assert params["signType"] == "MD5"
|
||||
assert params["signVersion"] == "1.0"
|
||||
assert len(params["nonce"]) == 12
|
||||
assert len(params["timestamp"]) == 14
|
||||
assert len(params["signature"]) == 32
|
||||
assert "accessSecret" not in params
|
||||
@@ -0,0 +1,71 @@
|
||||
import ipaddress
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from services.boxim_image_service import (
|
||||
BoxIMImageError,
|
||||
download_boxim_image,
|
||||
parse_boxim_image_url,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_boxim_image_prefers_origin_and_supports_relative_url():
|
||||
content = json.dumps({"originUrl": "/files/original.png", "thumbUrl": "/thumb.png"})
|
||||
assert parse_boxim_image_url(content, base_url="https://im.example/api") == (
|
||||
"https://im.example/files/original.png"
|
||||
)
|
||||
|
||||
|
||||
def test_download_boxim_image_streams_public_https(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.boxim_image_service._resolved_addresses",
|
||||
lambda _host, _port: {ipaddress.ip_address("8.8.8.8")},
|
||||
)
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "image/png"},
|
||||
content=b"png-bytes",
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
image = download_boxim_image(
|
||||
json.dumps({"originUrl": "https://cdn.example/case%20photo.png"}),
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
assert image.content == b"png-bytes"
|
||||
assert image.filename == "case photo.png"
|
||||
assert image.mime_type == "image/png"
|
||||
|
||||
|
||||
def test_download_boxim_image_rejects_private_network_url():
|
||||
with pytest.raises(BoxIMImageError, match="受限网络"):
|
||||
download_boxim_image(
|
||||
json.dumps({"originUrl": "https://127.0.0.1/private.png"}),
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||
)
|
||||
|
||||
|
||||
def test_download_boxim_image_stops_oversized_stream(monkeypatch):
|
||||
monkeypatch.setenv("CHAT_IMAGE_MAX_BYTES", "1024")
|
||||
monkeypatch.setattr(
|
||||
"services.boxim_image_service._resolved_addresses",
|
||||
lambda _host, _port: {ipaddress.ip_address("8.8.8.8")},
|
||||
)
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
headers={"content-length": "2048"},
|
||||
request=request,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(BoxIMImageError, match="超过大小限制"):
|
||||
download_boxim_image(
|
||||
json.dumps({"originUrl": "https://cdn.example/large.png"}),
|
||||
transport=transport,
|
||||
)
|
||||
@@ -0,0 +1,290 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app
|
||||
from models import ChatAttachment
|
||||
from routers.chat import (
|
||||
ChatIn,
|
||||
_attachment_contexts,
|
||||
_load_chat_attachments,
|
||||
_resolve_reply,
|
||||
)
|
||||
from services.chat_attachment_service import purge_expired_chat_attachments
|
||||
from services.token_billing import InsufficientTokensError
|
||||
from services.vision_service import PreparedImage
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
GENERAL_RESULT = {
|
||||
"content": json.dumps({
|
||||
"category": "general_image",
|
||||
"summary": "一张包含产品路线图的截图",
|
||||
"visible_text": "产品路线图",
|
||||
"key_facts": ["包含三个阶段"],
|
||||
"uncertainties": [],
|
||||
"medical": {},
|
||||
}, ensure_ascii=False),
|
||||
"usage": {"total_tokens": 120},
|
||||
}
|
||||
|
||||
|
||||
def test_owner_can_upload_and_cache_image_analysis(authorization_context):
|
||||
context = authorization_context
|
||||
prepared = PreparedImage(b"jpeg", "image/jpeg", 100, 80)
|
||||
with (
|
||||
patch("routers.chat.prepare_image", return_value=prepared),
|
||||
patch("routers.chat._run_billed_vision_call", return_value=GENERAL_RESULT),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("roadmap.png", b"image-bytes", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["status"] == "ready"
|
||||
assert payload["category"] == "general_image"
|
||||
assert payload["summary"] == "一张包含产品路线图的截图"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(ChatAttachment).filter(ChatAttachment.id == payload["id"]).one()
|
||||
assert stored.avatar_id == context["avatar"].id
|
||||
assert stored.extracted_text == "产品路线图"
|
||||
assert stored.structured_data["key_facts"] == ["包含三个阶段"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_non_owner_cannot_upload_chat_image(authorization_context):
|
||||
context = authorization_context
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||
headers=context["other_headers"],
|
||||
files={"file": ("private.png", b"image-bytes", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_image_upload_preserves_insufficient_points_response(authorization_context):
|
||||
context = authorization_context
|
||||
with patch(
|
||||
"routers.chat._analyze_image_bytes",
|
||||
side_effect=InsufficientTokensError("积分余额不足"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("private.png", b"image-bytes", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 402
|
||||
assert response.json()["detail"] == "积分余额不足"
|
||||
|
||||
|
||||
def test_public_share_can_upload_without_exposing_analysis_details(authorization_context):
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
avatar = db.get(type(context["avatar"]), context["avatar"].id)
|
||||
avatar.share_token = f"share-{context['suffix']}"
|
||||
db.commit()
|
||||
share_token = avatar.share_token
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routers.chat.prepare_image",
|
||||
return_value=PreparedImage(b"jpeg", "image/jpeg", 100, 80),
|
||||
),
|
||||
patch("routers.chat._run_billed_vision_call", return_value=GENERAL_RESULT),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/public/avatar/{share_token}/chat/images",
|
||||
files={"file": ("visitor.png", b"image-bytes", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["status"] == "ready"
|
||||
assert "structuredData" not in payload
|
||||
assert "extractedText" not in payload
|
||||
assert "visionModel" not in payload
|
||||
assert "ocrModel" not in payload
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.get(ChatAttachment, payload["id"])
|
||||
assert stored.uploader_kind == "public"
|
||||
assert stored.avatar_id == context["avatar"].id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_medical_document_uses_ocr_result(authorization_context):
|
||||
context = authorization_context
|
||||
general = {
|
||||
"content": json.dumps({
|
||||
"category": "medical_document",
|
||||
"summary": "血常规报告",
|
||||
"visible_text": "初步文字",
|
||||
"key_facts": [],
|
||||
"uncertainties": [],
|
||||
"medical": {"document_type": "检验报告"},
|
||||
}, ensure_ascii=False),
|
||||
"usage": {},
|
||||
}
|
||||
ocr = {"content": "白细胞 11.2 x10^9/L", "usage": {}}
|
||||
with (
|
||||
patch("routers.chat.prepare_image", return_value=PreparedImage(b"jpeg", "image/jpeg", 100, 80)),
|
||||
patch("routers.chat._run_billed_vision_call", side_effect=[general, ocr]) as model,
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||
headers=context["owner_headers"],
|
||||
files={"file": ("report.jpg", b"image-bytes", "image/jpeg")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
attachment_id = response.json()["data"]["id"]
|
||||
assert model.call_count == 2
|
||||
assert model.call_args_list[1].kwargs["source"] == "vision_medical_ocr"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).one()
|
||||
assert stored.extracted_text == "白细胞 11.2 x10^9/L"
|
||||
assert stored.ocr_model == "qwen-vl-ocr"
|
||||
assert "不能替代医生诊断" in stored.warning
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_attachment_cannot_cross_avatar_boundary(authorization_context):
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
attachment = ChatAttachment(
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="private.jpg",
|
||||
status="ready",
|
||||
expires_at=datetime.utcnow() + timedelta(hours=1),
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
body = ChatIn(message="看看图片", attachmentIds=[attachment.id])
|
||||
with pytest.raises(HTTPException, match="不属于当前分身") as caught:
|
||||
_load_chat_attachments(db, context["other_avatar"].id, body)
|
||||
assert caught.value.status_code == 400
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_expired_attachment_is_removed(authorization_context):
|
||||
context = authorization_context
|
||||
db = SessionLocal()
|
||||
try:
|
||||
attachment = ChatAttachment(
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="expired.jpg",
|
||||
status="ready",
|
||||
expires_at=datetime.utcnow() - timedelta(seconds=1),
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
attachment_id = attachment.id
|
||||
body = ChatIn(message="看看图片", attachmentIds=[attachment_id])
|
||||
with pytest.raises(HTTPException):
|
||||
_load_chat_attachments(db, context["avatar"].id, body)
|
||||
assert db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first() is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_cleanup_keeps_unexpired_attachment(authorization_context):
|
||||
context = authorization_context
|
||||
now = datetime.utcnow()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
expired = ChatAttachment(
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="expired.jpg",
|
||||
status="ready",
|
||||
expires_at=now - timedelta(seconds=1),
|
||||
)
|
||||
active = ChatAttachment(
|
||||
avatar_id=context["avatar"].id,
|
||||
filename="active.jpg",
|
||||
status="ready",
|
||||
expires_at=now + timedelta(hours=1),
|
||||
)
|
||||
db.add_all([expired, active])
|
||||
db.commit()
|
||||
expired_id, active_id = expired.id, active.id
|
||||
|
||||
assert purge_expired_chat_attachments(db, now=now) == 1
|
||||
assert db.get(ChatAttachment, expired_id) is None
|
||||
assert db.get(ChatAttachment, active_id) is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_image_context_keeps_standard_answer_authoritative():
|
||||
avatar = SimpleNamespace(
|
||||
id="avatar-vision",
|
||||
name="测试分身",
|
||||
description="产品顾问",
|
||||
config={},
|
||||
)
|
||||
model = Mock(return_value="标准退款期限是七天;图片显示的是商品包装。")
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
avatar,
|
||||
"退款期限是多少?",
|
||||
[],
|
||||
qa_pairs=[SimpleNamespace(question="退款期限是多少?", answer="七天", enabled=True)],
|
||||
search_fn=Mock(return_value=[]),
|
||||
model_client=model,
|
||||
image_contexts=[{
|
||||
"id": "attachment",
|
||||
"filename": "product.jpg",
|
||||
"category": "general_image",
|
||||
"summary": "商品包装",
|
||||
"extractedText": "",
|
||||
"structuredData": {},
|
||||
"warning": "",
|
||||
}],
|
||||
)
|
||||
|
||||
assert result["source"] == "qa"
|
||||
system = model.call_args.kwargs["messages"][0]["content"]
|
||||
assert "已确认标准答案" in system
|
||||
assert "七天" in system
|
||||
assert "商品包装" in system
|
||||
assert "标准答题对中的事实优先级高于图片资料" in system
|
||||
|
||||
|
||||
def test_attachment_context_does_not_expose_internal_fields():
|
||||
row = SimpleNamespace(
|
||||
id="attachment",
|
||||
filename="case.jpg",
|
||||
category="medical_document",
|
||||
summary="门诊病例",
|
||||
extracted_text="主诉:咳嗽",
|
||||
structured_data={"medical": {"chief_complaint": "咳嗽"}},
|
||||
warning="请核对原文",
|
||||
)
|
||||
context = _attachment_contexts([row])[0]
|
||||
assert context["filename"] == "case.jpg"
|
||||
assert "avatar_id" not in context
|
||||
assert "vision_model" not in context
|
||||
@@ -0,0 +1,87 @@
|
||||
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",
|
||||
"vision_model": "avatar-vision-model",
|
||||
"ocr_model": "avatar-ocr-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.vision_model == "avatar-vision-model"
|
||||
assert config.ocr_model == "avatar-ocr-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("VISION_MODEL", "fallback-vision")
|
||||
monkeypatch.setenv("VISION_OCR_MODEL", "fallback-ocr")
|
||||
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.vision_model == "fallback-vision"
|
||||
assert config.ocr_model == "fallback-ocr"
|
||||
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"
|
||||
@@ -0,0 +1,202 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models import Avatar, User
|
||||
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):
|
||||
def setUp(self):
|
||||
self.avatar = SimpleNamespace(
|
||||
id="avatar-1",
|
||||
owner_id="huihui-user-1",
|
||||
name="冯医生",
|
||||
display_name="冯医生",
|
||||
description="耳鼻喉科领域专家",
|
||||
photo_url="https://example.test/avatar.png",
|
||||
emoji="👨⚕️",
|
||||
status="active",
|
||||
config={
|
||||
"replyStyle": "professional",
|
||||
"creativity": 50,
|
||||
"rigor": 80,
|
||||
"humor": 20,
|
||||
"responseLength": "medium",
|
||||
"systemPrompt": "不要编造政策。",
|
||||
"profession": "医生",
|
||||
"position": "主任医师",
|
||||
"organization": "测试医院",
|
||||
"organizationAddress": "测试路1号",
|
||||
},
|
||||
)
|
||||
self.qa = SimpleNamespace(question="公司地址?", answer="标准地址", enabled=True)
|
||||
self.disabled_qa = SimpleNamespace(question="公司地址?", answer="错误答案", enabled=False)
|
||||
|
||||
def test_enabled_qa_wins_without_calling_model(self):
|
||||
fake_model = Mock()
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
" 公司地址? ",
|
||||
[],
|
||||
qa_pairs=[self.disabled_qa, self.qa],
|
||||
search_fn=lambda *_args, **_kwargs: [],
|
||||
model_client=fake_model,
|
||||
)
|
||||
self.assertEqual(result["source"], "qa")
|
||||
self.assertEqual(result["answer"], "标准地址")
|
||||
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):
|
||||
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
||||
with self.subTest(question=question):
|
||||
matched = _match_standard_qa(question, [self.disabled_qa, self.qa])
|
||||
self.assertIs(matched, self.qa)
|
||||
|
||||
def test_short_related_question_matches_single_standard_qa(self):
|
||||
matched = _match_standard_qa("地址", [self.qa])
|
||||
self.assertIs(matched, self.qa)
|
||||
|
||||
def test_ambiguous_short_question_does_not_pick_arbitrarily(self):
|
||||
hospital = SimpleNamespace(question="医院地址", answer="医院地址答案", enabled=True)
|
||||
company = SimpleNamespace(question="公司地址", answer="公司地址答案", enabled=True)
|
||||
self.assertIsNone(_match_standard_qa("地址", [hospital, company]))
|
||||
|
||||
def test_unrelated_question_does_not_match_standard_qa(self):
|
||||
self.assertIsNone(_match_standard_qa("今天天气怎么样", [self.qa]))
|
||||
|
||||
def test_knowledge_context_is_sent_to_qwen_after_qa_miss(self):
|
||||
fake_model = Mock(return_value="根据知识库内容回答")
|
||||
knowledge_hit = {
|
||||
"filename": "退款.md",
|
||||
"snippet": "知识库内容:七日内可申请退款。",
|
||||
"score": 0.92,
|
||||
}
|
||||
result = _resolve_reply(
|
||||
None,
|
||||
self.avatar,
|
||||
"退款规则",
|
||||
[],
|
||||
qa_pairs=[],
|
||||
search_fn=lambda *_args, **_kwargs: [knowledge_hit],
|
||||
model_client=fake_model,
|
||||
)
|
||||
self.assertEqual(result["source"], "knowledge")
|
||||
self.assertIn("知识库内容", fake_model.call_args.kwargs["messages"][0]["content"])
|
||||
self.assertIn("只能依据本人资料", fake_model.call_args.kwargs["messages"][0]["content"])
|
||||
|
||||
def test_prompt_contains_personality_configuration(self):
|
||||
messages = _build_prompt(self.avatar, [], "你好", [])
|
||||
self.assertIn("严谨度", messages[0]["content"])
|
||||
self.assertNotIn("冯医生", 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("单位地址:测试路1号", 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"])
|
||||
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):
|
||||
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
|
||||
system = messages[0]["content"]
|
||||
self.assertIn("没有检索到可靠资料", system)
|
||||
self.assertIn("不要凭通用知识", system)
|
||||
self.assertIn("不要提及知识库", system)
|
||||
self.assertIn("不得推断服务对象", system)
|
||||
self.assertIn("工作场所", system)
|
||||
|
||||
def test_public_avatar_payload_excludes_internal_configuration(self):
|
||||
payload = _public_avatar_payload(self.avatar)
|
||||
self.assertEqual(payload["displayName"], "冯医生")
|
||||
self.assertEqual(payload["photoUrl"], "https://example.test/avatar.png")
|
||||
self.assertNotIn("config", payload)
|
||||
self.assertNotIn("ownerId", payload)
|
||||
|
||||
def test_unshared_avatars_do_not_reuse_a_unique_share_token(self):
|
||||
first = Avatar(name="first")
|
||||
second = Avatar(name="second")
|
||||
self.assertIsNone(first.share_token)
|
||||
self.assertIsNone(second.share_token)
|
||||
|
||||
def test_standard_answer_can_be_emitted_as_sse_chunks(self):
|
||||
self.assertEqual(list(_iter_text_chunks("标准答案内容", size=2)), ["标准", "答案", "内容"])
|
||||
|
||||
def test_chat_rejects_avatar_owned_by_another_user(self):
|
||||
class Query:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.value
|
||||
|
||||
self_avatar = self.avatar
|
||||
|
||||
class DB:
|
||||
avatar = self_avatar
|
||||
|
||||
def query(self, model):
|
||||
return Query(
|
||||
self.avatar if model is Avatar else SimpleNamespace(huihui_user_id="huihui-user-2")
|
||||
)
|
||||
|
||||
db = DB()
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
_require_owned_avatar(db, self.avatar.id, "Bearer other-token")
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import embeddings
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return None
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
|
||||
class TextExtractionTests(unittest.TestCase):
|
||||
def write_text(self, suffix, content):
|
||||
handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
handle.close()
|
||||
self.addCleanup(lambda: os.path.exists(handle.name) and os.unlink(handle.name))
|
||||
with open(handle.name, "w", encoding="utf-8") as stream:
|
||||
stream.write(content)
|
||||
return handle.name
|
||||
|
||||
def test_extracts_utf8_markdown(self):
|
||||
path = self.write_text(".md", "# 退款规则\n\n七日内可申请退款。")
|
||||
self.assertEqual(embeddings.extract_text(path, ".md"), "# 退款规则\n\n七日内可申请退款。")
|
||||
|
||||
def test_extracts_utf8_text(self):
|
||||
path = self.write_text(".txt", "客服热线:400-123-4567")
|
||||
self.assertEqual(embeddings.extract_text(path, ".txt"), "客服热线:400-123-4567")
|
||||
|
||||
def test_rejects_unsupported_extension(self):
|
||||
path = self.write_text(".csv", "not supported")
|
||||
with self.assertRaises(ValueError):
|
||||
embeddings.extract_text(path, ".csv")
|
||||
|
||||
|
||||
class RemoteEmbeddingTests(unittest.TestCase):
|
||||
def test_large_input_is_split_into_provider_safe_batches(self):
|
||||
texts = [f"chunk-{index}" for index in range(14)]
|
||||
batch_sizes = []
|
||||
requested_urls = []
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
self.assertEqual(timeout, 30)
|
||||
requested_urls.append(request.full_url)
|
||||
payload = json.loads(request.data.decode("utf-8"))
|
||||
batch_sizes.append(len(payload["input"]))
|
||||
return FakeResponse({
|
||||
"data": [
|
||||
{"index": index, "embedding": [float(text.split("-")[1])]}
|
||||
for index, text in enumerate(payload["input"])
|
||||
]
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"EMBEDDING_API_URL": "https://embedding.example/v1",
|
||||
"EMBEDDING_API_KEY": "test-key",
|
||||
"EMBEDDING_MODEL": "text-embedding-v4",
|
||||
"EMBEDDING_BATCH_SIZE": "10",
|
||||
}), patch("embeddings.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
result = embeddings.embed(texts)
|
||||
|
||||
self.assertEqual(batch_sizes, [10, 4])
|
||||
self.assertEqual(requested_urls, [
|
||||
"https://embedding.example/v1/embeddings",
|
||||
"https://embedding.example/v1/embeddings",
|
||||
])
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for preserving local avatar ownership when Huihui IDs change."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from database import Base
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from routers.huihui_auth import _issue_session, token_login
|
||||
from services.boxim_client import BoxIMError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _add_avatar_data(db, owner_id: str, suffix: str = "1") -> Avatar:
|
||||
avatar = Avatar(id=f"avatar-{suffix}", owner_id=owner_id, name="冯医生")
|
||||
db.add_all(
|
||||
[
|
||||
avatar,
|
||||
TakeoverCursor(id=f"cursor-{suffix}", avatar_id=avatar.id, owner_id=owner_id),
|
||||
TakeoverMessage(
|
||||
id=f"message-{suffix}",
|
||||
avatar_id=avatar.id,
|
||||
owner_id=owner_id,
|
||||
boxim_message_id=f"box-{suffix}",
|
||||
peer_id="peer",
|
||||
direction="incoming",
|
||||
send_time=datetime(2026, 8, 20, 12, 0, 0),
|
||||
),
|
||||
TakeoverReplyTask(
|
||||
id=f"task-{suffix}",
|
||||
avatar_id=avatar.id,
|
||||
owner_id=owner_id,
|
||||
peer_id="peer",
|
||||
trigger_message_id=f"trigger-{suffix}",
|
||||
scheduled_at=datetime(2026, 8, 20, 12, 0, 3),
|
||||
boxim_local_id=f"local-{suffix}",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return avatar
|
||||
|
||||
|
||||
def _assert_avatar_data_owner(db, avatar_id: str, owner_id: str):
|
||||
assert db.query(Avatar).filter_by(id=avatar_id).one().owner_id == owner_id
|
||||
assert db.query(TakeoverCursor).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
|
||||
assert db.query(TakeoverMessage).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
|
||||
assert db.query(TakeoverReplyTask).filter_by(avatar_id=avatar_id).one().owner_id == owner_id
|
||||
|
||||
|
||||
def test_unique_phone_user_is_reused_when_huihui_id_changes(db):
|
||||
legacy = User(
|
||||
id="legacy-local",
|
||||
huihui_user_id="fat-user-id",
|
||||
phone="18500000000",
|
||||
app_token="old-session",
|
||||
)
|
||||
db.add(legacy)
|
||||
db.commit()
|
||||
avatar = _add_avatar_data(db, legacy.huihui_user_id)
|
||||
|
||||
response = _issue_session(
|
||||
db,
|
||||
"18500000000",
|
||||
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
|
||||
)
|
||||
|
||||
users = db.query(User).all()
|
||||
assert len(users) == 1
|
||||
assert users[0].id == "legacy-local"
|
||||
assert users[0].huihui_user_id == "prod-user-id"
|
||||
assert response["data"]["token"] == users[0].app_token
|
||||
_assert_avatar_data_owner(db, avatar.id, "prod-user-id")
|
||||
|
||||
|
||||
def test_existing_production_user_claims_one_legacy_phone_account(db):
|
||||
current = User(
|
||||
id="prod-local",
|
||||
huihui_user_id="prod-user-id",
|
||||
phone="18500000000",
|
||||
)
|
||||
legacy = User(
|
||||
id="legacy-local",
|
||||
huihui_user_id="fat-user-id",
|
||||
phone="18500000000",
|
||||
app_token="old-session",
|
||||
huihui_token="fat-token",
|
||||
)
|
||||
db.add_all([current, legacy])
|
||||
db.commit()
|
||||
avatar = _add_avatar_data(db, legacy.huihui_user_id)
|
||||
|
||||
_issue_session(
|
||||
db,
|
||||
"18500000000",
|
||||
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
|
||||
)
|
||||
|
||||
db.refresh(legacy)
|
||||
assert legacy.app_token == ""
|
||||
assert legacy.huihui_token == ""
|
||||
_assert_avatar_data_owner(db, avatar.id, "prod-user-id")
|
||||
|
||||
|
||||
def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
|
||||
first = User(id="first", huihui_user_id="fat-1", phone="18500000000")
|
||||
second = User(id="second", huihui_user_id="fat-2", phone="18500000000")
|
||||
db.add_all([first, second])
|
||||
db.commit()
|
||||
first_avatar = _add_avatar_data(db, first.huihui_user_id, "1")
|
||||
second_avatar = _add_avatar_data(db, second.huihui_user_id, "2")
|
||||
|
||||
_issue_session(
|
||||
db,
|
||||
"18500000000",
|
||||
{"userId": "prod-user-id", "nickname": "用户", "token": "prod-token"},
|
||||
)
|
||||
|
||||
assert db.query(User).count() == 3
|
||||
_assert_avatar_data_owner(db, first_avatar.id, "fat-1")
|
||||
_assert_avatar_data_owner(db, second_avatar.id, "fat-2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_login_uses_huihui_user_id_and_keeps_upstream_token_server_side(db):
|
||||
existing = User(
|
||||
id="existing-local",
|
||||
huihui_user_id="huihui-user-88",
|
||||
app_token="existing-app-session",
|
||||
)
|
||||
db.add(existing)
|
||||
db.commit()
|
||||
|
||||
client = AsyncMock()
|
||||
client.exchange_access_token.return_value = {"accessToken": "boxim-token"}
|
||||
client.get_self.return_value = {
|
||||
"id": 998877,
|
||||
"huihuiUserId": "huihui-user-88",
|
||||
"nickName": "会会用户",
|
||||
"headImage": "https://cdn.example/avatar.jpg",
|
||||
}
|
||||
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
|
||||
"routers.huihui_auth._create_boxim_client", return_value=client
|
||||
):
|
||||
response = await token_login({"token": "production-huihui-token"}, db)
|
||||
|
||||
assert response["code"] == 200
|
||||
assert response["data"]["token"] == "existing-app-session"
|
||||
assert "token" not in response["data"]["huihui"]
|
||||
user = db.query(User).one()
|
||||
assert user.huihui_user_id == "huihui-user-88"
|
||||
assert user.huihui_user_id != "998877"
|
||||
assert user.huihui_token == "production-huihui-token"
|
||||
assert user.nickname == "会会用户"
|
||||
assert user.avatar_url == "https://cdn.example/avatar.jpg"
|
||||
client.exchange_access_token.assert_awaited_once_with("production-huihui-token")
|
||||
client.get_self.assert_awaited_once_with("boxim-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_login_rejects_expired_huihui_token_without_creating_user(db):
|
||||
client = AsyncMock()
|
||||
client.exchange_access_token.side_effect = BoxIMError(
|
||||
"expired", auth_error=True
|
||||
)
|
||||
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
|
||||
"routers.huihui_auth._create_boxim_client", return_value=client
|
||||
):
|
||||
response = await token_login({"token": "expired-token"}, db)
|
||||
|
||||
assert response["code"] == 401
|
||||
assert response["message"] == "会会登录凭证无效或已过期"
|
||||
assert db.query(User).count() == 0
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,185 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
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
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
||||
avatar_id = "avatar-1"
|
||||
stored_name = "knowledge.md"
|
||||
doc = SimpleNamespace(
|
||||
avatar_id=avatar_id,
|
||||
file_url=f"/api/files/{avatar_id}/{stored_name}",
|
||||
to_dict=lambda: {"id": "doc-1", "fileUrl": f"/api/files/{avatar_id}/{stored_name}"},
|
||||
)
|
||||
stored_dir = tmp_path / avatar_id
|
||||
stored_dir.mkdir()
|
||||
stored_file = stored_dir / stored_name
|
||||
|
||||
with patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)):
|
||||
assert _doc_payload(doc)["filePresent"] is False
|
||||
stored_file.write_text("knowledge", encoding="utf-8")
|
||||
assert _doc_payload(doc)["filePresent"] is True
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Tests for takeover configuration and BOXIM connection status."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app
|
||||
from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask, User
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_update_takeover_accepts_camel_case_and_persists(authorization_context):
|
||||
context = authorization_context
|
||||
response = client.put(
|
||||
f"/api/avatar/{context['avatar'].id}/authorizations/takeover",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"authorizationId": context["authorization"].id,
|
||||
"takeoverEnabled": True,
|
||||
"takeoverMode": "delayed",
|
||||
"takeoverDelaySeconds": 60,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["code"] == 200
|
||||
assert payload["data"]["takeoverEnabled"] is True
|
||||
assert payload["data"]["takeoverMode"] == "delayed"
|
||||
assert payload["data"]["takeoverDelaySeconds"] == 60
|
||||
assert "takeover" in payload["data"]["permissions"]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored = db.query(Authorization).filter(
|
||||
Authorization.id == context["authorization"].id
|
||||
).first()
|
||||
assert stored.takeover_enabled is True
|
||||
assert stored.takeover_mode == "delayed"
|
||||
assert stored.takeover_delay_seconds == 60
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_disabling_authorization_also_disables_takeover(authorization_context):
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/authorizations/takeover"
|
||||
client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"authorizationId": context["authorization"].id,
|
||||
"takeoverEnabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
updated = client.put(
|
||||
f"/api/avatar/{context['avatar'].id}/authorizations",
|
||||
headers=context["owner_headers"],
|
||||
json={"id": context["authorization"].id, "status": "inactive"},
|
||||
).json()
|
||||
assert updated["code"] == 200
|
||||
assert updated["data"]["status"] == "inactive"
|
||||
assert updated["data"]["takeoverEnabled"] is False
|
||||
assert "takeover" not in updated["data"]["permissions"]
|
||||
|
||||
|
||||
def test_takeover_rejects_invalid_values_and_cross_avatar_access(authorization_context):
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/authorizations/takeover"
|
||||
|
||||
invalid_mode = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"authorization_id": context["authorization"].id,
|
||||
"takeover_mode": "invalid",
|
||||
},
|
||||
).json()
|
||||
assert invalid_mode["code"] == 400
|
||||
|
||||
invalid_delay = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"authorization_id": context["authorization"].id,
|
||||
"takeover_delay_seconds": 2,
|
||||
},
|
||||
).json()
|
||||
assert invalid_delay["code"] == 400
|
||||
|
||||
forbidden = client.put(
|
||||
endpoint,
|
||||
headers=context["other_headers"],
|
||||
json={
|
||||
"authorizationId": context["authorization"].id,
|
||||
"takeoverEnabled": True,
|
||||
},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
def test_takeover_is_limited_to_active_user_authorizations(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
created = client.post(
|
||||
f"/api/avatar/{avatar_id}/authorizations",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"targetType": "organization",
|
||||
"targetId": f"org-{context['suffix']}",
|
||||
"targetName": "测试组织",
|
||||
"permissions": ["chat"],
|
||||
},
|
||||
).json()
|
||||
response = client.put(
|
||||
f"/api/avatar/{avatar_id}/authorizations/takeover",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"authorizationId": created["data"]["id"],
|
||||
"takeoverEnabled": True,
|
||||
},
|
||||
).json()
|
||||
assert response["code"] == 400
|
||||
assert "单聊接管" in response["message"]
|
||||
|
||||
|
||||
def test_takeover_status_reports_disabled_and_requires_owner_login(authorization_context):
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/takeover/status"
|
||||
|
||||
disabled = client.get(endpoint, headers=context["owner_headers"])
|
||||
assert disabled.status_code == 200
|
||||
assert disabled.json()["data"]["status"] == "disabled"
|
||||
|
||||
client.put(
|
||||
f"/api/avatar/{context['avatar'].id}/permission-settings",
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat", "takeover"]},
|
||||
)
|
||||
needs_login = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert needs_login["enabled"] is True
|
||||
assert needs_login["status"] == "needs_login"
|
||||
assert "BOXIM" in needs_login["message"]
|
||||
|
||||
assert client.get(endpoint).status_code == 401
|
||||
assert client.get(endpoint, headers=context["other_headers"]).status_code == 403
|
||||
|
||||
|
||||
def test_takeover_status_reports_ready_pending_count_and_errors(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
endpoint = f"/api/avatar/{avatar_id}/takeover/status"
|
||||
client.put(
|
||||
f"/api/avatar/{avatar_id}/permission-settings",
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat", "takeover"]},
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
owner = db.query(User).filter(User.id == context["owner"].id).one()
|
||||
owner.huihui_token = "production-login-token"
|
||||
cursor = TakeoverCursor(
|
||||
avatar_id=avatar_id,
|
||||
owner_id=owner.huihui_user_id,
|
||||
boxim_owner_id="100",
|
||||
last_message_id="10",
|
||||
initialized=True,
|
||||
last_polled_at=datetime.utcnow(),
|
||||
)
|
||||
task = TakeoverReplyTask(
|
||||
avatar_id=avatar_id,
|
||||
owner_id=owner.huihui_user_id,
|
||||
peer_id="200",
|
||||
trigger_message_id="11",
|
||||
source_message_ids=["11"],
|
||||
prompt="你好",
|
||||
status="pending",
|
||||
scheduled_at=datetime.utcnow(),
|
||||
boxim_local_id="123",
|
||||
)
|
||||
db.add_all([cursor, task])
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
ready = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert ready["status"] == "ready"
|
||||
assert ready["pendingCount"] == 1
|
||||
assert ready["lastPolledAt"]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
|
||||
cursor.last_polled_at = datetime.utcnow() - timedelta(seconds=30)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
long_polling = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert long_polling["status"] == "ready"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
|
||||
cursor.last_polled_at = datetime.utcnow() - timedelta(seconds=61)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
stale = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert stale["status"] == "connecting"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
|
||||
cursor.last_error = "BOXIM 暂时不可用"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
failed = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert failed["status"] == "error"
|
||||
assert failed["message"] == "BOXIM 暂时不可用"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).one()
|
||||
avatar.config = {"authorizationPermissions": ["chat"]}
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
auto_disabled = client.get(endpoint, headers=context["owner_headers"]).json()["data"]
|
||||
assert auto_disabled["enabled"] is False
|
||||
assert auto_disabled["status"] == "error"
|
||||
|
||||
client.put(
|
||||
f"/api/avatar/{avatar_id}/permission-settings",
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat", "takeover"]},
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).one()
|
||||
assert cursor.initialized is False
|
||||
assert cursor.last_message_id == "0"
|
||||
assert cursor.last_error == ""
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,30 @@
|
||||
from database import SessionLocal
|
||||
from models import Authorization
|
||||
|
||||
|
||||
def test_authorization_takeover_fields():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
auth = db.query(Authorization).first()
|
||||
assert auth is not None
|
||||
# Check new fields exist and have default values
|
||||
assert hasattr(auth, 'takeover_enabled')
|
||||
assert hasattr(auth, 'takeover_mode')
|
||||
assert hasattr(auth, 'takeover_delay_seconds')
|
||||
assert auth.takeover_enabled == False
|
||||
assert auth.takeover_mode == 'immediate'
|
||||
assert auth.takeover_delay_seconds == 180
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_authorization_to_dict_includes_takeover():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
auth = db.query(Authorization).first()
|
||||
d = auth.to_dict()
|
||||
assert 'takeoverEnabled' in d
|
||||
assert 'takeoverMode' in d
|
||||
assert 'takeoverDelaySeconds' in d
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for the BOXIM takeover scheduler lifecycle."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def test_app_has_startup_and_shutdown_events():
|
||||
from main import app
|
||||
|
||||
assert app.router.on_startup
|
||||
assert app.router.on_shutdown
|
||||
|
||||
|
||||
@patch("services.takeover_service.TakeoverService")
|
||||
@patch("services.boxim_client.BoxIMClient")
|
||||
@patch("main.AsyncIOScheduler")
|
||||
def test_scheduler_uses_boxim_and_restart_safe_service(
|
||||
mock_scheduler_class,
|
||||
mock_boxim_class,
|
||||
mock_takeover_class,
|
||||
):
|
||||
import main
|
||||
|
||||
maintenance_scheduler = MagicMock()
|
||||
scheduler = MagicMock()
|
||||
mock_scheduler_class.side_effect = [maintenance_scheduler, scheduler]
|
||||
boxim = MagicMock()
|
||||
mock_boxim_class.return_value = boxim
|
||||
takeover = MagicMock()
|
||||
takeover.poll_messages = AsyncMock()
|
||||
takeover.process_reply_tasks = AsyncMock()
|
||||
mock_takeover_class.return_value = takeover
|
||||
|
||||
environment = {
|
||||
"HUIHUI_PLATFORM_BASE_URL": "https://open.example/api",
|
||||
"BOXIM_API_BASE_URL": "https://im.example/api",
|
||||
"HUIHUI_APP_ID": "app-id",
|
||||
"HUIHUI_ACCESS_ID": "access-id",
|
||||
"HUIHUI_ACCESS_SECRET": "secret",
|
||||
"BOXIM_POLL_INTERVAL_SECONDS": "1",
|
||||
}
|
||||
with patch("main.init_db"), patch("main.seed"), patch.dict(
|
||||
"os.environ", environment, clear=False
|
||||
):
|
||||
main.on_startup()
|
||||
|
||||
config = mock_boxim_class.call_args.args[0]
|
||||
assert config["HUIHUI_PLATFORM_BASE_URL"] == "https://open.example/api"
|
||||
assert config["BOXIM_API_BASE_URL"] == "https://im.example/api"
|
||||
mock_takeover_class.assert_called_once_with(
|
||||
main.SessionLocal,
|
||||
boxim,
|
||||
poll_concurrency=8,
|
||||
max_message_age_seconds=600,
|
||||
)
|
||||
|
||||
maintenance_scheduler.add_job.assert_called_once()
|
||||
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup"
|
||||
maintenance_scheduler.start.assert_called_once_with()
|
||||
|
||||
assert scheduler.add_job.call_count == 2
|
||||
poll_call, process_call = scheduler.add_job.call_args_list
|
||||
assert poll_call.args[0] is takeover.poll_messages
|
||||
assert poll_call.kwargs["id"] == "takeover_message_poll"
|
||||
assert poll_call.kwargs["trigger"].interval.total_seconds() == 1
|
||||
assert poll_call.kwargs["max_instances"] == 1
|
||||
assert poll_call.kwargs["coalesce"] is True
|
||||
assert process_call.args[0] is takeover.process_reply_tasks
|
||||
assert process_call.kwargs["id"] == "takeover_reply_process"
|
||||
assert process_call.kwargs["trigger"].interval.total_seconds() == 0.5
|
||||
assert process_call.kwargs["max_instances"] == 1
|
||||
assert process_call.kwargs["coalesce"] is True
|
||||
scheduler.start.assert_called_once_with()
|
||||
|
||||
main.takeover_scheduler = None
|
||||
main.maintenance_scheduler = None
|
||||
|
||||
|
||||
@patch("main.AsyncIOScheduler")
|
||||
def test_scheduler_failure_does_not_stop_the_api(mock_scheduler_class):
|
||||
import main
|
||||
|
||||
mock_scheduler_class.side_effect = RuntimeError("scheduler crash")
|
||||
with patch("main.init_db"), patch("main.seed"):
|
||||
main.on_startup()
|
||||
|
||||
assert main.takeover_scheduler is None
|
||||
assert main.maintenance_scheduler is None
|
||||
|
||||
|
||||
def test_shutdown_stops_only_the_scheduler():
|
||||
import main
|
||||
|
||||
scheduler = MagicMock()
|
||||
scheduler.running = True
|
||||
maintenance_scheduler = MagicMock()
|
||||
maintenance_scheduler.running = True
|
||||
main.takeover_scheduler = scheduler
|
||||
main.maintenance_scheduler = maintenance_scheduler
|
||||
|
||||
main.on_shutdown()
|
||||
|
||||
scheduler.shutdown.assert_called_once_with(wait=False)
|
||||
maintenance_scheduler.shutdown.assert_called_once_with(wait=False)
|
||||
assert main.takeover_scheduler is None
|
||||
assert main.maintenance_scheduler is None
|
||||
@@ -0,0 +1,811 @@
|
||||
"""End-to-end service tests for BOXIM takeover timing and human priority."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import Barrier
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from database import Base
|
||||
from models import Avatar, ChatAttachment, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from services.boxim_client import BoxIMError
|
||||
from services.boxim_image_service import DownloadedBoxIMImage
|
||||
from services.takeover_service import (
|
||||
AVATAR_LOCAL_ID_PREFIX,
|
||||
TakeoverService,
|
||||
_avatar_local_id,
|
||||
_plain_text_reply,
|
||||
)
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.value = datetime(2026, 8, 19, 10, 0, 0)
|
||||
|
||||
def now(self):
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds: int):
|
||||
self.value += timedelta(seconds=seconds)
|
||||
|
||||
def millis(self):
|
||||
return int(self.value.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||
|
||||
|
||||
class FakeBoxIM:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
self.sent = []
|
||||
self.read_receipts = []
|
||||
|
||||
async def exchange_access_token(self, huihui_token):
|
||||
assert huihui_token == "prod-huihui-token"
|
||||
return {"accessToken": "box-token", "accessTokenExpiresIn": 3600}
|
||||
|
||||
async def get_self(self, access_token):
|
||||
assert access_token == "box-token"
|
||||
return {"id": 100}
|
||||
|
||||
async def fetch_private_messages(self, access_token, min_id="0"):
|
||||
assert access_token == "box-token"
|
||||
return [item.copy() for item in self.messages if int(item["id"]) > int(min_id)]
|
||||
|
||||
async def mark_private_messages_read(self, access_token, friend_id, message_id):
|
||||
assert access_token == "box-token"
|
||||
self.read_receipts.append(
|
||||
{"friendId": str(friend_id), "messageId": str(message_id)}
|
||||
)
|
||||
|
||||
async def send_private_message(self, access_token, peer_id, content, *, local_id=None):
|
||||
self.sent.append({"peerId": str(peer_id), "content": content, "localId": str(local_id)})
|
||||
return {"id": 900 + len(self.sent), "localId": int(local_id)}
|
||||
|
||||
|
||||
class ConcurrentPollingBoxIM(FakeBoxIM):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.active_polls = 0
|
||||
self.peak_active_polls = 0
|
||||
|
||||
async def exchange_access_token(self, huihui_token):
|
||||
return {"accessToken": huihui_token, "accessTokenExpiresIn": 3600}
|
||||
|
||||
async def get_self(self, access_token):
|
||||
return {"id": 100 if access_token == "prod-huihui-token" else 101}
|
||||
|
||||
async def fetch_private_messages(self, access_token, min_id="0"):
|
||||
self.active_polls += 1
|
||||
self.peak_active_polls = max(self.peak_active_polls, self.active_polls)
|
||||
await asyncio.sleep(0.05)
|
||||
self.active_polls -= 1
|
||||
return []
|
||||
|
||||
|
||||
class ConcurrentMessagePollingBoxIM(ConcurrentPollingBoxIM):
|
||||
async def fetch_private_messages(self, access_token, min_id="0"):
|
||||
await super().fetch_private_messages(access_token, min_id)
|
||||
owner_id = 100 if access_token == "prod-huihui-token" else 101
|
||||
return [
|
||||
{
|
||||
"id": owner_id,
|
||||
"localId": owner_id,
|
||||
"sendId": owner_id + 100,
|
||||
"recvId": owner_id,
|
||||
"sendTime": 1_700_000_000_000,
|
||||
"type": 0,
|
||||
"content": "并发写入测试",
|
||||
}
|
||||
]
|
||||
|
||||
async def mark_private_messages_read(self, access_token, friend_id, message_id):
|
||||
await asyncio.sleep(0.05)
|
||||
self.read_receipts.append(
|
||||
{"friendId": str(friend_id), "messageId": str(message_id)}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_context(tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'takeover.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
Base.metadata.create_all(engine)
|
||||
db = session_factory()
|
||||
user = User(
|
||||
id="owner-local",
|
||||
huihui_user_id="owner-huihui",
|
||||
huihui_token="prod-huihui-token",
|
||||
app_token="app-token",
|
||||
)
|
||||
avatar = Avatar(
|
||||
id="avatar-1",
|
||||
owner_id=user.huihui_user_id,
|
||||
name="分身",
|
||||
status="active",
|
||||
config={
|
||||
"authorizationPermissions": ["chat", "takeover"],
|
||||
"takeoverReplyDelaySeconds": 3,
|
||||
},
|
||||
)
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
clock = Clock()
|
||||
boxim = FakeBoxIM()
|
||||
service = TakeoverService(session_factory, boxim, now=clock.now)
|
||||
return session_factory, service, boxim, clock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_sync_primes_cursor_without_replying_to_history(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
boxim.messages = [
|
||||
{"id": 10, "localId": 1, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "旧消息"}
|
||||
]
|
||||
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "不应发送"}):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
cursor = db.query(TakeoverCursor).one()
|
||||
assert cursor.initialized is True
|
||||
assert cursor.last_message_id == "10"
|
||||
assert db.query(TakeoverMessage).count() == 1
|
||||
assert db.query(TakeoverReplyTask).count() == 0
|
||||
assert boxim.sent == []
|
||||
assert boxim.read_receipts == [{"friendId": "200", "messageId": "10"}]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
|
||||
)
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
assert boxim.sent == []
|
||||
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
|
||||
|
||||
clock.advance(2)
|
||||
await service.poll_and_process_messages()
|
||||
assert boxim.sent == []
|
||||
|
||||
clock.advance(1)
|
||||
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"]}]
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).one()
|
||||
assert task.status == "sent"
|
||||
assert task.sent_at == clock.now()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incoming_image_is_analyzed_and_used_in_takeover_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 111,
|
||||
"localId": 111,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 1,
|
||||
"content": json.dumps(
|
||||
{
|
||||
"originUrl": "https://cdn.example/case.png",
|
||||
"thumbUrl": "https://cdn.example/case-thumb.png",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
db = session_factory()
|
||||
try:
|
||||
scheduled = db.query(TakeoverReplyTask).filter_by(trigger_message_id="111").one()
|
||||
assert scheduled.status == "pending"
|
||||
assert scheduled.prompt == "请看看这张图片。"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
clock.advance(3)
|
||||
|
||||
def analyze(db, avatar, content, **kwargs):
|
||||
assert content == b"image-content"
|
||||
attachment = ChatAttachment(
|
||||
avatar_id=avatar.id,
|
||||
uploader_kind=kwargs["uploader_kind"],
|
||||
filename=kwargs["filename"],
|
||||
mime_type="image/jpeg",
|
||||
file_size=len(content),
|
||||
status="ready",
|
||||
category="medical_document",
|
||||
summary="一张门诊病例",
|
||||
extracted_text="主诉:咳嗽三天",
|
||||
structured_data={"medical": {"chief_complaint": "咳嗽三天"}},
|
||||
warning="请核对原始资料",
|
||||
expires_at=clock.now() + timedelta(hours=24),
|
||||
)
|
||||
db.add(attachment)
|
||||
db.commit()
|
||||
db.refresh(attachment)
|
||||
return attachment
|
||||
|
||||
downloaded = DownloadedBoxIMImage(
|
||||
content=b"image-content",
|
||||
filename="case.png",
|
||||
mime_type="image/png",
|
||||
source_url="https://cdn.example/case.png",
|
||||
)
|
||||
with (
|
||||
patch("services.takeover_service.download_boxim_image", return_value=downloaded),
|
||||
patch("routers.chat._analyze_image_bytes", side_effect=analyze) as analyzer,
|
||||
patch("routers.chat._resolve_reply", return_value={"answer": "这份资料里写的是咳嗽三天。"}) as resolver,
|
||||
):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
analyzer.assert_called_once()
|
||||
assert resolver.call_args.args[2] == "请看看这张图片。"
|
||||
image_contexts = resolver.call_args.kwargs["image_contexts"]
|
||||
assert image_contexts[0]["summary"] == "一张门诊病例"
|
||||
assert image_contexts[0]["extractedText"] == "主诉:咳嗽三天"
|
||||
assert [item["content"] for item in boxim.sent] == ["这份资料里写的是咳嗽三天。"]
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
event = db.query(TakeoverMessage).filter_by(boxim_message_id="111").one()
|
||||
task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="111").one()
|
||||
assert event.attachment_id
|
||||
assert db.get(ChatAttachment, event.attachment_id).uploader_kind == "boxim"
|
||||
assert task.status == "sent"
|
||||
with patch(
|
||||
"services.takeover_service.download_boxim_image",
|
||||
side_effect=AssertionError("cached image must not be downloaded again"),
|
||||
):
|
||||
cached = service._takeover_image_attachment(db, db.get(Avatar, "avatar-1"), event)
|
||||
assert cached.id == event.attachment_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_text_recovers_recent_image_recorded_without_task(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
image_message = {
|
||||
"id": 113,
|
||||
"localId": 113,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 1,
|
||||
"content": json.dumps(
|
||||
{
|
||||
"originUrl": "https://cdn.example/case.png",
|
||||
"thumbUrl": "https://cdn.example/case-thumb.png",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
avatar = db.get(Avatar, "avatar-1")
|
||||
service._record_message(db, avatar, "100", image_message, schedule_reply=False)
|
||||
cursor = db.query(TakeoverCursor).one()
|
||||
cursor.last_message_id = "113"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
clock.advance(60)
|
||||
boxim.messages.extend(
|
||||
[
|
||||
image_message,
|
||||
{
|
||||
"id": 114,
|
||||
"localId": 114,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 0,
|
||||
"content": "请帮我看看这张图",
|
||||
},
|
||||
]
|
||||
)
|
||||
await service.poll_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="114").one()
|
||||
assert task.source_message_ids == ["113", "114"]
|
||||
assert task.prompt == "请看看这张图片。\n请帮我看看这张图"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
clock.advance(1)
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 115,
|
||||
"localId": 115,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 0,
|
||||
"content": "图里写了什么",
|
||||
}
|
||||
)
|
||||
await service.poll_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
latest = db.query(TakeoverReplyTask).filter_by(trigger_message_id="115").one()
|
||||
assert latest.source_message_ids == ["113", "114", "115"]
|
||||
assert latest.source_message_ids.count("113") == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_image_message_is_recorded_but_not_scheduled(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 112,
|
||||
"localId": 112,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 1,
|
||||
"content": json.dumps({"width": 100, "height": 100}),
|
||||
}
|
||||
)
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(TakeoverMessage).filter_by(boxim_message_id="112").one()
|
||||
assert db.query(TakeoverReplyTask).count() == 0
|
||||
finally:
|
||||
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_multiple_avatar_owners_are_polled_concurrently(service_context):
|
||||
session_factory, _service, _boxim, clock = service_context
|
||||
db = session_factory()
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
User(
|
||||
id="owner-local-2",
|
||||
huihui_user_id="owner-huihui-2",
|
||||
huihui_token="prod-huihui-token-2",
|
||||
app_token="app-token-2",
|
||||
),
|
||||
Avatar(
|
||||
id="avatar-2",
|
||||
owner_id="owner-huihui-2",
|
||||
name="分身二",
|
||||
status="active",
|
||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
boxim = ConcurrentMessagePollingBoxIM()
|
||||
service = TakeoverService(
|
||||
session_factory,
|
||||
boxim,
|
||||
poll_concurrency=2,
|
||||
now=clock.now,
|
||||
)
|
||||
|
||||
await service.poll_messages()
|
||||
|
||||
assert boxim.peak_active_polls == 2
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(TakeoverCursor).filter(TakeoverCursor.initialized.is_(True)).count() == 2
|
||||
assert db.query(TakeoverMessage).count() == 2
|
||||
assert len(boxim.read_receipts) == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delayed_poll_still_schedules_recent_message(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_messages()
|
||||
delayed_send_time = int(
|
||||
(clock.value - timedelta(seconds=150)).replace(tzinfo=timezone.utc).timestamp()
|
||||
* 1000
|
||||
)
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 13,
|
||||
"localId": 13,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": delayed_send_time,
|
||||
"type": 0,
|
||||
"content": "排队后仍需回复",
|
||||
}
|
||||
)
|
||||
|
||||
await service.poll_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).one()
|
||||
assert task.status == "pending"
|
||||
assert task.scheduled_at == clock.now()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "已经收到"}):
|
||||
await service.process_reply_tasks()
|
||||
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
|
||||
async def test_different_contacts_generate_without_blocking_each_other(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.extend(
|
||||
[
|
||||
{"id": 13, "localId": 31, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "联系人甲"},
|
||||
{"id": 14, "localId": 32, "sendId": 300, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "联系人乙"},
|
||||
]
|
||||
)
|
||||
both_generating = Barrier(2, timeout=2)
|
||||
|
||||
def resolve(_db, _avatar, prompt, _history, **_kwargs):
|
||||
both_generating.wait()
|
||||
return {"answer": f"回复{prompt[-1]}"}
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(3)
|
||||
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} == {
|
||||
("200", "回复甲"),
|
||||
("300", "回复乙"),
|
||||
}
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
assert {task.status for task in db.query(TakeoverReplyTask).all()} == {"sent"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_receipt_failure_does_not_advance_cursor(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 12, "localId": 3, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "未读消息"}
|
||||
)
|
||||
boxim.mark_private_messages_read = AsyncMock(side_effect=BoxIMError("回执失败"))
|
||||
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "稍后回复"}):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
cursor = db.query(TakeoverCursor).one()
|
||||
assert cursor.last_message_id == "0"
|
||||
assert db.query(TakeoverMessage).count() == 0
|
||||
assert db.query(TakeoverReplyTask).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
boxim.mark_private_messages_read = AsyncMock(return_value=None)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "稍后回复"}):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(TakeoverCursor).one().last_message_id == "12"
|
||||
assert db.query(TakeoverMessage).count() == 1
|
||||
assert db.query(TakeoverReplyTask).count() == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owner_message_cancels_pending_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 21, "localId": 3, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "在吗"}
|
||||
)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "在的"}):
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(2)
|
||||
boxim.messages.append(
|
||||
{"id": 22, "localId": 4, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来回复"}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
clock.advance(2)
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "21").one()
|
||||
assert task.status == "cancelled"
|
||||
assert task.cancel_reason == "owner_replied"
|
||||
assert boxim.sent == []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_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
|
||||
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(1)
|
||||
boxim.messages.append(
|
||||
{"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:
|
||||
await service.poll_and_process_messages()
|
||||
assert resolver.call_args.args[2] == "第一句\n第二句"
|
||||
assert [item["content"] for item in boxim.sent] == ["合并回复"]
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
tasks = db.query(TakeoverReplyTask).order_by(TakeoverReplyTask.created_at).all()
|
||||
assert [task.status for task in tasks] == ["cancelled", "sent"]
|
||||
assert tasks[0].cancel_reason == "newer_incoming_message"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_failure_disables_takeover_and_stops_retrying(service_context):
|
||||
session_factory, service, boxim, _ = service_context
|
||||
boxim.exchange_access_token = AsyncMock(
|
||||
side_effect=BoxIMError("无效的访问令牌", code=40101, auth_error=True)
|
||||
)
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
avatar = db.query(Avatar).one()
|
||||
cursor = db.query(TakeoverCursor).one()
|
||||
assert "takeover" not in avatar.config["authorizationPermissions"]
|
||||
assert cursor.initialized is False
|
||||
assert "重新登录" in cursor.last_error
|
||||
assert db.query(TakeoverReplyTask).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_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():
|
||||
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()
|
||||
@@ -0,0 +1,98 @@
|
||||
import io
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from services.chat_model_config import ChatModelConfig
|
||||
from services.vision_service import (
|
||||
ImageValidationError,
|
||||
build_attachment_warning,
|
||||
call_vision_model,
|
||||
parse_vision_analysis,
|
||||
prepare_image,
|
||||
)
|
||||
|
||||
|
||||
def _image_bytes(fmt="PNG", size=(120, 80)):
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", size, "#f97316").save(output, format=fmt)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _config():
|
||||
return ChatModelConfig(
|
||||
api_base_url="https://model.test/v1",
|
||||
api_key="secret-key",
|
||||
model="chat-model",
|
||||
max_tokens=1024,
|
||||
timeout_seconds=30,
|
||||
vision_model="vision-model",
|
||||
ocr_model="ocr-model",
|
||||
vision_max_tokens=2048,
|
||||
vision_timeout_seconds=90,
|
||||
source="test",
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_image_validates_and_reencodes_without_metadata():
|
||||
prepared = prepare_image(_image_bytes())
|
||||
|
||||
assert prepared.mime_type == "image/jpeg"
|
||||
assert prepared.width == 120
|
||||
assert prepared.height == 80
|
||||
with Image.open(io.BytesIO(prepared.data)) as image:
|
||||
assert image.format == "JPEG"
|
||||
assert not image.getexif()
|
||||
|
||||
|
||||
def test_prepare_image_rejects_non_image_content():
|
||||
with pytest.raises(ImageValidationError, match="格式无效"):
|
||||
prepare_image(b"not-an-image")
|
||||
|
||||
|
||||
def test_vision_request_uses_openai_compatible_image_content():
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"choices": [{"message": {"content": '{"category":"general_image"}'}}],
|
||||
"usage": {"total_tokens": 88},
|
||||
}
|
||||
prepared = prepare_image(_image_bytes())
|
||||
|
||||
with patch("services.vision_service.httpx.post", return_value=response) as request:
|
||||
result = call_vision_model(
|
||||
prepared,
|
||||
_config(),
|
||||
model="vision-model",
|
||||
prompt="describe",
|
||||
json_output=True,
|
||||
)
|
||||
|
||||
payload = request.call_args.kwargs["json"]
|
||||
content = payload["messages"][0]["content"]
|
||||
assert payload["model"] == "vision-model"
|
||||
assert payload["response_format"] == {"type": "json_object"}
|
||||
assert content[0]["type"] == "image_url"
|
||||
assert content[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
|
||||
assert content[1] == {"type": "text", "text": "describe"}
|
||||
assert result["usage"]["total_tokens"] == 88
|
||||
|
||||
|
||||
def test_parse_medical_analysis_and_build_warning():
|
||||
analysis = parse_vision_analysis(json.dumps({
|
||||
"category": "medical_document",
|
||||
"summary": "血常规报告",
|
||||
"visible_text": "白细胞 11.2",
|
||||
"key_facts": ["白细胞偏高"],
|
||||
"uncertainties": ["日期模糊"],
|
||||
"medical": {"document_type": "检验报告"},
|
||||
}, ensure_ascii=False))
|
||||
|
||||
assert analysis["category"] == "medical_document"
|
||||
assert analysis["medical"]["document_type"] == "检验报告"
|
||||
warning = build_attachment_warning(analysis, ocr_failed=True)
|
||||
assert "日期模糊" in warning
|
||||
assert "人工核对" in warning
|
||||
assert "不能替代医生诊断" in warning
|
||||
@@ -0,0 +1,42 @@
|
||||
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
|
||||
services:
|
||||
avatar-backend:
|
||||
build: ./backend
|
||||
image: avatar-test-backend:latest
|
||||
container_name: avatar-test-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: sqlite:////data/avatar.db
|
||||
UPLOAD_DIR: /data/uploads
|
||||
CHAT_MODEL_CONFIG_URL: http://host.docker.internal:8000/api/ai-models/runtime/digital-avatar
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- avatar-data:/data
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
avatar-frontend:
|
||||
build: .
|
||||
image: avatar-test-frontend:latest
|
||||
container_name: avatar-test-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8099:80" # 浏览器访问 http://<host>:8099
|
||||
depends_on:
|
||||
- avatar-backend
|
||||
networks:
|
||||
- avatar-net
|
||||
|
||||
networks:
|
||||
avatar-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
avatar-data:
|
||||
@@ -0,0 +1,146 @@
|
||||
# 数字分身 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
|
||||
BOXIM_POLL_CONCURRENCY=8
|
||||
BOXIM_MAX_MESSAGE_AGE_SECONDS=600
|
||||
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
|
||||
|
||||
VISION_MODEL=qwen3.6-flash
|
||||
VISION_OCR_MODEL=qwen-vl-ocr
|
||||
VISION_MAX_OUTPUT_TOKENS=2048
|
||||
VISION_TIMEOUT_SECONDS=90
|
||||
VISION_TOKEN_RESERVE=12000
|
||||
CHAT_IMAGE_MAX_BYTES=8388608
|
||||
CHAT_IMAGE_MAX_PIXELS=16000000
|
||||
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
||||
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||
```
|
||||
|
||||
如生产 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` 同时用于知识库文件和聊天图片上传。应用只保存图片识别结果,不保存原图;识别结果 24 小时失效,后台默认每小时清理一次。公开分享图片识别会消耗分身所有者积分,生产网关应针对 `/api/public/avatar/*/chat/images` 设置每 IP 和每分享令牌的上传频率限制,防止恶意消耗。
|
||||
|
||||
网关和应用日志必须关闭完整 URL 查询参数记录,任何异常日志都不得输出 token、Authorization、图片 Base64、病例正文或平台密钥。建议同时设置严格的 `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. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
||||
11. 私聊和公开分享各上传 JPG、PNG、WebP 图片并完成追问;上传非图片、超过 8MB 或跨分身附件时必须拒绝。
|
||||
12. 病例图片可以提取可见文字并标记待核对内容,医学影像不作确定诊断;视觉与 OCR 调用分别扣减积分。
|
||||
13. 检查服务器上传目录不残留聊天原图,数据库过期图片识别记录在清理周期后删除,日志不出现 Base64 或病例正文。
|
||||
|
||||
## 6. 回滚
|
||||
|
||||
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
|
||||
@@ -0,0 +1,184 @@
|
||||
# 数字分身图片与病例理解详细设计
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
本功能让数字分身在私聊和公开分享聊天中接收图片,并围绕图片内容继续使用现有的“标准答题对 -> 分身独立知识库 -> Qwen 兼容模型”链路回答。
|
||||
|
||||
第一期支持 JPEG、PNG、WebP,覆盖以下场景:
|
||||
|
||||
1. 普通照片、截图、图表和界面图片的内容理解。
|
||||
2. 病例、处方、检查单、检验报告等图片文档的文字和表格提取。
|
||||
3. X 光、CT、MRI 等医学影像的客观可见内容描述。
|
||||
|
||||
第一期不把通用视觉模型的输出当作医学诊断,不自动把图片或病例写入知识库,不保存原图供长期访问,也不支持 DICOM 原始影像。
|
||||
|
||||
## 2. 核心原则
|
||||
|
||||
- **资料优先级不变**:标准答题对最高,分身独立知识库其次,图片识别结果属于待核对的会话资料,最后才由模型组织表达。
|
||||
- **病例最小留存**:应用不把原图写入业务存储,上传内容在内存中归一化并调用视觉服务;数据库只保存结构化结果和必要元数据。
|
||||
- **严格隔离**:每条图片记录必须绑定 `avatar_id`,私聊校验分身所有者,公开聊天校验分享令牌对应的分身。
|
||||
- **不确定性显式化**:OCR 看不清、表格列错位、医学影像无法确认时必须指出待核对项,不允许补齐缺失内容。
|
||||
- **可计量**:视觉理解和病例 OCR 分别计入分身所有者的积分消耗,失败时释放预留积分。
|
||||
- **可降级**:OCR 失败但通用视觉结果有效时仍可回答;视觉主调用失败则不进入聊天发送。
|
||||
|
||||
## 3. 总体流程
|
||||
|
||||
```text
|
||||
用户选择图片
|
||||
-> 前端本地预览
|
||||
-> 私聊/公开图片上传接口
|
||||
-> 文件大小、MIME、真实格式、像素数校验
|
||||
-> 自动旋转、缩放、去 EXIF、统一 JPEG
|
||||
-> 通用视觉模型分类并输出结构化 JSON
|
||||
-> 若为病例/检查单,再调用 OCR 模型精确转录
|
||||
-> 保存结构化结果,不持久化原图
|
||||
-> 返回 attachmentId
|
||||
-> 用户发送文字 + attachmentIds
|
||||
-> 标准答题对匹配
|
||||
-> 用文字 + 图片提取结果检索独立知识库
|
||||
-> 把标准答案、知识片段、图片资料注入系统上下文
|
||||
-> Qwen SSE 流式回答
|
||||
```
|
||||
|
||||
## 4. 模型编排
|
||||
|
||||
### 4.1 通用视觉模型
|
||||
|
||||
默认 `qwen3.6-flash`,可在后台数字分身专用模型配置中修改。输入为归一化后的 Base64 Data URL,要求返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "general_image|document|medical_document|medical_image",
|
||||
"summary": "客观、完整的图片描述",
|
||||
"visible_text": "图片中可确认的文字",
|
||||
"key_facts": ["事实1", "事实2"],
|
||||
"uncertainties": ["无法确认的内容"],
|
||||
"medical": {
|
||||
"document_type": "",
|
||||
"patient_info": {},
|
||||
"chief_complaint": "",
|
||||
"findings": [],
|
||||
"measurements": [],
|
||||
"doctor_advice": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
模型提示词禁止诊断、补全被遮挡文字、猜测患者身份和输出模型信息。
|
||||
|
||||
### 4.2 病例 OCR
|
||||
|
||||
当 `category=medical_document` 时追加调用 `qwen-vl-ocr`,按原布局转录文字和表格。OCR 文本优先替换通用视觉输出中的 `visible_text`,但保留通用视觉模型提供的分类、摘要和不确定项。
|
||||
|
||||
### 4.3 医学影像
|
||||
|
||||
当 `category=medical_image` 时只保存客观描述,不输出疾病结论、分期、用药或治疗方案。聊天提示词必须要求结合正规影像报告和医生意见,并显示“图片识别结果仅供辅助,不能替代医生诊断”。
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
新增 `chat_attachments`:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `id` | 不可猜测的附件 ID |
|
||||
| `avatar_id` | 所属数字分身,强制隔离 |
|
||||
| `filename` | 原文件名,去除路径 |
|
||||
| `mime_type` / `file_size` | 上传元数据 |
|
||||
| `status` | `processing / ready / failed` |
|
||||
| `category` | 图片分类 |
|
||||
| `summary` | 通用视觉摘要 |
|
||||
| `extracted_text` | 可确认文字/OCR 结果 |
|
||||
| `structured_data` | 结构化 JSON |
|
||||
| `warning` | 不确定项和医学提示 |
|
||||
| `vision_model` / `ocr_model` | 实际调用模型 |
|
||||
| `created_at` / `used_at` | 创建和最近使用时间 |
|
||||
|
||||
不保存公开原图 URL。应用层不落盘原图;框架上传缓冲在请求结束时关闭,处理结果在 24 小时后自动清理。
|
||||
|
||||
## 6. API 设计
|
||||
|
||||
### 6.1 上传并解析
|
||||
|
||||
- `POST /api/avatar/{avatar_id}/chat/images`
|
||||
- `POST /api/public/avatar/{share_token}/chat/images`
|
||||
- `multipart/form-data: file`
|
||||
|
||||
成功返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "attachment-id",
|
||||
"filename": "病例.jpg",
|
||||
"status": "ready",
|
||||
"category": "medical_document",
|
||||
"summary": "门诊检查单",
|
||||
"warning": "部分手写内容需要人工核对"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 聊天
|
||||
|
||||
原聊天接口增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "请帮我看看异常指标",
|
||||
"attachmentIds": ["attachment-id"],
|
||||
"history": [
|
||||
{"role": "user", "content": "上一条问题", "attachmentIds": ["attachment-id"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
当前消息最多 3 张图,历史最多引用最近 3 个不同附件。后端只读取与当前 `avatar_id` 相同且状态为 `ready` 的记录。
|
||||
|
||||
## 7. 安全与隐私
|
||||
|
||||
- 单图最大 8MB,解码后最大 1600 万像素,最长边归一化到 4096 像素以内。
|
||||
- 使用 Pillow 验证真实图片格式并防止解压炸弹;重新编码时清除 EXIF、GPS 和其他元数据。
|
||||
- 图片不会写入 FastAPI `StaticFiles` 或知识库目录,模型请求和日志不得输出 Base64 内容。
|
||||
- 日志只记录附件 ID、分身 ID、状态、耗时和模型,不记录图片 Base64、OCR 全文、病例内容或 API Key。
|
||||
- 公开分享上传仍消耗分身所有者积分;余额不足时拒绝视觉调用。
|
||||
- 生产环境需要补充用户授权、数据处理协议、存储地域和模型供应商留存策略确认。
|
||||
|
||||
## 8. 前端交互
|
||||
|
||||
- 输入框左侧增加图片按钮,支持相册选择和移动端拍照。
|
||||
- 选择后显示本地缩略图和“正在识别图片”,识别完成前禁止发送。
|
||||
- 用户可删除待发送图片;发送后图片保留在当前会话气泡中,但刷新页面后不恢复原图。
|
||||
- 病例和医学影像在输入区及回答下方显示辅助提示,不使用恐吓式红色告警。
|
||||
- 上传或识别失败时保留文字输入,明确提示重新选择图片,不产生空白消息。
|
||||
|
||||
## 9. 配置
|
||||
|
||||
数字分身专用模型配置新增:
|
||||
|
||||
- `vision_model_version`,默认 `qwen3.6-flash`
|
||||
- `ocr_model_version`,默认 `qwen-vl-ocr`
|
||||
|
||||
环境变量兜底:
|
||||
|
||||
```dotenv
|
||||
VISION_MODEL=qwen3.6-flash
|
||||
VISION_OCR_MODEL=qwen-vl-ocr
|
||||
VISION_MAX_OUTPUT_TOKENS=2048
|
||||
VISION_TIMEOUT_SECONDS=90
|
||||
VISION_TOKEN_RESERVE=12000
|
||||
CHAT_IMAGE_MAX_BYTES=8388608
|
||||
CHAT_IMAGE_MAX_PIXELS=16000000
|
||||
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
||||
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||
```
|
||||
|
||||
视觉调用复用数字分身专用配置的 `api_base_url` 和 `api_key`,不额外复制密钥。
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
1. 普通照片、截图和图表能够返回与图片一致的描述并支持追问。
|
||||
2. 病例图片可以提取标题、患者字段、检查结果、异常指标和医生意见,模糊内容明确标记待核对。
|
||||
3. 上传后服务器业务目录不残留原图,响应和日志不包含 Base64 或完整病例正文。
|
||||
4. A 分身无法引用 B 分身附件;公开分享令牌无法访问其他分身附件。
|
||||
5. 有图片时标准答题对仍作为最高优先级事实,知识库命中次之。
|
||||
6. 视觉与 OCR 积分分别结算,失败调用释放预留积分。
|
||||
7. SSE 打字效果、Markdown、用户头像、公开分享和纯文本聊天均无回归。
|
||||
8. CT、MRI、X 光回答不作确定诊断,并显示人工复核提示。
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>会会数字分身</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<!-- digital.99hui.com 同域部署保持 apiBase 为空,接口使用 /api。
|
||||
仅跨域部署时才显式配置后端公网地址。 -->
|
||||
<script type="text/javascript">
|
||||
window.__APP_CONFIG__ = { apiBase: '' }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
# 牙齿防护知识手册
|
||||
|
||||
> 本手册用于「会会」数字分身知识库,覆盖日常护牙、牙线使用、饮食护牙、定期检查、常见口腔问题及儿童口腔保健,供分身专业应答引用。
|
||||
|
||||
## 一、日常刷牙
|
||||
|
||||
- **频率与时机**:每天早晚各刷一次,睡前刷牙尤为重要;饭后建议漱口,进食酸性食物后等待约 30 分钟再刷牙,避免即刻磨损被酸软化的牙釉质。
|
||||
- **巴氏刷牙法(Bass)**:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤 10 次左右,再向牙冠方向拂刷;依次覆盖牙齿外侧、内侧与咬合面。
|
||||
- **时长**:每次刷牙至少 2 分钟。
|
||||
- **工具**:选用软毛、小头牙刷;刷毛外翻或每 3 个月更换一次。
|
||||
- **牙膏**:推荐使用含氟牙膏,氟可增强牙釉质抗酸与再矿化能力。
|
||||
|
||||
## 二、牙线 / 牙缝清洁
|
||||
|
||||
- 每天至少使用一次牙线,清洁牙刷难以到达的牙齿邻面。
|
||||
- 取约 45cm 牙线,以 C 形包绕牙面,上下刮擦清除菌斑。
|
||||
- 牙缝较大或牙周炎人群,可配合使用牙间刷(间隙刷)。
|
||||
|
||||
## 三、饮食与护牙
|
||||
|
||||
- 控制游离糖摄入,少喝含糖饮料、少吃黏性甜食——糖是致龋的主要元凶。
|
||||
- 酸性饮料(碳酸饮料、果汁)会侵蚀牙釉质,建议用吸管饮用并尽快漱口,勿立即刷牙。
|
||||
- 多喝水,唾液具有自我清洁与再矿化作用。
|
||||
- 适量补充钙、磷、维生素 D(奶类、豆制品、深绿色蔬菜)。
|
||||
|
||||
## 四、定期检查与洁治
|
||||
|
||||
- 建议每 6 个月进行一次口腔检查与洁牙(洗牙)。
|
||||
- 洗牙清除牙石与菌斑,预防牙龈炎、牙周炎;**洗牙不会让牙缝变大**(牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成)。
|
||||
- 出现龋齿、牙龈出血、口臭、牙齿敏感应及时就医,越早处理越简单。
|
||||
|
||||
## 五、常见口腔问题
|
||||
|
||||
- **龋齿(蛀牙)**:浅龋可涂氟干预,但已形成龋洞必须充填,无法自愈。
|
||||
- **牙周病**:表现为牙龈红肿、出血、口臭、牙齿松动;基础治疗为洁治 + 龈下刮治,需长期维护。
|
||||
- **牙齿敏感**:遇冷热酸甜刺激痛,可用抗敏感牙膏;若持续加重需就诊排查楔状缺损或牙龈退缩。
|
||||
- **智齿**:阻生或反复发炎的智齿建议拔除。
|
||||
|
||||
## 六、儿童口腔保健
|
||||
|
||||
- 第一颗乳牙萌出(约 6 月龄)即开始清洁,可用纱布或指套牙刷。
|
||||
- **含氟牙膏用量**:3 岁以下用米粒大小,3–6 岁用豌豆大小,均需在家长监督下使用并防止误吞。
|
||||
- **窝沟封闭**:6–7 岁六龄齿萌出后做窝沟封闭,有效预防窝沟龋。
|
||||
- 定期涂氟,每半年一次口腔检查。
|
||||
|
||||
## 七、常见误区澄清
|
||||
|
||||
- 误区「洗牙伤牙」:事实为规范洗牙安全,不损伤牙釉质。
|
||||
- 误区「牙龈出血就不能刷」:事实为出血多因炎症,更应保持清洁并尽早就医。
|
||||
- 误区「乳牙坏了不用管」:事实为乳牙健康直接影响恒牙与颌骨发育。
|
||||
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"question": "每天应该刷牙几次?每次刷多久?",
|
||||
"answer": "建议每天早晚各刷一次,每次至少 2 分钟,并使用含氟牙膏;睡前那次尤为重要。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "正确的刷牙方法是什么?",
|
||||
"answer": "推荐巴氏刷牙法:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤约 10 次后再向牙冠方向拂刷,依次清洁牙齿外侧、内侧和咬合面。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "电动牙刷比手动牙刷更好吗?",
|
||||
"answer": "两者清洁效果相当,关键在于正确刷牙方法;电动牙刷更易保证刷牙时长,对刷牙不到位的人更友好。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "刷牙总出血是怎么回事?",
|
||||
"answer": "多为牙龈炎或牙周炎所致,建议尽快洗牙并就医检查,同时坚持正确刷牙和使用牙线,切勿因出血而停止刷牙。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "牙线必须每天用吗?怎么用?",
|
||||
"answer": "建议每天至少使用一次牙线清洁牙齿邻面。取约 45cm 牙线,以 C 形包绕牙面上下刮擦;牙缝较大者可配合牙间刷。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "蛀牙(龋齿)能自己好吗?",
|
||||
"answer": "浅龋可通过涂氟减缓进展,但已形成龋洞必须补牙,无法自愈;越早处理越简单、损伤越小。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "洗牙会让牙缝变大、伤牙齿吗?",
|
||||
"answer": "规范的洗牙安全且不损伤牙釉质。洗牙清除的是牙石,牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "牙齿遇冷热酸甜就酸痛,怎么办?",
|
||||
"answer": "属牙齿敏感,可先使用抗敏感牙膏;若持续加重,需就诊排查楔状缺损或牙龈退缩等成因,再对症处理。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "孩子几岁开始用含氟牙膏?用多少?",
|
||||
"answer": "第一颗乳牙萌出后即可在家长帮助下刷牙;3 岁以下用米粒大小含氟牙膏,3–6 岁用豌豆大小,均需监督防误吞。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "儿童六龄齿需要做窝沟封闭吗?",
|
||||
"answer": "建议 6–7 岁六龄齿萌出后做窝沟封闭,并定期涂氟、每半年口腔检查,可有效预防窝沟龋,保护恒牙。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "喝碳酸饮料会伤牙吗?怎么喝更好?",
|
||||
"answer": "碳酸饮料酸性强,会侵蚀牙釉质。建议用吸管减少接触、喝完尽快清水漱口,不要立即刷牙,并控制频次。",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"question": "多久做一次口腔检查和洗牙?",
|
||||
"answer": "建议每 6 个月进行一次口腔检查与洁牙,以便早期发现龋齿、牙周问题并及时干预。",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
# 完整主配置:覆盖 nginx:alpine 默认 /etc/nginx/nginx.conf
|
||||
# 新版 nginx 在受限容器内写 /run/nginx.pid 会报 Operation not permitted 并致命退出,
|
||||
# 这里把 pid 显式改到可写的 /tmp(main 上下文唯一一处),避免前端容器反复重启。
|
||||
pid /tmp/nginx.pid;
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Docker 容器重建后 IP 可能变化;按内置 DNS 周期解析服务名,避免 Nginx 缓存旧地址导致 /api 502。
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA 兜底(hash 路由下深链接也可正常加载)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端 API:保留 /api 前缀转发到 avatar-backend:8000
|
||||
location /api/ {
|
||||
set $avatar_backend http://avatar-backend:8000;
|
||||
proxy_pass $avatar_backend;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1736
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "digital-avatar-app",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "会会数字分身 Web App",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-webview-js": "^0.0.3",
|
||||
"axios": "^1.6.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "3.3.10"
|
||||
},
|
||||
"overrides": {
|
||||
"nanoid": "3.3.18",
|
||||
"postcss": "8.5.26"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
8f54097b01d5b0433f8aa5ea70ca930b
|
||||
@@ -0,0 +1 @@
|
||||
8f54097b01d5b0433f8aa5ea70ca930b
|
||||
@@ -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 |
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAvatarUpdatePayload,
|
||||
normalizeAvatarEditForm,
|
||||
pickAvatarId,
|
||||
unwrapListData,
|
||||
} from '../src/utils/avatar-page-data.js'
|
||||
import { renderChatMarkdownCharacters } from '../src/utils/chat-markdown.js'
|
||||
|
||||
assert.deepEqual(unwrapListData([{ id: 'a1' }]), [{ id: 'a1' }], 'unwrapListData should return raw arrays')
|
||||
assert.deepEqual(
|
||||
unwrapListData({ data: [{ id: 'a2' }], total: 1 }),
|
||||
[{ id: 'a2' }],
|
||||
'unwrapListData should unwrap wrapped collection payloads'
|
||||
)
|
||||
assert.deepEqual(unwrapListData(null), [], 'unwrapListData should fall back to an empty list')
|
||||
|
||||
assert.equal(
|
||||
pickAvatarId('current-id', [{ id: 'first-id' }]),
|
||||
'current-id',
|
||||
'pickAvatarId should prefer current avatar id'
|
||||
)
|
||||
assert.equal(
|
||||
pickAvatarId('', [{ id: 'first-id' }]),
|
||||
'first-id',
|
||||
'pickAvatarId should fall back to the first avatar id'
|
||||
)
|
||||
assert.equal(pickAvatarId('', []), null, 'pickAvatarId should return null when no avatar exists')
|
||||
|
||||
const boldReply = renderChatMarkdownCharacters('请注意:**不能自行诊断或随意用药**。')
|
||||
assert.equal(
|
||||
boldReply.map((character) => character.text).join(''),
|
||||
'请注意:不能自行诊断或随意用药。',
|
||||
'chat markdown should hide bold markers'
|
||||
)
|
||||
assert.equal(
|
||||
boldReply.filter((character) => character.bold).map((character) => character.text).join(''),
|
||||
'不能自行诊断或随意用药',
|
||||
'chat markdown should style bold text'
|
||||
)
|
||||
assert.equal(
|
||||
renderChatMarkdownCharacters('****重点****').map((character) => character.text).join(''),
|
||||
'重点',
|
||||
'chat markdown should tolerate repeated bold markers'
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeAvatarEditForm({
|
||||
name: '我的分身',
|
||||
displayName: '会会助手',
|
||||
description: '描述',
|
||||
status: 'inactive',
|
||||
photoUrl: 'https://img.example/avatar.png',
|
||||
config: {
|
||||
replyStyle: 'friendly',
|
||||
creativity: 72,
|
||||
rigor: 88,
|
||||
humor: 16,
|
||||
responseLength: 'short',
|
||||
systemPrompt: '不要编造',
|
||||
profession: '医生',
|
||||
position: '主任医师',
|
||||
organization: '测试医院',
|
||||
organizationAddress: '测试路 1 号',
|
||||
autoReply: false
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: '我的分身',
|
||||
displayName: '会会助手',
|
||||
description: '描述',
|
||||
status: 'inactive',
|
||||
photoUrl: 'https://img.example/avatar.png',
|
||||
replyStyle: 'friendly',
|
||||
creativity: 72,
|
||||
rigor: 88,
|
||||
humor: 16,
|
||||
responseLength: 'short',
|
||||
systemPrompt: '不要编造',
|
||||
profession: '医生',
|
||||
position: '主任医师',
|
||||
organization: '测试医院',
|
||||
organizationAddress: '测试路 1 号',
|
||||
autoReply: false,
|
||||
},
|
||||
'normalizeAvatarEditForm should map API avatars into edit form state'
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
buildAvatarUpdatePayload({
|
||||
name: '更新后的分身',
|
||||
displayName: '更新后的助手',
|
||||
description: '新描述',
|
||||
status: 'active',
|
||||
photoUrl: 'https://img.example/new-avatar.png',
|
||||
replyStyle: 'casual',
|
||||
creativity: 65,
|
||||
rigor: 70,
|
||||
humor: 25,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: '回答简洁',
|
||||
profession: '医生',
|
||||
position: '主任医师',
|
||||
organization: '测试医院',
|
||||
organizationAddress: '测试路 1 号',
|
||||
autoReply: true,
|
||||
}),
|
||||
{
|
||||
name: '更新后的分身',
|
||||
displayName: '更新后的助手',
|
||||
description: '新描述',
|
||||
status: 'active',
|
||||
photoUrl: 'https://img.example/new-avatar.png',
|
||||
config: {
|
||||
replyStyle: 'casual',
|
||||
creativity: 65,
|
||||
rigor: 70,
|
||||
humor: 25,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: '回答简洁',
|
||||
profession: '医生',
|
||||
position: '主任医师',
|
||||
organization: '测试医院',
|
||||
organizationAddress: '测试路 1 号',
|
||||
autoReply: true,
|
||||
},
|
||||
},
|
||||
'buildAvatarUpdatePayload should emit API-ready update payloads'
|
||||
)
|
||||
|
||||
const knowledgeView = fs.readFileSync(path.resolve('src/views/KnowledgeManage.vue'), 'utf8')
|
||||
assert.match(knowledgeView, /文档知识库/, 'knowledge page should expose the document tab')
|
||||
assert.match(knowledgeView, /标准问答对/, 'knowledge page should expose the QA tab')
|
||||
assert.match(knowledgeView, /activeTab/, 'knowledge page should switch active tabs')
|
||||
assert.match(knowledgeView, /accept="\.md,\.txt,\.pdf,\.doc,\.docx,\.xlsx"/, 'knowledge page should accept md and txt')
|
||||
assert.match(knowledgeView, /mobile-card-list/, 'knowledge page should render mobile-first card lists')
|
||||
assert.match(knowledgeView, /knowledge-card/, 'knowledge page should expose document and QA cards')
|
||||
|
||||
const chatView = fs.readFileSync(path.resolve('src/views/AvatarChat.vue'), 'utf8')
|
||||
assert.match(chatView, /avatar\?\.photoUrl/, 'chat should render the active avatar photo when available')
|
||||
assert.match(chatView, /userAvatarUrl/, 'chat should render the logged-in user photo when available')
|
||||
assert.match(chatView, /avatarStatus/, 'chat should synchronize the visible status indicator with avatar status')
|
||||
assert.match(chatView, /document\.title = avatar\.value/, 'chat should use the avatar name as the page title')
|
||||
assert.match(chatView, /position: sticky/, 'chat header should remain visible while the message list scrolls')
|
||||
assert.match(chatView, /typing-character/, 'chat replies should animate one character at a time')
|
||||
assert.match(chatView, /renderChatMarkdownCharacters/, 'chat replies should render markdown as safe web text')
|
||||
assert.match(chatView, /markdown-bold/, 'chat replies should style markdown emphasis without showing markers')
|
||||
assert.match(chatView, /streamAvatarChat/, 'private chat should consume SSE response chunks')
|
||||
assert.match(chatView, /streamPublicAvatarChat/, 'public chat should consume SSE response chunks')
|
||||
assert.match(chatView, /scrollDuringStream/, 'streaming replies should throttle scrolling to animation frames')
|
||||
assert.match(chatView, /typing-character\.newline/, 'streaming replies should render sentence line breaks')
|
||||
assert.match(chatView, /let attached = false/, 'assistant bubble should wait for the first streamed text chunk')
|
||||
assert.match(chatView, /reactive<DisplayMessage>/, 'every streamed character should update through a reactive reply object')
|
||||
assert.doesNotMatch(chatView, /你好,我是\{\{/, 'chat welcome card should not introduce the avatar by name')
|
||||
assert.doesNotMatch(chatView, /\/\[。!?;\]\/\.test\(character\)/, 'chat should not force a line break after every sentence')
|
||||
assert.match(chatView, /previous === '\\n'/, 'streaming text should collapse whitespace at line boundaries')
|
||||
assert.match(chatView, /welcome-avatar/, 'chat welcome should use the active avatar image instead of a generic icon')
|
||||
assert.doesNotMatch(chatView, /我会优先参考标准问答和知识库/, 'chat welcome should not expose internal answer sources')
|
||||
assert.match(chatView, /welcome-description/, 'chat welcome should render the avatar description')
|
||||
assert.doesNotMatch(chatView, /介绍一下你自己/, 'chat welcome should not contain fixed starter questions')
|
||||
|
||||
const editView = fs.readFileSync(path.resolve('src/views/AvatarEdit.vue'), 'utf8')
|
||||
assert.match(editView, />分身微调</, 'avatar edit page should use the requested title')
|
||||
assert.match(editView, /uploadAvatarPhoto/, 'avatar edit page should upload a clicked replacement photo')
|
||||
assert.doesNotMatch(editView, />头像链接</, 'avatar edit page should not expose a photo URL input')
|
||||
for (const field of ['profession', 'position', 'organization', 'organizationAddress']) {
|
||||
assert.match(editView, new RegExp(`formData\\.${field}`), `avatar edit page should expose ${field}`)
|
||||
}
|
||||
|
||||
const manageView = fs.readFileSync(path.resolve('src/views/AvatarManage.vue'), 'utf8')
|
||||
assert.match(manageView, /shareAvatar/, 'avatar management should offer a share action')
|
||||
assert.match(manageView, /createAvatarShareLink/, 'share action should create a public share link')
|
||||
|
||||
const router = fs.readFileSync(path.resolve('src/router/index.ts'), 'utf8')
|
||||
assert.match(router, /path: '\/share\/:shareToken'/, 'router should expose a public chat route')
|
||||
|
||||
console.log('avatar-page-data tests passed')
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
<!-- 底部菜单暂不展示,多分身能力统一从分身卡片进入。 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,531 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
|
||||
interface ApiClient {
|
||||
get<T>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>
|
||||
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>
|
||||
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
}
|
||||
|
||||
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
|
||||
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
|
||||
// - digital.99hui.com 同域部署:保持 '/api'
|
||||
// - 仅跨域部署时配置后端公网地址
|
||||
function resolveBaseURL(): string {
|
||||
const cfg = (window as any).__APP_CONFIG__
|
||||
if (cfg && cfg.apiBase) return cfg.apiBase as string
|
||||
const env = import.meta.env.VITE_API_BASE as string | undefined
|
||||
if (env) return env
|
||||
return '/api'
|
||||
}
|
||||
|
||||
// 统一认证 token(由 uniapp 壳通过 URL 注入,见 utils/uniapp-bridge.ts)
|
||||
let _authToken: string | null = null
|
||||
export function setAuthToken(token: string | null) {
|
||||
_authToken = token
|
||||
}
|
||||
export function getAuthToken(): string | null {
|
||||
return _authToken
|
||||
}
|
||||
|
||||
// 创建 axios 实例(复用现有项目模式)
|
||||
const createRequest = (config?: AxiosRequestConfig): ApiClient => {
|
||||
const request = axios.create({
|
||||
baseURL: resolveBaseURL(),
|
||||
timeout: 30000,
|
||||
...config
|
||||
})
|
||||
|
||||
// 请求拦截器:注入认证头
|
||||
request.interceptors.request.use((cfg) => {
|
||||
if (_authToken) {
|
||||
cfg.headers = cfg.headers || {}
|
||||
;(cfg.headers as any).Authorization = `Bearer ${_authToken}`
|
||||
}
|
||||
return cfg
|
||||
})
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(res) => {
|
||||
const data = fixDatetimeTZ(res.data)
|
||||
if (data && data.code && data.code !== 200) {
|
||||
console.error('API Error:', data.message)
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
// 解包标准响应 { code, message, data }
|
||||
if (data && data.code === 200) {
|
||||
return data.data !== undefined ? data.data : data
|
||||
}
|
||||
return data
|
||||
},
|
||||
(err) => {
|
||||
const msg = err.response?.data?.detail || err.response?.data?.message || err.message || '网络错误'
|
||||
console.error('Network Error:', msg)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
// The response interceptor unwraps the API envelope before callers receive it.
|
||||
return request as unknown as ApiClient
|
||||
}
|
||||
|
||||
// 递归修复时区标识(复用现有项目逻辑)
|
||||
function fixDatetimeTZ(obj: any): any {
|
||||
if (typeof obj === 'string') {
|
||||
return obj.replace(/T(\d{2}:\d{2}:\d{2})\+00:00/g, 'T$1\+08:00')
|
||||
}
|
||||
if (Array.isArray(obj)) return obj.map(fixDatetimeTZ)
|
||||
if (obj && typeof obj === 'object') {
|
||||
const result: any = {}
|
||||
for (const k in obj) result[k] = fixDatetimeTZ(obj[k])
|
||||
return result
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
const request = createRequest()
|
||||
|
||||
// ==================== 分身管理 API ====================
|
||||
|
||||
export interface Avatar {
|
||||
id: string
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
photoUrl?: string
|
||||
status: 'active' | 'inactive' | 'training'
|
||||
tokenBalance: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
// 获取分身列表
|
||||
export const getAvatarList = (params?: { page?: number; limit?: number }) =>
|
||||
request.get<{ data: Avatar[]; total: number }>('/avatar', { params })
|
||||
|
||||
// 获取分身详情
|
||||
export const getAvatarDetail = (id: string) =>
|
||||
request.get<Avatar>(`/avatar/${id}`)
|
||||
|
||||
// 创建分身
|
||||
export const createAvatar = (data: Partial<Avatar>) =>
|
||||
request.post<Avatar>('/avatar', data)
|
||||
|
||||
// 更新分身
|
||||
export const updateAvatar = (id: string, data: Partial<Avatar>) =>
|
||||
request.put<Avatar>(`/avatar/${id}`, data)
|
||||
|
||||
export const uploadAvatarPhoto = (id: string, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post<{ photoUrl: string }>(`/avatar/${id}/photo`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
// 删除分身
|
||||
export const deleteAvatar = (id: string) =>
|
||||
request.delete(`/avatar/${id}`)
|
||||
|
||||
// ==================== 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 余额
|
||||
export const getTokenBalance = () =>
|
||||
request.get<TokenBalance>('/token/balance')
|
||||
|
||||
// 获取充值套餐
|
||||
export const getRechargePlans = () =>
|
||||
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
|
||||
|
||||
// 创建会会支付订单;支付回调成功后积分到账。
|
||||
export const chargeToken = (
|
||||
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 ====================
|
||||
|
||||
export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover'
|
||||
|
||||
export interface AvatarPermissionSettings {
|
||||
avatarId: string
|
||||
permissions: AvatarPermission[]
|
||||
takeoverReplyDelaySeconds: number
|
||||
disabledAvatarIds?: string[]
|
||||
}
|
||||
|
||||
export const getAvatarPermissionSettings = (avatarId: string) =>
|
||||
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
|
||||
|
||||
export const updateAvatarPermissionSettings = (
|
||||
avatarId: string,
|
||||
permissions: AvatarPermission[],
|
||||
takeoverReplyDelaySeconds: number
|
||||
) => request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, {
|
||||
permissions,
|
||||
takeoverReplyDelaySeconds,
|
||||
})
|
||||
|
||||
export interface TakeoverStatus {
|
||||
enabled: boolean
|
||||
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
|
||||
message: string
|
||||
pendingCount: number
|
||||
takeoverReplyDelaySeconds: number
|
||||
lastPolledAt: string | null
|
||||
}
|
||||
|
||||
export const getTakeoverStatus = (avatarId: string) =>
|
||||
request.get<TakeoverStatus>(`/avatar/${avatarId}/takeover/status`)
|
||||
|
||||
export interface Authorization {
|
||||
id: string
|
||||
avatarId: string
|
||||
targetType: 'user' | 'organization' | 'application'
|
||||
targetId: string
|
||||
targetName: string
|
||||
permissions: string[]
|
||||
status: 'active' | 'inactive'
|
||||
takeoverEnabled: boolean
|
||||
takeoverMode: 'immediate' | 'delayed'
|
||||
takeoverDelaySeconds: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type AuthorizationInput = Pick<
|
||||
Authorization,
|
||||
'targetType' | 'targetId' | 'targetName' | 'permissions'
|
||||
>
|
||||
|
||||
// 获取授权列表
|
||||
export const getAuthorizationList = (avatarId: string) =>
|
||||
request.get<Authorization[]>(`/avatar/${avatarId}/authorizations`)
|
||||
|
||||
// 添加授权
|
||||
export const createAuthorization = (avatarId: string, data: AuthorizationInput) =>
|
||||
request.post<Authorization>(`/avatar/${avatarId}/authorizations`, data)
|
||||
|
||||
// 更新授权
|
||||
export const updateAuthorization = (avatarId: string, data: Partial<Authorization> & { id: string }) =>
|
||||
request.put<Authorization>(`/avatar/${avatarId}/authorizations`, data)
|
||||
|
||||
// 删除授权
|
||||
export const deleteAuthorization = (avatarId: string, authorizationId: string) =>
|
||||
request.delete<{ id: string }>(`/avatar/${avatarId}/authorizations/${authorizationId}`)
|
||||
|
||||
// 更新单聊接管配置
|
||||
export const updateTakeoverConfig = (avatarId: string, data: {
|
||||
authorizationId: string
|
||||
takeoverEnabled: boolean
|
||||
takeoverMode?: 'immediate' | 'delayed'
|
||||
takeoverDelaySeconds?: number
|
||||
}) =>
|
||||
request.put<Authorization>(`/avatar/${avatarId}/authorizations/takeover`, data)
|
||||
|
||||
// ==================== 组织管理 API ====================
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
emoji: string
|
||||
type: 'team' | 'company' | 'community'
|
||||
role: 'admin' | 'member' | 'viewer'
|
||||
memberCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateOrganizationInput {
|
||||
name: string
|
||||
desc?: string
|
||||
emoji?: string
|
||||
type?: 'team' | 'company' | 'community'
|
||||
}
|
||||
|
||||
// 获取组织列表
|
||||
export const getOrganizationList = (params?: any) =>
|
||||
request.get<{ data: Organization[]; total: number }>('/organizations', { params })
|
||||
|
||||
// 创建组织
|
||||
export const createOrganization = (data: CreateOrganizationInput) =>
|
||||
request.post<Organization>('/organizations', data)
|
||||
|
||||
// ==================== 知识库管理 API ====================
|
||||
|
||||
export interface KnowledgeDoc {
|
||||
id: string
|
||||
avatarId: string
|
||||
filename: string
|
||||
fileType: string
|
||||
fileSize: number
|
||||
fileUrl: string
|
||||
status: string
|
||||
filePresent?: boolean
|
||||
vectorized?: boolean
|
||||
embeddingModel?: string
|
||||
chunkCount?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface QAPair {
|
||||
id: string
|
||||
avatarId: string
|
||||
question: string
|
||||
answer: string
|
||||
enabled?: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
docId: string
|
||||
filename: string
|
||||
fileType: string
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
// 文档列表
|
||||
export const getKnowledgeDocs = (avatarId: string) =>
|
||||
request.get<KnowledgeDoc[]>(`/avatar/${avatarId}/knowledge/docs`)
|
||||
|
||||
// 上传文档(支持 md/txt/pdf/doc/docx/xlsx)
|
||||
export const uploadKnowledgeDoc = (avatarId: string, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
// 删除文档
|
||||
export const deleteKnowledgeDoc = (avatarId: string, docId: string) =>
|
||||
request.delete(`/avatar/${avatarId}/knowledge/docs/${docId}`)
|
||||
|
||||
// 标准问答对列表
|
||||
export const getQAPairs = (avatarId: string) =>
|
||||
request.get<QAPair[]>(`/avatar/${avatarId}/knowledge/qa`)
|
||||
|
||||
// 创建问答对
|
||||
export const createQAPair = (avatarId: string, data: { question: string; answer: string; enabled?: boolean }) =>
|
||||
request.post<QAPair>(`/avatar/${avatarId}/knowledge/qa`, data)
|
||||
|
||||
// 更新问答对
|
||||
export const updateQAPair = (avatarId: string, id: string, data: { question: string; answer: string; enabled?: boolean }) =>
|
||||
request.put<QAPair>(`/avatar/${avatarId}/knowledge/qa/${id}`, data)
|
||||
|
||||
// 切换问答对启用状态
|
||||
export const setQaEnabled = (avatarId: string, id: string, enabled: boolean) =>
|
||||
request.put<QAPair>(`/avatar/${avatarId}/knowledge/qa/${id}/enabled`, { enabled })
|
||||
|
||||
// 删除问答对
|
||||
export const deleteQAPair = (avatarId: string, id: string) =>
|
||||
request.delete(`/avatar/${avatarId}/knowledge/qa/${id}`)
|
||||
|
||||
// 文档向量检索
|
||||
export const searchKnowledge = (avatarId: string, q: string, topK = 5) =>
|
||||
request.get<SearchResult[]>(`/avatar/${avatarId}/knowledge/search`, {
|
||||
params: { q, top_k: topK }
|
||||
})
|
||||
|
||||
// ==================== 数字分身聊天 API ====================
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
attachmentIds?: string[]
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
answer: string
|
||||
source: 'qa' | 'knowledge' | 'vision' | 'qwen'
|
||||
references?: Array<{ docId?: string; filename?: string; fileType?: string; snippet?: string; score?: number }>
|
||||
}
|
||||
|
||||
export interface ChatAttachment {
|
||||
id: string
|
||||
avatarId: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
fileSize: number
|
||||
status: 'processing' | 'ready' | 'failed'
|
||||
category: 'general_image' | 'document' | 'medical_document' | 'medical_image'
|
||||
summary: string
|
||||
warning: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export interface ChatPayload {
|
||||
message: string
|
||||
attachmentIds?: string[]
|
||||
history?: ChatMessage[]
|
||||
}
|
||||
|
||||
export const sendAvatarChat = (avatarId: string, payload: ChatPayload) =>
|
||||
request.post<ChatResponse>(`/avatar/${avatarId}/chat`, payload)
|
||||
|
||||
export interface PublicAvatar {
|
||||
id: string
|
||||
name: string
|
||||
displayName: string
|
||||
description?: string
|
||||
photoUrl?: string
|
||||
emoji?: string
|
||||
status: 'active' | 'inactive' | 'training'
|
||||
}
|
||||
|
||||
export const createAvatarShareLink = (avatarId: string) =>
|
||||
request.post<{ shareToken: string }>(`/avatar/${avatarId}/share`)
|
||||
|
||||
export const getPublicAvatar = (shareToken: string) =>
|
||||
request.get<PublicAvatar>(`/public/avatar/${shareToken}`)
|
||||
|
||||
export const sendPublicAvatarChat = (shareToken: string, payload: ChatPayload) =>
|
||||
request.post<ChatResponse>(`/public/avatar/${shareToken}/chat`, payload)
|
||||
|
||||
const imageForm = (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return form
|
||||
}
|
||||
|
||||
export const uploadAvatarChatImage = (avatarId: string, file: File) =>
|
||||
request.post<ChatAttachment>(`/avatar/${avatarId}/chat/images`, imageForm(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
export const uploadPublicAvatarChatImage = (shareToken: string, file: File) =>
|
||||
request.post<ChatAttachment>(`/public/avatar/${shareToken}/chat/images`, imageForm(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
type ChatStreamHandlers = {
|
||||
onMeta: (meta: Pick<ChatResponse, 'source' | 'references'>) => void
|
||||
onDelta: (content: string) => void
|
||||
}
|
||||
|
||||
const streamChat = async (path: string, payload: ChatPayload, handlers: ChatStreamHandlers) => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'text/event-stream' }
|
||||
if (_authToken) headers.Authorization = `Bearer ${_authToken}`
|
||||
const response = await fetch(`${resolveBaseURL()}${path}`, { method: 'POST', headers, body: JSON.stringify(payload) })
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => null)
|
||||
throw new Error(errorBody?.detail || errorBody?.message || `对话请求失败(${response.status})`)
|
||||
}
|
||||
if (!response.body) throw new Error('对话响应为空,请稍后重试')
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done })
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() || ''
|
||||
for (const eventBlock of events) {
|
||||
const event = eventBlock.match(/^event:\s*(.+)$/m)?.[1] || 'message'
|
||||
const data = eventBlock.match(/^data:\s*(.+)$/m)?.[1]
|
||||
if (!data) continue
|
||||
const parsed = JSON.parse(data)
|
||||
if (event === 'meta') handlers.onMeta(parsed)
|
||||
if (event === 'delta') handlers.onDelta(parsed.content || '')
|
||||
if (event === 'error') throw new Error(parsed.message || '对话暂时不可用')
|
||||
}
|
||||
if (done) break
|
||||
}
|
||||
}
|
||||
|
||||
export const streamAvatarChat = (avatarId: string, payload: ChatPayload, handlers: ChatStreamHandlers) =>
|
||||
streamChat(`/avatar/${avatarId}/chat/stream`, payload, handlers)
|
||||
|
||||
export const streamPublicAvatarChat = (shareToken: string, payload: ChatPayload, handlers: ChatStreamHandlers) =>
|
||||
streamChat(`/public/avatar/${shareToken}/chat/stream`, payload, handlers)
|
||||
|
||||
// ==================== 会会用户资料 API ====================
|
||||
|
||||
export interface UserProfile {
|
||||
userId: string
|
||||
nickname: string
|
||||
avatarUrl: string
|
||||
}
|
||||
|
||||
// 获取会会系统中的用户头像与昵称
|
||||
export const getUserProfile = () =>
|
||||
request.get<UserProfile>('/user/profile')
|
||||
|
||||
// ==================== 会会短信验证码登录 API ====================
|
||||
|
||||
export interface SmsLoginResult {
|
||||
token: string
|
||||
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
|
||||
huihui: { userId: string; nickname: string; avatarUrl: string }
|
||||
}
|
||||
|
||||
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
|
||||
export const sendSmsCode = (phone: string) =>
|
||||
request.post<{ sent: boolean; devCode?: string; dev?: boolean }>('/huihui/sms/send', { phone })
|
||||
|
||||
// 短信验证码登录
|
||||
export const loginBySms = (phone: string, code: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/sms/login', { phone, code })
|
||||
|
||||
// 账号密码登录(会会 loginType=password)
|
||||
export const loginByPassword = (account: string, password: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
|
||||
|
||||
// Validate a production Huihui access token and exchange it for an app session.
|
||||
export const loginByHuihuiToken = (token: string) =>
|
||||
request.post<SmsLoginResult>('/huihui/token/login', { token })
|
||||
|
||||
// 当前登录用户
|
||||
export const getCurrentUser = () =>
|
||||
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
|
||||
|
||||
// 退出登录
|
||||
export const logoutUser = () =>
|
||||
request.post('/huihui/logout')
|
||||
|
||||
export default request
|
||||
@@ -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 |
@@ -0,0 +1,81 @@
|
||||
import { createApp } from 'vue'
|
||||
import uniWebView from '@dcloudio/uni-webview-js'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './store'
|
||||
import { getLaunchParams, onNativeMessage, stripLaunchToken, UniEvents } from '@/utils/uniapp-bridge'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
// Bundle the bridge locally so the H5 does not depend on an external CDN.
|
||||
;(window as any).uni = (window as any).uni || uniWebView
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
|
||||
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
||||
const params = getLaunchParams()
|
||||
const avatarStore = useAvatarStore(pinia)
|
||||
const userStore = useUserStore(pinia)
|
||||
|
||||
function syncAvatarProfile() {
|
||||
if (!userStore.isLogin || !userStore.user) return
|
||||
avatarStore.setNativeProfile({
|
||||
userId: (userStore.user as any).huihuiUserId || '',
|
||||
nickname: userStore.user.nickname || '',
|
||||
avatarUrl: userStore.user.avatarUrl || ''
|
||||
})
|
||||
}
|
||||
|
||||
async function exchangeInjectedToken(token: string) {
|
||||
stripLaunchToken()
|
||||
userStore.clearSession()
|
||||
await userStore.loginByToken(token)
|
||||
syncAvatarProfile()
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
|
||||
onNativeMessage((msg) => {
|
||||
if (!msg || !msg.type) return
|
||||
if (msg.type === 'tokenRefresh' && msg.token) {
|
||||
void exchangeInjectedToken(msg.token).catch(() => {
|
||||
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
|
||||
void router.replace('/login/sms')
|
||||
})
|
||||
}
|
||||
if (msg.type === 'userUpdate' && msg.user) {
|
||||
avatarStore.setNativeProfile(msg.user)
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
if (params.userId || params.nickname || params.avatar) {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: params.userId || '',
|
||||
nickname: params.nickname || '',
|
||||
avatarUrl: params.avatar || ''
|
||||
})
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
// 通知原生壳:H5 已就绪
|
||||
UniEvents.ready()
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { getAuthToken } from '@/api'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'AvatarHome',
|
||||
redirect: '/avatar/manage',
|
||||
meta: { title: '数字分身管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/create',
|
||||
name: 'AvatarCreate',
|
||||
component: () => import('@/views/AvatarCreate.vue'),
|
||||
meta: { title: '创建数字分身', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/manage',
|
||||
name: 'AvatarManage',
|
||||
component: () => import('@/views/AvatarManage.vue'),
|
||||
meta: { title: '数字分身管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/edit/:id',
|
||||
name: 'AvatarEdit',
|
||||
component: () => import('@/views/AvatarEdit.vue'),
|
||||
meta: { title: '分身微调', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/chat/:id',
|
||||
name: 'AvatarChat',
|
||||
component: () => import('@/views/AvatarChat.vue'),
|
||||
meta: { title: '和分身对话', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/share/:shareToken',
|
||||
name: 'AvatarPublicChat',
|
||||
component: () => import('@/views/AvatarChat.vue'),
|
||||
meta: { title: '和我聊聊' }
|
||||
},
|
||||
{
|
||||
path: '/authorization',
|
||||
name: 'AuthorizationManage',
|
||||
component: () => import('@/views/AuthorizationManage.vue'),
|
||||
meta: { title: '授权管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/:avatarId/authorization',
|
||||
name: 'AvatarAuthorizationManage',
|
||||
component: () => import('@/views/AuthorizationManage.vue'),
|
||||
meta: { title: '授权管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/token/charge',
|
||||
name: 'TokenCharge',
|
||||
component: () => import('@/views/TokenCharge.vue'),
|
||||
meta: { title: '积分充值', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/card',
|
||||
name: 'AvatarCard',
|
||||
component: () => import('@/views/AvatarCard.vue'),
|
||||
meta: { title: '分身名片', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/contacts',
|
||||
name: 'AvatarContacts',
|
||||
component: () => import('@/views/AvatarContacts.vue'),
|
||||
meta: { title: '分身人脉', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/projects',
|
||||
name: 'MyProjects',
|
||||
component: () => import('@/views/MyProjects.vue'),
|
||||
meta: { title: '我的项目', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/org/create',
|
||||
name: 'CreateOrg',
|
||||
component: () => import('@/views/CreateOrg.vue'),
|
||||
meta: { title: '创建组织', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/knowledge',
|
||||
name: 'KnowledgeManage',
|
||||
component: () => import('@/views/KnowledgeManage.vue'),
|
||||
meta: { title: '知识库管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/:avatarId/knowledge',
|
||||
name: 'AvatarKnowledgeManage',
|
||||
component: () => import('@/views/KnowledgeManage.vue'),
|
||||
meta: { title: '知识库管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/knowledge/qa/create',
|
||||
name: 'QaPairCreate',
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '添加问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/knowledge/qa/:qaId/edit',
|
||||
name: 'QaPairEdit',
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '编辑问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/:avatarId/knowledge/qa/create',
|
||||
name: 'AvatarQaPairCreate',
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '添加问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/avatar/:avatarId/knowledge/qa/:qaId/edit',
|
||||
name: 'AvatarQaPairEdit',
|
||||
component: () => import('@/views/QaPairEdit.vue'),
|
||||
meta: { title: '编辑问答对', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login/sms',
|
||||
name: 'SmsLogin',
|
||||
component: () => import('@/views/SmsLogin.vue'),
|
||||
meta: { title: '会会数字分身登录' }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
// 混合架构下 web-view 内用 hash 模式,原生返回键与深链更稳
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title as string || '会会数字分身'
|
||||
const hasLocalSession = Boolean(localStorage.getItem('hh_app_token'))
|
||||
const hasInjectedSession = Boolean(getAuthToken())
|
||||
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
|
||||
next({ path: '/login/sms', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getTokenUsage, getUserProfile } from '@/api'
|
||||
import { unwrapListData } from '@/utils/avatar-page-data'
|
||||
|
||||
export const useAvatarStore = defineStore('avatar', () => {
|
||||
// 已创建的分身列表(来自后端)
|
||||
const avatars = ref<any[]>([])
|
||||
// 当前用户所有分身共享的 Token 账户
|
||||
const tokenBalance = ref<number>(0)
|
||||
const tokenConsumed = ref<number>(0)
|
||||
const tokenUsageByAvatar = ref<Record<string, number>>({})
|
||||
// 当前选中分身 id
|
||||
const currentAvatarId = ref<string | null>(null)
|
||||
// 会会用户资料(头像/昵称,来自会会接口)
|
||||
const userProfile = ref<any>(null)
|
||||
|
||||
// 拉取分身列表
|
||||
const loadAvatars = async () => {
|
||||
try {
|
||||
const res = await getAvatarList()
|
||||
avatars.value = unwrapListData(res)
|
||||
if (avatars.value.length) currentAvatarId.value = avatars.value[0].id
|
||||
} catch (e) {
|
||||
console.error('加载分身失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取 Token 余额
|
||||
const loadTokenBalance = async () => {
|
||||
try {
|
||||
const res = await getTokenBalance()
|
||||
tokenBalance.value = (res as any)?.balance ?? 0
|
||||
tokenConsumed.value = (res as any)?.totalConsumed ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTokenUsage = async () => {
|
||||
try {
|
||||
const rows = await getTokenUsage()
|
||||
tokenUsageByAvatar.value = rows.reduce<Record<string, number>>((result, row) => {
|
||||
result[row.avatarId] = (result[row.avatarId] || 0) + row.totalTokens
|
||||
return result
|
||||
}, {})
|
||||
} catch (e) {
|
||||
console.error('加载 Token 用量失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取会会用户资料(头像/昵称)
|
||||
const loadUserProfile = async () => {
|
||||
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
|
||||
if (userProfile.value && (userProfile.value as any).__native) return
|
||||
try {
|
||||
const res = await getUserProfile()
|
||||
userProfile.value = res as any
|
||||
} catch (e) {
|
||||
console.error('加载用户资料失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 由 uniapp 壳注入的会会资料(混合架构),标记为原生来源
|
||||
const setNativeProfile = (profile: { userId?: string; nickname?: string; avatarUrl?: string }) => {
|
||||
userProfile.value = {
|
||||
userId: profile.userId || '',
|
||||
nickname: profile.nickname || '',
|
||||
avatarUrl: profile.avatarUrl || '',
|
||||
__native: true
|
||||
}
|
||||
}
|
||||
|
||||
// 创建分身(写入后端)
|
||||
const addAvatar = async (avatar: any) => {
|
||||
const res: any = await apiCreate(avatar)
|
||||
const created = res?.data ? res.data : res
|
||||
avatars.value.unshift(created)
|
||||
currentAvatarId.value = created.id
|
||||
return created
|
||||
}
|
||||
|
||||
// 删除分身(写入后端,级联清理关联数据)
|
||||
const removeAvatar = async (id: string) => {
|
||||
await apiDelete(id)
|
||||
avatars.value = avatars.value.filter((a) => a.id !== id)
|
||||
if (currentAvatarId.value === id) {
|
||||
currentAvatarId.value = avatars.value[0]?.id || null
|
||||
}
|
||||
}
|
||||
|
||||
// 同步余额(充值后)
|
||||
const setTokenBalance = (balance: number) => {
|
||||
tokenBalance.value = balance
|
||||
}
|
||||
|
||||
return {
|
||||
avatars,
|
||||
tokenBalance,
|
||||
tokenConsumed,
|
||||
tokenUsageByAvatar,
|
||||
currentAvatarId,
|
||||
userProfile,
|
||||
loadAvatars,
|
||||
loadTokenBalance,
|
||||
loadTokenUsage,
|
||||
loadUserProfile,
|
||||
setNativeProfile,
|
||||
addAvatar,
|
||||
removeAvatar,
|
||||
setTokenBalance
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
@@ -0,0 +1,115 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
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 USER_KEY = 'hh_app_user'
|
||||
|
||||
// 会会短信登录的本地会话(打通 会会 userId ↔ 本系统用户)
|
||||
export const useUserStore = defineStore('smsuser', () => {
|
||||
const token = ref<string>('')
|
||||
const user = ref<(UserProfile & { huihuiUserId?: string; phone?: string }) | null>(null)
|
||||
const isLogin = ref(false)
|
||||
|
||||
// 启动时从本地恢复会话
|
||||
const loadFromStorage = () => {
|
||||
const t = localStorage.getItem(TOKEN_KEY)
|
||||
const u = localStorage.getItem(USER_KEY)
|
||||
if (t && u) {
|
||||
try {
|
||||
token.value = t
|
||||
user.value = JSON.parse(u)
|
||||
isLogin.value = true
|
||||
setAuthToken(t)
|
||||
} catch {
|
||||
clearLocal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persist = () => {
|
||||
localStorage.setItem(TOKEN_KEY, token.value)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user.value))
|
||||
}
|
||||
|
||||
const clearLocal = () => {
|
||||
localStorage.removeItem(TOKEN_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)
|
||||
const sendCode = async (phone: string) => {
|
||||
return await sendSmsCode(phone)
|
||||
}
|
||||
|
||||
// 短信登录
|
||||
const login = async (phone: string, code: string) => {
|
||||
const result = await loginBySms(phone, code)
|
||||
clearHuihuiEmbeddedMode()
|
||||
return acceptLogin(result)
|
||||
}
|
||||
|
||||
// 账号密码登录
|
||||
const loginByPwd = async (account: string, password: string) => {
|
||||
const result = await loginByPassword(account, password)
|
||||
clearHuihuiEmbeddedMode()
|
||||
return acceptLogin(result)
|
||||
}
|
||||
|
||||
const loginByToken = async (huihuiToken: string) => {
|
||||
const result = await loginByHuihuiToken(huihuiToken)
|
||||
markHuihuiEmbeddedMode()
|
||||
return acceptLogin(result)
|
||||
}
|
||||
|
||||
// 退出
|
||||
const logout = async () => {
|
||||
try {
|
||||
await logoutUser()
|
||||
} catch {
|
||||
/* 忽略网络错误,本地清除即可 */
|
||||
}
|
||||
clearHuihuiEmbeddedMode()
|
||||
clearSession()
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isLogin,
|
||||
loadFromStorage,
|
||||
clearSession,
|
||||
sendCode,
|
||||
login,
|
||||
loginByPwd,
|
||||
loginByToken,
|
||||
logout
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/* 全局样式 - 会会数字分身 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #F97316;
|
||||
--primary-light: #FFF0E6;
|
||||
--text-primary: #18191C;
|
||||
--text-secondary: #9398AE;
|
||||
--bg-primary: #F8F9FA;
|
||||
--bg-white: #FFFFFF;
|
||||
--border-color: #EDEEF1;
|
||||
--success-color: #22C55E;
|
||||
--warning-color: #F59E0B;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #D1D5DB;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 通用工具类 */
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '@dcloudio/uni-webview-js' {
|
||||
const uniWebView: unknown
|
||||
export default uniWebView
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface AvatarPageRecord {
|
||||
id?: string | null
|
||||
name?: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
status?: 'active' | 'inactive' | 'training'
|
||||
photoUrl?: string
|
||||
config?: Partial<AvatarEditForm>
|
||||
}
|
||||
|
||||
export interface AvatarEditForm {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
status: 'active' | 'inactive' | 'training'
|
||||
photoUrl: string
|
||||
replyStyle: string
|
||||
creativity: number
|
||||
rigor: number
|
||||
humor: number
|
||||
responseLength: string
|
||||
systemPrompt: string
|
||||
profession: string
|
||||
position: string
|
||||
organization: string
|
||||
organizationAddress: string
|
||||
autoReply: boolean
|
||||
}
|
||||
|
||||
export interface AvatarUpdatePayload {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
status: AvatarEditForm['status']
|
||||
photoUrl: string
|
||||
config: Omit<AvatarEditForm, 'name' | 'displayName' | 'description' | 'status' | 'photoUrl'>
|
||||
}
|
||||
|
||||
export function unwrapListData<T>(value: T[] | { data?: T[] } | null | undefined): T[]
|
||||
export function pickAvatarId(
|
||||
currentAvatarId: string | null | undefined,
|
||||
avatars?: AvatarPageRecord[]
|
||||
): string | null
|
||||
export function pickScopedAvatarId(
|
||||
routeAvatarId: string | string[] | null | undefined,
|
||||
currentAvatarId: string | null | undefined,
|
||||
avatars?: AvatarPageRecord[]
|
||||
): string | null
|
||||
export function normalizeAvatarEditForm(avatar?: AvatarPageRecord): AvatarEditForm
|
||||
export function buildAvatarUpdatePayload(form: AvatarEditForm): AvatarUpdatePayload
|
||||
@@ -0,0 +1,59 @@
|
||||
export function unwrapListData(value) {
|
||||
if (Array.isArray(value)) return value
|
||||
if (Array.isArray(value?.data)) return value.data
|
||||
return []
|
||||
}
|
||||
|
||||
export function pickAvatarId(currentAvatarId, avatars) {
|
||||
return currentAvatarId || avatars?.[0]?.id || null
|
||||
}
|
||||
|
||||
export function pickScopedAvatarId(routeAvatarId, currentAvatarId, avatars) {
|
||||
const requested = Array.isArray(routeAvatarId) ? routeAvatarId[0] : routeAvatarId
|
||||
return requested ? String(requested) : pickAvatarId(currentAvatarId, avatars)
|
||||
}
|
||||
|
||||
export function normalizeAvatarEditForm(avatar = {}) {
|
||||
const config = avatar.config || {}
|
||||
return {
|
||||
name: avatar.name || '',
|
||||
displayName: avatar.displayName || '',
|
||||
description: avatar.description || '',
|
||||
status: avatar.status || 'active',
|
||||
photoUrl: avatar.photoUrl || '',
|
||||
replyStyle: config.replyStyle || 'professional',
|
||||
creativity: Number.isFinite(config.creativity) ? config.creativity : 50,
|
||||
rigor: Number.isFinite(config.rigor) ? config.rigor : 50,
|
||||
humor: Number.isFinite(config.humor) ? config.humor : 30,
|
||||
responseLength: config.responseLength || 'medium',
|
||||
systemPrompt: config.systemPrompt || '',
|
||||
profession: config.profession || '',
|
||||
position: config.position || '',
|
||||
organization: config.organization || '',
|
||||
organizationAddress: config.organizationAddress || '',
|
||||
autoReply: config.autoReply !== false,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAvatarUpdatePayload(form) {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
displayName: form.displayName.trim() || form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
status: form.status,
|
||||
photoUrl: form.photoUrl.trim(),
|
||||
config: {
|
||||
replyStyle: form.replyStyle,
|
||||
creativity: Number(form.creativity),
|
||||
rigor: Number(form.rigor),
|
||||
humor: Number(form.humor),
|
||||
responseLength: form.responseLength,
|
||||
systemPrompt: form.systemPrompt.trim(),
|
||||
profession: (form.profession || '').trim(),
|
||||
position: (form.position || '').trim(),
|
||||
organization: (form.organization || '').trim(),
|
||||
organizationAddress: (form.organizationAddress || '').trim(),
|
||||
autoReply: !!form.autoReply,
|
||||
},
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export interface ChatMarkdownCharacter {
|
||||
text: string
|
||||
key: string | number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
code: boolean
|
||||
heading: boolean
|
||||
newline: boolean
|
||||
}
|
||||
|
||||
export function renderChatMarkdownCharacters(value: string | string[]): ChatMarkdownCharacter[]
|
||||
@@ -0,0 +1,74 @@
|
||||
const markerRunLength = (characters, start, marker) => {
|
||||
let length = 0
|
||||
while (characters[start + length] === marker) length += 1
|
||||
return length
|
||||
}
|
||||
|
||||
export const renderChatMarkdownCharacters = (value) => {
|
||||
const characters = Array.isArray(value) ? value : Array.from(String(value || ''))
|
||||
const output = []
|
||||
let bold = false
|
||||
let italic = false
|
||||
let code = false
|
||||
let heading = false
|
||||
let lineStart = true
|
||||
|
||||
const push = (text, key) => {
|
||||
output.push({
|
||||
text,
|
||||
key,
|
||||
bold,
|
||||
italic,
|
||||
code,
|
||||
heading,
|
||||
newline: text === '\n',
|
||||
})
|
||||
}
|
||||
|
||||
for (let index = 0; index < characters.length; index += 1) {
|
||||
const character = characters[index]
|
||||
|
||||
if (lineStart && character === '#') {
|
||||
const length = markerRunLength(characters, index, '#')
|
||||
if (characters[index + length] === ' ') {
|
||||
heading = true
|
||||
index += length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (lineStart && (character === '-' || character === '*') && characters[index + 1] === ' ') {
|
||||
push('•', `${index}-bullet`)
|
||||
push(' ', `${index}-space`)
|
||||
index += 1
|
||||
lineStart = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (!code && (character === '*' || character === '_')) {
|
||||
const length = markerRunLength(characters, index, character)
|
||||
if (length >= 2) {
|
||||
bold = !bold
|
||||
index += length - 1
|
||||
continue
|
||||
}
|
||||
italic = !italic
|
||||
continue
|
||||
}
|
||||
|
||||
if (character === '`') {
|
||||
code = !code
|
||||
continue
|
||||
}
|
||||
|
||||
push(character, index)
|
||||
if (character === '\n') {
|
||||
heading = false
|
||||
lineStart = true
|
||||
} else {
|
||||
lineStart = false
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
|
||||
// 协议详见 uniapp-avatar/README.md
|
||||
//
|
||||
// uni-webview bridge is bundled by main.ts; no external CDN is required.
|
||||
|
||||
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
||||
const nativeMessageHandlers = new Set<(message: any) => void>()
|
||||
|
||||
export interface UniLaunchParams {
|
||||
token?: string
|
||||
userId?: string
|
||||
nickname?: string
|
||||
avatar?: 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 环境中
|
||||
export function isInUniWebView(): boolean {
|
||||
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 / 会会用户)
|
||||
export function getLaunchParams(): UniLaunchParams {
|
||||
const params: UniLaunchParams = {}
|
||||
readParams(window.location.search, params)
|
||||
const hashQueryIndex = window.location.hash.indexOf('?')
|
||||
if (hashQueryIndex >= 0) {
|
||||
readParams(window.location.hash.slice(hashQueryIndex + 1), params)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// Remove the one-time login credential before any route is rendered or logged.
|
||||
export function stripLaunchToken(): void {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('token')
|
||||
|
||||
const hash = url.hash.slice(1)
|
||||
const queryIndex = hash.indexOf('?')
|
||||
if (queryIndex >= 0) {
|
||||
const path = hash.slice(0, queryIndex)
|
||||
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1))
|
||||
hashParams.delete('token')
|
||||
const query = hashParams.toString()
|
||||
url.hash = `${path}${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
|
||||
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
|
||||
export function postToNative(message: Record<string, any>): boolean {
|
||||
if (!isInUniWebView()) return false
|
||||
;(window as any).uni.webView.postMessage({ data: message })
|
||||
return true
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
|
||||
export function onNativeMessage(handler: (message: any) => void): () => void {
|
||||
nativeMessageHandlers.add(handler)
|
||||
;(window as any)[BRIDGE_HANDLER] = (message: any) => {
|
||||
for (const listener of nativeMessageHandlers) {
|
||||
try {
|
||||
listener(message)
|
||||
} catch (e) {
|
||||
console.error('[uniBridge] handler error', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => nativeMessageHandlers.delete(handler)
|
||||
}
|
||||
|
||||
// 便捷事件
|
||||
export const UniEvents = {
|
||||
ready: () => postToNative({ type: 'ready' }),
|
||||
needLogin: () => postToNative({ type: 'needLogin' }),
|
||||
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
||||
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
||||
payment: (payment: Record<string, any>) => postToNative({ type: 'payment', payment }),
|
||||
back: () => postToNative({ type: 'back' })
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
<template>
|
||||
<div class="authorization-page" :class="{ embedded: isEmbedded }">
|
||||
<header v-if="!isEmbedded" class="page-header">
|
||||
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1>分身授权管理</h1>
|
||||
<span class="header-balance" aria-hidden="true"></span>
|
||||
</header>
|
||||
|
||||
<main class="page-content">
|
||||
<template v-if="activeAvatarId">
|
||||
<section class="permission-intro" aria-label="授权说明">
|
||||
<span class="intro-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect x="5" y="10" width="14" height="11" rx="2" />
|
||||
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</svg>
|
||||
</span>
|
||||
<p>设置数字分身可以执行的操作权限。开启后,分身将代表你自动进行相应操作。</p>
|
||||
</section>
|
||||
|
||||
<section class="permissions-card" :class="{ loading }" :aria-busy="loading">
|
||||
<button
|
||||
v-for="item in permissionItems"
|
||||
:key="item.key"
|
||||
class="permission-row"
|
||||
:class="{ takeover: item.key === 'takeover' }"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="permissionState[item.key]"
|
||||
:aria-label="`${permissionState[item.key] ? '关闭' : '开启'}${item.title}`"
|
||||
:disabled="loading || saving"
|
||||
@click="togglePermission(item.key)"
|
||||
>
|
||||
<span class="permission-icon" :class="item.tone" aria-hidden="true">
|
||||
<svg v-if="item.key === 'friend'" viewBox="0 0 24 24">
|
||||
<path d="M9.5 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
|
||||
<path d="M3.5 20a6 6 0 0 1 12 0M17 8v6m-3-3h6" />
|
||||
</svg>
|
||||
<svg v-else-if="item.key === 'chat'" viewBox="0 0 24 24">
|
||||
<path d="M20 14a4 4 0 0 1-4 4H8l-5 3 1.5-5A7 7 0 0 1 3 12V8a4 4 0 0 1 4-4h9a4 4 0 0 1 4 4v6Z" />
|
||||
</svg>
|
||||
<svg v-else-if="item.key === 'publish'" viewBox="0 0 24 24">
|
||||
<path d="M14.7 5.3 18.7 9.3M4 20l4.1-.8L19.5 7.8a2.8 2.8 0 0 0-4-4L4.8 14.5 4 20Z" />
|
||||
</svg>
|
||||
<svg v-else-if="item.key === 'browse'" viewBox="0 0 24 24">
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
<svg v-else-if="item.key === 'interact'" viewBox="0 0 24 24">
|
||||
<path d="M7 10v10H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h3Zm0 10h9.5a2 2 0 0 0 1.9-1.4l2.3-7A2 2 0 0 0 18.8 9H14l.8-3.2A2.3 2.3 0 0 0 12.6 3L7 10v10Z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24">
|
||||
<path d="M20 13.5a4.5 4.5 0 0 1-4.5 4.5H9l-5 3 1.4-4.5A6.5 6.5 0 0 1 3 11.4V8.5A4.5 4.5 0 0 1 7.5 4H14" />
|
||||
<path d="M18 3v5m-2.5-2.5h5" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<span class="permission-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>
|
||||
{{ item.key === 'takeover' ? takeoverDescription : item.description }}
|
||||
<span
|
||||
v-if="item.key === 'takeover' && takeoverConnectionLabel"
|
||||
class="connection-state"
|
||||
:class="takeoverConnectionTone"
|
||||
> · {{ takeoverConnectionLabel }}</span>
|
||||
</small>
|
||||
</span>
|
||||
|
||||
<span class="permission-switch" :class="{ active: permissionState[item.key] }" aria-hidden="true">
|
||||
<span></span>
|
||||
</span>
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<section v-else-if="!loading" class="empty-state">
|
||||
<span class="empty-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M8 11a4 4 0 1 1 8 0v1M5 20a7 7 0 0 1 14 0M19 8v4m-2-2h4" />
|
||||
</svg>
|
||||
</span>
|
||||
<h2>还没有数字分身</h2>
|
||||
<p>创建分身后即可设置操作权限。</p>
|
||||
<button type="button" @click="goCreate">创建数字分身</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<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()">
|
||||
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
||||
{{ saving ? '保存中...' : '保存授权设置' }}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<Transition name="toast">
|
||||
<p v-if="toastMessage" class="page-toast" role="status">{{ toastMessage }}</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
getAvatarPermissionSettings,
|
||||
getTakeoverStatus,
|
||||
updateAvatarPermissionSettings,
|
||||
type AvatarPermission,
|
||||
type TakeoverStatus,
|
||||
} from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
|
||||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||
|
||||
type PermissionState = Record<AvatarPermission, boolean>
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const avatarStore = useAvatarStore()
|
||||
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
|
||||
const isEmbedded = isHuihuiEmbeddedMode()
|
||||
|
||||
const permissionItems: Array<{
|
||||
key: AvatarPermission
|
||||
title: string
|
||||
description: string
|
||||
tone: string
|
||||
}> = [
|
||||
{
|
||||
key: 'friend',
|
||||
title: '交友与添加好友',
|
||||
description: '允许分身主动添加好友或接受好友请求',
|
||||
tone: 'orange',
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
title: '主动发起聊天',
|
||||
description: '允许分身主动与陌生人或好友发起聊天',
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
title: '发布微博内容',
|
||||
description: '允许分身自动发布动态内容',
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
key: 'browse',
|
||||
title: '浏览会会广场',
|
||||
description: '允许分身浏览广场内容',
|
||||
tone: 'violet',
|
||||
},
|
||||
{
|
||||
key: 'interact',
|
||||
title: '广场互动操作',
|
||||
description: '点赞、收藏、评论、回复等操作',
|
||||
tone: 'pink',
|
||||
},
|
||||
{
|
||||
key: 'takeover',
|
||||
title: '分身主动接管聊天回复',
|
||||
description: '收到私聊消息后按设定时间回复,主人发言时暂停',
|
||||
tone: 'cyan',
|
||||
},
|
||||
]
|
||||
|
||||
const permissionState = reactive<PermissionState>({
|
||||
friend: true,
|
||||
chat: true,
|
||||
publish: false,
|
||||
browse: false,
|
||||
interact: false,
|
||||
takeover: false,
|
||||
})
|
||||
const activeAvatarId = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toastMessage = ref('')
|
||||
const takeoverStatus = ref<TakeoverStatus | null>(null)
|
||||
const takeoverDelayValue = ref(3)
|
||||
const takeoverDelayUnit = ref<'seconds' | 'minutes'>('minutes')
|
||||
let toastTimer: number | undefined
|
||||
let takeoverStatusTimer: number | undefined
|
||||
|
||||
const takeoverConnectionLabel = computed(() => {
|
||||
if (takeoverStatus.value?.status === 'error' || takeoverStatus.value?.status === 'needs_login') {
|
||||
return '连接失败'
|
||||
}
|
||||
if (takeoverStatus.value?.status === 'ready') return '已连接'
|
||||
if (permissionState.takeover) return '正在连接'
|
||||
return ''
|
||||
})
|
||||
|
||||
const takeoverConnectionTone = computed(() => {
|
||||
if (takeoverStatus.value?.status === 'ready') return 'ready'
|
||||
if (takeoverStatus.value?.status === 'error' || takeoverStatus.value?.status === 'needs_login') {
|
||||
return 'failed'
|
||||
}
|
||||
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 enabled = new Set(permissions)
|
||||
for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key)
|
||||
}
|
||||
|
||||
const selectedPermissions = () => permissionItems
|
||||
.filter((item) => permissionState[item.key])
|
||||
.map((item) => item.key)
|
||||
|
||||
const readableError = (error: any, fallback: string) =>
|
||||
error?.response?.data?.detail || error?.response?.data?.message || error?.message || fallback
|
||||
|
||||
const showToast = (message: string) => {
|
||||
toastMessage.value = message
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
toastTimer = window.setTimeout(() => { toastMessage.value = '' }, 2400)
|
||||
}
|
||||
|
||||
const togglePermission = async (permission: AvatarPermission) => {
|
||||
errorMessage.value = ''
|
||||
const previousValue = permissionState[permission]
|
||||
permissionState[permission] = !permissionState[permission]
|
||||
if (permission === 'takeover') {
|
||||
takeoverStatus.value = null
|
||||
if (takeoverStatusTimer) {
|
||||
window.clearTimeout(takeoverStatusTimer)
|
||||
takeoverStatusTimer = undefined
|
||||
}
|
||||
const saved = await saveSettings(true)
|
||||
if (!saved) permissionState.takeover = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleTakeoverStatusRefresh = () => {
|
||||
if (takeoverStatusTimer) window.clearTimeout(takeoverStatusTimer)
|
||||
if (!permissionState.takeover || !activeAvatarId.value) {
|
||||
takeoverStatusTimer = undefined
|
||||
return
|
||||
}
|
||||
takeoverStatusTimer = window.setTimeout(async () => {
|
||||
await loadTakeoverStatus()
|
||||
scheduleTakeoverStatusRefresh()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!activeAvatarId.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const settings = await getAvatarPermissionSettings(activeAvatarId.value)
|
||||
setPermissions(settings.permissions || [])
|
||||
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
|
||||
await loadTakeoverStatus()
|
||||
scheduleTakeoverStatusRefresh()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = readableError(error, '授权设置加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTakeoverStatus = async () => {
|
||||
if (!activeAvatarId.value) return
|
||||
try {
|
||||
takeoverStatus.value = await getTakeoverStatus(activeAvatarId.value)
|
||||
if (!takeoverStatus.value.enabled && permissionState.takeover) {
|
||||
permissionState.takeover = false
|
||||
}
|
||||
} catch {
|
||||
takeoverStatus.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
|
||||
if (!activeAvatarId.value || loading.value || saving.value) return false
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
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 || [])
|
||||
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
|
||||
await loadTakeoverStatus()
|
||||
scheduleTakeoverStatusRefresh()
|
||||
if (takeoverToggle) {
|
||||
showToast(permissionState.takeover ? '接管已开启,正在连接' : '接管已关闭')
|
||||
} else {
|
||||
showToast('授权设置已保存')
|
||||
}
|
||||
return true
|
||||
} catch (error: any) {
|
||||
errorMessage.value = readableError(error, '授权设置保存失败,请重试')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => router.push('/avatar/manage')
|
||||
const goCreate = () => router.push('/avatar/create')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||||
activeAvatarId.value = avatarId.value || ''
|
||||
if (activeAvatarId.value) avatarStore.currentAvatarId = activeAvatarId.value
|
||||
await loadSettings()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
if (takeoverStatusTimer) window.clearTimeout(takeoverStatusTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.authorization-page {
|
||||
--orange: #f58632;
|
||||
--orange-deep: #f2792f;
|
||||
--ink: #18191c;
|
||||
--muted: #a0a5b4;
|
||||
position: relative;
|
||||
width: min(100%, 390px);
|
||||
min-height: 100dvh;
|
||||
margin: 0 auto;
|
||||
padding-bottom: calc(92px + env(safe-area-inset-bottom));
|
||||
overflow-x: hidden;
|
||||
color: var(--ink);
|
||||
background: #fafafa;
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
height: calc(72px + env(safe-area-inset-top));
|
||||
padding: env(safe-area-inset-top) 20px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr 34px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #7f8490;
|
||||
background: #f3f4f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-button svg {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.authorization-page.embedded .page-content {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.permission-intro {
|
||||
min-height: 96px;
|
||||
padding: 15px 16px 14px;
|
||||
border: 1px solid rgba(255, 227, 195, .45);
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(115deg, #fff8eb 0%, #fff4ec 100%);
|
||||
}
|
||||
|
||||
.intro-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #f18837;
|
||||
}
|
||||
|
||||
.intro-icon svg {
|
||||
font-size: 24px;
|
||||
stroke-width: 1.9;
|
||||
}
|
||||
|
||||
.permission-intro p {
|
||||
margin: 7px 0 0;
|
||||
color: #a66d45;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
margin-top: 16px;
|
||||
padding: 5px 0;
|
||||
overflow: hidden;
|
||||
border-radius: 15px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 26px rgba(69, 55, 43, .025);
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
|
||||
.permissions-card.loading {
|
||||
opacity: .62;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
width: 100%;
|
||||
min-height: 65px;
|
||||
padding: 8px 15px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 43px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-row:active:not(:disabled) {
|
||||
background: #fcfcfd;
|
||||
}
|
||||
|
||||
.permission-row.takeover {
|
||||
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 {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.permission-icon svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.permission-icon.orange {
|
||||
color: #ec9a45;
|
||||
background: #fff9ee;
|
||||
}
|
||||
|
||||
.permission-icon.blue {
|
||||
color: #619ae7;
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.permission-icon.green {
|
||||
color: #64c792;
|
||||
background: #f0fbf5;
|
||||
}
|
||||
|
||||
.permission-icon.violet {
|
||||
color: #8b7de4;
|
||||
background: #f6f4ff;
|
||||
}
|
||||
|
||||
.permission-icon.pink {
|
||||
color: #ec78a3;
|
||||
background: #fff3f7;
|
||||
}
|
||||
|
||||
.permission-icon.cyan {
|
||||
color: #35a6a2;
|
||||
background: #effaf9;
|
||||
}
|
||||
|
||||
.permission-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permission-copy strong,
|
||||
.permission-copy small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.connection-state {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-state.connecting {
|
||||
color: #d78532;
|
||||
}
|
||||
|
||||
.connection-state.ready {
|
||||
color: #31966b;
|
||||
}
|
||||
|
||||
.connection-state.failed {
|
||||
color: #d95849;
|
||||
}
|
||||
|
||||
.permission-copy strong {
|
||||
color: #26272b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.permission-copy small {
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permission-row.takeover .permission-copy small {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.permission-switch {
|
||||
position: relative;
|
||||
width: 43px;
|
||||
height: 25px;
|
||||
justify-self: end;
|
||||
border-radius: 999px;
|
||||
background: #d8dde5;
|
||||
box-shadow: inset 0 0 0 1px rgba(140, 148, 160, .08);
|
||||
transition: background .2s ease;
|
||||
}
|
||||
|
||||
.permission-switch span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(74, 72, 69, .2);
|
||||
transition: transform .2s cubic-bezier(.22, .85, .35, 1);
|
||||
}
|
||||
|
||||
.permission-switch.active {
|
||||
background: linear-gradient(120deg, #f59d3c, #f47d31);
|
||||
}
|
||||
|
||||
.permission-switch.active span {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 13px 4px 0;
|
||||
color: #d95849;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.save-area {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: min(100%, 390px);
|
||||
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%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.save-button {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: 24px;
|
||||
color: #fff;
|
||||
background: linear-gradient(105deg, #f7a33d 0%, #f47b2f 100%);
|
||||
box-shadow: 0 8px 20px rgba(240, 119, 43, .16);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
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 {
|
||||
opacity: .68;
|
||||
}
|
||||
|
||||
.saving-spinner {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid rgba(255, 255, 255, .48);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 470px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
color: #ed8739;
|
||||
background: #fff4e9;
|
||||
}
|
||||
|
||||
.empty-icon svg {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 18px 0 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 7px 0 20px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
padding: 11px 24px;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
background: linear-gradient(105deg, #f7a33d, #f47b2f);
|
||||
}
|
||||
|
||||
.page-toast {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
left: 50%;
|
||||
bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
max-width: min(310px, calc(100vw - 40px));
|
||||
margin: 0;
|
||||
padding: 10px 18px;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
background: rgba(36, 36, 39, .9);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, .14);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity .2s ease, transform .2s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 8px);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.authorization-page {
|
||||
border-radius: 0 0 34px 34px;
|
||||
box-shadow: 0 18px 55px rgba(32, 30, 28, .1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.permission-switch,
|
||||
.permission-switch span,
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.saving-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="avatar-card-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身名片</h1>
|
||||
</div>
|
||||
<button class="share-btn" @click="shareCard">分享</button>
|
||||
</header>
|
||||
|
||||
<!-- 名片预览 -->
|
||||
<section class="card-preview">
|
||||
<div class="digital-card">
|
||||
<div class="card-top">
|
||||
<div class="card-avatar">{{ avatar.emoji }}</div>
|
||||
<span class="card-status" :class="avatar.status">● {{ statusText(avatar.status) }}</span>
|
||||
</div>
|
||||
<h2 class="card-name">{{ avatar.displayName }}</h2>
|
||||
<p class="card-desc">{{ avatar.description }}</p>
|
||||
<div class="card-tags">
|
||||
<span class="card-tag" v-for="t in avatar.tags" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
<div class="card-qr">
|
||||
<div class="qr-placeholder" aria-hidden="true">▦</div>
|
||||
<span class="qr-tip">扫码添加我的分身</span>
|
||||
</div>
|
||||
<div class="card-id">会会号:{{ avatar.huihuiId }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 名片信息 -->
|
||||
<section class="info-section">
|
||||
<h3 class="section-title">名片信息</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row"><span class="info-label">分身名称</span><span class="info-value">{{ avatar.displayName }}</span></div>
|
||||
<div class="info-row"><span class="info-label">会会号</span><span class="info-value">{{ avatar.huihuiId }}</span></div>
|
||||
<div class="info-row"><span class="info-label">状态</span><span class="info-value">{{ statusText(avatar.status) }}</span></div>
|
||||
<div class="info-row"><span class="info-label">创建时间</span><span class="info-value">{{ formatDate(avatar.createdAt) }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作 -->
|
||||
<section class="action-section">
|
||||
<button class="primary-btn" @click="copyLink">复制分享链接</button>
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { getAvatarDetail } from '@/api'
|
||||
import { pickAvatarId } from '@/utils/avatar-page-data'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const avatarStore = useAvatarStore()
|
||||
const toast = ref('')
|
||||
const loading = ref(true)
|
||||
const selectedAvatarId = ref('')
|
||||
|
||||
const avatar = ref({
|
||||
emoji: '🤖',
|
||||
displayName: '会会助手',
|
||||
description: '我是您的AI数字分身,可以帮您管理日程、回复消息、处理任务。',
|
||||
huihuiId: 'huihui_8848',
|
||||
status: 'active',
|
||||
createdAt: '2026-07-01T10:00:00Z',
|
||||
tags: ['智能对话', '日程管理', '社交助手']
|
||||
})
|
||||
|
||||
const applyAvatar = (a: any) => {
|
||||
if (!a) return
|
||||
avatar.value = {
|
||||
emoji: a.emoji || '🤖',
|
||||
displayName: a.displayName || a.name || '我的分身',
|
||||
description: a.description || '暂无描述',
|
||||
huihuiId: a.huihuiId || 'huihui_' + (a.id || '0000'),
|
||||
status: a.status || 'active',
|
||||
createdAt: a.createdAt || new Date().toISOString(),
|
||||
tags: a.tags || ['智能对话', '日程管理']
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||||
const requestedId = String(route.query.id || '')
|
||||
const id = pickAvatarId(requestedId, avatarStore.avatars)
|
||||
selectedAvatarId.value = id || ''
|
||||
const localAvatar = avatarStore.avatars.find((a) => String(a.id) === id)
|
||||
if (localAvatar) {
|
||||
applyAvatar(localAvatar)
|
||||
} else if (requestedId) {
|
||||
applyAvatar(await getAvatarDetail(requestedId))
|
||||
selectedAvatarId.value = requestedId
|
||||
} else {
|
||||
applyAvatar(avatarStore.avatars[0])
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = (s: string) => ({ active: '活跃中', inactive: '未激活', training: '训练中' }[s] || '活跃中')
|
||||
const formatDate = (t: string) => new Date(t).toLocaleDateString('zh-CN')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const shareCard = () => {
|
||||
toast.value = '请复制链接分享给对方'
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
const copyLink = async () => {
|
||||
const id = selectedAvatarId.value
|
||||
const redirect = router.resolve({ path: '/avatar/card', query: id ? { id: String(id) } : undefined }).href
|
||||
const loginUrl = router.resolve({ path: '/login/sms', query: { redirect } }).href
|
||||
const hash = loginUrl.includes('#') ? loginUrl.slice(loginUrl.indexOf('#')) : `#${loginUrl}`
|
||||
const link = `${window.location.origin}${window.location.pathname}${hash}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
toast.value = '分享链接已复制'
|
||||
} catch {
|
||||
toast.value = link
|
||||
}
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-card-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.share-btn { background: rgba(255,255,255,0.2); border: none; color: white; padding: 6px 16px; border-radius: 20px; font-size: 14px; cursor: pointer; }
|
||||
|
||||
.card-preview { padding: 16px 20px; }
|
||||
.digital-card {
|
||||
background: linear-gradient(160deg, #FFF7ED 0%, #FFFFFF 60%);
|
||||
border: 1px solid #FFE4CC; border-radius: 20px; padding: 24px;
|
||||
box-shadow: 0 8px 24px rgba(249,115,22,0.12);
|
||||
}
|
||||
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.card-avatar {
|
||||
width: 64px; height: 64px; border-radius: 18px; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 34px;
|
||||
}
|
||||
.card-status { font-size: 12px; color: #22C55E; background: #ECFDF5; padding: 4px 10px; border-radius: 20px; }
|
||||
.card-name { font-size: 22px; font-weight: 700; color: #18191C; margin: 0 0 8px; }
|
||||
.card-desc { font-size: 14px; color: #6B7280; line-height: 1.6; margin: 0 0 16px; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||
.card-tag { padding: 4px 12px; background: #FFF0E6; color: #F97316; border-radius: 20px; font-size: 12px; font-weight: 500; }
|
||||
.card-qr {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 16px; background: white; border-radius: 14px; border: 1px dashed #FED7AA; margin-bottom: 14px;
|
||||
}
|
||||
.qr-placeholder { font-size: 56px; line-height: 1; color: #F97316; letter-spacing: 4px; }
|
||||
.qr-tip { font-size: 12px; color: #9398AE; }
|
||||
.card-id { text-align: center; font-size: 13px; color: #9398AE; }
|
||||
|
||||
.info-section { padding: 0 20px 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; margin: 0 0 12px; color: #18191C; }
|
||||
.info-list { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); overflow: hidden; }
|
||||
.info-row { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #F3F4F6; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-size: 14px; color: #6B7280; }
|
||||
.info-value { font-size: 14px; color: #18191C; font-weight: 500; }
|
||||
|
||||
.action-section { padding: 0 20px; }
|
||||
.primary-btn {
|
||||
width: 100%; padding: 14px; background: #F97316; color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: opacity 0.2s;
|
||||
}
|
||||
.primary-btn:hover { opacity: 0.9; }
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 12px 0 0; word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="chat-page" :class="{ 'has-pending-images': pendingImages.length }">
|
||||
<header class="chat-header">
|
||||
<button v-if="!isPublic" class="back-btn" @click="router.back()">‹</button>
|
||||
<div class="avatar-heading">
|
||||
<div class="avatar-mark">
|
||||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1>{{ avatar?.displayName || avatar?.name || '数字分身' }}</h1>
|
||||
<span class="online-state" :class="avatarStatus.tone"><i></i>{{ avatarStatus.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!isPublic" class="settings-btn" title="编辑分身" @click="router.push(`/avatar/edit/${avatarId}`)">⚙</button>
|
||||
</header>
|
||||
|
||||
<main ref="messageList" class="message-list">
|
||||
<div v-if="!messages.length" class="welcome-card">
|
||||
<div class="welcome-avatar">
|
||||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<h2>有什么想聊的?</h2>
|
||||
<p class="welcome-description">{{ avatar?.description || '很高兴和你聊聊。' }}</p>
|
||||
</div>
|
||||
|
||||
<article v-for="(message, index) in messages" :key="`${message.role}-${index}`" class="message-row" :class="message.role">
|
||||
<div v-if="message.role === 'assistant'" class="message-avatar avatar-message-face">
|
||||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<div class="message-column">
|
||||
<div v-if="message.attachments?.length" class="message-images">
|
||||
<figure v-for="attachment in message.attachments" :key="attachment.id" class="message-image-card">
|
||||
<img :src="attachment.previewUrl" :alt="attachment.filename" />
|
||||
<figcaption v-if="attachment.warning">{{ attachment.warning }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="message-bubble" :class="{ streaming: sending && message.role === 'assistant' && index === messages.length - 1 }">
|
||||
<template v-if="message.role === 'assistant'">
|
||||
<span
|
||||
v-for="character in renderChatMarkdownCharacters(message.characters?.length ? message.characters : message.content)"
|
||||
:key="character.key"
|
||||
class="typing-character"
|
||||
:class="{
|
||||
newline: character.newline,
|
||||
'markdown-bold': character.bold,
|
||||
'markdown-italic': character.italic,
|
||||
'markdown-code': character.code,
|
||||
'markdown-heading': character.heading
|
||||
}"
|
||||
>{{ character.text }}</span>
|
||||
</template>
|
||||
<template v-else>{{ message.content }}</template>
|
||||
</div>
|
||||
<div v-if="message.source || message.references?.length" class="message-source">
|
||||
{{ sourceLabel(message.source) }}
|
||||
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.role === 'user'" class="message-avatar user-message-face">
|
||||
<img v-if="userAvatarUrl" :src="userAvatarUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ userAvatarInitial }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="thinking" class="message-row assistant">
|
||||
<div class="message-avatar avatar-message-face">
|
||||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||
</div>
|
||||
<div class="message-bubble typing"><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="chat-error">{{ errorMessage }} <button @click="retryLast">重试</button></p>
|
||||
</main>
|
||||
|
||||
<form class="composer" @submit.prevent="sendMessage(inputText)">
|
||||
<div v-if="pendingImages.length" class="pending-images">
|
||||
<div v-for="image in pendingImages" :key="image.localId" class="pending-image" :class="image.status">
|
||||
<img :src="image.previewUrl" :alt="image.filename" />
|
||||
<div class="pending-image-copy">
|
||||
<strong>{{ image.status === 'uploading' ? '正在识别图片…' : image.summary || image.filename }}</strong>
|
||||
<span>{{ image.status === 'uploading' ? '正在提取图片中的可见内容' : categoryLabel(image.category) }}</span>
|
||||
</div>
|
||||
<button type="button" aria-label="移除图片" :disabled="sending" @click="removePendingImage(image.localId)">×</button>
|
||||
</div>
|
||||
<p v-if="hasPendingMedicalImage" class="medical-note">病例与医学影像识别仅供辅助,请以原始资料和医生意见为准。</p>
|
||||
</div>
|
||||
<div class="composer-row">
|
||||
<button class="image-btn" type="button" :disabled="sending || uploadingImage || pendingImages.length >= 3" aria-label="选择图片" @click="imageInput?.click()">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 18.5v-13Zm2 12.7 3.8-4.2 2.7 2.8 1.7-1.8 3.8 3.2V5.5a.5.5 0 0 0-.5-.5h-11a.5.5 0 0 0-.5.5v12.7Zm8.3-7.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"/></svg>
|
||||
</button>
|
||||
<input ref="imageInput" class="image-input" type="file" accept="image/jpeg,image/png,image/webp" multiple @change="selectImages" />
|
||||
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入问题,或选择一张图片…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
|
||||
<button class="send-btn" type="submit" :disabled="sending || uploadingImage || (!inputText.trim() && !readyPendingImages.length)">发送</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
getAvatarDetail,
|
||||
getPublicAvatar,
|
||||
streamAvatarChat,
|
||||
streamPublicAvatarChat,
|
||||
uploadAvatarChatImage,
|
||||
uploadPublicAvatarChatImage,
|
||||
type ChatAttachment,
|
||||
type ChatMessage
|
||||
} from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { renderChatMarkdownCharacters } from '@/utils/chat-markdown.js'
|
||||
|
||||
type DisplayMessage = ChatMessage & {
|
||||
source?: 'qa' | 'knowledge' | 'vision' | 'qwen' | 'public'
|
||||
references?: Array<{ filename?: string }>
|
||||
characters?: string[]
|
||||
attachments?: MessageAttachment[]
|
||||
}
|
||||
|
||||
type MessageAttachment = {
|
||||
id: string
|
||||
filename: string
|
||||
previewUrl: string
|
||||
category?: ChatAttachment['category']
|
||||
summary?: string
|
||||
warning?: string
|
||||
}
|
||||
|
||||
type PendingImage = MessageAttachment & {
|
||||
localId: string
|
||||
attachmentId?: string
|
||||
status: 'uploading' | 'ready'
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAvatarStore()
|
||||
const userStore = useUserStore()
|
||||
const shareToken = String(route.params.shareToken || '')
|
||||
const isPublic = Boolean(shareToken)
|
||||
const avatarId = ref(String(route.params.id || ''))
|
||||
const avatar = ref<any>(null)
|
||||
const messages = ref<DisplayMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
const thinking = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastRequest = ref<{ question: string; attachments: MessageAttachment[] } | null>(null)
|
||||
const messageList = ref<HTMLElement | null>(null)
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const pendingImages = ref<PendingImage[]>([])
|
||||
const previewUrls = new Set<string>()
|
||||
let scrollFrame: number | null = null
|
||||
|
||||
const userAvatarUrl = computed(() => userStore.user?.avatarUrl || store.userProfile?.avatarUrl || '')
|
||||
const userAvatarInitial = computed(() => (userStore.user?.nickname || store.userProfile?.nickname || '我').trim().slice(0, 1))
|
||||
const avatarStatus = computed(() => {
|
||||
const status = avatar.value?.status || 'active'
|
||||
if (status === 'inactive') return { tone: 'inactive', label: '当前已停用' }
|
||||
if (status === 'training') return { tone: 'training', label: '知识训练中' }
|
||||
return { tone: 'active', label: '在线,随时可以和我聊聊' }
|
||||
})
|
||||
const readyPendingImages = computed(() => pendingImages.value.filter((image) => image.status === 'ready' && image.attachmentId))
|
||||
const uploadingImage = computed(() => pendingImages.value.some((image) => image.status === 'uploading'))
|
||||
const hasPendingMedicalImage = computed(() => readyPendingImages.value.some((image) => ['medical_document', 'medical_image'].includes(image.category || '')))
|
||||
|
||||
const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
|
||||
qa: '标准问答对',
|
||||
knowledge: '参考文件知识库',
|
||||
vision: '图片理解',
|
||||
qwen: '智能回答',
|
||||
public: ''
|
||||
}
|
||||
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
||||
const categoryLabels: Record<ChatAttachment['category'], string> = {
|
||||
general_image: '图片内容已识别',
|
||||
document: '文档图片已识别',
|
||||
medical_document: '病例文字已提取,请核对原文',
|
||||
medical_image: '医学影像已作客观描述'
|
||||
}
|
||||
const categoryLabel = (category?: ChatAttachment['category']) => category ? categoryLabels[category] : '图片内容已识别'
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||
}
|
||||
|
||||
const scrollDuringStream = () => {
|
||||
if (scrollFrame !== null) return
|
||||
scrollFrame = window.requestAnimationFrame(() => {
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||
scrollFrame = null
|
||||
})
|
||||
}
|
||||
|
||||
const sleep = (delay: number) => new Promise<void>((resolve) => window.setTimeout(resolve, delay))
|
||||
|
||||
const createStreamReply = () => {
|
||||
const reply = reactive<DisplayMessage>({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
characters: []
|
||||
})
|
||||
let attached = false
|
||||
const attach = () => {
|
||||
if (attached) return
|
||||
messages.value.push(reply)
|
||||
attached = true
|
||||
}
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
const queue: string[] = []
|
||||
let draining: Promise<void> | null = null
|
||||
|
||||
const drain = async () => {
|
||||
while (queue.length) {
|
||||
let character = queue.shift() || ''
|
||||
if (character === '\r') continue
|
||||
if (/\s/.test(character) && character !== '\n') character = ' '
|
||||
const previous = reply.characters?.[reply.characters.length - 1] || ''
|
||||
if (character === ' ' && (!previous || previous === ' ' || previous === '\n')) continue
|
||||
if (character === '\n') {
|
||||
while (reply.characters?.[reply.characters.length - 1] === ' ') {
|
||||
reply.characters.pop()
|
||||
reply.content = reply.content.slice(0, -1)
|
||||
}
|
||||
if (!reply.characters?.length || reply.characters[reply.characters.length - 1] === '\n') continue
|
||||
}
|
||||
reply.content += character
|
||||
reply.characters?.push(character)
|
||||
scrollDuringStream()
|
||||
await sleep(/[,。!?;:\n]/.test(character) ? 140 : 28)
|
||||
}
|
||||
draining = null
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
append: (content: string) => {
|
||||
if (!content) return
|
||||
attach()
|
||||
if (reduceMotion) {
|
||||
reply.content += content
|
||||
void scrollToBottom()
|
||||
return
|
||||
}
|
||||
queue.push(...Array.from(content))
|
||||
if (!draining) draining = drain()
|
||||
},
|
||||
finish: async () => {
|
||||
if (draining) await draining
|
||||
while (reply.characters?.length && /[\s\n]/.test(reply.characters[reply.characters.length - 1])) {
|
||||
reply.characters.pop()
|
||||
reply.content = reply.content.slice(0, -1)
|
||||
}
|
||||
if (reduceMotion) reply.characters = []
|
||||
if (attached) await scrollToBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadAvatar = async () => {
|
||||
if (isPublic) {
|
||||
avatar.value = await getPublicAvatar(shareToken)
|
||||
avatarId.value = String(avatar.value?.id || '')
|
||||
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
||||
return
|
||||
}
|
||||
avatar.value = store.avatars.find((item) => String(item.id) === avatarId.value)
|
||||
if (!avatar.value) avatar.value = await getAvatarDetail(avatarId.value)
|
||||
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
||||
}
|
||||
|
||||
const removePendingImage = (localId: string) => {
|
||||
const target = pendingImages.value.find((image) => image.localId === localId)
|
||||
if (target) {
|
||||
URL.revokeObjectURL(target.previewUrl)
|
||||
previewUrls.delete(target.previewUrl)
|
||||
}
|
||||
pendingImages.value = pendingImages.value.filter((image) => image.localId !== localId)
|
||||
}
|
||||
|
||||
const selectImages = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const slots = Math.max(0, 3 - pendingImages.value.length)
|
||||
const files = Array.from(input.files || []).slice(0, slots)
|
||||
input.value = ''
|
||||
for (const file of files) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
errorMessage.value = '仅支持 JPG、PNG、WebP 图片'
|
||||
continue
|
||||
}
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
errorMessage.value = '单张图片不能超过 8MB'
|
||||
continue
|
||||
}
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
previewUrls.add(previewUrl)
|
||||
const localId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
pendingImages.value.push({
|
||||
id: localId,
|
||||
localId,
|
||||
filename: file.name,
|
||||
previewUrl,
|
||||
status: 'uploading'
|
||||
})
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = isPublic
|
||||
? await uploadPublicAvatarChatImage(shareToken, file)
|
||||
: await uploadAvatarChatImage(avatarId.value, file)
|
||||
const pending = pendingImages.value.find((image) => image.localId === localId)
|
||||
if (!pending) continue
|
||||
Object.assign(pending, {
|
||||
id: result.id,
|
||||
attachmentId: result.id,
|
||||
status: 'ready',
|
||||
category: result.category,
|
||||
summary: result.summary,
|
||||
warning: result.warning
|
||||
})
|
||||
} catch (error: any) {
|
||||
removePendingImage(localId)
|
||||
errorMessage.value = error?.response?.data?.detail || error?.message || '图片识别失败,请重新选择图片'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async (value: string, retryAttachments?: MessageAttachment[]) => {
|
||||
const selectedAttachments = retryAttachments || readyPendingImages.value.map((image) => ({
|
||||
id: image.attachmentId || image.id,
|
||||
filename: image.filename,
|
||||
previewUrl: image.previewUrl,
|
||||
category: image.category,
|
||||
summary: image.summary,
|
||||
warning: image.warning
|
||||
}))
|
||||
const question = value.trim() || (selectedAttachments.length ? '请帮我看看这张图片。' : '')
|
||||
if (!question || sending.value || (!retryAttachments && uploadingImage.value)) return
|
||||
const history = messages.value.slice(-10).map(({ role, content, attachments }) => ({
|
||||
role,
|
||||
content,
|
||||
attachmentIds: attachments?.map((attachment) => attachment.id) || []
|
||||
}))
|
||||
lastRequest.value = { question, attachments: selectedAttachments }
|
||||
inputText.value = ''
|
||||
errorMessage.value = ''
|
||||
if (!retryAttachments) pendingImages.value = []
|
||||
messages.value.push({ role: 'user', content: question, attachments: selectedAttachments })
|
||||
sending.value = true
|
||||
thinking.value = true
|
||||
await scrollToBottom()
|
||||
try {
|
||||
const payload = {
|
||||
message: question,
|
||||
attachmentIds: selectedAttachments.map((attachment) => attachment.id),
|
||||
history
|
||||
}
|
||||
const streamed = createStreamReply()
|
||||
const handlers = {
|
||||
onMeta: (meta: Pick<DisplayMessage, 'source' | 'references'>) => {
|
||||
streamed.reply.source = meta.source
|
||||
streamed.reply.references = meta.references
|
||||
},
|
||||
onDelta: (content: string) => {
|
||||
thinking.value = false
|
||||
streamed.append(content)
|
||||
}
|
||||
}
|
||||
if (isPublic) {
|
||||
await streamPublicAvatarChat(shareToken, payload, handlers)
|
||||
} else {
|
||||
await streamAvatarChat(avatarId.value, payload, handlers)
|
||||
}
|
||||
thinking.value = false
|
||||
await streamed.finish()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error?.message || '暂时无法回答,请稍后重试'
|
||||
} finally {
|
||||
thinking.value = false
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const retryLast = () => {
|
||||
if (!lastRequest.value || sending.value) return
|
||||
while (messages.value[messages.value.length - 1]?.role === 'assistant') messages.value.pop()
|
||||
if (messages.value[messages.value.length - 1]?.role === 'user') messages.value.pop()
|
||||
void sendMessage(lastRequest.value.question, lastRequest.value.attachments)
|
||||
}
|
||||
|
||||
onMounted(loadAvatar)
|
||||
onBeforeUnmount(() => {
|
||||
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||||
previewUrls.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-page { height: 100dvh; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #FFF8F1; color: #3B2417; }
|
||||
.chat-header { position: sticky; top: 0; z-index: 10; flex: 0 0 auto; display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: white; background: linear-gradient(135deg, #F97316, #FB923C); box-shadow: 0 5px 18px rgba(249, 115, 22, .2); }
|
||||
.back-btn, .settings-btn { border: 0; background: transparent; color: white; cursor: pointer; font-size: 25px; padding: 2px 6px; }
|
||||
.settings-btn { font-size: 20px; margin-left: auto; }
|
||||
.avatar-heading { display: flex; align-items: center; gap: 10px; }
|
||||
.avatar-mark { width: 38px; height: 38px; display: grid; place-items: center; overflow: hidden; border-radius: 13px; background: rgba(255,255,255,.24); font-size: 23px; }.avatar-mark img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar-heading h1 { margin: 0; font-size: 17px; }
|
||||
.online-state { display: flex; align-items: center; gap: 4px; margin-top: 3px; font-size: 11px; opacity: .9; }.online-state i { width: 7px; height: 7px; border-radius: 50%; background: #86EFAC; box-shadow: 0 0 0 2px rgba(255,255,255,.22); }.online-state.training i { background: #FDE68A; }.online-state.inactive i { background: #FDA4AF; }
|
||||
.message-list { min-height: 0; flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.chat-page.has-pending-images .message-list { padding-bottom: min(330px, 42vh); }
|
||||
.welcome-card { padding: 28px 20px; text-align: center; background: rgba(255,255,255,.72); border: 1px solid #FFE1C2; border-radius: 22px; box-shadow: 0 10px 28px rgba(181, 99, 35, .08); }
|
||||
.welcome-avatar { width: 64px; height: 64px; display: grid; place-items: center; margin: 0 auto 14px; overflow: hidden; border: 3px solid #fff; border-radius: 50%; background: #FFE4C7; box-shadow: 0 7px 16px rgba(181, 99, 35, .18); font-size: 32px; }.welcome-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.welcome-card h2 { margin: 0 0 8px; font-size: 20px; }.welcome-description { max-width: 340px; margin: 0 auto; color: #8B6B58; font-size: 14px; line-height: 1.65; }
|
||||
.message-row { display: flex; gap: 10px; margin: 18px 0; align-items: flex-start; }
|
||||
.message-row.user { justify-content: flex-end; }
|
||||
.message-avatar { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; border: 2px solid rgba(255,255,255,.9); border-radius: 14px; background: #FFE4C7; box-shadow: 0 3px 10px rgba(96, 52, 21, .12); font-size: 16px; }.message-avatar img { width: 100%; height: 100%; object-fit: cover; }.user-message-face { color: #fff; background: #D97706; }
|
||||
.message-column { max-width: min(78%, 560px); }
|
||||
.message-images { display: grid; grid-template-columns: repeat(2, minmax(0, 150px)); gap: 8px; margin-bottom: 8px; }
|
||||
.message-image-card { margin: 0; overflow: hidden; border: 1px solid #F4D4B8; border-radius: 14px; background: #fff; box-shadow: 0 4px 14px rgba(96, 52, 21, .08); }
|
||||
.message-image-card img { display: block; width: 100%; max-height: 210px; object-fit: cover; }
|
||||
.message-image-card figcaption { padding: 7px 9px; color: #8A5A3B; background: #FFF6ED; font-size: 10px; line-height: 1.45; }
|
||||
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 4px 16px 16px 16px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
|
||||
.message-bubble.streaming::after { content: ''; display: inline-block; width: 2px; height: 1.05em; margin-left: 3px; vertical-align: -0.16em; background: currentColor; animation: type-cursor .75s step-end infinite; }
|
||||
.typing-character { display: inline-block; animation: character-in .24s cubic-bezier(.2,.72,.25,1) both; }.typing-character.newline { display: block; height: 0; }
|
||||
.typing-character.markdown-bold { font-weight: 750; color: #2F1A10; }
|
||||
.typing-character.markdown-italic { font-style: italic; }
|
||||
.typing-character.markdown-code { margin: 0 1px; padding: 0 3px; border-radius: 4px; color: #9A3412; background: #FFF0E3; font-family: "SFMono-Regular", Consolas, monospace; font-size: .92em; }
|
||||
.typing-character.markdown-heading { font-weight: 750; font-size: 1.08em; }
|
||||
.user .message-bubble { color: white; border-radius: 16px 4px 16px 16px; background: #F97316; }
|
||||
.message-source { margin: 5px 4px 0; font-size: 11px; color: #A77A5B; }
|
||||
.typing { display: flex; gap: 4px; padding: 14px 16px; }
|
||||
.typing i { width: 5px; height: 5px; border-radius: 50%; background: #F97316; animation: blink 1s infinite alternate; }
|
||||
.typing i:nth-child(2) { animation-delay: .2s; }.typing i:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes blink { from { opacity: .25; } to { opacity: 1; } }
|
||||
@keyframes type-cursor { 50% { opacity: 0; } }
|
||||
@keyframes character-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.chat-error { margin: 4px auto; color: #B42318; font-size: 13px; }.chat-error button { border: 0; background: none; color: #C15F18; cursor: pointer; text-decoration: underline; }
|
||||
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; gap: 9px; padding: 10px max(18px, calc((100vw - 760px) / 2 + 18px)) 12px; background: rgba(255,255,255,.94); border-top: 1px solid #F4DCC7; backdrop-filter: blur(14px); }
|
||||
.composer-row { display: flex; align-items: flex-end; gap: 9px; }
|
||||
.composer textarea { flex: 1; resize: none; min-height: 22px; max-height: 100px; padding: 11px 13px; border: 1px solid #EED8C5; border-radius: 13px; font: inherit; color: #3B2417; outline: none; }.composer textarea:focus { border-color: #F97316; }
|
||||
.image-input { display: none; }
|
||||
.image-btn { flex: 0 0 auto; width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid #EED8C5; border-radius: 13px; color: #C65A11; background: #FFF8F1; cursor: pointer; }
|
||||
.image-btn svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
.image-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.pending-images { display: grid; gap: 7px; }
|
||||
.pending-image { display: grid; grid-template-columns: 48px minmax(0, 1fr) 30px; align-items: center; gap: 9px; min-height: 48px; padding: 6px 8px; border: 1px solid #F1D4BB; border-radius: 14px; background: #FFF9F3; }
|
||||
.pending-image img { width: 48px; height: 48px; object-fit: cover; border-radius: 10px; }
|
||||
.pending-image-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.pending-image-copy strong { overflow: hidden; color: #4B2B19; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pending-image-copy span { color: #9A7159; font-size: 10px; }
|
||||
.pending-image.uploading strong::after { content: ''; display: inline-block; width: 7px; height: 7px; margin-left: 7px; border: 2px solid #F6B889; border-top-color: #F97316; border-radius: 50%; animation: image-spin .7s linear infinite; }
|
||||
.pending-image > button { width: 28px; height: 28px; border: 0; border-radius: 9px; color: #9A7159; background: #F8E8D9; font-size: 19px; cursor: pointer; }
|
||||
.medical-note { margin: 0; padding: 0 2px; color: #9A5A2E; font-size: 10px; line-height: 1.45; }
|
||||
.send-btn { align-self: flex-end; padding: 11px 18px; border: 0; border-radius: 12px; color: white; background: #F97316; cursor: pointer; }.send-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
@keyframes image-spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 520px) {
|
||||
.message-images { grid-template-columns: minmax(0, 220px); }
|
||||
.message-column { max-width: 80%; }
|
||||
.send-btn { padding-inline: 14px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="contacts-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身人脉</h1>
|
||||
</div>
|
||||
<button class="add-btn" @click="addContact">+</button>
|
||||
</header>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<section class="search-section">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input v-model="keyword" class="search-input" placeholder="搜索人脉" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 人脉列表 -->
|
||||
<section class="contacts-section">
|
||||
<div class="contact-list" v-if="filteredContacts.length > 0">
|
||||
<div class="contact-item" v-for="c in filteredContacts" :key="c.id">
|
||||
<div class="contact-avatar">{{ c.emoji }}</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name-row">
|
||||
<span class="contact-name">{{ c.name }}</span>
|
||||
<span class="relation-tag" :class="c.relation">{{ relationText(c.relation) }}</span>
|
||||
</div>
|
||||
<span class="contact-last">最近互动:{{ c.last }}</span>
|
||||
</div>
|
||||
<button class="chat-btn" @click="openChat(c)">💬</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state" v-else>
|
||||
<span class="empty-icon">🤝</span>
|
||||
<p class="empty-text">还没有人脉,点击右上角添加</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="toast" v-if="toast">{{ toast }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const toast = ref('')
|
||||
|
||||
interface Contact { id: string; emoji: string; name: string; relation: string; last: string }
|
||||
const contacts = ref<Contact[]>([
|
||||
{ id: '1', emoji: '👩', name: '林小满', relation: 'friend', last: '今天 14:20' },
|
||||
{ id: '2', emoji: '🧑', name: '陈工', relation: 'colleague', last: '昨天 09:10' },
|
||||
{ id: '3', emoji: '👨', name: '王总', relation: 'client', last: '3 天前' },
|
||||
{ id: '4', emoji: '🧑', name: 'Anna', relation: 'friend', last: '上周' }
|
||||
])
|
||||
|
||||
const filteredContacts = computed(() =>
|
||||
contacts.value.filter(c => c.name.includes(keyword.value))
|
||||
)
|
||||
|
||||
const relationText = (r: string) => ({
|
||||
friend: '好友', colleague: '同事', client: '客户'
|
||||
}[r] || '好友')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const addContact = () => {
|
||||
const sample = ['👤', '🧑', '👩', '🧑']
|
||||
const names = ['新朋友', '合作伙伴', '张同学', '李经理']
|
||||
const relations = ['friend', 'colleague', 'client']
|
||||
const idx = contacts.value.length
|
||||
contacts.value.unshift({
|
||||
id: String(Date.now()),
|
||||
emoji: sample[idx % sample.length],
|
||||
name: names[idx % names.length],
|
||||
relation: relations[idx % relations.length],
|
||||
last: '刚刚添加'
|
||||
})
|
||||
toast.value = '已添加示例人脉'
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
const openChat = (c: Contact) => {
|
||||
toast.value = `打开与 ${c.name} 的对话(示例)`
|
||||
setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contacts-page { min-height: 100vh; background: #F8F9FA; padding-bottom: 40px; }
|
||||
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: linear-gradient(135deg, #F97316 0%, #FB923C 100%); color: white;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-btn { background: none; border: none; color: white; font-size: 24px; cursor: pointer; padding: 4px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.add-btn { background: rgba(255,255,255,0.2); border: none; color: white; font-size: 22px; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; }
|
||||
|
||||
.search-section { padding: 16px 20px; }
|
||||
.search-box { display: flex; align-items: center; gap: 8px; background: white; border-radius: 12px; padding: 10px 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.search-icon { font-size: 16px; }
|
||||
.search-input { flex: 1; border: none; outline: none; font-size: 14px; color: #18191C; background: transparent; }
|
||||
|
||||
.contacts-section { padding: 0 20px; }
|
||||
.contact-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.contact-item {
|
||||
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);
|
||||
}
|
||||
.contact-avatar {
|
||||
width: 44px; height: 44px; border-radius: 50%; background: #FFF0E6;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.contact-info { flex: 1; min-width: 0; }
|
||||
.contact-name-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.contact-name { font-size: 15px; font-weight: 600; color: #18191C; }
|
||||
.relation-tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.relation-tag.friend { background: #ECFDF5; color: #22C55E; }
|
||||
.relation-tag.colleague { background: #EFF6FF; color: #3B82F6; }
|
||||
.relation-tag.client { background: #FFF0E6; color: #F97316; }
|
||||
.contact-last { font-size: 12px; color: #9398AE; }
|
||||
.chat-btn {
|
||||
width: 38px; height: 38px; border-radius: 50%; border: none; background: #FFF0E6;
|
||||
font-size: 18px; cursor: pointer; flex-shrink: 0; transition: transform 0.2s;
|
||||
}
|
||||
.chat-btn:hover { transform: scale(1.08); }
|
||||
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 40px 20px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9398AE; margin: 0; }
|
||||
|
||||
.toast { text-align: center; font-size: 13px; color: #F97316; margin: 16px 20px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<div class="create-page">
|
||||
<!-- 步骤 0:引导落地页 -->
|
||||
<div v-if="step === 0" class="landing">
|
||||
<header class="hero">
|
||||
<div class="hero-icon">🤖</div>
|
||||
<h1 class="hero-title">创建你的数字分身</h1>
|
||||
<p class="hero-sub">AI 助手,为你工作、社交、创造价值</p>
|
||||
</header>
|
||||
|
||||
<section class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💬</div>
|
||||
<h3>智能对话</h3>
|
||||
<p>24/7 在线,自动回复消息、处理咨询</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📅</div>
|
||||
<h3>日程管理</h3>
|
||||
<p>智能安排会议、提醒重要事项</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🤝</div>
|
||||
<h3>社交助手</h3>
|
||||
<p>管理人脉、扩展社交圈</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<button class="create-btn" @click="step = 1">
|
||||
<span class="btn-icon">✨</span> 开始创建
|
||||
</button>
|
||||
<p class="hint">只需 4 步,快速拥有你的 AI 分身</p>
|
||||
</section>
|
||||
|
||||
<section class="existing" v-if="avatarStore.avatars.length > 0">
|
||||
<p>已有数字分身?</p>
|
||||
<button class="link-btn" @click="goManage">前往管理 →</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 1-4:创建向导 -->
|
||||
<div v-else class="wizard">
|
||||
<header class="wizard-header">
|
||||
<button class="back" @click="prev">‹</button>
|
||||
<h2 class="w-title">{{ stepTitles[step - 1] }}</h2>
|
||||
<span class="step-count">{{ step }}/4</span>
|
||||
</header>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: (step / 4 * 100) + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
<!-- Step 1:基础信息 -->
|
||||
<div v-if="step === 1" class="form">
|
||||
<div class="field">
|
||||
<label class="field-label">分身头像</label>
|
||||
<div class="avatar-pick">
|
||||
<div class="avatar-pick-preview">
|
||||
<img v-if="form.photoUrl" :src="form.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ form.emoji }}</span>
|
||||
</div>
|
||||
<p class="avatar-pick-hint">已沿用你的会会头像,也可在下一步选择表情形象</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">分身名称 <span class="req">*</span></label>
|
||||
<input v-model="form.name" class="input" placeholder="如:我的数字分身" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">显示名称</label>
|
||||
<input v-model="form.displayName" class="input" placeholder="如:会会助手" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">分身描述</label>
|
||||
<textarea v-model="form.description" class="textarea" rows="3" placeholder="描述它的功能与特点"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2:形象风格 -->
|
||||
<div v-else-if="step === 2" class="form">
|
||||
<p class="field-label">选择形象</p>
|
||||
|
||||
<!-- 默认:沿用当前用户的会会头像 -->
|
||||
<div
|
||||
v-if="userAvatarUrl"
|
||||
class="avatar-photo-option"
|
||||
:class="{ active: usePhoto }"
|
||||
@click="selectMyAvatar"
|
||||
>
|
||||
<div class="apo-thumb">
|
||||
<img :src="userAvatarUrl" alt="" referrerpolicy="no-referrer" />
|
||||
</div>
|
||||
<div class="apo-meta">
|
||||
<span class="apo-title">使用我的头像</span>
|
||||
<span class="apo-sub">沿用你的会会账号头像</span>
|
||||
</div>
|
||||
<span class="apo-check">✓</span>
|
||||
</div>
|
||||
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="e in emojis"
|
||||
:key="e"
|
||||
class="emoji-opt"
|
||||
:class="{ active: !usePhoto && form.emoji === e }"
|
||||
@click="selectEmoji(e)"
|
||||
>{{ e }}</button>
|
||||
</div>
|
||||
<p class="field-label">回复风格</p>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="s in styles"
|
||||
:key="s.value"
|
||||
class="seg-btn"
|
||||
:class="{ active: form.replyStyle === s.value }"
|
||||
@click="form.replyStyle = s.value"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3:灵魂配置 -->
|
||||
<div v-else-if="step === 3" class="form">
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>创造力</span><b>{{ form.creativity }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.creativity" class="range" />
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>严谨度</span><b>{{ form.rigor }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.rigor" class="range" />
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<div class="slider-head"><span>幽默感</span><b>{{ form.humor }}</b></div>
|
||||
<input type="range" min="0" max="100" v-model.number="form.humor" class="range" />
|
||||
</div>
|
||||
<p class="field-label">回复长度</p>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="l in lengths"
|
||||
:key="l.value"
|
||||
class="seg-btn"
|
||||
:class="{ active: form.responseLength === l.value }"
|
||||
@click="form.responseLength = l.value"
|
||||
>{{ l.label }}</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">系统提示词(选填)</label>
|
||||
<textarea v-model="form.systemPrompt" class="textarea" rows="3" placeholder="给分身的额外指令,如:语气要专业、回答要简洁"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4:确认创建 -->
|
||||
<div v-else class="confirm">
|
||||
<div class="confirm-avatar">
|
||||
<img v-if="usePhoto && form.photoUrl" :src="form.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||||
<span v-else>{{ form.emoji }}</span>
|
||||
</div>
|
||||
<h3 class="confirm-name">{{ form.displayName || form.name || '未命名分身' }}</h3>
|
||||
<p class="confirm-desc">{{ form.description || '暂无描述' }}</p>
|
||||
<ul class="confirm-list">
|
||||
<li><span>回复风格</span><b>{{ styleLabel }}</b></li>
|
||||
<li><span>创造力 / 严谨度 / 幽默感</span><b>{{ form.creativity }} / {{ form.rigor }} / {{ form.humor }}</b></li>
|
||||
<li><span>回复长度</span><b>{{ lengthLabel }}</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="wizard-footer">
|
||||
<button v-if="step > 1" class="btn-secondary" @click="prev">上一步</button>
|
||||
<button v-if="step < 4" class="btn-primary" :disabled="!canNext" @click="next">下一步</button>
|
||||
<button v-else class="btn-primary" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? '创建中...' : '创建分身' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { getCurrentUser } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const avatarStore = useAvatarStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const step = ref(0)
|
||||
const stepTitles = ['基础信息', '形象风格', '灵魂配置', '确认创建']
|
||||
const submitting = ref(false)
|
||||
|
||||
const emojis = ['🤖', '😊', '🦊', '🐱', '🦁', '🐼', '👩💻', '🧑🚀', '💡', '🌟']
|
||||
const styles = [
|
||||
{ value: 'professional', label: '专业' },
|
||||
{ value: 'casual', label: '轻松' },
|
||||
{ value: 'friendly', label: '亲切' }
|
||||
]
|
||||
const lengths = [
|
||||
{ value: 'short', label: '简短' },
|
||||
{ value: 'medium', label: '适中' },
|
||||
{ value: 'long', label: '详细' }
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
displayName: '',
|
||||
photoUrl: '',
|
||||
description: '',
|
||||
emoji: '🤖',
|
||||
replyStyle: 'professional',
|
||||
creativity: 50,
|
||||
rigor: 50,
|
||||
humor: 30,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: ''
|
||||
})
|
||||
|
||||
const styleLabel = computed(() => styles.find(s => s.value === form.replyStyle)?.label || '')
|
||||
const lengthLabel = computed(() => lengths.find(l => l.value === form.responseLength)?.label || '')
|
||||
|
||||
// 当前登录用户的会会头像(用于「形象选择」默认项)
|
||||
// 优先采用真实登录态 userStore.user,避免被空的 userProfile 覆盖
|
||||
const userAvatarUrl = computed(() => {
|
||||
const u = userStore.user
|
||||
const p = avatarStore.userProfile
|
||||
return ((u?.avatarUrl as string) || (p?.avatarUrl as string) || '')
|
||||
})
|
||||
|
||||
// 形象选择:默认沿用当前用户的会会头像;选中表情后切换为 emoji 模式
|
||||
const usePhoto = ref(false)
|
||||
const selectMyAvatar = () => {
|
||||
usePhoto.value = true
|
||||
form.photoUrl = userAvatarUrl.value
|
||||
}
|
||||
const selectEmoji = (e: string) => {
|
||||
form.emoji = e
|
||||
usePhoto.value = false
|
||||
form.photoUrl = ''
|
||||
}
|
||||
|
||||
const canNext = computed(() => {
|
||||
if (step.value === 1) return form.name.trim().length > 0
|
||||
return true
|
||||
})
|
||||
|
||||
const next = () => { if (step.value < 4) step.value++ }
|
||||
const prev = () => { if (step.value === 1) step.value = 0; else step.value-- }
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await avatarStore.addAvatar({
|
||||
name: form.name,
|
||||
displayName: form.displayName || form.name,
|
||||
photoUrl: usePhoto.value ? form.photoUrl : '',
|
||||
description: form.description,
|
||||
emoji: form.emoji,
|
||||
config: {
|
||||
replyStyle: form.replyStyle,
|
||||
creativity: form.creativity,
|
||||
rigor: form.rigor,
|
||||
humor: form.humor,
|
||||
responseLength: form.responseLength,
|
||||
systemPrompt: form.systemPrompt
|
||||
}
|
||||
})
|
||||
router.push('/avatar/manage')
|
||||
} catch (e) {
|
||||
alert('创建失败,请稍后重试')
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 预填会会登录用户的名称与头像(沿用当前账号资料)
|
||||
// 优先采用真实登录态 userStore.user(持久化会话),避免被空资料 / mock 覆盖
|
||||
onMounted(async () => {
|
||||
userStore.loadFromStorage()
|
||||
const real = userStore.user
|
||||
const native = avatarStore.userProfile
|
||||
let nick = real?.nickname || native?.nickname || ''
|
||||
let avatar = real?.avatarUrl || native?.avatarUrl || ''
|
||||
// 兜底:本地资料为空时,向服务端 /huihui/me 拉取真实登录态(Bearer app_token)
|
||||
if ((!nick || !avatar) && userStore.isLogin) {
|
||||
try {
|
||||
const me2: any = await getCurrentUser()
|
||||
nick = nick || me2?.nickname || ''
|
||||
avatar = avatar || me2?.avatarUrl || ''
|
||||
} catch { /* 忽略,保持本地值 */ }
|
||||
}
|
||||
if (nick) {
|
||||
if (!form.displayName) form.displayName = nick
|
||||
if (!form.name) form.name = nick
|
||||
}
|
||||
if (avatar && !form.photoUrl) form.photoUrl = avatar
|
||||
// 「形象选择」默认使用当前用户的会会头像
|
||||
if (userAvatarUrl.value) usePhoto.value = true
|
||||
})
|
||||
|
||||
const goManage = () => router.push('/avatar/manage')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
}
|
||||
|
||||
/* ===== 落地页 ===== */
|
||||
.landing { padding-bottom: 40px; }
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 50%, #FDBA74 100%);
|
||||
padding: 60px 20px 80px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100% { transform: scale(1);} 50% { transform: scale(1.1);} }
|
||||
.hero-title { font-size: 28px; font-weight: 700; margin: 0 0 12px; letter-spacing: 1px; }
|
||||
.hero-sub { font-size: 16px; opacity: 0.9; margin: 0; }
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
margin-top: -40px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.feature-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.feature-icon { font-size: 36px; margin-bottom: 12px; }
|
||||
.feature-card h3 { font-size: 15px; font-weight: 600; color: #18191C; margin: 0 0 8px; }
|
||||
.feature-card p { font-size: 12px; color: #9398AE; margin: 0; line-height: 1.4; }
|
||||
|
||||
.cta { padding: 30px 20px; text-align: center; }
|
||||
.create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 48px;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(249,115,22,0.4);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.create-btn:active { transform: scale(0.97); }
|
||||
.hint { font-size: 13px; color: #9398AE; margin-top: 16px; }
|
||||
|
||||
.existing { text-align: center; padding: 10px; }
|
||||
.existing p { font-size: 14px; color: #9398AE; margin: 0 0 12px; }
|
||||
.link-btn {
|
||||
background: none; border: 2px solid #F97316; color: #F97316;
|
||||
padding: 10px 24px; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.link-btn:hover { background: #F97316; color: white; }
|
||||
|
||||
/* ===== 向导 ===== */
|
||||
.wizard { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.wizard-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; background: white; border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
.back { background: none; border: none; font-size: 26px; cursor: pointer; color: #18191C; padding: 0 8px; }
|
||||
.w-title { font-size: 17px; font-weight: 600; margin: 0; color: #18191C; }
|
||||
.step-count { font-size: 13px; color: #9398AE; }
|
||||
|
||||
.progress { height: 4px; background: #EDEEF1; }
|
||||
.progress-bar { height: 100%; background: linear-gradient(90deg, #F97316, #FB923C); transition: width 0.35s ease; }
|
||||
|
||||
.wizard-body { flex: 1; padding: 24px 20px; }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field-label { font-size: 14px; font-weight: 500; color: #18191C; }
|
||||
.req { color: #EF4444; }
|
||||
|
||||
.avatar-pick { display: flex; flex-direction: column; gap: 10px; }
|
||||
.avatar-pick-preview {
|
||||
width: 72px; height: 72px; border-radius: 50%; overflow: hidden;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 36px; box-shadow: 0 4px 12px rgba(249,115,22,0.3);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
.avatar-pick-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar-pick-hint { font-size: 12px; color: #9398AE; margin: 0; }
|
||||
.input, .textarea {
|
||||
width: 100%; padding: 12px 16px; border: 1px solid #EDEEF1; border-radius: 10px;
|
||||
font-size: 15px; color: #18191C; background: white; font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input:focus, .textarea:focus { outline: none; border-color: #F97316; }
|
||||
.textarea { resize: vertical; }
|
||||
|
||||
.emoji-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
|
||||
.emoji-opt {
|
||||
aspect-ratio: 1; font-size: 28px; border: 2px solid #EDEEF1; border-radius: 12px;
|
||||
background: white; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.emoji-opt.active { border-color: #F97316; background: #FFF0E6; }
|
||||
|
||||
/* 形象选择:默认沿用会会头像 */
|
||||
.avatar-photo-option {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 16px; border: 2px solid #EDEEF1; border-radius: 14px;
|
||||
background: white; cursor: pointer; transition: all 0.2s; margin-bottom: 18px;
|
||||
}
|
||||
.avatar-photo-option.active { border-color: #F97316; background: #FFF0E6; }
|
||||
.apo-thumb {
|
||||
width: 52px; height: 52px; border-radius: 50%; overflow: hidden; flex: 0 0 auto;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
border: 2px solid #fff; box-shadow: 0 2px 8px rgba(249,115,22,0.3);
|
||||
}
|
||||
.apo-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.apo-meta { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.apo-title { font-size: 15px; font-weight: 600; color: #18191C; }
|
||||
.apo-sub { font-size: 12px; color: #9398AE; }
|
||||
.apo-check { font-size: 18px; color: #F97316; opacity: 0; transition: opacity 0.2s; flex: 0 0 auto; }
|
||||
.avatar-photo-option.active .apo-check { opacity: 1; }
|
||||
|
||||
.seg { display: flex; gap: 12px; }
|
||||
.seg-btn {
|
||||
flex: 1; padding: 12px; border: 2px solid #EDEEF1; border-radius: 10px; background: white;
|
||||
font-size: 14px; color: #9398AE; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.seg-btn.active { border-color: #F97316; color: #F97316; background: #FFF0E6; }
|
||||
|
||||
.slider-item { display: flex; flex-direction: column; gap: 10px; }
|
||||
.slider-head { display: flex; justify-content: space-between; font-size: 14px; color: #18191C; }
|
||||
.slider-head b { color: #F97316; }
|
||||
.range { -webkit-appearance: none; width: 100%; height: 6px; border-radius: 3px; background: #EDEEF1; outline: none; }
|
||||
.range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 22px; height: 22px; border-radius: 50%;
|
||||
background: #F97316; cursor: pointer; box-shadow: 0 2px 6px rgba(249,115,22,0.4);
|
||||
}
|
||||
|
||||
.confirm { display: flex; flex-direction: column; align-items: center; gap: 12px; padding-top: 10px; }
|
||||
.confirm-avatar {
|
||||
width: 88px; height: 88px; border-radius: 50%; overflow: hidden;
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 44px;
|
||||
box-shadow: 0 4px 12px rgba(249,115,22,0.3);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
.confirm-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.confirm-name { font-size: 20px; font-weight: 700; color: #18191C; margin: 0; }
|
||||
.confirm-desc { font-size: 14px; color: #9398AE; margin: 0; text-align: center; }
|
||||
.confirm-list { width: 100%; list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.confirm-list li {
|
||||
display: flex; justify-content: space-between; padding: 14px 16px; background: white;
|
||||
border-radius: 10px; font-size: 13px; color: #9398AE; box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.confirm-list li b { color: #18191C; font-weight: 600; }
|
||||
|
||||
.wizard-footer {
|
||||
display: flex; gap: 12px; padding: 16px 20px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: white; border-top: 1px solid #EDEEF1;
|
||||
}
|
||||
.btn-secondary {
|
||||
flex: 0 0 auto; padding: 14px 24px; background: white; color: #18191C;
|
||||
border: 2px solid #EDEEF1; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
flex: 1; padding: 14px; background: linear-gradient(135deg, #F97316, #FB923C); color: white;
|
||||
border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(249,115,22,0.3); transition: opacity 0.2s;
|
||||
}
|
||||
.btn-primary:disabled { background: #D1D5DB; box-shadow: none; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<div class="edit-avatar-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header v-if="!isEmbedded" class="page-header">
|
||||
<button class="back-btn" @click="goBack">‹</button>
|
||||
<h1 class="page-title">分身微调</h1>
|
||||
<span class="header-spacer" aria-hidden="true"></span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="status-banner">加载中...</div>
|
||||
<div v-else-if="errorMsg" class="status-banner error">{{ errorMsg }}</div>
|
||||
|
||||
<!-- 头像预览 -->
|
||||
<section class="photo-section">
|
||||
<label class="photo-container" for="avatar-photo-input">
|
||||
<div class="photo-preview">
|
||||
<img v-if="formData.photoUrl" :src="formData.photoUrl" alt="" class="photo-image" referrerpolicy="no-referrer" />
|
||||
<div v-else class="photo-placeholder">🤖</div>
|
||||
<span class="photo-edit-mark">更换</span>
|
||||
</div>
|
||||
<span class="photo-hint">{{ uploadingPhoto ? '头像上传中...' : '点击头像上传新图片' }}</span>
|
||||
</label>
|
||||
<input id="avatar-photo-input" class="photo-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" :disabled="uploadingPhoto" @change="selectPhoto" />
|
||||
</section>
|
||||
|
||||
<!-- 基本信息表单 -->
|
||||
<section class="form-section">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">分身名称</label>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
class="form-input"
|
||||
placeholder="请输入分身名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">显示名称</label>
|
||||
<input
|
||||
v-model="formData.displayName"
|
||||
class="form-input"
|
||||
placeholder="请输入显示名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">分身描述</label>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
class="form-textarea"
|
||||
placeholder="描述你的数字分身的功能和特点"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">职业</label>
|
||||
<input v-model="formData.profession" class="form-input" placeholder="例如:医生、律师、产品经理" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">职位</label>
|
||||
<input v-model="formData.position" class="form-input" placeholder="例如:主任医师、部门负责人" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">单位</label>
|
||||
<input v-model="formData.organization" class="form-input" placeholder="请输入所在单位" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">单位地址</label>
|
||||
<input v-model="formData.organizationAddress" class="form-input" placeholder="请输入单位详细地址" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">状态</label>
|
||||
<div class="status-selector">
|
||||
<button
|
||||
class="status-option"
|
||||
:class="{ active: formData.status === 'active' }"
|
||||
@click="formData.status = 'active'"
|
||||
>
|
||||
活跃中
|
||||
</button>
|
||||
<button
|
||||
class="status-option"
|
||||
:class="{ active: formData.status === 'inactive' }"
|
||||
@click="formData.status = 'inactive'"
|
||||
>
|
||||
未激活
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 高级设置 -->
|
||||
<section class="form-section">
|
||||
<h3 class="section-title">高级设置</h3>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">回复风格</label>
|
||||
<select v-model="formData.replyStyle" class="form-select">
|
||||
<option value="professional">专业正式</option>
|
||||
<option value="casual">轻松随意</option>
|
||||
<option value="friendly">友好亲切</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<div class="slider-head"><label class="form-label">创造力</label><b>{{ formData.creativity }}</b></div>
|
||||
<input v-model.number="formData.creativity" type="range" min="0" max="100" class="range" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<div class="slider-head"><label class="form-label">严谨度</label><b>{{ formData.rigor }}</b></div>
|
||||
<input v-model.number="formData.rigor" type="range" min="0" max="100" class="range" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<div class="slider-head"><label class="form-label">幽默感</label><b>{{ formData.humor }}</b></div>
|
||||
<input v-model.number="formData.humor" type="range" min="0" max="100" class="range" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">回复长度</label>
|
||||
<div class="choice-row">
|
||||
<button v-for="item in responseLengths" :key="item.value" type="button" class="choice-btn" :class="{ active: formData.responseLength === item.value }" @click="formData.responseLength = item.value">{{ item.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">系统提示词</label>
|
||||
<textarea v-model="formData.systemPrompt" class="form-textarea" rows="4" placeholder="给分身的额外指令,例如语气、禁用内容或回答边界"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">自动回复</label>
|
||||
<div class="toggle-container">
|
||||
<span class="toggle-label">{{ formData.autoReply ? '开启' : '关闭' }}</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: formData.autoReply }"
|
||||
@click="formData.autoReply = !formData.autoReply"
|
||||
>
|
||||
<span class="toggle-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 危险操作 -->
|
||||
<section class="danger-section">
|
||||
<button class="delete-btn" :disabled="loading || deleting" @click="deleteAvatar">
|
||||
{{ deleting ? '删除中...' : '删除数字分身' }}
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
||||
import { useAvatarStore } from '@/store/avatar'
|
||||
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
||||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const avatarStore = useAvatarStore()
|
||||
const avatarId = route.params.id as string
|
||||
const isEmbedded = isHuihuiEmbeddedMode()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
photoUrl: '',
|
||||
replyStyle: 'professional',
|
||||
creativity: 50,
|
||||
rigor: 50,
|
||||
humor: 30,
|
||||
responseLength: 'medium',
|
||||
systemPrompt: '',
|
||||
profession: '',
|
||||
position: '',
|
||||
organization: '',
|
||||
organizationAddress: '',
|
||||
autoReply: true
|
||||
})
|
||||
|
||||
const responseLengths = [
|
||||
{ value: 'short', label: '简短' },
|
||||
{ value: 'medium', label: '适中' },
|
||||
{ value: 'long', label: '详细' }
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const uploadingPhoto = ref(false)
|
||||
const deleting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const loadAvatar = async () => {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const avatar: any = await getAvatarDetail(avatarId)
|
||||
Object.assign(formData, normalizeAvatarEditForm(avatar))
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '分身加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectPhoto = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || uploadingPhoto.value) return
|
||||
uploadingPhoto.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const result = await uploadAvatarPhoto(avatarId, file)
|
||||
formData.photoUrl = result.photoUrl
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '头像上传失败,请重试'
|
||||
} finally {
|
||||
uploadingPhoto.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存修改
|
||||
const saveChanges = async () => {
|
||||
if (loading.value || saving.value) return
|
||||
if (!formData.name.trim()) {
|
||||
errorMsg.value = '请输入分身名称'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await updateAvatar(avatarId, buildAvatarUpdatePayload(formData))
|
||||
await avatarStore.loadAvatars()
|
||||
router.replace('/avatar/manage')
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '保存失败,请重试'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分身
|
||||
const deleteAvatar = async () => {
|
||||
if (loading.value || deleting.value) return
|
||||
if (confirm('确定要删除这个数字分身吗?此操作不可恢复。')) {
|
||||
deleting.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
await apiDeleteAvatar(avatarId)
|
||||
await avatarStore.loadAvatars()
|
||||
router.replace('/avatar/manage')
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.message || '删除失败,请重试'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAvatar()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edit-avatar-page {
|
||||
min-height: 100vh;
|
||||
background: #F8F9FA;
|
||||
padding-bottom: calc(104px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #EDEEF1;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
margin: 16px 20px 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #FFF7ED;
|
||||
color: #9A3412;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-banner.error {
|
||||
background: #FEF2F2;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.header-spacer { width: 40px; }
|
||||
|
||||
/* 头像上传 */
|
||||
.photo-section {
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-placeholder {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.photo-hint {
|
||||
font-size: 13px;
|
||||
color: #F97316;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.photo-input { display: none; }
|
||||
.photo-edit-mark {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 5px 0 7px;
|
||||
color: white;
|
||||
background: rgba(47, 26, 16, .68);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 表单区域 */
|
||||
.form-section {
|
||||
padding: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #18191C;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #EDEEF1;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: #18191C;
|
||||
background: white;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #EDEEF1;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: #18191C;
|
||||
background: white;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #F97316;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #EDEEF1;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: #18191C;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.slider-head .form-label { margin-bottom: 0; }
|
||||
.slider-head b { color: #F97316; font-size: 14px; }
|
||||
.range { width: 100%; accent-color: #F97316; }
|
||||
|
||||
.choice-row { display: flex; gap: 8px; }
|
||||
.choice-btn {
|
||||
flex: 1; padding: 10px 8px; border: 1px solid #EDEEF1; border-radius: 10px;
|
||||
background: white; color: #6B7280; cursor: pointer;
|
||||
}
|
||||
.choice-btn.active { border-color: #F97316; color: #F97316; background: #FFF0E6; }
|
||||
|
||||
/* 状态选择器 */
|
||||
.status-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-option {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 2px solid #EDEEF1;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
color: #9398AE;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-option.active {
|
||||
border-color: #F97316;
|
||||
color: #F97316;
|
||||
background: #FFF0E6;
|
||||
}
|
||||
|
||||
/* 开关切换 */
|
||||
.toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #EDEEF1;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 15px;
|
||||
color: #18191C;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
background: #D1D5DB;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.3s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: #F97316;
|
||||
}
|
||||
|
||||
.toggle-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.toggle-btn.active .toggle-dot {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* 危险操作 */
|
||||
.danger-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: white;
|
||||
color: #EF4444;
|
||||
border: 2px solid #EF4444;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #EF4444;
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user