upload project source code

This commit is contained in:
2026-04-30 18:49:43 +08:00
commit 9b394ba682
2277 changed files with 660945 additions and 0 deletions

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,537 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, Path, Body, WebSocket, Form, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from app.common.response import StreamResponse, SuccessResponse
from app.common.request import PaginationService
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.router_class import OperationLogRoute
from .service import McpService, AIProviderService, EmbeddingConfigService, KnowledgeBaseService, AIModelConfigService, AIModelTrainingService, AIModelTestService
from .schema import (
McpCreateSchema, McpUpdateSchema, ChatQuerySchema, McpQueryParam,
AIProviderCreateSchema, AIProviderUpdateSchema, AIProviderQueryParam,
EmbeddingConfigCreateSchema, EmbeddingConfigUpdateSchema, EmbeddingConfigQueryParam,
KnowledgeBaseCreateSchema, KnowledgeBaseUpdateSchema, KnowledgeBaseQueryParam,
AIModelConfigUpdateSchema, AIModelConfigQueryParam, AIModelTrainingChatSchema,
AIModelTestSchema
)
AIRouter = APIRouter(route_class=OperationLogRoute, prefix="/ai", tags=["MCP智能助手"])
AIProviderRouter = APIRouter(route_class=OperationLogRoute, prefix="/ai/provider", tags=["AI供应商配置"])
EmbeddingConfigRouter = APIRouter(route_class=OperationLogRoute, prefix="/ai/embedding", tags=["向量化配置"])
KnowledgeBaseRouter = APIRouter(route_class=OperationLogRoute, prefix="/ai/knowledge", tags=["知识库管理"])
AIModelConfigRouter = APIRouter(route_class=OperationLogRoute, prefix="/ai/model", tags=["AI模型配置"])
@AIRouter.post("/chat", summary="智能对话", description="与MCP智能助手进行对话")
async def chat_controller(
query: ChatQuerySchema,
auth: AuthSchema = Depends(AuthPermission())
) -> StreamingResponse:
"""
智能对话接口
参数:
- query (ChatQuerySchema): 聊天查询模型
返回:
- StreamingResponse: 流式响应,每次返回一个聊天响应
"""
user_name = auth.user.name if auth.user else "未知用户"
log.info(f"用户 {user_name} 发起智能对话: {query.message[:50]}...")
async def generate_response():
try:
async for chunk in McpService.chat_query(query=query):
# 确保返回的是字节串
if chunk:
yield chunk.encode('utf-8') if isinstance(chunk, str) else chunk
except Exception as e:
log.error(f"流式响应出错: {str(e)}")
yield f"抱歉,处理您的请求时出现了错误: {str(e)}".encode('utf-8')
return StreamResponse(generate_response(), media_type="text/plain; charset=utf-8")
@AIRouter.get("/detail/{id}", summary="获取 MCP 服务器详情", description="获取 MCP 服务器详情")
async def detail_controller(
id: int = Path(..., description="MCP ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
"""
获取 MCP 服务器详情接口
参数:
- id (int): MCP 服务器ID
返回:
- JSONResponse: 包含 MCP 服务器详情的 JSON 响应
"""
result_dict = await McpService.detail_service(auth=auth, id=id)
log.info(f"获取 MCP 服务器详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取 MCP 服务器详情成功")
@AIRouter.get("/list", summary="查询 MCP 服务器列表", description="查询 MCP 服务器列表")
async def list_controller(
page: PaginationQueryParam = Depends(),
search: McpQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
"""
查询 MCP 服务器列表接口
参数:
- page (PaginationQueryParam): 分页查询参数模型
- search (McpQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含 MCP 服务器列表的 JSON 响应
"""
result_dict_list = await McpService.list_service(auth=auth, search=search, order_by=page.order_by)
result_dict = await PaginationService.paginate(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size)
log.info(f"查询 MCP 服务器列表成功")
return SuccessResponse(data=result_dict, msg="查询 MCP 服务器列表成功")
@AIRouter.post("/create", summary="创建 MCP 服务器", description="创建 MCP 服务器")
async def create_controller(
data: McpCreateSchema,
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
"""
创建 MCP 服务器接口
参数:
- data (McpCreateSchema): 创建 MCP 服务器模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含创建 MCP 服务器结果的 JSON 响应
"""
result_dict = await McpService.create_service(auth=auth, data=data)
log.info(f"创建 MCP 服务器成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="创建 MCP 服务器成功")
@AIRouter.put("/update/{id}", summary="修改 MCP 服务器", description="修改 MCP 服务器")
async def update_controller(
data: McpUpdateSchema,
id: int = Path(..., description="MCP ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
"""
修改 MCP 服务器接口
参数:
- data (McpUpdateSchema): 修改 MCP 服务器模型
- id (int): MCP 服务器ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含修改 MCP 服务器结果的 JSON 响应
"""
result_dict = await McpService.update_service(auth=auth, id=id, data=data)
log.info(f"修改 MCP 服务器成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="修改 MCP 服务器成功")
@AIRouter.delete("/delete", summary="删除 MCP 服务器", description="删除 MCP 服务器")
async def delete_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
"""
删除 MCP 服务器接口
参数:
- ids (list[int]): MCP 服务器ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除 MCP 服务器结果的 JSON 响应
"""
await McpService.delete_service(auth=auth, ids=ids)
log.info(f"删除 MCP 服务器成功: {ids}")
return SuccessResponse(msg="删除 MCP 服务器成功")
@AIRouter.websocket("/ws/chat", name="WebSocket聊天")
async def websocket_chat_controller(
websocket: WebSocket,
):
"""
WebSocket聊天接口
ws://127.0.0.1:8001/api/v1/ai/mcp/ws/chat
"""
await websocket.accept()
try:
while True:
data = await websocket.receive_text()
# 流式发送响应
try:
async for chunk in McpService.chat_query(query=ChatQuerySchema(message=data)):
if chunk:
await websocket.send_text(chunk)
except Exception as e:
log.error(f"处理聊天查询出错: {str(e)}")
await websocket.send_text(f"抱歉,处理您的请求时出现了错误: {str(e)}")
except Exception as e:
log.error(f"WebSocket聊天出错: {str(e)}")
finally:
await websocket.close()
@AIModelConfigRouter.post("/test", summary="起名测试")
async def naming_test_controller(
test_data: AIModelTestSchema,
) -> JSONResponse:
"""
起名测试接口(用于小程序/外部调用)
- 读取模型配置和训练信息
- 内部流式调用AI供应商
- 组合响应后一次性返回
- 仅读数据,不保存对话记录
参数:
- model_type: 模型类型(enterprise_naming/personal_naming等)
- text: 用户输入的文本
返回:
- data: AI的完整响应文本
"""
log.info(f"[起名测试] 收到请求: model_type={test_data.model_type}, text={test_data.text[:50]}...")
result = await AIModelTestService.test_naming(
model_type=test_data.model_type,
text=test_data.text
)
log.info(f"[起名测试] 响应完成,长度: {len(result)}")
return SuccessResponse(data=result, msg="测试成功")
# ============== AI供应商配置 ==============
@AIProviderRouter.get("/detail/{id}", summary="获取AI供应商详情")
async def provider_detail_controller(
id: int = Path(..., description="AI供应商ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await AIProviderService.detail_service(auth=auth, id=id)
return SuccessResponse(data=result_dict, msg="获取AI供应商详情成功")
@AIProviderRouter.get("/list", summary="查询AI供应商列表")
async def provider_list_controller(
page: PaginationQueryParam = Depends(),
search: AIProviderQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict_list = await AIProviderService.list_service(auth=auth, search=search, order_by=page.order_by)
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="查询AI供应商列表成功")
@AIProviderRouter.post("/create", summary="创建AI供应商")
async def provider_create_controller(
data: AIProviderCreateSchema,
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await AIProviderService.create_service(auth=auth, data=data)
log.info(f"创建AI供应商成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="创建AI供应商成功")
@AIProviderRouter.put("/update/{id}", summary="修改AI供应商")
async def provider_update_controller(
data: AIProviderUpdateSchema,
id: int = Path(..., description="AI供应商ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await AIProviderService.update_service(auth=auth, id=id, data=data)
log.info(f"修改AI供应商成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="修改AI供应商成功")
@AIProviderRouter.delete("/delete", summary="删除AI供应商")
async def provider_delete_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
await AIProviderService.delete_service(auth=auth, ids=ids)
log.info(f"删除AI供应商成功: {ids}")
return SuccessResponse(msg="删除AI供应商成功")
# ============== 向量化配置 ==============
@EmbeddingConfigRouter.get("/detail/{id}", summary="获取向量化配置详情")
async def embedding_detail_controller(
id: int = Path(..., description="向量化配置ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await EmbeddingConfigService.detail_service(auth=auth, id=id)
return SuccessResponse(data=result_dict, msg="获取向量化配置详情成功")
@EmbeddingConfigRouter.get("/list", summary="查询向量化配置列表")
async def embedding_list_controller(
page: PaginationQueryParam = Depends(),
search: EmbeddingConfigQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict_list = await EmbeddingConfigService.list_service(auth=auth, search=search, order_by=page.order_by)
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="查询向量化配置列表成功")
@EmbeddingConfigRouter.post("/create", summary="创建向量化配置")
async def embedding_create_controller(
data: EmbeddingConfigCreateSchema,
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await EmbeddingConfigService.create_service(auth=auth, data=data)
log.info(f"创建向量化配置成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="创建向量化配置成功")
@EmbeddingConfigRouter.put("/update/{id}", summary="修改向量化配置")
async def embedding_update_controller(
data: EmbeddingConfigUpdateSchema,
id: int = Path(..., description="向量化配置ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await EmbeddingConfigService.update_service(auth=auth, id=id, data=data)
log.info(f"修改向量化配置成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="修改向量化配置成功")
@EmbeddingConfigRouter.delete("/delete", summary="删除向量化配置")
async def embedding_delete_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
await EmbeddingConfigService.delete_service(auth=auth, ids=ids)
log.info(f"删除向量化配置成功: {ids}")
return SuccessResponse(msg="删除向量化配置成功")
# ============== 知识库管理 ==============
@KnowledgeBaseRouter.get("/detail/{id}", summary="获取知识库详情")
async def knowledge_detail_controller(
id: int = Path(..., description="知识库ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await KnowledgeBaseService.detail_service(auth=auth, id=id)
return SuccessResponse(data=result_dict, msg="获取知识库详情成功")
@KnowledgeBaseRouter.get("/list", summary="查询知识库列表")
async def knowledge_list_controller(
page: PaginationQueryParam = Depends(),
search: KnowledgeBaseQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict_list = await KnowledgeBaseService.list_service(auth=auth, search=search, order_by=page.order_by)
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="查询知识库列表成功")
@KnowledgeBaseRouter.post("/create", summary="创建知识库")
async def knowledge_create_controller(
name: str = Form(..., max_length=100, description="知识库名称"),
embedding_config_id: int | None = Form(None, description="向量化配置ID"),
description: str | None = Form(None, max_length=255, description="备注"),
files: list[UploadFile] = File(default=[], description="上传的文件列表"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
# 构造数据对象
data = KnowledgeBaseCreateSchema(name=name, embedding_config_id=embedding_config_id, description=description)
result_dict = await KnowledgeBaseService.create_service(auth=auth, data=data, files=files)
log.info(f"创建知识库成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="创建知识库成功")
@KnowledgeBaseRouter.put("/update/{id}", summary="修改知识库")
async def knowledge_update_controller(
data: KnowledgeBaseUpdateSchema,
id: int = Path(..., description="知识库ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await KnowledgeBaseService.update_service(auth=auth, id=id, data=data)
log.info(f"修改知识库成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="修改知识库成功")
@KnowledgeBaseRouter.delete("/delete", summary="删除知识库")
async def knowledge_delete_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
await KnowledgeBaseService.delete_service(auth=auth, ids=ids)
log.info(f"删除知识库成功: {ids}")
return SuccessResponse(msg="删除知识库成功")
@KnowledgeBaseRouter.post("/retry/{id}", summary="重试向量化")
async def knowledge_retry_controller(
id: int = Path(..., description="知识库ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await KnowledgeBaseService.retry_service(auth=auth, id=id)
log.info(f"重试向量化成功: {id}")
return SuccessResponse(data=result_dict, msg="已启动重新向量化")
# ============== AI模型配置 ==============
@AIModelConfigRouter.get("/detail/{model_type}", summary="获取AI模型配置详情")
async def model_config_detail_controller(
model_type: str = Path(..., description="模型类型(naming/renaming/scoring/report)"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await AIModelConfigService.detail_service(auth=auth, model_type=model_type)
return SuccessResponse(data=result_dict, msg="获取AI模型配置详情成功")
@AIModelConfigRouter.get("/list", summary="查询AI模型配置列表")
async def model_config_list_controller(
page: PaginationQueryParam = Depends(),
search: AIModelConfigQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict_list = await AIModelConfigService.list_service(auth=auth, search=search, order_by=page.order_by)
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="查询AI模型配置列表成功")
@AIModelConfigRouter.put("/update/{model_type}", summary="更新AI模型配置")
async def model_config_update_controller(
data: AIModelConfigUpdateSchema,
model_type: str = Path(..., description="模型类型(naming/renaming/scoring/report)"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_dict = await AIModelConfigService.update_service(auth=auth, model_type=model_type, data=data)
log.info(f"更新AI模型配置成功: {model_type}")
return SuccessResponse(data=result_dict, msg="更新AI模型配置成功")
@AIModelConfigRouter.get("/available-models/{provider_id}", summary="获取可用模型列表")
async def available_models_controller(
provider_id: int = Path(..., description="AI供应商ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_list = await AIModelConfigService.get_available_models(auth=auth, provider_id=provider_id)
return SuccessResponse(data=result_list, msg="获取可用模型列表成功")
# ============== AI模型训练对话 ==============
@AIModelConfigRouter.get("/messages/{model_type}", summary="获取训练对话记录")
async def training_messages_controller(
model_type: str = Path(..., description="模型类型(naming/renaming/scoring/report)"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
result_list = await AIModelTrainingService.get_messages_service(auth=auth, model_type=model_type)
return SuccessResponse(data=result_list, msg="获取训练对话记录成功")
@AIModelConfigRouter.delete("/message/{message_id}", summary="删除单条训练对话记录")
async def delete_training_message_controller(
message_id: int = Path(..., description="对话记录ID"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
await AIModelTrainingService.delete_message_service(auth=auth, message_id=message_id)
log.info(f"删除训练对话记录成功: {message_id}")
return SuccessResponse(msg="删除训练对话记录成功")
@AIModelConfigRouter.delete("/messages/{model_type}", summary="清空训练对话记录")
async def clear_training_messages_controller(
model_type: str = Path(..., description="模型类型(naming/renaming/scoring/report)"),
auth: AuthSchema = Depends(AuthPermission())
) -> JSONResponse:
await AIModelTrainingService.clear_messages_service(auth=auth, model_type=model_type)
log.info(f"清空训练对话记录成功: {model_type}")
return SuccessResponse(msg="清空训练对话记录成功")
@AIModelConfigRouter.post("/chat", summary="训练对话")
async def training_chat_controller(
chat_data: AIModelTrainingChatSchema,
auth: AuthSchema = Depends(AuthPermission())
) -> StreamingResponse:
"""
训练对话接口,流式输出
"""
log.info(f"[训练对话] 收到请求: model_type={chat_data.model_type}, message={chat_data.message[:50]}...")
async def generate_response():
log.info("[训练对话] 开始生成响应")
chunk_count = 0
try:
async for chunk in AIModelTrainingService.chat_stream(auth=auth, chat_data=chat_data):
if chunk:
chunk_count += 1
yield chunk.encode('utf-8') if isinstance(chunk, str) else chunk
log.info(f"[训练对话] 响应生成完成,共 {chunk_count} 个 chunk")
except Exception as e:
log.error(f"训练对话出错: {str(e)}")
yield f"处理您的请求时出现了错误: {str(e)}".encode('utf-8')
return StreamResponse(generate_response(), media_type="text/plain; charset=utf-8")
@AIModelConfigRouter.websocket("/ws/chat")
async def training_websocket_chat_controller(
websocket: WebSocket,
):
"""
训练对话 WebSocket 接口
"""
from app.core.security import verify_token
from app.api.v1.module_system.auth.schema import AuthSchema
await websocket.accept()
try:
while True:
data = await websocket.receive_json()
model_type = data.get('model_type', 'naming')
message = data.get('message', '')
config_changed = data.get('config_changed', False)
config_data = data.get('config_data')
# 构建chat_data
chat_data = AIModelTrainingChatSchema(
model_type=model_type,
message=message,
config_changed=config_changed,
config_data=AIModelConfigUpdateSchema(**config_data) if config_data else None
)
# 构建简化的authWebSocket没有正常的认证流程实际使用时需要实现认证
auth = AuthSchema()
# 流式发送响应
try:
async for chunk in AIModelTrainingService.chat_stream(auth=auth, chat_data=chat_data):
if chunk:
await websocket.send_text(chunk)
except Exception as e:
log.error(f"处理训练对话出错: {str(e)}")
await websocket.send_text(f"处理您的请求时出现了错误: {str(e)}")
except Exception as e:
log.error(f"WebSocket训练对话出错: {str(e)}")
finally:
await websocket.close()

View File

@@ -0,0 +1,270 @@
# -*- coding: utf-8 -*-
from typing import Sequence, Any
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import McpModel, AIProviderModel, EmbeddingConfigModel, KnowledgeBaseModel, AIModelConfigModel, AIModelTrainingMessageModel
from .schema import (
McpCreateSchema, McpUpdateSchema,
AIProviderCreateSchema, AIProviderUpdateSchema,
EmbeddingConfigCreateSchema, EmbeddingConfigUpdateSchema,
KnowledgeBaseCreateSchema, KnowledgeBaseUpdateSchema,
AIModelConfigCreateSchema, AIModelConfigUpdateSchema,
AIModelTrainingMessageCreateSchema
)
class McpCRUD(CRUDBase[McpModel, McpCreateSchema, McpUpdateSchema]):
"""MCP 服务器数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD
参数:
- auth (AuthSchema): 认证信息模型
"""
self.auth = auth
super().__init__(model=McpModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> McpModel | None:
"""
获取MCP服务器详情
参数:
- id (int): MCP服务器ID
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- McpModel | None: MCP服务器模型实例如果存在
"""
return await self.get(id=id, preload=preload)
async def get_by_name_crud(self, name: str, preload: list[str | Any] | None = None) -> McpModel | None:
"""
通过名称获取MCP服务器
参数:
- name (str): MCP服务器名称
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- Optional[McpModel]: MCP服务器模型实例如果存在
"""
return await self.get(name=name, preload=preload)
async def get_list_crud(self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[McpModel]:
"""
列表查询MCP服务器
参数:
- search (dict | None): 查询参数字典
- order_by (list[dict[str, str]] | None): 排序参数列表
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[McpModel]: MCP服务器模型实例序列
"""
return await self.list(search=search or {}, order_by=order_by or [{'id': 'asc'}], preload=preload)
async def create_crud(self, data: McpCreateSchema) -> McpModel | None:
"""
创建MCP服务器
参数:
- data (McpCreateSchema): 创建MCP服务器模型
返回:
- Optional[McpModel]: 创建的MCP服务器模型实例如果成功
"""
return await self.create(data=data)
async def update_crud(self, id: int, data: McpUpdateSchema) -> McpModel | None:
"""
更新MCP服务器
参数:
- id (int): MCP服务器ID
- data (McpUpdateSchema): 更新MCP服务器模型
返回:
- McpModel | None: 更新的MCP服务器模型实例如果成功
"""
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
"""
批量删除MCP服务器
参数:
- ids (list[int]): MCP服务器ID列表
返回:
- None
"""
return await self.delete(ids=ids)
class AIProviderCRUD(CRUDBase[AIProviderModel, AIProviderCreateSchema, AIProviderUpdateSchema]):
"""AI供应商数据层"""
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=AIProviderModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> AIProviderModel | None:
return await self.get(id=id, preload=preload)
async def get_by_name_crud(self, name: str, preload: list[str | Any] | None = None) -> AIProviderModel | None:
return await self.get(name=name, preload=preload)
async def get_list_crud(self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[AIProviderModel]:
return await self.list(search=search or {}, order_by=order_by or [{'id': 'asc'}], preload=preload)
async def create_crud(self, data: AIProviderCreateSchema) -> AIProviderModel | None:
return await self.create(data=data)
async def update_crud(self, id: int, data: AIProviderUpdateSchema) -> AIProviderModel | None:
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
return await self.delete(ids=ids)
async def clear_default_crud(self) -> None:
"""清除所有默认设置"""
obj_list = await self.list(search={'is_default': 1})
for obj in obj_list:
await self.update(id=obj.id, data=AIProviderUpdateSchema(
name=obj.name,
provider_type=obj.provider_type,
base_url=obj.base_url,
api_key=obj.api_key,
is_default=0,
description=obj.description
))
class EmbeddingConfigCRUD(CRUDBase[EmbeddingConfigModel, EmbeddingConfigCreateSchema, EmbeddingConfigUpdateSchema]):
"""向量化配置数据层"""
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=EmbeddingConfigModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> EmbeddingConfigModel | None:
return await self.get(id=id, preload=preload)
async def get_by_name_crud(self, name: str, preload: list[str | Any] | None = None) -> EmbeddingConfigModel | None:
return await self.get(name=name, preload=preload)
async def get_list_crud(self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[EmbeddingConfigModel]:
return await self.list(search=search or {}, order_by=order_by or [{'id': 'asc'}], preload=preload)
async def create_crud(self, data: EmbeddingConfigCreateSchema) -> EmbeddingConfigModel | None:
return await self.create(data=data)
async def update_crud(self, id: int, data: EmbeddingConfigUpdateSchema) -> EmbeddingConfigModel | None:
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
return await self.delete(ids=ids)
async def clear_default_crud(self) -> None:
"""清除所有默认设置"""
obj_list = await self.list(search={'is_default': 1})
for obj in obj_list:
await self.update(id=obj.id, data=EmbeddingConfigUpdateSchema(
name=obj.name,
embedding_type=obj.embedding_type,
model_name=obj.model_name,
base_url=obj.base_url,
api_key=obj.api_key,
is_default=0,
description=obj.description
))
class KnowledgeBaseCRUD(CRUDBase[KnowledgeBaseModel, KnowledgeBaseCreateSchema, KnowledgeBaseUpdateSchema]):
"""知识库数据层"""
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=KnowledgeBaseModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> KnowledgeBaseModel | None:
return await self.get(id=id, preload=preload)
async def get_by_name_crud(self, name: str, preload: list[str | Any] | None = None) -> KnowledgeBaseModel | None:
return await self.get(name=name, preload=preload)
async def get_by_collection_crud(self, collection_name: str, preload: list[str | Any] | None = None) -> KnowledgeBaseModel | None:
return await self.get(collection_name=collection_name, preload=preload)
async def get_list_crud(self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[KnowledgeBaseModel]:
return await self.list(search=search or {}, order_by=order_by or [{'id': 'desc'}], preload=preload)
async def create_crud(self, data: dict) -> KnowledgeBaseModel | None:
"""create方法接受字典因为需要额外字段"""
return await self.create(data=data)
async def update_crud(self, id: int, data: KnowledgeBaseUpdateSchema | dict) -> KnowledgeBaseModel | None:
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
return await self.delete(ids=ids)
class AIModelConfigCRUD(CRUDBase[AIModelConfigModel, AIModelConfigCreateSchema, AIModelConfigUpdateSchema]):
"""AI模型配置数据层"""
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=AIModelConfigModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> AIModelConfigModel | None:
return await self.get(id=id, preload=preload)
async def get_by_model_type_crud(self, model_type: str, preload: list[str | Any] | None = None) -> AIModelConfigModel | None:
return await self.get(model_type=model_type, preload=preload)
async def get_list_crud(self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[AIModelConfigModel]:
return await self.list(search=search or {}, order_by=order_by or [{'id': 'asc'}], preload=preload)
async def create_crud(self, data: AIModelConfigCreateSchema | dict) -> AIModelConfigModel | None:
return await self.create(data=data)
async def update_crud(self, id: int, data: AIModelConfigUpdateSchema | dict) -> AIModelConfigModel | None:
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
return await self.delete(ids=ids)
class AIModelTrainingMessageCRUD(CRUDBase[AIModelTrainingMessageModel, AIModelTrainingMessageCreateSchema, AIModelTrainingMessageCreateSchema]):
"""AI模型训练对话数据层"""
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=AIModelTrainingMessageModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> AIModelTrainingMessageModel | None:
return await self.get(id=id, preload=preload)
async def get_list_by_config_crud(self, model_config_id: int, preload: list[str | Any] | None = None) -> Sequence[AIModelTrainingMessageModel]:
"""获取某个模型配置的所有训练对话"""
return await self.list(search={'model_config_id': model_config_id}, order_by=[{'id': 'asc'}], preload=preload)
async def create_crud(self, data: AIModelTrainingMessageCreateSchema | dict) -> AIModelTrainingMessageModel | None:
return await self.create(data=data)
async def delete_crud(self, ids: list[int]) -> None:
return await self.delete(ids=ids)
async def delete_by_config_crud(self, model_config_id: int) -> None:
"""删除某个模型配置的所有训练对话"""
messages = await self.list(search={'model_config_id': model_config_id})
if messages:
ids = [m.id for m in messages]
await self.delete(ids=ids)

View File

@@ -0,0 +1,155 @@
'''
Author: caoziyuan ziyuan.cao@zhuying.com
Date: 2025-12-22 17:42:10
LastEditors: caoziyuan ziyuan.cao@zhuying.com
LastEditTime: 2025-12-22 18:03:28
FilePath: \naming-backend\app\api\v1\module_application\ai\model.py
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
'''
# -*- coding: utf-8 -*-
from sqlalchemy import JSON, String, Integer, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, UserMixin
class McpModel(ModelMixin, UserMixin):
"""
MCP 服务器表
MCP类型:
- 0: stdio (标准输入输出)
- 1: sse (Server-Sent Events)
"""
__tablename__: str = 'app_ai_mcp'
__table_args__: dict[str, str] = ({'comment': 'MCP 服务器表'})
__loader_options__: list[str] = ["created_by", "updated_by"]
name: Mapped[str] = mapped_column(String(50), comment='MCP 名称')
type: Mapped[int] = mapped_column(Integer, default=0, comment='MCP 类型(0:stdio 1:sse)')
url: Mapped[str | None] = mapped_column(String(255), default=None, comment='远程 SSE 地址')
command: Mapped[str | None] = mapped_column(String(255), default=None, comment='MCP 命令')
args: Mapped[str | None] = mapped_column(String(255), default=None, comment='MCP 命令参数')
env: Mapped[dict[str, str] | None] = mapped_column(JSON(), default=None, comment='MCP 环境变量')
class AIProviderModel(ModelMixin, UserMixin):
"""
AI供应商配置表
存储AI服务供应商的接口地址和API Key
"""
__tablename__: str = 'app_ai_provider'
__table_args__: dict[str, str] = ({'comment': 'AI供应商配置表'})
__loader_options__: list[str] = ["created_by", "updated_by"]
name: Mapped[str] = mapped_column(String(50), nullable=False, comment='供应商名称')
provider_type: Mapped[str] = mapped_column(String(50), nullable=False, comment='供应商类型(openai/deepseek/anthropic/gemini/qwen等)')
base_url: Mapped[str] = mapped_column(String(255), nullable=False, comment='接口地址BaseURL')
api_key: Mapped[str] = mapped_column(String(255), nullable=False, comment='API Key')
is_default: Mapped[int] = mapped_column(Integer, default=0, comment='是否默认供应商(0:否 1:是)')
class EmbeddingConfigModel(ModelMixin, UserMixin):
"""
知识库向量化配置表
支持本地或远程向量化服务
"""
__tablename__: str = 'app_ai_embedding_config'
__table_args__: dict[str, str] = ({'comment': '知识库向量化配置表'})
__loader_options__: list[str] = ["created_by", "updated_by"]
name: Mapped[str] = mapped_column(String(50), nullable=False, comment='配置名称')
embedding_type: Mapped[int] = mapped_column(Integer, default=0, comment='向量化类型(0:本地 1:远程)')
model_name: Mapped[str] = mapped_column(String(100), nullable=False, comment='Embedding模型名称')
base_url: Mapped[str | None] = mapped_column(String(255), default=None, comment='远程接口地址(远程模式必填)')
api_key: Mapped[str | None] = mapped_column(String(255), default=None, comment='远程API Key(远程模式必填)')
is_default: Mapped[int] = mapped_column(Integer, default=0, comment='是否默认配置(0:否 1:是)')
class KnowledgeBaseModel(ModelMixin, UserMixin):
"""
知识库表
存储知识库信息,关联向量化配置
"""
__tablename__: str = 'app_ai_knowledge_base'
__table_args__: dict[str, str] = ({'comment': '知识库表'})
__loader_options__: list[str] = ["created_by", "updated_by", "embedding_config"]
name: Mapped[str] = mapped_column(String(100), nullable=False, comment='知识库名称')
embedding_config_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey('app_ai_embedding_config.id', ondelete="SET NULL", onupdate="CASCADE"),
default=None,
nullable=True,
index=True,
comment='向量化配置ID'
)
collection_name: Mapped[str] = mapped_column(String(100), nullable=False, comment='ChromaDB集合名称')
document_count: Mapped[int] = mapped_column(Integer, default=0, comment='文档数量')
vector_count: Mapped[int] = mapped_column(Integer, default=0, comment='向量数量')
kb_status: Mapped[int] = mapped_column(Integer, default=0, comment='知识库状态(0:待处理 1:处理中 2:已完成 3:处理失败)')
error_message: Mapped[str | None] = mapped_column(Text, default=None, comment='错误信息')
file_paths: Mapped[list[str] | None] = mapped_column(JSON, default=None, comment='文件路径列表')
# 关联关系
embedding_config: Mapped["EmbeddingConfigModel | None"] = relationship(
"EmbeddingConfigModel",
lazy="selectin",
foreign_keys=[embedding_config_id],
uselist=False
)
class AIModelConfigModel(ModelMixin, UserMixin):
"""
AI模型配置表
存储不同类型AI模型的配置信息
模型类型:
- enterprise_naming(企业起名), enterprise_renaming(企业改名), enterprise_scoring(企业测名), enterprise_scoring_trial(企业测名试用)
- personal_naming(个人起名), personal_renaming(个人改名), personal_scoring(个人测名), personal_scoring_trial(个人测名试用)
"""
__tablename__: str = 'app_ai_model_config'
__table_args__: dict[str, str] = ({'comment': 'AI模型配置表'})
__loader_options__: list[str] = ["created_by", "updated_by", "provider", "knowledge_bases"]
model_type: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, comment='模型类型(enterprise_naming/enterprise_renaming/enterprise_scoring/enterprise_scoring_trial/personal_naming/personal_renaming/personal_scoring/personal_scoring_trial)')
model_name: Mapped[str | None] = mapped_column(String(100), default=None, comment='使用的模型名称')
provider_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey('app_ai_provider.id', ondelete="SET NULL", onupdate="CASCADE"),
default=None,
nullable=True,
index=True,
comment='AI供应商ID'
)
system_prompt: Mapped[str | None] = mapped_column(Text, default=None, comment='系统提示词')
temperature: Mapped[float] = mapped_column(default=1.0, comment='模型温度(0-2)')
knowledge_base_ids: Mapped[list[int] | None] = mapped_column(JSON, default=None, comment='关联的知识库ID列表')
# 关联关系
provider: Mapped["AIProviderModel | None"] = relationship(
"AIProviderModel",
lazy="selectin",
foreign_keys=[provider_id],
uselist=False
)
class AIModelTrainingMessageModel(ModelMixin, UserMixin):
"""
AI模型训练对话记录表
存储训练对话的历史记录
"""
__tablename__: str = 'app_ai_model_training_message'
__table_args__: dict[str, str] = ({'comment': 'AI模型训练对话记录表'})
__loader_options__: list[str] = ["created_by", "updated_by"]
model_config_id: Mapped[int] = mapped_column(
Integer,
ForeignKey('app_ai_model_config.id', ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment='模型配置ID'
)
role: Mapped[str] = mapped_column(String(20), nullable=False, comment='角色(user/assistant)')
content: Mapped[str] = mapped_column(Text, nullable=False, comment='消息内容')

View File

@@ -0,0 +1,307 @@
# -*- coding: utf-8 -*-
from pydantic import ConfigDict, Field, HttpUrl, BaseModel
from fastapi import Query
from app.core.base_schema import BaseSchema
from app.common.enums import McpLLMProvider, EmbeddingType, KnowledgeBaseStatus
from app.core.base_schema import BaseSchema, UserBySchema
from app.common.enums import McpType
from app.core.validator import DateTimeStr
class ChatQuerySchema(BaseModel):
"""聊天查询模型"""
message: str = Field(..., min_length=1, max_length=4000, description="聊天消息")
class McpCreateSchema(BaseModel):
"""创建 MCP 服务器参数"""
name: str = Field(..., max_length=64, description='MCP 名称')
type: McpType = Field(McpType.stdio, description='MCP 类型')
description: str | None = Field(None, max_length=255, description='MCP 描述')
url: HttpUrl | None = Field(None, description='远程 SSE 地址')
command: str | None = Field(None, max_length=255, description='MCP 命令')
args: str | None = Field(None, max_length=255, description='MCP 命令参数,多个参数用英文逗号隔开')
env: dict[str, str] | None = Field(None, description='MCP 环境变量')
class McpUpdateSchema(McpCreateSchema):
"""更新 MCP 服务器参数"""
...
class McpOutSchema(McpCreateSchema, BaseSchema, UserBySchema):
"""MCP 服务器详情"""
model_config = ConfigDict(from_attributes=True)
class McpQueryParam:
"""MCP 服务器查询参数"""
def __init__(
self,
name: str | None = Query(None, description="MCP 名称"),
type: McpType | None = Query(None, description="MCP 类型"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"),
) -> None:
# 模糊查询字段
self.name = ("like", name) if name else None
# 精确查询字段
self.type = type
self.created_id = created_id
self.updated_id = updated_id
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))
class McpChatParam(BaseSchema):
"""MCP 聊天参数"""
pk: list[int] = Field(..., description='MCP ID 列表')
provider: McpLLMProvider = Field(McpLLMProvider.openai, description='LLM 供应商')
model: str = Field(..., description='LLM 名称')
key: str = Field(..., description='LLM API Key')
base_url: str | None = Field(None, description='自定义 LLM API 地址,必须兼容 openai 供应商')
prompt: str = Field(..., description='用户提示词')
# ============== AI供应商配置 ==============
class AIProviderCreateSchema(BaseModel):
"""创建 AI供应商参数"""
name: str = Field(..., max_length=50, description='供应商名称')
provider_type: str = Field(..., max_length=50, description='供应商类型(openai/deepseek/anthropic/gemini/qwen等)')
base_url: str = Field(..., max_length=255, description='接口地址BaseURL')
api_key: str = Field(..., max_length=255, description='API Key')
is_default: int = Field(0, description='是否默认供应商(0:否 1:是)')
description: str | None = Field(None, max_length=255, description='备注')
class AIProviderUpdateSchema(AIProviderCreateSchema):
"""更新 AI供应商参数"""
...
class AIProviderOutSchema(AIProviderCreateSchema, BaseSchema, UserBySchema):
"""AI供应商详情"""
model_config = ConfigDict(from_attributes=True)
class AIProviderQueryParam:
"""AI供应商查询参数"""
def __init__(
self,
name: str | None = Query(None, description="供应商名称"),
provider_type: str | None = Query(None, description="供应商类型"),
is_default: int | None = Query(None, description="是否默认"),
) -> None:
self.name = ("like", name) if name else None
self.provider_type = provider_type
self.is_default = is_default
# ============== 向量化配置 ==============
class EmbeddingConfigCreateSchema(BaseModel):
"""创建 向量化配置参数"""
name: str = Field(..., max_length=50, description='配置名称')
embedding_type: int = Field(0, description='向量化类型(0:本地 1:远程)')
model_name: str = Field(..., max_length=100, description='Embedding模型名称')
base_url: str | None = Field(None, max_length=255, description='远程接口地址(远程模式必填)')
api_key: str | None = Field(None, max_length=255, description='远程API Key(远程模式必填)')
is_default: int = Field(0, description='是否默认配置(0:否 1:是)')
description: str | None = Field(None, max_length=255, description='备注')
class EmbeddingConfigUpdateSchema(EmbeddingConfigCreateSchema):
"""更新 向量化配置参数"""
...
class EmbeddingConfigOutSchema(EmbeddingConfigCreateSchema, BaseSchema, UserBySchema):
"""向量化配置详情"""
model_config = ConfigDict(from_attributes=True)
class EmbeddingConfigQueryParam:
"""向量化配置查询参数"""
def __init__(
self,
name: str | None = Query(None, description="配置名称"),
embedding_type: int | None = Query(None, description="向量化类型"),
is_default: int | None = Query(None, description="是否默认"),
) -> None:
self.name = ("like", name) if name else None
self.embedding_type = embedding_type
self.is_default = is_default
# ============== 知识库 ==============
class KnowledgeBaseCreateSchema(BaseModel):
"""创建 知识库参数"""
name: str = Field(..., max_length=100, description='知识库名称')
embedding_config_id: int | None = Field(None, description='向量化配置ID')
description: str | None = Field(None, max_length=255, description='备注')
class KnowledgeBaseUpdateSchema(BaseModel):
"""更新 知识库参数"""
name: str | None = Field(None, max_length=100, description='知识库名称')
embedding_config_id: int | None = Field(None, description='向量化配置ID')
description: str | None = Field(None, max_length=255, description='备注')
class EmbeddingConfigRefSchema(BaseModel):
"""向量化配置引用"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
embedding_type: int
model_name: str
class KnowledgeBaseOutSchema(BaseSchema, UserBySchema):
"""知识库详情"""
model_config = ConfigDict(from_attributes=True)
name: str
embedding_config_id: int | None = None
collection_name: str
document_count: int = 0
vector_count: int = 0
kb_status: int = 0
error_message: str | None = None
description: str | None = None
file_paths: list[str] | None = None
embedding_config: EmbeddingConfigRefSchema | None = None
class KnowledgeBaseQueryParam:
"""知识库查询参数"""
def __init__(
self,
name: str | None = Query(None, description="知识库名称"),
embedding_config_id: int | None = Query(None, description="向量化配置ID"),
kb_status: int | None = Query(None, description="知识库状态"),
) -> None:
self.name = ("like", name) if name else None
self.embedding_config_id = embedding_config_id
self.kb_status = kb_status
# ============== AI模型配置 ==============
# AI模型类型常量
AI_MODEL_TYPES = {
'enterprise_naming': '企业起名',
'enterprise_renaming': '企业改名',
'enterprise_scoring': '企业测名',
'enterprise_scoring_trial': '企业测名试用',
'personal_naming': '个人起名',
'personal_renaming': '个人改名',
'personal_scoring': '个人测名',
'personal_scoring_trial': '个人测名试用',
'yuanfen_hepan': '缘分合盘',
'bazi_zeji': '八字择吉',
'caiyun_jiexi': '财运解析',
'caiyun_jiexi_qiye': '企业财运解析'
}
class AIProviderRefSchema(BaseModel):
"""供应商引用"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
provider_type: str
base_url: str
class AIModelConfigCreateSchema(BaseModel):
"""创建 AI模型配置参数"""
model_type: str = Field(..., max_length=50, description='模型类型(naming/renaming/scoring/report)')
model_name: str | None = Field(None, max_length=100, description='使用的模型名称')
provider_id: int | None = Field(None, description='AI供应商ID')
system_prompt: str | None = Field(None, description='系统提示词')
temperature: float = Field(1.0, ge=0, le=2, description='模型温度(0-2)')
knowledge_base_ids: list[int] | None = Field(None, description='关联的知识库ID列表')
class AIModelConfigUpdateSchema(BaseModel):
"""更新 AI模型配置参数"""
model_name: str | None = Field(None, max_length=100, description='使用的模型名称')
provider_id: int | None = Field(None, description='AI供应商ID')
system_prompt: str | None = Field(None, description='系统提示词')
temperature: float | None = Field(None, ge=0, le=2, description='模型温度(0-2)')
knowledge_base_ids: list[int] | None = Field(None, description='关联的知识库ID列表')
class AIModelConfigOutSchema(BaseSchema, UserBySchema):
"""模型配置详情"""
model_config = ConfigDict(from_attributes=True)
model_type: str
model_name: str | None = None
provider_id: int | None = None
system_prompt: str | None = None
temperature: float = 1.0
knowledge_base_ids: list[int] | None = None
provider: AIProviderRefSchema | None = None
class AIModelConfigQueryParam:
"""AI模型配置查询参数"""
def __init__(
self,
model_type: str | None = Query(None, description="模型类型"),
) -> None:
self.model_type = model_type
# ============== AI模型训练对话 ==============
class AIModelTrainingMessageCreateSchema(BaseModel):
"""创建训练对话消息参数"""
model_config_id: int = Field(..., description='模型配置ID')
role: str = Field(..., max_length=20, description='角色(user/assistant)')
content: str = Field(..., description='消息内容')
class AIModelTrainingMessageOutSchema(BaseSchema, UserBySchema):
"""训练对话消息详情"""
model_config = ConfigDict(from_attributes=True)
model_config_id: int
role: str
content: str
class AIModelTrainingChatSchema(BaseModel):
"""训练对话请求参数"""
model_type: str = Field(..., description='模型类型')
message: str = Field(..., min_length=1, description='用户消息')
# 如果配置有变动,先保存配置
config_changed: bool = Field(False, description='配置是否有变动')
config_data: AIModelConfigUpdateSchema | None = Field(None, description='变动的配置数据')
class AIModelTestSchema(BaseModel):
"""起名测试请求参数(用于小程序/外部调用)"""
model_type: str = Field(..., description='模型类型(enterprise_naming/personal_naming等)')
text: str = Field(..., min_length=1, max_length=4000, description='用户输入的文本')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
from typing import AsyncGenerator
from openai import AsyncOpenAI, OpenAI
from openai.types.chat.chat_completion import ChatCompletion
import httpx
from app.config.setting import settings
from app.core.logger import log
class AIClient:
"""
AI客户端类用于与OpenAI API交互。
"""
def __init__(self):
self.model = settings.OPENAI_MODEL
# 创建一个不带冲突参数的httpx客户端
self.http_client = httpx.AsyncClient(
timeout=30.0,
follow_redirects=True
)
# 使用自定义的http客户端
self.client = AsyncOpenAI(
api_key=settings.OPENAI_API_KEY,
base_url=settings.OPENAI_BASE_URL,
http_client=self.http_client
)
def _friendly_error_message(self, e: Exception) -> str:
"""将 OpenAI 或网络异常转换为友好的中文提示。"""
# 尝试获取状态码与错误体
status_code = getattr(e, "status_code", None)
body = getattr(e, "body", None)
message = None
error_type = None
error_code = None
try:
if isinstance(body, dict) and "error" in body:
err = body.get("error") or {}
error_type = err.get("type")
error_code = err.get("code")
message = err.get("message")
except Exception:
# 忽略解析失败
pass
text = str(e)
msg = message or text
# 特定错误映射
# 欠费/账户状态异常
if (error_code == "Arrearage") or (error_type == "Arrearage") or ("in good standing" in (msg or "")):
return "账户欠费或结算异常,访问被拒绝。请检查账号状态或更换有效的 API Key。"
# 鉴权失败
if status_code == 401 or "invalid api key" in msg.lower():
return "鉴权失败API Key 无效或已过期。请检查系统配置中的 API Key。"
# 权限不足或被拒绝
if status_code == 403 or error_type in {"PermissionDenied", "permission_denied"}:
return "访问被拒绝,权限不足或账号受限。请检查账户权限设置。"
# 配额不足或限流
if status_code == 429 or error_type in {"insufficient_quota", "rate_limit_exceeded"}:
return "请求过于频繁或配额已用尽。请稍后重试或提升账户配额。"
# 客户端错误
if status_code == 400:
return f"请求参数错误或服务拒绝:{message or '请检查输入内容。'}"
# 服务端错误
if status_code in {500, 502, 503, 504}:
return "服务暂时不可用,请稍后重试。"
# 默认兜底
return f"处理您的请求时出现错误:{msg}"
async def process(self, query: str) -> AsyncGenerator[str, None]:
"""
处理查询并返回流式响应
参数:
- query (str): 用户查询。
返回:
- AsyncGenerator[str, None]: 流式响应内容。
"""
system_prompt = """你是一个有用的AI助手可以帮助用户回答问题和提供帮助。请用中文回答用户的问题。"""
try:
# 使用 await 调用异步客户端
response = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": query}
],
stream=True
)
# 流式返回响应
async for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as e:
# 记录详细错误,返回友好提示
log.error(f"AI处理查询失败: {str(e)}")
yield self._friendly_error_message(e)
async def close(self) -> None:
"""
关闭客户端连接
"""
if hasattr(self, 'client'):
await self.client.close()
if hasattr(self, 'http_client'):
await self.http_client.aclose()

View File

@@ -0,0 +1,252 @@
# -*- coding: utf-8 -*-
"""
文档处理工具类
支持 txt、pdf、doc、docx、md 格式解析
"""
import os
import tempfile
from pathlib import Path
from typing import List, Optional, BinaryIO
from langchain.schema import Document
from fastapi import UploadFile
from app.core.logger import log
class DocumentProcessor:
"""文档处理器"""
# 支持的文件扩展名
SUPPORTED_EXTENSIONS = {'.txt', '.pdf', '.doc', '.docx', '.md'}
@classmethod
def is_supported(cls, filename: str) -> bool:
"""检查文件是否支持"""
ext = Path(filename).suffix.lower()
return ext in cls.SUPPORTED_EXTENSIONS
@classmethod
async def process_upload_file(cls, file: UploadFile) -> List[Document]:
"""
处理上传的文件
参数:
- file: FastAPI UploadFile 对象
返回:
- 文档列表
"""
if not file.filename:
log.warning("文件名为空,跳过处理")
return []
ext = Path(file.filename).suffix.lower()
if not cls.is_supported(file.filename):
log.warning(f"不支持的文件类型: {ext}")
return []
# 读取文件内容
content = await file.read()
await file.seek(0) # 重置文件指针
# 根据文件类型处理
try:
if ext == '.txt':
return cls._process_txt(content, file.filename)
elif ext == '.md':
return cls._process_markdown(content, file.filename)
elif ext == '.pdf':
return await cls._process_pdf(content, file.filename)
elif ext in {'.doc', '.docx'}:
return await cls._process_word(content, file.filename, ext)
else:
log.warning(f"未知的文件类型: {ext}")
return []
except Exception as e:
log.error(f"处理文件失败: {file.filename}, 错误: {e}")
return []
@classmethod
def _process_txt(cls, content: bytes, filename: str) -> List[Document]:
"""处理 TXT 文件"""
try:
# 尝试不同编码
text = None
for encoding in ['utf-8', 'gbk', 'gb2312', 'latin-1']:
try:
text = content.decode(encoding)
break
except UnicodeDecodeError:
continue
if text is None:
log.error(f"无法解码文件: {filename}")
return []
return [Document(
page_content=text,
metadata={"source": filename, "type": "txt"}
)]
except Exception as e:
log.error(f"处理 TXT 文件失败: {filename}, 错误: {e}")
return []
@classmethod
def _process_markdown(cls, content: bytes, filename: str) -> List[Document]:
"""处理 Markdown 文件"""
try:
text = content.decode('utf-8')
return [Document(
page_content=text,
metadata={"source": filename, "type": "markdown"}
)]
except Exception as e:
log.error(f"处理 Markdown 文件失败: {filename}, 错误: {e}")
return []
@classmethod
async def _process_pdf(cls, content: bytes, filename: str) -> List[Document]:
"""处理 PDF 文件"""
try:
# 使用 pypdf 或 pdfplumber 处理 PDF
import pypdf
from io import BytesIO
pdf_file = BytesIO(content)
reader = pypdf.PdfReader(pdf_file)
documents = []
for page_num, page in enumerate(reader.pages):
text = page.extract_text()
if text and text.strip():
documents.append(Document(
page_content=text,
metadata={
"source": filename,
"type": "pdf",
"page": page_num + 1
}
))
log.info(f"PDF 文件处理完成: {filename}, 共 {len(documents)}")
return documents
except ImportError:
log.error("未安装 pypdf 库,请运行: pip install pypdf")
return []
except Exception as e:
log.error(f"处理 PDF 文件失败: {filename}, 错误: {e}")
return []
@classmethod
async def _process_word(cls, content: bytes, filename: str, ext: str) -> List[Document]:
"""处理 Word 文件 (doc/docx)"""
try:
if ext == '.docx':
return cls._process_docx(content, filename)
else:
# .doc 格式需要特殊处理
return cls._process_doc(content, filename)
except Exception as e:
log.error(f"处理 Word 文件失败: {filename}, 错误: {e}")
return []
@classmethod
def _process_docx(cls, content: bytes, filename: str) -> List[Document]:
"""处理 DOCX 文件"""
try:
from docx import Document as DocxDocument
from io import BytesIO
docx_file = BytesIO(content)
doc = DocxDocument(docx_file)
# 提取所有段落文本
paragraphs = []
for para in doc.paragraphs:
if para.text.strip():
paragraphs.append(para.text)
text = '\n'.join(paragraphs)
if text.strip():
return [Document(
page_content=text,
metadata={"source": filename, "type": "docx"}
)]
return []
except ImportError:
log.error("未安装 python-docx 库,请运行: pip install python-docx")
return []
except Exception as e:
log.error(f"处理 DOCX 文件失败: {filename}, 错误: {e}")
return []
@classmethod
def _process_doc(cls, content: bytes, filename: str) -> List[Document]:
"""
处理 DOC 文件 (旧版 Word 格式)
注意: .doc 格式处理需要额外依赖,这里做简单提示
"""
try:
# 尝试使用 antiword 或 textract
# 如果没有安装,建议用户转换为 docx 格式
import subprocess
import tempfile
# 创建临时文件
with tempfile.NamedTemporaryFile(suffix='.doc', delete=False) as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
# 尝试使用 antiword
result = subprocess.run(
['antiword', tmp_path],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and result.stdout.strip():
return [Document(
page_content=result.stdout,
metadata={"source": filename, "type": "doc"}
)]
except (subprocess.TimeoutExpired, FileNotFoundError):
log.warning(f"无法处理 .doc 文件: {filename},建议转换为 .docx 格式")
finally:
os.unlink(tmp_path)
return []
except Exception as e:
log.error(f"处理 DOC 文件失败: {filename}, 错误: {e}")
return []
@classmethod
async def process_files(cls, files: List[UploadFile]) -> List[Document]:
"""
批量处理上传的文件
参数:
- files: UploadFile 列表
返回:
- 所有文档列表
"""
all_documents = []
for file in files:
if file.filename and cls.is_supported(file.filename):
docs = await cls.process_upload_file(file)
all_documents.extend(docs)
log.info(f"文件处理完成: {file.filename}, 提取 {len(docs)} 个文档")
else:
log.warning(f"跳过不支持的文件: {file.filename}")
log.info(f"批量处理完成,共 {len(all_documents)} 个文档")
return all_documents

View File

@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
"""
向量化工具类
支持本地和远程 embedding 模型
"""
import asyncio
from typing import List, Optional
from concurrent.futures import ThreadPoolExecutor
import chromadb
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document
from app.core.logger import log
from app.config.path_conf import BASE_DIR
# ChromaDB 持久化目录
CHROMA_PERSIST_DIR = BASE_DIR / "data" / "chroma_db"
# 全局 ChromaDB 客户端实例(单例模式)
_chroma_client = None
def get_chroma_client():
"""获取全局 ChromaDB 客户端实例(单例模式)"""
global _chroma_client
if _chroma_client is None:
# 确保持久化目录存在
CHROMA_PERSIST_DIR.mkdir(parents=True, exist_ok=True)
_chroma_client = chromadb.PersistentClient(
path=str(CHROMA_PERSIST_DIR)
)
return _chroma_client
class EmbeddingUtil:
"""向量化工具类"""
def __init__(
self,
embedding_type: int = 0,
model_name: str = "text-embedding-ada-002",
base_url: Optional[str] = None,
api_key: Optional[str] = None
):
"""
初始化向量化工具
参数:
- embedding_type: 0=本地, 1=远程
- model_name: Embedding模型名称
- base_url: 远程接口地址(远程模式必填)
- api_key: 远程API Key(远程模式必填)
"""
self.embedding_type = embedding_type
self.model_name = model_name
self.base_url = base_url
self.api_key = api_key
# 初始化 embedding 模型
self._embeddings = None
@property
def embeddings(self):
"""延迟加载 embedding 模型"""
if self._embeddings is None:
self._embeddings = self._create_embeddings()
return self._embeddings
def _create_embeddings(self):
"""创建 embedding 模型实例"""
if self.embedding_type == 0:
# 本地模式 - 使用 sentence-transformers
# sentence-transformers==3.3.1
# 本地Embedding模型可选手动安装 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
try:
from langchain_community.embeddings import HuggingFaceEmbeddings
log.info(f"使用本地 Embedding 模型: {self.model_name}")
return HuggingFaceEmbeddings(
model_name=self.model_name,
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
except Exception as e:
log.error(f"加载本地 Embedding 模型失败: {e}")
raise
else:
# 远程模式 - 使用 OpenAI 兼容接口
if not self.base_url or not self.api_key:
raise ValueError("远程模式必须提供 base_url 和 api_key")
# 自动拼接 /v1 路径(如果未包含)
api_base = self.base_url.rstrip('/')
if not api_base.endswith('/v1'):
api_base = f"{api_base}/v1"
log.info(f"使用远程 Embedding 模型: {self.model_name}, URL: {api_base}")
return OpenAIEmbeddings(
model=self.model_name,
base_url=api_base,
api_key=self.api_key,
)
def get_vector_store(self, collection_name: str) -> Chroma:
"""
获取或创建向量存储
参数:
- collection_name: 集合名称
返回:
- Chroma 向量存储实例
"""
# 确保目录存在
CHROMA_PERSIST_DIR.mkdir(parents=True, exist_ok=True)
return Chroma(
collection_name=collection_name,
embedding_function=self.embeddings,
persist_directory=str(CHROMA_PERSIST_DIR),
client=get_chroma_client(),
)
def split_documents(
self,
documents: List[Document],
chunk_size: int = 1000,
chunk_overlap: int = 200
) -> List[Document]:
"""
分割文档
参数:
- documents: 文档列表
- chunk_size: 分块大小
- chunk_overlap: 分块重叠大小
返回:
- 分割后的文档列表
"""
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=len,
separators=["\n\n", "\n", "", "", "", ".", "!", "?", " ", ""]
)
return text_splitter.split_documents(documents)
def add_documents(
self,
collection_name: str,
documents: List[Document]
) -> int:
"""
添加文档到向量存储
参数:
- collection_name: 集合名称
- documents: 文档列表
返回:
- 添加的向量数量
"""
if not documents:
return 0
# 分割文档
split_docs = self.split_documents(documents)
log.info(f"文档分割完成,共 {len(split_docs)} 个片段")
# 获取向量存储
vector_store = self.get_vector_store(collection_name)
# 添加文档
vector_store.add_documents(split_docs)
log.info(f"向量存储完成,集合: {collection_name}, 向量数: {len(split_docs)}")
return len(split_docs)
def delete_collection(self, collection_name: str) -> bool:
"""
删除集合
参数:
- collection_name: 集合名称
返回:
- 是否删除成功
"""
try:
client = get_chroma_client()
client.delete_collection(collection_name)
log.info(f"删除集合成功: {collection_name}")
return True
except Exception as e:
log.error(f"删除集合失败: {collection_name}, 错误: {e}")
return False
def similarity_search(
self,
collection_name: str,
query: str,
k: int = 4
) -> List[Document]:
"""
相似度搜索
参数:
- collection_name: 集合名称
- query: 查询文本
- k: 返回结果数量
返回:
- 相似文档列表
"""
vector_store = self.get_vector_store(collection_name)
return vector_store.similarity_search(query, k=k)
def get_collection_count(self, collection_name: str) -> int:
"""
获取集合中的向量数量
参数:
- collection_name: 集合名称
返回:
- 向量数量
"""
try:
client = get_chroma_client()
collection = client.get_collection(collection_name)
return collection.count()
except Exception as e:
log.warning(f"获取集合数量失败: {collection_name}, 错误: {e}")
return 0

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,330 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Body, Depends, Path
from fastapi.responses import JSONResponse, StreamingResponse
from app.common.response import StreamResponse, SuccessResponse
from app.common.request import PaginationService
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .tools.ap_scheduler import SchedulerUtil
from .service import JobService, JobLogService
from .schema import (
JobCreateSchema,
JobUpdateSchema,
JobQueryParam,
JobLogQueryParam
)
JobRouter = APIRouter(route_class=OperationLogRoute, prefix="/job", tags=["定时任务"])
@JobRouter.get("/detail/{id}", summary="获取定时任务详情", description="获取定时任务详情")
async def get_obj_detail_controller(
id: int = Path(..., description="定时任务ID"),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:query"]))
) -> JSONResponse:
"""
获取定时任务详情
参数:
- id (int): 定时任务ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含定时任务详情的JSON响应
"""
result_dict = await JobService.get_job_detail_service(id=id, auth=auth)
log.info(f"获取定时任务详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取定时任务详情成功")
@JobRouter.get("/list", summary="查询定时任务", description="查询定时任务")
async def get_obj_list_controller(
page: PaginationQueryParam = Depends(),
search: JobQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:query"]))
) -> JSONResponse:
"""
查询定时任务
参数:
- page (PaginationQueryParam): 分页查询参数模型
- search (JobQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含分页后的定时任务列表的JSON响应
"""
result_dict_list = await JobService.get_job_list_service(auth=auth, search=search, order_by=page.order_by)
result_dict = await PaginationService.paginate(data_list= result_dict_list, page_no= page.page_no, page_size = page.page_size)
log.info(f"查询定时任务列表成功")
return SuccessResponse(data=result_dict, msg="查询定时任务列表成功")
@JobRouter.post("/create", summary="创建定时任务", description="创建定时任务")
async def create_obj_controller(
data: JobCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_application:job:create"]))
) -> JSONResponse:
"""
创建定时任务
参数:
- data (JobCreateSchema): 创建参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含创建定时任务结果的JSON响应
"""
result_dict = await JobService.create_job_service(auth=auth, data=data)
log.info(f"创建定时任务成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="创建定时任务成功")
@JobRouter.put("/update/{id}", summary="修改定时任务", description="修改定时任务")
async def update_obj_controller(
data: JobUpdateSchema,
id: int = Path(..., description="定时任务ID"),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:update"]))
) -> JSONResponse:
"""
修改定时任务
参数:
- data (JobUpdateSchema): 更新参数模型
- id (int): 定时任务ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含修改定时任务结果的JSON响应
"""
result_dict = await JobService.update_job_service(auth=auth, id=id, data=data)
log.info(f"修改定时任务成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="修改定时任务成功")
@JobRouter.delete("/delete", summary="删除定时任务", description="删除定时任务")
async def delete_obj_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:delete"]))
) -> JSONResponse:
"""
删除定时任务
参数:
- ids (list[int]): ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除定时任务结果的JSON响应
"""
await JobService.delete_job_service(auth=auth, ids=ids)
log.info(f"删除定时任务成功: {ids}")
return SuccessResponse(msg="删除定时任务成功")
@JobRouter.post('/export', summary="导出定时任务", description="导出定时任务")
async def export_obj_list_controller(
search: JobQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:export"]))
) -> StreamingResponse:
"""
导出定时任务
参数:
- search (JobQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- StreamingResponse: 包含导出定时任务结果的流式响应
"""
result_dict_list = await JobService.get_job_list_service(search=search, auth=auth)
export_result = await JobService.export_job_service(data_list=result_dict_list)
log.info('导出定时任务成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers = {
'Content-Disposition': 'attachment; filename=job.xlsx'
}
)
@JobRouter.delete("/clear", summary="清空定时任务日志", description="清空定时任务日志")
async def clear_obj_log_controller(
auth: AuthSchema = Depends(AuthPermission(["module_application:job:delete"]))
) -> JSONResponse:
"""
清空定时任务日志
参数:
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含清空定时任务结果的JSON响应
"""
await JobService.clear_job_service(auth=auth)
log.info(f"清空定时任务成功")
return SuccessResponse(msg="清空定时任务成功")
@JobRouter.put("/option", summary="暂停/恢复/重启定时任务", description="暂停/恢复/重启定时任务")
async def option_obj_controller(
id: int = Body(..., description="定时任务ID"),
option: int = Body(..., description="操作类型 1: 暂停 2: 恢复 3: 重启"),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:update"]))
) -> JSONResponse:
"""
暂停/恢复/重启定时任务
参数:
- id (int): 定时任务ID
- option (int): 操作类型 1: 暂停 2: 恢复 3: 重启
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含操作定时任务结果的JSON响应
"""
await JobService.option_job_service(auth=auth, id=id, option=option)
log.info(f"操作定时任务成功: {id}")
return SuccessResponse(msg="操作定时任务成功")
@JobRouter.get("/log", summary="获取定时任务日志", description="获取定时任务日志", dependencies=[Depends(AuthPermission(["module_application:job:query"]))])
async def get_job_log_controller():
"""
获取定时任务日志
返回:
- JSONResponse: 获取定时任务日志的JSON响应
"""
data = [
{
"id": i.id,
"name": i.name,
"trigger": i.trigger.__class__.__name__,
"executor": i.executor,
"func": i.func,
"func_ref": i.func_ref,
"args": i.args,
"kwargs": i.kwargs,
"misfire_grace_time": i.misfire_grace_time,
"coalesce": i.coalesce,
"max_instances": i.max_instances,
"next_run_time": i.next_run_time,
"state": SchedulerUtil.get_single_job_status(job_id=i.id)
}
for i in SchedulerUtil.get_all_jobs()
]
return SuccessResponse(msg="获取定时任务日志成功", data=data)
# 定时任务日志管理接口
@JobRouter.get("/log/detail/{id}", summary="获取定时任务日志详情", description="获取定时任务日志详情")
async def get_job_log_detail_controller(
id: int = Path(..., description="定时任务日志ID"),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:query"]))
) -> JSONResponse:
"""
获取定时任务日志详情
参数:
- id (int): 定时任务日志ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 获取定时任务日志详情的JSON响应
"""
result_dict = await JobLogService.get_job_log_detail_service(id=id, auth=auth)
log.info(f"获取定时任务日志详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取定时任务日志详情成功")
@JobRouter.get("/log/list", summary="查询定时任务日志", description="查询定时任务日志")
async def get_job_log_list_controller(
page: PaginationQueryParam = Depends(),
search: JobLogQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:query"]))
) -> JSONResponse:
"""
查询定时任务日志
参数:
- page (PaginationQueryParam): 分页查询参数模型
- search (JobLogQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 查询定时任务日志列表的JSON响应
"""
order_by = [{"created_time": "desc"}]
result_dict_list = await JobLogService.get_job_log_list_service(auth=auth, search=search, order_by=order_by)
result_dict = await PaginationService.paginate(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size)
log.info(f"查询定时任务日志列表成功")
return SuccessResponse(data=result_dict, msg="查询定时任务日志列表成功")
@JobRouter.delete("/log/delete", summary="删除定时任务日志", description="删除定时任务日志")
async def delete_job_log_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:delete"]))
) -> JSONResponse:
"""
删除定时任务日志
参数:
- ids (list[int]): ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除定时任务日志结果的JSON响应
"""
await JobLogService.delete_job_log_service(auth=auth, ids=ids)
log.info(f"删除定时任务日志成功: {ids}")
return SuccessResponse(msg="删除定时任务日志成功")
@JobRouter.delete("/log/clear", summary="清空定时任务日志", description="清空定时任务日志")
async def clear_job_log_controller(
auth: AuthSchema = Depends(AuthPermission(["module_application:job:delete"]))
) -> JSONResponse:
"""
清空定时任务日志
参数:
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含清空定时任务日志结果的JSON响应
"""
await JobLogService.clear_job_log_service(auth=auth)
log.info(f"清空定时任务日志成功")
return SuccessResponse(msg="清空定时任务日志成功")
@JobRouter.post('/log/export', summary="导出定时任务日志", description="导出定时任务日志")
async def export_job_log_list_controller(
search: JobLogQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_application:job:export"]))
) -> StreamingResponse:
"""
导出定时任务日志
参数:
- search (JobLogQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- StreamingResponse: 包含导出定时任务日志结果的流式响应
"""
result_dict_list = await JobLogService.get_job_log_list_service(search=search, auth=auth)
export_result = await JobLogService.export_job_log_service(data_list=result_dict_list)
log.info('导出定时任务日志成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=job_log.xlsx'
}
)

View File

@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
from typing import Sequence, Any
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import JobModel, JobLogModel
from .schema import JobCreateSchema,JobUpdateSchema,JobLogCreateSchema,JobLogUpdateSchema
class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]):
"""定时任务数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化定时任务CRUD
参数:
- auth (AuthSchema): 认证信息模型
"""
self.auth = auth
super().__init__(model=JobModel, auth=auth)
async def get_obj_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> JobModel | None:
"""
获取定时任务详情
参数:
- id (int): 定时任务ID
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- JobModel | None: 定时任务模型,如果不存在则为None
"""
return await self.get(id=id, preload=preload)
async def get_obj_list_crud(self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[JobModel]:
"""
获取定时任务列表
参数:
- search (dict | None): 查询参数字典
- order_by (list[dict[str, str]] | None): 排序参数列表
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[JobModel]: 定时任务模型序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_obj_crud(self, data: JobCreateSchema) -> JobModel | None:
"""
创建定时任务
参数:
- data (JobCreateSchema): 创建定时任务模型
返回:
- JobModel | None: 创建的定时任务模型,如果创建失败则为None
"""
return await self.create(data=data)
async def update_obj_crud(self, id: int, data: JobUpdateSchema) -> JobModel | None:
"""
更新定时任务
参数:
- id (int): 定时任务ID
- data (JobUpdateSchema): 更新定时任务模型
返回:
- JobModel | None: 更新后的定时任务模型,如果更新失败则为None
"""
return await self.update(id=id, data=data)
async def delete_obj_crud(self, ids: list[int]) -> None:
"""
删除定时任务
参数:
- ids (list[int]): 定时任务ID列表
"""
return await self.delete(ids=ids)
async def set_obj_field_crud(self, ids: list[int], **kwargs) -> None:
"""
设置定时任务的可用状态
参数:
- ids (list[int]): 定时任务ID列表
- kwargs: 其他要设置的字段,例如 available=True 或 available=False
"""
return await self.set(ids=ids, **kwargs)
async def clear_obj_crud(self) -> None:
"""
清除定时任务日志
注意:
- 此操作会删除所有定时任务日志,请谨慎操作
"""
return await self.clear()
class JobLogCRUD(CRUDBase[JobLogModel, JobLogCreateSchema, JobLogUpdateSchema]):
"""定时任务日志数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化定时任务日志CRUD
参数:
- auth (AuthSchema): 认证信息模型
"""
self.auth = auth
super().__init__(model=JobLogModel, auth=auth)
async def get_obj_log_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> JobLogModel | None:
"""
获取定时任务日志详情
参数:
- id (int): 定时任务日志ID
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- JobLogModel | None: 定时任务日志模型,如果不存在则为None
"""
return await self.get(id=id, preload=preload)
async def get_obj_log_list_crud(self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[JobLogModel]:
"""
获取定时任务日志列表
参数:
- search (dict | None): 查询参数字典
- order_by (list[dict[str, str]] | None): 排序参数列表
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[JobLogModel]: 定时任务日志模型序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def delete_obj_log_crud(self, ids: list[int]) -> None:
"""
删除定时任务日志
参数:
- ids (list[int]): 定时任务日志ID列表
"""
return await self.delete(ids=ids)
async def clear_obj_log_crud(self) -> None:
"""
清除定时任务日志
注意:
- 此操作会删除所有定时任务日志,请谨慎操作
"""
return await self.clear()

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
import time
from datetime import datetime
from app.core.logger import log
def job(*args, **kwargs) -> None:
"""
定时任务执行同步函数示例
参数:
- args: 位置参数。
- kwargs: 关键字参数。
"""
try:
print(f"开始执行任务: {args}-{kwargs}")
time.sleep(3)
print(f'{datetime.now()}同步函数执行完成')
except Exception as e:
log.error(f"同步任务执行失败: {e}")
raise
async def async_job(*args, **kwargs) -> None:
"""
定时任务执行异步函数示例
参数:
- args: 位置参数。
- kwargs: 关键字参数。
"""
try:
print(f"开始执行任务: {args}-{kwargs}")
time.sleep(3)
print(f'{datetime.now()}异步函数执行完成')
except Exception as e:
log.error(f"异步任务执行失败: {e}")
raise

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
from sqlalchemy import Boolean, String, Integer, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, UserMixin
class JobModel(ModelMixin, UserMixin):
"""
定时任务调度表
- 0: 运行中
- 1: 暂停中
"""
__tablename__: str = 'app_job'
__table_args__: dict[str, str] = ({'comment': '定时任务调度表'})
__loader_options__: list[str] = ["job_logs", "created_by", "updated_by"]
name: Mapped[str | None] = mapped_column(String(64), nullable=True, default='', comment='任务名称')
jobstore: Mapped[str | None] = mapped_column(String(64), nullable=True, default='default', comment='存储器')
executor: Mapped[str | None] = mapped_column(String(64), nullable=True, default='default', comment='执行器:将运行此作业的执行程序的名称')
trigger: Mapped[str] = mapped_column(String(64), nullable=False, comment='触发器:控制此作业计划的 trigger 对象')
trigger_args: Mapped[str | None] = mapped_column(Text, nullable=True, comment='触发器参数')
func: Mapped[str] = mapped_column(Text, nullable=False, comment='任务函数')
args: Mapped[str | None] = mapped_column(Text, nullable=True, comment='位置参数')
kwargs: Mapped[str | None] = mapped_column(Text, nullable=True, comment='关键字参数')
coalesce: Mapped[bool] = mapped_column(Boolean, nullable=True, default=False, comment='是否合并运行:是否在多个运行时间到期时仅运行作业一次')
max_instances: Mapped[int] = mapped_column(Integer, nullable=True, default=1, comment='最大实例数:允许的最大并发执行实例数')
start_date: Mapped[str | None] = mapped_column(String(64), nullable=True, comment='开始时间')
end_date: Mapped[str | None] = mapped_column(String(64), nullable=True, comment='结束时间')
# 关联关系
job_logs: Mapped[list['JobLogModel'] | None] = relationship(
back_populates="job",
lazy="selectin"
)
class JobLogModel(ModelMixin):
"""
定时任务调度日志表
"""
__tablename__: str = 'app_job_log'
__table_args__: dict[str, str] = ({'comment': '定时任务调度日志表'})
__loader_options__: list[str] = ["job"]
job_name: Mapped[str] = mapped_column(String(64), nullable=False, comment='任务名称')
job_group: Mapped[str] = mapped_column(String(64), nullable=False, comment='任务组名')
job_executor: Mapped[str] = mapped_column(String(64), nullable=False, comment='任务执行器')
invoke_target: Mapped[str] = mapped_column(String(500), nullable=False, comment='调用目标字符串')
job_args: Mapped[str | None] = mapped_column(String(255), nullable=True, default='', comment='位置参数')
job_kwargs: Mapped[str | None] = mapped_column(String(255), nullable=True, default='', comment='关键字参数')
job_trigger: Mapped[str | None] = mapped_column(String(255), nullable=True, default='', comment='任务触发器')
job_message: Mapped[str | None] = mapped_column(String(500), nullable=True, default='', comment='日志信息')
exception_info: Mapped[str | None] = mapped_column(String(2000), nullable=True, default='', comment='异常信息')
# 任务关联
job_id: Mapped[int | None] = mapped_column(
ForeignKey('app_job.id', ondelete="CASCADE"),
nullable=True,
index=True,
comment='任务ID'
)
job: Mapped["JobModel | None"] = relationship(
back_populates="job_logs",
lazy="selectin"
)

View File

@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from fastapi import Query
from app.core.base_schema import BaseSchema, UserBySchema
from app.core.validator import DateTimeStr, datetime_validator
class JobCreateSchema(BaseModel):
"""
定时任务调度表对应pydantic模型
"""
name: str = Field(..., max_length=64, description='任务名称')
func: str = Field(..., description='任务函数')
trigger: str = Field(..., description='触发器:控制此作业计划的 trigger 对象')
args: str | None = Field(default=None, description='位置参数')
kwargs: str | None = Field(default=None, description='关键字参数')
coalesce: bool | None = Field(..., description='是否合并运行:是否在多个运行时间到期时仅运行作业一次')
max_instances: int | None = Field(default=1, ge=1, description='最大实例数:允许的最大并发执行实例数')
jobstore: str | None = Field(..., max_length=64, description='任务存储')
executor: str | None = Field(..., max_length=64, description='任务执行器:将运行此作业的执行程序的名称')
trigger_args: str | None = Field(default=None, description='触发器参数')
start_date: str | None = Field(default=None, description='开始时间')
end_date: str | None = Field(default=None, description='结束时间')
description: str | None = Field(default=None, max_length=255, description='描述')
status: str = Field(default='0', description='任务状态:启动,停止')
@field_validator('trigger')
@classmethod
def _validate_trigger(cls, v: str) -> str:
allowed = {'cron', 'interval', 'date'}
v = v.strip()
if v not in allowed:
raise ValueError('触发器必须为 cron/interval/date')
return v
@model_validator(mode='after')
def _validate_dates(self):
"""跨字段校验:结束时间不得早于开始时间。"""
if self.start_date and self.end_date:
try:
start = datetime_validator(self.start_date)
end = datetime_validator(self.end_date)
except Exception:
raise ValueError('时间格式必须为 YYYY-MM-DD HH:MM:SS')
if end < start:
raise ValueError('结束时间不能早于开始时间')
return self
class JobUpdateSchema(JobCreateSchema):
"""定时任务更新模型"""
...
class JobOutSchema(JobCreateSchema, BaseSchema, UserBySchema):
"""定时任务响应模型"""
model_config = ConfigDict(from_attributes=True)
...
class JobLogCreateSchema(BaseModel):
"""
定时任务调度日志表对应pydantic模型
"""
model_config = ConfigDict(from_attributes=True)
job_name: str = Field(..., description='任务名称')
job_group: str | None = Field(default=None, description='任务组名')
job_executor: str | None = Field(default=None, description='任务执行器')
invoke_target: str | None = Field(default=None, description='调用目标字符串')
job_args: str | None = Field(default=None, description='位置参数')
job_kwargs: str | None = Field(default=None, description='关键字参数')
job_trigger: str | None = Field(default=None, description='任务触发器')
job_message: str | None = Field(default=None, description='日志信息')
exception_info: str | None = Field(default=None, description='异常信息')
status: str = Field(default='0', description='任务状态:正常,失败')
description: str | None = Field(default=None, max_length=255, description='描述')
created_time: DateTimeStr | None = Field(default=None, description='创建时间')
updated_time: DateTimeStr | None = Field(default=None, description='更新时间')
class JobLogUpdateSchema(JobLogCreateSchema):
"""定时任务调度日志表更新模型"""
...
id: int | None = Field(default=None, description='任务日志ID')
class JobLogOutSchema(JobLogUpdateSchema, BaseSchema, UserBySchema):
"""定时任务调度日志表响应模型"""
model_config = ConfigDict(from_attributes=True)
...
class JobQueryParam:
"""定时任务查询参数"""
def __init__(
self,
name: str | None = Query(None, description="任务名称"),
status: str | None = Query(None, description="状态: 启动,停止"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"),
) -> None:
# 模糊查询字段
self.name = ("like", f"%{name}%") if name else None
# 精确查询字段
self.created_id = created_id
self.updated_id = updated_id
self.status = status
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))
class JobLogQueryParam:
"""定时任务查询参数"""
def __init__(
self,
job_id: int | None = Query(None, description="定时任务ID"),
job_name: str | None = Query(None, description="任务名称"),
status: str | None = Query(None, description="状态: 正常,失败"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
) -> None:
# 定时任务ID查询
self.job_id = job_id
# 模糊查询字段
self.job_name = ("like", job_name)
# 精确查询字段
self.status = status
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,307 @@
# -*- coding: utf-8 -*-
from app.core.exceptions import CustomException
from app.utils.cron_util import CronUtil
from app.utils.excel_util import ExcelUtil
from app.api.v1.module_system.auth.schema import AuthSchema
from .tools.ap_scheduler import SchedulerUtil
from .crud import JobCRUD, JobLogCRUD
from .schema import (
JobCreateSchema,
JobUpdateSchema,
JobOutSchema,
JobLogOutSchema,
JobQueryParam,
JobLogQueryParam
)
class JobService:
"""
定时任务管理模块服务层
"""
@classmethod
async def get_job_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取定时任务详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 定时任务ID
返回:
- Dict: 定时任务详情字典
"""
obj = await JobCRUD(auth).get_obj_by_id_crud(id=id)
return JobOutSchema.model_validate(obj).model_dump()
@classmethod
async def get_job_list_service(cls, auth: AuthSchema, search: JobQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> list[dict]:
"""
获取定时任务列表
参数:
- auth (AuthSchema): 认证信息模型
- search (JobQueryParam | None): 查询参数模型
- order_by (list[dict[str, str]] | None): 排序参数列表
返回:
- List[Dict]: 定时任务详情字典列表
"""
obj_list = await JobCRUD(auth).get_obj_list_crud(search=search.__dict__, order_by=order_by)
return [JobOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def create_job_service(cls, auth: AuthSchema, data: JobCreateSchema) -> dict:
"""
创建定时任务
参数:
- auth (AuthSchema): 认证信息模型
- data (JobCreateSchema): 定时任务创建模型
返回:
- Dict: 定时任务详情字典
"""
exist_obj = await JobCRUD(auth).get(name=data.name)
if exist_obj:
raise CustomException(msg='创建失败,该定时任务已存在')
obj = await JobCRUD(auth).create_obj_crud(data=data)
if not obj:
raise CustomException(msg='创建失败,该数据定时任务不存在')
SchedulerUtil().add_job(job_info=obj)
return JobOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_job_service(cls, auth: AuthSchema, id:int, data: JobUpdateSchema) -> dict:
"""
更新定时任务
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 定时任务ID
- data (JobUpdateSchema): 定时任务更新模型
返回:
- dict: 定时任务详情字典
"""
exist_obj = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg='更新失败,该定时任务不存在')
if data.trigger == 'cron' and data.trigger_args and not CronUtil.validate_cron_expression(data.trigger_args):
raise CustomException(msg=f'新增定时任务{data.name}失败, Cron表达式不正确')
obj = await JobCRUD(auth).update_obj_crud(id=id, data=data)
if not obj:
raise CustomException(msg='更新失败,该数据定时任务不存在')
SchedulerUtil().modify_job(job_id=obj.id)
return JobOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_job_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除定时任务
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 定时任务ID列表
"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
exist_obj = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg='删除失败,该数据定时任务不存在')
obj = await JobLogCRUD(auth).get(job_id=id)
if obj:
raise CustomException(msg=f'删除失败,该定时任务存 {exist_obj.name} 在日志记录')
SchedulerUtil().remove_job(job_id=id)
await JobCRUD(auth).delete_obj_crud(ids=ids)
@classmethod
async def clear_job_service(cls, auth: AuthSchema) -> None:
"""
清空所有定时任务
参数:
- auth (AuthSchema): 认证信息模型
"""
SchedulerUtil().clear_jobs()
await JobLogCRUD(auth).clear_obj_log_crud()
await JobCRUD(auth).clear_obj_crud()
@classmethod
async def option_job_service(cls, auth: AuthSchema, id: int, option: int) -> None:
"""
操作定时任务
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 定时任务ID
- option (int): 操作类型, 1: 暂停 2: 恢复 3: 重启
"""
# 1: 暂停 2: 恢复 3: 重启
obj = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if not obj:
raise CustomException(msg='操作失败,该数据定时任务不存在')
if option == 1:
SchedulerUtil().pause_job(job_id=id)
await JobCRUD(auth).set_obj_field_crud(ids=[id], status=False)
elif option == 2:
SchedulerUtil().resume_job(job_id=id)
await JobCRUD(auth).set_obj_field_crud(ids=[id], status=True)
elif option == 3:
# 重启任务:先移除再添加,确保使用最新的任务配置
SchedulerUtil().remove_job(job_id=id)
# 获取最新的任务配置
updated_job = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if updated_job:
# 重新添加任务
SchedulerUtil.add_job(job_info=updated_job)
# 设置状态为运行中
await JobCRUD(auth).set_obj_field_crud(ids=[id], status=True)
@classmethod
async def export_job_service(cls, data_list: list[dict]) -> bytes:
"""
导出定时任务列表
参数:
- data_list (list[dict]): 定时任务列表
返回:
- bytes: Excel文件字节流
"""
mapping_dict = {
'id': '编号',
'name': '任务名称',
'func': '任务函数',
'trigger': '触发器',
'args': '位置参数',
'kwargs': '关键字参数',
'coalesce': '是否合并运行',
'max_instances': '最大实例数',
'jobstore': '任务存储',
'executor': '任务执行器',
'trigger_args': '触发器参数',
'status': '任务状态',
'message': '日志信息',
'description': '备注',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建者ID',
'updated_id': '更新者ID',
}
# 复制数据并转换状态
data = data_list.copy()
for item in data:
item['status'] = '已完成' if item['status'] == '0' else '运行中' if item['status'] == '1' else '暂停'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
class JobLogService:
"""
定时任务日志管理模块服务层
"""
@classmethod
async def get_job_log_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取定时任务日志详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 定时任务日志ID
返回:
- dict: 定时任务日志详情字典
"""
obj = await JobLogCRUD(auth).get_obj_log_by_id_crud(id=id)
return JobLogOutSchema.model_validate(obj).model_dump()
@classmethod
async def get_job_log_list_service(cls, auth: AuthSchema, search: JobLogQueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""
获取定时任务日志列表
参数:
- auth (AuthSchema): 认证信息模型
- search (JobLogQueryParam | None): 查询参数模型, 包含分页信息和查询条件
- order_by (list[dict] | None): 排序参数列表, 每个元素为一个字典, 包含字段名和排序方向
返回:
- list[dict]: 定时任务日志详情字典列表
"""
obj_list = await JobLogCRUD(auth).get_obj_log_list_crud(search=search.__dict__, order_by=order_by)
return [JobLogOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def delete_job_log_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除定时任务日志
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 定时任务日志ID列表
"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
exist_obj = await JobLogCRUD(auth).get_obj_log_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg=f'删除失败该定时任务日志ID为{id}的记录不存在')
await JobLogCRUD(auth).delete_obj_log_crud(ids=ids)
@classmethod
async def clear_job_log_service(cls, auth: AuthSchema) -> None:
"""
清空定时任务日志
参数:
- auth (AuthSchema): 认证信息模型
"""
# 获取所有日志ID并批量删除
all_logs = await JobLogCRUD(auth).get_obj_log_list_crud()
if all_logs:
ids = [log.id for log in all_logs]
await JobLogCRUD(auth).delete_obj_log_crud(ids=ids)
@classmethod
async def export_job_log_service(cls, data_list: list[dict]) -> bytes:
"""
导出定时任务日志列表
参数:
- data_list (List[Dict[str, Any]]): 定时任务日志列表
返回:
- bytes: Excel文件字节流
"""
mapping_dict = {
'id': '编号',
'job_name': '任务名称',
'job_group': '任务组名',
'job_executor': '任务执行器',
'invoke_target': '调用目标字符串',
'job_args': '位置参数',
'job_kwargs': '关键字参数',
'job_trigger': '任务触发器',
'job_message': '日志信息',
'exception_info': '异常信息',
'status': '执行状态',
'created_time': '创建时间',
'updated_time': '更新时间',
}
# 复制数据并转换状态
data = data_list.copy()
for item in data:
item['status'] = '成功' if item.get('status') == '0' else '失败'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,589 @@
# -*- coding: utf-8 -*-
import json
import importlib
from datetime import datetime
from typing import Any
from asyncio import iscoroutinefunction
from apscheduler.job import Job
from apscheduler.events import JobExecutionEvent, EVENT_ALL, JobEvent
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.executors.pool import ProcessPoolExecutor
from apscheduler.jobstores.memory import MemoryJobStore
from apscheduler.jobstores.redis import RedisJobStore
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from concurrent.futures import ThreadPoolExecutor
from app.config.setting import settings
from app.core.database import engine, db_session, async_db_session
from app.core.exceptions import CustomException
from app.core.logger import log
from app.utils.cron_util import CronUtil
from app.api.v1.module_application.job.model import JobModel
job_stores = {
'default': MemoryJobStore(),
'sqlalchemy': SQLAlchemyJobStore(url=settings.DB_URI, engine=engine),
'redis': RedisJobStore(
host=settings.REDIS_HOST,
port=int(settings.REDIS_PORT),
username=settings.REDIS_USER,
password=settings.REDIS_PASSWORD,
db=int(settings.REDIS_DB_NAME),
),
}
# 配置执行器
executors = {
'default': AsyncIOExecutor(),
'processpool': ProcessPoolExecutor(max_workers=1) # 减少进程数量以减少资源消耗
}
# 配置默认参数
job_defaults = {
'coalesce': True, # 合并执行错过的任务
'max_instances': 1, # 最大实例数
}
# 配置调度器
scheduler = AsyncIOScheduler()
scheduler.configure(
jobstores=job_stores,
executors=executors,
job_defaults=job_defaults,
timezone='Asia/Shanghai'
)
class SchedulerUtil:
"""
定时任务相关方法
"""
@classmethod
def scheduler_event_listener(cls, event: JobEvent | JobExecutionEvent) -> None:
"""
监听任务执行事件。
参数:
- event (JobEvent | JobExecutionEvent): 任务事件对象。
返回:
- None
"""
# 延迟导入避免循环导入
from app.api.v1.module_application.job.model import JobLogModel
# 获取事件类型和任务ID
event_type = event.__class__.__name__
# 初始化任务状态
status = True
exception_info = ''
if isinstance(event, JobExecutionEvent) and event.exception:
exception_info = str(event.exception)
status = False
if hasattr(event, 'job_id'):
job_id = event.job_id
query_job = cls.get_job(job_id=job_id)
if query_job:
query_job_info = query_job.__getstate__()
# 获取任务名称
job_name = query_job_info.get('name')
# 获取任务组名
job_group = query_job._jobstore_alias
# # 获取任务执行器
job_executor = query_job_info.get('executor')
# 获取调用目标字符串
invoke_target = query_job_info.get('func')
# 获取调用函数位置参数
job_args = ','.join(map(str, query_job_info.get('args', [])))
# 获取调用函数关键字参数
job_kwargs = json.dumps(query_job_info.get('kwargs'))
# 获取任务触发器
job_trigger = str(query_job_info.get('trigger'))
# 构造日志消息
job_message = f"事件类型: {event_type}, 任务ID: {job_id}, 任务名称: {job_name}, 状态: {status}, 任务组: {job_group}, 错误详情: {exception_info}, 执行于{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
# 创建ORM对象
job_log = JobLogModel(
job_name=job_name,
job_group=job_group,
job_executor=job_executor,
invoke_target=invoke_target,
job_args=job_args,
job_kwargs=job_kwargs,
job_trigger=job_trigger,
job_message=job_message,
status=status,
exception_info=exception_info,
created_time=datetime.now(),
updated_time=datetime.now(),
job_id=job_id,
)
# 使用线程池执行操作以避免阻塞调度器和数据库锁定问题
executor = ThreadPoolExecutor(max_workers=1)
executor.submit(cls._save_job_log_async_wrapper, job_log)
executor.shutdown(wait=False)
@classmethod
def _save_job_log_async_wrapper(cls, job_log) -> None:
"""
异步保存任务日志的包装器函数,在独立线程中运行
参数:
- job_log (JobLogModel): 任务日志对象
返回:
- None
"""
with db_session.begin() as session:
try:
session.add(job_log)
session.commit()
except Exception as e:
session.rollback()
log.error(f"保存任务日志失败: {str(e)}")
finally:
session.close()
@classmethod
async def init_system_scheduler(cls) -> None:
"""
应用启动时初始化定时任务。
返回:
- None
"""
# 延迟导入避免循环导入
from app.api.v1.module_application.job.crud import JobCRUD
from app.api.v1.module_system.auth.schema import AuthSchema
log.info('🔎 开始启动定时任务...')
# 启动调度器
scheduler.start()
# 添加事件监听器
scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL)
async with async_db_session() as session:
async with session.begin():
auth = AuthSchema(db=session)
job_list = await JobCRUD(auth).get_obj_list_crud()
# 只在一个实例上初始化任务
# 使用Redis锁确保只有一个实例执行任务初始化
import redis.asyncio as redis
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=int(settings.REDIS_PORT),
username=settings.REDIS_USER,
password=settings.REDIS_PASSWORD,
db=int(settings.REDIS_DB_NAME),
)
# 尝试获取锁过期时间10秒
lock_key = "scheduler_init_lock"
lock_acquired = await redis_client.set(lock_key, "1", ex=10, nx=True)
if lock_acquired:
try:
for item in job_list:
# 检查任务是否已经存在
existing_job = cls.get_job(job_id=item.id)
if existing_job:
cls.remove_job(job_id=item.id) # 删除旧任务
# 添加新任务
cls.add_job(item)
# 根据数据库中保存的状态来设置任务状态
if hasattr(item, 'status') and item.status == "1":
# 如果任务状态为暂停,则立即暂停刚添加的任务
cls.pause_job(job_id=item.id)
log.info('✅️ 系统初始定时任务加载成功')
finally:
# 释放锁
await redis_client.delete(lock_key)
else:
# 等待其他实例完成初始化
import asyncio
await asyncio.sleep(2)
log.info('✅️ 定时任务已由其他实例初始化完成')
@classmethod
async def close_system_scheduler(cls) -> None:
"""
关闭系统定时任务。
返回:
- None
"""
try:
# 移除所有任务
scheduler.remove_all_jobs()
# 等待所有任务完成后再关闭
scheduler.shutdown(wait=True)
log.info('✅️ 关闭定时任务成功')
except Exception as e:
log.error(f'关闭定时任务失败: {str(e)}')
@classmethod
def get_job(cls, job_id: str | int) -> Job | None:
"""
根据任务ID获取任务对象。
参数:
- job_id (str | int): 任务ID。
返回:
- Job | None: 任务对象,未找到则为 None。
"""
return scheduler.get_job(job_id=str(job_id))
@classmethod
def get_all_jobs(cls) -> list[Job]:
"""
获取全部调度任务列表。
返回:
- list[Job]: 任务列表。
"""
return scheduler.get_jobs()
@classmethod
async def _task_wrapper(cls, job_id, func, *args, **kwargs):
"""
任务执行包装器,添加分布式锁防止同一任务被多个实例同时执行。
参数:
- job_id: 任务ID
- func: 实际要执行的任务函数
- *args: 任务函数位置参数
- **kwargs: 任务函数关键字参数
返回:
- 任务函数的返回值
"""
import redis.asyncio as redis
import asyncio
from app.config.setting import settings
# 创建Redis客户端
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=int(settings.REDIS_PORT),
username=settings.REDIS_USER,
password=settings.REDIS_PASSWORD,
db=int(settings.REDIS_DB_NAME),
)
# 生成锁键
lock_key = f"job_lock:{job_id}"
# 设置锁的过期时间根据任务类型调整这里设置为30秒
lock_expire = 30
lock_acquired = False
try:
# 尝试获取锁
lock_acquired = await redis_client.set(lock_key, "1", ex=lock_expire, nx=True)
if lock_acquired:
log.info(f"任务 {job_id} 获取执行锁成功")
# 执行任务
if iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
# 对于同步函数,使用线程池执行
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, func, *args, **kwargs)
else:
# 获取锁失败,记录日志
log.info(f"任务 {job_id} 获取执行锁失败,跳过本次执行")
return None
finally:
# 释放锁
if lock_acquired:
await redis_client.delete(lock_key)
log.info(f"任务 {job_id} 释放执行锁")
@classmethod
def add_job(cls, job_info: JobModel) -> Job:
"""
根据任务配置创建并添加调度任务。
参数:
- job_info (JobModel): 任务对象信息(包含触发器、函数、参数等)。
返回:
- Job: 新增的任务对象。
"""
# 动态导入模块
# 1. 解析调用目标
module_path, func_name = str(job_info.func).rsplit('.', 1)
module_path = "app.api.v1.module_application.job.function_task." + module_path
try:
module = importlib.import_module(module_path)
job_func = getattr(module, func_name)
# 2. 确定任务存储器优先使用redis确保分布式环境中任务同步
if job_info.jobstore is None:
job_info.jobstore = 'redis' # 改为默认使用redis存储
# 3. 确定执行器
job_executor = job_info.executor
if job_executor is None:
job_executor = 'default'
if job_info.trigger_args is None:
raise ValueError("触发器缺少参数")
# 异步函数必须使用默认执行器
if iscoroutinefunction(job_func):
job_executor = 'default'
# 4. 创建触发器
if job_info.trigger == 'date':
trigger = DateTrigger(run_date=job_info.trigger_args)
elif job_info.trigger == 'interval':
# 将传入的 interval 表达式拆分为不同的字段
fields = job_info.trigger_args.strip().split()
if len(fields) != 5:
raise ValueError("无效的 interval 表达式")
second, minute, hour, day, week = tuple([int(field) if field != '*' else 0 for field in fields])
# 秒、分、时、天、周(* * * * 1
trigger = IntervalTrigger(
weeks=week,
days=day,
hours=hour,
minutes=minute,
seconds=second,
start_date=job_info.start_date,
end_date=job_info.end_date,
timezone='Asia/Shanghai',
jitter=None
)
elif job_info.trigger == 'cron':
# 秒、分、时、天、月、星期几、年 ()
fields = job_info.trigger_args.strip().split()
if len(fields) not in (6, 7):
raise ValueError("无效的 Cron 表达式")
if not CronUtil.validate_cron_expression(job_info.trigger_args):
raise ValueError(f'定时任务{job_info.name}, Cron表达式不正确')
parsed_fields = [None if field in ('*', '?') else field for field in fields]
if len(fields) == 6:
parsed_fields.append(None)
second, minute, hour, day, month, day_of_week, year = tuple(parsed_fields)
trigger = CronTrigger(
second=second,
minute=minute,
hour=hour,
day=day,
month=month,
day_of_week=day_of_week,
year=year,
start_date=job_info.start_date,
end_date=job_info.end_date,
timezone='Asia/Shanghai'
)
else:
raise ValueError("无效的 trigger 触发器")
# 5. 添加任务(使用包装器函数)
job = scheduler.add_job(
func=cls._task_wrapper,
trigger=trigger,
args=[str(job_info.id), job_func] + (str(job_info.args).split(',') if job_info.args else []),
kwargs=json.loads(job_info.kwargs) if job_info.kwargs else {},
id=str(job_info.id),
name=job_info.name,
coalesce=job_info.coalesce,
max_instances=1, # 确保只有一个实例执行
jobstore=job_info.jobstore,
executor=job_executor,
)
log.info(f"任务 {job_info.id} 添加到 {job_info.jobstore} 存储器成功")
return job
except ModuleNotFoundError:
raise ValueError(f"未找到该模块:{module_path}")
except AttributeError:
raise ValueError(f"未找到该模块下的方法:{func_name}")
except Exception as e:
raise CustomException(msg=f"添加任务失败: {str(e)}")
@classmethod
def remove_job(cls, job_id: str | int) -> None:
"""
根据任务ID删除调度任务。
参数:
- job_id (str | int): 任务ID。
返回:
- None
"""
query_job = cls.get_job(job_id=str(job_id))
if query_job:
scheduler.remove_job(job_id=str(job_id))
@classmethod
def clear_jobs(cls) -> None:
"""
删除所有调度任务。
返回:
- None
"""
scheduler.remove_all_jobs()
@classmethod
def modify_job(cls, job_id: str | int) -> Job:
"""
更新指定任务的配置(运行中的任务下次执行生效)。
参数:
- job_id (str | int): 任务ID。
返回:
- Job: 更新后的任务对象。
异常:
- CustomException: 当任务不存在时抛出。
"""
query_job = cls.get_job(job_id=str(job_id))
if not query_job:
raise CustomException(msg=f"未找到该任务:{job_id}")
return scheduler.modify_job(job_id=str(job_id))
@classmethod
def pause_job(cls, job_id: str | int) -> None:
"""
暂停指定任务(仅运行中可暂停,已终止不可)。
参数:
- job_id (str | int): 任务ID。
返回:
- None
异常:
- ValueError: 当任务不存在时抛出。
"""
query_job = cls.get_job(job_id=str(job_id))
if not query_job:
raise ValueError(f"未找到该任务:{job_id}")
scheduler.pause_job(job_id=str(job_id))
@classmethod
def resume_job(cls, job_id: str | int) -> None:
"""
恢复指定任务(仅暂停中可恢复,已终止不可)。
参数:
- job_id (str | int): 任务ID。
返回:
- None
异常:
- ValueError: 当任务不存在时抛出。
"""
query_job = cls.get_job(job_id=str(job_id))
if not query_job:
raise ValueError(f"未找到该任务:{job_id}")
scheduler.resume_job(job_id=str(job_id))
@classmethod
def reschedule_job(cls, job_id: str | int, trigger=None, **trigger_args) -> Job | None:
"""
重启指定任务的触发器。
参数:
- job_id (str | int): 任务ID。
- trigger: 触发器类型
- **trigger_args: 触发器参数
返回:
- Job: 更新后的任务对象
异常:
- CustomException: 当任务不存在时抛出。
"""
query_job = cls.get_job(job_id=str(job_id))
if not query_job:
raise CustomException(msg=f"未找到该任务:{job_id}")
# 如果没有提供新的触发器,则使用现有触发器
if trigger is None:
# 获取当前任务的触发器配置
current_trigger = query_job.trigger
# 重新调度任务,使用当前的触发器
return scheduler.reschedule_job(job_id=str(job_id), trigger=current_trigger)
else:
# 使用新提供的触发器
return scheduler.reschedule_job(job_id=str(job_id), trigger=trigger, **trigger_args)
@classmethod
def get_single_job_status(cls, job_id: str | int) -> str:
"""
获取单个任务的当前状态。
参数:
- job_id (str | int): 任务ID
返回:
- str: 任务状态('running' | 'paused' | 'stopped' | 'unknown'
"""
job = cls.get_job(job_id=str(job_id))
if not job:
return 'unknown'
# 检查任务是否在暂停列表中
if job_id in scheduler._jobstores[job._jobstore_alias]._paused_jobs:
return 'paused'
# 检查调度器状态
if scheduler.state == 0: # STATE_STOPPED
return 'stopped'
return 'running'
@classmethod
def print_jobs(cls,jobstore: Any | None = None, out: Any | None = None):
"""
打印调度任务列表。
参数:
- jobstore (Any | None): 任务存储别名。
- out (Any | None): 输出目标。
返回:
- None
"""
scheduler.print_jobs(jobstore=jobstore, out=out)
@classmethod
def get_job_status(cls) -> str:
"""
获取调度器当前状态。
返回:
- str: 状态字符串('stopped' | 'running' | 'paused' | 'unknown')。
"""
#: constant indicating a scheduler's stopped state
STATE_STOPPED = 0
#: constant indicating a scheduler's running state (started and processing jobs)
STATE_RUNNING = 1
#: constant indicating a scheduler's paused state (started but not processing jobs)
STATE_PAUSED = 2
if scheduler.state == STATE_STOPPED:
return 'stopped'
elif scheduler.state == STATE_RUNNING:
return 'running'
elif scheduler.state == STATE_PAUSED:
return 'paused'
else:
return 'unknown'

View File

@@ -0,0 +1,9 @@
'''
Author: caoziyuan ziyuan.cao@zhuying.com
Date: 2025-12-22 17:25:15
LastEditors: caoziyuan ziyuan.cao@zhuying.com
LastEditTime: 2025-12-22 17:25:48
FilePath: \backend\app\api\v1\module_application\miniapp\__init__.py
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
'''
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio.client import Redis
from app.common.response import SuccessResponse
from app.core.logger import log
from app.core.dependencies import db_getter, redis_getter
from app.api.v1.module_system.auth.schema import AuthSchema
from .service import MiniappService
from .schema import MiniappLoginSchema, MiniappLoginOutSchema
MiniappRouter = APIRouter(prefix="/miniapp", tags=["小程序"])
@MiniappRouter.post("/login", summary="小程序登录", description="微信小程序用户登录", response_model=MiniappLoginOutSchema)
async def miniapp_login_controller(
data: MiniappLoginSchema,
db: AsyncSession = Depends(db_getter),
redis: Redis = Depends(redis_getter),
) -> JSONResponse:
"""
小程序登录接口
前端调用 wx.login() 获取 code传给此接口换取 token
参数:
- data (MiniappLoginSchema): 包含微信登录code
返回:
- MiniappLoginOutSchema: 包含access_token和用户信息
"""
auth = AuthSchema(db=db)
result = await MiniappService.login_service(auth=auth, redis=redis, data=data)
log.info(f"小程序用户登录成功")
return SuccessResponse(data=result, msg="登录成功")

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import MiniappUserModel
from .schema import MiniappUserCreateSchema, MiniappUserUpdateSchema
class MiniappUserCRUD(CRUDBase[MiniappUserModel, MiniappUserCreateSchema, MiniappUserUpdateSchema]):
"""小程序用户数据层"""
def __init__(self, auth: AuthSchema) -> None:
super().__init__(model=MiniappUserModel, auth=auth)
async def get_by_openid(self, openid: str) -> MiniappUserModel | None:
"""根据openid获取用户"""
return await self.get(openid=openid)
async def get_by_id_crud(self, id: int) -> MiniappUserModel | None:
"""根据ID获取用户"""
return await self.get(id=id)
async def update_last_login(self, id: int) -> MiniappUserModel | None:
"""更新最后登录时间"""
return await self.update(id=id, data={"last_login": datetime.now()})
async def update_session_key(self, id: int, session_key: str) -> MiniappUserModel | None:
"""更新session_key"""
return await self.update(id=id, data={"session_key": session_key})

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from sqlalchemy import String, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin
class MiniappUserModel(ModelMixin):
"""
小程序用户模型
存储微信小程序用户信息
"""
__tablename__: str = "miniapp_user"
__table_args__: dict[str, str] = ({'comment': '小程序用户表'})
openid: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True, comment="微信openid")
unionid: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, comment="微信unionid")
session_key: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="会话密钥")
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="昵称")
avatar: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="头像URL")
phone: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="手机号")
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, comment="最后登录时间")

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
from app.core.base_schema import BaseSchema
class MiniappLoginSchema(BaseModel):
"""小程序登录请求"""
code: str = Field(..., min_length=1, description="微信登录code")
class MiniappUserCreateSchema(BaseModel):
"""小程序用户创建"""
openid: str = Field(..., max_length=64, description="微信openid")
unionid: str | None = Field(default=None, max_length=64, description="微信unionid")
session_key: str | None = Field(default=None, max_length=64, description="会话密钥")
nickname: str | None = Field(default=None, max_length=64, description="昵称")
avatar: str | None = Field(default=None, max_length=512, description="头像URL")
class MiniappUserUpdateSchema(MiniappUserCreateSchema):
"""小程序用户更新"""
phone: str | None = Field(default=None, max_length=20, description="手机号")
class MiniappUserOutSchema(MiniappUserUpdateSchema, BaseSchema):
"""小程序用户响应"""
model_config = ConfigDict(from_attributes=True)
class MiniappLoginOutSchema(BaseModel):
"""小程序登录响应"""
access_token: str = Field(..., description="访问令牌")
token_type: str = Field(default="Bearer", description="令牌类型")
expires_in: int = Field(..., description="过期时间(秒)")
user: MiniappUserOutSchema = Field(..., description="用户信息")

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
import uuid
import json
import httpx
from datetime import datetime, timedelta
from redis.asyncio.client import Redis
from app.core.exceptions import CustomException
from app.core.logger import log
from app.core.security import create_access_token
from app.core.redis_crud import RedisCURD
from app.common.enums import RedisInitKeyConfig
from app.config.setting import settings
from app.api.v1.module_system.auth.schema import AuthSchema, JWTPayloadSchema
from .crud import MiniappUserCRUD
from .schema import (
MiniappLoginSchema,
MiniappUserCreateSchema,
MiniappUserOutSchema,
MiniappLoginOutSchema,
)
class MiniappService:
"""小程序服务层"""
# 微信登录接口
WX_LOGIN_URL = "https://api.weixin.qq.com/sns/jscode2session"
@classmethod
async def login_service(cls, auth: AuthSchema, redis: Redis, data: MiniappLoginSchema) -> dict:
"""
小程序登录
流程:
1. 用微信code换取openid和session_key
2. 查找或创建用户
3. 生成JWT token
"""
# 1. 调用微信接口获取openid
wx_result = await cls._get_wx_session(code=data.code)
openid = wx_result.get("openid")
session_key = wx_result.get("session_key")
unionid = wx_result.get("unionid")
if not openid:
raise CustomException(msg="微信登录失败无法获取openid")
# 2. 查找或创建用户
user = await MiniappUserCRUD(auth).get_by_openid(openid=openid)
if user:
# 更新session_key和登录时间
await MiniappUserCRUD(auth).update_session_key(id=user.id, session_key=session_key)
await MiniappUserCRUD(auth).update_last_login(id=user.id)
log.info(f"小程序用户登录: {openid}")
else:
# 创建新用户
user_data = MiniappUserCreateSchema(
openid=openid,
unionid=unionid,
session_key=session_key,
)
user = await MiniappUserCRUD(auth).create(data=user_data)
log.info(f"小程序新用户注册: {openid}")
# 3. 生成token
token_data = await cls._create_miniapp_token(redis=redis, user_id=user.id, openid=openid)
return MiniappLoginOutSchema(
access_token=token_data["access_token"],
token_type="Bearer",
expires_in=token_data["expires_in"],
user=MiniappUserOutSchema.model_validate(user)
).model_dump()
@classmethod
async def get_user_info_service(cls, auth: AuthSchema, user_id: int) -> dict:
"""获取用户信息"""
user = await MiniappUserCRUD(auth).get_by_id_crud(id=user_id)
if not user:
raise CustomException(msg="用户不存在")
return MiniappUserOutSchema.model_validate(user).model_dump()
@classmethod
async def _get_wx_session(cls, code: str) -> dict:
"""
调用微信接口获取session信息
注意: 需要在配置中设置 MINIAPP_APPID 和 MINIAPP_SECRET
"""
appid = getattr(settings, "MINIAPP_APPID", None)
secret = getattr(settings, "MINIAPP_SECRET", None)
if not appid or not secret:
# 开发环境模拟返回
log.warning("未配置小程序appid和secret使用模拟数据")
return {
"openid": f"mock_openid_{code[:8]}",
"session_key": "mock_session_key",
"unionid": None
}
params = {
"appid": appid,
"secret": secret,
"js_code": code,
"grant_type": "authorization_code"
}
async with httpx.AsyncClient() as client:
response = await client.get(cls.WX_LOGIN_URL, params=params)
result = response.json()
if "errcode" in result and result["errcode"] != 0:
log.error(f"微信登录失败: {result}")
raise CustomException(msg=f"微信登录失败: {result.get('errmsg', '未知错误')}")
return result
@classmethod
async def _create_miniapp_token(cls, redis: Redis, user_id: int, openid: str) -> dict:
"""创建小程序用户token"""
session_id = str(uuid.uuid4())
access_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
now = datetime.now()
session_info = json.dumps({
"session_id": session_id,
"user_id": user_id,
"openid": openid,
"login_type": "miniapp"
})
access_token = create_access_token(payload=JWTPayloadSchema(
sub=session_info,
is_refresh=False,
exp=now + access_expires,
))
# 存储到Redis
await RedisCURD(redis).set(
key=f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:miniapp:{session_id}',
value=access_token,
expire=int(access_expires.total_seconds())
)
return {
"access_token": access_token,
"expires_in": int(access_expires.total_seconds())
}

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Body, Depends, Path
from fastapi.responses import JSONResponse
from app.common.response import SuccessResponse
from app.common.request import PaginationService
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.base_schema import BatchSetAvailable
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.router_class import OperationLogRoute
from .service import ApplicationService
from .schema import (
ApplicationCreateSchema,
ApplicationUpdateSchema,
ApplicationQueryParam
)
MyAppRouter = APIRouter(route_class=OperationLogRoute, prefix="/myapp", tags=["应用管理"])
@MyAppRouter.get("/detail/{id}", summary="获取应用详情", description="获取应用详情")
async def get_obj_detail_controller(
id: int = Path(..., description="应用ID"),
auth: AuthSchema = Depends(AuthPermission(["module_application:myapp:query"]))
) -> JSONResponse:
"""
获取应用详情
参数:
- id (int): 应用ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含应用详情的JSON响应
"""
result_dict = await ApplicationService.detail_service(id=id, auth=auth)
log.info(f"获取应用详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取应用详情成功")
@MyAppRouter.get("/list", summary="查询应用列表", description="查询应用列表")
async def get_obj_list_controller(
page: PaginationQueryParam = Depends(),
search: ApplicationQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_application:myapp:query"]))
) -> JSONResponse:
"""
查询应用列表
参数:
- page (PaginationQueryParam): 分页参数模型
- search (ApplicationQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含应用列表的JSON响应
"""
result_dict_list = await ApplicationService.list_service(auth=auth, search=search, order_by=page.order_by)
result_dict = await PaginationService.paginate(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size)
log.info(f"查询应用列表成功")
return SuccessResponse(data=result_dict, msg="查询应用列表成功")
@MyAppRouter.post("/create", summary="创建应用", description="创建应用")
async def create_obj_controller(
data: ApplicationCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_application:myapp:create"]))
) -> JSONResponse:
"""
创建应用
参数:
- data (ApplicationCreateSchema): 应用创建模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含创建应用详情的JSON响应
"""
result_dict = await ApplicationService.create_service(auth=auth, data=data)
log.info(f"创建应用成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="创建应用成功")
@MyAppRouter.put("/update/{id}", summary="修改应用", description="修改应用")
async def update_obj_controller(
data: ApplicationUpdateSchema,
id: int = Path(..., description="应用ID"),
auth: AuthSchema = Depends(AuthPermission(["module_application:myapp:update"]))
) -> JSONResponse:
"""
修改应用
参数:
- data (ApplicationUpdateSchema): 应用更新模型
- id (int): 应用ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含修改应用详情的JSON响应
"""
result_dict = await ApplicationService.update_service(auth=auth, id=id, data=data)
log.info(f"修改应用成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="修改应用成功")
@MyAppRouter.delete("/delete", summary="删除应用", description="删除应用")
async def delete_obj_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_application:myapp:delete"]))
) -> JSONResponse:
"""
删除应用
参数:
- ids (list[int]): 应用ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除应用详情的JSON响应
"""
await ApplicationService.delete_service(auth=auth, ids=ids)
log.info(f"删除应用成功: {ids}")
return SuccessResponse(msg="删除应用成功")
@MyAppRouter.patch("/available/setting", summary="批量修改应用状态", description="批量修改应用状态")
async def batch_set_available_obj_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_application:myapp:patch"]))
) -> JSONResponse:
"""
批量修改应用状态
参数:
- data (BatchSetAvailable): 批量修改应用状态模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 批量修改应用状态成功
"""
await ApplicationService.set_available_service(auth=auth, data=data)
log.info(f"批量修改应用状态成功: {data.ids}")
return SuccessResponse(msg="批量修改应用状态成功")

View File

@@ -0,0 +1,101 @@
'''
Author: caoziyuan ziyuan.cao@zhuying.com
Date: 2025-12-15 17:37:50
LastEditors: caoziyuan ziyuan.cao@zhuying.com
LastEditTime: 2025-12-22 17:26:54
FilePath: \backend\app\api\v1\module_application\myapp\crud.py
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
'''
# -*- coding: utf-8 -*-
from typing import Sequence, Any
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import ApplicationModel
from .schema import ApplicationCreateSchema, ApplicationUpdateSchema
class ApplicationCRUD(CRUDBase[ApplicationModel, ApplicationCreateSchema, ApplicationUpdateSchema]):
"""应用系统数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化应用CRUD
参数:
- auth (AuthSchema): 认证信息模型
"""
self.auth = auth
super().__init__(model=ApplicationModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> ApplicationModel | None:
"""
根据id获取应用详情
参数:
- id (int): 应用ID
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- ApplicationModel | None: 应用详情,如果不存在则为None
"""
return await self.get(id=id, preload=preload)
async def list_crud(self, search: dict[str, Any] | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[ApplicationModel]:
"""
列表查询应用
参数:
- search (dict[str, Any] | None): 查询参数,默认None
- order_by (list[dict[str, str]] | None): 排序参数,默认None
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[ApplicationModel]: 应用列表
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_crud(self, data: ApplicationCreateSchema) -> ApplicationModel | None:
"""
创建应用
参数:
- data (ApplicationCreateSchema): 应用创建模型
返回:
- ApplicationModel | None: 创建的应用详情,如果创建失败则为None
"""
return await self.create(data=data)
async def update_crud(self, id: int, data: ApplicationUpdateSchema) -> ApplicationModel | None:
"""
更新应用
参数:
- id (int): 应用ID
- data (ApplicationUpdateSchema): 应用更新模型
返回:
- ApplicationModel | None: 更新后的应用详情,如果更新失败则为None
"""
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
"""
批量删除应用
参数:
- ids (list[int]): 应用ID列表
"""
return await self.delete(ids=ids)
async def set_available_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 应用ID列表
- status (str): 可用状态,True为可用,False为不可用
"""
return await self.set(ids=ids, status=status)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class ApplicationModel(ModelMixin, UserMixin):
"""
应用系统表
"""
__tablename__: str = 'app_myapp'
__table_args__: dict[str, str] = ({'comment': '应用系统表'})
__loader_options__: list[str] = ["created_by", "updated_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment='应用名称')
access_url: Mapped[str] = mapped_column(String(500), nullable=False, comment='访问地址')
icon_url: Mapped[str | None] = mapped_column(String(300), nullable=True, comment='应用图标URL')

View File

@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field, field_validator
from urllib.parse import urlparse
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class ApplicationCreateSchema(BaseModel):
"""应用创建模型"""
name: str = Field(..., max_length=64, description='应用名称')
access_url: str = Field(..., max_length=255, description="访问地址")
icon_url: str | None = Field(None, max_length=300, description="应用图标URL")
status: str = Field("0", description="是否启用(0:启用 1:禁用)")
description: str | None = Field(default=None, max_length=255, description="描述")
@field_validator('access_url')
@classmethod
def _validate_access_url(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError('访问地址不能为空')
parsed = urlparse(v)
if parsed.scheme not in ('http', 'https'):
raise ValueError('访问地址必须为 http/https URL')
return v
@field_validator('icon_url')
@classmethod
def _validate_icon_url(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if v == "":
return None
parsed = urlparse(v)
if parsed.scheme not in ('http', 'https'):
raise ValueError('应用图标URL必须为 http/https URL')
return v
class ApplicationUpdateSchema(ApplicationCreateSchema):
"""应用更新模型"""
...
class ApplicationOutSchema(ApplicationCreateSchema, BaseSchema, UserBySchema):
"""应用响应模型"""
model_config = ConfigDict(from_attributes=True)
class ApplicationQueryParam:
"""应用系统查询参数"""
def __init__(
self,
name: str | None = Query(None, description="应用名称"),
status: str | None = Query(None, description="是否启用"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"),
) -> None:
# 模糊查询字段
self.name = ("like", name) if name else None
# 精确查询字段
self.status = status
self.created_id = created_id
self.updated_id = updated_id
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import (
ApplicationCreateSchema,
ApplicationUpdateSchema,
ApplicationOutSchema,
ApplicationQueryParam
)
from .crud import ApplicationCRUD
class ApplicationService:
"""
应用系统管理服务层
"""
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取应用详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 应用ID
返回:
- dict: 应用详情字典
"""
obj = await ApplicationCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg='应用不存在')
return ApplicationOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_service(cls, auth: AuthSchema, search: ApplicationQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> list[dict]:
"""
获取应用列表
参数:
- auth (AuthSchema): 认证信息模型
- search (ApplicationQueryParam | None): 查询参数模型
- order_by (list[dict[str, str]] | None): 排序参数,支持字符串或字典列表
返回:
- list[dict]: 应用详情字典列表
"""
# 过滤空值
search_dict = search.__dict__ if search else None
obj_list = await ApplicationCRUD(auth).list_crud(search=search_dict, order_by=order_by)
return [ApplicationOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def create_service(cls, auth: AuthSchema, data: ApplicationCreateSchema) -> dict:
"""
创建应用
参数:
- auth (AuthSchema): 认证信息模型
- data (ApplicationCreateSchema): 应用创建模型
返回:
- Dict: 应用详情字典
"""
# 检查名称是否重复
obj = await ApplicationCRUD(auth).get(name=data.name)
if obj:
raise CustomException(msg='创建失败,应用名称已存在')
obj = await ApplicationCRUD(auth).create_crud(data=data)
return ApplicationOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_service(cls, auth: AuthSchema, id: int, data: ApplicationUpdateSchema) -> dict:
"""
更新应用
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 应用ID
- data (ApplicationUpdateSchema): 应用更新模型
返回:
- Dict: 应用详情字典
"""
obj = await ApplicationCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该应用不存在')
# 检查名称重复
exist_obj = await ApplicationCRUD(auth).get(name=data.name)
if exist_obj and exist_obj.id != id:
raise CustomException(msg='更新失败,应用名称重复')
obj = await ApplicationCRUD(auth).update_crud(id=id, data=data)
return ApplicationOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除应用
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 应用ID列表
返回:
- None
"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await ApplicationCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败,应用 {id} 不存在')
await ApplicationCRUD(auth).delete_crud(ids=ids)
@classmethod
async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""
批量设置应用状态
参数:
- auth (AuthSchema): 认证信息模型
- data (BatchSetAvailable): 批量设置应用状态模型
返回:
- None
"""
await ApplicationCRUD(auth).set_available_crud(ids=data.ids, status=data.status)

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# 工作流接口-开发中...

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import SuccessResponse
from app.core.dependencies import db_getter, get_current_user
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_common.activity.crud import ActivityCRUD
# 定义路由前缀
ActivityRouter = APIRouter(prefix="/activity", tags=["活动记录"])
@ActivityRouter.get("/display", summary="获取活动展示列表", description="获取前端展示格式的用户活动记录(无需登录)")
async def get_activity_display(
limit: int = 10,
db: AsyncSession = Depends(db_getter)
) -> SuccessResponse:
"""
获取活动展示列表(公开接口,无需登录)
返回格式: ["张** 预约了 宝宝起名 服务", "李** 完成了 个人改名 测算", ...]
"""
# 创建一个不需要用户信息的 auth 对象
auth = AuthSchema(db=db, check_data_scope=False)
crud = ActivityCRUD(auth)
data = await crud.get_display_list(limit=limit)
return SuccessResponse(data=data)
@ActivityRouter.post("/create", summary="创建活动记录", description="创建活动记录(需要登录)")
async def create_activity(
user_name: str,
action: str,
service_name: str,
service_type: str = None,
sort_order: int = 0,
auth: AuthSchema = Depends(get_current_user)
) -> SuccessResponse:
"""
创建活动记录(需要登录)
"""
crud = ActivityCRUD(auth)
data = {
"user_name": user_name,
"action": action,
"service_name": service_name,
"service_type": service_type,
"sort_order": sort_order
}
result = await crud.create(data)
return SuccessResponse(data={"id": result.id}, msg="创建成功")

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
from typing import List
from app.core.base_crud import CRUDBase
from app.api.v1.module_common.activity.model import ActivityModel
from app.api.v1.module_common.activity.schema import ActivityCreate, ActivityUpdate
from app.api.v1.module_system.auth.schema import AuthSchema
class ActivityCRUD(CRUDBase[ActivityModel, ActivityCreate, ActivityUpdate]):
"""活动记录CRUD"""
def __init__(self, auth: AuthSchema) -> None:
super().__init__(ActivityModel, auth)
async def get_display_list(self, limit: int = 10) -> List[str]:
"""
获取前端展示格式的活动列表
参数:
- limit: 返回数量限制
返回:
- List[str]: 格式化的活动文本列表
"""
activities = await self.list(
search={"status": "0"},
order_by=[{"sort_order": "asc"}, {"created_time": "desc"}]
)
result = []
for activity in activities[:limit]:
text = f"{activity.user_name} {activity.action}{activity.service_name} {activity.service_type or ''}".strip()
result.append(text)
return result

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from sqlalchemy import String, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin
class ActivityModel(ModelMixin):
"""用户活动记录表"""
__tablename__ = "biz_activity"
user_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="用户名(脱敏)")
action: Mapped[str] = mapped_column(String(50), nullable=False, comment="操作类型(预约/完成/购买/查看)")
service_name: Mapped[str] = mapped_column(String(100), nullable=False, comment="服务名称")
service_type: Mapped[str] = mapped_column(String(50), nullable=True, comment="服务类型")
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="排序顺序")

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class ActivityBase(BaseModel):
"""活动记录基础模型"""
user_name: str = Field(..., max_length=50, description="用户名(脱敏)")
action: str = Field(..., max_length=50, description="操作类型")
service_name: str = Field(..., max_length=100, description="服务名称")
service_type: Optional[str] = Field(None, max_length=50, description="服务类型")
sort_order: int = Field(default=0, description="排序顺序")
class ActivityCreate(ActivityBase):
"""创建活动记录"""
pass
class ActivityUpdate(BaseModel):
"""更新活动记录"""
user_name: Optional[str] = Field(None, max_length=50)
action: Optional[str] = Field(None, max_length=50)
service_name: Optional[str] = Field(None, max_length=100)
service_type: Optional[str] = Field(None, max_length=50)
sort_order: Optional[int] = None
class ActivityOut(ActivityBase):
"""活动记录输出模型"""
id: int
created_time: datetime
class Config:
from_attributes = True
class ActivityDisplay(BaseModel):
"""前端展示格式"""
text: str = Field(..., description="展示文本")

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,49 @@
'''
Author: caoziyuan ziyuan.cao@zhuying.com
Date: 2025-12-23 14:08:15
LastEditors: caoziyuan ziyuan.cao@zhuying.com
LastEditTime: 2025-12-23 14:24:55
FilePath: \naming-backend\app\api\v1\module_common\calendar\controller.py
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
'''
# -*- coding: utf-8 -*-
from datetime import date
from fastapi import APIRouter, Query
from app.common.response import SuccessResponse
from app.api.v1.module_common.calendar.service import CalendarService
# 定义路由
CalendarRouter = APIRouter(prefix="/calendar", tags=["万年历"])
@CalendarRouter.get("/today", summary="获取今日万年历", description="获取今天的农历信息、宜忌等(无需登录)")
async def get_today_calendar() -> SuccessResponse:
"""
获取今日万年历信息
返回格式:
{
"lunar": "腊月 十二",
"year": "甲辰年",
"solar": "2024.01.22",
"yi": ["出行", "开市", "交易", "裁衣"],
"ji": ["动土", "安葬", "破土", "作灶"]
}
"""
data = CalendarService.get_calendar_info()
return SuccessResponse(data=data)
@CalendarRouter.get("/date", summary="获取指定日期万年历", description="获取指定日期的农历信息、宜忌等(无需登录)")
async def get_date_calendar(
year: int = Query(..., description="年份2024"),
month: int = Query(..., ge=1, le=12, description="月份1-12"),
day: int = Query(..., ge=1, le=31, description="日期1-31")
) -> SuccessResponse:
"""
获取指定日期的万年历信息
"""
data = CalendarService.get_calendar_info(year, month, day)
return SuccessResponse(data=data)

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from typing import List
from pydantic import BaseModel, Field
class CalendarOut(BaseModel):
"""万年历返回数据结构"""
lunar: str = Field(..., description="农历日期,如:腊月 十二")
year: str = Field(..., description="农历年份,如:甲辰年")
solar: str = Field(..., description="公历日期2024.01.22")
yi: List[str] = Field(default=[], description="宜做的事情")
ji: List[str] = Field(default=[], description="忌做的事情")

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
import json
from datetime import datetime
from typing import Optional, List
from functools import lru_cache
from openai import OpenAI
from cnlunar import Lunar
from app.config.setting import settings
class CalendarService:
"""万年历服务 - 使用 cnlunar 获取农历信息DeepSeek 生成宜忌"""
# 内存缓存,存储日期对应的宜忌数据
_yi_ji_cache: dict = {}
@classmethod
def get_calendar_info(cls, year: Optional[int] = None, month: Optional[int] = None, day: Optional[int] = None) -> dict:
"""
获取万年历信息
参数:
- year: 年份,默认今年
- month: 月份,默认本月
- day: 日期,默认今天
返回:
- dict: 万年历信息
"""
# 获取日期
if year and month and day:
solar_date = datetime(year, month, day)
else:
solar_date = datetime.now()
# 使用 cnlunar 获取农历信息
lunar = Lunar(solar_date)
# 获取农历月份和日期
lunar_month = lunar.lunarMonthCn # 如:腊月
lunar_day = lunar.lunarDayCn # 如:十二
# 获取天干地支年份
gan_zhi_year = lunar.year8Char # 如:甲辰
# 使用 DeepSeek 生成宜忌(带缓存)
yi_list, ji_list = cls._get_yi_ji_cached(solar_date, lunar)
return {
"lunar": f"{lunar_month} {lunar_day}",
"year": f"{gan_zhi_year}",
"solar": solar_date.strftime("%Y.%m.%d"),
"yi": yi_list,
"ji": ji_list,
}
@classmethod
def _get_yi_ji_cached(cls, solar_date: datetime, lunar: Lunar) -> tuple[List[str], List[str]]:
"""
获取宜忌数据(带缓存)
同一天的数据只调用一次 API后续直接返回缓存
"""
# 用日期字符串作为缓存 key
cache_key = solar_date.strftime("%Y-%m-%d")
# 如果缓存中有数据,直接返回
if cache_key in cls._yi_ji_cache:
return cls._yi_ji_cache[cache_key]
# 缓存中没有,调用 API 获取
yi_list, ji_list = cls._get_yi_ji_from_deepseek(solar_date, lunar)
# 存入缓存
cls._yi_ji_cache[cache_key] = (yi_list, ji_list)
# 限制缓存大小,最多保留 30 天的数据
if len(cls._yi_ji_cache) > 30:
# 删除最早的一条
oldest_key = next(iter(cls._yi_ji_cache))
del cls._yi_ji_cache[oldest_key]
return yi_list, ji_list
@classmethod
def _get_yi_ji_from_deepseek(cls, solar_date: datetime, lunar: Lunar) -> tuple[List[str], List[str]]:
"""
调用 DeepSeek API 生成宜忌数据
参数:
- solar_date: 公历日期
- lunar: 农历对象
返回:
- tuple: (宜列表, 忌列表)
"""
try:
# 从配置文件读取 API 配置
client = OpenAI(
api_key=settings.DEEPSEEK_API_KEY,
base_url=settings.DEEPSEEK_BASE_URL
)
# 构建提示词
prompt = f"""请根据以下日期信息,生成中国传统黄历的宜忌数据。
日期信息:
- 公历:{solar_date.strftime("%Y年%m月%d")}
- 农历:{lunar.lunarMonthCn}{lunar.lunarDayCn}
- 天干地支:{lunar.year8Char}{lunar.month8Char}{lunar.day8Char}
请严格按照以下 JSON 格式返回,不要有其他内容:
{{"yi": ["宜做的事1", "宜做的事2", "宜做的事3", "宜做的事4"], "ji": ["忌做的事1", "忌做的事2", "忌做的事3", "忌做的事4"]}}
要求:
1. 宜和忌各返回4-6个项目
2. 使用传统黄历术语,如:祭祀、祈福、嫁娶、出行、开市、动土、安葬等
3. 只返回 JSON不要有任何解释"""
response = client.chat.completions.create(
model=settings.DEEPSEEK_MODEL,
messages=[
{"role": "system", "content": "你是一个精通中国传统黄历的专家,请根据日期生成准确的宜忌信息。"},
{"role": "user", "content": prompt}
],
temperature=0.7,
max_tokens=200
)
# 解析返回的 JSON
result_text = response.choices[0].message.content.strip()
result = json.loads(result_text)
return result.get("yi", []), result.get("ji", [])
except Exception as e:
# 如果 API 调用失败,返回默认值
print(f"DeepSeek API 调用失败: {e}")
return ["祭祀", "祈福", "出行", "开市"], ["动土", "安葬", "破土", "作灶"]

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Body, Depends, UploadFile, Request
from fastapi.responses import JSONResponse, FileResponse
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.common.response import SuccessResponse, UploadFileResponse
from app.core.router_class import OperationLogRoute
from app.utils.upload_util import UploadUtil
from .service import FileService
FileRouter = APIRouter(route_class=OperationLogRoute, prefix="/file", tags=["文件管理"])
@FileRouter.post("/upload", summary="上传文件", description="上传文件",dependencies=[Depends(AuthPermission(["module_common:file:upload"]))])
async def upload_controller(
file: UploadFile,
request: Request,
) -> JSONResponse:
"""
上传文件
参数:
- file (UploadFile): 上传的文件
- request (Request): 请求对象
返回:
- JSONResponse: 包含上传文件详情的JSON响应
"""
result_dict = await FileService.upload_service(base_url=str(request.base_url), file=file)
log.info(f"上传文件成功 {result_dict}")
return SuccessResponse(data=result_dict, msg="上传文件成功")
@FileRouter.post("/download", summary="下载文件", description="下载文件", dependencies=[Depends(AuthPermission(["module_common:file:download"]))])
async def download_controller(
background_tasks: BackgroundTasks,
file_path: str = Body(..., description="文件路径"),
delete: bool = Body(False, description="是否删除文件"),
) -> FileResponse:
"""
下载文件
参数:
- background_tasks (BackgroundTasks): 后台任务对象
- file_path (str): 文件路径
- delete (bool): 是否删除文件
返回:
- FileResponse: 包含下载文件的响应
"""
result = await FileService.download_service(file_path=file_path)
if delete:
background_tasks.add_task(UploadUtil.delete_file, Path(file_path))
log.info(f"下载文件成功")
return UploadFileResponse(file_path=result.file_path, filename=result.file_name)

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.alias_generators import to_camel
class ImportFieldModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
base_column: str | None = Field(description='数据库字段名', default=None)
excel_column: str | None = Field(description='excel字段名', default=None)
default_value: str | None = Field(description='默认值', default=None)
is_required: bool | None = Field(description='是否必传', default=None)
selected: bool | None = Field(description='是否勾选', default=None)
@model_validator(mode='before')
@classmethod
def _normalize(cls, data):
if isinstance(data, dict):
for key in ('base_column', 'excel_column', 'default_value'):
val = data.get(key)
if isinstance(val, str):
val = val.strip()
if val == '':
val = None
data[key] = val
# is_required 兼容转换
val = data.get('is_required')
if isinstance(val, str):
lowered = val.strip().lower()
if lowered in {'true', '1', 'y', 'yes'}:
data['is_required'] = True
elif lowered in {'false', '0', 'n', 'no'}:
data['is_required'] = False
return data
@model_validator(mode='after')
def _validate(self):
if self.selected and not (self.base_column and self.base_column.strip()):
raise ValueError('选中字段必须提供数据库字段名')
if self.is_required and not (self.excel_column and self.excel_column.strip()):
raise ValueError('必传字段必须提供excel字段名')
return self
class ImportModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
table_name: str | None = Field(description='表名', default=None)
sheet_name: str | None = Field(description='Sheet名', default=None)
filed_info: list[ImportFieldModel] | None = Field(description='字段关联表', default=None)
file_name: str | None = Field(description='文件名', default=None)
@model_validator(mode='after')
def _validate(self):
# excel_column 不重复(忽略 None
if self.filed_info:
seen = set()
for f in self.filed_info:
if f.excel_column:
key = f.excel_column.strip()
if key in seen:
raise ValueError('excel字段名存在重复')
seen.add(key)
return self

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
from typing import Dict
from fastapi import UploadFile
from app.core.exceptions import CustomException
from app.core.base_schema import UploadResponseSchema, DownloadFileSchema
from app.utils.upload_util import UploadUtil
class FileService:
"""
文件管理服务层
"""
@classmethod
async def upload_service(cls, base_url: str, file: UploadFile, upload_type: str = 'local') -> Dict:
"""
上传文件。
参数:
- base_url (str): 基础访问 URL。
- file (UploadFile): 上传文件对象。
- upload_type (str): 上传类型,'local''oss',默认 'local'
返回:
- Dict: 上传响应字典。
异常:
- CustomException: 当未选择文件或上传类型错误时抛出。
"""
if not file:
raise CustomException(msg="请选择要上传的文件")
if upload_type == 'local':
filename, filepath, file_url = await UploadUtil.upload_file(file=file, base_url=base_url)
else:
raise CustomException(msg="上传类型错误")
return UploadResponseSchema(
file_path=f'{filepath}',
file_name=filename,
origin_name=file.filename,
file_url=f'{file_url}',
).model_dump()
@classmethod
async def download_service(cls, file_path: str) -> DownloadFileSchema:
"""
下载文件。
参数:
- file_path (str): 文件路径。
返回:
- DownloadFileSchema: 下载文件响应对象。
异常:
- CustomException: 当未选择文件或文件不存在时抛出。
"""
if not file_path:
raise CustomException(msg="请选择要下载的文件")
if not UploadUtil.check_file_exists(file_path):
raise CustomException(msg="文件不存在")
file_name = UploadUtil.download_file(file_path)
return DownloadFileSchema(
file_path=file_path,
file_name=str(file_name),
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,125 @@
'''
Author: caoziyuan ziyuan.cao@zhuying.com
Date: 2025-12-23 15:20:07
LastEditors: caoziyuan ziyuan.cao@zhuying.com
LastEditTime: 2025-12-23 15:41:59
FilePath: \naming-backend\app\api\v1\module_common\goodname\controller.py
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
'''
# -*- coding: utf-8 -*-
from typing import List
from fastapi import APIRouter, Query, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import SuccessResponse
from app.core.dependencies import db_getter, get_current_user
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_common.goodname.crud import NameRecordCRUD
from app.api.v1.module_common.goodname.schema import NameRecordCreate, NameRecordUpdate, NameRecordOut
# 定义路由
GoodNameRouter = APIRouter(prefix="/goodname", tags=["佳名赏析"])
@GoodNameRouter.get("/list", summary="获取佳名列表", description="获取佳名赏析列表(无需登录)")
async def get_good_name_list(
limit: int = Query(default=3, ge=1, le=50, description="返回数量"),
db: AsyncSession = Depends(db_getter)
) -> SuccessResponse:
"""
获取佳名赏析列表
返回格式:
[
{"name": "清芷", "source": "楚辞", "desc": "岸芷汀兰,郁郁青青"},
{"name": "云帆", "source": "行路难", "desc": "长风破浪会有时,直挂云帆济沧海"},
...
]
"""
auth = AuthSchema(db=db, check_data_scope=False)
crud = NameRecordCRUD(auth)
data = await crud.get_good_names_for_display(limit)
return SuccessResponse(data=data)
@GoodNameRouter.get("/random", summary="随机获取佳名", description="随机获取佳名赏析(无需登录)")
async def get_random_good_names(
count: int = Query(default=3, ge=1, le=10, description="返回数量"),
db: AsyncSession = Depends(db_getter)
) -> SuccessResponse:
"""
随机获取佳名
"""
auth = AuthSchema(db=db, check_data_scope=False)
crud = NameRecordCRUD(auth)
data = await crud.get_random_names(count)
return SuccessResponse(data=data)
@GoodNameRouter.post("/create", summary="创建起名记录", description="创建起名记录(需要登录)")
async def create_name_record(
data: NameRecordCreate,
auth: AuthSchema = Depends(get_current_user)
) -> SuccessResponse:
"""
创建起名记录
"""
crud = NameRecordCRUD(auth)
result = await crud.create(data)
return SuccessResponse(data={"id": result.id}, msg="创建成功")
@GoodNameRouter.get("/my", summary="获取我的起名记录", description="获取当前用户的起名记录(需要登录)")
async def get_my_name_records(
page: int = Query(default=1, ge=1, description="页码"),
size: int = Query(default=10, ge=1, le=100, description="每页数量"),
auth: AuthSchema = Depends(get_current_user)
) -> SuccessResponse:
"""
获取我的起名记录
"""
crud = NameRecordCRUD(auth)
offset = (page - 1) * size
result = await crud.page(
offset=offset,
limit=size,
order_by=[{"created_time": "desc"}],
search={"created_id": auth.user.id},
out_schema=NameRecordOut
)
return SuccessResponse(data=result)
@GoodNameRouter.put("/favorite/{record_id}", summary="收藏/取消收藏", description="收藏或取消收藏起名记录(需要登录)")
async def toggle_favorite(
record_id: int,
auth: AuthSchema = Depends(get_current_user)
) -> SuccessResponse:
"""
收藏/取消收藏起名记录
"""
crud = NameRecordCRUD(auth)
record = await crud.get(id=record_id)
if not record:
return SuccessResponse(msg="记录不存在", success=False)
# 切换收藏状态
new_status = 0 if record.is_favorite == 1 else 1
await crud.update(record_id, {"is_favorite": new_status})
msg = "收藏成功" if new_status == 1 else "已取消收藏"
return SuccessResponse(msg=msg)
@GoodNameRouter.delete("/delete/{record_id}", summary="删除起名记录", description="删除起名记录(需要登录)")
async def delete_name_record(
record_id: int,
auth: AuthSchema = Depends(get_current_user)
) -> SuccessResponse:
"""
删除起名记录
"""
crud = NameRecordCRUD(auth)
await crud.delete([record_id])
return SuccessResponse(msg="删除成功")

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
from typing import List
from app.core.base_crud import CRUDBase
from app.api.v1.module_common.goodname.model import UserNameRecordModel
from app.api.v1.module_common.goodname.schema import NameRecordCreate, NameRecordUpdate
from app.api.v1.module_system.auth.schema import AuthSchema
class NameRecordCRUD(CRUDBase[UserNameRecordModel, NameRecordCreate, NameRecordUpdate]):
"""起名记录 CRUD"""
def __init__(self, auth: AuthSchema) -> None:
super().__init__(UserNameRecordModel, auth)
async def get_good_names_for_display(self, limit: int = 10) -> List[dict]:
"""
获取佳名赏析列表(用于前端展示)
参数:
- limit: 返回数量
返回:
- List[dict]: 格式化的佳名列表
"""
records = await self.list(
search={"status": "0"},
order_by=[{"created_time": "desc"}]
)
result = []
for record in records[:limit]:
result.append({
"name": record.name,
"source": record.source or "",
"desc": record.source_text or record.meaning or ""
})
return result
async def get_random_names(self, count: int = 3) -> List[dict]:
"""
随机获取佳名
参数:
- count: 返回数量
返回:
- List[dict]: 随机佳名列表
"""
import random
records = await self.list(
search={"status": "0"},
order_by=[{"id": "asc"}]
)
if not records:
return []
# 随机选择
selected = random.sample(list(records), min(count, len(records)))
result = []
for record in selected:
result.append({
"name": record.name,
"source": record.source or "",
"desc": record.source_text or record.meaning or ""
})
return result

View File

@@ -0,0 +1,57 @@
'''
Author: caoziyuan ziyuan.cao@zhuying.com
Date: 2025-12-23 15:22:53
LastEditors: caoziyuan ziyuan.cao@zhuying.com
LastEditTime: 2025-12-23 15:41:35
FilePath: \naming-backend\app\api\v1\module_common\goodname\model.py
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
'''
# -*- coding: utf-8 -*-
from sqlalchemy import String, Integer, Text, SmallInteger
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class UserNameRecordModel(ModelMixin, UserMixin):
"""用户起名记录表"""
__tablename__ = "biz_user_name_record"
# ========== 基本信息 ==========
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="起的名字")
surname: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="姓氏")
full_name: Mapped[str | None] = mapped_column(String(70), nullable=True, comment="完整姓名")
# ========== 名字来源 ==========
source: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="出处(如:诗经、楚辞)")
source_text: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原文/出处原句")
meaning: Mapped[str | None] = mapped_column(Text, nullable=True, comment="名字含义/寓意")
# ========== 起名对象信息 ==========
gender: Mapped[int | None] = mapped_column(SmallInteger, default=0, nullable=True, comment="性别(0:未知 1:男 2:女)")
birth_year: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="出生年份")
birth_month: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="出生月份")
birth_day: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="出生日期")
birth_hour: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="出生时辰(0-23)")
lunar_birth: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="农历生日")
# ========== 五行八字 ==========
wuxing: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="五行属性(如:金木水火土)")
bazi: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="八字")
wuxing_lack: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="五行缺失")
# ========== 起名类型 ==========
name_type: Mapped[int | None] = mapped_column(SmallInteger, default=1, nullable=True, comment="起名类型(1:宝宝起名 2:成人改名 3:公司起名 4:店铺起名)")
# ========== 评分相关 ==========
score: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="名字评分(0-100)")
score_detail: Mapped[str | None] = mapped_column(Text, nullable=True, comment="评分详情(JSON)")
# ========== 用户操作 ==========
is_favorite: Mapped[int] = mapped_column(SmallInteger, default=0, nullable=False, comment="是否收藏(0:否 1:是)")
is_used: Mapped[int] = mapped_column(SmallInteger, default=0, nullable=False, comment="是否已使用(0:否 1:是)")
# ========== 来源渠道 ==========
channel: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源渠道小程序、APP、网页")
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="用户IP地址")

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, Field
class GoodNameItem(BaseModel):
"""佳名赏析项(简化版,用于列表展示)"""
name: str = Field(..., description="名字")
source: str = Field(..., description="出处")
desc: str = Field(..., description="原文/释义")
class NameRecordCreate(BaseModel):
"""创建起名记录"""
name: str = Field(..., max_length=50, description="起的名字")
surname: Optional[str] = Field(None, max_length=20, description="姓氏")
full_name: Optional[str] = Field(None, max_length=70, description="完整姓名")
source: Optional[str] = Field(None, max_length=100, description="出处")
source_text: Optional[str] = Field(None, description="原文/出处原句")
meaning: Optional[str] = Field(None, description="名字含义/寓意")
gender: Optional[int] = Field(0, description="性别(0:未知 1:男 2:女)")
birth_year: Optional[int] = Field(None, description="出生年份")
birth_month: Optional[int] = Field(None, ge=1, le=12, description="出生月份")
birth_day: Optional[int] = Field(None, ge=1, le=31, description="出生日期")
birth_hour: Optional[int] = Field(None, ge=0, le=23, description="出生时辰")
lunar_birth: Optional[str] = Field(None, max_length=50, description="农历生日")
wuxing: Optional[str] = Field(None, max_length=20, description="五行属性")
bazi: Optional[str] = Field(None, max_length=50, description="八字")
wuxing_lack: Optional[str] = Field(None, max_length=20, description="五行缺失")
name_type: Optional[int] = Field(1, description="起名类型(1:宝宝起名 2:成人改名 3:公司起名 4:店铺起名)")
score: Optional[int] = Field(None, ge=0, le=100, description="名字评分")
score_detail: Optional[str] = Field(None, description="评分详情(JSON)")
channel: Optional[str] = Field(None, max_length=50, description="来源渠道")
class NameRecordUpdate(BaseModel):
"""更新起名记录"""
name: Optional[str] = Field(None, max_length=50)
surname: Optional[str] = Field(None, max_length=20)
full_name: Optional[str] = Field(None, max_length=70)
source: Optional[str] = Field(None, max_length=100)
source_text: Optional[str] = None
meaning: Optional[str] = None
is_favorite: Optional[int] = Field(None, description="是否收藏(0:否 1:是)")
is_used: Optional[int] = Field(None, description="是否已使用(0:否 1:是)")
class NameRecordOut(BaseModel):
"""起名记录输出"""
id: int
name: str
surname: Optional[str] = None
full_name: Optional[str] = None
source: Optional[str] = None
source_text: Optional[str] = None
meaning: Optional[str] = None
gender: Optional[int] = None
birth_year: Optional[int] = None
birth_month: Optional[int] = None
birth_day: Optional[int] = None
lunar_birth: Optional[str] = None
wuxing: Optional[str] = None
bazi: Optional[str] = None
wuxing_lack: Optional[str] = None
name_type: Optional[int] = None
score: Optional[int] = None
is_favorite: int = 0
is_used: int = 0
created_time: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from typing import List
class GoodNameService:
"""佳名赏析服务"""
# 佳名数据
GOOD_NAMES = [
{"name": "清芷", "source": "楚辞", "desc": "岸芷汀兰,郁郁青青"},
{"name": "云帆", "source": "行路难", "desc": "长风破浪会有时,直挂云帆济沧海"},
{"name": "望舒", "source": "离骚", "desc": "前望舒使先驱兮,后飞廉使奔属"},
{"name": "思齐", "source": "论语", "desc": "见贤思齐焉,见不贤而内自省也"},
{"name": "若华", "source": "楚辞", "desc": "桂树丛生兮山之幽,偃蹇连蜷兮枝相缭"},
{"name": "嘉言", "source": "尚书", "desc": "嘉言罔攸伏,野无遗贤"},
{"name": "明哲", "source": "诗经", "desc": "既明且哲,以保其身"},
{"name": "子衿", "source": "诗经", "desc": "青青子衿,悠悠我心"},
{"name": "静姝", "source": "诗经", "desc": "静女其姝,俟我于城隅"},
{"name": "修远", "source": "离骚", "desc": "路漫漫其修远兮,吾将上下而求索"},
{"name": "星汉", "source": "观沧海", "desc": "日月之行,若出其中;星汉灿烂,若出其里"},
{"name": "霁月", "source": "世说新语", "desc": "光风霁月,坦荡胸怀"},
]
@classmethod
def get_good_names(cls, limit: int = 10) -> List[dict]:
"""
获取佳名列表
参数:
- limit: 返回数量默认10条
返回:
- List[dict]: 佳名列表
"""
return cls.GOOD_NAMES[:limit]
@classmethod
def get_random_names(cls, count: int = 3) -> List[dict]:
"""
随机获取佳名
参数:
- count: 返回数量默认3条
返回:
- List[dict]: 随机佳名列表
"""
import random
return random.sample(cls.GOOD_NAMES, min(count, len(cls.GOOD_NAMES)))

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter
from fastapi.responses import JSONResponse
HealthRouter = APIRouter(prefix="", tags=["健康检查"])
@HealthRouter.get("/health", summary="健康检查", description="检查系统健康状态")
async def health_check() -> JSONResponse:
"""
健康检查接口
返回:
- JSONResponse: 包含健康状态的JSON响应
"""
return JSONResponse(content={"msg": "Healthy"}, status_code=200)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Body, Depends, Path, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
import urllib.parse
from app.common.response import StreamResponse, SuccessResponse
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.base_schema import BatchSetAvailable
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .service import DemoService
from .schema import (
DemoCreateSchema,
DemoUpdateSchema,
DemoQueryParam
)
DemoRouter = APIRouter(route_class=OperationLogRoute, prefix="/demo", tags=["示例模块"])
@DemoRouter.get("/detail/{id}", summary="获取示例详情", description="获取示例详情")
async def get_obj_detail_controller(
id: int = Path(..., description="示例ID"),
auth: AuthSchema = Depends(AuthPermission(["module_gencode:demo:query"]))
) -> JSONResponse:
"""
获取示例详情
参数:
- id (int): 示例ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含示例详情的JSON响应
"""
result_dict = await DemoService.detail_service(id=id, auth=auth)
log.info(f"获取示例详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取示例详情成功")
@DemoRouter.get("/list", summary="查询示例列表", description="查询示例列表")
async def get_obj_list_controller(
page: PaginationQueryParam = Depends(),
search: DemoQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_gencode:demo:query"]))
) -> JSONResponse:
"""
查询示例列表
参数:
- page (PaginationQueryParam): 分页查询参数
- search (DemoQueryParam): 查询参数
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含示例列表分页信息的JSON响应
"""
# 使用数据库分页而不是应用层分页
result_dict = await DemoService.page_service(
auth=auth,
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by
)
log.info("查询示例列表成功")
return SuccessResponse(data=result_dict, msg="查询示例列表成功")
@DemoRouter.post("/create", summary="创建示例", description="创建示例")
async def create_obj_controller(
data: DemoCreateSchema,
auth: AuthSchema = Depends(AuthPermission(["module_gencode:demo:create"]))
) -> JSONResponse:
"""
创建示例
参数:
- data (DemoCreateSchema): 示例创建模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含创建示例详情的JSON响应
"""
result_dict = await DemoService.create_service(auth=auth, data=data)
log.info(f"创建示例成功: {result_dict.get('name')}")
return SuccessResponse(data=result_dict, msg="创建示例成功")
@DemoRouter.put("/update/{id}", summary="修改示例", description="修改示例")
async def update_obj_controller(
data: DemoUpdateSchema,
id: int = Path(..., description="示例ID"),
auth: AuthSchema = Depends(AuthPermission(["module_gencode:demo:update"]))
) -> JSONResponse:
"""
修改示例
参数:
- data (DemoUpdateSchema): 示例更新模型
- id (int): 示例ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含修改示例详情的JSON响应
"""
result_dict = await DemoService.update_service(auth=auth, id=id, data=data)
log.info(f"修改示例成功: {result_dict.get('name')}")
return SuccessResponse(data=result_dict, msg="修改示例成功")
@DemoRouter.delete("/delete", summary="删除示例", description="删除示例")
async def delete_obj_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_gencode:demo:delete"]))
) -> JSONResponse:
"""
删除示例
参数:
- ids (list[int]): 示例ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除示例详情的JSON响应
"""
await DemoService.delete_service(auth=auth, ids=ids)
log.info(f"删除示例成功: {ids}")
return SuccessResponse(msg="删除示例成功")
@DemoRouter.patch("/available/setting", summary="批量修改示例状态", description="批量修改示例状态")
async def batch_set_available_obj_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["module_gencode:demo:patch"]))
) -> JSONResponse:
"""
批量修改示例状态
参数:
- data (BatchSetAvailable): 批量修改示例状态模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含批量修改示例状态详情的JSON响应
"""
await DemoService.set_available_service(auth=auth, data=data)
log.info(f"批量修改示例状态成功: {data.ids}")
return SuccessResponse(msg="批量修改示例状态成功")
@DemoRouter.post('/export', summary="导出示例", description="导出示例")
async def export_obj_list_controller(
search: DemoQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_gencode:demo:export"]))
) -> StreamingResponse:
"""
导出示例
参数:
- search (DemoQueryParam): 查询参数
- auth (AuthSchema): 认证信息模型
返回:
- StreamingResponse: 包含示例列表的Excel文件流响应
"""
result_dict_list = await DemoService.list_service(search=search, auth=auth)
export_result = await DemoService.batch_export_service(obj_list=result_dict_list)
log.info('导出示例成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=demo.xlsx'
}
)
@DemoRouter.post('/import', summary="导入示例", description="导入示例")
async def import_obj_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["module_gencode:demo:import"]))
) -> JSONResponse:
"""
导入示例
参数:
- file (UploadFile): 导入的Excel文件
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含导入示例详情的JSON响应
"""
batch_import_result = await DemoService.batch_import_service(file=file, auth=auth, update_support=True)
log.info(f"导入示例成功: {batch_import_result}")
return SuccessResponse(data=batch_import_result, msg="导入示例成功")
@DemoRouter.post('/download/template', summary="获取示例导入模板", description="获取示例导入模板", dependencies=[Depends(AuthPermission(["module_gencode:demo:download"]))])
async def export_obj_template_controller() -> StreamingResponse:
"""
获取示例导入模板
返回:
- StreamingResponse: 包含示例导入模板的Excel文件流响应
"""
import_template_result = await DemoService.import_template_download_service()
log.info('获取示例导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': f'attachment; filename={urllib.parse.quote("示例导入模板.xlsx")}',
'Access-Control-Expose-Headers': 'Content-Disposition'
}
)

View File

@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
from collections.abc import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import DemoModel
from .schema import DemoCreateSchema, DemoUpdateSchema, DemoOutSchema
class DemoCRUD(CRUDBase[DemoModel, DemoCreateSchema, DemoUpdateSchema]):
"""示例数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=DemoModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str] | None = None) -> DemoModel | None:
"""
详情
参数:
- id (int): 示例ID
- preload (list[str] | None): 预加载关系,未提供时使用模型默认项
返回:
- DemoModel | None: 示例模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list[str] | None = None) -> Sequence[DemoModel]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数
- preload (list[str] | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[DemoModel]: 示例模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_crud(self, data: DemoCreateSchema) -> DemoModel | None:
"""
创建
参数:
- data (DemoCreateSchema): 示例创建模型
返回:
- DemoModel | None: 示例模型实例或None
"""
return await self.create(data=data)
async def update_crud(self, id: int, data: DemoUpdateSchema) -> DemoModel | None:
"""
更新
参数:
- id (int): 示例ID
- data (DemoUpdateSchema): 示例更新模型
返回:
- DemoModel | None: 示例模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (List[int]): 示例ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 示例ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数
- search (dict | None): 查询参数
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=DemoOutSchema,
preload=preload
)

View File

@@ -0,0 +1,25 @@
'''
Author: caoziyuan ziyuan.cao@zhuying.com
Date: 2025-12-15 17:37:50
LastEditors: caoziyuan ziyuan.cao@zhuying.com
LastEditTime: 2025-12-22 17:25:22
FilePath: \backend\app\api\v1\module_gencode\demo\model.py
Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
'''
# -*- coding: utf-8 -*-
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class DemoModel(ModelMixin, UserMixin):
"""
示例表
"""
__tablename__: str = 'gen_demo'
__table_args__: dict[str, str] = ({'comment': '示例表'})
__loader_options__: list[str] = ["created_by", "updated_by"]
name: Mapped[str | None] = mapped_column(String(64), nullable=True, default='', comment='名称')

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from fastapi import Query
from app.core.validator import DateTimeStr
from app.core.base_schema import BaseSchema, UserBySchema
class DemoCreateSchema(BaseModel):
"""新增模型"""
name: str = Field(..., min_length=2, max_length=50, description='名称')
status: str = Field(default="0", description="是否启用(0:启用 1:禁用)")
description: str | None = Field(default=None, max_length=255, description="描述")
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
"""验证名称字段的格式和内容"""
# 去除首尾空格
v = v.strip()
if not v:
raise ValueError('名称不能为空')
return v
@model_validator(mode='after')
def _after_validation(self):
"""
核心业务规则校验
"""
# 长度校验:名称最小长度
if len(self.name) < 2 or len(self.name) > 50:
raise ValueError('名称长度必须在2-50个字符之间')
# 格式校验:名称只能包含字母、数字、下划线和中划线
if not self.name.isalnum() and not all(c in '-_' for c in self.name):
raise ValueError('名称只能包含字母、数字、下划线和中划线')
return self
class DemoUpdateSchema(DemoCreateSchema):
"""更新模型"""
...
class DemoOutSchema(DemoCreateSchema, BaseSchema, UserBySchema):
"""响应模型"""
model_config = ConfigDict(from_attributes=True)
class DemoQueryParam:
"""示例查询参数"""
def __init__(
self,
name: str | None = Query(None, description="名称"),
status: str | None = Query(None, description="是否启用"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"),
) -> None:
# 模糊查询字段
self.name = ("like", name)
# 精确查询字段
self.created_id = created_id
self.updated_id = updated_id
self.status = status
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))

View File

@@ -0,0 +1,312 @@
# -*- coding: utf-8 -*-
import io
from typing import Any
from fastapi import UploadFile
import pandas as pd
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import DemoCreateSchema, DemoUpdateSchema, DemoOutSchema, DemoQueryParam
from .crud import DemoCRUD
class DemoService:
"""
示例管理模块服务层
"""
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 示例ID
返回:
- dict: 示例模型实例字典
"""
obj = await DemoCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return DemoOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_service(cls, auth: AuthSchema, search: DemoQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> list[dict]:
"""
列表查询
参数:
- auth (AuthSchema): 认证信息模型
- search (DemoQueryParam | None): 查询参数
- order_by (list[dict[str, str]] | None): 排序参数
返回:
- list[dict]: 示例模型实例字典列表
"""
search_dict = search.__dict__ if search else None
obj_list = await DemoCRUD(auth).list_crud(search=search_dict, order_by=order_by)
return [DemoOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: DemoQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> dict:
"""
分页查询
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码
- page_size (int): 每页数量
- search (DemoQueryParam | None): 查询参数
- order_by (list[dict[str, str]] | None): 排序参数
返回:
- dict: 分页数据
"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await DemoCRUD(auth).page_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_service(cls, auth: AuthSchema, data: DemoCreateSchema) -> dict:
"""
创建
参数:
- auth (AuthSchema): 认证信息模型
- data (DemoCreateSchema): 示例创建模型
返回:
- dict: 示例模型实例字典
"""
obj = await DemoCRUD(auth).get(name=data.name)
if obj:
raise CustomException(msg='创建失败,名称已存在')
obj = await DemoCRUD(auth).create_crud(data=data)
return DemoOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_service(cls, auth: AuthSchema, id: int, data: DemoUpdateSchema) -> dict:
"""
更新
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 示例ID
- data (DemoUpdateSchema): 示例更新模型
返回:
- dict: 示例模型实例字典
"""
# 检查数据是否存在
obj = await DemoCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查名称是否重复
exist_obj = await DemoCRUD(auth).get(name=data.name)
if exist_obj and exist_obj.id != id:
raise CustomException(msg='更新失败,名称重复')
obj = await DemoCRUD(auth).update_crud(id=id, data=data)
return DemoOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 示例ID列表
返回:
- None
"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
# 检查所有要删除的数据是否存在
for id in ids:
obj = await DemoCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await DemoCRUD(auth).delete_crud(ids=ids)
@classmethod
async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""
批量设置状态
参数:
- auth (AuthSchema): 认证信息模型
- data (BatchSetAvailable): 批量设置状态模型
返回:
- None
"""
await DemoCRUD(auth).set_available_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_service(cls, obj_list: list[dict[str, Any]]) -> bytes:
"""
批量导出
参数:
- obj_list (list[dict[str, Any]]): 示例模型实例字典列表
返回:
- bytes: Excel文件字节流
"""
mapping_dict = {
'id': '编号',
'name': '名称',
'status': '状态',
'description': '备注',
'created_time': '创建时间',
'updated_time': '更新时间',
'created_id': '创建者',
}
# 复制数据并转换状态
data = obj_list.copy()
for item in data:
# 处理状态
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 处理创建者
creator_info = item.get('created_id')
if isinstance(creator_info, dict):
item['created_id'] = creator_info.get('name', '未知')
else:
item['created_id'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""
批量导入
参数:
- auth (AuthSchema): 认证信息模型
- file (UploadFile): 上传的Excel文件
- update_support (bool): 是否支持更新存在数据
返回:
- str: 导入结果信息
"""
header_dict = {
'名称': 'name',
'状态': 'status',
'描述': 'description'
}
try:
# 读取Excel文件
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
# 检查表头是否完整
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
# 重命名列名
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
required_fields = ['name', 'status']
errors = []
for field in required_fields:
missing_rows = df[df[field].isnull()].index.tolist()
if missing_rows:
field_name = [k for k,v in header_dict.items() if v == field][0]
rows_str = "".join([str(i+1) for i in missing_rows])
errors.append(f"{field_name}不能为空,第{rows_str}")
if errors:
raise CustomException(msg=f"导入失败,以下行缺少必要字段:\n{'; '.join(errors)}")
error_msgs = []
success_count = 0
count = 0
# 处理每一行数据
for index, row in df.iterrows():
count += 1
try:
# 数据转换前的类型检查
try:
status = True if row['status'] == '正常' else False
except ValueError:
error_msgs.append(f"{count}行: 状态必须是'正常''停用'")
continue
# 构建用户数据
data = {
"name": str(row['name']),
"status": status,
"description": str(row['description']),
}
# 处理用户导入
exists_obj = await DemoCRUD(auth).get(name=data["name"])
if exists_obj:
if update_support:
await DemoCRUD(auth).update(id=exists_obj.id, data=data)
success_count += 1
else:
error_msgs.append(f"{count}行: 对象 {data['name']} 已存在")
else:
await DemoCRUD(auth).create(data=data)
success_count += 1
except Exception as e:
error_msgs.append(f"{count}行: {str(e)}")
continue
# 返回详细的导入结果
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入用户失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_service(cls) -> bytes:
"""
下载导入模板
返回:
- bytes: Excel文件字节流
"""
header_list = ['名称', '状态', '描述']
selector_header_list = ['状态']
option_list = [{'状态': ['正常', '停用']}]
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,253 @@
# -*- coding: utf-8 -*-
from typing import List
from fastapi import APIRouter, Depends, Body, Path
from fastapi.responses import JSONResponse
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission
from app.core.base_params import PaginationQueryParam
from app.common.request import PaginationService
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import GenTableSchema, GenTableQueryParam
from .service import GenTableService
GenRouter = APIRouter(route_class=OperationLogRoute, prefix='/gencode', tags=["代码生成模块"])
@GenRouter.get("/list", summary="查询代码生成业务表列表", description="查询代码生成业务表列表")
async def gen_table_list_controller(
page: PaginationQueryParam = Depends(),
search: GenTableQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:query"]))
) -> JSONResponse:
"""
查询代码生成业务表列表
参数:
- page (PaginationQueryParam): 分页查询参数
- search (GenTableQueryParam): 搜索参数
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含查询结果和分页信息的JSON响应
"""
result_dict_list = await GenTableService.get_gen_table_list_service(auth=auth, search=search)
result_dict = await PaginationService.paginate(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size)
log.info('获取代码生成业务表列表成功')
return SuccessResponse(data=result_dict, msg="获取代码生成业务表列表成功")
@GenRouter.get("/db/list", summary="查询数据库表列表", description="查询数据库表列表")
async def get_gen_db_table_list_controller(
page: PaginationQueryParam = Depends(),
search: GenTableQueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["module_generator:dblist:query"]))
) -> JSONResponse:
"""
查询数据库表列表
参数:
- page (PaginationQueryParam): 分页查询参数
- search (GenTableQueryParam): 搜索参数
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含查询结果和分页信息的JSON响应
"""
result_dict_list = await GenTableService.get_gen_db_table_list_service(auth=auth, search=search)
result_dict = await PaginationService.paginate(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size)
log.info('获取数据库表列表成功')
return SuccessResponse(data=result_dict, msg="获取数据库表列表成功")
@GenRouter.post("/import", summary="导入表结构", description="导入表结构")
async def import_gen_table_controller(
table_names: List[str] = Body(..., description="表名列表"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:import"])),
) -> JSONResponse:
"""
导入表结构
参数:
- table_names (List[str]): 表名列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含导入结果和导入的表结构列表的JSON响应
"""
add_gen_table_list = await GenTableService.get_gen_db_table_list_by_name_service(auth, table_names)
result = await GenTableService.import_gen_table_service(auth, add_gen_table_list)
log.info('导入表结构成功')
return SuccessResponse(msg="导入表结构成功", data=result)
@GenRouter.get("/detail/{table_id}", summary="获取业务表详细信息", description="获取业务表详细信息")
async def gen_table_detail_controller(
table_id: int = Path(..., description="业务表ID"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:query"]))
) -> JSONResponse:
"""
获取业务表详细信息
参数:
- table_id (int): 业务表ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含业务表详细信息的JSON响应
"""
gen_table_detail_result = await GenTableService.get_gen_table_detail_service(auth, table_id)
log.info(f'获取table_id为{table_id}的信息成功')
return SuccessResponse(data=gen_table_detail_result, msg="获取业务表详细信息成功")
@GenRouter.post("/create", summary="创建表结构", description="创建表结构")
async def create_table_controller(
sql: str = Body(..., description="SQL语句用于创建表结构"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:create"])),
) -> JSONResponse:
"""
创建表结构
参数:
- sql (str): SQL语句用于创建表结构
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含创建结果的JSON响应
"""
result = await GenTableService.create_table_service(auth, sql)
log.info('创建表结构成功')
return SuccessResponse(msg="创建表结构成功", data=result)
@GenRouter.put("/update/{table_id}", summary="编辑业务表信息", description="编辑业务表信息")
async def update_gen_table_controller(
table_id: int = Path(..., description="业务表ID"),
data: GenTableSchema = Body(..., description="业务表信息"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:update"])),
) -> JSONResponse:
"""
编辑业务表信息
参数:
- table_id (int): 业务表ID
- data (GenTableSchema): 业务表信息模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含编辑结果的JSON响应
"""
result_dict = await GenTableService.update_gen_table_service(auth, data, table_id)
log.info('编辑业务表信息成功')
return SuccessResponse(data=result_dict, msg="编辑业务表信息成功")
@GenRouter.delete("/delete", summary="删除业务表信息", description="删除业务表信息")
async def delete_gen_table_controller(
ids: List[int] = Body(..., description="业务表ID列表"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:delete"]))
) -> JSONResponse:
"""
删除业务表信息
参数:
- ids (List[int]): 业务表ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除结果的JSON响应
"""
result = await GenTableService.delete_gen_table_service(auth, ids)
log.info('删除业务表信息成功')
return SuccessResponse(msg="删除业务表信息成功", data=result)
@GenRouter.patch("/batch/output", summary="批量生成代码", description="批量生成代码")
async def batch_gen_code_controller(
table_names: List[str] = Body(..., description="表名列表"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:patch"]))
) -> StreamResponse:
"""
批量生成代码
参数:
- table_names (List[str]): 表名列表
- auth (AuthSchema): 认证信息模型
返回:
- StreamResponse: 包含批量生成代码的ZIP文件流响应
"""
batch_gen_code_result = await GenTableService.batch_gen_code_service(auth, table_names)
log.info(f'批量生成代码成功,表名列表:{table_names}')
return StreamResponse(
data=bytes2file_response(batch_gen_code_result),
media_type='application/zip',
headers={'Content-Disposition': 'attachment; filename=code.zip'}
)
@GenRouter.post("/output/{table_name}", summary="生成代码到指定路径", description="生成代码到指定路径")
async def gen_code_local_controller(
table_name: str = Path(..., description="表名"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:code"]))
) -> JSONResponse:
"""
生成代码到指定路径
参数:
- table_name (str): 表名
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含生成结果的JSON响应
"""
result = await GenTableService.generate_code_service(auth, table_name)
log.info(f'生成代码,表名:{table_name},到指定路径成功')
return SuccessResponse(msg="生成代码到指定路径成功", data=result)
@GenRouter.get("/preview/{table_id}", summary="预览代码", description="预览代码")
async def preview_code_controller(
table_id: int = Path(..., description="业务表ID"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:gencode:query"]))
) -> JSONResponse:
"""
预览代码
参数:
- table_id (int): 业务表ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含预览代码的JSON响应
"""
preview_code_result = await GenTableService.preview_code_service(auth, table_id)
log.info(f'预览代码,表id{table_id},成功')
return SuccessResponse(data=preview_code_result, msg="预览代码成功")
@GenRouter.post("/sync_db/{table_name}", summary="同步数据库", description="同步数据库")
async def sync_db_controller(
table_name: str = Path(..., description="表名"),
auth: AuthSchema = Depends(AuthPermission(["module_generator:db:sync"]))
) -> JSONResponse:
"""
同步数据库
参数:
- table_name (str): 表名
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含同步数据库结果的JSON响应
"""
result = await GenTableService.sync_db_service(auth, table_name)
log.info(f'同步数据库,表名:{table_name},成功')
return SuccessResponse(msg="同步数据库成功", data=result)

View File

@@ -0,0 +1,575 @@
# -*- coding: utf-8 -*-
from sqlalchemy.engine.row import Row
from sqlalchemy import and_, select, text
from typing import Sequence
from sqlglot.expressions import Expression
from app.core.logger import log
from app.config.setting import settings
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import GenTableModel, GenTableColumnModel
from .schema import (
GenTableSchema,
GenTableColumnSchema,
GenTableColumnOutSchema,
GenDBTableSchema,
GenTableQueryParam
)
class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]):
"""代码生成业务表模块数据库操作层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD操作层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=GenTableModel, auth=auth)
async def get_gen_table_by_id(self, table_id: int, preload: list | None = None) -> GenTableModel | None:
"""
根据业务表ID获取需要生成的业务表信息。
参数:
- table_id (int): 业务表ID。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- GenTableModel | None: 业务表信息对象。
"""
return await self.get(id=table_id, preload=preload)
async def get_gen_table_by_name(self, table_name: str, preload: list | None = None) -> GenTableModel | None:
"""
根据业务表名称获取需要生成的业务表信息。
参数:
- table_name (str): 业务表名称。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- GenTableModel | None: 业务表信息对象。
"""
return await self.get(table_name=table_name, preload=preload)
async def get_gen_table_all(self, preload: list | None = None) -> Sequence[GenTableModel]:
"""
获取所有业务表信息。
参数:
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[GenTableModel]: 所有业务表信息列表。
"""
return await self.list(preload=preload)
async def get_gen_table_list(self, search: GenTableQueryParam | None = None, preload: list | None = None) -> Sequence[GenTableModel]:
"""
根据查询参数获取代码生成业务表列表信息。
参数:
- search (GenTableQueryParam | None): 查询参数对象。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[GenTableModel]: 业务表列表信息。
"""
return await self.list(search=search.__dict__, order_by=[{"created_time": "desc"}], preload=preload)
async def add_gen_table(self, add_model: GenTableSchema) -> GenTableModel:
"""
新增业务表信息。
参数:
- add_model (GenTableSchema): 新增业务表信息模型。
返回:
- GenTableModel: 新增的业务表信息对象。
"""
return await self.create(data=add_model)
async def edit_gen_table(self, table_id: int, edit_model: GenTableSchema) -> GenTableModel:
"""
修改业务表信息。
参数:
- table_id (int): 业务表ID。
- edit_model (GenTableSchema): 修改业务表信息模型。
返回:
- GenTableSchema: 修改后的业务表信息模型。
"""
# 排除嵌套对象字段避免SQLAlchemy尝试直接将字典设置到模型实例上
return await self.update(id=table_id, data=edit_model.model_dump(exclude_unset=True, exclude={"columns"}))
async def delete_gen_table(self, ids: list[int]) -> None:
"""
删除业务表信息。除了系统表。
参数:
- ids (list[int]): 业务表ID列表。
"""
await self.delete(ids=ids)
async def get_db_table_list(self, search: GenTableQueryParam | None = None) -> list[dict]:
"""
根据查询参数获取数据库表列表信息。
参数:
- search (GenTableQueryParam | None): 查询参数对象。
返回:
- list[dict]: 数据库表列表信息(已转为可序列化字典)。
"""
# 使用更健壮的方式检测数据库方言
if settings.DATABASE_TYPE == "postgres":
query_sql = (
select(
text("t.table_catalog as database_name"),
text("t.table_name as table_name"),
text("t.table_type as table_type"),
text("pd.description as table_comment"),
)
.select_from(text(
"information_schema.tables t \n"
"LEFT JOIN pg_catalog.pg_class c ON c.relname = t.table_name \n"
"LEFT JOIN pg_catalog.pg_namespace n ON n.nspname = t.table_schema AND c.relnamespace = n.oid \n"
"LEFT JOIN pg_catalog.pg_description pd ON pd.objoid = c.oid AND pd.objsubid = 0"
))
.where(
and_(
text("t.table_catalog = (select current_database())"),
text("t.is_insertable_into = 'YES'"),
text("t.table_schema = 'public'"),
)
)
)
else:
query_sql = (
select(
text("table_schema as database_name"),
text("table_name as table_name"),
text("table_type as table_type"),
text("table_comment as table_comment"),
)
.select_from(text("information_schema.tables"))
.where(
and_(
text("table_schema = (select database())"),
)
)
)
# 动态条件构造
params = {}
if search and search.table_name:
query_sql = query_sql.where(
text("lower(table_name) like lower(:table_name)")
)
params['table_name'] = f"%{search.table_name}%"
if search and search.table_comment:
# 对于PostgreSQL表注释字段是pd.description而不是table_comment
if settings.DATABASE_TYPE == "postgres":
query_sql = query_sql.where(
text("lower(pd.description) like lower(:table_comment)")
)
else:
query_sql = query_sql.where(
text("lower(table_comment) like lower(:table_comment)")
)
params['table_comment'] = f"%{search.table_comment}%"
# 执行查询并绑定参数
all_data = (await self.auth.db.execute(query_sql, params)).fetchall()
# 将Row对象转换为字典列表解决JSON序列化问题
dict_data = []
for row in all_data:
# 检查row是否为Row对象
if isinstance(row, Row):
# 使用._mapping获取字典
dict_row = GenDBTableSchema(**dict(row._mapping)).model_dump()
dict_data.append(dict_row)
else:
dict_row = GenDBTableSchema(**dict(row)).model_dump()
dict_data.append(dict_row)
return dict_data
async def get_db_table_list_by_names(self, table_names: list[str]) -> list[GenDBTableSchema]:
"""
根据业务表名称列表获取数据库表信息。
参数:
- table_names (list[str]): 业务表名称列表。
返回:
- list[GenDBTableSchema]: 数据库表信息对象列表。
"""
# 处理空列表情况
if not table_names:
return []
# 使用更健壮的方式检测数据库方言
if settings.DATABASE_TYPE == "postgres":
# PostgreSQL使用ANY操作符和正确的参数绑定
query_sql = """
SELECT
t.table_catalog as database_name,
t.table_name as table_name,
t.table_type as table_type,
pd.description as table_comment
FROM
information_schema.tables t
LEFT JOIN pg_catalog.pg_class c ON c.relname = t.table_name
LEFT JOIN pg_catalog.pg_namespace n ON n.nspname = t.table_schema AND c.relnamespace = n.oid
LEFT JOIN pg_catalog.pg_description pd ON pd.objoid = c.oid AND pd.objsubid = 0
WHERE
t.table_catalog = (select current_database())
AND t.is_insertable_into = 'YES'
AND t.table_schema = 'public'
AND t.table_name = ANY(:table_names)
"""
else:
query_sql = """
SELECT
table_schema as database_name,
table_name as table_name,
table_type as table_type,
table_comment as table_comment
FROM
information_schema.tables
WHERE
table_schema = (select database())
AND table_name IN :table_names
"""
# 创建新的数据库会话上下文来执行查询,避免受外部事务状态影响
try:
# 去重表名列表,避免重复查询
unique_table_names = list(set(table_names))
# 使用只读事务执行查询,不影响主事务
if settings.DATABASE_TYPE == "postgres":
gen_db_table_list = (await self.auth.db.execute(text(query_sql), {"table_names": unique_table_names})).fetchall()
else:
gen_db_table_list = (await self.auth.db.execute(text(query_sql), {"table_names": tuple(unique_table_names)})).fetchall()
except Exception as e:
log.error(f"查询表信息时发生错误: {e}")
# 查询错误时直接抛出,不需要事务处理
raise
# 将Row对象转换为字典列表解决JSON序列化问题
dict_data = []
for row in gen_db_table_list:
# 检查row是否为Row对象
if isinstance(row, Row):
# 使用._mapping获取字典
dict_row = GenDBTableSchema(**dict(row._mapping))
dict_data.append(dict_row)
else:
dict_row = GenDBTableSchema(**dict(row))
dict_data.append(dict_row)
return dict_data
async def check_table_exists(self, table_name: str) -> bool:
"""
检查数据库中是否已存在指定表名的表。
参数:
- table_name (str): 要检查的表名。
返回:
- bool: 如果表存在返回True否则返回False。
"""
try:
# 根据不同数据库类型使用不同的查询方式
if settings.DATABASE_TYPE.lower() == 'mysql':
query = text("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table_name")
else:
query = text("SELECT 1 FROM pg_tables WHERE tablename = :table_name")
result = await self.auth.db.execute(query, {"table_name": table_name})
return result.scalar() is not None
except Exception as e:
log.error(f"检查表格存在性时发生错误: {e}")
# 出错时返回False避免误报表已存在
return False
async def create_table_by_sql(self, sql_statements: list[Expression | None]) -> bool:
"""
根据SQL语句创建表结构。
参数:
- sql (str): 创建表的SQL语句。
返回:
- bool: 是否创建成功。
"""
try:
# 执行SQL但不手动提交事务由框架管理事务生命周期
for sql_statement in sql_statements:
if not sql_statement:
continue
sql = sql_statement.sql(dialect=settings.DATABASE_TYPE)
await self.auth.db.execute(text(sql))
return True
except Exception as e:
log.error(f"创建表时发生错误: {e}")
return False
async def execute_sql(self, sql: str) -> bool:
"""
执行SQL语句。
参数:
- sql (str): 要执行的SQL语句。
返回:
- bool: 是否执行成功。
"""
try:
# 执行SQL但不手动提交事务由框架管理事务生命周期
await self.auth.db.execute(text(sql))
return True
except Exception as e:
log.error(f"执行SQL时发生错误: {e}")
return False
class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, GenTableColumnSchema]):
"""代码生成业务表字段模块数据库操作层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD操作层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model=GenTableColumnModel, auth=auth)
async def get_gen_table_column_by_id(self, id: int, preload: list | None = None) -> GenTableColumnModel | None:
"""根据业务表字段ID获取业务表字段信息。
参数:
- id (int): 业务表字段ID。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- GenTableColumnModel | None: 业务表字段信息对象。
"""
return await self.get(id=id, preload=preload)
async def get_gen_table_column_list_by_table_id(self, table_id: int, preload: list | None = None) -> GenTableColumnModel | None:
"""根据业务表ID获取业务表字段列表信息。
参数:
- table_id (int): 业务表ID。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- GenTableColumnModel | None: 业务表字段列表信息对象。
"""
return await self.get(table_id=table_id, preload=preload)
async def list_gen_table_column_crud_by_table_id(self, table_id: int, order_by: list | None = None, preload: list | None = None) -> Sequence[GenTableColumnModel]:
"""根据业务表ID查询业务表字段列表。
参数:
- table_id (int): 业务表ID。
- order_by (list | None): 排序字段列表,每个元素为{"field": "字段名", "order": "asc" | "desc"}。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[GenTableColumnModel]: 业务表字段列表信息对象序列。
"""
return await self.list(search={"table_id": table_id}, order_by=order_by, preload=preload)
async def get_gen_db_table_columns_by_name(self, table_name: str | None) -> list[GenTableColumnOutSchema]:
"""
根据业务表名称获取业务表字段列表信息。
参数:
- table_name (str | None): 业务表名称。
返回:
- list[GenTableColumnOutSchema]: 业务表字段列表信息对象。
"""
# 检查表名是否为空
if not table_name:
raise ValueError("数据表名称不能为空")
try:
if settings.DATABASE_TYPE == "mysql":
query_sql = """
SELECT
c.column_name AS column_name,
c.column_comment AS column_comment,
c.column_type AS column_type,
c.character_maximum_length AS column_length,
c.column_default AS column_default,
c.ordinal_position AS sort,
(CASE WHEN c.column_key = 'PRI' THEN 1 ELSE 0 END) AS is_pk,
(CASE WHEN c.extra = 'auto_increment' THEN 1 ELSE 0 END) AS is_increment,
(CASE WHEN (c.is_nullable = 'NO' AND c.column_key != 'PRI') THEN 1 ELSE 0 END) AS is_nullable,
(CASE
WHEN c.column_name IN (
SELECT k.column_name
FROM information_schema.key_column_usage k
JOIN information_schema.table_constraints t
ON k.constraint_name = t.constraint_name
WHERE k.table_schema = c.table_schema
AND k.table_name = c.table_name
AND t.constraint_type = 'UNIQUE'
) THEN 1 ELSE 0
END) AS is_unique
FROM
information_schema.columns c
WHERE c.table_schema = (SELECT DATABASE())
AND c.table_name = :table_name
ORDER BY
c.ordinal_position
"""
else:
query_sql = """
SELECT
c.column_name AS column_name,
COALESCE(pgd.description, '') AS column_comment,
c.udt_name AS column_type,
c.character_maximum_length AS column_length,
c.column_default AS column_default,
c.ordinal_position AS sort,
(CASE WHEN EXISTS (
SELECT 1 FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = c.table_name
AND tc.constraint_type = 'PRIMARY KEY'
AND ccu.column_name = c.column_name
) THEN 1 ELSE 0 END) AS is_pk,
(CASE WHEN c.column_default LIKE 'nextval%' THEN 1 ELSE 0 END) AS is_increment,
(CASE WHEN c.is_nullable = 'NO' THEN 1 ELSE 0 END) AS is_nullable,
(CASE WHEN EXISTS (
SELECT 1 FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = c.table_name
AND tc.constraint_type = 'UNIQUE'
AND ccu.column_name = c.column_name
) THEN 1 ELSE 0 END) AS is_unique
FROM
information_schema.columns c
LEFT JOIN pg_catalog.pg_description pgd ON
pgd.objoid = (SELECT oid FROM pg_class WHERE relname = c.table_name)
AND pgd.objsubid = c.ordinal_position
WHERE c.table_catalog = current_database()
AND c.table_schema = 'public'
AND c.table_name = :table_name
ORDER BY
c.ordinal_position
"""
query = text(query_sql).bindparams(table_name=table_name)
result = await self.auth.db.execute(query)
rows = result.fetchall() if result else []
# 确保rows是可迭代对象
if not rows:
return []
columns_list = []
for row in rows:
# 防御性编程检查row是否有足够的元素
if len(row) >= 10:
columns_list.append(
GenTableColumnOutSchema(
column_name=row[0],
column_comment=row[1],
column_type=row[2],
column_length=str(row[3]) if row[3] is not None else '',
column_default=str(row[4]) if row[4] is not None else '',
sort=row[5],
is_pk=row[6],
is_increment=row[7],
is_nullable=row[8],
is_unique=row[9],
)
)
return columns_list
except Exception as e:
log.error(f"获取表{table_name}的字段列表时出错: {str(e)}")
# 确保即使出错也返回空列表而不是None
raise
async def list_gen_table_column_crud(self, search: dict | None = None, order_by: list | None = None, preload: list | None = None) -> Sequence[GenTableColumnModel]:
"""根据业务表字段查询业务表字段列表。
参数:
- search (dict | None): 查询参数,例如{"table_id": 1}。
- order_by (list | None): 排序字段列表,每个元素为{"field": "字段名", "order": "asc" | "desc"}。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[GenTableColumnModel]: 业务表字段列表信息对象序列。
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_gen_table_column_crud(self, data: GenTableColumnSchema) -> GenTableColumnModel | None:
"""创建业务表字段。
参数:
- data (GenTableColumnSchema): 业务表字段模型。
返回:
- GenTableColumnModel | None: 业务表字段列表信息对象。
"""
return await self.create(data=data)
async def update_gen_table_column_crud(self, id: int, data: GenTableColumnSchema) -> GenTableColumnModel | None:
"""更新业务表字段。
参数:
- id (int): 业务表字段ID。
- data (GenTableColumnSchema): 业务表字段模型。
返回:
- GenTableColumnModel | None: 业务表字段列表信息对象。
"""
# 将对象转换为字典避免SQLAlchemy直接操作对象时出现的状态问题
data_dict = data.model_dump(exclude_unset=True)
return await self.update(id=id, data=data_dict)
async def delete_gen_table_column_by_table_id_crud(self, table_ids: list[int]) -> None:
"""根据业务表ID批量删除业务表字段。
参数:
- table_ids (list[int]): 业务表ID列表。
返回:
- None
"""
# 先查询出这些表ID对应的所有字段ID
query = select(GenTableColumnModel.id).where(GenTableColumnModel.table_id.in_(table_ids))
result = await self.auth.db.execute(query)
column_ids = [row[0] for row in result.fetchall()]
# 如果有字段ID则删除这些字段
if column_ids:
await self.delete(ids=column_ids)
async def delete_gen_table_column_by_column_id_crud(self, column_ids: list[int]) -> None:
"""根据业务表字段ID批量删除业务表字段。
参数:
- column_ids (list[int]): 业务表字段ID列表。
返回:
- None
"""
return await self.delete(ids=column_ids)

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
from sqlalchemy import String, Integer, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from sqlalchemy.sql import expression
from app.config.setting import settings
from app.core.base_model import ModelMixin, UserMixin
from app.utils.common_util import SqlalchemyUtil
class GenTableModel(ModelMixin, UserMixin):
"""
代码生成表
"""
__tablename__: str = 'gen_table'
__table_args__: dict[str, str] = ({'comment': '代码生成表'})
__loader_options__: list[str] = ["columns", "created_by", "updated_by"]
table_name: Mapped[str] = mapped_column(String(200), nullable=False, default='', comment='表名称')
table_comment: Mapped[str | None] = mapped_column(String(500), nullable=True, comment='表描述')
class_name: Mapped[str] = mapped_column(String(100), nullable=False, default='', comment='实体类名称')
package_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='生成包路径')
module_name: Mapped[str | None] = mapped_column(String(30), nullable=True, comment='生成模块名')
business_name: Mapped[str | None] = mapped_column(String(30), nullable=True, comment='生成业务名')
function_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='生成功能名')
sub_table_name: Mapped[str | None] = mapped_column(
String(64),
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(settings.DATABASE_TYPE),
comment='关联子表的表名'
)
sub_table_fk_name: Mapped[str | None] = mapped_column(
String(64),
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(settings.DATABASE_TYPE),
comment='子表关联的外键名'
)
parent_menu_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment='父菜单ID')
# 关联关系
columns: Mapped[list['GenTableColumnModel']] = relationship(
order_by='GenTableColumnModel.sort',
back_populates='table',
cascade='all, delete-orphan'
)
@validates('table_name')
def validate_table_name(self, key: str, table_name: str) -> str:
"""验证表名不为空"""
if not table_name or not table_name.strip():
raise ValueError('表名称不能为空')
return table_name.strip()
@validates('class_name')
def validate_class_name(self, key: str, class_name: str) -> str:
"""验证类名不为空"""
if not class_name or not class_name.strip():
raise ValueError('实体类名称不能为空')
return class_name.strip()
class GenTableColumnModel(ModelMixin, UserMixin):
"""
代码生成表字段
数据隔离策略:
- 继承自GenTableModel的隔离级别
- 不需要customer_id
用于存储代码生成器的字段配置
"""
__tablename__: str = 'gen_table_column'
__table_args__: dict[str, str] = ({'comment': '代码生成表字段'})
__loader_options__: list[str] = ["created_by", "updated_by"]
# 数据库设计表字段
column_name: Mapped[str] = mapped_column(String(200), nullable=False, comment='列名称')
column_comment: Mapped[str | None] = mapped_column(String(500), nullable=True, comment='列描述')
column_type: Mapped[str] = mapped_column(String(100), nullable=False, comment='列类型')
column_length: Mapped[str | None] = mapped_column(String(50), nullable=True, comment='列长度')
column_default: Mapped[str | None] = mapped_column(String(200), nullable=True, comment='列默认值')
is_pk: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment='是否主键')
is_increment: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment='是否自增')
is_nullable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment='是否允许为空')
is_unique: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment='是否唯一')
# Python字段映射
python_type: Mapped[str | None] = mapped_column(String(100), nullable=True, comment='Python类型')
python_field: Mapped[str | None] = mapped_column(String(200), nullable=True, comment='Python字段名')
# 序列化配置
is_insert: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment='是否为新增字段')
is_edit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment='是否编辑字段')
is_list: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=expression.true(), comment='是否列表字段')
is_query: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=expression.false(), comment='是否查询字段')
query_type: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None, comment='查询方式')
# 前端展示配置
html_type: Mapped[str | None] = mapped_column(String(100), nullable=True, default='input', comment='显示类型')
dict_type: Mapped[str | None] = mapped_column(String(200), nullable=True, default='', comment='字典类型')
# 排序和扩展配置
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment='排序')
# 归属关系
table_id: Mapped[int] = mapped_column(
Integer,
ForeignKey('gen_table.id', ondelete='CASCADE'),
nullable=False,
index=True,
comment='归属表编号'
)
# 关联关系
table: Mapped['GenTableModel'] = relationship(back_populates='columns')
@validates('column_name')
def validate_column_name(self, key: str, column_name: str) -> str:
"""验证列名不为空"""
if not column_name or not column_name.strip():
raise ValueError('列名称不能为空')
return column_name.strip()
@validates('column_type')
def validate_column_type(self, key: str, column_type: str) -> str:
"""验证列类型不为空"""
if not column_type or not column_type.strip():
raise ValueError('列类型不能为空')
return column_type.strip()

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field, field_validator
from fastapi import Query
from app.core.base_schema import BaseSchema
class GenDBTableSchema(BaseModel):
"""数据库中的表信息(跨方言统一结构)。
- 供“导入表结构”与“同步结构”环节使用。
"""
model_config = ConfigDict(from_attributes=True)
database_name: str | None = Field(default=None, description='数据库名称')
table_name: str | None = Field(default=None, description='表名称')
table_type: str | None = Field(default=None, description='表类型')
table_comment: str | None = Field(default=None, description='表描述')
class GenTableColumnSchema(BaseModel):
"""代码生成业务表字段创建模型(原始字段+生成配置)。
- 从根本上解决问题所有字段都设置了合理的默认值避免None值问题
"""
model_config = ConfigDict(from_attributes=True)
table_id: int = Field(default=0, description='归属表编号')
column_name: str = Field(default='', description='列名称')
column_comment: str | None = Field(default='', description='列描述')
column_type: str = Field(default='varchar(255)', description='列类型')
column_length: str | None = Field(default='', description='列长度')
column_default: str | None = Field(default='', description='列默认值')
is_pk: bool = Field(default=False, description='是否主键True是 False否')
is_increment: bool = Field(default=False, description='是否自增True是 False否')
is_nullable: bool = Field(default=True, description='是否允许为空True是 False否')
is_unique: bool = Field(default=False, description='是否唯一True是 False否')
python_type: str | None = Field(default='str', description='python类型')
python_field: str | None = Field(default='', description='python字段名')
is_insert: bool = Field(default=True, description='是否为插入字段True是 False否')
is_edit: bool = Field(default=True, description='是否编辑字段True是 False否')
is_list: bool = Field(default=True, description='是否列表字段True是 False否')
is_query: bool = Field(default=True, description='是否查询字段True是 False否')
query_type: str | None = Field(default=None, description='查询方式(等于、不等于、大于、小于、范围)')
html_type: str | None = Field(default='input', description='显示类型(文本框、文本域、下拉框、复选框、单选框、日期控件)')
dict_type: str | None = Field(default='', description='字典类型')
sort: int = Field(default=0, description='排序')
class GenTableColumnOutSchema(GenTableColumnSchema, BaseSchema):
"""
业务表字段输出模型
"""
model_config = ConfigDict(from_attributes=True)
super_column: str | None = Field(default='0', description='是否为基类字段1是 0否')
class GenTableSchema(BaseModel):
"""代码生成业务表更新模型(扩展聚合字段)。
- 聚合:`columns`字段包含字段列表;`pk_column`主键字段;子表结构`sub_table`。
"""
"""代码生成业务表基础模型(创建/更新共享字段)。
- 说明:`params`为前端结构体,后端持久化为`options`的JSON。
"""
model_config = ConfigDict(from_attributes=True)
table_name: str= Field(..., description='表名称')
table_comment: str | None = Field(default=None, description='表描述')
class_name: str | None = Field(default=None, description='实体类名称')
package_name: str | None = Field(default=None, description='生成包路径')
module_name: str | None = Field(default=None, description='生成模块名')
business_name: str | None = Field(default=None, description='生成业务名')
function_name: str | None = Field(default=None, description='生成功能名')
sub_table_name: str | None = Field(default=None, description='关联子表的表名')
sub_table_fk_name: str | None = Field(default=None, description='子表关联的外键名')
parent_menu_id: int | None = Field(default=None, description='所属父级分类,生成页面时候生成菜单使用')
description: str | None = Field(default=None, max_length=255, description="描述")
columns: list['GenTableColumnOutSchema'] | None = Field(default=None, description='表列信息')
@field_validator('table_name')
@classmethod
def table_name_update(cls, v: str) -> str:
"""更新表名称"""
if not v:
raise ValueError('表名称不能为空')
return v
class GenTableOutSchema(GenTableSchema, BaseSchema):
"""业务表输出模型(面向控制器/前端)。
"""
model_config = ConfigDict(from_attributes=True)
pk_column: GenTableColumnOutSchema | None = Field(default=None, description='主键信息')
sub_table: GenTableSchema | None = Field(default=None, description='子表信息')
sub: bool | None = Field(default=None, description='是否为子表')
class GenTableQueryParam:
"""代码生成业务表查询参数
- 支持按`table_name`、`table_comment`进行模糊检索由CRUD层实现like
- 空值将被忽略,不参与过滤。
"""
def __init__(
self,
table_name: str | None = Query(None, description="表名称"),
table_comment: str | None = Query(None, description="表注释"),
) -> None:
# 模糊查询字段
self.table_name = table_name
self.table_comment = table_comment
class GenTableColumnQueryParam:
"""代码生成业务表字段查询参数
- `column_name`按like规则模糊查询透传到CRUD层
"""
def __init__(
self,
column_name: str | None = Query(None, description="列名称"),
) -> None:
# 模糊查询字段:约定("like", 值)格式便于CRUD解析
self.column_name = ("like", column_name)

View File

@@ -0,0 +1,573 @@
# -*- coding: utf-8 -*-
import io
import os
from pathlib import Path
import zipfile
from typing import Any
from sqlglot.expressions import Add, Alter, Create, Delete, Drop, Expression, Insert, Table, TruncateTable, Update
from sqlglot import parse as sqlglot_parse
from app.config.path_conf import BASE_DIR
from app.config.setting import settings
from app.core.logger import log
from app.core.exceptions import CustomException
from app.api.v1.module_system.auth.schema import AuthSchema
from .tools.jinja2_template_util import Jinja2TemplateUtil
from .tools.gen_util import GenUtils
from .schema import GenTableSchema, GenTableOutSchema, GenTableColumnSchema, GenTableColumnOutSchema, GenTableQueryParam
from .crud import GenTableColumnCRUD, GenTableCRUD
def handle_service_exception(func):
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f'{func.__name__}执行失败: {str(e)}')
return wrapper
class GenTableService:
"""代码生成业务表服务层"""
@classmethod
@handle_service_exception
async def get_gen_table_detail_service(cls, auth: AuthSchema, table_id: int) -> dict:
"""获取业务表详细信息(含字段与其他表列表)。
- 备注:优先解析`options`为`GenTableOptionSchema`,设置`parent_menu_id`等选项;保证`columns`与`tables`结构完整。
"""
gen_table = await cls.get_gen_table_by_id_service(auth, table_id)
return GenTableOutSchema.model_validate(gen_table).model_dump()
@classmethod
@handle_service_exception
async def get_gen_table_list_service(cls, auth: AuthSchema, search: GenTableQueryParam) -> list[dict]:
"""
获取代码生成业务表列表信息。
参数:
- auth (AuthSchema): 认证信息。
- search (GenTableQueryParam): 查询参数模型。
返回:
- list[dict]: 包含业务表列表信息的字典列表。
"""
gen_table_list_result = await GenTableCRUD(auth=auth).get_gen_table_list(search)
return [GenTableOutSchema.model_validate(obj).model_dump() for obj in gen_table_list_result]
@classmethod
@handle_service_exception
async def get_gen_db_table_list_service(cls, auth: AuthSchema, search: GenTableQueryParam) -> list[Any]:
"""获取数据库表列表(跨方言)。
- 备注:返回已转换为字典的结构,适用于前端直接展示;排序参数保留扩展位但当前未使用。
"""
gen_db_table_list_result = await GenTableCRUD(auth=auth).get_db_table_list(search)
return gen_db_table_list_result
@classmethod
@handle_service_exception
async def get_gen_db_table_list_by_name_service(cls, auth: AuthSchema, table_names: list[str]) -> list[GenTableOutSchema]:
"""根据表名称组获取数据库表信息。
- 校验:如有不存在的表名,抛出明确异常;返回统一的`GenTableOutSchema`列表。
"""
# 验证输入参数
if not table_names:
raise CustomException(msg="表名列表不能为空")
gen_db_table_list_result = await GenTableCRUD(auth).get_db_table_list_by_names(table_names)
# 修复将GenDBTableSchema对象转换为字典后再传递给GenTableOutSchema
result = []
for gen_table in gen_db_table_list_result:
# 确保table_name不为None
if gen_table.table_name is not None:
result.append(GenTableOutSchema(**gen_table.model_dump()))
return result
@classmethod
@handle_service_exception
async def import_gen_table_service(cls, auth: AuthSchema, gen_table_list: list[GenTableOutSchema]) -> bool | None:
"""导入表结构到生成器(持久化并初始化列)。
- 备注:避免重复导入;为每列调用`GenUtils.init_column_field`填充默认属性,保留语义一致性。
"""
# 检查是否有表需要导入
if not gen_table_list:
raise CustomException(msg="导入的表结构不能为空")
try:
for table in gen_table_list:
table_name = table.table_name
# 检查表是否已存在
existing_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name)
if existing_table:
raise CustomException(msg=f"以下表已存在,不能重复导入: {table_name}")
GenUtils.init_table(table)
if not table.columns:
table.columns = []
add_gen_table = await GenTableCRUD(auth).add_gen_table(GenTableSchema.model_validate(table.model_dump()))
gen_table_columns = await GenTableColumnCRUD(auth).get_gen_db_table_columns_by_name(table_name)
if len(gen_table_columns) > 0:
table.id = add_gen_table.id
for column in gen_table_columns:
column_schema = GenTableColumnSchema(
table_id=table.id,
column_name=column.column_name,
column_comment=column.column_comment,
column_type=column.column_type,
column_length=column.column_length,
column_default=column.column_default,
is_pk=column.is_pk,
is_increment=column.is_increment,
is_nullable=column.is_nullable,
is_unique=column.is_unique,
sort=column.sort,
python_type=column.python_type,
python_field=column.python_field,
)
GenUtils.init_column_field(column_schema, table)
await GenTableColumnCRUD(auth).create_gen_table_column_crud(column_schema)
return True
except Exception as e:
raise CustomException(msg=f'导入失败, {str(e)}')
@classmethod
@handle_service_exception
async def create_table_service(cls, auth: AuthSchema, sql: str) -> bool | None:
"""创建表结构并导入至代码生成模块。
- 校验:使用`sqlglot`确保仅包含`CREATE TABLE`语句;失败抛出明确异常。
- 唯一性检查:在创建前检查该表是否已存在于数据库中。
"""
# 验证SQL非空
if not sql or not sql.strip():
raise CustomException(msg='SQL语句不能为空')
try:
# 解析SQL语句
sql_statements = sqlglot_parse(sql, dialect=settings.DATABASE_TYPE)
if not sql_statements:
raise CustomException(msg='无法解析SQL语句请检查SQL语法')
# 校验sql语句是否为合法的建表语句
if not cls.__is_valid_create_table(sql_statements):
raise CustomException(msg='sql语句不是合法的建表语句')
# 获取要创建的表名
table_names = cls.__get_table_names(sql_statements)
# 创建CRUD实例
gen_table_crud = GenTableCRUD(auth=auth)
# 检查每个表是否已存在
for table_name in table_names:
# 检查数据库中是否已存在该表
if await gen_table_crud.check_table_exists(table_name):
raise CustomException(msg=f'{table_name} 已存在,请检查并修改表名后重试')
# 检查代码生成模块中是否已导入该表
existing_table = await gen_table_crud.get_gen_table_by_name(table_name)
if existing_table:
raise CustomException(msg=f'{table_name} 已在代码生成模块中存在,请检查并修改表名后重试')
# 表不存在执行SQL语句创建表
result = await gen_table_crud.create_table_by_sql(sql_statements)
if not result:
raise CustomException(msg=f'创建表 {table_names} 失败请检查SQL语句')
# 导入表结构到代码生成模块 - 简化逻辑移除多余的None检查
gen_table_list = await cls.get_gen_db_table_list_by_name_service(auth, table_names)
import_result = await cls.import_gen_table_service(auth, gen_table_list)
return import_result
except Exception as e:
raise CustomException(msg=f'创建表结构失败: {str(e)}')
@classmethod
@handle_service_exception
async def execute_sql_service(cls, auth: AuthSchema, gen_table: GenTableOutSchema) -> bool:
"""
执行菜单 SQLINSERT / DO 块)并写入 sys_menu。
- 仅处理菜单 SQL不再混杂建表逻辑
- 文件不存在时给出友好提示;
- 统一异常信息,日志与业务提示分离。
"""
sql_path = f'{BASE_DIR}/sql/menu/{gen_table.module_name}/{gen_table.business_name}.sql'
# 文件存在性前置检查,避免多余解析开销
if not os.path.isfile(sql_path):
raise CustomException(msg=f'菜单 SQL 文件不存在: {sql_path}')
sql = Path(sql_path).read_text(encoding='utf-8').strip()
if not sql:
raise CustomException(msg='菜单 SQL 文件内容为空')
# 仅做语法校验,不限制关键字;真正的语义安全由数据库权限控制
try:
statements = sqlglot_parse(sql, dialect=settings.DATABASE_TYPE)
if not statements:
raise CustomException(msg='菜单 SQL 语法解析失败,请检查文件内容')
except Exception as e:
log.error(f'菜单 SQL 解析异常: {e}')
raise CustomException(msg='菜单 SQL 语法错误,请检查文件内容')
# 执行 SQL
try:
await GenTableCRUD(auth).execute_sql(sql)
log.info(f'成功执行菜单 SQL: {sql_path}')
return True
except Exception as e:
log.error(f'菜单 SQL 执行失败: {e}')
raise CustomException(msg='菜单 SQL 执行失败,请确认语句及数据库状态')
@classmethod
def __is_valid_create_table(cls, sql_statements: list[Expression | None]) -> bool:
"""
校验SQL语句是否为合法的建表语句。
参数:
- sql_statements (list[Expression | None]): SQL的AST列表。
返回:
- bool: 校验结果。
"""
validate_create = [isinstance(sql_statement, Create) for sql_statement in sql_statements]
validate_forbidden_keywords = [
isinstance(
sql_statement,
(Add, Alter, Delete, Drop, Insert, TruncateTable, Update),
)
for sql_statement in sql_statements
]
if not any(validate_create) or any(validate_forbidden_keywords):
return False
return True
@classmethod
def __get_table_names(cls, sql_statements: list[Expression | None]) -> list[str]:
"""
获取SQL语句中所有的建表表名。
参数:
- sql_statements (list[Expression | None]): SQL的AST列表。
返回:
- list[str]: 建表表名列表。
"""
table_names = []
for sql_statement in sql_statements:
if isinstance(sql_statement, Create):
table = sql_statement.find(Table)
if table and table.name:
table_names.append(table.name)
return list(set(table_names))
@classmethod
@handle_service_exception
async def update_gen_table_service(cls, auth: AuthSchema, data: GenTableSchema, table_id: int) -> dict[str, Any]:
"""编辑业务表信息(含选项与字段)。
- 备注:将`params`序列化写入`options`以持久化;仅更新存在`id`的列,避免误创建。
"""
# 处理params为None的情况
gen_table_info = await cls.get_gen_table_by_id_service(auth, table_id)
if gen_table_info.id:
try:
# 直接调用edit_gen_table方法它会在内部处理排除嵌套字段的逻辑
result = await GenTableCRUD(auth).edit_gen_table(table_id, data)
# 处理data.columns为None的情况
if data.columns:
for gen_table_column in data.columns:
# 确保column有id字段
if hasattr(gen_table_column, 'id') and gen_table_column.id:
column_schema = GenTableColumnSchema(**gen_table_column.model_dump())
await GenTableColumnCRUD(auth).update_gen_table_column_crud(gen_table_column.id, column_schema)
return GenTableOutSchema.model_validate(result).model_dump()
except Exception as e:
raise CustomException(msg=str(e))
else:
raise CustomException(msg='业务表不存在')
@classmethod
@handle_service_exception
async def delete_gen_table_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除业务表信息(先删字段,再删表)。"""
# 验证ID列表非空
if not ids:
raise CustomException(msg="ID列表不能为空")
try:
# 先删除相关的字段信息
await GenTableColumnCRUD(auth=auth).delete_gen_table_column_by_table_id_crud(ids)
# 再删除表信息
await GenTableCRUD(auth=auth).delete_gen_table(ids)
except Exception as e:
raise CustomException(msg=str(e))
@classmethod
@handle_service_exception
async def get_gen_table_by_id_service(cls, auth: AuthSchema, table_id: int) -> GenTableOutSchema:
"""获取需要生成代码的业务表详细信息。
- 备注去除SQLAlchemy内部状态将`None`值转为适配前端的默认值;解析`options`补充选项。
"""
gen_table = await GenTableCRUD(auth=auth).get_gen_table_by_id(table_id)
if not gen_table:
raise CustomException(msg='业务表不存在')
result = GenTableOutSchema.model_validate(gen_table)
return result
@classmethod
@handle_service_exception
async def get_gen_table_all_service(cls, auth: AuthSchema) -> list[GenTableOutSchema]:
"""获取所有业务表信息(列表)。"""
gen_table_all = await GenTableCRUD(auth=auth).get_gen_table_all() or []
result = []
for gen_table in gen_table_all:
try:
table_out = GenTableOutSchema.model_validate(gen_table)
result.append(table_out)
except Exception as e:
log.error(f"转换业务表时出错: {str(e)}")
continue
return result
@classmethod
@handle_service_exception
async def preview_code_service(cls, auth: AuthSchema, table_id: int) -> dict[str, Any]:
"""
预览代码(根据模板渲染内存结果)。
- 备注构建Jinja2上下文根据模板类型与前端类型选择模板清单返回文件名到内容映射。
"""
gen_table = GenTableOutSchema.model_validate(
await GenTableCRUD(auth).get_gen_table_by_id(table_id)
)
await cls.set_pk_column(gen_table)
env = Jinja2TemplateUtil.get_env()
context = Jinja2TemplateUtil.prepare_context(gen_table)
template_list = Jinja2TemplateUtil.get_template_list()
preview_code_result = {}
for template in template_list:
try:
render_content = await env.get_template(template).render_async(**context)
preview_code_result[template] = render_content
except Exception as e:
log.error(f"渲染模板 {template} 时出错: {str(e)}")
# 即使某个模板渲染失败,也继续处理其他模板
preview_code_result[template] = f"渲染错误: {str(e)}"
return preview_code_result
@classmethod
@handle_service_exception
async def generate_code_service(cls, auth: AuthSchema, table_name: str) -> bool:
"""生成代码至指定路径(安全写入+可跳过覆盖)。
- 安全:限制写入在项目根目录内;越界路径自动回退到项目根目录。
"""
# 验证表名非空
if not table_name or not table_name.strip():
raise CustomException(msg='表名不能为空')
env = Jinja2TemplateUtil.get_env()
render_info = await cls.__get_gen_render_info(auth, table_name)
gen_table_schema = render_info[3]
for template in render_info[0]:
try:
render_content = await env.get_template(template).render_async(**render_info[2])
gen_path = cls.__get_gen_path(gen_table_schema, template)
if not gen_path:
raise CustomException(msg='【代码生成】生成路径为空')
# 确保目录存在
os.makedirs(os.path.dirname(gen_path), exist_ok=True)
with open(gen_path, 'w', encoding='utf-8') as f:
f.write(render_content)
except Exception as e:
raise CustomException(msg=f'渲染模板失败,表名:{gen_table_schema.table_name},详细错误信息:{str(e)}')
await cls.execute_sql_service(auth, gen_table_schema)
return True
@classmethod
@handle_service_exception
async def batch_gen_code_service(cls, auth: AuthSchema, table_names: list[str]) -> bytes:
"""
批量生成代码并打包为ZIP。
- 备注:内存生成并压缩,兼容多模板类型;供下载使用。
"""
# 验证表名列表非空
if not table_names:
raise CustomException(msg="表名列表不能为空")
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for table_name in table_names:
if not table_name.strip():
continue
try:
env = Jinja2TemplateUtil.get_env()
render_info = await cls.__get_gen_render_info(auth, table_name)
for template_file, output_file in zip(render_info[0], render_info[1]):
render_content = await env.get_template(template_file).render_async(**render_info[2])
zip_file.writestr(output_file, render_content)
except Exception as e:
log.error(f"批量生成代码时处理表 {table_name} 出错: {str(e)}")
# 继续处理其他表,不中断整个过程
continue
zip_data = zip_buffer.getvalue()
zip_buffer.close()
return zip_data
@classmethod
@handle_service_exception
async def sync_db_service(cls, auth: AuthSchema, table_name: str) -> None:
"""同步数据库表结构至生成器(保留用户配置)。
- 备注:按数据库实际字段重建或更新生成器字段;保留字典/查询/展示等用户自定义属性;清理已删除字段。
"""
# 验证表名非空
if not table_name or not table_name.strip():
raise CustomException(msg='表名不能为空')
gen_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name)
if not gen_table:
raise CustomException(msg='业务表不存在')
table = GenTableOutSchema.model_validate(gen_table)
if not table.id:
raise CustomException(msg='业务表ID不能为空')
table_columns = table.columns or []
table_column_map = {column.column_name: column for column in table_columns}
# 确保db_table_columns始终是列表类型避免None值
db_table_columns = await GenTableColumnCRUD(auth).get_gen_db_table_columns_by_name(table_name) or []
db_table_columns = [col for col in db_table_columns if col is not None]
db_table_column_names = [column.column_name for column in db_table_columns]
try:
for column in db_table_columns:
# 仅在缺省时初始化默认属性(包含 table_id 关联)
GenUtils.init_column_field(column, table)
# 利用schema层的默认值移除多余的None检查
if column.column_name in table_column_map:
prev_column = table_column_map[column.column_name]
# 复用旧记录ID确保执行更新
if hasattr(prev_column, 'id') and prev_column.id:
column.id = prev_column.id
# 保留用户配置的显示与查询属性 - 使用getattr确保安全访问
if hasattr(prev_column, 'dict_type') and prev_column.dict_type:
column.dict_type = prev_column.dict_type
if hasattr(prev_column, 'query_type') and prev_column.query_type:
column.query_type = prev_column.query_type
if hasattr(prev_column, 'html_type') and prev_column.html_type:
column.html_type = prev_column.html_type
# 保留关键用户自定义属性 - 安全处理is_pk
is_pk_bool = False
if hasattr(prev_column, 'is_pk'):
# 处理不同类型的is_pk值
if isinstance(prev_column.is_pk, bool):
is_pk_bool = prev_column.is_pk
else:
is_pk_bool = str(prev_column.is_pk) == '1'
# 安全处理nullable属性
if hasattr(prev_column, 'is_nullable') and not is_pk_bool:
column.is_nullable = prev_column.is_nullable
# 保留其他重要用户设置
if hasattr(prev_column, 'python_field'):
column.python_field = prev_column.python_field or column.python_field
if hasattr(column, 'id') and column.id:
await GenTableColumnCRUD(auth).update_gen_table_column_crud(column.id, column)
else:
await GenTableColumnCRUD(auth).create_gen_table_column_crud(column)
else:
# 设置table_id以确保新字段能正确关联到表
column.table_id = table.id
await GenTableColumnCRUD(auth).create_gen_table_column_crud(column)
del_columns = [column for column in table_columns if column.column_name not in db_table_column_names]
if del_columns:
for column in del_columns:
if hasattr(column, 'id') and column.id:
await GenTableColumnCRUD(auth).delete_gen_table_column_by_column_id_crud([column.id])
except Exception as e:
raise CustomException(msg=f'同步失败: {str(e)}')
@classmethod
async def set_pk_column(cls, gen_table: GenTableOutSchema) -> None:
"""设置主键列信息(主表/子表)。
- 备注:同时兼容`pk`布尔与`is_pk == '1'`字符串两种标识。
"""
if gen_table.columns:
for column in gen_table.columns:
# 修复:确保正确检查主键标识
if getattr(column, 'pk', False) or getattr(column, 'is_pk', '') == '1':
gen_table.pk_column = column
break
# 如果没有找到主键列且有列存在,使用第一个列作为主键
if gen_table.pk_column is None and gen_table.columns:
gen_table.pk_column = gen_table.columns[0]
@classmethod
async def __get_gen_render_info(cls, auth: AuthSchema, table_name: str) -> list[Any]:
"""
获取生成代码渲染模板相关信息。
参数:
- auth (AuthSchema): 认证对象。
- table_name (str): 业务表名称。
返回:
- list[Any]: [模板列表, 输出文件名列表, 渲染上下文, 业务表对象]。
异常:
- CustomException: 当业务表不存在或数据转换失败时抛出。
"""
gen_table_model = await GenTableCRUD(auth=auth).get_gen_table_by_name(table_name)
# 检查表是否存在
if gen_table_model is None:
raise CustomException(msg=f"业务表 {table_name} 不存在")
gen_table = GenTableOutSchema.model_validate(gen_table_model)
await cls.set_pk_column(gen_table)
context = Jinja2TemplateUtil.prepare_context(gen_table)
template_list = Jinja2TemplateUtil.get_template_list()
output_files = [Jinja2TemplateUtil.get_file_name(template, gen_table) for template in template_list]
return [template_list, output_files, context, gen_table]
@classmethod
def __get_gen_path(cls, gen_table: GenTableOutSchema, template: str) -> str | None:
"""根据GenTableOutSchema对象和模板名称生成路径。"""
try:
file_name = Jinja2TemplateUtil.get_file_name(template, gen_table)
# 默认写入到项目根目录backend的上一级
project_root = str(BASE_DIR.parent)
full_path = os.path.join(project_root, file_name)
# 确保路径在项目根目录内,防止路径遍历攻击
if not os.path.abspath(full_path).startswith(os.path.abspath(project_root)):
log.error(f"路径越界,回退到项目根目录: {file_name}")
# 回退到项目根目录下的generated文件夹
full_path = os.path.join(project_root, "generated", os.path.basename(file_name))
return full_path
except Exception as e:
log.error(f"生成路径时出错: {str(e)}")
return None
class GenTableColumnService:
"""代码生成业务表字段服务层"""
@classmethod
@handle_service_exception
async def get_gen_table_column_list_by_table_id_service(cls, auth: AuthSchema, table_id: int) -> list[dict[str, Any]]:
"""获取业务表字段列表信息(输出模型)。"""
gen_table_column_list_result = await GenTableColumnCRUD(auth).list_gen_table_column_crud({"table_id": table_id})
result = [GenTableColumnOutSchema.model_validate(gen_table_column).model_dump() for gen_table_column in gen_table_column_list_result]
return result

View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, UploadFile, Body, Path, Query
from fastapi.responses import StreamingResponse, JSONResponse
from app.common.response import SuccessResponse, StreamResponse
from app.core.dependencies import AuthPermission
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_params import PaginationQueryParam
from app.utils.common_util import bytes2file_response
from app.core.logger import log
from app.core.base_schema import BatchSetAvailable
from .service import {{ class_name }}Service
from .schema import {{ class_name }}CreateSchema, {{ class_name }}UpdateSchema, {{ class_name }}QueryParam
{{ class_name }}Router = APIRouter(prefix='/{{ business_name }}', tags=["{{ function_name }}模块"])
@{{ class_name }}Router.get("/detail/{id}", summary="获取{{ function_name }}详情", description="获取{{ function_name }}详情")
async def get_{{ business_name }}_detail_controller(
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:query"]))
) -> JSONResponse:
"""获取{{ function_name }}详情接口"""
result_dict = await {{ class_name }}Service.detail_{{ business_name }}_service(auth=auth, id=id)
log.info(f"获取{{ function_name }}详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取{{ function_name }}详情成功")
@{{ class_name }}Router.get("/list", summary="查询{{ function_name }}列表", description="查询{{ function_name }}列表")
async def get_{{ business_name }}_list_controller(
page: PaginationQueryParam = Depends(),
search: {{ class_name }}QueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:query"]))
) -> JSONResponse:
"""查询{{ function_name }}列表接口(数据库分页)"""
result_dict = await {{ class_name }}Service.page_{{ business_name }}_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=page.order_by
)
log.info("查询{{ function_name }}列表成功")
return SuccessResponse(data=result_dict, msg="查询{{ function_name }}列表成功")
@{{ class_name }}Router.post("/create", summary="创建{{ function_name }}", description="创建{{ function_name }}")
async def create_{{ business_name }}_controller(
data: {{ class_name }}CreateSchema,
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:create"]))
) -> JSONResponse:
"""创建{{ function_name }}接口"""
result_dict = await {{ class_name }}Service.create_{{ business_name }}_service(auth=auth, data=data)
log.info("创建{{ function_name }}成功")
return SuccessResponse(data=result_dict, msg="创建{{ function_name }}成功")
@{{ class_name }}Router.put("/update/{id}", summary="修改{{ function_name }}", description="修改{{ function_name }}")
async def update_{{ business_name }}_controller(
data: {{ class_name }}UpdateSchema,
id: int = Path(..., description="ID"),
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:update"]))
) -> JSONResponse:
"""修改{{ function_name }}接口"""
result_dict = await {{ class_name }}Service.update_{{ business_name }}_service(auth=auth, id=id, data=data)
log.info("修改{{ function_name }}成功")
return SuccessResponse(data=result_dict, msg="修改{{ function_name }}成功")
@{{ class_name }}Router.delete("/delete", summary="删除{{ function_name }}", description="删除{{ function_name }}")
async def delete_{{ business_name }}_controller(
ids: list[int] = Body(..., description="ID列表"),
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:delete"]))
) -> JSONResponse:
"""删除{{ function_name }}接口"""
await {{ class_name }}Service.delete_{{ business_name }}_service(auth=auth, ids=ids)
log.info(f"删除{{ function_name }}成功: {ids}")
return SuccessResponse(msg="删除{{ function_name }}成功")
@{{ class_name }}Router.patch("/available/setting", summary="批量修改{{ function_name }}状态", description="批量修改{{ function_name }}状态")
async def batch_set_available_{{ business_name }}_controller(
data: BatchSetAvailable,
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:patch"]))
) -> JSONResponse:
"""批量修改{{ function_name }}状态接口"""
await {{ class_name }}Service.set_available_{{ business_name }}_service(auth=auth, data=data)
log.info(f"批量修改{{ function_name }}状态成功: {data.ids}")
return SuccessResponse(msg="批量修改{{ function_name }}状态成功")
@{{ class_name }}Router.post('/export', summary="导出{{ function_name }}", description="导出{{ function_name }}")
async def export_{{ business_name }}_list_controller(
search: {{ class_name }}QueryParam = Depends(),
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:export"]))
) -> StreamingResponse:
"""导出{{ function_name }}接口"""
result_dict_list = await {{ class_name }}Service.list_{{ business_name }}_service(search=search, auth=auth)
export_result = await {{ class_name }}Service.batch_export_{{ business_name }}_service(obj_list=result_dict_list)
log.info('导出{{ function_name }}成功')
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename={{ table_name }}.xlsx'
}
)
@{{ class_name }}Router.post('/import', summary="导入{{ function_name }}", description="导入{{ function_name }}")
async def import_{{ business_name }}_list_controller(
file: UploadFile,
auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:import"]))
) -> JSONResponse:
"""导入{{ function_name }}接口"""
batch_import_result = await {{ class_name }}Service.batch_import_{{ business_name }}_service(file=file, auth=auth, update_support=True)
log.info("导入{{ function_name }}成功")
return SuccessResponse(data=batch_import_result, msg="导入{{ function_name }}成功")
@{{ class_name }}Router.post('/download/template', summary="获取{{ function_name }}导入模板", description="获取{{ function_name }}导入模板", dependencies=[Depends(AuthPermission(["{{ permission_prefix }}:download"]))])
async def export_{{ business_name }}_template_controller() -> StreamingResponse:
"""获取{{ function_name }}导入模板接口"""
import_template_result = await {{ class_name }}Service.import_template_download_{{ business_name }}_service()
log.info('获取{{ function_name }}导入模板成功')
return StreamResponse(
data=bytes2file_response(import_template_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename={{ table_name }}_template.xlsx'}
)

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
from typing import Sequence
from app.core.base_crud import CRUDBase
from app.api.v1.module_system.auth.schema import AuthSchema
from .model import {{ class_name }}Model
from .schema import {{ class_name }}CreateSchema, {{ class_name }}UpdateSchema, {{ class_name }}OutSchema
class {{ class_name }}CRUD(CRUDBase[{{ class_name }}Model, {{ class_name }}CreateSchema, {{ class_name }}UpdateSchema]):
"""{{ function_name }}数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化CRUD数据层
参数:
- auth (AuthSchema): 认证信息模型
"""
super().__init__(model={{ class_name }}Model, auth=auth)
async def get_by_id_{{ business_name }}_crud(self, id: int, preload: list | None = None) -> {{ class_name }}Model | None:
"""
详情
参数:
- id (int): 对象ID
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- {{ class_name }}Model | None: 模型实例或None
"""
return await self.get(id=id, preload=preload)
async def list_{{ business_name }}_crud(self, search: dict | None = None, order_by: list[dict] | None = None, preload: list | None = None) -> Sequence[{{ class_name }}Model]:
"""
列表查询
参数:
- search (dict | None): 查询参数
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[{{ class_name }}Model]: 模型实例序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_{{ business_name }}_crud(self, data: {{ class_name }}CreateSchema) -> {{ class_name }}Model | None:
"""
创建
参数:
- data ({{ class_name }}CreateSchema): 创建模型
返回:
- {{ class_name }}Model | None: 模型实例或None
"""
return await self.create(data=data)
async def update_{{ business_name }}_crud(self, id: int, data: {{ class_name }}UpdateSchema) -> {{ class_name }}Model | None:
"""
更新
参数:
- id (int): 对象ID
- data ({{ class_name }}UpdateSchema): 更新模型
返回:
- {{ class_name }}Model | None: 模型实例或None
"""
return await self.update(id=id, data=data)
async def delete_{{ business_name }}_crud(self, ids: list[int]) -> None:
"""
批量删除
参数:
- ids (list[int]): 对象ID列表
返回:
- None
"""
return await self.delete(ids=ids)
async def set_available_{{ business_name }}_crud(self, ids: list[int], status: str) -> None:
"""
批量设置可用状态
参数:
- ids (list[int]): 对象ID列表
- status (str): 可用状态
返回:
- None
"""
return await self.set(ids=ids, status=status)
async def page_{{ business_name }}_crud(self, offset: int, limit: int, order_by: list[dict] | None = None, search: dict | None = None, preload: list | None = None) -> dict:
"""
分页查询
参数:
- offset (int): 偏移量
- limit (int): 每页数量
- order_by (list[dict] | None): 排序参数,未提供时使用模型默认项
- search (dict | None): 查询参数,未提供时查询所有
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Dict: 分页数据
"""
order_by_list = order_by or [{'id': 'asc'}]
search_dict = search or {}
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema={{ class_name }}OutSchema,
preload=preload
)

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
{% for model_import in model_import_list %}
{{ model_import }}
{% endfor %}
{% if table.sub %}
from sqlalchemy.orm import relationship
{% endif %}
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class {{ class_name }}Model(ModelMixin, UserMixin):
"""
{{ function_name }}表
"""
__tablename__: str = '{{ table_name }}'
__table_args__: dict[str, str] = {'comment': '{{ function_name }}'}
__loader_options__: list[str] = ["created_by", "updated_by"]
{% for column in columns %}
{% if column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id'] %}
{{ column.column_name }}: Mapped[{{ column.python_type }} | None] = mapped_column({{ column.column_type|get_sqlalchemy_type }}, {% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}')
{% endif %}
{% endfor %}
{% if table.sub %}
{{ sub_class_name }}_list = relationship('{{ sub_class_name }}', back_populates='{{ business_name }}')
{% endif %}

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
{% if table.sub %}
from typing import List
{% endif %}
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
{% if table.created_time %}
from app.core.validator import DateTimeStr
{% endif %}
from app.core.base_schema import BaseSchema, UserBySchema
class {{ class_name }}CreateSchema(BaseModel):
"""
{{ function_name }}新增模型
"""
{% for column in columns %}
{% if column.column_name not in ['id', 'uuid', 'created_time', 'updated_time', 'created_id', 'updated_id'] %}
{% if column.column_name == 'status' %}
{{ column.column_name }}: {{ column.python_type }} = Field(default="0", description='{{ column.column_comment }}')
{% elif column.column_name == 'description' %}
{{ column.column_name }}: str | None = Field(default=None, max_length=255, description='{{ column.column_comment }}')
{% else %}
{{ column.column_name }}: {{ column.python_type }} = Field(default=..., description='{{ column.column_comment }}')
{% endif %}
{% endif %}
{% endfor %}
class {{ class_name }}UpdateSchema({{ class_name }}CreateSchema):
"""
{{ function_name }}更新模型
"""
...
class {{ class_name }}OutSchema({{ class_name }}CreateSchema, BaseSchema, UserBySchema):
"""
{{ function_name }}响应模型
"""
model_config = ConfigDict(from_attributes=True)
class {{ class_name }}QueryParam:
"""{{ function_name }}查询参数"""
def __init__(
self,
{% for column in columns %}
{% if column.query_type == 'LIKE' %}
{{ column.column_name }}: str | None = Query(None, description="{{ column.column_comment }}"),
{% endif %}
{% endfor %}
{% for column in columns %}
{% if column.query_type == 'EQ' and column.column_name not in ['created_time', 'updated_time'] %}
{{ column.column_name }}: {{ column.python_type }} | None = Query(None, description="{{ column.column_comment }}"),
{% endif %}
{% endfor %}
{% if table.created_time %}
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
{% endif %}
{% if table.updated_time %}
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
{% endif %}
) -> None:
{% for column in columns %}
{% if column.query_type == 'LIKE' %}
# 模糊查询字段
self.{{ column.column_name }} = ("like", {{ column.column_name }})
{% elif column.query_type == 'EQ' and column.column_name not in ['created_time', 'updated_time'] %}
# 精确查询字段
self.{{ column.column_name }} = {{ column.column_name }}
{% endif %}
{% endfor %}
{% if table.created_time %}
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = ("between", (created_time[0], created_time[1]))
{% endif %}
{% if table.updated_time %}
if updated_time and len(updated_time) == 2:
self.updated_time = ("between", (updated_time[0], updated_time[1]))
{% endif %}

View File

@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
import io
from fastapi import UploadFile
import pandas as pd
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from app.core.logger import log
from app.api.v1.module_system.auth.schema import AuthSchema
from .schema import {{ class_name }}CreateSchema, {{ class_name }}UpdateSchema, {{ class_name }}OutSchema, {{ class_name }}QueryParam
from .crud import {{ class_name }}CRUD
class {{ class_name }}Service:
"""
{{ function_name }}服务层
"""
@classmethod
async def detail_{{ business_name }}_service(cls, auth: AuthSchema, id: int) -> dict:
"""详情"""
obj = await {{ class_name }}CRUD(auth).get_by_id_{{ business_name }}_crud(id=id)
if not obj:
raise CustomException(msg="该数据不存在")
return {{ class_name }}OutSchema.model_validate(obj).model_dump()
@classmethod
async def list_{{ business_name }}_service(cls, auth: AuthSchema, search: {{ class_name }}QueryParam | None = None, order_by: list[dict] | None = None) -> list[dict]:
"""列表查询"""
search_dict = search.__dict__ if search else None
obj_list = await {{ class_name }}CRUD(auth).list_{{ business_name }}_crud(search=search_dict, order_by=order_by)
return [{{ class_name }}OutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def page_{{ business_name }}_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: {{ class_name }}QueryParam | None = None, order_by: list[dict] | None = None) -> dict:
"""分页查询(数据库分页)"""
search_dict = search.__dict__ if search else {}
order_by_list = order_by or [{'id': 'asc'}]
offset = (page_no - 1) * page_size
result = await {{ class_name }}CRUD(auth).page_{{ business_name }}_crud(
offset=offset,
limit=page_size,
order_by=order_by_list,
search=search_dict
)
return result
@classmethod
async def create_{{ business_name }}_service(cls, auth: AuthSchema, data: {{ class_name }}CreateSchema) -> dict:
"""创建"""
# 检查唯一性约束
{% for column in columns %}
{% if column.is_unique == '1' %}
obj = await {{ class_name }}CRUD(auth).get({{ column.column_name }}=data.{{ column.column_name }})
if obj:
raise CustomException(msg='创建失败,{{ column.column_comment }}已存在')
{% endif %}
{% endfor %}
obj = await {{ class_name }}CRUD(auth).create_{{ business_name }}_crud(data=data)
return {{ class_name }}OutSchema.model_validate(obj).model_dump()
@classmethod
async def update_{{ business_name }}_service(cls, auth: AuthSchema, id: int, data: {{ class_name }}UpdateSchema) -> dict:
"""更新"""
# 检查数据是否存在
obj = await {{ class_name }}CRUD(auth).get_by_id_{{ business_name }}_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
# 检查唯一性约束
{% for column in columns %}
{% if column.is_unique == '1' %}
exist_obj = await {{ class_name }}CRUD(auth).get({{ column.column_name }}=data.{{ column.column_name }})
if exist_obj and exist_obj.id != id:
raise CustomException(msg='更新失败,{{ column.column_comment }}重复')
{% endif %}
{% endfor %}
obj = await {{ class_name }}CRUD(auth).update_{{ business_name }}_crud(id=id, data=data)
return {{ class_name }}OutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_{{ business_name }}_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""删除"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
for id in ids:
obj = await {{ class_name }}CRUD(auth).get_by_id_{{ business_name }}_crud(id=id)
if not obj:
raise CustomException(msg=f'删除失败ID为{id}的数据不存在')
await {{ class_name }}CRUD(auth).delete_{{ business_name }}_crud(ids=ids)
@classmethod
async def set_available_{{ business_name }}_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
"""批量设置状态"""
await {{ class_name }}CRUD(auth).set_available_{{ business_name }}_crud(ids=data.ids, status=data.status)
@classmethod
async def batch_export_{{ business_name }}_service(cls, obj_list: list[dict]) -> bytes:
"""批量导出"""
mapping_dict = {
{% for column in columns %}
'{{ column.column_name }}': '{{ column.column_comment }}',
{% endfor %}
'updated_id': '更新者ID',
}
data = obj_list.copy()
for item in data:
# 状态转换
if 'status' in item:
item['status'] = '启用' if item.get('status') == '0' else '停用'
# 创建者转换
creator_info = item.get('creator')
if isinstance(creator_info, dict):
item['creator'] = creator_info.get('name', '未知')
elif creator_info is None:
item['creator'] = '未知'
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@classmethod
async def batch_import_{{ business_name }}_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str:
"""批量导入"""
header_dict = {
{% for column in columns %}
'{{ column.column_comment }}': '{{ column.column_name }}',
{% endfor %}
}
try:
contents = await file.read()
df = pd.read_excel(io.BytesIO(contents))
await file.close()
if df.empty:
raise CustomException(msg="导入文件为空")
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
df.rename(columns=header_dict, inplace=True)
# 验证必填字段
{% for column in columns %}
{% if column.required == '1' %}
errors = []
missing_rows = df[df['{{ column.column_name }}'].isnull()].index.tolist()
if missing_rows:
field_name = [k for k,v in header_dict.items() if v == field][0]
rows_str = "、".join([str(i+1) for i in missing_rows])
errors.append(f"{field_name}不能为空,第{rows_str}行")
if errors:
raise CustomException(msg=f"导入失败,以下行缺少必要字段:\n{'; '.join(errors)}")
{% endif %}
{% endfor %}
error_msgs = []
success_count = 0
count = 0
for index, row in df.iterrows():
count += 1
try:
data = {
{% for column in columns %}
"{{ column.column_name }}": row['{{ column.column_name }}'],
{% endfor %}
}
# 使用CreateSchema做校验后入库
create_schema = {{ class_name }}CreateSchema.model_validate(data)
# 检查唯一性约束
{% for column in columns %}
{% if column.is_unique == '1' %}
exists_obj = await {{ class_name }}CRUD(auth).get({{ column.column_name }}=create_schema.{{ column.column_name }})
if exists_obj:
if update_support:
await {{ class_name }}CRUD(auth).update(id=exists_obj.id, data=create_schema)
success_count += 1
else:
error_msgs.append(f"第{count}行: {{ column.column_comment }} {create_schema.{{ column.column_name }}} 已存在")
continue
{% endif %}
{% endfor %}
await {{ class_name }}CRUD(auth).create_{{ business_name }}_crud(data=create_schema)
success_count += 1
except Exception as e:
error_msgs.append(f"第{count}行: {str(e)}")
continue
result = f"成功导入 {success_count} 条数据"
if error_msgs:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except Exception as e:
log.error(f"批量导入失败: {str(e)}")
raise CustomException(msg=f"导入失败: {str(e)}")
@classmethod
async def import_template_download_{{ business_name }}_service(cls) -> bytes:
"""下载导入模板"""
header_list = [
{% for column in columns %}
'{{ column.column_comment }}',
{% endfor %}
]
selector_header_list = []
option_list = []
# 添加下拉选项
{% for column in columns %}
{% if column.html_type == 'select' and column.dict_type %}
selector_header_list.append('{{ column.column_comment }}')
option_list.append({'{{ column.column_comment }}': []})
{% endif %}
{% endfor %}
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list
)

View File

@@ -0,0 +1,64 @@
-- 统一的菜单 SQL兼容 MySQL / PostgreSQL对齐到 sys_menu 表结构
{# 布尔值与保留字列名处理 #}
{% set b_true = 1 if db_type == 'mysql' else true %}
{% set b_false = 0 if db_type == 'mysql' else false %}
{% set order_col = '`order`' if db_type == 'mysql' else '"order"' %}
{% set sys_menu = '`sys_menu`' if db_type == 'mysql' else '"sys_menu"' %}
{% set icon = "menu" %}
{% set set_uuid = "UUID()" if db_type == 'mysql' else "gen_random_uuid()" %}
{% if db_type == 'mysql' %}
-- 父菜单(类型=2菜单
INSERT INTO {{ sys_menu }}
(`name`, `type`, {{ order_col }}, `permission`, `icon`, `route_name`, `route_path`, `component_path`, `redirect`, `hidden`, `keep_alive`, `always_show`, `title`, `params`, `affix`, `parent_id`, `uuid`, `status`, `description`, `created_time`, `updated_time`)
VALUES
('{{ function_name }}', 2, 9999, '{{ permission_prefix }}:query', '{{ icon }}', '{{ business_name|snake_to_camel }}', '/{{ module_name }}/{{ business_name }}', '{{ module_name }}/{{ business_name }}/index', NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}', NULL, {{ b_false }}, {{ parent_menu_id }}, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW());
-- 获取父菜单IDMySQL
SELECT @parentId := LAST_INSERT_ID();
-- 按钮权限(类型=3按钮/权限)
INSERT INTO {{ sys_menu }}
(`name`, `type`, {{ order_col }}, `permission`, `icon`, `route_name`, `route_path`, `component_path`, `redirect`, `hidden`, `keep_alive`, `always_show`, `title`, `params`, `affix`, `parent_id`, `uuid`, `status`, `description`, `created_time`, `updated_time`)
VALUES
('{{ function_name }}查询', 3, 1, '{{ permission_prefix }}:query', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}查询', NULL, {{ b_false }}, @parentId, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}新增', 3, 2, '{{ permission_prefix }}:create', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}新增', NULL, {{ b_false }}, @parentId, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}修改', 3, 3, '{{ permission_prefix }}:update', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}修改', NULL, {{ b_false }}, @parentId, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}删除', 3, 4, '{{ permission_prefix }}:delete', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}删除', NULL, {{ b_false }}, @parentId, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}导出', 3, 5, '{{ permission_prefix }}:export', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}导出', NULL, {{ b_false }}, @parentId, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}导入', 3, 6, '{{ permission_prefix }}:import', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}导入', NULL, {{ b_false }}, @parentId, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}批量状态修改', 3, 7, '{{ permission_prefix }}:patch', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}批量状态修改', NULL, {{ b_false }}, @parentId, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}下载导入模板', 3, 8, '{{ permission_prefix }}:download', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}下载导入模板', NULL, {{ b_false }}, @parentId, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW());
{% elif db_type == 'postgres' %}
-- 菜单 SQLPostgreSQL DO 块方案)
DO $$
DECLARE
parent_id INTEGER;
BEGIN
-- 父菜单(类型=2菜单
INSERT INTO {{ sys_menu }}
(name, type, {{ order_col }}, permission, icon, route_name, route_path, component_path, redirect, hidden, keep_alive, always_show, title, params, affix, parent_id, uuid, status, description, created_time, updated_time )
VALUES
('{{ function_name }}', 2, 9999, '{{ permission_prefix }}:query', '{{ icon }}', '{{ business_name|snake_to_camel }}', '/{{ module_name }}/{{ business_name }}', '{{ module_name }}/{{ business_name }}/index', NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}', NULL, {{ b_false }}, {{ parent_menu_id }}, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW())
RETURNING id INTO parent_id;
-- 按钮权限(类型=3按钮/权限)
INSERT INTO {{ sys_menu }}
(name, type, {{ order_col }}, permission, icon, route_name, route_path, component_path, redirect, hidden, keep_alive, always_show, title, params, affix, parent_id, uuid, status, description, created_time, updated_time )
VALUES
('{{ function_name }}查询', 3, 1, '{{ permission_prefix }}:query', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}查询', NULL, {{ b_false }}, parent_id, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}新增', 3, 2, '{{ permission_prefix }}:create', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}新增', NULL, {{ b_false }}, parent_id, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}修改', 3, 3, '{{ permission_prefix }}:update', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}修改', NULL, {{ b_false }}, parent_id, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}删除', 3, 4, '{{ permission_prefix }}:delete', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}删除', NULL, {{ b_false }}, parent_id, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}导出', 3, 5, '{{ permission_prefix }}:export', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}导出', NULL, {{ b_false }}, parent_id, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}导入', 3, 6, '{{ permission_prefix }}:import', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}导入', NULL, {{ b_false }}, parent_id, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}批量状态修改', 3, 7, '{{ permission_prefix }}:patch', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}批量状态修改', NULL, {{ b_false }}, parent_id, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW()),
('{{ function_name }}下载导入模板', 3, 8, '{{ permission_prefix }}:download', NULL, NULL, NULL, NULL, NULL, {{ b_false }}, {{ b_true }}, {{ b_false }}, '{{ function_name }}下载导入模板', NULL, {{ b_false }}, parent_id, {{ set_uuid }}, '0', '{{ function_name }}菜单', NOW(), NOW());
-- 可选输出插入的父菜单ID调试用
RAISE NOTICE '{{ function_name }}菜单创建完成父菜单ID: %', parent_id;
END $$;
{% else %}
生成菜单 SQL 语句错误:{{ db_type }} 数据库不支持,请使用 mysql 或 postgres 数据库。
{% endif %}

View File

@@ -0,0 +1,137 @@
import request from "@/utils/request";
const API_PATH = "/{{ package_name }}/{{ business_name|lower }}";
const {{ class_name }}API = {
// 列表查询
list{{ class_name }}(query: {{ class_name }}PageQuery) {
return request<ApiResponse<PageResult<{{ class_name }}Table[]>>>({
url: `${API_PATH}/list`,
method: "get",
params: query,
});
},
// 详情查询
detail{{ class_name }}(id: number) {
return request<ApiResponse<{{ class_name }}Table>>({
url: `${API_PATH}/detail/${id}`,
method: "get",
});
},
// 新增
create{{ class_name }}(body: {{ class_name }}Form) {
return request<ApiResponse>({
url: `${API_PATH}/create`,
method: "post",
data: body,
});
},
// 修改(带主键)
update{{ class_name }}(id: number, body: {{ class_name }}Form) {
return request<ApiResponse>({
url: `${API_PATH}/update/${id}`,
method: "put",
data: body,
});
},
// 删除(支持批量)
delete{{ class_name }}(ids: number[]) {
return request<ApiResponse>({
url: `${API_PATH}/delete`,
method: "delete",
data: ids,
});
},
// 批量启用/停用
batch{{ class_name }}(body: BatchType) {
return request<ApiResponse>({
url: `${API_PATH}/available/setting`,
method: "patch",
data: body,
});
},
// 导出
export{{ class_name }}(query: {{ class_name }}PageQuery) {
return request<Blob>({
url: `${API_PATH}/export`,
method: "post",
data: query,
responseType: "blob",
});
},
// 下载导入模板
downloadTemplate{{ class_name }}() {
return request<Blob>({
url: `${API_PATH}/download/template`,
method: "post",
responseType: "blob",
});
},
// 导入
import{{ class_name }}(body: FormData) {
return request<ApiResponse>({
url: `${API_PATH}/import`,
method: "post",
data: body,
headers: { "Content-Type": "multipart/form-data" },
});
},
};
export default {{ class_name }}API;
// ------------------------------
// TS 类型声明
// ------------------------------
// 列表查询参数
export interface {{ class_name }}PageQuery extends PageQuery {
{% for column in columns %}
{% if column.is_query and column.column != "BETWEEN" and column.column_name not in ['created_time', 'updated_time'] %}
{{ column.column_name }}?: {{
'string' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio')
else 'number' if column.is_pk == '1'
else 'number' if column.column_name in ['created_id', 'updated_id']
else 'string'
}};
{% endif %}
{% endfor %}
created_time?: string[];
updated_time?: string[];
}
// 列表展示项
export interface {{ class_name }}Table extends BaseType{
{% for column in columns %}
{% if column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time'] %}
{{ column.column_name }}?: {{
'boolean' if ('status' in (column.column_name|lower)) or (column.html_type == 'radio')
else 'number' if column.is_pk == 1
else 'string'
}};
{% endif %}
{% endfor %}
created_by?: creatorType;
updated_by?: updatorType;
}
// 新增/修改/详情表单参数
export interface {{ class_name }}Form extends BaseFormType{
{% for column in columns %}
{% if (column.is_insert == 1 or column.is_edit == 1) and column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id'] %}
{{ column.column_name }}?: {{
'boolean' if ('status' in (column.column_name|lower)) or (column.html_type == 'radio')
else 'number' if column.is_pk == 1
else 'string'
}};
{% endif %}
{% endfor %}
}

View File

@@ -0,0 +1,858 @@
<!-- {{ function_name }} -->
<template>
<div class="app-container">
<!-- 搜索区域 -->
<div v-show="visible" class="search-container">
<el-form
ref="queryFormRef"
:model="queryFormData"
label-suffix=":"
:inline="true"
@submit.prevent="handleQuery"
>
{% for column in columns %}
{% if column.is_query == 1 %}
{% set dict_type = column.dict_type %}
{% set column_comment = column.column_comment if column.column_comment else '' %}
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.column_name == "status" %}
<el-form-item prop="status" label="状态">
<el-select
v-model="queryFormData.status"
placeholder="请选择状态"
style="width: 170px"
clearable
>
<el-option value="0" label="启用" />
<el-option value="1" label="停用" />
</el-select>
</el-form-item>
{% elif column.column_name == "created_id"%}
<el-form-item v-if="isExpand" prop="created_id" label="创建人">
<UserTableSelect
v-model="queryFormData.created_id"
@confirm-click="handleConfirm"
@clear-click="handleQuery"
/>
</el-form-item>
{% elif column.column_name == "updated_id"%}
<el-form-item v-if="isExpand" prop="updated_id" label="更新人">
<UserTableSelect
v-model="queryFormData.updated_id"
@confirm-click="handleConfirm"
@clear-click="handleQuery"
/>
</el-form-item>
{% elif column.column_name == "created_time"%}
<el-form-item v-if="isExpand" prop="created_time" label="创建时间">
<DatePicker v-model="createdDateRange" @update:model-value="handleCreatedDateRangeChange" />
</el-form-item>
{% elif column.column_name == "updated_time"%}
<el-form-item v-if="isExpand" prop="updated_time" label="更新时间">
<DatePicker v-model="updatedDateRange" @update:model-value="handleUpdatedDateRangeChange" />
</el-form-item>
{% elif column.html_type == "input" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}">
<el-input v-model="queryFormData.{{ column.column_name }}" placeholder="请输入{{ comment }}" clearable />
</el-form-item>
{% elif (column.html_type == "select" or column.html_type == "radio") and dict_type != "" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}">
<el-select v-model="queryFormData.{{ column.column_name }}" placeholder="请选择{{ comment }}" style="width: 180px" clearable>
<el-option v-for="dict in dictStore.getDictArray('{{ dict_type }}')" :key="dict.dict_value" :label="dict.dict_label" :value="dict.dict_value" />
</el-select>
</el-form-item>
{% elif (column.html_type == "select" or column.html_type == "radio") and dict_type %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}">
<el-select v-model="queryFormData.{{ column.column_name }}" placeholder="请选择{{ comment }}" clearable>
<el-option label="请选择字典生成" value="" />
</el-select>
</el-form-item>
{% elif column.html_type == "datetime" and column.query_type != "BETWEEN" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}">
<el-date-picker v-model="queryFormData.{{ column.column_name }}" type="date" value-format="YYYY-MM-DD" clearable placeholder="请选择{{ comment }}" />
</el-form-item>
{% endif %}
{% endif %}
{% endfor %}
<!-- 查询、重置、展开/收起按钮 -->
<el-form-item>
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:query']"
type="primary"
icon="search"
@click="handleQuery"
>
查询
</el-button>
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:query']"
icon="refresh"
@click="handleResetQuery"
>
重置
</el-button>
<!-- 展开/收起 -->
<template v-if="isExpandable">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ '{{' }} isExpand ? "收起" : "展开" {{ '}}' }}
<el-icon>
<template v-if="isExpand">
<ArrowUp />
</template>
<template v-else>
<ArrowDown />
</template>
</el-icon>
</el-link>
</template>
</el-form-item>
</el-form>
</div>
<!-- 内容区域 -->
<el-card class="data-table">
<template #header>
<div class="card-header">
<span>
{{ function_name }}列表
<el-tooltip content="{{ function_name }}列表">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
</span>
</div>
</template>
<!-- 功能区域 -->
<div class="data-table__toolbar">
<div class="data-table__toolbar--left">
<el-row :gutter="10">
<el-col :span="1.5">
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:create']"
type="success"
icon="plus"
@click="handleOpenDialog('create')"
>
新增
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:delete']"
type="danger"
icon="delete"
:disabled="selectIds.length === 0"
@click="handleDelete(selectIds)"
>
批量删除
</el-button>
</el-col>
<el-col :span="1.5">
<el-dropdown v-hasPerm="['{{ module_name }}:{{ business_name }}:batch']" trigger="click">
<el-button type="default" :disabled="selectIds.length === 0" icon="ArrowDown">
更多
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :icon="Check" @click="handleMoreClick('0')">
批量启用
</el-dropdown-item>
<el-dropdown-item :icon="CircleClose" @click="handleMoreClick('1')">
批量停用
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-col>
</el-row>
</div>
<div class="data-table__toolbar--right">
<el-row :gutter="10">
<el-col :span="1.5">
<el-tooltip content="导入">
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:import']"
type="success"
icon="upload"
circle
@click="handleOpenImportDialog"
/>
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="导出">
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:export']"
type="warning"
icon="download"
circle
@click="handleOpenExportsModal"
/>
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="搜索显示/隐藏">
<el-button
v-hasPerm="['*:*:*']"
type="info"
icon="search"
circle
@click="visible = !visible"
/>
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="刷新">
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:query']"
type="primary"
icon="refresh"
circle
@click="handleRefresh"
/>
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-popover placement="bottom" trigger="click">
<template #reference>
<el-button type="danger" icon="operation" circle></el-button>
</template>
<el-scrollbar max-height="350px">
<template v-for="column in tableColumns" :key="column.prop">
<el-checkbox v-if="column.prop" v-model="column.show" :label="column.label" />
</template>
</el-scrollbar>
</el-popover>
</el-col>
</el-row>
</div>
</div>
<!-- 表格区域:系统配置列表 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="pageTableData"
highlight-current-row
class="data-table__content"
:height="450"
border
stripe
@selection-change="handleSelectionChange"
>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column
v-if="tableColumns.find((col) => col.prop === 'selection')?.show"
type="selection"
min-width="55"
align="center"
/>
<el-table-column
v-if="tableColumns.find((col) => col.prop === 'index')?.show"
fixed
label="序号"
min-width="60"
>
<template #default="scope">
{{ '{{' }} (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 {{ '}}' }}
</template>
</el-table-column>
{% for column in columns %}
{% set python_field = column.column_name %}
{% set column_comment = column.column_comment if column.column_comment else '' %}
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.is_list == 1 %}
<el-table-column v-if="tableColumns.find((col) => col.prop === '{{ python_field }}')?.show" label="{{ comment }}" prop="{{ python_field }}" min-width="140">
{% if python_field == "status" %}
<template #default="scope">
<el-tag :type="scope.row.status == '0' ? 'success' : 'info'">
{{ '{{' }} scope.row.status == '0' ? '启用' : '停用' {{ '}}' }}
</el-tag>
</template>
{% elif python_field == "created_id" %}
<template #default="scope">
<el-tag>{{ '{{' }} scope.row.created_by?.name {{ '}}' }}</el-tag>
</template>
{% elif python_field == "updated_id" %}
<template #default="scope">
<el-tag>{{ '{{' }} scope.row.updated_by?.name {{ '}}' }}</el-tag>
</template>
{% endif %}
</el-table-column>
{% endif %}
{% endfor %}
<el-table-column
v-if="tableColumns.find((col) => col.prop === 'operation')?.show"
fixed="right"
label="操作"
align="center"
min-width="180"
>
<template #default="scope">
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:detail']"
type="info"
size="small"
link
icon="document"
@click="handleOpenDialog('detail', scope.row.id)"
>
详情
</el-button>
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:update']"
type="primary"
size="small"
link
icon="edit"
@click="handleOpenDialog('update', scope.row.id)"
>
编辑
</el-button>
<el-button
v-hasPerm="['{{ module_name }}:{{ business_name }}:delete']"
type="danger"
size="small"
link
icon="delete"
@click="handleDelete([scope.row.id])"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页区域 -->
<template #footer>
<pagination
v-model:total="total"
v-model:page="queryFormData.page_no"
v-model:limit="queryFormData.page_size"
@pagination="loadingData"
/>
</template>
</el-card>
<!-- 弹窗区域 -->
<el-dialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
@close="handleCloseDialog"
>
<!-- 详情 -->
<template v-if="dialogVisible.type === 'detail'">
<el-descriptions :column="4" border>
{% for column in columns %}
{% set column_comment = column.column_comment if column.column_comment else '' %}
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% if column.column_name == 'status' %}
<el-descriptions-item label="状态" :span="2">
<el-tag :type="detailFormData.status == '0' ? 'success' : 'danger'">
{{ '{{' }} detailFormData.status == '0' ? "启用" : "停用" {{ '}}' }}
</el-tag>
</el-descriptions-item>
{% elif column.column_name == 'created_id' %}
<el-descriptions-item label="创建人" :span="2">
{{ '{{' }} detailFormData.created_by?.name {{ '}}' }}
</el-descriptions-item>
{% elif column.column_name == 'updated_id' %}
<el-descriptions-item label="更新人" :span="2">
{{ '{{' }} detailFormData.updated_by?.name {{ '}}' }}
</el-descriptions-item>
{% else %}
<el-descriptions-item label="{{ comment }}" :span="2">
{{ '{{' }} detailFormData.{{ column.column_name }} {{ '}}' }}
</el-descriptions-item>
{% endif %}
{% endfor %}
</el-descriptions>
</template>
<!-- 新增、编辑表单 -->
<template v-else>
<el-form ref="dataFormRef" :model="formData" :rules="rules" label-suffix=":" label-width="auto" label-position="right">
{% for column in columns %}
{% if column.is_insert == 1 or column.is_edit == 1 %}
{% set dict_type = column.dict_type %}
{% set column_comment = column.column_comment if column.column_comment else '' %}
{% set parentheseIndex = column_comment.find("") %}
{% set comment = column_comment[:parentheseIndex] if parentheseIndex != -1 else column_comment %}
{% set required = 'true' if column.is_nullable == '1' else 'false' %}
{% if column.column_name not in ['id', 'uuid', 'created_time', 'updated_time', 'created_id', 'updated_id'] %}
{% if column.column_name == "status" %}
<el-form-item label="状态" prop="status" :required="true">
<el-radio-group v-model="formData.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
{% elif column.column_name == "description" %}
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
:rows="4"
:maxlength="100"
show-word-limit
type="textarea"
placeholder="请输入描述"
/>
</el-form-item>
{% elif column.html_type == "input" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}" :required="{{ required }}">
<el-input v-model="formData.{{ column.column_name }}" placeholder="请输入{{ comment }}" />
</el-form-item>
{% elif column.html_type == "textarea" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}" :required="{{ required }}">
<el-input v-model="formData.{{ column.column_name }}" type="textarea" placeholder="请输入{{ comment }}" rows="4" :maxlength="100" show-word-limit />
</el-form-item>
{% elif (column.html_type == "select" or column.html_type == "radio") and dict_type != "" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}" :required="{{ required }}">
<el-select v-model="formData.{{ column.column_name }}" placeholder="请选择{{ comment }}">
<el-option v-for="dict in dictStore.getDictArray('{{ dict_type }}')" :key="dict.dict_value" :label="dict.dict_label" :value="dict.dict_value" />
</el-select>
</el-form-item>
{% elif (column.html_type == "select" or column.html_type == "radio") and dict_type %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}" :required="{{ required }}">
<el-select v-model="formData.{{ column.column_name }}" placeholder="请选择{{ comment }}">
<el-option label="请选择字典生成" value="" />
</el-select>
</el-form-item>
{% elif column.html_type == "date" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}" :required="{{ required }}">
<el-date-picker v-model="formData.{{ column.column_name }}" type="date" value-format="YYYY-MM-DD" placeholder="请选择{{ comment }}" />
</el-form-item>
{% elif column.html_type == "datetime" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}" :required="{{ required }}">
<el-date-picker v-model="formData.{{ column.column_name }}" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="请选择{{ comment }}" />
</el-form-item>
{% elif column.html_type == "checkbox" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}">
<el-checkbox v-model="formData.{{ column.column_name }}">{{ comment }}</el-checkbox>
</el-form-item>
{% elif column.html_type == "imageUpload" %}
<el-form-item label="{{ comment }}" prop="{{ column.column_name }}">
<SingleImageUpload v-model="formData.{{ column.column_name }}" />
</el-form-item>
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
</el-form>
</template>
<template #footer>
<div class="dialog-footer">
<!-- 详情弹窗不需要确定按钮的提交逻辑 -->
<el-button @click="handleCloseDialog">取消</el-button>
<el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
确定
</el-button>
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 导入弹窗 -->
<ImportModal
v-model="importDialogVisible"
:content-config="curdContentConfig"
@upload="handleUpload"
/>
<!-- 导出弹窗 -->
<ExportModal
v-model="exportsDialogVisible"
:content-config="curdContentConfig"
:query-params="queryFormData"
:page-data="pageTableData"
:selection-data="selectionRows"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "{{ class_name }}",
inheritAttrs: false,
});
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { QuestionFilled, ArrowUp, ArrowDown, Check, CircleClose } from '@element-plus/icons-vue'
import { formatToDateTime } from "@/utils/dateUtil";
import { useDictStore } from "@/store";
import { ResultEnum } from '@/enums/api/result.enum'
import DatePicker from "@/components/DatePicker/index.vue";
import type { IContentConfig } from "@/components/CURD/types";
import ImportModal from "@/components/CURD/ImportModal.vue";
import ExportModal from "@/components/CURD/ExportModal.vue";
import {{ class_name }}API, { {{ class_name }}PageQuery, {{ class_name }}Table, {{ class_name }}Form } from '@/api/{{ module_name }}/{{ business_name }}'
const visible = ref(true);
const isExpand = ref(false);
const isExpandable = ref(true);
const queryFormRef = ref();
const dataFormRef = ref();
const total = ref(0);
const selectIds = ref<number[]>([]);
const selectionRows = ref<{{ class_name }}Table[]>([]);
const loading = ref(false);
// 字典仓库与需要加载的字典类型
const dictStore = useDictStore()
const dictTypes: any = [
{% for column in columns %}
{% if column.dict_type %}
'{{ column.dict_type }}',
{% endif %}
{% endfor %}
]
// 分页表单
const pageTableData = ref<{{ class_name }}Table[]>([]);
// 表格列配置
const tableColumns = ref([
{ prop: "selection", label: "选择框", show: true },
{ prop: "index", label: "序号", show: true },
{% for column in columns %}
{% if column.is_list == 1 %}
{ prop: '{{ column.column_name }}', label: '{{ column.column_comment or column.column_name }}', show: true },
{% endif %}
{% endfor %}
{ prop: 'operation', label: '操作', show: true }
]);
// 导出列(不含选择/序号/操作)
const exportColumns = [
{% for column in columns %}
{% if column.is_list == 1 %}
{ prop: '{{ column.column_name }}', label: '{{ column.column_comment or column.column_name }}' },
{% endif %}
{% endfor %}
]
// 导入/导出配置
const curdContentConfig = {
permPrefix: "{{ module_name }}:{{ business_name }}",
cols: exportColumns as any,
importTemplate: () => {{ class_name }}API.downloadTemplate{{ class_name }}(),
exportsAction: async (params: any) => {
const query: any = { ...params };
query.status = '0';
query.page_no = 1;
query.page_size = 9999;
const all: any[] = [];
while (true) {
const res = await {{ class_name }}API.list{{ class_name }}(query);
const items = res.data?.data?.items || [];
const total = res.data?.data?.total || 0;
all.push(...items);
if (all.length >= total || items.length === 0) break;
query.page_no += 1;
}
return all;
},
} as unknown as IContentConfig;
// 详情表单
const detailFormData = ref<{{ class_name }}Table>({});
// 日期范围临时变量
const createdDateRange = ref<[Date, Date] | []>([]);
// 更新时间范围临时变量
const updatedDateRange = ref<[Date, Date] | []>([]);
// 处理创建时间范围变化
function handleCreatedDateRangeChange(range: [Date, Date]) {
createdDateRange.value = range;
if (range && range.length === 2) {
queryFormData.created_time = [formatToDateTime(range[0]), formatToDateTime(range[1])];
} else {
queryFormData.created_time = undefined;
}
}
// 处理更新时间范围变化
function handleUpdatedDateRangeChange(range: [Date, Date]) {
updatedDateRange.value = range;
if (range && range.length === 2) {
queryFormData.updated_time = [formatToDateTime(range[0]), formatToDateTime(range[1])];
} else {
queryFormData.updated_time = undefined;
}
}
// 分页查询参数
const queryFormData = reactive<{{ class_name }}PageQuery>({
page_no: 1,
page_size: 10,
{% for column in columns %}
{% if column.is_query == 1 %}
{{ column.column_name }}: undefined,
{% endif %}
{% endfor %}
});
// 编辑表单
const formData = reactive<{{ class_name }}Form>({
{% for column in columns %}
{% if column.is_insert == 1 or column.is_edit == 1 %}
{% if column.column_name not in ['uuid', 'created_time', 'updated_time', 'created_id', 'updated_id'] %}
{{ column.column_name }}: undefined,
{% endif %}
{% endif %}
{% endfor %}
});
// 弹窗状态
const dialogVisible = reactive({
title: "",
visible: false,
type: "create" as "create" | "update" | "detail",
});
// 表单验证规则
const rules = reactive({
{% for column in columns %}
{% if column.is_insert == 1 or column.is_edit == 1 %}
{% set required = 'true' if column.is_nullable == 1 else 'false' %}
{{ column.column_name }}: [
{ required: {{ required }}, message: '请输入{{ column.column_comment or column.column_name }}', trigger: 'blur' },
],
{% endif %}
{% endfor %}
});
// 导入弹窗显示状态
const importDialogVisible = ref(false);
// 导出弹窗显示状态
const exportsDialogVisible = ref(false);
// 打开导入弹窗
function handleOpenImportDialog() {
importDialogVisible.value = true;
}
// 打开导出弹窗
function handleOpenExportsModal() {
exportsDialogVisible.value = true;
}
// 列表刷新
async function handleRefresh() {
await loadingData();
}
// 加载表格数据
async function loadingData() {
loading.value = true;
try {
const response = await {{ class_name }}API.list{{ class_name }}(queryFormData);
pageTableData.value = response.data.data.items;
total.value = response.data.data.total;
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}
// 查询(重置页码后获取数据)
async function handleQuery() {
queryFormData.page_no = 1;
loadingData();
}
// 选择创建人后触发查询
function handleConfirm() {
handleQuery();
}
// 重置查询
async function handleResetQuery() {
queryFormRef.value.resetFields();
queryFormData.page_no = 1;
// 重置日期范围选择器
createdDateRange.value = [];
updatedDateRange.value = [];
queryFormData.created_time = undefined;
queryFormData.updated_time = undefined;
loadingData();
}
// 定义初始表单数据常量
const initialFormData: {{ class_name }}Form = {
{% for column in columns %}
{% if column.is_insert == 1 or column.is_edit == 1 %}
{% if column.column_name not in ['uuid', 'created_time', 'updated_time', 'created_id', 'updated_id'] %}
{{ column.column_name }}: undefined,
{% endif %}
{% endif %}
{% endfor %}
};
// 重置表单
async function resetForm() {
if (dataFormRef.value) {
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
}
// 完全重置 formData 为初始状态
Object.assign(formData, initialFormData);
}
// 行复选框选中项变化
async function handleSelectionChange(selection: any) {
selectIds.value = selection.map((item: any) => item.id);
selectionRows.value = selection;
}
// 关闭弹窗
async function handleCloseDialog() {
dialogVisible.visible = false;
resetForm();
}
// 打开弹窗
async function handleOpenDialog(type: "create" | "update" | "detail", id?: number) {
dialogVisible.type = type;
if (id) {
const response = await {{ class_name }}API.detail{{ class_name }}(id);
if (type === "detail") {
dialogVisible.title = "详情";
Object.assign(detailFormData.value, response.data.data);
} else if (type === "update") {
dialogVisible.title = "修改";
Object.assign(formData, response.data.data);
}
} else {
dialogVisible.title = "新增{{ class_name }}";
{% for column in columns %}
{% if column.is_insert == 1 or column.is_edit == 1 %}
{% if column.column_name not in ['uuid', 'created_time', 'updated_time', 'created_id', 'updated_id'] %}
formData.{{ column.column_name }} = undefined;
{% endif %}
{% endif %}
{% endfor %}
}
dialogVisible.visible = true;
}
// 提交表单(防抖)
async function handleSubmit() {
// 表单校验
dataFormRef.value.validate(async (valid: any) => {
if (valid) {
loading.value = true;
// 根据弹窗传入的参数(deatil\create\update)判断走什么逻辑
const id = formData.id;
if (id) {
try {
await {{ class_name }}API.update{{ class_name }}(id, { id, ...formData });
dialogVisible.visible = false;
resetForm();
handleCloseDialog();
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
} else {
try {
await {{ class_name }}API.create{{ class_name }}(formData);
dialogVisible.visible = false;
resetForm();
handleCloseDialog();
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}
}
});
}
// 删除、批量删除
async function handleDelete(ids: number[]) {
ElMessageBox.confirm("确认删除该项数据?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
loading.value = true;
await {{ class_name }}API.delete{{ class_name }}(ids);
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
})
.catch(() => {
ElMessageBox.close();
});
}
// 批量启用/停用
async function handleMoreClick(status: string) {
if (selectIds.value.length) {
ElMessageBox.confirm(`确认${status === "0" ? "启用" : "停用"}该项数据?`, "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
loading.value = true;
await {{ class_name }}API.batch{{ class_name }}({ ids: selectIds.value, status });
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
})
.catch(() => {
ElMessageBox.close();
});
}
}
// 处理上传
const handleUpload = async (formData: FormData) => {
try {
const response = await {{ class_name }}API.import{{ class_name }}(formData);
if (response.data.code === ResultEnum.SUCCESS) {
ElMessage.success(`${response.data.msg}${response.data.data}`);
importDialogVisible.value = false;
await handleQuery();
}
} catch (error: any) {
console.error(error);
}
};
onMounted(async () => {
// 预加载字典数据
if (dictTypes.length > 0) {
await dictStore.getDict(dictTypes)
}
loadingData();
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,230 @@
# -*- coding: utf-8 -*-
import re
from app.common.constant import GenConstant
from app.utils.string_util import StringUtil
from app.api.v1.module_generator.gencode.schema import GenTableOutSchema, GenTableSchema, GenTableColumnSchema
class GenUtils:
"""代码生成器工具类"""
@classmethod
def init_table(cls, gen_table: GenTableSchema) -> None:
"""
初始化表信息
参数:
- gen_table (GenTableSchema): 业务表对象。
返回:
- None
"""
# 只有当字段为None时才设置默认值
gen_table.class_name = cls.convert_class_name(gen_table.table_name or "")
gen_table.package_name = 'gencode'
gen_table.module_name = f'module_{gen_table.package_name}'
gen_table.business_name = gen_table.table_name
gen_table.function_name = re.sub(r'(?:表|测试)', '', gen_table.table_comment or "")
@classmethod
def init_column_field(cls, column: GenTableColumnSchema, table: GenTableOutSchema) -> None:
"""
初始化列属性字段
参数:
- column (GenTableColumnSchema): 业务表字段对象。
- table (GenTableOutSchema): 业务表对象。
返回:
- None
"""
data_type = cls.get_db_type(column.column_type or "")
column_name = column.column_name or ""
if not table.id:
raise ValueError("业务表ID不能为空")
column.table_id = table.id
column.python_field = cls.to_camel_case(column_name)
# 只有当python_type为None时才设置默认类型
column.python_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_PYTHON, data_type)
if column.column_length is None:
column.column_length = ''
if column.column_default is None:
column.column_default = ''
if column.html_type is None:
if cls.arrays_contains(GenConstant.COLUMNTYPE_STR, data_type) or cls.arrays_contains(
GenConstant.COLUMNTYPE_TEXT, data_type
):
# 字符串长度超过500设置为文本域
column_length = cls.get_column_length(column.column_type or "")
html_type = (
GenConstant.HTML_TEXTAREA
if column_length >= 500 or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type)
else GenConstant.HTML_INPUT
)
column.html_type = html_type
elif cls.arrays_contains(GenConstant.COLUMNTYPE_TIME, data_type):
column.html_type = GenConstant.HTML_DATETIME
elif cls.arrays_contains(GenConstant.COLUMNTYPE_NUMBER, data_type):
column.html_type = GenConstant.HTML_INPUT
elif column_name.lower().endswith("status"):
column.html_type = GenConstant.HTML_RADIO
elif column_name.lower().endswith("type") or column_name.lower().endswith("sex"):
column.html_type = GenConstant.HTML_SELECT
elif column_name.lower().endswith("image"):
column.html_type = GenConstant.HTML_IMAGE_UPLOAD
elif column_name.lower().endswith("file"):
column.html_type = GenConstant.HTML_FILE_UPLOAD
elif column_name.lower().endswith("content"):
column.html_type = GenConstant.HTML_EDITOR
else:
column.html_type = GenConstant.HTML_INPUT
# 只有当is_insert为None时才设置插入字段默认所有字段都需要插入
if column.is_insert:
column.is_insert = GenConstant.REQUIRE
else:
column.is_insert = False
# 只有当is_edit为None时才设置编辑字段
if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_EDIT, column_name) and not column.is_pk:
column.is_edit = GenConstant.REQUIRE
else:
column.is_edit = False
# 只有当is_list为None时才设置列表字段
if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_LIST, column_name) and not column.is_pk:
column.is_list = GenConstant.REQUIRE
else:
column.is_list = False
# 只有当is_query为None时才设置查询字段
if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_QUERY, column_name) and not column.is_pk:
column.is_query = GenConstant.REQUIRE
# 直接设置查询类型,因为我们已经确定这是一个查询字段
if column_name.lower().endswith('name') or data_type in ['varchar', 'char', 'text']:
column.query_type = GenConstant.QUERY_LIKE
else:
column.query_type = GenConstant.QUERY_EQ
else:
column.is_query = False
column.query_type = None
@classmethod
def arrays_contains(cls, arr, target_value) -> bool:
"""
检查目标值是否在数组中
注意:从根本上解决问题,现在确保传入的参数都是正确的类型:
- arr 是列表类型且在GenConstant中定义
- target_value 不会是None
参数:
- arr: 数组类型
- target_value: 目标值
返回:
- bool: 如果目标值在数组中返回True否则返回False
"""
# 从根本上解决问题,不再需要复杂的防御性检查
# 因为现在我们确保传入的arr是GenConstant中定义的列表常量
# 并且target_value在调用前已经被处理过不会是None
# 简单直接地执行包含检查
target_str = str(target_value).lower()
return any(str(item).lower() == target_str for item in arr)
@classmethod
def convert_class_name(cls, table_name: str) -> str:
"""
表名转换成 Python 类名
参数:
- table_name (str): 业务表名。
返回:
- str: Python 类名。
"""
return StringUtil.convert_to_camel_case(table_name)
@classmethod
def replace_first(cls, input_string: str, search_list: list[str]) -> str:
"""
批量替换前缀
参数:
- input_string (str): 需要被替换的字符串。
- search_list (list[str]): 可替换的字符串列表。
返回:
- str: 替换后的字符串。
"""
for search_string in search_list:
if input_string.startswith(search_string):
return input_string.replace(search_string, '', 1)
return input_string
@classmethod
def get_db_type(cls, column_type: str) -> str:
"""
获取数据库类型字段
参数:
- column_type (str): 字段类型。
返回:
- str: 数据库类型。
"""
if '(' in column_type:
return column_type.split('(')[0]
return column_type
@classmethod
def get_column_length(cls, column_type: str) -> int:
"""
获取字段长度
参数:
- column_type (str): 字段类型,例如 'varchar(255)''decimal(10,2)'
返回:
- int: 字段长度优先取第一个长度值无法解析时返回0
"""
if '(' in column_type:
length = len(column_type.split('(')[1].split(')')[0])
return length
return 0
@classmethod
def split_column_type(cls, column_type: str) -> list[str]:
"""
拆分列类型
参数:
- column_type (str): 字段类型。
返回:
- list[str]: 拆分结果。
"""
if '(' in column_type and ')' in column_type:
return column_type.split('(')[1].split(')')[0].split(',')
return []
@classmethod
def to_camel_case(cls, text: str) -> str:
"""
将字符串转换为驼峰命名
参数:
- text (str): 需要转换的字符串
返回:
- str: 驼峰命名
"""
parts = text.split('_')
return parts[0] + ''.join(word.capitalize() for word in parts[1:])

View File

@@ -0,0 +1,395 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from jinja2.environment import Environment
from jinja2 import Environment, FileSystemLoader, Template
from typing import Any
from app.common.constant import GenConstant
from app.config.path_conf import TEMPLATE_DIR
from app.config.setting import settings
from app.utils.common_util import CamelCaseUtil, SnakeCaseUtil
from app.utils.string_util import StringUtil
from app.api.v1.module_generator.gencode.schema import GenTableOutSchema, GenTableColumnOutSchema
class Jinja2TemplateUtil:
"""
模板处理工具类
"""
# 项目路径
FRONTEND_PROJECT_PATH = 'frontend'
BACKEND_PROJECT_PATH = 'backend'
# 默认上级菜单,系统工具
DEFAULT_PARENT_MENU_ID = 7
# 环境对象
_env = None
@classmethod
def get_env(cls):
"""
获取模板环境对象。
参数:
- 无
返回:
- Environment: Jinja2 环境对象。
"""
try:
if cls._env is None:
cls._env = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=False, # 自动转义HTML
trim_blocks=True, # 删除多余的空行
lstrip_blocks=True, # 删除行首空格
keep_trailing_newline=True, # 保留行尾换行符
enable_async=True, # 开启异步支持
)
cls._env.filters.update(
{
'camel_to_snake': SnakeCaseUtil.camel_to_snake,
'snake_to_camel': CamelCaseUtil.snake_to_camel,
'get_sqlalchemy_type': cls.get_sqlalchemy_type
}
)
return cls._env
except Exception as e:
raise RuntimeError(f'初始化Jinja2模板引擎失败: {e}')
@classmethod
def get_template(cls, template_path: str) -> Template:
"""
获取模板。
参数:
- template_path (str): 模板路径。
返回:
- Template: Jinja2 模板对象。
异常:
- TemplateNotFound: 模板未找到时抛出。
"""
return cls.get_env().get_template(template_path)
@classmethod
def prepare_context(cls, gen_table: GenTableOutSchema) -> dict[str, Any]:
"""
准备模板变量。
参数:
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- Dict[str, Any]: 模板上下文字典。
"""
# 处理options为None的情况
# if not gen_table.options:
# raise ValueError('请先完善生成配置信息')
class_name = gen_table.class_name or ''
module_name = gen_table.module_name or ''
business_name = gen_table.business_name or ''
package_name = gen_table.package_name or ''
function_name = gen_table.function_name or ''
context = {
'table_name': gen_table.table_name or '',
'table_comment': gen_table.table_comment or '',
'function_name': function_name if StringUtil.is_not_empty(function_name) else '【请填写功能名称】',
'class_name': class_name,
'module_name': module_name,
'business_name': business_name,
'base_package': cls.get_package_prefix(package_name),
'package_name': package_name,
'datetime': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'pk_column': gen_table.pk_column,
'model_import_list': cls.get_model_import_list(gen_table),
'schema_import_list': cls.get_schema_import_list(gen_table),
'permission_prefix': cls.get_permission_prefix(module_name, business_name),
'columns': gen_table.columns or [],
'table': gen_table,
'dicts': cls.get_dicts(gen_table),
'db_type': settings.DATABASE_TYPE,
'column_not_add_show': GenConstant.COLUMNNAME_NOT_ADD_SHOW,
'column_not_edit_show': GenConstant.COLUMNNAME_NOT_EDIT_SHOW,
'parent_menu_id': int(gen_table.parent_menu_id) if gen_table.parent_menu_id is not None else int(cls.DEFAULT_PARENT_MENU_ID),
}
return context
@classmethod
def get_template_list(cls):
"""
获取模板列表。
参数:
- 无
返回:
- List[str]: 模板路径列表。
"""
templates = [
'python/controller.py.j2',
'python/service.py.j2',
'python/crud.py.j2',
'python/schema.py.j2',
'python/model.py.j2',
'python/__init__.py.j2',
'sql/sql.sql.j2',
'ts/api.ts.j2',
'vue/index.vue.j2',
]
return templates
@classmethod
def get_file_name(cls, template: str, gen_table: GenTableOutSchema):
"""
根据模板生成文件名。
参数:
- template (str): 模板路径字符串。
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- str: 模板生成的文件名。
异常:
- ValueError: 当无法生成有效文件名时抛出。
"""
module_name = gen_table.module_name or ''
business_name = gen_table.business_name or ''
# 验证必要的参数
if not module_name or not business_name:
raise ValueError(f"无法为模板 {template} 生成文件名:模块名或业务名未设置")
# 映射表方式简化
template_mapping = {
'controller.py.j2': f'{cls.BACKEND_PROJECT_PATH}/app/api/v1/{module_name}/{business_name}/controller.py',
'service.py.j2': f'{cls.BACKEND_PROJECT_PATH}/app/api/v1/{module_name}/{business_name}/service.py',
'crud.py.j2': f'{cls.BACKEND_PROJECT_PATH}/app/api/v1/{module_name}/{business_name}/crud.py',
'schema.py.j2': f'{cls.BACKEND_PROJECT_PATH}/app/api/v1/{module_name}/{business_name}/schema.py',
'model.py.j2': f'{cls.BACKEND_PROJECT_PATH}/app/api/v1/{module_name}/{business_name}/model.py',
'__init__.py.j2': f'{cls.BACKEND_PROJECT_PATH}/app/api/v1/{module_name}/{business_name}/__init__.py',
'sql.sql.j2': f'{cls.BACKEND_PROJECT_PATH}/sql/menu/{module_name}/{business_name}.sql',
'api.ts.j2': f'{cls.FRONTEND_PROJECT_PATH}/src/api/{module_name}/{business_name}.ts',
'index.vue.j2': f'{cls.FRONTEND_PROJECT_PATH}/src/views/{module_name}/{business_name}/index.vue'
}
# 查找匹配的模板路径
for key, path in template_mapping.items():
if key in template:
return path
# 默认处理
template_name = template.split('/')[-1].replace('.j2', '')
return f'{cls.BACKEND_PROJECT_PATH}/generated/{template_name}'
@classmethod
def get_package_prefix(cls, package_name: str) -> str:
"""
获取包前缀。
参数:
- package_name (str): 包名。
返回:
- str: 包前缀。
"""
# 修复:当包名中不存在'.'时,直接返回原包名
return package_name[: package_name.rfind('.')] if '.' in package_name else package_name
@classmethod
def get_schema_import_list(cls, gen_table: GenTableOutSchema):
"""
获取schema模板导入包列表
:param gen_table: 生成表的配置信息
:return: 导入包列表
"""
columns = gen_table.columns or []
import_list = set()
for column in columns:
if column.python_type in GenConstant.TYPE_DATE:
import_list.add(f'from datetime import {column.python_type}')
elif column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add('from decimal import Decimal')
if gen_table.sub:
if gen_table.sub_table and gen_table.sub_table.columns:
sub_columns = gen_table.sub_table.columns or []
for sub_column in sub_columns:
if sub_column.python_type in GenConstant.TYPE_DATE:
import_list.add(f'from datetime import {sub_column.python_type}')
elif sub_column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add('from decimal import Decimal')
return cls.merge_same_imports(list(import_list), 'from datetime import')
@classmethod
def get_model_import_list(cls, gen_table: GenTableOutSchema):
"""
获取do模板导入包列表
:param gen_table: 生成表的配置信息
:return: 导入包列表
"""
columns = gen_table.columns or []
import_list = set()
for column in columns:
if column.column_type:
data_type = cls.get_db_type(column.column_type)
if data_type in GenConstant.COLUMNTYPE_GEOMETRY:
import_list.add('from geoalchemy2 import Geometry')
import_list.add(
f'from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}'
)
if gen_table.sub:
import_list.add('from sqlalchemy import ForeignKey')
if gen_table.sub_table and gen_table.sub_table.columns:
sub_columns = gen_table.sub_table.columns or []
for sub_column in sub_columns:
if sub_column.column_type:
data_type = cls.get_db_type(sub_column.column_type)
import_list.add(
f'from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}'
)
return cls.merge_same_imports(list(import_list), 'from sqlalchemy import')
@classmethod
def get_db_type(cls, column_type: str) -> str:
"""
获取数据库字段类型。
参数:
- column_type (str): 字段类型字符串。
返回:
- str: 数据库类型(去除长度等修饰)。
"""
if '(' in column_type:
return column_type.split('(')[0]
return column_type
@classmethod
def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]:
"""
合并相同的导入语句。
参数:
- imports (list[str]): 导入语句列表。
- import_start (str): 导入语句的起始字符串。
返回:
- list[str]: 合并后的导入语句列表。
"""
merged_imports = []
_imports = []
for import_stmt in imports:
if import_stmt.startswith(import_start):
imported_items = import_stmt.split('import')[1].strip()
_imports.extend(imported_items.split(', '))
else:
merged_imports.append(import_stmt)
if _imports:
merged_datetime_import = f'{import_start} {", ".join(_imports)}'
merged_imports.append(merged_datetime_import)
return merged_imports
@classmethod
def get_dicts(cls, gen_table: GenTableOutSchema):
"""
获取字典列表。
参数:
- gen_table (GenTableOutSchema): 生成表的配置信息。
返回:
- str: 以逗号分隔的字典类型字符串。
"""
columns = gen_table.columns or []
dicts = set()
cls.add_dicts(dicts, columns)
# 处理sub_table为None的情况
if gen_table.sub_table is not None:
# 处理sub_table.columns为None的情况
sub_columns = gen_table.sub_table.columns or []
cls.add_dicts(dicts, sub_columns)
return ', '.join(dicts)
@classmethod
def add_dicts(cls, dicts: set[str], columns: list[GenTableColumnOutSchema]):
"""
添加字典类型到集合。
参数:
- dicts (set[str]): 字典类型集合。
- columns (list[GenTableColumnOutSchema]): 字段列表。
返回:
- set[str]: 更新后的字典类型集合。
"""
for column in columns:
super_column = column.super_column if column.super_column is not None else '0'
dict_type = column.dict_type or ''
html_type = column.html_type or ''
if (
not super_column
and StringUtil.is_not_empty(dict_type)
and StringUtil.equals_any_ignore_case(
html_type, [GenConstant.HTML_SELECT, GenConstant.HTML_RADIO, GenConstant.HTML_CHECKBOX]
)
):
dicts.add(f"'{dict_type}'")
@classmethod
def get_permission_prefix(cls, module_name: str | None, business_name: str | None) -> str:
"""
获取权限前缀。
参数:
- module_name (str | None): 模块名。
- business_name (str | None): 业务名。
返回:
- str: 权限前缀字符串。
"""
return f'{module_name}:{business_name}'
@classmethod
def get_sqlalchemy_type(cls, column):
"""
获取 SQLAlchemy 类型。
参数:
- column_type (Any): 列类型或包含 `column_type` 属性的对象。
返回:
- str: SQLAlchemy 类型字符串。
"""
if '(' in column:
column_type_list = column.split('(')
if column_type_list[0] in GenConstant.COLUMNTYPE_STR:
sqlalchemy_type = (
StringUtil.get_mapping_value_by_key_ignore_case(
GenConstant.DB_TO_SQLALCHEMY, column_type_list[0]
)
+ '('
+ column_type_list[1]
)
else:
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(
GenConstant.DB_TO_SQLALCHEMY, column_type_list[0]
)
else:
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(
GenConstant.DB_TO_SQLALCHEMY, column
)
return sqlalchemy_type

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from redis.asyncio.client import Redis
from app.common.response import SuccessResponse
from app.core.exceptions import CustomException
from app.core.dependencies import AuthPermission, redis_getter
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from .service import CacheService
CacheRouter = APIRouter(route_class=OperationLogRoute, prefix="/cache", tags=["缓存监控"])
@CacheRouter.get(
'/info',
dependencies=[Depends(AuthPermission(['module_monitor:cache:query']))],
summary="获取缓存监控信息",
description="获取缓存监控信息"
)
async def get_monitor_cache_info_controller(
redis: Redis = Depends(redis_getter)
) -> JSONResponse:
"""
获取缓存监控统计信息
返回:
- JSONResponse: 包含缓存监控统计信息的JSON响应
"""
result = await CacheService.get_cache_monitor_statistical_info_service(redis=redis)
log.info('获取缓存监控信息成功')
return SuccessResponse(data=result, msg='获取缓存监控信息成功')
@CacheRouter.get(
'/get/names',
dependencies=[Depends(AuthPermission(['module_monitor:cache:query']))],
summary="获取缓存名称列表",
description="获取缓存名称列表"
)
async def get_monitor_cache_name_controller() -> JSONResponse:
"""
获取缓存名称列表
返回:
- JSONResponse: 包含缓存名称列表的JSON响应
"""
result = await CacheService.get_cache_monitor_cache_name_service()
log.info('获取缓存名称列表成功')
return SuccessResponse(data=result, msg='获取缓存名称列表成功')
@CacheRouter.get(
'/get/keys/{cache_name}',
dependencies=[Depends(AuthPermission(['module_monitor:cache:query']))],
summary="获取缓存键名列表",
description="获取缓存键名列表"
)
async def get_monitor_cache_key_controller(
cache_name: str,
redis: Redis = Depends(redis_getter)
) -> JSONResponse:
"""
获取指定缓存名称下的键名列表
参数:
- cache_name (str): 缓存名称
返回:
- JSONResponse: 包含缓存键名列表的JSON响应
"""
result = await CacheService.get_cache_monitor_cache_key_service(redis=redis, cache_name=cache_name)
log.info(f'获取缓存{cache_name}的键名列表成功')
return SuccessResponse(data=result, msg=f'获取缓存{cache_name}的键名列表成功')
@CacheRouter.get(
'/get/value/{cache_name}/{cache_key}',
dependencies=[Depends(AuthPermission(['module_monitor:cache:query']))],
summary="获取缓存值",
description="获取缓存值"
)
async def get_monitor_cache_value_controller(
cache_name: str,
cache_key: str,
redis: Redis = Depends(redis_getter)
)-> JSONResponse:
"""
获取指定缓存键的值
参数:
- cache_name (str): 缓存名称
- cache_key (str): 缓存键
返回:
- JSONResponse: 包含缓存值的JSON响应
"""
result = await CacheService.get_cache_monitor_cache_value_service(redis=redis, cache_name=cache_name, cache_key=cache_key)
log.info(f'获取缓存{cache_name}:{cache_key}的值成功')
return SuccessResponse(data=result, msg=f'获取缓存{cache_name}:{cache_key}的值成功')
@CacheRouter.delete(
'/delete/name/{cache_name}',
dependencies=[Depends(AuthPermission(['module_monitor:cache:delete']))],
summary="清除指定缓存名称的所有缓存",
description="清除指定缓存名称的所有缓存"
)
async def clear_monitor_cache_name_controller(
cache_name: str,
redis: Redis = Depends(redis_getter)
) -> JSONResponse:
"""
清除指定缓存名称下的所有缓存
参数:
- cache_name (str): 缓存名称
返回:
- JSONResponse: 包含清除结果的JSON响应
"""
result = await CacheService.clear_cache_monitor_cache_name_service(redis=redis, cache_name=cache_name)
if not result:
raise CustomException(msg='清除缓存失败', data=result)
log.info(f'清除缓存{cache_name}成功')
return SuccessResponse(msg=f'{cache_name}对应键值清除成功', data=result)
@CacheRouter.delete(
'/delete/key/{cache_key}',
dependencies=[Depends(AuthPermission(['module_monitor:cache:delete']))],
summary="清除指定缓存键",
description="清除指定缓存键"
)
async def clear_monitor_cache_key_controller(
cache_key: str,
redis: Redis = Depends(redis_getter)
) -> JSONResponse:
"""
清除指定缓存键
参数:
- cache_key (str): 缓存键
返回:
- JSONResponse: 包含清除结果的JSON响应
"""
result = await CacheService.clear_cache_monitor_cache_key_service(redis=redis, cache_key=cache_key)
if not result:
raise CustomException(msg='清除缓存失败', data=result)
log.info(f'清除缓存键{cache_key}成功')
return SuccessResponse(msg=f'{cache_key}清除成功', data=result)
@CacheRouter.delete(
'/delete/all',
dependencies=[Depends(AuthPermission(['module_monitor:cache:delete']))],
summary="清除所有缓存",
description="清除所有缓存"
)
async def clear_monitor_cache_all_controller(
redis: Redis = Depends(redis_getter)
) -> JSONResponse:
"""
清除所有缓存
返回:
- JSONResponse: 包含清除结果的JSON响应
"""
result = await CacheService.clear_cache_monitor_all_service(redis=redis)
if not result:
raise CustomException(msg='清除缓存失败', data=result)
log.info('清除所有缓存成功')
return SuccessResponse(msg='所有缓存清除成功', data=result)

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class CacheMonitorSchema(BaseModel):
"""缓存监控信息模型"""
model_config = ConfigDict(from_attributes=True)
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):
"""缓存对象信息模型"""
model_config = ConfigDict(from_attributes=True)
cache_key: str = Field(..., description='缓存键名')
cache_name: str = Field(..., description='缓存名称')
cache_value: Any = Field(default=None, description='缓存值')
remark: str | None = Field(default=None, description='备注说明')

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
from redis.asyncio.client import Redis
from app.common.enums import RedisInitKeyConfig
from app.core.redis_crud import RedisCURD
from .schema import CacheMonitorSchema, CacheInfoSchema
class CacheService:
"""
缓存监控模块服务层
"""
@classmethod
async def get_cache_monitor_statistical_info_service(cls, redis: Redis)->dict:
"""
获取缓存监控信息。
参数:
- redis (Redis): Redis 对象。
返回:
- dict: 缓存监控信息字典。
"""
info = await RedisCURD(redis).info()
db_size = await RedisCURD(redis).db_size()
command_stats_dict = await RedisCURD(redis).commandstats()
command_stats = [
dict(name=key.split('_')[1], value=str(value.get('calls'))) for key, value in command_stats_dict.items()
]
result = CacheMonitorSchema(command_stats=command_stats, db_size=db_size, info=info)
return result.model_dump()
@classmethod
async def get_cache_monitor_cache_name_service(cls)->list:
"""
获取缓存名称列表信息。
返回:
- list: 缓存名称列表信息。
"""
name_list = []
for key_config in RedisInitKeyConfig:
name_list.append(
CacheInfoSchema(
cache_key='',
cache_name=key_config.key,
cache_value='',
remark=key_config.remark,
).model_dump()
)
return name_list
@classmethod
async def get_cache_monitor_cache_key_service(cls, redis: Redis, cache_name: str)->list:
"""
获取缓存键名列表信息。
参数:
- redis (Redis): Redis 对象。
- cache_name (str): 缓存名称。
返回:
- list: 缓存键名列表信息。
"""
cache_keys = await RedisCURD(redis).get_keys(f'{cache_name}*')
cache_key_list = [key.split(':', 1)[1] for key in cache_keys if key.startswith(f'{cache_name}:')]
return cache_key_list
@classmethod
async def get_cache_monitor_cache_value_service(cls, redis: Redis, cache_name: str, cache_key: str)->dict:
"""
获取缓存内容信息。
参数:
- redis (Redis): Redis 对象。
- cache_name (str): 缓存名称。
- cache_key (str): 缓存键名。
返回:
- dict: 缓存内容信息字典。
"""
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='').model_dump()
@classmethod
async def clear_cache_monitor_cache_name_service(cls, redis: Redis, cache_name: str)->bool:
"""
清除指定缓存名称对应的所有键值。
参数:
- redis (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
@classmethod
async def clear_cache_monitor_cache_key_service(cls, redis: Redis, cache_key: str)->bool:
"""
清除匹配指定键名的所有键值。
参数:
- redis (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
@classmethod
async def clear_cache_monitor_all_service(cls, redis: Redis)->bool:
"""
清除所有缓存。
参数:
- redis (Redis): Redis 对象。
返回:
- bool: 是否清理成功。
"""
cache_keys = await RedisCURD(redis).get_keys()
if cache_keys:
await RedisCURD(redis).delete(*cache_keys)
return True
# 避免清除所有的缓存,而采用上面的方式,只清除本系统内指定的所有缓存
# return await RedisCURD(redis).clear()

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Body, Depends
from fastapi.responses import JSONResponse
from redis.asyncio.client import Redis
from app.common.request import PaginationService
from app.common.response import SuccessResponse,ErrorResponse
from app.core.dependencies import AuthPermission, redis_getter
from app.core.base_params import PaginationQueryParam
from app.core.router_class import OperationLogRoute
from app.core.logger import log
from .schema import OnlineQueryParam
from .service import OnlineService
OnlineRouter = APIRouter(route_class=OperationLogRoute, prefix="/online", tags=["在线用户"])
@OnlineRouter.get(
'/list',
dependencies=[Depends(AuthPermission(['module_monitor:online:query']))],
summary="获取在线用户列表",
description="获取在线用户列表"
)
async def get_online_list_controller(
redis: Redis = Depends(redis_getter),
paging_query: PaginationQueryParam = Depends(),
search: OnlineQueryParam = Depends()
)->JSONResponse:
"""
获取在线用户列表
参数:
- redis (Redis): Redis异步客户端实例。
- paging_query (PaginationQueryParam): 分页查询参数模型。
- search (OnlineQueryParam): 查询参数模型。
返回:
- JSONResponse: 包含在线用户列表的JSON响应。
"""
result_dict_list = await OnlineService.get_online_list_service(redis=redis, search=search)
result_dict = await PaginationService.paginate(data_list= result_dict_list, page_no= paging_query.page_no, page_size = paging_query.page_size)
log.info('获取成功')
return SuccessResponse(data=result_dict,msg='获取成功')
@OnlineRouter.delete(
'/delete',
dependencies=[Depends(AuthPermission(['module_monitor:online:delete']))],
summary="强制下线",
description="强制下线"
)
async def delete_online_controller(
session_id: str = Body(..., description="会话编号"),
redis: Redis = Depends(redis_getter),
)->JSONResponse:
"""
强制下线指定在线用户
参数:
- session_id (str): 在线用户会话ID。
- redis (Redis): Redis异步客户端实例。
返回:
- JSONResponse: 包含操作结果的JSON响应。
"""
is_ok = await OnlineService.delete_online_service(redis=redis, session_id=session_id)
if is_ok:
log.info("强制下线成功")
return SuccessResponse(msg="强制下线成功")
else:
log.info("强制下线失败")
return ErrorResponse(msg="强制下线失败")
@OnlineRouter.delete(
'/clear',
dependencies=[Depends(AuthPermission(['module_monitor:online:delete']))],
summary="清除所有在线用户",
description="清除所有在线用户"
)
async def clear_online_controller(
redis: Redis = Depends(redis_getter),
)->JSONResponse:
"""
清除所有在线用户
参数:
- redis (Redis): Redis异步客户端实例。
返回:
- JSONResponse: 包含操作结果的JSON响应。
"""
is_ok = await OnlineService.clear_online_service(redis=redis)
if is_ok:
log.info("清除所有在线用户成功")
return SuccessResponse(msg="清除所有在线用户成功")
else:
log.info("清除所有在线用户失败")
return ErrorResponse(msg="清除所有在线用户失败")

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from app.core.validator import DateTimeStr
class OnlineOutSchema(BaseModel):
"""
在线用户对应pydantic模型
"""
model_config = ConfigDict(from_attributes=True)
name: str = Field(..., description='用户名称')
session_id: str = Field(..., description='会话编号')
user_id: int = Field(..., description='用户ID')
user_name: str = Field(..., description='用户名')
ipaddr: str | None = Field(default=None, description='登陆IP地址')
login_location: str | None = Field(default=None, description='登录所属地')
os: str | None = Field(default=None, description='操作系统')
browser: str | None = Field(default=None, description='浏览器')
login_time: DateTimeStr | None = Field(default=None, description='登录时间')
login_type: str | None = Field(default=None, description='登录类型 PC端 | 移动端')
class OnlineQueryParam:
"""在线用户查询参数"""
def __init__(
self,
name: str | None = Query(None, description="登录名称"),
ipaddr: str | None = Query(None, description="登陆IP地址"),
login_location: str | None = Query(None, description="登录所属地"),
) -> None:
# 模糊查询字段
self.name = ("like", f"%{name}%") if name else None
self.login_location = ("like", f"%{login_location}%") if login_location else None
self.ipaddr = ("like", f"%{ipaddr}%") if ipaddr else None

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
import json
from redis.asyncio.client import Redis
from app.common.enums import RedisInitKeyConfig
from app.core.redis_crud import RedisCURD
from app.core.security import decode_access_token
from app.core.logger import log
from .schema import OnlineQueryParam
class OnlineService:
"""在线用户管理模块服务层"""
@classmethod
async def get_online_list_service(cls, redis: Redis, search: OnlineQueryParam | None = None) -> list[dict]:
"""
获取在线用户列表信息(支持分页和搜索)
参数:
- redis (Redis): Redis异步客户端实例。
- search (OnlineQueryParam | None): 查询参数模型。
返回:
- list[dict]: 在线用户详情字典列表。
"""
keys = await RedisCURD(redis).get_keys(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*")
tokens = await RedisCURD(redis).mget(keys)
online_users = []
for token in tokens:
if not token:
continue
try:
payload = decode_access_token(token=token)
session_info = json.loads(payload.sub)
if cls._match_search_conditions(session_info, search):
online_users.append(session_info)
except Exception as e:
log.error(f"解析在线用户数据失败: {e}")
continue
# 按照 login_time 倒序排序
online_users.sort(key=lambda x: x.get('login_time', ''), reverse=True)
return online_users
@classmethod
async def delete_online_service(cls, redis: Redis, session_id: str) -> bool:
"""
强制下线指定在线用户
参数:
- redis (Redis): Redis异步客户端实例。
- session_id (str): 在线用户会话ID。
返回:
- bool: 如果操作成功则返回True否则返回False。
"""
# 删除 token
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}")
await RedisCURD(redis).delete(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}")
log.info(f"强制下线用户会话: {session_id}")
return True
@classmethod
async def clear_online_service(cls, redis: Redis) -> bool:
"""
强制下线所有在线用户
参数:
- redis (Redis): Redis异步客户端实例。
返回:
- bool: 如果操作成功则返回True否则返回False。
"""
# 删除 token
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:*")
await RedisCURD(redis).clear(f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:*")
log.info(f"清除所有在线用户会话成功")
return True
@staticmethod
def _match_search_conditions(online_info: dict, search: OnlineQueryParam | None = None) -> bool:
"""
检查是否匹配搜索条件
参数:
- online_info (dict): 在线用户信息字典。
- search (OnlineQueryParam | None): 查询参数模型。
返回:
- bool: 如果匹配则返回True否则返回False。
"""
if not search:
return True
if search.name and search.name[1]:
keyword = search.name[1].strip('%')
if keyword.lower() not in online_info.get("name", "").lower():
return False
if search.ipaddr and search.ipaddr[1]:
keyword = search.ipaddr[1].strip('%')
if keyword not in online_info.get("ipaddr", ""):
return False
if search.login_location and search.login_location[1]:
keyword = search.login_location[1].strip('%')
if keyword.lower() not in online_info.get("login_location", "").lower():
return False
return True

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Body, Depends, Query, Request, UploadFile, Form
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse
from app.common.response import StreamResponse, SuccessResponse
from app.common.request import PaginationService
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.logger import log
from .service import ResourceService
from .schema import (
ResourceMoveSchema,
ResourceCopySchema,
ResourceRenameSchema,
ResourceCreateDirSchema,
ResourceSearchQueryParam
)
ResourceRouter = APIRouter(route_class=OperationLogRoute, prefix="/resource", tags=["资源管理"])
@ResourceRouter.get(
"/list",
summary="获取目录列表",
description="获取指定目录下的文件和子目录列表",
dependencies=[Depends(AuthPermission(["module_monitor:resource:query"]))]
)
async def get_directory_list_controller(
request: Request,
page: PaginationQueryParam = Depends(),
search: ResourceSearchQueryParam = Depends(),
) -> JSONResponse:
"""
获取目录列表
参数:
- request (Request): FastAPI请求对象用于获取基础URL。
- page (PaginationQueryParam): 分页查询参数模型。
- search (ResourceSearchQueryParam): 资源查询参数模型。
返回:
- JSONResponse: 包含目录列表的JSON响应。
"""
# 获取资源列表(与案例模块保持一致的分页实现)
result_dict_list = await ResourceService.get_resources_list_service(
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
)
log.info(f"获取目录列表成功: {getattr(search, 'name', None) or ''}")
return SuccessResponse(data=result_dict, msg="获取目录列表成功")
@ResourceRouter.post(
"/upload",
summary="上传文件",
description="上传文件到指定目录",
dependencies=[Depends(AuthPermission(["module_monitor:resource:upload"]))])
async def upload_file_controller(
file: UploadFile,
request: Request,
target_path: str | None = Form(None, description="目标目录路径")
) -> JSONResponse:
"""
上传文件
参数:
- file (UploadFile): 要上传的文件对象。
- request (Request): FastAPI请求对象用于获取基础URL。
- target_path (str | None): 目标目录路径默认为None。
返回:
- JSONResponse: 包含上传文件信息的JSON响应。
"""
result_dict = await ResourceService.upload_file_service(
file=file,
target_path=target_path,
base_url=str(request.base_url)
)
log.info(f"上传文件成功: {result_dict['filename']}")
return SuccessResponse(data=result_dict, msg="上传文件成功")
@ResourceRouter.get(
"/download",
summary="下载文件",
description="下载指定文件",
dependencies=[Depends(AuthPermission(["module_monitor:resource:download"]))]
)
async def download_file_controller(
request: Request,
path: str = Query(..., description="文件路径")
) -> FileResponse:
"""
下载文件
参数:
- request (Request): FastAPI请求对象用于获取基础URL。
- path (str): 文件路径。
返回:
- FileResponse: 包含文件内容的文件响应。
"""
file_path = await ResourceService.download_file_service(
file_path=path,
base_url=str(request.base_url)
)
# 获取文件名
import os
filename = os.path.basename(file_path)
log.info(f"下载文件成功: {filename}")
return FileResponse(
path=file_path,
filename=filename,
media_type='application/octet-stream'
)
@ResourceRouter.delete(
"/delete",
summary="删除文件",
description="删除指定文件或目录",
dependencies=[Depends(AuthPermission(["module_monitor:resource:delete"]))]
)
async def delete_files_controller(
paths: list[str] = Body(..., description="文件路径列表")
) -> JSONResponse:
"""
删除文件
参数:
- paths (list[str]): 文件路径列表。
返回:
- JSONResponse: 包含删除结果的JSON响应。
"""
await ResourceService.delete_file_service(paths=paths)
log.info(f"删除文件成功: {paths}")
return SuccessResponse(msg="删除文件成功")
@ResourceRouter.post(
"/move",
summary="移动文件",
description="移动文件或目录",
dependencies=[Depends(AuthPermission(["module_monitor:resource:move"]))]
)
async def move_file_controller(
data: ResourceMoveSchema
) -> JSONResponse:
"""
移动文件
参数:
- data (ResourceMoveSchema): 移动文件参数模型。
返回:
- JSONResponse: 包含移动结果的JSON响应。
"""
await ResourceService.move_file_service(data=data)
log.info(f"移动文件成功: {data.source_path} -> {data.target_path}")
return SuccessResponse(msg="移动文件成功")
@ResourceRouter.post(
"/copy",
summary="复制文件",
description="复制文件或目录",
dependencies=[Depends(AuthPermission(["module_monitor:resource:copy"]))]
)
async def copy_file_controller(
data: ResourceCopySchema
) -> JSONResponse:
"""
复制文件
参数:
- data (ResourceCopySchema): 复制文件参数模型。
返回:
- JSONResponse: 包含复制结果的JSON响应。
"""
await ResourceService.copy_file_service(data=data)
log.info(f"复制文件成功: {data.source_path} -> {data.target_path}")
return SuccessResponse(msg="复制文件成功")
@ResourceRouter.post(
"/rename",
summary="重命名文件",
description="重命名文件或目录",
dependencies=[Depends(AuthPermission(["module_monitor:resource:rename"]))]
)
async def rename_file_controller(
data: ResourceRenameSchema
) -> JSONResponse:
"""
重命名文件
参数:
- data (ResourceRenameSchema): 重命名文件参数模型。
返回:
- JSONResponse: 包含重命名结果的JSON响应。
"""
await ResourceService.rename_file_service(data=data)
log.info(f"重命名文件成功: {data.old_path} -> {data.new_name}")
return SuccessResponse(msg="重命名文件成功")
@ResourceRouter.post(
"/create-dir",
summary="创建目录",
description="在指定路径创建新目录",
dependencies=[Depends(AuthPermission(["module_monitor:resource:create_dir"]))]
)
async def create_directory_controller(
data: ResourceCreateDirSchema
) -> JSONResponse:
"""
创建目录
参数:
- data (ResourceCreateDirSchema): 创建目录参数模型。
返回:
- JSONResponse: 包含创建目录结果的JSON响应。
"""
await ResourceService.create_directory_service(data=data)
log.info(f"创建目录成功: {data.parent_path}/{data.dir_name}")
return SuccessResponse(msg="创建目录成功")
@ResourceRouter.post(
"/export",
summary="导出资源列表",
description="导出资源列表",
dependencies=[Depends(AuthPermission(["module_monitor:resource:export"]))]
)
async def export_resource_list_controller(
request: Request,
search: ResourceSearchQueryParam = Depends()
) -> StreamingResponse:
"""
导出资源列表
参数:
- request (Request): FastAPI请求对象用于获取基础URL。
- search (ResourceSearchQueryParam): 资源查询参数模型。
返回:
- StreamingResponse: 包含导出资源列表的流式响应。
"""
# 获取搜索结果
result_dict_list = await ResourceService.get_resources_list_service(
search=search,
base_url=str(request.base_url)
)
export_result = await ResourceService.export_resource_service(data_list=result_dict_list)
log.info("导出资源列表成功")
return StreamResponse(
data=bytes2file_response(export_result),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': 'attachment; filename=resource_list.xlsx'
}
)

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from urllib.parse import urlparse
from fastapi import Query
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)
if 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):
if not value or len(value.strip()) == 0:
raise ValueError("路径不能为空")
return value.strip()
class ResourceCopySchema(ResourceMoveSchema):
"""资源复制模型"""
pass
class ResourceRenameSchema(BaseModel):
"""资源重命名模型"""
model_config = ConfigDict(from_attributes=True)
old_path: str = Field(..., description="原路径")
new_name: str = Field(..., description="新名称")
@field_validator('old_path', 'new_name')
@classmethod
def validate_inputs(cls, value: str):
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):
# 对于parent_path允许为空字符串表示根目录或 '/',其他情况必须非空
if info.field_name == 'parent_path':
# 允许空字符串或 '/' 表示根目录
if value is None:
raise ValueError("参数不能为空")
# 对于parent_path仍然严格检查路径遍历
if '..' in value or value.startswith('\\'):
raise ValueError("参数包含不安全字符")
else: # 对于dir_name仍然严格检查
if not value or len(value.strip()) == 0:
raise ValueError("参数不能为空")
if '..' in value or value.startswith('/') or value.startswith('\\'):
raise ValueError("参数包含不安全字符")
return value.strip()
class ResourceSearchQueryParam:
"""资源搜索查询参数"""
def __init__(
self,
name: str | None = Query(None, description="搜索关键词"),
path: str | None = Query(None, description="目录路径"),
) -> None:
# 模糊查询字段
self.name = ("like", name) if name else None
# 精确查询字段
self.path = path

View File

@@ -0,0 +1,826 @@
# -*- coding: utf-8 -*-
import os
import shutil
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from fastapi import UploadFile
from app.core.exceptions import CustomException
from app.core.logger import log
from app.utils.excel_util import ExcelUtil
from app.config.setting import settings
from .schema import (
ResourceItemSchema,
ResourceDirectorySchema,
ResourceUploadSchema,
ResourceMoveSchema,
ResourceCopySchema,
ResourceRenameSchema,
ResourceCreateDirSchema,
ResourceSearchQueryParam
)
class ResourceService:
"""
资源管理模块服务层 - 管理系统静态文件目录
"""
# 配置常量
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
MAX_SEARCH_RESULTS = 1000 # 最大搜索结果数
MAX_PATH_DEPTH = 20 # 最大路径深度
@classmethod
def _get_resource_root(cls) -> str:
"""
获取资源管理根目录
返回:
- str: 资源管理根目录路径。
"""
if not settings.STATIC_ENABLE:
raise CustomException(msg='静态文件服务未启用')
return str(settings.STATIC_ROOT)
@classmethod
def _get_safe_path(cls, path: str | None = None) -> str:
"""
获取安全的文件路径
参数:
- path (str | None): 原始文件路径。
返回:
- str: 安全的文件路径。
"""
resource_root = cls._get_resource_root()
if not path:
return resource_root
# 支持前端传递的完整URL或以STATIC_URL/ROOT_PATH+STATIC_URL开头的URL路径转换为相对资源路径
if isinstance(path, str):
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://') or path.startswith('https://'):
parsed = urlparse(path)
url_path = parsed.path or ''
path = strip_prefix(url_path)
else:
path = strip_prefix(path)
# 清理路径,移除危险字符
path = path.strip().replace('..', '').replace('//', '/')
# 规范化路径
if os.path.isabs(path):
safe_path = os.path.normpath(path)
else:
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):
raise CustomException(msg=f'访问路径不在允许范围内: {path}')
# 防止路径遍历攻击
if '..' in safe_path or safe_path.count('/') > cls.MAX_PATH_DEPTH:
raise CustomException(msg=f'不安全的路径格式: {path}')
return safe_path
@classmethod
def _path_exists(cls, path: str) -> bool:
"""
检查路径是否存在
参数:
- path (str): 要检查的路径。
返回:
- bool: 如果路径存在则返回True否则返回False。
"""
try:
safe_path = cls._get_safe_path(path)
return os.path.exists(safe_path)
except:
return False
@classmethod
def _generate_http_url(cls, file_path: str, base_url: str | None = None) -> str:
"""
生成文件的HTTP URL
参数:
- file_path (str): 文件的绝对路径。
- base_url (str | None): 基础URL用于生成完整URL。
返回:
- str: 文件的HTTP URL。
"""
resource_root = cls._get_resource_root()
try:
relative_path = os.path.relpath(file_path, resource_root)
# 确保路径使用正斜杠URL格式
url_path = relative_path.replace(os.sep, '/')
except ValueError:
# 如果无法计算相对路径,使用文件名
url_path = os.path.basename(file_path)
# 如果提供了base_url使用它生成完整URL否则使用settings.STATIC_URL
if base_url:
# 修复URL生成逻辑
base_part = base_url.rstrip('/')
static_part = settings.STATIC_URL.lstrip('/')
file_part = url_path.lstrip('/')
if base_part.endswith(':') or (len(base_part) > 0 and base_part[-1] not in ['/', ':']):
base_part += '/'
http_url = f"{base_part}{static_part}/{file_part}".replace('//', '/').replace(':/', '://')
else:
http_url = f"{settings.STATIC_URL}/{url_path}".replace('//', '/')
return http_url
@classmethod
def _get_file_info(cls, file_path: str, base_url: str | None = None) -> dict:
"""
获取文件或目录的详细信息如名称、大小、创建时间、修改时间、路径、深度、HTTP URL、是否隐藏、是否为目录等。
参数:
- file_path (str): 文件或目录的路径。
- base_url (str | None): 基础URL用于生成完整URL。
返回:
- dict: 文件或目录的详细信息字典。
"""
try:
safe_path = cls._get_safe_path(file_path)
if not os.path.exists(safe_path):
return {}
stat = os.stat(safe_path)
path_obj = Path(safe_path)
resource_root = cls._get_resource_root()
# 计算相对路径
try:
relative_path = os.path.relpath(safe_path, resource_root)
except ValueError:
relative_path = os.path.basename(safe_path)
# 计算深度
try:
depth = len(Path(safe_path).relative_to(resource_root).parts)
except ValueError:
depth = 0
# 生成HTTP URL路径而不是文件系统路径
http_url = cls._generate_http_url(safe_path, base_url)
# 检查是否为隐藏文件(文件名以点开头)
is_hidden = path_obj.name.startswith('.')
# 对于目录设置is_directory字段兼容前端
is_directory = os.path.isdir(safe_path)
# 将datetime对象转换为ISO格式的字符串确保JSON序列化成功
created_time = datetime.fromtimestamp(stat.st_ctime).isoformat()
modified_time = datetime.fromtimestamp(stat.st_mtime).isoformat()
return {
'name': path_obj.name,
'file_url': http_url, # 统一使用file_url字段
'relative_path': relative_path,
'is_file': os.path.isfile(safe_path),
'is_dir': is_directory,
'size': stat.st_size if os.path.isfile(safe_path) else None,
'created_time': created_time,
'modified_time': modified_time,
'is_hidden': is_hidden
}
except Exception as e:
log.error(f'获取文件信息失败: {str(e)}')
return {}
@classmethod
async def get_directory_list_service(cls, path: str | None = None, include_hidden: bool = False, base_url: str | None = None) -> dict:
"""
获取目录列表
参数:
- path (str | None): 目录路径。如果未指定,将使用静态文件根目录。
- include_hidden (bool): 是否包含隐藏文件。
- base_url (str | None): 基础URL用于生成完整URL。
返回:
- dict: 包含目录列表和统计信息的字典。
"""
try:
# 如果没有指定路径,使用静态文件根目录
if path is None:
safe_path = cls._get_resource_root()
display_path = cls._generate_http_url(safe_path, base_url)
else:
safe_path = cls._get_safe_path(path)
display_path = cls._generate_http_url(safe_path, base_url)
if not os.path.exists(safe_path):
raise CustomException(msg='目录不存在')
if not os.path.isdir(safe_path):
raise CustomException(msg='路径不是目录')
items = []
total_files = 0
total_dirs = 0
total_size = 0
try:
for item_name in os.listdir(safe_path):
# 跳过隐藏文件
if not include_hidden and item_name.startswith('.'):
continue
item_path = os.path.join(safe_path, item_name)
file_info = cls._get_file_info(item_path, base_url)
if file_info:
items.append(ResourceItemSchema(**file_info))
if file_info['is_file']:
total_files += 1
total_size += file_info.get('size', 0) or 0
elif file_info['is_dir']:
total_dirs += 1
except PermissionError:
raise CustomException(msg='没有权限访问此目录')
return ResourceDirectorySchema(
path=display_path, # 返回HTTP URL路径而不是文件系统路径
name=os.path.basename(safe_path),
items=items,
total_files=total_files,
total_dirs=total_dirs,
total_size=total_size
).model_dump()
except CustomException:
raise
except Exception as e:
log.error(f'获取目录列表失败: {str(e)}')
raise CustomException(msg=f'获取目录列表失败: {str(e)}')
@classmethod
async def get_resources_list_service(cls, search: ResourceSearchQueryParam | None = None, order_by: str | None = None, base_url: str | None = None) -> list[dict]:
"""
搜索资源列表(用于分页和导出)
参数:
- search (ResourceSearchQueryParam | None): 查询参数模型。
- order_by (str | None): 排序参数。
- base_url (str | None): 基础URL用于生成完整URL。
返回:
- list[dict]: 资源详情字典列表。
"""
try:
# 确定搜索路径
if search and hasattr(search, 'path') and search.path:
resource_root = cls._get_safe_path(search.path)
else:
resource_root = cls._get_resource_root()
# 检查路径是否存在
if not os.path.exists(resource_root):
raise CustomException(msg='目录不存在')
if not os.path.isdir(resource_root):
raise CustomException(msg='路径不是目录')
# 收集资源
all_resources = []
try:
for item_name in os.listdir(resource_root):
# 跳过隐藏文件
if item_name.startswith('.'):
continue
item_path = os.path.join(resource_root, item_name)
file_info = cls._get_file_info(item_path, base_url)
if file_info:
# 应用名称过滤
if search and hasattr(search, 'name') and search.name and search.name[1]:
search_keyword = search.name[1].lower()
if search_keyword not in file_info.get('name', '').lower():
continue
all_resources.append(file_info)
except PermissionError:
raise CustomException(msg='没有权限访问此目录')
# 应用排序
sorted_resources = cls._sort_results(all_resources, order_by)
# 限制最大结果数
if len(sorted_resources) > cls.MAX_SEARCH_RESULTS:
sorted_resources = sorted_resources[:cls.MAX_SEARCH_RESULTS]
return sorted_resources
except Exception as e:
log.error(f'搜索资源失败: {str(e)}')
raise CustomException(msg=f'搜索资源失败: {str(e)}')
@classmethod
async def export_resource_service(cls, data_list: list[dict]) -> bytes:
"""
导出资源列表
参数:
- data_list (list[dict]): 资源详情字典列表。
返回:
- bytes: Excel文件的二进制数据。
"""
mapping_dict = {
'name': '文件名',
'path': '文件路径',
'size': '文件大小',
'created_time': '创建时间',
'modified_time': '修改时间',
'parent_path': '父目录'
}
# 复制数据并转换状态
export_data = data_list.copy()
# 格式化文件大小
for item in export_data:
if item.get('size'):
item['size'] = cls._format_file_size(item['size'])
return ExcelUtil.export_list2excel(list_data=export_data, mapping_dict=mapping_dict)
@classmethod
async def _get_directory_stats(cls, path: str, include_hidden: bool = False) -> dict[str, int]:
"""
递归获取目录统计信息
参数:
- path (str): 目录路径。
- include_hidden (bool): 是否包含隐藏文件。
返回:
- 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, IOError):
continue
except Exception:
pass
return stats
@classmethod
def _sort_results(cls, results: list[dict], order_by: str | None = None) -> list[dict]:
"""
排序搜索结果
参数:
- results (list[dict]): 资源详情字典列表。
- order_by (str | None): 排序参数。
返回:
- list[dict]: 排序后的资源详情字典列表。
"""
try:
# 默认按名称升序排序
if not order_by:
return sorted(results, key=lambda x: x.get('name', ''), reverse=False)
# 解析order_by参数格式: [{'field':'asc/desc'}]
try:
sort_conditions = eval(order_by)
if isinstance(sort_conditions, list):
# 构建排序键函数
def sort_key(item):
keys = []
for cond in sort_conditions:
field = cond.get('field', 'name')
direction = cond.get('direction', 'asc')
# 获取字段值,默认为空字符串
value = item.get(field, '')
# 如果是日期字段,转换为可比较的格式
if field in ['created_time', 'modified_time', 'accessed_time'] and value:
value = datetime.fromisoformat(value)
keys.append(value)
return keys
# 确定排序方向(这里只支持单一方向,多个条件时使用第一个条件的方向)
reverse = False
if sort_conditions and isinstance(sort_conditions[0], dict):
direction = sort_conditions[0].get('direction', '').lower()
reverse = direction == 'desc'
return sorted(results, key=sort_key, reverse=reverse)
except:
# 如果解析失败,使用默认排序
pass
return sorted(results, key=lambda x: x.get('name', ''), reverse=False)
except:
return results
@classmethod
async def upload_file_service(cls, file: UploadFile, target_path: str | None = None, base_url: str | None = None) -> dict:
"""
上传文件到指定目录
参数:
- file (UploadFile): 上传的文件对象。
- target_path (str | None): 目标目录路径。
- base_url (str | None): 基础URL用于生成完整URL。
返回:
- dict: 包含文件信息的字典。
"""
if not file or not file.filename:
raise CustomException(msg="请选择要上传的文件")
# 文件名安全检查
if '..' in file.filename or '/' in file.filename or '\\' in file.filename:
raise CustomException(msg="文件名包含不安全字符")
try:
# 检查文件大小
content = await file.read()
if len(content) > cls.MAX_UPLOAD_SIZE:
raise CustomException(msg=f"文件太大,最大支持{cls.MAX_UPLOAD_SIZE // (1024*1024)}MB")
# 确定上传目录,如果没有指定目标路径,使用静态文件根目录
if target_path is None:
safe_dir = cls._get_resource_root()
else:
safe_dir = cls._get_safe_path(target_path)
# 创建目录(如果不存在)
os.makedirs(safe_dir, exist_ok=True)
# 生成文件路径
filename = file.filename
file_path = os.path.join(safe_dir, filename)
# 检查文件是否已存在
if os.path.exists(file_path):
# 生成唯一文件名
base_name, ext = os.path.splitext(filename)
counter = 1
while os.path.exists(file_path):
new_filename = f"{base_name}_{counter}{ext}"
file_path = os.path.join(safe_dir, new_filename)
counter += 1
filename = os.path.basename(file_path)
# 保存文件(使用已读取的内容)
with open(file_path, 'wb') as f:
f.write(content)
# 获取文件信息
file_info = cls._get_file_info(file_path, base_url)
# 生成文件URL
file_url = cls._generate_http_url(file_path, base_url)
log.info(f"文件上传成功: {filename}")
return ResourceUploadSchema(
filename=filename,
file_url=file_url,
file_size=file_info.get('size', 0),
upload_time=datetime.now()
).model_dump(mode='json')
except Exception as e:
log.error(f"文件上传失败: {str(e)}")
raise CustomException(msg=f"文件上传失败: {str(e)}")
@classmethod
async def download_file_service(cls, file_path: str, base_url: str | None = None) -> str:
"""
下载文件(返回本地文件系统路径)
参数:
- file_path (str): 文件路径可为相对路径、绝对路径或完整URL
- base_url (str | None): 基础URL用于生成完整URL不再直接返回URL
返回:
- str: 本地文件系统路径。
"""
try:
safe_path = cls._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='路径不是文件')
# 返回本地文件路径给 FileResponse 使用
log.info(f"定位文件路径: {safe_path}")
return safe_path
except CustomException:
raise
except Exception as e:
log.error(f"下载文件失败: {str(e)}")
raise CustomException(msg=f"下载文件失败: {str(e)}")
@classmethod
async def delete_file_service(cls, paths: list[str]) -> None:
"""
删除文件或目录
参数:
- paths (list[str]): 文件或目录路径列表。
返回:
- None
"""
if not paths:
raise CustomException(msg='删除失败,删除路径不能为空')
for path in paths:
try:
safe_path = cls._get_safe_path(path)
if not os.path.exists(safe_path):
log.error(f"路径不存在,跳过: {path}")
continue
if os.path.isfile(safe_path):
os.remove(safe_path)
log.info(f"删除文件成功: {safe_path}")
elif os.path.isdir(safe_path):
shutil.rmtree(safe_path)
log.info(f"删除目录成功: {safe_path}")
except Exception as e:
log.error(f"删除失败 {path}: {str(e)}")
raise CustomException(msg=f"删除失败 {path}: {str(e)}")
@classmethod
async def batch_delete_service(cls, paths: list[str]) -> dict[str, list[str]]:
"""
批量删除文件或目录
参数:
- paths (List[str]): 文件或目录路径列表。
返回:
- Dict[str, List[str]]: 包含成功删除路径和失败删除路径的字典。
"""
if not paths:
raise CustomException(msg='删除失败,删除路径不能为空')
success_paths = []
failed_paths = []
for path in paths:
try:
safe_path = cls._get_safe_path(path)
if not os.path.exists(safe_path):
failed_paths.append(path)
continue
if os.path.isfile(safe_path):
os.remove(safe_path)
success_paths.append(path)
log.info(f"删除文件成功: {safe_path}")
elif os.path.isdir(safe_path):
shutil.rmtree(safe_path)
success_paths.append(path)
log.info(f"删除目录成功: {safe_path}")
except Exception as e:
log.error(f"删除失败 {path}: {str(e)}")
failed_paths.append(path)
return {
"success": success_paths,
"failed": failed_paths
}
@classmethod
async def move_file_service(cls, data: ResourceMoveSchema) -> None:
"""
移动文件或目录
参数:
- data (ResourceMoveSchema): 包含源路径和目标路径的模型。
返回:
- None
"""
try:
source_path = cls._get_safe_path(data.source_path)
target_path = cls._get_safe_path(data.target_path)
if not os.path.exists(source_path):
raise CustomException(msg='源路径不存在')
# 检查目标路径是否已存在
if os.path.exists(target_path):
if not data.overwrite:
raise CustomException(msg='目标路径已存在')
else:
# 删除目标路径
if os.path.isfile(target_path):
os.remove(target_path)
else:
shutil.rmtree(target_path)
# 确保目标目录存在
target_dir = os.path.dirname(target_path)
os.makedirs(target_dir, exist_ok=True)
# 移动文件
shutil.move(source_path, target_path)
log.info(f"移动成功: {source_path} -> {target_path}")
except CustomException:
raise
except Exception as e:
log.error(f"移动失败: {str(e)}")
raise CustomException(msg=f"移动失败: {str(e)}")
@classmethod
async def copy_file_service(cls, data: ResourceCopySchema) -> None:
"""
复制文件或目录
参数:
- data (ResourceCopySchema): 包含源路径和目标路径的模型。
返回:
- None
"""
try:
source_path = cls._get_safe_path(data.source_path)
target_path = cls._get_safe_path(data.target_path)
if not os.path.exists(source_path):
raise CustomException(msg='源路径不存在')
# 检查目标路径是否已存在
if os.path.exists(target_path) and not data.overwrite:
raise CustomException(msg='目标路径已存在')
# 确保目标目录存在
target_dir = os.path.dirname(target_path)
os.makedirs(target_dir, exist_ok=True)
# 复制文件或目录
if os.path.isfile(source_path):
shutil.copy2(source_path, target_path)
else:
shutil.copytree(source_path, target_path, dirs_exist_ok=data.overwrite)
log.info(f"复制成功: {source_path} -> {target_path}")
except CustomException:
raise
except Exception as e:
log.error(f"复制失败: {str(e)}")
raise CustomException(msg=f"复制失败: {str(e)}")
@classmethod
async def rename_file_service(cls, data: ResourceRenameSchema) -> None:
"""
重命名文件或目录
参数:
- data (ResourceRenameSchema): 包含旧路径和新名称的模型。
返回:
- None
"""
try:
old_path = cls._get_safe_path(data.old_path)
if not os.path.exists(old_path):
raise CustomException(msg='文件或目录不存在')
# 生成新路径
parent_dir = os.path.dirname(old_path)
new_path = os.path.join(parent_dir, data.new_name)
if os.path.exists(new_path):
raise CustomException(msg='目标名称已存在')
# 重命名
os.rename(old_path, new_path)
log.info(f"重命名成功: {old_path} -> {new_path}")
except CustomException:
raise
except Exception as e:
log.error(f"重命名失败: {str(e)}")
raise CustomException(msg=f"重命名失败: {str(e)}")
@classmethod
async def create_directory_service(cls, data: ResourceCreateDirSchema) -> None:
"""
创建目录
参数:
- data (ResourceCreateDirSchema): 包含父目录路径和目录名称的模型。
返回:
- None
"""
try:
parent_path = cls._get_safe_path(data.parent_path)
if not os.path.exists(parent_path):
raise CustomException(msg='父目录不存在')
if not os.path.isdir(parent_path):
raise CustomException(msg='父路径不是目录')
# 生成新目录路径
new_dir_path = os.path.join(parent_path, data.dir_name)
# 安全检查:确保新目录名称不包含路径遍历字符
if '..' in data.dir_name or '/' in data.dir_name or '\\' in data.dir_name:
raise CustomException(msg='目录名称包含不安全字符')
if os.path.exists(new_dir_path):
raise CustomException(msg='目录已存在')
# 创建目录
os.makedirs(new_dir_path)
log.info(f"创建目录成功: {new_dir_path}")
except CustomException:
raise
except Exception as e:
log.error(f"创建目录失败: {str(e)}")
raise CustomException(msg=f"创建目录失败: {str(e)}")
@classmethod
def _format_file_size(cls, size_bytes: int) -> str:
"""
格式化文件大小
参数:
- size_bytes (int): 文件大小(字节)
返回:
- str: 格式化后的文件大小字符串(例如:"123.45MB"
"""
if size_bytes == 0:
return "0B"
size_names = ["B", "KB", "MB", "GB", "TB"]
i = 0
while size_bytes >= 1024 and i < len(size_names) - 1:
size_bytes = int(size_bytes / 1024)
i += 1
return f"{size_bytes:.2f}{size_names[i]}"

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from app.common.response import SuccessResponse
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from .service import ServerService
ServerRouter = APIRouter(route_class=OperationLogRoute, prefix="/server", tags=["服务器监控"])
@ServerRouter.get(
'/info',
summary="查询服务器监控信息",
description="查询服务器监控信息",
dependencies=[Depends(AuthPermission(["module_monitor:server:query"]))]
)
async def get_monitor_server_info_controller() -> JSONResponse:
"""
查询服务器监控信息
返回:
- JSONResponse: 包含服务器监控信息的JSON响应。
"""
result_dict = await ServerService.get_server_monitor_info_service()
log.info(f'获取服务器监控信息成功: {result_dict}')
return SuccessResponse(data=result_dict, msg='获取服务器监控信息成功')

View File

@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, ConfigDict, Field
class CpuInfoSchema(BaseModel):
"""CPU信息模型"""
model_config = ConfigDict(from_attributes=True)
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):
"""内存信息模型"""
model_config = ConfigDict(from_attributes=True)
total: str = Field(description="内存总量")
used: str = Field(description="已用内存")
free: str = Field(description="剩余内存")
usage: float = Field(ge=0, le=100, description="使用率(%)")
class SysInfoSchema(BaseModel):
"""系统信息模型"""
model_config = ConfigDict(from_attributes=True)
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运行信息模型"""
model_config = ConfigDict(from_attributes=True)
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):
"""磁盘信息模型"""
model_config = ConfigDict(from_attributes=True)
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):
"""服务器监控信息模型"""
model_config = ConfigDict(from_attributes=True)
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="磁盘信息")

Some files were not shown because too many files have changed in this diff Show More