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,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)