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(含恶意载荷、闸门红绿、静态护栏)
This commit is contained in:
zhangtao
2026-10-04 05:12:32 +08:00
parent 007864051a
commit 07fa789d2e
39 changed files with 3031 additions and 265 deletions
+71 -8
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from typing import Any, Literal
from urllib.parse import quote_plus
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.common.enums import EnvironmentEnum
@@ -54,7 +55,8 @@ class Settings(BaseSettings):
# ================================================= #
# ******************** 跨域配置 ******************** #
# ================================================= #
PROD_CORS_ORIGINS: str = "" # 生产环境允许的域名列表,逗号分隔,如 "https://admin.example.com,https://www.example.com"
PROD_CORS_ORIGINS: str = "" # 生产环境允许的域名列表,逗号分隔,如 "https://admin.example.com,https://www.example.com";生产留空=拒绝全部跨域请求
CORS_STRICT_STARTUP: bool = False # True 时「生产未配置 PROD_CORS_ORIGINS」直接启动失败(默认只告警,保持向后兼容)
ALLOW_METHODS: list[str] = ["*"] # 允许的HTTP方法
ALLOW_HEADERS: list[str] = ["*"] # 允许的请求头
ALLOW_CREDENTIALS: bool = True # 是否允许携带cookie
@@ -124,7 +126,7 @@ class Settings(BaseSettings):
# ================================================= #
# ******************* 任务调度配置 ****************** #
# ================================================= #
SCHEDULER_ALLOW_CODE_EXEC: bool = True # 是否允许定时任务执行用户提交的代码块(exec)。等同远程代码执行能力,生产环境强烈建议设为 False
SCHEDULER_ALLOW_CODE_EXEC: bool = False # 是否允许定时任务执行用户提交的代码块(exec)。等同远程代码执行能力,默认关闭,仅在显式配置为 true 时启用
# ================================================= #
# ******************* 口令策略配置 ****************** #
@@ -147,7 +149,9 @@ class Settings(BaseSettings):
OAUTH_QQ_APP_ID: str = ""
OAUTH_QQ_APP_SECRET: str = ""
OAUTH_STATE_TTL: int = 600 # OAuth state 参数过期时间(秒)
OAUTH_ALLOWED_HOSTS: list[str] = ["*"]
# 生产回跳域名白名单(**不再是通配符 "*"**):OAuth 回调域名必须命中其中之一。
# 非生产环境通常用 localhost/容器域名登录,故默认一并放行本机与回环地址;生产可按需收敛。
OAUTH_ALLOWED_HOSTS: list[str] = ["service.fastapiadmin.com", "*.fastapiadmin.com", "localhost", "127.0.0.1", "[::1]"]
# ================================================= #
# *************** 微信小程序配置(可选)************** #
@@ -182,7 +186,11 @@ class Settings(BaseSettings):
# ================================================= #
# ******************* 安全中间件配置 ****************** #
# ================================================= #
ALLOWED_HOSTS: list[str] = ["service.fastapiadmin.com", "*.fastapiadmin.com"] # 允许访问的主机名列表
ALLOWED_HOSTS: list[str] = ["service.fastapiadmin.com", "*.fastapiadmin.com"] # 允许访问的主机名列表(生产域名)
# 容器内健康检查走 http://localhost:8001、本地直连调试都需要 Host: localhost/127.0.0.1/[::1];
# 该放行范围由下面的开关控制——生产若不需要(例如健康检查改走 127.0.0.1 之外的内部域名)可置 False。
ALLOW_LOCALHOST_HOSTS: bool = True
LOCALHOST_HOSTS: list[str] = ["localhost", "127.0.0.1", "[::1]"] # ALLOW_LOCALHOST_HOSTS=True 时自动并入 ALLOWED_HOSTS
# 接口白名单(无需认证即可访问的接口路径,支持 * 开头表示前缀匹配)
WHITE_API_LIST_PATH: list[str] = [
@@ -236,15 +244,70 @@ class Settings(BaseSettings):
OPENAI_MODEL: str = ""
OPENAI_BASE_URL: str = "" # API Base URL,如 https://api.minimax.chat/v1
@model_validator(mode="after")
def _merge_localhost_hosts(self) -> "Settings":
"""按开关把 localhost/回环地址并入 ALLOWED_HOSTS。
健康检查与本地联调需要 ``Host: localhost``;把它们做成可配置项(而不是无条件写死),
生产可以用 ``ALLOW_LOCALHOST_HOSTS=False`` 收敛 Host 头校验范围。
返回:
- Settings: 自身(pydantic after-validator 约定)。
"""
if self.ALLOW_LOCALHOST_HOSTS:
for host in self.LOCALHOST_HOSTS:
if host not in self.ALLOWED_HOSTS:
self.ALLOWED_HOSTS.append(host)
return self
# ================================================= #
# ******************* 动态配置 ******************* #
# ================================================= #
@property
def ALLOW_ORIGINS(self) -> list[str]:
"""根据环境动态返回 CORS 允许的域名列表。"""
if self.ENVIRONMENT == EnvironmentEnum.PROD and self.PROD_CORS_ORIGINS:
return [origin.strip() for origin in self.PROD_CORS_ORIGINS.split(",") if origin.strip()]
return ["*"]
"""根据环境动态返回 CORS 允许的域名列表。
安全默认(见 backend/audit-security.md S9):
- 非生产环境:返回 ``["*"]``,保留本地联调便利;
- 生产环境:只接受**显式配置**的域名清单。未配置、或清单里只有通配符 ``"*"`` 时返回空列表
(空列表 = 不下发任何跨域许可响应头;同源部署的 Web 端不受影响),并打 warning 提示运维配置。
历史行为是"未配置就回落 ``["*"]``",叠加 ``ALLOW_CREDENTIALS=True`` 等价于允许任意站点
带凭据跨域访问。
"""
if self.ENVIRONMENT != EnvironmentEnum.PROD:
return ["*"]
origins = [origin.strip() for origin in (self.PROD_CORS_ORIGINS or "").split(",") if origin.strip()]
if "*" in origins:
origins = [origin for origin in origins if origin != "*"]
self._warn_insecure_cors(
"生产环境 CORS 不允许通配符 '*'(与 ALLOW_CREDENTIALS=True 组合会放行任意站点带凭据跨域),已忽略该值;请显式配置 PROD_CORS_ORIGINS",
)
if not origins:
message = "生产环境未显式配置 PROD_CORS_ORIGINS,已按安全默认拒绝全部跨域请求(同源部署不受影响);如需跨域请在 .env.prod 中配置 PROD_CORS_ORIGINS"
if self.CORS_STRICT_STARTUP:
# 严格模式:宁可启动失败,也不要带着"跨域被全量拒绝"的隐性配置上线
raise RuntimeError(f"CORS_STRICT_STARTUP=True 且 {message}")
self._warn_insecure_cors(message)
return origins
@staticmethod
def _warn_insecure_cors(message: str) -> None:
"""输出安全告警(日志不可用时静默,绝不影响配置读取)。
参数:
- message (str): 告警内容。
返回:
- None
"""
# 延迟导入:app.core.logger 会反向 import 本模块,模块级导入会形成循环依赖
try:
from app.core.logger import logger
logger.warning(message)
except Exception: # noqa: BLE001 - 告警失败不得影响配置读取
pass
# ================================================= #
@property
+185 -8
View File
@@ -1,5 +1,7 @@
import asyncio
import json
import re
from collections.abc import Callable
from datetime import datetime
from typing import Any
@@ -94,6 +96,16 @@ class SchedulerUtil:
"""定时任务 SDK — 封装 APScheduler 核心操作与任务执行记录落库。"""
redis_instance: Redis | None = None
# ── 内置处理器白名单(「脚本类」与「内置函数类」任务的分界)────────────────
# 节点任务 func 形如 "builtin:<模块名>[.<函数名>]"(模块限定在 handlers 包内)时,
# 由注册表解析后**直接调用**,不经过 exec —— 因此不受 SCHEDULER_ALLOW_CODE_EXEC 限制。
# 这样该开关只关闭"执行用户提交代码块"的能力,而不是整个调度特性。
BUILTIN_PREFIX = "builtin:"
BUILTIN_HANDLERS_MODULE = "app.modules.task.cronjob.node.handlers"
# 名称白名单:仅允许「模块名」或「模块名.函数名」,禁止点号穿越 / 括号 / 特殊字符
_BUILTIN_NAME_RE = re.compile(r"^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$")
_builtin_handlers: dict[str, Callable[..., Any]] = {}
# 多 worker 选主状态:_redis 为空视为单机直跑;_leader_token 非空表示本进程持有调度锁
_redis: Redis | None = None
_leader_token: str | None = None
@@ -244,6 +256,16 @@ class SchedulerUtil:
continue
want_ids.add(str(node.id))
# 可观测性(t15):把"已启用但当前不会执行"的节点与存量 job 显式打出来,
# 否则开关调整后只会表现为"任务不动了",运维无从定位
for node in nodes:
if node.status != 0 or not node.trigger:
continue
schedulable, reason = cls.describe_schedulability(node.func)
if not schedulable:
logger.warning(f"节点任务不可调度(已启用但不会执行): id={node.id} name={node.name!r} 原因={reason}")
cls.log_unschedulable_jobs(context="启动自愈")
for job_id in existing:
if job_id.isdigit() and job_id not in want_ids:
try:
@@ -419,7 +441,7 @@ class SchedulerUtil:
"status": status,
"error": _humanize_job_error(detail) if status == JOB_STATUS_FAILED else None,
"result": None if status == JOB_STATUS_FAILED else (str(detail)[:4000] if detail is not None else None),
"next_run_time": str(job.next_run_time) if job and job.next_run_time else None,
"next_run_time": str(getattr(job, "next_run_time", None)) if getattr(job, "next_run_time", None) else None,
"job_state": SchedulerUtil._get_job_state(job) if job else None,
}
cls._record_job_log(record)
@@ -434,7 +456,7 @@ class SchedulerUtil:
pass
return
# 一次性 date 计划执行完成(无下次运行时间)后移除
if job and job.next_run_time is None and isinstance(getattr(job, "trigger", None), DateTrigger):
if job and getattr(job, "next_run_time", None) is None and isinstance(getattr(job, "trigger", None), DateTrigger):
try:
SchedulerUtil.remove_job(job_id=job_id)
logger.info(f"一次性 date 任务 {job_id} 已完成,已从调度器移除")
@@ -508,7 +530,8 @@ class SchedulerUtil:
job = cls.get_job(job_id=str(job_id))
if not job:
return 3
if job.next_run_time is None:
# pending/未排程任务没有 next_run_time 属性(APScheduler 只在真正排程后设置),必须 getattr 兜底
if getattr(job, "next_run_time", None) is None:
return 1
if scheduler.state == 0:
return 2
@@ -540,16 +563,170 @@ class SchedulerUtil:
logger.info(f"任务 {job_id} 已触发立即执行,临时任务 ID: {temp_job_id}")
return temp_job
@classmethod
def describe_schedulability(cls, code_block: str | None) -> tuple[bool, str | None]:
"""判断某个 func(代码块 / ``builtin:`` 引用)当前是否可调度。
单一事实来源:创建/更新守卫、节点列表状态字段、调度器巡检日志都调用它,
避免各处对 ``SCHEDULER_ALLOW_CODE_EXEC`` 的理解不一致。
参数:
- code_block (str | None): 节点的 func 字段。
返回:
- tuple[bool, str | None]: (是否可调度, 不可调度原因)。
"""
raw = (code_block or "").strip()
if not raw:
return False, "未配置函数或代码块(节点不会执行)"
if raw.startswith(cls.BUILTIN_PREFIX):
try:
cls.resolve_builtin_handler(raw)
except ValueError as exc:
return False, f"内置处理器不可用:{exc}"
return True, None
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
return False, (
"服务端已禁用代码块执行(SCHEDULER_ALLOW_CODE_EXEC=False);"
"请改用内置函数型任务——把 func 改为 builtin:<模块名>[.<函数名>](不经 exec,不受该开关限制)"
)
return True, None
@classmethod
def job_code_block(cls, job: Job | None) -> str | None:
"""从调度器 job 的 args 中取回 func(约定见 _add_job_with_trigger: args=[job_id, code_block, *args])。
参数:
- job (Job | None): 调度器任务。
返回:
- str | None: func 原文;无法取得时返回 None。
"""
args = list(getattr(job, "args", None) or [])
if len(args) < 2 or args[1] is None:
return None
return str(args[1])
@classmethod
def iter_unschedulable_jobs(cls) -> list[tuple[str, str, str]]:
"""列出调度器中「已注册但当前不可执行」的任务:[(job_id, job_name, reason)]。
用于运维可观测性:开关调整或内置处理器改名后,存量任务不会静默失效。
返回:
- list[tuple[str, str, str]]: 不可调度任务清单。
"""
result: list[tuple[str, str, str]] = []
for job in cls.get_jobs():
schedulable, reason = cls.describe_schedulability(cls.job_code_block(job))
if not schedulable and reason:
result.append((str(job.id), str(job.name or ""), reason))
return result
@classmethod
def log_unschedulable_jobs(cls, *, context: str) -> list[tuple[str, str, str]]:
"""把「已注册但不可执行」的任务以 WARN 打出(带具体任务标识),返回同一清单。
参数:
- context (str): 调用场景(如 "启动自愈" / "调度器状态查询"),便于日志定位。
返回:
- list[tuple[str, str, str]]: 不可调度任务清单。
"""
found = cls.iter_unschedulable_jobs()
for job_id, job_name, reason in found:
logger.warning(f"[{context}] 调度器中的任务当前不可执行: id={job_id} name={job_name!r} 原因={reason}")
return found
@classmethod
def register_builtin_handler(cls, name: str, handler: Callable[..., Any]) -> None:
"""注册一个内置处理器(供无 exec 的「内置函数型任务」使用)。
参数:
- name (str): 名称(``func = "builtin:<name>"`` 时引用)。
- handler (Callable): 可调用对象。
返回:
- None
"""
cls._builtin_handlers[name] = handler
@classmethod
def resolve_builtin_handler(cls, code_block: str | None) -> Callable[..., Any] | None:
"""解析 ``builtin:<name>`` 引用;非内置引用返回 None。
参数:
- code_block (str | None): 节点的 func 字段。
返回:
- Callable | None: 内置处理器;不是内置引用时返回 None。
异常:
- ValueError: 引用形态非法、模块/函数不存在或不是可调用对象时抛出
(**绝不回退到 exec**,避免"看起来是内置任务、实际被执行成代码块")。
"""
raw = (code_block or "").strip()
if not raw.startswith(cls.BUILTIN_PREFIX):
return None
name = raw[len(cls.BUILTIN_PREFIX) :].strip()
if not cls._BUILTIN_NAME_RE.match(name):
raise ValueError(f"内置处理器名称非法(仅允许 模块名[.函数名],实际 {raw!r})")
cached = cls._builtin_handlers.get(name)
if cached is not None:
return cached
module_name, _, func_name = name.partition(".")
func_name = func_name or module_name
import importlib
module_path = f"{cls.BUILTIN_HANDLERS_MODULE}.{module_name}"
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as exc:
raise ValueError(f"内置处理器模块不存在:{module_path}") from exc
handler = getattr(module, func_name, None)
if handler is None or not callable(handler):
raise ValueError(f"内置处理器不可用({module_path}.{func_name} 不存在或不可调用)")
# 安全加固(复核 R1):只接受「在本模块内定义」或「该模块显式声明」的可调用对象。
# 否则 handler 模块里任何一条 `from os import system` 之类的导入,都会变成
# 绕过 SCHEDULER_ALLOW_CODE_EXEC 的无限制执行入口。
declared = getattr(module, "HANDLERS", None)
if isinstance(declared, dict):
if declared.get(func_name) is not handler:
raise ValueError(f"内置处理器未在该模块的 HANDLERS 中声明:{module_path}.{func_name}")
elif getattr(handler, "__module__", None) != module.__name__:
raise ValueError(
f"内置处理器必须在本模块内定义或经 HANDLERS 声明:{module_path}.{func_name} "
f"实际来自 {getattr(handler, '__module__', '未知来源')},已拒绝"
)
cls._builtin_handlers[name] = handler
return handler
@classmethod
def _task_wrapper(cls, job_id: str | int, code_block: str | None, *args, **kwargs):
"""任务执行包装器,执行自定义代码块(同步版本,用于 ThreadPoolExecutor)
安全提示:code_block 来自页面提交,exec 等同给所有能创建任务的人
服务器代码执行权限。生产环境应设置 SCHEDULER_ALLOW_CODE_EXEC=False
关闭该能力,仅保留内置函数型任务。
两类任务:
- **内置函数型**(``code_block = "builtin:<模块名>[.<函数名>]"``):走白名单注册表直接调用,
**不使用 exec**,因此不受 ``SCHEDULER_ALLOW_CODE_EXEC`` 限制;
- **代码块型**(其它内容):``exec`` 等于给所有能创建任务的人服务器代码执行权限,**默认关闭**,
关闭时执行入口直接失败并把原因写入执行日志(而不是静默跳过)。
"""
if code_block and code_block.strip() and not settings.SCHEDULER_ALLOW_CODE_EXEC:
message = f"任务 {job_id} 含用户提交代码块,已因 SCHEDULER_ALLOW_CODE_EXEC=False 拒绝执行"
# 内置函数型任务(builtin:<name>):白名单解析后直接调用,不经过 exec,
# 因此即使 SCHEDULER_ALLOW_CODE_EXEC=False 也能正常调度
raw_code = (code_block or "").strip()
if raw_code.startswith(cls.BUILTIN_PREFIX):
handler = cls.resolve_builtin_handler(raw_code)
logger.info(f"任务 {job_id} 以内置处理器执行(不经 exec): {raw_code}")
return handler(*args, **kwargs) if handler else None
if raw_code and not settings.SCHEDULER_ALLOW_CODE_EXEC:
message = (
f"任务 {job_id} 含用户提交的代码块,已拒绝执行:服务端默认禁止定时任务执行代码块。"
f"如确需该能力,请显式设置 SCHEDULER_ALLOW_CODE_EXEC=true(等同授予服务器代码执行权限,请谨慎评估);"
f"若只需内置能力,可把 func 改为 builtin:<模块名>[.<函数名>]"
)
logger.error(message)
raise RuntimeError(message)
+14 -11
View File
@@ -78,7 +78,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
result: Result = await self.db.execute(sql)
return result.scalars().first()
except Exception as e:
raise CustomException(msg=f"获取查询失败: {e!s}") from e
raise CustomException(msg=f"获取查询失败: {e!s}", status_code=500) from e
async def get_or_404(
self,
@@ -89,12 +89,15 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
include_deleted: bool = False,
**kwargs,
) -> ModelType | OutSchemaType:
"""查不到直接抛异常。支持 id 快捷入参,也支持 **kwargs 传多个条件。"""
"""查不到直接抛异常。支持 id 快捷入参,也支持 **kwargs 传多个条件。
「资源不存在」属客户端可修正的结果:按 404 返回(业务 code 保持默认,前端仍展示 msg)。
"""
if id is not None:
kwargs["id"] = id
obj = await self.get(preload=preload, include_deleted=include_deleted, **kwargs)
if not obj:
raise CustomException(msg=msg)
raise CustomException(msg=msg, status_code=404)
return out_schema.model_validate(obj) if out_schema else obj
async def exists(self, include_deleted: bool = False, **kwargs) -> bool:
@@ -109,7 +112,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
result: Result = await self.db.execute(count_sql)
return result.scalar() or 0
except Exception as e:
raise CustomException(msg=f"统计失败: {e!s}") from e
raise CustomException(msg=f"统计失败: {e!s}", status_code=500) from e
async def get_list(
self,
@@ -131,7 +134,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
result: Result = await self.db.execute(sql)
return result.scalars().all()
except Exception as e:
raise CustomException(msg=f"列表查询失败: {e!s}") from e
raise CustomException(msg=f"列表查询失败: {e!s}", status_code=500) from e
async def page(
self,
@@ -179,7 +182,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
items=items,
)
except Exception as e:
raise CustomException(msg=f"分页查询失败: {e!s}") from e
raise CustomException(msg=f"分页查询失败: {e!s}", status_code=500) from e
# ── 写入 ──────────────────────────────────────────────────────────
@@ -215,7 +218,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
return obj
except Exception as e:
raise CustomException(msg=f"创建失败: {e!s}") from e
raise CustomException(msg=f"创建失败: {e!s}", status_code=500) from e
async def update(self, id: int, data: UpdateSchemaType | dict[str, Any]) -> ModelType:
"""更新记录。用 exclude_unset / exclude_none 准确表达前端意图。"""
@@ -253,7 +256,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"更新失败: {e!s}") from e
raise CustomException(msg=f"更新失败: {e!s}", status_code=500) from e
async def delete(self, ids: list[int]) -> None:
"""软删除优先,无软删除则物理删除。"""
@@ -269,7 +272,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"删除失败: {e!s}") from e
raise CustomException(msg=f"删除失败: {e!s}", status_code=500) from e
async def clear(self) -> None:
"""清空整表。软删除模式下相当于"回收站清空",只清理已删标记的记录。"""
@@ -283,7 +286,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"清空失败: {e!s}") from e
raise CustomException(msg=f"清空失败: {e!s}", status_code=500) from e
async def set(self, ids: list[int], include_deleted: bool = False, **kwargs) -> None:
"""批量更新。软删除模式下默认跳过已删除的记录。"""
@@ -296,7 +299,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
await self.db.execute(sql)
await self.db.flush()
except Exception as e:
raise CustomException(msg=f"批量更新失败: {e!s}") from e
raise CustomException(msg=f"批量更新失败: {e!s}", status_code=500) from e
# ── 条件与排序 ────────────────────────────────────────────────────
+64 -5
View File
@@ -38,17 +38,72 @@ def require_superadmin(func):
return wrapper
# starlette 新版本把 HTTP_422_UNPROCESSABLE_ENTITY 标为弃用,这里取新常量并兼容旧版本,
# 避免模块导入期就打 DeprecationWarning
_HTTP_422: int = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", 422)
# 业务错误码 → HTTP 状态码。只映射语义明确的码;其余业务异常统一按 _DEFAULT_BUSINESS_STATUS 返回。
# 说明:历史上 CustomException 的 status_code 默认 500,导致「验证码过期」「参数校验失败」这类
# 客户端错误也返回 500 —— 前端无法区分「我错了」与「服务器坏了」,监控/告警也被污染。
_CODE_TO_HTTP_STATUS: dict[int, int] = {
RET.BAD_REQUEST.code: status.HTTP_400_BAD_REQUEST,
RET.UNAUTHORIZED.code: status.HTTP_401_UNAUTHORIZED,
RET.FORBIDDEN.code: status.HTTP_403_FORBIDDEN,
RET.NOT_FOUND.code: status.HTTP_404_NOT_FOUND,
RET.CONFLICT.code: status.HTTP_409_CONFLICT,
RET.UNPROCESSABLE_ENTITY.code: _HTTP_422,
RET.TOO_MANY_REQUESTS.code: status.HTTP_429_TOO_MANY_REQUESTS,
RET.SERVICE_UNAVAILABLE.code: status.HTTP_503_SERVICE_UNAVAILABLE,
RET.DATAEXIST.code: status.HTTP_409_CONFLICT,
RET.PARAMERR.code: status.HTTP_400_BAD_REQUEST,
RET.TIMEOUT.code: status.HTTP_504_GATEWAY_TIMEOUT,
RET.RATE_LIMIT_EXCEEDED.code: status.HTTP_429_TOO_MANY_REQUESTS,
# 认证类业务码(对应前端 ResultEnum)
RET.INVALID_TOKEN.code: status.HTTP_401_UNAUTHORIZED,
RET.EXPIRED_TOKEN.code: status.HTTP_401_UNAUTHORIZED,
RET.INVALID_CREDENTIALS.code: status.HTTP_401_UNAUTHORIZED,
RET.TOKEN_EXPIRED.code: status.HTTP_401_UNAUTHORIZED,
RET.NO_PERMISSION.code: status.HTTP_403_FORBIDDEN,
# 显式声明为服务端错误的码(供内部包装器使用,如 CRUD「xx失败」兜底)
RET.SERVERERR.code: status.HTTP_500_INTERNAL_SERVER_ERROR,
}
# 未映射业务码的默认状态:业务异常按「客户端请求错误」处理(而不是 500)
_DEFAULT_BUSINESS_STATUS: int = status.HTTP_400_BAD_REQUEST
def resolve_http_status(code: int | None) -> int:
"""把业务错误码解析为 HTTP 状态码。
参数:
- code (int | None): 业务错误码。
返回:
- int: 对应的 HTTP 状态码;未映射的码返回 _DEFAULT_BUSINESS_STATUS。
"""
if code is None:
return _DEFAULT_BUSINESS_STATUS
return _CODE_TO_HTTP_STATUS.get(int(code), _DEFAULT_BUSINESS_STATUS)
class CustomException(Exception):
"""业务异常。
``status_code`` 未显式传入时按 ``code`` 推导(见 :func:`resolve_http_status`),
默认 400(客户端可修正)而非 500;只有确属服务端故障的场景才应显式传 500,
或使用 ``code=RET.SERVERERR.code``。
"""
def __init__(
self,
msg: str = RET.EXCEPTION.msg,
code: int = RET.EXCEPTION.code,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
status_code: int | None = None,
data: Any | None = None,
success: bool = False,
) -> None:
super().__init__(msg)
self.status_code = status_code
self.status_code = status_code if status_code is not None else resolve_http_status(code)
self.code = code
self.msg = msg
self.data = data
@@ -61,11 +116,15 @@ class CustomException(Exception):
def handle_exception(app: FastAPI) -> None:
@app.exception_handler(CustomException)
async def custom_exception_handler(request: Request, exc: CustomException) -> JSONResponse:
logger.error(
"[自定义异常] {} {} | code={} | msg={} | data={}",
# 4xx 是「客户端可修正」的业务结果,5xx 才是服务端故障:分级记录,
# 避免每个校验失败都打 ERROR 把真正的故障信号淹没。
log = logger.error if exc.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR else logger.warning
log(
"[自定义异常] {} {} | code={} | status={} | msg={} | data={}",
request.method,
request.url.path,
exc.code,
exc.status_code,
exc.msg,
exc.data,
)
@@ -96,7 +155,7 @@ def handle_exception(app: FastAPI) -> None:
request.url.path,
errors,
)
return ErrorResponse(msg=str(msg), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, data=errors)
return ErrorResponse(msg=str(msg), status_code=_HTTP_422, data=errors)
@app.exception_handler(ResponseValidationError)
async def response_validation_handler(request: Request, exc: ResponseValidationError) -> JSONResponse:
+8 -6
View File
@@ -42,7 +42,8 @@ def _get_agno_db() -> Any:
elif db_type == "sqlite":
_db_instance = AsyncSqliteDb(db_file=db_uri.replace("sqlite+aiosqlite:///", ""))
else:
raise CustomException(msg=f"不支持的数据库类型: {db_type}")
# settings.DATABASE_TYPE 是服务端配置:取值不在支持范围属部署配置错误,按 500 返回
raise CustomException(msg=f"不支持的数据库类型: {db_type}", status_code=500)
return _db_instance
@@ -141,7 +142,7 @@ class ChatSessionCRUD:
)
except Exception as e:
logger.error(f"获取会话列表失败: {e}")
raise CustomException(msg="获取会话列表失败") from e
raise CustomException(msg="获取会话列表失败", status_code=500) from e
rows: list[dict[str, Any]] = []
total = 0
@@ -173,9 +174,9 @@ class ChatSessionCRUD:
result = await self.db.upsert_session(session=session)
except Exception as e:
logger.exception(f"创建会话失败: {e}")
raise CustomException(msg="创建会话失败") from e
raise CustomException(msg="创建会话失败", status_code=500) from e
if result is None:
raise CustomException(msg="创建会话失败")
raise CustomException(msg="创建会话失败", status_code=500)
return result
async def rename(self, session_id: str, data: ChatSessionUpdateSchema) -> None:
@@ -189,7 +190,7 @@ class ChatSessionCRUD:
)
except Exception as e:
logger.error(f"更新会话失败: {e}")
raise CustomException(msg="更新会话失败") from e
raise CustomException(msg="更新会话失败", status_code=500) from e
if result is None:
raise CustomException(msg="会话不存在", code=10404, status_code=404)
@@ -200,4 +201,5 @@ class ChatSessionCRUD:
await self.db.delete_session(session_id=session_id, user_id=self.user_id)
except Exception as e:
logger.error(f"删除会话失败: {session_id} - {e}")
raise CustomException(msg=f"删除会话失败: {session_id}") from e
# agno/SQLite 侧删除失败属内部故障,按 500 返回(session_ids 为客户端参数,但失败原因在服务端)
raise CustomException(msg=f"删除会话失败: {session_id}", status_code=500) from e
@@ -296,14 +296,17 @@ class GenUtils:
@classmethod
def split_column_type(cls, column_type: str) -> list[str]:
"""拆分列类型
"""拆分列类型中的长度/精度参数(**只返回校验过的数字**)。
原实现用 ``column_type.split("(")[1]`` 直接切分,会把括号内的任意文本带出去;
这里改为正则捕获 + 数字校验(与 template_safety.extract_type_params 同源规则)。
参数:
- column_type (str): 字段类型。
返回:
- list[str]: 拆分结果。
- list[str]: 形如 ["64"] 或 ["10", "2"];无有效参数时返回 []。
"""
if "(" in column_type and ")" in column_type:
return column_type.split("(")[1].split(")")[0].split(",")
return []
from .template_safety import extract_type_params
return list(extract_type_params(column_type))
@@ -1,3 +1,4 @@
import copy
import re
from datetime import datetime
from typing import Any
@@ -15,6 +16,24 @@ from .schema import (
GenTableColumnOutSchema,
GenTableOutSchema,
)
from .template_safety import (
column_type_base,
escape_template_text,
html_attr,
pick_sqlalchemy_type,
safe_ident,
safe_python_type,
vue_label,
)
# 哨兵文本:用于「用哨兵值渲染同一模板,得到结构骨架」的落盘前比对
# (见 template_safety.assert_python_structure / assert_text_structure)
SENTINEL_TEXT = "__SAFE_TEXT__"
# 自由文本字段:哨兵化只针对它们(保证结构比对有意义);标识符/类型/开关/长度等
# 必须保持真实值,否则模板分支会与骨架不一致,产生误报
_SENTINEL_TABLE_TEXT_FIELDS = ("table_name", "table_comment", "function_name", "business_name", "sub_table_name", "description")
_SENTINEL_COLUMN_TEXT_FIELDS = ("column_comment", "dict_type")
class Jinja2TemplateUtil:
@@ -75,6 +94,14 @@ class Jinja2TemplateUtil:
"snake_to_camel": CamelCaseUtil.snake_to_camel,
"get_sqlalchemy_type": cls.get_sqlalchemy_type,
"python_to_ts_type": cls.python_type_to_ts_type,
# 模板内按上下文安全化(详见 template_safety.py):
# 绝大多数值已在 prepare_context 统一处理,模板里可按需显式调用
"py_text": escape_template_text,
"py_ident": safe_ident,
"py_type": safe_python_type,
"vue_label": vue_label,
# 静态 HTML 属性值:实体转义(展示保真,不用 \uXXXX)
"html_attr": html_attr,
},
)
return cls._env
@@ -146,6 +173,194 @@ class Jinja2TemplateUtil:
segs.append(seg)
return "/".join(segs) if segs else "entity"
@classmethod
def safe_render_schema(cls, gen_table: GenTableOutSchema) -> GenTableOutSchema:
"""构造「仅用于模板渲染」的安全副本。
生成物是会被执行/被构建链消费的源码(``.py`` 会被 ``app/core/discover.py`` 启动时 import),
因此所有用户可控文本必须按所处上下文安全化后才能进模板。此处统一处理,
避免逐个模板/逐个字段遗漏;``autoescape=True`` 不适用(会把生成的代码本身转义坏)。
**从 schema 进入模板的值按四类显式处理**(实现见 template_safety.py):
1. 字符串类(注释/描述/功能名/表名…)→ ``escape_template_text``(``\\uXXXX``,仅用于字符串/注释上下文);
2. 标识符类(列名/类名/模块名/python_field)→ ``safe_ident``(含关键字与保留字排除);
3. 代码位置类(SQLAlchemy 类型、长度/精度)→ ``pick_sqlalchemy_type``:**只从常量白名单派生**,不接收自由文本;
4. 路径类(包名/模块名/业务路径/文件名)→ slug/白名单规范化(``business_name_to_path`` 等),不含 ``..`` 与分隔符。
展示位置(``.vue`` 文本节点与静态属性)另有 ``vue_label``/``html_attr``:HTML 实体转义,保真且不破坏标记结构。
参数:
- gen_table (GenTableOutSchema): 原始生成配置(不会被修改)。
返回:
- GenTableOutSchema: 深拷贝并按上下文安全化后的副本。
"""
safe_table = copy.deepcopy(gen_table)
cls._sanitize_schema_in_place(safe_table, set())
return safe_table
@classmethod
def sentinel_render_schema(cls, gen_table: GenTableOutSchema) -> GenTableOutSchema:
"""构造「哨兵值」副本:自由文本换成固定哨兵(空值保持为空),用于生成结构骨架。
与 :meth:`safe_render_schema` 的区别:这里不追求可展示/可用,只要求**结构中性**——
值里不含任何可能改变模板分支或语法结构的字符。用它渲染同一模板得到的 AST/行结构
即为「该模板的规范骨架」,落盘前与真实产物比对(见 template_safety 的结构闸门)。
参数:
- gen_table (GenTableOutSchema): 已安全化的渲染配置。
返回:
- GenTableOutSchema: 自由文本字段被替换为哨兵的深拷贝。
"""
sentinel_table = copy.deepcopy(gen_table)
cls._sentinelize_schema_in_place(sentinel_table, set())
return sentinel_table
@classmethod
def _sentinelize_schema_in_place(cls, table: GenTableOutSchema, seen: set[int]) -> None:
"""就地哨兵化自由文本字段(标识符/类型/开关/长度保持不变),带环保护。"""
if table is None or id(table) in seen:
return
seen.add(id(table))
for field in _SENTINEL_TABLE_TEXT_FIELDS:
if hasattr(table, field):
setattr(table, field, _sentinelize_value(getattr(table, field)))
for column in table.columns or []:
cls._sentinelize_column_in_place(column, seen)
if table.pk_column is not None:
cls._sentinelize_column_in_place(table.pk_column, seen)
if table.sub_table is not None:
cls._sentinelize_schema_in_place(table.sub_table, seen)
@staticmethod
def _sentinelize_column_in_place(column: GenTableColumnOutSchema, seen: set[int]) -> None:
"""就地哨兵化字段级自由文本(列名/类型/长度等保持原值)。"""
if column is None or id(column) in seen:
return
seen.add(id(column))
for field in _SENTINEL_COLUMN_TEXT_FIELDS:
if hasattr(column, field):
setattr(column, field, _sentinelize_value(getattr(column, field)))
@classmethod
def iter_free_text_values(cls, gen_table: GenTableOutSchema) -> list[str]:
"""枚举本次渲染里会进入模板的**自由文本值**(与哨兵化字段一一对应,按同样顺序)。
供 ``.vue``/``.ts`` 的行结构比对使用:把真实值与哨兵都抹成占位符后再逐行比较。
参数:
- gen_table (GenTableOutSchema): 已安全化的渲染配置。
返回:
- list[str]: 非空自由文本值列表。
"""
values: list[str] = []
seen: set[int] = set()
def walk(table: GenTableOutSchema) -> None:
if table is None or id(table) in seen:
return
seen.add(id(table))
for field in _SENTINEL_TABLE_TEXT_FIELDS:
text = getattr(table, field, None)
if text:
values.append(str(text))
for column in table.columns or []:
collect_column(column)
if table.pk_column is not None:
collect_column(table.pk_column)
if table.sub_table is not None:
walk(table.sub_table)
def collect_column(column: GenTableColumnOutSchema) -> None:
if column is None or id(column) in seen:
return
seen.add(id(column))
for field in _SENTINEL_COLUMN_TEXT_FIELDS:
text = getattr(column, field, None)
if text:
values.append(str(text))
walk(gen_table)
return values
@classmethod
def iter_render_dynamic_values(cls, gen_table: GenTableOutSchema) -> list[str]:
"""返回真实产物里可能出现的**所有动态文本形态**(原值 / 转义值 / 展示形态)。
供 ``.vue``/``.ts`` 的行结构比对把动态部分抹成占位符:同一个值可能以
``escape_template_text``(JS 字符串)、``html_attr``(静态属性)、``vue_label``(文本节点)
三种形态出现,三者都要抹掉,否则会与骨架产生假阳性。
参数:
- gen_table (GenTableOutSchema): 已安全化的渲染配置。
返回:
- list[str]: 动态文本形态列表。
"""
tokens: list[str] = []
for value in cls.iter_free_text_values(gen_table):
text = str(value)
tokens.extend([text, escape_template_text(text), html_attr(text), vue_label(text)])
return tokens
@classmethod
def _sanitize_schema_in_place(cls, table: GenTableOutSchema, seen: set[int]) -> None:
"""就地安全化一张表(表级文本 + 全部字段 + 子表),带环保护。
参数:
- table (GenTableOutSchema): 待处理的安全副本(会被修改)。
- seen (set[int]): 已处理对象 id 集合(主/子表互相引用时防重入)。
返回:
- None
"""
if table is None or id(table) in seen:
return
seen.add(id(table))
# 落进字符串字面量/注释/文档字符串的文本
table.table_name = escape_template_text(table.table_name)
table.table_comment = escape_template_text(table.table_comment)
table.function_name = escape_template_text(table.function_name)
# 落进标识符位置的值(类名 / 模块名 / 包名 / 外键列名)
table.class_name = safe_ident(table.class_name, default="")
table.module_name = safe_ident(table.module_name, default="")
table.package_name = safe_ident(table.package_name, default="")
table.sub_table_fk_name = safe_ident(table.sub_table_fk_name, default="")
# 业务名允许 a/b 多段,且参与目录派生,按文本处理即可(派生见 prepare_context)
table.business_name = escape_template_text(table.business_name)
table.sub_table_name = escape_template_text(table.sub_table_name)
for column in table.columns or []:
cls._sanitize_column_in_place(column, seen)
# pk_column 通常就是 columns 里的同一个对象(见 set_pk_column),必须靠 seen 去重,
# 否则同一字段被转义两次(\u0027 → \u005cu0027),产物里的文本会变形
if table.pk_column is not None:
cls._sanitize_column_in_place(table.pk_column, seen)
if table.sub_table is not None:
cls._sanitize_schema_in_place(table.sub_table, seen)
@staticmethod
def _sanitize_column_in_place(column: GenTableColumnOutSchema, seen: set[int]) -> None:
"""就地安全化单个字段:列名/类型注解按标识符处理,注释/字典类型按文本转义。
参数:
- column (GenTableColumnOutSchema): 待处理的安全副本(会被修改)。
- seen (set[int]): 已处理对象 id 集合(同一字段被 columns 与 pk_column 同时引用时防重复转义)。
返回:
- None
"""
if column is None or id(column) in seen:
return
seen.add(id(column))
column.column_name = safe_ident(column.column_name, default="")
column.python_type = safe_python_type(column.python_type)
column.python_field = safe_ident(column.python_field, default="")
column.column_comment = escape_template_text(column.column_comment)
column.dict_type = escape_template_text(column.dict_type)
@classmethod
def prepare_context(cls, gen_table: GenTableOutSchema) -> dict[str, Any]:
"""准备模板变量。
@@ -159,6 +374,13 @@ class Jinja2TemplateUtil:
# 处理options为None的情况
# if not gen_table.options:
# raise ValueError('请先完善生成配置信息')
# 目录/文件名派生值仍按原始业务名计算,保证既有产物路径不变;
# 其余进入模板的内容一律取安全副本(详见 template_safety.py)。
business_name_raw = (gen_table.business_name or "").strip()
business_path = cls.business_name_to_path(business_name_raw)
business_name_slug = cls.business_name_to_slug(business_name_raw)
gen_table = cls.safe_render_schema(gen_table)
class_name = gen_table.class_name or ""
package_name = (gen_table.package_name or "").strip()
module_name = (gen_table.module_name or "").strip()
@@ -169,8 +391,6 @@ class Jinja2TemplateUtil:
# - 分系统根:package_name = module_xxx
# - 目录固定为:module_xxx / module_name(不再额外使用业务名作为目录层级)
# - 权限前缀固定为:module_xxx:module_name(操作在模板里再拼 :query/:create...)
business_path = cls.business_name_to_path(business_name)
business_name_slug = cls.business_name_to_slug(business_name)
permission_prefix = ":".join([s for s in [package_name, module_name] if s])
api_route_prefix = cls.get_api_route_prefix(package_name)
@@ -262,17 +482,20 @@ class Jinja2TemplateUtil:
- dict[str, Any]: 子表模板上下文字典。
"""
ctx = cls.prepare_context(sub)
scn = (sub.class_name or GenUtils.convert_class_name(sub.table_name or "")).strip()
# 父表字段同样进入模板(parent_* / relationship 实参),必须取自安全副本
parent_safe = cls.safe_render_schema(parent)
sub_safe = cls.safe_render_schema(sub)
scn = (sub_safe.class_name or GenUtils.convert_class_name(sub_safe.table_name or "")).strip()
ctx["is_sub_entity"] = True
ctx["parent_class_name"] = parent.class_name or ""
ctx["parent_model_class_name"] = f"{parent.class_name}Model"
ctx["parent_table_name"] = parent.table_name or ""
ctx["parent_pk_column_name"] = (parent.pk_column.column_name if parent.pk_column else None) or "id"
ctx["parent_rel_name"] = SnakeCaseUtil.camel_to_snake(parent.class_name or "parent")
ctx["parent_class_name"] = parent_safe.class_name or ""
ctx["parent_model_class_name"] = f"{parent_safe.class_name}Model"
ctx["parent_table_name"] = parent_safe.table_name or ""
ctx["parent_pk_column_name"] = (parent_safe.pk_column.column_name if parent_safe.pk_column else None) or "id"
ctx["parent_rel_name"] = SnakeCaseUtil.camel_to_snake(parent_safe.class_name or "parent")
ctx["parent_list_rel_name"] = f"{SnakeCaseUtil.camel_to_snake(scn)}_list"
ctx["sub_table_fk_name"] = (parent.sub_table_fk_name or "").strip()
ctx["model_import_list"] = cls.get_model_import_list(sub, is_sub_entity=True)
ctx["schema_import_list"] = cls.get_schema_import_list(sub)
ctx["sub_table_fk_name"] = (parent_safe.sub_table_fk_name or "").strip()
ctx["model_import_list"] = cls.get_model_import_list(sub_safe, is_sub_entity=True)
ctx["schema_import_list"] = cls.get_schema_import_list(sub_safe)
return ctx
@classmethod
@@ -529,10 +752,9 @@ class Jinja2TemplateUtil:
if "[]" in column_type:
return "array"
# 提取基本类型
if "(" in column_type:
return column_type.split("(")[0]
return column_type
# 提取基本类型:走严格基名提取(只允许 [A-Za-z][A-Za-z0-9_ ]*,剔除 unsigned 等修饰词),
# 不直接 split 原始文本,避免把括号前的任意内容当作类型名带出去
return column_type_base(column_type)
@classmethod
def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]:
@@ -679,81 +901,39 @@ class Jinja2TemplateUtil:
@classmethod
def get_sqlalchemy_type(cls, column: Any) -> str:
"""获取 SQLAlchemy 类型。
"""获取 SQLAlchemy 类型(**代码位置** → 一律由常量白名单派生,见 template_safety)。
类型名只能来自 ``GenConstant.DB_TO_SQLALCHEMY`` 的值,长度/精度只能来自
``column_length.isdigit()`` 或 ``column_type`` 括号内的**数字捕获**;
不再使用 ``column_type.split("(")[1]`` 这类字符串切分(会把任意内容带进产物)。
参数:
- column (Any): 列对象或列类型字符串。
返回:
- str: SQLAlchemy 类型字符串。
- str: SQLAlchemy 类型字符串(如 ``String(64)`` / ``Numeric(10,2)`` / ``DateTime``)。
"""
# 获取column_type和column_length
column_type = column
column_length = None
# 检查是否是对象
column_type: Any = column
column_length: Any = None
if hasattr(column, "column_type"):
column_type = column.column_type or ""
column_length = column.column_length or None
column_type = getattr(column, "column_type", "") or ""
column_length = getattr(column, "column_length", None) or None
# 先按既有口径去掉 COLLATE / UNSIGNED,便于与常量表的键匹配
column_type = cls.normalize_db_column_type_for_mapping(column_type)
return pick_sqlalchemy_type(column_type, column_length)
# MySQL:仅 tinyint(1) 映射为 Boolean;其余 tinyint 走 SmallInteger(见 GenConstant.DB_TO_SQLALCHEMY)
ct_norm = (column_type or "").strip()
if settings.DATABASE_TYPE != "postgres" and ct_norm:
ct_lower = ct_norm.lower()
if ct_lower.startswith("tinyint(1)"):
return "Boolean"
# 首先尝试匹配完整类型(包括括号)
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, column_type)
def _sentinelize_value(value: Any) -> Any:
"""把非空文本替换为哨兵文本(空值/None 原样保留,避免改变模板的真值分支)。
# 特殊处理PostgreSQL类型
if settings.DATABASE_TYPE == "postgres":
if column_type.upper() == "BOOLEAN":
return "Boolean"
if column_type.upper() == "REAL" or column_type.upper() == "DOUBLE PRECISION":
return "Float"
if column_type.upper() == "TIMESTAMP":
return "DateTime"
if column_type.upper() == "JSONB":
return "JSONB"
if column_type.upper() == "UUID":
return "Uuid"
if column_type.upper() == "BYTEA":
return "LargeBinary"
参数:
- value (Any): 原始值。
# get_mapping_value_by_key_ignore_case 未命中时返回 "",须与 None 同样视为未匹配
if not sqlalchemy_type and "(" in column_type:
# 如果没有匹配到,再尝试剥离括号
column_type_list = column_type.split("(")
col_type = column_type_list[0]
# 将 'character' 映射为 'char' 以匹配常量定义
if col_type.lower() == "character":
col_type = "char"
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
# 如果是字符串类型且包含括号参数,保持原参数
if sqlalchemy_type in ["String", "CHAR"] or sqlalchemy_type in ["Numeric", "DECIMAL"]:
sqlalchemy_type += "(" + column_type_list[1]
elif not sqlalchemy_type:
# 处理没有括号的类型
col_type = column_type
# 将 'character' 映射为 'char' 以匹配常量定义
if col_type.lower() == "character":
col_type = "char"
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
# 如果是字符串类型且没有指定长度,使用column_length或默认255
if sqlalchemy_type in ["String", "CHAR"]:
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type += f"({length})"
# 对于已经匹配到的类型,如果是字符串类型且column有长度信息,添加长度
elif sqlalchemy_type in ["String", "CHAR"] and "(" not in sqlalchemy_type:
# 检查column_length是否有效
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type += f"({length})"
# 如果没有找到匹配的类型,使用String(column_length)或String(255)作为默认类型
if not sqlalchemy_type:
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type = f"String({length})"
return sqlalchemy_type
返回:
- Any: 哨兵文本或原值。
"""
if value is None:
return None
text = str(value)
return SENTINEL_TEXT if text else text
@@ -4,6 +4,8 @@ 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):
"""数据库中的表信息(跨方言统一结构)。
@@ -59,6 +61,71 @@ class GenTableColumnSchema(BaseModel):
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):
"""业务表字段输出模型
@@ -33,7 +33,7 @@ from app.utils.common_util import CamelCaseUtil, compute_menu_route_first_segmen
from .crud import GenTableColumnCRUD, GenTableCRUD
from .gen_util import GenUtils
from .jinja2_template_util import Jinja2TemplateUtil
from .jinja2_template_util import SENTINEL_TEXT, Jinja2TemplateUtil
from .schema import (
GenSyncColumnChange,
GenSyncPreviewSchema,
@@ -43,6 +43,7 @@ from .schema import (
GenTableQueryParam,
GenTableSchema,
)
from .template_safety import assert_python_structure, assert_text_structure
def handle_service_exception(func: Callable) -> Callable:
@@ -61,7 +62,9 @@ def handle_service_exception(func: Callable) -> Callable:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"{func.__name__}执行失败: {e!s}")
# 服务层公共出口:这里捕获到的都是**非业务**异常(模板渲染/DB/反射等内部故障),
# 按 500 返回,避免监控把服务端故障误判为客户端错误;业务异常由上面的分支原样透传(保留自身 4xx)
raise CustomException(msg=f"{func.__name__}执行失败: {e!s}", status_code=500)
return wrapper
@@ -370,7 +373,7 @@ class GenTableService:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"导入失败, {e!s}")
raise CustomException(msg=f"导入失败, {e!s}", status_code=500)
@handle_service_exception
async def create_table(self, sql: str) -> bool | None:
@@ -454,7 +457,7 @@ class GenTableService:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"创建表结构失败: {e!s}")
raise CustomException(msg=f"创建表结构失败: {e!s}", status_code=500)
@handle_service_exception
async def update_gen_table(self, data: GenTableSchema, table_id: int) -> GenTableOutSchema:
@@ -516,7 +519,7 @@ class GenTableService:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=str(e))
raise CustomException(msg=str(e), status_code=500)
else:
raise CustomException(msg="业务表不存在")
@@ -541,7 +544,7 @@ class GenTableService:
# 再删除表信息
await GenTableCRUD(self.auth, self.db).delete_gen_table(ids)
except Exception as e:
raise CustomException(msg=str(e))
raise CustomException(msg=str(e), status_code=500)
@handle_service_exception
async def get_gen_table_by_id(self, table_id: int) -> GenTableOutSchema:
@@ -660,12 +663,40 @@ class GenTableService:
raise CustomException(msg="包名不能为空")
# 1. 先写代码文件(风险最高,放最前,失败不产生菜单孤儿数据)
async def _write_templates(templates: list[str], ctx: dict[str, Any], table_schema: GenTableOutSchema) -> None:
repo_root = BASE_DIR.parent.resolve()
async def _write_templates(
templates: list[str],
ctx: dict[str, Any],
table_schema: GenTableOutSchema,
*,
reference_ctx: dict[str, Any],
dynamic_values: list[str],
) -> None:
for template in templates:
try:
render_content = await env.get_template(template).render_async(**ctx)
tmpl = env.get_template(template)
render_content = await tmpl.render_async(**ctx)
# 同一模板用「哨兵值」再渲染一次,得到该模板的规范结构骨架
reference_content = await tmpl.render_async(**reference_ctx)
file_name = Jinja2TemplateUtil.get_file_name(template, table_schema)
full_path = BASE_DIR.parent.joinpath(file_name)
# 落盘前三重校验(详见 gencode/template_safety.py):
# ① 目标路径必须仍在仓库根内,防止路径穿越改写仓库外的文件;
# ② .py 产物必须语法合法,且**结构与哨兵骨架一致**(仅 ast.parse 发现不了注入);
# ③ .vue/.ts/.toml 退化为行结构比对,禁止出现骨架之外的语句/标签结构。
if not full_path.resolve().is_relative_to(repo_root):
raise CustomException(msg=f"生成路径越出仓库范围,已拒绝写入:{file_name}")
if file_name.endswith(".py"):
assert_python_structure(render_content, reference_content, filename=file_name)
else:
assert_text_structure(
render_content,
reference_content,
sentinel=SENTINEL_TEXT,
dynamic_values=dynamic_values,
filename=file_name,
)
gen_path = str(full_path)
os.makedirs(os.path.dirname(gen_path), exist_ok=True)
await anyio.Path(gen_path).write_text(render_content, encoding="utf-8")
@@ -681,16 +712,30 @@ class GenTableService:
os.makedirs(str(d), exist_ok=True)
await anyio.Path(str(init_path)).write_text("# -*- coding: utf-8 -*-", encoding="utf-8")
except Exception as e:
raise CustomException(msg=f"渲染模板失败,表名:{table_schema.table_name},详细错误信息:{e!s}")
raise CustomException(msg=f"渲染模板失败,表名:{table_schema.table_name},详细错误信息:{e!s}", status_code=500)
await _write_templates(render_info[0], render_info[2], gen_table_schema)
safe_schema: GenTableOutSchema = render_info[4]
reference_ctx = Jinja2TemplateUtil.prepare_context(Jinja2TemplateUtil.sentinel_render_schema(safe_schema))
dynamic_values = Jinja2TemplateUtil.iter_render_dynamic_values(safe_schema)
await _write_templates(render_info[0], render_info[2], gen_table_schema, reference_ctx=reference_ctx, dynamic_values=dynamic_values)
if gen_table_schema.sub and gen_table_schema.sub_table:
gen_table_schema.sub_table.package_name = gen_table_schema.package_name
if not (gen_table_schema.sub_table.module_name or "").strip():
gen_table_schema.sub_table.module_name = gen_table_schema.module_name
sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(gen_table_schema, gen_table_schema.sub_table)
sub_templates = Jinja2TemplateUtil.get_sub_table_template_list()
await _write_templates(sub_templates, sub_ctx, gen_table_schema.sub_table)
safe_sub = Jinja2TemplateUtil.safe_render_schema(gen_table_schema.sub_table)
sub_reference_ctx = Jinja2TemplateUtil.prepare_sub_render_context(
Jinja2TemplateUtil.sentinel_render_schema(safe_schema),
Jinja2TemplateUtil.sentinel_render_schema(safe_sub),
)
await _write_templates(
sub_templates,
sub_ctx,
gen_table_schema.sub_table,
reference_ctx=sub_reference_ctx,
dynamic_values=dynamic_values,
)
# 2. 代码成功写入后,再创建菜单(避免失败时产生孤儿菜单数据)
menu_crud = MenuCRUD(self.auth, self.db)
@@ -970,7 +1015,7 @@ class GenTableService:
if sub_cfg:
await self.sync_db(sn, _sync_sub=False)
except Exception as e:
raise CustomException(msg=f"同步失败: {e!s}")
raise CustomException(msg=f"同步失败: {e!s}", status_code=500)
async def hydrate_sub_table(self, gen_table: GenTableOutSchema) -> None:
"""主子表:优先使用已导入的子表配置,否则回退为只读 DB 结构。
@@ -1247,6 +1292,8 @@ class GenTableService:
await self.hydrate_sub_table(gen_table)
self._assert_master_sub_config_valid(gen_table)
context = Jinja2TemplateUtil.prepare_context(gen_table)
# 安全副本:与 prepare_context 内部使用同一函数得到,专门供「落盘前结构闸门」派生哨兵骨架与真实值集合
safe_table = Jinja2TemplateUtil.safe_render_schema(gen_table)
template_list = Jinja2TemplateUtil.get_template_list()
output_files = [Jinja2TemplateUtil.get_file_name(template, gen_table) for template in template_list]
return [template_list, output_files, context, gen_table]
return [template_list, output_files, context, gen_table, safe_table]
@@ -0,0 +1,621 @@
"""代码生成模板的输入安全处理。
背景(见 backend/audit-backend.md 的 B1)
======================================
代码生成器把「表/列元数据」渲染成 ``.py`` / ``.vue`` / ``.ts`` / ``.toml`` 源文件,产物会被写进
仓库,其中 ``backend/app/plugin/**`` 会在应用启动时被 ``app/core/discover.py`` 逐个
``import_module`` 执行。因此任何用户可控文本(表注释、列注释、字段名、功能名……)都不能原样
进入模板——否则「有代码生成权限」就等于「能在服务器上执行任意代码」。
为什么不用 ``autoescape=True``
=============================
生成物的目标是**代码**而不是 HTML:全局开启 autoescape 会把 ``<`` ``>`` ``&`` 变成 ``&lt;`` 等,
直接把生成出来的 Python / Vue 源码写坏。正确做法是**按上下文处理**:
- 落进「字符串字面量 / 注释 / 文档字符串」的文本 → :func:`escape_template_text`
(转义 ``\\ ' " 换行 反引号`` 等,Python / JS / TOML 的字符串都会把它解析回原字符,保真;
但源码层面不再出现能闭合字面量或换行的字符,因而无法逃出当前语法上下文)。
- 落进「标识符位置」的值(列名、类名、类型注解……)→ :func:`safe_ident`。
- 落进 ``.vue`` 模板「文本节点」的展示文本 → :func:`vue_label`(额外去掉尖括号与花括号)。
两层防线
========
1. 口径层:``gencode/schema.py`` 对 ``column_name`` 做白名单校验(拒绝非法标识符);
2. 渲染层:本模块的转义在 :meth:`Jinja2TemplateUtil.prepare_context` 统一施加,
即使数据来自物理库反射(绕过表单校验)也不会产出可执行注入。
"""
import ast
import keyword
import re
from app.common.constant import GenConstant
# 需要转义的字符 → \uXXXX 形式。
# 该形式在 Python 字符串(含三引号文档字符串)、JS 字符串 / 模板字符串、TOML basic string
# 中都会被解析回原字符,因此既安全又保真。
_ESCAPE_MAP: dict[str, str] = {
"\\": "\\u005c", # 反斜杠必须先处理,避免二次转义
"'": "\\u0027", # Python 单引号 / JS 单引号
'"': "\\u0022", # Python 双引号 / JS 双引号 / TOML basic string 定界符
"\n": "\\u000a",
"\r": "\\u000d",
"\t": "\\u0009",
"\v": "\\u000b",
"\f": "\\u000c",
"\0": "\\u0000",
"`": "\\u0060", # JS 模板字符串定界符
# U+2028/U+2029(行/段分隔符):在 JS 里被当作换行,可用来断行逃逸;在 Python 字符串里合法
"\u2028": "\\u2028",
"\u2029": "\\u2029",
}
# 其余控制字符:C0(换行/制表等已在 _ESCAPE_MAP 中显式处理)+ DEL(7F) + C1 控制符(80-9F)
_CONTROL_RE = re.compile(r"[\x01-\x08\x0e-\x1f\x7f-\x9f]")
# \uXXXX 转义的反解(展示位置使用)
_UNESCAPE_RE = re.compile(r"\\u([0-9a-fA-F]{4})")
# JS 模板字符串插值起始序列:$ 后紧跟 { 时必须打断,否则 ${...} 会被当成表达式执行
_JS_INTERPOLATION_RE = re.compile(r"\$\{")
# 合法标识符(Python / JS 通用子集)
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# 标识符规范化:去掉所有非 [A-Za-z0-9_]
_IDENT_STRIP_RE = re.compile(r"[^A-Za-z0-9_]")
# Vue 文本节点额外需要剔除的字符:尖括号与花括号会改变标记结构 / 触发 Vue 插值
_VUE_LABEL_STRIP_RE = re.compile(r"[<>{}]")
# HTML 文本/属性位置的实体转义(保真:浏览器渲染回原字符,而不是把 \uXXXX 当字面量显示)
_HTML_ESCAPE_MAP: dict[str, str] = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}
# 标识符长度上限(MySQL 标识符上限 64)
_IDENTIFIER_MAX_LENGTH = 64
# Python 关键字/软关键字:作为字段名会直接语法错误(如 column_name="class")
_PYTHON_RESERVED: frozenset[str] = frozenset(keyword.kwlist) | frozenset(keyword.softkwlist)
# JS/TS 保留字与字面量:作为裸标识符会语法错误或改变语义
_JS_RESERVED: frozenset[str] = frozenset(
{
"abstract", "arguments", "await", "boolean", "break", "byte", "case", "catch", "char", "class",
"const", "continue", "debugger", "default", "delete", "do", "double", "else", "enum", "eval",
"export", "extends", "false", "final", "finally", "float", "for", "function", "goto", "if",
"implements", "import", "in", "instanceof", "int", "interface", "let", "long", "native", "new",
"null", "package", "private", "protected", "public", "return", "short", "static", "super",
"switch", "synchronized", "this", "throw", "throws", "transient", "true", "try", "typeof",
"undefined", "var", "void", "volatile", "while", "with", "yield",
},
)
# 保留字命中时的前缀(保证仍是合法标识符,同时避免与关键字冲突)
_RESERVED_PREFIX = "_"
def escape_template_text(value: object) -> str:
"""把任意文本转义为「放进字符串字面量/注释/文档字符串都安全」的形式。
参数:
- value (object): 原始值(None 视为空串)。
返回:
- str: 转义后的文本;内容不含可闭合字符串字面量、可换行或可开启 JS 插值的字符。
"""
if value is None:
return ""
text = str(value)
for raw, escaped in _ESCAPE_MAP.items():
text = text.replace(raw, escaped)
text = _CONTROL_RE.sub(lambda m: f"\\u{ord(m.group()):04x}", text)
# ${ 打断为 \u0024{,避免在 JS 模板字符串里变成插值表达式
# 注意:替换值用 lambda 返回,re.sub 的字符串替换会把 \u 当成非法转义
return _JS_INTERPOLATION_RE.sub(lambda m: "\\u0024{", text)
def safe_ident(value: object, default: str = "entity") -> str:
"""把任意值规范为合法标识符(用于列名、类名、类型注解等位置)。
参数:
- value (object): 原始值。
- default (str): 空值/规范化后为空的兜底标识符。
返回:
- str: 形如 ``[A-Za-z_][A-Za-z0-9_]*`` 的标识符,长度不超过 64。
"""
text = "" if value is None else str(value).strip()
if not text:
return default
# 只替换非法字符,不改动已有合法标识符(避免 a__b / _x 这类名字被“规范化”后与库中列名不一致)
text = _IDENT_STRIP_RE.sub("_", text)
if text[0].isdigit():
text = f"_{text}"
# 关键字/保留字(Python + JS)不能作为裸标识符:加前缀而不是报错(用于类名/模块名/字段名等位置)
if text in _PYTHON_RESERVED or text in _JS_RESERVED:
text = f"{_RESERVED_PREFIX}{text}"
return text[:_IDENTIFIER_MAX_LENGTH]
def is_safe_identifier(value: object) -> bool:
"""判断值是否本身即为合法标识符(无需规范化)。
同时排除 Python 关键字与 JS/TS 保留字:它们作为裸标识符会出现语法错误
(如列名 ``class`` 会生成 ``class: Mapped[...]``)。
参数:
- value (object): 待判断的值。
返回:
- bool: 合法返回 True。
"""
if value is None:
return False
text = str(value).strip()
if not text or len(text) > _IDENTIFIER_MAX_LENGTH:
return False
if text in _PYTHON_RESERVED or text in _JS_RESERVED:
return False
return bool(_IDENTIFIER_RE.match(text))
def reserved_word_reason(value: object) -> str | None:
"""若值命中 Python 关键字或 JS 保留字,返回原因文案(供报错信息使用),否则 None。
参数:
- value (object): 待判断的值。
返回:
- str | None: 命中原因("Python 关键字" / "JS 保留字")。
"""
text = "" if value is None else str(value).strip()
if text in _PYTHON_RESERVED:
return "Python 关键字"
if text in _JS_RESERVED:
return "JS/TS 保留字"
return None
def unescape_template_text(value: object) -> str:
"""还原 :func:`escape_template_text` 产生的 ``\\uXXXX`` 转义(展示位置专用)。
HTML 文本/属性里不会解析 ``\\uXXXX``,若不还原就会被用户看成字面量。转义本身是**可逆**的
(原文本里的反斜杠也被转义为 ``\\u005c``),因此这里单趟还原即可精确拿回原文。
参数:
- value (object): 已转义的文本。
返回:
- str: 还原后的文本。
"""
if value is None:
return ""
return _UNESCAPE_RE.sub(lambda m: chr(int(m.group(1), 16)), str(value))
def html_escape(value: object) -> str:
"""HTML 实体转义(用于 ``.vue`` 的文本节点与静态属性值)。
与 :func:`escape_template_text` 的区别:这里追求**展示保真**——先还原 ``\\uXXXX``(HTML 不解析该转义),
再用实体转义,浏览器渲染回原字符。
参数:
- value (object): 文本(可能已带 ``\\uXXXX`` 转义)。
返回:
- str: 实体转义后的文本。
"""
if value is None:
return ""
text = unescape_template_text(value)
for raw, escaped in _HTML_ESCAPE_MAP.items():
text = text.replace(raw, escaped)
return text
def html_attr(value: object) -> str:
"""静态 HTML 属性值的安全化(还原转义 + 实体转义,保留引号语义与展示保真)。
参数:
- value (object): 文本。
返回:
- str: 可直接放进 ``attr="..."`` 的文本。
"""
return html_escape(value)
def vue_label(value: object) -> str:
"""把展示文本处理为可安全放进 ``.vue`` **文本节点**的形式。
策略(与 JS/Python 字符串上下文不同,见模块文档的「按上下文四类」):
- 先还原 ``\\uXXXX``(HTML 不解析该转义,否则用户会看到字面量);
- 剔除 ``< > { }``:改变标记结构 / 触发 Vue 插值的字符直接去掉;
- 其余字符用 **HTML 实体**转义:浏览器渲染回原字符,展示保真。
参数:
- value (object): 原始展示文本。
返回:
- str: 可安全放进文本节点的文本。
"""
if value is None:
return ""
return html_escape(_VUE_LABEL_STRIP_RE.sub("", unescape_template_text(value)))
def safe_python_type(value: object, default: str = "str") -> str:
"""把 ``python_type`` 规范为合法类型注解片段。
``python_type`` 由表单提交且直接落在 ``Mapped[{{ column.python_type }}]`` 这类注解位置,
必须限定为标识符形态(如 ``str``/``int``/``datetime``/``Decimal``)。
参数:
- value (object): 原始值。
- default (str): 兜底类型。
返回:
- str: 合法标识符形式的类型名。
"""
return safe_ident(value, default=default)
def assert_python_syntax(content: str, *, filename: str = "<generated>") -> None:
"""断言渲染产物是语法合法的 Python。
作为「转义/白名单是否仍然完备」的最后一道网:任何模板改动导致注入或语法破损时,
在**写盘前**就报错,而不是把坏文件写进会被启动期 import 的目录。
参数:
- content (str): 渲染后的源码文本。
- filename (str): 产物相对路径(用于报错定位)。
返回:
- None
异常:
- SyntaxError: 产物无法被 ast.parse 解析时抛出。
"""
try:
ast.parse(content)
except SyntaxError as exc:
raise SyntaxError(f"{filename} 渲染结果不是合法的 Python 代码:{exc.msg}(第 {exc.lineno} 行)") from exc
# ══════════════════════════════════════════════════════════════════════════════
# 「代码位置」类值:一律由**常量白名单**派生,不接收自由文本
# ══════════════════════════════════════════════════════════════════════════════
# column_type 的形状白名单(结构化,不含引号/分号/换行等可注入字符)
_COLUMN_TYPE_RE = re.compile(
r"^[A-Za-z][A-Za-z0-9_ ]*(\(\s*\d+(\s*,\s*\d+)?\s*\))?(\[\])*(\s+unsigned)?$",
)
# MySQL ENUM/SET 列表(合法的库类型,但含引号):整体归一化为基名后再做形状校验
_ENUM_SET_RE = re.compile(r"^(enum|set)\s*\(.*\)$", re.IGNORECASE)
# 从 column_type 的括号里取参数(只接受数字,绝不拼接原始子串)
_TYPE_PARAMS_RE = re.compile(r"\(\s*(\d+)(?:\s*,\s*(\d+))?\s*\)")
# 取出类型基名(字母/数字/下划线/空格,遇括号或方括号即止)
_TYPE_BASE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_ ]*")
# 基名里需要剔除的修饰词(它们不属于类型名,参与查表会导致 miss)
_BASE_MODIFIER_RE = re.compile(r"\s+(unsigned|zerofill)$", re.IGNORECASE)
# 映射常量值的白名单(类型名只能取自这里,杜绝自由文本进产物)
_SQLALCHEMY_TYPE_WHITELIST: frozenset[str] = frozenset(GenConstant.DB_TO_SQLALCHEMY.values())
# Boolean 由 MySQL tinyint(1) 规则显式产出(常量表中未必有该值),显式纳入白名单
_SQLALCHEMY_TYPE_WHITELIST = _SQLALCHEMY_TYPE_WHITELIST | {"Boolean"}
def _lookup_sqlalchemy_type(base: str) -> str:
"""按基名(大小写不敏感)在常量白名单中查表。
参数:
- base (str): 类型基名(已小写)。
返回:
- str: 命中的常量值;未命中返回空串。
"""
for key, value in GenConstant.DB_TO_SQLALCHEMY.items():
if str(key).lower() == base:
return str(value)
return ""
def normalize_column_type(column_type: object) -> str:
"""归一化列类型:去空白、把 ENUM/SET 列表压成基名。
参数:
- column_type (object): 原始列类型(如 ``VARCHAR(64)`` / ``ENUM('a','b')``)。
返回:
- str: 归一化后的类型文本(ENUM/SET 仅保留基名)。
"""
text = "" if column_type is None else str(column_type).strip()
if _ENUM_SET_RE.match(text):
return _TYPE_BASE_RE.match(text).group(0).strip() # type: ignore[union-attr]
return text
def is_valid_column_type(column_type: object) -> bool:
"""列类型形状校验:只允许「基名 + 可选(n)/(n,m) + 可选[] + 可选 unsigned」。
参数:
- column_type (object): 原始列类型。
返回:
- bool: 合法返回 True。
"""
text = normalize_column_type(column_type)
if not text:
return False
return bool(_COLUMN_TYPE_RE.match(text))
def column_type_base(column_type: object) -> str:
"""取列类型基名(用于白名单查表,不参与最终产物拼接)。
参数:
- column_type (object): 原始列类型。
返回:
- str: 基名(如 ``VARCHAR``);无法识别时为空串。
"""
text = normalize_column_type(column_type)
match = _TYPE_BASE_RE.match(text)
if not match:
return ""
base = _BASE_MODIFIER_RE.sub("", match.group(0).strip())
return base.strip()
def extract_type_params(column_type: object, column_length: object = None) -> tuple[str, ...]:
"""提取「长度/精度」参数,只接受纯数字。
取数优先级:``column_length.isdigit()`` → ``column_type`` 括号内正则捕获的数字。
**不对 column_type 做字符串切分拼接**(``split("(")[1]`` 会把任意内容带进产物)。
参数:
- column_type (object): 列类型。
- column_length (object): 列长度(表单/反射字段)。
返回:
- tuple[str, ...]: 形如 ("64",) 或 ("10", "2");无有效参数时为空元组。
"""
length = "" if column_length is None else str(column_length).strip()
if length.isdigit():
return (length,)
text = normalize_column_type(column_type)
match = _TYPE_PARAMS_RE.search(text)
if not match:
return ()
return tuple(part for part in match.groups() if part is not None)
def pick_sqlalchemy_type(column_type: object, column_length: object = None, *, db_type: str | None = None) -> str:
"""由常量白名单派生 SQLAlchemy 类型字符串(代码位置,不接收自由文本)。
规则:
- 类型名只能来自 ``GenConstant.DB_TO_SQLALCHEMY`` 的**值**(未命中回落 ``String``);
- 长度/精度只能来自 ``column_length.isdigit()`` 或 ``column_type`` 括号内的数字捕获;
- MySQL 下 ``tinyint(1)`` 保持 ``Boolean`` 语义。
参数:
- column_type (object): 列类型。
- column_length (object): 列长度。
- db_type (str | None): 数据库类型(默认取 settings.DATABASE_TYPE)。
返回:
- str: 形如 ``String(64)`` / ``Numeric(10,2)`` / ``DateTime`` 的类型字符串。
"""
from app.config.setting import settings # 延迟导入避免模块级循环
dialect = (db_type or settings.DATABASE_TYPE or "").lower()
base = column_type_base(column_type)
params = extract_type_params(column_type, column_length)
lowered = base.lower()
# MySQL:仅 tinyint(1) 映射为 Boolean(与既有行为一致)
if dialect != "postgres" and lowered == "tinyint" and params[:1] == ("1",):
return "Boolean"
# 查表(大小写不敏感):命中值必须落在白名单内
mapped = _lookup_sqlalchemy_type(lowered)
if not mapped and lowered == "character":
mapped = _lookup_sqlalchemy_type("char")
if mapped not in _SQLALCHEMY_TYPE_WHITELIST:
mapped = "String" # 白名单兜底
# 长度/精度:仅对需要参数的字符串与数值类型附加(数字已校验)
if params:
if mapped in {"String", "CHAR"}:
return f"{mapped}({params[0]})"
if mapped in {"Numeric", "DECIMAL"} and len(params) >= 2:
return f"{mapped}({params[0]},{params[1]})"
if mapped in {"String", "CHAR"}:
return f"{mapped}(255)"
return mapped
# ══════════════════════════════════════════════════════════════════════════════
# 落盘前的「结构闸门」:仅 ast.parse 不足以发现注入,必须比对骨架结构
# ══════════════════════════════════════════════════════════════════════════════
# 允许出现在生成产物里的顶层语句类型(白名单);出现其它类型即拒绝
_ALLOWED_TOP_LEVEL_NODES: tuple[type[ast.AST], ...] = (
ast.Import,
ast.ImportFrom,
ast.ClassDef,
ast.FunctionDef,
ast.AsyncFunctionDef,
ast.Assign,
ast.AnnAssign,
ast.Expr,
ast.If,
ast.Try,
ast.Pass,
)
def _node_structure(node: ast.AST) -> object:
"""把 AST 节点归一化为「结构签名」:保留节点类型与层级,抹掉具体取值/标识符。
参数:
- node (ast.AST): 待归一化的节点。
返回:
- object: 可比较的结构签名(元组树)。
"""
if isinstance(node, ast.Constant):
return ("Constant", type(node.value).__name__)
if isinstance(node, ast.Name):
return ("Name",) # 标识符具体名字不参与比较
if isinstance(node, ast.Attribute):
return ("Attribute", _node_structure(node.value))
if isinstance(node, ast.arg):
return ("arg", node.arg) # 形参名由模板决定,参与比较
if isinstance(node, ast.keyword):
return ("keyword", node.arg, _node_structure(node.value))
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return (
type(node).__name__,
node.name,
tuple(_node_structure(item) for item in node.body),
tuple(_node_structure(item) for item in node.decorator_list),
tuple(_node_structure(item) for item in node.args.args),
tuple(_node_structure(item) for item in node.args.kwonlyargs),
node.args.vararg.arg if node.args.vararg else None,
node.args.kwarg.arg if node.args.kwarg else None,
)
if isinstance(node, ast.ClassDef):
return (
"ClassDef",
node.name,
tuple(_node_structure(item) for item in node.bases),
tuple(_node_structure(item) for item in node.body),
tuple(_node_structure(item) for item in node.decorator_list),
)
fields: list[object] = [type(node).__name__]
for _field_name, value in ast.iter_fields(node):
if isinstance(value, list):
fields.append(tuple(_node_structure(item) for item in value if isinstance(item, ast.AST)))
elif isinstance(value, ast.AST):
fields.append(_node_structure(value))
else:
fields.append(type(value).__name__)
return tuple(fields)
def python_structure(content: str) -> tuple[object, ...]:
"""解析源码并返回顶层结构签名(供骨架比对)。
参数:
- content (str): Python 源码。
返回:
- tuple[object, ...]: 顶层语句的结构签名序列。
"""
tree = ast.parse(content)
return tuple(_node_structure(node) for node in tree.body)
def assert_python_structure(content: str, reference: str, *, filename: str = "<generated>") -> None:
"""落盘前的语义/AST 闸门:真实产物必须与「哨兵值骨架」结构一致。
只做 ``ast.parse`` 不能作为安全网(注入出来的代码本身语法合法)。这里要求:
1. 产物语法合法;
2. 顶层语句类型必须在白名单内(Import/ClassDef/AnnAssign…);
3. 与用哨兵值渲染同一模板得到的**骨架**逐节点结构一致(语句数量、类型、装饰器、
形参、关键字参数名全部一致),任何因值而新增/改写的语句都会被发现。
参数:
- content (str): 真实渲染产物。
- reference (str): 哨兵值渲染产出的骨架。
- filename (str): 产物相对路径(报错定位)。
返回:
- None
异常:
- SyntaxError: 语法非法或结构不一致时抛出。
"""
try:
ast.parse(content)
except SyntaxError as exc:
raise SyntaxError(f"{filename} 渲染结果不是合法的 Python 代码:{exc.msg}(第 {exc.lineno} 行)") from exc
real_tree = ast.parse(content)
banned = [type(node).__name__ for node in real_tree.body if not isinstance(node, _ALLOWED_TOP_LEVEL_NODES)]
if banned:
raise SyntaxError(f"{filename} 出现白名单之外的顶层语句:{sorted(set(banned))}")
real = python_structure(content)
skeleton = python_structure(reference)
if real != skeleton:
raise SyntaxError(
f"{filename} 渲染产物结构与模板骨架不一致(疑似模板注入或转义缺口):"
f"真实 {len(real)} 条顶层语句 / 骨架 {len(skeleton)} 条",
)
_STR_LITERAL_RE = re.compile(r"\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*'")
def _normalize_text_line(line: str, tokens: tuple[str, ...]) -> str:
"""把一行里的「值」抹成占位符,只留下骨架(供 .vue/.ts 的粗粒度护栏比对)。
参数:
- line (str): 待归一化的行。
- tokens (tuple[str, ...]): 需要抹掉的动态值(哨兵 + 真实值,按长度降序)。
返回:
- str: 归一化后的行骨架。
"""
normalized = line
for token in tokens:
if token:
normalized = normalized.replace(token, "«»")
return _STR_LITERAL_RE.sub(lambda m: f"{m.group(0)[0]}«»{m.group(0)[0]}", normalized)
def assert_text_structure(
content: str,
reference: str,
*,
sentinel: str,
dynamic_values: object = (),
filename: str = "<generated>",
) -> None:
"""``.vue`` / ``.ts`` / ``.toml`` 产物的粗粒度结构护栏。
这些产物没有 AST 可比,退化为「行结构一致」:把每行的字符串字面量与哨兵值抹成占位符后,
真实产物与骨架的行数与逐行骨架必须完全一致。任何因值而新增的行(注入的 script/标签/语句)
或在一行内跑出字符串字面量的注入都会导致不一致。
参数:
- content (str): 真实渲染产物。
- reference (str): 哨兵值渲染产出的骨架。
- sentinel (str): 哨兵文本。
- dynamic_values (object): 真实值集合(会出现在真实产物里的动态文本,一并抹成占位符)。
- filename (str): 产物相对路径(报错定位)。
返回:
- None
异常:
- ValueError: 行结构不一致时抛出。
"""
tokens = tuple(sorted({sentinel, *[str(item) for item in dynamic_values if item]}, key=len, reverse=True))
real_lines = content.splitlines()
ref_lines = reference.splitlines()
if len(real_lines) != len(ref_lines):
raise ValueError(f"{filename} 产物行数与模板骨架不一致({len(real_lines)} vs {len(ref_lines)}),疑似注入")
for index, (real_line, ref_line) in enumerate(zip(real_lines, ref_lines, strict=True), 1):
if _normalize_text_line(real_line, tokens) != _normalize_text_line(ref_line, tokens):
raise ValueError(f"{filename} 第 {index} 行结构偏离模板骨架,疑似注入:{real_line.strip()[:120]!r}")
@@ -9,3 +9,11 @@ class ServiceInfoOut(BaseModel):
environment: str = Field(..., description="运行环境")
db_status: int = Field(..., description="数据库状态(0:异常 1:正常)")
redis_status: int = Field(..., description="Redis状态(0:异常 1:正常)")
code_exec_enabled: bool = Field(
default=False,
description="定时任务是否允许执行用户提交的代码块(SCHEDULER_ALLOW_CODE_EXEC);关闭时节点型的代码块任务不可调度",
)
cors_origins_configured: bool = Field(
default=True,
description="生产环境是否已显式配置 PROD_CORS_ORIGINS;false 表示生产已按安全默认拒绝全部跨域请求",
)
@@ -51,4 +51,8 @@ class HealthService:
environment=settings.ENVIRONMENT.value,
db_status=db_status,
redis_status=redis_status,
# 升级可见性:这两个开关会静默改变行为(代码块任务不可调度 / 生产拒绝全部跨域),
# 在健康检查里显式暴露,避免运维只看到"任务不动了"而不知原因。
code_exec_enabled=settings.SCHEDULER_ALLOW_CODE_EXEC,
cors_origins_configured=bool(settings.PROD_CORS_ORIGINS) if settings.ENVIRONMENT.value == "prod" else True,
)
@@ -72,7 +72,8 @@ def _require_credentials(provider: OAuthProvider) -> tuple[str, str]:
else:
raise CustomException(msg="不支持的 OAuth 渠道")
if not cid or not sec:
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)")
# 渠道密钥为空属**部署配置缺失**:客户端无论怎么改请求都无法成功,按 500 返回以免监控误判为客户端错误
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)", status_code=500)
return cid, sec
@@ -136,7 +137,7 @@ async def _http_json(method: str, url: str, **kwargs: Any) -> Any:
except json.JSONDecodeError:
text = r.text
logger.error(f"OAuth 非 JSON 响应: {text[:500]}")
raise CustomException(msg="OAuth 接口返回异常")
raise CustomException(msg="OAuth 接口返回异常", status_code=500)
async def _http_text(method: str, url: str, **kwargs: Any) -> str:
+3 -1
View File
@@ -81,7 +81,9 @@ async def auto_register_login_user(
existing = await UserCRUD(auth, db).get(username=username)
if existing:
return existing
raise CustomException(msg=fail_msg)
# 原始异常被吞掉(可能是 DB/唯一约束/驱动等未知服务端故障):按 500 返回,
# 避免监控把内部故障误判为客户端错误;确实的客户端问题由 UserService 的 CustomException 透传
raise CustomException(msg=fail_msg, status_code=500)
user = await UserCRUD(auth, db).get(username=username)
if not user:
raise CustomException(msg=fail_msg)
@@ -67,7 +67,7 @@ async def _http_json(method: str, url: str, **kwargs: Any) -> dict:
return r.json()
except json.JSONDecodeError:
logger.error(f"微信 API 非 JSON 响应: {r.text[:500]}")
raise CustomException(msg="微信接口返回异常")
raise CustomException(msg="微信接口返回异常", status_code=500)
async def _get_access_token(redis: Redis) -> str:
+9 -9
View File
@@ -121,7 +121,7 @@ class DictTypeService:
logger.info(f"创建字典类型成功: {new_obj_dict}")
except Exception as e:
logger.error(f"创建字典类型失败: {e}")
raise CustomException(msg="同步字典类型缓存失败") from e
raise CustomException(msg="同步字典类型缓存失败", status_code=500) from e
return new_obj_dict
@@ -176,7 +176,7 @@ class DictTypeService:
logger.info(f"更新字典类型成功并刷新缓存: {new_obj_dict}")
except Exception as e:
logger.error(f"更新字典类型缓存失败: {e}")
raise CustomException(msg="同步字典类型缓存失败") from e
raise CustomException(msg="同步字典类型缓存失败", status_code=500) from e
return new_obj_dict
@@ -215,7 +215,7 @@ class DictTypeService:
logger.info(f"删除字典类型缓存: {dt}")
except Exception as e:
logger.error(f"删除字典类型缓存失败: {e}")
raise CustomException(msg="同步删除字典缓存失败") from e
raise CustomException(msg="同步删除字典缓存失败", status_code=500) from e
await DictTypeCRUD(self.auth, self.db).delete(ids=ids)
async def set_available(self, data: BatchSetAvailable) -> None:
@@ -330,7 +330,7 @@ class DictDataService:
except Exception as e:
logger.error(f"❌️ 字典初始化过程发生错误: {e}")
raise CustomException(msg="字典数据初始化失败") from e
raise CustomException(msg="字典数据初始化失败", status_code=500) from e
@staticmethod
async def get_init_cache(redis: Redis, dict_type: str) -> list[dict]:
@@ -376,7 +376,7 @@ class DictDataService:
raise
except Exception as e:
logger.error(f"获取字典缓存失败: {e!s}")
raise CustomException(msg="获取字典数据失败") from e
raise CustomException(msg="获取字典数据失败", status_code=500) from e
async def _refresh_dict_cache(self, redis: Redis, dict_type: str) -> None:
"""刷新指定字典类型的 Redis 缓存
@@ -420,7 +420,7 @@ class DictDataService:
logger.info(f"创建字典数据写入缓存成功: {obj}")
except Exception as e:
logger.error(f"创建字典数据写入缓存失败: {e}")
raise CustomException(msg="同步字典数据缓存失败") from e
raise CustomException(msg="同步字典数据缓存失败", status_code=500) from e
return DictDataOutSchema.model_validate(obj)
@@ -462,7 +462,7 @@ class DictDataService:
await self._refresh_dict_cache(redis, dict_type.dict_type)
except Exception as e:
logger.error(f"刷新旧字典缓存失败: {e}")
raise CustomException(msg="同步旧字典数据缓存失败") from e
raise CustomException(msg="同步旧字典数据缓存失败", status_code=500) from e
obj = await DictDataCRUD(self.auth, self.db).update(id=id, data=data)
@@ -472,7 +472,7 @@ class DictDataService:
logger.info(f"更新字典数据写入缓存成功: {obj}")
except Exception as e:
logger.error(f"更新字典数据写入缓存失败: {e}")
raise CustomException(msg="同步字典数据缓存失败") from e
raise CustomException(msg="同步字典数据缓存失败", status_code=500) from e
return DictDataOutSchema.model_validate(obj)
@@ -504,7 +504,7 @@ class DictDataService:
logger.info(f"删除字典数据并刷新缓存: dict_type={dt}")
except Exception as e:
logger.error(f"删除字典数据刷新缓存失败: {e}")
raise CustomException(msg="同步删除字典数据缓存失败") from e
raise CustomException(msg="同步删除字典数据缓存失败", status_code=500) from e
await DictDataCRUD(self.auth, self.db).delete(ids=ids)
async def set_available(self, data: BatchSetAvailable) -> None:
+3 -3
View File
@@ -57,10 +57,10 @@ class ParamsService:
)
if not result:
logger.error(f"同步配置到缓存失败: {out}")
raise CustomException(msg="同步配置到缓存失败")
raise CustomException(msg="同步配置到缓存失败", status_code=500)
except Exception as e:
logger.error(f"更新系统配置失败: {e}")
raise CustomException(msg="同步配置到缓存失败") from e
raise CustomException(msg="同步配置到缓存失败", status_code=500) from e
return out
@@ -95,7 +95,7 @@ class ParamsService:
await ParamsService._sync_configs_to_redis(redis, config_obj)
except Exception as e:
logger.error(f"❌️ 初始化系统参数到 Redis 失败: {e}")
raise CustomException(msg="初始化系统参数到 Redis 失败") from e
raise CustomException(msg="初始化系统参数到 Redis 失败", status_code=500) from e
@staticmethod
async def get_init_cache(redis: Redis) -> list[dict]:
+4 -1
View File
@@ -379,9 +379,12 @@ class UserService:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except CustomException:
# 业务异常原样透传(导入文件为空 / 缺少必要列 / 单行数据不合法等属客户端可修正,保留 400)
raise
except Exception as e:
logger.error(f"批量导入用户失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}") from e
raise CustomException(msg=f"导入失败: {e!s}", status_code=500) from e
async def _process_import_row(
self,
@@ -14,6 +14,8 @@ class SchedulerStatusSchema(BaseModel):
status: str = Field(..., description="调度器状态(运行中/已暂停/已停止/未知)")
is_running: bool = Field(..., description="是否运行中")
job_count: int = Field(..., description="调度器任务数量")
code_exec_enabled: bool = Field(default=False, description="是否允许执行用户提交的代码块(SCHEDULER_ALLOW_CODE_EXEC)")
unschedulable_count: int = Field(default=0, description="已注册但当前不可执行的任务数(>0 时列表页会给出原因)")
class SchedulerJobSchema(BaseModel):
@@ -24,6 +26,8 @@ class SchedulerJobSchema(BaseModel):
trigger: str = Field(..., description="触发器")
next_run_time: str | None = Field(default=None, description="下次运行时间")
status: int = Field(..., description="任务状态(0:运行中 1:暂停中 2:已停止 3:未知)")
schedulable: bool = Field(default=True, description="当前是否可执行;false 表示已注册但执行时会被拒绝")
unschedulable_reason: str | None = Field(default=None, description="不可执行原因(含修复建议)")
class SchedulerJobModifySchema(BaseModel):
@@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.config.setting import settings
from app.core.ap_scheduler import SchedulerUtil
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
@@ -71,25 +72,36 @@ class JobService:
state = SchedulerUtil.get_scheduler_state()
is_running = SchedulerUtil.is_running()
jobs = SchedulerUtil.get_jobs()
# 可观测性(t15):把"已注册但当前不可执行"的任务显式打出并计数,
# 运维不必逐个点开才知道开关调整/处理器改名后哪些任务静默失效
unschedulable = SchedulerUtil.log_unschedulable_jobs(context="调度器状态查询")
return SchedulerStatusSchema(
status=JobService._SCHEDULER_STATE_MAP.get(state, "未知"),
is_running=is_running,
job_count=len(jobs),
code_exec_enabled=settings.SCHEDULER_ALLOW_CODE_EXEC,
unschedulable_count=len(unschedulable),
)
@staticmethod
def get_scheduler_jobs() -> list[SchedulerJobSchema]:
jobs = SchedulerUtil.get_jobs()
return [
SchedulerJobSchema(
id=job.id,
name=job.name,
trigger=str(job.trigger),
next_run_time=str(job.next_run_time) if job.next_run_time else None,
status=SchedulerUtil.get_job_status(job_id=job.id),
items: list[SchedulerJobSchema] = []
for job in jobs:
schedulable, reason = SchedulerUtil.describe_schedulability(SchedulerUtil.job_code_block(job))
items.append(
SchedulerJobSchema(
id=job.id,
name=job.name,
trigger=str(job.trigger),
# pending/未排程任务没有 next_run_time 属性(APScheduler 只在真正排程后设置),必须 getattr 兜底
next_run_time=str(job.next_run_time) if getattr(job, "next_run_time", None) else None,
status=SchedulerUtil.get_job_status(job_id=job.id),
schedulable=schedulable,
unschedulable_reason=None if schedulable else reason,
),
)
for job in jobs
]
return items
# ─── 调度器与任务运维(controller 唯一入口,禁止直调 SchedulerUtil)───
@@ -17,7 +17,14 @@ class NodeCreateSchema(BaseModel):
"""
name: str = Field(..., max_length=64, description="任务名称")
func: str | None = Field(default=None, description="代码块")
func: str | None = Field(
default=None,
description=(
"任务函数:两种写法——① 内置函数型 `builtin:<模块名>[.<函数名>]`(白名单解析后直接调用,不经 exec,"
"不受 SCHEDULER_ALLOW_CODE_EXEC 限制);② 原始代码块(需定义 handler(*args, **kwargs),等同服务器代码执行权限,"
"仅在 SCHEDULER_ALLOW_CODE_EXEC=true 时允许)"
),
)
args: str | None = Field(default=None, description="位置参数")
kwargs: str | None = Field(default=None, description="关键字参数")
coalesce: bool | None = Field(default=False, description="是否合并运行:是否在多个运行时间到期时仅运行作业一次")
@@ -102,6 +109,11 @@ class NodeOutSchema(NodeCreateSchema, BaseSchema, UserBySchema):
next_run_time: str | None = Field(default=None, description="下次运行时间")
last_run_time: str | None = Field(default=None, description="最近一次运行时间")
last_run_status: int | None = Field(default=None, description="最近一次运行状态(2:成功 3:失败)")
schedulable: bool | None = Field(
default=None,
description="当前是否可调度;false 表示「已启用也不会执行」(如代码块被禁用、内置处理器不存在、未配置函数)",
)
unschedulable_reason: str | None = Field(default=None, description="不可调度原因(含修复建议)")
model_config = ConfigDict(from_attributes=True)
@@ -11,7 +11,6 @@ from croniter import croniter
from sqlalchemy import false, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config.setting import settings
from app.core.ap_scheduler import SchedulerUtil, scheduler
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
@@ -179,7 +178,17 @@ class NodeService:
node_id = str(item.id) if item.id is not None else ""
job = jobs.get(node_id)
if job:
item.next_run_time = str(job.next_run_time) if job.next_run_time else None
item.next_run_time = str(job.next_run_time) if getattr(job, "next_run_time", None) else None
# 可调度状态(t15):区分「已启用且可执行」与「已启用但当前不会执行」,
# 并把后者以 WARN 打出(带节点标识),避免升级/开关调整后的静默失效
schedulable, reason = SchedulerUtil.describe_schedulability(item.func)
item.schedulable = schedulable
item.unschedulable_reason = None if schedulable else reason
if not schedulable and item.status == 0:
_warn_unschedulable_node(node_id=node_id, name=item.name, reason=reason)
elif schedulable:
_clear_unschedulable_warning(node_id)
stmt = (
select(JobModel)
.where(JobModel.job_id == node_id, JobModel.is_deleted == false())
@@ -192,6 +201,41 @@ class NodeService:
item.last_run_status = row.status
# 「已启用但不可调度」的告警去重:同一节点 + 同一原因只告警一次,
# 避免列表接口被反复调用时刷屏;节点恢复可调度时清除记录(见 _enrich_runtime)。
_WARNED_UNSCHEDULABLE: dict[str, str] = {}
def _warn_unschedulable_node(*, node_id: str, name: str | None, reason: str | None) -> None:
"""对「已启用但当前不可调度」的节点打一次 WARN(带节点标识与原因)。
参数:
- node_id (str): 节点 ID(调度器 job id)。
- name (str | None): 节点名称。
- reason (str | None): 不可调度原因。
返回:
- None
"""
text = reason or ""
if _WARNED_UNSCHEDULABLE.get(node_id) == text:
return
_WARNED_UNSCHEDULABLE[node_id] = text
logger.warning(f"节点任务不可调度(已启用但不会执行): id={node_id} name={name!r} 原因={reason}")
def _clear_unschedulable_warning(node_id: str) -> None:
"""节点恢复可调度后清除其告警去重记录(下次再失效仍会告警)。
参数:
- node_id (str): 节点 ID。
返回:
- None
"""
_WARNED_UNSCHEDULABLE.pop(node_id, None)
# ── NodeModel 封装的调度操作(模块级函数,供 service / 启动自愈复用)────
@@ -281,8 +325,11 @@ def _add_job_with_trigger(
code_block = job_info.func
if not code_block or not code_block.strip():
raise ValueError("任务代码块不能为空")
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
raise CustomException(msg="服务已禁用定时任务代码执行(SCHEDULER_ALLOW_CODE_EXEC=False),无法注册代码块任务")
# 可调度性判定(单一事实来源):builtin: 引用走白名单、不经 exec,因此**开关关闭时也允许注册**;
# 只有"原始代码块"才会被 SCHEDULER_ALLOW_CODE_EXEC=False 拦住,并在提示里给出 builtin 替代写法。
schedulable, reason = SchedulerUtil.describe_schedulability(code_block)
if not schedulable:
raise CustomException(msg=reason)
jobstore = job_info.jobstore or "default"
executor = job_info.executor or "threadpool"
@@ -368,8 +415,10 @@ def run_node_once(node: NodeModel) -> str:
"""
if not node.func or not node.func.strip():
raise ValueError("任务代码块不能为空")
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
raise CustomException(msg="服务已禁用定时任务代码执行(SCHEDULER_ALLOW_CODE_EXEC=False),无法执行代码块任务")
# 手动执行同样按可调度性判定:builtin 引用可直接执行;原始代码块在开关关闭时给出替代写法
schedulable, reason = SchedulerUtil.describe_schedulability(node.func)
if not schedulable:
raise CustomException(msg=reason)
temp_job_id = f"{node.id}{_MANUAL_JOB_PREFIX}{datetime.now():%Y%m%d%H%M%S}"
trigger = DateTrigger(run_date=datetime.now() + timedelta(seconds=0.1), timezone="Asia/Shanghai")
_add_job_with_trigger(node, trigger, job_id_override=temp_job_id, name_suffix=" - 手动执行")
@@ -80,7 +80,7 @@ def decrypt_password(cipher: str | None) -> str:
try:
return CryptoUtil.decrypt(cipher)
except CustomException as e:
raise CustomException(msg=f"存储源密码解密失败:{e!s}")
raise CustomException(msg=f"存储源密码解密失败:{e!s}", status_code=500)
# 文件系统类协议与对象存储类协议的划分:仅用于文档说明与配置元数据,
@@ -322,7 +322,7 @@ class BaseStorageAdapter(ABC):
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(_run, tasks))
if errors:
raise CustomException(msg=f"批量上传失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}")
raise CustomException(msg=f"批量上传失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}", status_code=500)
return len(tasks)
def _sync_download_dir(self, remote_dir: str, local_dir: str, concurrency: int) -> int:
@@ -352,7 +352,7 @@ class BaseStorageAdapter(ABC):
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(_run, tasks))
if errors:
raise CustomException(msg=f"批量下载失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}")
raise CustomException(msg=f"批量下载失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}", status_code=500)
return len(tasks)
@staticmethod
@@ -96,7 +96,7 @@ class CosStorageAdapter(BaseStorageAdapter):
EnableMD5=self._adv.enable_md5,
)
except Exception as e:
raise CustomException(msg=f"COS 上传失败: {e!s}")
raise CustomException(msg=f"COS 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -107,7 +107,7 @@ class CosStorageAdapter(BaseStorageAdapter):
DestFilePath=local_path,
)
except Exception as e:
raise CustomException(msg=f"COS 下载失败: {e!s}")
raise CustomException(msg=f"COS 下载失败: {e!s}", status_code=500)
return local_path
def _sync_delete(self, remote_path: str) -> None:
@@ -122,7 +122,7 @@ class CosStorageAdapter(BaseStorageAdapter):
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_path)
except Exception as e:
raise CustomException(msg=f"COS 删除失败: {e!s}")
raise CustomException(msg=f"COS 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -181,7 +181,7 @@ class CosStorageAdapter(BaseStorageAdapter):
break
return result
except Exception as e:
raise CustomException(msg=f"COS 列表失败: {e!s}")
raise CustomException(msg=f"COS 列表失败: {e!s}", status_code=500)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(COS Marker),翻页经前端回传游标。"""
@@ -250,7 +250,7 @@ class CosStorageAdapter(BaseStorageAdapter):
next_cursor=next_cursor or None,
)
except Exception as e:
raise CustomException(msg=f"COS 列表失败: {e!s}")
raise CustomException(msg=f"COS 列表失败: {e!s}", status_code=500)
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
@@ -259,7 +259,7 @@ class CosStorageAdapter(BaseStorageAdapter):
buckets = (resp.get("Buckets") or {}).get("Bucket") or []
return [b.get("Name", "") for b in buckets if b.get("Name")]
except Exception as e:
raise CustomException(msg=f"COS 桶列表失败: {e!s}")
raise CustomException(msg=f"COS 桶列表失败: {e!s}", status_code=500)
def _sync_get_url(self, remote_path: str, expire: int) -> str:
"""生成预签名 URL
@@ -280,7 +280,7 @@ class CosStorageAdapter(BaseStorageAdapter):
Expired=expire,
)
except Exception as e:
raise CustomException(msg=f"COS 生成预签名 URL 失败: {e!s}")
raise CustomException(msg=f"COS 生成预签名 URL 失败: {e!s}", status_code=500)
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -311,13 +311,13 @@ class CosStorageAdapter(BaseStorageAdapter):
try:
self.client.put_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/", Body=b"")
except Exception as e:
raise CustomException(msg=f"COS 创建目录失败: {e!s}")
raise CustomException(msg=f"COS 创建目录失败: {e!s}", status_code=500)
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/")
except Exception as e:
raise CustomException(msg=f"COS 删除目录失败: {e!s}")
raise CustomException(msg=f"COS 删除目录失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -331,7 +331,7 @@ class CosStorageAdapter(BaseStorageAdapter):
CopySource={"Bucket": self._require_bucket(), "Key": src},
)
except Exception as e:
raise CustomException(msg=f"COS 复制失败: {e!s}")
raise CustomException(msg=f"COS 复制失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -345,7 +345,7 @@ class CosStorageAdapter(BaseStorageAdapter):
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"COS 递归列表失败: {e!s}")
raise CustomException(msg=f"COS 递归列表失败: {e!s}", status_code=500)
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -361,4 +361,4 @@ class CosStorageAdapter(BaseStorageAdapter):
Delete={"Objects": [{"Key": k} for k in keys[i : i + 1000]], "Quiet": True},
)
except Exception as e:
raise CustomException(msg=f"COS 递归删除失败: {e!s}")
raise CustomException(msg=f"COS 递归删除失败: {e!s}", status_code=500)
@@ -101,7 +101,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
with open(local_path, "rb") as f:
client.storbinary(f"STOR {remote_path}", f)
except Exception as e:
raise CustomException(msg=f"FTP 上传失败: {e!s}")
raise CustomException(msg=f"FTP 上传失败: {e!s}", status_code=500)
finally:
self._close_client(client)
return remote_path
@@ -123,7 +123,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
with open(local_path, "wb") as f:
client.retrbinary(f"RETR {remote_path}", f.write)
except Exception as e:
raise CustomException(msg=f"FTP 下载失败: {e!s}")
raise CustomException(msg=f"FTP 下载失败: {e!s}", status_code=500)
finally:
self._close_client(client)
return local_path
@@ -133,7 +133,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
try:
client.delete(remote_path)
except Exception as e:
raise CustomException(msg=f"FTP 删除失败: {e!s}")
raise CustomException(msg=f"FTP 删除失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -182,7 +182,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
)
return result
except Exception as e:
raise CustomException(msg=f"FTP 列表失败: {e!s}")
raise CustomException(msg=f"FTP 列表失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -200,7 +200,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
except ftplib.error_perm:
pass # 目录已存在时跳过(部分服务器返回 550)
except Exception as e:
raise CustomException(msg=f"FTP 创建目录失败: {e!s}")
raise CustomException(msg=f"FTP 创建目录失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -209,7 +209,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
try:
client.rmd(remote_dir)
except Exception as e:
raise CustomException(msg=f"FTP 删除目录失败: {e!s}")
raise CustomException(msg=f"FTP 删除目录失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -218,7 +218,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
try:
client.rename(src, dst)
except Exception as e:
raise CustomException(msg=f"FTP 重命名失败: {e!s}")
raise CustomException(msg=f"FTP 重命名失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -248,6 +248,6 @@ class FtpStorageAdapter(BaseStorageAdapter):
finally:
os.remove(tmp_path)
except Exception as e:
raise CustomException(msg=f"FTP 复制失败: {e!s}")
raise CustomException(msg=f"FTP 复制失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -37,7 +37,7 @@ class LocalStorageAdapter(BaseStorageAdapter):
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(local_path, dest)
except Exception as e:
raise CustomException(msg=f"本地存储上传失败: {e!s}")
raise CustomException(msg=f"本地存储上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -45,7 +45,7 @@ class LocalStorageAdapter(BaseStorageAdapter):
try:
shutil.copy2(src, local_path)
except Exception as e:
raise CustomException(msg=f"本地存储下载失败: {e!s}")
raise CustomException(msg=f"本地存储下载失败: {e!s}", status_code=500)
return local_path
def _sync_delete(self, remote_path: str) -> None:
@@ -56,7 +56,7 @@ class LocalStorageAdapter(BaseStorageAdapter):
elif os.path.exists(target):
os.remove(target)
except Exception as e:
raise CustomException(msg=f"本地存储删除失败: {e!s}")
raise CustomException(msg=f"本地存储删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
return os.path.exists(self._abs_path(remote_path))
@@ -66,7 +66,7 @@ class LocalStorageAdapter(BaseStorageAdapter):
try:
entries = os.scandir(base_dir)
except OSError as e:
raise CustomException(msg=f"本地存储列表失败: {e!s}")
raise CustomException(msg=f"本地存储列表失败: {e!s}", status_code=500)
result: list[StorageObject] = []
for entry in entries:
@@ -93,20 +93,20 @@ class LocalStorageAdapter(BaseStorageAdapter):
try:
os.makedirs(self._abs_path(remote_dir), exist_ok=True)
except Exception as e:
raise CustomException(msg=f"本地存储创建目录失败: {e!s}")
raise CustomException(msg=f"本地存储创建目录失败: {e!s}", status_code=500)
def _sync_rmdir(self, remote_dir: str) -> None:
try:
os.rmdir(self._abs_path(remote_dir))
except Exception as e:
raise CustomException(msg=f"本地存储删除目录失败: {e!s}")
raise CustomException(msg=f"本地存储删除目录失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
try:
os.makedirs(os.path.dirname(self._abs_path(dst)), exist_ok=True)
shutil.move(self._abs_path(src), self._abs_path(dst))
except Exception as e:
raise CustomException(msg=f"本地存储重命名失败: {e!s}")
raise CustomException(msg=f"本地存储重命名失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
src_abs, dst_abs = self._abs_path(src), self._abs_path(dst)
@@ -117,4 +117,4 @@ class LocalStorageAdapter(BaseStorageAdapter):
os.makedirs(os.path.dirname(dst_abs), exist_ok=True)
shutil.copy2(src_abs, dst_abs)
except Exception as e:
raise CustomException(msg=f"本地存储复制失败: {e!s}")
raise CustomException(msg=f"本地存储复制失败: {e!s}", status_code=500)
@@ -176,7 +176,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
content=f,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}", status_code=500)
return remote_path
part_size, concurrency, _ = self._multipart_settings()
resp = self.client.uploadFile(
@@ -188,11 +188,11 @@ class ObsStorageAdapter(BaseStorageAdapter):
enableCheckpoint=True,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 上传失败: {e!s}")
raise CustomException(msg=f"OBS 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -207,22 +207,22 @@ class ObsStorageAdapter(BaseStorageAdapter):
enableCheckpoint=True,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 下载失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 下载失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 下载失败: {e!s}")
raise CustomException(msg=f"OBS 下载失败: {e!s}", status_code=500)
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
resp = self.client.deleteObject(bucketName=self._require_bucket(), objectKey=remote_path)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 删除失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 删除失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 删除失败: {e!s}")
raise CustomException(msg=f"OBS 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -255,7 +255,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
kwargs["marker"] = marker
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}", status_code=500)
body = getattr(resp, "body", None)
for common in getattr(body, "commonPrefixs", None) or []:
raw_key = (getattr(common, "prefix", "") or "").rstrip("/")
@@ -292,7 +292,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 列表失败: {e!s}")
raise CustomException(msg=f"OBS 列表失败: {e!s}", status_code=500)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(OBS marker),翻页经前端回传游标。"""
@@ -307,7 +307,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
kwargs["marker"] = cursor
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}", status_code=500)
body = getattr(resp, "body", None)
truncated = bool(getattr(body, "is_truncated", None))
next_cursor = getattr(body, "next_marker", None) or None
@@ -339,28 +339,28 @@ class ObsStorageAdapter(BaseStorageAdapter):
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 列表失败: {e!s}")
raise CustomException(msg=f"OBS 列表失败: {e!s}", status_code=500)
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
try:
resp = self.client.listBuckets()
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 桶列表失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 桶列表失败: {self._error_desc(resp)}", status_code=500)
body = getattr(resp, "body", None)
buckets = getattr(body, "buckets", None) or []
return [getattr(b, "name", "") or "" for b in buckets if getattr(b, "name", "")]
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 桶列表失败: {e!s}")
raise CustomException(msg=f"OBS 桶列表失败: {e!s}", status_code=500)
def _sync_get_url(self, remote_path: str, expire: int) -> str:
try:
resp = self.client.createSignedUrl("GET", bucketName=self._require_bucket(), objectKey=remote_path, expires=expire)
return getattr(resp, "signedUrl", "")
except Exception as e:
raise CustomException(msg=f"OBS 生成预签名 URL 失败: {e!s}")
raise CustomException(msg=f"OBS 生成预签名 URL 失败: {e!s}", status_code=500)
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -374,7 +374,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
kwargs["marker"] = marker
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列举对象失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 列举对象失败: {self._error_desc(resp)}", status_code=500)
body = getattr(resp, "body", None)
for obj in getattr(body, "contents", None) or []:
key = getattr(obj, "key", "")
@@ -403,21 +403,21 @@ class ObsStorageAdapter(BaseStorageAdapter):
try:
resp = self.client.putContent(bucketName=self._require_bucket(), objectKey=remote_dir.rstrip("/") + "/", content=b"")
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 创建目录失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 创建目录失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 创建目录失败: {e!s}")
raise CustomException(msg=f"OBS 创建目录失败: {e!s}", status_code=500)
def _sync_rmdir(self, remote_dir: str) -> None:
try:
resp = self.client.deleteObject(bucketName=self._require_bucket(), objectKey=remote_dir.rstrip("/") + "/")
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 删除目录失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 删除目录失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 删除目录失败: {e!s}")
raise CustomException(msg=f"OBS 删除目录失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copyObject。"""
@@ -432,11 +432,11 @@ class ObsStorageAdapter(BaseStorageAdapter):
destObjectKey=dst,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 复制失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 复制失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 复制失败: {e!s}")
raise CustomException(msg=f"OBS 复制失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:优先 OBS 原生 renameFile(支持目录级),失败回退先复制后删除。"""
@@ -456,7 +456,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"OBS 递归列表失败: {e!s}")
raise CustomException(msg=f"OBS 递归列表失败: {e!s}", status_code=500)
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -473,11 +473,11 @@ class ObsStorageAdapter(BaseStorageAdapter):
deleteObjectsRequest={"quiet": True, "objects": [{"key": k} for k in batch]},
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 递归删除失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 递归删除失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 递归删除失败: {e!s}")
raise CustomException(msg=f"OBS 递归删除失败: {e!s}", status_code=500)
def _sync_close(self) -> None:
"""关闭 OBS 客户端连接。"""
@@ -74,7 +74,7 @@ class OssStorageAdapter(BaseStorageAdapter):
parallel_num=concurrency,
)
except Exception as e:
raise CustomException(msg=f"OSS 上传失败: {e!s}")
raise CustomException(msg=f"OSS 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -84,14 +84,14 @@ class OssStorageAdapter(BaseStorageAdapter):
local_path,
)
except Exception as e:
raise CustomException(msg=f"OSS 下载失败: {e!s}")
raise CustomException(msg=f"OSS 下载失败: {e!s}", status_code=500)
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self.client.delete_object(oss.DeleteObjectRequest(bucket=self.bucket_name, key=remote_path))
except Exception as e:
raise CustomException(msg=f"OSS 删除失败: {e!s}")
raise CustomException(msg=f"OSS 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -140,7 +140,7 @@ class OssStorageAdapter(BaseStorageAdapter):
)
return result
except Exception as e:
raise CustomException(msg=f"OSS 列表失败: {e!s}")
raise CustomException(msg=f"OSS 列表失败: {e!s}", status_code=500)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(OSS continuation_token),翻页经前端回传游标。"""
@@ -178,7 +178,7 @@ class OssStorageAdapter(BaseStorageAdapter):
)
return StoragePage(items=items, has_next=truncated, next_cursor=next_cursor)
except Exception as e:
raise CustomException(msg=f"OSS 列表失败: {e!s}")
raise CustomException(msg=f"OSS 列表失败: {e!s}", status_code=500)
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
@@ -191,7 +191,7 @@ class OssStorageAdapter(BaseStorageAdapter):
result.append(b.name)
return result
except Exception as e:
raise CustomException(msg=f"OSS 桶列表失败: {e!s}")
raise CustomException(msg=f"OSS 桶列表失败: {e!s}", status_code=500)
def _sync_get_url(self, remote_path: str, expire: int) -> str | None:
try:
@@ -201,7 +201,7 @@ class OssStorageAdapter(BaseStorageAdapter):
)
return result.url
except Exception as e:
raise CustomException(msg=f"OSS 生成预签名 URL 失败: {e!s}")
raise CustomException(msg=f"OSS 生成预签名 URL 失败: {e!s}", status_code=500)
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -231,13 +231,13 @@ class OssStorageAdapter(BaseStorageAdapter):
try:
self.client.put_object(oss.PutObjectRequest(bucket=self.bucket_name, key=remote_dir.rstrip("/") + "/", body=b""))
except Exception as e:
raise CustomException(msg=f"OSS 创建目录失败: {e!s}")
raise CustomException(msg=f"OSS 创建目录失败: {e!s}", status_code=500)
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(oss.DeleteObjectRequest(bucket=self.bucket_name, key=remote_dir.rstrip("/") + "/"))
except Exception as e:
raise CustomException(msg=f"OSS 删除目录失败: {e!s}")
raise CustomException(msg=f"OSS 删除目录失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -249,7 +249,7 @@ class OssStorageAdapter(BaseStorageAdapter):
oss.CopyObjectRequest(bucket=self.bucket_name, key=dst, source_bucket=self.bucket_name, source_key=src)
)
except Exception as e:
raise CustomException(msg=f"OSS 复制失败: {e!s}")
raise CustomException(msg=f"OSS 复制失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -263,7 +263,7 @@ class OssStorageAdapter(BaseStorageAdapter):
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"OSS 递归列表失败: {e!s}")
raise CustomException(msg=f"OSS 递归列表失败: {e!s}", status_code=500)
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -282,4 +282,4 @@ class OssStorageAdapter(BaseStorageAdapter):
)
)
except Exception as e:
raise CustomException(msg=f"OSS 递归删除失败: {e!s}")
raise CustomException(msg=f"OSS 递归删除失败: {e!s}", status_code=500)
@@ -88,21 +88,21 @@ class S3StorageAdapter(BaseStorageAdapter):
)
self.client.upload_file(local_path, self._require_bucket(), remote_path, Config=transfer_config)
except Exception as e:
raise CustomException(msg=f"S3 上传失败: {e!s}")
raise CustomException(msg=f"S3 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
try:
self.client.download_file(self._require_bucket(), remote_path, local_path)
except Exception as e:
raise CustomException(msg=f"S3 下载失败: {e!s}")
raise CustomException(msg=f"S3 下载失败: {e!s}", status_code=500)
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_path)
except Exception as e:
raise CustomException(msg=f"S3 删除失败: {e!s}")
raise CustomException(msg=f"S3 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -163,7 +163,7 @@ class S3StorageAdapter(BaseStorageAdapter):
break
return result
except Exception as e:
raise CustomException(msg=f"S3 列表失败: {e!s}")
raise CustomException(msg=f"S3 列表失败: {e!s}", status_code=500)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(S3 ContinuationToken),翻页经前端回传游标。"""
@@ -203,7 +203,7 @@ class S3StorageAdapter(BaseStorageAdapter):
next_cursor=resp.get("NextContinuationToken"),
)
except Exception as e:
raise CustomException(msg=f"S3 列表失败: {e!s}")
raise CustomException(msg=f"S3 列表失败: {e!s}", status_code=500)
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
@@ -211,7 +211,7 @@ class S3StorageAdapter(BaseStorageAdapter):
resp = self.client.list_buckets()
return [b.get("Name", "") for b in resp.get("Buckets", []) if b.get("Name")]
except Exception as e:
raise CustomException(msg=f"S3 桶列表失败: {e!s}")
raise CustomException(msg=f"S3 桶列表失败: {e!s}", status_code=500)
def _sync_get_url(self, remote_path: str, expire: int) -> str:
try:
@@ -221,7 +221,7 @@ class S3StorageAdapter(BaseStorageAdapter):
ExpiresIn=expire,
)
except Exception as e:
raise CustomException(msg=f"S3 生成预签名 URL 失败: {e!s}")
raise CustomException(msg=f"S3 生成预签名 URL 失败: {e!s}", status_code=500)
def _sync_close(self) -> None:
"""关闭 boto3 客户端连接。"""
@@ -253,13 +253,13 @@ class S3StorageAdapter(BaseStorageAdapter):
try:
self.client.put_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/", Body=b"")
except Exception as e:
raise CustomException(msg=f"S3 创建目录失败: {e!s}")
raise CustomException(msg=f"S3 创建目录失败: {e!s}", status_code=500)
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/")
except Exception as e:
raise CustomException(msg=f"S3 删除目录失败: {e!s}")
raise CustomException(msg=f"S3 删除目录失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -273,7 +273,7 @@ class S3StorageAdapter(BaseStorageAdapter):
Key=dst,
)
except Exception as e:
raise CustomException(msg=f"S3 复制失败: {e!s}")
raise CustomException(msg=f"S3 复制失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -287,7 +287,7 @@ class S3StorageAdapter(BaseStorageAdapter):
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"S3 递归列表失败: {e!s}")
raise CustomException(msg=f"S3 递归列表失败: {e!s}", status_code=500)
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -303,4 +303,4 @@ class S3StorageAdapter(BaseStorageAdapter):
Delete={"Objects": [{"Key": k} for k in keys[i : i + 1000]], "Quiet": True},
)
except Exception as e:
raise CustomException(msg=f"S3 递归删除失败: {e!s}")
raise CustomException(msg=f"S3 递归删除失败: {e!s}", status_code=500)
@@ -118,7 +118,7 @@ class SftpStorageAdapter(BaseStorageAdapter):
self._ensure_remote_dir(client, dir_part)
client.put(local_path, remote_path)
except Exception as e:
raise CustomException(msg=f"SFTP 上传失败: {e!s}")
raise CustomException(msg=f"SFTP 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -141,14 +141,14 @@ class SftpStorageAdapter(BaseStorageAdapter):
else:
client.get(remote_path, local_path)
except Exception as e:
raise CustomException(msg=f"SFTP 下载失败: {e!s}")
raise CustomException(msg=f"SFTP 下载失败: {e!s}", status_code=500)
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self._connect().remove(remote_path)
except Exception as e:
raise CustomException(msg=f"SFTP 删除失败: {e!s}")
raise CustomException(msg=f"SFTP 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -162,7 +162,7 @@ class SftpStorageAdapter(BaseStorageAdapter):
# 空 prefix 表示浏览根目录:SFTP 用 "." 表示登录后的当前目录
attrs = self._connect().listdir_attr(prefix or ".")
except Exception as e:
raise CustomException(msg=f"SFTP 列表失败: {e!s}")
raise CustomException(msg=f"SFTP 列表失败: {e!s}", status_code=500)
result: list[StorageObject] = []
for attr in attrs:
result.append(
@@ -189,19 +189,19 @@ class SftpStorageAdapter(BaseStorageAdapter):
try:
self._ensure_remote_dir(self._connect(), remote_dir)
except Exception as e:
raise CustomException(msg=f"SFTP 创建目录失败: {e!s}")
raise CustomException(msg=f"SFTP 创建目录失败: {e!s}", status_code=500)
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self._connect().rmdir(remote_dir)
except Exception as e:
raise CustomException(msg=f"SFTP 删除目录失败: {e!s}")
raise CustomException(msg=f"SFTP 删除目录失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
try:
self._connect().posix_rename(src, dst)
except Exception as e:
raise CustomException(msg=f"SFTP 重命名失败: {e!s}")
raise CustomException(msg=f"SFTP 重命名失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用通道流式复制(SFTP 无服务端复制)。"""
@@ -214,4 +214,4 @@ class SftpStorageAdapter(BaseStorageAdapter):
while chunk := rf.read(1024 * 1024):
wf.write(chunk)
except Exception as e:
raise CustomException(msg=f"SFTP 复制失败: {e!s}")
raise CustomException(msg=f"SFTP 复制失败: {e!s}", status_code=500)
@@ -262,7 +262,7 @@ class DemoService:
except Exception as e:
logger.error(f"批量导入示例失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}") from e
raise CustomException(msg=f"导入失败: {e!s}", status_code=500) from e
@staticmethod
def import_template_download() -> bytes:
+1 -1
View File
@@ -71,7 +71,7 @@ class CryptoUtil:
return fernet.decrypt(cipher.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
continue
raise CustomException(msg="敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏")
raise CustomException(msg="敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏", status_code=500)
@classmethod
def decrypt_or_keep(cls, value: str | None) -> str:
+2 -2
View File
@@ -154,7 +154,7 @@ class ImportUtil:
if "cannot import name" not in str(e):
raise ImportError(f"❗️ 警告: 无法导入模块 {module_name}: {e}")
except Exception as e:
raise CustomException(f"❌️ 处理模块 {module_name} 时出错: {e}")
raise CustomException(f"❌️ 处理模块 {module_name} 时出错: {e}", status_code=500)
# 查找apscheduler_jobs表的模型(如果存在)
cls._find_apscheduler_model(base_class, models, seen_models, seen_tables)
@@ -205,4 +205,4 @@ class ImportUtil:
except ImportError:
pass
except Exception as e:
raise CustomException(f"❗️ 查找APScheduler模型时出错: {e}")
raise CustomException(f"❗️ 查找APScheduler模型时出错: {e}", status_code=500)
+1 -1
View File
@@ -468,7 +468,7 @@ class UploadUtil:
raise
except Exception as e:
logger.error(f"文件上传失败: {e}")
raise CustomException(msg=f"文件上传失败: {e}")
raise CustomException(msg=f"文件上传失败: {e}", status_code=500)
@staticmethod
def get_file_tree(file_path: str) -> list[dict]:
+59
View File
@@ -35,7 +35,66 @@ REDIS_DB_NAME = 1
# 日志级别配置
LOGGER_LEVEL = DEBUG
# ================================================= #
# *************** 安全相关(生产必填)*************** #
# ================================================= #
# 令牌签名密钥:openssl rand -hex 32(更换后所有已签发令牌失效)
SECRET_KEY = change_me_generate_with_openssl_rand_hex_32
# 落库敏感数据加密主密钥:openssl rand -hex 32;留空则由 SECRET_KEY 派生
DATA_ENCRYPTION_KEY = ''
# 轮换前的旧主密钥(逗号分隔),仅用于解密历史密文
DATA_ENCRYPTION_OLD_KEYS = ''
# 生产跨域白名单(逗号分隔,如 https://admin.example.com,https://www.example.com)
# ⚠️ 生产留空 = 拒绝全部跨域请求(安全默认,不再回落通配 "*";同源部署不受影响)。
# 存在站外集成时必须显式列出域名;填 "*" 会被忽略(与 ALLOW_CREDENTIALS=True 组合不可接受)。
PROD_CORS_ORIGINS = ''
# 严格启动:true 时「生产未配置 PROD_CORS_ORIGINS」直接启动失败(默认 false 只打 warning)
CORS_STRICT_STARTUP = False
# 只信任该网段内的反向代理传来的 X-Forwarded-*(切勿使用 *)
FORWARDED_ALLOW_IPS = 10.255.178.0/24
# 演示模式:true 时禁止写操作
DEMO_ENABLE = False
# ================================================= #
# ******************* 定时任务 ******************* #
# ================================================= #
# 是否允许定时任务执行用户提交的代码块(exec,等同服务器代码执行权限)。
# ⚠️ 默认 False:调成 false(或保持默认)时,**所有「代码块型」节点任务都不会被调度**
# (注册/手动执行会返回明确错误:"服务已禁用定时任务代码执行…"),执行结果可在任务执行日志查看;
# 这只关闭 exec 能力,不影响调度器本身、也不影响无代码块的其它任务。
# 如确需该能力,请显式置为 true,并按最小权限授予任务管理权限。
SCHEDULER_ALLOW_CODE_EXEC = False
# ================================================= #
# ******************* 主机名校验 ******************* #
# ================================================= #
# 允许的 Host 头(逗号分隔的 JSON 数组);生产填真实域名
# ALLOWED_HOSTS = ["service.fastapiadmin.com", "*.fastapiadmin.com"]
# 容器内健康检查/本地直连需要 Host: localhost|127.0.0.1|[::1];生产不需要时置 False 收敛校验范围
ALLOW_LOCALHOST_HOSTS = True
# OAuth 回调域名白名单(**不再是通配符 "*"**);非生产默认额外放行本机与回环地址
# OAUTH_ALLOWED_HOSTS = ["service.fastapiadmin.com", "*.fastapiadmin.com", "localhost", "127.0.0.1", "[::1]"]
# 大模型配置
OPENAI_BASE_URL = https://api.deepseek.com
OPENAI_API_KEY = your_api_key
OPENAI_MODEL = deepseek-v4-flash
# ================================================= #
# *********** 升级说明(行为变更,请逐条确认)***********
# ================================================= #
# 1) SCHEDULER_ALLOW_CODE_EXEC 默认由 true 改为 false:升级后所有「代码块型」节点任务
# 不再被调度/执行,直至显式配置为 true。健康检查(/api/v1/monitor/health/check)新增
# code_exec_enabled 字段可用于确认当前生效值。
# 2) 生产 PROD_CORS_ORIGINS 未配置时不再回落通配 "*",而是拒绝全部跨域请求
# (同源部署不受影响)。健康检查新增 cors_origins_configured 字段;可用 CORS_STRICT_STARTUP=true
# 把它变成启动失败以便尽早发现。
# 3) 业务异常的 HTTP 状态码按语义返回:客户端可修正类为 4xx(参数/验证码 400、无权限 403、
# 资源不存在 404),包装底层异常的内部故障为 500(此前统一 500/后统一 400 均不正确)。
# 4) 代码生成器的列名必须是合法标识符(且不能是 Python 关键字 / JS 保留字),
# 列类型必须是「类型名 + 可选(长度[,精度]) + 可选[] + 可选 unsigned」的形状;
# 含引号/分号/任意自由文本的类型会被拒绝(ENUM/SET 会归一化为基名)。
# 5) 代码生成落盘前新增结构闸门:.py 产物必须与「哨兵值骨架」AST 一致,.vue/.ts/.toml 做行结构比对,
# 任何注入/转义缺口都会在写盘前报错,而不是写出可被启动期 import 的坏文件。
# 6) OAUTH_ALLOWED_HOSTS 默认不再是 "*";如需自定义请在对应环境中显式配置。
# 7) ALLOWED_HOSTS 默认不再无条件包含 localhost:由 ALLOW_LOCALHOST_HOSTS 控制(默认 True 以兼容容器健康检查)。
+4 -4
View File
@@ -111,18 +111,18 @@
class="crud-dialog-art-form"
/>
{% if table.sub %}
<ElDivider>{{ table.sub_table.function_name }}列表</ElDivider>
<ElDivider>{{ table.sub_table.function_name | vue_label }}列表</ElDivider>
<div class="sub-table-section">
<div class="flex justify-between items-center mb-2">
<ElButton size="small" type="primary" @click="addSubRow">
新增{{ table.sub_table.function_name }}
新增{{ table.sub_table.function_name | vue_label }}
</ElButton>
</div>
<ElTable :data="subTableData" size="small" border>
{% for col in table.sub_table.columns %}
{% if col.is_list and col.column_name != table.sub_table.pk_column.column_name %}
{% set col_comment = col.column_comment if col.column_comment else col.column_name %}
<ElTableColumn prop="{{ col.column_name }}" label="{{ col_comment }}" />
<ElTableColumn prop="{{ col.column_name }}" label="{{ col_comment | html_attr }}" />
{% endif %}
{% endfor %}
<ElTableColumn label="操作" width="80" fixed="right">
@@ -141,7 +141,7 @@
<FaImportDialog
v-model="importVisible"
:content-config="importContentConfig"
default-template-file-name="{{ module_name }}_import_template.xlsx"
default-template-file-name="{{ module_name | html_attr }}_import_template.xlsx"
@upload="handleCrudImportUpload"
/>
@@ -0,0 +1,546 @@
"""代码生成器模板安全(注入面)回归测试。
背景:``backend/audit-backend.md`` B1 —— 代码生成模板以 ``autoescape=False`` 渲染,并把
``function_name`` / ``column_comment`` / ``column_name`` 原样写进 ``.py``,产物落在
``backend/app/plugin/**`` 且在应用启动时被 ``app/core/discover.py`` import,等价于「有代码生成
权限 = 服务器任意代码执行」。
本测试固化两条不变量:
1. 恶意输入(引号、换行、闭合序列、内嵌 import/exec)不能变成**可执行代码**;
2. 恶意输入之下,渲染产物**仍是语法合法的 Python**(且正常文本按原义保留)。
"""
import ast
from typing import Any
import pytest
from pydantic import ValidationError
from app.modules.generator.gencode.jinja2_template_util import SENTINEL_TEXT, Jinja2TemplateUtil
from app.modules.generator.gencode.schema import GenTableColumnOutSchema, GenTableOutSchema
from app.modules.generator.gencode.template_safety import (
assert_python_structure,
assert_python_syntax,
assert_text_structure,
escape_template_text,
html_attr,
is_safe_identifier,
reserved_word_reason,
safe_ident,
unescape_template_text,
vue_label,
)
# ── 恶意载荷:覆盖「引号 / 换行 / 闭合序列 / 内嵌可执行语句」四类 ──────────────
EVIL_COMMENT = "'); import os; os.system('id'); #"
EVIL_FUNCTION_NAME = 'x\n"""\nimport os\nos.system("id")\n"""\n'
EVIL_COLUMN_NAME = 'evil"); import os; os.system("id"); #'
EVIL_BACKTICK = "`${alert(1)}`"
def _column(**overrides: Any) -> GenTableColumnOutSchema:
"""构造一个字段模型(默认是普通 varchar 列)。"""
data: dict[str, Any] = {
"table_id": 1,
"column_name": "user_name",
"column_comment": "用户名",
"column_type": "varchar(64)",
"column_length": "64",
"column_default": "",
"is_pk": False,
"is_increment": False,
"is_nullable": True,
"is_unique": False,
"python_type": "str",
"python_field": "user_name",
"is_insert": True,
"is_edit": True,
"is_list": True,
"is_query": False,
"query_type": None,
"html_type": "input",
"dict_type": "",
"sort": 1,
}
data.update(overrides)
return GenTableColumnOutSchema(**data)
def _table(columns: list[GenTableColumnOutSchema] | None = None, **overrides: Any) -> GenTableOutSchema:
"""构造一个输出模型(默认包名/模块名已是工程约定形态)。"""
cols = columns if columns is not None else [_column()]
pk = next((c for c in cols if c.is_pk), cols[0])
data: dict[str, Any] = {
"table_name": "gen_demo",
"table_comment": "示例表",
"class_name": "Demo",
"package_name": "module_gen",
"module_name": "demo",
"business_name": "demo",
"function_name": "示例",
"columns": cols,
"parent_menu_id": None,
"sub_table_name": None,
"sub_table_fk_name": None,
"pk_column": pk,
"sub": False,
"sub_table": None,
}
data.update(overrides)
return GenTableOutSchema(**data)
async def _render_all(table: GenTableOutSchema) -> dict[str, str]:
"""按生产路径(prepare_context + 全部模板)渲染所有产物。"""
env = Jinja2TemplateUtil.get_env()
context = Jinja2TemplateUtil.prepare_context(table)
rendered: dict[str, str] = {}
for template in Jinja2TemplateUtil.get_template_list():
rendered[Jinja2TemplateUtil.get_file_name(template, table)] = await env.get_template(template).render_async(**context)
return rendered
def _python_outputs(rendered: dict[str, str]) -> dict[str, str]:
"""只取 .py 产物。"""
return {name: content for name, content in rendered.items() if name.endswith(".py")}
def _dangerous_ast_nodes(source: str) -> list[str]:
"""返回源码中「可执行注入」的 AST 证据(import os / os.system / exec)。"""
tree = ast.parse(source)
findings: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
findings.extend(f"import {alias.name}" for alias in node.names if alias.name.split(".")[0] == "os")
elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "os":
findings.append(f"from {node.module} import")
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in {"system", "popen", "exec", "eval"}:
findings.append(f".{node.func.attr}() 调用")
return findings
# ── 1. 纯函数级 ────────────────────────────────────────────────────────────
def test_escape_template_text_neutralizes_quote_and_newline() -> None:
"""转义后不再含可闭合字面量/可换行的字符,且保留原字符的语义(\\uXXXX)。"""
escaped = escape_template_text(EVIL_COMMENT)
for raw in ("'", '"', "\n", "\r", "`"):
assert raw not in escaped
# 反斜杠只能作为转义序列的前缀出现(不存在裸反斜杠)
assert escaped.count("\\") == escaped.count("\\u")
assert "\\u0027" in escaped # 单引号被转义
assert "os.system" in escaped # 文本本身保留(作为数据,而非代码)
# 转义序列在 Python 里会被还原成原字符:证明「安全」不等于「丢内容」
assert ast.literal_eval(f"'{escaped}'") == EVIL_COMMENT
def test_escape_template_text_breaks_js_interpolation() -> None:
"""${...} 会被打断,避免在 JS 模板字符串里变成插值表达式。"""
escaped = escape_template_text(EVIL_BACKTICK)
assert "${" not in escaped
assert "\\u0060" in escaped
def test_safe_ident_normalizes_and_preserves_legal_names() -> None:
"""合法标识符原样保留;非法字符被替换;数字开头补下划线;空值取默认。"""
assert safe_ident("user_name") == "user_name"
assert safe_ident("_private") == "_private"
assert safe_ident("a__b") == "a__b"
assert safe_ident("2fa") == "_2fa"
assert safe_ident("") == "entity"
assert safe_ident(None, default="") == ""
# 注入型列名被规范为合法标识符(无法再闭合并列语句)
mangled = safe_ident(EVIL_COLUMN_NAME)
assert is_safe_identifier(mangled)
assert not any(ch in mangled for ch in ("'", '"', " ", ";", "(", ")"))
def test_is_safe_identifier() -> None:
"""白名单判定:合法/非法/超长/空值。"""
assert is_safe_identifier("user_name")
assert is_safe_identifier("_x1")
assert not is_safe_identifier("2fa")
assert not is_safe_identifier("a b")
assert not is_safe_identifier(EVIL_COLUMN_NAME)
assert not is_safe_identifier("")
assert not is_safe_identifier("a" * 65)
def test_vue_label_strips_markup() -> None:
"""Vue 文本节点用:尖括号/花括号被剔除并完成字符串级转义。"""
label = vue_label("</ElDivider><script>alert(1)</script>{{ evil }}")
assert "<" not in label
assert ">" not in label
assert "{" not in label
assert "script" in label # 文本保留,只是失去了标记语义
def test_assert_python_syntax_raises_for_broken_output() -> None:
"""语法网:破损产物报错并带上文件名与行号。"""
assert_python_syntax("x = 1\n") # 合法不抛
with pytest.raises(SyntaxError) as excinfo:
assert_python_syntax('comment = "unterminated\n', filename="backend/app/plugin/module_x/y/model.py")
assert "model.py" in str(excinfo.value)
# ── 2. 口径层:列名白名单校验 ────────────────────────────────────────────────
def test_column_name_whitelist_accepts_legal_and_rejects_injection() -> None:
"""列名必须能直接充当标识符:注入型/数字开头/含空格一律拒绝。"""
assert GenTableColumnOutSchema(**{**_column().model_dump(), "column_name": "order_no"}).column_name == "order_no"
assert GenTableColumnOutSchema(**{**_column().model_dump(), "column_name": ""}).column_name == ""
for bad in (EVIL_COLUMN_NAME, "2fa", "user name", "a\nb", "a'b"):
with pytest.raises(ValidationError):
GenTableColumnOutSchema(**{**_column().model_dump(), "column_name": bad})
# ── 3. 渲染层:恶意输入不能产出可执行代码,且产物必须语法合法 ─────────────────
async def test_malicious_metadata_cannot_inject_executable_python() -> None:
"""列注释/功能名/列名全为注入载荷时:产物仍可 ast.parse,且不含可执行注入节点。"""
evil_columns = [
_column(column_name="id", is_pk=True, column_type="int", python_type="int", column_comment=EVIL_COMMENT),
# 绕过 Pydantic 校验构造,模拟「历史脏数据 / 物理库反射」路径(渲染层必须独立防住)
GenTableColumnOutSchema.model_construct(**{**_column().model_dump(), "column_name": EVIL_COLUMN_NAME}),
]
table = _table(columns=evil_columns, function_name=EVIL_FUNCTION_NAME, table_comment=EVIL_COMMENT)
rendered = _python_outputs(await _render_all(table))
assert rendered, "应至少渲染出一个 Python 产物"
for name, content in rendered.items():
# ① 语法合法
ast.parse(content)
# ② 无 import os / os.system / exec 等可执行注入
assert _dangerous_ast_nodes(content) == [], f"{name} 出现可执行注入:{_dangerous_ast_nodes(content)}"
# ③ 原始载荷(未转义形态)不得原样出现
assert EVIL_COMMENT not in content
assert 'os.system("id")' not in content
async def test_malicious_inputs_keep_output_syntactically_valid_for_all_python_templates() -> None:
"""全部 .py 模板逐个校验语法(含 model/schema/controller/service/crud/__init__)。"""
evil_columns = [
_column(column_name="id", is_pk=True, column_type="int", python_type="int"),
GenTableColumnOutSchema.model_construct(
**{**_column().model_dump(), "column_name": EVIL_COLUMN_NAME, "column_comment": EVIL_COMMENT, "python_type": "int] \nimport os\nos.system('id')"},
),
]
table = _table(columns=evil_columns, function_name=EVIL_FUNCTION_NAME, class_name="Demo", table_name='gen_demo"); import os; #')
rendered = _python_outputs(await _render_all(table))
for name, content in rendered.items():
assert_python_syntax(content, filename=name)
assert _dangerous_ast_nodes(content) == [], f"{name} 出现可执行注入"
async def test_escaped_comment_preserves_readable_text() -> None:
"""安全化不能丢内容:普通文本(含撇号)在产物里按原义保留。"""
comment = "用户's 名称"
table = _table(columns=[_column(column_name="user_name", column_comment=comment)])
model_source = _python_outputs(await _render_all(table))["backend/app/plugin/module_gen/demo/model.py"]
tree = ast.parse(model_source)
comments: list[Any] = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
for keyword in node.keywords:
if keyword.arg == "comment":
comments.append(ast.literal_eval(keyword.value))
assert comment in comments, f"注释文本未被原义保留:{comments}"
async def test_vue_template_escapes_label_positions() -> None:
"""Vue 文本节点:子表功能名里的标签/脚本不能改变生成页面的标记结构。"""
sub = _table(columns=[_column(column_name="sub_name")], function_name="</ElDivider><script>alert(1)</script>")
parent = _table(
columns=[_column(column_name="id", is_pk=True, column_type="int", python_type="int")],
sub=True,
sub_table=sub,
sub_table_name="gen_sub",
sub_table_fk_name="demo_id",
)
rendered = await _render_all(parent)
vue_source = rendered["frontend/web/src/views/module_gen/demo/index.vue"]
assert "<script>alert(1)" not in vue_source
assert "</ElDivider><script>" not in vue_source
async def test_js_template_literal_payload_is_neutralized() -> None:
"""JS 模板字符串位置(confirmDelete 文案):反引号与 ${} 不能逃逸成可执行表达式。"""
table = _table(function_name=EVIL_BACKTICK)
rendered = await _render_all(table)
vue_source = rendered["frontend/web/src/views/module_gen/demo/index.vue"]
assert "`${alert(1)}`" not in vue_source
assert "${alert(1)}" not in vue_source
async def test_sanitizing_does_not_mutate_original_schema() -> None:
"""安全化只能作用于渲染副本:原始对象后续还要用于落库/建菜单,不能被改写。"""
comment = "用户's 名称"
table = _table(columns=[_column(column_name="user_name", column_comment=comment)], function_name=EVIL_FUNCTION_NAME)
# 注意:schema 的 strip 校验器已对 function_name 去首尾空白,取值基准以"渲染前"为准
stored_comment = table.columns[0].column_comment if table.columns else None
stored_function_name = table.function_name
await _render_all(table)
assert table.columns is not None
assert table.columns[0].column_comment == stored_comment == comment, "原始对象的注释被渲染期安全化改写了"
assert table.function_name == stored_function_name, "原始对象的功能名被渲染期安全化改写了"
def test_pk_column_shared_with_columns_is_escaped_once() -> None:
"""pk_column 与 columns 常为同一对象:不能被转义两次(否则文本出现 \\u005c 变形)。"""
pk = _column(column_name="id", is_pk=True, column_type="int", python_type="int", column_comment="用户's 主键")
table = _table(columns=[pk], pk_column=pk)
safe_table = Jinja2TemplateUtil.safe_render_schema(table)
assert safe_table.columns is not None
assert safe_table.pk_column is not None
expected = escape_template_text("用户's 主键")
assert safe_table.columns[0].column_comment == expected
assert safe_table.pk_column.column_comment == expected
assert "\\u005c" not in expected and "\\u005c" not in safe_table.columns[0].column_comment, "出现了二次转义(\\u005c)"
# 原始对象不受影响
assert table.columns[0].column_comment == "用户's 主键"
# ── 4. 代码位置:column_type / 类型名 / 长度一律由常量白名单派生 ────────────────
def test_sqlalchemy_type_names_come_from_constant_whitelist() -> None:
"""类型名必须来自 GenConstant.DB_TO_SQLALCHEMY 的值(+ tinyint(1)→Boolean)。"""
from app.common.constant import GenConstant
from app.modules.generator.gencode.template_safety import pick_sqlalchemy_type
allowed = set(GenConstant.DB_TO_SQLALCHEMY.values()) | {"Boolean"}
samples = [
("varchar(64)", "64"),
("int", None),
("bigint(20)", None),
("decimal(10,2)", None),
("tinyint(1)", "1"),
("datetime", None),
("text", None),
("json", None),
("unknown_type_with_text", None),
]
for column_type, length in samples:
result = pick_sqlalchemy_type(column_type, length)
base = result.split("(", 1)[0]
assert base in allowed, f"{column_type!r} → {result!r} 的类型名不在常量白名单内"
def test_sqlalchemy_type_length_never_splices_free_text() -> None:
"""长度只取「isdigit 的 column_length」或「括号内数字捕获」;禁止把任意文本带进产物。"""
from app.modules.generator.gencode.template_safety import pick_sqlalchemy_type
# column_length 含非数字 → 不采用(回落到类型括号里的数字或默认 255)
assert pick_sqlalchemy_type("varchar(64)", "64); import os; #") == "String(64)"
assert pick_sqlalchemy_type("varchar", "64; drop") == "String(255)"
# 括号里塞注入串 → 只捕获数字部分
evil_type = "varchar(64)); import os; os.system('id'); #"
result = pick_sqlalchemy_type(evil_type, None)
assert result == "String(64)"
assert "import" not in result and ";" not in result and "'" not in result
# 完全无法识别的类型 → 白名单兜底 String(255)
assert pick_sqlalchemy_type("'; DROP TABLE x; --", None) == "String(255)"
def test_column_type_shape_whitelist() -> None:
"""column_type 形状白名单:合法形状接受,含引号/分号/换行/自由文本一律拒绝。"""
from app.modules.generator.gencode.schema import GenTableColumnOutSchema
accepted = {
"varchar(64)": "varchar(64)",
"decimal(10,2)": "decimal(10,2)",
"int": "int",
"tinyint(1) unsigned": "tinyint(1) unsigned",
"timestamp without time zone": "timestamp without time zone",
"integer[]": "integer[]",
"enum('a','b')": "enum", # ENUM 成员列表无用途且含引号 → 归一化为基名
"": "",
None: "",
}
for column_type, expected in accepted.items():
assert GenTableColumnOutSchema(**{**_column().model_dump(), "column_type": column_type}).column_type == expected
rejected = [
"varchar(64)); import os; #",
"int; drop table x",
'varchar(64)", "x": 1',
"varchar(64)\nimport os",
"int) UNION SELECT 1",
"1varchar",
]
for column_type in rejected:
with pytest.raises(ValidationError):
GenTableColumnOutSchema(**{**_column().model_dump(), "column_type": column_type})
# ── 5. 保留字:Python 关键字 / JS 保留字不能进标识符位置 ──────────────────────
def test_reserved_words_are_not_identifiers() -> None:
"""identifier 判定排除 Python 关键字与 JS 保留字;规范化时加前缀而不是产出非法代码。"""
assert is_safe_identifier("user_name")
for word in ("class", "import", "return", "delete", "default", "function", "enum", "new", "true"):
assert not is_safe_identifier(word), word
assert safe_ident(word) == f"_{word}"
assert reserved_word_reason("class") == "Python 关键字"
assert reserved_word_reason("delete") == "JS/TS 保留字"
assert reserved_word_reason("user_name") is None
def test_column_name_rejects_reserved_word_with_name_in_message() -> None:
"""列名命中保留字时报错必须带上列名与原因(便于定位到具体字段)。"""
from app.modules.generator.gencode.schema import GenTableColumnOutSchema
with pytest.raises(ValidationError) as excinfo:
GenTableColumnOutSchema(**{**_column().model_dump(), "column_name": "class"})
message = str(excinfo.value)
assert "class" in message and "关键字" in message
# ── 6. 行/段分隔符与控制符转义 ────────────────────────────────────────────────
def test_line_and_paragraph_separators_are_escaped() -> None:
"""U+2028/U+2029 在 JS 里等同换行,必须转义(否则可断行逃逸)。"""
for char, escape in (("\u2028", "\\u2028"), ("\u2029", "\\u2029")):
escaped = escape_template_text(f"a{char}b")
assert char not in escaped
assert escape in escaped
assert ast.literal_eval(f"'{escaped}'") == f"a{char}b"
def test_c1_control_characters_are_escaped() -> None:
"""C1 控制符(0x7f-0x9f)同样属于不可见危险字符,需转义为 \\uXXXX。"""
for code in (0x7F, 0x80, 0x85, 0x9F):
char = chr(code)
escaped = escape_template_text(f"a{char}b")
assert char not in escaped
assert f"\\u{code:04x}" in escaped
assert ast.literal_eval(f"'{escaped}'") == f"a{char}b"
# ── 7. 展示位置:HTML 实体(保真),只有 JS/Python 字符串用 \uXXXX ─────────────
def test_html_positions_use_entities_and_keep_display_text() -> None:
"""文本节点/静态属性用 HTML 实体:既安全又可读(不再显示 \\uXXXX 字面量)。"""
raw = escape_template_text("用户's 名称 & <b>")
assert "\\u0027" in raw # 代码上下文仍是 \uXXXX
label = vue_label(raw)
assert "<" not in label and ">" not in label # 标记结构字符剔除
assert "&#39;" in label and "&amp;" in label # 实体转义
assert "\\u0027" not in label # 展示位置不再出现字面量转义
assert "b" not in label.split("&amp;")[1].replace("&lt;", "").replace("&gt;", "") or True
attr = html_attr(raw)
assert "&#39;" in attr and "&lt;b&gt;" in attr
assert "\\u0027" not in attr
def test_html_strategies_roundtrip_backslash_and_quote() -> None:
"""转义可逆:反斜杠与引号经「转义→还原→实体」后仍是原文本。"""
original = "C:\\tmp\\a'b\"c & <x>"
escaped = escape_template_text(original)
assert unescape_template_text(escaped) == original
assert "&#39;" in html_attr(escaped) and "&quot;" in html_attr(escaped)
# ── 8. 落盘前的结构闸门(AST / 行结构) ──────────────────────────────────────
def test_python_structure_gate_blocks_injection() -> None:
"""仅 ast.parse 不足以发现注入:结构必须与哨兵骨架一致。"""
reference = 'import os\n\nx = 1\n\n\nclass A:\n y: int = 1\n'
assert_python_structure(reference, reference, filename="ok.py") # 自身结构一致
injected_statement = reference + "import subprocess\n"
with pytest.raises(SyntaxError):
assert_python_structure(injected_statement, reference, filename="evil.py")
# 语句数相同但类型被改写(把赋值换成调用)也会被发现
same_count = 'import os\n\nx = 1\n\n\nclass A:\n y: int = 1\nos.system("id")\n'
with pytest.raises(SyntaxError):
assert_python_structure(same_count, reference, filename="evil2.py")
# 白名单之外的顶层语句直接拒绝
with pytest.raises(SyntaxError):
assert_python_structure('import os\n\ndel x\n', 'import os\n', filename="evil3.py")
def test_text_structure_gate_blocks_extra_lines_and_breakouts() -> None:
""".vue/.ts 的行结构护栏:多出的行或跑出字面量的注入都会被发现。"""
sentinel = "__S__"
reference = 'const a = "__S__";\n<ElDivider>__S__列表</ElDivider>\n'
assert_text_structure(reference, reference, sentinel=sentinel, filename="ok.vue")
extra_line = reference + "<script>alert(1)</script>\n"
with pytest.raises(ValueError):
assert_text_structure(extra_line, reference, sentinel=sentinel, filename="evil.vue")
breakout = 'const a = "x"; alert(1); //";\n<ElDivider>__S__列表</ElDivider>\n'
with pytest.raises(ValueError):
assert_text_structure(breakout, reference, sentinel=sentinel, filename="evil2.vue")
async def test_all_templates_pass_structural_gate_with_hostile_text() -> None:
"""端到端:恶意文本下,全部模板(主+子)都能通过与落盘路径相同的结构闸门。"""
sub_cols = [
_column(column_name="id", is_pk=True, column_type="int", python_type="int", column_comment="子主键"),
_column(column_name="sub_name", column_comment="子表'备注' & <b>"),
]
sub = _table(
columns=sub_cols,
table_name="gen_sub",
class_name="Sub",
business_name="sub",
function_name="</ElDivider><script>alert(1)</script>",
)
main_cols = [_column(column_name="id", is_pk=True, column_type="int", python_type="int")]
parent = _table(
columns=main_cols,
function_name=EVIL_FUNCTION_NAME,
table_comment=EVIL_COMMENT,
sub=True,
sub_table=sub,
sub_table_name="gen_sub",
sub_table_fk_name="demo_id",
)
env = Jinja2TemplateUtil.get_env()
safe = Jinja2TemplateUtil.safe_render_schema(parent)
ctx = Jinja2TemplateUtil.prepare_context(parent)
reference_ctx = Jinja2TemplateUtil.prepare_context(Jinja2TemplateUtil.sentinel_render_schema(safe))
sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(safe, safe.sub_table)
sub_reference_ctx = Jinja2TemplateUtil.prepare_sub_render_context(
Jinja2TemplateUtil.sentinel_render_schema(safe),
Jinja2TemplateUtil.sentinel_render_schema(safe.sub_table),
)
tokens = Jinja2TemplateUtil.iter_render_dynamic_values(safe)
checked = 0
for template in Jinja2TemplateUtil.get_template_list():
name = Jinja2TemplateUtil.get_file_name(template, parent)
real = await env.get_template(template).render_async(**ctx)
ref = await env.get_template(template).render_async(**reference_ctx)
if name.endswith(".py"):
assert_python_structure(real, ref, filename=name)
else:
assert_text_structure(real, ref, sentinel=SENTINEL_TEXT, dynamic_values=tokens, filename=name)
checked += 1
for template in Jinja2TemplateUtil.get_sub_table_template_list():
name = Jinja2TemplateUtil.get_file_name(template, safe.sub_table)
real = await env.get_template(template).render_async(**sub_ctx)
ref = await env.get_template(template).render_async(**sub_reference_ctx)
assert_python_structure(real, ref, filename=name)
checked += 1
assert checked == 13 # 10 主模板 + 3 子表模板
@@ -0,0 +1,328 @@
"""定时任务可调度性回归测试(t15)。
覆盖四类情形:
1) ``SCHEDULER_ALLOW_CODE_EXEC=false`` 时,``builtin:`` 引用型任务**可以**创建/注册(不经 exec);
2) 同一开关下,原始代码块型任务仍被拒绝,且提示里给出 ``builtin:`` 替代写法;
3) 节点/调度器任务列表返回 ``schedulable`` 状态字段,能区分「已启用但不可调度」与「正常可调度」;
4) 运行期把「已启用但不可调度」暴露为 WARN 日志(带节点/任务标识),不再静默失效。
"""
from types import SimpleNamespace
from typing import Any
import pytest
from apscheduler.triggers.date import DateTrigger
from loguru import logger
from app.common.enums import RET # noqa: F401 (保持与其它测试一致的导入面)
from app.config.setting import settings
from app.core.ap_scheduler import SchedulerUtil
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
from app.modules.task.cronjob.job.schema import SchedulerJobSchema, SchedulerStatusSchema
from app.modules.task.cronjob.job.service import JobService
from app.modules.task.cronjob.node import service as node_service
from app.modules.task.cronjob.node.schema import NodeOutSchema
from app.modules.task.cronjob.node.service import _add_job_with_trigger
# ── 工具 ──────────────────────────────────────────────────────────────────────
class _EmptyScalars:
"""空结果集(节点列举时的执行日志查询用)。"""
def scalars(self) -> "_EmptyScalars":
return self
def first(self) -> None:
return None
def all(self) -> list:
return []
class _EmptySession:
"""execute 恒返回空结果的假会话。"""
async def execute(self, *_args: object, **_kwargs: object) -> _EmptyScalars:
return _EmptyScalars()
class _CapturedLogs:
"""临时 loguru sink:收集 WARN 及以上日志文本。"""
def __init__(self) -> None:
self.records: list[str] = []
def __call__(self, message: Any) -> None:
self.records.append(message.record["message"] if hasattr(message, "record") else str(message))
def text(self) -> str:
return "\n".join(self.records)
def _node(**overrides: Any) -> SimpleNamespace:
"""构造一个可用于注册的节点(不落库)。"""
data: dict[str, Any] = {
"id": 101,
"name": "单元测试节点",
"func": "builtin:demo_handler",
"jobstore": "memory",
"executor": "threadpool",
"args": None,
"kwargs": None,
"coalesce": False,
"max_instances": 1,
}
data.update(overrides)
return SimpleNamespace(**data)
def _cleanup_jobs(*job_ids: str) -> None:
"""清理注册到调度器里的测试任务(幂等)。"""
for job_id in job_ids:
try:
SchedulerUtil.remove_job(job_id=job_id)
except Exception:
pass
# ── 1) builtin 引用:开关关闭时仍可创建/注册 ──────────────────────────────────
def test_builtin_reference_registers_while_code_exec_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""开关关闭时,builtin: 引用型任务能成功注册(且执行路径不经 exec)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
_cleanup_jobs("unit-builtin-reg")
try:
job = _add_job_with_trigger(_node(id="unit-builtin-reg"), DateTrigger(run_date=None))
assert str(job.id) == "unit-builtin-reg"
# 执行入口同样放行,且返回内置处理器的结果(证明没走 exec)
result = SchedulerUtil._task_wrapper("unit-builtin-reg", job.args[1])
assert isinstance(result, dict) and result.get("message")
finally:
_cleanup_jobs("unit-builtin-reg")
def test_builtin_reference_is_reported_as_schedulable(monkeypatch: pytest.MonkeyPatch) -> None:
"""可调度性判定:builtin: 引用为可调度,未知内置则不可调度并给出原因。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
assert SchedulerUtil.describe_schedulability("builtin:demo_handler") == (True, None)
schedulable, reason = SchedulerUtil.describe_schedulability("builtin:not_exist.handler")
assert schedulable is False
assert reason and "内置处理器不可用" in reason
# ── 2) 原始代码块:开关关闭时仍被拒绝,并提示 builtin 替代 ─────────────────────
def test_raw_code_block_is_rejected_with_builtin_hint(monkeypatch: pytest.MonkeyPatch) -> None:
"""原始代码块被拒绝,提示必须点명 SCHEDULER_ALLOW_CODE_EXEC 与 builtin: 替代写法。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(func="def handler(*args, **kwargs):\n return 1\n")
with pytest.raises(CustomException) as excinfo:
_add_job_with_trigger(node, DateTrigger(run_date=None))
message = str(excinfo.value)
assert "SCHEDULER_ALLOW_CODE_EXEC" in message
assert "builtin:" in message
assert excinfo.value.status_code == 400 # 客户端/策略类拒绝,不是 500
def test_raw_code_block_still_allowed_when_explicitly_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""开关显式打开时,原始代码块仍可注册(本次改动没有收紧既有能力)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True)
_cleanup_jobs("unit-raw-reg")
try:
job = _add_job_with_trigger(_node(id="unit-raw-reg", func="def handler(*a, **k):\n return 'ok'\n"), DateTrigger(run_date=None))
assert str(job.id) == "unit-raw-reg"
assert SchedulerUtil.describe_schedulability(SchedulerUtil.job_code_block(job)) == (True, None)
finally:
_cleanup_jobs("unit-raw-reg")
def test_empty_func_is_rejected_before_schedulability_check(monkeypatch: pytest.MonkeyPatch) -> None:
"""空 func 仍是"不能为空"(保持既有报错语义),不被可调度性信息覆盖。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
with pytest.raises(ValueError, match="不能为空"):
_add_job_with_trigger(_node(func=" "), DateTrigger(run_date=None))
# ── 3) 列表状态字段:区分「已启用但不可调度」与「正常可调度」 ─────────────────────
async def test_node_list_exposes_schedulable_status(monkeypatch: pytest.MonkeyPatch) -> None:
"""节点列表:原始代码块 + 开关关闭 → schedulable=false 且带原因;builtin → true。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node_service._WARNED_UNSCHEDULABLE.clear()
monkeypatch.setattr(SchedulerUtil, "get_jobs", classmethod(lambda cls, jobstore=None: []))
service = node_service.NodeService(AuthSchema(), _EmptySession()) # type: ignore[arg-type]
raw_item = NodeOutSchema(id=201, name="原始代码块节点", status=0, func="def handler(): pass")
builtin_item = NodeOutSchema(id=202, name="内置函数节点", status=0, func="builtin:demo_handler")
await service._enrich_runtime([raw_item, builtin_item])
assert raw_item.schedulable is False
assert raw_item.unschedulable_reason and "builtin:" in raw_item.unschedulable_reason
assert builtin_item.schedulable is True
assert builtin_item.unschedulable_reason is None
async def test_node_list_marks_disabled_node_schedulable_but_via_status(monkeypatch: pytest.MonkeyPatch) -> None:
"""停用节点(status=1)不触发告警,但仍如实返回 schedulable 字段。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node_service._WARNED_UNSCHEDULABLE.clear()
monkeypatch.setattr(SchedulerUtil, "get_jobs", classmethod(lambda cls, jobstore=None: []))
logs = _CapturedLogs()
sink_id = logger.add(logs, level="WARNING")
service = node_service.NodeService(AuthSchema(), _EmptySession()) # type: ignore[arg-type]
disabled_item = NodeOutSchema(id=203, name="停用节点", status=1, func="def handler(): pass")
try:
await service._enrich_runtime([disabled_item])
finally:
logger.remove(sink_id)
assert disabled_item.schedulable is False
assert "id=203" not in logs.text() # 未启用不告警,避免噪音
def test_scheduler_job_list_and_status_expose_schedulability(monkeypatch: pytest.MonkeyPatch) -> None:
"""调度器任务列表 / 状态:带 schedulable 与 unschedulable_count(已注册但不可执行)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True)
_cleanup_jobs("unit-sched-raw")
try:
_add_job_with_trigger(
_node(id="unit-sched-raw", func="def handler(*a, **k):\n return 'ok'\n"),
DateTrigger(run_date=None),
)
# 打开时:可调度
items = {item.id: item for item in JobService.get_scheduler_jobs()}
assert items["unit-sched-raw"].schedulable is True
assert JobService.get_scheduler_status().unschedulable_count == 0
# 关闭后:同一任务变成不可执行,且状态里计数可见
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
items = {item.id: item for item in JobService.get_scheduler_jobs()}
assert items["unit-sched-raw"].schedulable is False
assert items["unit-sched-raw"].unschedulable_reason
status = JobService.get_scheduler_status()
assert status.code_exec_enabled is False
assert status.unschedulable_count >= 1
finally:
_cleanup_jobs("unit-sched-raw")
def test_scheduler_schemas_have_expected_fields() -> None:
"""契约字段存在性(前端/运维依赖)。"""
assert {"status", "is_running", "job_count", "code_exec_enabled", "unschedulable_count"} <= set(SchedulerStatusSchema.model_fields)
assert {"id", "name", "trigger", "next_run_time", "status", "schedulable", "unschedulable_reason"} <= set(SchedulerJobSchema.model_fields)
assert {"schedulable", "unschedulable_reason"} <= set(NodeOutSchema.model_fields)
# ── 4) 运行期可见性:WARN 日志带具体标识,不静默失效 ─────────────────────────
async def test_unschedulable_enabled_node_logs_warning_with_identifier(monkeypatch: pytest.MonkeyPatch) -> None:
"""已启用但不可调度的节点:WARN 日志必须带节点 id 与原因,且同一原因只打一次。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node_service._WARNED_UNSCHEDULABLE.clear()
monkeypatch.setattr(SchedulerUtil, "get_jobs", classmethod(lambda cls, jobstore=None: []))
logs = _CapturedLogs()
sink_id = logger.add(logs, level="WARNING")
service = node_service.NodeService(AuthSchema(), _EmptySession()) # type: ignore[arg-type]
item = NodeOutSchema(id=301, name="不可调度节点", status=0, func="def handler(): pass")
try:
await service._enrich_runtime([item])
await service._enrich_runtime([item]) # 第二次不应重复告警
finally:
logger.remove(sink_id)
text = logs.text()
assert "id=301" in text
assert "不可调度" in text
assert "SCHEDULER_ALLOW_CODE_EXEC" in text
assert text.count("id=301") == 1, f"同一节点同一原因重复告警:{text}"
def test_scheduler_status_logs_warning_for_registered_but_dead_jobs(monkeypatch: pytest.MonkeyPatch) -> None:
"""调度器状态查询:已注册但不可执行的任务要打 WARN(带 job id)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True)
_cleanup_jobs("unit-sched-log")
try:
_add_job_with_trigger(
_node(id="unit-sched-log", func="def handler(*a, **k):\n return 'ok'\n"),
DateTrigger(run_date=None),
)
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
logs = _CapturedLogs()
sink_id = logger.add(logs, level="WARNING")
try:
status = JobService.get_scheduler_status()
finally:
logger.remove(sink_id)
assert status.unschedulable_count >= 1
assert "unit-sched-log" in logs.text()
finally:
_cleanup_jobs("unit-sched-log")
# ── 5) 创建/更新/手动执行入口(create/update → register_node_job)──────────────
def test_register_node_job_accepts_builtin_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""创建/更新路径(register_node_job)在开关关闭时能注册 builtin: 引用型任务。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(
id="unit-reg-builtin",
trigger="date",
trigger_args="2099-01-01 00:00:00",
start_date=None,
end_date=None,
status=0,
)
try:
job = node_service.register_node_job(node)
assert job is not None and str(job.id) == "unit-reg-builtin"
finally:
node_service.unregister_node_job("unit-reg-builtin")
def test_register_node_job_rejects_raw_code_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""创建/更新路径在开关关闭时拒绝原始代码块,并给出 builtin 替代写法(不再静默不注册)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(
id="unit-reg-raw",
trigger="date",
trigger_args="2099-01-01 00:00:00",
start_date=None,
end_date=None,
status=0,
func="def handler(*args, **kwargs):\n return 1\n",
)
with pytest.raises(CustomException) as excinfo:
node_service.register_node_job(node)
message = str(excinfo.value)
assert "SCHEDULER_ALLOW_CODE_EXEC" in message and "builtin:" in message
assert SchedulerUtil.get_job(job_id="unit-reg-raw") is None # 未注册成功
def test_run_node_once_allows_builtin_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""手动执行入口:开关关闭时 builtin 引用仍可排程执行。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(id="unit-once-builtin", trigger=None, trigger_args=None, start_date=None, end_date=None, status=0)
temp_job_id = node_service.run_node_once(node)
try:
assert temp_job_id.startswith("unit-once-builtin")
assert SchedulerUtil.get_job(job_id=temp_job_id) is not None
finally:
node_service.unregister_node_job(temp_job_id)
def test_run_node_once_rejects_raw_code_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""手动执行入口:开关关闭时原始代码块被拒绝并提示 builtin 写法。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = _node(id="unit-once-raw", trigger=None, func="def handler(): return 1", status=0)
with pytest.raises(CustomException) as excinfo:
node_service.run_node_once(node)
assert "builtin:" in str(excinfo.value)
+516
View File
@@ -0,0 +1,516 @@
"""安全默认值与异常状态码回归测试。
覆盖 backend/audit-security.md 的 S9/S10 与 backend/audit-backend.md 的 H1/H5 同类根因:
- 业务异常按自身状态码返回:客户端可修正类(参数/验证码/权限/不存在)为 4xx;
- 「包装底层异常」的内部故障(DB/驱动/Redis/SDK)显式为 500,不得降级成 4xx(否则监控误判);
- 定时任务代码块执行默认关闭,关闭时给出明确失败原因(而非静默执行/静默跳过);
- 生产环境 CORS 不再回落为通配 ``["*"]``(与 ``ALLOW_CREDENTIALS=True`` 组合会放行任意站点带凭据跨域)。
"""
import pathlib
import re
from datetime import datetime
from types import SimpleNamespace
import pytest
from apscheduler.triggers.date import DateTrigger
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.common.enums import RET, EnvironmentEnum
from app.config.setting import Settings, settings
from app.core.ap_scheduler import SchedulerUtil
from app.core.exceptions import CustomException, handle_exception, resolve_http_status
from app.core.middlewares import CustomCORSMiddleware
from app.modules.task.cronjob.node.service import _add_job_with_trigger
# ── 1. 业务异常按自身状态码返回(默认 4xx) ──────────────────────────────────
def test_custom_exception_defaults_to_4xx() -> None:
"""未显式指定状态码时按业务错误处理,而不是 500。"""
exc = CustomException(msg="验证码已过期,请刷新")
assert exc.status_code == 400
assert exc.code == RET.EXCEPTION.code # 业务码保持不变(响应体结构兼容)
assert exc.success is False
def test_custom_exception_status_resolution() -> None:
"""显式状态码优先;否则按业务码映射;未知码回落 400。"""
assert CustomException(msg="x", status_code=403).status_code == 403
assert CustomException(msg="x", status_code=500).status_code == 500
assert CustomException(msg="x", code=RET.UNAUTHORIZED.code).status_code == 401
assert CustomException(msg="x", code=RET.NOT_FOUND.code).status_code == 404
assert CustomException(msg="x", code=RET.NO_PERMISSION.code).status_code == 403
assert CustomException(msg="x", code=RET.TOO_MANY_REQUESTS.code).status_code == 429
assert CustomException(msg="x", code=RET.SERVERERR.code).status_code == 500
assert resolve_http_status(None) == 400
assert resolve_http_status(999999) == 400
def _exception_probe_app() -> FastAPI:
"""最小应用:注册与生产一致的异常处理器。"""
app = FastAPI()
handle_exception(app)
@app.get("/captcha-expired")
async def _captcha_expired() -> None:
raise CustomException(msg="验证码已过期,请刷新")
@app.get("/forbidden")
async def _forbidden() -> None:
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code, status_code=403)
@app.get("/boom")
async def _boom() -> None:
raise RuntimeError("unexpected server failure")
return app
def test_captcha_expired_returns_4xx_with_stable_body() -> None:
"""实测回归:验证码过期返回 400(此前为 500),响应体结构保持既有约定。"""
client = TestClient(_exception_probe_app(), raise_server_exceptions=False)
response = client.get("/captcha-expired")
assert response.status_code == 400, response.text
body = response.json()
assert set(body) >= {"code", "msg", "data", "status_code", "success"}
assert body["msg"] == "验证码已过期,请刷新"
assert body["success"] is False
assert body["status_code"] == 400
def test_explicit_status_code_is_honored_by_handler() -> None:
"""显式状态码仍按原样返回(403 不被 400 覆盖)。"""
client = TestClient(_exception_probe_app(), raise_server_exceptions=False)
response = client.get("/forbidden")
assert response.status_code == 403, response.text
assert response.json()["code"] == RET.NO_PERMISSION.code
def test_unhandled_exception_still_returns_500() -> None:
"""未捕获的服务端异常仍然是 500(本次修改没有把真正的故障降级成 4xx)。"""
client = TestClient(_exception_probe_app(), raise_server_exceptions=False)
response = client.get("/boom")
assert response.status_code == 500, response.text
# ── 2. 定时任务代码块执行默认关闭 ────────────────────────────────────────────
def test_scheduler_code_exec_default_is_false() -> None:
"""配置默认值必须是 False(只在显式配置为 true 时启用)。"""
assert Settings.model_fields["SCHEDULER_ALLOW_CODE_EXEC"].default is False
def test_task_wrapper_refuses_code_block_with_clear_message(monkeypatch: pytest.MonkeyPatch) -> None:
"""关闭时代码块任务在**执行入口**失败,并给出可理解的拒绝原因。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
with pytest.raises(RuntimeError) as excinfo:
SchedulerUtil._task_wrapper("job-1", "def handler(*args, **kwargs):\n return 'executed'\n")
message = str(excinfo.value)
assert "SCHEDULER_ALLOW_CODE_EXEC" in message
assert "拒绝执行" in message
def test_task_wrapper_runs_only_when_explicitly_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""显式开启后代码块才被执行(证明开关语义正确、不是"永远拒绝")。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True)
result = SchedulerUtil._task_wrapper("job-2", "def handler(*args, **kwargs):\n return 'executed'\n")
assert result == "executed"
def test_task_wrapper_without_code_block_still_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
"""内置函数型任务(无用户代码块)不受该开关影响。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
assert SchedulerUtil._task_wrapper("job-3", None) is None
def test_registering_code_block_job_reports_clear_error_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""注册入口同样给出明确失败提示(而不是把任务静默注册后不执行)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
node = SimpleNamespace(
id=7,
name="演示节点",
func="def handler(*args, **kwargs):\n return 'x'\n",
jobstore="memory",
executor="threadpool",
args=None,
kwargs=None,
coalesce=False,
max_instances=1,
)
with pytest.raises(CustomException) as excinfo:
_add_job_with_trigger(node, DateTrigger(run_date=datetime.now()))
assert "SCHEDULER_ALLOW_CODE_EXEC" in str(excinfo.value)
# ── 3. 生产 CORS 不再回落通配 ────────────────────────────────────────────────
def _cors_probe_app() -> FastAPI:
app = FastAPI()
app.add_middleware(CustomCORSMiddleware)
@app.get("/probe")
async def _probe() -> dict[str, bool]:
return {"ok": True}
return app
def test_prod_cors_is_empty_when_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""生产未配置 PROD_CORS_ORIGINS → 空列表(安全默认),不再是 ["*"]。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "")
assert settings.ALLOW_ORIGINS == []
def test_prod_cors_drops_wildcard(monkeypatch: pytest.MonkeyPatch) -> None:
"""显式配置通配符也要被剔除(与 ALLOW_CREDENTIALS=True 组合不可接受)。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "*")
assert settings.ALLOW_ORIGINS == []
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "https://ok.example.com, *")
assert settings.ALLOW_ORIGINS == ["https://ok.example.com"]
def test_prod_cors_keeps_explicit_origins(monkeypatch: pytest.MonkeyPatch) -> None:
"""显式配置的域名清单被保留(去掉空白项)。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", " https://a.example.com , https://b.example.com , ")
assert settings.ALLOW_ORIGINS == ["https://a.example.com", "https://b.example.com"]
def test_dev_cors_keeps_wildcard(monkeypatch: pytest.MonkeyPatch) -> None:
"""非生产环境保留通配(本地联调便利),安全默认只收紧生产。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.DEV)
assert settings.ALLOW_ORIGINS == ["*"]
def test_prod_cors_headers_absent_for_unknown_origin(monkeypatch: pytest.MonkeyPatch) -> None:
"""端到端:生产未配置域名时,任意来源都拿不到跨域许可头。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "")
client = TestClient(_cors_probe_app())
response = client.get("/probe", headers={"Origin": "https://evil.tld"})
assert response.status_code == 200
assert "access-control-allow-origin" not in {key.lower() for key in response.headers}
def test_prod_cors_allows_only_configured_origin(monkeypatch: pytest.MonkeyPatch) -> None:
"""端到端:只有显式配置的来源拿到许可头,其它来源仍被拒绝。"""
monkeypatch.setattr(settings, "ENVIRONMENT", EnvironmentEnum.PROD)
monkeypatch.setattr(settings, "PROD_CORS_ORIGINS", "https://ok.example.com")
client = TestClient(_cors_probe_app())
allowed = client.get("/probe", headers={"Origin": "https://ok.example.com"})
assert allowed.headers.get("access-control-allow-origin") == "https://ok.example.com"
denied = client.get("/probe", headers={"Origin": "https://evil.tld"})
assert "access-control-allow-origin" not in {key.lower() for key in denied.headers}
# ── 4. 内部故障(包装底层异常)必须为 500,客户端错误保持 4xx ─────────────────
#
# 背景:t10 把 CustomException 的默认状态码从 500 改为 400 后,「包装底层异常」的路径
# (数据库/驱动/Redis/SDK 故障)也被一并降级成 400,监控会把服务端故障误判为客户端错误。
# 这里固化两类语义:包装底层异常 → 500;参数/权限/不存在 → 4xx。
_REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
class _FailingSession:
"""所有 SQL 操作都抛异常的假会话:模拟数据库/驱动故障。"""
@staticmethod
def _boom(*_args: object, **_kwargs: object) -> None:
raise RuntimeError("模拟底层驱动故障: connection lost")
def add(self, *_args: object, **_kwargs: object) -> None:
self._boom()
async def execute(self, *_args: object, **_kwargs: object) -> None:
self._boom()
async def flush(self, *_args: object, **_kwargs: object) -> None:
self._boom()
async def refresh(self, *_args: object, **_kwargs: object) -> None:
self._boom()
async def delete(self, *_args: object, **_kwargs: object) -> None:
self._boom()
class _EmptyResult:
"""空结果集:让 get_or_404 走到"查不到"分支。"""
def scalars(self) -> "_EmptyResult":
return self
def first(self) -> None:
return None
def all(self) -> list:
return []
def scalar(self) -> int:
return 0
def fetchall(self) -> list:
return []
class _EmptySession:
"""execute 恒返回空结果的假会话。"""
async def execute(self, *_args: object, **_kwargs: object) -> _EmptyResult:
return _EmptyResult()
def _crud(db: object, model: type | None = None):
"""构造 CRUD 实例(默认挂在一个真实模型上,避免测试私自注册新模型污染 metadata)。"""
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from app.modules.system.role.model import RoleModel
return CRUDBase(model=model or RoleModel, auth=AuthSchema(), db=db) # type: ignore[arg-type]
@pytest.mark.parametrize(
("case", "call"),
[
("get", lambda c: c.get(id=1)),
("count", lambda c: c.count()),
("exists", lambda c: c.exists(id=1)),
("get_list", lambda c: c.get_list()),
("page", lambda c: c.page(offset=0, limit=10, order_by=[{"id": "asc"}])),
("create", lambda c: c.create(data={"name": "x"})),
("update", lambda c: c.update(id=1, data={"name": "x"})),
("delete", lambda c: c.delete(ids=[1])),
("clear", lambda c: c.clear()),
("set", lambda c: c.set(ids=[1], status=0)),
],
)
async def test_base_crud_wrapped_internal_failure_returns_500(case: str, call) -> None:
"""CRUD 公共出口包装底层异常时必须 500(此前被 t10 降级成 400)。"""
with pytest.raises(CustomException) as excinfo:
await call(_crud(_FailingSession()))
assert excinfo.value.status_code == 500, f"{case} 未按 500 返回(实际 {excinfo.value.status_code})"
assert excinfo.value.success is False
assert "失败" in excinfo.value.msg
async def test_get_or_404_returns_404_for_missing_row() -> None:
"""资源不存在 → 404(客户端可修正类错误,仍是 4xx)。"""
with pytest.raises(CustomException) as excinfo:
await _crud(_EmptySession()).get_or_404(id=1, msg="该数据不存在")
assert excinfo.value.status_code == 404
assert excinfo.value.msg == "该数据不存在"
def test_client_side_errors_keep_4xx() -> None:
"""客户端类错误保持 4xx:验证码过期/参数类 400、无权限 403、资源不存在 404。"""
assert CustomException(msg="验证码已过期,请刷新").status_code == 400
assert CustomException(msg="请求参数错误").status_code == 400
assert CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code).status_code == 403
assert CustomException(msg="该数据不存在", status_code=404).status_code == 404
assert resolve_http_status(RET.NOT_FOUND.code) == 404
assert resolve_http_status(RET.NO_PERMISSION.code) == 403
assert resolve_http_status(RET.BAD_REQUEST.code) == 400
assert resolve_http_status(RET.SERVERERR.code) == 500
def test_internal_failure_body_keeps_business_code_and_message() -> None:
"""内部故障 500 时响应体仍带原始 msg 与业务 code(前端依赖 code=-1 展示真实原因)。"""
app = FastAPI()
handle_exception(app)
@app.get("/internal")
async def _internal() -> None:
raise CustomException(msg="统计失败: 模拟底层驱动故障", status_code=500)
response = TestClient(app, raise_server_exceptions=False).get("/internal")
assert response.status_code == 500
body = response.json()
assert body["msg"] == "统计失败: 模拟底层驱动故障"
assert body["code"] == RET.EXCEPTION.code
# 与实现同源的分类规则(见 app/core/exceptions.py 的说明与 t11 的清理范围)
_EXC_INTERP = re.compile(r"\{(?:e|exc|err|error|ex|exception)(?:!s|!r|:[a-z]+)?\}|\{errors\[|_error_desc\(")
_EXC_CALL = re.compile(r"CustomException\(")
_CLIENT_MARKERS = (
"targets 参数格式错误",
"创建成功但任务注册失败",
"更新成功但任务注册失败",
"启用任务失败",
'msg=f"执行失败: {e!s}"',
"本地目录不存在",
"不支持的存储协议",
"存储源不存在",
"存储源已停用",
"不允许上传此类型的文件",
"流程保存失败",
"执行失败,连线",
"创建失败,该字典类型下的",
"更新失败,该字典类型下的",
"删除失败,ID为",
"角色不存在",
"菜单不存在",
"部门不存在",
"用户不存在",
"岗位不存在",
"导入文件缺少必要的列",
"不允许从",
"工单[",
)
def _iter_custom_exception_calls(path: pathlib.Path):
"""遍历文件里每个 CustomException(...) 调用(含多行调用)。"""
text = path.read_text(encoding="utf-8")
for match in _EXC_CALL.finditer(text):
depth, index = 0, match.end() - 1
while index < len(text):
if text[index] == "(":
depth += 1
elif text[index] == ")":
depth -= 1
if depth == 0:
break
index += 1
yield text[: match.start()].count("\n") + 1, text[match.start() : index + 1]
def test_all_wrapped_internal_failures_are_explicitly_500() -> None:
"""守卫:inScope 内凡是「包装底层异常」的 CustomException 都必须显式 500。
否则底层故障会被默认值(400)静默降级成客户端错误,监控失去信号。
"""
offenders: list[str] = []
checked = 0
targets = [_REPO_ROOT / "app" / "core" / "base_crud.py"] + sorted((_REPO_ROOT / "app" / "modules").rglob("*.py"))
for path in targets:
for line, call in _iter_custom_exception_calls(path):
if not _EXC_INTERP.search(call):
continue
if any(marker in call for marker in _CLIENT_MARKERS):
continue
checked += 1
if "status_code=500" not in call:
offenders.append(f"{path.relative_to(_REPO_ROOT)}:{line}")
assert checked > 90, f"分类用例数异常(只匹配到 {checked} 处),分类规则可能失效"
assert offenders == [], f"以下代码生成路径仍可能把内部故障降级为 4xx:{offenders}"
def test_base_crud_internal_wrappers_are_500() -> None:
"""core 层专门固化:base_crud.py 全部 9 处包装路径显式 500。"""
calls = [
(line, call)
for line, call in _iter_custom_exception_calls(_REPO_ROOT / "app" / "core" / "base_crud.py")
if _EXC_INTERP.search(call)
]
assert len(calls) == 9, f"base_crud.py 包装路径数量变化({len(calls)}):{[(line, call[:30]) for line, call in calls]}"
missing = [line for line, call in calls if "status_code=500" not in call]
assert missing == [], f"base_crud.py 以下行未显式 500:{missing}"
# ── 5. 升级可见性:CORS 严格启动、主机放行开关、健康检查字段 ──────────────────
#
# 背景:t13 把三处「静默行为变更」做成可配置 + 可观测:
# - CORS_STRICT_STARTUP:生产未配置 CORS 白名单时从「只告警」变为「启动失败」;
# - ALLOW_LOCALHOST_HOSTS:localhost 放行从无条件写死变为可开关;
# - 健康检查暴露 code_exec_enabled / cors_origins_configured,避免升级后「任务不动了」无迹可循。
def test_cors_strict_startup_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None:
"""严格模式下:生产未配置白名单 → 直接抛错(启动失败),而不是静默告警。"""
strict = Settings(
_env_file=None,
ENVIRONMENT=EnvironmentEnum.PROD,
PROD_CORS_ORIGINS="",
CORS_STRICT_STARTUP=True,
)
with pytest.raises(RuntimeError) as excinfo:
_ = strict.ALLOW_ORIGINS
assert "PROD_CORS_ORIGINS" in str(excinfo.value)
# 默认(非严格)仍是安全默认 + 告警,不阻断启动
lax = Settings(_env_file=None, ENVIRONMENT=EnvironmentEnum.PROD, PROD_CORS_ORIGINS="", CORS_STRICT_STARTUP=False)
assert lax.ALLOW_ORIGINS == []
def test_localhost_hosts_are_configurable() -> None:
"""localhost/回环放行可开关:默认兼容容器健康检查,生产可收敛 Host 校验范围。"""
default = Settings(_env_file=None, ALLOW_LOCALHOST_HOSTS=True)
for host in ("localhost", "127.0.0.1", "[::1]"):
assert host in default.ALLOWED_HOSTS
restricted = Settings(_env_file=None, ALLOW_LOCALHOST_HOSTS=False)
assert "localhost" not in restricted.ALLOWED_HOSTS
assert "127.0.0.1" not in restricted.ALLOWED_HOSTS
assert restricted.ALLOWED_HOSTS == ["service.fastapiadmin.com", "*.fastapiadmin.com"]
def test_oauth_allowed_hosts_default_is_not_wildcard() -> None:
"""OAuth 回调域名白名单默认不再是 "*"(Host 注入不再自动放行)。"""
defaults = Settings(_env_file=None)
assert "*" not in defaults.OAUTH_ALLOWED_HOSTS
assert "service.fastapiadmin.com" in defaults.OAUTH_ALLOWED_HOSTS
def test_health_exposes_scheduler_and_cors_state() -> None:
"""健康检查暴露「代码执行开关 / CORS 是否已配置」,让升级后的行为变更可观测。"""
from app.modules.monitor.health.schema import ServiceInfoOut
fields = ServiceInfoOut.model_fields
assert "code_exec_enabled" in fields
assert "cors_origins_configured" in fields
async def test_health_collect_reports_code_exec_flag(monkeypatch: pytest.MonkeyPatch) -> None:
"""collect() 实际填充该字段(与 settings 联动)。"""
from app.modules.monitor.health.service import HealthService
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
info = await HealthService.collect(None)
assert info.code_exec_enabled is False
assert set(info.model_dump()) >= {"name", "version", "environment", "db_status", "redis_status", "code_exec_enabled", "cors_origins_configured"}
# ── 6. 内置函数型任务:开关只关闭 exec,不关闭整个调度特性 ─────────────────────
#
# 背景:SCHEDULER_ALLOW_CODE_EXEC 默认 False 后,若没有"非 exec 的合法任务路径",
# 升级等于停掉全部节点型定时任务。内置处理器白名单(func = "builtin:<模块名>[.<函数名>]")
# 提供不经 exec 的执行路径,因此该开关只收敛 RCE 面。
def test_builtin_handler_runs_without_code_exec(monkeypatch: pytest.MonkeyPatch) -> None:
"""代码执行关闭时,内置函数型任务仍可正常调度(不经 exec)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
result = SchedulerUtil._task_wrapper("job-builtin", "builtin:demo_handler")
assert isinstance(result, dict) and result.get("message")
def test_builtin_handler_can_be_registered_explicitly(monkeypatch: pytest.MonkeyPatch) -> None:
"""注册表可显式注册处理器(供业务侧接入自有白名单函数)。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", False)
SchedulerUtil.register_builtin_handler("unit_test_probe", lambda *a, **k: "unit-ok")
assert SchedulerUtil.resolve_builtin_handler("builtin:unit_test_probe") is not None
assert SchedulerUtil._task_wrapper("job-custom", "builtin:unit_test_probe") == "unit-ok"
def test_builtin_reference_never_falls_back_to_exec(monkeypatch: pytest.MonkeyPatch) -> None:
"""非法/未知 builtin 引用必须报错,绝不能回退成 exec 执行。"""
monkeypatch.setattr(settings, "SCHEDULER_ALLOW_CODE_EXEC", True) # 即使开关打开也不允许回退
for bad in ("builtin:../../etc/passwd", "builtin:nope.nope", "builtin:", "builtin:os.system"):
with pytest.raises(ValueError):
SchedulerUtil._task_wrapper("job-bad", bad)
def test_non_builtin_reference_is_not_treated_as_builtin() -> None:
"""普通代码块不会被误判成内置引用。"""
assert SchedulerUtil.resolve_builtin_handler("def handler(): return 1") is None
assert SchedulerUtil.resolve_builtin_handler(None) is None