Files
FastapiAdmin/backend/app/modules/task/cronjob/node/service.py
T
zhangtao a4e8727a86 chore(backend): 新增 basedpyright 校准配置,并修复它暴露的三处真缺陷
背景:仓库此前没有 pyright/basedpyright 配置,basedpyright 使用自身默认(≈recommended),
在 app/ 下产生 247 errors / 3799 warnings,其中 143 条是 reportMissingTypeArgument
(未参数化泛型)这类注解完整度噪音,会把真缺陷淹没(例如 OSS 的 Client.uploader 方法误用)。

配置(backend/pyrightconfig.json)
- typeCheckingMode: basic;显式把能抓真缺陷的规则保持为 error
  (reportAttributeAccessIssue / reportArgumentType / reportOptionalMemberAccess /
  reportIncompatibleMethodOverride / reportConstantRedefinition / reportMissingImports 等)
- 关闭注解完整度与风格类规则;tests/ 用 executionEnvironments 单独放宽(extraPaths 保证 app 可解析)
- 结果:app + main.py + tests 全量 0 errors
- 红绿验证:合成「str | None 传参」「MethodType 上取属性」「Optional 成员访问」三类
  仍全部报 error,证明校准没有把真缺陷一起关掉

修复配置暴露的三处真缺陷
1) template_safety.py:_SQLALCHEMY_TYPE_WHITELIST 对全大写常量二次赋值 → 合并为一次成型
2) template_safety.py:normalize_column_type 假定 _TYPE_BASE_RE 必命中(用 # type: ignore 盖住,
   None 时 .group() 会崩)→ 改为 None 安全,去掉 type-ignore
3) node/service.py ×2:CustomException(msg=reason) 中 reason 为 str | None,客户端可能收到空文案
   → 新增 SchedulerUtil.require_schedulable() 收敛「判定 + 抛异常 + 兜底文案」,调用点改为一行

验证:basedpyright 0 errors(原 247);ruff All checks passed;pytest 97 passed;
      已部署并线上复验(四端 200、Host 收窄、伪造令牌被拒、验证码过期 400 保留业务文案、
      容器内 require_schedulable 与 OSS 调用修正均已生效)
2026-10-04 06:00:16 +08:00

422 lines
17 KiB
Python

import json
from datetime import datetime, timedelta
from typing import Any
from apscheduler.job import Job
from apscheduler.jobstores.base import ConflictingIdError, JobLookupError
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from croniter import croniter
from sqlalchemy import false, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ap_scheduler import SchedulerUtil, scheduler
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.validator import datetime_validator
from app.utils.common_util import search_to_dict
from .crud import NodeCRUD
from .model import NodeModel
from .schema import (
NodeCreateSchema,
NodeOutSchema,
NodeQueryParam,
NodeRunResultSchema,
NodeUpdateSchema,
)
# APScheduler 正式任务 job id = 节点 id;手动执行用独立临时 id,避免覆盖正式计划
_MANUAL_JOB_PREFIX = ":manual:"
class NodeService:
"""节点管理模块服务层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def options(self) -> list[NodeOutSchema]:
obj_list = await NodeCRUD(self.auth, self.db).get_obj_list_crud()
return [NodeOutSchema.model_validate(obj) for obj in obj_list]
async def detail(self, id: int) -> NodeOutSchema:
obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
if not obj:
raise CustomException(msg="该节点不存在")
out = NodeOutSchema.model_validate(obj)
await self._enrich_runtime([out])
return out
async def get_list(
self,
search: NodeQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[NodeOutSchema]:
obj_list = await NodeCRUD(self.auth, self.db).get_obj_list_crud(search=search_to_dict(search, {}), order_by=order_by)
out_list = [NodeOutSchema.model_validate(obj) for obj in obj_list]
await self._enrich_runtime(out_list)
return out_list
async def page(
self,
page_no: int,
page_size: int,
search: NodeQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[NodeOutSchema]:
offset = (page_no - 1) * page_size
result = await NodeCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=NodeOutSchema,
)
await self._enrich_runtime(list(result.items))
return result
async def create(self, data: NodeCreateSchema) -> NodeOutSchema:
exist_obj = await NodeCRUD(self.auth, self.db).get(name=data.name)
if exist_obj:
raise CustomException(msg="创建失败,该节点已存在")
_validate_node_plan(data.trigger, data.trigger_args)
obj = await NodeCRUD(self.auth, self.db).create_obj_crud(data=data)
if not obj:
raise CustomException(msg="创建失败")
# 保存即注册:配置了触发方式则注册/刷新 APScheduler 任务
try:
register_node_job(obj)
except (ValueError, CustomException) as e:
await NodeCRUD(self.auth, self.db).delete_obj_crud(ids=[obj.id])
raise CustomException(msg=f"创建成功但任务注册失败: {e!s}") from e
out = NodeOutSchema.model_validate(obj)
await self._enrich_runtime([out])
return out
async def update(self, id: int, data: NodeUpdateSchema) -> NodeOutSchema:
exist_obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg="更新失败,该节点不存在")
# 预先校验新计划参数,避免 DB 更新后注册失败造成定义与调度不一致
if data.trigger is not None:
_validate_node_plan(data.trigger, data.trigger_args)
obj = await NodeCRUD(self.auth, self.db).update_obj_crud(id=id, data=data)
if not obj:
raise CustomException(msg="更新失败")
# 编辑即生效:按最新定义重新注册(代码/参数/计划任一变化都会同步到调度器)
try:
register_node_job(obj)
except (ValueError, CustomException) as e:
raise CustomException(msg=f"更新成功但任务注册失败: {e!s}") from e
out = NodeOutSchema.model_validate(obj)
await self._enrich_runtime([out])
return out
async def delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
for mid in ids:
exist_obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=mid)
if not exist_obj:
raise CustomException(msg="删除失败,该节点不存在")
unregister_node_job(node_id=mid)
await NodeCRUD(self.auth, self.db).delete_obj_crud(ids=ids)
async def clear(self) -> None:
NodeCRUD(self.auth, self.db)
SchedulerUtil.clear_jobs()
await NodeCRUD(self.auth, self.db).clear_obj_crud()
async def execute(self, id: int) -> NodeRunResultSchema:
"""手动执行一次:基于节点当前定义创建临时 job,不占用/覆盖正式计划。"""
obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
if not obj:
raise CustomException(msg="执行失败,该节点不存在")
try:
temp_job_id = run_node_once(node=obj)
except (ValueError, CustomException) as e:
raise CustomException(msg=f"执行失败: {e!s}") from e
return NodeRunResultSchema(job_id=temp_job_id, status="executed", trigger="manual")
async def batch_set_status(self, ids: list[int], status: int) -> None:
"""批量启停:0=启用 1=停用。启停直接作用于调度器 job(DB 与运行态一致)。"""
if not ids:
raise CustomException(msg="请选择要操作的数据")
for mid in ids:
obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=mid)
if not obj:
continue
if status == 1:
unregister_node_job(node_id=mid)
else:
try:
register_node_job(obj)
except (ValueError, CustomException) as e:
raise CustomException(msg=f"启用任务失败: {e!s}") from e
await NodeCRUD(self.auth, self.db).set(ids=ids, status=status)
# ── 运行时信息补充(下次运行/最近运行,来自调度器与执行日志)──────────
async def _enrich_runtime(self, items: list[NodeOutSchema]) -> None:
"""补充 next_run_time / last_run_time / last_run_status。
直接查当前请求 db 会话的执行日志表(job_id = 节点 id,含手动执行记录),
不受 JobCRUD 数据权限模型影响。
"""
if not items:
return
from ..job.model import JobModel # 延迟导入:避免模块级相互依赖
jobs = {job.id: job for job in SchedulerUtil.get_jobs()}
for item in items:
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 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())
.order_by(JobModel.created_time.desc())
.limit(1)
)
row = (await self.db.execute(stmt)).scalars().first()
if row:
item.last_run_time = row.created_time.isoformat() if row.created_time else None
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 / 启动自愈复用)────
def _build_job_trigger(
trigger: str | None,
trigger_args: str | None,
start_date: str | None,
end_date: str | None,
) -> Any:
"""依据节点持久化计划构造 APScheduler Trigger,非法参数抛 ValueError。"""
if trigger == "cron":
cron_expr = (trigger_args or "").strip()
fields = cron_expr.split()
if len(fields) not in (6, 7):
raise ValueError("无效的 Cron 表达式,格式: 秒 分 时 日 月 周 [年]")
# Quartz 风格的 ? 仅表示"该位无具体值",croniter 不支持,先统一规范化为 * 再校验
parsed = [field if field != "?" else "*" for field in fields]
try:
croniter(" ".join(parsed))
except (KeyError, ValueError):
raise ValueError(f"Cron表达式不正确: {cron_expr}")
if len(fields) == 6:
parsed.append("*")
second, minute, hour, day, month, day_of_week, year = parsed
if second == "*" and minute == "*" and hour == "*" and day == "*" and month == "*" and day_of_week in ("*", "?"):
raise ValueError("Cron表达式不允许每秒执行,请至少指定秒数(如:0 * * * * ? * 表示每分钟执行)")
return CronTrigger(
second=second,
minute=minute,
hour=hour,
day=day,
month=month,
day_of_week=day_of_week,
year=year,
start_date=start_date,
end_date=end_date,
timezone="Asia/Shanghai",
)
if trigger == "interval":
interval_args = (trigger_args or "").strip()
fields = interval_args.split()
if len(fields) != 5:
raise ValueError("无效的 interval 表达式,格式: 秒 分 时 天 周")
second, minute, hour, day, week = tuple(int(field) if field != "*" else 0 for field in fields)
return IntervalTrigger(
weeks=week,
days=day,
hours=hour,
minutes=minute,
seconds=second,
start_date=start_date,
end_date=end_date,
timezone="Asia/Shanghai",
)
if trigger == "date":
date_str = (trigger_args or "").strip()
if not date_str:
raise ValueError("date触发器缺少执行时间参数")
try:
datetime_validator(date_str)
except Exception:
raise ValueError("date触发时间必须为 YYYY-MM-DD HH:MM:SS")
return DateTrigger(run_date=date_str, timezone="Asia/Shanghai")
raise ValueError(f"不支持的触发方式: {trigger}")
def _validate_node_plan(
trigger: str | None,
trigger_args: str | None,
start_date: str | None = None,
end_date: str | None = None,
) -> None:
"""保存前预校验计划参数,避免 DB 落库后再注册失败。"""
if not trigger:
return
_build_job_trigger(trigger, trigger_args, start_date, end_date)
def _add_job_with_trigger(
job_info: NodeModel,
trigger,
*,
job_id_override: str | None = None,
name_suffix: str = "",
) -> Job:
"""将 NodeModel 封装的任务添加到 APScheduler 调度器。"""
code_block = job_info.func
if not code_block or not code_block.strip():
raise ValueError("任务代码块不能为空")
# 可调度性判定(单一事实来源):builtin: 引用走白名单、不经 exec,因此**开关关闭时也允许注册**;
# 只有"原始代码块"才会被 SCHEDULER_ALLOW_CODE_EXEC=False 拦住,并在提示里给出 builtin 替代写法。
SchedulerUtil.require_schedulable(code_block)
jobstore = job_info.jobstore or "default"
executor = job_info.executor or "threadpool"
job_args = []
if job_info.args:
args_str = str(job_info.args).strip()
if args_str:
job_args = [arg.strip() for arg in args_str.split(",") if arg.strip()]
job_kwargs = {}
if job_info.kwargs:
kwargs_str = str(job_info.kwargs).strip()
if kwargs_str:
try:
job_kwargs = json.loads(kwargs_str)
except json.JSONDecodeError:
raise ValueError(f"关键字参数JSON格式无效: {kwargs_str}")
job_id = job_id_override or str(job_info.id)
try:
job = scheduler.add_job(
func=SchedulerUtil._task_wrapper,
trigger=trigger,
args=[str(job_info.id), code_block, *job_args],
kwargs=job_kwargs,
id=job_id,
name=f"{job_info.name}{name_suffix}",
coalesce=job_info.coalesce,
max_instances=job_info.max_instances or 1,
jobstore=jobstore,
executor=executor,
)
logger.info(f"任务 {job_id} 添加到 {jobstore} 存储器成功")
return job
except ConflictingIdError:
try:
scheduler.remove_job(job_id=job_id, jobstore=jobstore)
except JobLookupError:
pass
job = scheduler.add_job(
func=SchedulerUtil._task_wrapper,
trigger=trigger,
args=[str(job_info.id), code_block, *job_args],
kwargs=job_kwargs,
id=job_id,
name=f"{job_info.name}{name_suffix}",
coalesce=job_info.coalesce,
max_instances=job_info.max_instances or 1,
jobstore=jobstore,
executor=executor,
)
logger.info(f"任务 {job_id} 已存在,已移除旧任务并重新添加")
return job
def register_node_job(node: NodeModel) -> Job | None:
"""按节点持久化计划注册/刷新正式任务(job id = 节点 id)。
- 停用节点 / 未配置触发方式:从调度器移除(若有),返回 None;
- 其余:按 trigger/trigger_args 注册,编辑时旧任务会自动被覆盖。
"""
if not node or node.status == 1 or not node.trigger or not (node.func or "").strip():
unregister_node_job(node_id=node.id)
return None
trigger = _build_job_trigger(node.trigger, node.trigger_args, node.start_date, node.end_date)
return _add_job_with_trigger(node, trigger)
def unregister_node_job(node_id: int | str) -> None:
"""将节点从调度器移除(幂等)。"""
try:
SchedulerUtil.remove_job(job_id=str(node_id))
except JobLookupError:
pass
def run_node_once(node: NodeModel) -> str:
"""手动执行一次:创建带独立临时 job id 的一次性任务,不覆盖正式计划。
执行成功/失败会像正式任务一样写入 task_job 执行日志(job_id 归一到节点 id)。
"""
if not node.func or not node.func.strip():
raise ValueError("任务代码块不能为空")
# 手动执行同样按可调度性判定:builtin 引用可直接执行;原始代码块在开关关闭时给出替代写法
SchedulerUtil.require_schedulable(node.func)
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=" - 手动执行")
return temp_job_id