Files
task_pilot/backend/src/tasks/service.rs
T
CNWei 8e6b3b92b9 feat: 支持 uv 从 pyproject/requirements 创建虚拟环境
- 工作目录探测新增 pyproject.toml / requirements*.txt 扫描、uv 可用性与 can_create
- 新增 POST /api/v1/python/venv/create:已有虚拟环境优先复用;pyproject 走 uv sync,requirements 走 uv venv + uv pip install,并使用默认 PyPI 镜像
- 编辑器在缺少虚拟环境且具备依赖文件时显示「用 uv 创建环境」;没有 uv 或没有依赖文件时明确报错
- 解释器字段改为可选,留空由服务端默认 python.exe 并使用虚拟环境;Python 任务缺少虚拟环境/依赖文件时运行直接失败,不再回退系统解释器
- 补后端探测/创建测试、前端入口测试与 README
2026-11-03 22:57:17 +08:00

400 lines
12 KiB
Rust

use std::{collections::BTreeMap, str::FromStr};
use axum::http::{HeaderName, HeaderValue};
use chrono::Utc;
use chrono_tz::Tz;
use croner::Cron;
use sqlx::SqlitePool;
use url::Url;
use crate::python_env::validate_configured_venv;
use crate::{error::ApiResult, tasks::model::*};
use super::repository::{self, ListFilters};
const MAX_TASK_ID_LEN: usize = 64;
const MAX_TASK_NAME_LEN: usize = 120;
const MAX_TIMEOUT_SECS: u64 = 86_400;
const MAX_ARGS: usize = 256;
const MAX_ARG_LEN: usize = 2_048;
const MAX_ENV_ENTRIES: usize = 128;
const MAX_ENV_KEY_LEN: usize = 128;
const MAX_ENV_VALUE_LEN: usize = 8_192;
const MAX_HTTP_BODY_LEN: usize = 1_048_576;
const DEFAULT_PAGE_SIZE: u32 = 20;
const MAX_PAGE_SIZE: u32 = 100;
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
request.name = request.name.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
normalize_action(&mut request.action);
validate_task_id(&request.id)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
repository::create(pool, &request).await
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<TaskResponse> {
repository::get(pool, id)
.await?
.ok_or_else(|| crate::error::ApiError::not_found(format!("任务 {id} 不存在")))
}
pub async fn update(
pool: &SqlitePool,
id: &str,
mut request: UpdateTaskRequest,
) -> ApiResult<TaskResponse> {
validate_task_id(id)?;
request.name = request.name.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
normalize_action(&mut request.action);
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
repository::update(pool, id, &request).await
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
if repository::delete(pool, id).await? {
Ok(())
} else {
Err(crate::error::ApiError::not_found(format!(
"任务 {id} 不存在"
)))
}
}
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<TaskResponse> {
if !repository::set_enabled(pool, id, enabled).await? {
return Err(crate::error::ApiError::not_found(format!(
"任务 {id} 不存在"
)));
}
get(pool, id).await
}
pub async fn list(pool: &SqlitePool, query: ListTasksQuery) -> ApiResult<TaskListResponse> {
let page = query.page.unwrap_or(1).max(1);
let page_size = query
.page_size
.unwrap_or(DEFAULT_PAGE_SIZE)
.clamp(1, MAX_PAGE_SIZE);
let filters = ListFilters {
q: query
.q
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty()),
enabled: query.enabled,
action_type: query.action_type,
page,
page_size,
};
repository::list(pool, &filters).await
}
fn validate_task_id(id: &str) -> ApiResult<()> {
if id.is_empty() || id.len() > MAX_TASK_ID_LEN {
return Err(crate::error::ApiError::validation(
"id",
format!("任务标识长度必须在 1 到 {MAX_TASK_ID_LEN} 个字符之间"),
));
}
if !id
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
{
return Err(crate::error::ApiError::validation(
"id",
"任务标识只能包含 ASCII 字母、数字、下划线和连字符",
));
}
Ok(())
}
fn validate_common(
name: &str,
cron_expression: &str,
timezone: &str,
timeout_secs: u64,
retry_policy: &RetryPolicy,
) -> ApiResult<()> {
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
return Err(crate::error::ApiError::validation(
"name",
format!("任务名称长度必须在 1 到 {MAX_TASK_NAME_LEN} 个字符之间"),
));
}
if timeout_secs == 0 || timeout_secs > MAX_TIMEOUT_SECS {
return Err(crate::error::ApiError::validation(
"timeout_secs",
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
));
}
if retry_policy.max_attempts > 10 {
return Err(crate::error::ApiError::validation(
"retry_policy.max_attempts",
"最大重试次数不能超过 10",
));
}
if retry_policy.delay_secs > 3_600 {
return Err(crate::error::ApiError::validation(
"retry_policy.delay_secs",
"重试间隔不能超过 3600 秒",
));
}
Tz::from_str(timezone).map_err(|_| {
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
})?;
let cron = Cron::from_str(cron_expression).map_err(|error| {
tracing::debug!(error = %error, "invalid cron expression");
let message =
crate::cron::CronPreviewError::InvalidExpression(error.to_string()).user_message();
crate::error::ApiError::validation("cron", message)
})?;
cron.find_next_occurrence(&Utc::now(), false)
.map_err(|error| {
tracing::debug!(error = %error, "cron has no future occurrence");
crate::error::ApiError::validation("cron", "Cron 表达式没有可计算的未来执行时间")
})?;
Ok(())
}
fn normalize_action(action: &mut TaskAction) {
if let TaskAction::Script {
venv, interpreter, ..
} = action
{
if interpreter.trim().is_empty() {
*interpreter = "python.exe".to_owned();
}
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
*venv = None;
}
}
}
fn validate_action(action: &TaskAction) -> ApiResult<()> {
match action {
TaskAction::Script {
working_dir,
venv,
interpreter,
script,
args,
env,
} => {
validate_required("action.working_dir", working_dir)?;
if let Some(venv) = venv {
validate_required("action.venv", venv)?;
validate_configured_venv(working_dir, venv).map_err(|error| {
crate::error::ApiError::validation(
"action.venv",
format!("虚拟环境无效: {error}"),
)
})?;
}
validate_required("action.interpreter", interpreter)?;
validate_required("action.script", script)?;
validate_args(args)?;
validate_env(env)?;
}
TaskAction::Http {
url,
headers,
body,
expected_status,
..
} => {
let parsed_url = Url::parse(url).map_err(|error| {
crate::error::ApiError::validation("action.url", format!("URL 无效: {error}"))
})?;
if !matches!(parsed_url.scheme(), "http" | "https") || parsed_url.host_str().is_none() {
return Err(crate::error::ApiError::validation(
"action.url",
"URL 必须使用 http 或 https 协议并包含主机名",
));
}
validate_headers(headers)?;
if body
.as_ref()
.is_some_and(|value| value.len() > MAX_HTTP_BODY_LEN)
{
return Err(crate::error::ApiError::validation(
"action.body",
"HTTP Body 不能超过 1 MiB",
));
}
if expected_status
.iter()
.any(|status| !(100..=599).contains(status))
{
return Err(crate::error::ApiError::validation(
"action.expected_status",
"期望状态码必须在 100 到 599 之间",
));
}
}
TaskAction::Process {
executable,
working_dir,
args,
env,
} => {
validate_required("action.executable", executable)?;
if let Some(working_dir) = working_dir {
validate_required("action.working_dir", working_dir)?;
}
validate_args(args)?;
validate_env(env)?;
}
}
Ok(())
}
fn validate_required(field: &str, value: &str) -> ApiResult<()> {
if value.trim().is_empty() {
Err(crate::error::ApiError::validation(field, "该字段不能为空"))
} else {
Ok(())
}
}
fn validate_args(args: &[String]) -> ApiResult<()> {
if args.len() > MAX_ARGS {
return Err(crate::error::ApiError::validation(
"action.args",
format!("参数数量不能超过 {MAX_ARGS} 个"),
));
}
if args.iter().any(|argument| argument.len() > MAX_ARG_LEN) {
return Err(crate::error::ApiError::validation(
"action.args",
format!("单个参数不能超过 {MAX_ARG_LEN} 个字符"),
));
}
Ok(())
}
fn validate_env(env: &BTreeMap<String, String>) -> ApiResult<()> {
if env.len() > MAX_ENV_ENTRIES {
return Err(crate::error::ApiError::validation(
"action.env",
format!("环境变量数量不能超过 {MAX_ENV_ENTRIES} 个"),
));
}
for (key, value) in env {
if key.is_empty()
|| key.len() > MAX_ENV_KEY_LEN
|| key.contains(['=', '\0'])
|| value.len() > MAX_ENV_VALUE_LEN
|| value.contains('\0')
{
return Err(crate::error::ApiError::validation(
"action.env",
"环境变量名称或值无效",
));
}
}
Ok(())
}
fn validate_headers(headers: &BTreeMap<String, String>) -> ApiResult<()> {
for (name, value) in headers {
HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
crate::error::ApiError::validation("action.headers", "HTTP Header 名称无效")
})?;
HeaderValue::from_str(value).map_err(|_| {
crate::error::ApiError::validation("action.headers", "HTTP Header 值无效")
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use axum::response::IntoResponse;
use super::*;
fn script_action() -> TaskAction {
TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
venv: None,
interpreter: "python.exe".to_owned(),
script: "main.py".to_owned(),
args: vec!["--mode".to_owned(), "full".to_owned()],
env: BTreeMap::new(),
}
}
#[test]
fn accepts_valid_task_fields() {
validate_task_id("db_backup_daily").expect("task id should be valid");
validate_common(
"每日数据库备份",
"0 0 2 * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect("task fields should be valid");
validate_action(&script_action()).expect("script action should be valid");
}
#[test]
fn rejects_invalid_cron() {
let error = validate_common(
"Invalid cron",
"*/0 * * * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect_err("invalid cron should fail");
assert_eq!(error.into_response().status(), 400);
}
#[test]
fn rejects_empty_script_path() {
let action = TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
venv: None,
interpreter: "python.exe".to_owned(),
script: " ".to_owned(),
args: Vec::new(),
env: BTreeMap::new(),
};
validate_action(&action).expect_err("empty script path should fail");
}
}