Compare commits

...
Author SHA1 Message Date
stefanfeng 434caac056 feat(avatar): honor square interaction authorization 2026-09-08 13:19:50 +08:00
stefanfeng 2a01a9946a Merge pull request 'fix(avatar): 修复知识库重新索引操作' (#19) from codex/avatar-reindex-action-20260908 into main
Reviewed-on: #19
2026-09-08 09:19:32 +08:00
stefanfeng 2ce1079bb6 fix(avatar): repair knowledge reindex action 2026-09-08 09:06:33 +08:00
stefanfeng 6fba6dbaaa Merge pull request 'fix(avatar): 分片上传大文件知识库' (#18) from codex/avatar-chunk-upload-20260907 into main
Reviewed-on: #18
2026-09-07 17:48:15 +08:00
stefanfeng e71267cf86 fix(avatar): upload knowledge files in chunks 2026-09-07 17:45:22 +08:00
stefanfeng 359e558dbe Merge pull request 'feat(avatar): 多文件知识库上传与进度展示' (#17) from codex/avatar-upload-progress-20260904 into main
Reviewed-on: #17
2026-09-04 17:33:05 +08:00
stefanfeng 3edf92c7cc feat(avatar): show multi-file knowledge upload progress 2026-09-04 16:40:16 +08:00
stefanfeng 97c4c73b58 Merge pull request 'fix(avatar): 异步知识库索引并修复大文件上传' (#16) from codex/avatar-knowledge-async-20260904 into main
Reviewed-on: #16
2026-09-04 15:59:11 +08:00
stefanfeng 08c58fe0e6 fix(avatar): cap knowledge files at 50MB 2026-09-04 15:18:39 +08:00
stefanfeng 6b7201e890 fix(avatar): align knowledge upload limit with production 2026-09-04 13:56:29 +08:00
stefanfeng 28553aba15 fix(avatar): allow knowledge uploads up to 20MB 2026-09-04 13:47:46 +08:00
stefanfeng 95f91450d0 Merge pull request 'fix(avatar): index knowledge documents asynchronously' (#15) from codex/avatar-knowledge-async-20260904 into main
Reviewed-on: #15
2026-09-04 11:56:01 +08:00
stefanfeng b98a2b9507 fix(avatar): index knowledge documents asynchronously 2026-09-04 11:53:56 +08:00
stefanfeng 59350fb41d Merge pull request 'fix(avatar): ground replies in recognized images' (#14) from codex/avatar-image-answer-hotfix-20260902 into main
Reviewed-on: #14
2026-09-02 14:49:15 +08:00
stefanfeng 6a4b35c49a fix(avatar): ground replies in recognized images 2026-09-02 14:47:27 +08:00
stefanfeng 207bbd02cf Merge pull request 'fix(avatar): recover BOXIM image replies' (#13) from codex/avatar-boxim-vision-hotfix-20260902 into main
Reviewed-on: #13
2026-09-02 14:13:19 +08:00
stefanfeng 7cac96356d fix(avatar): recover BOXIM image replies 2026-09-02 14:12:04 +08:00
stefanfeng 03c32309a8 Merge pull request 'feat(avatar): understand BOXIM image messages' (#12) from codex/avatar-boxim-vision-20260901 into main
Merge pull request #12: BOXIM image understanding
2026-09-01 15:36:19 +08:00
stefanfeng 0fc43908ae feat(avatar): understand BOXIM image messages 2026-09-01 15:35:41 +08:00
stefanfeng 3d999f9472 Merge pull request 'fix(avatar): prevent BOXIM polling starvation' (#11) from codex/avatar-takeover-poll-20260901 into main 2026-09-01 14:04:18 +08:00
stefanfeng 540edb58c4 fix(avatar): prevent BOXIM polling starvation 2026-09-01 14:03:35 +08:00
stefanfeng a7eb6ac2a5 Merge pull request #10 from codex/avatar-vision-chat-20260831
feat(avatar): 支持图片与病例理解对话
2026-09-01 10:41:51 +08:00
stefanfeng 016bc22c05 feat(avatar): add private vision chat support 2026-08-31 15:10:50 +08:00
stefanfeng 094f8cd40f Merge pull request 'fix(avatar): normalize embedding API endpoint' (#9) from codex/avatar-embedding-endpoint-20260828 into main
Reviewed-on: #9
2026-08-28 17:00:16 +08:00
stefanfeng 6794e88d53 fix(avatar): normalize embedding API endpoint 2026-08-28 16:59:22 +08:00
stefanfeng 7884430b3d Merge pull request 'feat(avatar): 优化会会 H5 嵌入管理流程' (#8) from codex/avatar-h5-embedded-layout-20260827 into main
Reviewed-on: #8
2026-08-27 11:41:21 +08:00
stefanfeng 46d42b7d98 fix(avatar): prevent takeover loops and isolate settings 2026-08-27 11:30:12 +08:00
stefanfeng ef58c5f2d2 feat(avatar): optimize embedded H5 management flow 2026-08-27 09:28:05 +08:00
stefanfeng 6e3fe5a616 Merge pull request 'feat(avatar): 接入会会支付并统一积分展示' (#7) from codex/avatar-token-copy-to-points-20260826 into main
Reviewed-on: #7
2026-08-26 14:53:58 +08:00
stefanfeng 67b6bd1b48 Merge pull request 'fix(avatar): 部署重启后自动恢复 BOXIM 接管' (#6) from codex/avatar-takeover-restart-safe-20260826 into main
Reviewed-on: #6
2026-08-26 14:53:49 +08:00
stefanfeng 0752001d85 Merge pull request 'feat(avatar): 数字分身自动跟随用户语言回答' (#5) from codex/avatar-auto-reply-language-20260826 into main
Reviewed-on: #5
2026-08-26 14:53:40 +08:00
stefanfeng c37294be17 feat(avatar): integrate Huihui payments 2026-08-26 14:41:21 +08:00
stefanfeng 5360cac8ad fix(avatar): normalize legacy plan copy 2026-08-26 14:02:38 +08:00
stefanfeng ea932f27fb feat(avatar): rename Token display to points 2026-08-26 14:01:12 +08:00
deploy bf0183bef4 add wechat mini-program domain verification file 2026-08-26 13:44:56 +08:00
stefanfeng 0c6419f37e fix(avatar): restore BOXIM takeover after restart 2026-08-26 13:25:16 +08:00
stefanfeng f768e7648f fix(avatar): prevent inferred reply scenarios 2026-08-26 11:56:54 +08:00
stefanfeng e30ab2b889 feat(avatar): follow user language in replies 2026-08-26 11:52:32 +08:00
stefanfeng 730f586784 Merge pull request 'docs(avatar): 配置 digital.99hui.com 生产域名' (#4) from codex/avatar-multi-management-integrated-20260825 into main
Reviewed-on: #4
2026-08-26 10:17:40 +08:00
stefanfeng ef2b1c6dd6 chore(avatar): add WeChat verification file 2026-08-26 09:51:13 +08:00
stefanfeng c693899b12 docs(avatar): set production H5 domain 2026-08-26 09:44:22 +08:00
stefanfeng d274ccb5e2 Merge pull request 'feat: 完成数字分身多分身管理与生产 H5 接入' (#3) from codex/avatar-multi-management-integrated-20260825 into main
Reviewed-on: #3
2026-08-26 09:43:52 +08:00
stefanfeng 5d19992f00 fix(avatar): remove SSO token from router state 2026-08-26 09:25:07 +08:00
stefanfeng 81aec1c63a feat(avatar): support production H5 token SSO 2026-08-26 09:17:41 +08:00
stefanfeng 4029c31ed7 fix(avatar): bundle uni bridge and add favicon 2026-08-25 17:19:30 +08:00
stefanfeng e24e89d326 fix(deploy): serialize model migration and pin nginx 2026-08-25 16:59:15 +08:00
stefanfeng dc34a03357 feat(ai): add dedicated digital avatar model config 2026-08-25 16:50:33 +08:00
stefanfeng 3f7ff9329a fix(avatar): align QA cards to the left 2026-08-25 15:36:49 +08:00
stefanfeng 699bbbde57 feat(avatar): add user token accounting 2026-08-25 13:24:02 +08:00
stefanfeng 7a0199e685 feat(avatar): improve multi-avatar management 2026-08-25 11:44:27 +08:00
stefanfeng 672019830d fix(avatar): prevent takeover replies blocking across chats 2026-08-21 16:21:00 +08:00
stefanfeng e720baa21e fix(avatar): batch knowledge embedding requests 2026-08-21 13:21:05 +08:00
stefanfeng 9e86cc64ac Merge pull request 'Codex/avatar integrated 20260819' (#2) from codex/avatar-integrated-20260819 into main
Reviewed-on: #2
2026-08-21 09:31:43 +08:00
stefanfeng e2b928273c fix(avatar): acknowledge BOXIM messages as read 2026-08-20 17:10:54 +08:00
stefanfeng 64b7680ec4 fix(avatar): keep BOXIM status ready during long polls 2026-08-20 15:58:07 +08:00
stefanfeng 89f52963b7 fix(avatar): persist takeover toggle immediately 2026-08-20 15:55:12 +08:00
stefanfeng 76bd22c24b fix(avatar): preserve ownership across Huihui environments 2026-08-20 15:27:29 +08:00
stefanfeng 51a317ccd9 fix(avatar): fail closed on BOXIM connection errors 2026-08-20 14:47:13 +08:00
stefanfeng 08590bf9ea fix(avatar): refresh BOXIM takeover status 2026-08-19 18:03:59 +08:00
stefanfeng 25fb8fbee5 feat(avatar): add BOXIM chat takeover 2026-08-19 17:56:57 +08:00
stefanfeng cfcfe7146e feat(avatar): align authorization page with design 2026-08-19 17:05:22 +08:00
stefanfeng 2e2adeb9e2 feat(avatar): complete authorization management 2026-08-19 16:21:19 +08:00
stefanfeng 25d2494616 fix: surface missing knowledge files 2026-08-19 15:13:22 +08:00
stefanfeng bd5f64d000 fix: align avatar route titles 2026-08-19 15:09:38 +08:00
stefanfeng 350df1d119 build: pin secure frontend transitive dependencies 2026-08-19 15:01:39 +08:00
stefanfeng 64462fac92 build: enforce locked frontend type checks 2026-08-19 14:57:40 +08:00
stefanfeng 6bf446f889 fix: restore reproducible frontend builds 2026-08-19 14:56:39 +08:00
stefanfeng 68ea87e1b2 fix: persist avatar data and uploads 2026-08-19 14:44:58 +08:00
stefanfeng c5bfa47a23 fix: await takeover polling jobs 2026-08-19 14:32:10 +08:00
stefanfeng 4ab7732da9 fix: preserve takeover and share token integrity 2026-08-19 14:23:43 +08:00
stefanfeng 4a2d788e85 feat: complete grounded digital avatar chat experience 2026-08-19 14:21:56 +08:00
stefanfeng 07d4a21379 fix: sync Huihui app user homepage profile 2026-08-18 08:48:20 +08:00
stefanfeng efc419e301 fix: sync virtual user profiles to Huihui public pages 2026-08-18 08:48:20 +08:00
stefanfeng c748950402 fix: accept camelCase authorizationId from frontend 2026-08-07 17:18:34 +08:00
stefanfeng f13d488f82 feat: add takeover config API endpoint
PUT /api/avatar/{avatar_id}/authorizations/takeover to update authorization
takeover settings (enabled, mode, delay_seconds) with validation.
2026-08-07 17:15:12 +08:00
stefanfeng 43f2ad9edb feat: add takeover config UI to authorization management page
- Added updateTakeoverConfig API function (PUT /api/avatar/{id}/authorizations/takeover)
- Added expandable takeover config section per auth card with toggle switch, mode selector (immediate/delayed), and delay seconds input
- Added takeover summary badge showing current config when not editing
- Styles follow existing light theme with indigo (#6366F1) accent
2026-08-07 17:07:21 +08:00
stefanfeng 8e365e63e1 feat: add scheduled message polling for avatar takeover
Add APScheduler BackgroundScheduler to on_startup that polls
takeover_service.poll_and_process_messages every 10 seconds. Added
redis and apscheduler dependencies. Added poll_and_process_messages,
fetch_unread_messages, and process_message methods to TakeoverService.
Scheduler initialization is graceful — Redis/Box IM failures do not
prevent app startup.
2026-08-07 17:01:04 +08:00
stefanfeng dbd668e451 fix: resolve owner through Avatar model in takeover service
- Fix C1: check_takeover_enabled now uses owner_huihui_id to query Avatar first
- Fix C2: execute_takeover resolves owner_id via Avatar model (Authorization has no owner_id)
- Fix I1: add test for None case (no Avatar found)
- Fix I2: add test verifying filter arguments
- Fix I3: implement process_delayed_queue to scan and dispatch expired messages
2026-08-07 16:55:25 +08:00
stefanfeng 190ec48d9c feat: add takeover service core logic
Implement TakeoverService for avatar takeover chat feature:
- check_takeover_enabled: query Authorization for enabled takeover
- generate_reply: call local chat API via httpx to generate responses
- execute_takeover: get owner IM credentials, generate reply, send via BoxIM
- enqueue_delayed_message: Redis delayed queue with graceful degradation
- process_delayed_queue: placeholder for scheduled worker

Service degrades gracefully when Redis is unavailable.
2026-08-07 16:46:57 +08:00
stefanfeng 0cc43a0d8c fix: exclude signature from signing string in BoxIMClient
Prevents stale signature values from leaking into the MD5 signing
calculation, matching the news_service.py pattern. Adds a test that
passes a stale signature in extra params and verifies the returned
signature is freshly computed.
2026-08-07 16:42:00 +08:00
stefanfeng 43bb57eacf feat: add Box IM client for Netease Yunxin integration
Add BoxIMClient with get_credentials and send_p2p_message methods,
using Huihui platform MD5 signing mechanism (sorted keys, 24h timestamp,
12-char alphanumeric nonce). Includes full test coverage.
2026-08-07 16:36:50 +08:00
stefanfeng 7327939b4d feat: add takeover fields to Authorization model
Add takeover_enabled, takeover_mode, and takeover_delay_seconds columns
to the Authorization model with a SQLite migration, and include them
in to_dict(). Test: test_takeover_model.py.
2026-08-07 16:30:29 +08:00
stefanfeng 76f0730b57 docs: add avatar takeover chat implementation plan 2026-08-07 16:22:58 +08:00
stefanfeng 6f6655e791 docs: add avatar takeover chat design spec 2026-08-07 16:12:49 +08:00
stefanfeng e3cce871b0 fix: add avatar photo upload endpoint to digital-avatar-app backend
- Add POST /api/avatar/{id}/photo endpoint
- Save uploaded photos to UPLOAD_DIR/{avatar_id}/
- Update avatar.photo_url in database
- Import UPLOAD_DIR from knowledge router
2026-08-07 15:10:59 +08:00
stefanfeng b7f8ac203b feat: add avatar photo upload via Huihui filecenter
- Add POST /avatars/{id}/upload-photo endpoint
- Use Huihui platform's filecenter API for image upload (same signing mechanism as news_service)
- Add upload button in avatar list table
- Frontend: handleUploadPhoto function with el-upload component
- Updates avatar photo_url in SQLite after successful upload
2026-08-07 14:28:22 +08:00
stefanfeng a5f7c86a4b fix: avatar list table full-width and image fallback
- Changed fixed width columns to min-width for elastic layout
- Add @error handler on el-avatar to hide broken images and show emoji
- Detail dialog avatar also handles image load errors
2026-08-07 14:04:45 +08:00
stefanfeng 6026b1279e fix: avatar list fixes — full-width table, absolute photo URLs, global token balance
- Table width set to 100%
- Photo URLs resolved to absolute paths using AVATAR_BACKEND_URL
- Token balance now reads from global token_account table
- Config: added AVATAR_BACKEND_URL setting
2026-08-07 13:53:02 +08:00
stefanfeng 57af1c7db1 fix: gracefully handle missing avatar SQLite database
- Return empty list instead of crashing when avatar.db doesn't exist
- Add is_available() check before each endpoint
- 503 status when trying to access unavailable database
2026-08-06 17:52:01 +08:00
stefanfeng bbbc97eb4a feat: add digital avatar management to admin dashboard
- Add avatars list page with pagination, search, status filter
- Add avatar detail dialog showing full info and config params
- Add on/off switch (active/inactive status toggle)
- Backend: avatar_service.py connects to digital avatar SQLite
- Backend: /api/avatars endpoints for list, detail, status update
- Sidebar menu item added below dashboard
- docker-compose: mount SQLite db and set AVATAR_DB_PATH env var
2026-08-06 17:43:50 +08:00
stefanfeng 1da5f7f10d fix: open logged-in users on avatar management 2026-07-24 17:54:02 +08:00
stefanfeng cc939b6298 fix: guard avatar entry and hide chat navigation 2026-07-24 17:50:19 +08:00
stefanfeng e3bda469bb feat: complete huihui square avatar workflows 2026-07-24 14:04:21 +08:00
stefanfeng 2ef58a44b8 chore: normalize avatar app whitespace 2026-07-23 17:29:29 +08:00
stefanfeng 2d9f26a6f0 feat: add avatar chat and knowledge workflow 2026-07-23 17:21:49 +08:00
stefanfeng 501f548bcc docs: add avatar chat implementation plan 2026-07-23 16:27:02 +08:00
stefanfeng cc8af846d5 docs: design avatar chat knowledge flow 2026-07-23 16:06:30 +08:00
142 changed files with 25649 additions and 181 deletions
+2 -1
View File
@@ -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=["数字分身管理"])
+58 -4
View File
@@ -1,8 +1,11 @@
"""AI模型配置接口"""
from fastapi import APIRouter, Depends, HTTPException
import secrets
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import select, update
from app.core.database import get_db
from app.core.config import settings
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
from app.models import AIModelConfig
from app.utils.crypto import encrypt, decrypt
@@ -22,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(),
+201
View File
@@ -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()
+83 -4
View File
@@ -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)
+37 -11
View File
@@ -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,17 +352,18 @@ 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)
# 更新数据库
await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url))
await db.commit()
# 如果已同步到平台,再调用 update_user_profile 更新头像字段
# 已登录用户必须同时写入会会当前资料和“TA 的主页”。
# 任一接口失败都不得返回“头像更新成功”。
if sync_to_platform and user.status == 2 and avatar_url:
await news_service.update_user_profile(db, user, avatar=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()
return ApiResponse(data={"avatar_url": avatar_url}, message="头像更新成功")
@router.post("/logout-all")
+7
View File
@@ -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"
+33 -1
View File
@@ -1,6 +1,7 @@
"""数据库连接管理"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
from app.core.logger import logger
@@ -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
View File
@@ -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():
+30
View File
@@ -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)
+10
View File
@@ -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
+29 -1
View File
@@ -28,7 +28,9 @@ class AIService:
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
result = await db.execute(
select(AIModelConfig).where(
AIModelConfig.is_default == 1, AIModelConfig.is_enabled == 1
AIModelConfig.usage_scope == "general",
AIModelConfig.is_default == 1,
AIModelConfig.is_enabled == 1,
)
)
return result.scalar_one_or_none()
@@ -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))
+399
View File
@@ -0,0 +1,399 @@
"""数字分身管理服务层 — 同步连接数字分身应用的 SQLite 数据库"""
import json
import os
from datetime import datetime, timedelta
from typing import Optional, Tuple
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import sessionmaker, Session
from app.core.config import settings
from app.core.logger import logger
from app.models import UserPersonality, VirtualUser
_engine = None
_SessionLocal: Optional[sessionmaker] = None
AVATAR_ACCOUNT_PREFIX = "__avatar__:"
SQUARE_INTERACTION_PERMISSION = "interact"
SQUARE_INTERACTION_ACTIONS = frozenset({"like", "collect", "comment", "reply"})
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
def _decode_config(value) -> dict:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
decoded = json.loads(value)
return decoded if isinstance(decoded, dict) else {}
except (json.JSONDecodeError, ValueError):
return {}
return {}
def is_delegated_avatar_user(user: VirtualUser | None) -> bool:
return bool(user and (user.account or "").startswith(AVATAR_ACCOUNT_PREFIX))
def delegated_avatar_id(user: VirtualUser | None) -> str:
if not is_delegated_avatar_user(user):
return ""
return (user.account or "")[len(AVATAR_ACCOUNT_PREFIX):]
def _list_square_interaction_authorizations(db: Session) -> list[dict]:
"""读取已明确授权分身参与广场互动的身份与会会令牌。"""
rows = db.execute(text("""
SELECT
a.id AS avatar_id,
a.name AS avatar_name,
a.display_name AS avatar_display_name,
a.description AS avatar_description,
a.photo_url AS avatar_photo_url,
a.config AS avatar_config,
u.huihui_user_id,
u.nickname AS owner_nickname,
u.avatar_url AS owner_avatar_url,
u.huihui_token
FROM avatars a
JOIN users u ON u.huihui_user_id = a.owner_id
WHERE a.status = 'active'
""")).fetchall()
authorized = []
for row in rows:
config = _decode_config(row.avatar_config)
permissions = config.get("authorizationPermissions", [])
if not isinstance(permissions, list) or SQUARE_INTERACTION_PERMISSION not in permissions:
continue
platform_uid = str(row.huihui_user_id or "").strip()
token = str(row.huihui_token or "").strip()
if not platform_uid or not token:
continue
authorized.append({
"avatar_id": str(row.avatar_id),
"avatar_name": row.avatar_display_name or row.avatar_name or row.owner_nickname or "数字分身",
"avatar_description": row.avatar_description or "",
"avatar_url": _resolve_photo_url(row.avatar_photo_url or row.owner_avatar_url or ""),
"config": config,
"platform_uid": platform_uid,
"token": token,
})
return authorized
def get_square_interaction_permissions(avatar_id: str) -> frozenset[str]:
"""实时复核授权;数据库不可用、令牌失效或撤权时一律拒绝执行。"""
avatar_db = get_session()
if avatar_db is None:
return frozenset()
try:
authorized_ids = {
item["avatar_id"] for item in _list_square_interaction_authorizations(avatar_db)
}
return SQUARE_INTERACTION_ACTIONS if avatar_id in authorized_ids else frozenset()
except Exception as exc:
logger.error(f"读取数字分身广场互动授权失败: {exc}")
return frozenset()
finally:
avatar_db.close()
def _word_count_range(config: dict) -> tuple[int, int]:
ranges = {
"short": (10, 35),
"medium": (20, 60),
"long": (30, 80),
}
return ranges.get(str(config.get("responseLength") or "medium"), (20, 60))
async def sync_square_interaction_users(db) -> set[str]:
"""把已授权分身同步为调度器身份,并刷新其会会会话。"""
avatar_db = get_session()
if avatar_db is None:
logger.warning("数字分身数据库不可用,跳过广场互动授权同步")
return set()
try:
authorized = _list_square_interaction_authorizations(avatar_db)
except Exception as exc:
logger.error(f"同步数字分身广场互动授权失败: {exc}")
return set()
finally:
avatar_db.close()
from app.core.redis_client import delete_session, set_session
result = await db.execute(
select(VirtualUser).where(VirtualUser.account.like(f"{AVATAR_ACCOUNT_PREFIX}%"))
)
existing_users = {delegated_avatar_id(user): user for user in result.scalars().all()}
authorized_ids = {item["avatar_id"] for item in authorized}
for avatar_id, user in existing_users.items():
if avatar_id not in authorized_ids:
user.is_enabled = 0
user.status = 0
user.session_token = None
user.session_expires_at = None
await delete_session(user.id)
for item in authorized:
avatar_id = item["avatar_id"]
user = existing_users.get(avatar_id)
if user is None:
user = VirtualUser(
nickname=item["avatar_name"],
account=f"{AVATAR_ACCOUNT_PREFIX}{avatar_id}",
password_enc="",
status=2,
is_enabled=1,
platform_uid=item["platform_uid"],
remark="用户授权的数字分身广场互动身份",
)
db.add(user)
await db.flush()
expires_at = datetime.now() + timedelta(days=1)
user.nickname = item["avatar_name"]
user.real_name = item["avatar_name"]
user.avatar_url = item["avatar_url"]
user.platform_uid = item["platform_uid"]
user.session_token = item["token"]
user.session_expires_at = expires_at
user.last_login_at = datetime.now()
user.status = 2
user.is_enabled = 1
config = item["config"]
personality_result = await db.execute(
select(UserPersonality).where(UserPersonality.user_id == user.id)
)
personality = personality_result.scalar_one_or_none()
word_min, word_max = _word_count_range(config)
prompt_parts = [item["avatar_description"], str(config.get("systemPrompt") or "")]
style_prompt = "\n".join(part.strip() for part in prompt_parts if part and part.strip())
if personality is None:
personality = UserPersonality(user_id=user.id)
db.add(personality)
personality.language_style = str(config.get("replyStyle") or "professional")
personality.personality_desc = item["avatar_description"]
personality.comment_style_prompt = style_prompt
personality.word_count_min = word_min
personality.word_count_max = word_max
await set_session(user.id, {
"token": item["token"],
"session_id": f"avatar:{avatar_id}",
"platform_uid": item["platform_uid"],
"org_id": "",
"login_time": datetime.now().isoformat(),
"nickname": item["avatar_name"],
"real_name": item["avatar_name"],
"avatar": item["avatar_url"],
"delegated_avatar_id": avatar_id,
}, expire=86400)
await db.commit()
return authorized_ids
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()
+549 -36
View File
@@ -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]:
# 同步到本地数据库
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} 信息已同步到目标系统")
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
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:
await db.execute(update(VirtualUser).where(
VirtualUser.id == user.id).values(**local_vals))
await db.commit()
logger.info(f"✅ 用户 {user.account} 三套资料与App用户主页均已同步")
return True, ""
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]:
+624 -115
View File
@@ -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,13 @@ class SchedulerService:
from app.core.database import AsyncSessionLocal
logger.info("⚡ 立即触发互动任务")
async with AsyncSessionLocal() as session:
await self._sync_delegated_avatar_users(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 +38,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 +108,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
@@ -86,7 +147,9 @@ class SchedulerService:
async def _check_sessions(self):
"""定时校验登录状态"""
from app.services.news_service import news_service
from app.services.avatar_service import is_delegated_avatar_user
async with AsyncSessionLocal() as db:
await self._sync_delegated_avatar_users(db)
result = await db.execute(
select(VirtualUser).where(VirtualUser.status == 2, VirtualUser.is_enabled == 1)
)
@@ -94,7 +157,7 @@ class SchedulerService:
for user in users:
try:
valid = await news_service.check_session(db, user)
if not valid:
if not valid and not is_delegated_avatar_user(user):
logger.warning(f"用户 {user.account} 会话失效,尝试重登")
await news_service.login(db, user)
except Exception as e:
@@ -103,6 +166,7 @@ class SchedulerService:
async def _run_interactions(self):
"""执行互动任务"""
async with AsyncSessionLocal() as db:
await self._sync_delegated_avatar_users(db)
# 检查调度器开关
enabled = await self._get_config(db, "scheduler_enabled", "true")
if enabled != "true":
@@ -124,8 +188,11 @@ class SchedulerService:
logger.debug(f"[调度] 当前北京时间 {now_time} 不在互动时段 {start_str}-{end_str}")
return
# 获取最小互动间隔(秒)
min_interval = int(await self._get_config(db, "interact_min_interval", "300"))
# 获取互动间隔范围(秒),与调度设置页面字段保持一致
min_interval = await self._get_int_config(db, "interact_interval_min", 300)
max_interval = await self._get_int_config(db, "interact_interval_max", min_interval)
min_interval = max(0, min_interval)
max_interval = max(min_interval, max_interval)
# 获取最大并发
max_concurrent = int(await self._get_config(db, "max_concurrent_users", "5"))
@@ -144,7 +211,7 @@ class SchedulerService:
await self._try_login_users(db)
return
# 检查互动间隔:过滤掉最近 min_interval 秒内已互动的用户
# 每个用户在其最小/最大间隔内取得稳定随机值,直到下次互动后再变化
now_dt = datetime.now()
eligible = []
for u in all_users:
@@ -152,11 +219,17 @@ class SchedulerService:
eligible.append(u)
else:
elapsed = (now_dt - u.last_interact_at).total_seconds()
if elapsed >= min_interval:
interval = random.Random(
f"{u.id}:{u.last_interact_at.isoformat()}"
).randint(min_interval, max_interval)
if elapsed >= interval:
eligible.append(u)
if not eligible:
logger.debug(f"[调度] 所有 {len(all_users)} 个用户在 {min_interval}s 内已互动,跳过本次")
logger.debug(
f"[调度] 所有 {len(all_users)} 个用户尚未达到 "
f"{min_interval}-{max_interval}s 随机互动间隔,跳过本次"
)
return
# 按最后互动时间升序排序:最久没互动的用户优先
@@ -197,10 +270,12 @@ class SchedulerService:
async def _try_login_users(self, db):
"""尝试登录未登录的用户"""
from app.services.news_service import news_service
from app.services.avatar_service import AVATAR_ACCOUNT_PREFIX
result = await db.execute(
select(VirtualUser).where(
VirtualUser.status.in_([0, 3]),
VirtualUser.is_enabled == 1
VirtualUser.is_enabled == 1,
~VirtualUser.account.like(f"{AVATAR_ACCOUNT_PREFIX}%"),
).limit(3)
)
users = result.scalars().all()
@@ -215,13 +290,41 @@ class SchedulerService:
"""执行单用户互动 - 基于真实接口"""
from app.services.news_service import news_service
from app.services.ai_service import ai_service
from app.services.avatar_service import (
delegated_avatar_id,
get_square_interaction_permissions,
is_delegated_avatar_user,
)
async with AsyncSessionLocal() as db:
try:
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": [],
}
allowed_actions = {"like", "collect", "comment", "reply", "forward"}
if is_delegated_avatar_user(user):
allowed_actions = set(
get_square_interaction_permissions(delegated_avatar_id(user))
)
if not allowed_actions:
user.status = 0
user.is_enabled = 0
await db.commit()
return {
"user_id": user.id,
"account": user.account,
"status": "skipped",
"reason": "avatar_interaction_not_authorized",
"interactions": [],
}
# 检查今日评论限额
can_comment = True
@@ -255,7 +358,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 +416,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,138 +433,531 @@ class SchedulerService:
forward_prob = float(await self._get_config_from_db(db, "forward_probability", "0.15"))
interactions_done = []
action_failures = []
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
done_on_this = today_done.get(news_id, set())
wants = {
"like": (
"like" in allowed_actions
and "like" not in done_on_this
and random.random() < like_prob
),
"collect": (
"collect" in allowed_actions
and "collect" not in done_on_this
and random.random() < collect_prob
),
"forward": (
"forward" in allowed_actions
and "forward" not in done_on_this
and random.random() < forward_prob
),
"reply": (
"reply" in allowed_actions
and can_comment
and personality is not None
and random.random() < reply_prob
),
"comment": (
"comment" in allowed_actions
and can_comment
and personality is not None
and not already_commented_this
and random.random() < comment_prob
),
}
if not any(wants.values()):
return {
"user_id": user.id,
"account": user.account,
"status": "skipped",
"reason": "no_actions_triggered",
"interactions": [],
"article_id": news_id,
"article_title": news_title,
}
# 只有动作命中调度概率后才打开文章
await news_service.read_news(db, user, news_id)
# 今日已对此文章做过的互动类型
done_on_this = today_done.get(news_id, set())
# ② 点赞(每篇文章每用户每天只点赞一次)
if "like" not in done_on_this and random.random() < like_prob:
if wants["like"]:
success, err = await news_service.like_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
await self._save_record(db, user, news_id, news_title, "like", None, 0, success, err)
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:
if wants["collect"]:
success, err = await news_service.collect_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
if success:
interactions_done.append("collect")
await self._incr_total(db, user_id)
else:
action_failures.append({"type": "collect", "error": err})
# ④ 转发(每篇文章每用户每天只转发一次)
if "forward" not in done_on_this and random.random() < forward_prob:
if wants["forward"]:
success, err = await news_service.forward_news(db, user, news_id)
await self._save_record(db, user, news_id, news_title, "forward", None, 0, success, err)
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
)
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:
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(
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
)
if success:
interactions_done.append("comment")
await db.execute(
update(VirtualUser).where(VirtualUser.id == user_id).values(
today_comment_count=VirtualUser.today_comment_count + 1,
total_interactions=VirtualUser.total_interactions + 1,
last_interact_at=datetime.now()
)
)
if wants["reply"]:
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,
)
interactions_done.extend(reply_actions)
action_failures.extend(reply_failures)
# ⑥ 评论成功后,随机回复其他用户的评论(互动链)
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")
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
if wants["comment"]:
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, 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,
platform_record_id=comment_record_id,
)
if success:
interactions_done.append("comment")
await db.execute(
update(VirtualUser).where(VirtualUser.id == user_id).values(
today_comment_count=VirtualUser.today_comment_count + 1,
total_interactions=VirtualUser.total_interactions + 1,
last_interact_at=datetime.now()
)
)
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:
await self._sync_delegated_avatar_users(db)
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):
from app.services.avatar_service import (
delegated_avatar_id,
get_square_interaction_permissions,
is_delegated_avatar_user,
)
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
if (
is_delegated_avatar_user(actor)
and "reply" not in get_square_interaction_permissions(delegated_avatar_id(actor))
):
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 _sync_delegated_avatar_users(self, db):
from app.services.avatar_service import sync_square_interaction_users
try:
return await sync_square_interaction_users(db)
except Exception as exc:
await db.rollback()
logger.error(f"数字分身广场互动身份同步异常: {exc}")
return set()
async def _incr_total(self, db, user_id: int):
await db.execute(
@@ -511,4 +1020,4 @@ class SchedulerService:
logger.info("每日计数重置完成")
scheduler_service = SchedulerService()
scheduler_service = SchedulerService()
+1 -1
View File
@@ -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,126 @@
import json
import os
import sqlite3
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from app.services import avatar_service
class AvatarSquareAuthorizationTests(unittest.TestCase):
def setUp(self):
fd, self.db_path = tempfile.mkstemp(suffix=".db")
os.close(fd)
connection = sqlite3.connect(self.db_path)
connection.executescript("""
CREATE TABLE users (
huihui_user_id TEXT,
nickname TEXT,
avatar_url TEXT,
huihui_token TEXT
);
CREATE TABLE avatars (
id TEXT,
owner_id TEXT,
name TEXT,
display_name TEXT,
description TEXT,
photo_url TEXT,
config TEXT,
status TEXT
);
""")
connection.execute(
"INSERT INTO users VALUES (?, ?, ?, ?)",
("huihui-7", "主人", "/owner.jpg", "huihui-token"),
)
connection.commit()
connection.close()
avatar_service._engine = None
avatar_service._SessionLocal = None
self.path_patch = patch.object(avatar_service.settings, "AVATAR_DB_PATH", self.db_path)
self.path_patch.start()
def tearDown(self):
self.path_patch.stop()
if avatar_service._engine is not None:
avatar_service._engine.dispose()
avatar_service._engine = None
avatar_service._SessionLocal = None
os.unlink(self.db_path)
def _insert_avatar(self, permissions, *, status="active", token=None):
connection = sqlite3.connect(self.db_path)
connection.execute(
"INSERT INTO avatars VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
"avatar-7",
"huihui-7",
"avatar",
"小会",
"语气友好,表达简洁",
"/avatar.jpg",
json.dumps({
"authorizationPermissions": permissions,
"replyStyle": "warm",
"responseLength": "short",
}),
status,
),
)
if token is not None:
connection.execute(
"UPDATE users SET huihui_token = ? WHERE huihui_user_id = ?",
(token, "huihui-7"),
)
connection.commit()
connection.close()
def test_interact_permission_exposes_only_requested_square_actions(self):
self._insert_avatar(["chat", "interact"])
permissions = avatar_service.get_square_interaction_permissions("avatar-7")
self.assertEqual(
permissions,
frozenset({"like", "collect", "comment", "reply"}),
)
self.assertNotIn("forward", permissions)
def test_missing_permission_inactive_avatar_or_missing_token_denies_execution(self):
scenarios = [
(["chat"], "active", "huihui-token"),
(["interact"], "inactive", "huihui-token"),
(["interact"], "active", ""),
]
for permissions, status, token in scenarios:
with self.subTest(permissions=permissions, status=status, token=token):
connection = sqlite3.connect(self.db_path)
connection.execute("DELETE FROM avatars")
connection.commit()
connection.close()
self._insert_avatar(permissions, status=status, token=token)
self.assertEqual(
avatar_service.get_square_interaction_permissions("avatar-7"),
frozenset(),
)
def test_delegated_avatar_identity_is_recognized_without_matching_normal_users(self):
delegated = SimpleNamespace(account="__avatar__:avatar-7")
normal = SimpleNamespace(account="13800000000")
self.assertTrue(avatar_service.is_delegated_avatar_user(delegated))
self.assertEqual(avatar_service.delegated_avatar_id(delegated), "avatar-7")
self.assertFalse(avatar_service.is_delegated_avatar_user(normal))
self.assertEqual(avatar_service.delegated_avatar_id(normal), "")
def test_response_length_maps_to_scheduler_comment_limits(self):
self.assertEqual(avatar_service._word_count_range({"responseLength": "short"}), (10, 35))
self.assertEqual(avatar_service._word_count_range({"responseLength": "long"}), (30, 80))
self.assertEqual(avatar_service._word_count_range({"responseLength": "unknown"}), (20, 60))
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -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()
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.git/
.env
*.log
backend/
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.env
*.log
backend/avatar.db
backend/routers/uploads/
+21
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.db
.env
logs/
*.log
+15
View File
@@ -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"]
+106
View File
@@ -0,0 +1,106 @@
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"),
("knowledge_docs", "error_message", "VARCHAR DEFAULT ''"),
("knowledge_docs", "index_stage", "VARCHAR DEFAULT ''"),
("knowledge_docs", "index_progress", "INTEGER DEFAULT 0"),
("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 <> ''"
)
+160
View File
@@ -0,0 +1,160 @@
"""
向量化服务:对文档/查询文本生成向量。
优先级:
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, on_progress=None):
"""返回 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 = []
total = len(texts)
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)
if on_progress:
on_progress(len(embeddings), total)
return embeddings
vectors = _hash_embedding(texts)
if on_progress:
on_progress(len(vectors), len(texts))
return vectors
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}"
+266
View File
@@ -0,0 +1,266 @@
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.knowledge_vectorizer import knowledge_vectorizer
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()
knowledge_vectorizer.start()
# 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()
+424
View File
@@ -0,0 +1,424 @@
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
error_message = Column(String, default="") # 建立索引失败原因
index_stage = Column(String, default="") # queued | extracting | chunking | embedding | ready | failed
index_progress = Column(Integer, default=0) # 0-100
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,
"errorMessage": self.error_message or "",
"indexStage": self.index_stage or "",
"indexProgress": int(self.index_progress or 0),
"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
+6
View File
@@ -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,471 @@
import os
import json
import shutil
import time
import uuid
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
from services.knowledge_vectorizer import knowledge_vectorizer
router = APIRouter()
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 = 50 * 1024 * 1024
UPLOAD_CHUNK_BYTES = 1024 * 1024
MULTIPART_CHUNK_BYTES = 5 * 1024 * 1024
MULTIPART_ROOT = ".multipart"
MULTIPART_TTL_SECONDS = 24 * 60 * 60
class QAIn(BaseModel):
question: str = ""
answer: str = ""
enabled: bool = True
class EnabledIn(BaseModel):
enabled: bool = True
class MultipartUploadIn(BaseModel):
filename: str
fileSize: int
totalChunks: int
def _validate_document(filename: str, file_size: int):
ext = os.path.splitext(filename or "")[1].lower()
if ext not in ALLOWED_EXT:
return None, f"不支持的文件类型:{ext or '空'},仅支持 md/txt/pdf/doc/docx/xlsx"
if file_size <= 0:
return None, "文件内容不能为空"
if file_size > MAX_UPLOAD_BYTES:
return None, "文件不能超过 50MB"
return ext, ""
def _multipart_dir(avatar_id: str, upload_id: str) -> str:
safe_avatar_id = os.path.basename(avatar_id)
safe_upload_id = os.path.basename(upload_id)
if (
safe_avatar_id != avatar_id
or safe_upload_id != upload_id
or len(upload_id) != 32
or any(character not in "0123456789abcdef" for character in upload_id)
):
raise HTTPException(status_code=400, detail="上传标识无效")
return os.path.join(UPLOAD_DIR, MULTIPART_ROOT, safe_avatar_id, safe_upload_id)
def _purge_stale_multipart_uploads(avatar_id: str):
avatar_upload_root = os.path.join(UPLOAD_DIR, MULTIPART_ROOT, os.path.basename(avatar_id))
if not os.path.isdir(avatar_upload_root):
return
cutoff = time.time() - MULTIPART_TTL_SECONDS
for entry in os.scandir(avatar_upload_root):
if entry.is_dir(follow_symlinks=False) and entry.stat(follow_symlinks=False).st_mtime < cutoff:
shutil.rmtree(entry.path, ignore_errors=True)
def _read_multipart_metadata(avatar_id: str, upload_id: str) -> tuple[str, dict]:
upload_dir = _multipart_dir(avatar_id, upload_id)
metadata_path = os.path.join(upload_dir, "metadata.json")
if not os.path.isfile(metadata_path):
raise HTTPException(status_code=404, detail="上传任务不存在或已过期")
with open(metadata_path, "r", encoding="utf-8") as stream:
return upload_dir, json.load(stream)
def _create_knowledge_doc(db: Session, avatar_id: str, filename: str, ext: str, file_size: int, stored: str):
doc = KnowledgeDoc(
id=uuid.uuid4().hex,
avatar_id=avatar_id,
filename=filename,
file_type=ext.lstrip("."),
file_size=file_size,
file_url=f"/api/files/{avatar_id}/{stored}",
status="parsing",
index_stage="queued",
index_progress=0,
)
db.add(doc)
db.commit()
db.refresh(doc)
knowledge_vectorizer.enqueue(doc.id)
return doc
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()
)
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, validation_error = _validate_document(file.filename or "", 1)
if validation_error:
return fail(validation_error, 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)
file_size = 0
try:
# Stream large files to disk so a 100MB upload does not occupy 100MB RAM.
with open(path, "wb") as f:
while chunk := await file.read(UPLOAD_CHUNK_BYTES):
file_size += len(chunk)
if file_size > MAX_UPLOAD_BYTES:
raise ValueError("文件不能超过 50MB")
f.write(chunk)
except ValueError as exc:
if os.path.exists(path):
os.remove(path)
return fail(str(exc), code=400)
if file_size == 0:
if os.path.exists(path):
os.remove(path)
return fail("文件内容不能为空", code=400)
doc = _create_knowledge_doc(db, avatar_id, file.filename or stored, ext, file_size, stored)
return ok(_doc_payload(doc))
@router.post("/avatar/{avatar_id}/knowledge/uploads")
def create_multipart_upload(
avatar_id: str,
body: MultipartUploadIn,
authorization: str = Header(None),
db: Session = Depends(get_db),
):
_require_owned_avatar(db, avatar_id, authorization)
ext, validation_error = _validate_document(body.filename, body.fileSize)
if validation_error:
return fail(validation_error, code=400)
expected_chunks = (body.fileSize + MULTIPART_CHUNK_BYTES - 1) // MULTIPART_CHUNK_BYTES
if body.totalChunks != expected_chunks:
return fail("文件分片数量不正确", code=400)
_purge_stale_multipart_uploads(avatar_id)
upload_id = uuid.uuid4().hex
upload_dir = _multipart_dir(avatar_id, upload_id)
os.makedirs(upload_dir, exist_ok=False)
metadata = {
"filename": body.filename,
"fileSize": body.fileSize,
"totalChunks": body.totalChunks,
"extension": ext,
}
with open(os.path.join(upload_dir, "metadata.json"), "w", encoding="utf-8") as stream:
json.dump(metadata, stream, ensure_ascii=False)
return ok({"uploadId": upload_id, "chunkSize": MULTIPART_CHUNK_BYTES})
@router.post("/avatar/{avatar_id}/knowledge/uploads/{upload_id}/chunks/{chunk_index}")
async def upload_multipart_chunk(
avatar_id: str,
upload_id: str,
chunk_index: int,
file: UploadFile = File(...),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
_require_owned_avatar(db, avatar_id, authorization)
upload_dir, metadata = _read_multipart_metadata(avatar_id, upload_id)
total_chunks = int(metadata["totalChunks"])
if chunk_index < 0 or chunk_index >= total_chunks:
return fail("文件分片序号不正确", code=400)
expected_size = min(
MULTIPART_CHUNK_BYTES,
int(metadata["fileSize"]) - chunk_index * MULTIPART_CHUNK_BYTES,
)
part_path = os.path.join(upload_dir, f"{chunk_index}.part")
temporary_path = f"{part_path}.uploading"
received = 0
try:
with open(temporary_path, "wb") as stream:
while chunk := await file.read(UPLOAD_CHUNK_BYTES):
received += len(chunk)
if received > expected_size:
raise ValueError("文件分片大小不正确")
stream.write(chunk)
if received != expected_size:
raise ValueError("文件分片大小不正确")
os.replace(temporary_path, part_path)
except ValueError as exc:
if os.path.exists(temporary_path):
os.remove(temporary_path)
return fail(str(exc), code=400)
return ok({"chunkIndex": chunk_index, "uploadedBytes": received})
@router.post("/avatar/{avatar_id}/knowledge/uploads/{upload_id}/complete")
def complete_multipart_upload(
avatar_id: str,
upload_id: str,
authorization: str = Header(None),
db: Session = Depends(get_db),
):
_require_owned_avatar(db, avatar_id, authorization)
upload_dir, metadata = _read_multipart_metadata(avatar_id, upload_id)
total_chunks = int(metadata["totalChunks"])
part_paths = [os.path.join(upload_dir, f"{index}.part") for index in range(total_chunks)]
if not all(os.path.isfile(path) for path in part_paths):
return fail("文件分片尚未上传完整", code=400)
if sum(os.path.getsize(path) for path in part_paths) != int(metadata["fileSize"]):
return fail("文件分片总大小不正确", code=400)
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
os.makedirs(avatar_dir, exist_ok=True)
stored = f"{uuid.uuid4().hex}{metadata['extension']}"
final_path = os.path.join(avatar_dir, stored)
temporary_path = f"{final_path}.assembling"
try:
with open(temporary_path, "wb") as output:
for part_path in part_paths:
with open(part_path, "rb") as source:
shutil.copyfileobj(source, output, UPLOAD_CHUNK_BYTES)
os.replace(temporary_path, final_path)
doc = _create_knowledge_doc(
db,
avatar_id,
metadata["filename"],
metadata["extension"],
int(metadata["fileSize"]),
stored,
)
except Exception:
if os.path.exists(temporary_path):
os.remove(temporary_path)
raise
shutil.rmtree(upload_dir, ignore_errors=True)
return ok(_doc_payload(doc))
@router.post("/avatar/{avatar_id}/knowledge/docs/{doc_id}/retry")
def retry_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)
if doc.vectorized and doc.status == "ready":
return ok(_doc_payload(doc))
stored_name = os.path.basename(doc.file_url or "")
if not stored_name or not os.path.isfile(os.path.join(UPLOAD_DIR, avatar_id, stored_name)):
return fail("原文件不可用,请重新上传", code=400)
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc.id).delete()
doc.status = "parsing"
doc.vectorized = False
doc.embedding_model = ""
doc.chunk_count = 0
doc.vectorized_at = None
doc.error_message = ""
doc.index_stage = "queued"
doc.index_progress = 0
db.commit()
db.refresh(doc)
knowledge_vectorizer.enqueue(doc.id)
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
@@ -0,0 +1,143 @@
"""Durable, serial knowledge-document indexing for the avatar knowledge base."""
import json
import logging
import os
import queue
import threading
from datetime import datetime, timezone
from database import SessionLocal
from models import KnowledgeChunk, KnowledgeDoc
import embeddings
logger = logging.getLogger(__name__)
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UPLOAD_DIR = os.path.abspath(
os.getenv("UPLOAD_DIR", os.path.join(BACKEND_DIR, "routers", "uploads"))
)
class KnowledgeVectorizer:
"""Indexes one document at a time so slow providers cannot block uploads."""
def __init__(self):
self._queue: queue.Queue[str] = queue.Queue()
self._queued: set[str] = set()
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
def start(self):
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(
target=self._run, name="knowledge-vectorizer", daemon=True
)
self._thread.start()
db = SessionLocal()
try:
# A process restart must not abandon documents already accepted by upload.
for (doc_id,) in db.query(KnowledgeDoc.id).filter(KnowledgeDoc.status == "parsing"):
self.enqueue(doc_id)
finally:
db.close()
def enqueue(self, doc_id: str):
with self._lock:
if doc_id in self._queued:
return
self._queued.add(doc_id)
self._queue.put(doc_id)
def _run(self):
while True:
doc_id = self._queue.get()
try:
self.vectorize_document(doc_id)
except Exception:
logger.exception("Unexpected knowledge vectorizer failure for %s", doc_id)
finally:
with self._lock:
self._queued.discard(doc_id)
self._queue.task_done()
def vectorize_document(self, doc_id: str):
db = SessionLocal()
try:
doc = db.get(KnowledgeDoc, doc_id)
if not doc or doc.status != "parsing":
return
stored_name = os.path.basename(doc.file_url or "")
path = os.path.join(UPLOAD_DIR, doc.avatar_id, stored_name)
if not stored_name or not os.path.isfile(path):
raise FileNotFoundError("原文件不可用,请重新上传")
self._set_progress(db, doc, "extracting", 8)
text = embeddings.extract_text(path, f".{doc.file_type}")
self._set_progress(db, doc, "chunking", 22)
chunks = embeddings.chunk_text(text)
if not chunks:
raise ValueError("文档没有可建立索引的文字内容")
self._set_progress(db, doc, "embedding", 30)
def embedding_progress(done: int, total: int):
percent = 30 + int((done / max(1, total)) * 65)
self._set_progress(db, doc, "embedding", min(percent, 95))
vectors = embeddings.embed(chunks, on_progress=embedding_progress)
if len(vectors) != len(chunks):
raise ValueError("向量服务返回数量与文档分段不一致")
# Commit the document and every chunk together. Chat only sees complete indexes.
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc.id).delete()
db.add_all(
[
KnowledgeChunk(
doc_id=doc.id,
avatar_id=doc.avatar_id,
content=chunk,
vector=json.dumps(vector),
chunk_index=index,
embedding_model=embeddings.MODEL,
)
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
]
)
doc.vectorized = True
doc.embedding_model = embeddings.MODEL
doc.chunk_count = len(chunks)
doc.vectorized_at = datetime.now(timezone.utc)
doc.status = "ready"
doc.error_message = ""
doc.index_stage = "ready"
doc.index_progress = 100
db.commit()
logger.info("Knowledge document %s indexed with %s chunks", doc.id, len(chunks))
except Exception as exc:
db.rollback()
failed_doc = db.get(KnowledgeDoc, doc_id)
if failed_doc:
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == failed_doc.id).delete()
failed_doc.status = "failed"
failed_doc.vectorized = False
failed_doc.embedding_model = ""
failed_doc.chunk_count = 0
failed_doc.vectorized_at = None
failed_doc.error_message = str(exc)[:300] or "建立知识索引失败"
failed_doc.index_stage = "failed"
failed_doc.index_progress = 0
db.commit()
logger.exception("Knowledge vectorization failed for %s: %s", doc_id, exc)
finally:
db.close()
@staticmethod
def _set_progress(db, doc, stage: str, progress: int):
doc.index_stage = stage
doc.index_progress = progress
db.commit()
knowledge_vectorizer = KnowledgeVectorizer()
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,332 @@
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,
_answer_denies_available_image,
_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_ready_image_context_never_returns_whole_image_access_denial():
avatar = SimpleNamespace(
id="avatar-vision",
name="测试分身",
description="产品顾问",
config={},
)
model = Mock(return_value="抱歉,我无法查看或识别图片,请重新上传。")
result = _resolve_reply(
None,
avatar,
"请看看这张图片",
[],
qa_pairs=[],
search_fn=Mock(return_value=[]),
model_client=model,
image_contexts=[{
"id": "attachment",
"filename": "report.jpg",
"category": "medical_document",
"summary": "一份耳鼻喉科门诊记录",
"extractedText": "主诉:咽痛三天",
"structuredData": {"key_facts": ["主诉为咽痛三天"]},
"warning": "请核对原始资料",
}],
)
assert result["source"] == "vision"
assert "一份耳鼻喉科门诊记录" in result["answer"]
assert "主诉为咽痛三天" in result["answer"]
assert "无法查看" not in result["answer"]
system = model.call_args.kwargs["messages"][0]["content"]
assert "当前会话图片已经成功读取" in system
assert "禁止声称无法查看" in system
def test_image_denial_detector_allows_uncertain_field_in_ready_image():
assert _answer_denies_available_image("我无法查看这张图片") is True
assert _answer_denies_available_image("图片中患者姓名无法辨认,主诉为咽痛三天。") is False
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,93 @@
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 = []
progress_updates = []
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,
on_progress=lambda completed, total: progress_updates.append((completed, total)),
)
self.assertEqual(batch_sizes, [10, 4])
self.assertEqual(requested_urls, [
"https://embedding.example/v1/embeddings",
"https://embedding.example/v1/embeddings",
])
self.assertEqual(result, [[float(index)] for index in range(14)])
self.assertEqual(progress_updates, [(10, 14), (14, 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,376 @@
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
from services.knowledge_vectorizer import knowledge_vectorizer
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_returns_before_background_vectorization(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
):
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"] == "parsing"
assert payload["vectorized"] is False
assert payload["chunkCount"] == 0
enqueue.assert_called_once_with(payload["id"])
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "parsing"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
db.delete(stored)
db.commit()
finally:
db.close()
def test_upload_rejects_oversize_file_before_queuing_indexing(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.MAX_UPLOAD_BYTES", 4),
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("oversize.md", b"12345", "text/markdown")},
)
payload = response.json()
assert payload["code"] == 400
assert payload["message"] == "文件不能超过 50MB"
enqueue.assert_not_called()
assert not list((tmp_path / context["avatar"].id).glob("*"))
def test_multipart_upload_reassembles_file_before_queuing_indexing(
tmp_path: Path,
authorization_context,
):
context = authorization_context
avatar_id = context["avatar"].id
content = b"0123456789"
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.MULTIPART_CHUNK_BYTES", 4),
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
):
created = client.post(
f"/api/avatar/{avatar_id}/knowledge/uploads",
headers=context["owner_headers"],
json={"filename": "large.pdf", "fileSize": len(content), "totalChunks": 3},
).json()["data"]
for index, chunk in enumerate((content[:4], content[4:8], content[8:])):
response = client.post(
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/chunks/{index}",
headers=context["owner_headers"],
files={"file": (f"chunk-{index}", chunk, "application/octet-stream")},
)
assert response.json()["code"] == 200
completed = client.post(
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/complete",
headers=context["owner_headers"],
).json()["data"]
assert completed["status"] == "parsing"
assert completed["fileSize"] == len(content)
enqueue.assert_called_once_with(completed["id"])
stored_path = tmp_path / avatar_id / Path(completed["fileUrl"]).name
assert stored_path.read_bytes() == content
assert not (tmp_path / ".multipart" / avatar_id / created["uploadId"]).exists()
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == completed["id"]).one()
db.delete(stored)
db.commit()
finally:
db.close()
def test_multipart_upload_rejects_incomplete_parts(
tmp_path: Path,
authorization_context,
):
context = authorization_context
avatar_id = context["avatar"].id
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.MULTIPART_CHUNK_BYTES", 4),
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
):
created = client.post(
f"/api/avatar/{avatar_id}/knowledge/uploads",
headers=context["owner_headers"],
json={"filename": "large.pdf", "fileSize": 6, "totalChunks": 2},
).json()["data"]
client.post(
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/chunks/0",
headers=context["owner_headers"],
files={"file": ("chunk-0", b"0123", "application/octet-stream")},
)
response = client.post(
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/complete",
headers=context["owner_headers"],
)
assert response.json()["code"] == 400
assert response.json()["message"] == "文件分片尚未上传完整"
enqueue.assert_not_called()
def test_background_vectorizer_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.knowledge_vectorizer.enqueue"),
):
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"] == "parsing"
with (
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
patch("services.knowledge_vectorizer.embeddings.embed", return_value=[[1.0, 0.0]]),
):
knowledge_vectorizer.vectorize_document(payload["id"])
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "ready"
assert stored.vectorized is True
assert stored.chunk_count == 1
assert stored.index_stage == "ready"
assert stored.index_progress == 100
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_background_vectorizer_keeps_failure_reason_for_retry(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.knowledge_vectorizer.enqueue"),
):
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"]
with (
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
patch("services.knowledge_vectorizer.embeddings.embed", side_effect=RuntimeError("provider unavailable")),
):
knowledge_vectorizer.vectorize_document(payload["id"])
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "failed"
assert stored.error_message == "provider unavailable"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
db.delete(stored)
db.commit()
finally:
db.close()
def test_retry_queues_a_failed_document_again(
tmp_path: Path,
authorization_context,
):
context = authorization_context
document_id = f"retry-doc-{context['suffix']}"
avatar_dir = tmp_path / context["avatar"].id
avatar_dir.mkdir()
(avatar_dir / "retry.md").write_text("retry content", encoding="utf-8")
db = SessionLocal()
try:
db.add(
KnowledgeDoc(
id=document_id,
avatar_id=context["avatar"].id,
filename="retry.md",
file_type="md",
file_url=f"/api/files/{context['avatar'].id}/retry.md",
status="failed",
error_message="provider unavailable",
)
)
db.commit()
finally:
db.close()
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs/{document_id}/retry",
headers=context["owner_headers"],
)
payload = response.json()["data"]
assert payload["status"] == "parsing"
assert payload["errorMessage"] == ""
enqueue.assert_called_once_with(document_id)
db = SessionLocal()
try:
db.query(KnowledgeDoc).filter(KnowledgeDoc.id == document_id).delete()
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,859 @@
"""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_explicit_followup_reuses_handled_image_within_two_days(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
image_message = {
"id": 116,
"localId": 116,
"sendId": 200,
"recvId": 100,
"sendTime": clock.millis(),
"type": 1,
"content": json.dumps({"originUrl": "https://cdn.example/handled-case.png"}),
}
boxim.messages.append(image_message)
await service.poll_messages()
db = session_factory()
try:
image_task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="116").one()
image_task.status = "sent"
image_task.sent_at = clock.now()
db.commit()
finally:
db.close()
clock.advance(47 * 60 * 60)
boxim.messages.append(
{
"id": 117,
"localId": 117,
"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="117").one()
assert task.source_message_ids == ["116", "117"]
assert task.prompt == "请看看这张图片。\n重新看一下刚才那张病例图片"
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
+42
View File
@@ -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 100m;
}
```
`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 光回答不作确定诊断,并显示人工复核提示。
+21
View File
@@ -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
}
]
+46
View File
@@ -0,0 +1,46 @@
# 完整主配置:覆盖 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;
# Keep the application gateway aligned with the production edge gateway.
# Without this Nginx rejects ordinary PDF uploads with HTTP 413 before
# FastAPI can return its user-facing file-size validation message.
client_max_body_size 100m;
# 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;
}
}
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
8f54097b01d5b0433f8aa5ea70ca930b
@@ -0,0 +1 @@
8f54097b01d5b0433f8aa5ea70ca930b
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="avatar-gradient" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop stop-color="#fb923c"/>
<stop offset="1" stop-color="#f97316"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#avatar-gradient)"/>
<circle cx="32" cy="24" r="10" fill="#fff"/>
<path d="M15 52c1.8-10 8-15 17-15s15.2 5 17 15" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

@@ -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')
+21
View File
@@ -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>
+608
View File
@@ -0,0 +1,608 @@
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
errorMessage?: string
indexStage?: string
indexProgress?: 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`)
const KNOWLEDGE_UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024
const uploadKnowledgeChunk = async (
avatarId: string,
uploadId: string,
chunkIndex: number,
chunk: Blob,
onProgress?: (loaded: number) => void
) => {
const form = new FormData()
form.append('file', chunk, `chunk-${chunkIndex}`)
let reportedLoaded = 0
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await request.post(
`/avatar/${avatarId}/knowledge/uploads/${uploadId}/chunks/${chunkIndex}`,
form,
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 2 * 60 * 1000,
onUploadProgress: (event) => {
reportedLoaded = Math.max(reportedLoaded, Math.min(event.loaded, chunk.size))
onProgress?.(reportedLoaded)
}
}
)
return
} catch (error: any) {
const status = Number(error?.response?.status || 0)
const retryable = !status || status === 408 || status === 429 || status >= 500
if (!retryable || attempt === 3) throw error
await new Promise((resolve) => window.setTimeout(resolve, attempt * 800))
}
}
}
// 大文件拆成 5MB 分片,避免生产代理的请求体限制拦截整个文件。
export const uploadKnowledgeDoc = async (
avatarId: string,
file: File,
onUploadProgress?: (loaded: number, total: number) => void
) => {
if (file.size > KNOWLEDGE_UPLOAD_CHUNK_SIZE) {
const totalChunks = Math.ceil(file.size / KNOWLEDGE_UPLOAD_CHUNK_SIZE)
const upload: any = await request.post(`/avatar/${avatarId}/knowledge/uploads`, {
filename: file.name,
fileSize: file.size,
totalChunks
})
let uploadedBytes = 0
for (let index = 0; index < totalChunks; index += 1) {
const start = index * KNOWLEDGE_UPLOAD_CHUNK_SIZE
const chunk = file.slice(start, Math.min(start + KNOWLEDGE_UPLOAD_CHUNK_SIZE, file.size))
await uploadKnowledgeChunk(
avatarId,
upload.uploadId,
index,
chunk,
(chunkLoaded) => onUploadProgress?.(uploadedBytes + chunkLoaded, file.size)
)
uploadedBytes += chunk.size
onUploadProgress?.(uploadedBytes, file.size)
}
return request.post<KnowledgeDoc>(
`/avatar/${avatarId}/knowledge/uploads/${upload.uploadId}/complete`,
undefined,
{ timeout: 2 * 60 * 1000 }
)
}
const form = new FormData()
form.append('file', file)
return request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
// A slow mobile uplink must not be mistaken for a failed upload.
timeout: 10 * 60 * 1000,
onUploadProgress: (event) => onUploadProgress?.(event.loaded, event.total || file.size)
})
}
// 删除文档
export const deleteKnowledgeDoc = (avatarId: string, docId: string) =>
request.delete(`/avatar/${avatarId}/knowledge/docs/${docId}`)
export const retryKnowledgeDoc = (avatarId: string, docId: string) =>
request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs/${docId}/retry`)
// 标准问答对列表
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
+4
View File
@@ -0,0 +1,4 @@
<svg fill="#1677FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>支付宝</title>
<path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

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

After

Width:  |  Height:  |  Size: 1.3 KiB

+81
View File
@@ -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()
+145
View File
@@ -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
+113
View File
@@ -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
}
})
+5
View File
@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
+115
View File
@@ -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
}
})
+58
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
declare module '@dcloudio/uni-webview-js' {
const uniWebView: unknown
export default uniWebView
}
+50
View File
@@ -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
View File
@@ -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>
+185
View File
@@ -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>
+465
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More