mirror of
https://github.com/rachelos/we-mp-rss.git
synced 2026-09-24 16:02:42 +08:00
1.4.9-Fix
This commit is contained in:
+332
-4
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user