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