Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/utils/template_util.py
T
insistence 726883f68a feat: 新增跨时区支持并完善定时任务调度 (#127)
* feat: 新增跨时区支持

* perf: 优化代码

* fix: 修复测试

* perf: 优化调度器日志打印
2026-09-12 17:01:05 +08:00

574 lines
22 KiB
Python

import json
import os
import re
from typing import Any
from jinja2 import Environment, FileSystemLoader
from common.constant import GenConstant
from config.env import DataBaseConfig
from exceptions.exception import ServiceWarning
from module_generator.entity.vo.gen_vo import GenTableColumnModel, GenTableModel
from utils.common_util import CamelCaseUtil, SnakeCaseUtil
from utils.string_util import StringUtil
from utils.time_util import GenTimeUtil, TimezoneUtil
class TemplateInitializer:
"""
模板引擎初始化类
"""
@classmethod
def init_jinja2(cls) -> Environment:
"""
初始化 Jinja2 模板引擎
:return: Jinja2 环境对象
"""
try:
template_dir = os.path.join(os.getcwd(), 'module_generator', 'templates')
env = Environment(
loader=FileSystemLoader(template_dir),
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
env.filters.update(
{
'camel_to_snake': SnakeCaseUtil.camel_to_snake,
'snake_to_camel': CamelCaseUtil.snake_to_camel,
'get_sqlalchemy_type': TemplateUtils.get_sqlalchemy_type,
}
)
return env
except Exception as e:
raise RuntimeError(f'初始化Jinja2模板引擎失败: {e}') from e
class TemplateUtils:
"""
模板工具类
"""
# 项目路径
FRONTEND_PROJECT_PATH = 'frontend'
BACKEND_PROJECT_PATH = 'backend'
DEFAULT_PARENT_MENU_ID = '3'
FORM_COL_SPAN_MAP = {2: '12', 3: '8'}
@classmethod
def prepare_context(cls, gen_table: GenTableModel) -> dict[str, Any]:
"""
准备模板变量
:param gen_table: 生成表的配置信息
:return: 模板上下文字典
"""
if not gen_table.options:
raise ServiceWarning(message='请先完善生成配置信息')
gen_table = gen_table.model_copy(deep=True)
cls.prepare_time_columns(gen_table)
class_name = gen_table.class_name
module_name = gen_table.module_name
business_name = gen_table.business_name
package_name = gen_table.package_name
tpl_category = gen_table.tpl_category
function_name = gen_table.function_name
source_name = gen_table.data_source_name or DataBaseConfig.db_default_source
source_config = DataBaseConfig.get_source(source_name)
default_source_name = DataBaseConfig.db_default_source
context = {
'tplCategory': tpl_category,
'tableName': gen_table.table_name,
'tableComment': gen_table.table_comment,
'functionName': function_name if StringUtil.is_not_empty(function_name) else '【请填写功能名称】',
'ClassName': class_name,
'className': class_name.lower(),
'moduleName': module_name,
'BusinessName': business_name.capitalize(),
'businessName': business_name,
'basePackage': cls.get_package_prefix(package_name),
'packageName': package_name,
'author': gen_table.function_author,
'colSpan': cls.get_col_span(gen_table.form_col_num),
'datetime': TimezoneUtil.to_business_time(TimezoneUtil.utc_now()).strftime('%Y-%m-%d %H:%M:%S'),
'pkColumn': gen_table.pk_column,
'doImportList': cls.get_do_import_list(gen_table),
'voImportList': cls.get_vo_import_list(gen_table),
'permissionPrefix': cls.get_permission_prefix(module_name, business_name),
'columns': gen_table.columns,
'instantFields': [
column.python_field
for column in gen_table.columns or []
if column.time_kind == 'instant' and (column.insert or column.edit)
],
'instantQueryFields': [
column.python_field
for column in gen_table.columns or []
if column.time_kind == 'instant' and column.query and column.query_type != 'BETWEEN'
],
'subInstantFields': [
column.python_field for column in gen_table.sub_table.columns or [] if column.time_kind == 'instant'
]
if gen_table.sub_table
else [],
'table': gen_table,
'dicts': cls.get_dicts(gen_table),
'dbType': source_config.db_type,
'dataSourceName': source_name,
'defaultDataSourceName': default_source_name,
'dbSessionDependency': (
f"DBSessionDependency('{source_name}')"
if source_name != default_source_name
else 'DBSessionDependency()'
),
'column_not_add_show': GenConstant.COLUMNNAME_NOT_ADD_SHOW,
'column_not_edit_show': GenConstant.COLUMNNAME_NOT_EDIT_SHOW,
}
# 设置扩展功能、菜单、树形结构、子表的上下文
cls.set_extensions_context(context, gen_table)
cls.set_menu_context(context, gen_table)
if tpl_category == GenConstant.TPL_TREE:
cls.set_tree_context(context, gen_table)
if tpl_category == GenConstant.TPL_SUB:
cls.set_sub_context(context, gen_table)
return context
@classmethod
def prepare_time_columns(cls, gen_table: GenTableModel) -> None:
"""
规范化模板副本中的主表和子表时间字段
只处理模板副本,不改写数据库中保存的生成配置。
:param gen_table: 用于渲染的生成表配置副本
"""
db_type = DataBaseConfig.get_source(gen_table.data_source_name).db_type
for table in [gen_table, gen_table.sub_table]:
if table is None:
continue
for column in table.columns or []:
try:
semantics = GenTimeUtil.get_time_semantics(column.column_type, db_type)
GenTimeUtil.validate_time_control(column.column_type, db_type, column.html_type)
except ServiceWarning as exc:
raise ServiceWarning(message=f'{table.table_name}.{column.column_name}: {exc.message}') from exc
column.vo_type = column.python_type
if semantics:
column.time_kind = semantics.kind
column.python_type = semantics.python_type
column.vo_type = semantics.vo_type
column.html_type = semantics.control
@classmethod
def set_extensions_context(cls, context: dict, gen_table: GenTableModel) -> None:
"""
设置扩展功能上下文
:param context: 模板上下文字典
:param gen_table: 生成表的配置信息
"""
params_obj = json.loads(gen_table.options) if gen_table.options else None
context['genView'] = cls.get_gen_view(params_obj)
@classmethod
def set_menu_context(cls, context: dict, gen_table: GenTableModel) -> None:
"""
设置菜单上下文
:param context: 模板上下文字典
:param gen_table: 生成表的配置信息
:return: 新的模板上下文字典
"""
options = gen_table.options
params_obj = json.loads(options)
context['parentMenuId'] = cls.get_parent_menu_id(params_obj)
@classmethod
def set_tree_context(cls, context: dict, gen_table: GenTableModel) -> None:
"""
设置树形结构上下文
:param context: 模板上下文字典
:param gen_table: 生成表的配置信息
:return: 新的模板上下文字典
"""
options = gen_table.options
params_obj = json.loads(options)
context['treeCode'] = cls.get_tree_code(params_obj)
context['treeParentCode'] = cls.get_tree_parent_code(params_obj)
context['treeName'] = cls.get_tree_name(params_obj)
context['expandColumn'] = cls.get_expand_column(gen_table)
@classmethod
def set_sub_context(cls, context: dict, gen_table: GenTableModel) -> None:
"""
设置子表上下文
:param context: 模板上下文字典
:param gen_table: 生成表的配置信息
:return: 新的模板上下文字典
"""
sub_table = gen_table.sub_table
sub_table_name = gen_table.sub_table_name
sub_table_fk_name = gen_table.sub_table_fk_name
sub_class_name = sub_table.class_name
sub_table_fk_class_name = StringUtil.convert_to_camel_case(sub_table_fk_name)
context['subTable'] = sub_table
context['subTableName'] = sub_table_name
context['subTableFkName'] = sub_table_fk_name
context['subTableFkClassName'] = sub_table_fk_class_name
context['subTableFkclassName'] = sub_table_fk_class_name[0].lower() + sub_table_fk_class_name[1:]
context['subClassName'] = sub_class_name
context['subclassName'] = sub_class_name.lower()
@classmethod
def get_template_list(cls, gen_table: GenTableModel) -> list[str]:
"""
获取模板列表
:param gen_table: 生成表的配置信息
:return: 模板列表
"""
tpl_category = gen_table.tpl_category
tpl_web_type = gen_table.tpl_web_type
params_obj = json.loads(gen_table.options) if gen_table.options else None
use_web_type = 'vue'
if tpl_web_type == 'element-plus':
use_web_type = 'vue/v3'
templates = [
'python/controller.py.jinja2',
'python/dao.py.jinja2',
'python/do.py.jinja2',
'python/service.py.jinja2',
'python/vo.py.jinja2',
'sql/sql.jinja2',
'js/api.js.jinja2',
]
if tpl_category == GenConstant.TPL_CRUD:
templates.append(f'{use_web_type}/index.vue.jinja2')
elif tpl_category == GenConstant.TPL_TREE:
templates.append(f'{use_web_type}/index-tree.vue.jinja2')
elif tpl_category == GenConstant.TPL_SUB:
templates.append(f'{use_web_type}/index.vue.jinja2')
# templates.append('python/sub-domain.python.jinja2')
if cls.get_gen_view(params_obj):
templates.append(f'{use_web_type}/view.vue.jinja2')
return templates
@classmethod
def get_file_name(cls, template: str, gen_table: GenTableModel) -> str:
"""
根据模板生成文件名
:param template: 模板路径
:param gen_table: 生成表的配置信息
:return: 模板生成文件名
"""
package_name = gen_table.package_name
module_name = gen_table.module_name
business_name = gen_table.business_name
vue_path = cls.FRONTEND_PROJECT_PATH
python_path = f'{cls.BACKEND_PROJECT_PATH}/{package_name.replace(".", "/")}'
if 'controller.py.jinja2' in template:
return f'{python_path}/controller/{business_name}_controller.py'
if 'dao.py.jinja2' in template:
return f'{python_path}/dao/{business_name}_dao.py'
if 'do.py.jinja2' in template:
return f'{python_path}/entity/do/{business_name}_do.py'
if 'service.py.jinja2' in template:
return f'{python_path}/service/{business_name}_service.py'
if 'vo.py.jinja2' in template:
return f'{python_path}/entity/vo/{business_name}_vo.py'
if 'sql.jinja2' in template:
return f'{cls.BACKEND_PROJECT_PATH}/sql/{business_name}_menu.sql'
if 'api.js.jinja2' in template:
return f'{vue_path}/api/{module_name}/{business_name}.js'
if 'index.vue.jinja2' in template or 'index-tree.vue.jinja2' in template:
return f'{vue_path}/views/{module_name}/{business_name}/index.vue'
if 'view.vue.jinja2' in template:
return f'{vue_path}/views/{module_name}/{business_name}/view.vue'
return ''
@classmethod
def get_package_prefix(cls, package_name: str) -> str:
"""
获取包前缀
:param package_name: 包名
:return: 包前缀
"""
return package_name[: package_name.rfind('.')]
@classmethod
def get_vo_import_list(cls, gen_table: GenTableModel) -> list[str]:
"""
获取vo模板导入包列表
:param gen_table: 生成表的配置信息
:return: 导入包列表
"""
import_list = set()
db_type = DataBaseConfig.get_source(gen_table.data_source_name).db_type
for table in [gen_table, gen_table.sub_table]:
if table is None:
continue
for column in table.columns or []:
semantics = GenTimeUtil.get_time_semantics(column.column_type, db_type)
if semantics:
import_list.add(f'from common.types import {semantics.vo_type}')
elif column.python_type == GenConstant.TYPE_DECIMAL:
import_list.add('from decimal import Decimal')
if any(
column.query_type == 'BETWEEN' and GenTimeUtil.get_time_semantics(column.column_type, db_type)
for column in gen_table.columns or []
):
import_list.add('from common.types import BusinessDate')
return cls.merge_same_imports(sorted(import_list), 'from common.types import')
@classmethod
def get_do_import_list(cls, gen_table: GenTableModel) -> list[str]:
"""
根据实际ORM列类型构建生成模型的导入列表
:param gen_table: 生成表配置信息
:return: 已合并同源导入的语句列表
"""
import_list = {'from sqlalchemy import Column'}
if gen_table.sub:
import_list.add('from sqlalchemy import ForeignKey')
for table in [gen_table, gen_table.sub_table]:
if table is None:
continue
for column in table.columns or []:
sqlalchemy_type = cls.get_sqlalchemy_type(column.column_type, gen_table.data_source_name)
if sqlalchemy_type == 'DbUtcDateTime':
if column.column_name not in {'create_time', 'update_time'}:
import_list.add('from common.types import DbUtcDateTime')
elif sqlalchemy_type == 'Geometry':
import_list.add('from geoalchemy2 import Geometry')
elif sqlalchemy_type.startswith('TIME('):
dialect = DataBaseConfig.get_source(gen_table.data_source_name).db_type
import_list.add(f'from sqlalchemy.dialects.{dialect} import TIME')
elif sqlalchemy_type.split('(', 1)[0] in {'JSONB', 'INET', 'CIDR', 'MACADDR'}:
import_list.add(f'from sqlalchemy.dialects.postgresql import {sqlalchemy_type}')
else:
import_list.add(f'from sqlalchemy import {sqlalchemy_type.split("(", 1)[0]}')
return cls.merge_same_imports(sorted(import_list), 'from sqlalchemy import')
@classmethod
def get_db_type(cls, column_type: str) -> str:
"""
获取规范化的数据库列类型
:param column_type: 数据库列类型
:return: 保留时区限定的规范化类型名称
"""
return GenTimeUtil.normalize_db_type(column_type)
@classmethod
def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]:
"""
合并相同的导入语句
:param imports: 导入语句列表
:param import_start: 导入语句的起始字符串
:return: 合并后的导入语句列表
"""
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: GenTableModel) -> str:
"""
获取字典列表
:param gen_table: 生成表的配置信息
:return: 字典列表
"""
columns = gen_table.columns or []
dicts = set()
cls.add_dicts(dicts, columns)
if gen_table.sub_table is not None:
cls.add_dicts(dicts, gen_table.sub_table.columns)
return ', '.join(dicts)
@classmethod
def add_dicts(cls, dicts: set[str], columns: list[GenTableColumnModel]) -> None:
"""
添加字典列表
:param dicts: 字典列表
:param columns: 字段列表
:return: 新的字典列表
"""
for column in columns:
if (
not column.super_column
and StringUtil.is_not_empty(column.dict_type)
and StringUtil.equals_any_ignore_case(
column.html_type, [GenConstant.HTML_SELECT, GenConstant.HTML_RADIO, GenConstant.HTML_CHECKBOX]
)
):
dicts.add(f"'{column.dict_type}'")
@classmethod
def get_permission_prefix(cls, module_name: str, business_name: str) -> str:
"""
获取权限前缀
:param module_name: 模块名
:param business_name: 业务名
:return: 权限前缀
"""
return f'{module_name}:{business_name}'
@classmethod
def get_col_span(cls, form_col_num: int | None) -> str:
"""
根据表单列数获取栅格跨度
:param form_col_num: 表单列数
:return: 栅格跨度
"""
return cls.FORM_COL_SPAN_MAP.get(form_col_num, '24')
@classmethod
def get_parent_menu_id(cls, params_obj: dict) -> str:
"""
获取上级菜单ID
:param params_obj: 菜单参数字典
:return: 上级菜单ID
"""
if params_obj and params_obj.get(GenConstant.PARENT_MENU_ID):
return params_obj.get(GenConstant.PARENT_MENU_ID)
return cls.DEFAULT_PARENT_MENU_ID
@classmethod
def get_gen_view(cls, params_obj: dict | None) -> bool:
"""
获取是否生成详情页
:param params_obj: 生成选项
:return: 是否生成详情页
"""
if not params_obj or GenConstant.GEN_VIEW not in params_obj:
return False
value = params_obj[GenConstant.GEN_VIEW]
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value == 1
return str(value).lower() in {'1', 'true', 'yes', 'y'}
@classmethod
def get_tree_code(cls, params_obj: dict) -> str:
"""
获取树编码
:param params_obj: 菜单参数字典
:return: 树编码
"""
if GenConstant.TREE_CODE in params_obj:
return cls.to_camel_case(params_obj.get(GenConstant.TREE_CODE))
return ''
@classmethod
def get_tree_parent_code(cls, params_obj: dict) -> str:
"""
获取树父编码
:param params_obj: 菜单参数字典
:return: 树父编码
"""
if GenConstant.TREE_PARENT_CODE in params_obj:
return cls.to_camel_case(params_obj.get(GenConstant.TREE_PARENT_CODE))
return ''
@classmethod
def get_tree_name(cls, params_obj: dict) -> str:
"""
获取树名称
:param params_obj: 菜单参数字典
:return: 树名称
"""
if GenConstant.TREE_NAME in params_obj:
return cls.to_camel_case(params_obj.get(GenConstant.TREE_NAME))
return ''
@classmethod
def get_expand_column(cls, gen_table: GenTableModel) -> int:
"""
获取展开列
:param gen_table: 生成表的配置信息
:return: 展开列
"""
options = gen_table.options
params_obj = json.loads(options)
tree_name = params_obj.get(GenConstant.TREE_NAME)
num = 0
for column in gen_table.columns or []:
if column.list:
num += 1
if column.column_name == tree_name:
break
return num
@classmethod
def to_camel_case(cls, text: str) -> str:
"""
将字符串转换为驼峰命名
:param text: 待转换的字符串
:return: 转换后的驼峰命名字符串
"""
parts = text.split('_')
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
@classmethod
def get_sqlalchemy_type(cls, column_type: str, source_name: str | None = None) -> str:
"""
获取SQLAlchemy类型
:param column_type: 列类型
:param source_name: 数据源名称
:return: SQLAlchemy类型
"""
source_config = DataBaseConfig.get_source(source_name)
normalized = column_type.lower().strip()
base = GenTimeUtil.normalize_db_type(normalized)
semantics = GenTimeUtil.get_time_semantics(column_type, source_config.db_type)
if semantics:
precision = re.search(r'\((\d+)\)', normalized)
if semantics.kind == 'time' and precision:
keyword = 'fsp' if source_config.db_type == 'mysql' else 'precision'
return f'TIME({keyword}={precision.group(1)})'
return semantics.sqlalchemy_type
type_mapping = GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING[source_config.db_type]
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(type_mapping, base) or 'String'
if '(' in normalized and sqlalchemy_type in {'String', 'CHAR', 'Numeric', 'DECIMAL'}:
precision = re.search(r'\(([^)]*)\)', normalized).group(1)
return f'{sqlalchemy_type}({precision})'
return sqlalchemy_type