Files
FastapiAdmin/backend/app/modules/generator/gencode/schema.py
T
zhangtao 07fa789d2e fix(backend): 安全默认值、异常语义、代码生成注入面与定时任务内置处理器
本批为 t10 → t13 → t15 三轮实现 + 一轮复核驳回整改的最终形态(pytest 83 passed / ruff clean,
已部署并线上验证)。

异常语义
- CustomException 默认状态码 500 → 400(约 433 处裸调用不再被计为服务端故障),
  且「包装底层异常」的 130 处显式恢复 500;get_or_404 → 404;响应体结构不变

代码生成注入面(复核 blocker F1,二次修复)
- column_type 不再做字符串拼接:改为常量白名单派生(类型名 ∈ DB_TO_SQLALCHEMY ∪ {Boolean},
  长度仅取数字/正则捕获),彻底移除 split("(")[1]
- 列类型加形状白名单;字符串/注释/文档字符串用 \uXXXX,标识符过 safe_ident(含 Python/JS 关键字),
  .vue 文本与静态属性改 HTML 实体
- 落盘闸门由 ast.parse 升级为 assert_python_structure(语法 + 顶层语句白名单 + 与哨兵骨架逐节点比对)
  与 assert_text_structure(.vue/.ts/.toml 行结构);U+2028/2029 与 C1 控制符纳入转义

安全默认值
- 生产 CORS 不再回落 ["*"](未配置即拒绝跨域,可选 CORS_STRICT_STARTUP 直接启动失败)
- OAUTH_ALLOWED_HOSTS 不再默认 "*";新增 ALLOW_LOCALHOST_HOSTS 控制 Host 校验范围
- ALLOWED_HOSTS 收窄为按配置派生;健康检查暴露 code_exec_enabled / cors_origins_configured

定时任务
- SCHEDULER_ALLOW_CODE_EXEC 默认 False;新增 builtin:<模块名>[.<函数名>] 内置处理器路线(不经 exec)
- 判定收敛到 describe_schedulability;列表返回 schedulable / unschedulable_reason;
  启动 reconcile 与状态查询对「已启用但不可调度」打 WARN(去重、恢复后清除)
- 加固:内置处理器必须在本模块内定义或经 HANDLERS 声明,拒绝模块内导入的外部对象(复核 R1)
- 顺带修复:_WARNED_UNSCHEDULABLE 的 set.pop TypeError、job.next_run_time 缺失导致的列表 500

测试:新增 tests/test_generator_template_safety.py、test_security_defaults.py、
test_scheduler_schedulability.py(含恶意载荷、闸门红绿、静态护栏)
2026-10-04 05:12:32 +08:00

360 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from .template_safety import is_safe_identifier, is_valid_column_type, normalize_column_type, reserved_word_reason
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 GenCreateTableSqlBody(BaseModel):
"""从代码生成页提交的建表 SQL(JSON 对象,便于与前端 axios 一致)。"""
sql: str = Field(..., description="CREATE TABLE 等 DDL,可多条语句")
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 = Field(default="str", description="python类型")
python_field: str = Field(default="", description="python字段名")
# 这些开关若默认 True,会导致导入/同步时无法触发自动推断(如主键 id 误入新增/编辑/列表/查询)
# 约定:None 表示“未配置”,由 GenUtils.init_column_field 推断填充
is_insert: bool | None = Field(default=None, description="是否为新增字段(True是 False否)")
is_edit: bool | None = Field(default=None, description="是否编辑字段(True是 False否)")
is_list: bool | None = Field(default=None, description="是否列表字段(True是 False否)")
is_query: bool | None = Field(default=None, description="是否查询字段(True是 False否)")
query_type: str | None = Field(default=None, description="查询方式(等于、不等于、大于、小于、范围)")
# html_type 若给默认值会导致导入/同步时无法触发自动推断(全部变成 input)
# 约定:None 表示“未配置”,由 GenUtils.init_column_field 推断填充
html_type: str | None = Field(
default=None,
description="显示类型(文本框、文本域、下拉框、复选框、单选框、日期控件)",
)
dict_type: str | None = Field(default="", description="字典类型")
sort: int = Field(default=0, description="排序")
@field_validator("column_name", mode="before")
@classmethod
def validate_column_name(cls, v: str | None) -> str:
"""列名白名单校验:必须能直接充当 Python / JS 标识符。
列名会被原样写进 ``Mapped[..]`` 属性名、``class`` 字段名与前端 ``prop``,
是注入面之一(见 backend/audit-backend.md B1)。非法名(含引号/空格/换行/数字开头等)
在此直接拒绝,避免生成出语法不合法或可被注入的代码。
参数:
- v (str | None): 原始列名。
返回:
- str: 去空白后的合法列名;未填写时返回空串(沿用历史行为)。
异常:
- ValueError: 列名不是合法标识符时抛出。
"""
if v is None:
return ""
text = str(v).strip()
if not text:
return ""
if not is_safe_identifier(text):
reason = reserved_word_reason(text)
if reason:
raise ValueError(
f"列名 {text!r} 是 {reason},作为字段名会生成非法代码(如 class: Mapped[...]),"
f"请改名后再导入/保存(可改为 {text}_ 之类)",
)
raise ValueError(f"列名 {text!r} 不是合法的标识符(仅允许字母/数字/下划线,且不能以数字开头),代码生成器无法为它生成合法代码,请先改名")
return text
@field_validator("column_type", mode="before")
@classmethod
def validate_column_type(cls, v: str | None) -> str:
"""列类型形状白名单校验(结构化,非法即拒绝)。
允许的形状:``基名`` + 可选 ``(数字)`` / ``(数字,数字)`` + 可选 ``[]`` + 可选 ``unsigned``。
MySQL 的 ``enum(...)``/``set(...)`` 会先归一化为基名 ``enum``/``set``(其成员列表既无用途也含引号)。
为什么必须校验:列类型会参与「代码位置」派生(SQLAlchemy 类型名 + 长度/精度),
任意文本一旦被拼接进生成代码就等于注入面(见 backend/audit-backend.md B1)。
参数:
- v (str | None): 原始列类型。
返回:
- str: 归一化后的列类型;未填写时返回空串(由 SQLAlchemy 映射回落 String)。
异常:
- ValueError: 形状非法时抛出(报错带上列类型原文)。
"""
if v is None:
return ""
text = str(v).strip()
if not text:
return ""
if not is_valid_column_type(text):
raise ValueError(
f"列类型 {text!r} 形状非法:仅允许「类型名 + 可选(长度[,精度]) + 可选[] + 可选 unsigned」,例如 varchar(64)、decimal(10,2)、tinyint(1) unsigned;"
"含引号/分号/括号内非数字等内容会被拒绝(代码生成器不接收自由文本类型)",
)
return normalize_column_type(text)
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=("功能子目录/路由段;导入时默认表名;同 module_name 下多表须不同。可含斜杠表示嵌套,参考 module_example:demo、demo/subdir、gen_demo。"),
)
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=(
"写入本地须选目录类型。有值:侧栏 上级/短包名/功能/按钮,页面路由 /包名/业务名。"
"留空:侧栏 module_包名/功能/按钮,页面路由 /module_包名/业务名(与 plugin 一致);"
"后端 HTTP 接口仍为 /短名(module_xxx→/xxx)。"
),
)
description: str | None = Field(default=None, max_length=255, description="描述")
columns: list["GenTableColumnOutSchema"] | None = Field(default=None, description="表列信息")
@staticmethod
def _normalize_slug_segment(v: str, *, allow_slash: bool = False) -> str:
"""将输入规范成工程约定的路径片段:
- 小写
- 只保留 a-z 0-9 _
- 连续 _ 合并
- 首字符不能是数字(则前置 _)
- allow_slash=True 时允许多段 path(每段分别规范)
"""
raw = (v or "").strip()
if not raw:
return ""
if allow_slash:
segs = [s for s in raw.strip("/").split("/") if s.strip()]
norm = [GenTableSchema._normalize_slug_segment(s, allow_slash=False) for s in segs]
norm = [s for s in norm if s]
return "/".join(norm)
s = raw.lower()
s = re.sub(r"[^a-z0-9_]+", "_", s)
s = re.sub(r"_+", "_", s).strip("_")
if not s:
return ""
if s[0].isdigit():
s = "_" + s
return s
@field_validator("table_name")
@classmethod
def table_name_update(cls, v: str) -> str:
"""校验并规范化表名称。
参数:
- v (str): 原始表名。
返回:
- str: 去空白后的表名。
异常:
- ValueError: 表名为空时抛出。
"""
if not v:
raise ValueError("表名称不能为空")
return v.strip()
@field_validator(
"table_comment",
"class_name",
"function_name",
"description",
mode="before",
)
@classmethod
def strip_optional_text_fields(cls, v: str | None) -> str | None:
"""文本类字段统一去首尾空格;空串视为 None。
参数:
- v (str | None): 原始值。
返回:
- str | None: 非空字符串或 None。
"""
if v is None:
return None
s = str(v).strip()
return s if s else None
@field_validator("package_name", mode="before")
@classmethod
def normalize_package_name(cls, v: str | None) -> str | None:
"""包名规范:必须是 module_xxx 形态(工程约定)。
参数:
- v (str | None): 原始包名。
返回:
- str | None: 规范化后的包名或 None。
"""
if v is None:
return None
s = cls._normalize_slug_segment(str(v), allow_slash=False)
if not s:
return None
return s if s.startswith("module_") else f"module_{s}"
@field_validator("module_name", mode="before")
@classmethod
def normalize_module_name(cls, v: str | None) -> str | None:
"""模块名规范:不带 module_ 前缀;统一按 slug 规范。
参数:
- v (str | None): 原始模块名。
返回:
- str | None: 规范化后的模块名或 None。
"""
if v is None:
return None
s = cls._normalize_slug_segment(str(v), allow_slash=False)
if not s:
return None
return s.removeprefix("module_")
@field_validator("business_name", mode="before")
@classmethod
def normalize_business_name(cls, v: str | None) -> str | None:
"""业务名允许多段(如 demo/subdir);统一按 slug 规范。
参数:
- v (str | None): 原始业务名。
返回:
- str | None: 规范化后的业务名或 None。
"""
if v is None:
return None
s = cls._normalize_slug_segment(str(v), allow_slash=True)
return s if s else None
@field_validator("sub_table_name", "sub_table_fk_name", mode="before")
@classmethod
def strip_optional_sub_fields(cls, v: str | None) -> str | None:
"""主子表字段去首尾空格,空串视为未填。
参数:
- v (str | None): 原始值。
返回:
- str | None: 非空字符串或 None。
"""
if v is None:
return None
s = str(v).strip()
return s if s else None
class GenTableOutSchema(GenTableSchema, BaseSchema, UserBySchema):
"""业务表输出模型(面向控制器/前端)。"""
model_config = ConfigDict(from_attributes=True)
pk_column: GenTableColumnOutSchema | None = Field(default=None, description="主键信息")
# 子表同样需要携带 columns/pk_column 等输出字段,使用 OutSchema 便于模板与类型检查
sub_table: "GenTableOutSchema | None" = Field(default=None, description="子表信息")
sub: bool | None = Field(default=None, description="是否为子表")
master_sub_hint: str | None = Field(
default=None,
description="主子表配置说明或异常提示(仅接口输出,不落库)",
)
class GenSyncColumnChange(BaseModel):
"""同步差异:单个字段的变化项(用于预览,不落库)。"""
model_config = ConfigDict(from_attributes=True)
column_name: str = Field(..., description="列名")
change_fields: list[str] = Field(default_factory=list, description="变化字段名列表")
before: dict = Field(default_factory=dict, description="同步前(当前 gen 配置)摘要")
after: dict = Field(default_factory=dict, description="同步后(来自 DB)摘要")
class GenSyncPreviewSchema(BaseModel):
"""同步数据库前的差异预览(主表 + 可选子表)。"""
model_config = ConfigDict(from_attributes=True)
table_name: str = Field(..., description="表名")
added: list[str] = Field(default_factory=list, description="新增列(DB有、gen无)")
removed: list[str] = Field(default_factory=list, description="删除列(gen有、DB无)")
changed: list[GenSyncColumnChange] = Field(default_factory=list, description="变更列(同名但属性变化)")
unchanged: int = Field(default=0, description="未变化列数(同名且关键属性一致)")
sub_table_name: str | None = Field(default=None, description="子表表名")
sub: "GenSyncPreviewSchema | None" = Field(default=None, description="子表差异(若配置了主子表)")
class GenTableQueryParam(BaseQueryParam, UserByQueryParam):
"""代码生成业务表查询参数
- 支持按`table_name`、`table_comment`进行模糊检索(由CRUD层实现like)。
- 空值将被忽略,不参与过滤。
"""
table_name: str | None = Field(None, description="表名称", json_schema_extra={"q": "like"})
table_comment: str | None = Field(None, description="表注释", json_schema_extra={"q": "like"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})