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
This commit is contained in:
stefanfeng
2026-08-07 14:28:22 +08:00
parent a5f7c86a4b
commit b7f8ac203b
3 changed files with 163 additions and 4 deletions
+131 -1
View File
@@ -1,8 +1,14 @@
"""数字分身管理 API 端点""" """数字分身管理 API 端点"""
from fastapi import APIRouter, Query, HTTPException 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.schemas import ApiResponse
from app.services.avatar_service import avatar_service, get_session, is_available 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 = APIRouter()
@@ -69,3 +75,127 @@ def update_avatar_status(avatar_id: str, body: dict):
return ApiResponse(data=data, message=msg) return ApiResponse(data=data, message=msg)
finally: finally:
db.close() 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()
+3
View File
@@ -90,6 +90,9 @@ export const tailLogFile = (filename, lines = 100) => request.get(`/logs/files/$
export const getAvatars = (params) => request.get('/avatars', { params }) export const getAvatars = (params) => request.get('/avatars', { params })
export const getAvatarDetail = (id) => request.get(`/avatars/${id}`) export const getAvatarDetail = (id) => request.get(`/avatars/${id}`)
export const updateAvatarStatus = (id, status) => request.put(`/avatars/${id}/status`, { status }) export const updateAvatarStatus = (id, status) => request.put(`/avatars/${id}/status`, { status })
export const uploadAvatarPhoto = (id, formData) => request.post(`/avatars/${id}/upload-photo`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
export default request export default request
export const uploadAvatar = (userId, formData) => request.post(`/users/${userId}/upload-avatar`, formData, { headers: { "Content-Type": "multipart/form-data" } }) export const uploadAvatar = (userId, formData) => request.post(`/users/${userId}/upload-avatar`, formData, { headers: { "Content-Type": "multipart/form-data" } })
+28 -2
View File
@@ -76,9 +76,14 @@
</el-tooltip> </el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="100" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div style="display:flex;gap:4px">
<el-upload :show-file-list="false" accept="image/*" :before-upload="(file) => handleUploadPhoto(row, file)">
<el-button size="small" :loading="row._uploading"> 上传</el-button>
</el-upload>
<el-button size="small" @click="openDetail(row)">详情</el-button> <el-button size="small" @click="openDetail(row)">详情</el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -195,7 +200,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getAvatars, updateAvatarStatus } from '@/api' import { getAvatars, updateAvatarStatus, uploadAvatarPhoto } from '@/api'
const avatars = ref([]) const avatars = ref([])
const total = ref(0) const total = ref(0)
@@ -255,6 +260,27 @@ function openDetail(row) {
detailVisible.value = true detailVisible.value = true
} }
async function handleUploadPhoto(row, file) {
const formData = new FormData()
formData.append('file', file)
row._uploading = true
try {
const res = await uploadAvatarPhoto(row.id, formData)
if (res.code === 200) {
row.photo_url = res.data?.photo_url || row.photo_url
ElMessage.success('头像上传成功')
loadAvatars()
} else {
ElMessage.error(res.message || '上传失败')
}
} catch (e) {
ElMessage.error('上传失败:' + (e.message || '未知错误'))
} finally {
row._uploading = false
}
return false // prevent el-upload default behavior
}
function handleAvatarError(e) { function handleAvatarError(e) {
// 图片加载失败时隐藏 img,只保留 emoji fallback // 图片加载失败时隐藏 img,只保留 emoji fallback
e.target.style.display = 'none' e.target.style.display = 'none'