|
|
import os
|
|
|
from pathlib import Path
|
|
|
|
|
|
from fastapi import UploadFile
|
|
|
|
|
|
from app.config.setting import settings
|
|
|
from app.core.base_schema import AuthSchema, DownloadFileSchema
|
|
|
from app.core.exceptions import CustomException
|
|
|
from app.core.logger import logger
|
|
|
from app.utils.minio_util import MinioUtil
|
|
|
from app.utils.upload_util import UploadUtil
|
|
|
|
|
|
from .crud import FileCRUD
|
|
|
from .schema import FileCreateSchema, FileOutSchema, FileQueryParam
|
|
|
|
|
|
|
|
|
class FileService:
|
|
|
"""
|
|
|
文件管理服务层
|
|
|
"""
|
|
|
|
|
|
@classmethod
|
|
|
async def upload_service(
|
|
|
cls,
|
|
|
auth: AuthSchema,
|
|
|
base_url: str,
|
|
|
file: UploadFile,
|
|
|
upload_type: str = "file",
|
|
|
target_path: str | None = None,
|
|
|
business_type: str | None = None,
|
|
|
business_id: int | None = None,
|
|
|
) -> FileOutSchema:
|
|
|
"""上传文件"""
|
|
|
original_name = file.filename or ""
|
|
|
content_type = file.content_type
|
|
|
file_size = file.size or 0
|
|
|
|
|
|
filename, filepath, file_url = await UploadUtil.upload_file(
|
|
|
file=file,
|
|
|
base_url=base_url,
|
|
|
upload_type=upload_type,
|
|
|
target_path=target_path,
|
|
|
)
|
|
|
|
|
|
storage_type = "local"
|
|
|
bucket_name = None
|
|
|
object_name = None
|
|
|
final_file_url = f"{file_url}"
|
|
|
final_file_path = f"{filepath}"
|
|
|
|
|
|
if settings.MINIO_ENABLE:
|
|
|
object_name = cls._build_object_name(upload_type=upload_type, filename=filename, filepath=filepath)
|
|
|
try:
|
|
|
final_file_url = MinioUtil.upload_file(
|
|
|
object_name=object_name,
|
|
|
file_path=filepath,
|
|
|
content_type=content_type,
|
|
|
)
|
|
|
except Exception:
|
|
|
UploadUtil.delete_file(filepath)
|
|
|
raise
|
|
|
bucket_name = settings.MINIO_BUCKET_NAME
|
|
|
storage_type = "minio"
|
|
|
final_file_path = object_name
|
|
|
UploadUtil.delete_file(filepath)
|
|
|
|
|
|
try:
|
|
|
file_record = await FileCRUD(auth).create(
|
|
|
data=FileCreateSchema(
|
|
|
storage_type=storage_type,
|
|
|
bucket_name=bucket_name,
|
|
|
object_name=object_name,
|
|
|
original_name=original_name,
|
|
|
file_name=filename,
|
|
|
file_ext=UploadUtil.get_extension_from_filename(filename),
|
|
|
content_type=content_type,
|
|
|
file_size=file_size,
|
|
|
file_path=final_file_path,
|
|
|
file_url=final_file_url,
|
|
|
upload_type=upload_type,
|
|
|
business_type=business_type,
|
|
|
business_id=business_id,
|
|
|
)
|
|
|
)
|
|
|
except Exception:
|
|
|
cls._delete_uploaded_object(storage_type=storage_type, bucket_name=bucket_name, object_name=object_name, file_path=final_file_path)
|
|
|
raise
|
|
|
return cls._to_out_schema(file_record)
|
|
|
|
|
|
@classmethod
|
|
|
async def page(
|
|
|
cls,
|
|
|
auth: AuthSchema,
|
|
|
page_no: int,
|
|
|
page_size: int,
|
|
|
search: FileQueryParam | None = None,
|
|
|
order_by: list[dict[str, str]] | None = None,
|
|
|
) -> dict:
|
|
|
offset = (page_no - 1) * page_size
|
|
|
page_result = await FileCRUD(auth).page(
|
|
|
offset=offset,
|
|
|
limit=page_size,
|
|
|
order_by=order_by or [{"id": "desc"}],
|
|
|
search=vars(search) if search else None,
|
|
|
out_schema=FileOutSchema,
|
|
|
)
|
|
|
for item in page_result.items:
|
|
|
item["origin_name"] = item.get("original_name")
|
|
|
return page_result
|
|
|
|
|
|
@classmethod
|
|
|
async def detail_service(cls, auth: AuthSchema, id: int) -> FileOutSchema:
|
|
|
file_record = await FileCRUD(auth).get_or_404(id=id, msg="文件记录不存在")
|
|
|
return cls._to_out_schema(file_record)
|
|
|
|
|
|
@classmethod
|
|
|
async def delete_service(cls, auth: AuthSchema, ids: list[int], delete_object: bool = False) -> None:
|
|
|
if len(ids) < 1:
|
|
|
raise CustomException(msg="删除失败,删除对象不能为空")
|
|
|
|
|
|
records = await FileCRUD(auth).get_list(search={"id": ("in", ids)})
|
|
|
record_map = {record.id: record for record in records}
|
|
|
for file_id in ids:
|
|
|
if file_id not in record_map:
|
|
|
raise CustomException(msg="删除失败,文件记录不存在")
|
|
|
|
|
|
if delete_object:
|
|
|
for record in records:
|
|
|
cls._delete_storage_object(record)
|
|
|
|
|
|
await FileCRUD(auth).delete(ids=ids)
|
|
|
|
|
|
@classmethod
|
|
|
def _to_out_schema(cls, file_record) -> FileOutSchema:
|
|
|
result = FileOutSchema.model_validate(file_record)
|
|
|
result.origin_name = result.original_name
|
|
|
return result
|
|
|
|
|
|
@classmethod
|
|
|
def _build_object_name(cls, upload_type: str, filename: str, filepath: Path) -> str:
|
|
|
try:
|
|
|
relative_path = filepath.resolve().relative_to(settings.UPLOAD_FILE_PATH.resolve())
|
|
|
return relative_path.as_posix()
|
|
|
except ValueError:
|
|
|
return f"{upload_type}/{filename}"
|
|
|
|
|
|
@classmethod
|
|
|
def _delete_storage_object(cls, file_record) -> None:
|
|
|
if file_record.storage_type == "minio" and file_record.object_name:
|
|
|
MinioUtil.remove_object(object_name=file_record.object_name, bucket_name=file_record.bucket_name)
|
|
|
return
|
|
|
if file_record.file_path:
|
|
|
UploadUtil.delete_file(Path(file_record.file_path))
|
|
|
|
|
|
@classmethod
|
|
|
def _delete_uploaded_object(cls, storage_type: str, bucket_name: str | None, object_name: str | None, file_path: str | None) -> None:
|
|
|
try:
|
|
|
if storage_type == "minio" and object_name:
|
|
|
MinioUtil.remove_object(object_name=object_name, bucket_name=bucket_name)
|
|
|
return
|
|
|
if file_path:
|
|
|
UploadUtil.delete_file(Path(file_path))
|
|
|
except Exception as e:
|
|
|
logger.error(f"清理上传文件失败: {e}")
|
|
|
|
|
|
@classmethod
|
|
|
def to_upload_response(cls, file_record: FileOutSchema):
|
|
|
from app.core.base_schema import UploadResponseSchema
|
|
|
|
|
|
return UploadResponseSchema(
|
|
|
file_path=file_record.file_path,
|
|
|
file_name=file_record.file_name,
|
|
|
origin_name=file_record.original_name,
|
|
|
file_url=file_record.file_url,
|
|
|
)
|
|
|
|
|
|
@classmethod
|
|
|
async def presigned_url_service(cls, auth: AuthSchema, id: int) -> str:
|
|
|
file_record = await FileCRUD(auth).get_or_404(id=id, msg="文件记录不存在")
|
|
|
if file_record.storage_type != "minio" or not file_record.object_name:
|
|
|
if file_record.file_url:
|
|
|
return file_record.file_url
|
|
|
raise CustomException(msg="该文件不是MinIO文件,无法生成预签名地址")
|
|
|
return MinioUtil.presigned_get_url(object_name=file_record.object_name, bucket_name=file_record.bucket_name)
|
|
|
|
|
|
@classmethod
|
|
|
async def download_service(cls, file_path: str) -> DownloadFileSchema:
|
|
|
"""下载文件"""
|
|
|
|
|
|
if not file_path:
|
|
|
raise CustomException(msg="请选择要下载的文件")
|
|
|
|
|
|
dangerous_patterns = ["../", "..\\", "\0"]
|
|
|
for pattern in dangerous_patterns:
|
|
|
if pattern in file_path:
|
|
|
logger.error(f"检测到路径穿越攻击: {file_path}")
|
|
|
raise CustomException(msg="非法的文件路径")
|
|
|
|
|
|
upload_root = settings.UPLOAD_FILE_PATH.resolve()
|
|
|
abs_path = os.path.normpath(os.path.abspath(file_path))
|
|
|
|
|
|
if not abs_path.startswith(str(upload_root)):
|
|
|
logger.error(f"路径不在上传目录内: {file_path}")
|
|
|
raise CustomException(msg="非法的文件路径")
|
|
|
|
|
|
if not UploadUtil.check_file_exists(abs_path):
|
|
|
raise CustomException(msg="文件不存在")
|
|
|
|
|
|
file_name = UploadUtil.download_file(abs_path)
|
|
|
|
|
|
return DownloadFileSchema(
|
|
|
file_path=abs_path,
|
|
|
file_name=str(file_name),
|
|
|
)
|