refactor: 移除 Python upstream 依赖,清理全部 legacy/Python 兼容层

- 移除 upstream_base_url 参数及 AETHER_GATEWAY_UPSTREAM 环境变量,gateway 不再需要指向 Python 宿主
- 删除所有 LEGACY_*/PYTHON_* 常量、路由组、header 定义及 sunset/phaseout 机制
- 将 legacy_gateway_bridge 重命名为 internal_gateway,executor 相关命名统一为 execution_runtime
- dev.sh 新增 Postgres/Redis 预检查,移除 upstream 相关启动参数和提示
- 新增 ai_public 路由处理器
- 全量适配 handler、test、state、control 等模块的命名和接口变更
This commit is contained in:
fawney19
2026-04-04 01:40:24 +08:00
parent 1d9c77522a
commit cbc811f6ce
484 changed files with 11046 additions and 3925 deletions

View File

@@ -0,0 +1,545 @@
use crate::gateway::async_task::CancelVideoTaskError;
use crate::gateway::{AppState, GatewayControlDecision, GatewayError, GatewayPublicRequestContext};
use aether_data::repository::video_tasks::{
StoredVideoTask, VideoTaskQueryFilter, VideoTaskStatus,
};
use axum::body::{Body, Bytes};
use axum::http::{self, Response};
use axum::response::IntoResponse;
use axum::Json;
use serde_json::json;
const CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL: &str = "Invalid token count payload";
const CLAUDE_COUNT_TOKENS_MISSING_BODY_DETAIL: &str = "请求体不能为空";
const GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL: &str = "Video task not found";
const AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL: &str = "Method not allowed";
const AI_PUBLIC_UNAUTHORIZED_DETAIL: &str = "Unauthorized";
pub(crate) fn ai_public_local_requires_buffered_body(
request_context: &GatewayPublicRequestContext,
) -> bool {
request_context
.control_decision
.as_ref()
.is_some_and(|decision| {
decision.route_class.as_deref() == Some("ai_public")
&& decision.route_family.as_deref() == Some("claude")
&& decision.route_kind.as_deref() == Some("count_tokens")
&& request_context.request_method == http::Method::POST
})
}
pub(crate) async fn maybe_build_local_ai_public_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Option<Response<Body>> {
if let Some(response) = maybe_build_local_ai_public_route_guard_response(request_context) {
return Some(response);
}
let decision = request_context.control_decision.as_ref()?;
if decision.route_class.as_deref() != Some("ai_public") {
return None;
}
if let Some(response) =
maybe_build_local_claude_count_tokens_response(request_context, request_body)
{
return Some(response);
}
maybe_build_local_gemini_video_operations_response(state, request_context, decision).await
}
fn maybe_build_local_ai_public_route_guard_response(
request_context: &GatewayPublicRequestContext,
) -> Option<Response<Body>> {
if request_context.request_path == "/upload/v1beta/files"
&& request_context.request_method != http::Method::POST
{
return Some(build_ai_public_error_response(
http::StatusCode::METHOD_NOT_ALLOWED,
AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL,
));
}
None
}
fn maybe_build_local_claude_count_tokens_response(
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("claude")
|| decision.route_kind.as_deref() != Some("count_tokens")
|| request_context.request_method != http::Method::POST
|| request_context.request_path != "/v1/messages/count_tokens"
{
return None;
}
let Some(request_body) = request_body else {
return Some(build_ai_public_error_response(
http::StatusCode::BAD_REQUEST,
CLAUDE_COUNT_TOKENS_MISSING_BODY_DETAIL,
));
};
let payload = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Some(build_ai_public_error_response(
http::StatusCode::BAD_REQUEST,
CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL,
));
}
};
let input_tokens = match estimate_claude_count_tokens(&payload) {
Ok(tokens) => tokens,
Err(_) => {
return Some(build_ai_public_error_response(
http::StatusCode::BAD_REQUEST,
CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL,
));
}
};
Some(Json(json!({ "input_tokens": input_tokens })).into_response())
}
async fn maybe_build_local_gemini_video_operations_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
decision: &GatewayControlDecision,
) -> Option<Response<Body>> {
if decision.route_family.as_deref() != Some("gemini")
|| decision.route_kind.as_deref() != Some("video")
{
return None;
}
if request_context.request_path == "/v1beta/operations" {
return Some(match request_context.request_method {
http::Method::GET => {
build_local_gemini_video_operations_list_response(state, decision).await
}
_ => build_ai_public_error_response(
http::StatusCode::METHOD_NOT_ALLOWED,
AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL,
),
});
}
let Some(operation_path) = request_context
.request_path
.strip_prefix("/v1beta/operations/")
else {
return None;
};
Some(match request_context.request_method {
http::Method::GET => {
build_local_gemini_video_operation_detail_response(state, decision, operation_path)
.await
}
http::Method::POST if operation_path.ends_with(":cancel") => {
build_local_gemini_video_operation_cancel_response(state, decision, operation_path)
.await
}
_ => build_ai_public_error_response(
http::StatusCode::METHOD_NOT_ALLOWED,
AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL,
),
})
}
async fn build_local_gemini_video_operations_list_response(
state: &AppState,
decision: &GatewayControlDecision,
) -> Response<Body> {
let Some(user_id) = decision
.auth_context
.as_ref()
.map(|auth_context| auth_context.user_id.trim())
.filter(|value| !value.is_empty())
else {
return build_ai_public_error_response(
http::StatusCode::UNAUTHORIZED,
AI_PUBLIC_UNAUTHORIZED_DETAIL,
);
};
let filter = VideoTaskQueryFilter {
user_id: Some(user_id.to_string()),
status: None,
model_substring: None,
client_api_format: Some("gemini:video".to_string()),
};
let tasks = match state.list_video_task_page(&filter, 0, 100).await {
Ok(tasks) => tasks,
Err(err) => {
return build_ai_public_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{err:?}"),
);
}
};
let operations = tasks
.into_iter()
.filter(is_gemini_video_task)
.map(|task| build_gemini_video_operation_payload(&task))
.collect::<Vec<_>>();
Json(json!({ "operations": operations })).into_response()
}
async fn build_local_gemini_video_operation_detail_response(
state: &AppState,
decision: &GatewayControlDecision,
operation_path: &str,
) -> Response<Body> {
let task =
match find_user_gemini_video_task_for_operation(state, decision, operation_path).await {
Ok(Some(task)) => task,
Ok(None) => {
return build_ai_public_error_response(
http::StatusCode::NOT_FOUND,
GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL,
);
}
Err(err) => {
return build_ai_public_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{err:?}"),
);
}
};
Json(build_gemini_video_operation_payload(&task)).into_response()
}
async fn build_local_gemini_video_operation_cancel_response(
state: &AppState,
decision: &GatewayControlDecision,
operation_path: &str,
) -> Response<Body> {
let task =
match find_user_gemini_video_task_for_operation(state, decision, operation_path).await {
Ok(Some(task)) => task,
Ok(None) => {
return build_ai_public_error_response(
http::StatusCode::NOT_FOUND,
GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL,
);
}
Err(err) => {
return build_ai_public_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{err:?}"),
);
}
};
match crate::gateway::async_task::cancel_video_task_record(state, &task.id).await {
Ok(_) => Json(json!({})).into_response(),
Err(CancelVideoTaskError::NotFound) => build_ai_public_error_response(
http::StatusCode::NOT_FOUND,
GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL,
),
Err(CancelVideoTaskError::InvalidStatus(status)) => build_ai_public_error_response(
http::StatusCode::BAD_REQUEST,
format!(
"Cannot cancel task with status: {}",
video_task_status_name(status)
),
),
Err(CancelVideoTaskError::Response(response)) => response,
Err(CancelVideoTaskError::Gateway(err)) => build_ai_public_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("{err:?}"),
),
}
}
async fn find_user_gemini_video_task_for_operation(
state: &AppState,
decision: &GatewayControlDecision,
operation_path: &str,
) -> Result<Option<StoredVideoTask>, GatewayError> {
let Some(user_id) = decision
.auth_context
.as_ref()
.map(|auth_context| auth_context.user_id.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let Some(short_id) = extract_short_id_from_gemini_operation_path(operation_path) else {
return Ok(None);
};
let Some(task) = state.find_video_task_by_short_id(short_id).await? else {
return Ok(None);
};
if task.user_id.as_deref().map(str::trim) != Some(user_id) || !is_gemini_video_task(&task) {
return Ok(None);
}
Ok(Some(task))
}
fn extract_short_id_from_gemini_operation_path(operation_path: &str) -> Option<&str> {
let trimmed = operation_path.trim_matches('/');
if trimmed.is_empty() {
return None;
}
let short_id = trimmed
.strip_suffix(":cancel")
.unwrap_or(trimmed)
.rsplit('/')
.next()?;
(!short_id.is_empty()).then_some(short_id)
}
fn is_gemini_video_task(task: &StoredVideoTask) -> bool {
matches!(
task.provider_api_format
.as_deref()
.or(task.client_api_format.as_deref())
.map(str::trim),
Some("gemini:video")
)
}
fn build_gemini_video_operation_payload(task: &StoredVideoTask) -> serde_json::Value {
match task.status {
VideoTaskStatus::Completed => json!({
"name": gemini_video_operation_name(task),
"done": true,
"response": {
"generateVideoResponse": {
"generatedSamples": [
{
"video": {
"uri": format!(
"/v1beta/files/aev_{}:download?alt=media",
gemini_operation_short_id(task)
),
"mimeType": "video/mp4",
}
}
]
}
}
}),
VideoTaskStatus::Failed | VideoTaskStatus::Expired => json!({
"name": gemini_video_operation_name(task),
"done": true,
"error": {
"code": task.error_code.clone().unwrap_or_else(|| "UNKNOWN".to_string()),
"message": task
.error_message
.clone()
.unwrap_or_else(|| "Video generation failed".to_string()),
}
}),
_ => json!({
"name": gemini_video_operation_name(task),
"done": false,
"metadata": gemini_video_operation_metadata(task),
}),
}
}
fn gemini_video_operation_name(task: &StoredVideoTask) -> String {
format!(
"models/{}/operations/{}",
gemini_operation_model(task),
gemini_operation_short_id(task)
)
}
fn gemini_operation_model(task: &StoredVideoTask) -> String {
task.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
task.external_task_id.as_deref().and_then(|external_id| {
let parts = external_id.split('/').collect::<Vec<_>>();
if parts.len() >= 2 && parts[0] == "models" && !parts[1].trim().is_empty() {
Some(parts[1].trim().to_string())
} else {
None
}
})
})
.unwrap_or_else(|| "unknown".to_string())
}
fn gemini_operation_short_id(task: &StoredVideoTask) -> String {
task.short_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(task.id.as_str())
.to_string()
}
fn gemini_video_operation_metadata(task: &StoredVideoTask) -> serde_json::Value {
task.request_metadata
.as_ref()
.and_then(|metadata| metadata.get("rust_local_snapshot"))
.and_then(|snapshot| snapshot.get("Gemini"))
.and_then(|gemini| gemini.get("metadata"))
.cloned()
.unwrap_or_else(|| json!({}))
}
fn 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",
}
}
fn build_ai_public_error_response(
status: http::StatusCode,
detail: impl Into<String>,
) -> Response<Body> {
(status, Json(json!({ "detail": detail.into() }))).into_response()
}
fn estimate_claude_count_tokens(payload: &serde_json::Value) -> Result<u64, ()> {
let object = payload.as_object().ok_or(())?;
let model = object
.get("model")
.and_then(serde_json::Value::as_str)
.ok_or(())?;
if model.trim().is_empty() {
return Err(());
}
let messages = object
.get("messages")
.and_then(serde_json::Value::as_array)
.ok_or(())?;
let system_tokens = estimate_claude_system_tokens(object.get("system"))?;
let message_tokens = estimate_claude_message_tokens(messages)?;
Ok(system_tokens.saturating_add(message_tokens))
}
fn estimate_claude_system_tokens(system: Option<&serde_json::Value>) -> Result<u64, ()> {
let Some(system) = system else {
return Ok(0);
};
match system {
serde_json::Value::Null => Ok(0),
serde_json::Value::String(text) => Ok(estimate_text_tokens(text)),
serde_json::Value::Array(blocks) => {
let mut total = 0_u64;
for block in blocks {
let block = block.as_object().ok_or(())?;
if let Some(text) = block.get("text").and_then(serde_json::Value::as_str) {
total = total.saturating_add(estimate_text_tokens(text));
}
}
Ok(total)
}
serde_json::Value::Object(_) => Ok(0),
_ => Err(()),
}
}
fn estimate_claude_message_tokens(messages: &[serde_json::Value]) -> Result<u64, ()> {
let mut total = 0_u64;
for message in messages {
let message = message.as_object().ok_or(())?;
let role = message
.get("role")
.and_then(serde_json::Value::as_str)
.ok_or(())?;
if !matches!(role, "user" | "assistant") {
return Err(());
}
total = total.saturating_add(4);
let content = message.get("content").ok_or(())?;
match content {
serde_json::Value::String(text) => {
total = total.saturating_add(estimate_text_tokens(text));
}
serde_json::Value::Array(items) => {
for item in items {
let item = item.as_object().ok_or(())?;
if let Some(text) = item.get("text").and_then(serde_json::Value::as_str) {
total = total.saturating_add(estimate_text_tokens(text));
}
}
}
_ => return Err(()),
}
}
Ok(total)
}
fn estimate_text_tokens(text: &str) -> u64 {
if text.is_empty() {
return 0;
}
let char_count = text.chars().count() as u64;
std::cmp::max(1, char_count / 4)
}
#[cfg(test)]
mod tests {
use super::estimate_claude_count_tokens;
use serde_json::json;
#[test]
fn estimates_claude_count_tokens_from_system_and_messages() {
let payload = json!({
"model": "claude-sonnet-4-5",
"system": [{"type": "text", "text": "abcdefghijklmnop"}],
"messages": [
{
"role": "user",
"content": "abcdefghijkl"
},
{
"role": "assistant",
"content": [
{"type": "text", "text": "abcdefgh"},
{"type": "tool_use", "name": "ignored", "input": {"city": "SF"}}
]
}
]
});
assert_eq!(estimate_claude_count_tokens(&payload), Ok(17));
}
#[test]
fn rejects_invalid_claude_count_tokens_payload() {
let payload = json!({
"model": "claude-sonnet-4-5",
"messages": [{"role": "system", "content": "bad"}]
});
assert_eq!(estimate_claude_count_tokens(&payload), Err(()));
}
}

View File

@@ -1,4 +1,15 @@
use super::*;
use crate::gateway::api::ai::public_api_format_local_path;
use crate::gateway::handlers::{
query_param_optional_bool, query_param_value, unix_secs_to_rfc3339,
};
use crate::gateway::AppState;
use aether_data::repository::candidates::{
PublicHealthTimelineBucket, RequestCandidateStatus, StoredRequestCandidate,
};
use aether_data::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) fn request_candidate_status_label(status: RequestCandidateStatus) -> &'static str {
match status {

View File

@@ -1,9 +1,40 @@
pub(crate) use super::*;
mod ai_public;
mod catalog_helpers;
mod support;
mod system_modules_helpers;
pub(crate) use self::catalog_helpers::*;
pub(crate) use self::support::*;
pub(crate) use self::system_modules_helpers::*;
pub(crate) use self::ai_public::{
ai_public_local_requires_buffered_body, maybe_build_local_ai_public_response,
};
pub(crate) use self::catalog_helpers::{
admin_requested_force_stream, api_format_display_name, build_api_format_health_monitor_payload,
build_public_catalog_models_payload, build_public_catalog_search_models_payload,
build_public_health_timeline, build_public_providers_payload, normalize_admin_base_url,
provider_key_api_formats, request_candidate_event_unix_secs, request_candidate_status_label,
ApiFormatHealthMonitorOptions,
};
pub(crate) use self::system_modules_helpers::{
admin_module_by_name, admin_module_name_from_enabled_path, admin_module_name_from_status_path,
apply_admin_email_template_update, apply_admin_system_config_update,
apply_admin_system_settings_update, build_admin_api_formats_payload,
build_admin_email_template_payload, build_admin_email_templates_payload,
build_admin_keys_grouped_by_format_payload, build_admin_module_runtime_state,
build_admin_module_status_payload, build_admin_module_validation_result,
build_admin_modules_status_payload, build_admin_system_check_update_payload,
build_admin_system_config_detail_payload, build_admin_system_config_export_payload,
build_admin_system_configs_payload, build_admin_system_settings_payload,
build_admin_system_stats_payload, build_admin_system_users_export_payload,
build_public_auth_modules_status_payload, capability_detail_by_name, current_aether_version,
delete_admin_system_config, enabled_key_capability_short_names,
escape_admin_email_template_html, ldap_module_config_is_valid, module_available_from_env,
preview_admin_email_template, read_admin_email_template_payload,
render_admin_email_template_html, reset_admin_email_template,
serialize_admin_system_users_export_wallet, serialize_public_capability,
supported_capability_names, system_config_bool, system_config_string,
AdminSetModuleEnabledRequest, PUBLIC_CAPABILITY_DEFINITIONS,
};
pub(crate) use self::support::{
build_unhandled_public_support_response, matches_model_mapping_for_models,
maybe_build_local_admin_announcements_response, maybe_build_local_public_support_response,
};

View File

@@ -1,4 +1,23 @@
pub(crate) use super::*;
use super::{
build_api_format_health_monitor_payload, build_public_auth_modules_status_payload,
build_public_catalog_models_payload, build_public_catalog_search_models_payload,
build_public_providers_payload, capability_detail_by_name, escape_admin_email_template_html,
ldap_module_config_is_valid, module_available_from_env, read_admin_email_template_payload,
render_admin_email_template_html, serialize_public_capability, supported_capability_names,
system_config_bool, system_config_string, ApiFormatHealthMonitorOptions,
PUBLIC_CAPABILITY_DEFINITIONS,
};
use crate::gateway::handlers::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, query_param_bool,
query_param_optional_bool, query_param_value, unix_secs_to_rfc3339,
};
use crate::gateway::{AppState, GatewayError, GatewayPublicRequestContext};
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};
#[path = "support/announcements.rs"]
mod support_announcements;
@@ -34,25 +53,36 @@ use self::support_auth::auth_session::{
};
use self::support_auth::{
build_auth_error_response, build_auth_json_response, build_auth_registration_settings_payload,
build_auth_settings_payload, maybe_build_local_auth_legacy_response,
build_auth_settings_payload, maybe_build_local_auth_response,
};
use self::support_dashboard::maybe_build_local_dashboard_legacy_response;
use self::support_dashboard::maybe_build_local_dashboard_response;
use self::support_models::{
build_models_auth_error_response, maybe_build_local_models_response, models_api_format,
};
use self::support_monitoring::maybe_build_local_user_monitoring_response;
use self::support_payment::maybe_build_local_payment_callback_response;
use self::support_test_connection::maybe_build_local_test_connection_response;
use self::support_user_me::maybe_build_local_users_me_legacy_response;
use self::support_user_me::maybe_build_local_users_me_response;
use self::support_wallet::{
maybe_build_local_wallet_legacy_response, sanitize_wallet_gateway_response,
maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
wallet_normalize_optional_string_field, wallet_payment_order_payload_from_row,
};
fn build_public_support_maintenance_response(detail: &str) -> Response<Body> {
pub(crate) fn build_unhandled_public_support_response(
request_context: &GatewayPublicRequestContext,
) -> Response<Body> {
let decision = request_context
.control_decision
.as_ref()
.expect("public support response requires control decision");
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": detail })),
http::StatusCode::NOT_IMPLEMENTED,
Json(json!({
"detail": "public support route not implemented in rust frontdoor",
"route_family": decision.route_family,
"route_kind": decision.route_kind,
"request_path": request_context.request_path,
})),
)
.into_response()
}
@@ -61,58 +91,27 @@ pub(crate) async fn maybe_build_local_public_support_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
request_body: Option<&Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_class.as_deref() != Some("public_support") {
return None;
}
if decision.route_family.as_deref() == Some("oauth_public_legacy") {
return Some(
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"detail": "OAuth public routes are retired; use Rust maintenance backend",
})),
)
.into_response(),
);
if decision.route_family.as_deref() == Some("auth") {
return maybe_build_local_auth_response(state, request_context, headers, request_body)
.await;
}
if decision.route_family.as_deref() == Some("oauth_user_legacy") {
return Some(
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"detail": "OAuth user routes are retired; use Rust maintenance backend",
})),
)
.into_response(),
);
if decision.route_family.as_deref() == Some("dashboard") {
return Some(maybe_build_local_dashboard_response(state, request_context, headers).await);
}
if decision.route_family.as_deref() == Some("auth_legacy") {
return maybe_build_local_auth_legacy_response(
state,
request_context,
headers,
request_body,
)
.await;
}
if decision.route_family.as_deref() == Some("dashboard_legacy") {
return Some(
maybe_build_local_dashboard_legacy_response(state, request_context, headers).await,
);
}
if decision.route_family.as_deref() == Some("monitoring_user_legacy") {
if decision.route_family.as_deref() == Some("monitoring_user") {
return maybe_build_local_user_monitoring_response(state, request_context, headers).await;
}
if decision.route_family.as_deref() == Some("announcement_user_legacy") {
if decision.route_family.as_deref() == Some("announcement_user") {
return maybe_build_local_announcement_user_response(
state,
request_context,
@@ -122,29 +121,21 @@ pub(crate) async fn maybe_build_local_public_support_response(
.await;
}
if decision.route_family.as_deref() == Some("wallet_legacy") {
if decision.route_family.as_deref() == Some("wallet") {
if let Some(response) =
maybe_build_local_wallet_legacy_response(state, request_context, headers, request_body)
.await
maybe_build_local_wallet_response(state, request_context, headers, request_body).await
{
return Some(response);
}
return Some(build_public_support_maintenance_response(
"Wallet routes require Rust maintenance backend",
));
return Some(build_unhandled_public_support_response(request_context));
}
if decision.route_family.as_deref() == Some("users_me_legacy") {
return maybe_build_local_users_me_legacy_response(
state,
request_context,
headers,
request_body,
)
.await;
if decision.route_family.as_deref() == Some("users_me") {
return maybe_build_local_users_me_response(state, request_context, headers, request_body)
.await;
}
if decision.route_family.as_deref() == Some("payment_callback_legacy") {
if decision.route_family.as_deref() == Some("payment_callback") {
return maybe_build_local_payment_callback_response(
state,
request_context,

View File

@@ -1,8 +1,3 @@
use super::*;
const ANNOUNCEMENTS_MAINTENANCE_DETAIL: &str =
"Announcement routes require Rust maintenance backend";
#[path = "announcements/admin_routes.rs"]
mod announcements_admin_routes;
#[path = "announcements/public_routes.rs"]

View File

@@ -1,11 +1,28 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::gateway::{AppState, GatewayError, GatewayPublicRequestContext};
use super::announcements_shared::{
announcements_bad_request_response, announcements_not_found_response,
build_public_announcement_payload, parse_optional_rfc3339_unix_secs,
public_announcement_id_from_path,
};
use super::*;
use aether_data::repository::announcements::{CreateAnnouncementRecord, UpdateAnnouncementRecord};
fn build_admin_announcement_writer_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": "公告写入暂不可用" })),
)
.into_response()
}
#[derive(Debug, serde::Deserialize)]
struct AdminAnnouncementCreateRequest {
title: String,
@@ -51,6 +68,9 @@ pub(crate) async fn maybe_build_local_admin_announcements_response(
"/api/announcements" | "/api/announcements/"
) =>
{
if !state.has_announcement_data_writer() {
return Ok(Some(build_admin_announcement_writer_unavailable_response()));
}
let Some(request_body) = request_body else {
return Ok(Some(announcements_bad_request_response("请求体不能为空")));
};
@@ -75,9 +95,7 @@ pub(crate) async fn maybe_build_local_admin_announcements_response(
})?;
let record = build_create_record(payload, operator_id)?;
let Some(created) = state.create_announcement(record).await? else {
return Ok(Some(build_public_support_maintenance_response(
super::ANNOUNCEMENTS_MAINTENANCE_DETAIL,
)));
return Ok(Some(build_admin_announcement_writer_unavailable_response()));
};
let mut response = build_public_announcement_payload(&created);
response["message"] = json!("公告创建成功");
@@ -89,6 +107,9 @@ pub(crate) async fn maybe_build_local_admin_announcements_response(
else {
return Ok(Some(announcements_not_found_response()));
};
if !state.has_announcement_data_writer() {
return Ok(Some(build_admin_announcement_writer_unavailable_response()));
}
let Some(request_body) = request_body else {
return Ok(Some(announcements_bad_request_response("请求体不能为空")));
};
@@ -104,7 +125,7 @@ pub(crate) async fn maybe_build_local_admin_announcements_response(
let record = build_update_record(announcement_id, payload)?;
return Ok(Some(match state.update_announcement(record).await? {
Some(_) => Json(json!({ "message": "公告更新成功" })).into_response(),
None => announcements_not_found_response(),
None => build_admin_announcement_writer_unavailable_response(),
}));
}
Some("delete_announcement") if request_context.request_method == http::Method::DELETE => {
@@ -113,6 +134,9 @@ pub(crate) async fn maybe_build_local_admin_announcements_response(
else {
return Ok(Some(announcements_not_found_response()));
};
if !state.has_announcement_data_writer() {
return Ok(Some(build_admin_announcement_writer_unavailable_response()));
}
if state
.find_announcement_by_id(announcement_id)
.await?
@@ -124,7 +148,7 @@ pub(crate) async fn maybe_build_local_admin_announcements_response(
return Ok(Some(if deleted {
Json(json!({ "message": "公告已删除" })).into_response()
} else {
build_public_support_maintenance_response(super::ANNOUNCEMENTS_MAINTENANCE_DETAIL)
build_admin_announcement_writer_unavailable_response()
}));
}
_ => {}

View File

@@ -1,10 +1,19 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use crate::gateway::{AppState, GatewayPublicRequestContext};
use super::super::build_unhandled_public_support_response;
use super::announcements_shared::{
announcements_bad_request_response, announcements_internal_detail,
announcements_internal_error_response, announcements_not_found_response,
build_public_announcement_list_payload, build_public_announcement_payload,
parse_public_announcements_query, public_announcement_id_from_path,
};
use super::*;
pub(crate) async fn maybe_build_local_public_announcements_response(
state: &AppState,
@@ -69,7 +78,11 @@ pub(crate) async fn maybe_build_local_public_announcements_response(
Some(Json(build_public_announcement_list_payload(page)).into_response())
}
Some("detail") => {
let announcement_id = public_announcement_id_from_path(&request_context.request_path)?;
let Some(announcement_id) =
public_announcement_id_from_path(&request_context.request_path)
else {
return Some(build_unhandled_public_support_response(request_context));
};
let announcement = match state.find_announcement_by_id(announcement_id).await {
Ok(Some(value)) => value,
Ok(None) => return Some(announcements_not_found_response()),
@@ -81,6 +94,6 @@ pub(crate) async fn maybe_build_local_public_announcements_response(
};
Some(Json(build_public_announcement_payload(&announcement)).into_response())
}
_ => None,
_ => Some(build_unhandled_public_support_response(request_context)),
}
}

View File

@@ -1,8 +1,18 @@
use super::*;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use aether_data::repository::announcements::{
AnnouncementListQuery, StoredAnnouncement, StoredAnnouncementPage,
};
use crate::gateway::handlers::{query_param_optional_bool, query_param_value};
use crate::gateway::GatewayError;
pub(super) fn parse_public_announcements_query(
query: Option<&str>,
default_active_only: bool,

View File

@@ -1,9 +1,19 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::gateway::{AppState, GatewayPublicRequestContext};
use super::super::{build_unhandled_public_support_response, resolve_authenticated_local_user};
use super::announcements_shared::{
announcements_bad_request_response, announcements_internal_detail,
announcements_internal_error_response, announcements_not_found_response,
read_status_announcement_id_from_path,
};
use super::*;
#[derive(Debug, serde::Deserialize)]
struct AnnouncementReadStatusRequest {
@@ -29,7 +39,7 @@ pub(crate) async fn maybe_build_local_announcement_user_response(
request_body: Option<&axum::body::Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("announcement_user_legacy") {
if decision.route_family.as_deref() != Some("announcement_user") {
return None;
}
if !state.has_announcement_data_reader() {
@@ -91,7 +101,7 @@ pub(crate) async fn maybe_build_local_announcement_user_response(
let announcement_id =
match read_status_announcement_id_from_path(&request_context.request_path) {
Some(value) => value,
None => return None,
None => return Some(build_unhandled_public_support_response(request_context)),
};
match state.find_announcement_by_id(announcement_id).await {
Ok(Some(_)) => {}
@@ -112,9 +122,7 @@ pub(crate) async fn maybe_build_local_announcement_user_response(
}
Some(Json(json!({ "message": "公告已标记为已读" })).into_response())
}
_ => Some(build_public_support_maintenance_response(
super::ANNOUNCEMENTS_MAINTENANCE_DETAIL,
)),
_ => Some(build_unhandled_public_support_response(request_context)),
}
}

View File

@@ -1,4 +1,18 @@
use super::*;
pub(super) use super::{
build_unhandled_public_support_response, decrypt_catalog_secret_with_fallbacks,
escape_admin_email_template_html, ldap_module_config_is_valid, module_available_from_env,
read_admin_email_template_payload, render_admin_email_template_html, system_config_bool,
system_config_string, AppState, GatewayError, GatewayPublicRequestContext,
};
pub(super) use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
pub(super) use regex::Regex;
use serde::Deserialize;
pub(super) use serde_json::json;
#[path = "auth_helpers.rs"]
mod auth_helpers;
@@ -229,8 +243,10 @@ async fn handle_auth_login(
}
}
_ => {
return build_public_support_maintenance_response(
"Non-local auth login requires Rust maintenance backend",
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"不支持的认证类型",
false,
)
}
};
@@ -238,14 +254,14 @@ async fn handle_auth_login(
build_auth_login_success_response(state, headers, client_device_id, user).await
}
pub(super) async fn maybe_build_local_auth_legacy_response(
pub(super) async fn maybe_build_local_auth_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("auth_legacy") {
if decision.route_family.as_deref() != Some("auth") {
return None;
}
@@ -278,8 +294,54 @@ pub(super) async fn maybe_build_local_auth_legacy_response(
Some("logout") if request_context.request_path == "/api/auth/logout" => {
Some(handle_auth_logout(state, request_context, headers).await)
}
_ => Some(build_public_support_maintenance_response(
"Auth routes require Rust maintenance backend",
)),
_ => Some(build_unhandled_public_support_response(request_context)),
}
}
#[cfg(test)]
mod tests {
use super::{maybe_build_local_auth_response, AppState, GatewayPublicRequestContext};
use crate::gateway::GatewayControlDecision;
use axum::body::to_bytes;
use axum::http::{HeaderMap, Method, StatusCode, Uri};
fn request_context(method: Method, uri: &str, route_kind: &str) -> GatewayPublicRequestContext {
GatewayPublicRequestContext::from_request_parts(
"trace-auth-unhandled",
&method,
&uri.parse::<Uri>().expect("uri should parse"),
&HeaderMap::new(),
Some(GatewayControlDecision::synthetic(
uri,
Some("public_support".to_string()),
Some("auth".to_string()),
Some(route_kind.to_string()),
Some("user:auth".to_string()),
)),
)
}
#[tokio::test]
async fn auth_unhandled_route_returns_local_not_implemented_response() {
let state = AppState::new().expect("gateway should build");
let request_context = request_context(Method::POST, "/api/auth/login/history", "login");
let response =
maybe_build_local_auth_response(&state, &request_context, &HeaderMap::new(), None)
.await
.expect("auth handler should return response");
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(
payload["detail"],
"public support route not implemented in rust frontdoor"
);
assert_eq!(payload["route_family"], "auth");
assert_eq!(payload["route_kind"], "login");
assert_eq!(payload["request_path"], "/api/auth/login/history");
}
}

View File

@@ -1,4 +1,11 @@
use super::*;
use super::{
decrypt_catalog_secret_with_fallbacks, escape_admin_email_template_html, json,
read_admin_email_template_payload, render_admin_email_template_html, system_config_bool,
system_config_string, system_config_u16, AppState, GatewayError,
AUTH_EMAIL_VERIFICATION_PREFIX, AUTH_EMAIL_VERIFIED_PREFIX, AUTH_EMAIL_VERIFIED_TTL_SECS,
AUTH_SMTP_TIMEOUT_SECS,
};
use base64::Engine;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(super) struct StoredAuthEmailVerificationCode {

View File

@@ -1,4 +1,8 @@
use super::*;
use super::{
http, json, ldap_module_config_is_valid, module_available_from_env, system_config_bool,
system_config_string, AppState, Body, GatewayError, GatewayPublicRequestContext, IntoResponse,
Json, Response,
};
pub(crate) async fn build_auth_registration_settings_payload(
state: &AppState,

View File

@@ -1,4 +1,7 @@
use super::*;
use super::{
decrypt_catalog_secret_with_fallbacks, ldap_config_is_enabled, module_available_from_env,
normalize_auth_login_identifier, system_config_bool, AppState, GatewayError,
};
#[derive(Debug, Clone)]
pub(super) struct AuthLdapRuntimeConfig {

View File

@@ -1,4 +1,15 @@
use super::*;
use super::{
auth_email_is_verified, auth_now, auth_registration_email_configured,
auth_verification_code_expire_minutes, auth_verification_send_cooldown_seconds,
build_auth_error_response, build_auth_json_response, build_auth_verification_email,
clear_auth_email_pending_code, clear_auth_email_verification, generate_auth_verification_code,
http, json, mark_auth_email_verified, read_auth_email_verification_code, read_auth_smtp_config,
send_auth_email, store_auth_email_verification_code, system_config_bool, system_config_f64,
system_config_string, system_config_string_list, AppState, Body, GatewayError, Regex, Response,
};
use serde::Deserialize;
const AUTH_REGISTRATION_STORAGE_UNAVAILABLE_DETAIL: &str = "注册数据存储暂不可用";
#[derive(Debug, Deserialize)]
struct AuthRegisterRequest {
@@ -496,8 +507,10 @@ pub(super) async fn handle_auth_register(
);
}
}) else {
return build_public_support_maintenance_response(
"Auth registration requires Rust data backend",
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
AUTH_REGISTRATION_STORAGE_UNAVAILABLE_DETAIL,
false,
);
};

View File

@@ -1,4 +1,11 @@
use super::*;
use super::{
auth_access_token_expiry_hours, auth_client_ip, auth_jwt_secret, auth_now,
auth_refresh_cookie_name, auth_user_agent, build_auth_error_response, build_auth_json_response,
build_auth_refresh_cookie_clear_header, build_auth_refresh_cookie_header, extract_bearer_token,
extract_client_device_id, extract_cookie_value, http, json, AppState, Body,
GatewayPublicRequestContext, Response, AUTH_REFRESH_TOKEN_EXPIRATION_DAYS,
};
use uuid::Uuid;
fn base64url_encode(bytes: &[u8]) -> String {
use base64::Engine;

View File

@@ -1,6 +1,8 @@
use super::*;
const DASHBOARD_MAINTENANCE_DETAIL: &str = "Dashboard routes require Rust maintenance backend";
pub(super) use super::{
build_auth_error_response, build_unhandled_public_support_response, query_param_value,
resolve_authenticated_local_user, AppState, GatewayError, GatewayPublicRequestContext,
};
use axum::{body::Body, http, response::Response};
#[path = "dashboard_filters.rs"]
mod dashboard_helpers;
@@ -10,7 +12,7 @@ use self::dashboard_helpers::{
handle_dashboard_recent_requests_get, handle_dashboard_stats_get,
};
pub(super) async fn maybe_build_local_dashboard_legacy_response(
pub(super) async fn maybe_build_local_dashboard_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
@@ -40,6 +42,54 @@ pub(super) async fn maybe_build_local_dashboard_legacy_response(
{
handle_dashboard_daily_stats_get(state, request_context, headers).await
}
_ => build_public_support_maintenance_response(DASHBOARD_MAINTENANCE_DETAIL),
_ => build_unhandled_public_support_response(request_context),
}
}
#[cfg(test)]
mod tests {
use super::{
maybe_build_local_dashboard_response, AppState, GatewayPublicRequestContext,
};
use crate::gateway::GatewayControlDecision;
use axum::body::to_bytes;
use axum::http::{HeaderMap, Method, StatusCode, Uri};
fn request_context(method: Method, uri: &str, route_kind: &str) -> GatewayPublicRequestContext {
GatewayPublicRequestContext::from_request_parts(
"trace-dashboard-unhandled",
&method,
&uri.parse::<Uri>().expect("uri should parse"),
&HeaderMap::new(),
Some(GatewayControlDecision::synthetic(
uri,
Some("public_support".to_string()),
Some("dashboard".to_string()),
Some(route_kind.to_string()),
Some("user:dashboard".to_string()),
)),
)
}
#[tokio::test]
async fn dashboard_unhandled_route_returns_local_not_implemented_response() {
let state = AppState::new().expect("gateway should build");
let request_context = request_context(Method::GET, "/api/dashboard/stats/history", "stats");
let response =
maybe_build_local_dashboard_response(&state, &request_context, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(
payload["detail"],
"public support route not implemented in rust frontdoor"
);
assert_eq!(payload["route_family"], "dashboard");
assert_eq!(payload["route_kind"], "stats");
assert_eq!(payload["request_path"], "/api/dashboard/stats/history");
}
}

View File

@@ -1,5 +1,16 @@
use super::*;
use super::{
build_auth_error_response, query_param_value, resolve_authenticated_local_user, AppState,
GatewayError, GatewayPublicRequestContext,
};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use chrono::Datelike;
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy)]
struct DashboardDateRange {

View File

@@ -1,4 +1,6 @@
use super::*;
use axum::{body::Body, response::Response};
pub(super) use super::{query_param_value, AppState, GatewayPublicRequestContext};
#[path = "models/responses.rs"]
mod models_responses;

View File

@@ -1,5 +1,11 @@
use super::*;
use axum::response::IntoResponse;
use aether_data::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(crate) fn build_models_auth_error_response(api_format: &str) -> Response<Body> {
match api_format {

View File

@@ -1,3 +1,7 @@
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{body::Body, response::Response};
use super::models_responses::{
build_claude_model_detail_response, build_claude_models_list_response,
build_empty_models_list_response, build_gemini_model_detail_response,
@@ -6,7 +10,7 @@ use super::models_responses::{
build_openai_models_list_response,
};
use super::models_shared::{filter_rows_for_models, models_api_format, models_detail_id};
use super::*;
use super::{query_param_value, AppState, GatewayPublicRequestContext};
pub(super) async fn maybe_build_local_models_route_response(
state: &AppState,

View File

@@ -1,4 +1,7 @@
use super::*;
use aether_data::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
use regex::Regex;
use super::GatewayPublicRequestContext;
pub(crate) fn models_api_format(request_context: &GatewayPublicRequestContext) -> Option<&str> {
request_context

View File

@@ -1,8 +1,9 @@
use super::*;
use super::{build_auth_error_response, resolve_authenticated_local_user};
use axum::{body::Body, http, response::Response};
const USER_MONITORING_MAINTENANCE_DETAIL: &str =
"User monitoring routes require Rust maintenance backend";
pub(super) use super::{
build_auth_error_response, build_unhandled_public_support_response,
resolve_authenticated_local_user, AppState, GatewayPublicRequestContext,
};
#[path = "monitoring/audit_logs.rs"]
mod user_monitoring_audit_logs;
@@ -18,7 +19,7 @@ pub(super) async fn maybe_build_local_user_monitoring_response(
headers: &http::HeaderMap,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("monitoring_user_legacy") {
if decision.route_family.as_deref() != Some("monitoring_user") {
return None;
}
@@ -35,8 +36,63 @@ pub(super) async fn maybe_build_local_user_monitoring_response(
{
Some(handle_user_rate_limit_status(state, request_context, headers).await)
}
_ => Some(build_public_support_maintenance_response(
USER_MONITORING_MAINTENANCE_DETAIL,
)),
_ => Some(build_unhandled_public_support_response(request_context)),
}
}
#[cfg(test)]
mod tests {
use super::{
maybe_build_local_user_monitoring_response, AppState, GatewayPublicRequestContext,
};
use crate::gateway::GatewayControlDecision;
use axum::body::to_bytes;
use axum::http::{HeaderMap, Method, StatusCode, Uri};
fn request_context(method: Method, uri: &str, route_kind: &str) -> GatewayPublicRequestContext {
GatewayPublicRequestContext::from_request_parts(
"trace-monitoring-unhandled",
&method,
&uri.parse::<Uri>().expect("uri should parse"),
&HeaderMap::new(),
Some(GatewayControlDecision::synthetic(
uri,
Some("public_support".to_string()),
Some("monitoring_user".to_string()),
Some(route_kind.to_string()),
Some("user:monitoring".to_string()),
)),
)
}
#[tokio::test]
async fn monitoring_unhandled_route_returns_local_not_implemented_response() {
let state = AppState::new().expect("gateway should build");
let request_context = request_context(
Method::GET,
"/api/monitoring/my-audit-logs/history",
"audit_logs",
);
let response =
maybe_build_local_user_monitoring_response(&state, &request_context, &HeaderMap::new())
.await
.expect("monitoring handler should return response");
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(
payload["detail"],
"public support route not implemented in rust frontdoor"
);
assert_eq!(payload["route_family"], "monitoring_user");
assert_eq!(payload["route_kind"], "audit_logs");
assert_eq!(
payload["request_path"],
"/api/monitoring/my-audit-logs/history"
);
}
}

View File

@@ -1,11 +1,18 @@
use axum::{http, response::IntoResponse};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use chrono::Utc;
use serde_json::{json, Value};
use sqlx::Row;
use crate::gateway::handlers::shared::query_param_value;
use super::{
build_auth_error_response, query_param_value, resolve_authenticated_local_user, AppState, Body,
GatewayPublicRequestContext, Json, Response,
build_auth_error_response, resolve_authenticated_local_user, AppState,
GatewayPublicRequestContext,
};
fn parse_user_monitoring_limit(query: Option<&str>) -> Result<usize, String> {

View File

@@ -1,10 +1,15 @@
use axum::{http, response::IntoResponse};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use chrono::Utc;
use serde_json::json;
use super::{
build_auth_error_response, resolve_authenticated_local_user, AppState, Body,
GatewayPublicRequestContext, Json, Response,
build_auth_error_response, resolve_authenticated_local_user, AppState,
GatewayPublicRequestContext,
};
fn normalize_rate_limit_value(value: Option<i32>) -> u32 {

View File

@@ -1,4 +1,6 @@
use super::*;
use axum::{body::Body, http, response::Response};
pub(super) use super::{build_auth_error_response, AppState, GatewayPublicRequestContext};
#[path = "payment/postgres.rs"]
mod payment_postgres;
@@ -10,11 +12,19 @@ mod payment_shared;
#[path = "payment/test_support.rs"]
mod payment_test_support;
pub(super) use self::payment_postgres::*;
pub(super) use self::payment_shared::*;
use self::payment_postgres::handle_payment_callback_with_postgres;
use self::payment_shared::NormalizedPaymentCallbackRequest;
const PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL: &str = "支付回调存储暂不可用";
fn build_payment_callback_storage_unavailable_response() -> Response<Body> {
build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL,
false,
)
}
pub(super) async fn maybe_build_local_payment_callback_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -29,3 +39,30 @@ pub(super) async fn maybe_build_local_payment_callback_response(
)
.await
}
#[cfg(test)]
mod tests {
use super::{
build_payment_callback_storage_unavailable_response,
PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL,
};
use axum::body::to_bytes;
use axum::http;
use serde_json::json;
#[tokio::test]
async fn payment_callback_storage_unavailable_response_is_explicit_local_503() {
let response = build_payment_callback_storage_unavailable_response();
assert_eq!(response.status(), http::StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(
payload,
json!({ "detail": PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL })
);
}
}

View File

@@ -2,8 +2,17 @@ use super::payment_shared::{
payment_callback_mark_failed_response, payment_callback_payload_hash,
NormalizedPaymentCallbackRequest,
};
use super::*;
use axum::{body::Body, http, response::Response};
use chrono::Utc;
use serde_json::json;
use sqlx::Row;
use uuid::Uuid;
use super::super::{build_auth_json_response, wallet_payment_order_payload_from_row};
use super::{
build_auth_error_response, build_payment_callback_storage_unavailable_response, AppState,
GatewayPublicRequestContext,
};
async fn update_payment_callback_failure(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
@@ -46,9 +55,7 @@ pub(super) async fn handle_payment_callback_with_postgres(
signature_valid: bool,
) -> Response<Body> {
let Some(pool) = state.postgres_pool() else {
return build_public_support_maintenance_response(
"Payment callback routes require Rust maintenance backend",
);
return build_payment_callback_storage_unavailable_response();
};
let mut tx = match pool.begin().await {
Ok(value) => value,
@@ -323,6 +330,10 @@ FOR UPDATE
.try_get::<String, _>("wallet_id")
.ok()
.unwrap_or_default();
let order_payment_method = order_row
.try_get::<String, _>("payment_method")
.ok()
.unwrap_or_default();
let order_amount_usd = order_row
.try_get::<f64, _>("amount_usd")
.ok()
@@ -353,6 +364,24 @@ FOR UPDATE
&request_context.request_path,
);
}
if !order_payment_method.eq_ignore_ascii_case(payment_method) {
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
"payment method mismatch",
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
duplicate,
"payment method mismatch",
payment_method,
&request_context.request_path,
);
}
if order_status == "credited" {
let _ = sqlx::query(
r#"
@@ -726,3 +755,57 @@ WHERE id = $1
None,
)
}
#[cfg(test)]
mod tests {
use super::{handle_payment_callback_with_postgres, AppState, NormalizedPaymentCallbackRequest};
use crate::gateway::handlers::public::support::support_payment::PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL;
use crate::gateway::GatewayPublicRequestContext;
use axum::body::to_bytes;
use axum::http::{HeaderMap, Method, Uri};
use serde_json::json;
#[tokio::test]
async fn payment_callback_postgres_handler_returns_explicit_503_without_pool() {
let state = AppState::new().expect("state should build");
let request_context = GatewayPublicRequestContext::from_request_parts(
"trace-payment-callback-postgres-missing",
&Method::POST,
&"/api/payment/callback/alipay"
.parse::<Uri>()
.expect("uri should parse"),
&HeaderMap::new(),
None,
);
let payload = NormalizedPaymentCallbackRequest {
callback_key: "callback-key-1".to_string(),
order_no: Some("order-no-1".to_string()),
gateway_order_id: Some("gateway-order-1".to_string()),
amount_usd: 10.0,
pay_amount: Some(10.0),
pay_currency: Some("USD".to_string()),
exchange_rate: Some(1.0),
payload: json!({ "status": "paid" }),
};
let response = handle_payment_callback_with_postgres(
&state,
"alipay",
&request_context,
&payload,
true,
)
.await;
assert_eq!(response.status(), http::StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(
payload,
json!({ "detail": PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL })
);
}
}

View File

@@ -1,9 +1,14 @@
use axum::{body::Body, http, response::Response};
use super::payment_shared::{
normalize_payment_callback_request, payment_callback_payment_method_from_path,
payment_callback_secret, payment_callback_signature_matches, PaymentCallbackRequest,
PAYMENT_CALLBACK_SIGNATURE_HEADER, PAYMENT_CALLBACK_TOKEN_HEADER,
};
use super::*;
use super::{
build_auth_error_response, build_payment_callback_storage_unavailable_response,
handle_payment_callback_with_postgres, AppState, GatewayPublicRequestContext,
};
pub(super) async fn maybe_build_local_payment_callback_route_response(
state: &AppState,
@@ -12,7 +17,7 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
request_body: Option<&axum::body::Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("payment_callback_legacy")
if decision.route_family.as_deref() != Some("payment_callback")
|| decision.route_kind.as_deref() != Some("callback")
{
return None;
@@ -114,7 +119,7 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
#[cfg(test)]
{
return Some(
payment_test_support::handle_payment_callback_with_test_store(
super::payment_test_support::handle_payment_callback_with_test_store(
&payment_method,
request_context,
&payload,
@@ -126,8 +131,6 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
#[cfg(not(test))]
{
Some(build_public_support_maintenance_response(
"Payment callback routes require Rust maintenance backend",
))
Some(build_payment_callback_storage_unavailable_response())
}
}

View File

@@ -1,7 +1,11 @@
use super::*;
use axum::{body::Body, http, response::Response};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use super::super::{build_auth_json_response, wallet_normalize_optional_string_field};
pub(super) const PAYMENT_CALLBACK_TOKEN_HEADER: &str = "x-payment-callback-token";
pub(super) const PAYMENT_CALLBACK_SIGNATURE_HEADER: &str = "x-payment-callback-signature";

View File

@@ -1,5 +1,13 @@
use super::super::support_wallet::wallet_test_recharge_store;
use super::*;
use axum::{body::Body, http, response::Response};
use chrono::Utc;
use serde_json::json;
use super::super::{build_auth_json_response, sanitize_wallet_gateway_response};
use super::payment_shared::{
payment_callback_mark_failed_response, NormalizedPaymentCallbackRequest,
};
use super::GatewayPublicRequestContext;
#[derive(Debug, Clone)]
struct PaymentTestCallbackRecord {
@@ -77,6 +85,23 @@ pub(super) async fn handle_payment_callback_with_test_store(
&request_context.request_path,
);
};
let order_payment_method = order.payload["payment_method"]
.as_str()
.unwrap_or_default()
.to_string();
if !order_payment_method.eq_ignore_ascii_case(payment_method) {
callback_store.push(PaymentTestCallbackRecord {
callback_key: payload.callback_key.clone(),
payment_order_id: order.payload["id"].as_str().map(ToOwned::to_owned),
status: "failed".to_string(),
});
return payment_callback_mark_failed_response(
duplicate,
"payment method mismatch",
payment_method,
&request_context.request_path,
);
}
let order_amount = order.payload["amount_usd"].as_f64().unwrap_or_default();
if (payload.amount_usd - order_amount).abs() > f64::EPSILON {

View File

@@ -1,4 +1,7 @@
use super::*;
use axum::{body::Body, response::Response};
pub(super) use super::{query_param_value, AppState, GatewayPublicRequestContext};
use crate::gateway::handlers::admin::provider_catalog_key_supports_format;
#[path = "test_connection/route.rs"]
mod test_connection_route;

View File

@@ -1,5 +1,18 @@
use std::collections::BTreeMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use super::test_connection_shared::select_test_connection_provider;
use super::*;
use super::{
provider_catalog_key_supports_format, query_param_value, AppState, GatewayPublicRequestContext,
};
pub(super) async fn maybe_build_local_test_connection_route_response(
state: &AppState,

View File

@@ -1,4 +1,4 @@
use super::*;
use aether_data::repository::provider_catalog::StoredProviderCatalogProvider;
pub(super) fn select_test_connection_provider(
providers: Vec<StoredProviderCatalogProvider>,

View File

@@ -1,7 +1,17 @@
use super::*;
use super::{
auth_password_policy_level, build_auth_error_response, build_auth_wallet_summary_payload,
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, handle_auth_me,
query_param_optional_bool, query_param_value, resolve_authenticated_local_user,
unix_secs_to_rfc3339, validate_auth_register_password, AppState, AuthenticatedLocalUserContext,
GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS,
};
use crate::gateway::handlers::admin::{
admin_stats_bad_request_response, build_admin_endpoint_health_status_payload,
list_usage_for_optional_range, parse_bounded_u32, round_to, AdminStatsTimeRange,
AdminStatsUsageFilter,
};
use crate::gateway::handlers::internal::build_management_token_payload;
const USERS_ME_MAINTENANCE_DETAIL: &str =
"User self-service routes require Rust maintenance backend";
const USERS_ME_AVAILABLE_MODELS_FETCH_LIMIT: usize = 1000;
#[path = "user_me_management_tokens.rs"]
@@ -33,4 +43,4 @@ use user_me_shared::*;
mod user_me_routes;
use user_me_routes::*;
pub(super) use self::user_me_routes::maybe_build_local_users_me_legacy_response;
pub(super) use self::user_me_routes::maybe_build_local_users_me_response;

View File

@@ -1,4 +1,23 @@
use super::*;
use std::collections::{BTreeMap, BTreeSet};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use super::{
build_auth_error_response, decrypt_catalog_secret_with_fallbacks,
encrypt_catalog_secret_with_fallbacks, format_users_me_optional_unix_secs_iso8601,
known_capability_names, normalize_user_model_capability_settings_input,
query_param_optional_bool, resolve_authenticated_local_user,
user_configurable_capability_names, AppState, GatewayPublicRequestContext,
};
const USERS_ME_API_KEY_WRITE_UNAVAILABLE_DETAIL: &str = "用户 API 密钥写入暂不可用";
#[derive(Debug, Deserialize)]
struct UsersMeCreateApiKeyRequest {
@@ -52,17 +71,42 @@ struct UsersMeUpdateApiKeyCapabilitiesRequest {
capabilities: Option<Vec<String>>,
}
fn users_me_api_key_id_from_path(request_path: &str) -> Option<String> {
request_path
fn users_me_api_key_path_segments(request_path: &str) -> Option<Vec<&str>> {
let raw = request_path
.strip_prefix("/api/users/me/api-keys/")?
.trim()
.trim_matches('/')
.trim_matches('/');
if raw.is_empty() {
return None;
}
let segments = raw
.split('/')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| !value.contains('/'))
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
(!segments.is_empty()).then_some(segments)
}
fn users_me_api_key_detail_id_from_path(request_path: &str) -> Option<String> {
let segments = users_me_api_key_path_segments(request_path)?;
(segments.len() == 1).then(|| segments[0].to_string())
}
fn users_me_api_key_nested_id_from_path(request_path: &str, suffix: &str) -> Option<String> {
let segments = users_me_api_key_path_segments(request_path)?;
(segments.len() == 2 && segments[1] == suffix).then(|| segments[0].to_string())
}
pub(super) fn users_me_api_key_detail_path_matches(request_path: &str) -> bool {
users_me_api_key_detail_id_from_path(request_path).is_some()
}
pub(super) fn users_me_api_key_providers_path_matches(request_path: &str) -> bool {
users_me_api_key_nested_id_from_path(request_path, "providers").is_some()
}
pub(super) fn users_me_api_key_capabilities_path_matches(request_path: &str) -> bool {
users_me_api_key_nested_id_from_path(request_path, "capabilities").is_some()
}
fn users_me_masked_api_key_display(state: &AppState, ciphertext: Option<&str>) -> String {
@@ -83,6 +127,14 @@ fn users_me_masked_api_key_display(state: &AppState, ciphertext: Option<&str>) -
format!("{prefix}...{suffix}")
}
fn build_users_me_api_key_writer_unavailable_response() -> Response<Body> {
build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_API_KEY_WRITE_UNAVAILABLE_DETAIL,
false,
)
}
fn build_users_me_api_key_list_payload(
state: &AppState,
record: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
@@ -291,8 +343,9 @@ pub(super) async fn handle_users_me_api_key_detail_get(
Ok(value) => value,
Err(response) => return response,
};
let Some(api_key_id) = users_me_api_key_id_from_path(&request_context.request_path) else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
let Some(api_key_id) = users_me_api_key_detail_id_from_path(&request_context.request_path)
else {
return build_auth_error_response(http::StatusCode::NOT_FOUND, "API密钥不存在", false);
};
let include_key = query_param_optional_bool(
request_context.request_query_string.as_deref(),
@@ -372,20 +425,15 @@ pub(super) async fn handle_users_me_api_key_detail_get(
.into_response()
}
async fn resolve_users_me_api_key_snapshot(
async fn resolve_users_me_api_key_snapshot_by_id(
state: &AppState,
user_id: &str,
request_path: &str,
api_key_id: &str,
) -> Result<crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, Response<Body>> {
let Some(api_key_id) = users_me_api_key_id_from_path(request_path) else {
return Err(build_public_support_maintenance_response(
USERS_ME_MAINTENANCE_DETAIL,
));
};
let snapshot = match state
.read_auth_api_key_snapshot(
user_id,
&api_key_id,
api_key_id,
chrono::Utc::now().timestamp().max(0) as u64,
)
.await
@@ -436,7 +484,7 @@ pub(super) async fn handle_users_me_api_key_create(
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
if !state.has_auth_api_key_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
@@ -497,7 +545,7 @@ pub(super) async fn handle_users_me_api_key_create(
)
}
}) else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
};
Json(json!({
@@ -518,22 +566,21 @@ pub(super) async fn handle_users_me_api_key_update(
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
if !state.has_auth_api_key_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
let snapshot = match resolve_users_me_api_key_snapshot(
state,
&auth.user.id,
&request_context.request_path,
)
.await
{
Ok(value) => value,
Err(response) => return response,
let Some(api_key_id) = users_me_api_key_detail_id_from_path(&request_context.request_path)
else {
return build_auth_error_response(http::StatusCode::NOT_FOUND, "API密钥不存在", false);
};
let snapshot =
match resolve_users_me_api_key_snapshot_by_id(state, &auth.user.id, &api_key_id).await {
Ok(value) => value,
Err(response) => return response,
};
if let Err(response) = ensure_users_me_api_key_mutable(&snapshot) {
return response;
}
@@ -586,7 +633,7 @@ pub(super) async fn handle_users_me_api_key_update(
)
}
}) else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
};
let mut payload =
@@ -602,22 +649,21 @@ pub(super) async fn handle_users_me_api_key_patch(
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
if !state.has_auth_api_key_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
let snapshot = match resolve_users_me_api_key_snapshot(
state,
&auth.user.id,
&request_context.request_path,
)
.await
{
Ok(value) => value,
Err(response) => return response,
let Some(api_key_id) = users_me_api_key_detail_id_from_path(&request_context.request_path)
else {
return build_auth_error_response(http::StatusCode::NOT_FOUND, "API密钥不存在", false);
};
let snapshot =
match resolve_users_me_api_key_snapshot_by_id(state, &auth.user.id, &api_key_id).await {
Ok(value) => value,
Err(response) => return response,
};
if let Err(response) = ensure_users_me_api_key_mutable(&snapshot) {
return response;
}
@@ -649,7 +695,7 @@ pub(super) async fn handle_users_me_api_key_patch(
)
}
}) else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
};
Json(json!({
@@ -666,22 +712,21 @@ pub(super) async fn handle_users_me_api_key_delete(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_auth_api_key_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
let snapshot = match resolve_users_me_api_key_snapshot(
state,
&auth.user.id,
&request_context.request_path,
)
.await
{
Ok(value) => value,
Err(response) => return response,
let Some(api_key_id) = users_me_api_key_detail_id_from_path(&request_context.request_path)
else {
return build_auth_error_response(http::StatusCode::NOT_FOUND, "API密钥不存在", false);
};
let snapshot =
match resolve_users_me_api_key_snapshot_by_id(state, &auth.user.id, &api_key_id).await {
Ok(value) => value,
Err(response) => return response,
};
if let Err(response) = ensure_users_me_api_key_mutable(&snapshot) {
return response;
}
@@ -707,22 +752,22 @@ pub(super) async fn handle_users_me_api_key_providers_put(
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
if !state.has_auth_api_key_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
let snapshot = match resolve_users_me_api_key_snapshot(
state,
&auth.user.id,
&request_context.request_path,
)
.await
{
Ok(value) => value,
Err(response) => return response,
let Some(api_key_id) =
users_me_api_key_nested_id_from_path(&request_context.request_path, "providers")
else {
return build_auth_error_response(http::StatusCode::NOT_FOUND, "API密钥不存在", false);
};
let snapshot =
match resolve_users_me_api_key_snapshot_by_id(state, &auth.user.id, &api_key_id).await {
Ok(value) => value,
Err(response) => return response,
};
if let Err(response) = ensure_users_me_api_key_mutable(&snapshot) {
return response;
}
@@ -825,7 +870,7 @@ pub(super) async fn handle_users_me_api_key_providers_put(
)
}
}) else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
};
Json(json!({
@@ -842,22 +887,22 @@ pub(super) async fn handle_users_me_api_key_capabilities_put(
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
if !state.has_auth_api_key_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
Ok(value) => value,
Err(response) => return response,
};
let snapshot = match resolve_users_me_api_key_snapshot(
state,
&auth.user.id,
&request_context.request_path,
)
.await
{
Ok(value) => value,
Err(response) => return response,
let Some(api_key_id) =
users_me_api_key_nested_id_from_path(&request_context.request_path, "capabilities")
else {
return build_auth_error_response(http::StatusCode::NOT_FOUND, "API密钥不存在", false);
};
let snapshot =
match resolve_users_me_api_key_snapshot_by_id(state, &auth.user.id, &api_key_id).await {
Ok(value) => value,
Err(response) => return response,
};
if let Err(response) = ensure_users_me_api_key_mutable(&snapshot) {
return response;
}
@@ -899,7 +944,7 @@ pub(super) async fn handle_users_me_api_key_capabilities_put(
)
}
}) else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_api_key_writer_unavailable_response();
};
Json(json!({

View File

@@ -1,4 +1,22 @@
use super::*;
use std::collections::{BTreeMap, BTreeSet};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use super::{
build_admin_endpoint_health_status_payload, build_auth_error_response, query_param_value,
resolve_authenticated_local_user, AppState, GatewayPublicRequestContext,
USERS_ME_AVAILABLE_MODELS_FETCH_LIMIT,
};
const USERS_ME_MODEL_CATALOG_UNAVAILABLE_DETAIL: &str = "用户模型目录暂不可用";
const USERS_ME_PROVIDER_CATALOG_UNAVAILABLE_DETAIL: &str = "用户提供商目录暂不可用";
const USERS_ME_ENDPOINT_STATUS_UNAVAILABLE_DETAIL: &str = "用户端点健康数据暂不可用";
fn build_users_me_available_model_payload(
model: aether_data::repository::global_models::StoredPublicGlobalModel,
@@ -60,8 +78,10 @@ async fn resolve_users_me_allowed_global_model_ids(
};
if !state.has_provider_catalog_data_reader() {
return Err(build_public_support_maintenance_response(
USERS_ME_MAINTENANCE_DETAIL,
return Err(build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_PROVIDER_CATALOG_UNAVAILABLE_DETAIL,
false,
));
}
@@ -118,7 +138,11 @@ pub(super) async fn handle_users_me_available_models(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_global_model_data_reader() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_MODEL_CATALOG_UNAVAILABLE_DETAIL,
false,
);
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -237,7 +261,11 @@ pub(super) async fn handle_users_me_providers_get(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_provider_catalog_data_reader() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_PROVIDER_CATALOG_UNAVAILABLE_DETAIL,
false,
);
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -379,7 +407,11 @@ pub(super) async fn handle_users_me_endpoint_status_get(
};
let Some(payload) = build_admin_endpoint_health_status_payload(state, 6).await else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_ENDPOINT_STATUS_UNAVAILABLE_DETAIL,
false,
);
};
let Some(items) = payload.as_array() else {
return build_auth_error_response(

View File

@@ -1,4 +1,24 @@
use super::*;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use chrono::Utc;
use serde_json::json;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use aether_data::repository::management_tokens::{
CreateManagementTokenRecord, ManagementTokenListQuery, RegenerateManagementTokenSecret,
StoredManagementTokenUserSummary, UpdateManagementTokenRecord,
};
use super::{
build_auth_error_response, build_management_token_payload, query_param_optional_bool,
query_param_value, resolve_authenticated_local_user, AppState, AuthenticatedLocalUserContext,
GatewayPublicRequestContext,
};
use crate::gateway::LocalMutationOutcome;
const USERS_ME_MANAGEMENT_TOKEN_PREFIX: &str = "ae_";
@@ -7,6 +27,10 @@ const USERS_ME_MANAGEMENT_TOKEN_DISPLAY_PREFIX_LEN: usize = 7;
const USERS_ME_MANAGEMENT_TOKEN_FETCH_LIMIT: usize = 10_000;
const USERS_ME_MANAGEMENT_TOKEN_DEFAULT_MAX_PER_USER: usize = 20;
const USERS_ME_MANAGEMENT_TOKEN_MAX_PER_USER_ENV: &str = "MANAGEMENT_TOKEN_MAX_PER_USER";
const USERS_ME_MANAGEMENT_TOKEN_READ_UNAVAILABLE_DETAIL: &str =
"用户 Management Token 数据暂不可用";
const USERS_ME_MANAGEMENT_TOKEN_WRITE_UNAVAILABLE_DETAIL: &str =
"用户 Management Token 写入暂不可用";
#[derive(Debug, Clone)]
struct UsersMeManagementTokenCreateInput {
@@ -46,39 +70,47 @@ pub(super) fn users_me_management_tokens_root(request_path: &str) -> bool {
)
}
fn users_me_management_token_id_from_path(request_path: &str) -> Option<String> {
fn users_me_management_token_path_segments(request_path: &str) -> Option<Vec<&str>> {
let raw = request_path
.strip_prefix("/api/me/management-tokens/")?
.trim()
.trim_matches('/');
if raw.is_empty() || raw.contains('/') {
if raw.is_empty() {
return None;
}
Some(raw.to_string())
let segments = raw
.split('/')
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
(!segments.is_empty()).then_some(segments)
}
fn users_me_management_token_id_from_path(request_path: &str) -> Option<String> {
let segments = users_me_management_token_path_segments(request_path)?;
(segments.len() == 1).then(|| segments[0].to_string())
}
fn users_me_management_token_status_id_from_path(request_path: &str) -> Option<String> {
let raw = request_path
.strip_prefix("/api/me/management-tokens/")?
.trim()
.trim_matches('/');
let token_id = raw.strip_suffix("/status")?.trim_matches('/');
if token_id.is_empty() || token_id.contains('/') {
return None;
}
Some(token_id.to_string())
let segments = users_me_management_token_path_segments(request_path)?;
(segments.len() == 2 && segments[1] == "status").then(|| segments[0].to_string())
}
fn users_me_management_token_regenerate_id_from_path(request_path: &str) -> Option<String> {
let raw = request_path
.strip_prefix("/api/me/management-tokens/")?
.trim()
.trim_matches('/');
let token_id = raw.strip_suffix("/regenerate")?.trim_matches('/');
if token_id.is_empty() || token_id.contains('/') {
return None;
}
Some(token_id.to_string())
let segments = users_me_management_token_path_segments(request_path)?;
(segments.len() == 2 && segments[1] == "regenerate").then(|| segments[0].to_string())
}
pub(super) fn users_me_management_token_detail_path_matches(request_path: &str) -> bool {
users_me_management_token_id_from_path(request_path).is_some()
}
pub(super) fn users_me_management_token_toggle_path_matches(request_path: &str) -> bool {
users_me_management_token_status_id_from_path(request_path).is_some()
}
pub(super) fn users_me_management_token_regenerate_path_matches(request_path: &str) -> bool {
users_me_management_token_regenerate_id_from_path(request_path).is_some()
}
fn users_me_management_token_max_per_user() -> usize {
@@ -114,6 +146,22 @@ fn users_me_management_token_prefix(value: &str) -> Option<String> {
})
}
fn build_users_me_management_token_reader_unavailable_response() -> Response<Body> {
build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_MANAGEMENT_TOKEN_READ_UNAVAILABLE_DETAIL,
false,
)
}
fn build_users_me_management_token_writer_unavailable_response() -> Response<Body> {
build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_MANAGEMENT_TOKEN_WRITE_UNAVAILABLE_DETAIL,
false,
)
}
fn users_me_management_token_limit(query: Option<&str>) -> usize {
query_param_value(query, "limit")
.and_then(|value| value.parse::<usize>().ok())
@@ -326,9 +374,7 @@ async fn list_users_me_management_tokens_for_user(
) -> Result<aether_data::repository::management_tokens::StoredManagementTokenListPage, Response<Body>>
{
if !state.has_management_token_reader() {
return Err(build_public_support_maintenance_response(
USERS_ME_MAINTENANCE_DETAIL,
));
return Err(build_users_me_management_token_reader_unavailable_response());
}
state
.list_management_tokens(&ManagementTokenListQuery {
@@ -354,9 +400,7 @@ async fn resolve_users_me_management_token(
) -> Result<aether_data::repository::management_tokens::StoredManagementTokenWithUser, Response<Body>>
{
if !state.has_management_token_reader() {
return Err(build_public_support_maintenance_response(
USERS_ME_MAINTENANCE_DETAIL,
));
return Err(build_users_me_management_token_reader_unavailable_response());
}
match state.get_management_token_with_user(token_id).await {
Ok(Some(token)) if token.token.user_id == user_id => Ok(token),
@@ -379,7 +423,7 @@ pub(super) async fn handle_users_me_management_tokens_list(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_management_token_reader() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_management_token_reader_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -433,7 +477,7 @@ pub(super) async fn handle_users_me_management_token_create(
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
if !state.has_management_token_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_management_token_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -512,7 +556,7 @@ pub(super) async fn handle_users_me_management_token_create(
build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
Ok(LocalMutationOutcome::Unavailable) => {
build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL)
build_users_me_management_token_writer_unavailable_response()
}
Ok(LocalMutationOutcome::NotFound) => build_auth_error_response(
http::StatusCode::NOT_FOUND,
@@ -557,7 +601,7 @@ pub(super) async fn handle_users_me_management_token_update(
request_body: Option<&axum::body::Bytes>,
) -> Response<Body> {
if !state.has_management_token_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_management_token_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -646,7 +690,7 @@ pub(super) async fn handle_users_me_management_token_update(
build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
Ok(LocalMutationOutcome::Unavailable) => {
build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL)
build_users_me_management_token_writer_unavailable_response()
}
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
@@ -662,7 +706,7 @@ pub(super) async fn handle_users_me_management_token_delete(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_management_token_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_management_token_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -702,7 +746,7 @@ pub(super) async fn handle_users_me_management_token_toggle(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_management_token_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_management_token_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -750,7 +794,7 @@ pub(super) async fn handle_users_me_management_token_regenerate(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_management_token_writer() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_management_token_writer_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -793,7 +837,7 @@ pub(super) async fn handle_users_me_management_token_regenerate(
build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
Ok(LocalMutationOutcome::Unavailable) => {
build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL)
build_users_me_management_token_writer_unavailable_response()
}
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,

View File

@@ -1,4 +1,20 @@
use super::*;
use std::collections::BTreeSet;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use super::{
build_auth_error_response, resolve_authenticated_local_user, AppState,
GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS,
};
const USERS_ME_PREFERENCES_STORAGE_UNAVAILABLE_DETAIL: &str = "用户偏好设置存储暂不可用";
const USERS_ME_MODEL_CAPABILITIES_STORAGE_UNAVAILABLE_DETAIL: &str = "用户模型能力配置存储暂不可用";
pub(super) fn user_configurable_capability_names() -> BTreeSet<&'static str> {
PUBLIC_CAPABILITY_DEFINITIONS
@@ -328,7 +344,11 @@ pub(super) async fn handle_users_me_preferences_put(
match state.write_user_preferences(&preferences).await {
Ok(Some(_)) => Json(json!({ "message": "偏好设置更新成功" })).into_response(),
Ok(None) => build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL),
Ok(None) => build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_PREFERENCES_STORAGE_UNAVAILABLE_DETAIL,
false,
),
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user preference update failed: {err:?}"),
@@ -371,7 +391,14 @@ pub(super) async fn handle_users_me_model_capabilities_put(
.update_user_model_capability_settings(&auth.user.id, settings)
.await
{
Ok(value) => value,
Ok(Some(value)) => value,
Ok(None) => {
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_MODEL_CAPABILITIES_STORAGE_UNAVAILABLE_DETAIL,
false,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
@@ -383,7 +410,7 @@ pub(super) async fn handle_users_me_model_capabilities_put(
Json(json!({
"message": "模型能力配置已更新",
"model_capability_settings": persisted.unwrap_or(serde_json::Value::Null),
"model_capability_settings": persisted,
}))
.into_response()
}

View File

@@ -1,4 +1,19 @@
use super::*;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use super::{
auth_password_policy_level, build_auth_error_response, resolve_authenticated_local_user,
validate_auth_register_password, AppState, GatewayPublicRequestContext,
};
const USERS_ME_PROFILE_STORAGE_UNAVAILABLE_DETAIL: &str = "用户资料存储暂不可用";
const USERS_ME_CREDENTIAL_STORAGE_UNAVAILABLE_DETAIL: &str = "用户凭证存储暂不可用";
#[derive(Debug, Deserialize)]
struct UsersMeUpdateProfileRequest {
@@ -97,7 +112,11 @@ pub(super) async fn handle_users_me_detail_put(
.await
{
Ok(Some(_)) => Json(json!({ "message": "个人信息更新成功" })).into_response(),
Ok(None) => build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL),
Ok(None) => build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_PROFILE_STORAGE_UNAVAILABLE_DETAIL,
false,
),
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user profile update failed: {err:?}"),
@@ -197,7 +216,13 @@ pub(super) async fn handle_users_me_password_patch(
.await
{
Ok(Some(_)) => {}
Ok(None) => return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL),
Ok(None) => {
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_CREDENTIAL_STORAGE_UNAVAILABLE_DETAIL,
false,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,

View File

@@ -1,13 +1,37 @@
use super::*;
use crate::gateway::handlers::public::support::build_unhandled_public_support_response;
use axum::{body::Body, http, response::Response};
pub(crate) async fn maybe_build_local_users_me_legacy_response(
use super::{
handle_auth_me, handle_users_me_api_key_capabilities_put, handle_users_me_api_key_create,
handle_users_me_api_key_delete, handle_users_me_api_key_detail_get,
handle_users_me_api_key_patch, handle_users_me_api_key_providers_put,
handle_users_me_api_key_update, handle_users_me_api_keys_get, handle_users_me_available_models,
handle_users_me_delete_other_sessions, handle_users_me_delete_session,
handle_users_me_detail_put, handle_users_me_endpoint_status_get,
handle_users_me_management_token_create, handle_users_me_management_token_delete,
handle_users_me_management_token_detail_get, handle_users_me_management_token_regenerate,
handle_users_me_management_token_toggle, handle_users_me_management_token_update,
handle_users_me_management_tokens_list, handle_users_me_model_capabilities_get,
handle_users_me_model_capabilities_put, handle_users_me_password_patch,
handle_users_me_preferences_get, handle_users_me_preferences_put,
handle_users_me_providers_get, handle_users_me_sessions_get, handle_users_me_update_session,
handle_users_me_usage_active_get, handle_users_me_usage_get, handle_users_me_usage_heatmap_get,
handle_users_me_usage_interval_timeline_get, users_me_api_key_capabilities_path_matches,
users_me_api_key_detail_path_matches, users_me_api_key_providers_path_matches,
users_me_management_token_detail_path_matches,
users_me_management_token_regenerate_path_matches,
users_me_management_token_toggle_path_matches, users_me_management_tokens_root,
users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext,
};
pub(crate) async fn maybe_build_local_users_me_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("users_me_legacy") {
if decision.route_family.as_deref() != Some("users_me") {
return None;
}
@@ -30,16 +54,12 @@ pub(crate) async fn maybe_build_local_users_me_legacy_response(
Some(handle_users_me_delete_other_sessions(state, request_context, headers).await)
}
Some("session_delete")
if request_context
.request_path
.starts_with("/api/users/me/sessions/") =>
if users_me_session_detail_path_matches(&request_context.request_path) =>
{
Some(handle_users_me_delete_session(state, request_context, headers).await)
}
Some("session_update")
if request_context
.request_path
.starts_with("/api/users/me/sessions/") =>
if users_me_session_detail_path_matches(&request_context.request_path) =>
{
Some(
handle_users_me_update_session(state, request_context, headers, request_body).await,
@@ -72,32 +92,24 @@ pub(crate) async fn maybe_build_local_users_me_legacy_response(
)
}
Some("api_key_detail")
if request_context
.request_path
.starts_with("/api/users/me/api-keys/") =>
if users_me_api_key_detail_path_matches(&request_context.request_path) =>
{
Some(handle_users_me_api_key_detail_get(state, request_context, headers).await)
}
Some("management_token_detail")
if request_context
.request_path
.starts_with("/api/me/management-tokens/") =>
if users_me_management_token_detail_path_matches(&request_context.request_path) =>
{
Some(handle_users_me_management_token_detail_get(state, request_context, headers).await)
}
Some("api_key_update")
if request_context
.request_path
.starts_with("/api/users/me/api-keys/") =>
if users_me_api_key_detail_path_matches(&request_context.request_path) =>
{
Some(
handle_users_me_api_key_update(state, request_context, headers, request_body).await,
)
}
Some("management_token_update")
if request_context
.request_path
.starts_with("/api/me/management-tokens/") =>
if users_me_management_token_detail_path_matches(&request_context.request_path) =>
{
Some(
handle_users_me_management_token_update(
@@ -110,37 +122,27 @@ pub(crate) async fn maybe_build_local_users_me_legacy_response(
)
}
Some("api_key_patch")
if request_context
.request_path
.starts_with("/api/users/me/api-keys/") =>
if users_me_api_key_detail_path_matches(&request_context.request_path) =>
{
Some(handle_users_me_api_key_patch(state, request_context, headers, request_body).await)
}
Some("management_token_toggle")
if request_context
.request_path
.starts_with("/api/me/management-tokens/") =>
if users_me_management_token_toggle_path_matches(&request_context.request_path) =>
{
Some(handle_users_me_management_token_toggle(state, request_context, headers).await)
}
Some("api_key_delete")
if request_context
.request_path
.starts_with("/api/users/me/api-keys/") =>
if users_me_api_key_detail_path_matches(&request_context.request_path) =>
{
Some(handle_users_me_api_key_delete(state, request_context, headers).await)
}
Some("management_token_delete")
if request_context
.request_path
.starts_with("/api/me/management-tokens/") =>
if users_me_management_token_detail_path_matches(&request_context.request_path) =>
{
Some(handle_users_me_management_token_delete(state, request_context, headers).await)
}
Some("api_key_providers_update")
if request_context
.request_path
.starts_with("/api/users/me/api-keys/") =>
if users_me_api_key_providers_path_matches(&request_context.request_path) =>
{
Some(
handle_users_me_api_key_providers_put(
@@ -153,9 +155,7 @@ pub(crate) async fn maybe_build_local_users_me_legacy_response(
)
}
Some("api_key_capabilities_update")
if request_context
.request_path
.starts_with("/api/users/me/api-keys/") =>
if users_me_api_key_capabilities_path_matches(&request_context.request_path) =>
{
Some(
handle_users_me_api_key_capabilities_put(
@@ -168,9 +168,7 @@ pub(crate) async fn maybe_build_local_users_me_legacy_response(
)
}
Some("management_token_regenerate")
if request_context
.request_path
.starts_with("/api/me/management-tokens/") =>
if users_me_management_token_regenerate_path_matches(&request_context.request_path) =>
{
Some(handle_users_me_management_token_regenerate(state, request_context, headers).await)
}
@@ -230,8 +228,6 @@ pub(crate) async fn maybe_build_local_users_me_legacy_response(
.await,
)
}
_ => Some(build_public_support_maintenance_response(
USERS_ME_MAINTENANCE_DETAIL,
)),
_ => Some(build_unhandled_public_support_response(request_context)),
}
}

View File

@@ -1,4 +1,17 @@
use super::*;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use super::{
build_auth_error_response, format_users_me_optional_datetime_iso8601,
format_users_me_required_session_datetime_iso8601, resolve_authenticated_local_user, AppState,
GatewayPublicRequestContext,
};
#[derive(Debug, Deserialize)]
struct UsersMeUpdateSessionLabelRequest {
@@ -6,16 +19,18 @@ struct UsersMeUpdateSessionLabelRequest {
}
fn users_me_session_id_from_path(request_path: &str) -> Option<String> {
request_path
let raw = request_path
.strip_prefix("/api/users/me/sessions/")?
.trim()
.trim_matches('/')
.split('/')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| !value.contains('/'))
.map(ToOwned::to_owned)
.trim_matches('/');
if raw.is_empty() || raw.contains('/') {
return None;
}
Some(raw.to_string())
}
pub(super) fn users_me_session_detail_path_matches(request_path: &str) -> bool {
users_me_session_id_from_path(request_path).is_some()
}
fn build_users_me_session_payload(
@@ -131,7 +146,7 @@ pub(super) async fn handle_users_me_delete_session(
Err(response) => return response,
};
let Some(session_id) = users_me_session_id_from_path(&request_context.request_path) else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_auth_error_response(http::StatusCode::NOT_FOUND, "会话不存在", false);
};
let session = match state.find_user_session(&auth.user.id, &session_id).await {
@@ -181,7 +196,7 @@ pub(super) async fn handle_users_me_update_session(
Err(response) => return response,
};
let Some(session_id) = users_me_session_id_from_path(&request_context.request_path) else {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_auth_error_response(http::StatusCode::NOT_FOUND, "会话不存在", false);
};
let Some(request_body) = request_body else {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "请求数据验证失败", false);

View File

@@ -1,7 +1,9 @@
use super::*;
use chrono::{DateTime, Utc};
use crate::gateway::gateway_data::StoredUserSessionRecord;
pub(super) fn format_users_me_optional_datetime_iso8601(
value: Option<chrono::DateTime<chrono::Utc>>,
value: Option<DateTime<Utc>>,
) -> Option<String> {
value.map(|value| value.to_rfc3339())
}
@@ -9,11 +11,11 @@ pub(super) fn format_users_me_optional_datetime_iso8601(
pub(super) fn format_users_me_optional_unix_secs_iso8601(value: Option<u64>) -> Option<String> {
let secs = value?;
let secs = i64::try_from(secs).ok()?;
chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0).map(|value| value.to_rfc3339())
DateTime::<Utc>::from_timestamp(secs, 0).map(|value| value.to_rfc3339())
}
pub(super) fn format_users_me_required_session_datetime_iso8601(
session: &crate::gateway::gateway_data::StoredUserSessionRecord,
session: &StoredUserSessionRecord,
) -> Option<String> {
session
.created_at

View File

@@ -1,4 +1,30 @@
use super::*;
use std::collections::{BTreeMap, BTreeSet};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use chrono::Utc;
use serde_json::json;
use super::{
admin_stats_bad_request_response, build_auth_error_response, build_auth_wallet_summary_payload,
list_usage_for_optional_range, parse_bounded_u32, query_param_value,
resolve_authenticated_local_user, round_to, unix_secs_to_rfc3339, AdminStatsTimeRange,
AdminStatsUsageFilter, AppState, GatewayPublicRequestContext,
};
const USERS_ME_USAGE_DATA_UNAVAILABLE_DETAIL: &str = "用户用量数据暂不可用";
fn build_users_me_usage_reader_unavailable_response() -> Response<Body> {
build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_USAGE_DATA_UNAVAILABLE_DETAIL,
false,
)
}
fn parse_users_me_usage_limit(query: Option<&str>) -> Result<usize, String> {
match query_param_value(query, "limit") {
@@ -522,7 +548,7 @@ pub(super) async fn handle_users_me_usage_get(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_usage_data_reader() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_usage_reader_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -680,7 +706,7 @@ pub(super) async fn handle_users_me_usage_active_get(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_usage_data_reader() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_usage_reader_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -740,7 +766,7 @@ pub(super) async fn handle_users_me_usage_interval_timeline_get(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_usage_data_reader() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_usage_reader_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
@@ -819,7 +845,7 @@ pub(super) async fn handle_users_me_usage_heatmap_get(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_usage_data_reader() {
return build_public_support_maintenance_response(USERS_ME_MAINTENANCE_DETAIL);
return build_users_me_usage_reader_unavailable_response();
}
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {

View File

@@ -1,5 +1,9 @@
use super::*;
use sqlx::Row;
pub(super) use super::{
build_auth_error_response, build_auth_json_response, build_auth_wallet_summary_payload,
query_param_value, resolve_authenticated_local_user, unix_secs_to_rfc3339, AppState,
GatewayError, GatewayPublicRequestContext,
};
pub(super) use axum::{body::Body, http, response::Response};
#[cfg(test)]
#[path = "wallet/test_support.rs"]
@@ -7,7 +11,12 @@ mod test_support;
#[cfg(test)]
pub(crate) use self::test_support::wallet_test_recharge_store;
#[cfg(test)]
use self::test_support::*;
use self::test_support::{
record_wallet_test_recharge, record_wallet_test_refund, wallet_test_recharge_order_by_id,
wallet_test_recharge_orders_for_user, wallet_test_refund_by_id,
wallet_test_refund_by_idempotency, wallet_test_refunds_for_wallet,
wallet_test_reserved_refund_amount,
};
#[path = "wallet/flow.rs"]
mod flow;
#[path = "wallet/reads.rs"]
@@ -25,15 +34,19 @@ use self::reads::{
};
use self::recharge::{
handle_wallet_create_recharge, handle_wallet_recharge_detail, handle_wallet_recharge_list,
wallet_recharge_detail_path_matches,
};
pub(crate) use self::recharge::{
sanitize_wallet_gateway_response, wallet_payment_order_payload_from_row,
};
use self::refunds::{
handle_wallet_create_refund, handle_wallet_refund_detail, handle_wallet_refunds_list,
wallet_refund_detail_path_matches,
};
const WALLET_LEGACY_TIMEZONE: &str = "Asia/Shanghai";
const WALLET_RECHARGE_STORAGE_UNAVAILABLE_DETAIL: &str = "钱包充值后端暂不可用";
const WALLET_REFUND_STORAGE_UNAVAILABLE_DETAIL: &str = "钱包退款后端暂不可用";
const WALLET_SAFE_GATEWAY_RESPONSE_KEYS: &[&str] = &[
"gateway",
"display_name",
@@ -61,14 +74,30 @@ pub(super) fn wallet_normalize_optional_string_field(
Ok(Some(trimmed.to_string()))
}
pub(super) async fn maybe_build_local_wallet_legacy_response(
pub(super) fn build_wallet_recharge_storage_unavailable_response() -> Response<Body> {
build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
WALLET_RECHARGE_STORAGE_UNAVAILABLE_DETAIL,
false,
)
}
pub(super) fn build_wallet_refund_storage_unavailable_response() -> Response<Body> {
build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
WALLET_REFUND_STORAGE_UNAVAILABLE_DETAIL,
false,
)
}
pub(super) async fn maybe_build_local_wallet_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
request_body: Option<&axum::body::Bytes>,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("wallet_legacy") {
if decision.route_family.as_deref() != Some("wallet") {
return None;
}
@@ -103,9 +132,7 @@ pub(super) async fn maybe_build_local_wallet_legacy_response(
}
if decision.route_kind.as_deref() == Some("refund_detail")
&& request_context
.request_path
.starts_with("/api/wallet/refunds/")
&& wallet_refund_detail_path_matches(&request_context.request_path)
{
return Some(handle_wallet_refund_detail(state, request_context, headers).await);
}
@@ -133,12 +160,54 @@ pub(super) async fn maybe_build_local_wallet_legacy_response(
}
if decision.route_kind.as_deref() == Some("recharge_detail")
&& request_context
.request_path
.starts_with("/api/wallet/recharge/")
&& wallet_recharge_detail_path_matches(&request_context.request_path)
{
return Some(handle_wallet_recharge_detail(state, request_context, headers).await);
}
None
}
#[cfg(test)]
mod tests {
use super::{
build_wallet_recharge_storage_unavailable_response,
build_wallet_refund_storage_unavailable_response,
WALLET_RECHARGE_STORAGE_UNAVAILABLE_DETAIL, WALLET_REFUND_STORAGE_UNAVAILABLE_DETAIL,
};
use axum::body::to_bytes;
use axum::http;
use serde_json::json;
#[tokio::test]
async fn wallet_recharge_storage_unavailable_response_is_explicit_local_503() {
let response = build_wallet_recharge_storage_unavailable_response();
assert_eq!(response.status(), http::StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(
payload,
json!({ "detail": WALLET_RECHARGE_STORAGE_UNAVAILABLE_DETAIL })
);
}
#[tokio::test]
async fn wallet_refund_storage_unavailable_response_is_explicit_local_503() {
let response = build_wallet_refund_storage_unavailable_response();
assert_eq!(response.status(), http::StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(
payload,
json!({ "detail": WALLET_REFUND_STORAGE_UNAVAILABLE_DETAIL })
);
}
}

View File

@@ -1,4 +1,12 @@
use super::*;
use super::{
build_auth_error_response, build_auth_json_response, build_wallet_daily_usage_payload,
build_wallet_payload, build_wallet_zero_today_entry, http, parse_wallet_limit,
parse_wallet_offset, resolve_authenticated_local_user, unix_secs_to_rfc3339,
wallet_fixed_offset, wallet_today_billing_date_string, wallet_transaction_payload_from_row,
AppState, Body, GatewayPublicRequestContext, Response, WALLET_LEGACY_TIMEZONE,
};
use serde_json::json;
use sqlx::Row;
fn wallet_flow_sort_key(item_type: &str, payload: &serde_json::Value) -> (String, u8, String) {
match item_type {

View File

@@ -1,4 +1,14 @@
use super::*;
use super::{
build_auth_error_response, build_auth_json_response, build_auth_wallet_summary_payload, http,
query_param_value, resolve_authenticated_local_user, unix_secs_to_rfc3339, AppState, Body,
GatewayError, GatewayPublicRequestContext, Response, WALLET_LEGACY_TIMEZONE,
};
use crate::gateway::handlers::admin::round_to;
use chrono::Utc;
use serde_json::json;
use sqlx::Row;
const WALLET_TODAY_COST_UNAVAILABLE_DETAIL: &str = "钱包今日费用数据暂不可用";
pub(super) fn build_wallet_payload(
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
@@ -179,8 +189,10 @@ pub(super) async fn handle_wallet_today_cost(
headers: &http::HeaderMap,
) -> Response<Body> {
if !state.has_usage_data_reader() {
return build_public_support_maintenance_response(
"Wallet routes require Rust maintenance backend",
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
WALLET_TODAY_COST_UNAVAILABLE_DETAIL,
false,
);
}

View File

@@ -1,4 +1,20 @@
use super::*;
use super::{
build_auth_error_response, build_auth_json_response, build_wallet_payload,
build_wallet_recharge_storage_unavailable_response, http, parse_wallet_limit,
parse_wallet_offset, resolve_authenticated_local_user, unix_secs_to_rfc3339,
wallet_normalize_optional_string_field, AppState, Body, GatewayError,
GatewayPublicRequestContext, Response, WALLET_SAFE_GATEWAY_RESPONSE_KEYS,
};
#[cfg(test)]
use super::{
record_wallet_test_recharge, wallet_test_recharge_order_by_id,
wallet_test_recharge_orders_for_user,
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use sqlx::Row;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct WalletCreateRechargeRequest {
@@ -114,16 +130,17 @@ fn wallet_checkout_payload(
}
fn wallet_order_id_from_path(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/wallet/recharge/")?
.trim()
.trim_matches('/')
.split('/')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| !value.contains('/'))
.map(ToOwned::to_owned)
let trimmed = request_path.trim_end_matches('/');
let order_id = trimmed.strip_prefix("/api/wallet/recharge/")?.trim();
if order_id.is_empty() || order_id.contains('/') {
None
} else {
Some(order_id.to_string())
}
}
pub(super) fn wallet_recharge_detail_path_matches(request_path: &str) -> bool {
wallet_order_id_from_path(request_path).is_some()
}
pub(crate) fn sanitize_wallet_gateway_response(
@@ -357,9 +374,7 @@ pub(super) async fn handle_wallet_create_recharge(
);
}
#[cfg(not(test))]
return build_public_support_maintenance_response(
"Wallet routes require Rust maintenance backend",
);
return build_wallet_recharge_storage_unavailable_response();
};
let mut tx = match pool.begin().await {

View File

@@ -1,4 +1,20 @@
use super::*;
use super::{
build_auth_error_response, build_auth_json_response, build_wallet_payload,
build_wallet_refund_storage_unavailable_response, http, parse_wallet_limit,
parse_wallet_offset, resolve_authenticated_local_user, unix_secs_to_rfc3339,
wallet_normalize_optional_string_field, AppState, Body, GatewayError,
GatewayPublicRequestContext, Response,
};
#[cfg(test)]
use super::{
record_wallet_test_refund, wallet_test_refund_by_id, wallet_test_refund_by_idempotency,
wallet_test_refunds_for_wallet, wallet_test_reserved_refund_amount,
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use sqlx::Row;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct WalletCreateRefundRequest {
@@ -65,16 +81,17 @@ fn wallet_build_refund_no(now: chrono::DateTime<chrono::Utc>) -> String {
}
fn wallet_refund_id_from_path(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/wallet/refunds/")?
.trim()
.trim_matches('/')
.split('/')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| !value.contains('/'))
.map(ToOwned::to_owned)
let trimmed = request_path.trim_end_matches('/');
let refund_id = trimmed.strip_prefix("/api/wallet/refunds/")?.trim();
if refund_id.is_empty() || refund_id.contains('/') {
None
} else {
Some(refund_id.to_string())
}
}
pub(super) fn wallet_refund_detail_path_matches(request_path: &str) -> bool {
wallet_refund_id_from_path(request_path).is_some()
}
fn wallet_refund_payload_from_row(
@@ -491,9 +508,7 @@ pub(super) async fn handle_wallet_create_refund(
return build_auth_json_response(http::StatusCode::OK, created, None);
}
#[cfg(not(test))]
return build_public_support_maintenance_response(
"Wallet refund routes require Rust maintenance backend",
);
return build_wallet_refund_storage_unavailable_response();
};
let mut tx = match pool.begin().await {

View File

@@ -1,5 +1,3 @@
use super::*;
#[derive(Debug, Clone)]
pub(super) struct WalletTestRefundRecord {
pub(crate) wallet_id: String,

View File

@@ -1,5 +1,3 @@
pub(crate) use super::*;
#[path = "system_modules_helpers/capabilities.rs"]
mod system_modules_capabilities;
#[path = "system_modules_helpers/keys_grouped.rs"]
@@ -9,7 +7,28 @@ mod system_modules_modules;
#[path = "system_modules_helpers/system.rs"]
mod system_modules_system;
pub(crate) use self::system_modules_capabilities::*;
pub(crate) use self::system_modules_keys_grouped::*;
pub(crate) use self::system_modules_modules::*;
pub(crate) use self::system_modules_system::*;
pub(crate) use self::system_modules_capabilities::{
capability_detail_by_name, enabled_key_capability_short_names, serialize_public_capability,
supported_capability_names, PUBLIC_CAPABILITY_DEFINITIONS,
};
pub(crate) use self::system_modules_keys_grouped::build_admin_keys_grouped_by_format_payload;
pub(crate) use self::system_modules_modules::{
admin_module_by_name, admin_module_name_from_enabled_path, admin_module_name_from_status_path,
build_admin_module_runtime_state, build_admin_module_status_payload,
build_admin_module_validation_result, build_admin_modules_status_payload,
build_public_auth_modules_status_payload, ldap_module_config_is_valid,
AdminSetModuleEnabledRequest,
};
pub(crate) use self::system_modules_system::{
apply_admin_email_template_update, apply_admin_system_config_update,
apply_admin_system_settings_update, build_admin_api_formats_payload,
build_admin_email_template_payload, build_admin_email_templates_payload,
build_admin_system_check_update_payload, build_admin_system_config_detail_payload,
build_admin_system_config_export_payload, build_admin_system_configs_payload,
build_admin_system_settings_payload, build_admin_system_stats_payload,
build_admin_system_users_export_payload, current_aether_version, delete_admin_system_config,
escape_admin_email_template_html, module_available_from_env, preview_admin_email_template,
read_admin_email_template_payload, render_admin_email_template_html,
reset_admin_email_template, serialize_admin_system_users_export_wallet, system_config_bool,
system_config_string,
};

View File

@@ -1,4 +1,4 @@
use super::*;
use serde_json::json;
#[derive(Clone, Copy)]
pub(crate) struct PublicCapabilityDefinition {

View File

@@ -1,4 +1,9 @@
use super::*;
use super::enabled_key_capability_short_names;
use crate::gateway::handlers::{json_string_list, masked_catalog_api_key, unix_secs_to_rfc3339};
use crate::gateway::AppState;
use serde_json::json;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) async fn build_admin_keys_grouped_by_format_payload(
state: &AppState,

View File

@@ -1,4 +1,6 @@
use super::*;
use super::{module_available_from_env, system_config_bool};
use crate::gateway::{AppState, GatewayError};
use serde_json::json;
#[derive(Clone, Copy)]
struct PublicAuthModuleDefinition {

View File

@@ -1,4 +1,16 @@
use super::*;
use crate::gateway::api::ai::admin_endpoint_signature_parts;
use crate::gateway::handlers::{decrypt_catalog_secret_with_fallbacks, unix_secs_to_rfc3339};
use crate::gateway::{AppState, GatewayError};
use aether_crypto::encrypt_python_fernet_plaintext;
use aether_data::repository::global_models::{
AdminGlobalModelListQuery, AdminProviderModelListQuery,
};
use axum::body::Bytes;
use axum::http;
use chrono::Utc;
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
#[derive(Debug, Clone, Copy)]
struct AdminApiFormatDefinition {
@@ -219,7 +231,7 @@ pub(crate) async fn build_admin_system_settings_payload(
pub(crate) async fn apply_admin_system_settings_update(
state: &AppState,
request_body: &axum::body::Bytes,
request_body: &Bytes,
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
let payload = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(serde_json::Value::Object(payload)) => payload,
@@ -603,7 +615,7 @@ pub(crate) fn render_admin_email_template_html(
pub(crate) async fn apply_admin_email_template_update(
state: &AppState,
template_type: &str,
request_body: &axum::body::Bytes,
request_body: &Bytes,
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
let Some(definition) = admin_email_template_definition(template_type) else {
return Ok(Err((
@@ -677,7 +689,7 @@ pub(crate) async fn apply_admin_email_template_update(
pub(crate) async fn preview_admin_email_template(
state: &AppState,
template_type: &str,
request_body: Option<&axum::body::Bytes>,
request_body: Option<&Bytes>,
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
let Some(definition) = admin_email_template_definition(template_type) else {
return Ok(Err((
@@ -1550,7 +1562,7 @@ pub(crate) async fn build_admin_system_config_detail_payload(
pub(crate) async fn apply_admin_system_config_update(
state: &AppState,
requested_key: &str,
request_body: &axum::body::Bytes,
request_body: &Bytes,
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
let payload = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(serde_json::Value::Object(payload)) => payload,