feat: 新增隧道管理功能,前后端完整实现
- 新增统一 ApiResponse 类型定义,与 Rust 端响应结构对齐 - 新增 Rust 后端隧道数据库表、模型、命令和 API 接口 - 新增前端隧道类型定义、API 调用、卡片组件和表单弹窗 - 新增中英双语隧道相关国际化文案 - 更新服务器类型定义,引入共享的 ApiResponse
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
use sqlx::SqlitePool;
|
||||
use tauri::State;
|
||||
use tauri_helper::auto_collect_command;
|
||||
|
||||
use crate::models::{ApiResponse, Tunnel, TunnelPayload};
|
||||
|
||||
/// 允许的代理类型(对应 frpc 的 type)
|
||||
const PROXY_TYPES: [&str; 8] = [
|
||||
"tcp", "udp", "http", "https", "stcp", "xtcp", "sudp", "tcpmux",
|
||||
];
|
||||
|
||||
/// 允许的 Proxy Protocol 版本
|
||||
const PROXY_PROTOCOL_VERSIONS: [&str; 2] = ["v1", "v2"];
|
||||
|
||||
/// 允许的健康检查类型
|
||||
const HEALTH_CHECK_TYPES: [&str; 2] = ["tcp", "http"];
|
||||
|
||||
/// 统一查询字段,使用 concat! 保证结果为 'static str(sqlx 0.9 要求)
|
||||
macro_rules! select_fields {
|
||||
() => {
|
||||
"id, name, proxy_type, local_ip, local_port, remote_port, custom_domains, subdomain, locations, secret_key, use_encryption, use_compression, bandwidth_limit, proxy_protocol_version, health_check_type, health_check_timeout, health_check_max_failed, health_check_interval, health_check_path, load_balancer_group, load_balancer_group_key, remark, sort, pinned, enabled, created_at, updated_at"
|
||||
};
|
||||
}
|
||||
|
||||
/// 校验提交数据,返回错误信息(None 表示通过)
|
||||
fn validate(payload: &TunnelPayload) -> Option<String> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Some("隧道名称不能为空".to_string());
|
||||
}
|
||||
if payload.local_ip.trim().is_empty() {
|
||||
return Some("本地 IP 不能为空".to_string());
|
||||
}
|
||||
if payload.local_port < 1 || payload.local_port > 65535 {
|
||||
return Some("本地端口必须在 1 - 65535 之间".to_string());
|
||||
}
|
||||
if let Some(remote_port) = payload.remote_port {
|
||||
if !(1..=65535).contains(&remote_port) {
|
||||
return Some("远程端口必须在 1 - 65535 之间".to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 归一化代理类型,非法值回退为 tcp
|
||||
fn normalize_proxy_type(raw: &str) -> String {
|
||||
let value = raw.trim().to_ascii_lowercase();
|
||||
if PROXY_TYPES.contains(&value.as_str()) {
|
||||
value
|
||||
} else {
|
||||
"tcp".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 空字符串转 None,避免写入无意义的空串
|
||||
fn clean(value: &Option<String>) -> Option<String> {
|
||||
value
|
||||
.as_ref()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// 归一化枚举值:合法则返回清理后的值,否则返回 None
|
||||
fn normalize_choice(value: &Option<String>, allowed: &[&str]) -> Option<String> {
|
||||
clean(value)
|
||||
.map(|v| v.to_ascii_lowercase())
|
||||
.filter(|v| allowed.contains(&v.as_str()))
|
||||
}
|
||||
|
||||
/// 仅保留正整数,非法值(<= 0)返回 None
|
||||
fn positive(value: Option<i32>) -> Option<i32> {
|
||||
value.filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
async fn fetch_all(pool: &SqlitePool) -> Result<Vec<Tunnel>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Tunnel>(concat!(
|
||||
"SELECT ",
|
||||
select_fields!(),
|
||||
" FROM tunnels ORDER BY pinned DESC, sort ASC, id ASC"
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_by_id(pool: &SqlitePool, id: i64) -> Result<Tunnel, sqlx::Error> {
|
||||
sqlx::query_as::<_, Tunnel>(concat!(
|
||||
"SELECT ",
|
||||
select_fields!(),
|
||||
" FROM tunnels WHERE id = ?"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 获取隧道列表
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn get_tunnel_list(
|
||||
state: State<'_, SqlitePool>,
|
||||
) -> Result<ApiResponse<Vec<Tunnel>>, String> {
|
||||
Ok(match fetch_all(&state).await {
|
||||
Ok(list) => ApiResponse::success(list),
|
||||
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 新增隧道
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn create_tunnel(
|
||||
state: State<'_, SqlitePool>,
|
||||
payload: TunnelPayload,
|
||||
) -> Result<ApiResponse<Tunnel>, String> {
|
||||
if let Some(msg) = validate(&payload) {
|
||||
return Ok(ApiResponse::error(400, &msg));
|
||||
}
|
||||
|
||||
Ok(match insert_tunnel(&state, &payload).await {
|
||||
Ok(tunnel) => ApiResponse::success(tunnel),
|
||||
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn insert_tunnel(pool: &SqlitePool, payload: &TunnelPayload) -> Result<Tunnel, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO tunnels (name, proxy_type, local_ip, local_port, remote_port, custom_domains, \
|
||||
subdomain, locations, secret_key, use_encryption, use_compression, bandwidth_limit, \
|
||||
proxy_protocol_version, health_check_type, health_check_timeout, health_check_max_failed, \
|
||||
health_check_interval, health_check_path, load_balancer_group, load_balancer_group_key, \
|
||||
remark, sort, pinned, enabled) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(payload.name.trim())
|
||||
.bind(normalize_proxy_type(&payload.proxy_type))
|
||||
.bind(payload.local_ip.trim())
|
||||
.bind(payload.local_port)
|
||||
.bind(payload.remote_port)
|
||||
.bind(clean(&payload.custom_domains))
|
||||
.bind(clean(&payload.subdomain))
|
||||
.bind(clean(&payload.locations))
|
||||
.bind(clean(&payload.secret_key))
|
||||
.bind(payload.use_encryption)
|
||||
.bind(payload.use_compression)
|
||||
.bind(clean(&payload.bandwidth_limit))
|
||||
.bind(normalize_choice(
|
||||
&payload.proxy_protocol_version,
|
||||
&PROXY_PROTOCOL_VERSIONS,
|
||||
))
|
||||
.bind(normalize_choice(
|
||||
&payload.health_check_type,
|
||||
&HEALTH_CHECK_TYPES,
|
||||
))
|
||||
.bind(positive(payload.health_check_timeout))
|
||||
.bind(positive(payload.health_check_max_failed))
|
||||
.bind(positive(payload.health_check_interval))
|
||||
.bind(clean(&payload.health_check_path))
|
||||
.bind(clean(&payload.load_balancer_group))
|
||||
.bind(clean(&payload.load_balancer_group_key))
|
||||
.bind(clean(&payload.remark))
|
||||
.bind(payload.sort)
|
||||
.bind(payload.pinned)
|
||||
.bind(payload.enabled)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let id = result.last_insert_rowid();
|
||||
fetch_by_id(pool, id).await
|
||||
}
|
||||
|
||||
/// 编辑隧道
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn update_tunnel(
|
||||
state: State<'_, SqlitePool>,
|
||||
payload: TunnelPayload,
|
||||
) -> Result<ApiResponse<Tunnel>, String> {
|
||||
let Some(id) = payload.id else {
|
||||
return Ok(ApiResponse::error(400, "缺少隧道 ID"));
|
||||
};
|
||||
if let Some(msg) = validate(&payload) {
|
||||
return Ok(ApiResponse::error(400, &msg));
|
||||
}
|
||||
|
||||
Ok(match update_tunnel_row(&state, id, &payload).await {
|
||||
Ok(Some(tunnel)) => ApiResponse::success(tunnel),
|
||||
Ok(None) => ApiResponse::error(404, "隧道不存在"),
|
||||
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_tunnel_row(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
payload: &TunnelPayload,
|
||||
) -> Result<Option<Tunnel>, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE tunnels SET name = ?, proxy_type = ?, local_ip = ?, local_port = ?, remote_port = ?, \
|
||||
custom_domains = ?, subdomain = ?, locations = ?, secret_key = ?, use_encryption = ?, \
|
||||
use_compression = ?, bandwidth_limit = ?, proxy_protocol_version = ?, health_check_type = ?, \
|
||||
health_check_timeout = ?, health_check_max_failed = ?, health_check_interval = ?, \
|
||||
health_check_path = ?, load_balancer_group = ?, load_balancer_group_key = ?, remark = ?, \
|
||||
sort = ?, pinned = ?, enabled = ?, updated_at = datetime('now', 'localtime') \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(payload.name.trim())
|
||||
.bind(normalize_proxy_type(&payload.proxy_type))
|
||||
.bind(payload.local_ip.trim())
|
||||
.bind(payload.local_port)
|
||||
.bind(payload.remote_port)
|
||||
.bind(clean(&payload.custom_domains))
|
||||
.bind(clean(&payload.subdomain))
|
||||
.bind(clean(&payload.locations))
|
||||
.bind(clean(&payload.secret_key))
|
||||
.bind(payload.use_encryption)
|
||||
.bind(payload.use_compression)
|
||||
.bind(clean(&payload.bandwidth_limit))
|
||||
.bind(normalize_choice(
|
||||
&payload.proxy_protocol_version,
|
||||
&PROXY_PROTOCOL_VERSIONS,
|
||||
))
|
||||
.bind(normalize_choice(
|
||||
&payload.health_check_type,
|
||||
&HEALTH_CHECK_TYPES,
|
||||
))
|
||||
.bind(positive(payload.health_check_timeout))
|
||||
.bind(positive(payload.health_check_max_failed))
|
||||
.bind(positive(payload.health_check_interval))
|
||||
.bind(clean(&payload.health_check_path))
|
||||
.bind(clean(&payload.load_balancer_group))
|
||||
.bind(clean(&payload.load_balancer_group_key))
|
||||
.bind(clean(&payload.remark))
|
||||
.bind(payload.sort)
|
||||
.bind(payload.pinned)
|
||||
.bind(payload.enabled)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(fetch_by_id(pool, id).await?))
|
||||
}
|
||||
|
||||
/// 删除隧道
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn delete_tunnel(
|
||||
state: State<'_, SqlitePool>,
|
||||
id: i64,
|
||||
) -> Result<ApiResponse<()>, String> {
|
||||
Ok(
|
||||
match sqlx::query("DELETE FROM tunnels WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.rows_affected() == 0 => ApiResponse::error(404, "隧道不存在"),
|
||||
Ok(_) => ApiResponse::success_empty(),
|
||||
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 启用 / 禁用隧道
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn set_tunnel_enabled(
|
||||
state: State<'_, SqlitePool>,
|
||||
id: i64,
|
||||
enabled: bool,
|
||||
) -> Result<ApiResponse<Tunnel>, String> {
|
||||
Ok(match toggle_enabled(&state, id, enabled).await {
|
||||
Ok(Some(tunnel)) => ApiResponse::success(tunnel),
|
||||
Ok(None) => ApiResponse::error(404, "隧道不存在"),
|
||||
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn toggle_enabled(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
enabled: bool,
|
||||
) -> Result<Option<Tunnel>, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE tunnels SET enabled = ?, updated_at = datetime('now', 'localtime') WHERE id = ?",
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(fetch_by_id(pool, id).await?))
|
||||
}
|
||||
|
||||
/// 置顶 / 取消置顶隧道
|
||||
#[tauri::command]
|
||||
#[auto_collect_command]
|
||||
pub async fn set_tunnel_pinned(
|
||||
state: State<'_, SqlitePool>,
|
||||
id: i64,
|
||||
pinned: bool,
|
||||
) -> Result<ApiResponse<Tunnel>, String> {
|
||||
Ok(
|
||||
match sqlx::query(
|
||||
"UPDATE tunnels SET pinned = ?, updated_at = datetime('now', 'localtime') WHERE id = ?",
|
||||
)
|
||||
.bind(pinned)
|
||||
.bind(id)
|
||||
.execute(&*state)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.rows_affected() == 0 => ApiResponse::error(404, "隧道不存在"),
|
||||
Ok(_) => match fetch_by_id(&state, id).await {
|
||||
Ok(tunnel) => ApiResponse::success(tunnel),
|
||||
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||
},
|
||||
Err(e) => ApiResponse::error(500, &e.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ use crate::commands::server::{
|
||||
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
|
||||
update_server,
|
||||
};
|
||||
use crate::commands::tunnel::{
|
||||
create_tunnel, delete_tunnel, get_tunnel_list, set_tunnel_enabled, set_tunnel_pinned,
|
||||
update_tunnel,
|
||||
};
|
||||
use crate::services::db::init_pool;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
|
||||
@@ -95,3 +95,95 @@ pub struct ServerPayload {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 隧道代理配置(对应 SQLite 中的 tunnels 表)
|
||||
///
|
||||
/// 字段命名参考 frpc 的 proxy 配置,其中 `proxy_type` 序列化为 `type`,
|
||||
/// 与前端类型保持一致(`type` 是 Rust 关键字,故字段名做映射)。
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct Tunnel {
|
||||
pub id: i64,
|
||||
/// 隧道名称,对应 frpc 的 proxy name
|
||||
pub name: String,
|
||||
/// 代理类型,对应 frpc 的 type:tcp / udp / http / https / stcp / xtcp / sudp / tcpmux
|
||||
#[serde(rename = "type")]
|
||||
pub proxy_type: String,
|
||||
/// 本地 IP,对应 localIP
|
||||
pub local_ip: String,
|
||||
/// 本地端口,对应 localPort
|
||||
pub local_port: i32,
|
||||
/// 远程端口,对应 remotePort(tcp / udp 必填)
|
||||
pub remote_port: Option<i32>,
|
||||
/// 自定义域名,对应 customDomains(多个以英文逗号分隔)
|
||||
pub custom_domains: Option<String>,
|
||||
/// 子域名,对应 subdomain
|
||||
pub subdomain: Option<String>,
|
||||
/// URL 前缀,对应 locations(多个以英文逗号分隔)
|
||||
pub locations: Option<String>,
|
||||
/// 密钥,对应 secretKey(stcp / xtcp / sudp)
|
||||
pub secret_key: Option<String>,
|
||||
/// 传输加密,对应 transport.useEncryption
|
||||
pub use_encryption: bool,
|
||||
/// 传输压缩,对应 transport.useCompression
|
||||
pub use_compression: bool,
|
||||
/// 带宽限制,对应 transport.bandwidthLimit
|
||||
pub bandwidth_limit: Option<String>,
|
||||
/// Proxy Protocol 版本,对应 transport.proxyProtocolVersion
|
||||
pub proxy_protocol_version: Option<String>,
|
||||
/// 健康检查类型,对应 healthCheck.type:tcp / http
|
||||
pub health_check_type: Option<String>,
|
||||
/// 健康检查超时(秒),对应 healthCheck.timeoutSeconds
|
||||
pub health_check_timeout: Option<i32>,
|
||||
/// 健康检查最大失败次数,对应 healthCheck.maxFailed
|
||||
pub health_check_max_failed: Option<i32>,
|
||||
/// 健康检查间隔(秒),对应 healthCheck.intervalSeconds
|
||||
pub health_check_interval: Option<i32>,
|
||||
/// 健康检查路径,对应 healthCheck.path
|
||||
pub health_check_path: Option<String>,
|
||||
/// 负载均衡组,对应 loadBalancer.group
|
||||
pub load_balancer_group: Option<String>,
|
||||
/// 负载均衡组密钥,对应 loadBalancer.groupKey
|
||||
pub load_balancer_group_key: Option<String>,
|
||||
/// 备注
|
||||
pub remark: Option<String>,
|
||||
/// 排序(越小越靠前)
|
||||
pub sort: i32,
|
||||
/// 是否置顶
|
||||
pub pinned: bool,
|
||||
/// 是否启用(可同时启用多个)
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 新增 / 编辑隧道的提交数据
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TunnelPayload {
|
||||
/// 编辑时携带,新增时为空
|
||||
pub id: Option<i64>,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub proxy_type: String,
|
||||
pub local_ip: String,
|
||||
pub local_port: i32,
|
||||
pub remote_port: Option<i32>,
|
||||
pub custom_domains: Option<String>,
|
||||
pub subdomain: Option<String>,
|
||||
pub locations: Option<String>,
|
||||
pub secret_key: Option<String>,
|
||||
pub use_encryption: bool,
|
||||
pub use_compression: bool,
|
||||
pub bandwidth_limit: Option<String>,
|
||||
pub proxy_protocol_version: Option<String>,
|
||||
pub health_check_type: Option<String>,
|
||||
pub health_check_timeout: Option<i32>,
|
||||
pub health_check_max_failed: Option<i32>,
|
||||
pub health_check_interval: Option<i32>,
|
||||
pub health_check_path: Option<String>,
|
||||
pub load_balancer_group: Option<String>,
|
||||
pub load_balancer_group_key: Option<String>,
|
||||
pub remark: Option<String>,
|
||||
pub sort: i32,
|
||||
pub pinned: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -39,5 +39,42 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
// 隧道表(字段参考 frpc 的 proxy 配置)
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS tunnels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
proxy_type TEXT NOT NULL DEFAULT 'tcp',
|
||||
local_ip TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
local_port INTEGER NOT NULL,
|
||||
remote_port INTEGER,
|
||||
custom_domains TEXT,
|
||||
subdomain TEXT,
|
||||
locations TEXT,
|
||||
secret_key TEXT,
|
||||
use_encryption INTEGER NOT NULL DEFAULT 0,
|
||||
use_compression INTEGER NOT NULL DEFAULT 0,
|
||||
bandwidth_limit TEXT,
|
||||
proxy_protocol_version TEXT,
|
||||
health_check_type TEXT,
|
||||
health_check_timeout INTEGER,
|
||||
health_check_max_failed INTEGER,
|
||||
health_check_interval INTEGER,
|
||||
health_check_path TEXT,
|
||||
load_balancer_group TEXT,
|
||||
load_balancer_group_key TEXT,
|
||||
remark TEXT,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user