From d5f5c956f7bde33b269fe22b67d4473c0b2b559d Mon Sep 17 00:00:00 2001 From: RachelOS <1327444968@qq.com> Date: Mon, 30 Mar 2026 17:30:50 +0800 Subject: [PATCH] 1.4.9-Fix --- apis/tools.py | 336 +++++++++++++++++++++++++++++++++++++++++++++++++- driver/wx.py | 6 +- 2 files changed, 336 insertions(+), 6 deletions(-) diff --git a/apis/tools.py b/apis/tools.py index 60f88e0d..dfa3afad 100644 --- a/apis/tools.py +++ b/apis/tools.py @@ -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) \ No newline at end of file + 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)}") \ No newline at end of file diff --git a/driver/wx.py b/driver/wx.py index db173a3a..3a3b28a2 100644 --- a/driver/wx.py +++ b/driver/wx.py @@ -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()