mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate
- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦 - 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块 - 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支 - 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合 - 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor - 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::http::{self, Response};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL: &str = "Admin Gemini files data unavailable";
|
||||
const ADMIN_GEMINI_FILE_UPLOAD_DETAIL: &str = "Admin Gemini file upload requires Rust uploader";
|
||||
const ADMIN_GEMINI_FILES_DEFAULT_PAGE: usize = 1;
|
||||
const ADMIN_GEMINI_FILES_DEFAULT_PAGE_SIZE: usize = 20;
|
||||
const ADMIN_GEMINI_FILES_MAX_PAGE_SIZE: usize = 100;
|
||||
|
||||
mod read_routes;
|
||||
mod upload;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_gemini_files_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if decision.route_family.as_deref() != Some("gemini_files_manage") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(response) =
|
||||
read_routes::maybe_build_local_admin_gemini_files_read_response(state, request_context)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if let Some(response) = upload::maybe_build_local_admin_gemini_files_upload_response(
|
||||
state,
|
||||
request_context,
|
||||
request_body,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn admin_gemini_files_key_capable(key: &StoredProviderCatalogKey) -> bool {
|
||||
key.is_active
|
||||
&& key
|
||||
.capabilities
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("gemini_files"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn admin_gemini_files_error_response(
|
||||
status: http::StatusCode,
|
||||
detail: impl Into<String>,
|
||||
) -> Response<Body> {
|
||||
(status, Json(json!({ "detail": detail.into() }))).into_response()
|
||||
}
|
||||
|
||||
fn admin_gemini_files_now_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use super::{
|
||||
admin_gemini_files_error_response, admin_gemini_files_key_capable,
|
||||
admin_gemini_files_now_unix_secs, ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
|
||||
ADMIN_GEMINI_FILES_DEFAULT_PAGE, ADMIN_GEMINI_FILES_DEFAULT_PAGE_SIZE,
|
||||
ADMIN_GEMINI_FILES_MAX_PAGE_SIZE,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::{
|
||||
admin_gemini_file_mapping_id_from_path, is_admin_gemini_files_capable_keys_root,
|
||||
is_admin_gemini_files_mappings_root, is_admin_gemini_files_stats_root,
|
||||
query_param_optional_bool, query_param_value, unix_secs_to_rfc3339,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use axum::body::Body;
|
||||
use axum::http::{self, Response};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AdminGeminiFilesPageQuery {
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
include_expired: bool,
|
||||
search: Option<String>,
|
||||
}
|
||||
|
||||
fn admin_gemini_files_page_query(
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Result<Option<AdminGeminiFilesPageQuery>, GatewayError> {
|
||||
let query = request_context.request_query_string.as_deref();
|
||||
let page = match query_param_value(query, "page") {
|
||||
Some(raw) => raw.parse::<usize>().ok().filter(|value| *value >= 1),
|
||||
None => Some(ADMIN_GEMINI_FILES_DEFAULT_PAGE),
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
GatewayError::Internal("admin gemini files page query should validate".to_string())
|
||||
})?;
|
||||
let page_size = match query_param_value(query, "page_size") {
|
||||
Some(raw) => raw
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|value| (1..=ADMIN_GEMINI_FILES_MAX_PAGE_SIZE).contains(value)),
|
||||
None => Some(ADMIN_GEMINI_FILES_DEFAULT_PAGE_SIZE),
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
GatewayError::Internal("admin gemini files page_size query should validate".to_string())
|
||||
})?;
|
||||
let include_expired = query_param_optional_bool(query, "include_expired").unwrap_or(false);
|
||||
let search = query_param_value(query, "search").and_then(|value| {
|
||||
let trimmed = value.trim().to_string();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
});
|
||||
Ok(Some(AdminGeminiFilesPageQuery {
|
||||
page,
|
||||
page_size,
|
||||
include_expired,
|
||||
search,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn admin_gemini_files_key_name_map(
|
||||
state: &AppState,
|
||||
) -> Result<BTreeMap<String, String>, GatewayError> {
|
||||
let capable_keys = admin_gemini_files_all_keys(state).await?;
|
||||
Ok(capable_keys
|
||||
.into_iter()
|
||||
.map(|key| (key.id, key.name))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn admin_gemini_files_username_map<'a, I>(
|
||||
state: &AppState,
|
||||
mappings: I,
|
||||
) -> Result<BTreeMap<String, String>, GatewayError>
|
||||
where
|
||||
I: Iterator<Item = &'a aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping>,
|
||||
{
|
||||
let user_ids = mappings
|
||||
.filter_map(|mapping| mapping.user_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let users = state.list_users_by_ids(&user_ids).await?;
|
||||
Ok(users
|
||||
.into_iter()
|
||||
.map(|user| (user.id, user.username))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn admin_gemini_files_capable_keys(
|
||||
state: &AppState,
|
||||
) -> Result<Vec<serde_json::Value>, GatewayError> {
|
||||
let providers = state.list_provider_catalog_providers(false).await?;
|
||||
let provider_name_by_id = providers
|
||||
.iter()
|
||||
.map(|provider| (provider.id.as_str(), provider.name.as_str()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let keys = admin_gemini_files_all_keys(state).await?;
|
||||
Ok(keys
|
||||
.into_iter()
|
||||
.filter(admin_gemini_files_key_capable)
|
||||
.map(|key| {
|
||||
json!({
|
||||
"id": key.id,
|
||||
"name": key.name,
|
||||
"provider_name": provider_name_by_id.get(key.provider_id.as_str()).copied(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn admin_gemini_files_all_keys(
|
||||
state: &AppState,
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, GatewayError> {
|
||||
let providers = state.list_provider_catalog_providers(false).await?;
|
||||
let provider_ids = providers
|
||||
.into_iter()
|
||||
.map(|provider| provider.id)
|
||||
.collect::<Vec<_>>();
|
||||
state
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
fn build_admin_gemini_file_mapping_payload(
|
||||
mapping: &aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping,
|
||||
key_name: Option<&str>,
|
||||
username: Option<&str>,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": mapping.id,
|
||||
"file_name": mapping.file_name,
|
||||
"key_id": mapping.key_id,
|
||||
"key_name": key_name,
|
||||
"user_id": mapping.user_id,
|
||||
"username": username,
|
||||
"display_name": mapping.display_name,
|
||||
"mime_type": mapping.mime_type,
|
||||
"created_at": unix_secs_to_rfc3339(mapping.created_at_unix_secs),
|
||||
"expires_at": unix_secs_to_rfc3339(mapping.expires_at_unix_secs),
|
||||
"is_expired": mapping.expires_at_unix_secs <= now_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_gemini_files_read_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let now_unix_secs = admin_gemini_files_now_unix_secs();
|
||||
|
||||
match request_context
|
||||
.control_decision
|
||||
.as_ref()
|
||||
.and_then(|decision| decision.route_kind.as_deref())
|
||||
{
|
||||
Some("list_mappings")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& is_admin_gemini_files_mappings_root(&request_context.request_path) =>
|
||||
{
|
||||
if !state.has_gemini_file_mapping_data_reader() {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
let page = match admin_gemini_files_page_query(request_context)? {
|
||||
Some(value) => value,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let mappings = state
|
||||
.list_gemini_file_mappings(
|
||||
&aether_data::repository::gemini_file_mappings::GeminiFileMappingListQuery {
|
||||
include_expired: page.include_expired,
|
||||
search: page.search.clone(),
|
||||
offset: (page.page - 1).saturating_mul(page.page_size),
|
||||
limit: page.page_size,
|
||||
now_unix_secs,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let key_name_by_id = admin_gemini_files_key_name_map(state).await?;
|
||||
let username_by_id =
|
||||
admin_gemini_files_username_map(state, mappings.items.iter()).await?;
|
||||
let items = mappings
|
||||
.items
|
||||
.iter()
|
||||
.map(|mapping| {
|
||||
build_admin_gemini_file_mapping_payload(
|
||||
mapping,
|
||||
key_name_by_id
|
||||
.get(mapping.key_id.as_str())
|
||||
.map(String::as_str),
|
||||
username_by_id
|
||||
.get(mapping.user_id.as_deref().unwrap_or(""))
|
||||
.map(String::as_str),
|
||||
now_unix_secs,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Some(
|
||||
Json(json!({
|
||||
"items": items,
|
||||
"total": mappings.total,
|
||||
"page": page.page,
|
||||
"page_size": page.page_size,
|
||||
}))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
Some("stats")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& is_admin_gemini_files_stats_root(&request_context.request_path) =>
|
||||
{
|
||||
if !state.has_gemini_file_mapping_data_reader() {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
let stats = state.summarize_gemini_file_mappings(now_unix_secs).await?;
|
||||
let capable_keys_count = admin_gemini_files_capable_keys(state).await?.len();
|
||||
let by_mime_type = stats
|
||||
.by_mime_type
|
||||
.into_iter()
|
||||
.map(|item| (item.mime_type, json!(item.count)))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
Ok(Some(
|
||||
Json(json!({
|
||||
"total_mappings": stats.total_mappings,
|
||||
"active_mappings": stats.active_mappings,
|
||||
"expired_mappings": stats.expired_mappings,
|
||||
"by_mime_type": by_mime_type,
|
||||
"capable_keys_count": capable_keys_count,
|
||||
}))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
Some("delete_mapping")
|
||||
if request_context.request_method == http::Method::DELETE
|
||||
&& request_context
|
||||
.request_path
|
||||
.starts_with("/api/admin/gemini-files/mappings/") =>
|
||||
{
|
||||
if !state.has_gemini_file_mapping_data_writer() {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
let Some(mapping_id) =
|
||||
admin_gemini_file_mapping_id_from_path(&request_context.request_path)
|
||||
else {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
"Mapping not found",
|
||||
)));
|
||||
};
|
||||
let Some(mapping) = state.delete_gemini_file_mapping_by_id(&mapping_id).await? else {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
"Mapping not found",
|
||||
)));
|
||||
};
|
||||
Ok(Some(
|
||||
Json(json!({
|
||||
"message": "Mapping deleted successfully",
|
||||
"file_name": mapping.file_name,
|
||||
}))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
Some("cleanup_mappings")
|
||||
if request_context.request_method == http::Method::DELETE
|
||||
&& is_admin_gemini_files_mappings_root(&request_context.request_path) =>
|
||||
{
|
||||
if !state.has_gemini_file_mapping_data_writer() {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
let deleted_count = state
|
||||
.delete_expired_gemini_file_mappings(now_unix_secs)
|
||||
.await?;
|
||||
Ok(Some(
|
||||
Json(json!({
|
||||
"message": format!("Cleaned up {deleted_count} expired mappings"),
|
||||
"deleted_count": deleted_count,
|
||||
}))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
Some("capable_keys")
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& is_admin_gemini_files_capable_keys_root(&request_context.request_path) =>
|
||||
{
|
||||
let capable_keys = admin_gemini_files_capable_keys(state).await?;
|
||||
Ok(Some(Json(capable_keys).into_response()))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
use super::{
|
||||
admin_gemini_files_error_response, admin_gemini_files_key_capable,
|
||||
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::{is_admin_gemini_files_upload_root, query_param_value};
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_contracts::{ExecutionPlan, ExecutionResult, RequestBody};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::http::{self, Response};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AdminGeminiFilesUploadRequest {
|
||||
display_name: String,
|
||||
mime_type: String,
|
||||
body_bytes: Vec<u8>,
|
||||
body_bytes_b64: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AdminGeminiFilesUploadExecutionSuccess {
|
||||
file_name: String,
|
||||
display_name: Option<String>,
|
||||
mime_type: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_gemini_files_upload_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
match request_context
|
||||
.control_decision
|
||||
.as_ref()
|
||||
.and_then(|decision| decision.route_kind.as_deref())
|
||||
{
|
||||
Some("upload")
|
||||
if request_context.request_method == http::Method::POST
|
||||
&& is_admin_gemini_files_upload_root(&request_context.request_path) =>
|
||||
{
|
||||
if !state.has_gemini_file_mapping_data_writer() {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
ADMIN_GEMINI_FILES_DATA_UNAVAILABLE_DETAIL,
|
||||
)));
|
||||
}
|
||||
let upload =
|
||||
match admin_gemini_files_parse_upload_request(request_context, request_body) {
|
||||
Ok(upload) => upload,
|
||||
Err(detail) => {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
detail,
|
||||
)));
|
||||
}
|
||||
};
|
||||
let key_ids = admin_gemini_files_query_key_ids(request_context);
|
||||
if key_ids.is_empty() {
|
||||
return Ok(Some(admin_gemini_files_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"key_ids 不能为空",
|
||||
)));
|
||||
}
|
||||
let response = admin_gemini_files_upload_across_keys(
|
||||
state,
|
||||
"",
|
||||
request_context.trace_id.as_str(),
|
||||
&upload,
|
||||
&key_ids,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(Json(response).into_response()))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_gemini_files_query_key_ids(request_context: &GatewayPublicRequestContext) -> Vec<String> {
|
||||
let mut key_ids = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let Some(raw) = query_param_value(request_context.request_query_string.as_deref(), "key_ids")
|
||||
else {
|
||||
return key_ids;
|
||||
};
|
||||
for key_id in raw.split(',') {
|
||||
let trimmed = key_id.trim();
|
||||
if trimmed.is_empty() || !seen.insert(trimmed.to_string()) {
|
||||
continue;
|
||||
}
|
||||
key_ids.push(trimmed.to_string());
|
||||
}
|
||||
key_ids
|
||||
}
|
||||
|
||||
fn admin_gemini_files_parse_upload_request(
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Result<AdminGeminiFilesUploadRequest, String> {
|
||||
let content_type = request_context
|
||||
.request_content_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "Content-Type 缺失".to_string())?;
|
||||
let boundary = admin_gemini_files_multipart_boundary(content_type)?;
|
||||
let body = request_body
|
||||
.filter(|body| !body.is_empty())
|
||||
.ok_or_else(|| "上传文件不能为空".to_string())?;
|
||||
let (display_name, mime_type, body_bytes) =
|
||||
admin_gemini_files_extract_file_part(body.as_ref(), &boundary)?;
|
||||
Ok(AdminGeminiFilesUploadRequest {
|
||||
display_name,
|
||||
mime_type,
|
||||
body_bytes_b64: base64::engine::general_purpose::STANDARD.encode(&body_bytes),
|
||||
body_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_gemini_files_multipart_boundary(content_type: &str) -> Result<String, String> {
|
||||
let normalized = content_type.trim();
|
||||
if !normalized
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("multipart/form-data")
|
||||
{
|
||||
return Err("Content-Type 必须是 multipart/form-data".to_string());
|
||||
}
|
||||
for part in normalized.split(';').skip(1) {
|
||||
let Some((key, value)) = part.trim().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if !key.trim().eq_ignore_ascii_case("boundary") {
|
||||
continue;
|
||||
}
|
||||
let boundary = value.trim().trim_matches('"').trim();
|
||||
if !boundary.is_empty() {
|
||||
return Ok(boundary.to_string());
|
||||
}
|
||||
}
|
||||
Err("multipart boundary 缺失".to_string())
|
||||
}
|
||||
|
||||
fn admin_gemini_files_extract_file_part(
|
||||
body: &[u8],
|
||||
boundary: &str,
|
||||
) -> Result<(String, String, Vec<u8>), String> {
|
||||
let boundary_marker = format!("--{boundary}");
|
||||
let next_boundary_marker = format!("\r\n--{boundary}");
|
||||
let boundary_bytes = boundary_marker.as_bytes();
|
||||
let next_boundary_bytes = next_boundary_marker.as_bytes();
|
||||
|
||||
let mut cursor = 0usize;
|
||||
while cursor < body.len() {
|
||||
if !body[cursor..].starts_with(boundary_bytes) {
|
||||
return Err("multipart body 格式无效".to_string());
|
||||
}
|
||||
cursor += boundary_bytes.len();
|
||||
if body[cursor..].starts_with(b"--") {
|
||||
break;
|
||||
}
|
||||
if !body[cursor..].starts_with(b"\r\n") {
|
||||
return Err("multipart body 缺少头部分隔符".to_string());
|
||||
}
|
||||
cursor += 2;
|
||||
let Some(headers_end_rel) = admin_gemini_files_find_subslice(&body[cursor..], b"\r\n\r\n")
|
||||
else {
|
||||
return Err("multipart part 缺少头部".to_string());
|
||||
};
|
||||
let headers_end = cursor + headers_end_rel;
|
||||
let headers_text = std::str::from_utf8(&body[cursor..headers_end])
|
||||
.map_err(|_| "multipart part 头部编码无效".to_string())?;
|
||||
cursor = headers_end + 4;
|
||||
let Some(next_boundary_rel) =
|
||||
admin_gemini_files_find_subslice(&body[cursor..], next_boundary_bytes)
|
||||
else {
|
||||
return Err("multipart body 缺少结束边界".to_string());
|
||||
};
|
||||
let content_end = cursor + next_boundary_rel;
|
||||
let content = &body[cursor..content_end];
|
||||
cursor = content_end + 2;
|
||||
|
||||
let Some((field_name, file_name, mime_type)) =
|
||||
admin_gemini_files_parse_part_headers(headers_text)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if field_name != "file" {
|
||||
continue;
|
||||
}
|
||||
return Ok((
|
||||
file_name.unwrap_or_else(|| "uploaded-file".to_string()),
|
||||
mime_type.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
content.to_vec(),
|
||||
));
|
||||
}
|
||||
|
||||
Err("multipart body 中缺少 file 字段".to_string())
|
||||
}
|
||||
|
||||
fn admin_gemini_files_parse_part_headers(
|
||||
headers_text: &str,
|
||||
) -> Option<(String, Option<String>, Option<String>)> {
|
||||
let mut field_name = None;
|
||||
let mut file_name = None;
|
||||
let mut mime_type = None;
|
||||
|
||||
for line in headers_text.split("\r\n") {
|
||||
let Some((header_name, header_value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let header_name = header_name.trim();
|
||||
let header_value = header_value.trim();
|
||||
if header_name.eq_ignore_ascii_case("content-disposition") {
|
||||
for part in header_value.split(';').skip(1) {
|
||||
let Some((key, value)) = part.trim().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_matches('"').trim();
|
||||
if key.eq_ignore_ascii_case("name") && !value.is_empty() {
|
||||
field_name = Some(value.to_string());
|
||||
} else if key.eq_ignore_ascii_case("filename") && !value.is_empty() {
|
||||
file_name = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
} else if header_name.eq_ignore_ascii_case("content-type") && !header_value.is_empty() {
|
||||
mime_type = Some(header_value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
field_name.map(|field_name| (field_name, file_name, mime_type))
|
||||
}
|
||||
|
||||
fn admin_gemini_files_find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
if haystack.is_empty() || needle.is_empty() || haystack.len() < needle.len() {
|
||||
return None;
|
||||
}
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
async fn admin_gemini_files_upload_across_keys(
|
||||
state: &AppState,
|
||||
execution_runtime_base_url: &str,
|
||||
trace_id: &str,
|
||||
upload: &AdminGeminiFilesUploadRequest,
|
||||
requested_key_ids: &[String],
|
||||
) -> Result<serde_json::Value, GatewayError> {
|
||||
let keys = state
|
||||
.read_provider_catalog_keys_by_ids(requested_key_ids)
|
||||
.await?;
|
||||
let key_by_id = keys
|
||||
.iter()
|
||||
.map(|key| (key.id.as_str(), key))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let provider_ids = keys
|
||||
.iter()
|
||||
.map(|key| key.provider_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let endpoints = state
|
||||
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
|
||||
.await?;
|
||||
let endpoints_by_provider_id = endpoints.into_iter().fold(
|
||||
BTreeMap::<String, Vec<StoredProviderCatalogEndpoint>>::new(),
|
||||
|mut out, endpoint| {
|
||||
out.entry(endpoint.provider_id.clone())
|
||||
.or_default()
|
||||
.push(endpoint);
|
||||
out
|
||||
},
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut success_count = 0usize;
|
||||
let mut fail_count = 0usize;
|
||||
|
||||
for key_id in requested_key_ids {
|
||||
let Some(key) = key_by_id.get(key_id.as_str()) else {
|
||||
fail_count += 1;
|
||||
results.push(json!({
|
||||
"key_id": key_id,
|
||||
"key_name": serde_json::Value::Null,
|
||||
"success": false,
|
||||
"file_name": serde_json::Value::Null,
|
||||
"error": "Key 不存在",
|
||||
}));
|
||||
continue;
|
||||
};
|
||||
|
||||
let key_name = Some(key.name.clone());
|
||||
let outcome = admin_gemini_files_upload_single_key(
|
||||
state,
|
||||
execution_runtime_base_url,
|
||||
trace_id,
|
||||
upload,
|
||||
key,
|
||||
endpoints_by_provider_id.get(&key.provider_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
match outcome {
|
||||
Ok(success) => {
|
||||
success_count += 1;
|
||||
results.push(json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key_name,
|
||||
"success": true,
|
||||
"file_name": success.file_name,
|
||||
"error": serde_json::Value::Null,
|
||||
}));
|
||||
}
|
||||
Err(error) => {
|
||||
fail_count += 1;
|
||||
results.push(json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key_name,
|
||||
"success": false,
|
||||
"file_name": serde_json::Value::Null,
|
||||
"error": error,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"display_name": upload.display_name,
|
||||
"mime_type": upload.mime_type,
|
||||
"size_bytes": upload.body_bytes.len(),
|
||||
"results": results,
|
||||
"success_count": success_count,
|
||||
"fail_count": fail_count,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn admin_gemini_files_upload_single_key(
|
||||
state: &AppState,
|
||||
_execution_runtime_base_url: &str,
|
||||
trace_id: &str,
|
||||
upload: &AdminGeminiFilesUploadRequest,
|
||||
key: &StoredProviderCatalogKey,
|
||||
endpoints: Option<&Vec<StoredProviderCatalogEndpoint>>,
|
||||
) -> Result<AdminGeminiFilesUploadExecutionSuccess, String> {
|
||||
if !admin_gemini_files_key_capable(key) {
|
||||
return Err("Key 不支持 Gemini Files".to_string());
|
||||
}
|
||||
let Some(endpoint) = endpoints.and_then(|endpoints| {
|
||||
endpoints.iter().find(|endpoint| {
|
||||
endpoint.is_active
|
||||
&& endpoint
|
||||
.api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("gemini:chat")
|
||||
})
|
||||
}) else {
|
||||
return Err("找不到有效的 gemini:chat 端点".to_string());
|
||||
};
|
||||
let transport = state
|
||||
.read_provider_transport_snapshot(&key.provider_id, &endpoint.id, &key.id)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
.ok_or_else(|| "无法读取 Key 传输配置".to_string())?;
|
||||
if !crate::provider_transport::policy::supports_local_gemini_transport_with_network(
|
||||
&transport,
|
||||
"gemini:chat",
|
||||
) {
|
||||
return Err("Key 传输配置不支持 Gemini Files 上传".to_string());
|
||||
}
|
||||
if transport.endpoint.body_rules.is_some() {
|
||||
return Err("Gemini Files 二进制上传暂不支持 endpoint body_rules".to_string());
|
||||
}
|
||||
let (auth_header, auth_value) =
|
||||
crate::provider_transport::auth::resolve_local_gemini_auth(&transport)
|
||||
.ok_or_else(|| "Key 缺少可用的 Gemini 认证信息".to_string())?;
|
||||
|
||||
let mut provider_request_headers =
|
||||
crate::provider_transport::auth::build_passthrough_headers_with_auth(
|
||||
&http::HeaderMap::new(),
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
);
|
||||
provider_request_headers.insert("content-type".to_string(), upload.mime_type.clone());
|
||||
let original_request_body = json!({
|
||||
"body_bytes_b64": upload.body_bytes_b64,
|
||||
});
|
||||
if !crate::provider_transport::apply_local_header_rules(
|
||||
&mut provider_request_headers,
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
&[auth_header.as_str(), "content-type"],
|
||||
&original_request_body,
|
||||
Some(&original_request_body),
|
||||
) {
|
||||
return Err("Key 端点 header_rules 应用失败".to_string());
|
||||
}
|
||||
|
||||
let upload_path = transport
|
||||
.endpoint
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("/upload/v1beta/files");
|
||||
let upload_query = if upload_path.contains("uploadType=") {
|
||||
None
|
||||
} else {
|
||||
Some("uploadType=resumable")
|
||||
};
|
||||
let upstream_url = crate::provider_transport::url::build_gemini_files_passthrough_url(
|
||||
&transport.endpoint.base_url,
|
||||
upload_path,
|
||||
upload_query,
|
||||
)
|
||||
.ok_or_else(|| "无法构建 Gemini Files 上传地址".to_string())?;
|
||||
|
||||
let plan = ExecutionPlan {
|
||||
request_id: format!("{trace_id}:admin-gemini-upload:{}", key.id),
|
||||
candidate_id: None,
|
||||
provider_name: Some(transport.provider.name.clone()),
|
||||
provider_id: transport.provider.id.clone(),
|
||||
endpoint_id: transport.endpoint.id.clone(),
|
||||
key_id: transport.key.id.clone(),
|
||||
method: "POST".to_string(),
|
||||
url: upstream_url,
|
||||
headers: provider_request_headers,
|
||||
content_type: Some(upload.mime_type.clone()),
|
||||
content_encoding: None,
|
||||
body: RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(upload.body_bytes_b64.clone()),
|
||||
body_ref: None,
|
||||
},
|
||||
stream: false,
|
||||
client_api_format: "gemini:files".to_string(),
|
||||
provider_api_format: "gemini:files".to_string(),
|
||||
model_name: Some("gemini-files".to_string()),
|
||||
proxy: crate::provider_transport::resolve_transport_proxy_snapshot_with_tunnel_affinity(
|
||||
state, &transport,
|
||||
)
|
||||
.await,
|
||||
tls_profile: crate::provider_transport::resolve_transport_tls_profile(&transport),
|
||||
timeouts: crate::provider_transport::resolve_transport_execution_timeouts(&transport),
|
||||
};
|
||||
|
||||
let result = admin_gemini_files_execute_upload_plan(state, trace_id, &plan)
|
||||
.await
|
||||
.map_err(|error| format!("{error:?}"))?;
|
||||
if result.status_code >= 400 {
|
||||
return Err(admin_gemini_files_execution_error_message(&result));
|
||||
}
|
||||
let body_json = admin_gemini_files_execution_json_body(&result)
|
||||
.ok_or_else(|| "上传成功但上游响应缺少 JSON body".to_string())?;
|
||||
let success = admin_gemini_files_upload_success_from_body(&body_json, upload)
|
||||
.ok_or_else(|| admin_gemini_files_execution_error_message(&result))?;
|
||||
crate::usage::reporting::store_local_gemini_file_mapping(
|
||||
state,
|
||||
success.file_name.as_str(),
|
||||
key.id.as_str(),
|
||||
None,
|
||||
success
|
||||
.display_name
|
||||
.as_deref()
|
||||
.or(Some(upload.display_name.as_str())),
|
||||
success
|
||||
.mime_type
|
||||
.as_deref()
|
||||
.or(Some(upload.mime_type.as_str())),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("上传成功但本地映射写入失败: {err:?}"))?;
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
async fn admin_gemini_files_execute_upload_plan(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
plan: &ExecutionPlan,
|
||||
) -> Result<ExecutionResult, GatewayError> {
|
||||
crate::execution_runtime::execute_execution_runtime_sync_plan(state, Some(trace_id), plan).await
|
||||
}
|
||||
|
||||
fn admin_gemini_files_execution_json_body(result: &ExecutionResult) -> Option<serde_json::Value> {
|
||||
if let Some(body_json) = result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
{
|
||||
return Some(body_json.clone());
|
||||
}
|
||||
let content_type = result
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case("content-type"))
|
||||
.map(|(_, value)| value.trim().to_ascii_lowercase());
|
||||
if !content_type
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.starts_with("application/json"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let body_bytes_b64 = result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.body_bytes_b64.as_deref())?;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_bytes_b64)
|
||||
.ok()?;
|
||||
serde_json::from_slice(&decoded).ok()
|
||||
}
|
||||
|
||||
fn admin_gemini_files_upload_success_from_body(
|
||||
body_json: &serde_json::Value,
|
||||
upload: &AdminGeminiFilesUploadRequest,
|
||||
) -> Option<AdminGeminiFilesUploadExecutionSuccess> {
|
||||
let file_object = body_json
|
||||
.get("file")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.or_else(|| body_json.as_object())?;
|
||||
let file_name = file_object
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let display_name = file_object
|
||||
.get("displayName")
|
||||
.or_else(|| file_object.get("display_name"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| Some(upload.display_name.clone()));
|
||||
let mime_type = file_object
|
||||
.get("mimeType")
|
||||
.or_else(|| file_object.get("mime_type"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| Some(upload.mime_type.clone()));
|
||||
Some(AdminGeminiFilesUploadExecutionSuccess {
|
||||
file_name: file_name.to_string(),
|
||||
display_name,
|
||||
mime_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_gemini_files_execution_error_message(result: &ExecutionResult) -> String {
|
||||
if let Some(body_json) = admin_gemini_files_execution_json_body(result) {
|
||||
if let Some(message) = body_json
|
||||
.get("error")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|error| error.get("message"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return message.to_string();
|
||||
}
|
||||
if let Some(message) = body_json
|
||||
.get("message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return message.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(error) = result
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|error| error.message.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return error.to_string();
|
||||
}
|
||||
format!("上传失败,状态码 {}", result.status_code)
|
||||
}
|
||||
5
apps/aether-gateway/src/handlers/admin/features/mod.rs
Normal file
5
apps/aether-gateway/src/handlers/admin/features/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod gemini_files;
|
||||
mod video_tasks;
|
||||
|
||||
pub(crate) use self::gemini_files::maybe_build_local_admin_gemini_files_response;
|
||||
pub(crate) use self::video_tasks::maybe_build_local_admin_video_tasks_response;
|
||||
@@ -0,0 +1,127 @@
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::video_tasks::{StoredVideoTask, VideoTaskStatus};
|
||||
use axum::http;
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(super) fn admin_video_task_status_name(status: VideoTaskStatus) -> &'static str {
|
||||
match status {
|
||||
VideoTaskStatus::Pending => "pending",
|
||||
VideoTaskStatus::Submitted => "submitted",
|
||||
VideoTaskStatus::Queued => "queued",
|
||||
VideoTaskStatus::Processing => "processing",
|
||||
VideoTaskStatus::Completed => "completed",
|
||||
VideoTaskStatus::Failed => "failed",
|
||||
VideoTaskStatus::Cancelled => "cancelled",
|
||||
VideoTaskStatus::Expired => "expired",
|
||||
VideoTaskStatus::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn admin_video_task_timestamp(unix_secs: Option<u64>) -> Option<String> {
|
||||
unix_secs.and_then(|value| {
|
||||
chrono::DateTime::<Utc>::from_timestamp(value as i64, 0)
|
||||
.map(|timestamp| timestamp.to_rfc3339_opts(SecondsFormat::Secs, true))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn truncate_admin_video_task_prompt(prompt: Option<&str>) -> Option<String> {
|
||||
prompt.map(|value| {
|
||||
if value.chars().count() <= 100 {
|
||||
value.to_string()
|
||||
} else {
|
||||
let mut truncated = value.chars().take(100).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
truncated
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_video_task_provider_names(
|
||||
state: &AppState,
|
||||
tasks: &[StoredVideoTask],
|
||||
) -> Result<BTreeMap<String, String>, GatewayError> {
|
||||
let provider_ids = tasks
|
||||
.iter()
|
||||
.filter_map(|task| task.provider_id.as_ref())
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
if provider_ids.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
Ok(state
|
||||
.read_provider_catalog_providers_by_ids(&provider_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|provider| (provider.id, provider.name))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_video_task_list_item(
|
||||
task: &StoredVideoTask,
|
||||
provider_names: &BTreeMap<String, String>,
|
||||
) -> Value {
|
||||
let provider_name = task
|
||||
.provider_id
|
||||
.as_deref()
|
||||
.and_then(|provider_id| provider_names.get(provider_id))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
json!({
|
||||
"id": task.id,
|
||||
"external_task_id": task.external_task_id,
|
||||
"user_id": task.user_id,
|
||||
"username": task.username.clone().unwrap_or_else(|| "Unknown".to_string()),
|
||||
"model": task.model,
|
||||
"prompt": truncate_admin_video_task_prompt(task.prompt.as_deref()),
|
||||
"status": admin_video_task_status_name(task.status),
|
||||
"progress_percent": task.progress_percent,
|
||||
"progress_message": task.progress_message,
|
||||
"provider_id": task.provider_id,
|
||||
"provider_name": provider_name,
|
||||
"duration_seconds": task.duration_seconds,
|
||||
"resolution": task.resolution,
|
||||
"aspect_ratio": task.aspect_ratio,
|
||||
"video_url": task.video_url,
|
||||
"error_code": task.error_code,
|
||||
"error_message": task.error_message,
|
||||
"poll_count": task.poll_count,
|
||||
"max_poll_count": task.max_poll_count,
|
||||
"created_at": admin_video_task_timestamp(Some(task.created_at_unix_secs)),
|
||||
"completed_at": admin_video_task_timestamp(task.completed_at_unix_secs),
|
||||
"submitted_at": admin_video_task_timestamp(task.submitted_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn admin_video_task_detail_id_from_path(request_path: &str) -> Option<&str> {
|
||||
let task_id = request_path.strip_prefix("/api/admin/video-tasks/")?;
|
||||
if task_id.is_empty() || task_id.contains('/') || task_id == "stats" {
|
||||
return None;
|
||||
}
|
||||
Some(task_id)
|
||||
}
|
||||
|
||||
pub(super) fn admin_video_task_nested_id_from_path<'a>(
|
||||
request_path: &'a str,
|
||||
suffix: &str,
|
||||
) -> Option<&'a str> {
|
||||
let task_id = request_path
|
||||
.strip_prefix("/api/admin/video-tasks/")?
|
||||
.strip_suffix(suffix)?;
|
||||
if task_id.is_empty() || task_id.contains('/') {
|
||||
return None;
|
||||
}
|
||||
Some(task_id)
|
||||
}
|
||||
|
||||
pub(super) fn current_admin_video_task_unix_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::{AppState, GatewayError};
|
||||
use axum::{body::Body, response::Response};
|
||||
|
||||
mod builders;
|
||||
mod routes;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_video_tasks_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
routes::maybe_build_local_admin_video_tasks_response(state, request_context).await
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
use crate::async_task;
|
||||
use crate::control::GatewayPublicRequestContext;
|
||||
use crate::handlers::admin::shared::{attach_admin_audit_response, query_param_value};
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::video_tasks::{VideoTaskQueryFilter, VideoTaskStatus};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::super::auth::build_proxy_error_response;
|
||||
use super::builders::{
|
||||
admin_video_task_detail_id_from_path, admin_video_task_nested_id_from_path,
|
||||
admin_video_task_status_name, admin_video_task_timestamp, build_admin_video_task_list_item,
|
||||
build_admin_video_task_provider_names, current_admin_video_task_unix_secs,
|
||||
};
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_video_tasks_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if decision.route_family.as_deref() != Some("video_tasks_manage") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/video-tasks" | "/api/admin/video-tasks/"
|
||||
)
|
||||
{
|
||||
let status =
|
||||
match query_param_value(request_context.request_query_string.as_deref(), "status") {
|
||||
Some(value) => match VideoTaskStatus::from_database(&value) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err) => {
|
||||
return Ok(Some(build_proxy_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
err.to_string(),
|
||||
None,
|
||||
)));
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let filter = VideoTaskQueryFilter {
|
||||
user_id: query_param_value(request_context.request_query_string.as_deref(), "user_id"),
|
||||
status,
|
||||
model_substring: query_param_value(
|
||||
request_context.request_query_string.as_deref(),
|
||||
"model",
|
||||
),
|
||||
client_api_format: None,
|
||||
};
|
||||
let page = query_param_value(request_context.request_query_string.as_deref(), "page")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(1);
|
||||
let page_size =
|
||||
query_param_value(request_context.request_query_string.as_deref(), "page_size")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(20);
|
||||
let response = async_task::read_video_task_page(state, &filter, page, page_size).await?;
|
||||
let provider_names = build_admin_video_task_provider_names(state, &response.items).await?;
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"items": response
|
||||
.items
|
||||
.iter()
|
||||
.map(|task| build_admin_video_task_list_item(task, &provider_names))
|
||||
.collect::<Vec<_>>(),
|
||||
"total": response.total,
|
||||
"page": response.page,
|
||||
"page_size": response.page_size,
|
||||
"pages": response.pages,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if request_context.request_method == http::Method::GET
|
||||
&& matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/api/admin/video-tasks/stats" | "/api/admin/video-tasks/stats/"
|
||||
)
|
||||
{
|
||||
let filter = VideoTaskQueryFilter {
|
||||
user_id: None,
|
||||
status: None,
|
||||
model_substring: None,
|
||||
client_api_format: None,
|
||||
};
|
||||
let stats =
|
||||
async_task::read_video_task_stats(state, &filter, current_admin_video_task_unix_secs())
|
||||
.await?;
|
||||
let active_users = state.count_distinct_video_task_users(&filter).await?;
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"total": stats.total,
|
||||
"by_status": stats.by_status,
|
||||
"by_model": stats.by_model,
|
||||
"today_count": stats.today_count,
|
||||
"active_users": active_users,
|
||||
"processing_count": stats.processing_count,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if request_context.request_method == http::Method::POST {
|
||||
let Some(task_id) =
|
||||
admin_video_task_nested_id_from_path(&request_context.request_path, "/cancel")
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let stored = match async_task::cancel_video_task_record(state, task_id).await {
|
||||
Ok(stored) => stored,
|
||||
Err(async_task::CancelVideoTaskError::NotFound) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Video task not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
Err(async_task::CancelVideoTaskError::InvalidStatus(status)) => {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"detail": format!(
|
||||
"Cannot cancel task with status: {}",
|
||||
admin_video_task_status_name(status),
|
||||
),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
Err(async_task::CancelVideoTaskError::Response(response)) => {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
Err(async_task::CancelVideoTaskError::Gateway(err)) => {
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
"id": stored.id,
|
||||
"status": "cancelled",
|
||||
"message": "Task cancelled successfully",
|
||||
}))
|
||||
.into_response(),
|
||||
"admin_video_task_cancelled",
|
||||
"cancel_video_task",
|
||||
"video_task",
|
||||
&stored.id,
|
||||
)));
|
||||
}
|
||||
|
||||
if request_context.request_method == http::Method::GET {
|
||||
let Some(task_id) =
|
||||
admin_video_task_nested_id_from_path(&request_context.request_path, "/video")
|
||||
else {
|
||||
let Some(task_id) = admin_video_task_detail_id_from_path(&request_context.request_path)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(task) = async_task::read_video_task_detail(state, task_id).await? else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Video task not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
let provider_names =
|
||||
build_admin_video_task_provider_names(state, std::slice::from_ref(&task)).await?;
|
||||
let provider_name = task
|
||||
.provider_id
|
||||
.as_deref()
|
||||
.and_then(|provider_id| provider_names.get(provider_id))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let endpoint = if let Some(endpoint_id) = task.endpoint_id.as_ref() {
|
||||
let endpoints = state
|
||||
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(endpoint_id))
|
||||
.await?;
|
||||
endpoints.into_iter().next()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let endpoint_payload = endpoint.map(|endpoint| {
|
||||
json!({
|
||||
"id": endpoint.id,
|
||||
"base_url": endpoint.base_url,
|
||||
"api_format": endpoint.api_format,
|
||||
})
|
||||
});
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert("id".to_string(), json!(task.id));
|
||||
payload.insert("external_task_id".to_string(), json!(task.external_task_id));
|
||||
payload.insert("user_id".to_string(), json!(task.user_id));
|
||||
payload.insert(
|
||||
"username".to_string(),
|
||||
json!(task
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_string())),
|
||||
);
|
||||
payload.insert("api_key_id".to_string(), json!(task.api_key_id));
|
||||
payload.insert("provider_id".to_string(), json!(task.provider_id));
|
||||
payload.insert("provider_name".to_string(), json!(provider_name));
|
||||
payload.insert("endpoint_id".to_string(), json!(task.endpoint_id));
|
||||
payload.insert(
|
||||
"endpoint".to_string(),
|
||||
endpoint_payload.unwrap_or(serde_json::Value::Null),
|
||||
);
|
||||
payload.insert("key_id".to_string(), json!(task.key_id));
|
||||
payload.insert(
|
||||
"client_api_format".to_string(),
|
||||
json!(task.client_api_format),
|
||||
);
|
||||
payload.insert(
|
||||
"provider_api_format".to_string(),
|
||||
json!(task.provider_api_format),
|
||||
);
|
||||
payload.insert("format_converted".to_string(), json!(task.format_converted));
|
||||
payload.insert("model".to_string(), json!(task.model));
|
||||
payload.insert("prompt".to_string(), json!(task.prompt));
|
||||
payload.insert(
|
||||
"original_request_body".to_string(),
|
||||
json!(task.original_request_body),
|
||||
);
|
||||
payload.insert(
|
||||
"converted_request_body".to_string(),
|
||||
serde_json::Value::Null,
|
||||
);
|
||||
payload.insert("duration_seconds".to_string(), json!(task.duration_seconds));
|
||||
payload.insert("resolution".to_string(), json!(task.resolution));
|
||||
payload.insert("aspect_ratio".to_string(), json!(task.aspect_ratio));
|
||||
payload.insert("size".to_string(), json!(task.size));
|
||||
payload.insert(
|
||||
"status".to_string(),
|
||||
json!(admin_video_task_status_name(task.status)),
|
||||
);
|
||||
payload.insert("progress_percent".to_string(), json!(task.progress_percent));
|
||||
payload.insert("progress_message".to_string(), json!(task.progress_message));
|
||||
payload.insert("video_url".to_string(), json!(task.video_url));
|
||||
payload.insert("video_urls".to_string(), serde_json::Value::Null);
|
||||
payload.insert("thumbnail_url".to_string(), serde_json::Value::Null);
|
||||
payload.insert("video_size_bytes".to_string(), serde_json::Value::Null);
|
||||
payload.insert(
|
||||
"video_duration_seconds".to_string(),
|
||||
serde_json::Value::Null,
|
||||
);
|
||||
payload.insert("video_expires_at".to_string(), serde_json::Value::Null);
|
||||
payload.insert("stored_video_path".to_string(), serde_json::Value::Null);
|
||||
payload.insert("storage_provider".to_string(), serde_json::Value::Null);
|
||||
payload.insert("error_code".to_string(), json!(task.error_code));
|
||||
payload.insert("error_message".to_string(), json!(task.error_message));
|
||||
payload.insert("retry_count".to_string(), json!(task.retry_count));
|
||||
payload.insert("max_retries".to_string(), serde_json::Value::Null);
|
||||
payload.insert(
|
||||
"poll_interval_seconds".to_string(),
|
||||
json!(task.poll_interval_seconds),
|
||||
);
|
||||
payload.insert(
|
||||
"next_poll_at".to_string(),
|
||||
json!(admin_video_task_timestamp(task.next_poll_at_unix_secs)),
|
||||
);
|
||||
payload.insert("poll_count".to_string(), json!(task.poll_count));
|
||||
payload.insert("max_poll_count".to_string(), json!(task.max_poll_count));
|
||||
payload.insert(
|
||||
"created_at".to_string(),
|
||||
json!(admin_video_task_timestamp(Some(task.created_at_unix_secs))),
|
||||
);
|
||||
payload.insert(
|
||||
"updated_at".to_string(),
|
||||
json!(admin_video_task_timestamp(Some(task.updated_at_unix_secs))),
|
||||
);
|
||||
payload.insert(
|
||||
"submitted_at".to_string(),
|
||||
json!(admin_video_task_timestamp(task.submitted_at_unix_secs)),
|
||||
);
|
||||
payload.insert(
|
||||
"completed_at".to_string(),
|
||||
json!(admin_video_task_timestamp(task.completed_at_unix_secs)),
|
||||
);
|
||||
payload.insert("request_metadata".to_string(), json!(task.request_metadata));
|
||||
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(serde_json::Value::Object(payload)).into_response(),
|
||||
"admin_video_task_detail_viewed",
|
||||
"view_video_task_detail",
|
||||
"video_task",
|
||||
&task.id,
|
||||
)));
|
||||
};
|
||||
let Some(task) = async_task::read_video_task_detail(state, task_id).await? else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Video task not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
if task
|
||||
.video_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
{
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Video not available" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
let Some(source) = async_task::read_video_task_video_source(state, task_id).await? else {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": "Video not available" })),
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
async_task::build_video_task_video_response(state, task_id, source).await?,
|
||||
"admin_video_task_video_viewed",
|
||||
"view_video_task_video",
|
||||
"video_task_video",
|
||||
task_id,
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
Reference in New Issue
Block a user