From 0f1916e5444a1ad885e7a7709a9334c996e1cccb Mon Sep 17 00:00:00 2001 From: zhangtao <9480807882@qq.com> Date: Wed, 29 Oct 2025 01:41:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=99=A8):=20=E9=87=8D=E6=9E=84=E4=BB=A3=E7=A0=81=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=A8=A1=E6=9D=BF=E5=B9=B6=E5=A2=9E=E5=BC=BA=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构代码生成模板结构,将模板文件移动到标准目录 添加文件覆盖控制和安全路径检查,防止生成到项目外 优化生成逻辑,支持跳过已存在文件并返回统计信息 完善模板内容,增加分页查询和更多功能实现细节 --- .../v1/module_generator/gencode/controller.py | 4 - .../v1/module_generator/gencode/service.py | 32 +- backend/app/utils/jinja2_template_util.py | 38 +- .../app/v1/module_demo/python/__init__.py.j2 | 1 + .../v1/module_demo}/python/controller.py.j2 | 12 +- .../app/v1/module_demo}/python/crud.py.j2 | 27 +- .../app/v1/module_demo}/python/model.py.j2 | 5 +- .../app/v1/module_demo}/python/param.py.j2 | 0 .../app/v1/module_demo}/python/schema.py.j2 | 0 .../app/v1/module_demo}/python/service.py.j2 | 29 +- .../templates/{ => backend}/sql/sql.sql.j2 | 0 .../{vue => frontend/src/api}/api.ts.j2 | 52 ++- .../src/views/module_demo}/vue/index.vue.j2 | 376 +++++++++++++----- 13 files changed, 419 insertions(+), 157 deletions(-) create mode 100644 backend/templates/backend/app/v1/module_demo/python/__init__.py.j2 rename backend/templates/{ => backend/app/v1/module_demo}/python/controller.py.j2 (95%) rename backend/templates/{ => backend/app/v1/module_demo}/python/crud.py.j2 (54%) rename backend/templates/{ => backend/app/v1/module_demo}/python/model.py.j2 (89%) rename backend/templates/{ => backend/app/v1/module_demo}/python/param.py.j2 (100%) rename backend/templates/{ => backend/app/v1/module_demo}/python/schema.py.j2 (100%) rename backend/templates/{ => backend/app/v1/module_demo}/python/service.py.j2 (82%) rename backend/templates/{ => backend}/sql/sql.sql.j2 (100%) rename backend/templates/{vue => frontend/src/api}/api.ts.j2 (72%) rename backend/templates/{ => frontend/src/views/module_demo}/vue/index.vue.j2 (51%) diff --git a/backend/app/api/v1/module_generator/gencode/controller.py b/backend/app/api/v1/module_generator/gencode/controller.py index fa068790..3d8d753b 100644 --- a/backend/app/api/v1/module_generator/gencode/controller.py +++ b/backend/app/api/v1/module_generator/gencode/controller.py @@ -222,10 +222,6 @@ async def gen_code_local_controller( 返回: - JSONResponse: 包含生成结果的JSON响应 """ - from app.config.setting import settings - if not settings.allow_overwrite: - logger.error('【系统预设】不允许生成文件覆盖到本地') - return ErrorResponse(msg='【系统预设】不允许生成文件覆盖到本地') result = await GenTableService.generate_code_service(auth, table_name) logger.info('生成代码到指定路径成功') return SuccessResponse(msg="生成代码到指定路径成功", data=result) diff --git a/backend/app/api/v1/module_generator/gencode/service.py b/backend/app/api/v1/module_generator/gencode/service.py index eb35682e..753ea4bb 100644 --- a/backend/app/api/v1/module_generator/gencode/service.py +++ b/backend/app/api/v1/module_generator/gencode/service.py @@ -408,18 +408,35 @@ class GenTableService: """ env = Jinja2TemplateInitializerUtil.init_jinja2() render_info = await cls.__get_gen_render_info(auth, table_name) + gen_table_schema = render_info[3] + skipped = 0 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(render_info[3], template) + gen_path = cls.__get_gen_path(gen_table_schema, template) if gen_path: + # 只允许写入到项目根目录及其子目录 + project_root = os.path.realpath(str(settings.BASE_DIR.parent)) + target_path = os.path.realpath(gen_path) + if not target_path.startswith(project_root): + raise CustomException(msg='生成路径不允许,请选择项目目录内路径') + os.makedirs(os.path.dirname(gen_path), exist_ok=True) + + # 覆盖控制:存在且不允许覆盖则跳过 + if os.path.exists(gen_path) and not settings.allow_overwrite: + skipped += 1 + continue + with open(gen_path, 'w', encoding='utf-8') as f: f.write(render_content) except Exception as e: - raise CustomException(msg=f'渲染模板失败,表名:{render_info[3].table_name},详细错误信息:{str(e)}') + raise CustomException(msg=f'渲染模板失败,表名:{gen_table_schema.table_name},详细错误信息:{str(e)}') - return SuccessResponse(msg='生成代码成功') + msg = '生成代码成功' + if skipped: + msg += f'(已跳过 {skipped} 个已存在文件)' + return SuccessResponse(msg=msg) @classmethod async def batch_gen_code_service(cls, auth: AuthSchema, table_names: List[str]) -> bytes: @@ -682,13 +699,14 @@ class GenTableService: - Optional[str]: 生成的文件路径,若失败则返回None。 """ try: - gen_path = gen_table.gen_path or "" + gen_path = (gen_table.gen_path or '').strip() file_name = Jinja2TemplateUtil.get_file_name(template, gen_table) - # 修复:检查文件名是否为空 if not file_name: return None - if gen_path == '/': - return os.path.join(os.getcwd(), GEN_PATH, file_name) + # 默认写入到项目根目录(backend的上一级) + project_root = str(settings.BASE_DIR.parent) + if gen_path in ['', '/']: + return os.path.join(project_root, file_name) else: return os.path.join(gen_path, file_name) except Exception: diff --git a/backend/app/utils/jinja2_template_util.py b/backend/app/utils/jinja2_template_util.py index 59a3a8a1..53ff0d06 100644 --- a/backend/app/utils/jinja2_template_util.py +++ b/backend/app/utils/jinja2_template_util.py @@ -248,33 +248,35 @@ class Jinja2TemplateUtil: # 处理空值情况 category = tpl_category or GenConstant.TPL_CRUD templates = [ - # Python相关模板 - 'python/controller.py.j2', - 'python/service.py.j2', - 'python/crud.py.j2', - 'python/schema.py.j2', - 'python/param.py.j2', - 'python/model.py.j2', - # Vue相关模板 - f'{use_web_type}/api.ts.j2', - # SQL脚本模板 - 'sql/sql.sql.j2', + # Python相关模板(调整为实际目录) + 'backend/app/v1/module_demo/python/controller.py.j2', + 'backend/app/v1/module_demo/python/service.py.j2', + 'backend/app/v1/module_demo/python/crud.py.j2', + 'backend/app/v1/module_demo/python/schema.py.j2', + 'backend/app/v1/module_demo/python/param.py.j2', + 'backend/app/v1/module_demo/python/model.py.j2', + 'backend/app/v1/module_demo/python/__init__.py.j2', + + # Vue相关模板(API) + 'frontend/src/api/api.ts.j2', + # SQL脚本模板(调整为实际目录) + 'backend/sql/sql.sql.j2', ] if category == GenConstant.TPL_CRUD: - templates.append(f'{use_web_type}/index.vue.j2') + templates.append(f'frontend/src/views/module_demo/{use_web_type}/index.vue.j2') elif category == GenConstant.TPL_TREE: - templates.append(f'{use_web_type}/index-tree.vue.j2') + templates.append(f'frontend/src/views/module_demo/{use_web_type}/index-tree.vue.j2') elif category == GenConstant.TPL_SUB: - templates.append(f'{use_web_type}/index.vue.j2') + templates.append(f'frontend/src/views/module_demo/{use_web_type}/index.vue.j2') return templates @classmethod - def get_file_name(cls, template: List[str], gen_table: GenTableOutSchema): + def get_file_name(cls, template: str, gen_table: GenTableOutSchema): """ 根据模板生成文件名。 参数: - - template (List[str]): 模板列表。 + - template (str): 模板路径字符串。 - gen_table (GenTableOutSchema): 生成表的配置信息。 返回: @@ -299,7 +301,9 @@ class Jinja2TemplateUtil: return f'{python_path}/app/api/v1/{module_name}/{business_name}/param.py' elif 'schema.py.j2' in template: return f'{python_path}/app/api/v1/{module_name}/{business_name}/schema.py' - elif 'sql.j2' in template: + elif '__init__.py.j2' in template: + return f'{vue_path}/src/views/{module_name}/{business_name}/__init__.py' + elif 'sql.sql.j2' in template: return f'{cls.BACKEND_PROJECT_PATH}/sql/{module_name}/{business_name}_menu.sql' elif 'api.ts.j2' in template: return f'{vue_path}/src/api/{module_name}/{business_name}.ts' diff --git a/backend/templates/backend/app/v1/module_demo/python/__init__.py.j2 b/backend/templates/backend/app/v1/module_demo/python/__init__.py.j2 new file mode 100644 index 00000000..44d37d30 --- /dev/null +++ b/backend/templates/backend/app/v1/module_demo/python/__init__.py.j2 @@ -0,0 +1 @@ +# -*- coding:utf-8 -*- \ No newline at end of file diff --git a/backend/templates/python/controller.py.j2 b/backend/templates/backend/app/v1/module_demo/python/controller.py.j2 similarity index 95% rename from backend/templates/python/controller.py.j2 rename to backend/templates/backend/app/v1/module_demo/python/controller.py.j2 index 8a6f47f1..0c87e44a 100644 --- a/backend/templates/python/controller.py.j2 +++ b/backend/templates/backend/app/v1/module_demo/python/controller.py.j2 @@ -6,7 +6,6 @@ from app.common.response import SuccessResponse, StreamResponse from app.core.dependencies import AuthPermission from app.core.router_class import OperationLogRoute from app.api.v1.module_system.auth.schema import AuthSchema -from app.common.request import PaginationService from app.core.base_params import PaginationQueryParam from app.utils.common_util import bytes2file_response from app.core.logger import logger @@ -34,9 +33,14 @@ async def get_obj_list_controller( search: {{ table_name|snake_to_pascal_case }}QueryParam = Depends(), auth: AuthSchema = Depends(AuthPermission(["{{ permission_prefix }}:query"])) ) -> JSONResponse: - """查询{{ function_name }}列表接口""" - result_dict_list = await {{ table_name|snake_to_pascal_case }}Service.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) + """查询{{ function_name }}列表接口(数据库分页)""" + result_dict = await {{ table_name|snake_to_pascal_case }}Service.page_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 + ) logger.info("查询{{ function_name }}列表成功") return SuccessResponse(data=result_dict, msg="查询{{ function_name }}列表成功") diff --git a/backend/templates/python/crud.py.j2 b/backend/templates/backend/app/v1/module_demo/python/crud.py.j2 similarity index 54% rename from backend/templates/python/crud.py.j2 rename to backend/templates/backend/app/v1/module_demo/python/crud.py.j2 index db8e07a3..13c4fba4 100644 --- a/backend/templates/python/crud.py.j2 +++ b/backend/templates/backend/app/v1/module_demo/python/crud.py.j2 @@ -1,11 +1,11 @@ # -*- coding:utf-8 -*- -from typing import Dict, List, Optional, Sequence +from typing import Dict, List, Optional, Sequence, Union, Any from app.core.base_crud import CRUDBase from app.api.v1.module_system.auth.schema import AuthSchema from .model import {{ table_name|snake_to_pascal_case }}Model -from .schema import {{ table_name|snake_to_pascal_case }}CreateSchema, {{ table_name|snake_to_pascal_case }}UpdateSchema +from .schema import {{ table_name|snake_to_pascal_case }}CreateSchema, {{ table_name|snake_to_pascal_case }}UpdateSchema, {{ table_name|snake_to_pascal_case }}OutSchema class {{ table_name|snake_to_pascal_case }}CRUD(CRUDBase[{{ table_name|snake_to_pascal_case }}Model, {{ table_name|snake_to_pascal_case }}CreateSchema, {{ table_name|snake_to_pascal_case }}UpdateSchema]): @@ -15,13 +15,13 @@ class {{ table_name|snake_to_pascal_case }}CRUD(CRUDBase[{{ table_name|snake_to_ """初始化CRUD""" super().__init__(model={{ table_name|snake_to_pascal_case }}Model, auth=auth) - async def get_by_id_crud(self, id: int) -> Optional[{{ table_name|snake_to_pascal_case }}Model]: + async def get_by_id_crud(self, id: int, preload: Optional[List[Union[str, Any]]] = None) -> Optional[{{ table_name|snake_to_pascal_case }}Model]: """详情""" - return await self.get(id=id) + return await self.get(id=id, preload=preload) - async def list_crud(self, search: Optional[Dict] = None, order_by: Optional[List[Dict[str, str]]] = None) -> Sequence[{{ table_name|snake_to_pascal_case }}Model]: + async def list_crud(self, search: Optional[Dict] = None, order_by: Optional[List[Dict[str, str]]] = None, preload: Optional[List[Union[str, Any]]] = None) -> Sequence[{{ table_name|snake_to_pascal_case }}Model]: """列表查询""" - return await self.list(search=search, order_by=order_by) + return await self.list(search=search, order_by=order_by, preload=preload) async def create_crud(self, data: {{ table_name|snake_to_pascal_case }}CreateSchema) -> Optional[{{ table_name|snake_to_pascal_case }}Model]: """创建""" @@ -37,4 +37,17 @@ class {{ table_name|snake_to_pascal_case }}CRUD(CRUDBase[{{ table_name|snake_to_ async def set_available_crud(self, ids: List[int], status: bool) -> None: """批量设置可用状态""" - return await self.set(ids=ids, status=status) \ No newline at end of file + return await self.set(ids=ids, status=status) + + async def page_crud(self, offset: int, limit: int, order_by: Optional[List[Dict[str, str]]] = None, search: Optional[Dict] = None, preload: Optional[List[Union[str, Any]]] = 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={{ table_name|snake_to_pascal_case }}OutSchema, + preload=preload + ) \ No newline at end of file diff --git a/backend/templates/python/model.py.j2 b/backend/templates/backend/app/v1/module_demo/python/model.py.j2 similarity index 89% rename from backend/templates/python/model.py.j2 rename to backend/templates/backend/app/v1/module_demo/python/model.py.j2 index 466c706b..d6596b2a 100644 --- a/backend/templates/python/model.py.j2 +++ b/backend/templates/backend/app/v1/module_demo/python/model.py.j2 @@ -2,8 +2,8 @@ from datetime import datetime from typing import Optional -from sqlalchemy import String, Integer, Text, DateTime -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Integer, Text, DateTime, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.base_model import CreatorMixin @@ -15,6 +15,7 @@ class {{ table_name|snake_to_pascal_case }}Model(CreatorMixin): __tablename__ = '{{ table_name }}' __table_args__ = {'comment': '{{ function_name }}'} + __loader_options__ = ["creator"] {% for column in columns %} {{ column.column_name }}: Mapped[Optional[{{ column.python_type }}]] = mapped_column({{ column.column_type|get_sqlalchemy_type }}, nullable=True, comment='{{ column.column_comment }}') diff --git a/backend/templates/python/param.py.j2 b/backend/templates/backend/app/v1/module_demo/python/param.py.j2 similarity index 100% rename from backend/templates/python/param.py.j2 rename to backend/templates/backend/app/v1/module_demo/python/param.py.j2 diff --git a/backend/templates/python/schema.py.j2 b/backend/templates/backend/app/v1/module_demo/python/schema.py.j2 similarity index 100% rename from backend/templates/python/schema.py.j2 rename to backend/templates/backend/app/v1/module_demo/python/schema.py.j2 diff --git a/backend/templates/python/service.py.j2 b/backend/templates/backend/app/v1/module_demo/python/service.py.j2 similarity index 82% rename from backend/templates/python/service.py.j2 rename to backend/templates/backend/app/v1/module_demo/python/service.py.j2 index 90930c64..5178d10a 100644 --- a/backend/templates/python/service.py.j2 +++ b/backend/templates/backend/app/v1/module_demo/python/service.py.j2 @@ -34,6 +34,20 @@ class {{ table_name|snake_to_pascal_case }}Service: search_dict = search.__dict__ if search else None obj_list = await {{ table_name|snake_to_pascal_case }}CRUD(auth).list_crud(search=search_dict, order_by=order_by) return [{{ table_name|snake_to_pascal_case }}OutSchema.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: Optional[{{ table_name|snake_to_pascal_case }}QueryParam] = None, order_by: Optional[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 {{ table_name|snake_to_pascal_case }}CRUD(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: {{ table_name|snake_to_pascal_case }}CreateSchema) -> Dict: @@ -80,6 +94,17 @@ class {{ table_name|snake_to_pascal_case }}Service: } data = obj_list.copy() + for item in data: + # 状态转换 + if 'status' in item: + item['status'] = '正常' if item.get('status') 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 @@ -117,7 +142,9 @@ class {{ table_name|snake_to_pascal_case }}Service: "{{ column.column_name }}": row['{{ column.column_name }}'], {% endfor %} } - await {{ table_name|snake_to_pascal_case }}CRUD(auth).create(data=data) + # 使用CreateSchema做校验后入库 + create_schema = {{ table_name|snake_to_pascal_case }}CreateSchema.model_validate(data) + await {{ table_name|snake_to_pascal_case }}CRUD(auth).create_crud(data=create_schema) success_count += 1 except Exception as e: error_msgs.append(f"第{count}行: {str(e)}") diff --git a/backend/templates/sql/sql.sql.j2 b/backend/templates/backend/sql/sql.sql.j2 similarity index 100% rename from backend/templates/sql/sql.sql.j2 rename to backend/templates/backend/sql/sql.sql.j2 diff --git a/backend/templates/vue/api.ts.j2 b/backend/templates/frontend/src/api/api.ts.j2 similarity index 72% rename from backend/templates/vue/api.ts.j2 rename to backend/templates/frontend/src/api/api.ts.j2 index ace5bd73..acd67923 100644 --- a/backend/templates/vue/api.ts.j2 +++ b/backend/templates/frontend/src/api/api.ts.j2 @@ -1,6 +1,6 @@ import request from "@/utils/request"; -const API_PATH = "/{{ module_name }}/{{ business_name }}"; +const API_PATH = "/{{ module_name }}/{{ business_name|lower }}"; // 参考 demo.ts 的风格,提供标准的 CRUD 与导入/导出 API(TypeScript) const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = { @@ -60,7 +60,7 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = { // 下载导入模板 downloadTemplate() { - return request({ + return request({ url: `${API_PATH}/download/template`, method: "post", responseType: "blob", @@ -68,7 +68,7 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = { }, // 导入 - import(data: any) { + import(data: FormData) { return request({ url: `${API_PATH}/import`, method: "post", @@ -76,6 +76,14 @@ const {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API = { headers: { "Content-Type": "multipart/form-data" }, }); }, + // 批量启用/停用 + batchAvailable(body: { ids: number[]; status: boolean }) { + return request({ + url: `${API_PATH}/available/setting`, + method: "patch", + data: body, + }); + }, }; export default {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API; @@ -86,13 +94,13 @@ export default {{ business_name|replace('_', ' ')|title|replace(' ', '') }}API; export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}PageQuery extends PageQuery { {% for column in columns %} - {% if column.is_query == "1" and column.query_type != "BETWEEN" %} - {{ column.python_field }}?: {{ - 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') - else 'number' if column.is_pk == '1' - else 'string' - }}; - {% endif %} + {% if column.is_query == "1" and column.query_type != "BETWEEN" %} + {{ column.python_field }}?: {{ + 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') + else 'number' if column.is_pk == '1' + else 'string' + }}; + {% endif %} {% endfor %} // 时间范围查询(按示例统一字段名,如需按字段拆分可在页面层处理) start_time?: string; @@ -101,11 +109,11 @@ export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Pag export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Table { {% for column in columns %} - {{ column.python_field }}?: {{ - 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') - else 'number' if column.is_pk == '1' - else 'string' - }}; + {{ column.python_field }}?: {{ + 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') + else 'number' if column.is_pk == '1' + else 'string' + }}; {% endfor %} creator?: creatorType; } @@ -113,12 +121,12 @@ export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Tab export interface {{ business_name|replace('_', ' ')|title|replace(' ', '') }}Form { id?: number; {% for column in columns %} - {% if column.is_insert == "1" or column.is_edit == "1" %} - {{ column.python_field }}?: {{ - 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') - else 'number' if column.is_pk == '1' - else 'string' - }}; - {% endif %} + {% if column.is_insert == "1" or column.is_edit == "1" %} + {{ column.python_field }}?: {{ + 'boolean' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') + else 'number' if column.is_pk == '1' + else 'string' + }}; + {% endif %} {% endfor %} } diff --git a/backend/templates/vue/index.vue.j2 b/backend/templates/frontend/src/views/module_demo/vue/index.vue.j2 similarity index 51% rename from backend/templates/vue/index.vue.j2 rename to backend/templates/frontend/src/views/module_demo/vue/index.vue.j2 index ceecd2e6..84cc18b5 100644 --- a/backend/templates/vue/index.vue.j2 +++ b/backend/templates/frontend/src/views/module_demo/vue/index.vue.j2 @@ -1,8 +1,8 @@