添加qiming-rcoder模块

This commit is contained in:
Codex
2026-06-01 13:54:52 +08:00
parent 8092c4b1f8
commit 4b1a580132
539 changed files with 151650 additions and 0 deletions

View 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

View 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))
}
}

View 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
}

View 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::RCoderAgentRunner 模式正在开发中
/// - 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,
))
}
}

View File

@@ -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")))
}

View File

@@ -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,
));
}
}
}

View 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_keyuser_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_idComputerAgentRunner 模式)
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_idComputerAgentRunner 必需)
)
.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_idComputerAgentRunner 模式)
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
// ============================================================================

View File

@@ -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` - 音频流 WebSocketOpus 编码)
///
/// ## 端口说明
/// - **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"));
}
}

View 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)
}

View 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(),
})
}

View 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::*;

File diff suppressed because it is too large Load Diff

View 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,
}

View 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
}

View 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());
}
}

View 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。
///
/// 适用于需要同时兼容 GETquery和 POSTbody两种调用方式的接口。
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 stringJSON 应该优先
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);
}
}

View 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");
}
}

View 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};

View 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"
);
}
}