feat: 首页改版与 uv / 镜像配置收敛,精简任务数据结构

- 页头只保留品牌与操作图标,三栏改为 1:2:1、容器最宽 1320
- 任务卡片:状态标签移入标题行并缩小,隐藏任务标识,操作行贴内容底边
- uv 配置弹窗:删掉与首页重复的状态条,三段配置改成标签页
- 镜像源:弹窗去掉默认镜像汇总,首页卡片改为展示默认镜像与运行时注入的变量
- 后端:迁移合并为单一基线 0001_init,删掉未生效的 concurrency_policy / retry_*,
  运行触发只剩 schedule / manual;contracts、前端生成类型、README / PLAN 同步
This commit is contained in:
2026-11-08 21:26:47 +08:00
parent 37479f48c7
commit 8fdc55e17d
40 changed files with 2421 additions and 1360 deletions
-28
View File
@@ -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);
+127
View File
@@ -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
View File
@@ -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,
+46 -2
View File
@@ -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();
-2
View File
@@ -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",
}
}
}
-1
View File
@@ -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}"))),
}
}
+2 -3
View File
@@ -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,
+8 -26
View File
@@ -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>,
+17 -40
View File
@@ -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",
+15 -17
View File
@@ -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
View File
@@ -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;
+24
View File
@@ -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;
+75
View File
@@ -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;