fix: stabilize cron scheduling and run history

This commit is contained in:
Winnie
2026-08-17 18:55:31 +08:00
parent 23823325e6
commit ff700d8db4
16 changed files with 320 additions and 55 deletions
+11
View File
@@ -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
View File
@@ -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,
+22 -1
View File
@@ -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)
+13
View File
@@ -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,
+21 -8
View File
@@ -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)
+33 -1
View File
@@ -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,
+15
View File
@@ -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,
+9
View File
@@ -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:
+16
View File
@@ -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"
) )
+22
View File
@@ -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,
+28
View File
@@ -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,
+14 -9
View File
@@ -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"}
+4 -4
View File
@@ -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 });
}
} }
}, },
+58 -9
View File
@@ -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"]