feat: 新增权限指令并集成到多个页面组件

refactor(backend): 重构数据库模型和初始化逻辑

fix(frontend): 修复权限指令在多个组件中的使用问题

perf(backend): 优化数据库连接和初始化性能

docs: 更新低代码生成器的README文档

style: 清理无用代码和注释

chore: 移除不再需要的依赖项

test: 更新用户权限相关测试用例

ci: 更新CI配置以支持新的权限检查

build: 更新依赖项版本
This commit is contained in:
zhangtao
2025-09-28 23:42:26 +08:00
parent 3138fde133
commit 0aeb77e0bd
91 changed files with 5102 additions and 3872 deletions
@@ -2,17 +2,20 @@
from fastapi import APIRouter, Body, Depends, Path, Query, Request, UploadFile, Form
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse
import urllib.parse
import os
from typing import List, Optional
from app.common.response import StreamResponse, SuccessResponse
from app.common.response import StreamResponse, SuccessResponse, ErrorResponse
from app.common.request import PaginationService
from app.utils.common_util import bytes2file_response
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from app.core.logger import logger
from ...module_system.auth.schema import AuthSchema
from .param import ResourceQueryParam
from .param import ResourceSearchQueryParam
from .schema import (
ResourceSearchSchema,
ResourceMoveSchema,
ResourceCopySchema,
ResourceRenameSchema,
@@ -20,44 +23,33 @@ from .schema import (
)
from .service import ResourceService
ResourceRouter = APIRouter(route_class=OperationLogRoute, prefix="/resource", tags=["资源管理"])
@ResourceRouter.get("/list", summary="获取目录列表", description="获取指定目录下的文件和子目录列表")
async def get_directory_list_controller(
request: Request,
path: Optional[str] = Query(None, description="目录路径"),
include_hidden: bool = Query(False, description="是否包含隐藏文件"),
page: PaginationQueryParam = Depends(),
search: ResourceSearchQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(permissions=["monitor:resource:query"]))
) -> JSONResponse:
"""获取目录列表"""
result_dict = await ResourceService.get_directory_list_service(
auth=auth,
path=path,
include_hidden=include_hidden,
# 获取资源列表(与案例模块保持一致的分页实现)
result_dict_list = await ResourceService.get_resources_list_service(
search=search,
order_by=page.order_by,
base_url=str(request.base_url)
)
logger.info(f"获取目录列表成功: {path or 'default'}")
# 使用分页服务进行分页处理(与案例模块保持一致)
result_dict = await PaginationService.paginate(
data_list=result_dict_list,
page_no=page.page_no,
page_size=page.page_size
)
logger.info(f"获取目录列表成功: {getattr(search, 'name', None) or ''}")
return SuccessResponse(data=result_dict, msg="获取目录列表成功")
@ResourceRouter.post("/search", summary="搜索资源", description="根据条件搜索资源")
async def search_resources_controller(
request: Request,
search: ResourceSearchSchema,
auth: AuthSchema = Depends(AuthPermission(permissions=["monitor:resource:search"]))
) -> JSONResponse:
"""搜索资源"""
result_list = await ResourceService.search_resources_service(
auth=auth,
search=search,
base_url=str(request.base_url)
)
logger.info(f"搜索资源成功,找到 {len(result_list)} 个结果")
return SuccessResponse(data=result_list, msg=f"搜索成功,找到 {len(result_list)} 个结果")
@ResourceRouter.post("/upload", summary="上传文件", description="上传文件到指定目录")
async def upload_file_controller(
file: UploadFile,
@@ -137,7 +129,7 @@ async def copy_file_controller(
@ResourceRouter.post("/rename", summary="重命名文件", description="重命名文件或目录")
async def rename_file_controller(
data: ResourceRenameSchema,
auth: AuthSchema = Depends(AuthPermission(permissions=["rmonitor:resource:rename"]))
auth: AuthSchema = Depends(AuthPermission(permissions=["monitor:resource:rename"]))
) -> JSONResponse:
"""重命名文件"""
await ResourceService.rename_file_service(auth=auth, data=data)
@@ -156,34 +148,19 @@ async def create_directory_controller(
return SuccessResponse(msg="创建目录成功")
@ResourceRouter.get("/stats", summary="获取资源统计", description="获取资源统计信息")
async def get_resource_stats_controller(
request: Request,
auth: AuthSchema = Depends(AuthPermission(permissions=["monitor:resource:query"]))
) -> JSONResponse:
"""获取资源统计"""
result_dict = await ResourceService.get_stats_service(
auth=auth,
base_url=str(request.base_url)
)
logger.info("获取资源统计成功")
return SuccessResponse(data=result_dict, msg="获取资源统计成功")
@ResourceRouter.post("/export", summary="导出资源列表", description="导出资源列表")
async def export_resource_list_controller(
request: Request,
search: ResourceSearchSchema,
search: ResourceSearchQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(permissions=["monitor:resource:export"]))
) -> StreamingResponse:
"""导出资源列表"""
# 获取搜索结果
result_list = await ResourceService.search_resources_service(
auth=auth,
result_dict_list = await ResourceService.search_resources_service(
search=search,
base_url=str(request.base_url)
)
export_result = await ResourceService.export_resource_service(data_list=result_list)
export_result = await ResourceService.export_resource_service(data_list=result_dict_list)
logger.info("导出资源列表成功")
return StreamResponse(
@@ -1,33 +1,20 @@
# -*- coding: utf-8 -*-
from typing import Optional, List
from pydantic import BaseModel, Field
from enum import Enum
from typing import Optional
from fastapi import Query
class ResourceSearchQueryParam:
"""资源搜索查询参数"""
class ResourceType(Enum):
"""资源类型枚举"""
IMAGE = "image" # 图片
VIDEO = "video" # 视频
AUDIO = "audio" # 音频
DOCUMENT = "document" # 文档
ARCHIVE = "archive" # 压缩包
OTHER = "other" # 其他
class ResourceQueryParam(BaseModel):
"""资源查询参数模型"""
path: Optional[str] = Field(None, description="文件路径")
keyword: Optional[str] = Field(None, description="关键词搜索")
resource_type: Optional[ResourceType] = Field(None, description="资源类型")
file_extension: Optional[str] = Field(None, description="文件扩展名")
min_size: Optional[int] = Field(None, ge=0, description="最小文件大小")
max_size: Optional[int] = Field(None, ge=0, description="最大文件大小")
include_hidden: bool = Field(False, description="包含隐藏文件")
recursive: bool = Field(True, description="递归搜索")
max_depth: int = Field(10, description="最大搜索深度")
sort_by: Optional[str] = Field("name", description="排序字段(name/size/modified_time)")
sort_order: Optional[str] = Field("asc", description="排序方式(asc/desc)")
class Config:
use_enum_values = True
def __init__(
self,
name: Optional[str] = Query(None, description="搜索关键词"),
path: Optional[str] = Query(None, description="目录路径"),
) -> None:
super().__init__()
# 模糊查询字段
self.name = ("like", name) if name else None
# 精确查询字段
self.path = path
@@ -4,42 +4,26 @@ from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pathlib import Path
from enum import Enum
class ResourceType(Enum):
"""资源类型枚举"""
IMAGE = "image" # 图片
VIDEO = "video" # 视频
AUDIO = "audio" # 音频
DOCUMENT = "document" # 文档
ARCHIVE = "archive" # 压缩包
OTHER = "other" # 其他
class ResourceItemSchema(BaseModel):
"""资源项目模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
model_config = ConfigDict(from_attributes=True)
name: str = Field(..., description="文件名")
path: str = Field(..., description="文件路径")
file_url: str = Field(..., description="文件URL路径")
relative_path: str = Field(..., description="相对路径")
is_file: bool = Field(..., description="是否为文件")
is_dir: bool = Field(..., description="是否为目录")
size: Optional[int] = Field(None, description="文件大小(字节)")
file_type: Optional[str] = Field(None, description="文件类型")
file_extension: Optional[str] = Field(None, description="文件扩展名")
resource_type: Optional[ResourceType] = Field(None, description="资源类型")
created_time: Optional[datetime] = Field(None, description="创建时间")
modified_time: Optional[datetime] = Field(None, description="修改时间")
accessed_time: Optional[datetime] = Field(None, description="访问时间")
parent_path: Optional[str] = Field(None, description="父目录路径")
depth: int = Field(0, description="目录深度")
is_hidden: bool = Field(False, description="是否为隐藏文件")
class ResourceDirectorySchema(BaseModel):
"""资源目录模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
model_config = ConfigDict(from_attributes=True)
path: str = Field(..., description="目录路径")
name: str = Field(..., description="目录名称")
@@ -47,53 +31,22 @@ class ResourceDirectorySchema(BaseModel):
total_files: int = Field(0, description="文件总数")
total_dirs: int = Field(0, description="目录总数")
total_size: int = Field(0, description="总大小")
class ResourceStatsSchema(BaseModel):
"""资源统计模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
mount_point: str = Field(..., description="挂载点")
total_files: int = Field(0, description="文件总数")
total_dirs: int = Field(0, description="目录总数")
total_size: int = Field(0, description="总大小")
free_space: int = Field(0, description="可用空间")
used_space: int = Field(0, description="已用空间")
total_space: int = Field(0, description="总空间")
type_stats: Dict[str, int] = Field(default_factory=dict, description="类型统计")
extension_stats: Dict[str, int] = Field(default_factory=dict, description="扩展名统计")
class ResourceSearchSchema(BaseModel):
"""资源搜索模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
keyword: Optional[str] = Field(None, description="关键词")
file_type: Optional[str] = Field(None, description="文件类型")
resource_type: Optional[ResourceType] = Field(None, description="资源类型")
min_size: Optional[int] = Field(None, description="最小文件大小")
max_size: Optional[int] = Field(None, description="最大文件大小")
start_date: Optional[datetime] = Field(None, description="开始日期")
end_date: Optional[datetime] = Field(None, description="结束日期")
extensions: Optional[List[str]] = Field(None, description="文件扩展名列表")
include_hidden: bool = Field(False, description="包含隐藏文件")
max_depth: int = Field(10, description="最大搜索深度")
class ResourceUploadSchema(BaseModel):
"""资源上传响应模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
model_config = ConfigDict(from_attributes=True)
filename: str = Field(..., description="文件名")
file_path: str = Field(..., description="文件路径")
file_url: str = Field(..., description="访问URL")
file_size: int = Field(..., description="文件大小")
resource_type: ResourceType = Field(..., description="资源类型")
upload_time: datetime = Field(..., description="上传时间")
class ResourceMoveSchema(BaseModel):
"""资源移动模型"""
model_config = ConfigDict(from_attributes=True)
source_path: str = Field(..., description="源路径")
target_path: str = Field(..., description="目标路径")
overwrite: bool = Field(False, description="是否覆盖")
@@ -113,6 +66,8 @@ class ResourceCopySchema(ResourceMoveSchema):
class ResourceRenameSchema(BaseModel):
"""资源重命名模型"""
model_config = ConfigDict(from_attributes=True)
old_path: str = Field(..., description="原路径")
new_name: str = Field(..., description="新名称")
@@ -126,12 +81,25 @@ class ResourceRenameSchema(BaseModel):
class ResourceCreateDirSchema(BaseModel):
"""创建目录模型"""
model_config = ConfigDict(from_attributes=True)
parent_path: str = Field(..., description="父目录路径")
dir_name: str = Field(..., description="目录名称")
dir_name: str = Field(..., description="目录名称", max_length=255)
@field_validator('parent_path', 'dir_name')
@classmethod
def validate_inputs(cls, value: str):
if not value or len(value.strip()) == 0:
raise ValueError("参数不能为空")
def validate_inputs(cls, value: str, info):
# 对于parent_path允许为空字符串(表示根目录)或 '/',其他情况必须非空
if info.field_name == 'parent_path':
# 允许空字符串或 '/' 表示根目录
if value is None:
raise ValueError("参数不能为空")
# 对于parent_path仍然严格检查路径遍历
if '..' in value or value.startswith('\\'):
raise ValueError("参数包含不安全字符")
else: # 对于dir_name仍然严格检查
if not value or len(value.strip()) == 0:
raise ValueError("参数不能为空")
if '..' in value or value.startswith('/') or value.startswith('\\'):
raise ValueError("参数包含不安全字符")
return value.strip()
@@ -2,34 +2,25 @@
import os
import shutil
import hashlib
import io
import psutil
from datetime import datetime
from typing import List, Dict, Any, Optional
from pathlib import Path
from fastapi import UploadFile
from PIL import Image
import pylibmagic # 不要删除,否则import magic 导入启动报错ImportError: failed to find libmagic. Check your installation
import magic
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.excel_util import ExcelUtil
from app.config.setting import settings
from ...module_system.auth.schema import AuthSchema
from .param import ResourceQueryParam
from .param import ResourceSearchQueryParam
from .schema import (
ResourceItemSchema,
ResourceDirectorySchema,
ResourceStatsSchema,
ResourceSearchSchema,
ResourceUploadSchema,
ResourceMoveSchema,
ResourceCopySchema,
ResourceRenameSchema,
ResourceCreateDirSchema,
ResourceType
ResourceCreateDirSchema
)
@@ -51,7 +42,7 @@ class ResourceService:
return str(settings.STATIC_ROOT)
@classmethod
def _get_safe_path(cls, path: str = None) -> str:
def _get_safe_path(cls, path: Optional[str] = None) -> str:
"""获取安全的文件路径"""
resource_root = cls._get_resource_root()
@@ -89,6 +80,33 @@ class ResourceService:
except:
return False
@classmethod
def _generate_http_url(cls, file_path: str, base_url: Optional[str] = None) -> str:
"""生成文件的HTTP URL"""
resource_root = cls._get_resource_root()
try:
relative_path = os.path.relpath(file_path, resource_root)
# 确保路径使用正斜杠(URL格式)
url_path = relative_path.replace(os.sep, '/')
except ValueError:
# 如果无法计算相对路径,使用文件名
url_path = os.path.basename(file_path)
# 如果提供了base_url,使用它生成完整URL,否则使用settings.STATIC_URL
if base_url:
from urllib.parse import urljoin
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
file_part = url_path.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
http_url = f"{base_part}{static_part}/{file_part}".replace('//', '/').replace(':/', '://')
else:
http_url = f"{settings.STATIC_URL}/{url_path}".replace('//', '/')
return http_url
@classmethod
def _get_file_info(cls, file_path: str, base_url: Optional[str] = None) -> Dict[str, Any]:
"""获取文件信息"""
@@ -101,27 +119,6 @@ class ResourceService:
path_obj = Path(safe_path)
resource_root = cls._get_resource_root()
# 获取文件扩展名和类型
file_extension = path_obj.suffix.lower() if path_obj.suffix else None
# 优先使用 magic 库检测 MIME 类型
file_type = None
if os.path.isfile(safe_path):
try:
file_type = magic.from_file(safe_path, mime=True)
except Exception as e:
logger.debug(f"magic 库检测文件类型失败: {e}")
# 如果 magic 检测失败或不可用,使用扩展名检测
if not file_type and file_extension:
file_type = cls._get_mime_type_from_extension(file_extension)
# 如果仍然没有类型,使用默认值
if not file_type:
file_type = 'application/octet-stream' if os.path.isfile(safe_path) else None
resource_type = cls._determine_resource_type(file_type, file_extension)
# 计算相对路径
try:
relative_path = os.path.relpath(safe_path, resource_root)
@@ -135,33 +132,28 @@ class ResourceService:
depth = 0
# 生成HTTP URL路径而不是文件系统路径
if base_url:
from urllib.parse import urljoin
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
relative_part = relative_path.lstrip('/')
# 手动构建URL而不是使用urljoin,避免双斜杠问题
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
http_url = f"{base_part}{static_part}/{relative_part}".replace('\\', '/').replace('//', '/').replace(':/', '://')
else:
http_url = f"{settings.STATIC_URL}/{relative_path}".replace('\\', '/').replace('//', '/')
http_url = cls._generate_http_url(safe_path, base_url)
# 检查是否为隐藏文件(文件名以点开头)
is_hidden = path_obj.name.startswith('.')
# 对于目录,设置is_directory字段(兼容前端)
is_directory = os.path.isdir(safe_path)
# 将datetime对象转换为ISO格式的字符串,确保JSON序列化成功
created_time = datetime.fromtimestamp(stat.st_ctime).isoformat()
modified_time = datetime.fromtimestamp(stat.st_mtime).isoformat()
return {
'name': path_obj.name,
'path': http_url, # 返回HTTP URL而不是文件系统路径
'file_url': http_url, # 统一使用file_url字段
'relative_path': relative_path,
'is_file': os.path.isfile(safe_path),
'is_dir': os.path.isdir(safe_path),
'is_dir': is_directory,
'size': stat.st_size if os.path.isfile(safe_path) else None,
'file_type': file_type,
'file_extension': file_extension,
'resource_type': resource_type,
'created_time': datetime.fromtimestamp(stat.st_ctime),
'modified_time': datetime.fromtimestamp(stat.st_mtime),
'accessed_time': datetime.fromtimestamp(stat.st_atime),
'parent_path': str(path_obj.parent),
'depth': depth
'created_time': created_time,
'modified_time': modified_time,
'is_hidden': is_hidden
}
except Exception as e:
logger.error(f'获取文件信息失败: {str(e)}')
@@ -170,7 +162,6 @@ class ResourceService:
@classmethod
async def get_directory_list_service(
cls,
auth: AuthSchema,
path: Optional[str] = None,
include_hidden: bool = False,
base_url: Optional[str] = None
@@ -180,45 +171,10 @@ class ResourceService:
# 如果没有指定路径,使用静态文件根目录
if path is None:
safe_path = cls._get_resource_root()
# 对于根目录,返回静态URL路径
if base_url:
from urllib.parse import urljoin
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
display_path = f"{base_part}{static_part}".replace('//', '/').replace(':/', '://')
else:
display_path = settings.STATIC_URL
display_path = cls._generate_http_url(safe_path, base_url)
else:
safe_path = cls._get_safe_path(path)
# 对于子目录,生成相对于静态URL的路径
resource_root = cls._get_resource_root()
try:
relative_path = os.path.relpath(safe_path, resource_root)
if base_url:
from urllib.parse import urljoin
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
relative_part = relative_path.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
display_path = f"{base_part}{static_part}/{relative_part}".replace('\\', '/').replace('//', '/').replace(':/', '://')
else:
display_path = f"{settings.STATIC_URL}/{relative_path}".replace('\\', '/').replace('//', '/')
except ValueError:
if base_url:
from urllib.parse import urljoin
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
display_path = f"{base_part}{static_part}".replace('//', '/').replace(':/', '://')
else:
display_path = settings.STATIC_URL
display_path = cls._generate_http_url(safe_path, base_url)
if not os.path.exists(safe_path):
raise CustomException(msg='目录不存在')
@@ -259,14 +215,107 @@ class ResourceService:
total_files=total_files,
total_dirs=total_dirs,
total_size=total_size
).model_dump(mode='json')
).model_dump()
except CustomException:
raise
except Exception as e:
logger.error(f'获取目录列表失败: {str(e)}')
raise CustomException(msg=f'获取目录列表失败: {str(e)}')
@classmethod
async def search_resources_service(
cls,
search: Optional[ResourceSearchQueryParam] = None,
order_by: Optional[str] = None,
base_url: Optional[str] = None
) -> List[Dict]:
"""搜索资源列表(用于分页和导出)"""
try:
# 确定搜索路径
if search and hasattr(search, 'path') and search.path:
resource_root = cls._get_safe_path(search.path)
else:
resource_root = cls._get_resource_root()
# 检查路径是否存在
if not os.path.exists(resource_root):
raise CustomException(msg='目录不存在')
if not os.path.isdir(resource_root):
raise CustomException(msg='路径不是目录')
# 收集资源
all_resources = []
try:
for item_name in os.listdir(resource_root):
# 跳过隐藏文件
if item_name.startswith('.'):
continue
item_path = os.path.join(resource_root, item_name)
file_info = cls._get_file_info(item_path, base_url)
if file_info:
# 应用名称过滤
if search and hasattr(search, 'name') and search.name and search.name[1]:
search_keyword = search.name[1].lower()
if search_keyword not in file_info.get('name', '').lower():
continue
all_resources.append(file_info)
except PermissionError:
raise CustomException(msg='没有权限访问此目录')
# 应用排序
sorted_resources = cls._sort_results(all_resources, order_by)
# 限制最大结果数
if len(sorted_resources) > cls.MAX_SEARCH_RESULTS:
sorted_resources = sorted_resources[:cls.MAX_SEARCH_RESULTS]
return sorted_resources
except CustomException:
raise
except Exception as e:
logger.error(f'搜索资源失败: {str(e)}')
raise CustomException(msg=f'搜索资源失败: {str(e)}')
@classmethod
async def get_resources_list_service(
cls,
search: Optional[ResourceSearchQueryParam] = None,
order_by: Optional[str] = None,
base_url: Optional[str] = None
) -> List[Dict]:
"""获取资源列表(用于分页查询)"""
return await cls.search_resources_service(search=search, order_by=order_by, base_url=base_url)
@classmethod
async def export_resource_service(cls, data_list: List[Dict[str, Any]]) -> bytes:
"""导出资源列表"""
mapping_dict = {
'name': '文件名',
'path': '文件路径',
'size': '文件大小',
'created_time': '创建时间',
'modified_time': '修改时间',
'parent_path': '父目录'
}
# 复制数据并转换状态
export_data = data_list.copy()
# 格式化文件大小
for item in export_data:
if item.get('size'):
item['size'] = cls._format_file_size(item['size'])
return ExcelUtil.export_list2excel(list_data=export_data, mapping_dict=mapping_dict)
@classmethod
async def _get_directory_stats(cls, path: str, include_hidden: bool = False) -> Dict[str, int]:
"""递归获取目录统计信息"""
@@ -294,126 +343,45 @@ class ResourceService:
return stats
@classmethod
async def search_resources_service(
cls,
auth: AuthSchema,
search: ResourceSearchSchema,
base_url: Optional[str] = None
) -> List[Dict]:
"""搜索资源"""
try:
# 使用静态文件根目录作为搜索起点
search_root = cls._get_resource_root()
results = []
for root, dirs, files in os.walk(search_root):
# 控制搜索深度
try:
depth = len(Path(root).relative_to(search_root).parts)
except ValueError:
depth = 0
if depth > search.max_depth:
dirs.clear() # 阻止进一步深入
continue
# 过滤隐藏文件夹(性能优化)
if not search.include_hidden:
dirs[:] = [d for d in dirs if not d.startswith('.')]
files = [f for f in files if not f.startswith('.')]
# 优化:先过滤文件名,再进行详细检查
if search.keyword:
files = [f for f in files if search.keyword.lower() in f.lower()]
# 搜索文件
for file in files:
file_path = os.path.join(root, file)
# 优化:先进行快速检查
if search.extensions:
file_ext = os.path.splitext(file)[1].lower()
if file_ext not in search.extensions:
continue
file_info = cls._get_file_info(file_path, base_url)
if cls._match_search_criteria(file_info, search):
results.append(file_info)
# 限制结果数量防止内存溢出
if len(results) >= cls.MAX_SEARCH_RESULTS:
logger.warning(f"搜索结果过多,已截断到前{cls.MAX_SEARCH_RESULTS}")
break
if len(results) >= cls.MAX_SEARCH_RESULTS:
break
# 排序结果
return cls._sort_results(results, search)
except Exception as e:
logger.error(f'搜索资源失败: {str(e)}')
raise CustomException(msg=f'搜索资源失败: {str(e)}')
@classmethod
def _match_search_criteria(cls, file_info: Dict, search: ResourceSearchSchema) -> bool:
"""检查文件是否匹配搜索条件"""
if not file_info or not file_info.get('is_file'):
return False
# 关键词搜索
if search.keyword:
if search.keyword.lower() not in file_info.get('name', '').lower():
return False
# 文件类型搜索
if search.file_type:
if search.file_type.lower() != file_info.get('file_type', '').lower():
return False
# 资源类型搜索
if search.resource_type:
if search.resource_type != file_info.get('resource_type'):
return False
# 文件大小搜索
file_size = file_info.get('size', 0) or 0
if search.min_size and file_size < search.min_size:
return False
if search.max_size and file_size > search.max_size:
return False
# 扩展名搜索
if search.extensions:
file_ext = file_info.get('file_extension', '')
if file_ext not in search.extensions:
return False
# 时间范围搜索
modified_time = file_info.get('modified_time')
if modified_time:
if search.start_date and modified_time < search.start_date:
return False
if search.end_date and modified_time > search.end_date:
return False
return True
@classmethod
def _sort_results(cls, results: List[Dict], search: ResourceSearchSchema) -> List[Dict]:
def _sort_results(cls, results: List[Dict], order_by: Optional[str] = None) -> List[Dict]:
"""排序搜索结果"""
sort_key = 'name'
if hasattr(search, 'sort_by') and search.sort_by:
sort_key = search.sort_by
reverse = False
if hasattr(search, 'sort_order') and search.sort_order == 'desc':
reverse = True
try:
return sorted(results, key=lambda x: x.get(sort_key, ''), reverse=reverse)
# 默认按名称升序排序
if not order_by:
return sorted(results, key=lambda x: x.get('name', ''), reverse=False)
# 解析order_by参数,格式: [{'field':'asc/desc'}]
try:
sort_conditions = eval(order_by)
if isinstance(sort_conditions, list):
# 构建排序键函数
def sort_key(item):
keys = []
for cond in sort_conditions:
field = cond.get('field', 'name')
direction = cond.get('direction', 'asc')
# 获取字段值,默认为空字符串
value = item.get(field, '')
# 如果是日期字段,转换为可比较的格式
if field in ['created_time', 'modified_time', 'accessed_time'] and value:
value = datetime.fromisoformat(value)
keys.append(value)
return keys
# 确定排序方向(这里只支持单一方向,多个条件时使用第一个条件的方向)
reverse = False
if sort_conditions and isinstance(sort_conditions[0], dict):
direction = sort_conditions[0].get('direction', '').lower()
reverse = direction == 'desc'
return sorted(results, key=sort_key, reverse=reverse)
except:
# 如果解析失败,使用默认排序
pass
return sorted(results, key=lambda x: x.get('name', ''), reverse=False)
except:
return results
@@ -470,38 +438,8 @@ class ResourceService:
# 获取文件信息
file_info = cls._get_file_info(file_path, base_url)
# 生成相对于资源根目录的URL路径
resource_root = cls._get_resource_root()
try:
relative_path = os.path.relpath(file_path, resource_root)
# 确保路径使用正斜杠(URL格式)
file_url_path = relative_path.replace(os.sep, '/')
# 如果提供了base_url,使用它生成完整URL,否则使用settings.STATIC_URL
if base_url:
from urllib.parse import urljoin
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
file_url_part = file_url_path.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
file_url = f"{base_part}{static_part}/{file_url_part}".replace('//', '/').replace(':/', '://')
else:
file_url = f"{settings.STATIC_URL}/{file_url_path}".replace('//', '/')
except ValueError:
# 如果无法计算相对路径,使用文件名
filename = os.path.basename(file_path)
if base_url:
from urllib.parse import urljoin
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
filename_part = filename.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
file_url = f"{base_part}{static_part}/{filename_part}".replace('//', '/').replace(':/', '://')
else:
file_url = f"{settings.STATIC_URL}/{filename}"
# 生成文件URL
file_url = cls._generate_http_url(file_path, base_url)
logger.info(f"文件上传成功: {filename}")
@@ -510,7 +448,6 @@ class ResourceService:
file_path=file_url, # 返回HTTP URL而不是文件系统路径
file_url=file_url,
file_size=file_info.get('size', 0),
resource_type=file_info.get('resource_type', ResourceType.OTHER),
upload_time=datetime.now()
).model_dump(mode='json')
@@ -531,39 +468,9 @@ class ResourceService:
raise CustomException(msg='路径不是文件')
# 生成HTTP URL路径而不是返回文件系统路径
resource_root = cls._get_resource_root()
try:
relative_path = os.path.relpath(safe_path, resource_root)
# 生成HTTP URL
if base_url:
from urllib.parse import urljoin
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
relative_part = relative_path.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
http_url = f"{base_part}{static_part}/{relative_part}".replace('\\', '/').replace('//', '/').replace(':/', '://')
else:
http_url = f"{settings.STATIC_URL}/{relative_path}".replace('\\', '/').replace('//', '/')
logger.info(f"生成文件访问URL: {http_url}")
return http_url
except ValueError:
# 如果无法计算相对路径,使用文件名
filename = os.path.basename(safe_path)
if base_url:
from urllib.parse import urljoin
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
filename_part = filename.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
http_url = f"{base_part}{static_part}/{filename_part}".replace('//', '/').replace(':/', '://')
else:
http_url = f"{settings.STATIC_URL}/{filename}"
logger.info(f"生成文件访问URL: {http_url}")
return http_url
http_url = cls._generate_http_url(safe_path, base_url)
logger.info(f"生成文件访问URL: {http_url}")
return http_url
except CustomException:
raise
@@ -596,6 +503,41 @@ class ResourceService:
logger.error(f"删除失败 {path}: {str(e)}")
raise CustomException(msg=f"删除失败 {path}: {str(e)}")
@classmethod
async def batch_delete_service(cls, auth: AuthSchema, paths: List[str]) -> Dict[str, List[str]]:
"""批量删除文件或目录"""
if not paths:
raise CustomException(msg='删除失败,删除路径不能为空')
success_paths = []
failed_paths = []
for path in paths:
try:
safe_path = cls._get_safe_path(path)
if not os.path.exists(safe_path):
failed_paths.append(path)
continue
if os.path.isfile(safe_path):
os.remove(safe_path)
success_paths.append(path)
logger.info(f"删除文件成功: {safe_path}")
elif os.path.isdir(safe_path):
shutil.rmtree(safe_path)
success_paths.append(path)
logger.info(f"删除目录成功: {safe_path}")
except Exception as e:
logger.error(f"删除失败 {path}: {str(e)}")
failed_paths.append(path)
return {
"success": success_paths,
"failed": failed_paths
}
@classmethod
async def move_file_service(cls, auth: AuthSchema, data: ResourceMoveSchema) -> None:
"""移动文件或目录"""
@@ -704,6 +646,10 @@ class ResourceService:
# 生成新目录路径
new_dir_path = os.path.join(parent_path, data.dir_name)
# 安全检查:确保新目录名称不包含路径遍历字符
if '..' in data.dir_name or '/' in data.dir_name or '\\' in data.dir_name:
raise CustomException(msg='目录名称包含不安全字符')
if os.path.exists(new_dir_path):
raise CustomException(msg='目录已存在')
@@ -717,118 +663,6 @@ class ResourceService:
logger.error(f"创建目录失败: {str(e)}")
raise CustomException(msg=f"创建目录失败: {str(e)}")
@classmethod
async def get_stats_service(cls, auth: AuthSchema, base_url: Optional[str] = None) -> Dict:
"""获取资源统计信息"""
try:
# 使用静态文件根目录
stats_root = cls._get_resource_root()
# 获取磁盘空间信息
disk_usage = psutil.disk_usage(stats_root)
total_space = disk_usage.total
free_space = disk_usage.free
used_space = disk_usage.used
# 统计文件信息
total_files = 0
total_dirs = 0
total_size = 0
type_stats = {}
extension_stats = {}
for root, dirs, files in os.walk(stats_root):
total_dirs += len(dirs)
for file in files:
file_path = os.path.join(root, file)
try:
file_info = cls._get_file_info(file_path, base_url)
if file_info:
total_files += 1
total_size += file_info.get('size', 0) or 0
# 类型统计
resource_type = file_info.get('resource_type')
if resource_type:
type_name = resource_type.value if hasattr(resource_type, 'value') else str(resource_type)
type_stats[type_name] = type_stats.get(type_name, 0) + 1
# 扩展名统计
extension = file_info.get('file_extension', '')
if extension:
extension_stats[extension] = extension_stats.get(extension, 0) + 1
except Exception:
continue
return ResourceStatsSchema(
mount_point=stats_root,
total_files=total_files,
total_dirs=total_dirs,
total_size=total_size,
free_space=free_space,
used_space=used_space,
total_space=total_space,
type_stats=type_stats,
extension_stats=extension_stats
).model_dump(mode='json')
except Exception as e:
logger.error(f"获取统计信息失败: {str(e)}")
raise CustomException(msg=f"获取统计信息失败: {str(e)}")
@classmethod
async def export_resource_service(cls, data_list: List[Dict[str, Any]]) -> bytes:
"""导出资源列表"""
mapping_dict = {
'name': '文件名',
'path': '文件路径',
'size': '文件大小',
'file_type': 'MIME类型',
'file_extension': '文件扩展名',
'resource_type': '资源类型',
'created_time': '创建时间',
'modified_time': '修改时间',
'parent_path': '父目录'
}
# 复制数据并转换状态
export_data = data_list.copy()
for item in export_data:
# 处理枚举值
if 'resource_type' in item and hasattr(item['resource_type'], 'value'):
item['resource_type'] = item['resource_type'].value
# 格式化文件大小
if item.get('size'):
item['size'] = cls._format_file_size(item['size'])
return ExcelUtil.export_list2excel(list_data=export_data, mapping_dict=mapping_dict)
@classmethod
def _determine_resource_type(cls, file_type: str, file_extension: str) -> ResourceType:
"""根据MIME类型和文件扩展名确定资源类型"""
if not file_type:
return ResourceType.OTHER
if file_type.startswith('image/'):
return ResourceType.IMAGE
elif file_type.startswith('video/'):
return ResourceType.VIDEO
elif file_type.startswith('audio/'):
return ResourceType.AUDIO
elif file_type in ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv']:
return ResourceType.DOCUMENT
elif file_type in ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
'application/gzip', 'application/x-tar']:
return ResourceType.ARCHIVE
else:
return ResourceType.OTHER
@classmethod
def _format_file_size(cls, size_bytes: int) -> str:
"""格式化文件大小"""
@@ -838,50 +672,7 @@ class ResourceService:
size_names = ["B", "KB", "MB", "GB", "TB"]
i = 0
while size_bytes >= 1024 and i < len(size_names) - 1:
size_bytes /= 1024.0
size_bytes = int(size_bytes / 1024)
i += 1
return f"{size_bytes:.2f}{size_names[i]}"
@classmethod
def _get_mime_type_from_extension(cls, file_extension: str) -> str:
"""根据文件扩展名获取MIME类型"""
if not file_extension:
return 'application/octet-stream'
# 扩展更全面的MIME类型映射
mime_types = {
# 图片类型
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.tiff': 'image/tiff',
# 视频类型
'.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv', '.flv': 'video/x-flv', '.webm': 'video/webm',
'.mkv': 'video/x-matroska', '.m4v': 'video/x-m4v',
# 音频类型
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.aac': 'audio/aac',
'.ogg': 'audio/ogg', '.flac': 'audio/flac', '.m4a': 'audio/mp4',
# 文档类型
'.pdf': 'application/pdf', '.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain', '.csv': 'text/csv', '.rtf': 'application/rtf',
# 代码文件
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
'.json': 'application/json', '.xml': 'application/xml',
'.py': 'text/x-python', '.java': 'text/x-java-source',
# 压缩文件
'.zip': 'application/zip', '.rar': 'application/x-rar-compressed',
'.7z': 'application/x-7z-compressed', '.tar': 'application/x-tar',
'.gz': 'application/gzip', '.bz2': 'application/x-bzip2'
}
return mime_types.get(file_extension.lower(), 'application/octet-stream')