- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航 - 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方 - Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先 - 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除 - 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
216 lines
6.2 KiB
Rust
216 lines
6.2 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use sqlx::{FromRow, SqlitePool};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
error::{ApiError, ApiResult},
|
|
python_mirrors::{
|
|
model::{DefaultPythonMirror, PythonMirrorResponse},
|
|
service::ValidatedMirror,
|
|
},
|
|
};
|
|
|
|
#[derive(Debug, FromRow)]
|
|
struct MirrorRow {
|
|
id: String,
|
|
name: String,
|
|
index_url: String,
|
|
trusted_host: Option<String>,
|
|
enabled: bool,
|
|
is_default: bool,
|
|
created_at: DateTime<Utc>,
|
|
updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl From<MirrorRow> for PythonMirrorResponse {
|
|
fn from(row: MirrorRow) -> Self {
|
|
Self {
|
|
id: row.id,
|
|
name: row.name,
|
|
index_url: row.index_url,
|
|
trusted_host: row.trusted_host,
|
|
enabled: row.enabled,
|
|
is_default: row.is_default,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn list(pool: &SqlitePool) -> ApiResult<Vec<PythonMirrorResponse>> {
|
|
let rows = sqlx::query_as::<_, MirrorRow>(
|
|
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
|
|
FROM python_mirrors
|
|
ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC",
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows.into_iter().map(Into::into).collect())
|
|
}
|
|
|
|
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
|
|
let row = sqlx::query_as::<_, MirrorRow>(
|
|
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
|
|
FROM python_mirrors WHERE id = ?",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?;
|
|
Ok(row.into())
|
|
}
|
|
|
|
pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult<PythonMirrorResponse> {
|
|
if index_url_exists(pool, &input.index_url, None).await? {
|
|
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
|
|
}
|
|
|
|
let id = Uuid::new_v4().simple().to_string();
|
|
let now = Utc::now();
|
|
sqlx::query(
|
|
"INSERT INTO python_mirrors
|
|
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
|
|
)
|
|
.bind(&id)
|
|
.bind(&input.name)
|
|
.bind(&input.index_url)
|
|
.bind(input.trusted_host.as_deref())
|
|
.bind(input.enabled)
|
|
.bind(now)
|
|
.bind(now)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(map_mirror_error)?;
|
|
|
|
get(pool, &id).await
|
|
}
|
|
|
|
pub async fn update(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
input: ValidatedMirror,
|
|
) -> ApiResult<PythonMirrorResponse> {
|
|
if index_url_exists(pool, &input.index_url, Some(id)).await? {
|
|
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
|
|
}
|
|
|
|
let now = Utc::now();
|
|
let result = sqlx::query(
|
|
"UPDATE python_mirrors
|
|
SET name = ?, index_url = ?, trusted_host = ?, enabled = ?,
|
|
is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END,
|
|
updated_at = ?
|
|
WHERE id = ?",
|
|
)
|
|
.bind(&input.name)
|
|
.bind(&input.index_url)
|
|
.bind(input.trusted_host.as_deref())
|
|
.bind(input.enabled)
|
|
.bind(input.enabled)
|
|
.bind(now)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(map_mirror_error)?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
|
}
|
|
get(pool, id).await
|
|
}
|
|
|
|
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
|
|
let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。
|
|
pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
|
|
let now = Utc::now();
|
|
let mut transaction = pool.begin().await?;
|
|
|
|
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?")
|
|
.bind(id)
|
|
.fetch_optional(&mut *transaction)
|
|
.await?;
|
|
if exists.is_none() {
|
|
transaction.rollback().await?;
|
|
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
|
}
|
|
|
|
sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1")
|
|
.bind(now)
|
|
.execute(&mut *transaction)
|
|
.await?;
|
|
sqlx::query(
|
|
"UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?",
|
|
)
|
|
.bind(now)
|
|
.bind(id)
|
|
.execute(&mut *transaction)
|
|
.await?;
|
|
transaction.commit().await?;
|
|
|
|
get(pool, id).await
|
|
}
|
|
|
|
/// 执行器读取:只返回「启用且是默认」的镜像。
|
|
pub async fn default_enabled(pool: &SqlitePool) -> ApiResult<Option<DefaultPythonMirror>> {
|
|
let row = sqlx::query_as::<_, (String, String, Option<String>)>(
|
|
"SELECT name, index_url, trusted_host
|
|
FROM python_mirrors
|
|
WHERE enabled = 1 AND is_default = 1
|
|
LIMIT 1",
|
|
)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(
|
|
row.map(|(name, index_url, trusted_host)| DefaultPythonMirror {
|
|
name,
|
|
index_url,
|
|
trusted_host,
|
|
}),
|
|
)
|
|
}
|
|
|
|
async fn index_url_exists(
|
|
pool: &SqlitePool,
|
|
index_url: &str,
|
|
excluding_id: Option<&str>,
|
|
) -> ApiResult<bool> {
|
|
let existing: Option<(String,)> = match excluding_id {
|
|
Some(id) => {
|
|
sqlx::query_as(
|
|
"SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?",
|
|
)
|
|
.bind(index_url)
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
}
|
|
None => {
|
|
sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)")
|
|
.bind(index_url)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
}
|
|
};
|
|
Ok(existing.is_some())
|
|
}
|
|
|
|
fn map_mirror_error(error: sqlx::Error) -> ApiError {
|
|
if let sqlx::Error::Database(database_error) = &error
|
|
&& database_error.is_unique_violation()
|
|
{
|
|
return ApiError::mirror_conflict("该索引地址已经存在");
|
|
}
|
|
ApiError::from(error)
|
|
}
|