init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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="备注说明")
|
||||
@@ -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}分钟"
|
||||
Reference in New Issue
Block a user