- 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
202 lines
6.8 KiB
Python
202 lines
6.8 KiB
Python
"""数字分身管理 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()
|