service.py 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424
  1. import io
  2. import os
  3. import re
  4. import zipfile
  5. from collections.abc import Callable
  6. from typing import Any
  7. import anyio
  8. import sqlglot
  9. from sqlglot.expressions import (
  10. Alter,
  11. Comment,
  12. Create,
  13. Delete,
  14. Drop,
  15. Insert,
  16. Table,
  17. TruncateTable,
  18. Update,
  19. )
  20. from app.api.v1.module_system.auth.schema import AuthSchema
  21. from app.common.constant import GenConstant
  22. from app.config.path_conf import BASE_DIR
  23. from app.config.setting import settings
  24. from app.core.exceptions import CustomException
  25. from app.core.logger import log
  26. from .crud import GenTableColumnCRUD, GenTableCRUD
  27. from .schema import (
  28. GenSyncColumnChange,
  29. GenSyncPreviewSchema,
  30. GenTableColumnOutSchema,
  31. GenTableColumnSchema,
  32. GenTableOutSchema,
  33. GenTableQueryParam,
  34. GenTableSchema,
  35. )
  36. from .tools.gen_util import GenUtils
  37. from .tools.jinja2_template_util import Jinja2TemplateUtil
  38. def handle_service_exception(func: Callable) -> Callable:
  39. """
  40. 服务层异步方法装饰器:透传 CustomException,其余异常包装为 CustomException。
  41. 参数:
  42. - func (Callable): 被装饰的异步可调用对象。
  43. 返回:
  44. - Callable: 包装后的可调用对象(异步)。
  45. """
  46. async def wrapper(*args, **kwargs):
  47. try:
  48. return await func(*args, **kwargs)
  49. except CustomException:
  50. raise
  51. except Exception as e:
  52. raise CustomException(msg=f"{func.__name__}执行失败: {e!s}")
  53. return wrapper
  54. _MENU_TYPE_CATALOG = 1 # 与 sys_menu.type、前端 MenuTypeEnum.CATALOG 一致
  55. _MENU_TYPE_MENU = 2
  56. class GenTableService:
  57. """代码生成业务表服务层"""
  58. @classmethod
  59. async def _effective_package_name(
  60. cls, auth: AuthSchema, parent_catalog_id: int | None, package_name: str | None
  61. ) -> str:
  62. """根据「是否选择上级目录」计算最终包名(分系统根目录)。
  63. 规则(与你描述一致):
  64. - **未选上级目录**:认为是「新分系统」,包名固定为 ``module_目录``(即 ``module_xxx``)。
  65. - **已选上级目录**:认为是「分系统内新模块」,包名继承上级目录对应的 ``module_xxx``。
  66. """
  67. pn = (package_name or "").strip()
  68. # 1) 选择上级目录:从上级菜单 route_path 第一段推断 module_xxx
  69. if parent_catalog_id is not None:
  70. from app.api.v1.module_system.menu.crud import MenuCRUD
  71. m = await MenuCRUD(auth).get_by_id_crud(parent_catalog_id)
  72. if not m:
  73. raise CustomException(msg="上级菜单不存在")
  74. route_path = (getattr(m, "route_path", None) or "").strip()
  75. # 期望形如 /module_xxx 或 /module_xxx/yyy
  76. seg = route_path.strip("/").split("/", 1)[0] if route_path else ""
  77. seg = (seg or "").strip()
  78. if seg:
  79. return seg if seg.startswith("module_") else f"module_{seg}"
  80. # 路由缺失则回退包名字段
  81. if pn:
  82. return pn if pn.startswith("module_") else f"module_{pn}"
  83. raise CustomException(msg="无法从上级目录推断分系统包名,请先完善上级目录路由")
  84. # 2) 未选上级目录:以包名字段为准,并确保 module_ 前缀
  85. if not pn:
  86. raise CustomException(msg="包名不能为空")
  87. return pn if pn.startswith("module_") else f"module_{pn}"
  88. @classmethod
  89. async def _assert_parent_menu_is_catalog(cls, auth: AuthSchema, parent_menu_id: int | None) -> None:
  90. """上级菜单仅允许目录:与前端树只展示目录一致,避免挂到菜单/按钮下。"""
  91. if parent_menu_id is None:
  92. return
  93. from app.api.v1.module_system.menu.crud import MenuCRUD
  94. m = await MenuCRUD(auth).get_by_id_crud(parent_menu_id)
  95. if not m:
  96. raise CustomException(msg="上级菜单不存在")
  97. if m.type != _MENU_TYPE_CATALOG:
  98. raise CustomException(msg="上级菜单须选择目录类型")
  99. @classmethod
  100. def _menu_route_first_segment(
  101. cls, parent_catalog_id: int | None, package_name: str, module_name: str | None
  102. ) -> str:
  103. """前端页面路由首段(与菜单 ``route_path`` 第一段一致)。
  104. 统一规则:始终使用分系统包名 ``module_xxx`` 作为路由首段。
  105. - **无上级目录**:``/module_xxx/...``(新分系统)
  106. - **有上级目录**:``/module_xxx/...``(继承上级所属分系统)
  107. """
  108. pn = (package_name or "").strip()
  109. if not pn:
  110. raise CustomException(msg="包名不能为空")
  111. return pn if pn.startswith("module_") else f"module_{pn}"
  112. @classmethod
  113. def _catalog_menu_dir_key(
  114. cls, parent_catalog_id: int | None, package_name: str, module_name: str | None
  115. ) -> str:
  116. """菜单上「模块目录」节点的 name(与路由第一段 package 独立)。
  117. 统一为 **目录 → 菜单 → 按钮**:
  118. - 目录节点固定为 ``module_name``(你填写的“模块”)
  119. - 是否选择上级目录,仅影响分系统根 ``module_xxx`` 的推断方式(见 ``_effective_package_name``)
  120. """
  121. pn = (package_name or "").strip()
  122. mn = (module_name or "").strip()
  123. if not pn:
  124. raise CustomException(msg="包名不能为空")
  125. if not mn:
  126. raise CustomException(msg="模块名不能为空")
  127. return mn
  128. @classmethod
  129. async def _get_or_create_package_directory_menu(
  130. cls,
  131. menu_crud: Any,
  132. parent_catalog_id: int | None,
  133. package_name: str,
  134. module_name: str | None,
  135. business_name: str,
  136. ) -> int:
  137. """创建或复用 type=1 模块目录;固定为「目录 → 菜单 → 按钮」中的第一层目录。"""
  138. from app.api.v1.module_system.menu.schema import MenuCreateSchema
  139. from app.utils.common_util import CamelCaseUtil
  140. pn = (package_name or "").strip()
  141. if not pn:
  142. raise CustomException(msg="包名不能为空")
  143. mn = (module_name or "").strip()
  144. dir_key = cls._catalog_menu_dir_key(parent_catalog_id, pn, module_name)
  145. if parent_catalog_id is not None:
  146. existing = await menu_crud.get(
  147. name=dir_key, type=_MENU_TYPE_CATALOG, parent_id=parent_catalog_id
  148. )
  149. else:
  150. existing = await menu_crud.get(
  151. name=dir_key, type=_MENU_TYPE_CATALOG, parent_id=("None", None)
  152. )
  153. if existing:
  154. log.info(
  155. f"代码生成:复用模块目录菜单 id={existing.id} name={dir_key!r} parent={parent_catalog_id!r}"
  156. )
  157. return int(existing.id)
  158. route_first = cls._menu_route_first_segment(parent_catalog_id, pn, module_name)
  159. # 目录菜单固定跳到模块根:/{module_xxx}/{module_name}
  160. catalog_route_path = f"/{route_first}/{mn}"
  161. redirect = f"/{route_first}/{mn}"
  162. # route_name 须唯一且体现「分系统+模块目录」,勿仅用 package(会与 module_example 根混淆)
  163. catalog_route_name = CamelCaseUtil.snake_to_camel(f"{route_first}_{mn}")
  164. created = await menu_crud.create(
  165. MenuCreateSchema(
  166. name=dir_key,
  167. type=_MENU_TYPE_CATALOG,
  168. order=9999,
  169. permission=None,
  170. icon="menu",
  171. route_name=catalog_route_name,
  172. route_path=catalog_route_path,
  173. component_path=None,
  174. redirect=redirect,
  175. hidden=False,
  176. keep_alive=True,
  177. always_show=False,
  178. title=dir_key,
  179. params=None,
  180. affix=False,
  181. parent_id=parent_catalog_id,
  182. status="0",
  183. description="模块目录(代码生成)",
  184. )
  185. )
  186. log.info(
  187. f"代码生成:新建模块目录菜单 id={created.id} name={dir_key!r} under_parent={parent_catalog_id!r}"
  188. )
  189. return int(created.id)
  190. @classmethod
  191. def normalize_and_validate_master_sub(cls, data: GenTableSchema) -> None:
  192. """
  193. 主子表业务规则:子表表名与外键列同填或同空;子表表名不得与主表相同。
  194. 参数:
  195. - data (GenTableSchema): 主表配置。
  196. 返回:
  197. - None
  198. 异常:
  199. - CustomException: 规则不满足时抛出。
  200. """
  201. sn = data.sub_table_name
  202. fk = data.sub_table_fk_name
  203. if bool(sn) ^ bool(fk):
  204. raise CustomException(msg="子表表名与子表外键列须同时填写或同时留空")
  205. tn = (data.table_name or "").strip()
  206. if sn and fk and sn == tn:
  207. raise CustomException(msg="子表表名不能与主表表名相同")
  208. @classmethod
  209. @handle_service_exception
  210. async def get_gen_table_detail_service(cls, auth: AuthSchema, table_id: int) -> dict:
  211. """获取详细信息。
  212. 参数:
  213. - auth (AuthSchema): 认证信息。
  214. - table_id (int): 业务表ID。
  215. 返回:
  216. - dict: 包含业务表详细信息的字典。
  217. """
  218. gen_table = await cls.get_gen_table_by_id_service(auth, table_id)
  219. return gen_table.model_dump()
  220. @classmethod
  221. @handle_service_exception
  222. async def get_gen_table_list_service(
  223. cls, auth: AuthSchema, search: GenTableQueryParam
  224. ) -> list[dict]:
  225. """
  226. 获取代码生成业务表列表信息。
  227. 参数:
  228. - auth (AuthSchema): 认证信息。
  229. - search (GenTableQueryParam): 查询参数模型。
  230. 返回:
  231. - list[dict]: 包含业务表列表信息的字典列表。
  232. """
  233. gen_table_list_result = await GenTableCRUD(auth=auth).get_gen_table_list(search)
  234. return [GenTableOutSchema.model_validate(obj).model_dump() for obj in gen_table_list_result]
  235. @classmethod
  236. @handle_service_exception
  237. async def get_gen_table_page_service(
  238. cls,
  239. auth: AuthSchema,
  240. page_no: int,
  241. page_size: int,
  242. search: GenTableQueryParam,
  243. order_by: list[dict[str, str]] | None = None,
  244. ) -> dict:
  245. """
  246. 分页查询代码生成业务表(数据库 OFFSET/LIMIT)。
  247. 参数:
  248. - auth (AuthSchema): 认证信息。
  249. - page_no (int): 页码。
  250. - page_size (int): 每页条数。
  251. - search (GenTableQueryParam): 查询条件。
  252. - order_by (list[dict[str, str]] | None): 排序。
  253. 返回:
  254. - dict: 分页结果。
  255. """
  256. offset = (page_no - 1) * page_size
  257. order = order_by or [{"created_time": "desc"}]
  258. return await GenTableCRUD(auth=auth).page(
  259. offset=offset,
  260. limit=page_size,
  261. order_by=order,
  262. search=search.__dict__,
  263. out_schema=GenTableOutSchema,
  264. )
  265. @classmethod
  266. @handle_service_exception
  267. async def get_gen_db_table_list_service(
  268. cls, auth: AuthSchema, search: GenTableQueryParam
  269. ) -> list[Any]:
  270. """获取数据库表列表。
  271. 参数:
  272. - auth (AuthSchema): 认证信息。
  273. - search (GenTableQueryParam): 查询参数模型。
  274. 返回:
  275. - list[Any]: 包含数据库表列表信息的任意类型列表。
  276. """
  277. gen_db_table_list_result = await GenTableCRUD(auth=auth).get_db_table_list(search)
  278. return gen_db_table_list_result
  279. @classmethod
  280. @handle_service_exception
  281. async def get_gen_db_table_page_service(
  282. cls,
  283. auth: AuthSchema,
  284. page_no: int,
  285. page_size: int,
  286. search: GenTableQueryParam,
  287. ) -> dict[str, Any]:
  288. """
  289. 数据库表列表分页(数据库侧 OFFSET/LIMIT)。
  290. 参数:
  291. - auth (AuthSchema): 认证信息。
  292. - page_no (int): 页码。
  293. - page_size (int): 每页条数。
  294. - search (GenTableQueryParam): 查询条件。
  295. 返回:
  296. - dict[str, Any]: 含 items、total、has_next 等字段。
  297. """
  298. offset = (page_no - 1) * page_size
  299. items, total = await GenTableCRUD(auth=auth).get_db_table_page(
  300. search=search, offset=offset, limit=page_size
  301. )
  302. return {
  303. "items": items,
  304. "total": total,
  305. "page_no": page_no,
  306. "page_size": page_size,
  307. "has_next": offset + page_size < total,
  308. }
  309. @classmethod
  310. @handle_service_exception
  311. async def get_gen_db_table_list_by_name_service(
  312. cls, auth: AuthSchema, table_names: list[str]
  313. ) -> list[GenTableOutSchema]:
  314. """根据表名称组获取数据库表信息。
  315. 参数:
  316. - auth (AuthSchema): 认证信息。
  317. - table_names (list[str]): 业务表名称列表。
  318. 返回:
  319. - list[GenTableOutSchema]: 包含业务表详细信息的模型列表。
  320. """
  321. gen_db_table_list_result = await GenTableCRUD(auth).get_db_table_list_by_names(table_names)
  322. # 修复:将GenDBTableSchema对象转换为字典后再传递给GenTableOutSchema
  323. result = [
  324. GenTableOutSchema(**gen_table.model_dump()) for gen_table in gen_db_table_list_result
  325. ]
  326. return result
  327. @classmethod
  328. @handle_service_exception
  329. async def import_gen_table_service(
  330. cls, auth: AuthSchema, gen_table_list: list[GenTableOutSchema]
  331. ) -> bool:
  332. """导入表结构到生成器。
  333. 参数:
  334. - auth (AuthSchema): 认证信息。
  335. - gen_table_list (list[GenTableOutSchema]): 包含业务表详细信息的模型列表。
  336. 返回:
  337. - bool: 成功时返回True,失败时抛出异常。
  338. """
  339. # 检查是否有表需要导入
  340. if not gen_table_list:
  341. raise CustomException(msg="导入的表结构不能为空")
  342. try:
  343. for table in gen_table_list:
  344. _row = {
  345. k: v
  346. for k, v in table.model_dump().items()
  347. if k in GenTableSchema.model_fields
  348. }
  349. cls.normalize_and_validate_master_sub(GenTableSchema.model_validate(_row))
  350. table_name = table.table_name
  351. # 检查表是否已存在
  352. existing_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name)
  353. if existing_table:
  354. raise CustomException(msg=f"以下表已存在,不能重复导入: {table_name}")
  355. GenUtils.init_table(table)
  356. if not table.columns:
  357. table.columns = []
  358. add_gen_table = await GenTableCRUD(auth).add_gen_table(
  359. GenTableSchema.model_validate(table.model_dump())
  360. )
  361. gen_table_columns = await GenTableColumnCRUD(auth).get_gen_db_table_columns_by_name(
  362. table_name
  363. )
  364. if len(gen_table_columns) > 0:
  365. table.id = add_gen_table.id
  366. for column in gen_table_columns:
  367. column_schema = GenTableColumnSchema(
  368. table_id=table.id,
  369. column_name=column.column_name,
  370. column_comment=column.column_comment,
  371. column_type=column.column_type,
  372. column_length=column.column_length,
  373. column_default=column.column_default,
  374. is_pk=column.is_pk,
  375. is_increment=column.is_increment,
  376. is_nullable=column.is_nullable,
  377. is_unique=column.is_unique,
  378. sort=column.sort,
  379. python_type=column.python_type,
  380. python_field=column.python_field,
  381. )
  382. GenUtils.init_column_field(column_schema, table)
  383. await GenTableColumnCRUD(auth).create_gen_table_column_crud(column_schema)
  384. return True
  385. except Exception as e:
  386. raise CustomException(msg=f"导入失败, {e!s}")
  387. @classmethod
  388. @handle_service_exception
  389. async def create_table_service(cls, auth: AuthSchema, sql: str) -> bool | None:
  390. """创建表结构并导入至代码生成模块。
  391. 参数:
  392. - auth (AuthSchema): 认证信息。
  393. - sql (str): 包含`CREATE TABLE`语句的SQL字符串。
  394. 返回:
  395. - bool | None: 成功时返回True,失败时抛出异常。
  396. """
  397. # 验证SQL非空
  398. if not sql or not sql.strip():
  399. raise CustomException(msg="SQL语句不能为空")
  400. try:
  401. # 解析SQL语句
  402. sql_statements = sqlglot.parse(sql, dialect=settings.DATABASE_TYPE)
  403. if not sql_statements:
  404. raise CustomException(msg="无法解析SQL语句,请检查SQL语法")
  405. # 校验 SQL 是否为合法的建表语句集合:
  406. # - 允许:CREATE TABLE + COMMENT ON TABLE/COLUMN +(可选)ALTER TABLE ADD CONSTRAINT ... FOREIGN KEY ...
  407. # - 禁止:DROP/DELETE/INSERT/UPDATE/TRUNCATE 等破坏性语句
  408. has_create = any(isinstance(s, Create) for s in sql_statements)
  409. if not has_create:
  410. raise CustomException(msg="sql语句不是合法的建表语句:缺少 CREATE TABLE")
  411. forbidden = (Delete, Drop, Insert, TruncateTable, Update)
  412. if any(isinstance(s, forbidden) for s in sql_statements):
  413. raise CustomException(msg="sql语句包含禁止的关键操作(DROP/DELETE/INSERT/UPDATE/TRUNCATE)")
  414. # 获取要创建的表名
  415. table_names = []
  416. for sql_statement in sql_statements:
  417. if isinstance(sql_statement, Create):
  418. table = sql_statement.find(Table)
  419. if table and table.name:
  420. table_names.append(table.name)
  421. table_names = list(set(table_names))
  422. # 创建CRUD实例
  423. gen_table_crud = GenTableCRUD(auth=auth)
  424. # 检查每个表是否已存在
  425. for table_name in table_names:
  426. # 检查数据库中是否已存在该表
  427. if await gen_table_crud.check_table_exists(table_name):
  428. raise CustomException(msg=f"表 {table_name} 已存在,请检查并修改表名后重试")
  429. # 检查代码生成模块中是否已导入该表
  430. existing_table = await gen_table_crud.get_gen_table_by_name(table_name)
  431. if existing_table:
  432. raise CustomException(
  433. msg=f"表 {table_name} 已在代码生成模块中存在,请检查并修改表名后重试"
  434. )
  435. # 表不存在,执行SQL语句创建表
  436. for sql_statement in sql_statements:
  437. # 只执行白名单语句:Create / Comment /(受限)Alter
  438. if not isinstance(sql_statement, (Create, Comment, Alter)):
  439. continue
  440. exc_sql = sql_statement.sql(dialect=settings.DATABASE_TYPE)
  441. log.info(f"执行SQL语句: {exc_sql}")
  442. # ALTER 仅允许添加外键约束,避免任意 ALTER 带来的破坏性
  443. if isinstance(sql_statement, Alter):
  444. upper = exc_sql.upper()
  445. allow = (
  446. "ALTER TABLE" in upper
  447. and "ADD" in upper
  448. and "CONSTRAINT" in upper
  449. and "FOREIGN KEY" in upper
  450. and "DROP" not in upper
  451. and "RENAME" not in upper
  452. and "SET " not in upper
  453. )
  454. if not allow:
  455. raise CustomException(
  456. msg="仅允许 ALTER TABLE ADD CONSTRAINT ... FOREIGN KEY ...(拒绝其它 ALTER)"
  457. )
  458. if not await gen_table_crud.execute_sql(exc_sql):
  459. raise CustomException(msg=f"执行SQL语句 {exc_sql} 失败,请检查数据库")
  460. return True
  461. except Exception as e:
  462. raise CustomException(msg=f"创建表结构失败: {e!s}")
  463. @classmethod
  464. @handle_service_exception
  465. async def update_gen_table_service(
  466. cls, auth: AuthSchema, data: GenTableSchema, table_id: int
  467. ) -> dict[str, Any]:
  468. """编辑业务表信息。
  469. 参数:
  470. - auth (AuthSchema): 认证信息。
  471. - data (GenTableSchema): 包含业务表详细信息的模型。
  472. - table_id (int): 业务表ID。
  473. 返回:
  474. - dict[str, Any]: 更新后的业务表信息。
  475. """
  476. # 处理params为None的情况
  477. gen_table_info = await cls.get_gen_table_by_id_service(auth, table_id)
  478. if gen_table_info.id:
  479. try:
  480. cls.normalize_and_validate_master_sub(data)
  481. await cls._assert_parent_menu_is_catalog(auth, data.parent_menu_id)
  482. # 直接调用edit_gen_table方法,它会在内部处理排除嵌套字段的逻辑
  483. result = await GenTableCRUD(auth).edit_gen_table(table_id, data)
  484. if not result:
  485. raise CustomException(msg="更新业务表信息失败")
  486. # 处理data.columns为None的情况
  487. if data.columns:
  488. for gen_table_column in data.columns:
  489. # 确保column有id字段
  490. if hasattr(gen_table_column, "id") and gen_table_column.id:
  491. column_schema = GenTableColumnSchema(**gen_table_column.model_dump())
  492. await GenTableColumnCRUD(auth).update_gen_table_column_crud(
  493. gen_table_column.id, column_schema
  494. )
  495. # 重新获取带有预加载关系的对象,避免懒加载导致的MissingGreenlet错误
  496. updated_gen_table = await GenTableCRUD(auth).get_gen_table_by_id(table_id)
  497. out = GenTableOutSchema.model_validate(updated_gen_table)
  498. await cls.set_pk_column(out)
  499. await cls.hydrate_sub_table(auth, out)
  500. return out.model_dump()
  501. except CustomException:
  502. raise
  503. except Exception as e:
  504. raise CustomException(msg=str(e))
  505. else:
  506. raise CustomException(msg="业务表不存在")
  507. @classmethod
  508. @handle_service_exception
  509. async def delete_gen_table_service(cls, auth: AuthSchema, ids: list[int]) -> None:
  510. """删除业务表信息(先删字段,再删表)。
  511. 参数:
  512. - auth (AuthSchema): 认证信息。
  513. - ids (list[int]): 业务表ID列表。
  514. 返回:
  515. - None
  516. """
  517. # 验证ID列表非空
  518. if not ids:
  519. raise CustomException(msg="ID列表不能为空")
  520. try:
  521. # 先删除相关的字段信息
  522. await GenTableColumnCRUD(auth=auth).delete_gen_table_column_by_table_id_crud(ids)
  523. # 再删除表信息
  524. await GenTableCRUD(auth=auth).delete_gen_table(ids)
  525. except Exception as e:
  526. raise CustomException(msg=str(e))
  527. @classmethod
  528. @handle_service_exception
  529. async def get_gen_table_by_id_service(
  530. cls, auth: AuthSchema, table_id: int
  531. ) -> GenTableOutSchema:
  532. """获取需要生成代码的业务表详细信息。
  533. 参数:
  534. - auth (AuthSchema): 认证信息。
  535. - table_id (int): 业务表ID。
  536. 返回:
  537. - GenTableOutSchema: 业务表详细信息模型。
  538. """
  539. gen_table = await GenTableCRUD(auth=auth).get_gen_table_by_id(table_id)
  540. if not gen_table:
  541. raise CustomException(msg="业务表不存在")
  542. result = GenTableOutSchema.model_validate(gen_table)
  543. await cls.set_pk_column(result)
  544. await cls.hydrate_sub_table(auth, result)
  545. return result
  546. @classmethod
  547. @handle_service_exception
  548. async def get_gen_table_all_service(cls, auth: AuthSchema) -> list[GenTableOutSchema]:
  549. """获取所有业务表信息(列表)。
  550. 参数:
  551. - auth (AuthSchema): 认证信息。
  552. 返回:
  553. - list[GenTableOutSchema]: 业务表详细信息模型列表。
  554. """
  555. gen_table_all = await GenTableCRUD(auth=auth).get_gen_table_all() or []
  556. result = []
  557. for gen_table in gen_table_all:
  558. try:
  559. table_out = GenTableOutSchema.model_validate(gen_table)
  560. result.append(table_out)
  561. except Exception as e:
  562. log.error(f"转换业务表时出错: {e!s}")
  563. continue
  564. return result
  565. @classmethod
  566. @handle_service_exception
  567. async def preview_code_service(cls, auth: AuthSchema, table_id: int) -> dict[str, Any]:
  568. """
  569. 预览代码(根据模板渲染内存结果)。
  570. 参数:
  571. - auth (AuthSchema): 认证信息。
  572. - table_id (int): 业务表ID。
  573. 返回:
  574. - dict[str, Any]: 文件名到渲染内容的映射。
  575. """
  576. raw = await GenTableCRUD(auth).get_gen_table_by_id(table_id)
  577. if not raw:
  578. raise CustomException(msg="业务表不存在")
  579. gen_table = GenTableOutSchema.model_validate(raw)
  580. await cls.set_pk_column(gen_table)
  581. await cls.hydrate_sub_table(auth, gen_table)
  582. cls._assert_master_sub_config_valid(gen_table)
  583. # 预览回显的路径/包名规则必须与「写入本地」一致:
  584. # - 选择上级目录:继承上级目录所属 module_xxx
  585. # - 未选上级目录:使用表单包名(并补齐 module_ 前缀)
  586. gen_table.package_name = await cls._effective_package_name(
  587. auth, gen_table.parent_menu_id, gen_table.package_name
  588. )
  589. # 子表与主表同分系统/同模块
  590. if gen_table.sub and gen_table.sub_table:
  591. gen_table.sub_table.package_name = gen_table.package_name
  592. if not (gen_table.sub_table.module_name or "").strip():
  593. gen_table.sub_table.module_name = gen_table.module_name
  594. env = Jinja2TemplateUtil.get_env()
  595. context = Jinja2TemplateUtil.prepare_context(gen_table)
  596. template_list = Jinja2TemplateUtil.get_template_list()
  597. preview_code_result: dict[str, Any] = {}
  598. for template in template_list:
  599. try:
  600. render_content = await env.get_template(template).render_async(**context)
  601. out_key = Jinja2TemplateUtil.get_file_name(template, gen_table)
  602. preview_code_result[out_key] = render_content
  603. except Exception as e:
  604. log.error(f"渲染模板 {template} 时出错: {e!s}")
  605. out_key = Jinja2TemplateUtil.get_file_name(template, gen_table)
  606. preview_code_result[out_key] = f"渲染错误: {e!s}"
  607. if gen_table.sub and gen_table.sub_table:
  608. sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(gen_table, gen_table.sub_table)
  609. sub_table = gen_table.sub_table
  610. for template in template_list:
  611. try:
  612. render_content = await env.get_template(template).render_async(**sub_ctx)
  613. out_key = Jinja2TemplateUtil.get_file_name(template, sub_table)
  614. preview_code_result[out_key] = render_content
  615. except Exception as e:
  616. log.error(f"渲染子表模板 {template} 时出错: {e!s}")
  617. out_key = Jinja2TemplateUtil.get_file_name(template, sub_table)
  618. preview_code_result[out_key] = f"渲染错误: {e!s}"
  619. return preview_code_result
  620. @classmethod
  621. @handle_service_exception
  622. async def generate_code_service(cls, auth: AuthSchema, table_name: str) -> bool:
  623. """生成代码至指定路径(安全写入+可跳过覆盖)。
  624. 菜单固定为 **目录(type=1) + 菜单(type=2) + 按钮(type=3)**:
  625. - **目录层(name)**:固定为你填写的 ``module_name``(模块)
  626. - **分系统根(package_name)**:
  627. - 选上级目录:继承上级目录所属 ``module_xxx``
  628. - 未选上级目录:使用你填写的包名(自动补齐 ``module_`` 前缀)
  629. - **页面路由**:``/{module_xxx}/{module_name}/{business_path}``
  630. - **组件路径**:``module_xxx/module_name/business_path/index``
  631. - **后端 HTTP 接口前缀**:由动态路由发现容器提供 ``/xxx``(``module_xxx``→去掉 ``module_``)
  632. 参数:
  633. - auth (AuthSchema): 认证信息。
  634. - table_name (str): 业务表名。
  635. 返回:
  636. - bool: 生成是否成功。
  637. """
  638. # 验证表名非空
  639. if not table_name or not table_name.strip():
  640. raise CustomException(msg="表名不能为空")
  641. env = Jinja2TemplateUtil.get_env()
  642. render_info = await cls.__get_gen_render_info(auth, table_name)
  643. gen_table_schema: GenTableOutSchema = render_info[3]
  644. from app.api.v1.module_system.menu.crud import MenuCRUD
  645. from app.api.v1.module_system.menu.schema import MenuCreateSchema
  646. from app.utils.common_util import CamelCaseUtil
  647. # 按“上级目录”规则矫正最终包名(分系统根)
  648. gen_table_schema.package_name = await cls._effective_package_name(
  649. auth, gen_table_schema.parent_menu_id, gen_table_schema.package_name
  650. )
  651. # 统一权限前缀(对齐 module_example/demo):
  652. # - module_xxx:module_name(操作在按钮/模板中追加 :query/:create...)
  653. pn = (gen_table_schema.package_name or "").strip()
  654. mn = (gen_table_schema.module_name or "").strip()
  655. if not mn:
  656. raise CustomException(msg="模块名不能为空")
  657. permission_prefix = ":".join([s for s in [pn, mn] if s])
  658. # 创建菜单 CRUD 实例
  659. menu_crud = MenuCRUD(auth)
  660. if not gen_table_schema.function_name:
  661. raise CustomException(msg="功能名称不能为空")
  662. if not gen_table_schema.package_name:
  663. raise CustomException(msg="包名不能为空")
  664. await cls._assert_parent_menu_is_catalog(auth, gen_table_schema.parent_menu_id)
  665. # 1. 目录 + 菜单 + 按钮:先取/建模块目录(名称规则见 _catalog_menu_dir_key)
  666. dir_menu_id = await cls._get_or_create_package_directory_menu(
  667. menu_crud,
  668. gen_table_schema.parent_menu_id,
  669. gen_table_schema.package_name,
  670. gen_table_schema.module_name,
  671. gen_table_schema.business_name or "",
  672. )
  673. # 检查同一模块目录下是否已有同名功能菜单(避免与其它模块下的同名功能冲突)
  674. existing_func_menu = await menu_crud.get(
  675. name=gen_table_schema.function_name,
  676. type=_MENU_TYPE_MENU,
  677. parent_id=dir_menu_id,
  678. )
  679. if existing_func_menu:
  680. raise CustomException(
  681. msg=f"该模块目录下功能菜单「{gen_table_schema.function_name}」已存在,不能重复创建"
  682. )
  683. route_seg = cls._menu_route_first_segment(
  684. gen_table_schema.parent_menu_id,
  685. gen_table_schema.package_name or "",
  686. gen_table_schema.module_name,
  687. )
  688. _pn = (gen_table_schema.package_name or "").strip()
  689. _mn = (gen_table_schema.module_name or "").strip()
  690. if not _mn:
  691. raise CustomException(msg="模块名不能为空")
  692. # 与 Jinja2TemplateUtil.get_file_name 统一:module_xxx/{module_name}
  693. _route_path = f"/{route_seg}/{_mn}"
  694. _component_path = f"{_pn}/{_mn}/index"
  695. # 创建功能菜单(类型=2:菜单)
  696. parent_menu = await menu_crud.create(
  697. MenuCreateSchema(
  698. name=gen_table_schema.function_name,
  699. type=_MENU_TYPE_MENU,
  700. order=9999,
  701. permission=f"{permission_prefix}:query",
  702. icon="menu",
  703. # route_name 使用模块名(对齐 module_example/demo:/example/demo)
  704. route_name=CamelCaseUtil.snake_to_camel(_mn),
  705. route_path=_route_path,
  706. component_path=_component_path,
  707. redirect=None,
  708. hidden=False,
  709. keep_alive=True,
  710. always_show=False,
  711. title=gen_table_schema.function_name,
  712. params=None,
  713. affix=False,
  714. parent_id=dir_menu_id, # 使用目录菜单ID或用户指定的parent_menu_id作为父ID
  715. status="0",
  716. description=f"{gen_table_schema.function_name}功能菜单",
  717. )
  718. )
  719. # 创建按钮权限(类型=3:按钮/权限)
  720. buttons = [
  721. {
  722. "name": f"{gen_table_schema.function_name}查询",
  723. "permission": f"{permission_prefix}:query",
  724. "order": 1,
  725. },
  726. {
  727. "name": f"{gen_table_schema.function_name}详情",
  728. "permission": f"{permission_prefix}:detail",
  729. "order": 2,
  730. },
  731. {
  732. "name": f"{gen_table_schema.function_name}新增",
  733. "permission": f"{permission_prefix}:create",
  734. "order": 3,
  735. },
  736. {
  737. "name": f"{gen_table_schema.function_name}修改",
  738. "permission": f"{permission_prefix}:update",
  739. "order": 4,
  740. },
  741. {
  742. "name": f"{gen_table_schema.function_name}删除",
  743. "permission": f"{permission_prefix}:delete",
  744. "order": 5,
  745. },
  746. {
  747. "name": f"{gen_table_schema.function_name}批量状态修改",
  748. "permission": f"{permission_prefix}:patch",
  749. "order": 6,
  750. },
  751. {
  752. "name": f"{gen_table_schema.function_name}导出",
  753. "permission": f"{permission_prefix}:export",
  754. "order": 7,
  755. },
  756. {
  757. "name": f"{gen_table_schema.function_name}导入",
  758. "permission": f"{permission_prefix}:import",
  759. "order": 8,
  760. },
  761. {
  762. "name": f"{gen_table_schema.function_name}下载导入模板",
  763. "permission": f"{permission_prefix}:download",
  764. "order": 9,
  765. },
  766. ]
  767. for button in buttons:
  768. # 检查按钮权限是否已存在
  769. await menu_crud.create(
  770. MenuCreateSchema(
  771. name=button["name"],
  772. type=3,
  773. order=button["order"],
  774. permission=button["permission"],
  775. icon=None,
  776. route_name=None,
  777. route_path=None,
  778. component_path=None,
  779. redirect=None,
  780. hidden=False,
  781. keep_alive=True,
  782. always_show=False,
  783. title=button["name"],
  784. params=None,
  785. affix=False,
  786. parent_id=parent_menu.id,
  787. status="0",
  788. description=f"{gen_table_schema.function_name}功能按钮",
  789. )
  790. )
  791. log.info(f"成功创建按钮权限: {button['name']}")
  792. log.info(f"成功创建{gen_table_schema.function_name}菜单及按钮权限")
  793. # 2. 菜单创建成功后,再生成页面代码(主表 + 可选子表)
  794. async def _write_templates(
  795. templates: list[str], ctx: dict[str, Any], table_schema: GenTableOutSchema
  796. ) -> None:
  797. for template in templates:
  798. try:
  799. render_content = await env.get_template(template).render_async(**ctx)
  800. file_name = Jinja2TemplateUtil.get_file_name(template, table_schema)
  801. full_path = BASE_DIR.parent.joinpath(file_name)
  802. gen_path = str(full_path)
  803. if not gen_path:
  804. raise CustomException(msg="【代码生成】生成路径为空")
  805. os.makedirs(os.path.dirname(gen_path), exist_ok=True)
  806. await anyio.Path(gen_path).write_text(render_content, encoding="utf-8")
  807. # Python 插件目录需保证包层级可导入:为分系统/模块目录补齐 __init__.py
  808. # 生成规则固定为 backend/app/plugin/{module_xxx}/{module_name}/...
  809. pn = (table_schema.package_name or "").strip()
  810. mn = (table_schema.module_name or "").strip()
  811. if pn and mn:
  812. plugin_base = BASE_DIR.parent.joinpath(f"backend/app/plugin/{pn}")
  813. module_base = plugin_base.joinpath(mn)
  814. for d in (plugin_base, module_base):
  815. init_path = d.joinpath("__init__.py")
  816. if not init_path.exists():
  817. os.makedirs(str(d), exist_ok=True)
  818. await anyio.Path(str(init_path)).write_text(
  819. "# -*- coding: utf-8 -*-", encoding="utf-8"
  820. )
  821. except Exception as e:
  822. raise CustomException(
  823. msg=f"渲染模板失败,表名:{table_schema.table_name},详细错误信息:{e!s}"
  824. )
  825. await _write_templates(render_info[0], render_info[2], gen_table_schema)
  826. if gen_table_schema.sub and gen_table_schema.sub_table:
  827. sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(
  828. gen_table_schema, gen_table_schema.sub_table
  829. )
  830. await _write_templates(render_info[0], sub_ctx, gen_table_schema.sub_table)
  831. return True
  832. @classmethod
  833. @handle_service_exception
  834. async def batch_gen_code_service(cls, auth: AuthSchema, table_names: list[str]) -> bytes:
  835. """
  836. 批量生成代码并打包为ZIP。
  837. - 备注:内存生成并压缩,兼容多模板类型;供下载使用。
  838. 参数:
  839. - auth (AuthSchema): 认证信息。
  840. - table_names (list[str]): 业务表名列表。
  841. 返回:
  842. - bytes: 包含所有生成代码的ZIP文件内容。
  843. """
  844. valid_names = [t.strip() for t in table_names if t and str(t).strip()]
  845. if not valid_names:
  846. raise CustomException(msg="表名列表不能为空")
  847. zip_buffer = io.BytesIO()
  848. file_count = 0
  849. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
  850. for table_name in valid_names:
  851. try:
  852. env = Jinja2TemplateUtil.get_env()
  853. render_info = await cls.__get_gen_render_info(auth, table_name)
  854. gen_tbl = render_info[3]
  855. for template_file, output_file in zip(
  856. render_info[0], render_info[1], strict=False
  857. ):
  858. render_content = await env.get_template(template_file).render_async(
  859. **render_info[2]
  860. )
  861. zip_file.writestr(output_file, render_content)
  862. file_count += 1
  863. if gen_tbl.sub and gen_tbl.sub_table:
  864. sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(
  865. gen_tbl, gen_tbl.sub_table
  866. )
  867. sub_tbl = gen_tbl.sub_table
  868. for template_file in render_info[0]:
  869. render_content = await env.get_template(template_file).render_async(
  870. **sub_ctx
  871. )
  872. out_path = Jinja2TemplateUtil.get_file_name(template_file, sub_tbl)
  873. zip_file.writestr(out_path, render_content)
  874. file_count += 1
  875. except Exception as e:
  876. log.error(f"批量生成代码时处理表 {table_name} 出错: {e!s}")
  877. # 继续处理其他表,不中断整个过程
  878. continue
  879. zip_data = zip_buffer.getvalue()
  880. zip_buffer.close()
  881. if file_count == 0:
  882. raise CustomException(
  883. msg="未能生成任何代码文件:请检查所选表是否存在于代码生成配置中,或主子表、字段配置是否正确"
  884. )
  885. return zip_data
  886. @classmethod
  887. @handle_service_exception
  888. async def sync_db_service(cls, auth: AuthSchema, table_name: str, _sync_sub: bool = True) -> None:
  889. """
  890. 同步数据库表结构到业务表。
  891. 参数:
  892. - auth (AuthSchema): 认证信息。
  893. - table_name (str): 业务表名。
  894. 返回:
  895. - None
  896. """
  897. # 验证表名非空
  898. if not table_name or not table_name.strip():
  899. raise CustomException(msg="表名不能为空")
  900. gen_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name)
  901. if not gen_table:
  902. raise CustomException(msg="业务表不存在")
  903. table = GenTableOutSchema.model_validate(gen_table)
  904. if not table.id:
  905. raise CustomException(msg="业务表ID不能为空")
  906. table_columns = table.columns or []
  907. table_column_map = {column.column_name: column for column in table_columns}
  908. # 确保db_table_columns始终是列表类型,避免None值
  909. db_table_columns = (
  910. await GenTableColumnCRUD(auth).get_gen_db_table_columns_by_name(table_name) or []
  911. )
  912. db_table_columns = [col for col in db_table_columns if col is not None]
  913. db_table_column_names = [column.column_name for column in db_table_columns]
  914. try:
  915. # 参考 RuoYi:同步 DB 元信息,但尽量保留用户“生成配置”字段(dict/html/query/python_field...)
  916. preserve_keys = {
  917. "dict_type",
  918. "query_type",
  919. "python_field",
  920. "python_type",
  921. "is_insert",
  922. "is_edit",
  923. "is_list",
  924. "is_query",
  925. "sort",
  926. }
  927. for column in db_table_columns:
  928. GenUtils.init_column_field(column, table)
  929. if column.column_name in table_column_map:
  930. prev_column = table_column_map[column.column_name]
  931. if getattr(prev_column, "id", None):
  932. column.id = prev_column.id
  933. prev_dump = prev_column.model_dump() if hasattr(prev_column, "model_dump") else {}
  934. for k in preserve_keys:
  935. if k in prev_dump and prev_dump.get(k) not in (None, ""):
  936. setattr(column, k, prev_dump.get(k))
  937. # html_type:保留用户显式选择的非 input;若仍是默认 input,则允许按 DB 重新推断
  938. prev_html = prev_dump.get("html_type")
  939. if prev_html not in (None, "", GenConstant.HTML_INPUT):
  940. column.html_type = prev_html
  941. # 主键强约束:避免历史配置导致主键出现在新增/编辑/列表/查询
  942. if bool(getattr(column, "is_pk", False)):
  943. column.is_insert = False
  944. column.is_edit = False
  945. column.is_list = False
  946. column.is_query = False
  947. column.query_type = None
  948. # is_nullable:主键列以 DB 为准,其余保留用户设置
  949. if not bool(getattr(column, "is_pk", False)) and hasattr(prev_column, "is_nullable"):
  950. column.is_nullable = prev_column.is_nullable
  951. if hasattr(column, "id") and column.id:
  952. await GenTableColumnCRUD(auth).update_gen_table_column_crud(
  953. column.id, column
  954. )
  955. else:
  956. await GenTableColumnCRUD(auth).create_gen_table_column_crud(column)
  957. else:
  958. # 设置table_id以确保新字段能正确关联到表
  959. column.table_id = table.id
  960. await GenTableColumnCRUD(auth).create_gen_table_column_crud(column)
  961. del_columns = [
  962. column
  963. for column in table_columns
  964. if column.column_name not in db_table_column_names
  965. ]
  966. if del_columns:
  967. for column in del_columns:
  968. if hasattr(column, "id") and column.id:
  969. await GenTableColumnCRUD(auth).delete_gen_table_column_by_column_id_crud([
  970. column.id
  971. ])
  972. # 主子表:若子表也已导入生成器,则一并同步子表配置(更接近 RuoYi 体验)
  973. sn = (table.sub_table_name or "").strip()
  974. fk = (table.sub_table_fk_name or "").strip()
  975. if _sync_sub and sn and fk:
  976. sub_cfg = await GenTableCRUD(auth).get_gen_table_by_name(sn)
  977. if sub_cfg:
  978. await cls.sync_db_service(auth, sn, _sync_sub=False)
  979. except Exception as e:
  980. raise CustomException(msg=f"同步失败: {e!s}")
  981. @classmethod
  982. async def hydrate_sub_table(cls, auth: AuthSchema, gen_table: GenTableOutSchema) -> None:
  983. """
  984. 主子表:优先使用已导入的子表配置,否则回退为只读 DB 结构。
  985. 对齐 RuoYi:子表宜为独立 gen_table;主表仅引用。
  986. 参数:
  987. - auth (AuthSchema): 认证信息。
  988. - gen_table (GenTableOutSchema): 主表输出模型(原地填充 sub_table、master_sub_hint 等)。
  989. 返回:
  990. - None
  991. """
  992. gen_table.master_sub_hint = None
  993. sub_name_raw = (gen_table.sub_table_name or "").strip()
  994. fk_raw = (gen_table.sub_table_fk_name or "").strip()
  995. if not sub_name_raw and not fk_raw:
  996. gen_table.sub = False
  997. gen_table.sub_table = None
  998. return
  999. if sub_name_raw and not fk_raw:
  1000. gen_table.sub = False
  1001. gen_table.sub_table = None
  1002. gen_table.master_sub_hint = "已填写子表表名,请同时填写「子表外键列」后再保存"
  1003. return
  1004. if fk_raw and not sub_name_raw:
  1005. gen_table.sub = False
  1006. gen_table.sub_table = None
  1007. gen_table.master_sub_hint = "已填写子表外键列,请同时填写「子表表名」后再保存"
  1008. return
  1009. if sub_name_raw == (gen_table.table_name or "").strip():
  1010. gen_table.sub = False
  1011. gen_table.sub_table = None
  1012. gen_table.master_sub_hint = "子表表名不能与主表表名相同"
  1013. return
  1014. # 1) 若子表已作为 gen_table 导入,则使用其 columns 配置(可控、可复用)
  1015. try:
  1016. sub_cfg_model = await GenTableCRUD(auth).get_gen_table_by_name(sub_name_raw, preload=["columns"])
  1017. except Exception:
  1018. sub_cfg_model = None
  1019. if sub_cfg_model:
  1020. sub_cfg = GenTableOutSchema.model_validate(sub_cfg_model)
  1021. await cls.set_pk_column(sub_cfg)
  1022. # 校验外键列存在于子表配置中
  1023. fk_names = {c.column_name for c in (sub_cfg.columns or []) if c.column_name}
  1024. if fk_raw not in fk_names:
  1025. gen_table.sub = False
  1026. gen_table.sub_table = None
  1027. gen_table.master_sub_hint = (
  1028. f"子表「{sub_name_raw}」已导入生成器,但其字段配置中不存在外键列「{fk_raw}」。"
  1029. "请先在子表的字段配置中同步/保存后再生成。"
  1030. )
  1031. return
  1032. gen_table.sub = True
  1033. gen_table.sub_table = sub_cfg
  1034. gen_table.master_sub_hint = (
  1035. "主子表已启用:子表字段来自「已导入的子表配置」(更接近 RuoYi 的方式)。"
  1036. "如需调整子表字段,请在列表中打开该子表进行配置。"
  1037. )
  1038. return
  1039. # 2) 回退:仅从 DB 读取结构(只读,无法配置子表字段)
  1040. try:
  1041. gen_table_columns = await GenTableColumnCRUD(auth).get_gen_db_table_columns_by_name(
  1042. sub_name_raw
  1043. )
  1044. except Exception as e:
  1045. log.warning(f"获取子表 {sub_name_raw} 字段失败: {e!s}")
  1046. gen_table.sub = False
  1047. gen_table.sub_table = None
  1048. gen_table.master_sub_hint = f"无法读取子表结构:{e!s}"
  1049. return
  1050. if not gen_table_columns:
  1051. gen_table.sub = False
  1052. gen_table.sub_table = None
  1053. gen_table.master_sub_hint = (
  1054. f"当前数据库中不存在表「{sub_name_raw}」或该表无列,请先建表再配置主子表"
  1055. )
  1056. return
  1057. fk_names = {c.column_name for c in gen_table_columns if c.column_name}
  1058. if fk_raw not in fk_names:
  1059. gen_table.sub = False
  1060. gen_table.sub_table = None
  1061. gen_table.master_sub_hint = (
  1062. f"子表「{sub_name_raw}」中不存在名为「{fk_raw}」的列,请核对外键列名"
  1063. )
  1064. return
  1065. table_comment = await GenTableCRUD(auth).get_db_table_comment(sub_name_raw)
  1066. sub = GenTableOutSchema.model_validate(
  1067. {
  1068. "id": -1,
  1069. "table_name": sub_name_raw,
  1070. "table_comment": table_comment or None,
  1071. "class_name": GenUtils.convert_class_name(sub_name_raw),
  1072. "package_name": gen_table.package_name,
  1073. "module_name": gen_table.module_name,
  1074. "business_name": sub_name_raw,
  1075. "function_name": re.sub(r"(?:表|测试)", "", table_comment or "") or sub_name_raw,
  1076. "sub_table_name": None,
  1077. "sub_table_fk_name": None,
  1078. "parent_menu_id": gen_table.parent_menu_id,
  1079. "columns": [],
  1080. "sub": False,
  1081. "sub_table": None,
  1082. }
  1083. )
  1084. for column in gen_table_columns:
  1085. col_dump = column.model_dump()
  1086. col_dump["table_id"] = -1
  1087. col_schema = GenTableColumnSchema.model_validate(col_dump)
  1088. GenUtils.init_column_field(col_schema, sub)
  1089. if sub.columns is None:
  1090. sub.columns = []
  1091. sub.columns.append(GenTableColumnOutSchema(**col_schema.model_dump()))
  1092. await cls.set_pk_column(sub)
  1093. gen_table.sub = True
  1094. gen_table.sub_table = sub
  1095. gen_table.master_sub_hint = (
  1096. "主子表已启用:当前子表仅从数据库结构读取(只读)。"
  1097. f"若要像 RuoYi 那样可配置子表字段,请先在「导入」中把子表「{sub_name_raw}」也导入生成器。"
  1098. )
  1099. @classmethod
  1100. def _sync_preview_diff(
  1101. cls,
  1102. current_cols: list[GenTableColumnOutSchema],
  1103. db_cols: list[GenTableColumnOutSchema],
  1104. ) -> tuple[list[str], list[str], list[GenSyncColumnChange], int]:
  1105. cur_map = {c.column_name: c for c in (current_cols or []) if c and c.column_name}
  1106. db_map = {c.column_name: c for c in (db_cols or []) if c and c.column_name}
  1107. cur_names = set(cur_map.keys())
  1108. db_names = set(db_map.keys())
  1109. added = sorted(db_names - cur_names)
  1110. removed = sorted(cur_names - db_names)
  1111. changed: list[GenSyncColumnChange] = []
  1112. unchanged = 0
  1113. keys = [
  1114. "column_type",
  1115. "column_comment",
  1116. "column_default",
  1117. "column_length",
  1118. "is_pk",
  1119. "is_increment",
  1120. "is_nullable",
  1121. "is_unique",
  1122. ]
  1123. for name in sorted(cur_names & db_names):
  1124. before = cur_map[name]
  1125. after = db_map[name]
  1126. diff_fields: list[str] = []
  1127. b_dump = before.model_dump()
  1128. a_dump = after.model_dump()
  1129. for k in keys:
  1130. if b_dump.get(k) != a_dump.get(k):
  1131. diff_fields.append(k)
  1132. if diff_fields:
  1133. changed.append(
  1134. GenSyncColumnChange(
  1135. column_name=name,
  1136. change_fields=diff_fields,
  1137. before={k: b_dump.get(k) for k in keys},
  1138. after={k: a_dump.get(k) for k in keys},
  1139. )
  1140. )
  1141. else:
  1142. unchanged += 1
  1143. return added, removed, changed, unchanged
  1144. @classmethod
  1145. @handle_service_exception
  1146. async def sync_db_preview_service(
  1147. cls, auth: AuthSchema, table_name: str
  1148. ) -> dict[str, Any]:
  1149. """
  1150. 同步数据库前差异预览(主表 + 可选子表)。
  1151. 参数:
  1152. - auth (AuthSchema): 认证信息。
  1153. - table_name (str): 主表物理表名。
  1154. 返回:
  1155. - dict[str, Any]: 预览差异结构(可序列化)。
  1156. 异常:
  1157. - CustomException: 表名无效或业务表不存在等。
  1158. """
  1159. if not table_name or not table_name.strip():
  1160. raise CustomException(msg="表名不能为空")
  1161. gen_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name, preload=["columns"])
  1162. if not gen_table:
  1163. raise CustomException(msg="业务表不存在")
  1164. table = GenTableOutSchema.model_validate(gen_table)
  1165. if not table.id:
  1166. raise CustomException(msg="业务表ID不能为空")
  1167. db_cols = await GenTableColumnCRUD(auth).get_gen_db_table_columns_by_name(table_name)
  1168. added, removed, changed, unchanged = cls._sync_preview_diff(
  1169. current_cols=table.columns or [],
  1170. db_cols=db_cols or [],
  1171. )
  1172. preview = GenSyncPreviewSchema(
  1173. table_name=table_name,
  1174. added=added,
  1175. removed=removed,
  1176. changed=changed,
  1177. unchanged=unchanged,
  1178. )
  1179. # 子表差异:如果启用主子表,则同时预览子表(无论子表是否已导入)
  1180. sn = (table.sub_table_name or "").strip()
  1181. fk = (table.sub_table_fk_name or "").strip()
  1182. if sn and fk:
  1183. preview.sub_table_name = sn
  1184. # 优先取“已导入的子表配置”,否则用 DB 结构(只读)
  1185. sub_cfg = await GenTableCRUD(auth).get_gen_table_by_name(sn, preload=["columns"])
  1186. if sub_cfg:
  1187. cur_sub_cols = GenTableOutSchema.model_validate(sub_cfg).columns or []
  1188. else:
  1189. cur_sub_cols = []
  1190. db_sub_cols = await GenTableColumnCRUD(auth).get_gen_db_table_columns_by_name(sn)
  1191. s_added, s_removed, s_changed, s_unchanged = cls._sync_preview_diff(
  1192. current_cols=cur_sub_cols,
  1193. db_cols=db_sub_cols or [],
  1194. )
  1195. preview.sub = GenSyncPreviewSchema(
  1196. table_name=sn,
  1197. added=s_added,
  1198. removed=s_removed,
  1199. changed=s_changed,
  1200. unchanged=s_unchanged,
  1201. )
  1202. return preview.model_dump()
  1203. @classmethod
  1204. def _assert_master_sub_config_valid(cls, gen_table: GenTableOutSchema) -> None:
  1205. """预览/生成前校验主子表配置是否可用。"""
  1206. sn = (gen_table.sub_table_name or "").strip()
  1207. fk = (gen_table.sub_table_fk_name or "").strip()
  1208. if not sn and not fk:
  1209. return
  1210. if not sn or not fk:
  1211. raise CustomException(
  1212. msg=gen_table.master_sub_hint
  1213. or "子表表名与子表外键列须同时填写或同时留空"
  1214. )
  1215. if not gen_table.sub_table:
  1216. raise CustomException(
  1217. msg=gen_table.master_sub_hint
  1218. or "无法生成主子表代码:请确认子表已在当前数据库中存在,且外键列名正确"
  1219. )
  1220. @classmethod
  1221. async def set_pk_column(cls, gen_table: GenTableOutSchema) -> None:
  1222. """设置主键列信息(主表/子表)。
  1223. - 备注:同时兼容`pk`布尔与`is_pk == '1'`字符串两种标识。
  1224. 参数:
  1225. - gen_table (GenTableOutSchema): 业务表详细信息模型。
  1226. 返回:
  1227. - None
  1228. """
  1229. if gen_table.columns:
  1230. for column in gen_table.columns:
  1231. is_pk = getattr(column, "is_pk", False)
  1232. if bool(is_pk) if isinstance(is_pk, bool) else str(is_pk) == "1":
  1233. gen_table.pk_column = column
  1234. break
  1235. # 如果没有找到主键列且有列存在,使用第一个列作为主键
  1236. if gen_table.pk_column is None and gen_table.columns:
  1237. gen_table.pk_column = gen_table.columns[0]
  1238. @classmethod
  1239. async def __get_gen_render_info(cls, auth: AuthSchema, table_name: str) -> list[Any]:
  1240. """
  1241. 获取生成代码渲染模板相关信息。
  1242. 参数:
  1243. - auth (AuthSchema): 认证对象。
  1244. - table_name (str): 业务表名称。
  1245. 返回:
  1246. - list[Any]: [模板列表, 输出文件名列表, 渲染上下文, 业务表对象]。
  1247. 异常:
  1248. - CustomException: 当业务表不存在或数据转换失败时抛出。
  1249. """
  1250. gen_table_model = await GenTableCRUD(auth=auth).get_gen_table_by_name(table_name)
  1251. # 检查表是否存在
  1252. if gen_table_model is None:
  1253. raise CustomException(msg=f"业务表 {table_name} 不存在")
  1254. gen_table = GenTableOutSchema.model_validate(gen_table_model)
  1255. # 生成代码时按“上级目录”规则矫正最终包名(不落库,仅影响本次生成/预览/下载/写入)
  1256. gen_table.package_name = await cls._effective_package_name(
  1257. auth, gen_table.parent_menu_id, gen_table.package_name
  1258. )
  1259. await cls.set_pk_column(gen_table)
  1260. await cls.hydrate_sub_table(auth, gen_table)
  1261. cls._assert_master_sub_config_valid(gen_table)
  1262. context = Jinja2TemplateUtil.prepare_context(gen_table)
  1263. template_list = Jinja2TemplateUtil.get_template_list()
  1264. output_files = [
  1265. Jinja2TemplateUtil.get_file_name(template, gen_table) for template in template_list
  1266. ]
  1267. return [template_list, output_files, context, gen_table]
  1268. class GenTableColumnService:
  1269. """代码生成业务表字段服务层"""
  1270. @classmethod
  1271. @handle_service_exception
  1272. async def get_gen_table_column_list_by_table_id_service(
  1273. cls, auth: AuthSchema, table_id: int
  1274. ) -> list[dict[str, Any]]:
  1275. """获取业务表字段列表信息(输出模型)。
  1276. 参数:
  1277. - auth (AuthSchema): 认证信息。
  1278. - table_id (int): 业务表ID。
  1279. 返回:
  1280. - list[dict[str, Any]]: 业务表字段列表,每个元素为字段详细信息字典。
  1281. """
  1282. gen_table_column_list_result = await GenTableColumnCRUD(auth).list_gen_table_column_crud({
  1283. "table_id": table_id
  1284. })
  1285. result = [
  1286. GenTableColumnOutSchema.model_validate(gen_table_column).model_dump()
  1287. for gen_table_column in gen_table_column_list_result
  1288. ]
  1289. return result