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:
@@ -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()
|
||||||
|
|||||||
@@ -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" } })
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user