feat: 首页改版与 uv / 镜像配置收敛,精简任务数据结构
- 页头只保留品牌与操作图标,三栏改为 1:2:1、容器最宽 1320 - 任务卡片:状态标签移入标题行并缩小,隐藏任务标识,操作行贴内容底边 - uv 配置弹窗:删掉与首页重复的状态条,三段配置改成标签页 - 镜像源:弹窗去掉默认镜像汇总,首页卡片改为展示默认镜像与运行时注入的变量 - 后端:迁移合并为单一基线 0001_init,删掉未生效的 concurrency_policy / retry_*, 运行触发只剩 schedule / manual;contracts、前端生成类型、README / PLAN 同步
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cron TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
|
||||
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
|
||||
CHECK (concurrency_policy = 'skip'),
|
||||
retry_max_attempts INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (retry_max_attempts >= 0),
|
||||
retry_delay_secs INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (retry_delay_secs >= 0),
|
||||
action_type TEXT NOT NULL
|
||||
CHECK (action_type IN ('script', 'http', 'process')),
|
||||
action_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
|
||||
ON tasks(updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
|
||||
ON tasks(enabled);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
|
||||
ON tasks(action_type);
|
||||
@@ -0,0 +1,127 @@
|
||||
-- TaskPilot 基线结构:任务、运行记录、Python 镜像与 uv 设置。
|
||||
-- 这是全新起点(不再兼容旧库):任务不再持久化并发/重试策略,运行触发只有定时与手动。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
cron TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
|
||||
action_type TEXT NOT NULL
|
||||
CHECK (action_type IN ('script', 'http', 'process')),
|
||||
action_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
|
||||
ON tasks(updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
|
||||
ON tasks(enabled);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
|
||||
ON tasks(action_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL
|
||||
CHECK (trigger_type IN ('schedule', 'manual')),
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN (
|
||||
'queued', 'running', 'succeeded', 'failed',
|
||||
'timed_out', 'cancelled', 'skipped'
|
||||
)),
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
exit_code INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
|
||||
ON task_runs(task_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_status
|
||||
ON task_runs(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS python_mirrors (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
index_url TEXT NOT NULL,
|
||||
trusted_host TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 同一时间只允许一个默认镜像
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
|
||||
ON python_mirrors(is_default)
|
||||
WHERE is_default = 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
|
||||
ON python_mirrors(index_url);
|
||||
|
||||
INSERT OR IGNORE INTO python_mirrors
|
||||
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
||||
VALUES
|
||||
(
|
||||
'tsinghua',
|
||||
'清华 TUNA',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
NULL,
|
||||
1,
|
||||
1,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'aliyun',
|
||||
'阿里云',
|
||||
'https://mirrors.aliyun.com/pypi/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'ustc',
|
||||
'中科大',
|
||||
'https://pypi.mirrors.ustc.edu.cn/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'pypi',
|
||||
'PyPI 官方',
|
||||
'https://pypi.org/simple',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
);
|
||||
|
||||
-- uv 只认平台自带的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
|
||||
-- extra_env / mirror_url 分别存额外的 UV_* 环境变量和下载镜像。
|
||||
CREATE TABLE IF NOT EXISTS uv_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
|
||||
custom_path TEXT,
|
||||
extra_env TEXT NOT NULL DEFAULT '{}',
|
||||
mirror_url TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, extra_env, updated_at)
|
||||
VALUES (1, 'managed', NULL, '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
@@ -1,23 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL
|
||||
CHECK (trigger_type IN ('schedule', 'manual', 'retry')),
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN (
|
||||
'queued', 'running', 'succeeded', 'failed',
|
||||
'timed_out', 'cancelled', 'skipped'
|
||||
)),
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
exit_code INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
|
||||
ON task_runs(task_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_status
|
||||
ON task_runs(status);
|
||||
@@ -1,62 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS python_mirrors (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
index_url TEXT NOT NULL,
|
||||
trusted_host TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 同一时间只允许一个默认镜像
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
|
||||
ON python_mirrors(is_default)
|
||||
WHERE is_default = 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
|
||||
ON python_mirrors(index_url);
|
||||
|
||||
INSERT OR IGNORE INTO python_mirrors
|
||||
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
||||
VALUES
|
||||
(
|
||||
'tsinghua',
|
||||
'清华 TUNA',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
NULL,
|
||||
1,
|
||||
1,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'aliyun',
|
||||
'阿里云',
|
||||
'https://mirrors.aliyun.com/pypi/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'ustc',
|
||||
'中科大',
|
||||
'https://pypi.mirrors.ustc.edu.cn/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'pypi',
|
||||
'PyPI 官方',
|
||||
'https://pypi.org/simple',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- uv 可执行文件来自哪里:系统 PATH(system)、程序目录下的 uv 目录(managed),
|
||||
-- 或用户自己下载后指定的路径(custom)。只保留一行。
|
||||
CREATE TABLE IF NOT EXISTS uv_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
source TEXT NOT NULL DEFAULT 'system' CHECK (source IN ('system', 'managed', 'custom')),
|
||||
custom_path TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at)
|
||||
VALUES (1, 'system', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
@@ -1,21 +0,0 @@
|
||||
-- uv 只认平台自己的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
|
||||
CREATE TABLE uv_settings_v2 (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
|
||||
custom_path TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO uv_settings_v2 (id, source, custom_path, updated_at)
|
||||
SELECT id,
|
||||
CASE WHEN source = 'custom' THEN 'custom' ELSE 'managed' END,
|
||||
custom_path,
|
||||
updated_at
|
||||
FROM uv_settings;
|
||||
|
||||
DROP TABLE uv_settings;
|
||||
|
||||
ALTER TABLE uv_settings_v2 RENAME TO uv_settings;
|
||||
|
||||
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at)
|
||||
VALUES (1, 'managed', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
@@ -1,2 +0,0 @@
|
||||
-- uv 管理页允许额外注入 UV_* 环境变量(缓存目录、Python 安装目录、超时等)。
|
||||
ALTER TABLE uv_settings ADD COLUMN extra_env TEXT NOT NULL DEFAULT '{}';
|
||||
+3
-6
@@ -45,9 +45,8 @@ use crate::{
|
||||
state::AppState,
|
||||
tasks::{
|
||||
model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
|
||||
ScriptLanguage, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
|
||||
TaskStatsResponse, UpdateTaskRequest,
|
||||
CreateTaskRequest, HttpMethod, ScheduledTaskResponse, ScriptLanguage, TaskAction,
|
||||
TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse, UpdateTaskRequest,
|
||||
},
|
||||
routes::{
|
||||
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
||||
@@ -194,7 +193,7 @@ pub async fn create_python_venv(
|
||||
tag = "system",
|
||||
params(ListDirectoriesQuery),
|
||||
responses(
|
||||
(status = 200, description = "Subdirectories of the requested path, or the drive list", body = DirectoryListingResponse),
|
||||
(status = 200, description = "Subdirectories of the requested path (plus files when `include_files=true`), or the drive list", body = DirectoryListingResponse),
|
||||
(status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
@@ -336,8 +335,6 @@ pub async fn list_schedule(
|
||||
TaskActionType,
|
||||
ScriptLanguage,
|
||||
HttpMethod,
|
||||
ConcurrencyPolicy,
|
||||
RetryPolicy,
|
||||
TaskAction,
|
||||
CreateTaskRequest,
|
||||
UpdateTaskRequest,
|
||||
|
||||
@@ -14,6 +14,9 @@ pub const MAX_ENTRIES: usize = 500;
|
||||
pub struct ListDirectoriesQuery {
|
||||
/// 绝对目录路径;留空表示列出盘符
|
||||
pub path: Option<String>,
|
||||
/// 是否一并返回文件(挑选可执行文件时用);默认只返回目录
|
||||
#[serde(default)]
|
||||
pub include_files: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
@@ -22,6 +25,8 @@ pub struct DirectoryEntry {
|
||||
pub name: String,
|
||||
/// 绝对路径,选中后回填到表单
|
||||
pub path: String,
|
||||
/// true 表示目录(可继续进入),false 表示文件(`include_files` 时才出现)
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
@@ -79,6 +84,7 @@ pub fn list_directories(
|
||||
DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display()))
|
||||
})?;
|
||||
|
||||
let include_files = query.include_files.unwrap_or(false);
|
||||
let mut entries = Vec::new();
|
||||
let mut truncated = false;
|
||||
for entry in reader.flatten() {
|
||||
@@ -87,19 +93,26 @@ pub fn list_directories(
|
||||
.file_type()
|
||||
.map(|kind| kind.is_dir())
|
||||
.unwrap_or_else(|_| entry_path.is_dir());
|
||||
if !is_directory {
|
||||
if !is_directory && !include_files {
|
||||
continue;
|
||||
}
|
||||
entries.push(DirectoryEntry {
|
||||
name: entry.file_name().to_string_lossy().into_owned(),
|
||||
path: entry_path.to_string_lossy().into_owned(),
|
||||
is_dir: is_directory,
|
||||
});
|
||||
if entries.len() >= MAX_ENTRIES {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
entries.sort_by_key(|entry| entry.name.to_lowercase());
|
||||
// 目录在前、文件在后,各自按名称排序:挑选文件时不用在一堆文件里找文件夹。
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.is_dir
|
||||
.cmp(&left.is_dir)
|
||||
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(DirectoryListingResponse {
|
||||
path: Some(directory.to_string_lossy().into_owned()),
|
||||
@@ -126,6 +139,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
|
||||
DirectoryEntry {
|
||||
name: format!("{letter}:\\"),
|
||||
path: format!("{letter}:\\"),
|
||||
is_dir: true,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -136,6 +150,7 @@ fn logical_drives() -> Vec<DirectoryEntry> {
|
||||
vec![DirectoryEntry {
|
||||
name: "/".to_owned(),
|
||||
path: "/".to_owned(),
|
||||
is_dir: true,
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -146,6 +161,14 @@ mod tests {
|
||||
fn query(path: Option<&str>) -> ListDirectoriesQuery {
|
||||
ListDirectoriesQuery {
|
||||
path: path.map(str::to_owned),
|
||||
include_files: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn query_with_files(path: Option<&str>) -> ListDirectoriesQuery {
|
||||
ListDirectoriesQuery {
|
||||
path: path.map(str::to_owned),
|
||||
include_files: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +187,7 @@ mod tests {
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec!["Alpha", "zeta"]);
|
||||
assert!(listing.entries.iter().all(|entry| entry.is_dir));
|
||||
assert_eq!(
|
||||
listing.parent.as_deref(),
|
||||
Some(temp.path().parent().unwrap().to_str().unwrap())
|
||||
@@ -171,6 +195,26 @@ mod tests {
|
||||
assert!(!listing.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_files_when_requested_after_directories() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
fs::create_dir(temp.path().join("bin")).unwrap();
|
||||
fs::write(temp.path().join("zeta.exe"), "x").unwrap();
|
||||
fs::write(temp.path().join("Alpha.txt"), "x").unwrap();
|
||||
|
||||
let listing =
|
||||
list_directories(&query_with_files(Some(temp.path().to_str().unwrap()))).unwrap();
|
||||
|
||||
let names = listing
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec!["bin", "Alpha.txt", "zeta.exe"]);
|
||||
assert!(listing.entries[0].is_dir);
|
||||
assert!(listing.entries[1..].iter().all(|entry| !entry.is_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_absolute_paths_for_entries() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -7,7 +7,6 @@ use utoipa::{IntoParams, ToSchema};
|
||||
pub enum RunTrigger {
|
||||
Schedule,
|
||||
Manual,
|
||||
Retry,
|
||||
}
|
||||
|
||||
impl RunTrigger {
|
||||
@@ -15,7 +14,6 @@ impl RunTrigger {
|
||||
match self {
|
||||
Self::Schedule => "schedule",
|
||||
Self::Manual => "manual",
|
||||
Self::Retry => "retry",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,6 @@ fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
|
||||
match value {
|
||||
"schedule" => Ok(RunTrigger::Schedule),
|
||||
"manual" => Ok(RunTrigger::Manual),
|
||||
"retry" => Ok(RunTrigger::Retry),
|
||||
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ fn lock_next_runs(
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, ScriptLanguage, TaskAction};
|
||||
use crate::tasks::model::{ScriptLanguage, TaskAction};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -206,12 +206,11 @@ mod tests {
|
||||
TaskResponse {
|
||||
id: "schedule_test".to_owned(),
|
||||
name: "Schedule test".to_owned(),
|
||||
description: String::new(),
|
||||
cron: cron.to_owned(),
|
||||
timezone: timezone.to_owned(),
|
||||
enabled: true,
|
||||
timeout_secs: 30,
|
||||
concurrency_policy: ConcurrencyPolicy::Skip,
|
||||
retry_policy: RetryPolicy::default(),
|
||||
action: TaskAction::Script {
|
||||
working_dir: ".".to_owned(),
|
||||
venv: None,
|
||||
|
||||
@@ -84,22 +84,6 @@ pub enum HttpMethod {
|
||||
Post,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConcurrencyPolicy {
|
||||
#[default]
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RetryPolicy {
|
||||
#[serde(default)]
|
||||
pub max_attempts: u32,
|
||||
#[serde(default)]
|
||||
pub delay_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum TaskAction {
|
||||
@@ -176,6 +160,9 @@ const fn default_timeout_secs() -> u64 {
|
||||
pub struct CreateTaskRequest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 一句话说明,展示在任务卡片和详情里
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub cron: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
@@ -183,10 +170,6 @@ pub struct CreateTaskRequest {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_timeout_secs")]
|
||||
pub timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
#[serde(default)]
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
}
|
||||
|
||||
@@ -194,6 +177,9 @@ pub struct CreateTaskRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateTaskRequest {
|
||||
pub name: String,
|
||||
/// 一句话说明,展示在任务卡片和详情里
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub cron: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
@@ -201,10 +187,6 @@ pub struct UpdateTaskRequest {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_timeout_secs")]
|
||||
pub timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
#[serde(default)]
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
}
|
||||
|
||||
@@ -212,12 +194,12 @@ pub struct UpdateTaskRequest {
|
||||
pub struct TaskResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 一句话说明
|
||||
pub description: String,
|
||||
pub cron: String,
|
||||
pub timezone: String,
|
||||
pub enabled: bool,
|
||||
pub timeout_secs: u64,
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
@@ -4,15 +4,14 @@ use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
tasks::model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
|
||||
TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
CreateTaskRequest, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
|
||||
UpdateTaskRequest,
|
||||
},
|
||||
};
|
||||
|
||||
const TASK_COLUMNS: &str = "
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||
action_type, action_json, created_at, updated_at
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
@@ -59,13 +58,11 @@ pub struct ListFilters {
|
||||
struct TaskRow {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
cron: String,
|
||||
timezone: String,
|
||||
enabled: bool,
|
||||
timeout_secs: i64,
|
||||
concurrency_policy: String,
|
||||
retry_max_attempts: i64,
|
||||
retry_delay_secs: i64,
|
||||
action_type: String,
|
||||
action_json: String,
|
||||
created_at: DateTime<Utc>,
|
||||
@@ -76,13 +73,6 @@ impl TryFrom<TaskRow> for TaskResponse {
|
||||
type Error = ApiError;
|
||||
|
||||
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
|
||||
if row.concurrency_policy != "skip" {
|
||||
return Err(ApiError::internal(format!(
|
||||
"unknown concurrency policy: {}",
|
||||
row.concurrency_policy
|
||||
)));
|
||||
}
|
||||
|
||||
let action: TaskAction = serde_json::from_str(&row.action_json)?;
|
||||
if row.action_type != action.action_type().as_str() {
|
||||
return Err(ApiError::internal(format!(
|
||||
@@ -94,15 +84,11 @@ impl TryFrom<TaskRow> for TaskResponse {
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
cron: row.cron,
|
||||
timezone: row.timezone,
|
||||
enabled: row.enabled,
|
||||
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
|
||||
concurrency_policy: ConcurrencyPolicy::Skip,
|
||||
retry_policy: RetryPolicy {
|
||||
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
|
||||
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
|
||||
},
|
||||
action,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
@@ -115,20 +101,17 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
|
||||
let action_json = serde_json::to_string(&request.action)?;
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO tasks (
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||
action_type, action_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&request.id)
|
||||
.bind(&request.name)
|
||||
.bind(&request.description)
|
||||
.bind(&request.cron)
|
||||
.bind(&request.timezone)
|
||||
.bind(request.enabled)
|
||||
.bind(request.timeout_secs as i64)
|
||||
.bind("skip")
|
||||
.bind(i64::from(request.retry_policy.max_attempts))
|
||||
.bind(request.retry_policy.delay_secs as i64)
|
||||
.bind(request.action.action_type().as_str())
|
||||
.bind(action_json)
|
||||
.bind(now)
|
||||
@@ -149,12 +132,11 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
|
||||
Ok(TaskResponse {
|
||||
id: request.id.clone(),
|
||||
name: request.name.clone(),
|
||||
description: request.description.clone(),
|
||||
cron: request.cron.clone(),
|
||||
timezone: request.timezone.clone(),
|
||||
enabled: request.enabled,
|
||||
timeout_secs: request.timeout_secs,
|
||||
concurrency_policy: request.concurrency_policy,
|
||||
retry_policy: request.retry_policy.clone(),
|
||||
action: request.action.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -164,9 +146,8 @@ pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
|
||||
let row = sqlx::query_as::<_, TaskRow>(
|
||||
"SELECT
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||
action_type, action_json, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE id = ?",
|
||||
)
|
||||
@@ -186,19 +167,16 @@ pub async fn update(
|
||||
let action_json = serde_json::to_string(&request.action)?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE tasks SET
|
||||
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
|
||||
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
|
||||
name = ?, description = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
|
||||
action_type = ?, action_json = ?, updated_at = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&request.name)
|
||||
.bind(&request.description)
|
||||
.bind(&request.cron)
|
||||
.bind(&request.timezone)
|
||||
.bind(request.enabled)
|
||||
.bind(request.timeout_secs as i64)
|
||||
.bind("skip")
|
||||
.bind(i64::from(request.retry_policy.max_attempts))
|
||||
.bind(request.retry_policy.delay_secs as i64)
|
||||
.bind(request.action.action_type().as_str())
|
||||
.bind(action_json)
|
||||
.bind(now)
|
||||
@@ -269,9 +247,8 @@ pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskLis
|
||||
pub async fn list_enabled_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
|
||||
let rows = sqlx::query_as::<_, TaskRow>(
|
||||
"SELECT
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
id, name, description, cron, timezone, enabled, timeout_secs,
|
||||
action_type, action_json, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE enabled = 1
|
||||
ORDER BY updated_at DESC, id ASC",
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::repository::{self, ListFilters};
|
||||
|
||||
const MAX_TASK_ID_LEN: usize = 64;
|
||||
const MAX_TASK_NAME_LEN: usize = 120;
|
||||
const MAX_DESCRIPTION_LEN: usize = 500;
|
||||
const MAX_TIMEOUT_SECS: u64 = 86_400;
|
||||
const MAX_ARGS: usize = 256;
|
||||
const MAX_ARG_LEN: usize = 2_048;
|
||||
@@ -26,17 +27,18 @@ 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.description = request.description.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_description(&request.description)?;
|
||||
validate_common(
|
||||
&request.name,
|
||||
&request.cron,
|
||||
&request.timezone,
|
||||
request.timeout_secs,
|
||||
&request.retry_policy,
|
||||
)?;
|
||||
validate_action(&request.action)?;
|
||||
|
||||
@@ -56,16 +58,17 @@ pub async fn update(
|
||||
) -> ApiResult<TaskResponse> {
|
||||
validate_task_id(id)?;
|
||||
request.name = request.name.trim().to_owned();
|
||||
request.description = request.description.trim().to_owned();
|
||||
request.cron = request.cron.trim().to_owned();
|
||||
request.timezone = request.timezone.trim().to_owned();
|
||||
normalize_action(&mut request.action);
|
||||
|
||||
validate_description(&request.description)?;
|
||||
validate_common(
|
||||
&request.name,
|
||||
&request.cron,
|
||||
&request.timezone,
|
||||
request.timeout_secs,
|
||||
&request.retry_policy,
|
||||
)?;
|
||||
validate_action(&request.action)?;
|
||||
|
||||
@@ -131,12 +134,21 @@ fn validate_task_id(id: &str) -> ApiResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_description(description: &str) -> ApiResult<()> {
|
||||
if description.chars().count() > MAX_DESCRIPTION_LEN {
|
||||
return Err(crate::error::ApiError::validation(
|
||||
"description",
|
||||
format!("任务说明不能超过 {MAX_DESCRIPTION_LEN} 个字符"),
|
||||
));
|
||||
}
|
||||
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(
|
||||
@@ -150,18 +162,6 @@ fn validate_common(
|
||||
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 时区名称")
|
||||
@@ -371,7 +371,6 @@ mod tests {
|
||||
"0 0 2 * * *",
|
||||
"Asia/Shanghai",
|
||||
300,
|
||||
&RetryPolicy::default(),
|
||||
)
|
||||
.expect("task fields should be valid");
|
||||
validate_action(&script_action()).expect("script action should be valid");
|
||||
@@ -384,7 +383,6 @@ mod tests {
|
||||
"*/0 * * * * *",
|
||||
"Asia/Shanghai",
|
||||
300,
|
||||
&RetryPolicy::default(),
|
||||
)
|
||||
.expect_err("invalid cron should fail");
|
||||
|
||||
|
||||
+192
-15
@@ -15,6 +15,7 @@ use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::SqlitePool;
|
||||
use url::Url;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -32,11 +33,17 @@ const PYTHON_DIR_DEFAULT: &str = "python";
|
||||
const CACHE_DIR_DEFAULT: &str = "uv-cache";
|
||||
const PYTHON_INSTALL_DIR_ENV: &str = "UV_PYTHON_INSTALL_DIR";
|
||||
const CACHE_DIR_ENV: &str = "UV_CACHE_DIR";
|
||||
/// 没有本机 uv 时从这里下载(中科大维护的 GitHub Release 镜像)。
|
||||
const UV_MIRROR: &str = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease";
|
||||
const PYTHON_MIRROR_ENV: &str = "UV_PYTHON_INSTALL_MIRROR";
|
||||
/// 内置的 uv 下载镜像根(中科大维护的 GitHub Release 镜像);可在「镜像源」里改成别的镜像。
|
||||
const DEFAULT_MIRROR_BASE: &str = "https://mirrors.ustc.edu.cn/github-release";
|
||||
/// 镜像根下 uv 自己的发布目录。
|
||||
const UV_RELEASE_PATH: &str = "astral-sh/uv/LatestRelease";
|
||||
/// 镜像根下 python-build-standalone 的发布目录(uv 的 `UV_PYTHON_INSTALL_MIRROR`)。
|
||||
const PYTHON_RELEASE_PATH: &str = "astral-sh/python-build-standalone/releases/download";
|
||||
const MAX_UV_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024;
|
||||
const MAX_UV_ENV_ENTRIES: usize = 32;
|
||||
const MAX_UV_ENV_VALUE_CHARS: usize = 512;
|
||||
const MAX_MIRROR_URL_CHARS: usize = 2048;
|
||||
|
||||
/// uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
|
||||
/// 会随启动方式变化,不适合当调度器的隐式依赖。
|
||||
@@ -72,6 +79,8 @@ pub struct UvSettings {
|
||||
pub source: UvSource,
|
||||
pub custom_path: Option<String>,
|
||||
pub extra_env: BTreeMap<String, String>,
|
||||
/// uv 与 Python 解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认镜像
|
||||
pub mirror_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UvSettings {
|
||||
@@ -80,6 +89,7 @@ impl Default for UvSettings {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env: BTreeMap::new(),
|
||||
mirror_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,8 +109,15 @@ impl UvSettings {
|
||||
|
||||
/// 注入给 uv 的环境变量:两个路径变量没配时用相对默认值(`python` / `uv-cache`),
|
||||
/// 注入前统一解析成绝对路径——uv 的 cwd 会变,相对路径不能直接交给它。
|
||||
/// 解释器的下载镜像同理:用户没在 `UV_*` 里覆盖时,注入镜像源派生出的地址。
|
||||
pub fn env(&self) -> BTreeMap<String, String> {
|
||||
let mut env = self.extra_env.clone();
|
||||
if !env
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case(PYTHON_MIRROR_ENV))
|
||||
{
|
||||
env.insert(PYTHON_MIRROR_ENV.to_owned(), self.python_mirror_url());
|
||||
}
|
||||
for (key, default) in UV_PATH_DEFAULTS {
|
||||
let value = env
|
||||
.get(key)
|
||||
@@ -114,6 +131,27 @@ impl UvSettings {
|
||||
env
|
||||
}
|
||||
|
||||
/// uv 与 Python 解释器的下载镜像根(去掉结尾斜杠);没配就用内置默认。
|
||||
pub fn mirror_base(&self) -> String {
|
||||
self.mirror_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MIRROR_BASE)
|
||||
.trim_end_matches('/')
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// uv 自己那个压缩包的下载地址前缀。
|
||||
pub fn uv_download_base(&self) -> String {
|
||||
format!("{}/{}", self.mirror_base(), UV_RELEASE_PATH)
|
||||
}
|
||||
|
||||
/// `UV_PYTHON_INSTALL_MIRROR`:uv 下载 Python 解释器的地址前缀。
|
||||
pub fn python_mirror_url(&self) -> String {
|
||||
format!("{}/{}", self.mirror_base(), PYTHON_RELEASE_PATH)
|
||||
}
|
||||
|
||||
/// uv 下载 Python 的目录(解析后的绝对路径)。
|
||||
pub fn python_install_dir(&self) -> PathBuf {
|
||||
let value = self
|
||||
@@ -244,13 +282,13 @@ fn install_archive(archive: &[u8], managed: &Path) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// 从镜像下载当前架构的 uv,校验 sha256 后安装到程序目录的 `uv` 文件夹。
|
||||
async fn download_and_install() -> Result<String, String> {
|
||||
async fn download_and_install(base: &str) -> Result<String, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.user_agent("TaskPilot/0.1")
|
||||
.build()
|
||||
.map_err(|error| format!("无法创建下载客户端: {error}"))?;
|
||||
let url = format!("{UV_MIRROR}/{}", archive_name());
|
||||
let url = format!("{}/{}", base.trim_end_matches('/'), archive_name());
|
||||
|
||||
let checksum = client
|
||||
.get(format!("{url}.sha256"))
|
||||
@@ -312,6 +350,12 @@ pub struct UvSettingsResponse {
|
||||
pub python_dir: String,
|
||||
/// 实际生效的 uv 缓存目录(绝对路径)
|
||||
pub cache_dir: String,
|
||||
/// 当前使用的下载镜像根(GitHub Release 镜像);空值时返回内置默认
|
||||
pub mirror_url: String,
|
||||
/// uv 自身压缩包的下载地址前缀
|
||||
pub uv_download_url: String,
|
||||
/// Python 解释器的下载地址前缀(注入 `UV_PYTHON_INSTALL_MIRROR`)
|
||||
pub python_mirror_url: String,
|
||||
/// 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行
|
||||
pub env_defaults: BTreeMap<String, String>,
|
||||
/// 当前设置指向的 uv;不可用时 `version` 为空
|
||||
@@ -330,6 +374,9 @@ pub struct UpdateUvSettingsRequest {
|
||||
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
|
||||
#[serde(default)]
|
||||
pub extra_env: BTreeMap<String, String>,
|
||||
/// uv 本体与解释器的下载镜像根(GitHub Release 镜像);空 = 内置默认
|
||||
#[serde(default)]
|
||||
pub mirror_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。
|
||||
@@ -378,10 +425,24 @@ fn describe(settings: &UvSettings) -> UvSettingsResponse {
|
||||
managed_dir: managed_directory.to_string_lossy().into_owned(),
|
||||
python_dir: settings.python_install_dir().to_string_lossy().into_owned(),
|
||||
cache_dir: settings.cache_dir().to_string_lossy().into_owned(),
|
||||
env_defaults: UV_PATH_DEFAULTS
|
||||
.iter()
|
||||
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
||||
.collect(),
|
||||
mirror_url: settings.mirror_base(),
|
||||
uv_download_url: settings.uv_download_base(),
|
||||
python_mirror_url: settings
|
||||
.extra_env
|
||||
.get(PYTHON_MIRROR_ENV)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| settings.python_mirror_url()),
|
||||
env_defaults: {
|
||||
let mut defaults: BTreeMap<String, String> = UV_PATH_DEFAULTS
|
||||
.iter()
|
||||
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
||||
.collect();
|
||||
defaults.insert(
|
||||
PYTHON_MIRROR_ENV.to_owned(),
|
||||
settings.python_mirror_url(),
|
||||
);
|
||||
defaults
|
||||
},
|
||||
executable: current.map(|candidate| candidate.path.clone()),
|
||||
version: current.and_then(|candidate| candidate.version.clone()),
|
||||
candidates,
|
||||
@@ -460,40 +521,69 @@ fn validate(request: UpdateUvSettingsRequest) -> ApiResult<UvSettings> {
|
||||
source: request.source,
|
||||
custom_path,
|
||||
extra_env: validate_extra_env(&request.extra_env)?,
|
||||
mirror_url: validate_mirror_url(request.mirror_url)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_mirror_url(value: Option<String>) -> ApiResult<Option<String>> {
|
||||
let Some(raw) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.chars().count() > MAX_MIRROR_URL_CHARS {
|
||||
return Err(ApiError::validation("url", "镜像地址过长"));
|
||||
}
|
||||
let parsed = Url::parse(trimmed)
|
||||
.map_err(|_| ApiError::validation("url", "镜像地址不是有效的 URL"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(ApiError::validation("url", "镜像地址只支持 http 或 https"));
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
return Err(ApiError::validation("url", "镜像地址缺少主机名"));
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(ApiError::validation("url", "镜像地址不能包含用户名或密码"));
|
||||
}
|
||||
Ok(Some(trimmed.to_owned()))
|
||||
}
|
||||
|
||||
async fn load(pool: &SqlitePool) -> ApiResult<UvSettings> {
|
||||
let row = sqlx::query_as::<_, (String, Option<String>, String)>(
|
||||
"SELECT source, custom_path, extra_env FROM uv_settings WHERE id = 1",
|
||||
let row = sqlx::query_as::<_, (String, Option<String>, String, Option<String>)>(
|
||||
"SELECT source, custom_path, extra_env, mirror_url FROM uv_settings WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let Some((source, custom_path, extra_env)) = row else {
|
||||
let Some((source, custom_path, extra_env, mirror_url)) = row else {
|
||||
return Ok(UvSettings::default());
|
||||
};
|
||||
Ok(UvSettings {
|
||||
source: UvSource::parse(&source).unwrap_or(UvSource::Managed),
|
||||
custom_path,
|
||||
extra_env: serde_json::from_str(&extra_env).unwrap_or_default(),
|
||||
mirror_url,
|
||||
})
|
||||
}
|
||||
|
||||
async fn save(pool: &SqlitePool, settings: &UvSettings) -> ApiResult<()> {
|
||||
let extra_env = serde_json::to_string(&settings.extra_env)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO uv_settings (id, source, custom_path, extra_env, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?)
|
||||
"INSERT INTO uv_settings (id, source, custom_path, extra_env, mirror_url, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
custom_path = excluded.custom_path,
|
||||
extra_env = excluded.extra_env,
|
||||
mirror_url = excluded.mirror_url,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(settings.source.as_str())
|
||||
.bind(settings.custom_path.as_deref())
|
||||
.bind(extra_env)
|
||||
.bind(settings.mirror_url.as_deref())
|
||||
.bind(Utc::now())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
@@ -551,12 +641,12 @@ pub async fn update_uv_settings(
|
||||
)]
|
||||
pub async fn download_uv(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
|
||||
// shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。
|
||||
let version = download_and_install()
|
||||
let mut settings = load(&state.pool).await?;
|
||||
let version = download_and_install(&settings.uv_download_base())
|
||||
.await
|
||||
.map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?;
|
||||
tracing::info!(%version, "downloaded uv into the managed directory");
|
||||
|
||||
let mut settings = load(&state.pool).await?;
|
||||
// 下载的目的就是让它生效,所以直接切到程序目录 uv。
|
||||
settings.source = UvSource::Managed;
|
||||
save(&state.pool, &settings).await?;
|
||||
@@ -589,6 +679,7 @@ mod tests {
|
||||
source,
|
||||
custom_path: custom_path.map(str::to_owned),
|
||||
extra_env: BTreeMap::new(),
|
||||
mirror_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +689,7 @@ mod tests {
|
||||
source: UvSource::Custom,
|
||||
custom_path: Some(r"C:\tools\uv\uv.exe".to_owned()),
|
||||
extra_env: BTreeMap::new(),
|
||||
mirror_url: None,
|
||||
};
|
||||
assert_eq!(custom.program(), PathBuf::from(r"C:\tools\uv\uv.exe"));
|
||||
|
||||
@@ -605,6 +697,7 @@ mod tests {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env: BTreeMap::new(),
|
||||
mirror_url: None,
|
||||
};
|
||||
assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE));
|
||||
}
|
||||
@@ -638,6 +731,7 @@ mod tests {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
mirror_url: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -656,6 +750,7 @@ mod tests {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
mirror_url: None,
|
||||
});
|
||||
assert!(result.is_err(), "{name} should be rejected");
|
||||
}
|
||||
@@ -667,6 +762,7 @@ mod tests {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
mirror_url: None,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
@@ -693,6 +789,87 @@ mod tests {
|
||||
assert!(parse_sha256("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_uv_and_python_urls_from_the_mirror_base() {
|
||||
let settings = UvSettings {
|
||||
mirror_url: Some("https://mirror.example/github-release/".to_owned()),
|
||||
..UvSettings::default()
|
||||
};
|
||||
|
||||
assert_eq!(settings.mirror_base(), "https://mirror.example/github-release");
|
||||
assert_eq!(
|
||||
settings.uv_download_base(),
|
||||
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.python_mirror_url(),
|
||||
"https://mirror.example/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_builtin_mirror() {
|
||||
let settings = UvSettings::default();
|
||||
|
||||
assert_eq!(settings.mirror_base(), DEFAULT_MIRROR_BASE);
|
||||
assert_eq!(
|
||||
settings.uv_download_base(),
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.python_mirror_url(),
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_mirror_urls() {
|
||||
assert_eq!(validate_mirror_url(None).unwrap(), None);
|
||||
assert_eq!(validate_mirror_url(Some(" ".to_owned())).unwrap(), None);
|
||||
assert_eq!(
|
||||
validate_mirror_url(Some(
|
||||
"https://mirrors.ustc.edu.cn/github-release/".to_owned()
|
||||
))
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("https://mirrors.ustc.edu.cn/github-release")
|
||||
);
|
||||
assert!(validate_mirror_url(Some("ftp://mirror.example".to_owned())).is_err());
|
||||
assert!(validate_mirror_url(Some("not a url".to_owned())).is_err());
|
||||
assert!(validate_mirror_url(Some("https://user:[email protected]".to_owned())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_the_python_mirror_and_keeps_user_overrides() {
|
||||
let settings = UvSettings::default();
|
||||
assert_eq!(
|
||||
settings
|
||||
.env()
|
||||
.get("UV_PYTHON_INSTALL_MIRROR")
|
||||
.map(String::as_str),
|
||||
Some(
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
)
|
||||
);
|
||||
|
||||
let mut extra_env = BTreeMap::new();
|
||||
extra_env.insert(
|
||||
"UV_PYTHON_INSTALL_MIRROR".to_owned(),
|
||||
"https://my.mirror/python".to_owned(),
|
||||
);
|
||||
let custom = UvSettings {
|
||||
extra_env,
|
||||
..UvSettings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
custom
|
||||
.env()
|
||||
.get("UV_PYTHON_INSTALL_MIRROR")
|
||||
.map(String::as_str),
|
||||
Some("https://my.mirror/python")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_uv_from_the_archive() {
|
||||
use std::io::Write;
|
||||
|
||||
@@ -131,6 +131,30 @@ async fn directory_listing_endpoint_returns_subdirectories() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_listing_endpoint_includes_files_when_requested() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let tools = temp_dir.path().join("tools");
|
||||
fs::create_dir_all(tools.join("bin")).expect("bin directory should be created");
|
||||
fs::write(tools.join("uv.exe"), "x").expect("file should be written");
|
||||
|
||||
let uri = format!(
|
||||
"/api/v1/system/directories?path={}&include_files=true",
|
||||
encode_query_path(&tools)
|
||||
);
|
||||
let (status, body) = get_json(&app, &uri).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let entries = body["entries"]
|
||||
.as_array()
|
||||
.expect("entries should be an array");
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["name"], "bin");
|
||||
assert_eq!(entries[0]["is_dir"], true);
|
||||
assert_eq!(entries[1]["name"], "uv.exe");
|
||||
assert_eq!(entries[1]["is_dir"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_listing_endpoint_rejects_relative_paths() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
@@ -197,6 +197,81 @@ async fn uv_settings_mutations_require_token() {
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_mirror_is_saved_with_the_settings_and_drives_both_urls() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
body["mirror_url"],
|
||||
"https://mirrors.ustc.edu.cn/github-release"
|
||||
);
|
||||
assert_eq!(
|
||||
body["uv_download_url"],
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease"
|
||||
);
|
||||
assert_eq!(
|
||||
body["python_mirror_url"],
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
);
|
||||
// 解释器镜像作为默认环境变量出现在列表里,可以被显式覆盖
|
||||
assert_eq!(
|
||||
body["env_defaults"]["UV_PYTHON_INSTALL_MIRROR"],
|
||||
"https://mirrors.ustc.edu.cn/github-release/astral-sh/python-build-standalone/releases/download"
|
||||
);
|
||||
|
||||
// 非法镜像地址会被拒绝
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"source": "managed",
|
||||
"mirror_url": "ftp://mirror.example"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "url");
|
||||
|
||||
// 保存自定义镜像:两个下载地址都由它派生
|
||||
let (status, saved) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"source": "managed",
|
||||
"mirror_url": "https://mirror.example/github-release/"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved["mirror_url"], "https://mirror.example/github-release");
|
||||
assert_eq!(
|
||||
saved["uv_download_url"],
|
||||
"https://mirror.example/github-release/astral-sh/uv/LatestRelease"
|
||||
);
|
||||
|
||||
// 传 null 恢复内置默认
|
||||
let (status, restored) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({ "source": "managed", "mirror_url": null })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
restored["mirror_url"],
|
||||
"https://mirrors.ustc.edu.cn/github-release"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_download_requires_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
Reference in New Issue
Block a user