init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .controller import LogRouter
|
||||
|
||||
__all__ = ["LogRouter"]
|
||||
@@ -0,0 +1,113 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter, get_current_user
|
||||
from app.core.router_class import OperationLogRoute
|
||||
from app.utils.common_util import bytes2file_response
|
||||
|
||||
from .schema import (
|
||||
LoginLogDetailOutSchema,
|
||||
LoginLogOutSchema,
|
||||
LoginLogQueryParam,
|
||||
OperationLogDetailOutSchema,
|
||||
OperationLogOutSchema,
|
||||
OperationLogQueryParam,
|
||||
)
|
||||
from .service import LoginLogService, OperationLogService
|
||||
|
||||
LogRouter = APIRouter(route_class=OperationLogRoute, prefix="/log", tags=["日志管理"])
|
||||
|
||||
|
||||
@LogRouter.get("/login/detail/{id}", summary="获取登录日志详情", response_model=ResponseSchema[LoginLogDetailOutSchema])
|
||||
async def get_log_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:login_log:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="登录日志ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await LoginLogService(auth, db).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取登录日志详情成功")
|
||||
|
||||
|
||||
@LogRouter.get("/login/list", summary="查询登录日志列表", response_model=ResponseSchema[PageResultSchema[LoginLogOutSchema]])
|
||||
async def get_log_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:login_log:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[LoginLogQueryParam, Query()],
|
||||
) -> JSONResponse:
|
||||
result_dict = await LoginLogService(auth, db).page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询登录日志列表成功")
|
||||
|
||||
|
||||
@LogRouter.delete("/login/delete", summary="删除登录日志", response_model=ResponseSchema)
|
||||
async def delete_log_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:login_log:delete"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await LoginLogService(auth, db).delete(ids=ids)
|
||||
return SuccessResponse(msg="删除登录日志成功")
|
||||
|
||||
|
||||
@LogRouter.get("/operation/detail/{id}", summary="获取操作日志详情", response_model=ResponseSchema[OperationLogDetailOutSchema], dependencies=[Security(AuthPermission(["module_system:log:query"]))])
|
||||
async def get_operation_log_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="操作日志ID", gt=0)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await OperationLogService(auth, db).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取操作日志详情成功")
|
||||
|
||||
|
||||
@LogRouter.get(
|
||||
"/operation/list", summary="获取操作日志列表", response_model=ResponseSchema[PageResultSchema[OperationLogOutSchema]], dependencies=[Security(AuthPermission(["module_system:log:query"]))],
|
||||
)
|
||||
async def get_operation_log_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[OperationLogQueryParam, Query()],
|
||||
) -> JSONResponse:
|
||||
result_dict = await OperationLogService(auth, db).page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询操作日志列表成功")
|
||||
|
||||
|
||||
@LogRouter.delete("/operation/delete", summary="删除操作日志", response_model=ResponseSchema, dependencies=[Security(AuthPermission(["module_system:log:delete"]))])
|
||||
async def delete_operation_log_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await OperationLogService(auth, db).delete(ids=ids)
|
||||
return SuccessResponse(msg="删除操作日志成功")
|
||||
|
||||
|
||||
@LogRouter.post("/operation/export", summary="导出操作日志", dependencies=[Security(AuthPermission(["module_system:log:export"]))])
|
||||
async def export_operation_log_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
search: Annotated[OperationLogQueryParam, Body()],
|
||||
) -> StreamingResponse:
|
||||
operation_log_query_result = await OperationLogService(auth, db).get_list(search=search)
|
||||
operation_log_export_result = OperationLogService.export_list(operation_log_list=[item.model_dump() for item in operation_log_query_result])
|
||||
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(operation_log_export_result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=operation_log.xlsx"},
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import LoginLogModel, OperationLogModel
|
||||
from .schema import LoginLogCreateSchema, OperationLogCreateSchema
|
||||
|
||||
|
||||
class LoginLogCRUD(CRUDBase[LoginLogModel, LoginLogCreateSchema, None]):
|
||||
"""登录日志数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
super().__init__(model=LoginLogModel, auth=auth, db=db)
|
||||
|
||||
|
||||
class OperationLogCRUD(CRUDBase[OperationLogModel, OperationLogCreateSchema, None]):
|
||||
"""操作日志 CRUD"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
super().__init__(model=OperationLogModel, auth=auth, db=db)
|
||||
@@ -0,0 +1,55 @@
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.config.setting import settings
|
||||
from app.core.base_model import ModelMixin
|
||||
|
||||
|
||||
def get_log_text_column_type():
|
||||
"""根据数据库类型选择适合存储大文本的列类型。
|
||||
"""
|
||||
db_type = settings.DATABASE_TYPE
|
||||
if db_type == "mysql":
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
return LONGTEXT
|
||||
if db_type == "postgres":
|
||||
from sqlalchemy.dialects.postgresql import TEXT
|
||||
|
||||
return TEXT
|
||||
return Text
|
||||
|
||||
|
||||
class LoginLogModel(ModelMixin):
|
||||
"""登录日志模型
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_login_log"
|
||||
__table_args__: dict[str, str] = {"comment": "登录日志表"}
|
||||
|
||||
status: Mapped[int] = mapped_column(Integer, default=1, index=True, comment="登录状态(1成功 2失败)")
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="用户名")
|
||||
login_location: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="登录位置")
|
||||
login_ip: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="登录IP地址")
|
||||
request_os: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="操作系统")
|
||||
request_browser: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="浏览器")
|
||||
msg: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="提示消息")
|
||||
|
||||
|
||||
class OperationLogModel(ModelMixin):
|
||||
"""操作日志模型"""
|
||||
|
||||
__tablename__: str = "sys_operation_log"
|
||||
__table_args__: dict[str, str] = {"comment": "操作日志表"}
|
||||
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="操作人用户名")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="操作状态(0:成功 1:失败)")
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
request_path: Mapped[str] = mapped_column(String(255), index=True, comment="请求路径")
|
||||
request_method: Mapped[str] = mapped_column(String(10), comment="请求方式")
|
||||
request_payload: Mapped[str | None] = mapped_column(get_log_text_column_type(), comment="请求体")
|
||||
response_code: Mapped[int] = mapped_column(Integer, comment="响应状态码")
|
||||
response_json: Mapped[str | None] = mapped_column(get_log_text_column_type(), nullable=True, comment="响应体")
|
||||
process_time: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="处理时间")
|
||||
request_ip: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True, comment="请求IP")
|
||||
@@ -0,0 +1,103 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema
|
||||
|
||||
ALLOWED_REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
|
||||
|
||||
class LoginLogCreateSchema(BaseModel):
|
||||
"""新增登录日志"""
|
||||
|
||||
username: str = Field(..., min_length=1, max_length=64, description="用户名")
|
||||
status: int = Field(default=1, ge=1, le=2, description="登录状态(1成功 2失败)")
|
||||
login_ip: str | None = Field(default=None, max_length=50, description="登录IP地址")
|
||||
login_location: str | None = Field(default=None, max_length=255, description="登录位置")
|
||||
request_os: str | None = Field(default=None, max_length=64, description="操作系统")
|
||||
request_browser: str | None = Field(default=None, max_length=64, description="浏览器")
|
||||
msg: str | None = Field(default=None, max_length=255, description="提示消息")
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("用户名不能为空")
|
||||
if len(v) > 64:
|
||||
raise ValueError("用户名长度不能超过64个字符")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, v: int) -> int:
|
||||
if v not in [1, 2]:
|
||||
raise ValueError("登录状态只能为1(成功)或2(失败)")
|
||||
return v
|
||||
|
||||
|
||||
class LoginLogOutSchema(LoginLogCreateSchema, BaseSchema):
|
||||
"""登录日志响应"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class LoginLogDetailOutSchema(LoginLogOutSchema):
|
||||
"""登录日志详情响应"""
|
||||
|
||||
|
||||
class LoginLogQueryParam(BaseQueryParam):
|
||||
"""登录日志查询参数"""
|
||||
|
||||
username: str | None = Field(None, max_length=64, description="用户名", json_schema_extra={"q": "like"})
|
||||
status: int | None = Field(None, description="登录状态(1:成功 2:失败)", json_schema_extra={"q": "eq"})
|
||||
|
||||
|
||||
class OperationLogQueryParam(BaseQueryParam):
|
||||
"""操作日志查询参数"""
|
||||
|
||||
request_path: str | None = Field(None, description="请求路径", json_schema_extra={"q": "like"})
|
||||
request_method: str | None = Field(None, description="请求方式", json_schema_extra={"q": "eq"})
|
||||
username: str | None = Field(None, description="用户名", json_schema_extra={"q": "like"})
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:成功 1:失败)", json_schema_extra={"q": "eq"})
|
||||
request_ip: str | None = Field(None, description="请求IP", json_schema_extra={"q": "eq"})
|
||||
|
||||
|
||||
class OperationLogOutSchema(BaseSchema):
|
||||
"""操作日志响应模型"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
username: str = Field(..., description="操作人用户名")
|
||||
status: int | None = Field(default=None, description="状态(0:启动 1:停用)")
|
||||
description: str | None = Field(default=None, description="描述")
|
||||
request_path: str = Field(..., description="请求路径")
|
||||
request_method: str = Field(..., description="请求方式")
|
||||
response_code: int = Field(..., description="响应状态码")
|
||||
process_time: str | None = Field(default=None, description="处理时间")
|
||||
request_ip: str | None = Field(default=None, description="请求IP")
|
||||
|
||||
|
||||
class OperationLogDetailOutSchema(OperationLogOutSchema):
|
||||
"""操作日志详情响应模型"""
|
||||
|
||||
request_payload: str | None = Field(default=None, description="请求体")
|
||||
response_json: str | None = Field(default=None, description="响应体")
|
||||
|
||||
|
||||
class OperationLogCreateSchema(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64, description="操作人用户名")
|
||||
request_path: str = Field(..., min_length=1, max_length=255, description="请求路径")
|
||||
request_method: str = Field(..., description="请求方式")
|
||||
request_payload: str | None = Field(None, description="请求体")
|
||||
response_code: int = Field(200, ge=100, le=599, description="响应状态码")
|
||||
response_json: str | None = Field(None, description="响应体")
|
||||
process_time: str | None = Field(None, max_length=20, description="处理时间")
|
||||
description: str | None = Field(None, description="备注")
|
||||
request_ip: str | None = Field(None, max_length=50, description="请求IP")
|
||||
|
||||
@field_validator("request_method")
|
||||
@classmethod
|
||||
def validate_request_method(cls, value: str) -> str:
|
||||
upper_value = value.upper()
|
||||
if upper_value not in ALLOWED_REQUEST_METHODS:
|
||||
raise ValueError(f"请求方式必须是: {', '.join(ALLOWED_REQUEST_METHODS)}")
|
||||
return upper_value
|
||||
@@ -0,0 +1,149 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config.setting import settings
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.database import async_db_session
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.utils.common_util import search_to_dict
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
|
||||
from .crud import LoginLogCRUD, OperationLogCRUD
|
||||
from .model import OperationLogModel
|
||||
from .schema import (
|
||||
LoginLogDetailOutSchema,
|
||||
LoginLogOutSchema,
|
||||
LoginLogQueryParam,
|
||||
OperationLogDetailOutSchema,
|
||||
OperationLogOutSchema,
|
||||
OperationLogQueryParam,
|
||||
)
|
||||
|
||||
|
||||
class LoginLogService:
|
||||
"""登录日志管理服务"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
async def detail(self, id: int) -> LoginLogDetailOutSchema:
|
||||
obj = await LoginLogCRUD(self.auth, self.db).get_or_404(id=id)
|
||||
return LoginLogDetailOutSchema.model_validate(obj)
|
||||
|
||||
async def page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: LoginLogQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[LoginLogOutSchema]:
|
||||
return await LoginLogCRUD(self.auth, self.db).page(
|
||||
offset=(page_no - 1) * page_size,
|
||||
limit=page_size,
|
||||
order_by=order_by or [{"updated_time": "desc"}],
|
||||
search=search_to_dict(search),
|
||||
out_schema=LoginLogOutSchema,
|
||||
)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
|
||||
existing = await LoginLogCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)})
|
||||
existing_map = {obj.id for obj in existing}
|
||||
for nid in ids:
|
||||
if nid not in existing_map:
|
||||
raise CustomException(msg=f"删除失败,ID为{nid}的数据不存在")
|
||||
|
||||
await LoginLogCRUD(self.auth, self.db).delete(ids=ids)
|
||||
|
||||
|
||||
class OperationLogService:
|
||||
"""操作日志管理服务"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_operation_log() -> bool:
|
||||
from .model import LoginLogModel
|
||||
|
||||
retention_days = settings.OPERATION_LOG_RETENTION_DAYS
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||
async with async_db_session() as session:
|
||||
op_stmt = delete(OperationLogModel).where(OperationLogModel.created_time < cutoff)
|
||||
op_result: Any = await session.execute(op_stmt)
|
||||
|
||||
login_stmt = delete(LoginLogModel).where(LoginLogModel.created_time < cutoff)
|
||||
login_result: Any = await session.execute(login_stmt)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"操作日志清理完成: 操作日志 {op_result.rowcount} 条, 登录日志 {login_result.rowcount} 条")
|
||||
return True
|
||||
|
||||
async def page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: OperationLogQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[OperationLogOutSchema]:
|
||||
crud = OperationLogCRUD(self.auth, self.db)
|
||||
return await crud.page(
|
||||
offset=(page_no - 1) * page_size,
|
||||
limit=page_size,
|
||||
order_by=order_by or [{"id": "desc"}],
|
||||
search=search_to_dict(search),
|
||||
out_schema=OperationLogOutSchema,
|
||||
)
|
||||
|
||||
async def detail(self, id: int) -> OperationLogDetailOutSchema:
|
||||
crud = OperationLogCRUD(self.auth, self.db)
|
||||
obj = await crud.get_or_404(id=id)
|
||||
return OperationLogDetailOutSchema.model_validate(obj)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
existing = await OperationLogCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)})
|
||||
existing_map = {obj.id for obj in existing}
|
||||
for nid in ids:
|
||||
if nid not in existing_map:
|
||||
raise CustomException(msg="删除失败,该数据不存在")
|
||||
crud = OperationLogCRUD(self.auth, self.db)
|
||||
await crud.delete(ids=ids)
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
search: OperationLogQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> list[OperationLogOutSchema]:
|
||||
crud = OperationLogCRUD(self.auth, self.db)
|
||||
obj_list = await crud.get_list(
|
||||
search=search_to_dict(search),
|
||||
order_by=order_by or [{"id": "desc"}],
|
||||
)
|
||||
return [OperationLogOutSchema.model_validate(obj) for obj in obj_list]
|
||||
|
||||
@staticmethod
|
||||
def export_list(operation_log_list: list[dict[str, Any]]) -> bytes:
|
||||
"""导出操作日志列表"""
|
||||
mapping_dict = {
|
||||
"id": "日志编号",
|
||||
"request_path": "请求路径",
|
||||
"request_method": "请求方法",
|
||||
"request_ip": "请求IP",
|
||||
"request_payload": "请求参数",
|
||||
"response_code": "响应状态码",
|
||||
"process_time": "耗时(ms)",
|
||||
"created_time": "操作时间",
|
||||
"created_id": "操作用户ID",
|
||||
}
|
||||
return ExcelUtil.export_list2excel(list_data=operation_log_list, mapping_dict=mapping_dict)
|
||||
Reference in New Issue
Block a user