You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

374 lines
14 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

from __future__ import annotations
import hashlib
import os
from datetime import datetime
from io import BytesIO
from typing import Dict, Optional
from flask import current_app, url_for
from sqlalchemy import select, exists
from iti.applications.common.enums import StatusEnum
from iti.applications.common.exceptions.biz_exp import BizException
from iti.applications.common.storage import StorageManager
from iti.applications.extensions import cache_simple, db
from iti.applications.models import SysFile, SysFileDirectory
class SysFileService:
TUS_CACHE_PREFIX = "tus_upload:"
# ------------------------------------------------------------------
# 普通上传
# ------------------------------------------------------------------
@classmethod
def upload_file(
cls,
file,
directory_id: Optional[str] = None,
metadata: Optional[Dict] = None,
storage_type: Optional[str] = None,
) -> SysFile:
metadata = metadata or {}
file.seek(0)
file_bytes = file.read()
file_hash = hashlib.md5(file_bytes).hexdigest()
file.seek(0)
resolved_storage_type = cls._resolve_storage_type(storage_type, directory_id)
existing = db.session.scalar(
select(
exists(SysFile).where(
SysFile.file_hash == file_hash,
SysFile.status == StatusEnum.ENABLED.value,
)
)
)
if existing and existing.storage_type == resolved_storage_type:
# 秒传:更新已有记录
existing.filename = file.filename
existing.directory_id = directory_id
existing.metadata_ = metadata if metadata else None
db.session.commit()
return existing
storage = StorageManager.get_storage(resolved_storage_type)
ext = os.path.splitext(file.filename or "")[1]
# 为支持多存储类型,前缀加上存储类型,使用冒号分隔,避免唯一索引冲突
file_key = f"{resolved_storage_type}:{datetime.now():%Y%m%d}/{file_hash}{ext}"
upload_result = storage.upload(
BytesIO(file_bytes), file_key, getattr(file, "mimetype", None)
)
new_file = SysFile(
filename=file.filename,
file_key=file_key,
file_hash=file_hash,
mime_type=getattr(file, "mimetype", None),
file_size=len(file_bytes),
extension=ext,
storage_type=storage.storage_type,
storage_info=upload_result if resolved_storage_type != "local" else None,
directory_id=directory_id,
metadata_=metadata if metadata else None,
status=StatusEnum.ENABLED.value,
)
db.session.add(new_file)
db.session.commit()
return new_file
# ------------------------------------------------------------------
# TUS 上传
# ------------------------------------------------------------------
@classmethod
def init_tus_upload(
cls,
filename: str,
file_size: int,
file_hash: Optional[str] = None,
directory_id: Optional[str] = None,
metadata: Optional[Dict] = None,
storage_type: Optional[str] = None,
) -> Dict:
metadata = metadata or {}
resolved_storage_type = cls._resolve_storage_type(storage_type, directory_id)
if file_hash:
existing = db.session.scalar(
select(SysFile)
.filter_by(file_hash=file_hash, status=StatusEnum.ENABLED.value)
.limit(1)
)
if existing and existing.storage_type == resolved_storage_type:
# 秒传:更新已有记录
existing.filename = filename
existing.directory_id = directory_id
existing.metadata_ = metadata if metadata else None
db.session.commit()
return {"instant_upload": True, "file": existing}
import uuid
upload_id = str(uuid.uuid4())
ext = os.path.splitext(filename or "")[1]
# TUS 任务阶段同样加上存储类型前缀
file_key = f"{resolved_storage_type}:{datetime.now():%Y%m%d}/{upload_id}{ext}"
resolved_storage_type = cls._resolve_storage_type(storage_type, directory_id)
storage = StorageManager.get_storage(resolved_storage_type)
upload_data = {
"upload_id": upload_id,
"filename": filename,
"file_key": file_key,
"file_hash": file_hash,
"file_size": file_size,
"offset": 0,
"storage_type": storage.storage_type,
"directory_id": directory_id,
"metadata": metadata,
"created_at": datetime.now().isoformat(),
}
cache_simple.set(cls._cache_key(upload_id), upload_data, timeout=7 * 24 * 3600)
return {"instant_upload": False, "upload_id": upload_id, "file_key": file_key}
@classmethod
def get_tus_upload_progress(cls, upload_id: str) -> Dict:
upload_data = cache_simple.get(cls._cache_key(upload_id))
if not upload_data:
raise BizException("上传任务不存在或已过期", code=404)
return {
"offset": upload_data.get("offset", 0),
"total_size": upload_data.get("file_size", 0),
}
@classmethod
def upload_tus_chunk(cls, upload_id: str, offset: int, chunk_data: bytes) -> Dict:
cache_key = cls._cache_key(upload_id)
upload_data = cache_simple.get(cache_key)
if not upload_data:
raise BizException("上传任务不存在或已过期", code=404)
current_offset = upload_data.get("offset", 0)
if offset != current_offset:
raise BizException(
f"偏移量不匹配: 期望 {current_offset}, 实际 {offset}", code=409
)
storage = StorageManager.get_storage(upload_data["storage_type"])
chunk_stream = BytesIO(chunk_data)
storage.append_chunk(upload_data["file_key"], chunk_stream, offset)
new_offset = offset + len(chunk_data)
upload_data["offset"] = new_offset
file_size = upload_data.get("file_size", 0)
if new_offset >= file_size:
storage_type = upload_data.get("storage_type")
metadata = upload_data.get("metadata")
file_record = SysFile(
filename=upload_data.get("filename"),
file_key=upload_data.get("file_key"),
file_hash=upload_data.get("file_hash"),
file_size=file_size,
extension=os.path.splitext(upload_data.get("filename") or "")[1],
storage_type=storage_type,
storage_info=upload_data.get("storage_info")
if storage_type != "local"
else None,
directory_id=upload_data.get("directory_id"),
metadata_=metadata if metadata else None,
status=StatusEnum.ENABLED.value,
)
db.session.add(file_record)
db.session.commit()
cache_simple.delete(cache_key)
return {"completed": True, "new_offset": new_offset, "file": file_record}
cache_simple.set(cache_key, upload_data, timeout=7 * 24 * 3600)
return {"completed": False, "new_offset": new_offset}
@classmethod
def abort_tus_upload(cls, upload_id: str) -> None:
cache_key = cls._cache_key(upload_id)
upload_data = cache_simple.get(cache_key)
if not upload_data:
raise BizException("上传任务不存在或已过期", code=404)
storage = StorageManager.get_storage(upload_data["storage_type"])
try:
storage.delete(upload_data["file_key"])
except FileNotFoundError:
pass
cache_simple.delete(cache_key)
# ------------------------------------------------------------------
# 文件访问工具
# ------------------------------------------------------------------
@staticmethod
def get_file_by_id(file_id: str) -> SysFile:
file_obj = db.session.get(SysFile, file_id)
if not file_obj or file_obj.status != StatusEnum.ENABLED.value:
raise BizException("文件不存在", code=404)
return file_obj
@classmethod
def get_file_url(cls, file_id: str, expires: int = 3600) -> str:
"""
获取文件访问URL
Args:
file_id: 文件ID
expires: 过期时间0表示永久仅对OSS生效
Returns:
文件访问URL
"""
file_obj = cls.get_file_by_id(file_id)
storage = StorageManager.get_storage(file_obj.storage_type)
# 本地存储返回后端下载路由
if file_obj.storage_type == "local":
return url_for(
"sys.sys_file.download_file", file_id=file_id, _external=True
)
# OSS存储返回直接访问URL
return storage.get_url(file_obj.file_key, expires=expires)
@classmethod
def get_preview_url(cls, file_id: str) -> str:
"""获取预览URL
- local: 返回后端预览路由
- 非local: 委托存储适配器生成签名直链(可包含预览处理能力)
"""
file_obj = cls.get_file_by_id(file_id)
if file_obj.storage_type == "local":
return url_for("sys.sys_file.preview_file", file_id=file_id, _external=True)
storage = StorageManager.get_storage(file_obj.storage_type)
return storage.get_preview_url(file_obj.file_key, expires=3600)
@classmethod
def get_thumbnail_url(
cls, file_id: str, width: int = 200, height: int = 200, mode: str = "fit"
) -> Optional[str]:
"""获取缩略图URL
- local: 返回后端缩略图路由
- 阿里云OSS: 使用 x-oss-process 生成处理后直链
- 其它OSS: 暂返回原文件直链(后续可分别接入各自图片处理能力)
"""
file_obj = cls.get_file_by_id(file_id)
if not file_obj.mime_type or not file_obj.mime_type.startswith("image/"):
return None
if file_obj.storage_type == "local":
return url_for(
"sys.sys_file.thumbnail_file",
file_id=file_id,
w=width,
h=height,
mode=mode,
_external=True,
)
storage = StorageManager.get_storage(file_obj.storage_type)
# 委托存储适配器生成缩略图直链(包含签名与处理参数)
return storage.get_thumbnail_url(
file_obj.file_key, width=width, height=height, mode=mode, expires=3600
)
@classmethod
def get_thumbnail(
cls, file_id: str, width: int = 200, height: int = 200, mode: str = "fit"
) -> BytesIO:
"""生成缩略图"""
file_obj = cls.get_file_by_id(file_id)
# 非图片类型不支持缩略图
if not file_obj.mime_type or not file_obj.mime_type.startswith("image/"):
raise BizException("该文件类型不支持缩略图", code=400)
# OSS 存储:使用 OSS 图片处理
if file_obj.storage_type != "local":
# 对于 OSS直接重定向到带图片处理参数的URL
# TODO: 各OSS需要实现 get_thumbnail_url 方法
return cls.download_file(file_id)
# 本地存储:使用 Pillow 生成缩略图
try:
from PIL import Image
from io import BytesIO as PILBytesIO
except ImportError:
raise BizException("需要安装 Pillow: pip install Pillow", code=500)
# 下载原图
file_stream = cls.download_file(file_id)
img = Image.open(file_stream)
# 转换为 RGB处理 PNG 透明通道等)
if img.mode in ("RGBA", "LA", "P"):
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
# 根据模式调整大小
if mode == "fill":
# 填充模式:裁剪居中
img.thumbnail((width * 2, height * 2), Image.Resampling.LANCZOS)
left = (img.width - width) / 2
top = (img.height - height) / 2
img = img.crop((left, top, left + width, top + height))
elif mode == "pad":
# 填充模式:保持比例,添加白边
img.thumbnail((width, height), Image.Resampling.LANCZOS)
background = Image.new("RGB", (width, height), (255, 255, 255))
offset = ((width - img.width) // 2, (height - img.height) // 2)
background.paste(img, offset)
img = background
else: # fit (默认)
# 适应模式:保持比例
img.thumbnail((width, height), Image.Resampling.LANCZOS)
# 保存为 JPEG
output = PILBytesIO()
img.save(output, format="JPEG", quality=85, optimize=True)
output.seek(0)
return output
@classmethod
def download_file(cls, file_id: str) -> BytesIO:
file_obj = cls.get_file_by_id(file_id)
storage = StorageManager.get_storage(file_obj.storage_type)
return storage.download(file_obj.file_key)
@staticmethod
def _cache_key(upload_id: str) -> str:
return f"{SysFileService.TUS_CACHE_PREFIX}{upload_id}"
@staticmethod
def _resolve_storage_type(
storage_type: Optional[str], directory_id: Optional[str]
) -> str:
if storage_type:
return storage_type
if directory_id:
directory = db.session.get(SysFileDirectory, directory_id)
if directory and directory.default_storage_type:
return directory.default_storage_type
config = current_app.config.get("FILE_STORAGE", {})
return config.get("DEFAULT_STORAGE_TYPE", "local")