fix: stabilize cron scheduling and run history
This commit is contained in:
@@ -1,3 +1,9 @@
|
|||||||
|
"""旧版工作区级员工管理接口。
|
||||||
|
|
||||||
|
路由前缀为 ``/api/v1/admin``,依赖当前工作区的管理员权限。新的系统级用户、
|
||||||
|
工作区和成员管理接口在 ``platform.py``;本模块主要保留给兼容旧前端调用。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@@ -39,6 +45,7 @@ class EmployeeUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def require_admin(context: RequestContext) -> None:
|
def require_admin(context: RequestContext) -> None:
|
||||||
|
# 统一在路由入口处做角色判断,避免每个 CRUD 分支重复写权限代码。
|
||||||
if not context.is_admin:
|
if not context.is_admin:
|
||||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "仅管理员可以管理员工")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "仅管理员可以管理员工")
|
||||||
|
|
||||||
@@ -80,6 +87,7 @@ async def member_row(
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 旧版接口:列出当前工作区内的员工及其角色。
|
||||||
@router.get("/employees")
|
@router.get("/employees")
|
||||||
async def list_employees(
|
async def list_employees(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -107,6 +115,7 @@ async def list_employees(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 旧版接口:在当前工作区中创建员工及成员关系。
|
||||||
@router.post("/employees", status_code=status.HTTP_201_CREATED)
|
@router.post("/employees", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_employee(
|
async def create_employee(
|
||||||
payload: EmployeeCreate,
|
payload: EmployeeCreate,
|
||||||
@@ -160,6 +169,7 @@ async def create_employee(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 旧版接口:更新员工显示信息、角色或状态。
|
||||||
@router.patch("/employees/{user_id}")
|
@router.patch("/employees/{user_id}")
|
||||||
async def update_employee(
|
async def update_employee(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -215,6 +225,7 @@ async def update_employee(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 旧版接口:移除当前工作区中的员工成员关系。
|
||||||
@router.delete("/employees/{user_id}")
|
@router.delete("/employees/{user_id}")
|
||||||
async def delete_employee(
|
async def delete_employee(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
+16
-13
@@ -1,4 +1,10 @@
|
|||||||
"""Cookie+JWT authentication endpoints.
|
"""登录与会话认证接口。
|
||||||
|
|
||||||
|
中文导读:浏览器调用登录接口后,后端把 JWT 放入 HttpOnly Cookie;之后前端
|
||||||
|
通过 ``fetch(..., credentials='same-origin')`` 自动携带 Cookie。需要工作区的
|
||||||
|
接口会继续由 ``request_context`` 校验 ``workspace_id`` 和成员权限。
|
||||||
|
|
||||||
|
Cookie+JWT authentication endpoints.
|
||||||
|
|
||||||
The user-facing flow is:
|
The user-facing flow is:
|
||||||
1. POST /api/v1/auth/login — verify password, set HttpOnly cookie
|
1. POST /api/v1/auth/login — verify password, set HttpOnly cookie
|
||||||
@@ -30,10 +36,8 @@ from backend.dependencies import database_session, load_user_permissions
|
|||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
# Cookie config. ``secure=True`` requires HTTPS — the only safe
|
# Cookie 配置:生产环境走 HTTPS 时应设置 Secure;本地 HTTP 开发环境会根据
|
||||||
# assumption in production. Dev environments running on plain HTTP
|
# 实际请求协议决定是否设置,避免浏览器因 Secure Cookie 而丢弃登录状态。
|
||||||
# should reverse-proxy with TLS termination or set the env knob
|
|
||||||
# (future extension).
|
|
||||||
COOKIE_NAME = "access_token"
|
COOKIE_NAME = "access_token"
|
||||||
COOKIE_TTL_SECONDS = 24 * 60 * 60
|
COOKIE_TTL_SECONDS = 24 * 60 * 60
|
||||||
COOKIE_SAMESITE = "lax"
|
COOKIE_SAMESITE = "lax"
|
||||||
@@ -41,12 +45,8 @@ COOKIE_SAMESITE = "lax"
|
|||||||
|
|
||||||
def _set_session_cookie(request: Request, response: Response, token: str) -> None:
|
def _set_session_cookie(request: Request, response: Response, token: str) -> None:
|
||||||
forwarded_scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
forwarded_scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||||
# Operators running behind a TLS-terminating proxy that strips
|
# 反向代理通常用 X-Forwarded-Proto 告诉后端原始协议;若代理未传该头,
|
||||||
# X-Forwarded-Proto can opt into forcing the Secure flag via
|
# 可通过配置强制启用 Secure,防止 HTTPS 场景下出现不安全 Cookie。
|
||||||
# ``settings.cookie_force_secure`` — without that override a plain
|
|
||||||
# HTTP request (no forwarded scheme, no TLS upgrade visible to the
|
|
||||||
# app) would yield an insecure cookie and modern browsers would
|
|
||||||
# silently drop it on the HTTPS round trip.
|
|
||||||
secure = forwarded_scheme == "https" or settings.cookie_force_secure
|
secure = forwarded_scheme == "https" or settings.cookie_force_secure
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=COOKIE_NAME,
|
key=COOKIE_NAME,
|
||||||
@@ -95,6 +95,7 @@ def _workspace_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。
|
||||||
@router.post("/api/v1/auth/login")
|
@router.post("/api/v1/auth/login")
|
||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -131,8 +132,8 @@ async def login(
|
|||||||
"invalid username or password",
|
"invalid username or password",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pull all active memberships. The earliest join wins as default
|
# 返回用户可进入的工作区列表;当前没有默认工作区字段,因此最早加入的
|
||||||
# because there is no `is_default` column on `workspace_members`.
|
# 工作区作为前端的初始选择。
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Workspaces, Roles, WorkspaceMembers.joined_at)
|
select(Workspaces, Roles, WorkspaceMembers.joined_at)
|
||||||
@@ -196,6 +197,7 @@ async def login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 清除浏览器 Cookie,使当前会话立即失效。
|
||||||
@router.post("/api/v1/auth/logout")
|
@router.post("/api/v1/auth/logout")
|
||||||
async def logout(response: Response) -> dict[str, Any]:
|
async def logout(response: Response) -> dict[str, Any]:
|
||||||
"""Clear the session cookie. Idempotent."""
|
"""Clear the session cookie. Idempotent."""
|
||||||
@@ -207,6 +209,7 @@ async def logout(response: Response) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 返回当前登录用户、权限和可访问工作区,用于前端初始化登录态。
|
||||||
@router.get("/api/v1/auth/me")
|
@router.get("/api/v1/auth/me")
|
||||||
async def me(
|
async def me(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
"""FastAPI dependencies for the public API.
|
"""公共 FastAPI 依赖。
|
||||||
|
|
||||||
|
中文导读:
|
||||||
|
|
||||||
|
* ``database_session``:为一次请求提供数据库事务;成功提交、异常回滚。
|
||||||
|
* ``current_user``:只验证登录 Cookie 并得到当前用户,不关心工作区。
|
||||||
|
* ``request_context``:普通业务接口最常使用的依赖,同时验证用户、
|
||||||
|
``workspace_id``、工作区成员关系和角色权限。
|
||||||
|
|
||||||
|
路由函数把这些函数写进 ``Depends(...)`` 后,FastAPI 会先完成校验,再把
|
||||||
|
结果作为参数传给路由函数;因此业务代码无需重复解析 Cookie 或查询成员关系。
|
||||||
|
|
||||||
|
FastAPI dependencies for the public API.
|
||||||
|
|
||||||
Two distinct concerns live here:
|
Two distinct concerns live here:
|
||||||
|
|
||||||
@@ -48,6 +60,12 @@ ACCESS_TOKEN_COOKIE = "access_token"
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RequestContext:
|
class RequestContext:
|
||||||
|
"""已完成认证和工作区授权后的请求上下文。
|
||||||
|
|
||||||
|
路由依赖 ``request_context`` 后会得到该对象,用其中的用户、工作区和角色
|
||||||
|
执行业务权限判断,避免在每个接口中重复查询。
|
||||||
|
"""
|
||||||
|
|
||||||
request_id: str
|
request_id: str
|
||||||
user: Users
|
user: Users
|
||||||
workspace: Workspaces
|
workspace: Workspaces
|
||||||
@@ -67,6 +85,7 @@ class RequestContext:
|
|||||||
|
|
||||||
|
|
||||||
async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
|
async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
|
||||||
|
# 一个请求对应一个事务范围,避免不同请求意外共用同一个 Session。
|
||||||
async with session_scope(request.app.state.session_factory) as session:
|
async with session_scope(request.app.state.session_factory) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
@@ -82,6 +101,7 @@ async def current_user(
|
|||||||
a workspace-scoped context, or use ``Depends(current_user)`` for
|
a workspace-scoped context, or use ``Depends(current_user)`` for
|
||||||
workspace-agnostic endpoints (e.g. ``/api/v1/auth/me``).
|
workspace-agnostic endpoints (e.g. ``/api/v1/auth/me``).
|
||||||
"""
|
"""
|
||||||
|
# 登录接口写入 HttpOnly Cookie;后续浏览器请求会自动携带它。
|
||||||
token = request.cookies.get(ACCESS_TOKEN_COOKIE)
|
token = request.cookies.get(ACCESS_TOKEN_COOKIE)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
||||||
@@ -122,6 +142,7 @@ async def request_context(
|
|||||||
because they own the platform. Non-admin users still need an
|
because they own the platform. Non-admin users still need an
|
||||||
active ``WorkspaceMembers`` row in an active ``Workspaces`` row.
|
active ``WorkspaceMembers`` row in an active ``Workspaces`` row.
|
||||||
"""
|
"""
|
||||||
|
# 大多数工作区接口通过这个依赖统一完成“登录 + 工作区 + 角色”三层校验。
|
||||||
user = await current_user(request, session)
|
user = await current_user(request, session)
|
||||||
is_system_admin = await resolve_is_system_admin(session, user)
|
is_system_admin = await resolve_is_system_admin(session, user)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
"""Jupyter 访问的鉴权桥接。
|
||||||
|
|
||||||
|
浏览器访问 ``/jupyter/{workspace_id}/...`` 时,Nginx 会先向本模块的
|
||||||
|
``/api/v1/auth/jupyter`` 发起内部 auth_request。后端验证用户、工作区和文件
|
||||||
|
锁,再把实际 Jupyter 地址与内部令牌写进响应头,由 Nginx 转发请求。
|
||||||
|
|
||||||
|
本模块只负责鉴权和路由选择;真正启动、管理 Jupyter 进程的是 ``runtime`` 服务。
|
||||||
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from common.auth.jwt import JwtError, verify_jwt_token
|
from common.auth.jwt import JwtError, verify_jwt_token
|
||||||
@@ -44,6 +53,8 @@ async def check_notebook_is_locked(
|
|||||||
different user, and is currently locked. The owner is always let
|
different user, and is currently locked. The owner is always let
|
||||||
through; a missing row is treated as "not owned yet" and allowed.
|
through; a missing row is treated as "not owned yet" and allowed.
|
||||||
"""
|
"""
|
||||||
|
# 锁信息保存在数据库的 Scripts 记录中,而非前端内存;因此多浏览器/多用户
|
||||||
|
# 访问同一 notebook 时也能得到一致结果。
|
||||||
statement = select(Scripts.owner_user_id, Scripts.is_locked).where(
|
statement = select(Scripts.owner_user_id, Scripts.is_locked).where(
|
||||||
Scripts.workspace_id == workspace_id,
|
Scripts.workspace_id == workspace_id,
|
||||||
Scripts.script_name == notebook_path,
|
Scripts.script_name == notebook_path,
|
||||||
@@ -78,6 +89,8 @@ async def load_active_membership_or_403(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# 供 Nginx auth_request 调用:验证访问 Jupyter 的身份、成员关系和文件锁,
|
||||||
|
# 再返回应转发到的 Jupyter 地址及内部令牌。
|
||||||
@router.get("/api/v1/auth/jupyter")
|
@router.get("/api/v1/auth/jupyter")
|
||||||
async def verify_jupyter_access(
|
async def verify_jupyter_access(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
"""后端服务总入口。
|
||||||
|
|
||||||
|
这里负责两件事:
|
||||||
|
1. 在应用启动时准备数据库、对象存储以及 Runtime 的 HTTP 客户端;
|
||||||
|
2. 将各业务模块的路由注册到同一个 FastAPI 应用中。
|
||||||
|
|
||||||
|
浏览器请求先经过 Nginx 的 ``/api/`` 反向代理,随后才会到达本文件创建的
|
||||||
|
应用。具体接口实现分别在 ``auth.py``、``scripts.py``、``schedules.py`` 等
|
||||||
|
模块中。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -38,15 +49,14 @@ configure_logging(settings.log_level)
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: Any) -> AsyncIterator[None]:
|
async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||||
|
# 生命周期内创建的对象挂在 app.state 上,路由通过 Depends 或 Request
|
||||||
|
# 取得它们;这样每个请求不会重复创建数据库连接或 HTTP 客户端。
|
||||||
engine = create_database_engine(settings.database_url)
|
engine = create_database_engine(settings.database_url)
|
||||||
app.state.session_factory = create_session_factory(engine)
|
app.state.session_factory = create_session_factory(engine)
|
||||||
|
|
||||||
# Storage API is part of the backend process. Platform routers call
|
# 存储接口与业务路由运行在同一个 backend 进程中,因此直接复用存储对象,
|
||||||
# the helpers in ``backend.services.storage`` directly (in-process),
|
# 不需要再通过 HTTP 调用自己。字典键使用真实桶名,便于上传会话和存储
|
||||||
# so no HTTP client is needed. The dict is keyed by the actual
|
# 对象记录直接定位对应的存储后端。
|
||||||
# bucket name (e.g. "versions"), matching ``UploadSessions.bucket_name``
|
|
||||||
# and ``StorageObjects.bucket_name`` so call sites can do
|
|
||||||
# ``object_stores[upload.bucket_name].put(...)`` directly.
|
|
||||||
app.state.object_stores: dict[str, StorageBackend | AsyncStorageBackend] = { # noqa: F821
|
app.state.object_stores: dict[str, StorageBackend | AsyncStorageBackend] = { # noqa: F821
|
||||||
actual_bucket_name(purpose): create_storage(build_storage_config(purpose))
|
actual_bucket_name(purpose): create_storage(build_storage_config(purpose))
|
||||||
for purpose in PURPOSE_BUCKETS
|
for purpose in PURPOSE_BUCKETS
|
||||||
@@ -63,7 +73,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
app.state.runtime_client = RuntimeClient(runtime_http_client)
|
app.state.runtime_client = RuntimeClient(runtime_http_client)
|
||||||
# Short timeout — refresh is best-effort and runs in a BackgroundTask.
|
# rclone 目录缓存刷新属于尽力而为的后台动作,不能拖慢用户保存文件的请求。
|
||||||
rclone_http_client = httpx.AsyncClient(
|
rclone_http_client = httpx.AsyncClient(
|
||||||
base_url=settings.rclone_rc_url,
|
base_url=settings.rclone_rc_url,
|
||||||
timeout=httpx.Timeout(30.0),
|
timeout=httpx.Timeout(30.0),
|
||||||
@@ -81,6 +91,7 @@ app = create_service_app(
|
|||||||
settings.service_name,
|
settings.service_name,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
# 面向浏览器的公开 API:认证、脚本、资源、调度和系统管理。
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(jupyter_router)
|
app.include_router(jupyter_router)
|
||||||
app.include_router(resources_router)
|
app.include_router(resources_router)
|
||||||
@@ -90,12 +101,14 @@ app.include_router(scripts_router)
|
|||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(platform_router)
|
app.include_router(platform_router)
|
||||||
|
|
||||||
# Reuse the proven storage endpoints without running another FastAPI service.
|
# 内部存储接口额外加上 /internal 前缀,供后端服务间调用,不作为普通前端 API。
|
||||||
app.include_router(storage_api_router, prefix="/internal")
|
app.include_router(storage_api_router, prefix="/internal")
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def access_log(request: Request, call_next):
|
async def access_log(request: Request, call_next):
|
||||||
|
# 每个 HTTP 请求都记录方法、路径、状态码和耗时;排查页面请求失败时,
|
||||||
|
# Docker Desktop 中 backend 容器的 Logs 就会显示这里生成的日志。
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
"""System-admin (platform-scope) endpoints for workspace & membership management.
|
"""系统级管理接口。
|
||||||
|
|
||||||
|
中文导读:本模块管理全平台的用户、工作区、成员关系和角色权限。它使用
|
||||||
|
``system_admin_context`` 进行平台管理员校验,因此不要求请求者先加入某个具体
|
||||||
|
工作区;普通工作区内的业务接口则使用 ``request_context``。
|
||||||
|
|
||||||
|
System-admin (platform-scope) endpoints for workspace & membership management.
|
||||||
|
|
||||||
All routes under ``/api/v1/platform/*`` are gated by
|
All routes under ``/api/v1/platform/*`` are gated by
|
||||||
:func:`system_admin_context`, which requires the requester to hold a
|
:func:`system_admin_context`, which requires the requester to hold a
|
||||||
@@ -102,6 +108,7 @@ MEMBER_STATUS_VALUES = ("active", "disabled", "locked")
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# 创建工作区时前端提交的请求体;禁止未声明字段。
|
||||||
class WorkspaceCreate(BaseModel):
|
class WorkspaceCreate(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -111,6 +118,7 @@ class WorkspaceCreate(BaseModel):
|
|||||||
description: str | None = Field(default=None, max_length=1000)
|
description: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
# 编辑工作区时允许修改的字段;禁用操作必须走删除接口而不是直接传状态。
|
||||||
class WorkspaceUpdate(BaseModel):
|
class WorkspaceUpdate(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -121,6 +129,7 @@ class WorkspaceUpdate(BaseModel):
|
|||||||
status: Literal["active", "archived"] | None = None
|
status: Literal["active", "archived"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# 将已存在用户加入工作区的请求体;角色继承用户的平台角色。
|
||||||
class MemberCreate(BaseModel):
|
class MemberCreate(BaseModel):
|
||||||
"""Add a user to a workspace. Role is inherited from the user's
|
"""Add a user to a workspace. Role is inherited from the user's
|
||||||
platform role (Users.platform_role_id) — not set here."""
|
platform role (Users.platform_role_id) — not set here."""
|
||||||
@@ -130,6 +139,7 @@ class MemberCreate(BaseModel):
|
|||||||
user_id: str = Field(min_length=26, max_length=26)
|
user_id: str = Field(min_length=26, max_length=26)
|
||||||
|
|
||||||
|
|
||||||
|
# 更新成员在该工作区中的可用状态,不直接在这里修改平台角色。
|
||||||
class MemberUpdate(BaseModel):
|
class MemberUpdate(BaseModel):
|
||||||
"""Update a workspace membership's status. Role cannot be changed
|
"""Update a workspace membership's status. Role cannot be changed
|
||||||
via this endpoint — workspace role is always inherited from the
|
via this endpoint — workspace role is always inherited from the
|
||||||
@@ -141,6 +151,7 @@ class MemberUpdate(BaseModel):
|
|||||||
member_status: Literal["active", "disabled", "locked"] | None = None
|
member_status: Literal["active", "disabled", "locked"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# 新建平台用户的请求体;创建用户不等同于把用户加入某个工作区。
|
||||||
class PlatformEmployeeCreate(BaseModel):
|
class PlatformEmployeeCreate(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -151,6 +162,7 @@ class PlatformEmployeeCreate(BaseModel):
|
|||||||
role_code: Literal["admin", "developer"] | None = None
|
role_code: Literal["admin", "developer"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# 修改平台用户资料、状态或平台角色的请求体。
|
||||||
class PlatformEmployeeUpdate(BaseModel):
|
class PlatformEmployeeUpdate(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -160,6 +172,7 @@ class PlatformEmployeeUpdate(BaseModel):
|
|||||||
role_code: Literal["admin", "developer"] | None = None
|
role_code: Literal["admin", "developer"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# 用完整权限集合替换某个平台角色菜单权限的请求体。
|
||||||
class RolePermissionsPatch(BaseModel):
|
class RolePermissionsPatch(BaseModel):
|
||||||
"""Replace a platform role's permission set wholesale.
|
"""Replace a platform role's permission set wholesale.
|
||||||
|
|
||||||
@@ -181,6 +194,8 @@ class RolePermissionsPatch(BaseModel):
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SystemAdminContext:
|
class SystemAdminContext:
|
||||||
|
"""通过系统管理员校验后的上下文,只包含当前用户和请求追踪 ID。"""
|
||||||
|
|
||||||
"""Resolved identity for a system-admin request.
|
"""Resolved identity for a system-admin request.
|
||||||
|
|
||||||
Carries the request id, the authenticated user row, and the resolved
|
Carries the request id, the authenticated user row, and the resolved
|
||||||
@@ -197,6 +212,7 @@ async def system_admin_context(
|
|||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(database_session),
|
session: AsyncSession = Depends(database_session),
|
||||||
) -> SystemAdminContext:
|
) -> SystemAdminContext:
|
||||||
|
"""验证当前用户是否为平台管理员,供 /api/v1/platform 下的路由依赖。"""
|
||||||
"""Resolve the requester as a system admin.
|
"""Resolve the requester as a system admin.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
@@ -368,6 +384,7 @@ def _envelope(request_id: str, data: Any, meta: dict[str, Any] | None = None) ->
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# 列出整个平台的非删除用户;不局限于某一个工作区。
|
||||||
@router.get("/employees")
|
@router.get("/employees")
|
||||||
async def list_platform_employees(
|
async def list_platform_employees(
|
||||||
context: SystemAdminContext = Depends(system_admin_context),
|
context: SystemAdminContext = Depends(system_admin_context),
|
||||||
@@ -389,6 +406,7 @@ async def list_platform_employees(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 创建平台用户;后续可再通过成员接口把该用户加入工作区。
|
||||||
@router.post("/employees", status_code=status.HTTP_201_CREATED)
|
@router.post("/employees", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_platform_employee(
|
async def create_platform_employee(
|
||||||
payload: PlatformEmployeeCreate,
|
payload: PlatformEmployeeCreate,
|
||||||
@@ -429,6 +447,7 @@ async def create_platform_employee(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 更新平台用户资料、账号状态或平台角色,同时保护最少管理员等约束。
|
||||||
@router.patch("/employees/{user_id}")
|
@router.patch("/employees/{user_id}")
|
||||||
async def update_platform_employee(
|
async def update_platform_employee(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -577,6 +596,7 @@ async def update_platform_employee(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 软删除平台用户,并级联标记其工作区成员关系为删除。
|
||||||
@router.delete("/employees/{user_id}")
|
@router.delete("/employees/{user_id}")
|
||||||
async def delete_platform_employee(
|
async def delete_platform_employee(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -629,6 +649,7 @@ async def delete_platform_employee(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# 列出平台中全部未删除工作区。
|
||||||
@router.get("/workspaces")
|
@router.get("/workspaces")
|
||||||
async def list_workspaces(
|
async def list_workspaces(
|
||||||
context: SystemAdminContext = Depends(system_admin_context),
|
context: SystemAdminContext = Depends(system_admin_context),
|
||||||
@@ -656,6 +677,7 @@ async def list_workspaces(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 创建工作区,并将当前系统管理员初始化为该工作区管理员。
|
||||||
@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
|
@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_workspace(
|
async def create_workspace(
|
||||||
payload: WorkspaceCreate,
|
payload: WorkspaceCreate,
|
||||||
@@ -702,6 +724,7 @@ async def create_workspace(
|
|||||||
return _envelope(context.request_id, workspace_payload(workspace))
|
return _envelope(context.request_id, workspace_payload(workspace))
|
||||||
|
|
||||||
|
|
||||||
|
# 读取单个工作区详情,包含已归档或禁用状态。
|
||||||
@router.get("/workspaces/{workspace_id}")
|
@router.get("/workspaces/{workspace_id}")
|
||||||
async def get_workspace(
|
async def get_workspace(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -713,6 +736,7 @@ async def get_workspace(
|
|||||||
return _envelope(context.request_id, workspace_payload(workspace))
|
return _envelope(context.request_id, workspace_payload(workspace))
|
||||||
|
|
||||||
|
|
||||||
|
# 更新工作区可编辑属性,例如名称、配额、描述和归档状态。
|
||||||
@router.patch("/workspaces/{workspace_id}")
|
@router.patch("/workspaces/{workspace_id}")
|
||||||
async def update_workspace(
|
async def update_workspace(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -740,6 +764,7 @@ async def update_workspace(
|
|||||||
return _envelope(context.request_id, workspace_payload(workspace))
|
return _envelope(context.request_id, workspace_payload(workspace))
|
||||||
|
|
||||||
|
|
||||||
|
# 软删除/禁用工作区,并级联处理其活动成员关系。
|
||||||
@router.delete("/workspaces/{workspace_id}")
|
@router.delete("/workspaces/{workspace_id}")
|
||||||
async def delete_workspace(
|
async def delete_workspace(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -780,6 +805,7 @@ async def delete_workspace(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# 列出一个工作区的活动成员与成员状态。
|
||||||
@router.get("/workspaces/{workspace_id}/members")
|
@router.get("/workspaces/{workspace_id}/members")
|
||||||
async def list_members(
|
async def list_members(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -811,6 +837,7 @@ async def list_members(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 将已有平台用户加入指定工作区。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/workspaces/{workspace_id}/members",
|
"/workspaces/{workspace_id}/members",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
@@ -894,6 +921,7 @@ async def add_member(
|
|||||||
return _envelope(context.request_id, member_payload(user, role, membership))
|
return _envelope(context.request_id, member_payload(user, role, membership))
|
||||||
|
|
||||||
|
|
||||||
|
# 更新成员状态,例如禁用或锁定;同时保证工作区不会失去最后一个管理员。
|
||||||
@router.patch("/workspaces/{workspace_id}/members/{user_id}")
|
@router.patch("/workspaces/{workspace_id}/members/{user_id}")
|
||||||
async def update_member(
|
async def update_member(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -952,6 +980,7 @@ async def update_member(
|
|||||||
return _envelope(context.request_id, member_payload(user, role, membership))
|
return _envelope(context.request_id, member_payload(user, role, membership))
|
||||||
|
|
||||||
|
|
||||||
|
# 移除某个工作区成员,并保护最后一名管理员及当前操作者的安全约束。
|
||||||
@router.delete("/workspaces/{workspace_id}/members/{user_id}")
|
@router.delete("/workspaces/{workspace_id}/members/{user_id}")
|
||||||
async def remove_member(
|
async def remove_member(
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -1062,6 +1091,7 @@ def _role_payload(role: Roles, permission_codes: list[str]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 列出平台角色及其拥有的菜单权限代码。
|
||||||
@router.get("/roles")
|
@router.get("/roles")
|
||||||
async def list_platform_roles(
|
async def list_platform_roles(
|
||||||
context: SystemAdminContext = Depends(system_admin_context),
|
context: SystemAdminContext = Depends(system_admin_context),
|
||||||
@@ -1086,6 +1116,7 @@ async def list_platform_roles(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 获取一个角色当前配置的权限代码集合。
|
||||||
@router.get("/roles/{role_code}/permissions")
|
@router.get("/roles/{role_code}/permissions")
|
||||||
async def get_role_permissions(
|
async def get_role_permissions(
|
||||||
role_code: str,
|
role_code: str,
|
||||||
@@ -1100,6 +1131,7 @@ async def get_role_permissions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 以请求中的完整集合更新角色权限,并保留管理员角色的必要系统权限。
|
||||||
@router.patch("/roles/{role_code}/permissions")
|
@router.patch("/roles/{role_code}/permissions")
|
||||||
async def patch_role_permissions(
|
async def patch_role_permissions(
|
||||||
role_code: str,
|
role_code: str,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
"""工作区数据资源 API。
|
||||||
|
|
||||||
|
资源上传分为三步:创建上传会话 → 写入文件字节 → 绑定为可见的数据资源。
|
||||||
|
这种拆分使前端可以分别处理元数据、文件传输和最终展示;资源文件本身由存储层
|
||||||
|
保存,数据库只保存资源与存储对象的关联关系。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -134,6 +141,7 @@ def can_view(resource: DataResources, context: RequestContext) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。
|
||||||
@router.post("/{resource_id}/jupyter-relative-path")
|
@router.post("/{resource_id}/jupyter-relative-path")
|
||||||
async def resource_jupyter_relative_path(
|
async def resource_jupyter_relative_path(
|
||||||
resource_id: str,
|
resource_id: str,
|
||||||
@@ -163,6 +171,7 @@ async def resource_jupyter_relative_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 上传第 1 步:创建上传会话,登记文件名、大小、类型等预期元数据。
|
||||||
@router.post("/uploads", status_code=status.HTTP_201_CREATED)
|
@router.post("/uploads", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_resource_upload(
|
async def create_resource_upload(
|
||||||
payload: CreateResourceUploadRequest,
|
payload: CreateResourceUploadRequest,
|
||||||
@@ -193,6 +202,7 @@ async def create_resource_upload(
|
|||||||
return {"request_id": context.request_id, "data": data, "meta": {}}
|
return {"request_id": context.request_id, "data": data, "meta": {}}
|
||||||
|
|
||||||
|
|
||||||
|
# 上传第 2 步:将浏览器传来的二进制文件写入已创建的上传会话。
|
||||||
@router.put("/uploads/{upload_id}")
|
@router.put("/uploads/{upload_id}")
|
||||||
async def upload_resource_bytes(
|
async def upload_resource_bytes(
|
||||||
upload_id: str,
|
upload_id: str,
|
||||||
@@ -216,6 +226,7 @@ async def upload_resource_bytes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 上传第 3 步:把已完成的上传会话绑定为工作区可见的数据资源。
|
||||||
@router.post("/uploads/{upload_id}/bind")
|
@router.post("/uploads/{upload_id}/bind")
|
||||||
async def bind_resource(
|
async def bind_resource(
|
||||||
upload_id: str,
|
upload_id: str,
|
||||||
@@ -349,6 +360,7 @@ async def bind_resource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 列出当前工作区可见的数据资源,可按可见性或关键字筛选。
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_resources(
|
async def list_resources(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -428,6 +440,7 @@ async def get_visible_resource(
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# 查询单个数据资源的元数据与其关联文件信息。
|
||||||
@router.get("/{resource_id}")
|
@router.get("/{resource_id}")
|
||||||
async def get_resource(
|
async def get_resource(
|
||||||
resource_id: str,
|
resource_id: str,
|
||||||
@@ -446,6 +459,7 @@ async def get_resource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 为资源文件生成带时效的下载链接。
|
||||||
@router.post("/{resource_id}/download-url")
|
@router.post("/{resource_id}/download-url")
|
||||||
async def resource_download_url(
|
async def resource_download_url(
|
||||||
resource_id: str,
|
resource_id: str,
|
||||||
@@ -467,6 +481,7 @@ async def resource_download_url(
|
|||||||
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
|
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
|
||||||
|
|
||||||
|
|
||||||
|
# 软删除数据资源及其关联对象,遵循存储层的回收站策略。
|
||||||
@router.delete("/{resource_id}")
|
@router.delete("/{resource_id}")
|
||||||
async def delete_resource(
|
async def delete_resource(
|
||||||
resource_id: str,
|
resource_id: str,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
"""Backend 调用 Runtime 服务的轻量 HTTP 客户端。
|
||||||
|
|
||||||
|
后端不直接管理 Jupyter 进程;涉及工作区运行时状态、编辑会话或访问票据时,
|
||||||
|
会通过本客户端请求 Docker 内网中的 ``runtime`` 服务。网络错误会统一包装成
|
||||||
|
``RuntimeClientError``,让路由返回可识别的 503 错误。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -34,6 +41,8 @@ class RuntimeClient:
|
|||||||
path: str,
|
path: str,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
# 将 httpx 的连接/响应异常转换为项目统一的业务异常,调用方无需关心
|
||||||
|
# 底层 HTTP 客户端的具体异常类型。
|
||||||
try:
|
try:
|
||||||
response = await self.client.request(method, path, json=payload)
|
response = await self.client.request(method, path, json=payload)
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
"""调度运行的触发与查询 API。
|
||||||
|
|
||||||
|
手动运行接口只负责在 MySQL 中创建 ``ScheduleRuns`` 和 Outbox 事件;真正执行
|
||||||
|
任务的是 ``schedule`` 容器,它轮询 Outbox 后运行 DAG 节点。本模块还提供运行
|
||||||
|
历史、节点状态、日志和结果文件的查询入口。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -51,6 +58,7 @@ RunStatus = Literal[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# 手动触发调度时允许前端附带的运行原因;严格拒绝未定义字段。
|
||||||
class RunScheduleRequest(StrictModel):
|
class RunScheduleRequest(StrictModel):
|
||||||
reason: str = Field(default="manual_run", min_length=1, max_length=255)
|
reason: str = Field(default="manual_run", min_length=1, max_length=255)
|
||||||
|
|
||||||
@@ -64,6 +72,8 @@ def _iso(value: datetime | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _http_error_from_trigger(exc: TriggerError) -> HTTPException:
|
def _http_error_from_trigger(exc: TriggerError) -> HTTPException:
|
||||||
|
# 调度领域异常统一转换为 HTTP 状态码,前端可据此区分“找不到任务”、
|
||||||
|
# “DAG 无效”和“请求参数不合法”等情况。
|
||||||
if isinstance(exc, ScheduleNotFound):
|
if isinstance(exc, ScheduleNotFound):
|
||||||
return HTTPException(status.HTTP_404_NOT_FOUND, str(exc))
|
return HTTPException(status.HTTP_404_NOT_FOUND, str(exc))
|
||||||
if isinstance(exc, InvalidDag):
|
if isinstance(exc, InvalidDag):
|
||||||
@@ -226,6 +236,7 @@ async def _artifact_bytes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 立即触发一次调度:写入运行记录和 Outbox,由 schedule 容器异步接手执行。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/schedules/{schedule_id}/run",
|
"/api/v1/schedules/{schedule_id}/run",
|
||||||
status_code=status.HTTP_202_ACCEPTED,
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
@@ -276,6 +287,7 @@ async def run_schedule_now(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 按调度或状态筛选运行历史,供前端运行记录列表展示。
|
||||||
@router.get("/api/v1/schedule-runs")
|
@router.get("/api/v1/schedule-runs")
|
||||||
async def list_schedule_runs(
|
async def list_schedule_runs(
|
||||||
schedule_id: str | None = Query(default=None),
|
schedule_id: str | None = Query(default=None),
|
||||||
@@ -305,6 +317,7 @@ async def list_schedule_runs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 查询一次运行的详情,包括每个节点的执行状态。
|
||||||
@router.get("/api/v1/schedule-runs/{run_id}")
|
@router.get("/api/v1/schedule-runs/{run_id}")
|
||||||
async def get_schedule_run(
|
async def get_schedule_run(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
@@ -319,6 +332,7 @@ async def get_schedule_run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 返回某个节点运行关联的日志/结果产物元数据及可访问地址。
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/artifacts"
|
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/artifacts"
|
||||||
)
|
)
|
||||||
@@ -361,6 +375,7 @@ async def get_schedule_node_run_artifacts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 读取节点运行日志正文,通常由前端日志面板按需调用。
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/logs"
|
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/logs"
|
||||||
)
|
)
|
||||||
@@ -387,6 +402,7 @@ async def read_schedule_node_run_logs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 为节点运行结果生成下载响应或重定向地址。
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/result"
|
"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/result"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
"""DAG 调度定义 API。
|
||||||
|
|
||||||
|
这里管理调度任务本身:名称、Cron、节点、边和 DAG 校验;实际的定时轮询与节点
|
||||||
|
执行由独立的 ``schedule`` 容器完成。``workflow_version`` 用于乐观并发控制:
|
||||||
|
前端修改画布时必须携带当前版本,避免两个人的编辑互相覆盖。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import heapq
|
import heapq
|
||||||
@@ -70,6 +77,7 @@ def cron_preview(
|
|||||||
count: int,
|
count: int,
|
||||||
base_time: datetime | None = None,
|
base_time: datetime | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
# 该接口只计算并预览下几次触发时间,不会创建或修改任何调度任务。
|
||||||
if len(expression.split()) != 5:
|
if len(expression.split()) != 5:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
@@ -464,6 +472,7 @@ async def _require_valid_when_enabled(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 根据 Cron 表达式预览未来触发时间,不会保存或执行任务。
|
||||||
@router.post("/api/v1/cron/preview")
|
@router.post("/api/v1/cron/preview")
|
||||||
async def preview_cron(
|
async def preview_cron(
|
||||||
payload: CronPreviewRequest,
|
payload: CronPreviewRequest,
|
||||||
@@ -481,6 +490,7 @@ async def preview_cron(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 列出调度产生的可展示版本/产物,供前端结果面板使用。
|
||||||
@router.get("/api/v1/schedule-artifacts")
|
@router.get("/api/v1/schedule-artifacts")
|
||||||
async def list_schedule_artifacts(
|
async def list_schedule_artifacts(
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
@@ -530,6 +540,7 @@ async def list_schedule_artifacts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 列出当前工作区的调度定义及其节点、边数量等摘要信息。
|
||||||
@router.get("/api/v1/schedules")
|
@router.get("/api/v1/schedules")
|
||||||
async def list_schedules(
|
async def list_schedules(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -591,6 +602,7 @@ async def list_schedules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 创建新的 DAG 调度定义;初始状态不包含节点和边。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/schedules",
|
"/api/v1/schedules",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
@@ -643,6 +655,7 @@ async def create_schedule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 获取一个调度的完整画布数据,包括节点、边和当前工作流版本。
|
||||||
@router.get("/api/v1/schedules/{schedule_id}")
|
@router.get("/api/v1/schedules/{schedule_id}")
|
||||||
async def get_schedule(
|
async def get_schedule(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
@@ -657,6 +670,7 @@ async def get_schedule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 更新调度基本属性,如名称、Cron、时区、是否启用和并发策略。
|
||||||
@router.put("/api/v1/schedules/{schedule_id}")
|
@router.put("/api/v1/schedules/{schedule_id}")
|
||||||
@router.patch("/api/v1/schedules/{schedule_id}")
|
@router.patch("/api/v1/schedules/{schedule_id}")
|
||||||
async def update_schedule(
|
async def update_schedule(
|
||||||
@@ -721,6 +735,7 @@ async def update_schedule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 删除调度定义;请求携带 workflow_version 以避免误删他人刚修改的画布。
|
||||||
@router.delete("/api/v1/schedules/{schedule_id}")
|
@router.delete("/api/v1/schedules/{schedule_id}")
|
||||||
async def delete_schedule(
|
async def delete_schedule(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
@@ -752,6 +767,7 @@ async def delete_schedule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 向调度画布新增一个执行节点,并关联已发布的脚本版本。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/schedules/{schedule_id}/nodes",
|
"/api/v1/schedules/{schedule_id}/nodes",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
@@ -807,6 +823,7 @@ async def create_schedule_node(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 更新节点名称、执行参数、超时、重试和画布坐标等配置。
|
||||||
@router.put("/api/v1/schedules/{schedule_id}/nodes/{node_id}")
|
@router.put("/api/v1/schedules/{schedule_id}/nodes/{node_id}")
|
||||||
async def update_schedule_node(
|
async def update_schedule_node(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
@@ -862,6 +879,7 @@ async def update_schedule_node(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 从调度画布删除节点,并同步清理关联边。
|
||||||
@router.delete("/api/v1/schedules/{schedule_id}/nodes/{node_id}")
|
@router.delete("/api/v1/schedules/{schedule_id}/nodes/{node_id}")
|
||||||
async def delete_schedule_node(
|
async def delete_schedule_node(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
@@ -915,6 +933,7 @@ async def delete_schedule_node(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 在两个节点之间新增依赖边,表示目标节点必须等待源节点完成。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/schedules/{schedule_id}/edges",
|
"/api/v1/schedules/{schedule_id}/edges",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
@@ -991,6 +1010,7 @@ async def create_schedule_edge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 修改一条依赖边的条件表达式或其他可编辑字段。
|
||||||
@router.put("/api/v1/schedules/{schedule_id}/edges/{edge_id}")
|
@router.put("/api/v1/schedules/{schedule_id}/edges/{edge_id}")
|
||||||
async def update_schedule_edge(
|
async def update_schedule_edge(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
@@ -1025,6 +1045,7 @@ async def update_schedule_edge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 删除节点之间的依赖关系,不会删除节点本身。
|
||||||
@router.delete("/api/v1/schedules/{schedule_id}/edges/{edge_id}")
|
@router.delete("/api/v1/schedules/{schedule_id}/edges/{edge_id}")
|
||||||
async def delete_schedule_edge(
|
async def delete_schedule_edge(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
@@ -1059,6 +1080,7 @@ async def delete_schedule_edge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 校验画布是否为可执行 DAG,例如是否存在环、孤立节点或无效版本。
|
||||||
@router.post("/api/v1/schedules/{schedule_id}/validate")
|
@router.post("/api/v1/schedules/{schedule_id}/validate")
|
||||||
async def validate_schedule(
|
async def validate_schedule(
|
||||||
schedule_id: str,
|
schedule_id: str,
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
"""工作区脚本与 Notebook 的公开 API。
|
||||||
|
|
||||||
|
脚本元数据(名称、归属、锁、版本关系)保存在 MySQL;文件正文和版本产物保存到
|
||||||
|
对象存储或本地 ``data/``。本模块负责把两者保持一致,并提供目录、上传、编辑锁、
|
||||||
|
版本发布和下载地址等接口。
|
||||||
|
|
||||||
|
前端的 ``frontend/app/services/api.ts`` 通过 ``/api/v1/scripts`` 和
|
||||||
|
``/api/v1/workspace-directories`` 调用这里的路由。
|
||||||
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -555,6 +565,7 @@ async def create_script_record(
|
|||||||
return script, storage_object
|
return script, storage_object
|
||||||
|
|
||||||
|
|
||||||
|
# 新建空的 Python 脚本或 Notebook:同时创建数据库元数据和初始文件内容。
|
||||||
@router.post("/api/v1/scripts", status_code=status.HTTP_201_CREATED)
|
@router.post("/api/v1/scripts", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_script(
|
async def create_script(
|
||||||
payload: CreateScriptRequest,
|
payload: CreateScriptRequest,
|
||||||
@@ -587,6 +598,7 @@ async def create_script(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 上传现有脚本文件:校验文件名/类型后写入存储,并建立 Scripts 记录。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/scripts/upload",
|
"/api/v1/scripts/upload",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
@@ -647,6 +659,7 @@ async def upload_script(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 返回旧版一次性完整目录树,保留给兼容旧前端;新页面通常按目录懒加载。
|
||||||
@router.get("/api/v1/workspace-tree")
|
@router.get("/api/v1/workspace-tree")
|
||||||
async def get_workspace_tree(
|
async def get_workspace_tree(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -718,6 +731,7 @@ async def get_workspace_tree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 查询某个目录下的直接子目录,供前端按需展开工作区树。
|
||||||
@router.get("/api/v1/workspace-directories")
|
@router.get("/api/v1/workspace-directories")
|
||||||
async def list_workspace_directories(
|
async def list_workspace_directories(
|
||||||
parent_path: str = Query(default=""),
|
parent_path: str = Query(default=""),
|
||||||
@@ -790,6 +804,7 @@ async def list_workspace_directories(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 在工作区内创建逻辑目录;目录信息由脚本相对路径推导,不对应容器本地文件夹。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/workspace-directories",
|
"/api/v1/workspace-directories",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
@@ -946,6 +961,7 @@ async def create_workspace_directory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 删除逻辑目录及其下属脚本记录;实际文件按存储层的软删除规则处理。
|
||||||
@router.delete("/api/v1/workspace-directories")
|
@router.delete("/api/v1/workspace-directories")
|
||||||
async def delete_workspace_directory(
|
async def delete_workspace_directory(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1028,6 +1044,7 @@ async def delete_workspace_directory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 列出当前工作区中用户有权查看的脚本与 Notebook 元数据。
|
||||||
@router.get("/api/v1/scripts")
|
@router.get("/api/v1/scripts")
|
||||||
async def list_scripts(
|
async def list_scripts(
|
||||||
context: RequestContext = Depends(request_context),
|
context: RequestContext = Depends(request_context),
|
||||||
@@ -1057,6 +1074,7 @@ async def list_scripts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。
|
||||||
@router.get("/api/v1/scripts/{script_id}/content")
|
@router.get("/api/v1/scripts/{script_id}/content")
|
||||||
async def get_script_content(
|
async def get_script_content(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
@@ -1107,6 +1125,7 @@ async def get_script_content(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 查询单个脚本的元数据,例如类型、路径、锁状态和拥有者。
|
||||||
@router.get("/api/v1/scripts/{script_id}")
|
@router.get("/api/v1/scripts/{script_id}")
|
||||||
async def get_script(
|
async def get_script(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
@@ -1125,6 +1144,7 @@ async def get_script(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 保存编辑器提交的新内容;会校验工作区权限和文件编辑锁。
|
||||||
@router.put("/api/v1/scripts/{script_id}")
|
@router.put("/api/v1/scripts/{script_id}")
|
||||||
async def update_script(
|
async def update_script(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
@@ -1190,6 +1210,7 @@ async def update_script(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 修改脚本锁定状态,避免其他用户同时编辑同一份文件。
|
||||||
@router.patch("/api/v1/scripts/{script_id}/lock")
|
@router.patch("/api/v1/scripts/{script_id}/lock")
|
||||||
async def set_script_lock(
|
async def set_script_lock(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
@@ -1232,6 +1253,7 @@ async def set_script_lock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 软删除脚本;元数据标记删除,历史版本可按规则继续保留。
|
||||||
@router.delete("/api/v1/scripts/{script_id}")
|
@router.delete("/api/v1/scripts/{script_id}")
|
||||||
async def delete_script(
|
async def delete_script(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
@@ -1277,6 +1299,7 @@ async def delete_script(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 将当前脚本内容发布为不可变版本,供调度节点和回溯下载使用。
|
||||||
@router.post(
|
@router.post(
|
||||||
"/api/v1/scripts/{script_id}/versions",
|
"/api/v1/scripts/{script_id}/versions",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
@@ -1398,6 +1421,7 @@ async def publish_version(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 列出某脚本已经发布的历史版本。
|
||||||
@router.get("/api/v1/scripts/{script_id}/versions")
|
@router.get("/api/v1/scripts/{script_id}/versions")
|
||||||
async def list_versions(
|
async def list_versions(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
@@ -1419,6 +1443,7 @@ async def list_versions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 读取脚本最近一次发布的版本;未发布时返回空结果。
|
||||||
@router.get("/api/v1/scripts/{script_id}/latest-version")
|
@router.get("/api/v1/scripts/{script_id}/latest-version")
|
||||||
async def latest_version(
|
async def latest_version(
|
||||||
script_id: str,
|
script_id: str,
|
||||||
@@ -1470,6 +1495,7 @@ async def latest_version(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 查询单个发布版本的元数据和关联脚本信息。
|
||||||
@router.get("/api/v1/versions/{versions_id}")
|
@router.get("/api/v1/versions/{versions_id}")
|
||||||
async def get_version(
|
async def get_version(
|
||||||
versions_id: str,
|
versions_id: str,
|
||||||
@@ -1486,6 +1512,7 @@ async def get_version(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 隐藏/删除一个发布版本;是否保留实际产物由存储删除策略决定。
|
||||||
@router.delete("/api/v1/versions/{versions_id}")
|
@router.delete("/api/v1/versions/{versions_id}")
|
||||||
async def delete_version(
|
async def delete_version(
|
||||||
versions_id: str,
|
versions_id: str,
|
||||||
@@ -1533,6 +1560,7 @@ async def delete_version(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 为某个版本产物生成带时效的下载地址,而非把大文件直接经 API 返回。
|
||||||
@router.post("/api/v1/versions/{versions_id}/download-url")
|
@router.post("/api/v1/versions/{versions_id}/download-url")
|
||||||
async def version_download_url(
|
async def version_download_url(
|
||||||
versions_id: str,
|
versions_id: str,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
"""后端内部对象存储接口与通用辅助函数。
|
||||||
|
|
||||||
|
这些路由由 ``main.py`` 额外挂载到 ``/internal``,用于上传会话、对象记录和
|
||||||
|
下载地址等内部协作。业务路由通常直接复用本模块/``services.storage`` 的函数,
|
||||||
|
而不是让浏览器直接调用这些内部接口。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -85,9 +92,8 @@ def safe_file_name(value: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
# Pre-resolved bucket map (for read-only callers like services/storage.py).
|
# 预先解析的“用途 → 桶名”映射,供只读调用方快速使用。若工作区配置了专属
|
||||||
# Re-resolved at module load; re-resolve via resolve_bucket() if the
|
# artifact_bucket,则必须调用 resolve_bucket(),让工作区级覆盖规则生效。
|
||||||
# workspace.artifact_bucket override matters.
|
|
||||||
BUCKET_FOR_USAGE: dict[str, str] = {
|
BUCKET_FOR_USAGE: dict[str, str] = {
|
||||||
usage_type: actual_bucket_name(purpose)
|
usage_type: actual_bucket_name(purpose)
|
||||||
for usage_type, purpose in USAGE_TYPE_TO_PURPOSE.items()
|
for usage_type, purpose in USAGE_TYPE_TO_PURPOSE.items()
|
||||||
@@ -134,12 +140,9 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Internal routes are mounted from main.py via
|
# 内部路由由 main.py 以 /internal 前缀挂载。数据库引擎、Session 工厂和对象
|
||||||
# ``include_router(router, prefix="/internal")``. The engine, session
|
# 存储实例均在应用生命周期中创建;本模块只定义路由和供 services.storage 复用的
|
||||||
# factory, and object_stores live on the main app's lifespan — this
|
# 存储辅助函数(如 storage_payload、resolve_bucket、BUCKET_FOR_USAGE)。
|
||||||
# module only owns the route definitions and the storage-helper
|
|
||||||
# utilities (``storage_payload``, ``resolve_bucket``, ``BUCKET_FOR_USAGE``
|
|
||||||
# …) consumed by ``backend.services.storage``.
|
|
||||||
router = APIRouter(tags=["internal-storage"])
|
router = APIRouter(tags=["internal-storage"])
|
||||||
|
|
||||||
|
|
||||||
@@ -470,6 +473,7 @@ async def restore_object(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 管理动作:永久清理超过保留期限或指定的回收站对象。
|
||||||
@router.post("/v1/admin/trash/purge")
|
@router.post("/v1/admin/trash/purge")
|
||||||
async def purge_trash_object(
|
async def purge_trash_object(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
@@ -520,6 +524,7 @@ async def purge_trash_object(
|
|||||||
return {"data": {"storage_object_id": storage_object_id, "purged": True}}
|
return {"data": {"storage_object_id": storage_object_id, "purged": True}}
|
||||||
|
|
||||||
|
|
||||||
|
# 检查内部存储后端是否可用,供健康检查和排障使用。
|
||||||
@router.get("/health/storage")
|
@router.get("/health/storage")
|
||||||
async def internal_health() -> dict[str, str]:
|
async def internal_health() -> dict[str, str]:
|
||||||
return {"status": "ready", "service": "storage-api"}
|
return {"status": "ready", "service": "storage-api"}
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ from common.db.models import (
|
|||||||
from common.eventing import add_outbox_event, schedule_event_type, utcnow
|
from common.eventing import add_outbox_event, schedule_event_type, utcnow
|
||||||
from common.ids import new_ulid
|
from common.ids import new_ulid
|
||||||
|
|
||||||
# A stable user_id used for cron-triggered runs. The corresponding
|
# Cron 运行需要一个可审计的 ``triggered_by``。这里使用基线迁移已经创建的
|
||||||
# ``Users`` row is seeded by the auth-bootstrap migration so any audit
|
# 管理员用户;该值必须是 26 个字符,才能写入 ``ScheduleRuns.triggered_by``
|
||||||
# query joining on ``ScheduleRuns.triggered_by`` still resolves.
|
# 的 ``CHAR(26)`` 字段。
|
||||||
SYSTEM_CRON_USER_ID = "01HZZZZZZZZZZZZZZZZZZZZZZCR"
|
SYSTEM_CRON_USER_ID = "00000000000000000000000001"
|
||||||
|
|
||||||
|
|
||||||
TriggerType = Literal["manual", "cron", "api"]
|
TriggerType = Literal["manual", "cron", "api"]
|
||||||
|
|||||||
@@ -141,19 +141,38 @@ export default function SchedulePage({
|
|||||||
setArtifacts([]);
|
setArtifacts([]);
|
||||||
setSchedule(null);
|
setSchedule(null);
|
||||||
setRuns([]);
|
setRuns([]);
|
||||||
setRunsLoading(true);
|
setRunsLoading(false);
|
||||||
void useSchedulesStore.getState().loadInitial();
|
void useSchedulesStore.getState().loadInitial();
|
||||||
}, [workspaceId, api, setSchedules, setArtifacts, setSchedule]);
|
}, [workspaceId, api, setSchedules, setArtifacts, setSchedule]);
|
||||||
|
|
||||||
|
// 进入调度页或切换调度方案后,主动加载该方案已经存在的运行记录。
|
||||||
|
// 原先只有「手动运行」和运行中的轮询会更新列表,因此从其他页面返回时会
|
||||||
|
// 一直停留在加载状态,直到产生新的运行记录。
|
||||||
|
useEffect(() => {
|
||||||
|
const scheduleId = schedule?.schedule_id;
|
||||||
|
if (!scheduleId || !api) {
|
||||||
|
setRuns([]);
|
||||||
|
setRunsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void useSchedulesStore.getState().refreshRuns(scheduleId, true);
|
||||||
|
}, [schedule?.schedule_id, api, setRuns, setRunsLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scheduleId = schedule?.schedule_id;
|
const scheduleId = schedule?.schedule_id;
|
||||||
if (!scheduleId || !api) return;
|
if (!scheduleId || !api) return;
|
||||||
if (!runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return;
|
const hasActiveRun = runs.some(
|
||||||
|
(item) => item.run_status === "queued" || item.run_status === "running",
|
||||||
|
);
|
||||||
|
const isEnabledCron = schedule.trigger_type === "cron" && schedule.enabled;
|
||||||
|
// Cron 会由后端在未来某个整分钟创建新记录。即使当前没有运行中的
|
||||||
|
// 记录,也要持续刷新,才能让新一轮运行自动出现在右侧列表中。
|
||||||
|
if (!hasActiveRun && !isEnabledCron) return;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void useSchedulesStore.getState().refreshRuns(scheduleId);
|
void useSchedulesStore.getState().refreshRuns(scheduleId);
|
||||||
}, 1500);
|
}, hasActiveRun ? 1500 : 3000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [schedule?.schedule_id, runs, api]);
|
}, [schedule?.schedule_id, schedule?.trigger_type, schedule?.enabled, runs, api]);
|
||||||
|
|
||||||
// Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX.
|
// Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX.
|
||||||
const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||||
@@ -292,10 +311,15 @@ export default function SchedulePage({
|
|||||||
}}
|
}}
|
||||||
onDrop={onCanvasDrop}
|
onDrop={onCanvasDrop}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget) {
|
// 画布实际内容位于 surface/SVG 子元素中,不能只比较 currentTarget;
|
||||||
setSelectedNodeId(null);
|
// 点击节点或连线时保留选择,点击其余空白位置则返回调度方案基本信息。
|
||||||
setSelectedEdgeId(null);
|
const target = event.target as Element;
|
||||||
}
|
if (
|
||||||
|
target.closest(".schedule-node") ||
|
||||||
|
target.matches(".schedule-edge-line, .schedule-edge-hit")
|
||||||
|
) return;
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -553,7 +553,11 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (showLoading) await handleError(error, "运行记录加载失败");
|
if (showLoading) await handleError(error, "运行记录加载失败");
|
||||||
} finally {
|
} finally {
|
||||||
if (showLoading) set({ runsLoading: false });
|
// 如果用户已切换到另一个调度方案,不能让旧请求结束时覆盖新方案的
|
||||||
|
// 加载状态;新方案会由自己的请求负责关闭 loading。
|
||||||
|
if (showLoading && get().schedule?.schedule_id === scheduleId) {
|
||||||
|
set({ runsLoading: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import UTC
|
from datetime import UTC, datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
@@ -66,6 +66,12 @@ class CronScheduler:
|
|||||||
self._on_trigger = on_trigger
|
self._on_trigger = on_trigger
|
||||||
_ACTIVE_TRIGGER = on_trigger
|
_ACTIVE_TRIGGER = on_trigger
|
||||||
self._sync_task: asyncio.Task[None] | None = None
|
self._sync_task: asyncio.Task[None] | None = None
|
||||||
|
# 仅在调度配置实际变化时才重置 APScheduler job。若每 5 秒都
|
||||||
|
# reschedule,一旦恰好落在整分钟之后,就可能把本分钟的触发跳过。
|
||||||
|
self._job_signatures: dict[str, tuple[str, str, int]] = {}
|
||||||
|
# APScheduler 的定时唤醒异常时,由 5 秒同步循环兜底。键保存的是
|
||||||
|
# 已由兜底路径处理的“本地整分钟”,避免同一分钟重复提交。
|
||||||
|
self._fallback_dispatched_minutes: dict[str, datetime] = {}
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Start APScheduler and spawn the periodic sync loop."""
|
"""Start APScheduler and spawn the periodic sync loop."""
|
||||||
@@ -120,8 +126,9 @@ class CronScheduler:
|
|||||||
|
|
||||||
- adds jobs for enabled cron schedules present in MySQL
|
- adds jobs for enabled cron schedules present in MySQL
|
||||||
- removes jobs whose schedule has been disabled / soft-deleted
|
- removes jobs whose schedule has been disabled / soft-deleted
|
||||||
- updates ``Schedules.next_run_at`` from the next APScheduler tick
|
- updates ``Schedules.next_run_at`` from the Cron expression itself
|
||||||
"""
|
"""
|
||||||
|
due_schedule_ids: list[str] = []
|
||||||
async with session_scope(self.session_factory) as session:
|
async with session_scope(self.session_factory) as session:
|
||||||
schedules = list(
|
schedules = list(
|
||||||
(
|
(
|
||||||
@@ -142,14 +149,16 @@ class CronScheduler:
|
|||||||
job_id = f"schedule:{item.schedule_id}"
|
job_id = f"schedule:{item.schedule_id}"
|
||||||
active_job_ids.add(job_id)
|
active_job_ids.add(job_id)
|
||||||
expression = (item.cron_expression or "").strip()
|
expression = (item.cron_expression or "").strip()
|
||||||
|
max_instances = max(1, item.max_concurrency)
|
||||||
|
signature = (expression, item.timezone, max_instances)
|
||||||
trigger = CronTrigger.from_crontab(
|
trigger = CronTrigger.from_crontab(
|
||||||
expression,
|
expression,
|
||||||
timezone=ZoneInfo(item.timezone),
|
timezone=ZoneInfo(item.timezone),
|
||||||
)
|
)
|
||||||
if self.scheduler.get_job(job_id) is not None:
|
now = datetime.now(ZoneInfo(item.timezone))
|
||||||
self.scheduler.reschedule_job(job_id, trigger=trigger)
|
minute = now.replace(second=0, microsecond=0)
|
||||||
updated_count += 1
|
job_changed = False
|
||||||
else:
|
if self.scheduler.get_job(job_id) is None:
|
||||||
self.scheduler.add_job(
|
self.scheduler.add_job(
|
||||||
dispatch_persisted_cron,
|
dispatch_persisted_cron,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
@@ -157,12 +166,45 @@ class CronScheduler:
|
|||||||
id=job_id,
|
id=job_id,
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
max_instances=max(1, item.max_concurrency),
|
max_instances=max_instances,
|
||||||
misfire_grace_time=60,
|
misfire_grace_time=60,
|
||||||
)
|
)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
job = self.scheduler.get_job(job_id)
|
job_changed = True
|
||||||
item.next_run_at = naive_utc(job.next_run_time)
|
elif self._job_signatures.get(job_id) != signature:
|
||||||
|
# 服务重启后的首次同步也会走这里,确保持久化 job 与
|
||||||
|
# 数据库当前配置一致;之后配置不变时保留原定时点。
|
||||||
|
self.scheduler.reschedule_job(job_id, trigger=trigger)
|
||||||
|
self.scheduler.modify_job(
|
||||||
|
job_id,
|
||||||
|
max_instances=max_instances,
|
||||||
|
)
|
||||||
|
updated_count += 1
|
||||||
|
job_changed = True
|
||||||
|
self._job_signatures[job_id] = signature
|
||||||
|
# 以 CronTrigger 本身计算下次执行时间,不依赖 APScheduler 的
|
||||||
|
# 内部唤醒状态;页面展示的「下次执行」也因此保持准确。
|
||||||
|
item.next_run_at = naive_utc(
|
||||||
|
trigger.get_next_fire_time(None, now)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 首次观察或刚修改表达式时,从下一个整分钟才开始兜底,符合
|
||||||
|
# Cron 的常规语义,避免用户在本分钟中途保存后立刻多跑一次。
|
||||||
|
if job_changed or job_id not in self._fallback_dispatched_minutes:
|
||||||
|
self._fallback_dispatched_minutes[job_id] = minute
|
||||||
|
|
||||||
|
# 正常情况下 APScheduler 会在整分钟回调。实测其偶发漏唤醒时,
|
||||||
|
# 这里每 5 秒检查一次当前分钟是否命中表达式,并补发一次。
|
||||||
|
due_at = trigger.get_next_fire_time(
|
||||||
|
minute - timedelta(minutes=1),
|
||||||
|
minute,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
due_at == minute
|
||||||
|
and self._fallback_dispatched_minutes.get(job_id) != minute
|
||||||
|
):
|
||||||
|
self._fallback_dispatched_minutes[job_id] = minute
|
||||||
|
due_schedule_ids.append(item.schedule_id)
|
||||||
removed_count = 0
|
removed_count = 0
|
||||||
for job in self.scheduler.get_jobs():
|
for job in self.scheduler.get_jobs():
|
||||||
if (
|
if (
|
||||||
@@ -170,6 +212,8 @@ class CronScheduler:
|
|||||||
and job.id not in active_job_ids
|
and job.id not in active_job_ids
|
||||||
):
|
):
|
||||||
self.scheduler.remove_job(job.id)
|
self.scheduler.remove_job(job.id)
|
||||||
|
self._job_signatures.pop(job.id, None)
|
||||||
|
self._fallback_dispatched_minutes.pop(job.id, None)
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
if added_count or updated_count or removed_count:
|
if added_count or updated_count or removed_count:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -178,6 +222,11 @@ class CronScheduler:
|
|||||||
updated_count,
|
updated_count,
|
||||||
removed_count,
|
removed_count,
|
||||||
)
|
)
|
||||||
|
# 在数据库同步事务提交后再创建运行记录,避免两个会话同时读取调度方案
|
||||||
|
# 时发生不必要的锁等待。重复回调由运行记录的幂等键自动去重。
|
||||||
|
for schedule_id in due_schedule_ids:
|
||||||
|
logger.debug("cron fallback dispatch: schedule={}", schedule_id[-12:])
|
||||||
|
await self._on_trigger(schedule_id)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["CronScheduler"]
|
__all__ = ["CronScheduler"]
|
||||||
|
|||||||
Reference in New Issue
Block a user