添加qiming-rcoder模块
This commit is contained in:
648
qiming-rcoder/crates/rcoder/src/handler/agent_cancel_handler.rs
Normal file
648
qiming-rcoder/crates/rcoder/src/handler/agent_cancel_handler.rs
Normal file
@@ -0,0 +1,648 @@
|
||||
//! Agent任务取消处理器
|
||||
//!
|
||||
//! 转发取消请求到容器内的 agent_runner 服务
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::router::AppState;
|
||||
use docker_manager::ContainerBasicInfo;
|
||||
use shared_types::{AppError, AgentCancelRequest, AgentCancelResponse, ComputerAgentCancelRequest, HttpResult};
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, extract_grpc_addr, get_locale_from_headers};
|
||||
|
||||
/// Computer Agent 取消任务的查询参数(仅用于测试)
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ComputerCancelQuery {
|
||||
/// 用户ID,用于标识特定的用户容器(ComputerAgentRunner模式,可与 pod_id 二选一)
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: Option<String>,
|
||||
/// 项目ID,必填,用于标识要取消的特定项目的 agent
|
||||
#[schema(example = "project456")]
|
||||
pub project_id: String,
|
||||
/// 会话ID,用于标识要取消的会话(可选)
|
||||
#[serde(default)]
|
||||
#[schema(example = "session789")]
|
||||
pub session_id: Option<String>,
|
||||
/// Pod ID,用于共享容器模式下的容器定位(可选)
|
||||
#[serde(default)]
|
||||
pub pod_id: Option<String>,
|
||||
/// 租户ID(可选)
|
||||
#[serde(default, deserialize_with = "shared_types::flexible_string::flexible_string")]
|
||||
pub tenant_id: Option<String>,
|
||||
/// 空间ID(可选)
|
||||
#[serde(default, deserialize_with = "shared_types::flexible_string::flexible_string")]
|
||||
pub space_id: Option<String>,
|
||||
/// 隔离类型(可选),如 "project", "tenant", "space"
|
||||
#[serde(default)]
|
||||
pub isolation_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 取消操作的标识符
|
||||
#[derive(Debug, Clone)]
|
||||
enum CancelIdentifier {
|
||||
/// RCoder 模式:使用 project_id
|
||||
Project(String),
|
||||
/// ComputerAgentRunner 模式:使用 user_id
|
||||
User(String),
|
||||
/// 共享容器模式:使用 pod_id
|
||||
Pod(String),
|
||||
}
|
||||
|
||||
/// 统一的容器查询函数 - 通过 DuckDB 查询
|
||||
async fn get_container_for_cancel_duckdb(
|
||||
state: &AppState,
|
||||
identifier: &CancelIdentifier,
|
||||
) -> Result<Option<ContainerBasicInfo>, AppError> {
|
||||
let identifier_display = match identifier {
|
||||
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
|
||||
CancelIdentifier::User(uid) => format!("user_id={}", uid),
|
||||
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
|
||||
};
|
||||
|
||||
info!(
|
||||
"🔍 [CANCEL_CONTAINER_DUCKDB] Looking up container: {}",
|
||||
identifier_display
|
||||
);
|
||||
|
||||
let container_info = match identifier {
|
||||
CancelIdentifier::Project(project_id) => {
|
||||
// RCoder 模式:直接通过 project_id 查询
|
||||
// ProjectAdapter.get() 内部会调用 get_container_for_project
|
||||
state
|
||||
.get_project(project_id)
|
||||
.and_then(|info| info.container().cloned())
|
||||
}
|
||||
CancelIdentifier::User(user_id) => {
|
||||
// ComputerAgentRunner 模式:通过 user_id 查询容器
|
||||
// 使用新添加的 get_container_by_user_id 方法
|
||||
state.projects.get_container_by_user_id(user_id)
|
||||
}
|
||||
CancelIdentifier::Pod(pod_id) => {
|
||||
// 共享容器模式:通过 pod_id 查询容器
|
||||
// 目前暂时使用 get_container_by_user_id 作为占位,后续需要实现 get_container_by_pod_id
|
||||
state.projects.get_container_by_pod_id(pod_id)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref info) = container_info {
|
||||
info!(
|
||||
"✅ [CANCEL_CONTAINER_DUCKDB] Container found: {}, container_id={}, service_url={}",
|
||||
identifier_display, info.container_id, info.service_url
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"ℹ️ [CANCEL_CONTAINER_DUCKDB] Container not found: {}, no need to cancel",
|
||||
identifier_display
|
||||
);
|
||||
}
|
||||
|
||||
Ok(container_info)
|
||||
}
|
||||
|
||||
/// 转发取消请求到容器内的 agent_runner 服务
|
||||
///
|
||||
/// 🎯 使用 gRPC CancelSession RPC 替代 HTTP 转发
|
||||
async fn forward_cancel_request_to_container_service(
|
||||
project_id: &str,
|
||||
session_id: Option<&str>,
|
||||
container_info: &ContainerBasicInfo,
|
||||
grpc_pool: &Arc<crate::grpc::GrpcChannelPool>,
|
||||
locale: &'static str,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
|
||||
let session_id_display = session_id
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "None".to_string());
|
||||
info!(
|
||||
"📤 [CANCEL_FORWARD] Forwarding cancel request to container (gRPC): project_id={}, session_id={}, container_id={}",
|
||||
project_id, session_id_display, container_info.container_id
|
||||
);
|
||||
|
||||
// 🎯 使用 gRPC 替代 HTTP
|
||||
// 从 service_url 提取 gRPC 地址
|
||||
let grpc_addr = extract_grpc_addr(&container_info.service_url)?;
|
||||
|
||||
info!(
|
||||
"📡 [CANCEL_FORWARD] Sending gRPC cancel request to: {}, session_id={}",
|
||||
grpc_addr, session_id_display
|
||||
);
|
||||
|
||||
// 构建 session_id(如果未提供则使用空字符串,由 Agent Runner 根据 project_id 查找)
|
||||
let session_id_str = session_id.unwrap_or("").to_string();
|
||||
let reason = "User requested cancellation".to_string();
|
||||
|
||||
// 调用 gRPC CancelSession
|
||||
match crate::grpc::grpc_cancel_session_with_pool(
|
||||
grpc_pool,
|
||||
&grpc_addr,
|
||||
session_id_str.clone(),
|
||||
reason,
|
||||
project_id.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(grpc_response) => {
|
||||
if grpc_response.success {
|
||||
info!(
|
||||
"✅ [CANCEL_FORWARD] gRPC cancel succeeded: session_id={}",
|
||||
session_id_str
|
||||
);
|
||||
Ok(HttpResult::success(AgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id_str,
|
||||
}))
|
||||
} else {
|
||||
let error_msg = grpc_response
|
||||
.message
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
error!("[CANCEL_FORWARD] gRPC cancelfailed: {}", error_msg);
|
||||
Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_CANCEL_FAILED,
|
||||
locale,
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("[CANCEL_FORWARD] gRPC call failed: {}", e);
|
||||
|
||||
// 检查特定的 gRPC 错误码并分类处理
|
||||
if let Some(status) = crate::grpc::extract_grpc_status(&e) {
|
||||
use tonic::Code;
|
||||
match status.code() {
|
||||
Code::NotFound => {
|
||||
// 会话或 Agent 不存在,返回成功(幂等设计)
|
||||
info!("[CANCEL_FORWARD] Session not found, cancel succeeded");
|
||||
return Ok(HttpResult::success(AgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id.unwrap_or("").to_string(),
|
||||
}));
|
||||
}
|
||||
Code::Unavailable => {
|
||||
// Agent Worker 不可用,需要判断是容器已销毁还是临时故障
|
||||
// 通过 Docker API 检查容器是否真的存在
|
||||
let container_exists = check_container_exists_by_info(container_info, rcoder_prefix, computer_prefix).await;
|
||||
|
||||
if !container_exists {
|
||||
// 容器已销毁,取消目标已达成(幂等设计)
|
||||
info!(
|
||||
"[CANCEL_FORWARD] container already destroyed, cancel request already completed"
|
||||
);
|
||||
return Ok(HttpResult::success(AgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id.unwrap_or("").to_string(),
|
||||
}));
|
||||
} else {
|
||||
// 容器存在但服务不可用(可能是临时故障),返回错误
|
||||
warn!(
|
||||
"[CANCEL_FORWARD] Agent Worker unavailable (container exists, may be temporary failure)"
|
||||
);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_SERVICE_UNAVAILABLE,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
other_code => {
|
||||
// 其他 gRPC 状态码
|
||||
error!("[CANCEL_FORWARD] gRPC error code: {:?}", other_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其他 gRPC 通信失败(网络错误等)
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查容器是否真实存在(通过 Docker API)
|
||||
///
|
||||
/// 用于区分 Unavailable 错误的原因:
|
||||
/// - 容器已销毁 → 返回 false(取消目标已达成)
|
||||
/// - 容器存在但服务不可用 → 返回 true(临时故障)
|
||||
///
|
||||
/// 使用容器名称而非 ID,因为容器重启后 ID 会变,但名称不变
|
||||
async fn check_container_exists_by_info(
|
||||
container_info: &ContainerBasicInfo,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) -> bool {
|
||||
match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(runtime) => {
|
||||
// 使用配置化的前缀,而不是硬编码的 ServiceType::container_prefix()
|
||||
|
||||
let query = if let Some(identifier) = container_info
|
||||
.container_name
|
||||
.strip_prefix(&format!("{}-", computer_prefix))
|
||||
{
|
||||
runtime
|
||||
.get_container_info_by_identifier(
|
||||
identifier,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
} else if let Some(identifier) = container_info
|
||||
.container_name
|
||||
.strip_prefix(&format!("{}-", rcoder_prefix))
|
||||
{
|
||||
runtime
|
||||
.get_container_info_by_identifier(identifier, &shared_types::ServiceType::RCoder)
|
||||
.await
|
||||
} else {
|
||||
return true;
|
||||
};
|
||||
|
||||
match query {
|
||||
Ok(Some(info)) => {
|
||||
debug!(
|
||||
"🔍 [CANCEL_FORWARD] Runtime container exists: name={}, id={}",
|
||||
info.container_name, info.container_id
|
||||
);
|
||||
true
|
||||
}
|
||||
Ok(None) => {
|
||||
info!(
|
||||
"🔍 [CANCEL_FORWARD] Runtime container not found (already destroyed): {}",
|
||||
container_info.container_name
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [CANCEL_FORWARD] Failed to query runtime container status: {}, conservatively assuming container exists",
|
||||
e
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 无法获取 runtime,保守地认为容器存在
|
||||
warn!(
|
||||
"[CANCEL_FORWARD] Failed to get runtime: {}, conservatively assuming container exists",
|
||||
e
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部核心处理函数 v2:处理会话取消请求(支持多种服务类型)
|
||||
///
|
||||
/// 使用 DuckDB 统一查询,支持 RCoder 和 ComputerAgentRunner 两种模式
|
||||
async fn handle_session_cancel_internal_v2(
|
||||
state: &AppState,
|
||||
identifier: CancelIdentifier,
|
||||
project_id: String, // 必填:传递给 agent_runner 的项目ID
|
||||
session_id: Option<String>, // 可选:会话ID
|
||||
locale: &'static str,
|
||||
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
|
||||
let session_id_display = session_id
|
||||
.as_deref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "None".to_string());
|
||||
|
||||
let identifier_display = match &identifier {
|
||||
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
|
||||
CancelIdentifier::User(uid) => format!("user_id={}", uid),
|
||||
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
|
||||
};
|
||||
|
||||
info!(
|
||||
"🛑 [CANCEL_FORWARD_V2] Received cancel task request: session_id={}, project_id={}, {}",
|
||||
session_id_display, project_id, identifier_display
|
||||
);
|
||||
|
||||
// 获取容器(不创建)
|
||||
let container_info = get_container_for_cancel_duckdb(state, &identifier).await?;
|
||||
|
||||
// 如果容器不存在,说明任务已经结束或从未启动,直接返回成功
|
||||
let Some(container_info) = container_info else {
|
||||
info!(
|
||||
"✅ [CANCEL_FORWARD_V2] Container not found, cancel target already achieved: {}",
|
||||
identifier_display
|
||||
);
|
||||
return Ok(HttpResult::success(AgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id.unwrap_or_else(|| "all".to_string()),
|
||||
}));
|
||||
};
|
||||
|
||||
// 转发取消请求到容器服务
|
||||
let result = forward_cancel_request_to_container_service(
|
||||
&project_id, // 使用传入的 project_id
|
||||
session_id.as_deref(),
|
||||
&container_info,
|
||||
&state.grpc_pool,
|
||||
locale,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
)
|
||||
.await;
|
||||
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"✅ [CANCEL_FORWARD_V2] Cancel request handled successfully: project_id={}, {}",
|
||||
project_id, identifier_display
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [CANCEL_FORWARD_V2] Cancel request handling failed: project_id={}, {}, error={}",
|
||||
project_id, identifier_display, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 处理agent任务取消请求
|
||||
///
|
||||
/// 转发取消请求到容器内的 agent_runner 服务(使用 gRPC)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/agent/session/cancel",
|
||||
request_body = AgentCancelRequest,
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功转发取消请求到容器",
|
||||
body = HttpResult<AgentCancelResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"session_id": "session456"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "INVALID_PARAMS",
|
||||
"message": "Invalid project_id or session_id"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "未找到对应的项目或会话",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "PROJECT_NOT_FOUND",
|
||||
"message": "Project or session not found"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "转发取消请求失败",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "CANCEL_FAILED",
|
||||
"message": "Failed to forward cancel request to container"
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "agent",
|
||||
operation_id = "agent_session_cancel",
|
||||
summary = "转发Agent任务取消请求(gRPC)",
|
||||
description = "将取消请求通过 gRPC 转发到容器内的 agent_runner 服务"
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn agent_session_cancel(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<AgentCancelRequest>,
|
||||
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
info!(
|
||||
"🚫 [CANCEL] Agent cancel request: project_id={}, session_id={:?}",
|
||||
project_id, request.session_id
|
||||
);
|
||||
|
||||
handle_session_cancel_internal_v2(
|
||||
&state,
|
||||
CancelIdentifier::Project(project_id.to_string()),
|
||||
project_id.to_string(),
|
||||
request.session_id,
|
||||
locale,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 处理 Computer Agent 任务取消请求
|
||||
///
|
||||
/// 转发取消请求到容器内的 agent_runner 服务(使用 gRPC)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/session/cancel",
|
||||
request_body = ComputerAgentCancelRequest,
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功转发取消请求到容器",
|
||||
body = HttpResult<AgentCancelResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"session_id": "session456"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "ERR_VALIDATION",
|
||||
"message": "user_id 或 pod_id is required"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "未找到对应的用户容器或会话",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "CONTAINER_NOT_FOUND",
|
||||
"message": "User container not found"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "转发取消请求失败",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "CANCEL_FAILED",
|
||||
"message": "Failed to forward cancel request to container"
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_agent_session_cancel",
|
||||
summary = "转发 Computer Agent 任务取消请求(支持 user_id)",
|
||||
description = "将 Computer Agent 取消请求通过 gRPC 转发到容器内的 agent_runner 服务,支持通过 user_id 或 pod_id 定位用户容器"
|
||||
)]
|
||||
#[instrument(skip(state), fields(user_id = ?request.user_id.as_ref().map(|s| s.as_str()), project_id = %request.project_id, pod_id = ?request.pod_id.as_ref().map(|s| s.as_str())))]
|
||||
pub async fn computer_agent_session_cancel(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentCancelRequest>,
|
||||
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 验证 user_id 或 pod_id 至少有一个
|
||||
let has_user_id = request.user_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
let has_pod_id = request.pod_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
if !has_user_id && !has_pod_id {
|
||||
error!("[COMPUTER_CANCEL] user_id or pod_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
// 验证 project_id 不为空
|
||||
if request.project_id.trim().is_empty() {
|
||||
error!("[COMPUTER_CANCEL] project_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"🚀 [COMPUTER_CANCEL] Starting to process cancel request: user_id={:?}, pod_id={:?}, project_id={}, session_id={:?}",
|
||||
request.user_id, request.pod_id, request.project_id, request.session_id
|
||||
);
|
||||
|
||||
// 使用 user_id 或 pod_id 来构建 CancelIdentifier
|
||||
let identifier = if has_user_id {
|
||||
CancelIdentifier::User(request.user_id.clone().unwrap())
|
||||
} else {
|
||||
CancelIdentifier::Pod(request.pod_id.clone().unwrap())
|
||||
};
|
||||
|
||||
handle_session_cancel_internal_v2(
|
||||
&state,
|
||||
identifier,
|
||||
request.project_id, // 必填的 project_id
|
||||
request.session_id,
|
||||
locale,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_computer_cancel_query_deserialization() {
|
||||
// 测试 ComputerCancelQuery 反序列化
|
||||
let query_json = json!({
|
||||
"user_id": "user_123",
|
||||
"project_id": "project_456",
|
||||
"session_id": "session_789"
|
||||
});
|
||||
|
||||
let query: ComputerCancelQuery = serde_json::from_value(query_json).unwrap();
|
||||
assert_eq!(query.user_id, Some("user_123".to_string()));
|
||||
assert_eq!(query.project_id, "project_456");
|
||||
assert_eq!(query.session_id, Some("session_789".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_computer_cancel_query_optional_session() {
|
||||
// 测试不带 session_id 的情况
|
||||
let query_json = json!({
|
||||
"user_id": "user_123",
|
||||
"project_id": "project_456"
|
||||
});
|
||||
|
||||
let query: ComputerCancelQuery = serde_json::from_value(query_json).unwrap();
|
||||
assert_eq!(query.user_id, Some("user_123".to_string()));
|
||||
assert_eq!(query.project_id, "project_456");
|
||||
assert_eq!(query.session_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_identifier_display() {
|
||||
// 测试 CancelIdentifier 的显示格式
|
||||
let project_id = CancelIdentifier::Project("test_project".to_string());
|
||||
let user_id = CancelIdentifier::User("test_user".to_string());
|
||||
let pod_id = CancelIdentifier::Pod("test_pod".to_string());
|
||||
|
||||
let display = match &project_id {
|
||||
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(display, "project_id=test_project");
|
||||
|
||||
let display = match &user_id {
|
||||
CancelIdentifier::User(uid) => format!("user_id={}", uid),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(display, "user_id=test_user");
|
||||
|
||||
let display = match &pod_id {
|
||||
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(display, "pod_id=test_pod");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
137
qiming-rcoder/crates/rcoder/src/handler/agent_status_handler.rs
Normal file
137
qiming-rcoder/crates/rcoder/src/handler/agent_status_handler.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use super::utils::{I18nPath, get_locale_from_headers};
|
||||
use crate::{AgentStatusResponse, AppError, HttpResult, router::AppState};
|
||||
|
||||
/// 查询Agent状态
|
||||
///
|
||||
/// 查询指定项目的Agent服务状态信息(保持原有接口兼容性)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/agent/status/{project_id}",
|
||||
params(
|
||||
("project_id" = String, Path, description = "项目ID", example = "test_project")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功获取Agent状态",
|
||||
body = HttpResult<AgentStatusResponse>,
|
||||
examples(
|
||||
("Agent存活" = (value = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "test_project",
|
||||
"is_alive": true,
|
||||
"session_id": "session123",
|
||||
"status": "Active",
|
||||
"last_activity": "2024-01-01T12:00:00Z",
|
||||
"created_at": "2024-01-01T10:00:00Z",
|
||||
"model_provider": {
|
||||
"id": "custom",
|
||||
"name": "custom",
|
||||
"api_protocol": "OpenAI",
|
||||
"default_model": "gpt-4"
|
||||
}
|
||||
},
|
||||
"error": null
|
||||
}))),
|
||||
("Agent不存活" = (value = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "test_project",
|
||||
"is_alive": false
|
||||
},
|
||||
"error": null
|
||||
})))
|
||||
)
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "INVALID_PARAMS",
|
||||
"message": "project_id cannot be empty"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "agent",
|
||||
operation_id = "agent_status",
|
||||
summary = "查询Agent状态",
|
||||
description = "查询指定项目的Agent服务状态信息。如果Agent在容器中存在且运行正常,返回完整的状态信息(包括会话ID、活动时间、模型配置等);如果Agent不存在,只返回project_id和is_alive=false。"
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn agent_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nPath(project_id): I18nPath<String>,
|
||||
) -> Result<HttpResult<AgentStatusResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let project_id = project_id.trim();
|
||||
|
||||
if project_id.is_empty() {
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"📊 [AGENT_STATUS] Received agent status query: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 从 DuckDB 存储中获取 Agent 信息
|
||||
if let Some(agent_info) = state.get_project(project_id) {
|
||||
let response = AgentStatusResponse {
|
||||
project_id: agent_info.project_id().to_string(),
|
||||
is_alive: true,
|
||||
session_id: agent_info.session_id().map(|s| s.to_string()),
|
||||
status: agent_info.status().cloned(),
|
||||
last_activity: Some(agent_info.last_activity()),
|
||||
created_at: Some(agent_info.created_at()),
|
||||
model_provider: agent_info
|
||||
.model_provider()
|
||||
.as_ref()
|
||||
.map(|mp| mp.to_safe_info()),
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [AGENT_STATUS] Successfully retrieved Agent status: project_id={}, status={:?}",
|
||||
project_id,
|
||||
agent_info.status()
|
||||
);
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
} else {
|
||||
info!(
|
||||
"📭 [AGENT_STATUS] Agent service not found: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
let response = AgentStatusResponse {
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: false,
|
||||
session_id: None,
|
||||
status: None,
|
||||
last_activity: None,
|
||||
created_at: None,
|
||||
model_provider: None,
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
}
|
||||
}
|
||||
238
qiming-rcoder/crates/rcoder/src/handler/agent_stop_handler.rs
Normal file
238
qiming-rcoder/crates/rcoder/src/handler/agent_stop_handler.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
//! Agent任务停止处理器
|
||||
//!
|
||||
//! 转发停止请求到容器内的 agent_runner 服务
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, get_locale_from_headers};
|
||||
use crate::{AppError, HttpResult, router::AppState};
|
||||
use shared_types::{AgentStopRequest, AgentStopResponse};
|
||||
|
||||
/// 直接销毁指定项目对应的容器
|
||||
async fn destroy_container_for_project(
|
||||
state: &Arc<AppState>,
|
||||
project_id: &str,
|
||||
pod_id: Option<&str>,
|
||||
locale: &'static str,
|
||||
) -> Result<HttpResult<AgentStopResponse>, AppError> {
|
||||
// 容器标识符:pod_id 优先,否则使用 project_id(与创建时一致)
|
||||
let container_identifier = pod_id.unwrap_or(project_id);
|
||||
|
||||
info!(
|
||||
"[STOP_DESTROY] startingdestroycontainer: project_id={}, pod_id={:?}, container_identifier={}",
|
||||
project_id, pod_id, container_identifier
|
||||
);
|
||||
|
||||
let runtime = match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
error!("[STOP_DESTROY] Failed to get runtime: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_CONTAINER_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let container_info = runtime
|
||||
.get_container_info_by_identifier(container_identifier, &shared_types::ServiceType::RCoder)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some(container_info) = container_info {
|
||||
info!(
|
||||
"🎯 [STOP_DESTROY] Container found, starting destruction: container_identifier={}, container_id={}, container_name={}",
|
||||
container_identifier, container_info.container_id, container_info.container_name
|
||||
);
|
||||
|
||||
// 停止容器(使用 container_identifier 构造正确的 pod name / container name)
|
||||
let stop_result = runtime
|
||||
.stop_container_by_identifier(container_identifier, &shared_types::ServiceType::RCoder)
|
||||
.await;
|
||||
|
||||
if let Err(e) = stop_result {
|
||||
error!("[STOP_DESTROY] stoppedcontainerfailed: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_STOP_FAILED,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
// 清理旧容器的 gRPC 连接(避免复用已失效的 TCP 连接)
|
||||
if !container_info.container_ip.is_empty() {
|
||||
let old_grpc_addr = format!(
|
||||
"{}:{}",
|
||||
container_info.container_ip,
|
||||
shared_types::GRPC_DEFAULT_PORT
|
||||
);
|
||||
state.grpc_pool.remove(&old_grpc_addr);
|
||||
}
|
||||
|
||||
// 从 DuckDB 存储中移除项目(如果 project_id 不是 "unknown")
|
||||
if container_info.project_id != "unknown" {
|
||||
state.remove_project(&container_info.project_id);
|
||||
}
|
||||
|
||||
info!(
|
||||
"✅ [STOP_DESTROY] Container destroyed successfully: project_id={}, container_id={}, container_name={}",
|
||||
project_id, container_info.container_id, container_info.container_name
|
||||
);
|
||||
|
||||
let response = AgentStopResponse {
|
||||
success: true,
|
||||
project_id: project_id.to_string(),
|
||||
session_id: None,
|
||||
message: shared_types::get_i18n_message("success.container_destroyed", locale),
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
} else {
|
||||
// 容器不存在,但返回成功
|
||||
info!(
|
||||
"📭 [STOP_DESTROY] Container does not exist, no need to destroy: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
let response = AgentStopResponse {
|
||||
success: true,
|
||||
project_id: project_id.to_string(),
|
||||
session_id: None,
|
||||
message: shared_types::get_i18n_message("success.container_not_exist", locale),
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止指定项目的Agent服务
|
||||
///
|
||||
/// 直接销毁 project_id 对应的容器,不向容器内的 agent_runner 发送消息
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/agent/stop",
|
||||
request_body = AgentStopRequest,
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功销毁容器",
|
||||
body = HttpResult<AgentStopResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"project_id": "test_project",
|
||||
"session_id": null,
|
||||
"message": "容器已成功销毁"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 200,
|
||||
description = "容器不存在但返回成功",
|
||||
body = HttpResult<AgentStopResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"project_id": "test_project",
|
||||
"session_id": null,
|
||||
"message": "容器不存在,无需销毁"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "INVALID_PARAMS",
|
||||
"message": "Invalid project_id parameter"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "销毁容器失败",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "DESTROY_FAILED",
|
||||
"message": "Failed to destroy container"
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "agent",
|
||||
operation_id = "agent_stop",
|
||||
summary = "销毁Agent容器",
|
||||
description = "直接销毁 project_id 对应的容器,不向容器内的 agent_runner 发送消息。如果容器不存在,也返回成功。"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn agent_stop(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<AgentStopRequest>,
|
||||
) -> Result<HttpResult<AgentStopResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
info!(
|
||||
"🛑 [STOP_DESTROY] Received container destroy request: project_id={}, pod_id={:?}",
|
||||
project_id, request.pod_id
|
||||
);
|
||||
|
||||
// 直接销毁容器
|
||||
let result = destroy_container_for_project(&state, project_id, request.pod_id.as_deref(), locale).await;
|
||||
|
||||
match &result {
|
||||
Ok(response) => {
|
||||
if let Some(data) = response.data.as_ref() {
|
||||
if data.success {
|
||||
info!(
|
||||
"[STOP_DESTROY] containerdestroysucceeded: project_id={}",
|
||||
project_id
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
"[STOP_DESTROY] containerdestroyfailed: project_id={}",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
error!(
|
||||
"[STOP_DESTROY] Empty response: project_id={}",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [STOP_DESTROY] 销毁容器过程中出错: project_id={}, error={}",
|
||||
project_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
579
qiming-rcoder/crates/rcoder/src/handler/chat_handler.rs
Normal file
579
qiming-rcoder/crates/rcoder/src/handler/chat_handler.rs
Normal file
@@ -0,0 +1,579 @@
|
||||
//! 聊天处理器
|
||||
//!
|
||||
//! 将原始 HTTP 请求直接转发到容器内的 agent_runner 服务
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{extract::State, http::HeaderMap};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_types::{AgentChatRequest, ChatAgentConfig, IsolationType, ModelProviderConfig, ProjectAndContainerInfo};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{router::AppState, *};
|
||||
use docker_manager::ContainerBasicInfo;
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, extract_grpc_addr_with_port, get_locale_from_headers, build_workspace_path};
|
||||
|
||||
/// 处理聊天请求 - 转发到容器化 agent_runner 服务
|
||||
///
|
||||
/// 1. 根据 project_id 检查或动态创建对应的容器(默认使用 ServiceType::RCoder)
|
||||
/// 2. 将原始聊天请求直接转发到容器内的 agent_runner 服务
|
||||
/// 3. 获取并返回 agent_runner 的处理结果
|
||||
///
|
||||
/// 注意:
|
||||
/// - 所有参数处理(如 project_id、session_id 生成)都由 agent_runner 处理
|
||||
/// - RCoder 只负责容器管理和请求转发
|
||||
/// - 当前默认使用 ServiceType::RCoder,AgentRunner 模式正在开发中
|
||||
/// - Resume 会话的降级逻辑已在 agent_runner 层通过 list_sessions API 预检查处理
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/chat",
|
||||
request_body(
|
||||
content = AgentChatRequest,
|
||||
description = "聊天请求,包含用户输入的 prompt 和可选的多媒体附件",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功处理聊天请求",
|
||||
body = HttpResult<ChatResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "test_project",
|
||||
"session_id": "session456",
|
||||
"error": null,
|
||||
"request_id": "req_123456789"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误或容器服务异常",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "INTERNAL001",
|
||||
"message": "Internal server error"
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "chat",
|
||||
operation_id = "handle_chat",
|
||||
summary = "转发聊天消息到容器化 AI 服务",
|
||||
description = "根据 project_id 动态管理容器(默认使用 ServiceType::RCoder),将原始聊天请求直接转发到容器内的 agent_runner 服务进行处理"
|
||||
)]
|
||||
#[instrument(skip(state, request), fields(project_id = ?request.project_id, session_id = ?request.session_id))]
|
||||
pub async fn handle_chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(mut request): I18nJsonOrQuery<AgentChatRequest>,
|
||||
) -> Result<HttpResult<ChatResponse>, AppError> {
|
||||
// 获取语言设置
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
let project_id = match &request.project_id {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
let project_id = crate::service::container_manager::generate_project_id();
|
||||
request.project_id = Some(project_id.clone()); // 设置 project_id
|
||||
project_id
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 隔离类型参数校验 ==========
|
||||
// IF pod_id IS NOT NULL THEN isolation_type, tenant_id, space_id 必须非空
|
||||
if request.pod_id.is_some() {
|
||||
if request.isolation_type.is_none() {
|
||||
error!("[CHAT] Validation failed: isolation_type is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"isolation_type is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
if request.tenant_id.is_none() {
|
||||
error!("[CHAT] Validation failed: tenant_id is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"tenant_id is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
if request.space_id.is_none() {
|
||||
error!("[CHAT] Validation failed: space_id is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"space_id is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
|
||||
// 验证 isolation_type 值有效(大小写不敏感)
|
||||
if let Some(ref it) = request.isolation_type {
|
||||
if IsolationType::from_str(it).is_err() {
|
||||
error!("[CHAT] Validation failed: invalid isolation_type '{}', expected tenant|space|project", it);
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
&format!("invalid isolation_type '{}', expected: tenant, space, project", it),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 记录验证通过的参数(此时 pod_id, isolation_type, tenant_id, space_id 必定为 Some)
|
||||
if let (Some(pid), Some(it), Some(tid), Some(sid)) = (
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
) {
|
||||
info!(
|
||||
"🔒 [CHAT] Isolation parameters validated: pod_id={}, isolation_type={}, tenant_id={}, space_id={}",
|
||||
pid, it, tid, sid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 构建工作空间路径 ==========
|
||||
// 根据 isolation_type 确定容器内工作目录:
|
||||
// - tenant/space: /app/project_workspace/{tenant_id}/{space_id}/{project_id}
|
||||
// - project 或默认: /app/project_workspace/{project_id}
|
||||
let container_work_path = build_workspace_path(
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
&project_id,
|
||||
);
|
||||
|
||||
info!(
|
||||
"📁 [CHAT] Workspace path determined: {} (isolation_type={})",
|
||||
container_work_path,
|
||||
request.isolation_type.as_deref().unwrap_or("project")
|
||||
);
|
||||
|
||||
// 验证资源限制配置
|
||||
if let Some(ref agent_config) = request.agent_config {
|
||||
if let Some(ref resource_limits) = agent_config.resource_limits {
|
||||
resource_limits.validate().map_err(|e| {
|
||||
AppError::validation_error(&format!("Invalid resource limits: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"🚀 [CHAT] Starting to process chat request: project_id={}, session_id={:?}, prompt_length={}, attachments_count={}, model_provider={}",
|
||||
project_id,
|
||||
request.session_id,
|
||||
request.prompt.len(),
|
||||
request.attachments.len(),
|
||||
request
|
||||
.model_provider
|
||||
.as_ref()
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| "None".to_string())
|
||||
);
|
||||
|
||||
// 打印 agent_config 配置信息(debug 级别)
|
||||
info!(
|
||||
"🔧 [CHAT] agent_config: project_id={}, agent_config={:?}",
|
||||
project_id, request.agent_config
|
||||
);
|
||||
|
||||
// 第一步:获取或创建容器,默认使用 ServiceType::RCoder
|
||||
let service_type = shared_types::ServiceType::RCoder;
|
||||
let container_info =
|
||||
crate::service::container_manager::ContainerManager::get_or_create_container(
|
||||
&project_id,
|
||||
&service_type,
|
||||
request
|
||||
.agent_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.resource_limits.clone()),
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
&container_work_path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 第二步:获取或创建 ProjectAndContainerInfo - 使用 DuckDB 存储
|
||||
let _ = {
|
||||
info!(
|
||||
"[CHAT] Getting/creating project: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 检查项目是否存在
|
||||
if let Some(existing_info) = state.get_project(&project_id) {
|
||||
info!(
|
||||
"[CHAT] Project exists, checking for update: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 检查是否需要更新扩展状态
|
||||
let needs_extended_update = existing_info.container().is_none()
|
||||
|| existing_info.model_provider().is_none()
|
||||
|| existing_info.request_id().is_none();
|
||||
|
||||
if needs_extended_update {
|
||||
// 创建更新后的信息
|
||||
let mut mutable_info = (*existing_info).clone();
|
||||
// 补充 pod_id(兼容旧数据或服务重启后丢失的情况)
|
||||
if mutable_info.pod_id().is_none() && request.pod_id.is_some() {
|
||||
mutable_info.set_pod_id(request.pod_id.clone());
|
||||
}
|
||||
mutable_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(service_type.clone()),
|
||||
);
|
||||
mutable_info.update_activity();
|
||||
|
||||
let arc_info = Arc::new(mutable_info);
|
||||
state.insert_project(project_id.clone(), arc_info.clone());
|
||||
|
||||
info!(
|
||||
"✅ [CHAT] Project info fully updated: project_id={}, container_id={}",
|
||||
project_id, container_info.container_id
|
||||
);
|
||||
|
||||
arc_info
|
||||
} else {
|
||||
// 只需要更新活动时间
|
||||
state.update_activity(&project_id);
|
||||
info!(
|
||||
"[CHAT] Activity time updated: project_id={}",
|
||||
project_id
|
||||
);
|
||||
existing_info
|
||||
}
|
||||
} else {
|
||||
info!(
|
||||
"[CHAT] Creating new project: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 创建新的 ProjectAndContainerInfo
|
||||
let mut new_info = ProjectAndContainerInfo::new(project_id.clone());
|
||||
new_info.set_pod_id(request.pod_id.clone());
|
||||
new_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(service_type.clone()),
|
||||
);
|
||||
|
||||
let arc_info = Arc::new(new_info);
|
||||
state.insert_project(project_id.clone(), arc_info.clone());
|
||||
|
||||
info!(
|
||||
"✅ [CHAT] Project info created: project_id={}, container_id={}",
|
||||
project_id, container_info.container_id
|
||||
);
|
||||
|
||||
arc_info
|
||||
}
|
||||
};
|
||||
|
||||
// 请求到达时立即更新活动时间(不等待请求执行结果)
|
||||
// 这样可以防止在 gRPC 请求期间被 cleanup_task 误清理
|
||||
state.update_activity(&project_id);
|
||||
debug!("[CHAT] Updated activity time: project_id={}", project_id);
|
||||
|
||||
// 🆕 自动查找 session_id 逻辑
|
||||
// 如果用户没有传递 session_id,尝试从状态中查找最新的 session_id
|
||||
let session_id_to_use = match &request.session_id {
|
||||
Some(sid) if !sid.is_empty() => {
|
||||
debug!("[CHAT] Using provided session_id: {}", sid);
|
||||
sid.clone()
|
||||
}
|
||||
_ => {
|
||||
// 用户没有传递 session_id,尝试查找最新的
|
||||
match state.get_project(&project_id) {
|
||||
Some(project_info) => {
|
||||
let existing_session_id = project_info.session_id();
|
||||
match existing_session_id {
|
||||
Some(sid) if !sid.is_empty() => {
|
||||
info!(
|
||||
"🔄 [CHAT] No session_id provided, auto using latest session: project_id={}, session_id={}",
|
||||
project_id, sid
|
||||
);
|
||||
sid.to_string()
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
"[CHAT] No existing session_id for project, will create new session"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
debug!("[CHAT] No project exists, will create new session");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 克隆 request 并修改 session_id
|
||||
let mut request_for_forward = request.clone();
|
||||
request_for_forward.session_id = if session_id_to_use.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(session_id_to_use)
|
||||
};
|
||||
// 🆕 自动查找 session_id 逻辑结束
|
||||
|
||||
// 第三步:转发请求到容器服务(使用全局连接池)
|
||||
info!("[CHAT] Forwarding request to container service");
|
||||
let result = forward_request_to_container_service(
|
||||
&request_for_forward,
|
||||
&container_info,
|
||||
&state.grpc_pool,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
locale,
|
||||
)
|
||||
.await;
|
||||
info!("[CHAT] Container request completed: success={}", result.is_ok());
|
||||
|
||||
// 响应后状态更新 - 使用 DuckDB 存储
|
||||
// 无论请求成功还是失败,只要响应中包含 session_id,都要更新映射
|
||||
// 这样用户可以通过 SSE 接口获取错误通知,而不会收到 SESSION_EXPIRED 错误
|
||||
if let Ok(http_result) = &result {
|
||||
if let Some(chat_response) = &http_result.data {
|
||||
let session_id = chat_response.session_id.clone();
|
||||
|
||||
// 只有当 session_id 非空时才更新映射
|
||||
if !session_id.is_empty() {
|
||||
info!(
|
||||
"📊 [CHAT] Received chat response, starting state update: session_id={}, success={}",
|
||||
session_id,
|
||||
http_result.is_success()
|
||||
);
|
||||
|
||||
// 更新会话信息(同时更新 session_id 和 session-to-container 映射)
|
||||
info!(
|
||||
"🔗 [SESSION_MAP] Associated session_id {} to project_id {}",
|
||||
session_id, project_id
|
||||
);
|
||||
state.update_session(&project_id, &session_id);
|
||||
|
||||
// 更新项目活动时间
|
||||
state.update_activity(&project_id);
|
||||
|
||||
if http_result.is_success() {
|
||||
info!(
|
||||
"🎯 [CHAT] All state updates completed: project_id={}, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"⚠️ [CHAT] Request failed but session mapping saved: project_id={}, session_id={}, code={}, message={}",
|
||||
project_id, session_id, http_result.code, http_result.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.as_ref().map_or(true, |r| {
|
||||
!r.is_success() && r.data.as_ref().map_or(true, |d| d.session_id.is_empty())
|
||||
}) {
|
||||
error!("[CHAT] Container returned error: {:?}", result);
|
||||
}
|
||||
|
||||
info!("[CHAT] Request completed: project_id={}", project_id);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 转发请求到容器内的 agent_runner 服务
|
||||
///
|
||||
/// 🎯 使用 gRPC Chat RPC 替代 HTTP 转发(使用全局连接池)
|
||||
async fn forward_request_to_container_service(
|
||||
request: &AgentChatRequest,
|
||||
container_info: &ContainerBasicInfo,
|
||||
grpc_pool: &Arc<crate::grpc::GrpcChannelPool>,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
locale: &'static str,
|
||||
) -> Result<crate::HttpResult<ChatResponse>, crate::AppError> {
|
||||
let project_id = if let Some(id) = &request.project_id {
|
||||
id.clone()
|
||||
} else {
|
||||
error!("[FORWARD]session project_id is required");
|
||||
return Ok(crate::HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
};
|
||||
|
||||
info!(
|
||||
"📤 [FORWARD] Forwarding request to container (gRPC): project_id={}, session_id={:?}, container_id={}, service_url={}",
|
||||
project_id, request.session_id, container_info.container_id, container_info.service_url
|
||||
);
|
||||
|
||||
// 🎯 使用 gRPC 替代 HTTP
|
||||
// 使用实时 IP 获取,避免容器重建后 IP 变化导致连接失败
|
||||
// 直接使用 container_info.container_name(创建时已确定,无需重新拼接)
|
||||
let container_name = container_info.container_name.clone();
|
||||
let mut grpc_addr = match super::utils::get_realtime_container_ip(
|
||||
&container_name,
|
||||
&container_info.container_ip,
|
||||
rcoder_prefix,
|
||||
computer_prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT),
|
||||
Err(e) => {
|
||||
warn!("[FORWARD] Real-time IP resolution failed: {}, falling back to service_url", e);
|
||||
extract_grpc_addr_with_port(&container_info.service_url, shared_types::GRPC_DEFAULT_PORT)?
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"📡 [FORWARD] Sending gRPC request to: {}, prompt_length={}, attachments_count={}",
|
||||
grpc_addr,
|
||||
request.prompt.len(),
|
||||
request.attachments.len()
|
||||
);
|
||||
|
||||
// 调用 gRPC Chat(使用全局连接池,带重试和被动驱逐机制)
|
||||
let max_retries = 2;
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
match crate::grpc::grpc_chat_with_pool(
|
||||
grpc_pool,
|
||||
&grpc_addr,
|
||||
project_id.clone(),
|
||||
request.session_id.clone(),
|
||||
request.prompt.clone(),
|
||||
request.attachments.clone(),
|
||||
request.data_source_attachments.clone(),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(std::time::Duration::from_secs(300)), // 5 分钟超时,避免永久阻塞
|
||||
// 新增参数 (v2)
|
||||
request.system_prompt.clone(),
|
||||
request.user_prompt.clone(),
|
||||
request.agent_config.clone(),
|
||||
Some(shared_types::ServiceType::RCoder), // ✅ RCoder 模式使用 RCoder ServiceType
|
||||
None, // RCoder 模式不需要 user_id
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(grpc_response) => {
|
||||
if grpc_response.success {
|
||||
// 转换为内部 ChatResponse
|
||||
let chat_response = crate::grpc::grpc_response_to_chat_response(grpc_response);
|
||||
info!(
|
||||
"✅ [FORWARD] gRPC response success: project_id={}, session_id={}",
|
||||
chat_response.project_id, chat_response.session_id
|
||||
);
|
||||
return Ok(crate::HttpResult::success(chat_response));
|
||||
} else {
|
||||
let error_msg = grpc_response
|
||||
.error
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
// 🎯 从 gRPC 响应中提取错误码(完整透传)
|
||||
let error_code = grpc_response
|
||||
.error_code
|
||||
.unwrap_or_else(|| shared_types::error_codes::ERR_AGENT_ERROR.to_string());
|
||||
error!(
|
||||
"❌ [FORWARD] gRPC response error: code={}, message={}",
|
||||
error_code, error_msg
|
||||
);
|
||||
return Ok(crate::HttpResult::error(&error_code, &error_msg));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [FORWARD] gRPC call failed (attempt {}/{}): {}",
|
||||
attempt, max_retries, e
|
||||
);
|
||||
|
||||
// ✅ 使用错误分类判断是否应该重试
|
||||
let should_retry = crate::grpc::should_retry_error(&e);
|
||||
|
||||
if should_retry && attempt < max_retries {
|
||||
// 可重试错误:清理连接池并重新获取 IP 后重试
|
||||
info!("🔄 [FORWARD] Detected retryable error, re-resolving container IP and retrying...");
|
||||
grpc_pool.remove(&grpc_addr);
|
||||
|
||||
// 重新获取最新容器 IP(容器可能已重建,IP 可能变化)
|
||||
match super::utils::get_realtime_container_ip(
|
||||
&container_name,
|
||||
&container_info.container_ip,
|
||||
rcoder_prefix,
|
||||
computer_prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => {
|
||||
let new_addr = format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT);
|
||||
info!(
|
||||
"🔄 [FORWARD] Container IP re-resolved: {} -> {}",
|
||||
grpc_addr, new_addr
|
||||
);
|
||||
grpc_addr = new_addr;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [FORWARD] Failed to re-resolve container IP, keeping old address: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
} else if !should_retry {
|
||||
// 不可重试错误:直接返回
|
||||
error!("[FORWARD] Detected non-retryable error, stopped retry: {}", e);
|
||||
last_error = Some(e);
|
||||
break;
|
||||
}
|
||||
|
||||
// 最后一次尝试失败
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有重试都失败
|
||||
if let Some(e) = last_error {
|
||||
error!("[FORWARD] gRPC request failed after all retries: {}", e);
|
||||
|
||||
// gRPC 通信失败,直接返回错误
|
||||
// 注:业务错误码(如 Agent busy)现在由 agent_runner 通过 grpc_response.error_code 返回
|
||||
// 这里只处理真正的 gRPC 通信层错误
|
||||
Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ERROR,
|
||||
locale,
|
||||
))
|
||||
} else {
|
||||
// 理论上不会走到这里,除非 max_retries < 1
|
||||
Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ERROR,
|
||||
locale,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Computer Agent Status Handler
|
||||
//!
|
||||
//! 查询 Computer Agent 的运行状态(通过 gRPC GetStatus 主动确认)
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, get_locale_from_headers, get_realtime_container_ip};
|
||||
use crate::router::AppState;
|
||||
use crate::{AppError, HttpResult};
|
||||
use shared_types::{ComputerAgentStatusRequest, ComputerAgentStatusResponse};
|
||||
|
||||
/// gRPC GetStatus 最大重试次数
|
||||
const GRPC_MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// gRPC GetStatus 请求超时时间(秒)
|
||||
const GRPC_REQUEST_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// 处理 Computer Agent 状态查询
|
||||
///
|
||||
/// 核心流程:
|
||||
/// 1. 验证 user_id 和 project_id
|
||||
/// 2. 查询容器是否存在且运行中
|
||||
/// 3. 主动调用 gRPC GetStatus 确认 Agent 真实状态
|
||||
/// 4. 返回综合状态信息
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/status",
|
||||
request_body(
|
||||
content = ComputerAgentStatusRequest,
|
||||
description = "Computer Agent 状态查询请求",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功获取 Agent 状态",
|
||||
body = HttpResult<ComputerAgentStatusResponse>,
|
||||
examples(
|
||||
("Agent 已启动" = (value = json!({
|
||||
"success": true,
|
||||
"code": "0000",
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"user_id": "user_123",
|
||||
"project_id": "proj_456",
|
||||
"is_alive": true,
|
||||
"session_id": "session_abc123",
|
||||
"status": "idle",
|
||||
"last_activity": "2024-01-01T12:00:00Z",
|
||||
"created_at": "2024-01-01T10:00:00Z"
|
||||
}
|
||||
}))),
|
||||
("Agent 未启动" = (value = json!({
|
||||
"success": true,
|
||||
"code": "0000",
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"user_id": "user_123",
|
||||
"project_id": "proj_456",
|
||||
"is_alive": false
|
||||
}
|
||||
})))
|
||||
)
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_agent_status",
|
||||
summary = "查询 Computer Agent 状态",
|
||||
description = "查询指定 user_id + project_id 对应的 Computer Agent 是否已启动。通过主动调用子容器的 gRPC GetStatus 接口确认 Agent 真实状态。"
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn computer_agent_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentStatusRequest>,
|
||||
) -> Result<HttpResult<ComputerAgentStatusResponse>, AppError> {
|
||||
// 获取语言设置
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
// 1. 参数验证:user_id 或 pod_id 至少有一个
|
||||
let has_user_id = request.user_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
let has_pod_id = request.pod_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
if !has_user_id && !has_pod_id {
|
||||
error!("[COMPUTER_AGENT_STATUS] user_id or pod_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
// 用于日志输出的标识符
|
||||
let identifier_display = if has_user_id {
|
||||
format!("user_id={}", request.user_id.as_ref().unwrap())
|
||||
} else {
|
||||
format!("pod_id={}", request.pod_id.as_ref().unwrap())
|
||||
};
|
||||
|
||||
info!(
|
||||
"🔍 [COMPUTER_AGENT_STATUS] Querying Agent status: {}, project_id={}",
|
||||
identifier_display, project_id
|
||||
);
|
||||
|
||||
// 2. 查询容器信息(通过 Runtime)
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_AGENT_STATUS] Failed to get runtime: {}", e);
|
||||
AppError::internal_server_error(&format!("Failed to get runtime: {}", e))
|
||||
})?;
|
||||
|
||||
// 获取容器标识符(user_id 或 pod_id)
|
||||
let identifier = request.user_id.clone().or(request.pod_id.clone());
|
||||
let identifier_str = identifier.as_deref().unwrap_or("");
|
||||
|
||||
// 获取容器信息(ComputerAgentRunner 使用 user_id 或 pod_id 作为容器标识)
|
||||
let container_info = match runtime
|
||||
.get_container_info_by_identifier(
|
||||
identifier_str,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
info!(
|
||||
"📭 [COMPUTER_AGENT_STATUS] Container not found: identifier={}",
|
||||
identifier_str
|
||||
);
|
||||
// Early return: 直接 move request 的字段
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [COMPUTER_AGENT_STATUS] Failed to query container info: identifier={}, error={}",
|
||||
identifier_str, e
|
||||
);
|
||||
return Err(AppError::internal_server_error(&format!(
|
||||
"Failed to query container info: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 检查容器是否运行中
|
||||
if container_info.status != "running" {
|
||||
info!(
|
||||
"⚠️ [COMPUTER_AGENT_STATUS] Container not running: identifier={}, status={}",
|
||||
identifier_str, container_info.status
|
||||
);
|
||||
// Early return: 直接 move request 的字段
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_AGENT_STATUS] Container running: container_id={}, container_ip={}",
|
||||
container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
// 4. 主动调用 gRPC GetStatus 确认 Agent 真实状态
|
||||
// 使用实时 IP 获取,避免 restart 后 IP 过期
|
||||
let grpc_addr = match get_realtime_container_ip(
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [COMPUTER_AGENT_STATUS] Failed to get container IP: identifier={}, error={}",
|
||||
identifier_str, e
|
||||
);
|
||||
// Early return: 直接 move request 的字段
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"📡 [COMPUTER_AGENT_STATUS] gRPC address: {}, project_id={}",
|
||||
grpc_addr, project_id
|
||||
);
|
||||
|
||||
// 调用 gRPC GetStatus(带超时和重试,重试时自动重新获取 IP)
|
||||
let grpc_response = match call_grpc_get_status_with_retry(
|
||||
&state.grpc_pool,
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
project_id,
|
||||
GRPC_MAX_RETRIES,
|
||||
locale,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_AGENT_STATUS] gRPC GetStatus call failed: {}, project_id={}, error={}",
|
||||
identifier_display, project_id, e
|
||||
);
|
||||
// gRPC 调用失败视为 Agent 不存在
|
||||
// Early return: 直接 move request 的字段
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// 5. 使用 is_found 字段判断 Agent 是否存活
|
||||
let is_alive = grpc_response.is_found;
|
||||
|
||||
if !is_alive {
|
||||
info!(
|
||||
"📭 [COMPUTER_AGENT_STATUS] Agent not started: {}, project_id={}, is_found={}",
|
||||
identifier_display, project_id, grpc_response.is_found
|
||||
);
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// 6. Agent 存活,从 DuckDB 获取完整信息
|
||||
let response = if let Some(project_info) = state.get_project(project_id) {
|
||||
ComputerAgentStatusResponse {
|
||||
user_id: request.user_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: true,
|
||||
session_id: project_info.session_id().map(|s| s.to_string()),
|
||||
status: Some(grpc_response.status.clone()),
|
||||
last_activity: Some(project_info.last_activity()),
|
||||
created_at: Some(project_info.created_at()),
|
||||
}
|
||||
} else {
|
||||
// DuckDB 中无记录,但 gRPC 确认 Agent 存在
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_AGENT_STATUS] Agent exists but no DuckDB record (may be due to service restart causing state loss): {}, project_id={}. Attempting self-healing...",
|
||||
identifier_display, project_id
|
||||
);
|
||||
|
||||
// 🛡️ 自愈逻辑 (Self-Healing)
|
||||
// 自动恢复丢失的项目记录,防止容器被孤立清理器误杀
|
||||
let mut project_info =
|
||||
shared_types::ProjectAndContainerInfo::new(project_id.to_string());
|
||||
project_info.set_user_id(request.user_id.clone());
|
||||
project_info.set_pod_id(request.pod_id.clone());
|
||||
|
||||
// 恢复容器信息
|
||||
// 注意:这里我们使用查询到的 container_info
|
||||
project_info.set_container(Some(container_info.clone()));
|
||||
project_info.set_service_type(Some(shared_types::ServiceType::ComputerAgentRunner));
|
||||
|
||||
// 设置状态 (根据 gRPC 返回的状态)
|
||||
// gRPC status: "idle", "busy" -> 对应 AgentStatus
|
||||
// 简单起见,我们先恢复记录,状态会在后续的心跳或交互中更新
|
||||
|
||||
// 如果 gRPC 返回了 session_id(虽然 GetStatus 通常不返回 session_id),尝试恢复
|
||||
// 但目前的 GetStatusResponse 没有 session_id 字段,所以只能置空或尝试从其他地方恢复
|
||||
// 这里暂时置空,等下次聊天时会自动更新
|
||||
|
||||
// 插入到 DuckDB
|
||||
state.insert_project(project_id.to_string(), Arc::new(project_info.clone()));
|
||||
|
||||
info!(
|
||||
"🔄 [COMPUTER_AGENT_STATUS] ✅ Self-healing succeeded: restored project record project_id={}, {}",
|
||||
project_id, identifier_display
|
||||
);
|
||||
|
||||
ComputerAgentStatusResponse {
|
||||
user_id: request.user_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: true,
|
||||
session_id: None, // 恢复时暂时无法获知 session_id
|
||||
status: Some(grpc_response.status.clone()),
|
||||
// 使用当前时间作为最后活动时间,避免立即被清理
|
||||
last_activity: Some(chrono::Utc::now()),
|
||||
created_at: Some(chrono::Utc::now()), // 使用当前时间作为创建时间(近似)
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_AGENT_STATUS] Agent status query completed: {}, project_id={}, is_alive={}, status={}",
|
||||
identifier_display,
|
||||
project_id,
|
||||
response.is_alive,
|
||||
response.status.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
}
|
||||
|
||||
/// 调用 gRPC GetStatus(带重试机制)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `pool`: gRPC 连接池
|
||||
/// - `grpc_addr`: gRPC 服务地址
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `max_retries`: 最大重试次数
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(status)`: 从 Agent 返回的状态字符串(可能的值取决于 Agent 实现,通常为 "idle", "busy", "error", "not_found" 等)
|
||||
/// - `Err(e)`: gRPC 调用失败(网络错误、超时、连接失败等)
|
||||
///
|
||||
/// # 重试策略
|
||||
/// - 仅对可重试的错误进行重试:Unavailable, DeadlineExceeded, Unknown, Internal
|
||||
/// - 使用指数退避:100ms, 200ms, 400ms
|
||||
/// - 失败后自动从连接池移除失败的连接,并重新获取容器 IP
|
||||
async fn call_grpc_get_status_with_retry(
|
||||
pool: &Arc<crate::grpc::GrpcChannelPool>,
|
||||
container_name: &str,
|
||||
fallback_ip: &str,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
project_id: &str,
|
||||
max_retries: u32,
|
||||
locale: &'static str,
|
||||
) -> anyhow::Result<shared_types::grpc::GetStatusResponse> {
|
||||
let mut last_error = None;
|
||||
let mut grpc_addr = format!("{}:{}", fallback_ip, shared_types::GRPC_DEFAULT_PORT);
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
// 重新获取最新容器 IP(每次重试时)
|
||||
if attempt > 1 {
|
||||
match get_realtime_container_ip(container_name, fallback_ip, rcoder_prefix, computer_prefix).await {
|
||||
Ok(ip) => {
|
||||
let new_addr = format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT);
|
||||
info!(
|
||||
"🔄 [GRPC_GET_STATUS] Container IP re-resolved: {} -> {}",
|
||||
grpc_addr, new_addr
|
||||
);
|
||||
grpc_addr = new_addr;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [GRPC_GET_STATUS] Failed to re-resolve container IP, keeping old address: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match pool.get_client(&grpc_addr).await {
|
||||
Ok(mut client) => {
|
||||
let request = shared_types::grpc::GetStatusRequest {
|
||||
project_id: project_id.to_string(),
|
||||
session_id: String::new(), // 查询项目级别状态
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
let mut tonic_request = crate::grpc::new_request_with_locale(request, locale);
|
||||
tonic_request
|
||||
.set_timeout(std::time::Duration::from_secs(GRPC_REQUEST_TIMEOUT_SECS));
|
||||
|
||||
match client.get_status(tonic_request).await {
|
||||
Ok(response) => {
|
||||
let grpc_response = response.into_inner();
|
||||
debug!(
|
||||
"✅ [GRPC_GET_STATUS] Attempt {} succeeded: project_id={}, status={}, is_found={}",
|
||||
attempt, project_id, grpc_response.status, grpc_response.is_found
|
||||
);
|
||||
return Ok(grpc_response);
|
||||
}
|
||||
Err(e) => {
|
||||
// 直接判断原始 tonic::Status,避免信息丢失
|
||||
let should_retry = matches!(
|
||||
e.code(),
|
||||
tonic::Code::Unavailable
|
||||
| tonic::Code::DeadlineExceeded
|
||||
| tonic::Code::Unknown
|
||||
| tonic::Code::Internal
|
||||
);
|
||||
|
||||
if should_retry && attempt < max_retries {
|
||||
warn!(
|
||||
"⚠️ [GRPC_GET_STATUS] Attempt {} failed (retryable): project_id={}, code={:?}, error={}",
|
||||
attempt,
|
||||
project_id,
|
||||
e.code(),
|
||||
e
|
||||
);
|
||||
// 从连接池移除失败的连接
|
||||
pool.remove(&grpc_addr);
|
||||
last_error = Some(anyhow::anyhow!("gRPC call failed: {}", e));
|
||||
|
||||
// 指数退避: 100ms, 200ms, 400ms
|
||||
let delay_ms = 100 * (1 << (attempt - 1));
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||
continue;
|
||||
} else {
|
||||
error!(
|
||||
"❌ [GRPC_GET_STATUS] Attempt {} failed (non-retryable or max retries reached): project_id={}, code={:?}, error={}",
|
||||
attempt,
|
||||
project_id,
|
||||
e.code(),
|
||||
e
|
||||
);
|
||||
return Err(anyhow::anyhow!("gRPC call failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [GRPC_GET_STATUS] Attempt {} to get gRPC client failed: error={}",
|
||||
attempt, e
|
||||
);
|
||||
// 从连接池移除可能失效的连接
|
||||
pool.remove(&grpc_addr);
|
||||
last_error = Some(e);
|
||||
if attempt < max_retries {
|
||||
// 指数退避: 100ms, 200ms, 400ms
|
||||
let delay_ms = 100 * (1 << (attempt - 1));
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Unknown error")))
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Computer Agent 停止处理器
|
||||
//!
|
||||
//! 处理停止特定 project_id 的 Agent 请求(不销毁容器)。
|
||||
//! 与 RCoder 的 agent_stop 不同,这里只停止单个 project_id 的 Agent,
|
||||
//! 容器会继续运行其他 project_id 的 Agent。
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument, warn};
|
||||
|
||||
use crate::{AppError, HttpResult, router::AppState};
|
||||
use shared_types::{ComputerAgentStopRequest, ComputerAgentStopResponse};
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, extract_grpc_addr, get_locale_from_headers};
|
||||
|
||||
/// 停止 Computer Agent
|
||||
///
|
||||
/// 停止特定 user_id 下的特定 project_id 的 Agent。
|
||||
/// 注意:这不会销毁容器,容器会继续运行其他 project_id 的 Agent。
|
||||
///
|
||||
/// 只有当 user_id 下所有 project_id 都闲置时,容器才会被清理任务销毁。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/stop",
|
||||
request_body(
|
||||
content = ComputerAgentStopRequest,
|
||||
description = "停止特定 project_id 的 Agent 请求",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功停止 Agent",
|
||||
body = HttpResult<ComputerAgentStopResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"message": "Agent 已停止",
|
||||
"user_id": "user_123",
|
||||
"project_id": "proj_456"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到指定的容器或 Agent",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_agent_stop",
|
||||
summary = "停止 Computer Agent",
|
||||
description = "停止特定 project_id 的 Agent,不销毁容器"
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn computer_agent_stop(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentStopRequest>,
|
||||
) -> Result<HttpResult<ComputerAgentStopResponse>, AppError> {
|
||||
// 获取语言设置
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
// 1. 验证参数:user_id 或 pod_id 至少有一个
|
||||
let has_user_id = request.user_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
let has_pod_id = request.pod_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
if !has_user_id && !has_pod_id {
|
||||
error!("[COMPUTER_STOP] user_id or pod_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = request.user_id.clone();
|
||||
let pod_id = request.pod_id.clone();
|
||||
|
||||
info!(
|
||||
"🛑 [COMPUTER_STOP] Starting to stop Agent: user_id={:?}, pod_id={:?}, project_id={}, session_id={:?}",
|
||||
user_id, pod_id, project_id, request.session_id
|
||||
);
|
||||
|
||||
// 2. 查找容器(根据 user_id 或 pod_id)
|
||||
let container_info = if has_user_id {
|
||||
crate::service::ComputerContainerManager::get_container_info(user_id.as_ref().unwrap()).await?
|
||||
} else {
|
||||
// TODO: 实现通过 pod_id 查找容器的逻辑
|
||||
warn!("[COMPUTER_STOP] pod_id lookup not fully implemented yet");
|
||||
None
|
||||
};
|
||||
|
||||
let container_info = match container_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
warn!("[COMPUTER_STOP] Container not found: user_id={:?}, pod_id={:?}", user_id, pod_id);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_CONTAINER_NOT_FOUND,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"📦 [COMPUTER_STOP] Container found: container_id={}, ip={}",
|
||||
container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
// 3. 通过 gRPC 调用 StopAgent RPC
|
||||
info!(
|
||||
"🔄 [COMPUTER_STOP] Preparing to call StopAgent RPC: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 提取 gRPC 地址
|
||||
let grpc_addr = extract_grpc_addr(&container_info.service_url)?;
|
||||
info!("[COMPUTER_STOP] gRPC addr: {}", grpc_addr);
|
||||
|
||||
// 调用 StopAgent RPC
|
||||
match crate::grpc::grpc_stop_agent_with_pool(
|
||||
&state.grpc_pool,
|
||||
&grpc_addr,
|
||||
project_id.to_string(),
|
||||
request
|
||||
.session_id
|
||||
.clone()
|
||||
.or_else(|| Some("User requested stop".to_string())),
|
||||
false, // force=false,优雅停止
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
info!(
|
||||
"📥 [COMPUTER_STOP] Received StopAgent response: result={}, success={}",
|
||||
response.result, response.success
|
||||
);
|
||||
|
||||
if response.success {
|
||||
// 🆕 清除 rcoder 端的 session_id(即使成功停止,也清理会话状态)
|
||||
state.clear_session(&project_id);
|
||||
|
||||
let message = format!(
|
||||
"Agent {} stopped successfully, container {} continues running",
|
||||
project_id, container_info.container_id
|
||||
);
|
||||
|
||||
let stop_response = ComputerAgentStopResponse {
|
||||
success: true,
|
||||
message,
|
||||
user_id: user_id.clone(),
|
||||
pod_id: pod_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_STOP] Agent stop completed: user_id={:?}, pod_id={:?}, project_id={}",
|
||||
user_id, pod_id, project_id
|
||||
);
|
||||
return Ok(HttpResult::success(stop_response));
|
||||
} else {
|
||||
// Agent 停止失败或已经停止
|
||||
match response.result.as_str() {
|
||||
"not_found" => {
|
||||
warn!(
|
||||
"[COMPUTER_STOP] Agent not found: project_id={}",
|
||||
project_id
|
||||
);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_AGENT_NOT_FOUND,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
"already_stopped" => {
|
||||
info!(
|
||||
"ℹ️ [COMPUTER_STOP] Agent already in stopped state: project_id={}",
|
||||
project_id
|
||||
);
|
||||
// 🆕 清除 rcoder 端的 session_id(即使 Agent 已停止,也清理会话状态)
|
||||
state.clear_session(&project_id);
|
||||
|
||||
let message =
|
||||
shared_types::get_i18n_message("success.agent_already_stopped", locale);
|
||||
let stop_response = ComputerAgentStopResponse {
|
||||
success: true,
|
||||
message,
|
||||
user_id: user_id.clone(),
|
||||
pod_id: pod_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
};
|
||||
return Ok(HttpResult::success(stop_response));
|
||||
}
|
||||
"error" => {
|
||||
let err_msg = response.message.unwrap_or_else(|| "Unknown error".to_string());
|
||||
error!("[COMPUTER_STOP] Agent stoppedfailed: {}", err_msg);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_STOP_FAILED,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
"[COMPUTER_STOP] not response: {}",
|
||||
response.result
|
||||
);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_UNKNOWN,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [COMPUTER_STOP] StopAgent RPC call failed: {}, project_id={}",
|
||||
e, project_id
|
||||
);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
992
qiming-rcoder/crates/rcoder/src/handler/computer_chat_handler.rs
Normal file
992
qiming-rcoder/crates/rcoder/src/handler/computer_chat_handler.rs
Normal file
@@ -0,0 +1,992 @@
|
||||
//! Computer Agent Runner 聊天处理器
|
||||
//!
|
||||
//! 处理 Computer Agent Runner 模式的聊天请求。
|
||||
//! 与 RCoder 的 project_id 容器模式不同,ComputerAgentRunner 使用 user_id 作为容器标识。
|
||||
//!
|
||||
//! ## 请求流程
|
||||
//! ```text
|
||||
//! POST /computer/chat { user_id, project_id?, prompt, ... }
|
||||
//! ↓
|
||||
//! 1. 验证 user_id
|
||||
//! 2. 生成 project_id(若未提供)
|
||||
//! 3. get_or_create_container_for_user(user_id)
|
||||
//! - 挂载配置: config.yml mounts (配置化管理)
|
||||
//! - 宿主机: /computer-project-workspace/{user_id} → 容器: /home/user
|
||||
//! 4. 创建项目工作目录: /home/user/{project_id} (通过挂载自动同步)
|
||||
//! 5. 创建/更新项目和会话信息
|
||||
//! 6. gRPC Chat RPC → agent_runner (带 project_id)
|
||||
//! 7. 更新会话映射
|
||||
//! 8. 返回 ChatResponse
|
||||
//! ```
|
||||
//!
|
||||
//! 注意:Resume 会话的降级逻辑已在 agent_runner 层通过 list_sessions API 预检查处理
|
||||
|
||||
use axum::{extract::State, http::HeaderMap};
|
||||
use shared_types::{ChatResponse, ComputerChatRequest, IsolationType};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use crate::{AppError, HttpResult, router::AppState, service::ComputerContainerManager};
|
||||
use docker_manager::ContainerBasicInfo;
|
||||
|
||||
use super::utils::{
|
||||
I18nJsonOrQuery, extract_grpc_addr_with_port, get_locale_from_headers,
|
||||
get_realtime_container_ip, project_dir, build_computer_workspace_path,
|
||||
};
|
||||
|
||||
/// 处理 Computer Agent 聊天请求
|
||||
///
|
||||
/// 1. 根据 user_id 获取或创建用户容器
|
||||
/// 2. 将聊天请求转发到容器内的 agent_runner 服务
|
||||
/// 3. 更新会话映射
|
||||
///
|
||||
/// 注意:
|
||||
/// - user_id 是必填的,用于标识用户的容器
|
||||
/// - project_id 可选,若未提供则自动生成
|
||||
/// - 一个用户容器内可以运行多个 project_id 的 Agent 实例
|
||||
/// - Resume 会话的降级逻辑已在 agent_runner 层通过 list_sessions API 预检查处理
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/chat",
|
||||
request_body(
|
||||
content = ComputerChatRequest,
|
||||
description = "Computer Agent 聊天请求,包含 user_id 和 prompt",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功处理聊天请求",
|
||||
body = HttpResult<ChatResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "proj_456",
|
||||
"session_id": "session789",
|
||||
"error": null,
|
||||
"request_id": "req_123456789"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误(如 user_id 为空)",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "handle_computer_chat",
|
||||
summary = "发送聊天消息到 Computer Agent",
|
||||
description = "根据 user_id 动态管理容器,一个用户对应一个带桌面环境的容器"
|
||||
)]
|
||||
#[instrument(skip(state, request), fields(user_id = %request.user_id, project_id = ?request.project_id))]
|
||||
pub async fn handle_computer_chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(mut request): I18nJsonOrQuery<ComputerChatRequest>,
|
||||
) -> Result<HttpResult<ChatResponse>, AppError> {
|
||||
// 获取语言设置
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 1. 验证 user_id
|
||||
if request.user_id.trim().is_empty() {
|
||||
error!("[COMPUTER_CHAT] user_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = request.user_id.clone();
|
||||
|
||||
// ========== 隔离类型参数校验 ==========
|
||||
// IF pod_id IS NOT NULL THEN isolation_type, tenant_id, space_id 必须非空
|
||||
if request.pod_id.is_some() {
|
||||
if request.isolation_type.is_none() {
|
||||
error!("[COMPUTER_CHAT] Validation failed: isolation_type is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"isolation_type is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
if request.tenant_id.is_none() {
|
||||
error!("[COMPUTER_CHAT] Validation failed: tenant_id is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"tenant_id is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
if request.space_id.is_none() {
|
||||
error!("[COMPUTER_CHAT] Validation failed: space_id is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"space_id is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
|
||||
// 验证 isolation_type 值有效(大小写不敏感)
|
||||
if let Some(ref it) = request.isolation_type {
|
||||
if IsolationType::from_str(it).is_err() {
|
||||
error!("[COMPUTER_CHAT] Validation failed: invalid isolation_type '{}', expected tenant|space|project", it);
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
&format!("invalid isolation_type '{}', expected: tenant, space, project", it),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 记录验证通过的参数(此时 pod_id, isolation_type, tenant_id, space_id 必定为 Some)
|
||||
if let (Some(pid), Some(it), Some(tid), Some(sid)) = (
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
) {
|
||||
info!(
|
||||
"🔒 [COMPUTER_CHAT] Isolation parameters validated: pod_id={}, isolation_type={}, tenant_id={}, space_id={}",
|
||||
pid, it, tid, sid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 生成或使用提供的 project_id
|
||||
let project_id = match &request.project_id {
|
||||
Some(id) if !id.trim().is_empty() => id.clone(),
|
||||
_ => {
|
||||
let generated_id = crate::service::container_manager::generate_project_id();
|
||||
request.project_id = Some(generated_id.clone());
|
||||
generated_id
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"🚀 [COMPUTER_CHAT] Starting to process request: user_id={}, project_id={}, session_id={:?}, prompt_len={}, attachments={}, model_provider={:?}, agent_config={:?}",
|
||||
user_id,
|
||||
project_id,
|
||||
request.session_id,
|
||||
request.prompt.len(),
|
||||
request.attachments.len(),
|
||||
request.model_provider,
|
||||
request.agent_config
|
||||
);
|
||||
|
||||
// 3. 验证资源限制配置
|
||||
if let Some(ref agent_config) = request.agent_config {
|
||||
if let Some(ref resource_limits) = agent_config.resource_limits {
|
||||
if let Err(e) = resource_limits.validate() {
|
||||
error!("[COMPUTER_CHAT] Resource limits validation failed: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_INVALID_RESOURCE_LIMITS,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. === 并发保护:检查是否有其他请求正在创建同一用户的容器 ===
|
||||
// 使用原子标记(DashMap)避免并发请求互相干扰,无死锁风险
|
||||
let mut waited_container_info: Option<ContainerBasicInfo> = None;
|
||||
if let Some(creating_since) = state.pod_creating.get(&user_id) {
|
||||
let elapsed = creating_since.elapsed();
|
||||
drop(creating_since); // 释放 DashMap ref
|
||||
|
||||
// 标记超过 60 秒视为过期(创建方可能已崩溃),忽略并继续
|
||||
if elapsed < std::time::Duration::from_secs(60) {
|
||||
info!(
|
||||
"⏳ [COMPUTER_CHAT] Container is being created, waiting for completion: user_id={}, elapsed={:?}",
|
||||
user_id, elapsed
|
||||
);
|
||||
|
||||
// 轮询等待容器就绪(最多等 30 秒,每秒检查一次)
|
||||
for wait_sec in 1..=30 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
// 标记已被移除 = 创建完成
|
||||
if !state.pod_creating.contains_key(&user_id) {
|
||||
// 尝试获取容器信息
|
||||
if let Ok(runtime) = docker_manager::runtime::RuntimeManager::get().await
|
||||
{
|
||||
if let Ok(Some(info)) = runtime
|
||||
.get_container_info_by_identifier(
|
||||
&user_id,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
info!(
|
||||
"✅ [COMPUTER_CHAT] Wait successful, container is ready (waited {}s): user_id={}, container_id={}",
|
||||
wait_sec, user_id, info.container_id
|
||||
);
|
||||
waited_container_info = Some(info);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if wait_sec % 5 == 0 {
|
||||
debug!(
|
||||
"[COMPUTER_CHAT] Still waiting for container creation: user_id={}, {}s elapsed",
|
||||
user_id, wait_sec
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if waited_container_info.is_none() {
|
||||
// 等待超时,继续正常的创建流程(此时标记可能已过期被清理)
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Wait for container creation timeout (30s), will try to create: user_id={}",
|
||||
user_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 标记过期,清理后继续
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Creation marker expired ({:?}), cleaning up and continuing",
|
||||
elapsed
|
||||
);
|
||||
state.pod_creating.remove(&user_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 获取或创建用户容器
|
||||
let container_info = if let Some(info) = waited_container_info {
|
||||
// 使用等待获得的容器信息
|
||||
info!(
|
||||
"📦 [COMPUTER_CHAT] Using ready container (waiting for other request to finish creation): user_id={}, container_id={}",
|
||||
user_id, info.container_id
|
||||
);
|
||||
info
|
||||
} else {
|
||||
// 正常创建容器 - 设置标记防止并发
|
||||
state
|
||||
.pod_creating
|
||||
.insert(user_id.clone(), std::time::Instant::now());
|
||||
|
||||
let result = ComputerContainerManager::get_or_create_container_for_user(
|
||||
&user_id,
|
||||
request
|
||||
.agent_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.resource_limits.clone()),
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// 清除标记(无论成功还是失败)
|
||||
state.pod_creating.remove(&user_id);
|
||||
|
||||
match result {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
error!("[COMPUTER_CHAT] Failed to get or create container: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_CONTAINER_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 🛡️ 二次验证:确保容器 IP 非空
|
||||
// 容器管理器应该已经处理了空 IP 的情况,但缓存/Docker API 可能返回不一致结果
|
||||
// 如果 IP 为空,先清理旧容器再强制重建(不返回错误给客户端)
|
||||
let container_info = if container_info.container_ip.trim().is_empty() {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Container has empty IP after get_or_create, cleaning up and recreating: \
|
||||
user_id={}, old_container_id={}",
|
||||
user_id, container_info.container_id
|
||||
);
|
||||
// 必须先清理旧容器,否则 create_container 发现同名 "running" 容器会复用它
|
||||
let container_identifier = request.pod_id.as_deref().unwrap_or(&user_id);
|
||||
if let Ok(runtime) = docker_manager::runtime::RuntimeManager::get().await {
|
||||
if let Err(e) = runtime
|
||||
.stop_container_by_identifier(
|
||||
container_identifier,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Failed to cleanup broken container before recreate: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
ComputerContainerManager::force_create_container_for_user(
|
||||
&user_id,
|
||||
request
|
||||
.agent_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.resource_limits.clone()),
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CHAT] Force recreate container failed: {}", e);
|
||||
AppError::with_message(
|
||||
shared_types::error_codes::ERR_CONTAINER_ERROR,
|
||||
format!("Container recreation failed: {}", e),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
container_info
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_CHAT] Container ready: user_id={}, container_id={}, ip={}",
|
||||
user_id, container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
// 🔍 检测 user_id 变化:同一个 project_id 被不同的 user_id 请求
|
||||
// 这通常意味着负载测试脚本使用了多个不同的 user_id,会导致创建多个容器浪费资源
|
||||
if let Some(existing_info) = state.get_project(&project_id) {
|
||||
if let Some(existing_user_id) = existing_info.user_id() {
|
||||
if existing_user_id != user_id {
|
||||
warn!(
|
||||
"⚠️ [USER_ID_MISMATCH] Detected user_id change for project_id: \
|
||||
project_id={}, original user_id={}, new user_id={}, time={}. \
|
||||
This may be caused by load test scripts using different user_ids, \
|
||||
which creates multiple containers and wastes resources. \
|
||||
Please ensure the same project_id uses the same user_id in your test scripts.",
|
||||
project_id,
|
||||
existing_user_id,
|
||||
user_id,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🛡️ 关键修复:容器创建成功后立即插入 DuckDB 记录
|
||||
// 这样可以防止孤立容器清理器误判并清理刚创建的容器
|
||||
//
|
||||
// 必须在 gRPC 请求之前就插入记录,因为:
|
||||
// 1. 孤立容器清理器会检查 DuckDB 中是否存在该 user_id 的记录
|
||||
// 2. 如果记录不存在,容器会被判定为孤立并清理
|
||||
// 3. gRPC 请求是异步的,可能需要较长时间才能返回
|
||||
ensure_project_mapping_in_state(&state, &user_id, &project_id, &container_info, &request)?;
|
||||
|
||||
// 请求到达时立即更新活动时间(不等待请求执行结果)
|
||||
// 这样可以防止在 gRPC 请求期间被 cleanup_task 误清理
|
||||
// 注意:这里使用 project_id 而不是 user_id,因为 DuckDB 的 key 是 project_id
|
||||
state.update_activity(&project_id);
|
||||
debug!(
|
||||
"🔄 [COMPUTER_CHAT] Updated activity time: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 5. 创建项目工作目录(在用户容器内)
|
||||
// Computer Agent Runner 需要在用户工作区内为 project_id 创建子目录
|
||||
if let Err(e) = ensure_project_workspace_exists(
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
&user_id,
|
||||
&project_id,
|
||||
)
|
||||
.await {
|
||||
error!("[COMPUTER_CHAT] Failed to create project workspace: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_WORKSPACE_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
// 6. 注册 VNC 后端到 Pingora(用于 WebSocket 代理)
|
||||
if let Some(ref pingora_service) = state.pingora_service {
|
||||
pingora_service.add_vnc_backend(&user_id, &container_info.container_ip);
|
||||
debug!(
|
||||
"🔗 [COMPUTER_CHAT] VNC backend registered: user_id={} -> {}",
|
||||
user_id, container_info.container_ip
|
||||
);
|
||||
}
|
||||
|
||||
// 6.5. 🆕 主动查询 Agent 状态 (User Request)
|
||||
// 在转发请求前,主动查询 Agent 状态,确保状态是最新的。
|
||||
// 这有助于在容器重启后,确认 Agent 是否真正处于空闲状态。
|
||||
{
|
||||
// 💫 使用实时 IP 获取,避免 restart 后 IP 过期的问题
|
||||
let grpc_addr_result = async {
|
||||
let container_ip = get_realtime_container_ip(
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("IP resolution error: {}", e))?;
|
||||
|
||||
Ok::<_, String>(format!(
|
||||
"{}:{}",
|
||||
container_ip,
|
||||
shared_types::GRPC_DEFAULT_PORT
|
||||
))
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Ok(grpc_addr) = grpc_addr_result {
|
||||
debug!("[COMPUTER_CHAT] Checking Agent status: {}", grpc_addr);
|
||||
if let Ok(mut client) = state.grpc_pool.get_client(&grpc_addr).await {
|
||||
let status_req = shared_types::grpc::GetStatusRequest {
|
||||
project_id: project_id.clone(),
|
||||
session_id: "".to_string(), // 我们只关心 project 级别的状态
|
||||
};
|
||||
|
||||
let mut grpc_request = crate::grpc::new_request_with_locale(status_req, locale);
|
||||
grpc_request.set_timeout(std::time::Duration::from_secs(5));
|
||||
|
||||
match client.get_status(grpc_request).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.into_inner().status;
|
||||
info!(
|
||||
"📊 [COMPUTER_CHAT] Agent current status: project_id={}, status={}",
|
||||
project_id, status
|
||||
);
|
||||
// 如果状态是 idle,我们可以更有信心地继续
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[COMPUTER_CHAT] Failed to get Agent status: {}", e);
|
||||
// Query failed不阻止请求继续,可能是网络波动,让后续的 Chat 请求去处理
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 🆕 自动查找 session_id 逻辑
|
||||
// 如果用户没有传递 session_id,尝试从状态中查找最新的 session_id
|
||||
let session_id_to_use = match &request.session_id {
|
||||
Some(sid) if !sid.is_empty() => {
|
||||
debug!("[COMPUTER_CHAT] Using session_id: {}", sid);
|
||||
sid.clone()
|
||||
}
|
||||
_ => {
|
||||
// 用户没有传递 session_id,尝试查找最新的
|
||||
match state.get_project(&project_id) {
|
||||
Some(project_info) => {
|
||||
let existing_session_id = project_info.session_id();
|
||||
match existing_session_id {
|
||||
Some(sid) if !sid.is_empty() => {
|
||||
info!(
|
||||
"🔄 [COMPUTER_CHAT] No session_id provided, auto using latest session: project_id={}, session_id={}",
|
||||
project_id, sid
|
||||
);
|
||||
sid.to_string()
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
"[COMPUTER_CHAT] Project exists, creating new session"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
debug!("[COMPUTER_CHAT] No project, creating new session");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 克隆 request 并修改 session_id
|
||||
let mut request_for_forward = request.clone();
|
||||
request_for_forward.session_id = if session_id_to_use.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(session_id_to_use)
|
||||
};
|
||||
// 🆕 自动查找 session_id 逻辑结束
|
||||
|
||||
// 8. 转发请求到容器服务(使用 gRPC)
|
||||
let result = forward_computer_request_to_container(
|
||||
&request_for_forward, // 使用修改后的 request
|
||||
&project_id,
|
||||
&container_info,
|
||||
&state.grpc_pool,
|
||||
locale,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 8. 更新会话映射(填充所有三个映射表,保持一致性)
|
||||
// 无论请求成功还是失败,只要响应中包含 session_id,都要更新映射
|
||||
// 这样用户可以通过 SSE 接口获取错误通知,而不会收到 SESSION_EXPIRED 错误
|
||||
if let Some(chat_response) = &result.data {
|
||||
let session_id = chat_response.session_id.clone();
|
||||
|
||||
// 只有当 session_id 非空时才更新映射
|
||||
if !session_id.is_empty() {
|
||||
info!(
|
||||
"🔗 [COMPUTER_CHAT] Associated session: session_id={} -> user_id={}, project_id={}, success={}",
|
||||
session_id,
|
||||
user_id,
|
||||
project_id,
|
||||
result.is_success()
|
||||
);
|
||||
|
||||
// 从 Runtime API 获取最新容器信息,避免使用过期 IP
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CHAT] Failed to get runtime: {}", e);
|
||||
AppError::internal_server_error(&format!("Failed to get runtime: {}", e))
|
||||
})?;
|
||||
|
||||
let container_info = match runtime
|
||||
.get_container_info_by_identifier(
|
||||
&user_id,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(info)) => {
|
||||
info!(
|
||||
"🔄 [COMPUTER_CHAT] Getting latest container info from Runtime API: user_id={}, container_id={}, container_ip={}",
|
||||
user_id, info.container_id, info.container_ip
|
||||
);
|
||||
info
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Container not found in runtime: user_id={}, using cached container info",
|
||||
user_id
|
||||
);
|
||||
// 使用之前获取的容器信息
|
||||
container_info.clone()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Failed to get container info from runtime: user_id={}, error={}, using cached container info",
|
||||
user_id, e
|
||||
);
|
||||
// 使用之前获取的容器信息
|
||||
container_info.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// ComputerAgentRunner 模式:每个 project 独立记录
|
||||
// 使用真正的 project_id 作为 map_key,user_id 存储在数据字段中
|
||||
let map_key = project_id.clone();
|
||||
|
||||
// 检查是否已存在该 project_id 的记录
|
||||
if let Some(existing_info) = state.get_project(&map_key) {
|
||||
// 已存在:更新信息
|
||||
let mut updated_info = (*existing_info).clone();
|
||||
|
||||
// 更新活动时间
|
||||
updated_info.update_activity();
|
||||
updated_info.update_session(session_id.clone());
|
||||
|
||||
// 更新扩展信息
|
||||
updated_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(shared_types::ServiceType::ComputerAgentRunner),
|
||||
);
|
||||
|
||||
state.insert_project(map_key.clone(), Arc::new(updated_info));
|
||||
|
||||
// 更新会话映射
|
||||
state.update_session(&map_key, &session_id);
|
||||
|
||||
info!(
|
||||
"🔄 [COMPUTER_CHAT] Updated existing container mapping: user_id={}, project_id={}, session_id={} (last_activity refreshed)",
|
||||
user_id, project_id, session_id
|
||||
);
|
||||
} else {
|
||||
// 不存在:创建新的 ProjectAndContainerInfo
|
||||
let mut project_info = shared_types::ProjectAndContainerInfo::new(map_key.clone());
|
||||
|
||||
// 设置 user_id(ComputerAgentRunner 模式)
|
||||
project_info.set_user_id(Some(user_id.clone()));
|
||||
// 设置 pod_id(共享容器模式)
|
||||
project_info.set_pod_id(request.pod_id.clone());
|
||||
|
||||
// 更新会话ID
|
||||
project_info.update_session(session_id.clone());
|
||||
|
||||
// 更新扩展信息(容器、模型配置等)
|
||||
project_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(shared_types::ServiceType::ComputerAgentRunner),
|
||||
);
|
||||
|
||||
state.insert_project(map_key.clone(), Arc::new(project_info));
|
||||
|
||||
// 更新会话映射
|
||||
state.update_session(&map_key, &session_id);
|
||||
|
||||
info!(
|
||||
"🆕 [COMPUTER_CHAT] Created new container mapping: user_id={}, project_id={}, session_id={}",
|
||||
user_id, project_id, session_id
|
||||
);
|
||||
}
|
||||
|
||||
if result.is_success() {
|
||||
info!(
|
||||
"✅ [COMPUTER_CHAT] Request processed: user_id={}, project_id={}, session_id={} (all mappings updated)",
|
||||
user_id, project_id, session_id
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Request failed but session mapping saved: user_id={}, project_id={}, session_id={}, code={}, message={}",
|
||||
user_id, project_id, session_id, result.code, result.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !result.is_success()
|
||||
&& result
|
||||
.data
|
||||
.as_ref()
|
||||
.map_or(true, |d| d.session_id.is_empty())
|
||||
{
|
||||
error!(
|
||||
"❌ [COMPUTER_CHAT] Container service returned error (no session_id): user_id={}, project_id={}, code={}, message={}",
|
||||
user_id, project_id, result.code, result.message
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 转发请求到容器内的 agent_runner 服务(仅使用 gRPC)
|
||||
///
|
||||
/// 与 RCoder 的 forward_request_to_container_service 类似,
|
||||
/// 但专门用于 ComputerAgentRunner 模式。
|
||||
async fn forward_computer_request_to_container(
|
||||
request: &ComputerChatRequest,
|
||||
project_id: &str,
|
||||
container_info: &ContainerBasicInfo,
|
||||
grpc_pool: &Arc<crate::grpc::GrpcChannelPool>,
|
||||
locale: &'static str,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) -> HttpResult<ChatResponse> {
|
||||
info!(
|
||||
"📤 [COMPUTER_FORWARD] Forwarding request to container (gRPC): user_id={}, project_id={}, session_id={:?}, container_id={}",
|
||||
request.user_id, project_id, request.session_id, container_info.container_id
|
||||
);
|
||||
|
||||
// 直接使用 gRPC 的健康检查机制,不额外检查容器状态
|
||||
// gRPC 连接失败会自动返回错误,由上层处理
|
||||
|
||||
// 从 service_url 提取 gRPC 地址
|
||||
// 🆕 使用实时 IP 获取,避免 restart 后 IP 过期的问题
|
||||
let mut grpc_addr = match get_realtime_container_ip(
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
rcoder_prefix,
|
||||
computer_prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_FORWARD] Real-time IP resolution failed: {}, trying to extract from service_url",
|
||||
e
|
||||
);
|
||||
match extract_grpc_addr_with_port(
|
||||
&container_info.service_url,
|
||||
shared_types::GRPC_DEFAULT_PORT,
|
||||
) {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
error!("[COMPUTER_FORWARD] Failed to extract gRPC address: {}", e);
|
||||
return HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ADDR_ERROR,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"📡 [COMPUTER_FORWARD] gRPC address: {}, prompt_len={}, attachments={}",
|
||||
grpc_addr,
|
||||
request.prompt.len(),
|
||||
request.attachments.len()
|
||||
);
|
||||
|
||||
// Computer Agent Runner 的工作目录路径
|
||||
// 在容器内:/app/computer-project-workspace/{user_id}/{project_id}
|
||||
let project_workspace = format!("{}/", project_dir(&request.user_id, &project_id));
|
||||
|
||||
debug!(
|
||||
"[COMPUTER_FORWARD] projectworkdirectory: {}",
|
||||
project_workspace
|
||||
);
|
||||
|
||||
// gRPC 调用(带重试机制)
|
||||
let max_retries = 2;
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
match crate::grpc::grpc_chat_with_pool(
|
||||
grpc_pool,
|
||||
&grpc_addr,
|
||||
project_id.to_string(),
|
||||
request.session_id.clone(),
|
||||
request.prompt.clone(),
|
||||
request.attachments.clone(),
|
||||
request.data_source_attachments.clone(),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(std::time::Duration::from_secs(300)), // 5 分钟超时,避免永久阻塞
|
||||
request.system_prompt.clone(),
|
||||
request.user_prompt.clone(),
|
||||
request.agent_config.clone(),
|
||||
Some(shared_types::ServiceType::ComputerAgentRunner), // ✅ 传递正确的 ServiceType
|
||||
Some(request.user_id.clone()), // ✅ 传递 user_id(ComputerAgentRunner 必需)
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(grpc_response) => {
|
||||
if grpc_response.success {
|
||||
let chat_response = crate::grpc::grpc_response_to_chat_response(grpc_response);
|
||||
info!(
|
||||
"✅ [COMPUTER_FORWARD] gRPC response success: project_id={}, session_id={}",
|
||||
chat_response.project_id, chat_response.session_id
|
||||
);
|
||||
return HttpResult::success(chat_response);
|
||||
} else {
|
||||
let error_msg = grpc_response
|
||||
.error
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
// 🎯 从 gRPC 响应中提取错误码(完整透传)
|
||||
let error_code = grpc_response
|
||||
.error_code
|
||||
.unwrap_or_else(|| shared_types::error_codes::ERR_AGENT_ERROR.to_string());
|
||||
error!(
|
||||
"❌ [COMPUTER_FORWARD] gRPC response error: code={}, message={}",
|
||||
error_code, error_msg
|
||||
);
|
||||
return HttpResult::error(&error_code, &error_msg);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_FORWARD] gRPC call failed (attempt {}/{}): {}",
|
||||
attempt, max_retries, e
|
||||
);
|
||||
|
||||
let should_retry = crate::grpc::should_retry_error(&e);
|
||||
|
||||
if should_retry && attempt < max_retries {
|
||||
info!(
|
||||
"🔄 [COMPUTER_FORWARD] Detected retryable error, re-resolving container IP and retrying..."
|
||||
);
|
||||
grpc_pool.remove(&grpc_addr);
|
||||
|
||||
// 重新获取最新容器 IP(容器可能已重建,IP 可能变化)
|
||||
match get_realtime_container_ip(
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
rcoder_prefix,
|
||||
computer_prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => {
|
||||
let new_addr = format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT);
|
||||
info!(
|
||||
"🔄 [COMPUTER_FORWARD] Container IP re-resolved: {} -> {}",
|
||||
grpc_addr, new_addr
|
||||
);
|
||||
grpc_addr = new_addr;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_FORWARD] Failed to re-resolve container IP, keeping old address: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
} else if !should_retry {
|
||||
error!(
|
||||
"[COMPUTER_FORWARD] Retry error, stopped retry: {}",
|
||||
e
|
||||
);
|
||||
last_error = Some(e);
|
||||
break;
|
||||
}
|
||||
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
if let Some(e) = last_error {
|
||||
error!(
|
||||
"❌ [COMPUTER_FORWARD] gRPC final call failed: {}, user_id={}, project_id={}",
|
||||
e, request.user_id, project_id
|
||||
);
|
||||
|
||||
// gRPC 通信失败,直接返回错误
|
||||
// 注:业务错误码(如 Agent busy)现在由 agent_runner 通过 grpc_response.error_code 返回
|
||||
HttpResult::error_with_locale(shared_types::error_codes::ERR_GRPC_ERROR, locale)
|
||||
} else {
|
||||
HttpResult::error_with_locale(shared_types::error_codes::ERR_UNKNOWN, locale)
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保 project_id 对应的工作目录存在
|
||||
///
|
||||
/// Computer Agent Runner 的目录结构:
|
||||
/// /app/computer-project-workspace/{user_id}/{project_id}/
|
||||
///
|
||||
/// 注意:这个目录已经在 docker-compose.yml 中挂载,可以直接在 rcoder 容器内创建
|
||||
///
|
||||
/// # 参数
|
||||
/// - `isolation_type`: 隔离类型(可选)
|
||||
/// - `tenant_id`: 租户 ID(可选)
|
||||
/// - `space_id`: 空间 ID(可选)
|
||||
/// - `user_id`: 用户 ID(当 isolation_type 为 project 时使用)
|
||||
/// - `project_id`: 项目 ID
|
||||
async fn ensure_project_workspace_exists(
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
// 根据隔离类型构建工作空间路径
|
||||
let project_workspace_path = std::path::PathBuf::from(build_computer_workspace_path(
|
||||
isolation_type,
|
||||
tenant_id,
|
||||
space_id,
|
||||
user_id,
|
||||
project_id,
|
||||
));
|
||||
|
||||
debug!(
|
||||
"📁 [COMPUTER_CHAT] Ensuring project workspace directory exists: {:?}",
|
||||
project_workspace_path
|
||||
);
|
||||
|
||||
// 直接在 rcoder 容器内创建目录
|
||||
tokio::fs::create_dir_all(&project_workspace_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"❌ [COMPUTER_CHAT] Failed to create project workspace: path={:?}, error={}",
|
||||
project_workspace_path, e
|
||||
);
|
||||
AppError::internal_server_error(&format!("Failed to create project workspace: {}", e))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_CHAT] Project workspace directory created: user_id={}, project_id={}, isolation_type={:?}, path={:?}",
|
||||
user_id, project_id, isolation_type, project_workspace_path
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确保 DuckDB 中存在 project_id 到容器的映射
|
||||
///
|
||||
/// 🛡️ 关键修复:容器创建成功后立即插入 DuckDB 记录
|
||||
///
|
||||
/// 这样可以防止孤立容器清理器误判并清理刚创建的容器,因为:
|
||||
/// 1. 孤立容器清理器会检查 DuckDB 中是否存在该 user_id 关联的记录
|
||||
/// 2. 如果记录不存在,容器会被判定为孤立并清理
|
||||
/// 3. gRPC 请求是异步的,可能需要较长时间才能返回
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `state` - 应用状态
|
||||
/// * `user_id` - 用户 ID
|
||||
/// * `project_id` - 项目 ID
|
||||
/// * `container_info` - 容器信息
|
||||
/// * `request` - 聊天请求
|
||||
fn ensure_project_mapping_in_state(
|
||||
state: &Arc<crate::router::AppState>,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
container_info: &ContainerBasicInfo,
|
||||
request: &ComputerChatRequest,
|
||||
) -> Result<(), AppError> {
|
||||
// 检查是否已存在该 project_id 的记录
|
||||
if let Some(existing_project) = state.get_project(project_id) {
|
||||
// 如果记录存在,检查容器ID是否变更
|
||||
if let Some(existing_container) = existing_project.container() {
|
||||
if existing_container.container_id != container_info.container_id {
|
||||
info!(
|
||||
"🔄 [COMPUTER_CHAT] Detected container change: project_id={}, old_cid={}, new_cid={}",
|
||||
project_id, existing_container.container_id, container_info.container_id
|
||||
);
|
||||
// 容器变更,继续执行后续的插入/更新逻辑(insert_project 会执行 upsert)
|
||||
} else {
|
||||
debug!(
|
||||
"🔄 [COMPUTER_CHAT] DuckDB record already exists and container unchanged: project_id={}",
|
||||
project_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
// 现有记录没有容器信息,继续更新
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的 ProjectAndContainerInfo
|
||||
let mut project_info = shared_types::ProjectAndContainerInfo::new(project_id.to_string());
|
||||
|
||||
// 设置 user_id(ComputerAgentRunner 模式)
|
||||
project_info.set_user_id(Some(user_id.to_string()));
|
||||
// 设置 pod_id(共享容器模式)
|
||||
project_info.set_pod_id(request.pod_id.clone());
|
||||
|
||||
// 更新容器信息
|
||||
project_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(shared_types::ServiceType::ComputerAgentRunner),
|
||||
);
|
||||
|
||||
// 立即插入到 DuckDB
|
||||
state.insert_project(project_id.to_string(), Arc::new(project_info));
|
||||
|
||||
info!(
|
||||
"🆕 [COMPUTER_CHAT] Inserted DuckDB record (immediately after container creation): user_id={}, project_id={}, container_id={}",
|
||||
user_id, project_id, container_info.container_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSE 进度流处理器(复用现有的 agent_session_notification)
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,752 @@
|
||||
//! Computer Agent Runner VNC 桌面处理器
|
||||
//!
|
||||
//! 提供 VNC 桌面访问功能,允许用户通过 WebSocket 连接到容器内的 noVNC 服务。
|
||||
//!
|
||||
//! ## 端口说明
|
||||
//! - noVNC WebSocket: 6080 (容器内)
|
||||
//!
|
||||
//! ## 实现状态
|
||||
//!
|
||||
//! **当前版本已实现 Pingora WebSocket 透明代理。**
|
||||
//!
|
||||
//! 客户端应使用 Pingora 代理路径访问 VNC:
|
||||
//! - VNC 页面: `http://{proxy_host}/computer/vnc/{user_id}/{project_id}/vnc.html`
|
||||
//! - WebSocket: `ws://{proxy_host}/computer/vnc/{user_id}/{project_id}/websockify`
|
||||
//!
|
||||
//! Pingora 会自动将请求透明代理到对应用户容器的 noVNC 服务(端口 6080)。
|
||||
//!
|
||||
//! ## 安全说明
|
||||
//! - 生产环境中,客户端只通过代理地址访问,不直接暴露容器内部 IP
|
||||
//! - user_id 到 container_ip 的映射在 Pingora 内部管理
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::utils::I18nPath;
|
||||
use super::utils::get_locale_from_headers;
|
||||
use crate::{AppError, HttpResult, router::AppState, service::ComputerContainerManager};
|
||||
|
||||
/// VNC 桌面路径参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct DesktopPathParams {
|
||||
/// 用户 ID
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
#[schema(example = "proj_456")]
|
||||
pub project_id: String,
|
||||
}
|
||||
|
||||
/// VNC 桌面访问响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DesktopAccessResponse {
|
||||
/// 操作是否成功
|
||||
pub success: bool,
|
||||
|
||||
/// Pingora 代理的 VNC 访问 URL(推荐使用)
|
||||
#[schema(example = "/computer/vnc/user_123/proj_456/vnc.html")]
|
||||
pub proxy_vnc_url: String,
|
||||
|
||||
/// Pingora 代理的 WebSocket 连接 URL(推荐使用)
|
||||
#[schema(example = "/computer/vnc/user_123/proj_456/websockify")]
|
||||
pub proxy_websocket_url: String,
|
||||
|
||||
/// 直接访问的 noVNC URL(仅开发/测试使用)
|
||||
#[schema(example = "http://172.17.0.5:6080/vnc.html")]
|
||||
pub direct_vnc_url: String,
|
||||
|
||||
/// 直接访问的 WebSocket URL(仅开发/测试使用)
|
||||
#[schema(example = "ws://172.17.0.5:6080/websockify")]
|
||||
pub direct_websocket_url: String,
|
||||
|
||||
/// 容器 ID
|
||||
pub container_id: String,
|
||||
|
||||
/// 容器 IP 地址(内部 IP,不应直接暴露给外部客户端)
|
||||
pub container_ip: String,
|
||||
|
||||
/// 用户 ID
|
||||
pub user_id: String,
|
||||
|
||||
/// 项目 ID
|
||||
pub project_id: String,
|
||||
|
||||
/// 访问提示
|
||||
#[schema(example = "请使用 proxy_vnc_url 或 proxy_websocket_url 访问 VNC 桌面")]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 错误响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DesktopErrorResponse {
|
||||
/// 错误代码
|
||||
pub error: String,
|
||||
/// 错误消息
|
||||
pub message: String,
|
||||
/// 用户 ID
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
pub project_id: String,
|
||||
}
|
||||
|
||||
/// noVNC 默认端口
|
||||
const NOVNC_PORT: u16 = 6080;
|
||||
|
||||
/// 获取 VNC 桌面访问信息
|
||||
///
|
||||
/// 返回 VNC 桌面的访问 URL。
|
||||
/// 推荐使用 Pingora 代理路径(proxy_vnc_url / proxy_websocket_url)访问,
|
||||
/// 这样可以避免暴露容器内部 IP。
|
||||
///
|
||||
/// ## 访问方式
|
||||
/// - **推荐**: 使用 `proxy_vnc_url` 通过 Pingora 代理访问
|
||||
/// - **开发测试**: 可以使用 `direct_vnc_url` 直接访问(需要网络互通)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/desktop/{user_id}/{project_id}",
|
||||
params(
|
||||
("user_id" = String, Path, description = "用户 ID"),
|
||||
("project_id" = String, Path, description = "项目 ID")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功获取 VNC 访问信息",
|
||||
body = HttpResult<DesktopAccessResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"proxy_vnc_url": "/computer/vnc/user_123/proj_456/vnc.html",
|
||||
"proxy_websocket_url": "/computer/vnc/user_123/proj_456/websockify",
|
||||
"direct_vnc_url": "http://172.17.0.5:6080/vnc.html",
|
||||
"direct_websocket_url": "ws://172.17.0.5:6080/websockify",
|
||||
"container_id": "abc123def456",
|
||||
"container_ip": "172.17.0.5",
|
||||
"user_id": "user_123",
|
||||
"project_id": "proj_456",
|
||||
"message": "请使用 proxy_vnc_url 访问 VNC 桌面"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到用户容器",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_desktop_vnc",
|
||||
summary = "获取 VNC 桌面访问信息",
|
||||
description = "返回 VNC 桌面的访问 URL,推荐使用 Pingora 代理路径访问"
|
||||
)]
|
||||
#[instrument(skip(_state), fields(user_id = %params.user_id, project_id = %params.project_id))]
|
||||
pub async fn computer_desktop_vnc(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nPath(params): I18nPath<DesktopPathParams>,
|
||||
) -> Result<HttpResult<DesktopAccessResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let user_id = params.user_id.clone();
|
||||
let project_id = params.project_id.clone();
|
||||
|
||||
// 1. 验证参数
|
||||
if user_id.trim().is_empty() {
|
||||
error!("[DESKTOP_VNC] user_id is required");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
&shared_types::get_i18n_message("error.user_id_required", locale),
|
||||
));
|
||||
}
|
||||
|
||||
if project_id.trim().is_empty() {
|
||||
error!("[DESKTOP_VNC] project_id is required");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
&shared_types::get_i18n_message("error.project_id_required", locale),
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"🖥️ [DESKTOP_VNC] Getting VNC access info: user_id={}, project_id={}",
|
||||
user_id, project_id
|
||||
);
|
||||
|
||||
// 2. 查找用户容器
|
||||
let container_info = ComputerContainerManager::get_container_info(&user_id).await?;
|
||||
|
||||
let container_info = match container_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
warn!("[DESKTOP_VNC] Container not found: user_id={}", user_id);
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_CONTAINER_NOT_FOUND,
|
||||
locale,
|
||||
&shared_types::get_i18n_message("error.container_not_found", locale),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"📦 [DESKTOP_VNC] Found container: container_id={}, ip={}",
|
||||
container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
// 3. 构建 VNC 访问 URL
|
||||
let container_ip = &container_info.container_ip;
|
||||
|
||||
// Pingora 代理路径(推荐使用)
|
||||
let proxy_vnc_url = format!("/computer/vnc/{}/{}/vnc.html", user_id, project_id);
|
||||
let proxy_websocket_url = format!("/computer/vnc/{}/{}/websockify", user_id, project_id);
|
||||
|
||||
// 直接访问路径(仅开发测试使用)
|
||||
let direct_vnc_url = format!("http://{}:{}/vnc.html", container_ip, NOVNC_PORT);
|
||||
let direct_websocket_url = format!("ws://{}:{}/websockify", container_ip, NOVNC_PORT);
|
||||
|
||||
// 4. 返回访问信息
|
||||
let response = DesktopAccessResponse {
|
||||
success: true,
|
||||
proxy_vnc_url: proxy_vnc_url.clone(),
|
||||
proxy_websocket_url: proxy_websocket_url.clone(),
|
||||
direct_vnc_url,
|
||||
direct_websocket_url,
|
||||
container_id: container_info.container_id.clone(),
|
||||
container_ip: container_ip.clone(),
|
||||
user_id: user_id.clone(),
|
||||
project_id: project_id.clone(),
|
||||
message: "请使用 proxy_vnc_url 或 proxy_websocket_url 通过 Pingora 代理访问 VNC 桌面"
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [DESKTOP_VNC] VNC access info generated: user_id={}, proxy_vnc_url={}",
|
||||
user_id, proxy_vnc_url
|
||||
);
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
}
|
||||
|
||||
/// VNC 桌面代理路径参数(用于 Pingora 代理)
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct VncProxyPathParams {
|
||||
/// 用户 ID
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
#[schema(example = "proj_456")]
|
||||
pub project_id: String,
|
||||
/// 剩余路径(可选)
|
||||
#[schema(example = "vnc.html", nullable = true)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// VNC 桌面代理路径(通过 Pingora 代理)
|
||||
///
|
||||
/// 这是一个占位实现,用于生成 OpenAPI 文档。
|
||||
/// 实际的 VNC 代理请求会通过 Pingora 透明代理到容器的 noVNC 服务。
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - `GET /computer/vnc/{user_id}/{project_id}/vnc.html` - VNC 桌面页面
|
||||
/// - `GET /computer/vnc/{user_id}/{project_id}/websockify` - VNC WebSocket 连接
|
||||
/// - `GET /computer/vnc/{user_id}/{project_id}/{*path}` - 其他 noVNC 资源
|
||||
///
|
||||
/// ## 实现说明
|
||||
/// 完整的 WebSocket 代理需要:
|
||||
/// 1. HTTP Upgrade 处理
|
||||
/// 2. 双向 WebSocket 帧转发
|
||||
/// 3. 连接生命周期管理
|
||||
///
|
||||
/// 当前实现使用 Pingora 透明代理,客户端请求会直接代理到容器内部服务。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/vnc/{user_id}/{project_id}/{*path}",
|
||||
params(
|
||||
("user_id" = String, Path, description = "用户 ID"),
|
||||
("project_id" = String, Path, description = "项目 ID"),
|
||||
("path" = Option<String>, Path, description = "剩余路径,如 vnc.html, websockify 等")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功访问 VNC 资源",
|
||||
body = String,
|
||||
example = "<!DOCTYPE html>\\n<html>\\n<head><title>noVNC</title></head>\\n<body>noVNC Client</body>\\n</html>"
|
||||
),
|
||||
(
|
||||
status = 101,
|
||||
description = "WebSocket 升级响应",
|
||||
body = String
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到用户容器或资源不存在",
|
||||
body = HttpResult<DesktopErrorResponse>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"code": "PROXY_REDIRECT",
|
||||
"message": "请使用 Pingora 代理路径访问 VNC 桌面,路径: /computer/vnc/user_123/proj_456/vnc.html",
|
||||
"tid": null
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_vnc_proxy",
|
||||
summary = "VNC 桌面代理",
|
||||
description = r#"
|
||||
通过 Pingora 代理访问容器的 VNC 桌面服务。
|
||||
|
||||
## 访问方式
|
||||
|
||||
### VNC 桌面页面
|
||||
```
|
||||
GET /computer/vnc/{user_id}/{project_id}/vnc.html
|
||||
```
|
||||
|
||||
### WebSocket 连接
|
||||
```
|
||||
GET /computer/vnc/{user_id}/{project_id}/websockify
|
||||
```
|
||||
|
||||
### 其他资源
|
||||
```
|
||||
GET /computer/vnc/{user_id}/{project_id}/{*path}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. 客户端请求到达 RCoder 服务
|
||||
2. Axum 路由器匹配到 VNC 代理路径
|
||||
3. 请求转发给 Pingora 代理服务
|
||||
4. Pingora 根据 user_id 查找容器 IP
|
||||
5. Pingora 透明代理请求到容器的 noVNC 服务(端口 6080)
|
||||
6. 响应返回给客户端
|
||||
|
||||
## 使用示例
|
||||
|
||||
```javascript
|
||||
// 访问 VNC 桌面页面
|
||||
window.open('/computer/vnc/user_123/proj_456/vnc.html', '_blank');
|
||||
|
||||
// 或在 iframe 中嵌入
|
||||
<iframe src="/computer/vnc/user_123/proj_456/vnc.html" width="100%" height="600"></iframe>
|
||||
```
|
||||
"#
|
||||
)]
|
||||
#[allow(dead_code)]
|
||||
pub async fn computer_desktop_proxy(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
I18nPath((user_id, project_id, path)): I18nPath<(String, String, Option<String>)>,
|
||||
) -> impl IntoResponse {
|
||||
// 占位实现:实际代理由 Pingora 处理
|
||||
// 这里返回 501 是为了表明这个端点应该由 Pingora 代理
|
||||
|
||||
let error_response = DesktopErrorResponse {
|
||||
error: "PROXY_REDIRECT".to_string(),
|
||||
message: format!(
|
||||
"请使用 Pingora 代理路径访问 VNC 桌面,路径: /computer/vnc/{}/{}/{}",
|
||||
user_id,
|
||||
project_id,
|
||||
path.as_deref().unwrap_or("vnc.html")
|
||||
),
|
||||
user_id,
|
||||
project_id,
|
||||
};
|
||||
|
||||
(StatusCode::NOT_IMPLEMENTED, Json(error_response))
|
||||
}
|
||||
|
||||
/// 音频代理路径参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct AudioProxyPathParams {
|
||||
/// 用户 ID
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
#[schema(example = "proj_456")]
|
||||
pub project_id: String,
|
||||
/// 剩余路径
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - 空字符串或 `index.html`: 音频播放器页面(HTTP 6090)
|
||||
/// - `ws`: 音频流 WebSocket(端口 6089)
|
||||
/// - `ws/{token}`: 带认证的音频流 WebSocket
|
||||
#[schema(example = "index.html", nullable = true)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// 音频流代理(通过 Pingora 代理)
|
||||
///
|
||||
/// 这是一个占位实现,用于生成 OpenAPI 文档。
|
||||
/// 实际的音频代理请求会通过 Pingora 透明代理到容器的音频服务。
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - `GET /computer/audio/{user_id}/{project_id}/` - 音频播放器页面
|
||||
/// - `GET /computer/audio/{user_id}/{project_id}/index.html` - 音频播放器页面
|
||||
/// - `GET /computer/audio/{user_id}/{project_id}/ws` - 音频流 WebSocket(Opus 编码)
|
||||
///
|
||||
/// ## 端口说明
|
||||
/// - **HTTP 6090**: 音频播放器页面和静态文件服务
|
||||
/// - **WebSocket 6089**: Opus 音频流(48kHz 双声道)
|
||||
///
|
||||
/// ## 工作原理
|
||||
/// 1. 客户端请求到达 RCoder 服务
|
||||
/// 2. Pingora 根据 user_id 查找容器 IP
|
||||
/// 3. 路径判断:`ws` 或 `ws/*` → WebSocket 端口 6089,其他 → HTTP 端口 6090
|
||||
/// 4. Pingora 透明代理请求到容器的音频服务
|
||||
/// 5. 音频流使用 WebSocket 传输 Opus 编码的音频数据
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/audio/{user_id}/{project_id}/{*path}",
|
||||
params(
|
||||
("user_id" = String, Path, description = "用户 ID"),
|
||||
("project_id" = String, Path, description = "项目 ID"),
|
||||
("path" = Option<String>, Path, description = "剩余路径")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功访问音频播放器页面",
|
||||
body = String,
|
||||
content_type = "text/html",
|
||||
example = "<!DOCTYPE html>\\n<html>\\n<head><title>Audio Player</title></head>\\n<body>...</body>\\n</html>"
|
||||
),
|
||||
(
|
||||
status = 101,
|
||||
description = "WebSocket 升级响应(音频流)",
|
||||
body = String
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到用户容器或资源不存在",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
),
|
||||
(
|
||||
status = 503,
|
||||
description = "代理服务未启用",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_audio_proxy",
|
||||
summary = "音频流代理",
|
||||
description = r#"
|
||||
通过 Pingora 代理访问容器的音频流服务。
|
||||
|
||||
## 访问方式
|
||||
|
||||
### 音频播放器页面
|
||||
```
|
||||
GET /computer/audio/{user_id}/{project_id}/
|
||||
GET /computer/audio/{user_id}/{project_id}/index.html
|
||||
```
|
||||
|
||||
### 音频流 WebSocket
|
||||
```
|
||||
WebSocket /computer/audio/{user_id}/{project_id}/ws
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. 客户端请求到达 RCoder 服务
|
||||
2. Axum 路由器匹配到音频代理路径
|
||||
3. 请求转发给 Pingora 代理服务
|
||||
4. Pingora 根据 user_id 查找容器 IP
|
||||
5. **路径判断**:
|
||||
- `path == "ws"` 或 `path.starts_with("ws/")` → WebSocket 端口 6089
|
||||
- 其他(包括空路径)→ HTTP 端口 6090
|
||||
6. Pingora 透明代理请求到容器的音频服务
|
||||
7. 响应返回给客户端
|
||||
|
||||
## 音频编码格式
|
||||
|
||||
- **编码**: Opus
|
||||
- **采样率**: 48kHz
|
||||
- **声道**: 双声道(Stereo)
|
||||
- **传输**: WebSocket 二进制帧
|
||||
|
||||
## 使用示例
|
||||
|
||||
```javascript
|
||||
// 访问音频播放器页面
|
||||
window.open('/computer/audio/user_123/proj_456/', '_blank');
|
||||
|
||||
// 连接音频流 WebSocket
|
||||
const ws = new WebSocket('ws://localhost:8088/computer/audio/user_123/proj_456/ws');
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onmessage = (event) => {
|
||||
const opusData = new Uint8Array(event.data);
|
||||
// 解码 Opus → PCM → 播放
|
||||
};
|
||||
```
|
||||
"#
|
||||
)]
|
||||
#[allow(dead_code)]
|
||||
pub async fn computer_audio_proxy(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
I18nPath((user_id, project_id, path)): I18nPath<(String, String, Option<String>)>,
|
||||
) -> impl IntoResponse {
|
||||
let error_response = DesktopErrorResponse {
|
||||
error: "PROXY_REDIRECT".to_string(),
|
||||
message: format!(
|
||||
"请使用 Pingora 代理路径访问音频服务,路径: /computer/audio/{}/{}/{}",
|
||||
user_id,
|
||||
project_id,
|
||||
path.as_deref().unwrap_or("")
|
||||
),
|
||||
user_id,
|
||||
project_id,
|
||||
};
|
||||
|
||||
(StatusCode::NOT_IMPLEMENTED, Json(error_response))
|
||||
}
|
||||
|
||||
/// IME 输入法代理路径参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct ImeProxyPathParams {
|
||||
/// 用户 ID
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
#[schema(example = "proj_456")]
|
||||
pub project_id: String,
|
||||
/// 剩余路径
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - `connect`: IME WebSocket 连接端点(端口 6091)
|
||||
/// - 其他值会被转发到 IME 服务
|
||||
#[schema(example = "connect", nullable = true)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// IME 输入法代理(通过 Pingora 代理)
|
||||
///
|
||||
/// 这是一个占位实现,用于生成 OpenAPI 文档。
|
||||
/// 实际的 IME 代理请求会通过 Pingora 透明代理到容器的 IME 服务。
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - `WebSocket /computer/ime/{user_id}/{project_id}/connect` - IME WebSocket 连接
|
||||
///
|
||||
/// ## 端口说明
|
||||
/// - **WebSocket 6091**: IME 输入法透传服务
|
||||
///
|
||||
/// ## 工作原理
|
||||
/// 客户端本地输入法(浏览器 IME)通过 WebSocket 发送文本到 Pingora,
|
||||
/// Pingora 代理到容器 IME 服务,容器使用 xdotool 将文本输入到远程桌面。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/ime/{user_id}/{project_id}/{*path}",
|
||||
params(
|
||||
("user_id" = String, Path, description = "用户 ID"),
|
||||
("project_id" = String, Path, description = "项目 ID"),
|
||||
("path" = Option<String>, Path, description = "剩余路径")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 101,
|
||||
description = "WebSocket 升级响应(IME 连接)",
|
||||
body = String
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到用户容器",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
),
|
||||
(
|
||||
status = 503,
|
||||
description = "代理服务未启用",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_ime_proxy",
|
||||
summary = "IME 输入法代理",
|
||||
description = r#"
|
||||
通过 Pingora 代理访问容器的 IME 输入法透传服务。
|
||||
|
||||
## 访问方式
|
||||
|
||||
### IME WebSocket 连接
|
||||
```
|
||||
WebSocket /computer/ime/{user_id}/{project_id}/connect
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **客户端**: 浏览器本地 IME 输入中文
|
||||
2. **WebSocket 发送**: 将文本通过 WebSocket 发送到 Pingora
|
||||
3. **Pingora 代理**: 根据 user_id 查找容器 IP,代理到端口 6091
|
||||
4. **容器 IME 服务**: 接收文本,使用 xdotool 输入到远程桌面
|
||||
5. **远程桌面**: 显示输入的文本
|
||||
|
||||
## 消息格式
|
||||
|
||||
### 客户端 → 容器
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"text": "你好,世界",
|
||||
"method": "xdotool"
|
||||
}
|
||||
```
|
||||
|
||||
### 容器 → 客户端
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "文本已输入"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```javascript
|
||||
// 连接 IME WebSocket
|
||||
const imeWs = new WebSocket('ws://localhost:8088/computer/ime/user_123/proj_456/connect');
|
||||
|
||||
// 发送文本
|
||||
imeWs.send(JSON.stringify({
|
||||
type: 'text',
|
||||
text: '测试中文输入',
|
||||
method: 'xdotool'
|
||||
}));
|
||||
|
||||
// 接收响应
|
||||
imeWs.onmessage = (event) => {
|
||||
const response = JSON.parse(event.data);
|
||||
console.log('IME 响应:', response);
|
||||
};
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 文本长度限制:1000 字符
|
||||
- 危险控制字符过滤(NULL, ESC)
|
||||
- 使用 `--` 参数分隔符防止命令注入
|
||||
"#
|
||||
)]
|
||||
#[allow(dead_code)]
|
||||
pub async fn computer_ime_proxy(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
I18nPath((user_id, project_id, path)): I18nPath<(String, String, Option<String>)>,
|
||||
) -> impl IntoResponse {
|
||||
let error_response = DesktopErrorResponse {
|
||||
error: "PROXY_REDIRECT".to_string(),
|
||||
message: format!(
|
||||
"请使用 Pingora 代理路径访问 IME 服务,路径: /computer/ime/{}/{}/{}",
|
||||
user_id,
|
||||
project_id,
|
||||
path.as_deref().unwrap_or("connect")
|
||||
),
|
||||
user_id,
|
||||
project_id,
|
||||
};
|
||||
|
||||
(StatusCode::NOT_IMPLEMENTED, Json(error_response))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/// 检查 VNC 服务是否可用
|
||||
///
|
||||
/// 尝试连接到容器的 noVNC 端口,验证服务是否运行
|
||||
#[allow(dead_code)]
|
||||
async fn check_vnc_available(container_ip: &str) -> bool {
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
let addr = format!("{}:{}", container_ip, NOVNC_PORT);
|
||||
|
||||
match timeout(Duration::from_secs(2), TcpStream::connect(&addr)).await {
|
||||
Ok(Ok(_)) => {
|
||||
info!("[DESKTOP_VNC] VNC reachable: {}", addr);
|
||||
true
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!("[DESKTOP_VNC] VNC connectionfailed: {} - {}", addr, e);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("[DESKTOP_VNC] VNC connectiontimeout: {}", addr);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 单元测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vnc_url_format() {
|
||||
let ip = "172.17.0.5";
|
||||
let user_id = "user_123";
|
||||
let project_id = "proj_456";
|
||||
|
||||
// Pingora 代理路径
|
||||
let proxy_vnc_url = format!("/computer/vnc/{}/{}/vnc.html", user_id, project_id);
|
||||
let proxy_ws_url = format!("/computer/vnc/{}/{}/websockify", user_id, project_id);
|
||||
|
||||
// 直接访问路径
|
||||
let direct_vnc_url = format!("http://{}:{}/vnc.html", ip, NOVNC_PORT);
|
||||
let direct_ws_url = format!("ws://{}:{}/websockify", ip, NOVNC_PORT);
|
||||
|
||||
assert_eq!(proxy_vnc_url, "/computer/vnc/user_123/proj_456/vnc.html");
|
||||
assert_eq!(proxy_ws_url, "/computer/vnc/user_123/proj_456/websockify");
|
||||
assert_eq!(direct_vnc_url, "http://172.17.0.5:6080/vnc.html");
|
||||
assert_eq!(direct_ws_url, "ws://172.17.0.5:6080/websockify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desktop_access_response_serialization() {
|
||||
let response = DesktopAccessResponse {
|
||||
success: true,
|
||||
proxy_vnc_url: "/computer/vnc/user_123/proj_456/vnc.html".to_string(),
|
||||
proxy_websocket_url: "/computer/vnc/user_123/proj_456/websockify".to_string(),
|
||||
direct_vnc_url: "http://172.17.0.5:6080/vnc.html".to_string(),
|
||||
direct_websocket_url: "ws://172.17.0.5:6080/websockify".to_string(),
|
||||
container_id: "abc123".to_string(),
|
||||
container_ip: "172.17.0.5".to_string(),
|
||||
user_id: "user_123".to_string(),
|
||||
project_id: "proj_456".to_string(),
|
||||
message: "Test message".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
assert!(json.contains("proxy_vnc_url"));
|
||||
assert!(json.contains("proxy_websocket_url"));
|
||||
assert!(json.contains("direct_vnc_url"));
|
||||
assert!(json.contains("user_123"));
|
||||
}
|
||||
}
|
||||
290
qiming-rcoder/crates/rcoder/src/handler/debug_handler.rs
Normal file
290
qiming-rcoder/crates/rcoder/src/handler/debug_handler.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! 调试 API 处理器
|
||||
//!
|
||||
//! 提供用于问题排查的调试接口
|
||||
//!
|
||||
//! ⚠️ 警告:这些接口仅用于开发和调试,生产环境应禁用或添加权限控制
|
||||
|
||||
use axum::{Json, extract::State, http::HeaderMap};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::router::AppState;
|
||||
use shared_types::{
|
||||
HttpResult,
|
||||
error_codes::{ERR_INTERNAL_SERVER_ERROR, ERR_INVALID_PARAMS},
|
||||
get_i18n_message,
|
||||
};
|
||||
|
||||
use super::utils::get_locale_from_headers;
|
||||
|
||||
/// DuckDB 查询请求
|
||||
#[derive(Debug, Deserialize, Serialize, ToSchema)]
|
||||
pub struct DebugSqlQueryRequest {
|
||||
/// SQL 查询语句
|
||||
///
|
||||
/// 支持的表:
|
||||
/// - `projects`: 项目记录表
|
||||
/// - `containers`: 容器记录表
|
||||
///
|
||||
/// 示例查询:
|
||||
/// - `SELECT * FROM projects`
|
||||
/// - `SELECT * FROM containers`
|
||||
/// - `SELECT project_id, session_id, container_id FROM projects WHERE session_id = 'xxx'`
|
||||
#[schema(example = "SELECT * FROM projects LIMIT 10")]
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
/// DuckDB 查询响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DebugSqlQueryResponse {
|
||||
/// 查询结果的列名
|
||||
pub columns: Vec<String>,
|
||||
/// 查询结果的行数据(每行是一个列名到值的映射)
|
||||
pub rows: Vec<serde_json::Value>,
|
||||
/// 返回的行数
|
||||
pub row_count: usize,
|
||||
/// 执行时间(毫秒)
|
||||
pub execution_time_ms: u64,
|
||||
}
|
||||
|
||||
/// 存储统计信息响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DebugStorageStatsResponse {
|
||||
/// 项目总数
|
||||
pub total_projects: usize,
|
||||
/// 容器总数
|
||||
pub total_containers: usize,
|
||||
/// 活跃会话数
|
||||
pub active_sessions: usize,
|
||||
/// 按服务类型统计的项目数
|
||||
pub projects_by_service_type: std::collections::HashMap<String, usize>,
|
||||
}
|
||||
|
||||
/// 执行 DuckDB SQL 查询(调试用)
|
||||
///
|
||||
/// ⚠️ 此接口仅用于开发和调试目的
|
||||
///
|
||||
/// 支持任意 SELECT 查询,用于查看内存数据库中的数据状态
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/debug/sql",
|
||||
request_body(
|
||||
content = DebugSqlQueryRequest,
|
||||
description = "SQL 查询请求",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "查询成功",
|
||||
body = HttpResult<DebugSqlQueryResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"columns": ["project_id", "session_id", "container_id"],
|
||||
"rows": [
|
||||
{"project_id": "user_123", "session_id": "xxx", "container_id": "yyy"}
|
||||
],
|
||||
"row_count": 1,
|
||||
"execution_time_ms": 5
|
||||
},
|
||||
"code": "0"
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "SQL 语法错误或不支持的操作",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "debug",
|
||||
operation_id = "debug_sql_query",
|
||||
summary = "执行 DuckDB SQL 查询(调试用)",
|
||||
description = "执行任意 SELECT 查询,用于查看内存数据库中的数据状态。\n\n⚠️ 仅用于开发和调试,生产环境应禁用此接口。"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn debug_sql_query(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DebugSqlQueryRequest>,
|
||||
) -> HttpResult<DebugSqlQueryResponse> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
debug!("[DEBUG_SQL] executing: {}", request.sql);
|
||||
|
||||
// 安全检查:Only SELECT queries are allowed
|
||||
let sql_trimmed = request.sql.trim().to_uppercase();
|
||||
if !sql_trimmed.starts_with("SELECT") {
|
||||
warn!("[DEBUG_SQL] non-SELECT query: {}", request.sql);
|
||||
return HttpResult::error_with_message(
|
||||
ERR_INVALID_PARAMS,
|
||||
locale,
|
||||
&get_i18n_message("error.select_only", locale),
|
||||
);
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
match execute_sql_query(&state.projects, &request.sql) {
|
||||
Ok((columns, rows)) => {
|
||||
let row_count = rows.len();
|
||||
let execution_time_ms = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
debug!(
|
||||
"✅ [DEBUG_SQL] Query succeeded: {} rows, {} ms",
|
||||
row_count, execution_time_ms
|
||||
);
|
||||
|
||||
HttpResult::success(DebugSqlQueryResponse {
|
||||
columns,
|
||||
rows,
|
||||
row_count,
|
||||
execution_time_ms,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("[DEBUG_SQL] Query failed: {}", e);
|
||||
HttpResult::error_with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.query_execution_failed", locale),
|
||||
e
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取存储统计信息(调试用)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/debug/storage/stats",
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "获取统计信息成功",
|
||||
body = HttpResult<DebugStorageStatsResponse>
|
||||
)
|
||||
),
|
||||
tag = "debug",
|
||||
operation_id = "debug_storage_stats",
|
||||
summary = "获取 DuckDB 存储统计信息(调试用)"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn debug_storage_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_headers: HeaderMap,
|
||||
) -> HttpResult<DebugStorageStatsResponse> {
|
||||
let stats = state.projects.get_stats();
|
||||
|
||||
let mut projects_by_service_type = std::collections::HashMap::new();
|
||||
for (st, count) in stats.projects_by_service_type {
|
||||
projects_by_service_type.insert(st.to_string(), count);
|
||||
}
|
||||
|
||||
HttpResult::success(DebugStorageStatsResponse {
|
||||
total_projects: stats.total_projects,
|
||||
total_containers: stats.total_containers,
|
||||
active_sessions: stats.active_sessions,
|
||||
projects_by_service_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// 快捷查询:获取所有项目
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/debug/projects",
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "获取项目列表成功",
|
||||
body = HttpResult<DebugSqlQueryResponse>
|
||||
)
|
||||
),
|
||||
tag = "debug",
|
||||
operation_id = "debug_list_projects",
|
||||
summary = "获取所有项目记录(调试用)"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn debug_list_projects(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> HttpResult<DebugSqlQueryResponse> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let start_time = std::time::Instant::now();
|
||||
let sql = "SELECT project_id, session_id, service_type, container_id, user_id, agent_status_name, created_at, last_activity FROM projects ORDER BY last_activity DESC";
|
||||
|
||||
match execute_sql_query(&state.projects, sql) {
|
||||
Ok((columns, rows)) => HttpResult::success(DebugSqlQueryResponse {
|
||||
columns,
|
||||
row_count: rows.len(),
|
||||
rows,
|
||||
execution_time_ms: start_time.elapsed().as_millis() as u64,
|
||||
}),
|
||||
Err(e) => HttpResult::error_with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.query_execution_failed", locale),
|
||||
e
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 快捷查询:获取所有容器
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/debug/containers",
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "获取容器列表成功",
|
||||
body = HttpResult<DebugSqlQueryResponse>
|
||||
)
|
||||
),
|
||||
tag = "debug",
|
||||
operation_id = "debug_list_containers",
|
||||
summary = "获取所有容器记录(调试用)"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn debug_list_containers(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> HttpResult<DebugSqlQueryResponse> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let start_time = std::time::Instant::now();
|
||||
let sql = "SELECT container_id, container_name, container_ip, service_type, status, created_at, last_activity FROM containers ORDER BY last_activity DESC";
|
||||
|
||||
match execute_sql_query(&state.projects, sql) {
|
||||
Ok((columns, rows)) => HttpResult::success(DebugSqlQueryResponse {
|
||||
columns,
|
||||
row_count: rows.len(),
|
||||
rows,
|
||||
execution_time_ms: start_time.elapsed().as_millis() as u64,
|
||||
}),
|
||||
Err(e) => HttpResult::error_with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.query_execution_failed", locale),
|
||||
e
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 SQL 查询的内部函数
|
||||
fn execute_sql_query(
|
||||
adapter: &crate::storage::ProjectAdapter,
|
||||
sql: &str,
|
||||
) -> Result<(Vec<String>, Vec<serde_json::Value>), String> {
|
||||
// 获取底层 DuckDB 存储
|
||||
adapter.execute_raw_query(sql)
|
||||
}
|
||||
39
qiming-rcoder/crates/rcoder/src/handler/health_handler.rs
Normal file
39
qiming-rcoder/crates/rcoder/src/handler/health_handler.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! 健康检查处理器
|
||||
|
||||
use axum::Json;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 健康检查响应结构
|
||||
///
|
||||
/// 统一的服务健康检查响应格式
|
||||
#[derive(serde::Serialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
/// 服务状态
|
||||
#[schema(example = "healthy")]
|
||||
pub status: String,
|
||||
/// 时间戳
|
||||
#[schema(example = "2024-01-15T10:30:00Z")]
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
/// 服务名称
|
||||
#[schema(example = "rcoder-ai-service")]
|
||||
pub service: String,
|
||||
}
|
||||
|
||||
/// 健康检查端点
|
||||
///
|
||||
/// 检查服务的健康状态
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
responses(
|
||||
(status = 200, description = "服务健康状态", body = HealthResponse)
|
||||
),
|
||||
tag = "system"
|
||||
)]
|
||||
pub async fn health_check() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "healthy".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
service: "rcoder-ai-service".to_string(),
|
||||
})
|
||||
}
|
||||
37
qiming-rcoder/crates/rcoder/src/handler/mod.rs
Normal file
37
qiming-rcoder/crates/rcoder/src/handler/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! HTTP 路由和处理器模块
|
||||
mod agent_cancel_handler;
|
||||
mod agent_session_notification;
|
||||
mod agent_status_handler;
|
||||
mod agent_stop_handler;
|
||||
mod chat_handler;
|
||||
mod computer_agent_status_handler;
|
||||
mod computer_agent_stop_handler;
|
||||
mod computer_chat_handler;
|
||||
mod computer_desktop_handler;
|
||||
mod health_handler;
|
||||
pub mod pod_handler;
|
||||
pub mod proxy_api;
|
||||
pub mod proxy_handler_api;
|
||||
pub mod utils;
|
||||
|
||||
// 调试处理器(仅在启用 debug feature 时可用)
|
||||
#[cfg(feature = "debug")]
|
||||
mod debug_handler;
|
||||
|
||||
pub use agent_cancel_handler::*;
|
||||
pub use agent_session_notification::*;
|
||||
pub use agent_status_handler::*;
|
||||
pub use agent_stop_handler::*;
|
||||
pub use chat_handler::*;
|
||||
pub use computer_agent_status_handler::*;
|
||||
pub use computer_agent_stop_handler::*;
|
||||
pub use computer_chat_handler::*;
|
||||
pub use computer_desktop_handler::*;
|
||||
pub use health_handler::*;
|
||||
pub use pod_handler::*;
|
||||
pub use proxy_api::*;
|
||||
pub use proxy_handler_api::*;
|
||||
|
||||
// 仅在启用 debug feature 时导出 debug handler
|
||||
#[cfg(feature = "debug")]
|
||||
pub use debug_handler::*;
|
||||
2383
qiming-rcoder/crates/rcoder/src/handler/pod_handler.rs
Normal file
2383
qiming-rcoder/crates/rcoder/src/handler/pod_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
196
qiming-rcoder/crates/rcoder/src/handler/proxy_api.rs
Normal file
196
qiming-rcoder/crates/rcoder/src/handler/proxy_api.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
//! Pingora 反向代理 API 数据结构
|
||||
//!
|
||||
//! 为 OpenAPI 文档提供 Pingora 代理接口的数据结构定义。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Pingora 代理路径参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct ProxyPathParams {
|
||||
/// 目标端口号
|
||||
#[schema(example = 3000)]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// Pingora 代理路径和尾部参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct ProxyPathWithTailParams {
|
||||
/// 目标端口号
|
||||
#[schema(example = 3000)]
|
||||
pub port: u16,
|
||||
/// 路径尾部 (剩余的路径部分)
|
||||
#[schema(example = "/api/users")]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Pingora 代理响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProxyResponse {
|
||||
/// 代理状态
|
||||
#[schema(example = true)]
|
||||
pub success: bool,
|
||||
/// 目标端口
|
||||
#[schema(example = 3000)]
|
||||
pub target_port: u16,
|
||||
/// 目标主机
|
||||
#[schema(example = "127.0.0.1")]
|
||||
pub target_host: String,
|
||||
/// 目标URL
|
||||
#[schema(example = "http://127.0.0.1:3000/api/users")]
|
||||
pub target_url: String,
|
||||
/// 响应时间 (毫秒)
|
||||
#[schema(example = 45)]
|
||||
pub response_time_ms: Option<u64>,
|
||||
/// 负载均衡器信息
|
||||
pub load_balancer: LoadBalancerInfo,
|
||||
}
|
||||
|
||||
/// 负载均衡器信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LoadBalancerInfo {
|
||||
/// 负载均衡算法
|
||||
#[schema(example = "round-robin")]
|
||||
pub algorithm: String,
|
||||
/// 是否启用健康检查
|
||||
#[schema(example = true)]
|
||||
pub health_check_enabled: bool,
|
||||
/// 后端服务数量
|
||||
#[schema(example = 3)]
|
||||
pub backend_count: usize,
|
||||
}
|
||||
|
||||
/// Pingora 代理状态信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProxyStatus {
|
||||
/// 代理服务状态
|
||||
#[schema(example = "running")]
|
||||
pub status: String,
|
||||
/// 监听端口
|
||||
#[schema(example = 8080)]
|
||||
pub listen_port: u16,
|
||||
/// 默认后端端口
|
||||
#[schema(example = 3000)]
|
||||
pub default_backend_port: u16,
|
||||
/// 默认后端主机
|
||||
#[schema(example = "127.0.0.1")]
|
||||
pub default_backend_host: String,
|
||||
/// 已配置的后端服务
|
||||
pub backends: Vec<BackendInfo>,
|
||||
/// 负载均衡配置
|
||||
pub load_balancer: LoadBalancerInfo,
|
||||
}
|
||||
|
||||
/// 后端服务信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct BackendInfo {
|
||||
/// 端口号
|
||||
#[schema(example = 3000)]
|
||||
pub port: u16,
|
||||
/// 主机地址
|
||||
#[schema(example = "127.0.0.1")]
|
||||
pub host: String,
|
||||
/// 健康状态
|
||||
#[schema(example = "healthy")]
|
||||
pub health_status: String,
|
||||
/// 最后检查时间
|
||||
#[schema(example = "2025-01-12T10:30:00Z")]
|
||||
pub last_check: String,
|
||||
}
|
||||
|
||||
/// Pingora 代理错误响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProxyErrorResponse {
|
||||
/// 错误代码
|
||||
#[schema(example = "BACKEND_NOT_FOUND")]
|
||||
pub error: String,
|
||||
/// 错误消息
|
||||
#[schema(example = "未找到端口 9999 对应的后端服务")]
|
||||
pub message: String,
|
||||
/// 目标端口
|
||||
#[schema(example = 9999)]
|
||||
pub target_port: u16,
|
||||
/// 请求时间戳
|
||||
#[schema(example = "2025-01-12T10:30:00Z")]
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// 代理统计信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProxyStats {
|
||||
/// 总请求数
|
||||
#[schema(example = 15420)]
|
||||
pub total_requests: u64,
|
||||
/// 成功请求数
|
||||
#[schema(example = 15200)]
|
||||
pub successful_requests: u64,
|
||||
/// 失败请求数
|
||||
#[schema(example = 220)]
|
||||
pub failed_requests: u64,
|
||||
/// 平均响应时间 (毫秒)
|
||||
#[schema(example = 35.5)]
|
||||
pub avg_response_time_ms: f64,
|
||||
/// 当前活跃连接数
|
||||
#[schema(example = 12)]
|
||||
pub active_connections: u32,
|
||||
/// 按端口统计
|
||||
pub port_stats: Vec<PortStats>,
|
||||
}
|
||||
|
||||
/// 端口统计信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PortStats {
|
||||
/// 端口号
|
||||
#[schema(example = 3000)]
|
||||
pub port: u16,
|
||||
/// 请求数
|
||||
#[schema(example = 8560)]
|
||||
pub requests: u64,
|
||||
/// 成功率
|
||||
#[schema(example = 0.987)]
|
||||
pub success_rate: f64,
|
||||
/// 平均响应时间
|
||||
#[schema(example = 28.3)]
|
||||
pub avg_response_time_ms: f64,
|
||||
}
|
||||
|
||||
/// 代理配置信息
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProxyConfig {
|
||||
/// 监听端口
|
||||
#[schema(example = 8080)]
|
||||
pub listen_port: u16,
|
||||
/// 默认后端端口
|
||||
#[schema(example = 3000)]
|
||||
pub default_backend_port: u16,
|
||||
/// 默认后端主机
|
||||
#[schema(example = "127.0.0.1")]
|
||||
pub default_backend_host: String,
|
||||
/// 负载均衡算法
|
||||
#[schema(example = "round-robin")]
|
||||
pub load_balancing_algorithm: String,
|
||||
/// 健康检查配置
|
||||
pub health_check: HealthCheckConfig,
|
||||
}
|
||||
|
||||
/// 健康检查配置
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthCheckConfig {
|
||||
/// 是否启用
|
||||
#[schema(example = true)]
|
||||
pub enabled: bool,
|
||||
/// 检查间隔 (秒)
|
||||
#[schema(example = 5)]
|
||||
pub interval_seconds: u32,
|
||||
/// 超时时间 (秒)
|
||||
#[schema(example = 3)]
|
||||
pub timeout_seconds: u32,
|
||||
/// 健康阈值
|
||||
#[schema(example = 2)]
|
||||
pub healthy_threshold: u32,
|
||||
/// 不健康阈值
|
||||
#[schema(example = 3)]
|
||||
pub unhealthy_threshold: u32,
|
||||
}
|
||||
585
qiming-rcoder/crates/rcoder/src/handler/proxy_handler_api.rs
Normal file
585
qiming-rcoder/crates/rcoder/src/handler/proxy_handler_api.rs
Normal file
@@ -0,0 +1,585 @@
|
||||
//! Pingora 代理 API 处理函数
|
||||
//!
|
||||
//! 提供 Pingora 代理相关的 API 接口,主要用于文档展示和状态查询。
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::proxy_api::*;
|
||||
use crate::router::AppState;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
/// Pingora 代理状态查询
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/status",
|
||||
tag = "proxy",
|
||||
summary = "获取 Pingora 代理服务状态",
|
||||
description = "返回当前 Pingora 代理服务的运行状态和配置信息",
|
||||
responses(
|
||||
(status = 200, description = "成功获取代理状态", body = ProxyStatus),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<ProxyStatus>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() || state.pingora_service.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service is not enabled or unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let svc = state.pingora_service.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_SERVICE_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy service instance is unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let conf = svc.config().clone();
|
||||
|
||||
// 收集后端列表
|
||||
let backends_arc = svc.backends();
|
||||
let backends_map = backends_arc.read().await;
|
||||
let backend_count = backends_map.len();
|
||||
// 收集后端列表(从缓存快照)
|
||||
let health_map = svc.health_snapshot().await;
|
||||
let backends = backends_map
|
||||
.iter()
|
||||
.map(|(port, host)| {
|
||||
if let Some(health) = health_map.get(port) {
|
||||
let last_check_str = DateTime::<Utc>::from(health.last_check).to_rfc3339();
|
||||
BackendInfo {
|
||||
port: *port,
|
||||
host: host.clone(),
|
||||
health_status: health.status.as_str().to_string(),
|
||||
last_check: last_check_str,
|
||||
}
|
||||
} else {
|
||||
BackendInfo {
|
||||
port: *port,
|
||||
host: host.clone(),
|
||||
health_status: "unknown".to_string(),
|
||||
last_check: Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let status = ProxyStatus {
|
||||
status: "running".to_string(),
|
||||
listen_port: conf.listen_port,
|
||||
default_backend_port: conf.default_backend_port,
|
||||
default_backend_host: conf.backend_host.clone(),
|
||||
backends,
|
||||
load_balancer: LoadBalancerInfo {
|
||||
algorithm: if svc.use_round_robin {
|
||||
"round-robin".to_string()
|
||||
} else {
|
||||
"ketama".to_string()
|
||||
},
|
||||
health_check_enabled: true,
|
||||
backend_count,
|
||||
},
|
||||
};
|
||||
|
||||
info!(
|
||||
"Query proxy status: port {}, default backend: {}:{} (backend count: {})",
|
||||
status.listen_port, status.default_backend_host, status.default_backend_port, backend_count
|
||||
);
|
||||
|
||||
Ok(Json(status))
|
||||
}
|
||||
|
||||
/// Pingora 代理统计信息
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/stats",
|
||||
tag = "proxy",
|
||||
summary = "获取 Pingora 代理统计信息",
|
||||
description = "返回代理服务的请求统计和性能指标",
|
||||
responses(
|
||||
(status = 200, description = "成功获取统计信息", body = ProxyStats),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<ProxyStats>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
// 需要代理配置启用且服务可用
|
||||
if state.config.proxy_config.is_none() || state.pingora_service.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service is not enabled or unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let svc = state.pingora_service.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_SERVICE_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy service instance is unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let m = &svc.metrics;
|
||||
|
||||
let total_requests = m.total_requests.load(Ordering::Relaxed);
|
||||
let successful_requests = m.successful_responses.load(Ordering::Relaxed);
|
||||
let failed_requests = m.failed_responses.load(Ordering::Relaxed);
|
||||
let avg_response_time_ms = m.avg_response_time_ms();
|
||||
|
||||
// 按端口统计
|
||||
let snaps = m.port_snapshots();
|
||||
let port_stats = snaps
|
||||
.into_iter()
|
||||
.map(|ps| {
|
||||
let total = ps.successes + ps.failures;
|
||||
let success_rate = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(ps.successes as f64) / (total as f64)
|
||||
};
|
||||
let avg_ms = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(ps.total_response_time_ns as f64) / 1_000_000.0 / (total as f64)
|
||||
};
|
||||
PortStats {
|
||||
port: ps.port,
|
||||
requests: ps.requests,
|
||||
success_rate,
|
||||
avg_response_time_ms: avg_ms,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let stats = ProxyStats {
|
||||
total_requests,
|
||||
successful_requests,
|
||||
failed_requests,
|
||||
avg_response_time_ms,
|
||||
active_connections: m.active_connections.load(Ordering::Relaxed) as u32,
|
||||
port_stats,
|
||||
};
|
||||
|
||||
info!(
|
||||
"Query proxy stats: total requests {}, success {}, failed {}, avg response time {:.2}ms",
|
||||
stats.total_requests,
|
||||
stats.successful_requests,
|
||||
stats.failed_requests,
|
||||
stats.avg_response_time_ms
|
||||
);
|
||||
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
/// Pingora 代理配置查询
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/config",
|
||||
tag = "proxy",
|
||||
summary = "获取 Pingora 代理配置",
|
||||
description = "返回当前代理服务的配置信息",
|
||||
responses(
|
||||
(status = 200, description = "成功获取配置信息", body = ProxyConfig),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<ProxyConfig>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() || state.pingora_service.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service is not enabled or unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let svc = state.pingora_service.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_SERVICE_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy service instance is unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let conf = svc.config();
|
||||
let app_conf = &state.config;
|
||||
let hc_conf = &app_conf
|
||||
.proxy_config
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_CONFIG_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy config unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.health_check;
|
||||
|
||||
let config = ProxyConfig {
|
||||
listen_port: conf.listen_port,
|
||||
default_backend_port: conf.default_backend_port,
|
||||
default_backend_host: conf.backend_host.clone(),
|
||||
load_balancing_algorithm: if svc.use_round_robin {
|
||||
"round-robin".to_string()
|
||||
} else {
|
||||
"ketama".to_string()
|
||||
},
|
||||
health_check: HealthCheckConfig {
|
||||
enabled: hc_conf.enabled,
|
||||
interval_seconds: hc_conf.interval_seconds as u32,
|
||||
timeout_seconds: hc_conf.timeout_seconds as u32,
|
||||
healthy_threshold: hc_conf.healthy_threshold,
|
||||
unhealthy_threshold: hc_conf.unhealthy_threshold,
|
||||
},
|
||||
};
|
||||
|
||||
info!(
|
||||
"Query proxy config: listen port {}, default backend: {}:{}, LB algorithm: {}",
|
||||
config.listen_port,
|
||||
config.default_backend_host,
|
||||
config.default_backend_port,
|
||||
config.load_balancing_algorithm
|
||||
);
|
||||
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
/// 代理到指定端口(重定向到 Pingora)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/{port}",
|
||||
tag = "proxy",
|
||||
summary = "重定向到 Pingora 代理(无路径)",
|
||||
description = r#"
|
||||
重定向请求到 Pingora 代理服务,无额外路径。
|
||||
|
||||
## 工作原理
|
||||
此接口会返回 307 重定向,将请求转发到 Pingora 代理服务的实际端口。
|
||||
|
||||
## 实际代理路径
|
||||
真正的代理由 Pingora 处理,路径格式为:
|
||||
```
|
||||
GET /proxy/{port}/
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
```bash
|
||||
# 访问此接口
|
||||
GET /proxy/3000
|
||||
|
||||
# 返回 307 重定向到:
|
||||
# http://127.0.0.1:{pingora_port}/proxy/3000/
|
||||
|
||||
# Pingora 代理到:
|
||||
# 127.0.0.1:3000/
|
||||
```
|
||||
"#,
|
||||
params(
|
||||
("port" = u16, Path, description = "目标端口号")
|
||||
),
|
||||
responses(
|
||||
(status = 307, description = "重定向到 Pingora 代理服务", body = String),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_to_port(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(port): Path<u16>,
|
||||
) -> Result<axum::response::Response, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service not enabled".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let proxy_config = state.config.proxy_config.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_CONFIG_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy config unavailable".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let listen_port = proxy_config.listen_port;
|
||||
|
||||
// 重定向到 Pingora 真实代理端口
|
||||
let location = format!("http://127.0.0.1:{}/proxy/{}", listen_port, port);
|
||||
|
||||
let resp = axum::http::Response::builder()
|
||||
.status(StatusCode::TEMPORARY_REDIRECT)
|
||||
.header(axum::http::header::LOCATION, location)
|
||||
.body(axum::body::Body::empty())
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "RESPONSE_BUILD_ERROR".to_string(),
|
||||
message: format!("failed to build response: {}", e),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// 代理到指定端口和路径(重定向到 Pingora)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/{port}/{*path}",
|
||||
tag = "proxy",
|
||||
summary = "重定向到 Pingora 代理(含路径)",
|
||||
description = r#"
|
||||
重定向请求到 Pingora 代理服务,包含完整路径信息。
|
||||
|
||||
## 工作原理
|
||||
此接口会返回 307 重定向,将请求转发到 Pingora 代理服务的实际端口和路径。
|
||||
|
||||
## 实际代理路径
|
||||
真正的代理由 Pingora 处理,路径格式为:
|
||||
```
|
||||
GET /proxy/{port}/{path}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
```bash
|
||||
# 访问此接口
|
||||
GET /proxy/8080/api/users
|
||||
|
||||
# 返回 307 重定向到:
|
||||
# http://127.0.0.1:{pingora_port}/proxy/8080/api/users
|
||||
|
||||
# Pingora 代理到:
|
||||
# 127.0.0.1:8080/api/users
|
||||
```
|
||||
"#,
|
||||
params(
|
||||
("port" = u16, Path, description = "目标端口号"),
|
||||
("path" = String, Path, description = "目标路径")
|
||||
),
|
||||
responses(
|
||||
(status = 307, description = "重定向到 Pingora 代理服务", body = String),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_to_port_with_path(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((port, path)): Path<(u16, String)>,
|
||||
) -> Result<axum::response::Response, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service not enabled".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let proxy_config = state.config.proxy_config.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_CONFIG_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy config unavailable".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let listen_port = proxy_config.listen_port;
|
||||
|
||||
let target_path = if path.is_empty() || path == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
|
||||
// 重定向到 Pingora 真实代理端口(保持相同的路径)
|
||||
let location = format!(
|
||||
"http://127.0.0.1:{}/proxy/{}{}",
|
||||
listen_port, port, target_path
|
||||
);
|
||||
|
||||
let resp = axum::http::Response::builder()
|
||||
.status(StatusCode::TEMPORARY_REDIRECT)
|
||||
.header(axum::http::header::LOCATION, location)
|
||||
.body(axum::body::Body::empty())
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "RESPONSE_BUILD_ERROR".to_string(),
|
||||
message: format!("failed to build response: {}", e),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// 通用代理请求处理器
|
||||
async fn proxy_request_handler(
|
||||
state: Arc<AppState>,
|
||||
port: u16,
|
||||
path: Option<String>,
|
||||
) -> Result<Json<ProxyResponse>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service not enabled".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let proxy_config = state.config.proxy_config.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_CONFIG_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy config unavailable".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let target_host = &proxy_config.backend_host;
|
||||
let target_path = path.unwrap_or_else(|| "/".to_string());
|
||||
let target_url = format!("http://{}:{}{}", target_host, port, target_path);
|
||||
|
||||
debug!("Mock proxy request: {} -> {}", port, target_url);
|
||||
|
||||
// 这里只是用于文档展示,实际的代理由 Pingora 服务器处理
|
||||
// 如果用户访问这些接口,我们会返回信息,说明实际的代理在 Pingora 服务器端口
|
||||
|
||||
let response = ProxyResponse {
|
||||
success: true,
|
||||
target_port: port,
|
||||
target_host: target_host.clone(),
|
||||
target_url: target_url.clone(),
|
||||
response_time_ms: Some(35),
|
||||
load_balancer: LoadBalancerInfo {
|
||||
algorithm: "round-robin".to_string(),
|
||||
health_check_enabled: true,
|
||||
backend_count: 1,
|
||||
},
|
||||
};
|
||||
|
||||
info!(
|
||||
"Proxy request documentation demo: port {}, path {}, target: {}",
|
||||
port, target_path, target_url
|
||||
);
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// 查询参数
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ProxyQueryParams {
|
||||
/// 端口号(用于向后兼容)
|
||||
#[param(example = 3000)]
|
||||
pub port: Option<u16>,
|
||||
/// 路径(可选)
|
||||
#[param(example = "/api/users")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// 使用查询参数的代理方式(向后兼容)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy",
|
||||
tag = "proxy",
|
||||
summary = "使用查询参数代理(向后兼容)",
|
||||
description = "通过查询参数指定目标端口和路径,保持向后兼容性",
|
||||
params(
|
||||
ProxyQueryParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "代理成功", body = ProxyResponse),
|
||||
(status = 400, description = "缺少端口参数", body = ProxyErrorResponse),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_with_query_params(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<ProxyQueryParams>,
|
||||
) -> Result<Json<ProxyResponse>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
let port = params.port.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "MISSING_PORT".to_string(),
|
||||
message: "Missing port parameter".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = params.path.clone().unwrap_or_else(|| "/".to_string());
|
||||
warn!(
|
||||
"Using deprecated query parameter proxy method, recommended path format: /proxy/{}/{}",
|
||||
port, path
|
||||
);
|
||||
|
||||
proxy_request_handler(state, port, Some(path)).await
|
||||
}
|
||||
165
qiming-rcoder/crates/rcoder/src/handler/utils/grpc_addr.rs
Normal file
165
qiming-rcoder/crates/rcoder/src/handler/utils/grpc_addr.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! gRPC 地址解析工具
|
||||
//!
|
||||
//! 从 service_url 提取 gRPC 连接地址。
|
||||
|
||||
use crate::AppError;
|
||||
use shared_types::GRPC_DEFAULT_PORT;
|
||||
use shared_types::error_codes::ERR_GRPC_ADDR_ERROR;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// 从 service_url 提取 gRPC 地址(使用指定端口)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `service_url`: 服务 URL,格式如 `http://192.168.1.100:8086`
|
||||
/// - `grpc_port`: gRPC 端口号
|
||||
///
|
||||
/// # 返回
|
||||
/// 格式化的 gRPC 地址(`host:port` 格式)
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let addr = extract_grpc_addr_with_port("http://192.168.1.100:8086", 50051)?;
|
||||
/// assert_eq!(addr, "192.168.1.100:50051");
|
||||
/// ```
|
||||
pub fn extract_grpc_addr_with_port(service_url: &str, grpc_port: u16) -> Result<String, AppError> {
|
||||
// 自动添加 scheme 如果缺失(为了能够通过 Url::parse 解析)
|
||||
let url_str = if service_url.contains("://") {
|
||||
service_url.to_string()
|
||||
} else {
|
||||
format!("http://{}", service_url)
|
||||
};
|
||||
|
||||
let url = url::Url::parse(&url_str).map_err(|e| {
|
||||
AppError::with_message(ERR_GRPC_ADDR_ERROR, format!("Invalid service_url: {}", e))
|
||||
})?;
|
||||
|
||||
let host = url.host_str().ok_or_else(|| {
|
||||
AppError::with_i18n_key(ERR_GRPC_ADDR_ERROR, "error.grpc_service_url_missing_host")
|
||||
})?;
|
||||
|
||||
Ok(format!("{}:{}", host, grpc_port))
|
||||
}
|
||||
|
||||
/// 从 service_url 提取 gRPC 地址(使用默认端口)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `service_url`: 服务 URL,格式如 `http://192.168.1.100:8086`
|
||||
///
|
||||
/// # 返回
|
||||
/// 格式化的 gRPC 地址(`host:GRPC_DEFAULT_PORT` 格式)
|
||||
pub fn extract_grpc_addr(service_url: &str) -> Result<String, AppError> {
|
||||
extract_grpc_addr_with_port(service_url, GRPC_DEFAULT_PORT)
|
||||
}
|
||||
|
||||
/// 从 Docker API 实时获取容器 IP
|
||||
///
|
||||
/// 使用容器名称(如 `computer-agent-runner-user_123`)查询,
|
||||
/// 因为 container_id 在容器重启后会改变,但 container_name 是稳定的。
|
||||
///
|
||||
/// 查询顺序:Runtime find_container(带内部缓存)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `container_name`: 容器名称
|
||||
/// - `fallback_ip`: 回退 IP 地址
|
||||
/// - `rcoder_prefix`: RCoder 服务的容器前缀(从配置读取)
|
||||
/// - `computer_prefix`: ComputerAgentRunner 服务的容器前缀(从配置读取)
|
||||
pub async fn get_realtime_container_ip(
|
||||
container_name: &str,
|
||||
fallback_ip: &str,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) -> Result<String, String> {
|
||||
// 查询 Runtime API
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get runtime: {}", e))?;
|
||||
|
||||
// 使用配置化的前缀,而不是硬编码的 ServiceType::container_prefix()
|
||||
let (identifier, service_type) = if let Some(id) =
|
||||
container_name.strip_prefix(&format!("{}-", computer_prefix))
|
||||
{
|
||||
(id, shared_types::ServiceType::ComputerAgentRunner)
|
||||
} else if let Some(id) = container_name.strip_prefix(&format!("{}-", rcoder_prefix)) {
|
||||
(id, shared_types::ServiceType::RCoder)
|
||||
} else {
|
||||
return Ok(fallback_ip.to_string());
|
||||
};
|
||||
|
||||
// 通过 Runtime 的 find_container 查询容器 IP
|
||||
// find_container 会直接调用 Docker API 获取最新的容器信息
|
||||
match runtime.find_container(identifier, &service_type).await {
|
||||
Ok(Some(info)) if !info.container_ip.is_empty() => {
|
||||
debug!(
|
||||
"🔍 [IP_QUERY] Got container IP: container_name={}, ip={}",
|
||||
container_name, info.container_ip
|
||||
);
|
||||
Ok(info.container_ip)
|
||||
}
|
||||
Ok(_) => {
|
||||
// find_container 返回空或 IP 为空,使用 fallback
|
||||
info!(
|
||||
"⚠️ [IP_QUERY] find_container returned empty, using fallback: container_name={}",
|
||||
container_name
|
||||
);
|
||||
Ok(fallback_ip.to_string())
|
||||
}
|
||||
Err(e) => Err(format!("runtime find_container failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_http() {
|
||||
let result = extract_grpc_addr("http://192.168.1.100:8086").unwrap();
|
||||
assert_eq!(result, "192.168.1.100:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_https() {
|
||||
let result = extract_grpc_addr("https://example.com:443").unwrap();
|
||||
assert_eq!(result, "example.com:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_custom_port() {
|
||||
let result = extract_grpc_addr_with_port("http://192.168.1.100:8086", 9999).unwrap();
|
||||
assert_eq!(result, "192.168.1.100:9999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_without_port_in_url() {
|
||||
let result = extract_grpc_addr("http://192.168.1.100").unwrap();
|
||||
assert_eq!(result, "192.168.1.100:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_missing_scheme() {
|
||||
// 测试缺失 scheme,应该被自动补全为 http:// 并解析
|
||||
let result = extract_grpc_addr("192.168.1.100:8086").unwrap();
|
||||
assert_eq!(result, "192.168.1.100:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_ipv6() {
|
||||
// 测试 IPv6 地址
|
||||
let result = extract_grpc_addr("http://[::1]:8086").unwrap();
|
||||
assert_eq!(result, "[::1]:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_malformed() {
|
||||
// 测试无效 URL
|
||||
let result = extract_grpc_addr("http://");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_empty() {
|
||||
// 测试空字符串
|
||||
let result = extract_grpc_addr("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
502
qiming-rcoder/crates/rcoder/src/handler/utils/i18n_extractors.rs
Normal file
502
qiming-rcoder/crates/rcoder/src/handler/utils/i18n_extractors.rs
Normal file
@@ -0,0 +1,502 @@
|
||||
//! Locale-aware request extractors.
|
||||
//!
|
||||
//! These wrappers map Axum extractor rejections into unified `AppError` so
|
||||
//! HTTP error responses are consistently localized through `AppError::into_response`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
FromRequest, FromRequestParts, Json, Path, Query, Request,
|
||||
rejection::{JsonRejection, PathRejection, QueryRejection},
|
||||
},
|
||||
http::request::Parts,
|
||||
};
|
||||
use axum::http::Uri;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
/// Locale-aware JSON extractor.
|
||||
pub struct I18nJson<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for I18nJson<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Json::<T>::from_request(req, state).await {
|
||||
Ok(Json(value)) => Ok(Self(value)),
|
||||
Err(rejection) => Err(map_json_rejection(rejection)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Locale-aware query extractor.
|
||||
pub struct I18nQuery<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequestParts<S> for I18nQuery<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
Query<T>: FromRequestParts<S, Rejection = QueryRejection>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Query::<T>::from_request_parts(parts, state).await {
|
||||
Ok(Query(value)) => Ok(Self(value)),
|
||||
Err(rejection) => Err(map_query_rejection(rejection)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Locale-aware path extractor.
|
||||
pub struct I18nPath<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequestParts<S> for I18nPath<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
Path<T>: FromRequestParts<S, Rejection = PathRejection>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Path::<T>::from_request_parts(parts, state).await {
|
||||
Ok(Path(value)) => Ok(Self(value)),
|
||||
Err(rejection) => Err(map_path_rejection(rejection)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 JSON 或 Query 参数提取器
|
||||
///
|
||||
/// 支持两种输入方式:
|
||||
/// 1. JSON body: `{"project_id": "xxx"}`
|
||||
/// 2. Query params: `?project_id=xxx`
|
||||
///
|
||||
/// 如果两者同时存在,优先使用 JSON body。
|
||||
///
|
||||
/// 适用于需要同时兼容 GET(query)和 POST(body)两种调用方式的接口。
|
||||
pub struct I18nJsonOrQuery<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for I18nJsonOrQuery<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: DeserializeOwned + serde::Serialize + Send,
|
||||
Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
// 1. 使用 http::Uri 提取 query string
|
||||
let uri: Uri = req.uri().clone();
|
||||
let query_string = uri.query().unwrap_or("");
|
||||
|
||||
// 2. 解析 query string 为 serde_json::Value
|
||||
let query_value: serde_json::Value = if query_string.is_empty() {
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
} else {
|
||||
// 使用 serde_urlencoded 解析为 HashMap,然后转为 Value
|
||||
match serde_urlencoded::from_str::<HashMap<String, serde_json::Value>>(query_string) {
|
||||
Ok(map) => serde_json::Value::Object(map.into_iter().collect()),
|
||||
Err(_) => {
|
||||
return Err(AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 尝试解析 JSON body
|
||||
match Json::<T>::from_request(req, state).await {
|
||||
Ok(Json(json_value)) => {
|
||||
// 4. JSON 存在,合并两者(JSON 优先)
|
||||
let json_value_as_value = serde_json::to_value(&json_value)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let merged = deep_merge(query_value, json_value_as_value);
|
||||
// 反序列化为 T(只需要 DeserializeOwned)
|
||||
let result = serde_json::from_value(merged)
|
||||
.map_err(|_| AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
))?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
Err(_) => {
|
||||
// 5. JSON 解析失败,检查 query string 是否为空
|
||||
if query_string.is_empty() {
|
||||
// 两者都为空,返回参数错误
|
||||
return Err(AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
));
|
||||
}
|
||||
// 使用 query string 反序列化
|
||||
let result = serde_json::from_value(query_value)
|
||||
.map_err(|_| AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
))?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> I18nJsonOrQuery<T>
|
||||
where
|
||||
T: garde::Validate,
|
||||
T::Context: Default,
|
||||
{
|
||||
/// 校验并转换为 AppError
|
||||
///
|
||||
/// 使用方法:
|
||||
/// ```ignore
|
||||
/// let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
/// ```
|
||||
pub fn validate_into_app_error(self) -> Result<Self, AppError> {
|
||||
self.0.validate().map_err(shared_types::garde_err_to_app_error)?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 深度合并两个 JSON 对象
|
||||
///
|
||||
/// base: 基础值(query string)
|
||||
/// override: 覆盖值(JSON body)
|
||||
///
|
||||
/// 规则:override 中的非 null 值会覆盖 base 中的对应值
|
||||
fn deep_merge(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
|
||||
match (base, override_) {
|
||||
(serde_json::Value::Object(mut base_map), serde_json::Value::Object(override_map)) => {
|
||||
for (key, override_value) in override_map {
|
||||
let base_value = base_map.remove(&key).unwrap_or(serde_json::Value::Null);
|
||||
let merged_value = if override_value.is_null() {
|
||||
// override 值为 null,使用 base 的值
|
||||
base_value
|
||||
} else if override_value.is_object() && base_value.is_object() {
|
||||
// 两者都是对象,递归合并
|
||||
deep_merge(base_value, override_value)
|
||||
} else {
|
||||
// override 有值(非 null 且不是对象),直接使用 override
|
||||
override_value
|
||||
};
|
||||
base_map.insert(key, merged_value);
|
||||
}
|
||||
serde_json::Value::Object(base_map)
|
||||
}
|
||||
// 如果 override 不是对象,直接使用 override
|
||||
(_, override_) => override_,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Option<String> 中安全提取非空字符串
|
||||
pub fn require_field<'a>(value: &'a Option<String>, field_name: &str) -> Result<&'a str, AppError> {
|
||||
value
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.as_str())
|
||||
.ok_or_else(|| AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
&format!("{} is required", field_name),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_json_rejection(rejection: JsonRejection) -> AppError {
|
||||
use shared_types::error_codes::{ERR_INVALID_PARAMS, ERR_VALIDATION};
|
||||
|
||||
match rejection {
|
||||
JsonRejection::JsonDataError(_) => {
|
||||
AppError::with_i18n_key(ERR_VALIDATION, "error.validation")
|
||||
}
|
||||
JsonRejection::JsonSyntaxError(_)
|
||||
| JsonRejection::MissingJsonContentType(_)
|
||||
| JsonRejection::BytesRejection(_) => {
|
||||
AppError::with_i18n_key(ERR_INVALID_PARAMS, "error.invalid_params")
|
||||
}
|
||||
_ => AppError::with_i18n_key(ERR_INVALID_PARAMS, "error.invalid_params"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_query_rejection(_rejection: QueryRejection) -> AppError {
|
||||
AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
)
|
||||
}
|
||||
|
||||
fn map_path_rejection(_rejection: PathRejection) -> AppError {
|
||||
AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::to_bytes,
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{I18nJson, I18nJsonOrQuery, I18nPath, I18nQuery};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
value: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryParams {
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
/// 模拟 StopAgentQuery 结构体
|
||||
/// 注意:project_id 使用 Option 以测试缺失字段的情况
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
struct StopAgentQuery {
|
||||
#[serde(default)]
|
||||
project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pod_id: Option<String>,
|
||||
#[serde(default)]
|
||||
tenant_id: Option<String>,
|
||||
#[serde(default)]
|
||||
space_id: Option<String>,
|
||||
#[serde(default)]
|
||||
isolation_type: Option<String>,
|
||||
}
|
||||
|
||||
async fn json_handler(I18nJson(payload): I18nJson<Payload>) -> impl IntoResponse {
|
||||
payload.value.to_string()
|
||||
}
|
||||
|
||||
async fn query_handler(I18nQuery(params): I18nQuery<QueryParams>) -> impl IntoResponse {
|
||||
params.limit.to_string()
|
||||
}
|
||||
|
||||
async fn path_handler(I18nPath(id): I18nPath<u32>) -> impl IntoResponse {
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// I18nJsonOrQuery 测试处理器
|
||||
async fn stop_handler(I18nJsonOrQuery(params): I18nJsonOrQuery<StopAgentQuery>) -> impl IntoResponse {
|
||||
format!("project_id={}", params.project_id.unwrap_or_default())
|
||||
}
|
||||
|
||||
// ==================== I18nJsonOrQuery 测试 ====================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_with_only_json_body() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"project_id": "test_project"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"project_id=test_project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_with_only_query_string() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop?project_id=test_project")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"project_id=test_project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_with_json_body_and_query_string_prefers_json() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
// 发送 JSON body 和 query string,JSON 应该优先
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop?project_id=from_query")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"project_id": "from_json"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"project_id=from_json");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_with_query_string_and_all_fields() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop?project_id=proj123&pod_id=pod456&tenant_id=tenant789")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"project_id=proj123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_empty_body_and_empty_query_returns_error() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 应该返回 BAD_REQUEST 因为既没有 JSON body 也没有有效的 query string
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_invalid_json_returns_error() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"project_id":}"#)) // invalid JSON
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_missing_project_id_in_json() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
// JSON body 缺少 project_id 字段,但 serde 会成功解析(project_id 是 String 类型会默认null)
|
||||
// 不过 StopAgentQuery 的 project_id 是必填字段,这里用 empty string 测试
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 这里会成功解析,但 project_id 为空字符串
|
||||
// 实际的验证逻辑在 handler 中,不在 extractor 中
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ==================== 原有测试 ====================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_rejection_returns_bad_request() {
|
||||
let app = Router::new().route("/json", post(json_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/json")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"value":"oops"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_query_rejection_returns_bad_request() {
|
||||
let app = Router::new().route("/query", get(query_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/query?limit=abc")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_path_rejection_returns_bad_request() {
|
||||
let app = Router::new().route("/path/{id}", get(path_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/path/not-a-number")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
125
qiming-rcoder/crates/rcoder/src/handler/utils/locale.rs
Normal file
125
qiming-rcoder/crates/rcoder/src/handler/utils/locale.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! 语言检测工具
|
||||
//!
|
||||
//! 从 HTTP 请求中提取语言偏好
|
||||
//! 优先级:HTTP Header > 环境变量 > 默认值 (en-US)
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use shared_types::{SUPPORTED_LOCALES, parse_accept_language};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 默认语言(硬编码回退值)
|
||||
const FALLBACK_LOCALE: &str = "en-US";
|
||||
|
||||
/// 缓存的环境变量默认语言
|
||||
static DEFAULT_LOCALE_CACHED: OnceLock<&'static str> = OnceLock::new();
|
||||
|
||||
/// 从环境变量获取默认语言(带缓存)
|
||||
///
|
||||
/// 环境变量名:DEFAULT_LOCALE
|
||||
/// 如果未设置或无效,返回硬编码默认值 "en-US"
|
||||
///
|
||||
/// 使用 OnceLock 缓存,只在首次调用时读取环境变量
|
||||
fn get_default_locale_from_env() -> &'static str {
|
||||
*DEFAULT_LOCALE_CACHED.get_or_init(|| {
|
||||
if let Ok(locale) = std::env::var("DEFAULT_LOCALE") {
|
||||
// 验证是否为支持的语言
|
||||
if SUPPORTED_LOCALES.contains(&locale.as_str()) {
|
||||
// 匹配已知的静态字符串,避免内存泄漏
|
||||
match locale.as_str() {
|
||||
"zh-CN" => return "zh-CN",
|
||||
"zh-TW" => return "zh-TW",
|
||||
"en-US" => return "en-US",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
FALLBACK_LOCALE
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 HTTP 请求头获取语言
|
||||
///
|
||||
/// 语言检测优先级:
|
||||
/// 1. HTTP Accept-Language Header
|
||||
/// 2. 环境变量 DEFAULT_LOCALE
|
||||
/// 3. 硬编码默认值 "en-US"
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `headers` - HTTP 请求头
|
||||
///
|
||||
/// # Returns
|
||||
/// 语言代码,如 "zh-CN", "en-US"
|
||||
pub fn get_locale_from_headers(headers: &HeaderMap) -> &'static str {
|
||||
// 1. 首先尝试从 HTTP Header 获取
|
||||
if let Some(header) = headers.get("Accept-Language").and_then(|v| v.to_str().ok()) {
|
||||
// parse_accept_language 返回的总是支持的语言或默认值
|
||||
return parse_accept_language(Some(header));
|
||||
}
|
||||
|
||||
// 2. 尝试从环境变量获取(已缓存)
|
||||
get_default_locale_from_env()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::header::HeaderName;
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_zh_cn() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"zh-CN".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_locale_from_headers(&headers), "zh-CN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_zh_tw() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"zh-TW".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_locale_from_headers(&headers), "zh-TW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_zh_hk() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"zh-HK".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_locale_from_headers(&headers), "zh-TW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_en_us() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"en-US".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_locale_from_headers(&headers), "en-US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_none() {
|
||||
let headers = HeaderMap::new();
|
||||
// 无 Header 且无环境变量时,应返回默认值 en-US
|
||||
assert_eq!(get_locale_from_headers(&headers), "en-US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_unsupported() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"fr-FR".parse().unwrap(),
|
||||
);
|
||||
// 不支持的语言应返回默认值
|
||||
assert_eq!(get_locale_from_headers(&headers), "en-US");
|
||||
}
|
||||
}
|
||||
15
qiming-rcoder/crates/rcoder/src/handler/utils/mod.rs
Normal file
15
qiming-rcoder/crates/rcoder/src/handler/utils/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Handler 工具模块
|
||||
//!
|
||||
//! 提供 handler 层共享的工具函数和常量。
|
||||
|
||||
mod grpc_addr;
|
||||
mod i18n_extractors;
|
||||
mod locale;
|
||||
mod paths;
|
||||
|
||||
pub use grpc_addr::{
|
||||
extract_grpc_addr, extract_grpc_addr_with_port, get_realtime_container_ip,
|
||||
};
|
||||
pub use i18n_extractors::{I18nJson, I18nJsonOrQuery, I18nPath, I18nQuery, require_field};
|
||||
pub use locale::get_locale_from_headers;
|
||||
pub use paths::{COMPUTER_WORKSPACE_ROOT, WORKSPACE_ROOT, build_workspace_path, build_computer_workspace_path, project_dir, user_dir};
|
||||
244
qiming-rcoder/crates/rcoder/src/handler/utils/paths.rs
Normal file
244
qiming-rcoder/crates/rcoder/src/handler/utils/paths.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
//! Computer Use 模式的路径常量和辅助函数
|
||||
//!
|
||||
//! 统一管理容器内的项目工作空间路径,避免硬编码分散在各处。
|
||||
|
||||
/// 容器内 RCoder 项目工作空间的根目录
|
||||
///
|
||||
/// 容器内的目录结构(isolation_type=project):
|
||||
/// ```text
|
||||
/// /app/project_workspace/
|
||||
/// └── {project_id}/
|
||||
/// └── (项目文件)
|
||||
/// ```
|
||||
///
|
||||
/// 容器内的目录结构(isolation_type=tenant/space):
|
||||
/// ```text
|
||||
/// /app/project_workspace/
|
||||
/// └── {tenant_id}/
|
||||
/// └── {space_id}/
|
||||
/// └── {project_id}/
|
||||
/// └── (项目文件)
|
||||
/// ```
|
||||
pub const WORKSPACE_ROOT: &str = "/app/project_workspace";
|
||||
|
||||
/// 容器内 Computer Use 项目工作空间的根目录
|
||||
///
|
||||
/// 容器内的目录结构(isolation_type=project):
|
||||
/// ```text
|
||||
/// /app/computer-project-workspace/
|
||||
/// └── {user_id}/
|
||||
/// └── {project_id}/
|
||||
/// └── (项目文件)
|
||||
/// ```
|
||||
///
|
||||
/// 容器内的目录结构(isolation_type=tenant/space):
|
||||
/// ```text
|
||||
/// /app/computer-project-workspace/
|
||||
/// └── {tenant_id}/
|
||||
/// └── {space_id}/
|
||||
/// └── {project_id}/
|
||||
/// └── (项目文件)
|
||||
/// ```
|
||||
pub const COMPUTER_WORKSPACE_ROOT: &str = "/app/computer-project-workspace";
|
||||
|
||||
/// 构建用户目录路径(Computer Use 模式)
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let path = user_dir("user123");
|
||||
/// assert_eq!(path, "/app/computer-project-workspace/user123");
|
||||
/// ```
|
||||
pub fn user_dir(user_id: &str) -> String {
|
||||
format!("{}/{}", COMPUTER_WORKSPACE_ROOT, user_id)
|
||||
}
|
||||
|
||||
/// 构建项目目录路径(Computer Use 模式,project 隔离)
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let path = project_dir("user123", "project456");
|
||||
/// assert_eq!(path, "/app/computer-project-workspace/user123/project456");
|
||||
/// ```
|
||||
pub fn project_dir(user_id: &str, project_id: &str) -> String {
|
||||
format!("{}/{}/{}", COMPUTER_WORKSPACE_ROOT, user_id, project_id)
|
||||
}
|
||||
|
||||
/// 根据隔离类型构建 RCoder 工作空间路径
|
||||
///
|
||||
/// # 参数
|
||||
/// - `isolation_type`: 隔离类型,可选值为 "tenant"、"space"、"project"
|
||||
/// - `tenant_id`: 租户 ID(当 isolation_type 为 tenant 或 space 时必需)
|
||||
/// - `space_id`: 空间 ID(当 isolation_type 为 tenant 或 space 时必需)
|
||||
/// - `project_id`: 项目 ID(必需)
|
||||
///
|
||||
/// # 返回
|
||||
/// 拼接后的容器内路径
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// // project 隔离(默认)
|
||||
/// build_workspace_path(Some("project"), None, None, "proj_123")
|
||||
/// // 返回: "/app/project_workspace/proj_123"
|
||||
///
|
||||
/// // tenant 隔离
|
||||
/// build_workspace_path(Some("tenant"), Some("t1"), Some("s1"), "proj_123")
|
||||
/// // 返回: "/app/project_workspace/t1/s1/proj_123"
|
||||
/// ```
|
||||
pub fn build_workspace_path(
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
project_id: &str,
|
||||
) -> String {
|
||||
// 大小写不敏感:统一转小写后匹配
|
||||
let normalized = isolation_type.map(|s| s.to_lowercase());
|
||||
match normalized.as_deref() {
|
||||
Some("tenant") | Some("space") => {
|
||||
// tenant/space: /app/project_workspace/{tenant_id}/{space_id}/{project_id}
|
||||
let tid = tenant_id.unwrap_or("default");
|
||||
let sid = space_id.unwrap_or("default");
|
||||
format!("{}/{}/{}/{}", WORKSPACE_ROOT, tid, sid, project_id)
|
||||
}
|
||||
_ => {
|
||||
// project (默认): /app/project_workspace/{project_id}
|
||||
format!("{}/{}", WORKSPACE_ROOT, project_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据隔离类型构建 Computer 工作空间路径
|
||||
///
|
||||
/// # 参数
|
||||
/// - `isolation_type`: 隔离类型,可选值为 "tenant"、"space"、"project"
|
||||
/// - `tenant_id`: 租户 ID(当 isolation_type 为 tenant 或 space 时必需)
|
||||
/// - `space_id`: 空间 ID(当 isolation_type 为 tenant 或 space 时必需)
|
||||
/// - `user_id`: 用户 ID(当 isolation_type 为 project 时使用)
|
||||
/// - `project_id`: 项目 ID(必需)
|
||||
///
|
||||
/// # 返回
|
||||
/// 拼接后的容器内路径
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// // project 隔离(默认)
|
||||
/// build_computer_workspace_path(Some("project"), None, None, "user_123", "proj_456")
|
||||
/// // 返回: "/app/computer-project-workspace/user_123/proj_456"
|
||||
///
|
||||
/// // tenant 隔离
|
||||
/// build_computer_workspace_path(Some("tenant"), Some("t1"), Some("s1"), "user_123", "proj_456")
|
||||
/// // 返回: "/app/computer-project-workspace/t1/s1/proj_456"
|
||||
/// ```
|
||||
pub fn build_computer_workspace_path(
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
) -> String {
|
||||
// 大小写不敏感:统一转小写后匹配
|
||||
let normalized = isolation_type.map(|s| s.to_lowercase());
|
||||
match normalized.as_deref() {
|
||||
Some("tenant") | Some("space") => {
|
||||
// tenant/space: /app/computer-project-workspace/{tenant_id}/{space_id}/{project_id}
|
||||
let tid = tenant_id.unwrap_or("default");
|
||||
let sid = space_id.unwrap_or("default");
|
||||
format!("{}/{}/{}/{}", COMPUTER_WORKSPACE_ROOT, tid, sid, project_id)
|
||||
}
|
||||
_ => {
|
||||
// project (默认): /app/computer-project-workspace/{user_id}/{project_id}
|
||||
format!("{}/{}/{}", COMPUTER_WORKSPACE_ROOT, user_id, project_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_user_dir() {
|
||||
assert_eq!(
|
||||
user_dir("user123"),
|
||||
"/app/computer-project-workspace/user123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_dir() {
|
||||
assert_eq!(
|
||||
project_dir("user123", "project456"),
|
||||
"/app/computer-project-workspace/user123/project456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_path_project() {
|
||||
// project 隔离(默认)
|
||||
assert_eq!(
|
||||
build_workspace_path(None, None, None, "proj_123"),
|
||||
"/app/project_workspace/proj_123"
|
||||
);
|
||||
assert_eq!(
|
||||
build_workspace_path(Some("project"), None, None, "proj_123"),
|
||||
"/app/project_workspace/proj_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_path_tenant() {
|
||||
// tenant 隔离
|
||||
assert_eq!(
|
||||
build_workspace_path(Some("tenant"), Some("t1"), Some("s1"), "proj_123"),
|
||||
"/app/project_workspace/t1/s1/proj_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_path_space() {
|
||||
// space 隔离
|
||||
assert_eq!(
|
||||
build_workspace_path(Some("space"), Some("t1"), Some("s1"), "proj_123"),
|
||||
"/app/project_workspace/t1/s1/proj_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_path_defaults() {
|
||||
// tenant/space 模式下使用默认值
|
||||
assert_eq!(
|
||||
build_workspace_path(Some("tenant"), None, None, "proj_123"),
|
||||
"/app/project_workspace/default/default/proj_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_computer_workspace_path_project() {
|
||||
// project 隔离(默认)
|
||||
assert_eq!(
|
||||
build_computer_workspace_path(None, None, None, "user_123", "proj_456"),
|
||||
"/app/computer-project-workspace/user_123/proj_456"
|
||||
);
|
||||
assert_eq!(
|
||||
build_computer_workspace_path(Some("project"), None, None, "user_123", "proj_456"),
|
||||
"/app/computer-project-workspace/user_123/proj_456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_computer_workspace_path_tenant() {
|
||||
// tenant 隔离
|
||||
assert_eq!(
|
||||
build_computer_workspace_path(Some("tenant"), Some("t1"), Some("s1"), "user_123", "proj_456"),
|
||||
"/app/computer-project-workspace/t1/s1/proj_456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_computer_workspace_path_space() {
|
||||
// space 隔离
|
||||
assert_eq!(
|
||||
build_computer_workspace_path(Some("space"), Some("t1"), Some("s1"), "user_123", "proj_456"),
|
||||
"/app/computer-project-workspace/t1/s1/proj_456"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user