WebUI 前端 & 后端超级大重构
This commit is contained in:
3
src/webui/routers/emoji/__init__.py
Normal file
3
src/webui/routers/emoji/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
843
src/webui/routers/emoji/routes.py
Normal file
843
src/webui/routers/emoji/routes.py
Normal file
@@ -0,0 +1,843 @@
|
||||
"""表情包管理 API 路由"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from PIL import Image
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import col, select
|
||||
|
||||
from src.common.database.database import get_db_session
|
||||
from src.common.database.database_model import Images, ImageType
|
||||
from src.webui.core import get_token_manager, verify_auth_token_from_cookie_or_header as verify_auth_token
|
||||
|
||||
from .schemas import (
|
||||
BatchDeleteRequest,
|
||||
BatchDeleteResponse,
|
||||
DescriptionForm,
|
||||
EmojiDeleteResponse,
|
||||
EmojiDetailResponse,
|
||||
EmojiFile,
|
||||
EmojiFiles,
|
||||
EmojiListResponse,
|
||||
EmojiUpdateRequest,
|
||||
EmojiUpdateResponse,
|
||||
EmojiUploadResponse,
|
||||
EmotionForm,
|
||||
IsRegisteredForm,
|
||||
ThumbnailCacheStatsResponse,
|
||||
ThumbnailCleanupResponse,
|
||||
ThumbnailPreheatResponse,
|
||||
emoji_to_response,
|
||||
)
|
||||
from .support import (
|
||||
EMOJI_REGISTERED_DIR,
|
||||
THUMBNAIL_CACHE_DIR,
|
||||
background_generate_thumbnail,
|
||||
cleanup_orphaned_thumbnails,
|
||||
ensure_thumbnail_cache_dir,
|
||||
generate_thumbnail,
|
||||
get_generating_lock,
|
||||
get_generating_thumbnails,
|
||||
get_thumbnail_cache_path,
|
||||
get_thumbnail_executor,
|
||||
logger,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/emoji", tags=["Emoji"])
|
||||
|
||||
|
||||
@router.get("/list", response_model=EmojiListResponse)
|
||||
async def get_emoji_list(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
search: Optional[str] = Query(None, description="搜索关键词"),
|
||||
is_registered: Optional[bool] = Query(None, description="是否已注册筛选"),
|
||||
is_banned: Optional[bool] = Query(None, description="是否被禁用筛选"),
|
||||
sort_by: Optional[str] = Query("query_count", description="排序字段"),
|
||||
sort_order: Optional[str] = Query("desc", description="排序方向"),
|
||||
maibot_session: Optional[str] = Cookie(None),
|
||||
) -> EmojiListResponse:
|
||||
"""获取表情包列表。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
statement = select(Images).where(col(Images.image_type) == ImageType.EMOJI)
|
||||
|
||||
if search:
|
||||
statement = statement.where(
|
||||
(col(Images.description).contains(search)) | (col(Images.image_hash).contains(search))
|
||||
)
|
||||
|
||||
if is_registered is not None:
|
||||
statement = statement.where(col(Images.is_registered) == is_registered)
|
||||
|
||||
if is_banned is not None:
|
||||
statement = statement.where(col(Images.is_banned) == is_banned)
|
||||
|
||||
sort_field_map = {
|
||||
"usage_count": col(Images.query_count),
|
||||
"query_count": col(Images.query_count),
|
||||
"register_time": col(Images.register_time),
|
||||
"record_time": col(Images.record_time),
|
||||
"last_used_time": col(Images.last_used_time),
|
||||
}
|
||||
sort_field = sort_field_map.get(sort_by or "query_count", col(Images.query_count))
|
||||
statement = statement.order_by(sort_field.asc() if sort_order == "asc" else sort_field.desc())
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
statement = statement.offset(offset).limit(page_size)
|
||||
|
||||
with get_db_session() as session:
|
||||
emojis = session.exec(statement).all()
|
||||
|
||||
count_statement = select(func.count()).select_from(Images).where(col(Images.image_type) == ImageType.EMOJI)
|
||||
if search:
|
||||
count_statement = count_statement.where(
|
||||
(col(Images.description).contains(search)) | (col(Images.image_hash).contains(search))
|
||||
)
|
||||
if is_registered is not None:
|
||||
count_statement = count_statement.where(col(Images.is_registered) == is_registered)
|
||||
if is_banned is not None:
|
||||
count_statement = count_statement.where(col(Images.is_banned) == is_banned)
|
||||
total = session.exec(count_statement).one()
|
||||
|
||||
return EmojiListResponse(
|
||||
success=True,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
data=[emoji_to_response(emoji) for emoji in emojis],
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"获取表情包列表失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取表情包列表失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.get("/{emoji_id}", response_model=EmojiDetailResponse)
|
||||
async def get_emoji_detail(emoji_id: int, maibot_session: Optional[str] = Cookie(None)) -> EmojiDetailResponse:
|
||||
"""获取表情包详细信息。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
with get_db_session() as session:
|
||||
statement = select(Images).where(
|
||||
col(Images.id) == emoji_id,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
if not (emoji := session.exec(statement).first()):
|
||||
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||
|
||||
return EmojiDetailResponse(success=True, data=emoji_to_response(emoji))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"获取表情包详情失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取表情包详情失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.patch("/{emoji_id}", response_model=EmojiUpdateResponse)
|
||||
async def update_emoji(
|
||||
emoji_id: int,
|
||||
request: EmojiUpdateRequest,
|
||||
maibot_session: Optional[str] = Cookie(None),
|
||||
) -> EmojiUpdateResponse:
|
||||
"""增量更新表情包。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
with get_db_session() as session:
|
||||
statement = select(Images).where(
|
||||
col(Images.id) == emoji_id,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
emoji = session.exec(statement).first()
|
||||
|
||||
if not emoji:
|
||||
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="未提供任何需要更新的字段")
|
||||
|
||||
if "is_registered" in update_data and update_data["is_registered"] and not emoji.is_registered:
|
||||
update_data["register_time"] = datetime.now()
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(emoji, field, value)
|
||||
|
||||
session.add(emoji)
|
||||
logger.info(f"表情包已更新: ID={emoji_id}, 字段: {list(update_data.keys())}")
|
||||
|
||||
return EmojiUpdateResponse(
|
||||
success=True,
|
||||
message=f"成功更新 {len(update_data)} 个字段",
|
||||
data=emoji_to_response(emoji),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"更新表情包失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"更新表情包失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.delete("/{emoji_id}", response_model=EmojiDeleteResponse)
|
||||
async def delete_emoji(emoji_id: int, maibot_session: Optional[str] = Cookie(None)) -> EmojiDeleteResponse:
|
||||
"""删除表情包。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
with get_db_session() as session:
|
||||
statement = select(Images).where(
|
||||
col(Images.id) == emoji_id,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
emoji = session.exec(statement).first()
|
||||
|
||||
if not emoji:
|
||||
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||
|
||||
emoji_hash = emoji.image_hash
|
||||
session.delete(emoji)
|
||||
logger.info(f"表情包已删除: ID={emoji_id}, hash={emoji_hash}")
|
||||
return EmojiDeleteResponse(success=True, message=f"成功删除表情包: {emoji_hash}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"删除表情包失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"删除表情包失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_emoji_stats(maibot_session: Optional[str] = Cookie(None)) -> dict[str, Any]:
|
||||
"""获取表情包统计数据。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
with get_db_session() as session:
|
||||
total_statement = select(func.count()).select_from(Images).where(col(Images.image_type) == ImageType.EMOJI)
|
||||
registered_statement = (
|
||||
select(func.count())
|
||||
.select_from(Images)
|
||||
.where(
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
col(Images.is_registered),
|
||||
)
|
||||
)
|
||||
banned_statement = (
|
||||
select(func.count())
|
||||
.select_from(Images)
|
||||
.where(
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
col(Images.is_banned),
|
||||
)
|
||||
)
|
||||
|
||||
total = session.exec(total_statement).one()
|
||||
registered = session.exec(registered_statement).one()
|
||||
banned = session.exec(banned_statement).one()
|
||||
|
||||
formats: dict[str, int] = {}
|
||||
format_statement = select(Images.full_path).where(col(Images.image_type) == ImageType.EMOJI)
|
||||
for full_path in session.exec(format_statement).all():
|
||||
suffix = Path(full_path).suffix.lower().lstrip(".")
|
||||
fmt = suffix or "unknown"
|
||||
formats[fmt] = formats.get(fmt, 0) + 1
|
||||
|
||||
top_used_statement = (
|
||||
select(Images)
|
||||
.where(col(Images.image_type) == ImageType.EMOJI)
|
||||
.order_by(col(Images.query_count).desc())
|
||||
.limit(10)
|
||||
)
|
||||
top_used_list = [
|
||||
{
|
||||
"id": emoji.id,
|
||||
"emoji_hash": emoji.image_hash,
|
||||
"description": emoji.description,
|
||||
"usage_count": emoji.query_count,
|
||||
}
|
||||
for emoji in session.exec(top_used_statement).all()
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"total": total,
|
||||
"registered": registered,
|
||||
"banned": banned,
|
||||
"unregistered": total - registered,
|
||||
"formats": formats,
|
||||
"top_used": top_used_list,
|
||||
},
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"获取统计数据失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.post("/{emoji_id}/register", response_model=EmojiUpdateResponse)
|
||||
async def register_emoji(emoji_id: int, maibot_session: Optional[str] = Cookie(None)) -> EmojiUpdateResponse:
|
||||
"""注册表情包。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
with get_db_session() as session:
|
||||
statement = select(Images).where(
|
||||
col(Images.id) == emoji_id,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
emoji = session.exec(statement).first()
|
||||
|
||||
if not emoji:
|
||||
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||
if emoji.is_registered:
|
||||
raise HTTPException(status_code=400, detail="该表情包已经注册")
|
||||
|
||||
emoji.is_registered = True
|
||||
emoji.is_banned = False
|
||||
emoji.register_time = datetime.now()
|
||||
session.add(emoji)
|
||||
|
||||
logger.info(f"表情包已注册: ID={emoji_id}")
|
||||
return EmojiUpdateResponse(success=True, message="表情包注册成功", data=emoji_to_response(emoji))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"注册表情包失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"注册表情包失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.post("/{emoji_id}/ban", response_model=EmojiUpdateResponse)
|
||||
async def ban_emoji(emoji_id: int, maibot_session: Optional[str] = Cookie(None)) -> EmojiUpdateResponse:
|
||||
"""禁用表情包。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
with get_db_session() as session:
|
||||
statement = select(Images).where(
|
||||
col(Images.id) == emoji_id,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
emoji = session.exec(statement).first()
|
||||
|
||||
if not emoji:
|
||||
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||
|
||||
emoji.is_banned = True
|
||||
emoji.is_registered = False
|
||||
session.add(emoji)
|
||||
|
||||
logger.info(f"表情包已禁用: ID={emoji_id}")
|
||||
return EmojiUpdateResponse(success=True, message="表情包禁用成功", data=emoji_to_response(emoji))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"禁用表情包失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"禁用表情包失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.get("/{emoji_id}/thumbnail", response_model=None)
|
||||
async def get_emoji_thumbnail(
|
||||
emoji_id: int,
|
||||
token: Optional[str] = Query(None, description="访问令牌"),
|
||||
maibot_session: Optional[str] = Cookie(None),
|
||||
original: bool = Query(False, description="是否返回原图"),
|
||||
) -> FileResponse | JSONResponse:
|
||||
"""获取表情包缩略图。"""
|
||||
try:
|
||||
token_manager = get_token_manager()
|
||||
is_valid = False
|
||||
|
||||
if maibot_session and token_manager.verify_token(maibot_session):
|
||||
is_valid = True
|
||||
elif token and token_manager.verify_token(token):
|
||||
is_valid = True
|
||||
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=401, detail="Token 无效或已过期")
|
||||
|
||||
with get_db_session() as session:
|
||||
statement = select(Images).where(
|
||||
col(Images.id) == emoji_id,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
emoji = session.exec(statement).first()
|
||||
|
||||
if not emoji:
|
||||
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||
if not os.path.exists(emoji.full_path):
|
||||
raise HTTPException(status_code=404, detail="表情包文件不存在")
|
||||
|
||||
if original:
|
||||
mime_types = {
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
"bmp": "image/bmp",
|
||||
}
|
||||
suffix = Path(emoji.full_path).suffix.lower().lstrip(".")
|
||||
media_type = mime_types.get(suffix, "application/octet-stream")
|
||||
return FileResponse(
|
||||
path=emoji.full_path,
|
||||
media_type=media_type,
|
||||
filename=f"{emoji.image_hash}.{suffix}",
|
||||
)
|
||||
|
||||
cache_path = get_thumbnail_cache_path(emoji.image_hash)
|
||||
if cache_path.exists():
|
||||
return FileResponse(
|
||||
path=str(cache_path),
|
||||
media_type="image/webp",
|
||||
filename=f"{emoji.image_hash}_thumb.webp",
|
||||
)
|
||||
|
||||
generating_lock = get_generating_lock()
|
||||
generating_thumbnails = get_generating_thumbnails()
|
||||
with generating_lock:
|
||||
if emoji.image_hash not in generating_thumbnails:
|
||||
generating_thumbnails.add(emoji.image_hash)
|
||||
get_thumbnail_executor().submit(background_generate_thumbnail, emoji.full_path, emoji.image_hash)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=202,
|
||||
content={
|
||||
"status": "generating",
|
||||
"message": "缩略图正在生成中,请稍后重试",
|
||||
"emoji_id": emoji_id,
|
||||
},
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"获取表情包缩略图失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取表情包缩略图失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.post("/batch/delete", response_model=BatchDeleteResponse)
|
||||
async def batch_delete_emojis(
|
||||
request: BatchDeleteRequest,
|
||||
maibot_session: Optional[str] = Cookie(None),
|
||||
) -> BatchDeleteResponse:
|
||||
"""批量删除表情包。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
if not request.emoji_ids:
|
||||
raise HTTPException(status_code=400, detail="未提供要删除的表情包ID")
|
||||
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
failed_ids: list[int] = []
|
||||
|
||||
for emoji_id in request.emoji_ids:
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
statement = select(Images).where(
|
||||
col(Images.id) == emoji_id,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
if emoji := session.exec(statement).first():
|
||||
session.delete(emoji)
|
||||
deleted_count += 1
|
||||
logger.info(f"批量删除表情包: {emoji_id}")
|
||||
else:
|
||||
failed_count += 1
|
||||
failed_ids.append(emoji_id)
|
||||
except Exception as e:
|
||||
logger.error(f"删除表情包 {emoji_id} 失败: {e}")
|
||||
failed_count += 1
|
||||
failed_ids.append(emoji_id)
|
||||
|
||||
message = f"成功删除 {deleted_count} 个表情包"
|
||||
if failed_count > 0:
|
||||
message += f",{failed_count} 个失败"
|
||||
|
||||
return BatchDeleteResponse(
|
||||
success=True,
|
||||
message=message,
|
||||
deleted_count=deleted_count,
|
||||
failed_count=failed_count,
|
||||
failed_ids=failed_ids,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"批量删除表情包失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"批量删除失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.post("/upload", response_model=EmojiUploadResponse)
|
||||
async def upload_emoji(
|
||||
file: EmojiFile,
|
||||
description: DescriptionForm = "",
|
||||
emotion: EmotionForm = "",
|
||||
is_registered: IsRegisteredForm = True,
|
||||
maibot_session: Optional[str] = Cookie(None),
|
||||
) -> EmojiUploadResponse:
|
||||
"""上传并注册表情包。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
if not file.content_type:
|
||||
raise HTTPException(status_code=400, detail="无法识别文件类型")
|
||||
|
||||
allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
if file.content_type not in allowed_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的文件类型: {file.content_type},支持: {', '.join(allowed_types)}",
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
if not file_content:
|
||||
raise HTTPException(status_code=400, detail="文件内容为空")
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(file_content)) as img:
|
||||
img.verify()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"无效的图片文件: {str(e)}") from e
|
||||
|
||||
with Image.open(io.BytesIO(file_content)) as img:
|
||||
img_format = img.format.lower() if img.format else "png"
|
||||
|
||||
emoji_hash = hashlib.md5(file_content).hexdigest()
|
||||
|
||||
with get_db_session() as session:
|
||||
existing_statement = select(Images).where(
|
||||
col(Images.image_hash) == emoji_hash,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
if existing_emoji := session.exec(existing_statement).first():
|
||||
raise HTTPException(status_code=409, detail=f"已存在相同的表情包 (ID: {existing_emoji.id})")
|
||||
|
||||
os.makedirs(EMOJI_REGISTERED_DIR, exist_ok=True)
|
||||
|
||||
timestamp = int(datetime.now().timestamp())
|
||||
filename = f"emoji_{timestamp}_{emoji_hash[:8]}.{img_format}"
|
||||
full_path = os.path.join(EMOJI_REGISTERED_DIR, filename)
|
||||
|
||||
counter = 1
|
||||
while os.path.exists(full_path):
|
||||
filename = f"emoji_{timestamp}_{emoji_hash[:8]}_{counter}.{img_format}"
|
||||
full_path = os.path.join(EMOJI_REGISTERED_DIR, filename)
|
||||
counter += 1
|
||||
|
||||
with open(full_path, "wb") as output_file:
|
||||
_ = output_file.write(file_content)
|
||||
|
||||
logger.info(f"表情包文件已保存: {full_path}")
|
||||
emotion_str = ",".join(item.strip() for item in emotion.split(",") if item.strip()) if emotion else ""
|
||||
|
||||
current_time = datetime.now()
|
||||
with get_db_session() as session:
|
||||
emoji = Images(
|
||||
image_type=ImageType.EMOJI,
|
||||
full_path=full_path,
|
||||
image_hash=emoji_hash,
|
||||
description=description,
|
||||
emotion=emotion_str or None,
|
||||
query_count=0,
|
||||
is_registered=is_registered,
|
||||
is_banned=False,
|
||||
record_time=current_time,
|
||||
register_time=current_time if is_registered else None,
|
||||
last_used_time=None,
|
||||
)
|
||||
session.add(emoji)
|
||||
session.flush()
|
||||
|
||||
logger.info(f"表情包已上传并注册: ID={emoji.id}, hash={emoji_hash}")
|
||||
return EmojiUploadResponse(
|
||||
success=True,
|
||||
message="表情包上传成功" + ("并已注册" if is_registered else ""),
|
||||
data=emoji_to_response(emoji),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"上传表情包失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.post("/batch/upload")
|
||||
async def batch_upload_emoji(
|
||||
files: EmojiFiles,
|
||||
emotion: EmotionForm = "",
|
||||
is_registered: IsRegisteredForm = True,
|
||||
maibot_session: Optional[str] = Cookie(None),
|
||||
) -> dict[str, Any]:
|
||||
"""批量上传表情包。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
results: dict[str, Any] = {
|
||||
"success": True,
|
||||
"total": len(files),
|
||||
"uploaded": 0,
|
||||
"failed": 0,
|
||||
"details": [],
|
||||
}
|
||||
|
||||
allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
os.makedirs(EMOJI_REGISTERED_DIR, exist_ok=True)
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
if file.content_type not in allowed_types:
|
||||
results["failed"] += 1
|
||||
results["details"].append(
|
||||
{
|
||||
"filename": file.filename,
|
||||
"success": False,
|
||||
"error": f"不支持的文件类型: {file.content_type}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
file_content = await file.read()
|
||||
if not file_content:
|
||||
results["failed"] += 1
|
||||
results["details"].append(
|
||||
{"filename": file.filename, "success": False, "error": "文件内容为空"}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(file_content)) as img:
|
||||
img_format = img.format.lower() if img.format else "png"
|
||||
except Exception as e:
|
||||
results["failed"] += 1
|
||||
results["details"].append(
|
||||
{"filename": file.filename, "success": False, "error": f"无效的图片: {str(e)}"}
|
||||
)
|
||||
continue
|
||||
|
||||
emoji_hash = hashlib.md5(file_content).hexdigest()
|
||||
|
||||
with get_db_session() as session:
|
||||
existing_statement = select(Images).where(
|
||||
col(Images.image_hash) == emoji_hash,
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
)
|
||||
if session.exec(existing_statement).first():
|
||||
results["failed"] += 1
|
||||
results["details"].append(
|
||||
{"filename": file.filename, "success": False, "error": "已存在相同的表情包"}
|
||||
)
|
||||
continue
|
||||
|
||||
timestamp = int(datetime.now().timestamp())
|
||||
filename = f"emoji_{timestamp}_{emoji_hash[:8]}.{img_format}"
|
||||
full_path = os.path.join(EMOJI_REGISTERED_DIR, filename)
|
||||
|
||||
counter = 1
|
||||
while os.path.exists(full_path):
|
||||
filename = f"emoji_{timestamp}_{emoji_hash[:8]}_{counter}.{img_format}"
|
||||
full_path = os.path.join(EMOJI_REGISTERED_DIR, filename)
|
||||
counter += 1
|
||||
|
||||
with open(full_path, "wb") as output_file:
|
||||
_ = output_file.write(file_content)
|
||||
|
||||
emotion_str = ",".join(item.strip() for item in emotion.split(",") if item.strip()) if emotion else ""
|
||||
current_time = datetime.now()
|
||||
|
||||
with get_db_session() as session:
|
||||
emoji = Images(
|
||||
image_type=ImageType.EMOJI,
|
||||
full_path=full_path,
|
||||
image_hash=emoji_hash,
|
||||
description="",
|
||||
emotion=emotion_str or None,
|
||||
query_count=0,
|
||||
is_registered=is_registered,
|
||||
is_banned=False,
|
||||
record_time=current_time,
|
||||
register_time=current_time if is_registered else None,
|
||||
last_used_time=None,
|
||||
)
|
||||
session.add(emoji)
|
||||
session.flush()
|
||||
|
||||
results["uploaded"] += 1
|
||||
results["details"].append(
|
||||
{"filename": file.filename, "success": True, "id": emoji.id}
|
||||
)
|
||||
except Exception as e:
|
||||
results["failed"] += 1
|
||||
results["details"].append(
|
||||
{"filename": file.filename, "success": False, "error": str(e)}
|
||||
)
|
||||
|
||||
results["message"] = f"成功上传 {results['uploaded']} 个,失败 {results['failed']} 个"
|
||||
return results
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"批量上传表情包失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"批量上传失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.get("/thumbnail-cache/stats", response_model=ThumbnailCacheStatsResponse)
|
||||
async def get_thumbnail_cache_stats(maibot_session: Optional[str] = Cookie(None)) -> ThumbnailCacheStatsResponse:
|
||||
"""获取缩略图缓存统计信息。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
ensure_thumbnail_cache_dir()
|
||||
cache_files = list(THUMBNAIL_CACHE_DIR.glob("*.webp"))
|
||||
total_count = len(cache_files)
|
||||
total_size_mb = round(sum(item.stat().st_size for item in cache_files) / (1024 * 1024), 2)
|
||||
|
||||
with get_db_session() as session:
|
||||
count_statement = select(func.count()).select_from(Images).where(col(Images.image_type) == ImageType.EMOJI)
|
||||
emoji_count = session.exec(count_statement).one()
|
||||
|
||||
coverage_percent = round((total_count / emoji_count * 100) if emoji_count > 0 else 0, 1)
|
||||
return ThumbnailCacheStatsResponse(
|
||||
success=True,
|
||||
cache_dir=str(THUMBNAIL_CACHE_DIR.absolute()),
|
||||
total_count=total_count,
|
||||
total_size_mb=total_size_mb,
|
||||
emoji_count=emoji_count,
|
||||
coverage_percent=coverage_percent,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"获取缩略图缓存统计失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取统计失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.post("/thumbnail-cache/cleanup", response_model=ThumbnailCleanupResponse)
|
||||
async def cleanup_thumbnail_cache(maibot_session: Optional[str] = Cookie(None)) -> ThumbnailCleanupResponse:
|
||||
"""清理孤立的缩略图缓存。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
cleaned, kept = cleanup_orphaned_thumbnails()
|
||||
return ThumbnailCleanupResponse(
|
||||
success=True,
|
||||
message=f"清理完成:删除 {cleaned} 个孤立缓存,保留 {kept} 个有效缓存",
|
||||
cleaned_count=cleaned,
|
||||
kept_count=kept,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"清理缩略图缓存失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"清理失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.post("/thumbnail-cache/preheat", response_model=ThumbnailPreheatResponse)
|
||||
async def preheat_thumbnail_cache(
|
||||
limit: int = Query(100, ge=1, le=1000, description="最多预热数量"),
|
||||
maibot_session: Optional[str] = Cookie(None),
|
||||
) -> ThumbnailPreheatResponse:
|
||||
"""预热缩略图缓存。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
ensure_thumbnail_cache_dir()
|
||||
|
||||
with get_db_session() as session:
|
||||
statement = (
|
||||
select(Images)
|
||||
.where(
|
||||
col(Images.image_type) == ImageType.EMOJI,
|
||||
col(Images.is_banned).is_(False),
|
||||
)
|
||||
.order_by(col(Images.query_count).desc())
|
||||
.limit(limit * 2)
|
||||
)
|
||||
emojis = session.exec(statement).all()
|
||||
|
||||
generated = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for emoji in emojis:
|
||||
if generated >= limit:
|
||||
break
|
||||
|
||||
cache_path = get_thumbnail_cache_path(emoji.image_hash)
|
||||
if cache_path.exists():
|
||||
skipped += 1
|
||||
continue
|
||||
if not os.path.exists(emoji.full_path):
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(get_thumbnail_executor(), generate_thumbnail, emoji.full_path, emoji.image_hash)
|
||||
generated += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"预热缩略图失败 {emoji.image_hash}: {e}")
|
||||
failed += 1
|
||||
|
||||
return ThumbnailPreheatResponse(
|
||||
success=True,
|
||||
message=f"预热完成:生成 {generated} 个,跳过 {skipped} 个已缓存,失败 {failed} 个",
|
||||
generated_count=generated,
|
||||
skipped_count=skipped,
|
||||
failed_count=failed,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"预热缩略图缓存失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"预热失败: {str(e)}") from e
|
||||
|
||||
|
||||
@router.delete("/thumbnail-cache/clear", response_model=ThumbnailCleanupResponse)
|
||||
async def clear_all_thumbnail_cache(maibot_session: Optional[str] = Cookie(None)) -> ThumbnailCleanupResponse:
|
||||
"""清空所有缩略图缓存。"""
|
||||
try:
|
||||
verify_auth_token(maibot_session)
|
||||
|
||||
if not THUMBNAIL_CACHE_DIR.exists():
|
||||
return ThumbnailCleanupResponse(
|
||||
success=True,
|
||||
message="缓存目录不存在,无需清理",
|
||||
cleaned_count=0,
|
||||
kept_count=0,
|
||||
)
|
||||
|
||||
cleaned = 0
|
||||
for cache_file in THUMBNAIL_CACHE_DIR.glob("*.webp"):
|
||||
try:
|
||||
cache_file.unlink()
|
||||
cleaned += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"删除缓存文件失败 {cache_file.name}: {e}")
|
||||
|
||||
logger.info(f"已清空缩略图缓存: 删除 {cleaned} 个文件")
|
||||
return ThumbnailCleanupResponse(
|
||||
success=True,
|
||||
message=f"已清空所有缩略图缓存:删除 {cleaned} 个文件",
|
||||
cleaned_count=cleaned,
|
||||
kept_count=0,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"清空缩略图缓存失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"清空失败: {str(e)}") from e
|
||||
140
src/webui/routers/emoji/schemas.py
Normal file
140
src/webui/routers/emoji/schemas.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
from fastapi import File, Form, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.common.database.database_model import Images
|
||||
|
||||
EmojiFile = Annotated[UploadFile, File(description="表情包图片文件")]
|
||||
EmojiFiles = Annotated[List[UploadFile], File(description="多个表情包图片文件")]
|
||||
DescriptionForm = Annotated[str, Form(description="表情包描述")]
|
||||
EmotionForm = Annotated[str, Form(description="情感标签,多个用逗号分隔")]
|
||||
IsRegisteredForm = Annotated[bool, Form(description="是否直接注册")]
|
||||
|
||||
|
||||
class EmojiResponse(BaseModel):
|
||||
"""表情包响应"""
|
||||
|
||||
id: int
|
||||
full_path: str
|
||||
emoji_hash: str
|
||||
description: str
|
||||
query_count: int
|
||||
is_registered: bool
|
||||
is_banned: bool
|
||||
emotion: Optional[str]
|
||||
record_time: float
|
||||
register_time: Optional[float]
|
||||
last_used_time: Optional[float]
|
||||
|
||||
|
||||
class EmojiListResponse(BaseModel):
|
||||
"""表情包列表响应"""
|
||||
|
||||
success: bool
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
data: List[EmojiResponse]
|
||||
|
||||
|
||||
class EmojiDetailResponse(BaseModel):
|
||||
"""表情包详情响应"""
|
||||
|
||||
success: bool
|
||||
data: EmojiResponse
|
||||
|
||||
|
||||
class EmojiUpdateRequest(BaseModel):
|
||||
"""表情包更新请求"""
|
||||
|
||||
description: Optional[str] = None
|
||||
is_registered: Optional[bool] = None
|
||||
is_banned: Optional[bool] = None
|
||||
emotion: Optional[str] = None
|
||||
|
||||
|
||||
class EmojiUpdateResponse(BaseModel):
|
||||
"""表情包更新响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[EmojiResponse] = None
|
||||
|
||||
|
||||
class EmojiDeleteResponse(BaseModel):
|
||||
"""表情包删除响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求"""
|
||||
|
||||
emoji_ids: List[int]
|
||||
|
||||
|
||||
class BatchDeleteResponse(BaseModel):
|
||||
"""批量删除响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_count: int
|
||||
failed_count: int
|
||||
failed_ids: List[int] = []
|
||||
|
||||
|
||||
class EmojiUploadResponse(BaseModel):
|
||||
"""表情包上传响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
data: Optional[EmojiResponse] = None
|
||||
|
||||
|
||||
class ThumbnailCacheStatsResponse(BaseModel):
|
||||
"""缩略图缓存统计响应"""
|
||||
|
||||
success: bool
|
||||
cache_dir: str
|
||||
total_count: int
|
||||
total_size_mb: float
|
||||
emoji_count: int
|
||||
coverage_percent: float
|
||||
|
||||
|
||||
class ThumbnailCleanupResponse(BaseModel):
|
||||
"""缩略图清理响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
cleaned_count: int
|
||||
kept_count: int
|
||||
|
||||
|
||||
class ThumbnailPreheatResponse(BaseModel):
|
||||
"""缩略图预热响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
generated_count: int
|
||||
skipped_count: int
|
||||
failed_count: int
|
||||
|
||||
|
||||
def emoji_to_response(image: Images) -> EmojiResponse:
|
||||
"""将数据库表情包模型转换为响应对象。"""
|
||||
return EmojiResponse(
|
||||
id=image.id if image.id is not None else 0,
|
||||
full_path=image.full_path,
|
||||
emoji_hash=image.image_hash,
|
||||
description=image.description,
|
||||
query_count=image.query_count,
|
||||
is_registered=image.is_registered,
|
||||
is_banned=image.is_banned,
|
||||
emotion=image.emotion,
|
||||
record_time=image.record_time.timestamp() if image.record_time else 0.0,
|
||||
register_time=image.register_time.timestamp() if image.register_time else None,
|
||||
last_used_time=image.last_used_time.timestamp() if image.last_used_time else None,
|
||||
)
|
||||
142
src/webui/routers/emoji/support.py
Normal file
142
src/webui/routers/emoji/support.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
from PIL import Image
|
||||
from sqlmodel import col, select
|
||||
|
||||
from src.common.database.database import get_db_session
|
||||
from src.common.database.database_model import Images, ImageType
|
||||
from src.common.logger import get_logger
|
||||
|
||||
logger = get_logger("webui.emoji")
|
||||
|
||||
THUMBNAIL_CACHE_DIR = Path("data/emoji_thumbnails")
|
||||
THUMBNAIL_SIZE = (200, 200)
|
||||
THUMBNAIL_QUALITY = 80
|
||||
EMOJI_REGISTERED_DIR = os.path.join("data", "emoji_registed")
|
||||
|
||||
_thumbnail_locks: dict[str, threading.Lock] = {}
|
||||
_locks_lock = threading.Lock()
|
||||
_thumbnail_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="thumbnail")
|
||||
_generating_thumbnails: set[str] = set()
|
||||
_generating_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_thumbnail_executor() -> ThreadPoolExecutor:
|
||||
"""获取缩略图生成线程池。"""
|
||||
return _thumbnail_executor
|
||||
|
||||
|
||||
def get_generating_lock() -> threading.Lock:
|
||||
"""获取缩略图生成状态锁。"""
|
||||
return _generating_lock
|
||||
|
||||
|
||||
def get_generating_thumbnails() -> set[str]:
|
||||
"""获取正在生成的缩略图哈希集合。"""
|
||||
return _generating_thumbnails
|
||||
|
||||
|
||||
def _get_thumbnail_lock(file_hash: str) -> threading.Lock:
|
||||
"""获取指定文件哈希的锁,用于防止并发生成同一缩略图。"""
|
||||
with _locks_lock:
|
||||
if file_hash not in _thumbnail_locks:
|
||||
_thumbnail_locks[file_hash] = threading.Lock()
|
||||
return _thumbnail_locks[file_hash]
|
||||
|
||||
|
||||
def _background_generate_thumbnail(source_path: str, file_hash: str) -> None:
|
||||
"""在线程池中后台生成缩略图。"""
|
||||
try:
|
||||
_generate_thumbnail(source_path, file_hash)
|
||||
except Exception as e:
|
||||
logger.warning(f"后台生成缩略图失败 {file_hash}: {e}")
|
||||
finally:
|
||||
with _generating_lock:
|
||||
_generating_thumbnails.discard(file_hash)
|
||||
|
||||
|
||||
def ensure_thumbnail_cache_dir() -> Path:
|
||||
"""确保缩略图缓存目录存在。"""
|
||||
_ = THUMBNAIL_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return THUMBNAIL_CACHE_DIR
|
||||
|
||||
|
||||
def get_thumbnail_cache_path(file_hash: str) -> Path:
|
||||
"""获取缩略图缓存路径。"""
|
||||
return THUMBNAIL_CACHE_DIR / f"{file_hash}.webp"
|
||||
|
||||
|
||||
def _generate_thumbnail(source_path: str, file_hash: str) -> Path:
|
||||
"""生成缩略图并保存到缓存目录。"""
|
||||
ensure_thumbnail_cache_dir()
|
||||
cache_path = get_thumbnail_cache_path(file_hash)
|
||||
|
||||
lock = _get_thumbnail_lock(file_hash)
|
||||
with lock:
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
try:
|
||||
with Image.open(source_path) as img:
|
||||
if getattr(img, "n_frames", 1) > 1:
|
||||
img.seek(0)
|
||||
|
||||
if img.mode in ("P", "PA"):
|
||||
img = img.convert("RGBA")
|
||||
elif img.mode == "LA":
|
||||
img = img.convert("RGBA")
|
||||
elif img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
img.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
||||
img.save(cache_path, "WEBP", quality=THUMBNAIL_QUALITY, method=6)
|
||||
logger.debug(f"生成缩略图: {file_hash} -> {cache_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"生成缩略图失败 {file_hash}: {e},将返回原图")
|
||||
raise
|
||||
|
||||
return cache_path
|
||||
|
||||
|
||||
def generate_thumbnail(source_path: str, file_hash: str) -> Path:
|
||||
"""暴露给路由层的缩略图生成函数。"""
|
||||
return _generate_thumbnail(source_path, file_hash)
|
||||
|
||||
|
||||
def background_generate_thumbnail(source_path: str, file_hash: str) -> None:
|
||||
"""暴露给路由层的后台缩略图生成函数。"""
|
||||
_background_generate_thumbnail(source_path, file_hash)
|
||||
|
||||
|
||||
def cleanup_orphaned_thumbnails() -> tuple[int, int]:
|
||||
"""清理孤立的缩略图缓存。"""
|
||||
if not THUMBNAIL_CACHE_DIR.exists():
|
||||
return 0, 0
|
||||
|
||||
with get_db_session() as session:
|
||||
statement = select(Images.image_hash).where(col(Images.image_type) == ImageType.EMOJI)
|
||||
valid_hashes = set(session.exec(statement).all())
|
||||
|
||||
cleaned = 0
|
||||
kept = 0
|
||||
|
||||
for cache_file in THUMBNAIL_CACHE_DIR.glob("*.webp"):
|
||||
file_hash = cache_file.stem
|
||||
if file_hash not in valid_hashes:
|
||||
try:
|
||||
cache_file.unlink()
|
||||
cleaned += 1
|
||||
logger.debug(f"清理孤立缩略图: {cache_file.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理缩略图失败 {cache_file.name}: {e}")
|
||||
else:
|
||||
kept += 1
|
||||
|
||||
if cleaned > 0:
|
||||
logger.info(f"清理孤立缩略图: 删除 {cleaned} 个,保留 {kept} 个")
|
||||
|
||||
return cleaned, kept
|
||||
Reference in New Issue
Block a user