1.4.9-Fix

This commit is contained in:
RachelOS
2026-03-30 17:30:50 +08:00
parent 1c1e3d9bed
commit d5f5c956f7
2 changed files with 336 additions and 6 deletions
+332 -4
View File
@@ -1,20 +1,26 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body, File, UploadFile, Form
from fastapi.responses import FileResponse, Response
from starlette.background import BackgroundTask
from pydantic import BaseModel, Field
from core.auth import get_current_user_or_ak
from core.db import DB
from .base import success_response, error_response,BaseResponse
from datetime import datetime
from typing import Optional, List
from typing import Optional, List, Literal
import os
import threading
import asyncio
from concurrent.futures import ThreadPoolExecutor
import io
import uuid
import base64
# 导入导出工具
from tools.mdtools.export import export_md_to_doc, process_articles
# 图片处理
from PIL import Image
router = APIRouter(prefix="/tools", tags=["工具"])
# Schema 模型定义
@@ -307,4 +313,326 @@ async def delete_export_file_by_query(
"""
# 创建请求对象并调用主删除函数
request = DeleteFileRequest(filename=filename, mp_id=mp_id)
return await delete_export_file(request, current_user)
return await delete_export_file(request, current_user)
# ==================== 图片裁剪功能 ====================
# 裁剪方式枚举
CropMode = Literal[
"center", "top", "bottom", "left", "right",
"top-left", "top-right", "bottom-left", "bottom-right"
]
class ImageCropRequest(BaseModel):
"""图片裁剪请求模型(用于URL或base64输入)"""
image_url: Optional[str] = Field(None, description="图片URL地址")
image_base64: Optional[str] = Field(None, description="Base64编码的图片数据")
aspect_ratio: Optional[str] = Field(None, description="目标比例,如 '16:9', '4:3', '1:1' 或自定义 '800:600'")
width: Optional[int] = Field(None, description="目标宽度(像素),与aspect_ratio二选一")
height: Optional[int] = Field(None, description="目标高度(像素),与aspect_ratio二选一")
mode: CropMode = Field("center", description="裁剪方式:center(居中), top(顶部), bottom(底部), left(左侧), right(右侧), top-left, top-right, bottom-left, bottom-right")
output_format: str = Field("png", description="输出格式:png, jpeg, webp")
return_base64: bool = Field(False, description="是否返回base64编码,默认返回文件下载")
class ImageCropResponse(BaseModel):
"""图片裁剪响应模型"""
width: int = Field(..., description="裁剪后宽度")
height: int = Field(..., description="裁剪后高度")
original_width: int = Field(..., description="原始宽度")
original_height: int = Field(..., description="原始高度")
format: str = Field(..., description="输出格式")
file_url: Optional[str] = Field(None, description="文件下载地址")
base64: Optional[str] = Field(None, description="Base64编码的图片数据")
def calculate_crop_box(
original_width: int,
original_height: int,
target_ratio: float,
mode: str
) -> tuple:
"""
计算裁剪区域
Args:
original_width: 原图宽度
original_height: 原图高度
target_ratio: 目标宽高比 (width/height)
mode: 裁剪方式
Returns:
(left, top, right, bottom) 裁剪区域
"""
original_ratio = original_width / original_height
if original_ratio > target_ratio:
# 原图更宽,需要裁剪宽度
new_width = int(original_height * target_ratio)
new_height = original_height
# 根据模式确定水平位置
if mode in ["left", "top-left", "bottom-left"]:
left = 0
elif mode in ["right", "top-right", "bottom-right"]:
left = original_width - new_width
else: # center, top, bottom
left = (original_width - new_width) // 2
right = left + new_width
top = 0
bottom = original_height
else:
# 原图更高,需要裁剪高度
new_width = original_width
new_height = int(original_width / target_ratio)
# 根据模式确定垂直位置
if mode in ["top", "top-left", "top-right"]:
top = 0
elif mode in ["bottom", "bottom-left", "bottom-right"]:
top = original_height - new_height
else: # center, left, right
top = (original_height - new_height) // 2
bottom = top + new_height
left = 0
right = original_width
return (left, top, right, bottom)
def process_image_crop(
image_data: bytes,
aspect_ratio: Optional[str],
target_width: Optional[int],
target_height: Optional[int],
mode: str,
output_format: str
) -> tuple:
"""
处理图片裁剪
Returns:
(cropped_image_bytes, original_size, new_size)
"""
# 打开图片
img = Image.open(io.BytesIO(image_data))
original_width, original_height = img.size
# 如果是RGBA模式且输出格式不支持透明,转换为RGB
if img.mode == "RGBA" and output_format.lower() in ["jpeg", "jpg"]:
# 创建白色背景
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3] if len(img.split()) == 4 else None)
img = background
elif img.mode != "RGB" and img.mode != "RGBA":
img = img.convert("RGB")
# 计算目标比例
if aspect_ratio:
# 解析比例字符串
parts = aspect_ratio.split(":")
if len(parts) != 2:
raise ValueError(f"无效的比例格式: {aspect_ratio},正确格式如 '16:9'")
ratio_width, ratio_height = float(parts[0]), float(parts[1])
target_ratio = ratio_width / ratio_height
elif target_width and target_height:
target_ratio = target_width / target_height
elif target_width:
# 只指定宽度,按原图比例
target_ratio = target_width / (target_width * original_height / original_width)
elif target_height:
# 只指定高度,按原图比例
target_ratio = (target_height * original_width / original_height) / target_height
else:
# 不裁剪,直接返回原图
output_buffer = io.BytesIO()
save_format = "JPEG" if output_format.lower() in ["jpeg", "jpg"] else output_format.upper()
img.save(output_buffer, format=save_format)
return output_buffer.getvalue(), (original_width, original_height), (original_width, original_height)
# 计算裁剪区域
crop_box = calculate_crop_box(original_width, original_height, target_ratio, mode)
# 执行裁剪
cropped_img = img.crop(crop_box)
new_width, new_height = cropped_img.size
# 如果指定了精确尺寸,进行缩放
if target_width and target_height:
cropped_img = cropped_img.resize((target_width, target_height), Image.Resampling.LANCZOS)
new_width, new_height = target_width, target_height
# 输出到字节流
output_buffer = io.BytesIO()
save_format = "JPEG" if output_format.lower() in ["jpeg", "jpg"] else output_format.upper()
if save_format == "JPEG":
cropped_img.save(output_buffer, format=save_format, quality=95)
else:
cropped_img.save(output_buffer, format=save_format)
return output_buffer.getvalue(), (original_width, original_height), (new_width, new_height)
@router.post("/image/crop", summary="图片裁剪")
async def crop_image(
file: Optional[UploadFile] = File(None),
image_url: Optional[str] = Form(None),
image_base64: Optional[str] = Form(None),
aspect_ratio: Optional[str] = Form(None),
width: Optional[int] = Form(None),
height: Optional[int] = Form(None),
mode: CropMode = Form("center"),
output_format: str = Form("png"),
return_base64: bool = Form(False),
current_user: dict = Depends(get_current_user_or_ak)
):
"""
图片裁剪接口
支持三种图片输入方式(优先级从高到低):
1. file: 上传文件
2. image_url: 图片URL地址
3. image_base64: Base64编码的图片
裁剪参数:
- aspect_ratio: 目标比例,如 '16:9', '4:3', '1:1' 或自定义 '800:600'
- width/height: 目标尺寸(可选,同时指定会缩放到精确尺寸)
- mode: 裁剪方式
- center: 居中裁剪
- top: 顶部裁剪
- bottom: 底部裁剪
- left: 左侧裁剪
- right: 右侧裁剪
- top-left: 左上角裁剪
- top-right: 右上角裁剪
- bottom-left: 左下角裁剪
- bottom-right: 右下角裁剪
- output_format: 输出格式 (png/jpeg/webp)
- return_base64: 是否返回base64,默认返回文件下载
"""
try:
import httpx
# 获取图片数据
image_data = None
if file:
# 从上传文件获取
image_data = await file.read()
elif image_url:
# 从URL下载
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(image_url)
if response.status_code != 200:
return error_response(400, f"下载图片失败: HTTP {response.status_code}")
image_data = response.content
elif image_base64:
# 从base64解码
# 移除可能的data:image/xxx;base64,前缀
if "," in image_base64:
image_base64 = image_base64.split(",")[1]
image_data = base64.b64decode(image_base64)
else:
return error_response(400, "请提供图片:上传文件(file)、图片URL(image_url)或Base64数据(image_base64)")
# 执行裁剪
cropped_data, original_size, new_size = process_image_crop(
image_data=image_data,
aspect_ratio=aspect_ratio,
target_width=width,
target_height=height,
mode=mode,
output_format=output_format
)
# 返回结果
if return_base64:
# 返回base64
base64_data = base64.b64encode(cropped_data).decode("utf-8")
mime_type = f"image/{output_format.lower()}"
return success_response({
"width": new_size[0],
"height": new_size[1],
"original_width": original_size[0],
"original_height": original_size[1],
"format": output_format,
"base64": f"data:{mime_type};base64,{base64_data}"
})
else:
# 保存到临时文件并返回下载链接
temp_dir = "./data/temp/cropped"
os.makedirs(temp_dir, exist_ok=True)
filename = f"cropped_{uuid.uuid4().hex[:8]}_{datetime.now().strftime('%Y%m%d%H%M%S')}.{output_format}"
file_path = os.path.join(temp_dir, filename)
with open(file_path, "wb") as f:
f.write(cropped_data)
return success_response({
"width": new_size[0],
"height": new_size[1],
"original_width": original_size[0],
"original_height": original_size[1],
"format": output_format,
"file_url": f"/api/v1/tools/image/download/{filename}",
"filename": filename
})
except ValueError as e:
return error_response(400, str(e))
except Exception as e:
return error_response(500, f"图片裁剪失败: {str(e)}")
@router.get("/image/download/{filename}", summary="下载裁剪后的图片")
async def download_cropped_image(
filename: str,
delete_after_download: bool = Query(True, description="下载后删除临时文件")
):
"""
下载裁剪后的图片
"""
try:
temp_dir = os.path.abspath("./data/temp/cropped")
safe_path = os.path.abspath(os.path.join(temp_dir, filename))
# 安全检查
if not safe_path.startswith(temp_dir):
return error_response(403, "非法的文件路径请求")
if not os.path.exists(safe_path):
raise HTTPException(status_code=404, detail="文件不存在或已过期")
def cleanup_file():
try:
if os.path.exists(safe_path) and delete_after_download:
os.remove(safe_path)
except Exception:
pass
# 确定MIME类型
ext = filename.rsplit(".", 1)[-1].lower()
mime_map = {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"webp": "image/webp"
}
media_type = mime_map.get(ext, "application/octet-stream")
return FileResponse(
path=safe_path,
filename=filename,
media_type=media_type,
background=BackgroundTask(cleanup_file)
)
except HTTPException:
raise
except Exception as e:
return error_response(500, f"下载失败: {str(e)}")
+4 -2
View File
@@ -325,8 +325,10 @@ class Wx:
self.set_lock()
with self._login_lock:
self._haslogin = False
# 使用更短的锁持有时间,只保护变量修改
self._login_lock.acquire()
self._haslogin = False
self._login_lock.release()
# 清理现有资源
self.cleanup_resources()