init: 初始化 dpb 桃育种系统代码库

前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
34047007@qq.com
2026-08-06 00:17:49 +08:00
commit b95053c52c
1469 changed files with 322298 additions and 0 deletions
@@ -0,0 +1,13 @@
from fastapi import APIRouter
from .cache.controller import CacheRouter
from .online.controller import OnlineRouter
from .resource.controller import ResourceRouter
from .server.controller import ServerRouter
monitor_router = APIRouter(prefix="/monitor")
monitor_router.include_router(CacheRouter)
monitor_router.include_router(OnlineRouter)
monitor_router.include_router(ResourceRouter)
monitor_router.include_router(ServerRouter)
+1
View File
@@ -0,0 +1 @@
+64
View File
@@ -0,0 +1,64 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Security
from fastapi.responses import JSONResponse
from redis.asyncio.client import Redis
from app.api.v1.module_monitor.cache.schema import CacheInfoSchema, CacheMonitorSchema
from app.common.response import ResponseSchema, SuccessResponse
from app.core.dependencies import AuthPermission, redis_getter
from app.core.router_class import OperationLogRoute
from .service import CacheService
CacheRouter = APIRouter(route_class=OperationLogRoute, prefix="/cache", tags=["缓存监控"])
@CacheRouter.get("/info", summary="获取缓存监控信息", response_model=ResponseSchema[CacheMonitorSchema], dependencies=[Security(AuthPermission(["module_monitor:cache:query"]))])
async def get_monitor_cache_info_controller(
redis: Annotated[Redis, Depends(redis_getter)],
) -> JSONResponse:
result = await CacheService.get_monitor_statistical_info(redis=redis)
return SuccessResponse(data=result, msg="获取缓存监控信息成功")
@CacheRouter.get("/get/names", summary="获取缓存名称列表", response_model=ResponseSchema[list[CacheInfoSchema]], dependencies=[Security(AuthPermission(["module_monitor:cache:query"]))])
async def get_monitor_cache_name_controller() -> JSONResponse:
result = await CacheService.get_monitor_cache_names()
return SuccessResponse(data=result, msg="获取缓存名称列表成功")
@CacheRouter.get("/get/keys/{cache_name}", summary="获取缓存键名列表", response_model=ResponseSchema[list[CacheInfoSchema]], dependencies=[Security(AuthPermission(["module_monitor:cache:query"]))])
async def get_monitor_cache_key_controller(cache_name: Annotated[str, Path(description="缓存名称")], redis: Annotated[Redis, Depends(redis_getter)]) -> JSONResponse:
result = await CacheService.get_monitor_cache_keys(redis=redis, cache_name=cache_name)
return SuccessResponse(data=result, msg=f"获取缓存{cache_name}的键名列表成功")
@CacheRouter.get("/get/value/{cache_name}/{cache_key}", summary="获取缓存值", response_model=ResponseSchema[CacheInfoSchema], dependencies=[Security(AuthPermission(["module_monitor:cache:query"]))])
async def get_monitor_cache_value_controller(
cache_name: Annotated[str, Path(description="缓存名称")],
cache_key: Annotated[str, Path(description="缓存键名")],
redis: Annotated[Redis, Depends(redis_getter)],
) -> JSONResponse:
result = await CacheService.get_monitor_cache_value(redis=redis, cache_name=cache_name, cache_key=cache_key)
return SuccessResponse(data=result, msg=f"获取缓存{cache_name}:{cache_key}的值成功")
@CacheRouter.delete("/delete/name/{cache_name}", summary="清除指定缓存名称的所有缓存", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:cache:delete"]))])
async def clear_monitor_cache_name_controller(cache_name: Annotated[str, Path(description="缓存名称")], redis: Annotated[Redis, Depends(redis_getter)]) -> JSONResponse:
result = await CacheService.clear_monitor_cache_by_name(redis=redis, cache_name=cache_name)
return SuccessResponse(msg=f"{cache_name}对应键值清除成功", data=result)
@CacheRouter.delete("/delete/key/{cache_key}", summary="清除指定缓存键", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:cache:delete"]))])
async def clear_monitor_cache_key_controller(cache_key: Annotated[str, Path(description="缓存键名")], redis: Annotated[Redis, Depends(redis_getter)]) -> JSONResponse:
result = await CacheService.clear_monitor_cache_by_key(redis=redis, cache_key=cache_key)
return SuccessResponse(msg=f"{cache_key}清除成功", data=result)
@CacheRouter.delete("/clear", summary="清除所有缓存", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:cache:delete"]))])
async def clear_monitor_cache_all_controller(
redis: Annotated[Redis, Depends(redis_getter)],
) -> JSONResponse:
result = await CacheService.clear_monitor_cache_all(redis=redis)
return SuccessResponse(msg="所有缓存清除成功", data=result)
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel, Field
class CacheMonitorSchema(BaseModel):
"""缓存监控信息模型"""
command_stats: list[dict] = Field(default_factory=list, description="Redis命令统计信息")
db_size: int = Field(default=0, description="Redis数据库中的Key总数")
info: dict = Field(default_factory=dict, description="Redis服务器信息")
class CacheInfoSchema(BaseModel):
"""缓存对象信息模型"""
cache_key: str = Field(..., description="缓存键名")
cache_name: str = Field(..., description="缓存名称")
cache_value: str | None = Field(default=None, description="缓存值")
remark: str | None = Field(default=None, description="备注说明")
+67
View File
@@ -0,0 +1,67 @@
from redis.asyncio.client import Redis
from app.common.enums import RedisInitKeyConfig
from app.core.redis_crud import RedisCURD
from .schema import CacheInfoSchema, CacheMonitorSchema
class CacheService:
"""缓存监控模块服务层"""
@staticmethod
async def get_monitor_statistical_info(redis: Redis) -> CacheMonitorSchema:
info = await RedisCURD(redis).info()
db_size = await RedisCURD(redis).db_size()
command_stats_dict = await RedisCURD(redis).commandstats()
command_stats = [{"name": key.split("_")[1], "value": str(value.get("calls"))} for key, value in command_stats_dict.items()]
return CacheMonitorSchema(command_stats=command_stats, db_size=db_size, info=info)
@staticmethod
async def get_monitor_cache_names() -> list[CacheInfoSchema]:
return [
CacheInfoSchema(
cache_key="",
cache_name=key_config.key,
cache_value="",
remark=key_config.remark,
)
for key_config in RedisInitKeyConfig
]
@staticmethod
async def get_monitor_cache_keys(redis: Redis, cache_name: str) -> list:
cache_keys = await RedisCURD(redis).get_keys(f"{cache_name}*")
return [key.split(":", 1)[1] for key in cache_keys if key.startswith(f"{cache_name}:")]
@staticmethod
async def get_monitor_cache_value(redis: Redis, cache_name: str, cache_key: str) -> CacheInfoSchema:
cache_value = await RedisCURD(redis).get(f"{cache_name}:{cache_key}")
return CacheInfoSchema(
cache_key=cache_key,
cache_name=cache_name,
cache_value=cache_value,
remark="",
)
@staticmethod
async def clear_monitor_cache_by_name(redis: Redis, cache_name: str) -> bool:
cache_keys = await RedisCURD(redis).get_keys(f"{cache_name}*")
if cache_keys:
await RedisCURD(redis).delete(*cache_keys)
return True
@staticmethod
async def clear_monitor_cache_by_key(redis: Redis, cache_key: str) -> bool:
cache_keys = await RedisCURD(redis).get_keys(f"*{cache_key}")
if cache_keys:
await RedisCURD(redis).delete(*cache_keys)
return True
@staticmethod
async def clear_monitor_cache_all(redis: Redis) -> bool:
cache_keys = await RedisCURD(redis).get_keys()
if cache_keys:
await RedisCURD(redis).delete(*cache_keys)
return True
@@ -0,0 +1 @@
@@ -0,0 +1,68 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Query, Security
from fastapi.responses import JSONResponse
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.request import PaginationService
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_schema import AuthSchema, PaginationQueryParam
from app.core.dependencies import AuthPermission, db_getter, get_current_user, redis_getter
from app.core.router_class import OperationLogRoute
from .schema import DashboardStatsSchema, OnlineOutSchema, OnlineQueryParam
from .service import OnlineService
OnlineRouter = APIRouter(route_class=OperationLogRoute, prefix="/online", tags=["在线用户"])
@OnlineRouter.get("/list", summary="获取在线用户列表", response_model=ResponseSchema[list[OnlineOutSchema]], dependencies=[Security(AuthPermission(["module_monitor:online:query"]))])
async def get_online_list_controller(
redis: Annotated[Redis, Depends(redis_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[OnlineQueryParam, Query()],
) -> JSONResponse:
result_dict_list = await OnlineService.get_online_list(redis=redis, search=search)
result_dict = await PaginationService.paginate(
data_list=result_dict_list,
page_no=page.page_no,
page_size=page.page_size,
)
return SuccessResponse(data=result_dict, msg="获取成功")
@OnlineRouter.get("/current", summary="获取当前用户的在线会话", response_model=ResponseSchema[list[OnlineOutSchema]], dependencies=[Depends(get_current_user)])
async def get_current_online_controller(
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(get_current_user)],
) -> JSONResponse:
sessions = await OnlineService.get_current_user_sessions(redis=redis, user_id=auth.user.id)
return SuccessResponse(data=sessions, msg="获取当前用户在线会话成功")
@OnlineRouter.delete("/delete", summary="强制下线", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:online:delete"]))])
async def delete_online_controller(
session_id: Annotated[str, Body(description="会话编号")],
redis: Annotated[Redis, Depends(redis_getter)],
) -> JSONResponse:
await OnlineService.delete_online(redis=redis, session_id=session_id)
return SuccessResponse(msg="强制下线成功")
@OnlineRouter.delete("/clear", summary="清除所有在线用户", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:online:delete"]))])
async def clear_online_controller(
redis: Annotated[Redis, Depends(redis_getter)],
) -> JSONResponse:
await OnlineService.clear_online(redis=redis)
return SuccessResponse(msg="清除所有在线用户成功")
@OnlineRouter.get("/stats", summary="获取仪表盘统计数据", response_model=ResponseSchema[DashboardStatsSchema])
async def get_dashboard_stats_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
redis: Annotated[Redis, Depends(redis_getter)],
_auth: Annotated[AuthSchema, Security(AuthPermission(["module_monitor:dashboard:query"]))],
) -> JSONResponse:
data = await OnlineService.get_dashboard_stats(db=db, redis=redis)
return SuccessResponse(data=data, msg="获取仪表盘统计成功")
@@ -0,0 +1,36 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.core.base_schema import SessionInfoSchema
class OnlineOutSchema(SessionInfoSchema):
"""在线用户响应模型 — ``SessionInfoSchema`` 的公开子集。"""
class OnlineQueryParam(BaseModel):
"""在线用户查询参数"""
name: str | None = Field(None, description="登录名称")
ipaddr: str | None = Field(None, description="登陆IP地址")
login_location: str | None = Field(None, description="登录所属地")
class RecentLoginItem(BaseModel):
"""最近登录记录"""
username: str
status: int
login_time: datetime
login_ip: str | None = None
login_location: str | None = None
class DashboardStatsSchema(BaseModel):
"""仪表盘统计数据"""
online_users: int = 0
total_users: int = 0
today_login_count: int = 0
today_unique_users: int = 0
week_user_created: int = 0
recent_logins: list[RecentLoginItem] = []
@@ -0,0 +1,140 @@
import json
from datetime import date, datetime, timedelta
from redis.asyncio.client import Redis
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.log.model import LoginLogModel
from app.api.v1.module_system.user.model import UserModel
from app.common.enums import RedisInitKeyConfig
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
from app.core.security import decode_access_token
from .schema import DashboardStatsSchema, OnlineQueryParam, RecentLoginItem
class OnlineService:
"""在线用户管理模块服务层"""
@staticmethod
async def get_online_list(redis: Redis, search: OnlineQueryParam | None = None) -> list[dict]:
keys = await RedisCURD(redis).scan_keys(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*")
tokens = await RedisCURD(redis).mget(keys)
online_users = []
for key, token in zip(keys, tokens, strict=True):
if not token:
continue
try:
payload = decode_access_token(token=token)
session_id = payload.sub
# 从 Redis 读取完整会话信息
raw = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
if not raw:
continue
session_info = json.loads(raw)
# 内联搜索匹配逻辑
if search:
if search.name and search.name[1]:
kw = search.name[1].strip("%")
if kw.lower() not in session_info.get("name", "").lower():
continue
if search.ipaddr and search.ipaddr[1]:
kw = search.ipaddr[1].strip("%")
if kw not in session_info.get("ipaddr", ""):
continue
if search.login_location and search.login_location[1]:
kw = search.login_location[1].strip("%")
if kw.lower() not in session_info.get("login_location", "").lower():
continue
online_users.append(session_info)
except Exception:
# token 已过期或无效,清理 Redis 中的脏数据
key_str = key.decode() if isinstance(key, bytes) else key
session_id = key_str.split(":")[-1]
await RedisCURD(redis).delete(key_str)
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}")
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
continue
online_users.sort(key=lambda x: x.get("login_time", ""), reverse=True)
return online_users
@staticmethod
async def get_current_user_sessions(redis: Redis, user_id: int) -> list[dict]:
"""获取当前用户的在线会话列表"""
all_online = await OnlineService.get_online_list(redis)
return [s for s in all_online if s.get("user_id") == user_id]
@staticmethod
async def delete_online(redis: Redis, session_id: str) -> None:
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}")
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}")
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
logger.info(f"强制下线用户会话: {session_id}")
@staticmethod
async def clear_online(redis: Redis) -> None:
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*")
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:*")
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.USER_SESSION.key}:*")
logger.info("清除所有在线用户会话成功")
@staticmethod
async def get_dashboard_stats(db: AsyncSession, redis: Redis) -> DashboardStatsSchema:
"""获取仪表盘统计数据"""
today_start = datetime.combine(date.today(), datetime.min.time())
week_start = today_start - timedelta(days=7)
online_count = len(await OnlineService.get_online_list(redis))
users_sql = select(func.count()).select_from(UserModel).where(UserModel.is_deleted.is_(False))
user_count = (await db.execute(users_sql)).scalar() or 0
users_week_sql = (
select(func.count()).select_from(UserModel)
.where(UserModel.is_deleted.is_(False), UserModel.created_time >= week_start)
)
user_week_count = (await db.execute(users_week_sql)).scalar() or 0
today_login_sql = (
select(func.count()).select_from(LoginLogModel)
.where(LoginLogModel.created_time >= today_start)
)
today_login_count = (await db.execute(today_login_sql)).scalar() or 0
today_unique_sql = (
select(func.count(func.distinct(LoginLogModel.username)))
.select_from(LoginLogModel)
.where(LoginLogModel.created_time >= today_start)
)
today_unique_count = (await db.execute(today_unique_sql)).scalar() or 0
recent_stmt = (
select(LoginLogModel.username, LoginLogModel.status, LoginLogModel.created_time,
LoginLogModel.login_ip, LoginLogModel.login_location)
.where(LoginLogModel.is_deleted.is_(False))
.order_by(LoginLogModel.created_time.desc())
.limit(10)
)
recent_rows = (await db.execute(recent_stmt)).all()
recent_logins = [
RecentLoginItem(username=r.username, status=r.status, login_time=r.created_time,
login_ip=r.login_ip, login_location=r.login_location)
for r in recent_rows
]
result = DashboardStatsSchema(
online_users=online_count,
total_users=user_count,
today_login_count=today_login_count,
today_unique_users=today_unique_count,
week_user_created=user_week_count,
recent_logins=recent_logins,
)
return result
@@ -0,0 +1,8 @@
# 见 docs/PLUGIN_ARCHITECTURE.md
name = "monitor"
title = "监控"
version = "1.0.0"
description = "监控功能;路由由 module_monitor/**/controller 动态注册。"
optional = true
tags = ["monitor"]
@@ -0,0 +1,123 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, File, Form, Query, Request, Security, UploadFile, status
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from app.api.v1.module_common.file.service import FileService
from app.common.request import PaginationService
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse, UploadFileResponse
from app.core.base_schema import PaginationQueryParam, UploadResponseSchema
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from .schema import ResourceCopySchema, ResourceCreateDirSchema, ResourceItemSchema, ResourceMoveSchema, ResourceRenameSchema, ResourceSearchQueryParam
from .service import ResourceService
ResourceRouter = APIRouter(route_class=OperationLogRoute, prefix="/resource", tags=["资源管理"])
@ResourceRouter.get("/list", summary="获取目录列表", response_model=ResponseSchema[list[ResourceItemSchema]], dependencies=[Security(AuthPermission(["module_monitor:resource:query"]))])
async def get_directory_list_controller(
request: Request,
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[ResourceSearchQueryParam, Query()],
) -> JSONResponse:
result_dict_list = await ResourceService.get_resources_list(search=search, base_url=str(request.base_url))
result_dict = await PaginationService.paginate(
data_list=result_dict_list,
page_no=page.page_no,
page_size=page.page_size,
)
return SuccessResponse(data=result_dict, msg="获取目录列表成功")
@ResourceRouter.post("/upload", summary="上传文件", response_model=ResponseSchema[UploadResponseSchema], dependencies=[Security(AuthPermission(["module_monitor:resource:upload"]))])
async def upload_file_controller(
request: Request,
file: Annotated[UploadFile, File(description="上传文件")],
target_path: Annotated[str | None, Form(description="目标目录路径")] = None,
) -> JSONResponse:
result = await FileService.upload_service(
base_url=str(request.base_url),
file=file,
upload_type="resource",
target_path=target_path,
)
return SuccessResponse(data=result, msg="上传文件成功")
@ResourceRouter.get(
"/download",
summary="下载文件",
dependencies=[Security(AuthPermission(["module_monitor:resource:download"]))],
)
async def download_file_controller(
path: Annotated[str, Query(description="文件路径")],
) -> FileResponse:
file_path = await ResourceService.download_file(file_path=path)
import os
filename = os.path.basename(file_path)
return UploadFileResponse(
file_path=file_path,
filename=filename,
media_type="application/octet-stream",
)
@ResourceRouter.delete("/delete", summary="删除文件", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:resource:delete"]))])
async def delete_files_controller(
paths: Annotated[list[str], Body(description="文件路径列表")],
) -> JSONResponse:
await ResourceService.delete_file(paths=paths)
return SuccessResponse(msg="删除文件成功")
@ResourceRouter.post("/move", summary="移动文件", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:resource:move"]))])
async def move_file_controller(
data: Annotated[ResourceMoveSchema, Body(description="移动文件参数")],
) -> JSONResponse:
await ResourceService.move_file(data=data)
return SuccessResponse(msg="移动文件成功")
@ResourceRouter.post("/copy", summary="复制文件", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:resource:copy"]))])
async def copy_file_controller(
data: Annotated[ResourceCopySchema, Body(description="复制文件参数")],
) -> JSONResponse:
await ResourceService.copy_file(data=data)
return SuccessResponse(msg="复制文件成功")
@ResourceRouter.post("/rename", summary="重命名文件", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:resource:rename"]))])
async def rename_file_controller(
data: Annotated[ResourceRenameSchema, Body(description="重命名文件参数")],
) -> JSONResponse:
await ResourceService.rename_file(data=data)
return SuccessResponse(msg="重命名文件成功")
@ResourceRouter.post("/mkdir", status_code=status.HTTP_201_CREATED, summary="创建目录", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_monitor:resource:mkdir"]))])
async def create_directory_controller(
data: Annotated[ResourceCreateDirSchema, Body(description="创建目录参数")],
) -> JSONResponse:
await ResourceService.create_directory(data=data)
return SuccessResponse(msg="创建目录成功")
@ResourceRouter.post("/export", summary="导出资源列表", dependencies=[Security(AuthPermission(["module_monitor:resource:export"]))])
async def export_resource_list_controller(
request: Request,
search: Annotated[ResourceSearchQueryParam, Query()],
) -> StreamingResponse:
result_dict_list = await ResourceService.get_resources_list(search=search, base_url=str(request.base_url))
export_result = await ResourceService.export_resource(data_list=result_dict_list)
return StreamResponse(
data=bytes2file_response(export_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=resource_list.xlsx"},
)
@@ -0,0 +1,191 @@
from datetime import datetime
from urllib.parse import urlparse
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
class ResourceItemSchema(BaseModel):
"""资源项目模型"""
model_config = ConfigDict(from_attributes=True)
name: 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: int | None = Field(None, description="文件大小(字节)")
created_time: datetime | None = Field(None, description="创建时间")
modified_time: datetime | None = Field(None, description="修改时间")
is_hidden: bool = Field(False, description="是否为隐藏文件")
@field_validator("file_url")
@classmethod
def _validate_file_url(cls, v: str) -> str:
v = v.strip()
parsed = urlparse(v)
# 允许相对路径(以 / 开头)和完整的 http/https URL
if parsed.scheme and parsed.scheme not in ("http", "https"):
raise ValueError("文件URL必须为 http/https 或相对路径")
return v
@field_validator("relative_path")
@classmethod
def _validate_relative_path(cls, v: str) -> str:
v = v.strip()
if ".." in v or v.startswith("\\"):
raise ValueError("相对路径包含不安全字符")
return v
@model_validator(mode="after")
def _validate_flags(self):
if self.is_file and self.is_dir:
raise ValueError("不能同时为文件和目录")
if not self.is_file and not self.is_dir:
raise ValueError("必须是文件或目录之一")
# 根据名称自动修正隐藏标记
self.is_hidden = self.name.startswith(".")
return self
class ResourceDirectorySchema(BaseModel):
"""资源目录模型"""
model_config = ConfigDict(from_attributes=True)
path: str = Field(..., description="目录路径")
name: str = Field(..., description="目录名称")
items: list[ResourceItemSchema] = Field(default_factory=list, description="目录项")
total_files: int = Field(0, description="文件总数")
total_dirs: int = Field(0, description="目录总数")
total_size: int = Field(0, description="总大小")
class ResourceUploadSchema(BaseModel):
"""资源上传响应模型"""
model_config = ConfigDict(from_attributes=True)
filename: str = Field(..., description="文件名")
file_url: str = Field(..., description="访问URL")
file_size: int = 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="是否覆盖")
@field_validator("source_path", "target_path")
@classmethod
def validate_paths(cls, value: str):
"""校验移动/复制涉及的源路径与目标路径非空并去首尾空格。
参数:
- value (str): 路径字段当前值。
返回:
- str: 去空格后的路径。
异常:
- ValueError: 路径为空时抛出。
"""
if not value or len(value.strip()) == 0:
raise ValueError("路径不能为空")
return value.strip()
class ResourceCopySchema(ResourceMoveSchema):
"""资源复制模型"""
class ResourceRenameSchema(BaseModel):
"""资源重命名模型"""
model_config = ConfigDict(from_attributes=True)
old_path: str = Field(..., description="原路径")
new_name: str = Field(..., max_length=255, description="新名称")
@field_validator("old_path", "new_name")
@classmethod
def validate_inputs(cls, value: str):
"""校验重命名所需的原路径与新名称非空并去首尾空格。
参数:
- value (str): 字段当前值。
返回:
- str: 去空格后的值。
异常:
- ValueError: 值为空时抛出。
"""
if not value or len(value.strip()) == 0:
raise ValueError("参数不能为空")
return value.strip()
@field_validator("new_name")
@classmethod
def _validate_new_name(cls, v: str) -> str:
v = v.strip()
if ".." in v or "/" in v or "\\" in v:
raise ValueError("新名称包含不安全字符")
return v
class ResourceCreateDirSchema(BaseModel):
"""创建目录模型"""
model_config = ConfigDict(from_attributes=True)
parent_path: str = Field(..., description="父目录路径")
dir_name: str = Field(..., description="目录名称", max_length=255)
@field_validator("parent_path", "dir_name")
@classmethod
def validate_inputs(cls, value: str, info):
"""校验创建目录的父路径与目录名,防止路径遍历等不安全输入。
参数:
- value (str): 当前字段值。
- info: Pydantic 校验上下文(含 `field_name`)。
返回:
- str: 规范化后的字段值。
异常:
- ValueError: 含不安全字符或目录名为空时抛出。
"""
# 对于parent_path允许为空字符串(表示根目录)或 '/',其他情况必须非空
if info.field_name == "parent_path":
# 对于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(("/", "\\")):
raise ValueError("参数包含不安全字符")
return value.strip()
class ResourceSearchQueryParam(BaseModel):
"""资源搜索查询参数"""
name: str | None = Field(None, description="搜索关键词")
path: str | None = Field(None, description="目录路径")
include_hidden: bool = Field(False, description="是否包含隐藏文件")
@@ -0,0 +1,471 @@
import ast
import os
import re
import shutil
import urllib.parse
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from app.config.path_conf import STATIC_DIR
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.excel_util import ExcelUtil
from .schema import (
ResourceCopySchema,
ResourceCreateDirSchema,
ResourceItemSchema,
ResourceMoveSchema,
ResourceRenameSchema,
ResourceSearchQueryParam,
)
class ResourceService:
"""资源管理模块服务层 - 管理系统静态文件目录(仅管理 upload 目录)"""
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
MAX_SEARCH_RESULTS = 1000
MAX_PATH_DEPTH = 20
@staticmethod
def _get_resource_root() -> str:
resource_root = str(settings.UPLOAD_FILE_PATH)
os.makedirs(resource_root, exist_ok=True)
return resource_root
@staticmethod
def _get_safe_path(path: str | None = None) -> str:
resource_root = ResourceService._get_resource_root()
if not path or not isinstance(path, str):
return resource_root
static_prefix = settings.STATIC_URL.rstrip("/")
root_prefix = settings.ROOT_PATH.rstrip("/") if getattr(settings, "ROOT_PATH", "") else ""
root_static_prefix = f"{root_prefix}{static_prefix}" if root_prefix else static_prefix
def strip_prefix(p: str) -> str:
if p.startswith(root_static_prefix):
return p[len(root_static_prefix) :].lstrip("/")
if p.startswith(static_prefix):
return p[len(static_prefix) :].lstrip("/")
return p
if path.startswith(("http://", "https://")):
parsed = urlparse(path)
url_path = parsed.path or ""
path = strip_prefix(url_path)
else:
path = strip_prefix(path)
path = path.strip().replace("//", "/").replace("\\\\\\\\", "/").replace("\\\\", "/")
path = path.removeprefix("/")
path = path.removeprefix("upload/")
if ".." in path or "\x00" in path:
logger.error(f"检测到路径遍历攻击尝试: {path}")
raise CustomException(msg="非法的路径格式")
decoded_path = urllib.parse.unquote(path)
if ".." in decoded_path:
logger.error(f"检测到编码后的路径遍历攻击: {path}")
raise CustomException(msg="非法的路径格式")
safe_path = os.path.normpath(os.path.join(resource_root, path))
resource_root_abs = os.path.normpath(os.path.abspath(resource_root))
safe_path_abs = os.path.normpath(os.path.abspath(safe_path))
if not safe_path_abs.startswith(resource_root_abs + os.sep) and safe_path_abs != resource_root_abs:
logger.error(f"路径遍历攻击被阻止: 尝试访问 {safe_path_abs}, 但根目录是 {resource_root_abs}")
raise CustomException(msg="访问路径不在允许范围内")
try:
relative_path = os.path.relpath(safe_path_abs, resource_root_abs)
if relative_path.count(os.sep) > ResourceService.MAX_PATH_DEPTH:
raise CustomException(msg="路径深度超过限制")
except ValueError:
raise CustomException(msg="无效的路径")
return safe_path_abs
@staticmethod
def _path_exists(path: str) -> bool:
try:
safe_path = ResourceService._get_safe_path(path)
return os.path.exists(safe_path)
except Exception as e:
raise CustomException(msg=f"检查路径是否存在失败: {e!s}")
@staticmethod
def _sanitize_filename(filename: str) -> str:
if not filename:
return f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}"
dangerous_patterns = [
r"\.\.",
r"[\/]",
r"\x00",
r"%2e%2e",
r"%252e%252e",
]
for pattern in dangerous_patterns:
if re.search(pattern, filename, re.IGNORECASE):
logger.error(f"检测到文件名路径遍历攻击: {filename}")
return f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}"
decoded = urllib.parse.unquote(filename)
decoded_twice = urllib.parse.unquote(decoded)
for check in [decoded, decoded_twice]:
if ".." in check or "/" in check or "\\" in check:
logger.error(f"检测到编码后的文件名攻击: {filename}")
return f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}"
filename = os.path.basename(filename)
filename = re.sub(r'[<>:"|?*\x00-\x1f]', "", filename)
filename = re.sub(r"\.{2,}", ".", filename)
filename = filename.strip(". ")
if not filename:
filename = f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}"
return filename
@staticmethod
def _detect_file_type(content: bytes) -> str | None:
if content.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if content.startswith(b"GIF87a") or content.startswith(b"GIF89a"):
return "image/gif"
if content.startswith(b"PK\x03\x04"):
if b"[Content_Types].xml" in content[:1000]:
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
return "application/zip"
if content.startswith(b"%PDF"):
return "application/pdf"
if content.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
return "application/msword"
return None
@staticmethod
def _generate_http_url(file_path: str, base_url: str | None = None) -> str:
static_root = str(STATIC_DIR)
try:
relative_path = os.path.relpath(file_path, static_root)
url_path = relative_path.replace(os.sep, "/")
except ValueError:
url_path = os.path.basename(file_path)
if base_url:
base_part = base_url.rstrip("/")
static_part = settings.STATIC_URL.lstrip("/")
file_part = url_path.lstrip("/")
http_url = f"{base_part}/{static_part}/{file_part}".replace("//", "/").replace(":/", "://")
else:
http_url = f"{settings.STATIC_URL}/{url_path}".replace("//", "/")
return http_url
@staticmethod
def _get_file_info(file_path: str, base_url: str | None = None) -> ResourceItemSchema | None:
try:
safe_path = file_path
if not os.path.exists(safe_path):
return None
stat = os.stat(safe_path)
path_obj = Path(safe_path)
resource_root = ResourceService._get_resource_root()
try:
relative_path = os.path.relpath(safe_path, resource_root)
except ValueError:
relative_path = os.path.basename(safe_path)
http_url = ResourceService._generate_http_url(safe_path, base_url)
is_hidden = path_obj.name.startswith(".")
return ResourceItemSchema(
name=path_obj.name,
file_url=http_url,
relative_path=relative_path,
is_file=os.path.isfile(safe_path),
is_dir=os.path.isdir(safe_path),
size=stat.st_size if os.path.isfile(safe_path) else None,
created_time=datetime.fromtimestamp(stat.st_ctime),
modified_time=datetime.fromtimestamp(stat.st_mtime),
is_hidden=is_hidden,
)
except Exception as e:
logger.error(f"获取文件信息失败: {e!s}")
return None
@staticmethod
async def get_resources_list(
search: ResourceSearchQueryParam | None = None,
order_by: str | None = None,
base_url: str | None = None,
) -> list[ResourceItemSchema]:
try:
if search and hasattr(search, "path") and search.path and isinstance(search.path, str):
resource_root = ResourceService._get_safe_path(search.path)
else:
resource_root = ResourceService._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:
include_hidden = search.include_hidden if search and hasattr(search, "include_hidden") else False
for item_name in os.listdir(resource_root):
if item_name.startswith(".") and not include_hidden:
continue
item_path = os.path.join(resource_root, item_name)
file_info = ResourceService._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.name.lower():
continue
all_resources.append(file_info)
except PermissionError:
raise CustomException(msg="没有权限访问此目录")
sorted_resources = ResourceService._sort_results(all_resources, order_by)
if len(sorted_resources) > ResourceService.MAX_SEARCH_RESULTS:
sorted_resources = sorted_resources[: ResourceService.MAX_SEARCH_RESULTS]
return sorted_resources
except Exception as e:
logger.error(f"搜索资源失败: {e!s}")
raise CustomException(msg=f"搜索资源失败: {e!s}")
@staticmethod
async def export_resource(data_list: list[ResourceItemSchema]) -> bytes:
mapping_dict = {
"name": "文件名",
"path": "文件路径",
"size": "文件大小",
"created_time": "创建时间",
"modified_time": "修改时间",
"parent_path": "父目录",
}
export_data = [item.model_dump() for item in data_list]
for item in export_data:
if item.get("size"):
item["size"] = ResourceService._format_file_size(item["size"])
return ExcelUtil.export_list2excel(list_data=export_data, mapping_dict=mapping_dict)
@staticmethod
async def download_file(file_path: str) -> str:
safe_path = ResourceService._get_safe_path(file_path)
if not os.path.exists(safe_path):
raise CustomException(msg="文件不存在")
if not os.path.isfile(safe_path):
raise CustomException(msg="路径不是文件")
return safe_path
@staticmethod
async def delete_file(paths: list[str]) -> None:
for path_item in paths:
safe_path = ResourceService._get_safe_path(path_item)
if not os.path.exists(safe_path):
raise CustomException(msg=f"文件不存在: {path_item}")
try:
if os.path.isfile(safe_path):
os.remove(safe_path)
elif os.path.isdir(safe_path):
shutil.rmtree(safe_path)
else:
raise CustomException(msg=f"无法识别的文件类型: {path_item}")
except PermissionError:
raise CustomException(msg=f"没有权限删除: {path_item}")
except OSError as e:
raise CustomException(msg=f"删除失败: {path_item} - {e!s}")
logger.info(f"成功删除: {path_item}")
@staticmethod
async def move_file(data: ResourceMoveSchema) -> None:
source_safe = ResourceService._get_safe_path(data.source_path)
target_dir_safe = ResourceService._get_safe_path(data.target_path)
if not os.path.exists(source_safe):
raise CustomException(msg=f"源文件不存在: {data.source_path}")
if not os.path.isdir(target_dir_safe):
raise CustomException(msg=f"目标目录不存在: {data.target_path}")
filename = os.path.basename(source_safe)
target_path = os.path.join(target_dir_safe, filename)
if os.path.exists(target_path):
raise CustomException(msg=f"目标位置已存在同名文件: {filename}")
try:
shutil.move(source_safe, target_path)
except PermissionError:
raise CustomException(msg=f"没有权限移动文件: {data.source_path}")
except OSError as e:
raise CustomException(msg=f"移动文件失败: {e!s}")
logger.info(f"成功移动文件: {data.source_path} -> {data.target_path}")
@staticmethod
async def copy_file(data: ResourceCopySchema) -> None:
source_safe = ResourceService._get_safe_path(data.source_path)
target_dir_safe = ResourceService._get_safe_path(data.target_path)
if not os.path.exists(source_safe):
raise CustomException(msg=f"源文件不存在: {data.source_path}")
if not os.path.isdir(target_dir_safe):
raise CustomException(msg=f"目标目录不存在: {data.target_path}")
filename = os.path.basename(source_safe)
target_path = os.path.join(target_dir_safe, filename)
if os.path.exists(target_path):
raise CustomException(msg=f"目标位置已存在同名文件: {filename}")
try:
if os.path.isdir(source_safe):
shutil.copytree(source_safe, target_path)
else:
shutil.copy2(source_safe, target_path)
except PermissionError:
raise CustomException(msg=f"没有权限复制文件: {data.source_path}")
except OSError as e:
raise CustomException(msg=f"复制文件失败: {e!s}")
logger.info(f"成功复制文件: {data.source_path} -> {data.target_path}")
@staticmethod
async def rename_file(data: ResourceRenameSchema) -> None:
safe_path = ResourceService._get_safe_path(data.old_path)
parent_dir = os.path.dirname(safe_path)
safe_name = ResourceService._sanitize_filename(data.new_name)
new_path = os.path.join(parent_dir, safe_name)
if os.path.exists(new_path):
raise CustomException(msg=f"目标文件名已存在: {safe_name}")
try:
os.rename(safe_path, new_path)
except PermissionError:
raise CustomException(msg=f"没有权限重命名: {data.old_path}")
except OSError as e:
raise CustomException(msg=f"重命名失败: {e!s}")
logger.info(f"成功重命名: {data.old_path} -> {safe_name}")
@staticmethod
async def create_directory(data: ResourceCreateDirSchema) -> None:
parent_dir = ResourceService._get_safe_path(data.parent_path)
if not os.path.isdir(parent_dir):
raise CustomException(msg=f"父目录不存在: {data.parent_path}")
safe_name = ResourceService._sanitize_filename(data.dir_name)
new_dir = os.path.join(parent_dir, safe_name)
if os.path.exists(new_dir):
raise CustomException(msg=f"目录已存在: {data.dir_name}")
try:
os.makedirs(new_dir, exist_ok=False)
except PermissionError:
raise CustomException(msg=f"没有权限创建目录: {data.dir_name}")
except OSError as e:
raise CustomException(msg=f"创建目录失败: {e!s}")
logger.info(f"成功创建目录: {data.parent_path}/{safe_name}")
@staticmethod
async def _get_directory_stats(path: str, include_hidden: bool = False) -> dict[str, int]:
stats = {"files": 0, "dirs": 0, "size": 0}
try:
for root, dirs, files in os.walk(path):
if not include_hidden:
dirs[:] = [d for d in dirs if not d.startswith(".")]
files = [f for f in files if not f.startswith(".")]
stats["dirs"] += len(dirs)
stats["files"] += len(files)
for file in files:
file_path = os.path.join(root, file)
try:
stats["size"] += os.path.getsize(file_path)
except OSError:
continue
except Exception:
pass
return stats
@staticmethod
def _sort_results(results: list[ResourceItemSchema], order_by: str | None = None) -> list[ResourceItemSchema]:
try:
if not order_by:
return sorted(results, key=lambda x: x.name, reverse=False)
sort_conditions = ast.literal_eval(order_by)
if isinstance(sort_conditions, list):
def sort_key(item):
keys = []
for cond in sort_conditions:
field = cond.get("field", "name")
value = getattr(item, field, "")
if field in ["created_time", "modified_time", "accessed_time"] and value:
if isinstance(value, str):
value = datetime.fromisoformat(value)
keys.append(value)
return keys
reverse = False
if sort_conditions and isinstance(sort_conditions[0], dict):
order = sort_conditions[0].get("order", "asc")
reverse = order.lower() == "desc"
return sorted(results, key=sort_key, reverse=reverse)
return sorted(results, key=lambda x: x.name, reverse=False)
except (ValueError, SyntaxError):
return sorted(results, key=lambda x: x.name, reverse=False)
@staticmethod
def _format_file_size(size_bytes: int) -> str:
size = float(size_bytes)
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024:
return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} TB"
@@ -0,0 +1 @@
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Security
from fastapi.responses import JSONResponse
from app.api.v1.module_monitor.server.schema import ServerMonitorSchema
from app.common.response import ResponseSchema, SuccessResponse
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from .service import ServerService
ServerRouter = APIRouter(route_class=OperationLogRoute, prefix="/server", tags=["服务器监控"])
@ServerRouter.get("/info", summary="查询服务器监控信息", response_model=ResponseSchema[ServerMonitorSchema], dependencies=[Security(AuthPermission(["module_monitor:server:query"]))])
async def get_monitor_server_info_controller() -> JSONResponse:
result_dict = await ServerService.get_server_monitor_info()
return SuccessResponse(data=result_dict, msg="获取服务器监控信息成功")
@@ -0,0 +1,65 @@
from pydantic import BaseModel, Field
class CpuInfoSchema(BaseModel):
"""CPU信息模型"""
cpu_num: int = Field(description="CPU核心数")
used: float = Field(ge=0, le=100, description="CPU用户使用率(%)")
sys: float = Field(ge=0, le=100, description="CPU系统使用率(%)")
free: float = Field(ge=0, le=100, description="CPU空闲率(%)")
class MemoryInfoSchema(BaseModel):
"""内存信息模型"""
total: str = Field(description="内存总量")
used: str = Field(description="已用内存")
free: str = Field(description="剩余内存")
usage: float = Field(ge=0, le=100, description="使用率(%)")
class SysInfoSchema(BaseModel):
"""系统信息模型"""
computer_ip: str = Field(description="服务器IP")
computer_name: str = Field(description="服务器名称")
os_arch: str = Field(description="系统架构")
os_name: str = Field(description="操作系统")
user_dir: str = Field(description="项目路径")
class PyInfoSchema(BaseModel):
"""Python运行信息模型"""
name: str = Field(description="Python名称")
version: str = Field(description="Python版本")
start_time: str = Field(description="启动时间")
run_time: str = Field(description="运行时长")
home: str = Field(description="安装路径")
memory_used: str = Field(description="内存占用")
memory_usage: float = Field(ge=0, le=100, description="内存使用率(%)")
memory_total: str = Field(description="总内存")
memory_free: str = Field(description="剩余内存")
class DiskInfoSchema(BaseModel):
"""磁盘信息模型"""
dir_name: str = Field(description="磁盘路径")
sys_type_name: str = Field(description="文件系统类型")
type_name: str = Field(description="磁盘类型")
total: str = Field(description="总容量")
used: str = Field(description="已用容量")
free: str = Field(description="可用容量")
usage: float = Field(ge=0, le=100, description="使用率(%)")
class ServerMonitorSchema(BaseModel):
"""服务器监控信息模型"""
cpu: CpuInfoSchema = Field(description="CPU信息")
mem: MemoryInfoSchema = Field(description="内存信息")
py: PyInfoSchema = Field(description="Python运行信息")
sys: SysInfoSchema = Field(description="系统信息")
disks: list[DiskInfoSchema] = Field(default_factory=list, description="磁盘信息")
@@ -0,0 +1,116 @@
import platform
import socket
import time
from pathlib import Path
import psutil
from app.utils.common_util import bytes2human
from .schema import (
CpuInfoSchema,
DiskInfoSchema,
MemoryInfoSchema,
PyInfoSchema,
ServerMonitorSchema,
SysInfoSchema,
)
class ServerService:
"""服务监控模块服务层"""
@staticmethod
async def get_server_monitor_info() -> ServerMonitorSchema:
return ServerMonitorSchema(
cpu=ServerService._get_cpu_info(),
mem=ServerService._get_memory_info(),
sys=ServerService._get_system_info(),
py=ServerService._get_python_info(),
disks=ServerService._get_disk_info(),
)
@staticmethod
def _get_cpu_info() -> CpuInfoSchema:
cpu_times = psutil.cpu_times_percent()
cpu_num = psutil.cpu_count(logical=True)
if not cpu_num:
cpu_num = 1
return CpuInfoSchema(
cpu_num=cpu_num,
used=cpu_times.user,
sys=cpu_times.system,
free=cpu_times.idle,
)
@staticmethod
def _get_memory_info() -> MemoryInfoSchema:
memory = psutil.virtual_memory()
return MemoryInfoSchema(
total=bytes2human(memory.total),
used=bytes2human(memory.used),
free=bytes2human(memory.free),
usage=memory.percent,
)
@staticmethod
def _get_system_info() -> SysInfoSchema:
hostname = socket.gethostname()
return SysInfoSchema(
computer_ip=socket.gethostbyname(hostname),
computer_name=platform.node(),
os_arch=platform.machine(),
os_name=platform.platform(),
user_dir=str(Path.cwd()),
)
@staticmethod
def _get_python_info() -> PyInfoSchema:
current_process = psutil.Process()
memory = psutil.virtual_memory()
process_memory = current_process.memory_info()
start_time = current_process.create_time()
run_time = ServerService._calculate_run_time(start_time)
return PyInfoSchema(
name=current_process.name(),
version=platform.python_version(),
start_time=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(start_time)),
run_time=run_time,
home=str(Path(current_process.exe())),
memory_total=bytes2human(memory.available),
memory_used=bytes2human(process_memory.rss),
memory_free=bytes2human(memory.available - process_memory.rss),
memory_usage=round((process_memory.rss / memory.available) * 100, 2),
)
@staticmethod
def _get_disk_info() -> list[DiskInfoSchema]:
disk_info = []
for partition in psutil.disk_partitions():
try:
usage = psutil.disk_usage(partition.mountpoint)
mount_point = str(Path(partition.mountpoint))
disk_info.append(
DiskInfoSchema(
dir_name=mount_point,
sys_type_name=partition.fstype,
type_name=f"本地固定磁盘({mount_point}",
total=bytes2human(usage.total),
used=bytes2human(usage.used),
free=bytes2human(usage.free),
usage=usage.percent,
),
)
except (PermissionError, FileNotFoundError):
continue
return disk_info
@staticmethod
def _calculate_run_time(start_time: float) -> str:
difference = time.time() - start_time
days = int(difference // (24 * 60 * 60))
hours = int((difference % (24 * 60 * 60)) // (60 * 60))
minutes = int((difference % (60 * 60)) // 60)
return f"{days}{hours}小时{minutes}分钟"