|
|
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")
|