"""数字分身管理 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()