mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
fix(public): 修复用户可见性、额度、验证与 Codex 探测
This commit is contained in:
@@ -17,6 +17,13 @@ use aether_data::repository::auth::{
|
|||||||
read_resolved_auth_api_key_snapshot_by_user_api_key_ids,
|
read_resolved_auth_api_key_snapshot_by_user_api_key_ids,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct GatewayUserEffectiveListPolicies {
|
||||||
|
pub(crate) allowed_providers: Option<Vec<String>>,
|
||||||
|
pub(crate) allowed_api_formats: Option<Vec<String>>,
|
||||||
|
pub(crate) allowed_models: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl GatewayDataState {
|
impl GatewayDataState {
|
||||||
pub(crate) async fn is_other_user_auth_email_taken(
|
pub(crate) async fn is_other_user_auth_email_taken(
|
||||||
&self,
|
&self,
|
||||||
@@ -1732,29 +1739,9 @@ impl GatewayDataState {
|
|||||||
apply_admin_unrestricted_auth_snapshot(&mut snapshot);
|
apply_admin_unrestricted_auth_snapshot(&mut snapshot);
|
||||||
return Ok(Some(snapshot));
|
return Ok(Some(snapshot));
|
||||||
}
|
}
|
||||||
let mut groups = repository
|
let groups = self
|
||||||
.list_user_groups_for_user(&snapshot.user_id)
|
.effective_user_groups_for_user(&snapshot.user_id)
|
||||||
.await?;
|
.await?;
|
||||||
let dynamic_group_ids = self
|
|
||||||
.active_membership_group_ids_for_user(&snapshot.user_id)
|
|
||||||
.await?;
|
|
||||||
if !dynamic_group_ids.is_empty() {
|
|
||||||
groups.extend(
|
|
||||||
repository
|
|
||||||
.list_user_groups_by_ids(&dynamic_group_ids)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
let mut deduped = std::collections::BTreeMap::new();
|
|
||||||
for group in groups {
|
|
||||||
deduped.insert(group.id.clone(), group);
|
|
||||||
}
|
|
||||||
groups = deduped.into_values().collect();
|
|
||||||
}
|
|
||||||
groups.sort_by(|left, right| {
|
|
||||||
left.name
|
|
||||||
.cmp(&right.name)
|
|
||||||
.then_with(|| left.id.cmp(&right.id))
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut allowed_providers =
|
let mut allowed_providers =
|
||||||
resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
|
resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
|
||||||
@@ -1798,6 +1785,80 @@ impl GatewayDataState {
|
|||||||
Ok(Some(snapshot))
|
Ok(Some(snapshot))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn resolve_user_effective_list_policies(
|
||||||
|
&self,
|
||||||
|
user: &StoredUserAuthRecord,
|
||||||
|
) -> Result<GatewayUserEffectiveListPolicies, DataLayerError> {
|
||||||
|
if user.role.eq_ignore_ascii_case("admin") {
|
||||||
|
return Ok(GatewayUserEffectiveListPolicies::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = if self.user_reader.is_some() {
|
||||||
|
self.effective_user_groups_for_user(&user.id).await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
Ok(GatewayUserEffectiveListPolicies {
|
||||||
|
allowed_providers: resolve_effective_list_policy(
|
||||||
|
user.allowed_providers.clone(),
|
||||||
|
&user.allowed_providers_mode,
|
||||||
|
&groups,
|
||||||
|
|group| {
|
||||||
|
(
|
||||||
|
&group.allowed_providers_mode,
|
||||||
|
group.allowed_providers.clone(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
allowed_api_formats: resolve_effective_list_policy(
|
||||||
|
user.allowed_api_formats.clone(),
|
||||||
|
&user.allowed_api_formats_mode,
|
||||||
|
&groups,
|
||||||
|
|group| {
|
||||||
|
(
|
||||||
|
&group.allowed_api_formats_mode,
|
||||||
|
group.allowed_api_formats.clone(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
allowed_models: resolve_effective_list_policy(
|
||||||
|
user.allowed_models.clone(),
|
||||||
|
&user.allowed_models_mode,
|
||||||
|
&groups,
|
||||||
|
|group| (&group.allowed_models_mode, group.allowed_models.clone()),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn effective_user_groups_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
|
||||||
|
let Some(repository) = self.user_reader.as_ref() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let mut groups = repository.list_user_groups_for_user(user_id).await?;
|
||||||
|
let dynamic_group_ids = self.active_membership_group_ids_for_user(user_id).await?;
|
||||||
|
if !dynamic_group_ids.is_empty() {
|
||||||
|
groups.extend(
|
||||||
|
repository
|
||||||
|
.list_user_groups_by_ids(&dynamic_group_ids)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
let mut deduped = std::collections::BTreeMap::new();
|
||||||
|
for group in groups {
|
||||||
|
deduped.insert(group.id.clone(), group);
|
||||||
|
}
|
||||||
|
groups = deduped.into_values().collect();
|
||||||
|
}
|
||||||
|
groups.sort_by(|left, right| {
|
||||||
|
left.name
|
||||||
|
.cmp(&right.name)
|
||||||
|
.then_with(|| left.id.cmp(&right.id))
|
||||||
|
});
|
||||||
|
Ok(groups)
|
||||||
|
}
|
||||||
|
|
||||||
async fn active_membership_group_ids_for_user(
|
async fn active_membership_group_ids_for_user(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use self::invalid::{
|
|||||||
codex_structured_invalid_reason,
|
codex_structured_invalid_reason,
|
||||||
};
|
};
|
||||||
use self::parse::{
|
use self::parse::{
|
||||||
build_codex_quota_exhausted_fallback_metadata, parse_codex_usage_headers,
|
parse_codex_backend_me_response, parse_codex_usage_headers, parse_codex_wham_usage_response,
|
||||||
parse_codex_wham_usage_response,
|
|
||||||
};
|
};
|
||||||
use self::plan::{build_codex_quota_request_spec, execute_codex_quota_plan};
|
use self::plan::{build_codex_quota_request_spec, execute_codex_quota_plan};
|
||||||
use super::shared::{
|
use super::shared::{
|
||||||
@@ -111,7 +110,7 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
|||||||
"key_id": key.id,
|
"key_id": key.id,
|
||||||
"key_name": key.name,
|
"key_name": key.name,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": format!("wham/usage 请求执行失败: {detail}"),
|
"message": format!("backend-api/me 请求执行失败: {detail}"),
|
||||||
"status_code": 502,
|
"status_code": 502,
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
@@ -138,7 +137,9 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|body| body.json_body.as_ref())
|
.and_then(|body| body.json_body.as_ref())
|
||||||
{
|
{
|
||||||
if let Some(parsed) = parse_codex_wham_usage_response(body_json, now_unix_secs) {
|
if let Some(parsed) = parse_codex_backend_me_response(body_json, now_unix_secs)
|
||||||
|
.or_else(|| parse_codex_wham_usage_response(body_json, now_unix_secs))
|
||||||
|
{
|
||||||
metadata_update = Some(json!({
|
metadata_update = Some(json!({
|
||||||
"codex": merge_codex_quota_metadata(header_metadata.as_ref(), &parsed)
|
"codex": merge_codex_quota_metadata(header_metadata.as_ref(), &parsed)
|
||||||
}));
|
}));
|
||||||
@@ -151,21 +152,21 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
|||||||
status = "success".to_string();
|
status = "success".to_string();
|
||||||
} else {
|
} else {
|
||||||
status = "no_metadata".to_string();
|
status = "no_metadata".to_string();
|
||||||
message = Some("响应中未包含限额信息".to_string());
|
message = Some("backend-api/me 响应中未包含账号信息".to_string());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message = Some("无法解析 wham/usage API 响应".to_string());
|
message = Some("无法解析 backend-api/me API 响应".to_string());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let err_msg = extract_execution_error_message(&result);
|
let err_msg = extract_execution_error_message(&result);
|
||||||
message = Some(match err_msg.as_deref() {
|
message = Some(match err_msg.as_deref() {
|
||||||
Some(detail) if !detail.is_empty() => {
|
Some(detail) if !detail.is_empty() => {
|
||||||
format!(
|
format!(
|
||||||
"wham/usage API 返回状态码 {}: {}",
|
"backend-api/me API 返回状态码 {}: {}",
|
||||||
result.status_code, detail
|
result.status_code, detail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => format!("wham/usage API 返回状态码 {}", result.status_code),
|
_ => format!("backend-api/me API 返回状态码 {}", result.status_code),
|
||||||
});
|
});
|
||||||
|
|
||||||
match result.status_code {
|
match result.status_code {
|
||||||
@@ -222,26 +223,14 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
|||||||
oauth_invalid_reason = reason;
|
oauth_invalid_reason = reason;
|
||||||
status = "workspace_deactivated".to_string();
|
status = "workspace_deactivated".to_string();
|
||||||
} else {
|
} else {
|
||||||
let plan_type = transport
|
let (at, reason) = codex_build_invalid_state(
|
||||||
.key
|
&key,
|
||||||
.decrypted_auth_config
|
codex_structured_invalid_reason(402, err_msg.as_deref()),
|
||||||
.as_deref()
|
|
||||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
|
|
||||||
.and_then(|value| {
|
|
||||||
value
|
|
||||||
.get("plan_type")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
});
|
|
||||||
metadata_update = Some(json!({
|
|
||||||
"codex": build_codex_quota_exhausted_fallback_metadata(
|
|
||||||
plan_type.as_deref(),
|
|
||||||
now_unix_secs,
|
now_unix_secs,
|
||||||
)
|
);
|
||||||
}));
|
oauth_invalid_at_unix_secs = at;
|
||||||
(oauth_invalid_at_unix_secs, oauth_invalid_reason) =
|
oauth_invalid_reason = reason;
|
||||||
quota_refresh_success_invalid_state(&key);
|
status = "payment_required".to_string();
|
||||||
status = "quota_exhausted".to_string();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
403 => {
|
403 => {
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ pub(super) fn parse_codex_wham_usage_response(
|
|||||||
admin_provider_quota_pure::parse_codex_wham_usage_response(value, updated_at_unix_secs)
|
admin_provider_quota_pure::parse_codex_wham_usage_response(value, updated_at_unix_secs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_codex_backend_me_response(
|
||||||
|
value: &serde_json::Value,
|
||||||
|
updated_at_unix_secs: u64,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
admin_provider_quota_pure::parse_codex_backend_me_response(value, updated_at_unix_secs)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn parse_codex_usage_headers(
|
pub(super) fn parse_codex_usage_headers(
|
||||||
headers: &BTreeMap<String, String>,
|
headers: &BTreeMap<String, String>,
|
||||||
updated_at_unix_secs: u64,
|
updated_at_unix_secs: u64,
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ pub(crate) struct AdminProviderUpdateRequest {
|
|||||||
|
|
||||||
pub(crate) type AdminProviderUpdatePatch = AdminTypedObjectPatch<AdminProviderUpdateRequest>;
|
pub(crate) type AdminProviderUpdatePatch = AdminTypedObjectPatch<AdminProviderUpdateRequest>;
|
||||||
|
|
||||||
pub(crate) const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
|
pub(crate) const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/me";
|
||||||
pub(crate) const KIRO_USAGE_LIMITS_PATH: &str = "/getUsageLimits";
|
pub(crate) const KIRO_USAGE_LIMITS_PATH: &str = "/getUsageLimits";
|
||||||
pub(crate) const KIRO_USAGE_SDK_VERSION: &str = "1.0.0";
|
pub(crate) const KIRO_USAGE_SDK_VERSION: &str = "1.0.0";
|
||||||
pub(crate) const ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH: &str = "/v1internal:fetchAvailableModels";
|
pub(crate) const ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH: &str = "/v1internal:fetchAvailableModels";
|
||||||
|
|||||||
@@ -50,6 +50,28 @@ pub(crate) fn normalize_admin_base_url(base_url: &str) -> Result<String, String>
|
|||||||
Ok(normalized.to_string())
|
Ok(normalized.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sanitize_public_model_config_for_user(
|
||||||
|
config: Option<serde_json::Value>,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let Some(mut config) = config else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if let Some(object) = config.as_object_mut() {
|
||||||
|
for key in [
|
||||||
|
"model_mappings",
|
||||||
|
"model_mapping",
|
||||||
|
"global_model_mappings",
|
||||||
|
"provider_model_mappings",
|
||||||
|
"provider_model_aliases",
|
||||||
|
"mapping_preview",
|
||||||
|
"model_mapping_preview",
|
||||||
|
] {
|
||||||
|
object.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(config)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn admin_requested_force_stream(value: &serde_json::Value) -> bool {
|
pub(crate) fn admin_requested_force_stream(value: &serde_json::Value) -> bool {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::Bool(value) => *value,
|
serde_json::Value::Bool(value) => *value,
|
||||||
@@ -165,21 +187,12 @@ pub(crate) async fn build_public_providers_payload(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|provider| {
|
.map(|provider| {
|
||||||
let provider_id = provider.id.clone();
|
let provider_id = provider.id.clone();
|
||||||
let description = provider
|
|
||||||
.config
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|value| value.get("description"))
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let model_count = models_by_provider
|
let model_count = models_by_provider
|
||||||
.get(&provider_id)
|
.get(&provider_id)
|
||||||
.map(BTreeSet::len)
|
.map(BTreeSet::len)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
json!({
|
json!({
|
||||||
"id": provider_id.clone(),
|
"id": provider_id.clone(),
|
||||||
"name": provider.name,
|
|
||||||
"description": description,
|
|
||||||
"website": provider.website,
|
|
||||||
"is_active": provider.is_active,
|
"is_active": provider.is_active,
|
||||||
"provider_priority": provider.provider_priority,
|
"provider_priority": provider.provider_priority,
|
||||||
"models_count": model_count,
|
"models_count": model_count,
|
||||||
@@ -196,8 +209,6 @@ pub(crate) async fn build_public_providers_payload(
|
|||||||
fn serialize_public_catalog_model(model: StoredPublicCatalogModel) -> serde_json::Value {
|
fn serialize_public_catalog_model(model: StoredPublicCatalogModel) -> serde_json::Value {
|
||||||
json!({
|
json!({
|
||||||
"id": model.id,
|
"id": model.id,
|
||||||
"provider_id": model.provider_id,
|
|
||||||
"provider_name": model.provider_name,
|
|
||||||
"name": model.name,
|
"name": model.name,
|
||||||
"display_name": model.display_name,
|
"display_name": model.display_name,
|
||||||
"description": model.description,
|
"description": model.description,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub(crate) use self::catalog_helpers::{
|
|||||||
build_public_catalog_models_payload, build_public_catalog_search_models_payload,
|
build_public_catalog_models_payload, build_public_catalog_search_models_payload,
|
||||||
build_public_health_timeline, build_public_providers_payload, normalize_admin_base_url,
|
build_public_health_timeline, build_public_providers_payload, normalize_admin_base_url,
|
||||||
provider_key_api_formats, request_candidate_event_unix_ms, request_candidate_status_label,
|
provider_key_api_formats, request_candidate_event_unix_ms, request_candidate_status_label,
|
||||||
ApiFormatHealthMonitorOptions,
|
sanitize_public_model_config_for_user, ApiFormatHealthMonitorOptions,
|
||||||
};
|
};
|
||||||
pub(crate) use self::system_modules_helpers::{
|
pub(crate) use self::system_modules_helpers::{
|
||||||
build_admin_keys_grouped_by_format_payload, build_public_auth_modules_status_payload,
|
build_admin_keys_grouped_by_format_payload, build_public_auth_modules_status_payload,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use super::{
|
|||||||
build_api_format_health_monitor_payload, build_public_auth_modules_status_payload,
|
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_catalog_models_payload, build_public_catalog_search_models_payload,
|
||||||
build_public_providers_payload, capability_detail_by_name, ldap_module_config_is_valid,
|
build_public_providers_payload, capability_detail_by_name, ldap_module_config_is_valid,
|
||||||
serialize_public_capability, supported_capability_names, ApiFormatHealthMonitorOptions,
|
sanitize_public_model_config_for_user, serialize_public_capability, supported_capability_names,
|
||||||
PUBLIC_CAPABILITY_DEFINITIONS,
|
ApiFormatHealthMonitorOptions, PUBLIC_CAPABILITY_DEFINITIONS,
|
||||||
};
|
};
|
||||||
use crate::control::GatewayPublicRequestContext;
|
use crate::control::GatewayPublicRequestContext;
|
||||||
use crate::handlers::shared::{
|
use crate::handlers::shared::{
|
||||||
@@ -232,10 +232,17 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||||
.unwrap_or_else(|| "AI Gateway".to_string());
|
.unwrap_or_else(|| "AI Gateway".to_string());
|
||||||
|
let show_github_link = state
|
||||||
|
.read_system_config_json_value("show_github_link")
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let show_github_link = system_config_bool(show_github_link.as_ref(), true);
|
||||||
return Some(
|
return Some(
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"site_name": site_name,
|
"site_name": site_name,
|
||||||
"site_subtitle": site_subtitle,
|
"site_subtitle": site_subtitle,
|
||||||
|
"show_github_link": show_github_link,
|
||||||
}))
|
}))
|
||||||
.into_response(),
|
.into_response(),
|
||||||
);
|
);
|
||||||
@@ -374,7 +381,7 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
"default_price_per_request": model.default_price_per_request,
|
"default_price_per_request": model.default_price_per_request,
|
||||||
"default_tiered_pricing": model.default_tiered_pricing,
|
"default_tiered_pricing": model.default_tiered_pricing,
|
||||||
"supported_capabilities": model.supported_capabilities,
|
"supported_capabilities": model.supported_capabilities,
|
||||||
"config": model.config,
|
"config": sanitize_public_model_config_for_user(model.config),
|
||||||
"usage_count": model.usage_count,
|
"usage_count": model.usage_count,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -576,15 +583,11 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let current_provider = providers
|
|
||||||
.first()
|
|
||||||
.map(|provider| provider.name.clone())
|
|
||||||
.unwrap_or_else(|| "None".to_string());
|
|
||||||
return Some(
|
return Some(
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"message": "AI Proxy with Modular Architecture v4.0.0",
|
"message": "AI Proxy with Modular Architecture v4.0.0",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"current_provider": current_provider,
|
"current_provider": serde_json::Value::Null,
|
||||||
"available_providers": providers.len(),
|
"available_providers": providers.len(),
|
||||||
"config": {},
|
"config": {},
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
@@ -647,10 +650,8 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|provider| {
|
.map(|provider| {
|
||||||
let provider_id = provider.id.clone();
|
let provider_id = provider.id.clone();
|
||||||
let provider_name = provider.name.clone();
|
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"id": provider_id.clone(),
|
"id": provider_id.clone(),
|
||||||
"name": provider_name,
|
|
||||||
"is_active": provider.is_active,
|
"is_active": provider.is_active,
|
||||||
"provider_priority": provider.provider_priority,
|
"provider_priority": provider.provider_priority,
|
||||||
});
|
});
|
||||||
@@ -661,7 +662,6 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
.filter(|endpoint| endpoint.provider_id == provider_id)
|
.filter(|endpoint| endpoint.provider_id == provider_id)
|
||||||
.map(|endpoint| json!({
|
.map(|endpoint| json!({
|
||||||
"id": endpoint.id,
|
"id": endpoint.id,
|
||||||
"base_url": endpoint.base_url,
|
|
||||||
"api_format": endpoint.api_format,
|
"api_format": endpoint.api_format,
|
||||||
"is_active": endpoint.is_active,
|
"is_active": endpoint.is_active,
|
||||||
}))
|
}))
|
||||||
@@ -709,19 +709,19 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
};
|
};
|
||||||
let provider = match provider {
|
let provider = match provider {
|
||||||
Some(provider) => provider,
|
Some(provider) => provider,
|
||||||
None => state
|
None => {
|
||||||
.list_provider_catalog_providers(false)
|
return Some(
|
||||||
.await
|
(
|
||||||
.ok()
|
http::StatusCode::NOT_FOUND,
|
||||||
.unwrap_or_default()
|
Json(json!({ "detail": "Provider not found" })),
|
||||||
.into_iter()
|
)
|
||||||
.find(|provider| provider.name == provider_identifier)?,
|
.into_response(),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let provider_id = provider.id.clone();
|
let provider_id = provider.id.clone();
|
||||||
let provider_name = provider.name.clone();
|
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"id": provider_id.clone(),
|
"id": provider_id.clone(),
|
||||||
"name": provider_name,
|
|
||||||
"is_active": provider.is_active,
|
"is_active": provider.is_active,
|
||||||
"provider_priority": provider.provider_priority,
|
"provider_priority": provider.provider_priority,
|
||||||
});
|
});
|
||||||
@@ -739,7 +739,6 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
.map(|endpoint| {
|
.map(|endpoint| {
|
||||||
json!({
|
json!({
|
||||||
"id": endpoint.id,
|
"id": endpoint.id,
|
||||||
"base_url": endpoint.base_url,
|
|
||||||
"api_format": endpoint.api_format,
|
"api_format": endpoint.api_format,
|
||||||
"is_active": endpoint.is_active,
|
"is_active": endpoint.is_active,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ pub(super) use serde_json::json;
|
|||||||
mod auth_helpers;
|
mod auth_helpers;
|
||||||
pub(crate) use auth_helpers::*;
|
pub(crate) use auth_helpers::*;
|
||||||
|
|
||||||
|
#[path = "auth_turnstile.rs"]
|
||||||
|
mod auth_turnstile;
|
||||||
|
use auth_turnstile::*;
|
||||||
|
|
||||||
#[path = "auth_email.rs"]
|
#[path = "auth_email.rs"]
|
||||||
mod auth_email;
|
mod auth_email;
|
||||||
use auth_email::*;
|
use auth_email::*;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::{
|
use super::{
|
||||||
http, json, ldap_module_config_is_valid, module_available_from_env, system_config_bool,
|
auth_turnstile_public_settings, http, json, ldap_module_config_is_valid,
|
||||||
system_config_string, AppState, Body, GatewayError, GatewayPublicRequestContext, IntoResponse,
|
module_available_from_env, system_config_bool, system_config_string, AppState, Body,
|
||||||
Json, Response,
|
GatewayError, GatewayPublicRequestContext, IntoResponse, Json, Response,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) async fn build_auth_registration_settings_payload(
|
pub(crate) async fn build_auth_registration_settings_payload(
|
||||||
@@ -40,12 +40,17 @@ pub(crate) async fn build_auth_registration_settings_payload(
|
|||||||
Some(value) if matches!(value.as_str(), "weak" | "medium" | "strong") => value,
|
Some(value) if matches!(value.as_str(), "weak" | "medium" | "strong") => value,
|
||||||
_ => "weak".to_string(),
|
_ => "weak".to_string(),
|
||||||
};
|
};
|
||||||
|
let turnstile_settings = auth_turnstile_public_settings(state)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.detail))?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"enable_registration": enable_registration,
|
"enable_registration": enable_registration,
|
||||||
"require_email_verification": require_email_verification,
|
"require_email_verification": require_email_verification,
|
||||||
"email_configured": email_configured,
|
"email_configured": email_configured,
|
||||||
"password_policy_level": password_policy_level,
|
"password_policy_level": password_policy_level,
|
||||||
|
"turnstile_enabled": turnstile_settings.enabled,
|
||||||
|
"turnstile_site_key": turnstile_settings.site_key,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use super::{
|
|||||||
clear_auth_email_pending_code, clear_auth_email_verification, generate_auth_verification_code,
|
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,
|
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,
|
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,
|
system_config_string, system_config_string_list, verify_auth_turnstile_token, AppState, Body,
|
||||||
|
GatewayError, Regex, Response,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -16,11 +17,13 @@ struct AuthRegisterRequest {
|
|||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
turnstile_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AuthEmailRequest {
|
struct AuthEmailRequest {
|
||||||
email: String,
|
email: String,
|
||||||
|
turnstile_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -195,20 +198,6 @@ pub(super) async fn handle_auth_send_verification_code(
|
|||||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "邮箱格式无效", false);
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "邮箱格式无效", false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if state
|
|
||||||
.find_user_auth_by_identifier(&email)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return build_auth_error_response(
|
|
||||||
http::StatusCode::BAD_REQUEST,
|
|
||||||
"该邮箱已被注册,请直接登录或使用其他邮箱",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
match validate_auth_email_suffix(state, &email).await {
|
match validate_auth_email_suffix(state, &email).await {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(detail)) => {
|
Ok(Err(detail)) => {
|
||||||
@@ -223,6 +212,26 @@ pub(super) async fn handle_auth_send_verification_code(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(err) =
|
||||||
|
verify_auth_turnstile_token(state, payload.turnstile_token.as_deref(), None).await
|
||||||
|
{
|
||||||
|
return build_auth_error_response(err.status, err.detail, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if state
|
||||||
|
.find_user_auth_by_identifier(&email)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
"该邮箱已被注册,请直接登录或使用其他邮箱",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let smtp_config = match read_auth_smtp_config(state).await {
|
let smtp_config = match read_auth_smtp_config(state).await {
|
||||||
Ok(Some(value)) => value,
|
Ok(Some(value)) => value,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
@@ -421,6 +430,10 @@ pub(super) async fn handle_auth_register(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let Err(err) =
|
||||||
|
verify_auth_turnstile_token(state, payload.turnstile_token.as_deref(), None).await
|
||||||
|
{
|
||||||
|
return build_auth_error_response(err.status, err.detail, false);
|
||||||
}
|
}
|
||||||
if let Some(email) = email.as_deref() {
|
if let Some(email) = email.as_deref() {
|
||||||
match validate_auth_email_suffix(state, email).await {
|
match validate_auth_email_suffix(state, email).await {
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
use super::{
|
||||||
|
decrypt_catalog_secret_with_fallbacks, http, system_config_bool, system_config_string,
|
||||||
|
system_config_string_list, AppState,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const TURNSTILE_SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct AuthTurnstilePublicSettings {
|
||||||
|
pub(super) enabled: bool,
|
||||||
|
pub(super) site_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct AuthTurnstileError {
|
||||||
|
pub(super) status: http::StatusCode,
|
||||||
|
pub(super) detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AuthTurnstileConfig {
|
||||||
|
enabled: bool,
|
||||||
|
site_key: Option<String>,
|
||||||
|
secret_key: Option<String>,
|
||||||
|
siteverify_url: String,
|
||||||
|
allowed_hostnames: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TurnstileSiteverifyResponse {
|
||||||
|
success: bool,
|
||||||
|
hostname: Option<String>,
|
||||||
|
#[serde(default, rename = "error-codes")]
|
||||||
|
error_codes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn turnstile_error(status: http::StatusCode, detail: impl Into<String>) -> AuthTurnstileError {
|
||||||
|
AuthTurnstileError {
|
||||||
|
status,
|
||||||
|
detail: detail.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn turnstile_config_error(detail: impl Into<String>) -> AuthTurnstileError {
|
||||||
|
turnstile_error(http::StatusCode::INTERNAL_SERVER_ERROR, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_auth_turnstile_config(
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<AuthTurnstileConfig, AuthTurnstileError> {
|
||||||
|
let enabled = state
|
||||||
|
.read_system_config_json_value("turnstile_enabled")
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
|
||||||
|
})?;
|
||||||
|
let site_key = state
|
||||||
|
.read_system_config_json_value("turnstile_site_key")
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
|
||||||
|
})?;
|
||||||
|
let secret_key = state
|
||||||
|
.read_system_config_json_value("turnstile_secret_key")
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
|
||||||
|
})?;
|
||||||
|
let siteverify_url = state
|
||||||
|
.read_system_config_json_value("turnstile_siteverify_url")
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
|
||||||
|
})?;
|
||||||
|
let allowed_hostnames = state
|
||||||
|
.read_system_config_json_value("turnstile_allowed_hostnames")
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
turnstile_config_error(format!("auth turnstile settings lookup failed: {err:?}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let secret_key = system_config_string(secret_key.as_ref()).map(|value| {
|
||||||
|
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(AuthTurnstileConfig {
|
||||||
|
enabled: system_config_bool(enabled.as_ref(), false),
|
||||||
|
site_key: system_config_string(site_key.as_ref()),
|
||||||
|
secret_key,
|
||||||
|
siteverify_url: system_config_string(siteverify_url.as_ref())
|
||||||
|
.unwrap_or_else(|| TURNSTILE_SITEVERIFY_URL.to_string()),
|
||||||
|
allowed_hostnames: system_config_string_list(allowed_hostnames.as_ref()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn auth_turnstile_public_settings(
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<AuthTurnstilePublicSettings, AuthTurnstileError> {
|
||||||
|
let config = read_auth_turnstile_config(state).await?;
|
||||||
|
let enabled = config.enabled && config.site_key.is_some();
|
||||||
|
Ok(AuthTurnstilePublicSettings {
|
||||||
|
enabled,
|
||||||
|
site_key: enabled.then_some(config.site_key).flatten(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn turnstile_service_error(error_codes: &[String]) -> bool {
|
||||||
|
error_codes.iter().any(|code| {
|
||||||
|
matches!(
|
||||||
|
code.trim(),
|
||||||
|
"missing-input-secret" | "invalid-input-secret" | "internal-error"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn turnstile_hostname_allowed(hostname: Option<&str>, allowed_hostnames: &[String]) -> bool {
|
||||||
|
if allowed_hostnames.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(hostname) = hostname.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let hostname = hostname.to_ascii_lowercase();
|
||||||
|
allowed_hostnames.iter().any(|allowed| allowed == &hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn verify_auth_turnstile_token(
|
||||||
|
state: &AppState,
|
||||||
|
token: Option<&str>,
|
||||||
|
remote_ip: Option<&str>,
|
||||||
|
) -> Result<(), AuthTurnstileError> {
|
||||||
|
let config = read_auth_turnstile_config(state).await?;
|
||||||
|
if !config.enabled || config.site_key.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let token = token
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| turnstile_error(http::StatusCode::BAD_REQUEST, "请先完成人机验证"))?;
|
||||||
|
let secret_key = config.secret_key.as_deref().ok_or_else(|| {
|
||||||
|
turnstile_error(http::StatusCode::SERVICE_UNAVAILABLE, "人机验证服务未配置")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(8))
|
||||||
|
.build()
|
||||||
|
.map_err(|err| {
|
||||||
|
turnstile_error(
|
||||||
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
format!("人机验证服务暂不可用: {err}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut form = vec![
|
||||||
|
("secret", secret_key.to_string()),
|
||||||
|
("response", token.to_string()),
|
||||||
|
];
|
||||||
|
if let Some(remote_ip) = remote_ip.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
form.push(("remoteip", remote_ip.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(config.siteverify_url)
|
||||||
|
.form(&form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
turnstile_error(
|
||||||
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"人机验证服务暂不可用",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(turnstile_error(
|
||||||
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"人机验证服务暂不可用",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = response
|
||||||
|
.json::<TurnstileSiteverifyResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
turnstile_error(
|
||||||
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"人机验证服务暂不可用",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if payload.success {
|
||||||
|
if turnstile_hostname_allowed(payload.hostname.as_deref(), &config.allowed_hostnames) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(turnstile_error(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
"人机验证失败,请重试",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if turnstile_service_error(&payload.error_codes) {
|
||||||
|
return Err(turnstile_error(
|
||||||
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"人机验证服务暂不可用",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(turnstile_error(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
"人机验证失败,请重试",
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::super::support_wallet::build_wallet_balance_payload_for_user;
|
||||||
use super::{
|
use super::{
|
||||||
build_auth_error_response, query_param_value, resolve_authenticated_local_user, AppState,
|
build_auth_error_response, query_param_value, resolve_authenticated_local_user, AppState,
|
||||||
GatewayError, GatewayPublicRequestContext,
|
GatewayError, GatewayPublicRequestContext,
|
||||||
@@ -180,6 +181,35 @@ fn dashboard_format_usd(value: f64) -> String {
|
|||||||
format!("${:.2}", dashboard_round_f64(value, 2))
|
format!("${:.2}", dashboard_round_f64(value, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dashboard_json_f64(value: Option<&serde_json::Value>) -> f64 {
|
||||||
|
value.and_then(serde_json::Value::as_f64).unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dashboard_wallet_card_value_and_subvalue(
|
||||||
|
wallet_payload: &serde_json::Value,
|
||||||
|
) -> (String, String) {
|
||||||
|
let unlimited = wallet_payload
|
||||||
|
.get("unlimited")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if unlimited {
|
||||||
|
return ("无限额度".to_string(), "无限额度".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let package_balance = dashboard_json_f64(wallet_payload.get("package_balance")).max(0.0);
|
||||||
|
let wallet_balance = dashboard_json_f64(wallet_payload.get("wallet_balance")).max(0.0);
|
||||||
|
let total_available =
|
||||||
|
dashboard_json_f64(wallet_payload.get("total_available_balance")).max(0.0);
|
||||||
|
(
|
||||||
|
dashboard_format_usd(total_available),
|
||||||
|
format!(
|
||||||
|
"套餐额度 {} · 钱包余额 {}",
|
||||||
|
dashboard_format_usd(package_balance),
|
||||||
|
dashboard_format_usd(wallet_balance)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn dashboard_format_percentage(value: f64) -> String {
|
fn dashboard_format_percentage(value: f64) -> String {
|
||||||
format!("{:.1}%", dashboard_round_f64(value, 1))
|
format!("{:.1}%", dashboard_round_f64(value, 1))
|
||||||
}
|
}
|
||||||
@@ -1050,26 +1080,10 @@ pub(super) async fn handle_dashboard_stats_get(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let wallet_value = wallet
|
let wallet_payload =
|
||||||
.as_ref()
|
build_wallet_balance_payload_for_user(state, &auth.user.id, wallet.as_ref()).await;
|
||||||
.map(|wallet| {
|
let (wallet_value, wallet_sub_value) =
|
||||||
if wallet.limit_mode.eq_ignore_ascii_case("unlimited") {
|
dashboard_wallet_card_value_and_subvalue(&wallet_payload);
|
||||||
"无限制".to_string()
|
|
||||||
} else {
|
|
||||||
dashboard_format_usd(wallet.balance)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| dashboard_format_usd(0.0));
|
|
||||||
let wallet_sub_value = wallet
|
|
||||||
.as_ref()
|
|
||||||
.map(|wallet| {
|
|
||||||
if wallet.limit_mode.eq_ignore_ascii_case("unlimited") {
|
|
||||||
"无限额度".to_string()
|
|
||||||
} else {
|
|
||||||
format!("赠款 {}", dashboard_format_usd(wallet.gift_balance))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "暂无钱包".to_string());
|
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"stats": [
|
"stats": [
|
||||||
{
|
{
|
||||||
@@ -1305,6 +1319,19 @@ pub(super) async fn handle_dashboard_provider_status_get(
|
|||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
) -> Response<Body> {
|
) -> Response<Body> {
|
||||||
|
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !dashboard_role_is_admin(&auth.user.role) {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::FORBIDDEN,
|
||||||
|
"仅管理员可查看供应商状态",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if !state.has_usage_data_reader() {
|
if !state.has_usage_data_reader() {
|
||||||
return dashboard_backend_unavailable_response("Usage data backend unavailable");
|
return dashboard_backend_unavailable_response("Usage data backend unavailable");
|
||||||
}
|
}
|
||||||
@@ -1312,16 +1339,7 @@ pub(super) async fn handle_dashboard_provider_status_get(
|
|||||||
return dashboard_backend_unavailable_response("Provider catalog backend unavailable");
|
return dashboard_backend_unavailable_response("Provider catalog backend unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
|
let cache_identity = "admin";
|
||||||
Ok(value) => value,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
|
|
||||||
let cache_identity = if dashboard_role_is_admin(&auth.user.role) {
|
|
||||||
"admin"
|
|
||||||
} else {
|
|
||||||
auth.user.id.as_str()
|
|
||||||
};
|
|
||||||
let cache_key = format!("provider:{cache_identity}");
|
let cache_key = format!("provider:{cache_identity}");
|
||||||
let cache_ttl = std::time::Duration::from_secs(20);
|
let cache_ttl = std::time::Duration::from_secs(20);
|
||||||
|
|
||||||
@@ -1398,11 +1416,7 @@ pub(super) async fn handle_dashboard_provider_status_get(
|
|||||||
.cmp(right["name"].as_str().unwrap_or_default())
|
.cmp(right["name"].as_str().unwrap_or_default())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let limit = if dashboard_role_is_admin(&auth.user.role) {
|
let limit = 10;
|
||||||
10
|
|
||||||
} else {
|
|
||||||
5
|
|
||||||
};
|
|
||||||
if entries.len() > limit {
|
if entries.len() > limit {
|
||||||
entries.truncate(limit);
|
entries.truncate(limit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
const PUBLIC_MODELS_OWNER: &str = "aether";
|
||||||
|
|
||||||
pub(crate) fn build_models_auth_error_response(api_format: &str) -> Response<Body> {
|
pub(crate) fn build_models_auth_error_response(api_format: &str) -> Response<Body> {
|
||||||
match api_format {
|
match api_format {
|
||||||
"claude:messages" => (
|
"claude:messages" => (
|
||||||
@@ -109,7 +111,7 @@ pub(super) fn build_openai_models_list_response(
|
|||||||
"id": row.global_model_name,
|
"id": row.global_model_name,
|
||||||
"object": "model",
|
"object": "model",
|
||||||
"created": 0,
|
"created": 0,
|
||||||
"owned_by": row.provider_name,
|
"owned_by": PUBLIC_MODELS_OWNER,
|
||||||
})
|
})
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
}))
|
}))
|
||||||
@@ -123,7 +125,7 @@ pub(super) fn build_openai_model_detail_response(
|
|||||||
"id": row.global_model_name,
|
"id": row.global_model_name,
|
||||||
"object": "model",
|
"object": "model",
|
||||||
"created": 0,
|
"created": 0,
|
||||||
"owned_by": row.provider_name,
|
"owned_by": PUBLIC_MODELS_OWNER,
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
|
|||||||
Some(
|
Some(
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"provider": provider.name,
|
"provider_id": provider.id,
|
||||||
"endpoint_id": endpoint.id,
|
"endpoint_id": endpoint.id,
|
||||||
"api_format": format_value,
|
"api_format": format_value,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ pub(super) fn select_test_connection_provider(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
if let Some(provider_query) = provider_query {
|
if let Some(provider_query) = provider_query {
|
||||||
if let Some(provider) = providers.iter().find(|provider| {
|
if let Some(provider) = providers
|
||||||
provider.id.eq_ignore_ascii_case(provider_query)
|
.iter()
|
||||||
|| provider.name.eq_ignore_ascii_case(provider_query)
|
.find(|provider| provider.id.eq_ignore_ascii_case(provider_query))
|
||||||
}) {
|
{
|
||||||
return Some(provider.clone());
|
return Some(provider.clone());
|
||||||
}
|
}
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
providers.into_iter().next()
|
providers.into_iter().next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use super::{
|
|||||||
auth_password_policy_level, build_auth_error_response, build_auth_wallet_summary_payload,
|
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,
|
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, handle_auth_me,
|
||||||
handle_users_me_api_key_install_session_create, query_param_optional_bool, query_param_value,
|
handle_users_me_api_key_install_session_create, query_param_optional_bool, query_param_value,
|
||||||
resolve_authenticated_local_user, unix_secs_to_rfc3339,
|
resolve_authenticated_local_user, sanitize_public_model_config_for_user, unix_secs_to_rfc3339,
|
||||||
users_me_api_key_install_sessions_path_matches, validate_auth_register_password, AppState,
|
users_me_api_key_install_sessions_path_matches, validate_auth_register_password, AppState,
|
||||||
AuthenticatedLocalUserContext, GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS,
|
AuthenticatedLocalUserContext, GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,15 +14,23 @@ use serde_json::json;
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
build_admin_endpoint_health_status_payload, build_auth_error_response, query_param_value,
|
build_admin_endpoint_health_status_payload, build_auth_error_response, query_param_value,
|
||||||
resolve_authenticated_local_user, AppState, GatewayPublicRequestContext,
|
resolve_authenticated_local_user, sanitize_public_model_config_for_user, AppState,
|
||||||
USERS_ME_AVAILABLE_MODELS_FETCH_LIMIT,
|
GatewayPublicRequestContext, USERS_ME_AVAILABLE_MODELS_FETCH_LIMIT,
|
||||||
};
|
};
|
||||||
|
|
||||||
const USERS_ME_MODEL_CATALOG_UNAVAILABLE_DETAIL: &str = "用户模型目录暂不可用";
|
const USERS_ME_MODEL_CATALOG_UNAVAILABLE_DETAIL: &str = "用户模型目录暂不可用";
|
||||||
const USERS_ME_PROVIDER_CATALOG_UNAVAILABLE_DETAIL: &str = "用户提供商目录暂不可用";
|
const USERS_ME_PROVIDER_CATALOG_UNAVAILABLE_DETAIL: &str = "用户提供商目录暂不可用";
|
||||||
const USERS_ME_ENDPOINT_STATUS_UNAVAILABLE_DETAIL: &str = "用户端点健康数据暂不可用";
|
const USERS_ME_ENDPOINT_STATUS_UNAVAILABLE_DETAIL: &str = "用户端点健康数据暂不可用";
|
||||||
|
|
||||||
fn build_users_me_available_model_payload(model: StoredPublicGlobalModel) -> serde_json::Value {
|
fn build_users_me_available_model_payload(
|
||||||
|
model: StoredPublicGlobalModel,
|
||||||
|
hide_mapping_config: bool,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let config = if hide_mapping_config {
|
||||||
|
sanitize_public_model_config_for_user(model.config)
|
||||||
|
} else {
|
||||||
|
model.config
|
||||||
|
};
|
||||||
json!({
|
json!({
|
||||||
"id": model.id,
|
"id": model.id,
|
||||||
"name": model.name,
|
"name": model.name,
|
||||||
@@ -31,7 +39,7 @@ fn build_users_me_available_model_payload(model: StoredPublicGlobalModel) -> ser
|
|||||||
"default_price_per_request": model.default_price_per_request,
|
"default_price_per_request": model.default_price_per_request,
|
||||||
"default_tiered_pricing": model.default_tiered_pricing,
|
"default_tiered_pricing": model.default_tiered_pricing,
|
||||||
"supported_capabilities": model.supported_capabilities,
|
"supported_capabilities": model.supported_capabilities,
|
||||||
"config": model.config,
|
"config": config,
|
||||||
"usage_count": model.usage_count,
|
"usage_count": model.usage_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -49,35 +57,27 @@ fn parse_users_me_available_models_query(query: Option<&str>) -> (usize, usize,
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn users_me_allowed_provider_names(
|
fn users_me_allowed_provider_names(
|
||||||
user: &aether_data::repository::users::StoredUserAuthRecord,
|
allowed_providers: Option<&[String]>,
|
||||||
) -> Option<BTreeSet<String>> {
|
) -> Option<BTreeSet<String>> {
|
||||||
if user.role.eq_ignore_ascii_case("admin") {
|
allowed_providers.map(|providers| {
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
user.allowed_providers
|
|
||||||
.as_ref()
|
|
||||||
.map(|providers| {
|
|
||||||
providers
|
providers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| value.trim().to_ascii_lowercase())
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.collect::<BTreeSet<_>>()
|
.collect::<BTreeSet<_>>()
|
||||||
})
|
})
|
||||||
.filter(|providers| !providers.is_empty())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_users_me_allowed_global_model_ids(
|
async fn resolve_users_me_allowed_global_model_ids(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
user: &aether_data::repository::users::StoredUserAuthRecord,
|
allowed_providers: Option<&[String]>,
|
||||||
) -> Result<Option<BTreeSet<String>>, Response<Body>> {
|
) -> Result<Option<BTreeSet<String>>, Response<Body>> {
|
||||||
let Some(allowed_providers) = user
|
let Some(allowed_providers) = allowed_providers else {
|
||||||
.allowed_providers
|
|
||||||
.as_ref()
|
|
||||||
.filter(|providers| !providers.is_empty())
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
if allowed_providers.is_empty() {
|
||||||
|
return Ok(Some(BTreeSet::new()));
|
||||||
|
}
|
||||||
|
|
||||||
if !state.has_provider_catalog_data_reader() {
|
if !state.has_provider_catalog_data_reader() {
|
||||||
return Err(build_auth_error_response(
|
return Err(build_auth_error_response(
|
||||||
@@ -155,10 +155,35 @@ pub(super) async fn handle_users_me_available_models(
|
|||||||
let (skip, limit, search) =
|
let (skip, limit, search) =
|
||||||
parse_users_me_available_models_query(request_context.request_query_string.as_deref());
|
parse_users_me_available_models_query(request_context.request_query_string.as_deref());
|
||||||
|
|
||||||
|
let effective_policies = if auth.user.role.eq_ignore_ascii_case("admin") {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match state
|
||||||
|
.data
|
||||||
|
.resolve_user_effective_list_policies(&auth.user)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => Some(value),
|
||||||
|
Err(err) => {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("user policy lookup failed: {err:?}"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
let provider_model_ids = if auth.user.role.eq_ignore_ascii_case("admin") {
|
let provider_model_ids = if auth.user.role.eq_ignore_ascii_case("admin") {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
match resolve_users_me_allowed_global_model_ids(state, &auth.user).await {
|
match resolve_users_me_allowed_global_model_ids(
|
||||||
|
state,
|
||||||
|
effective_policies
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|policies| policies.allowed_providers.as_deref()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(response) => return response,
|
Err(response) => return response,
|
||||||
}
|
}
|
||||||
@@ -166,9 +191,9 @@ pub(super) async fn handle_users_me_available_models(
|
|||||||
let allowed_models: Option<BTreeSet<String>> = if auth.user.role.eq_ignore_ascii_case("admin") {
|
let allowed_models: Option<BTreeSet<String>> = if auth.user.role.eq_ignore_ascii_case("admin") {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
auth.user
|
effective_policies
|
||||||
.allowed_models
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
.and_then(|policies| policies.allowed_models.as_ref())
|
||||||
.map(|models: &Vec<String>| {
|
.map(|models: &Vec<String>| {
|
||||||
models
|
models
|
||||||
.iter()
|
.iter()
|
||||||
@@ -176,9 +201,18 @@ pub(super) async fn handle_users_me_available_models(
|
|||||||
.filter(|value: &String| !value.is_empty())
|
.filter(|value: &String| !value.is_empty())
|
||||||
.collect::<BTreeSet<_>>()
|
.collect::<BTreeSet<_>>()
|
||||||
})
|
})
|
||||||
.filter(|models: &BTreeSet<String>| !models.is_empty())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let hide_mapping_config = !auth.user.role.eq_ignore_ascii_case("admin");
|
||||||
|
|
||||||
|
let allowed_models: Option<BTreeSet<String>> = allowed_models.map(|models| {
|
||||||
|
models
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
let page = if provider_model_ids.is_none() && allowed_models.is_none() {
|
let page = if provider_model_ids.is_none() && allowed_models.is_none() {
|
||||||
match state
|
match state
|
||||||
.list_public_global_models(&PublicGlobalModelQuery {
|
.list_public_global_models(&PublicGlobalModelQuery {
|
||||||
@@ -247,7 +281,7 @@ pub(super) async fn handle_users_me_available_models(
|
|||||||
"models": page
|
"models": page
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(build_users_me_available_model_payload)
|
.map(|model| build_users_me_available_model_payload(model, hide_mapping_config))
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
"total": page.total,
|
"total": page.total,
|
||||||
}))
|
}))
|
||||||
@@ -271,7 +305,26 @@ pub(super) async fn handle_users_me_providers_get(
|
|||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(response) => return response,
|
Err(response) => return response,
|
||||||
};
|
};
|
||||||
let allowed_provider_names = users_me_allowed_provider_names(&auth.user);
|
let expose_provider_details = auth.user.role.eq_ignore_ascii_case("admin");
|
||||||
|
let allowed_provider_names = if expose_provider_details {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let effective_policies = match state
|
||||||
|
.data
|
||||||
|
.resolve_user_effective_list_policies(&auth.user)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("user policy lookup failed: {err:?}"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
users_me_allowed_provider_names(effective_policies.allowed_providers.as_deref())
|
||||||
|
};
|
||||||
|
|
||||||
let mut providers = match state.list_provider_catalog_providers(true).await {
|
let mut providers = match state.list_provider_catalog_providers(true).await {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
@@ -315,15 +368,18 @@ pub(super) async fn handle_users_me_providers_get(
|
|||||||
};
|
};
|
||||||
let mut endpoints_by_provider = BTreeMap::<String, Vec<serde_json::Value>>::new();
|
let mut endpoints_by_provider = BTreeMap::<String, Vec<serde_json::Value>>::new();
|
||||||
for endpoint in endpoints {
|
for endpoint in endpoints {
|
||||||
|
let mut endpoint_payload = json!({
|
||||||
|
"id": endpoint.id,
|
||||||
|
"api_format": endpoint.api_format,
|
||||||
|
"is_active": endpoint.is_active,
|
||||||
|
});
|
||||||
|
if expose_provider_details {
|
||||||
|
endpoint_payload["base_url"] = json!(endpoint.base_url);
|
||||||
|
}
|
||||||
endpoints_by_provider
|
endpoints_by_provider
|
||||||
.entry(endpoint.provider_id)
|
.entry(endpoint.provider_id)
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(json!({
|
.push(endpoint_payload);
|
||||||
"id": endpoint.id,
|
|
||||||
"api_format": endpoint.api_format,
|
|
||||||
"base_url": endpoint.base_url,
|
|
||||||
"is_active": endpoint.is_active,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut models_by_provider = BTreeMap::<String, Vec<serde_json::Value>>::new();
|
let mut models_by_provider = BTreeMap::<String, Vec<serde_json::Value>>::new();
|
||||||
@@ -375,20 +431,23 @@ pub(super) async fn handle_users_me_providers_get(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|provider| {
|
.map(|provider| {
|
||||||
let provider_id = provider.id.clone();
|
let provider_id = provider.id.clone();
|
||||||
|
let mut payload = json!({
|
||||||
|
"id": provider_id.clone(),
|
||||||
|
"provider_priority": provider.provider_priority,
|
||||||
|
"endpoints": endpoints_by_provider.remove(&provider_id).unwrap_or_default(),
|
||||||
|
"models": models_by_provider.remove(&provider_id).unwrap_or_default(),
|
||||||
|
});
|
||||||
|
if expose_provider_details {
|
||||||
let description = provider
|
let description = provider
|
||||||
.config
|
.config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|value| value.get("description"))
|
.and_then(|value| value.get("description"))
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(ToOwned::to_owned);
|
.map(ToOwned::to_owned);
|
||||||
json!({
|
payload["name"] = json!(provider.name);
|
||||||
"id": provider_id.clone(),
|
payload["description"] = json!(description);
|
||||||
"name": provider.name,
|
}
|
||||||
"description": description,
|
payload
|
||||||
"provider_priority": provider.provider_priority,
|
|
||||||
"endpoints": endpoints_by_provider.remove(&provider_id).unwrap_or_default(),
|
|
||||||
"models": models_by_provider.remove(&provider_id).unwrap_or_default(),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ fn validate_user_model_capability_settings(
|
|||||||
|
|
||||||
fn build_users_me_preferences_payload(
|
fn build_users_me_preferences_payload(
|
||||||
preferences: &GatewayUserPreferenceView,
|
preferences: &GatewayUserPreferenceView,
|
||||||
|
expose_provider_details: bool,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
json!({
|
let mut payload = json!({
|
||||||
"avatar_url": preferences.avatar_url,
|
"avatar_url": preferences.avatar_url,
|
||||||
"bio": preferences.bio,
|
"bio": preferences.bio,
|
||||||
"default_provider_id": preferences.default_provider_id,
|
"default_provider_id": preferences.default_provider_id,
|
||||||
"default_provider": preferences.default_provider_name,
|
|
||||||
"theme": preferences.theme,
|
"theme": preferences.theme,
|
||||||
"language": preferences.language,
|
"language": preferences.language,
|
||||||
"timezone": preferences.timezone,
|
"timezone": preferences.timezone,
|
||||||
@@ -89,7 +89,11 @@ fn build_users_me_preferences_payload(
|
|||||||
"usage_alerts": preferences.usage_alerts,
|
"usage_alerts": preferences.usage_alerts,
|
||||||
"announcements": preferences.announcement_notifications,
|
"announcements": preferences.announcement_notifications,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
if expose_provider_details {
|
||||||
|
payload["default_provider"] = json!(preferences.default_provider_name);
|
||||||
|
}
|
||||||
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_users_me_optional_string_field(
|
fn parse_users_me_optional_string_field(
|
||||||
@@ -188,7 +192,11 @@ pub(super) async fn handle_users_me_preferences_get(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Json(build_users_me_preferences_payload(&preferences)).into_response()
|
Json(build_users_me_preferences_payload(
|
||||||
|
&preferences,
|
||||||
|
auth.user.role.eq_ignore_ascii_case("admin"),
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_users_me_preferences_put(
|
pub(super) async fn handle_users_me_preferences_put(
|
||||||
|
|||||||
@@ -722,6 +722,7 @@ pub(super) async fn handle_users_me_usage_get(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if include_actual_cost {
|
||||||
summary_by_provider = match state
|
summary_by_provider = match state
|
||||||
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
|
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
|
||||||
created_from_unix_secs,
|
created_from_unix_secs,
|
||||||
@@ -740,6 +741,7 @@ pub(super) async fn handle_users_me_usage_get(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
summary_by_api_format = match state
|
summary_by_api_format = match state
|
||||||
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
|
.summarize_usage_breakdown(&UsageBreakdownSummaryQuery {
|
||||||
created_from_unix_secs,
|
created_from_unix_secs,
|
||||||
@@ -936,7 +938,6 @@ pub(super) async fn handle_users_me_usage_get(
|
|||||||
"avg_response_time": avg_response_time,
|
"avg_response_time": avg_response_time,
|
||||||
"billing": build_auth_wallet_summary_payload(wallet.as_ref()),
|
"billing": build_auth_wallet_summary_payload(wallet.as_ref()),
|
||||||
"summary_by_model": build_users_me_usage_summary_by_model(&summary_by_model, include_actual_cost),
|
"summary_by_model": build_users_me_usage_summary_by_model(&summary_by_model, include_actual_cost),
|
||||||
"summary_by_provider": build_users_me_usage_summary_by_provider(&summary_by_provider),
|
|
||||||
"summary_by_api_format": build_users_me_usage_summary_by_api_format(&summary_by_api_format),
|
"summary_by_api_format": build_users_me_usage_summary_by_api_format(&summary_by_api_format),
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"total": total_record_count,
|
"total": total_record_count,
|
||||||
@@ -948,6 +949,9 @@ pub(super) async fn handle_users_me_usage_get(
|
|||||||
});
|
});
|
||||||
if include_actual_cost {
|
if include_actual_cost {
|
||||||
payload["total_actual_cost"] = json!(total_actual_cost);
|
payload["total_actual_cost"] = json!(total_actual_cost);
|
||||||
|
payload["summary_by_provider"] = json!(build_users_me_usage_summary_by_provider(
|
||||||
|
&summary_by_provider
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Json(payload).into_response()
|
Json(payload).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ mod redeem;
|
|||||||
#[path = "wallet/refunds.rs"]
|
#[path = "wallet/refunds.rs"]
|
||||||
mod refunds;
|
mod refunds;
|
||||||
use self::flow::handle_wallet_flow;
|
use self::flow::handle_wallet_flow;
|
||||||
|
pub(in crate::handlers::public::support) use self::reads::build_wallet_balance_payload_for_user;
|
||||||
use self::reads::{
|
use self::reads::{
|
||||||
build_wallet_daily_usage_payload, build_wallet_payload, build_wallet_zero_today_entry,
|
build_wallet_daily_usage_payload, build_wallet_payload, build_wallet_zero_today_entry,
|
||||||
handle_wallet_balance, handle_wallet_today_cost, handle_wallet_transactions,
|
handle_wallet_balance, handle_wallet_today_cost, handle_wallet_transactions,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fn build_wallet_balance_payload(
|
|||||||
payload
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_wallet_balance_payload_for_user(
|
pub(in crate::handlers::public::support) async fn build_wallet_balance_payload_for_user(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
|
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
|||||||
struct SeenExecutionRuntimeRequest {
|
struct SeenExecutionRuntimeRequest {
|
||||||
url: String,
|
url: String,
|
||||||
authorization: String,
|
authorization: String,
|
||||||
|
accept: String,
|
||||||
provider_api_format: String,
|
provider_api_format: String,
|
||||||
total_ms: Option<u64>,
|
total_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
|||||||
.get("authorization")
|
.get("authorization")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
accept: plan.headers.get("accept").cloned().unwrap_or_default(),
|
||||||
provider_api_format: plan.provider_api_format.clone(),
|
provider_api_format: plan.provider_api_format.clone(),
|
||||||
total_ms: plan
|
total_ms: plan
|
||||||
.timeouts
|
.timeouts
|
||||||
@@ -78,41 +80,22 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
|||||||
request_id: plan.request_id,
|
request_id: plan.request_id,
|
||||||
candidate_id: None,
|
candidate_id: None,
|
||||||
status_code: 200,
|
status_code: 200,
|
||||||
headers: BTreeMap::from([
|
headers: BTreeMap::new(),
|
||||||
(
|
|
||||||
"x-codex-primary-reset-after-seconds".to_string(),
|
|
||||||
"18000".to_string(),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"x-codex-primary-reset-at".to_string(),
|
|
||||||
"1900000000".to_string(),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"x-codex-secondary-reset-after-seconds".to_string(),
|
|
||||||
"604800".to_string(),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"x-codex-secondary-reset-at".to_string(),
|
|
||||||
"1900500000".to_string(),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
body: Some(aether_contracts::ResponseBody {
|
body: Some(aether_contracts::ResponseBody {
|
||||||
json_body: Some(json!({
|
json_body: Some(json!({
|
||||||
"plan_type": "plus",
|
"user": {
|
||||||
"rate_limit": {
|
"id": "user-codex-123",
|
||||||
"primary_window": {
|
"email": "codex@example.com",
|
||||||
"used_percent": 12.5,
|
"name": "Codex User"
|
||||||
"window_minutes": 300
|
|
||||||
},
|
},
|
||||||
"secondary_window": {
|
"account": {
|
||||||
"used_percent": 55.0,
|
"id": "acct-codex-123",
|
||||||
"window_minutes": 10080
|
"name": "Personal",
|
||||||
}
|
"plan_type": "plus"
|
||||||
},
|
},
|
||||||
"credits": {
|
"plan": {
|
||||||
"has_credits": true,
|
"type": "Plus",
|
||||||
"balance": 42.0,
|
"title": "ChatGPT Plus"
|
||||||
"unlimited": false
|
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
body_bytes_b64: None,
|
body_bytes_b64: None,
|
||||||
@@ -147,7 +130,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
|||||||
)],
|
)],
|
||||||
));
|
));
|
||||||
|
|
||||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||||
let gateway = build_router_with_state(
|
let gateway = build_router_with_state(
|
||||||
build_state_with_execution_runtime_override(execution_runtime_url.clone())
|
build_state_with_execution_runtime_override(execution_runtime_url.clone())
|
||||||
@@ -184,18 +167,22 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
|||||||
);
|
);
|
||||||
assert_eq!(payload["results"][0]["quota_snapshot"]["plan_type"], "plus");
|
assert_eq!(payload["results"][0]["quota_snapshot"]["plan_type"], "plus");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["results"][0]["quota_snapshot"]["reset_at"],
|
payload["results"][0]["quota_snapshot"]["exhausted"],
|
||||||
1_900_000_000u64
|
json!(false)
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
payload["results"][0]["quota_snapshot"]["credits"]["balance"],
|
|
||||||
json!(42.0)
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["results"][0]["quota_snapshot"]["windows"]
|
payload["results"][0]["quota_snapshot"]["windows"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.map(Vec::len),
|
.map(Vec::len),
|
||||||
Some(2usize)
|
Some(0usize)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["metadata"]["email"],
|
||||||
|
"codex@example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["results"][0]["metadata"]["account_id"],
|
||||||
|
"acct-codex-123"
|
||||||
);
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
@@ -206,12 +193,13 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
|||||||
.expect("execution runtime request should be captured");
|
.expect("execution runtime request should be captured");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.url,
|
seen_execution_runtime_request.url,
|
||||||
"https://chatgpt.com/backend-api/wham/usage"
|
"https://chatgpt.com/backend-api/me"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.authorization,
|
seen_execution_runtime_request.authorization,
|
||||||
"Bearer sk-codex-123"
|
"Bearer sk-codex-123"
|
||||||
);
|
);
|
||||||
|
assert_eq!(seen_execution_runtime_request.accept, "application/json");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.provider_api_format,
|
seen_execution_runtime_request.provider_api_format,
|
||||||
"openai:responses"
|
"openai:responses"
|
||||||
@@ -237,32 +225,130 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
|||||||
.upstream_metadata
|
.upstream_metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|value| value.get("codex"))
|
.and_then(|value| value.get("codex"))
|
||||||
.and_then(|value| value.get("primary_used_percent")),
|
.and_then(|value| value.get("email")),
|
||||||
Some(&json!(55.0))
|
Some(&json!("codex@example.com"))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reloaded[0]
|
reloaded[0]
|
||||||
.upstream_metadata
|
.upstream_metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|value| value.get("codex"))
|
.and_then(|value| value.get("codex"))
|
||||||
.and_then(|value| value.get("primary_reset_at")),
|
.and_then(|value| value.get("account_id")),
|
||||||
Some(&json!(1_900_500_000u64))
|
Some(&json!("acct-codex-123"))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(reloaded[0]
|
||||||
reloaded[0]
|
|
||||||
.upstream_metadata
|
.upstream_metadata
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|value| value.get("codex"))
|
.and_then(|value| value.get("codex"))
|
||||||
.and_then(|value| value.get("secondary_used_percent")),
|
.and_then(|value| value.get("primary_used_percent"))
|
||||||
Some(&json!(12.5))
|
.is_none());
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
execution_runtime_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_marks_codex_key_invalid_when_backend_me_returns_payment_required() {
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/api/admin/endpoints/providers/provider-codex/refresh-quota",
|
||||||
|
any(move |_request: Request| async move {
|
||||||
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let execution_runtime = Router::new().route(
|
||||||
|
"/v1/execute/sync",
|
||||||
|
any(move |request: Request| async move {
|
||||||
|
let plan: aether_contracts::ExecutionPlan = serde_json::from_slice(
|
||||||
|
&to_bytes(request.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("plan should parse");
|
||||||
|
let result = aether_contracts::ExecutionResult {
|
||||||
|
request_id: plan.request_id,
|
||||||
|
candidate_id: None,
|
||||||
|
status_code: 402,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Some(aether_contracts::ResponseBody {
|
||||||
|
json_body: Some(json!({
|
||||||
|
"error": {
|
||||||
|
"message": "payment required"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
body_bytes_b64: None,
|
||||||
|
}),
|
||||||
|
telemetry: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
(StatusCode::OK, Json(result))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![StoredProviderCatalogProvider::new(
|
||||||
|
"provider-codex".to_string(),
|
||||||
|
"codex".to_string(),
|
||||||
|
Some("https://example.com".to_string()),
|
||||||
|
"codex".to_string(),
|
||||||
|
)
|
||||||
|
.expect("provider should build")],
|
||||||
|
vec![sample_endpoint(
|
||||||
|
"endpoint-codex-cli",
|
||||||
|
"provider-codex",
|
||||||
|
"openai:responses",
|
||||||
|
"https://chatgpt.com/backend-api",
|
||||||
|
)],
|
||||||
|
vec![sample_key(
|
||||||
|
"key-codex-a",
|
||||||
|
"provider-codex",
|
||||||
|
"openai:responses",
|
||||||
|
"sk-codex-123",
|
||||||
|
)],
|
||||||
|
));
|
||||||
|
|
||||||
|
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
build_state_with_execution_runtime_override(execution_runtime_url.clone())
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||||
|
provider_catalog_repository.clone(),
|
||||||
|
)
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"{gateway_url}/api/admin/endpoints/providers/provider-codex/refresh-quota"
|
||||||
|
))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["success"], 0);
|
||||||
|
assert_eq!(payload["failed"], 1);
|
||||||
|
assert_eq!(payload["results"][0]["status"], "payment_required");
|
||||||
|
assert_eq!(payload["results"][0]["status_code"], 402);
|
||||||
|
|
||||||
|
let reloaded = provider_catalog_repository
|
||||||
|
.list_keys_by_ids(&["key-codex-a".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("keys should read");
|
||||||
|
assert_eq!(reloaded.len(), 1);
|
||||||
|
assert!(reloaded[0].oauth_invalid_at_unix_secs.is_some());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reloaded[0]
|
reloaded[0].oauth_invalid_reason.as_deref(),
|
||||||
.upstream_metadata
|
Some("[ACCOUNT_BLOCK] payment required")
|
||||||
.as_ref()
|
|
||||||
.and_then(|value| value.get("codex"))
|
|
||||||
.and_then(|value| value.get("secondary_reset_at")),
|
|
||||||
Some(&json!(1_900_000_000u64))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -1139,7 +1225,7 @@ async fn gateway_reports_codex_quota_runtime_failures_locally_without_falling_ba
|
|||||||
assert!(payload["results"][0]["message"]
|
assert!(payload["results"][0]["message"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("message should be string")
|
.expect("message should be string")
|
||||||
.contains("wham/usage 请求执行失败: execution runtime returned HTTP 500"));
|
.contains("backend-api/me 请求执行失败: execution runtime returned HTTP 500"));
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
let reloaded = provider_catalog_repository
|
let reloaded = provider_catalog_repository
|
||||||
|
|||||||
@@ -194,16 +194,17 @@ fn codex_quota_execution_result(request_id: &str) -> serde_json::Value {
|
|||||||
"headers": {},
|
"headers": {},
|
||||||
"body": {
|
"body": {
|
||||||
"json_body": {
|
"json_body": {
|
||||||
"plan_type": "plus",
|
"user": {
|
||||||
"rate_limit": {
|
"id": "user-codex-123",
|
||||||
"primary_window": {
|
"email": "alice@example.com"
|
||||||
"used_percent": 10.0,
|
|
||||||
"window_minutes": 300
|
|
||||||
},
|
},
|
||||||
"secondary_window": {
|
"account": {
|
||||||
"used_percent": 20.0,
|
"id": "acct-codex-123",
|
||||||
"window_minutes": 10080
|
"plan_type": "plus"
|
||||||
}
|
},
|
||||||
|
"plan": {
|
||||||
|
"type": "Plus",
|
||||||
|
"title": "ChatGPT Plus"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2453,8 +2454,8 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
|||||||
.as_str()
|
.as_str()
|
||||||
.expect("account_state_recheck_error should be string when recheck is attempted");
|
.expect("account_state_recheck_error should be string when recheck is attempted");
|
||||||
assert!(
|
assert!(
|
||||||
account_state_recheck_error == "wham/usage API 返回状态码 401"
|
account_state_recheck_error == "backend-api/me API 返回状态码 401"
|
||||||
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
|
|| account_state_recheck_error.starts_with("backend-api/me 请求执行失败:"),
|
||||||
"unexpected account_state_recheck_error: {account_state_recheck_error}"
|
"unexpected account_state_recheck_error: {account_state_recheck_error}"
|
||||||
);
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
@@ -4924,8 +4925,8 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
|||||||
.as_str()
|
.as_str()
|
||||||
.expect("account_state_recheck_error should be string when attempted");
|
.expect("account_state_recheck_error should be string when attempted");
|
||||||
assert!(
|
assert!(
|
||||||
account_state_recheck_error == "wham/usage API 返回状态码 401"
|
account_state_recheck_error == "backend-api/me API 返回状态码 401"
|
||||||
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
|
|| account_state_recheck_error.starts_with("backend-api/me 请求执行失败:"),
|
||||||
"unexpected account_state_recheck_error: {account_state_recheck_error}"
|
"unexpected account_state_recheck_error: {account_state_recheck_error}"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -4943,7 +4944,7 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
|||||||
.expect("execution runtime request should be captured");
|
.expect("execution runtime request should be captured");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.url,
|
seen_execution_runtime_request.url,
|
||||||
"https://chatgpt.com/backend-api/wham/usage"
|
"https://chatgpt.com/backend-api/me"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.authorization,
|
seen_execution_runtime_request.authorization,
|
||||||
@@ -4967,7 +4968,7 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
|||||||
.expect("refreshed api key should decrypt");
|
.expect("refreshed api key should decrypt");
|
||||||
assert_eq!(decrypted_api_key, "refreshed-codex-access-token");
|
assert_eq!(decrypted_api_key, "refreshed-codex-access-token");
|
||||||
if account_state_recheck_attempted
|
if account_state_recheck_attempted
|
||||||
&& payload["account_state_recheck_error"] == "wham/usage API 返回状态码 401"
|
&& payload["account_state_recheck_error"] == "backend-api/me API 返回状态码 401"
|
||||||
{
|
{
|
||||||
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ async fn gateway_handles_public_openai_models_without_hitting_fallback_probe() {
|
|||||||
assert_eq!(payload["object"], "list");
|
assert_eq!(payload["object"], "list");
|
||||||
assert_eq!(payload["data"][0]["id"], "gpt-4.1");
|
assert_eq!(payload["data"][0]["id"], "gpt-4.1");
|
||||||
assert_eq!(payload["data"][1]["id"], "gpt-5");
|
assert_eq!(payload["data"][1]["id"], "gpt-5");
|
||||||
assert_eq!(payload["data"][0]["owned_by"], "openai");
|
assert_eq!(payload["data"][0]["owned_by"], "aether");
|
||||||
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -231,7 +231,7 @@ async fn gateway_handles_public_openai_models_with_cross_format_candidates_witho
|
|||||||
list_response.json().await.expect("json body should parse");
|
list_response.json().await.expect("json body should parse");
|
||||||
assert_eq!(list_payload["object"], "list");
|
assert_eq!(list_payload["object"], "list");
|
||||||
assert_eq!(list_payload["data"][0]["id"], "claude-3-7-sonnet");
|
assert_eq!(list_payload["data"][0]["id"], "claude-3-7-sonnet");
|
||||||
assert_eq!(list_payload["data"][0]["owned_by"], "claude");
|
assert_eq!(list_payload["data"][0]["owned_by"], "aether");
|
||||||
|
|
||||||
let detail_response = client
|
let detail_response = client
|
||||||
.get(format!("{gateway_url}/v1/models/claude-3-7-sonnet"))
|
.get(format!("{gateway_url}/v1/models/claude-3-7-sonnet"))
|
||||||
@@ -245,7 +245,7 @@ async fn gateway_handles_public_openai_models_with_cross_format_candidates_witho
|
|||||||
.await
|
.await
|
||||||
.expect("json body should parse");
|
.expect("json body should parse");
|
||||||
assert_eq!(detail_payload["id"], "claude-3-7-sonnet");
|
assert_eq!(detail_payload["id"], "claude-3-7-sonnet");
|
||||||
assert_eq!(detail_payload["owned_by"], "claude");
|
assert_eq!(detail_payload["owned_by"], "aether");
|
||||||
|
|
||||||
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
|||||||
@@ -669,6 +669,7 @@ async fn gateway_handles_public_catalog_site_info_without_proxying_upstream() {
|
|||||||
vec![
|
vec![
|
||||||
("site_name".to_string(), json!("Aether Local")),
|
("site_name".to_string(), json!("Aether Local")),
|
||||||
("site_subtitle".to_string(), json!("Rust Only")),
|
("site_subtitle".to_string(), json!("Rust Only")),
|
||||||
|
("show_github_link".to_string(), json!(false)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -685,6 +686,7 @@ async fn gateway_handles_public_catalog_site_info_without_proxying_upstream() {
|
|||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["site_name"], "Aether Local");
|
assert_eq!(payload["site_name"], "Aether Local");
|
||||||
assert_eq!(payload["site_subtitle"], "Rust Only");
|
assert_eq!(payload["site_subtitle"], "Rust Only");
|
||||||
|
assert_eq!(payload["show_github_link"], false);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -757,7 +759,9 @@ async fn gateway_handles_public_catalog_providers_without_proxying_upstream() {
|
|||||||
let providers = payload.as_array().expect("providers should be an array");
|
let providers = payload.as_array().expect("providers should be an array");
|
||||||
assert_eq!(providers.len(), 2);
|
assert_eq!(providers.len(), 2);
|
||||||
assert_eq!(providers[0]["id"], "provider-openai");
|
assert_eq!(providers[0]["id"], "provider-openai");
|
||||||
assert_eq!(providers[0]["name"], "openai");
|
assert!(providers[0].get("name").is_none());
|
||||||
|
assert!(providers[0].get("description").is_none());
|
||||||
|
assert!(providers[0].get("website").is_none());
|
||||||
assert_eq!(providers[0]["provider_priority"], 10);
|
assert_eq!(providers[0]["provider_priority"], 10);
|
||||||
assert_eq!(providers[0]["endpoints_count"], 1);
|
assert_eq!(providers[0]["endpoints_count"], 1);
|
||||||
assert_eq!(providers[0]["active_endpoints_count"], 1);
|
assert_eq!(providers[0]["active_endpoints_count"], 1);
|
||||||
@@ -834,7 +838,8 @@ async fn gateway_handles_public_catalog_models_without_proxying_upstream() {
|
|||||||
let models = payload.as_array().expect("models should be an array");
|
let models = payload.as_array().expect("models should be an array");
|
||||||
assert_eq!(models.len(), 1);
|
assert_eq!(models.len(), 1);
|
||||||
assert_eq!(models[0]["id"], "model-openai-gpt5");
|
assert_eq!(models[0]["id"], "model-openai-gpt5");
|
||||||
assert_eq!(models[0]["provider_name"], "openai");
|
assert!(models[0].get("provider_id").is_none());
|
||||||
|
assert!(models[0].get("provider_name").is_none());
|
||||||
assert_eq!(models[0]["name"], "gpt-5");
|
assert_eq!(models[0]["name"], "gpt-5");
|
||||||
assert_eq!(models[0]["display_name"], "GPT 5");
|
assert_eq!(models[0]["display_name"], "GPT 5");
|
||||||
assert_eq!(models[0]["tags"], serde_json::Value::Null);
|
assert_eq!(models[0]["tags"], serde_json::Value::Null);
|
||||||
@@ -993,7 +998,8 @@ async fn gateway_handles_public_catalog_search_models_without_proxying_upstream(
|
|||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
let models = payload.as_array().expect("models should be an array");
|
let models = payload.as_array().expect("models should be an array");
|
||||||
assert_eq!(models.len(), 1);
|
assert_eq!(models.len(), 1);
|
||||||
assert_eq!(models[0]["provider_name"], "claude");
|
assert!(models[0].get("provider_id").is_none());
|
||||||
|
assert!(models[0].get("provider_name").is_none());
|
||||||
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
|
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
|
||||||
assert_eq!(models[0]["display_name"], "Claude Sonnet 4.5");
|
assert_eq!(models[0]["display_name"], "Claude Sonnet 4.5");
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
@@ -1101,10 +1107,16 @@ async fn gateway_handles_public_global_models_without_proxying_upstream() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut gpt_model = sample_public_global_model("gm-3", "gpt-5", "GPT 5", true);
|
||||||
|
gpt_model.config = Some(json!({
|
||||||
|
"description": "Public description",
|
||||||
|
"model_mappings": ["gpt-5-upstream"],
|
||||||
|
"provider_model_mappings": [{"name": "provider-gpt-5"}],
|
||||||
|
}));
|
||||||
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
|
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
|
||||||
sample_public_global_model("gm-1", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
|
sample_public_global_model("gm-1", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
|
||||||
sample_public_global_model("gm-2", "disabled-model", "Disabled Model", false),
|
sample_public_global_model("gm-2", "disabled-model", "Disabled Model", false),
|
||||||
sample_public_global_model("gm-3", "gpt-5", "GPT 5", true),
|
gpt_model,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
@@ -1131,6 +1143,16 @@ async fn gateway_handles_public_global_models_without_proxying_upstream() {
|
|||||||
assert_eq!(payload["models"][0]["name"], "gpt-5");
|
assert_eq!(payload["models"][0]["name"], "gpt-5");
|
||||||
assert_eq!(payload["models"][0]["display_name"], "GPT 5");
|
assert_eq!(payload["models"][0]["display_name"], "GPT 5");
|
||||||
assert_eq!(payload["models"][0]["usage_count"], 0);
|
assert_eq!(payload["models"][0]["usage_count"], 0);
|
||||||
|
assert_eq!(
|
||||||
|
payload["models"][0]["config"]["description"],
|
||||||
|
"Public description"
|
||||||
|
);
|
||||||
|
assert!(payload["models"][0]["config"]
|
||||||
|
.get("model_mappings")
|
||||||
|
.is_none());
|
||||||
|
assert!(payload["models"][0]["config"]
|
||||||
|
.get("provider_model_mappings")
|
||||||
|
.is_none());
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -1409,6 +1431,8 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream()
|
|||||||
("smtp_host".to_string(), json!("smtp.example.com")),
|
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||||
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
||||||
("password_policy_level".to_string(), json!("strong")),
|
("password_policy_level".to_string(), json!("strong")),
|
||||||
|
("turnstile_enabled".to_string(), json!(true)),
|
||||||
|
("turnstile_site_key".to_string(), json!("site-key-123")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
@@ -1434,6 +1458,8 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream()
|
|||||||
"require_email_verification": true,
|
"require_email_verification": true,
|
||||||
"email_configured": true,
|
"email_configured": true,
|
||||||
"password_policy_level": "strong",
|
"password_policy_level": "strong",
|
||||||
|
"turnstile_enabled": true,
|
||||||
|
"turnstile_site_key": "site-key-123",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
@@ -1689,9 +1715,9 @@ async fn gateway_handles_public_providers_without_proxying_upstream() {
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["providers"][0]["name"], "openai");
|
assert!(payload["providers"][0].get("name").is_none());
|
||||||
assert_eq!(payload["providers"][0]["provider_priority"], 10);
|
assert_eq!(payload["providers"][0]["provider_priority"], 10);
|
||||||
assert_eq!(payload["providers"][1]["name"], "anthropic");
|
assert!(payload["providers"][1].get("name").is_none());
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -1739,7 +1765,7 @@ async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["id"], "provider-1");
|
assert_eq!(payload["id"], "provider-1");
|
||||||
assert_eq!(payload["name"], "openai");
|
assert!(payload.get("name").is_none());
|
||||||
assert_eq!(payload["provider_priority"], 10);
|
assert_eq!(payload["provider_priority"], 10);
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
@@ -1748,11 +1774,9 @@ async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["id"], "provider-1");
|
assert_eq!(payload["detail"], "Provider not found");
|
||||||
assert_eq!(payload["name"], "openai");
|
|
||||||
assert_eq!(payload["provider_priority"], 10);
|
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
@@ -1805,6 +1829,9 @@ async fn gateway_handles_public_providers_with_endpoints_without_proxying_upstre
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["providers"][0]["endpoints"][0]["id"], "endpoint-1");
|
assert_eq!(payload["providers"][0]["endpoints"][0]["id"], "endpoint-1");
|
||||||
|
assert!(payload["providers"][0]["endpoints"][0]
|
||||||
|
.get("base_url")
|
||||||
|
.is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["providers"][0]["endpoints"][0]["api_format"],
|
payload["providers"][0]["endpoints"][0]["api_format"],
|
||||||
"openai:chat"
|
"openai:chat"
|
||||||
@@ -1926,7 +1953,7 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
|
|||||||
|
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.get(format!(
|
.get(format!(
|
||||||
"{gateway_url}/v1/test-connection?provider=openai&model=gpt-5&api_format=openai:chat"
|
"{gateway_url}/v1/test-connection?provider=provider-1&model=gpt-5&api_format=openai:chat"
|
||||||
))
|
))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -1935,7 +1962,8 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(payload["status"], "success");
|
assert_eq!(payload["status"], "success");
|
||||||
assert_eq!(payload["provider"], "openai");
|
assert!(payload.get("provider").is_none());
|
||||||
|
assert_eq!(payload["provider_id"], "provider-1");
|
||||||
assert_eq!(payload["api_format"], "openai:chat");
|
assert_eq!(payload["api_format"], "openai:chat");
|
||||||
assert_eq!(payload["response_id"], "resp_local_test");
|
assert_eq!(payload["response_id"], "resp_local_test");
|
||||||
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 1);
|
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 1);
|
||||||
@@ -4771,7 +4799,13 @@ async fn gateway_handles_users_me_preferences_locally_without_proxying_upstream(
|
|||||||
"refresh-token-placeholder",
|
"refresh-token-placeholder",
|
||||||
now,
|
now,
|
||||||
)],
|
)],
|
||||||
std::iter::empty::<crate::data::state::StoredUserPreferenceRecord>(),
|
{
|
||||||
|
let mut preference =
|
||||||
|
crate::data::state::StoredUserPreferenceRecord::default_for_user("user-auth-1");
|
||||||
|
preference.default_provider_id = Some("provider-openai".to_string());
|
||||||
|
preference.default_provider_name = Some("openai".to_string());
|
||||||
|
vec![preference]
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -4791,6 +4825,8 @@ async fn gateway_handles_users_me_preferences_locally_without_proxying_upstream(
|
|||||||
assert_eq!(get_payload["language"], "zh-CN");
|
assert_eq!(get_payload["language"], "zh-CN");
|
||||||
assert_eq!(get_payload["timezone"], "Asia/Shanghai");
|
assert_eq!(get_payload["timezone"], "Asia/Shanghai");
|
||||||
assert_eq!(get_payload["notifications"]["email"], true);
|
assert_eq!(get_payload["notifications"]["email"], true);
|
||||||
|
assert_eq!(get_payload["default_provider_id"], "provider-openai");
|
||||||
|
assert!(get_payload.get("default_provider").is_none());
|
||||||
|
|
||||||
let put_response = client
|
let put_response = client
|
||||||
.put(format!("{gateway_url}/api/users/me/preferences"))
|
.put(format!("{gateway_url}/api/users/me/preferences"))
|
||||||
@@ -5041,6 +5077,7 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
|
|||||||
105
|
105
|
||||||
);
|
);
|
||||||
assert_eq!(payload["summary_by_model"][0]["total_input_context"], 120);
|
assert_eq!(payload["summary_by_model"][0]["total_input_context"], 120);
|
||||||
|
assert!(payload.get("summary_by_provider").is_none());
|
||||||
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
|
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
@@ -7552,8 +7589,10 @@ async fn gateway_handles_users_me_providers_locally_without_proxying_upstream()
|
|||||||
let providers = payload.as_array().expect("providers should be array");
|
let providers = payload.as_array().expect("providers should be array");
|
||||||
assert_eq!(providers.len(), 1);
|
assert_eq!(providers.len(), 1);
|
||||||
assert_eq!(providers[0]["id"], "provider-openai");
|
assert_eq!(providers[0]["id"], "provider-openai");
|
||||||
assert_eq!(providers[0]["name"], "openai");
|
assert!(providers[0].get("name").is_none());
|
||||||
|
assert!(providers[0].get("description").is_none());
|
||||||
assert_eq!(providers[0]["endpoints"][0]["id"], "endpoint-openai-1");
|
assert_eq!(providers[0]["endpoints"][0]["id"], "endpoint-openai-1");
|
||||||
|
assert!(providers[0]["endpoints"][0].get("base_url").is_none());
|
||||||
assert_eq!(providers[0]["models"][0]["name"], "gpt-5");
|
assert_eq!(providers[0]["models"][0]["name"], "gpt-5");
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
@@ -7990,6 +8029,127 @@ async fn gateway_handles_auth_send_verification_code_locally_without_proxying_up
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_requires_turnstile_token_before_auth_send_verification_code() {
|
||||||
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
|
start_auth_gateway_with_builder(|| {
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(
|
||||||
|
vec![
|
||||||
|
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||||
|
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
||||||
|
("turnstile_enabled".to_string(), json!(true)),
|
||||||
|
("turnstile_site_key".to_string(), json!("site-key-123")),
|
||||||
|
("turnstile_secret_key".to_string(), json!("secret-key-123")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/auth/send-verification-code"))
|
||||||
|
.json(&json!({ "email": "alice@example.com" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["detail"], "请先完成人机验证");
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_verifies_turnstile_token_before_auth_send_verification_code() {
|
||||||
|
let seen_siteverify_body = Arc::new(Mutex::new(None::<String>));
|
||||||
|
let seen_siteverify_body_clone = Arc::clone(&seen_siteverify_body);
|
||||||
|
let siteverify = Router::new().route(
|
||||||
|
"/turnstile/siteverify",
|
||||||
|
any(move |request: Request| {
|
||||||
|
let seen_siteverify_body_inner = Arc::clone(&seen_siteverify_body_clone);
|
||||||
|
async move {
|
||||||
|
let body = String::from_utf8(
|
||||||
|
to_bytes(request.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read")
|
||||||
|
.to_vec(),
|
||||||
|
)
|
||||||
|
.expect("siteverify body should be utf8");
|
||||||
|
*seen_siteverify_body_inner
|
||||||
|
.lock()
|
||||||
|
.expect("mutex should lock") = Some(body);
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"hostname": "localhost"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let (siteverify_url, siteverify_handle) = start_server(siteverify).await;
|
||||||
|
|
||||||
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
|
start_auth_gateway_with_builder(|| {
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(
|
||||||
|
vec![
|
||||||
|
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||||
|
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
||||||
|
("smtp_from_name".to_string(), json!("Aether Mail")),
|
||||||
|
("turnstile_enabled".to_string(), json!(true)),
|
||||||
|
("turnstile_site_key".to_string(), json!("site-key-123")),
|
||||||
|
("turnstile_secret_key".to_string(), json!("secret-key-123")),
|
||||||
|
(
|
||||||
|
"turnstile_siteverify_url".to_string(),
|
||||||
|
json!(format!("{siteverify_url}/turnstile/siteverify")),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"turnstile_allowed_hostnames".to_string(),
|
||||||
|
json!(["localhost"]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/auth/send-verification-code"))
|
||||||
|
.json(&json!({
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"turnstile_token": "turnstile-token-123"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["success"], true);
|
||||||
|
let siteverify_body = seen_siteverify_body
|
||||||
|
.lock()
|
||||||
|
.expect("mutex should lock")
|
||||||
|
.clone()
|
||||||
|
.expect("siteverify should be called");
|
||||||
|
assert!(siteverify_body.contains("secret=secret-key-123"));
|
||||||
|
assert!(siteverify_body.contains("response=turnstile-token-123"));
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
siteverify_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_auth_verification_status_locally_without_proxying_upstream() {
|
async fn gateway_handles_auth_verification_status_locally_without_proxying_upstream() {
|
||||||
let now = Utc::now() - chrono::Duration::seconds(10);
|
let now = Utc::now() - chrono::Duration::seconds(10);
|
||||||
@@ -8209,6 +8369,203 @@ async fn gateway_handles_users_me_available_models_locally_without_proxying_upst
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_filters_users_me_available_models_by_group_policy_and_hides_model_mappings() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut user = sample_auth_user(now);
|
||||||
|
user.allowed_providers = None;
|
||||||
|
user.allowed_providers_mode = "unrestricted".to_string();
|
||||||
|
user.allowed_models = None;
|
||||||
|
user.allowed_models_mode = "unrestricted".to_string();
|
||||||
|
let access_token = build_test_auth_token(
|
||||||
|
"access",
|
||||||
|
serde_json::Map::from_iter([
|
||||||
|
("user_id".to_string(), json!(user.id)),
|
||||||
|
("role".to_string(), json!(user.role)),
|
||||||
|
(
|
||||||
|
"created_at".to_string(),
|
||||||
|
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session_id".to_string(),
|
||||||
|
json!("session-users-me-group-models"),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
now + chrono::Duration::hours(1),
|
||||||
|
);
|
||||||
|
let mut allowed_model =
|
||||||
|
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true);
|
||||||
|
allowed_model.config = Some(json!({
|
||||||
|
"description": "Claude detail",
|
||||||
|
"model_mappings": ["claude-upstream"]
|
||||||
|
}));
|
||||||
|
let mut blocked_model = sample_public_global_model("gm-1", "gpt-5", "GPT 5", true);
|
||||||
|
blocked_model.config = Some(json!({
|
||||||
|
"description": "GPT detail",
|
||||||
|
"model_mappings": ["gpt-upstream"]
|
||||||
|
}));
|
||||||
|
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
|
||||||
|
blocked_model,
|
||||||
|
allowed_model,
|
||||||
|
]));
|
||||||
|
let user_repository: Arc<dyn UserReadRepository> =
|
||||||
|
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||||
|
let group = user_repository
|
||||||
|
.create_user_group(UpsertUserGroupRecord {
|
||||||
|
name: "Claude only".to_string(),
|
||||||
|
description: None,
|
||||||
|
priority: 0,
|
||||||
|
allowed_providers: None,
|
||||||
|
allowed_providers_mode: "unrestricted".to_string(),
|
||||||
|
allowed_api_formats: None,
|
||||||
|
allowed_api_formats_mode: "unrestricted".to_string(),
|
||||||
|
allowed_models: Some(vec!["claude-sonnet-4-5".to_string()]),
|
||||||
|
allowed_models_mode: "specific".to_string(),
|
||||||
|
rate_limit: None,
|
||||||
|
rate_limit_mode: "system".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("group should create")
|
||||||
|
.expect("group should exist");
|
||||||
|
user_repository
|
||||||
|
.add_user_to_group(&group.id, "user-auth-1")
|
||||||
|
.await
|
||||||
|
.expect("group membership should create");
|
||||||
|
|
||||||
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
|
start_auth_gateway_with_builder(|| {
|
||||||
|
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
|
||||||
|
global_model_repository,
|
||||||
|
)
|
||||||
|
.with_user_reader(Arc::clone(&user_repository));
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(data_state)
|
||||||
|
.with_auth_sessions_for_tests([sample_auth_session(
|
||||||
|
"user-auth-1",
|
||||||
|
"session-users-me-group-models",
|
||||||
|
"device-users-me-group-models",
|
||||||
|
"refresh-token-placeholder",
|
||||||
|
now,
|
||||||
|
)])
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(format!("{gateway_url}/api/users/me/available-models"))
|
||||||
|
.header("authorization", format!("Bearer {access_token}"))
|
||||||
|
.header("x-client-device-id", "device-users-me-group-models")
|
||||||
|
.header("user-agent", "AetherTest/1.0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
let models = payload["models"]
|
||||||
|
.as_array()
|
||||||
|
.expect("models should be an array");
|
||||||
|
assert_eq!(payload["total"], 1);
|
||||||
|
assert_eq!(models.len(), 1);
|
||||||
|
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
|
||||||
|
assert_eq!(models[0]["config"]["description"], "Claude detail");
|
||||||
|
assert!(models[0]["config"].get("model_mappings").is_none());
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_returns_no_users_me_available_models_when_group_denies_all_models() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut user = sample_auth_user(now);
|
||||||
|
user.allowed_providers = None;
|
||||||
|
user.allowed_providers_mode = "unrestricted".to_string();
|
||||||
|
user.allowed_models = None;
|
||||||
|
user.allowed_models_mode = "unrestricted".to_string();
|
||||||
|
let access_token = build_test_auth_token(
|
||||||
|
"access",
|
||||||
|
serde_json::Map::from_iter([
|
||||||
|
("user_id".to_string(), json!(user.id)),
|
||||||
|
("role".to_string(), json!(user.role)),
|
||||||
|
(
|
||||||
|
"created_at".to_string(),
|
||||||
|
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session_id".to_string(),
|
||||||
|
json!("session-users-me-deny-all-models"),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
now + chrono::Duration::hours(1),
|
||||||
|
);
|
||||||
|
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
|
||||||
|
sample_public_global_model("gm-1", "gpt-5", "GPT 5", true),
|
||||||
|
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
|
||||||
|
]));
|
||||||
|
let user_repository: Arc<dyn UserReadRepository> =
|
||||||
|
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||||
|
let group = user_repository
|
||||||
|
.create_user_group(UpsertUserGroupRecord {
|
||||||
|
name: "No models".to_string(),
|
||||||
|
description: None,
|
||||||
|
priority: 0,
|
||||||
|
allowed_providers: None,
|
||||||
|
allowed_providers_mode: "unrestricted".to_string(),
|
||||||
|
allowed_api_formats: None,
|
||||||
|
allowed_api_formats_mode: "unrestricted".to_string(),
|
||||||
|
allowed_models: None,
|
||||||
|
allowed_models_mode: "deny_all".to_string(),
|
||||||
|
rate_limit: None,
|
||||||
|
rate_limit_mode: "system".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("group should create")
|
||||||
|
.expect("group should exist");
|
||||||
|
user_repository
|
||||||
|
.add_user_to_group(&group.id, "user-auth-1")
|
||||||
|
.await
|
||||||
|
.expect("group membership should create");
|
||||||
|
|
||||||
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
|
start_auth_gateway_with_builder(|| {
|
||||||
|
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
|
||||||
|
global_model_repository,
|
||||||
|
)
|
||||||
|
.with_user_reader(Arc::clone(&user_repository));
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(data_state)
|
||||||
|
.with_auth_sessions_for_tests([sample_auth_session(
|
||||||
|
"user-auth-1",
|
||||||
|
"session-users-me-deny-all-models",
|
||||||
|
"device-users-me-deny-all-models",
|
||||||
|
"refresh-token-placeholder",
|
||||||
|
now,
|
||||||
|
)])
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(format!("{gateway_url}/api/users/me/available-models"))
|
||||||
|
.header("authorization", format!("Bearer {access_token}"))
|
||||||
|
.header("x-client-device-id", "device-users-me-deny-all-models")
|
||||||
|
.header("user-agent", "AetherTest/1.0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["total"], 0);
|
||||||
|
assert_eq!(payload["models"].as_array().map(Vec::len), Some(0));
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_returns_service_unavailable_for_users_me_available_models_without_provider_catalog(
|
async fn gateway_returns_service_unavailable_for_users_me_available_models_without_provider_catalog(
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -7,6 +7,40 @@ use super::{
|
|||||||
StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot, StoredUserAuthRecord,
|
StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot, StoredUserAuthRecord,
|
||||||
StoredUserExportRow, Utc,
|
StoredUserExportRow, Utc,
|
||||||
};
|
};
|
||||||
|
use aether_data_contracts::repository::billing::{
|
||||||
|
BillingReadRepository, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
|
||||||
|
};
|
||||||
|
use aether_data_contracts::DataLayerError;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct StaticDailyQuotaBillingRepository {
|
||||||
|
user_id: String,
|
||||||
|
quota: UserDailyQuotaAvailabilityRecord,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl BillingReadRepository for StaticDailyQuotaBillingRepository {
|
||||||
|
async fn find_model_context(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
provider_api_key_id: Option<&str>,
|
||||||
|
global_model_name: &str,
|
||||||
|
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||||
|
let _ = (provider_id, provider_api_key_id, global_model_name);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_user_daily_quota_availability(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
|
||||||
|
if user_id == self.user_id {
|
||||||
|
Ok(Some(self.quota.clone()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn stable_dashboard_now() -> chrono::DateTime<Utc> {
|
fn stable_dashboard_now() -> chrono::DateTime<Utc> {
|
||||||
Utc::now()
|
Utc::now()
|
||||||
@@ -173,6 +207,93 @@ async fn gateway_handles_dashboard_stats_locally_without_proxying_upstream() {
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_dashboard_stats_user_wallet_card_uses_wallet_center_balance_breakdown() {
|
||||||
|
let now = stable_dashboard_now();
|
||||||
|
let user = sample_auth_user(now);
|
||||||
|
let access_token = build_test_auth_token(
|
||||||
|
"access",
|
||||||
|
serde_json::Map::from_iter([
|
||||||
|
("user_id".to_string(), json!(user.id)),
|
||||||
|
("role".to_string(), json!(user.role)),
|
||||||
|
(
|
||||||
|
"created_at".to_string(),
|
||||||
|
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||||
|
),
|
||||||
|
("session_id".to_string(), json!("session-dashboard-wallet")),
|
||||||
|
]),
|
||||||
|
chrono::Utc::now() + chrono::Duration::hours(1),
|
||||||
|
);
|
||||||
|
let session = sample_auth_session(
|
||||||
|
"user-auth-1",
|
||||||
|
"session-dashboard-wallet",
|
||||||
|
"device-dashboard-wallet",
|
||||||
|
"refresh-dashboard-wallet",
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![]));
|
||||||
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||||
|
user.clone()
|
||||||
|
]));
|
||||||
|
let mut wallet = sample_auth_wallet("user-auth-1", now);
|
||||||
|
wallet.balance = 7.0;
|
||||||
|
wallet.gift_balance = 3.0;
|
||||||
|
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
|
||||||
|
let billing_repository: Arc<dyn BillingReadRepository> =
|
||||||
|
Arc::new(StaticDailyQuotaBillingRepository {
|
||||||
|
user_id: "user-auth-1".to_string(),
|
||||||
|
quota: UserDailyQuotaAvailabilityRecord {
|
||||||
|
has_active_daily_quota: true,
|
||||||
|
total_quota_usd: 120.0,
|
||||||
|
used_usd: 20.0,
|
||||||
|
remaining_usd: 100.0,
|
||||||
|
allow_wallet_overage: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(Vec::<(
|
||||||
|
Option<String>,
|
||||||
|
StoredAuthApiKeySnapshot,
|
||||||
|
)>::new()));
|
||||||
|
|
||||||
|
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||||
|
start_auth_gateway_with_builder(|| {
|
||||||
|
let data_state = GatewayDataState::with_usage_billing_and_wallet_for_tests(
|
||||||
|
usage_repository,
|
||||||
|
Arc::clone(&billing_repository),
|
||||||
|
wallet_repository,
|
||||||
|
)
|
||||||
|
.with_user_reader(user_repository)
|
||||||
|
.with_auth_api_key_reader(auth_repository);
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(data_state)
|
||||||
|
.with_auth_sessions_for_tests([session])
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(format!("{gateway_url}/api/dashboard/stats?days=30"))
|
||||||
|
.header("authorization", format!("Bearer {access_token}"))
|
||||||
|
.header("x-client-device-id", "device-dashboard-wallet")
|
||||||
|
.header("user-agent", "AetherTest/1.0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["stats"][2]["name"], "钱包余额");
|
||||||
|
assert_eq!(payload["stats"][2]["value"], "$110.00");
|
||||||
|
assert_eq!(
|
||||||
|
payload["stats"][2]["subValue"],
|
||||||
|
"套餐额度 $100.00 · 钱包余额 $10.00"
|
||||||
|
);
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_dashboard_stats_include_end_of_day_boundary() {
|
async fn gateway_dashboard_stats_include_end_of_day_boundary() {
|
||||||
let now = stable_dashboard_now();
|
let now = stable_dashboard_now();
|
||||||
@@ -1071,16 +1192,9 @@ async fn gateway_handles_dashboard_provider_status_locally_without_proxying_upst
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
let providers = payload["providers"].as_array().expect("array");
|
assert_eq!(payload["detail"], "仅管理员可查看供应商状态");
|
||||||
assert_eq!(providers.len(), 3);
|
|
||||||
assert_eq!(providers[0]["name"], "openai");
|
|
||||||
assert_eq!(providers[0]["requests"], 2);
|
|
||||||
assert_eq!(providers[1]["name"], "claude");
|
|
||||||
assert_eq!(providers[1]["requests"], 1);
|
|
||||||
assert_eq!(providers[2]["name"], "gemini");
|
|
||||||
assert_eq!(providers[2]["requests"], 0);
|
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
|
|||||||
@@ -314,6 +314,133 @@ pub fn parse_codex_wham_usage_response(
|
|||||||
Some(serde_json::Value::Object(result))
|
Some(serde_json::Value::Object(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn codex_json_object<'a>(
|
||||||
|
root: &'a serde_json::Map<String, serde_json::Value>,
|
||||||
|
keys: &[&str],
|
||||||
|
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| root.get(*key).and_then(serde_json::Value::as_object))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_json_string_from_object(
|
||||||
|
object: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||||
|
keys: &[&str],
|
||||||
|
) -> Option<String> {
|
||||||
|
let object = object?;
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| coerce_json_string(object.get(*key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_json_string_from_root(
|
||||||
|
root: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
keys: &[&str],
|
||||||
|
) -> Option<String> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| coerce_json_string(root.get(*key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_backend_me_account_object(
|
||||||
|
root: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||||
|
codex_json_object(root, &["account", "current_account", "selected_account"])
|
||||||
|
.or_else(|| {
|
||||||
|
root.get("accounts")
|
||||||
|
.and_then(serde_json::Value::as_array)?
|
||||||
|
.iter()
|
||||||
|
.filter_map(serde_json::Value::as_object)
|
||||||
|
.find(|account| {
|
||||||
|
account
|
||||||
|
.get("is_default")
|
||||||
|
.or_else(|| account.get("selected"))
|
||||||
|
.or_else(|| account.get("current"))
|
||||||
|
.and_then(coerce_json_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
root.get("accounts")
|
||||||
|
.and_then(serde_json::Value::as_array)?
|
||||||
|
.iter()
|
||||||
|
.find_map(serde_json::Value::as_object)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_backend_me_plan_object<'a>(
|
||||||
|
root: &'a serde_json::Map<String, serde_json::Value>,
|
||||||
|
account: Option<&'a serde_json::Map<String, serde_json::Value>>,
|
||||||
|
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
|
||||||
|
codex_json_object(root, &["plan", "subscription", "workspace_plan"]).or_else(|| {
|
||||||
|
account
|
||||||
|
.and_then(|account| account.get("plan"))
|
||||||
|
.and_then(serde_json::Value::as_object)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_codex_backend_me_response(
|
||||||
|
value: &serde_json::Value,
|
||||||
|
updated_at_unix_secs: u64,
|
||||||
|
) -> Option<serde_json::Value> {
|
||||||
|
let root = value.as_object()?;
|
||||||
|
if root.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = codex_json_object(root, &["user", "auth_user", "profile"]);
|
||||||
|
let account = codex_backend_me_account_object(root);
|
||||||
|
let plan = codex_backend_me_plan_object(root, account);
|
||||||
|
let mut result = serde_json::Map::new();
|
||||||
|
|
||||||
|
if let Some(user_id) = codex_json_string_from_object(user, &["id", "user_id"])
|
||||||
|
.or_else(|| codex_json_string_from_root(root, &["user_id"]))
|
||||||
|
{
|
||||||
|
result.insert("user_id".to_string(), json!(user_id));
|
||||||
|
}
|
||||||
|
if let Some(email) = codex_json_string_from_object(user, &["email"])
|
||||||
|
.or_else(|| codex_json_string_from_root(root, &["email"]))
|
||||||
|
{
|
||||||
|
result.insert("email".to_string(), json!(email));
|
||||||
|
}
|
||||||
|
if let Some(name) = codex_json_string_from_object(user, &["name", "display_name", "full_name"])
|
||||||
|
.or_else(|| codex_json_string_from_root(root, &["name", "display_name", "full_name"]))
|
||||||
|
{
|
||||||
|
result.insert("user_name".to_string(), json!(name));
|
||||||
|
}
|
||||||
|
if let Some(account_id) =
|
||||||
|
codex_json_string_from_object(account, &["id", "account_id", "accountId", "workspace_id"])
|
||||||
|
.or_else(|| {
|
||||||
|
codex_json_string_from_root(root, &["account_id", "accountId", "workspace_id"])
|
||||||
|
})
|
||||||
|
{
|
||||||
|
result.insert("account_id".to_string(), json!(account_id));
|
||||||
|
}
|
||||||
|
if let Some(account_name) =
|
||||||
|
codex_json_string_from_object(account, &["name", "title", "display_name"])
|
||||||
|
{
|
||||||
|
result.insert("account_name".to_string(), json!(account_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan_type = codex_json_string_from_object(
|
||||||
|
account,
|
||||||
|
&["plan_type", "planType", "subscription_plan", "tier"],
|
||||||
|
)
|
||||||
|
.or_else(|| codex_json_string_from_object(plan, &["type", "plan_type", "name", "tier"]))
|
||||||
|
.or_else(|| codex_json_string_from_root(root, &["plan_type", "planType"]));
|
||||||
|
if let Some(plan_type) = normalize_codex_plan_type(plan_type.as_deref()) {
|
||||||
|
result.insert("plan_type".to_string(), json!(plan_type));
|
||||||
|
}
|
||||||
|
if let Some(plan_title) =
|
||||||
|
codex_json_string_from_object(plan, &["title", "display_name", "label"])
|
||||||
|
{
|
||||||
|
result.insert("plan_title".to_string(), json!(plan_title));
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
result.insert("updated_at".to_string(), json!(updated_at_unix_secs));
|
||||||
|
Some(serde_json::Value::Object(result))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_codex_usage_headers(
|
pub fn parse_codex_usage_headers(
|
||||||
headers: &BTreeMap<String, String>,
|
headers: &BTreeMap<String, String>,
|
||||||
updated_at_unix_secs: u64,
|
updated_at_unix_secs: u64,
|
||||||
@@ -512,6 +639,14 @@ pub fn codex_structured_invalid_reason(status_code: u16, upstream_message: Optio
|
|||||||
};
|
};
|
||||||
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}");
|
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}");
|
||||||
}
|
}
|
||||||
|
if status_code == 402 {
|
||||||
|
let detail = if message.is_empty() {
|
||||||
|
"Codex 账户需要付款 (402)"
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
};
|
||||||
|
return format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}");
|
||||||
|
}
|
||||||
message.to_string()
|
message.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,9 +656,7 @@ pub fn codex_runtime_invalid_reason(
|
|||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
match status_code {
|
match status_code {
|
||||||
401 => Some(codex_structured_invalid_reason(401, upstream_message)),
|
401 => Some(codex_structured_invalid_reason(401, upstream_message)),
|
||||||
402 if codex_looks_like_workspace_deactivated(upstream_message) => {
|
402 => Some(codex_structured_invalid_reason(402, upstream_message)),
|
||||||
Some(codex_structured_invalid_reason(402, upstream_message))
|
|
||||||
}
|
|
||||||
403 if codex_looks_like_token_invalidated(upstream_message)
|
403 if codex_looks_like_token_invalidated(upstream_message)
|
||||||
|| codex_looks_like_account_deactivated(upstream_message) =>
|
|| codex_looks_like_account_deactivated(upstream_message) =>
|
||||||
{
|
{
|
||||||
@@ -913,9 +1046,9 @@ pub fn parse_chatgpt_web_conversation_init_response(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
codex_build_invalid_state, codex_runtime_invalid_reason,
|
codex_build_invalid_state, codex_runtime_invalid_reason,
|
||||||
parse_chatgpt_web_conversation_init_response, parse_codex_wham_usage_response,
|
parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response,
|
||||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
parse_codex_wham_usage_response, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
|
||||||
OAUTH_REQUEST_FAILED_PREFIX,
|
OAUTH_REFRESH_FAILED_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -938,6 +1071,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_runtime_invalid_reason_marks_402_as_account_blocked() {
|
||||||
|
assert_eq!(
|
||||||
|
codex_runtime_invalid_reason(402, Some("payment required")),
|
||||||
|
Some(format!("{OAUTH_ACCOUNT_BLOCK_PREFIX}payment required"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codex_runtime_invalid_reason_ignores_generic_403() {
|
fn codex_runtime_invalid_reason_ignores_generic_403() {
|
||||||
assert_eq!(codex_runtime_invalid_reason(403, Some("forbidden")), None);
|
assert_eq!(codex_runtime_invalid_reason(403, Some("forbidden")), None);
|
||||||
@@ -1067,6 +1208,40 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_codex_backend_me_identity_metadata_without_quota_windows() {
|
||||||
|
let parsed = parse_codex_backend_me_response(
|
||||||
|
&json!({
|
||||||
|
"user": {
|
||||||
|
"id": "user-codex-123",
|
||||||
|
"email": "codex@example.com",
|
||||||
|
"name": "Codex User"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"id": "acct-codex-123",
|
||||||
|
"name": "Personal",
|
||||||
|
"plan_type": "plus"
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"type": "Plus",
|
||||||
|
"title": "ChatGPT Plus"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
1_777_000_000,
|
||||||
|
)
|
||||||
|
.expect("codex backend me should parse");
|
||||||
|
|
||||||
|
assert_eq!(parsed.get("user_id"), Some(&json!("user-codex-123")));
|
||||||
|
assert_eq!(parsed.get("email"), Some(&json!("codex@example.com")));
|
||||||
|
assert_eq!(parsed.get("account_id"), Some(&json!("acct-codex-123")));
|
||||||
|
assert_eq!(parsed.get("account_name"), Some(&json!("Personal")));
|
||||||
|
assert_eq!(parsed.get("plan_type"), Some(&json!("plus")));
|
||||||
|
assert_eq!(parsed.get("plan_title"), Some(&json!("ChatGPT Plus")));
|
||||||
|
assert_eq!(parsed.get("updated_at"), Some(&json!(1_777_000_000u64)));
|
||||||
|
assert!(parsed.get("primary_used_percent").is_none());
|
||||||
|
assert!(parsed.get("secondary_used_percent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_chatgpt_web_image_quota_from_conversation_init() {
|
fn parses_chatgpt_web_image_quota_from_conversation_init() {
|
||||||
let parsed = parse_chatgpt_web_conversation_init_response(
|
let parsed = parse_chatgpt_web_conversation_init_response(
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ struct AdminApiFormatDefinition {
|
|||||||
|
|
||||||
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
|
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
|
||||||
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
|
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
|
||||||
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &["smtp_password"];
|
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &["smtp_password", "turnstile_secret_key"];
|
||||||
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
||||||
AdminApiFormatDefinition {
|
AdminApiFormatDefinition {
|
||||||
value: "openai:chat",
|
value: "openai:chat",
|
||||||
@@ -1492,6 +1492,9 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
|
|||||||
"auto_delete_expired_keys" => Some(json!(false)),
|
"auto_delete_expired_keys" => Some(json!(false)),
|
||||||
"email_suffix_mode" => Some(json!("none")),
|
"email_suffix_mode" => Some(json!("none")),
|
||||||
"email_suffix_list" => Some(json!([])),
|
"email_suffix_list" => Some(json!([])),
|
||||||
|
"turnstile_enabled" => Some(json!(false)),
|
||||||
|
"turnstile_site_key" => Some(json!("")),
|
||||||
|
"turnstile_allowed_hostnames" => Some(json!([])),
|
||||||
"enable_format_conversion" => Some(json!(false)),
|
"enable_format_conversion" => Some(json!(false)),
|
||||||
"enable_model_directives" => Some(json!(false)),
|
"enable_model_directives" => Some(json!(false)),
|
||||||
"model_directives" => Some(json!({
|
"model_directives" => Some(json!({
|
||||||
@@ -2821,6 +2824,7 @@ mod tests {
|
|||||||
fn sensitive_admin_system_config_keys_are_case_insensitive() {
|
fn sensitive_admin_system_config_keys_are_case_insensitive() {
|
||||||
assert!(is_sensitive_admin_system_config_key("smtp_password"));
|
assert!(is_sensitive_admin_system_config_key("smtp_password"));
|
||||||
assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD"));
|
assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD"));
|
||||||
|
assert!(is_sensitive_admin_system_config_key("turnstile_secret_key"));
|
||||||
assert!(!is_sensitive_admin_system_config_key("site_name"));
|
assert!(!is_sensitive_admin_system_config_key("site_name"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ pub use providers::{
|
|||||||
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
|
||||||
DefaultProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
|
DefaultProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
|
||||||
UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
|
UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
|
||||||
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL,
|
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_BACKEND_ME_URL,
|
||||||
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
|
CODEX_WHAM_USAGE_URL, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
|
||||||
};
|
};
|
||||||
pub use quota::{
|
pub use quota::{
|
||||||
provider_pool_key_account_quota_exhausted, provider_pool_key_scheduling_label,
|
provider_pool_key_account_quota_exhausted, provider_pool_key_scheduling_label,
|
||||||
@@ -118,6 +118,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_quota_request_uses_backend_me_probe_endpoint() {
|
||||||
|
let spec = build_codex_pool_quota_request(
|
||||||
|
"key-1",
|
||||||
|
Some(("authorization".to_string(), "Bearer access".to_string())),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("spec should build");
|
||||||
|
|
||||||
|
assert_eq!(spec.method, "GET");
|
||||||
|
assert_eq!(spec.url, "https://chatgpt.com/backend-api/me");
|
||||||
|
assert_eq!(
|
||||||
|
spec.headers.get("authorization").map(String::as_str),
|
||||||
|
Some("Bearer access")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spec.headers.get("accept").map(String::as_str),
|
||||||
|
Some("application/json")
|
||||||
|
);
|
||||||
|
assert_eq!(spec.model_name.as_deref(), Some("codex-backend-me"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codex_quota_request_skips_account_header_for_free_accounts() {
|
fn codex_quota_request_skips_account_header_for_free_accounts() {
|
||||||
let spec = build_codex_pool_quota_request(
|
let spec = build_codex_pool_quota_request(
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ use crate::quota::{
|
|||||||
};
|
};
|
||||||
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
|
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
|
||||||
|
|
||||||
pub const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
|
pub const CODEX_BACKEND_ME_URL: &str = "https://chatgpt.com/backend-api/me";
|
||||||
|
pub const CODEX_WHAM_USAGE_URL: &str = CODEX_BACKEND_ME_URL;
|
||||||
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
|
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -110,13 +111,13 @@ pub fn build_codex_pool_quota_request(
|
|||||||
provider_name: "codex".to_string(),
|
provider_name: "codex".to_string(),
|
||||||
quota_kind: "codex".to_string(),
|
quota_kind: "codex".to_string(),
|
||||||
method: "GET".to_string(),
|
method: "GET".to_string(),
|
||||||
url: CODEX_WHAM_USAGE_URL.to_string(),
|
url: CODEX_BACKEND_ME_URL.to_string(),
|
||||||
headers,
|
headers,
|
||||||
content_type: None,
|
content_type: None,
|
||||||
json_body: None,
|
json_body: None,
|
||||||
client_api_format: "openai:responses".to_string(),
|
client_api_format: "openai:responses".to_string(),
|
||||||
provider_api_format: "openai:responses".to_string(),
|
provider_api_format: "openai:responses".to_string(),
|
||||||
model_name: Some("codex-wham-usage".to_string()),
|
model_name: Some("codex-backend-me".to_string()),
|
||||||
accept_invalid_certs: false,
|
accept_invalid_certs: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub use chatgpt_web::{
|
|||||||
CHATGPT_WEB_DEFAULT_BASE_URL,
|
CHATGPT_WEB_DEFAULT_BASE_URL,
|
||||||
};
|
};
|
||||||
pub use codex::CodexProviderPoolAdapter;
|
pub use codex::CodexProviderPoolAdapter;
|
||||||
pub use codex::{build_codex_pool_quota_request, CODEX_WHAM_USAGE_URL};
|
pub use codex::{build_codex_pool_quota_request, CODEX_BACKEND_ME_URL, CODEX_WHAM_USAGE_URL};
|
||||||
pub use default::DefaultProviderPoolAdapter;
|
pub use default::DefaultProviderPoolAdapter;
|
||||||
pub use kiro::KiroProviderPoolAdapter;
|
pub use kiro::KiroProviderPoolAdapter;
|
||||||
pub use kiro::{
|
pub use kiro::{
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface UserStats {
|
|||||||
|
|
||||||
export interface SendVerificationCodeRequest {
|
export interface SendVerificationCodeRequest {
|
||||||
email: string
|
email: string
|
||||||
|
turnstile_token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendVerificationCodeResponse {
|
export interface SendVerificationCodeResponse {
|
||||||
@@ -67,6 +68,7 @@ export interface RegisterRequest {
|
|||||||
email?: string
|
email?: string
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
|
turnstile_token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterResponse {
|
export interface RegisterResponse {
|
||||||
@@ -81,6 +83,8 @@ export interface RegistrationSettingsResponse {
|
|||||||
require_email_verification: boolean
|
require_email_verification: boolean
|
||||||
email_configured: boolean
|
email_configured: boolean
|
||||||
password_policy_level: string
|
password_policy_level: string
|
||||||
|
turnstile_enabled?: boolean
|
||||||
|
turnstile_site_key?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSettingsResponse {
|
export interface AuthSettingsResponse {
|
||||||
@@ -153,10 +157,17 @@ export const authApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendVerificationCode(email: string): Promise<SendVerificationCodeResponse> {
|
async sendVerificationCode(
|
||||||
|
email: string,
|
||||||
|
turnstileToken?: string
|
||||||
|
): Promise<SendVerificationCodeResponse> {
|
||||||
|
const payload: SendVerificationCodeRequest = { email }
|
||||||
|
if (turnstileToken) {
|
||||||
|
payload.turnstile_token = turnstileToken
|
||||||
|
}
|
||||||
const response = await apiClient.post<SendVerificationCodeResponse>(
|
const response = await apiClient.post<SendVerificationCodeResponse>(
|
||||||
'/api/auth/send-verification-code',
|
'/api/auth/send-verification-code',
|
||||||
{ email }
|
payload
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface UserPreferences {
|
|||||||
avatar_url?: string
|
avatar_url?: string
|
||||||
bio?: string
|
bio?: string
|
||||||
default_provider_id?: string // UUID
|
default_provider_id?: string // UUID
|
||||||
default_provider?: Record<string, unknown>
|
default_provider?: Record<string, unknown> | string | null // 仅管理员可见
|
||||||
theme: string
|
theme: string
|
||||||
language: string
|
language: string
|
||||||
timezone?: string
|
timezone?: string
|
||||||
@@ -52,7 +52,7 @@ export interface ProviderConfig {
|
|||||||
// 使用记录接口
|
// 使用记录接口
|
||||||
export interface UsageRecordDetail {
|
export interface UsageRecordDetail {
|
||||||
id: string
|
id: string
|
||||||
provider: string
|
provider?: string // 仅管理员可见
|
||||||
model: string
|
model: string
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
effective_input_tokens?: number
|
effective_input_tokens?: number
|
||||||
|
|||||||
34
frontend/src/composables/__tests__/useSiteInfo.spec.ts
Normal file
34
frontend/src/composables/__tests__/useSiteInfo.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const apiClientMocks = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
default: apiClientMocks,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useSiteInfo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
apiClientMocks.get.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads github link display setting from public site info', async () => {
|
||||||
|
apiClientMocks.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
site_name: 'Custom Aether',
|
||||||
|
site_subtitle: 'Gateway',
|
||||||
|
show_github_link: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { useSiteInfo } = await import('../useSiteInfo')
|
||||||
|
const { siteName, siteSubtitle, showGithubLink, refreshSiteInfo } = useSiteInfo()
|
||||||
|
await refreshSiteInfo()
|
||||||
|
|
||||||
|
expect(siteName.value).toBe('Custom Aether')
|
||||||
|
expect(siteSubtitle.value).toBe('Gateway')
|
||||||
|
expect(showGithubLink.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,11 +4,13 @@ import apiClient from '@/api/client'
|
|||||||
interface SiteInfo {
|
interface SiteInfo {
|
||||||
site_name: string
|
site_name: string
|
||||||
site_subtitle: string
|
site_subtitle: string
|
||||||
|
show_github_link?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模块级缓存,所有组件共享同一份数据
|
// 模块级缓存,所有组件共享同一份数据
|
||||||
const siteName = ref('Aether')
|
const siteName = ref('Aether')
|
||||||
const siteSubtitle = ref('AI Gateway')
|
const siteSubtitle = ref('AI Gateway')
|
||||||
|
const showGithubLink = ref(true)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
let fetchPromise: Promise<void> | null = null
|
let fetchPromise: Promise<void> | null = null
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ async function fetchSiteInfo() {
|
|||||||
const response = await apiClient.get<SiteInfo>('/api/public/site-info')
|
const response = await apiClient.get<SiteInfo>('/api/public/site-info')
|
||||||
siteName.value = response.data.site_name
|
siteName.value = response.data.site_name
|
||||||
siteSubtitle.value = response.data.site_subtitle
|
siteSubtitle.value = response.data.site_subtitle
|
||||||
|
showGithubLink.value = response.data.show_github_link !== false
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
} catch {
|
} catch {
|
||||||
// 加载失败时保持默认值,允许后续重试
|
// 加载失败时保持默认值,允许后续重试
|
||||||
@@ -35,7 +38,7 @@ export function useSiteInfo() {
|
|||||||
if (!loaded.value && !fetchPromise) {
|
if (!loaded.value && !fetchPromise) {
|
||||||
fetchPromise = fetchSiteInfo()
|
fetchPromise = fetchSiteInfo()
|
||||||
}
|
}
|
||||||
return { siteName, siteSubtitle, refreshSiteInfo }
|
return { siteName, siteSubtitle, showGithubLink, refreshSiteInfo }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 站点名称变化时同步更新 document.title
|
// 站点名称变化时同步更新 document.title
|
||||||
|
|||||||
@@ -227,6 +227,8 @@
|
|||||||
:require-email-verification="requireEmailVerification"
|
:require-email-verification="requireEmailVerification"
|
||||||
:email-configured="emailConfigured"
|
:email-configured="emailConfigured"
|
||||||
:password-policy-level="passwordPolicyLevel"
|
:password-policy-level="passwordPolicyLevel"
|
||||||
|
:turnstile-enabled="turnstileEnabled"
|
||||||
|
:turnstile-site-key="turnstileSiteKey"
|
||||||
@success="handleRegisterSuccess"
|
@success="handleRegisterSuccess"
|
||||||
@switch-to-login="handleSwitchToLogin"
|
@switch-to-login="handleSwitchToLogin"
|
||||||
/>
|
/>
|
||||||
@@ -270,6 +272,8 @@ const showRegisterDialog = ref(false)
|
|||||||
const requireEmailVerification = ref(false)
|
const requireEmailVerification = ref(false)
|
||||||
const emailConfigured = ref(true) // 邮箱服务是否已配置
|
const emailConfigured = ref(true) // 邮箱服务是否已配置
|
||||||
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
|
const passwordPolicyLevel = ref<PasswordPolicyLevel>('weak')
|
||||||
|
const turnstileEnabled = ref(false)
|
||||||
|
const turnstileSiteKey = ref<string | null>(null)
|
||||||
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
const allowRegistration = ref(false) // 由系统配置控制,默认关闭
|
||||||
|
|
||||||
// LDAP authentication settings
|
// LDAP authentication settings
|
||||||
@@ -388,6 +392,8 @@ onMounted(async () => {
|
|||||||
requireEmailVerification.value = !!regSettings.require_email_verification
|
requireEmailVerification.value = !!regSettings.require_email_verification
|
||||||
emailConfigured.value = !!regSettings.email_configured
|
emailConfigured.value = !!regSettings.email_configured
|
||||||
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
|
passwordPolicyLevel.value = normalizePasswordPolicyLevel(regSettings.password_policy_level)
|
||||||
|
turnstileEnabled.value = !!regSettings.turnstile_enabled
|
||||||
|
turnstileSiteKey.value = regSettings.turnstile_site_key || null
|
||||||
|
|
||||||
localEnabled.value = authSettings.local_enabled
|
localEnabled.value = authSettings.local_enabled
|
||||||
ldapEnabled.value = authSettings.ldap_enabled
|
ldapEnabled.value = authSettings.ldap_enabled
|
||||||
@@ -413,6 +419,8 @@ onMounted(async () => {
|
|||||||
requireEmailVerification.value = false
|
requireEmailVerification.value = false
|
||||||
emailConfigured.value = false
|
emailConfigured.value = false
|
||||||
passwordPolicyLevel.value = 'weak'
|
passwordPolicyLevel.value = 'weak'
|
||||||
|
turnstileEnabled.value = false
|
||||||
|
turnstileSiteKey.value = null
|
||||||
localEnabled.value = true
|
localEnabled.value = true
|
||||||
ldapEnabled.value = false
|
ldapEnabled.value = false
|
||||||
ldapExclusive.value = false
|
ldapExclusive.value = false
|
||||||
|
|||||||
@@ -55,6 +55,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="turnstileRequired && (!requireEmailVerification || !emailVerified)"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<Label>人机验证 <span class="text-destructive">*</span></Label>
|
||||||
|
<TurnstileWidget
|
||||||
|
ref="turnstileWidgetRef"
|
||||||
|
v-model="turnstileToken"
|
||||||
|
:site-key="turnstileSiteKey"
|
||||||
|
:disabled="isLoading || isSendingCode"
|
||||||
|
@error="handleTurnstileError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Verification Code Section (仅当需要邮箱验证时显示) -->
|
<!-- Verification Code Section (仅当需要邮箱验证时显示) -->
|
||||||
<div
|
<div
|
||||||
v-if="emailConfigured && requireEmailVerification"
|
v-if="emailConfigured && requireEmailVerification"
|
||||||
@@ -232,7 +246,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||||
import { authApi } from '@/api/auth'
|
import { authApi, type RegisterRequest } from '@/api/auth'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
@@ -245,12 +259,15 @@ import { Dialog } from '@/components/ui'
|
|||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
import Label from '@/components/ui/label.vue'
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import TurnstileWidget from './TurnstileWidget.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean
|
open?: boolean
|
||||||
requireEmailVerification?: boolean
|
requireEmailVerification?: boolean
|
||||||
emailConfigured?: boolean
|
emailConfigured?: boolean
|
||||||
passwordPolicyLevel?: PasswordPolicyLevel
|
passwordPolicyLevel?: PasswordPolicyLevel
|
||||||
|
turnstileEnabled?: boolean
|
||||||
|
turnstileSiteKey?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
@@ -263,7 +280,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
open: false,
|
open: false,
|
||||||
requireEmailVerification: false,
|
requireEmailVerification: false,
|
||||||
emailConfigured: true,
|
emailConfigured: true,
|
||||||
passwordPolicyLevel: 'weak'
|
passwordPolicyLevel: 'weak',
|
||||||
|
turnstileEnabled: false,
|
||||||
|
turnstileSiteKey: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
@@ -379,11 +398,26 @@ const codeSentAt = ref<number | null>(null)
|
|||||||
const cooldownSeconds = ref(0)
|
const cooldownSeconds = ref(0)
|
||||||
const expireMinutes = ref(5)
|
const expireMinutes = ref(5)
|
||||||
const cooldownTimer = ref<number | null>(null)
|
const cooldownTimer = ref<number | null>(null)
|
||||||
|
const turnstileToken = ref('')
|
||||||
|
const turnstileWidgetRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||||
|
|
||||||
|
const turnstileSiteKey = computed(() => props.turnstileSiteKey || '')
|
||||||
|
const turnstileRequired = computed(() => !!props.turnstileEnabled && !!turnstileSiteKey.value)
|
||||||
|
|
||||||
|
const resetTurnstile = () => {
|
||||||
|
turnstileToken.value = ''
|
||||||
|
turnstileWidgetRef.value?.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTurnstileError = (message: string) => {
|
||||||
|
showError(message, '人机验证失败')
|
||||||
|
}
|
||||||
|
|
||||||
// Send code cooldown timer
|
// Send code cooldown timer
|
||||||
const canSendCode = computed(() => {
|
const canSendCode = computed(() => {
|
||||||
if (!formData.value.email) return false
|
if (!formData.value.email) return false
|
||||||
if (cooldownSeconds.value > 0) return false
|
if (cooldownSeconds.value > 0) return false
|
||||||
|
if (turnstileRequired.value && !turnstileToken.value) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -391,6 +425,7 @@ const sendCodeButtonText = computed(() => {
|
|||||||
if (isSendingCode.value) return '发送中...'
|
if (isSendingCode.value) return '发送中...'
|
||||||
if (emailVerified.value) return '验证成功'
|
if (emailVerified.value) return '验证成功'
|
||||||
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
||||||
|
if (turnstileRequired.value && !turnstileToken.value) return '请先完成人机验证'
|
||||||
if (codeSentAt.value) return '重新发送验证码'
|
if (codeSentAt.value) return '重新发送验证码'
|
||||||
return '发送验证码'
|
return '发送验证码'
|
||||||
})
|
})
|
||||||
@@ -428,6 +463,8 @@ const canSubmit = computed(() => {
|
|||||||
if (!formData.value.email || !emailVerified.value) {
|
if (!formData.value.email || !emailVerified.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
} else if (turnstileRequired.value && !turnstileToken.value) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password match
|
// Check password match
|
||||||
@@ -484,6 +521,7 @@ watch(
|
|||||||
cooldownTimer.value = null
|
cooldownTimer.value = null
|
||||||
}
|
}
|
||||||
codeDigits.value = ['', '', '', '', '', '']
|
codeDigits.value = ['', '', '', '', '', '']
|
||||||
|
resetTurnstile()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除之前的定时器
|
// 清除之前的定时器
|
||||||
@@ -551,6 +589,7 @@ const resetForm = () => {
|
|||||||
isSendingCode.value = false
|
isSendingCode.value = false
|
||||||
codeSentAt.value = null
|
codeSentAt.value = null
|
||||||
cooldownSeconds.value = 0
|
cooldownSeconds.value = 0
|
||||||
|
resetTurnstile()
|
||||||
|
|
||||||
// Reset password field nonce
|
// Reset password field nonce
|
||||||
formNonce.value = createFormNonce()
|
formNonce.value = createFormNonce()
|
||||||
@@ -581,9 +620,13 @@ const handleSendCode = async () => {
|
|||||||
isSendingCode.value = true
|
isSendingCode.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authApi.sendVerificationCode(formData.value.email)
|
const response = await authApi.sendVerificationCode(
|
||||||
|
formData.value.email,
|
||||||
|
turnstileRequired.value ? turnstileToken.value : undefined
|
||||||
|
)
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
resetTurnstile()
|
||||||
codeSentAt.value = Date.now()
|
codeSentAt.value = Date.now()
|
||||||
if (response.expire_minutes) {
|
if (response.expire_minutes) {
|
||||||
expireMinutes.value = response.expire_minutes
|
expireMinutes.value = response.expire_minutes
|
||||||
@@ -599,9 +642,11 @@ const handleSendCode = async () => {
|
|||||||
codeInputRefs.value[0]?.focus()
|
codeInputRefs.value[0]?.focus()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
resetTurnstile()
|
||||||
showError(response.message || '请稍后重试', '发送失败')
|
showError(response.message || '请稍后重试', '发送失败')
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
resetTurnstile()
|
||||||
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
|
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
|
||||||
} finally {
|
} finally {
|
||||||
isSendingCode.value = false
|
isSendingCode.value = false
|
||||||
@@ -657,13 +702,17 @@ const handleSubmit = async () => {
|
|||||||
showError('请先完成邮箱验证')
|
showError('请先完成邮箱验证')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!props.requireEmailVerification && turnstileRequired.value && !turnstileToken.value) {
|
||||||
|
showError('请先完成人机验证')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
loadingText.value = '注册中...'
|
loadingText.value = '注册中...'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 构建请求数据:邮箱可选
|
// 构建请求数据:邮箱可选
|
||||||
const registerData: { email?: string; username: string; password: string } = {
|
const registerData: RegisterRequest = {
|
||||||
username: formData.value.username,
|
username: formData.value.username,
|
||||||
password: formData.value.password
|
password: formData.value.password
|
||||||
}
|
}
|
||||||
@@ -671,6 +720,9 @@ const handleSubmit = async () => {
|
|||||||
if (formData.value.email && formData.value.email.trim()) {
|
if (formData.value.email && formData.value.email.trim()) {
|
||||||
registerData.email = formData.value.email
|
registerData.email = formData.value.email
|
||||||
}
|
}
|
||||||
|
if (!props.requireEmailVerification && turnstileRequired.value) {
|
||||||
|
registerData.turnstile_token = turnstileToken.value
|
||||||
|
}
|
||||||
|
|
||||||
const response = await authApi.register(registerData)
|
const response = await authApi.register(registerData)
|
||||||
|
|
||||||
@@ -679,6 +731,7 @@ const handleSubmit = async () => {
|
|||||||
emit('success')
|
emit('success')
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
resetTurnstile()
|
||||||
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
|
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|||||||
157
frontend/src/features/auth/components/TurnstileWidget.vue
Normal file
157
frontend/src/features/auth/components/TurnstileWidget.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="min-h-[65px]"
|
||||||
|
:class="disabled ? 'pointer-events-none opacity-60' : ''"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||||
|
|
||||||
|
let loadTurnstilePromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
interface TurnstileApi {
|
||||||
|
render: (container: HTMLElement, options: Record<string, unknown>) => string
|
||||||
|
reset: (widgetId: string) => void
|
||||||
|
remove: (widgetId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: TurnstileApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
siteKey: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
modelValue: '',
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
error: [message: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const widgetId = ref<string | null>(null)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
function loadTurnstileScript(): Promise<void> {
|
||||||
|
if (window.turnstile) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (loadTurnstilePromise) {
|
||||||
|
return loadTurnstilePromise
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTurnstilePromise = new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(
|
||||||
|
`script[src="${TURNSTILE_SCRIPT_URL}"]`
|
||||||
|
)
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve(), { once: true })
|
||||||
|
existing.addEventListener('error', () => {
|
||||||
|
existing.remove()
|
||||||
|
loadTurnstilePromise = null
|
||||||
|
reject(new Error('turnstile script failed'))
|
||||||
|
}, { once: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = TURNSTILE_SCRIPT_URL
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () => {
|
||||||
|
script.remove()
|
||||||
|
loadTurnstilePromise = null
|
||||||
|
reject(new Error('turnstile script failed'))
|
||||||
|
}
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
|
||||||
|
return loadTurnstilePromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWidget() {
|
||||||
|
if (widgetId.value && window.turnstile) {
|
||||||
|
window.turnstile.remove(widgetId.value)
|
||||||
|
}
|
||||||
|
widgetId.value = null
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderWidget() {
|
||||||
|
if (!props.siteKey || !containerRef.value) return
|
||||||
|
clearWidget()
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await loadTurnstileScript()
|
||||||
|
await nextTick()
|
||||||
|
if (!window.turnstile || !containerRef.value) return
|
||||||
|
widgetId.value = window.turnstile.render(containerRef.value, {
|
||||||
|
sitekey: props.siteKey,
|
||||||
|
callback: (token: string) => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
emit('update:modelValue', token)
|
||||||
|
},
|
||||||
|
'expired-callback': () => {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
},
|
||||||
|
'error-callback': () => {
|
||||||
|
const message = '人机验证加载失败,请重试'
|
||||||
|
errorMessage.value = message
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
emit('error', message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
const message = '人机验证加载失败,请重试'
|
||||||
|
errorMessage.value = message
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
emit('error', message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
errorMessage.value = ''
|
||||||
|
if (widgetId.value && window.turnstile) {
|
||||||
|
window.turnstile.reset(widgetId.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void renderWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void renderWidget()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (widgetId.value && window.turnstile) {
|
||||||
|
window.turnstile.remove(widgetId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.siteKey, () => {
|
||||||
|
void renderWidget()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ reset })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||||
|
|
||||||
|
import RegisterDialog from '../RegisterDialog.vue'
|
||||||
|
|
||||||
|
const authApiMocks = vi.hoisted(() => ({
|
||||||
|
sendVerificationCode: vi.fn(),
|
||||||
|
getVerificationStatus: vi.fn(),
|
||||||
|
verifyEmail: vi.fn(),
|
||||||
|
register: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/auth', () => ({
|
||||||
|
authApi: authApiMocks,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/errorParser', () => ({
|
||||||
|
parseApiError: (_error: unknown, fallback: string) => fallback,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../TurnstileWidget.vue', () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'TurnstileWidgetStub',
|
||||||
|
props: {
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
siteKey: { type: String, required: true },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(_props, { emit, expose }) {
|
||||||
|
expose({ reset: vi.fn() })
|
||||||
|
return () =>
|
||||||
|
h('button', {
|
||||||
|
type: 'button',
|
||||||
|
'data-testid': 'turnstile-widget',
|
||||||
|
onClick: () => emit('update:modelValue', 'turnstile-token-123'),
|
||||||
|
}, 'Turnstile')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/ui', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return {
|
||||||
|
Dialog: defineComponent({
|
||||||
|
name: 'DialogStub',
|
||||||
|
props: { open: { type: Boolean, default: false } },
|
||||||
|
emits: ['update:open'],
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () => props.open
|
||||||
|
? h('div', [slots.default?.(), slots.footer?.()])
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/button.vue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return {
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'ButtonStub',
|
||||||
|
props: {
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
type: { type: String, default: 'button' },
|
||||||
|
},
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { attrs, emit, slots }) {
|
||||||
|
return () => h('button', {
|
||||||
|
...attrs,
|
||||||
|
type: props.type,
|
||||||
|
disabled: props.disabled,
|
||||||
|
onClick: (event: MouseEvent) => emit('click', event),
|
||||||
|
}, slots.default?.())
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/input.vue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return {
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'InputStub',
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number], default: '' },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
type: { type: String, default: 'text' },
|
||||||
|
id: { type: String, default: undefined },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { attrs, emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
...attrs,
|
||||||
|
id: props.id,
|
||||||
|
type: props.type,
|
||||||
|
disabled: props.disabled,
|
||||||
|
value: props.modelValue,
|
||||||
|
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/label.vue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return {
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'LabelStub',
|
||||||
|
setup(_props, { attrs, slots }) {
|
||||||
|
return () => h('label', attrs, slots.default?.())
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||||
|
|
||||||
|
function mountRegisterDialog() {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const app = createApp(RegisterDialog, {
|
||||||
|
open: true,
|
||||||
|
requireEmailVerification: true,
|
||||||
|
emailConfigured: true,
|
||||||
|
turnstileEnabled: true,
|
||||||
|
turnstileSiteKey: 'site-key-123',
|
||||||
|
'onUpdate:open': vi.fn(),
|
||||||
|
})
|
||||||
|
app.mount(root)
|
||||||
|
mountedApps.push({ app, root })
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let index = 0; index < 4; index += 1) {
|
||||||
|
await Promise.resolve()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
authApiMocks.sendVerificationCode.mockReset()
|
||||||
|
authApiMocks.getVerificationStatus.mockReset()
|
||||||
|
authApiMocks.verifyEmail.mockReset()
|
||||||
|
authApiMocks.register.mockReset()
|
||||||
|
authApiMocks.getVerificationStatus.mockResolvedValue({
|
||||||
|
has_pending_code: false,
|
||||||
|
is_verified: false,
|
||||||
|
cooldown_remaining: null,
|
||||||
|
code_expires_in: null,
|
||||||
|
})
|
||||||
|
authApiMocks.sendVerificationCode.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
message: 'ok',
|
||||||
|
expire_minutes: 5,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const { app, root } of mountedApps.splice(0)) {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
}
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RegisterDialog Turnstile verification flow', () => {
|
||||||
|
it('shows Turnstile before sending code and includes the token in the request', async () => {
|
||||||
|
const root = mountRegisterDialog()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(root.textContent).toContain('Turnstile')
|
||||||
|
const emailInput = root.querySelector('#reg-email') as HTMLInputElement
|
||||||
|
emailInput.value = 'alice@example.com'
|
||||||
|
emailInput.dispatchEvent(new Event('input'))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
const sendButtonBeforeToken = Array.from(root.querySelectorAll('button'))
|
||||||
|
.find((button) => button.textContent?.includes('请先完成人机验证')) as HTMLButtonElement
|
||||||
|
expect(sendButtonBeforeToken.disabled).toBe(true)
|
||||||
|
|
||||||
|
const turnstileButton = root.querySelector('[data-testid="turnstile-widget"]') as HTMLButtonElement
|
||||||
|
turnstileButton.click()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
const sendButton = Array.from(root.querySelectorAll('button'))
|
||||||
|
.find((button) => button.textContent?.includes('发送验证码')) as HTMLButtonElement
|
||||||
|
expect(sendButton.disabled).toBe(false)
|
||||||
|
sendButton.click()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(authApiMocks.sendVerificationCode).toHaveBeenCalledWith(
|
||||||
|
'alice@example.com',
|
||||||
|
'turnstile-token-123'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -325,6 +325,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- GitHub Link -->
|
<!-- GitHub Link -->
|
||||||
<a
|
<a
|
||||||
|
v-if="showGithubLink"
|
||||||
href="https://github.com/fawney19/Aether"
|
href="https://github.com/fawney19/Aether"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -412,7 +413,7 @@ const route = useRoute()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const moduleStore = useModuleStore()
|
const moduleStore = useModuleStore()
|
||||||
const { themeMode, toggleDarkMode } = useDarkMode()
|
const { themeMode, toggleDarkMode } = useDarkMode()
|
||||||
const { siteName, siteSubtitle } = useSiteInfo()
|
const { siteName, siteSubtitle, showGithubLink } = useSiteInfo()
|
||||||
const isDemo = computed(() => isDemoMode())
|
const isDemo = computed(() => isDemoMode())
|
||||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,13 @@
|
|||||||
id="section-site-info"
|
id="section-site-info"
|
||||||
:site-name="systemConfig.site_name"
|
:site-name="systemConfig.site_name"
|
||||||
:site-subtitle="systemConfig.site_subtitle"
|
:site-subtitle="systemConfig.site_subtitle"
|
||||||
|
:show-github-link="systemConfig.show_github_link"
|
||||||
:loading="siteInfoLoading"
|
:loading="siteInfoLoading"
|
||||||
:has-changes="hasSiteInfoChanges"
|
:has-changes="hasSiteInfoChanges"
|
||||||
@save="saveSiteInfo"
|
@save="saveSiteInfo"
|
||||||
@update:site-name="systemConfig.site_name = $event"
|
@update:site-name="systemConfig.site_name = $event"
|
||||||
@update:site-subtitle="systemConfig.site_subtitle = $event"
|
@update:site-subtitle="systemConfig.site_subtitle = $event"
|
||||||
|
@update:show-github-link="systemConfig.show_github_link = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 配置导出/导入 -->
|
<!-- 配置导出/导入 -->
|
||||||
|
|||||||
@@ -51,6 +51,21 @@
|
|||||||
显示在导航栏品牌名称下方
|
显示在导航栏品牌名称下方
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="md:col-span-2 flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||||
|
<div>
|
||||||
|
<Label class="block text-sm font-medium">
|
||||||
|
GitHub 仓库入口
|
||||||
|
</Label>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
控制首页、指南页和控制台顶部的 GitHub 链接是否展示
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
:model-value="showGithubLink"
|
||||||
|
:disabled="loading"
|
||||||
|
@update:model-value="$emit('update:showGithubLink', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
</template>
|
</template>
|
||||||
@@ -59,11 +74,13 @@
|
|||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
import Label from '@/components/ui/label.vue'
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import Switch from '@/components/ui/switch.vue'
|
||||||
import { CardSection } from '@/components/layout'
|
import { CardSection } from '@/components/layout'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
siteName: string
|
siteName: string
|
||||||
siteSubtitle: string
|
siteSubtitle: string
|
||||||
|
showGithubLink: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
hasChanges: boolean
|
hasChanges: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -72,5 +89,6 @@ defineEmits<{
|
|||||||
save: []
|
save: []
|
||||||
'update:siteName': [value: string]
|
'update:siteName': [value: string]
|
||||||
'update:siteSubtitle': [value: string]
|
'update:siteSubtitle': [value: string]
|
||||||
|
'update:showGithubLink': [value: boolean]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||||
|
|
||||||
|
import SiteInfoSection from '../SiteInfoSection.vue'
|
||||||
|
|
||||||
|
vi.mock('@/components/layout', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return {
|
||||||
|
CardSection: defineComponent({
|
||||||
|
name: 'CardSectionStub',
|
||||||
|
props: {
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () => h('section', [
|
||||||
|
h('h2', props.title),
|
||||||
|
h('p', props.description),
|
||||||
|
slots.actions?.(),
|
||||||
|
slots.default?.(),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/button.vue', () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'ButtonStub',
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('button', slots.default?.())
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||||
|
|
||||||
|
function mountSection(onUpdateShowGithubLink = vi.fn()) {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const app = createApp(SiteInfoSection, {
|
||||||
|
siteName: 'Aether',
|
||||||
|
siteSubtitle: 'AI Gateway',
|
||||||
|
showGithubLink: false,
|
||||||
|
loading: false,
|
||||||
|
hasChanges: true,
|
||||||
|
onSave: vi.fn(),
|
||||||
|
'onUpdate:siteName': vi.fn(),
|
||||||
|
'onUpdate:siteSubtitle': vi.fn(),
|
||||||
|
'onUpdate:showGithubLink': onUpdateShowGithubLink,
|
||||||
|
})
|
||||||
|
app.mount(root)
|
||||||
|
mountedApps.push({ app, root })
|
||||||
|
return { root, onUpdateShowGithubLink }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const { app, root } of mountedApps.splice(0)) {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
}
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SiteInfoSection', () => {
|
||||||
|
it('renders and emits the github link display switch', async () => {
|
||||||
|
const { root, onUpdateShowGithubLink } = mountSection()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(root.textContent).toContain('GitHub 仓库入口')
|
||||||
|
const switchButton = root.querySelector('[role="switch"]') as HTMLButtonElement | null
|
||||||
|
expect(switchButton?.getAttribute('aria-checked')).toBe('false')
|
||||||
|
|
||||||
|
switchButton?.click()
|
||||||
|
expect(onUpdateShowGithubLink).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ export interface SystemConfig {
|
|||||||
// 站点信息
|
// 站点信息
|
||||||
site_name: string
|
site_name: string
|
||||||
site_subtitle: string
|
site_subtitle: string
|
||||||
|
show_github_link: boolean
|
||||||
// 网络代理
|
// 网络代理
|
||||||
system_proxy_node_id: string | null
|
system_proxy_node_id: string | null
|
||||||
// 基础配置
|
// 基础配置
|
||||||
@@ -49,6 +50,7 @@ const CONFIG_KEYS = [
|
|||||||
// 站点信息
|
// 站点信息
|
||||||
'site_name',
|
'site_name',
|
||||||
'site_subtitle',
|
'site_subtitle',
|
||||||
|
'show_github_link',
|
||||||
// 网络代理
|
// 网络代理
|
||||||
'system_proxy_node_id',
|
'system_proxy_node_id',
|
||||||
// 基础配置
|
// 基础配置
|
||||||
@@ -91,6 +93,7 @@ function createDefaultConfig(): SystemConfig {
|
|||||||
// 站点信息
|
// 站点信息
|
||||||
site_name: 'Aether',
|
site_name: 'Aether',
|
||||||
site_subtitle: 'AI Gateway',
|
site_subtitle: 'AI Gateway',
|
||||||
|
show_github_link: true,
|
||||||
// 网络代理
|
// 网络代理
|
||||||
system_proxy_node_id: null,
|
system_proxy_node_id: null,
|
||||||
// 基础配置
|
// 基础配置
|
||||||
@@ -149,7 +152,8 @@ export function useSystemConfig() {
|
|||||||
if (!originalConfig.value) return false
|
if (!originalConfig.value) return false
|
||||||
return (
|
return (
|
||||||
systemConfig.value.site_name !== originalConfig.value.site_name ||
|
systemConfig.value.site_name !== originalConfig.value.site_name ||
|
||||||
systemConfig.value.site_subtitle !== originalConfig.value.site_subtitle
|
systemConfig.value.site_subtitle !== originalConfig.value.site_subtitle ||
|
||||||
|
systemConfig.value.show_github_link !== originalConfig.value.show_github_link
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -273,6 +277,11 @@ export function useSystemConfig() {
|
|||||||
value: systemConfig.value.site_subtitle,
|
value: systemConfig.value.site_subtitle,
|
||||||
description: '站点副标题',
|
description: '站点副标题',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'show_github_link',
|
||||||
|
value: systemConfig.value.show_github_link,
|
||||||
|
description: '是否显示 GitHub 仓库入口',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
configItems.map((item) =>
|
configItems.map((item) =>
|
||||||
@@ -282,6 +291,7 @@ export function useSystemConfig() {
|
|||||||
if (originalConfig.value) {
|
if (originalConfig.value) {
|
||||||
originalConfig.value.site_name = systemConfig.value.site_name
|
originalConfig.value.site_name = systemConfig.value.site_name
|
||||||
originalConfig.value.site_subtitle = systemConfig.value.site_subtitle
|
originalConfig.value.site_subtitle = systemConfig.value.site_subtitle
|
||||||
|
originalConfig.value.show_github_link = systemConfig.value.show_github_link
|
||||||
}
|
}
|
||||||
await refreshSiteInfo()
|
await refreshSiteInfo()
|
||||||
success('站点信息已保存')
|
success('站点信息已保存')
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
|
v-if="showGithubLink"
|
||||||
href="https://github.com/fawney19/Aether"
|
href="https://github.com/fawney19/Aether"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -89,7 +90,10 @@
|
|||||||
<!-- Desktop layout (>= md): Centered nav with balanced spacing -->
|
<!-- Desktop layout (>= md): Centered nav with balanced spacing -->
|
||||||
<div class="h-16 hidden md:flex items-center justify-between px-8">
|
<div class="h-16 hidden md:flex items-center justify-between px-8">
|
||||||
<!-- Left spacer for balance (matches right icons width) -->
|
<!-- Left spacer for balance (matches right icons width) -->
|
||||||
<div class="w-[76px] shrink-0" />
|
<div
|
||||||
|
class="shrink-0"
|
||||||
|
:class="showGithubLink ? 'w-[76px]' : 'w-9'"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Center: Logo + Nav + Login Button -->
|
<!-- Center: Logo + Nav + Login Button -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -186,6 +190,7 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
|
v-if="showGithubLink"
|
||||||
href="https://github.com/fawney19/Aether"
|
href="https://github.com/fawney19/Aether"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -495,7 +500,7 @@ import {
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { isDark, themeMode, toggleDarkMode } = useDarkMode()
|
const { isDark, themeMode, toggleDarkMode } = useDarkMode()
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
const { siteName, siteSubtitle } = useSiteInfo()
|
const { siteName, siteSubtitle, showGithubLink } = useSiteInfo()
|
||||||
|
|
||||||
const dashboardPath = computed(() =>
|
const dashboardPath = computed(() =>
|
||||||
authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||||
|
|||||||
@@ -261,6 +261,7 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
|
v-if="showGithubLink"
|
||||||
href="https://github.com/fawney19/Aether"
|
href="https://github.com/fawney19/Aether"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -340,7 +341,7 @@ import { guideNavItems } from './guide-config'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { themeMode, toggleDarkMode } = useDarkMode()
|
const { themeMode, toggleDarkMode } = useDarkMode()
|
||||||
const { siteName, siteSubtitle } = useSiteInfo()
|
const { siteName, siteSubtitle, showGithubLink } = useSiteInfo()
|
||||||
|
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
const baseUrl = ref(typeof window !== 'undefined' ? window.location.origin : 'https://your-aether.com')
|
const baseUrl = ref(typeof window !== 'undefined' ? window.location.origin : 'https://your-aether.com')
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { panelClasses } from './guide-config'
|
import { panelClasses } from './guide-config'
|
||||||
|
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||||
|
|
||||||
// 部署步骤数据
|
// 部署步骤数据
|
||||||
const activeDeployTab = ref(0)
|
const activeDeployTab = ref(0)
|
||||||
const copiedStep = ref<string | null>(null)
|
const copiedStep = ref<string | null>(null)
|
||||||
|
const { showGithubLink } = useSiteInfo()
|
||||||
|
|
||||||
const productionSteps = [
|
const productionSteps = [
|
||||||
{
|
{
|
||||||
@@ -397,6 +399,7 @@ function copyStep(stepId: string, code: string) {
|
|||||||
<h3>1. Aether-Proxy</h3>
|
<h3>1. Aether-Proxy</h3>
|
||||||
<p>Rust实现, 超小资源占有, 适合性能低的VPS直接使用。</p>
|
<p>Rust实现, 超小资源占有, 适合性能低的VPS直接使用。</p>
|
||||||
<a
|
<a
|
||||||
|
v-if="showGithubLink"
|
||||||
href="https://github.com/fawney19/Aether/tree/main/aether-proxy"
|
href="https://github.com/fawney19/Aether/tree/main/aether-proxy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|||||||
@@ -789,7 +789,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
|
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch, markRaw } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
|
import { dashboardApi, type DashboardStat, type DailyStat, type ProviderSummary } from '@/api/dashboard'
|
||||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||||
@@ -1328,7 +1328,7 @@ async function loadDashboardData() {
|
|||||||
})
|
})
|
||||||
stats.value = statsData.stats.map(stat => ({
|
stats.value = statsData.stats.map(stat => ({
|
||||||
...stat,
|
...stat,
|
||||||
icon: iconMap[stat.icon] || Activity
|
icon: markRaw(iconMap[stat.icon] || Activity)
|
||||||
}))
|
}))
|
||||||
if (statsData.today) todayStats.value = statsData.today
|
if (statsData.today) todayStats.value = statsData.today
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
|
|||||||
169
frontend/src/views/shared/__tests__/Dashboard.spec.ts
Normal file
169
frontend/src/views/shared/__tests__/Dashboard.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||||
|
|
||||||
|
import Dashboard from '../Dashboard.vue'
|
||||||
|
|
||||||
|
const dashboardApiMocks = vi.hoisted(() => ({
|
||||||
|
getStats: vi.fn(),
|
||||||
|
getDailyStats: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth', () => ({
|
||||||
|
useAuthStore: () => ({
|
||||||
|
canAccessAdmin: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isAuditAdmin: false,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/dashboard', () => ({
|
||||||
|
dashboardApi: dashboardApiMocks,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/announcements', () => ({
|
||||||
|
announcementApi: {
|
||||||
|
getAnnouncements: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
markAsRead: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/charts/BarChart.vue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return { default: defineComponent({ name: 'BarChartStub', setup: () => () => h('div') }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/charts/DoughnutChart.vue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return { default: defineComponent({ name: 'DoughnutChartStub', setup: () => () => h('div') }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/charts/LineChart.vue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return { default: defineComponent({ name: 'LineChartStub', setup: () => () => h('div') }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/common', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
return {
|
||||||
|
TimeRangePicker: defineComponent({
|
||||||
|
name: 'TimeRangePickerStub',
|
||||||
|
setup() {
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/components/ui', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
const passthrough = (name: string, tag = 'div') => defineComponent({
|
||||||
|
name,
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h(tag, slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
Card: passthrough('CardStub', 'section'),
|
||||||
|
Badge: passthrough('BadgeStub', 'span'),
|
||||||
|
Button: passthrough('ButtonStub', 'button'),
|
||||||
|
Skeleton: defineComponent({ name: 'SkeletonStub', setup: () => () => h('div') }),
|
||||||
|
Dialog: passthrough('DialogStub'),
|
||||||
|
Table: passthrough('TableStub', 'table'),
|
||||||
|
TableHeader: passthrough('TableHeaderStub', 'thead'),
|
||||||
|
TableBody: passthrough('TableBodyStub', 'tbody'),
|
||||||
|
TableRow: passthrough('TableRowStub', 'tr'),
|
||||||
|
TableHead: passthrough('TableHeadStub', 'th'),
|
||||||
|
TableCell: passthrough('TableCellStub', 'td'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('lucide-vue-next', async () => {
|
||||||
|
const Icon = defineComponent({
|
||||||
|
name: 'IconStub',
|
||||||
|
setup() {
|
||||||
|
return () => h('span')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
Users: Icon,
|
||||||
|
Activity: Icon,
|
||||||
|
TrendingUp: Icon,
|
||||||
|
DollarSign: Icon,
|
||||||
|
Key: Icon,
|
||||||
|
Hash: Icon,
|
||||||
|
Zap: Icon,
|
||||||
|
Bell: Icon,
|
||||||
|
AlertCircle: Icon,
|
||||||
|
AlertTriangle: Icon,
|
||||||
|
Info: Icon,
|
||||||
|
Wrench: Icon,
|
||||||
|
Loader2: Icon,
|
||||||
|
Clock: Icon,
|
||||||
|
Database: Icon,
|
||||||
|
Shuffle: Icon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||||
|
|
||||||
|
function mountDashboard() {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const app = createApp(Dashboard)
|
||||||
|
app.mount(root)
|
||||||
|
mountedApps.push({ app, root })
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
await Promise.resolve()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dashboardApiMocks.getStats.mockReset()
|
||||||
|
dashboardApiMocks.getDailyStats.mockReset()
|
||||||
|
dashboardApiMocks.getDailyStats.mockResolvedValue({
|
||||||
|
daily_stats: [],
|
||||||
|
model_summary: [],
|
||||||
|
period: { start_date: '2026-05-01', end_date: '2026-05-15', days: 15 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const { app, root } of mountedApps.splice(0)) {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
}
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dashboard ordinary user wallet card', () => {
|
||||||
|
it('renders package and wallet balance split from mocked stats', async () => {
|
||||||
|
dashboardApiMocks.getStats.mockResolvedValue({
|
||||||
|
stats: [
|
||||||
|
{ name: 'API 密钥', value: '0', subValue: '活跃 0', icon: 'Activity' },
|
||||||
|
{ name: '本月请求', value: '0', subValue: '今日 0', icon: 'Users' },
|
||||||
|
{
|
||||||
|
name: '钱包余额',
|
||||||
|
value: '$110.00',
|
||||||
|
subValue: '套餐额度 $100.00 · 钱包余额 $10.00',
|
||||||
|
icon: 'DollarSign',
|
||||||
|
},
|
||||||
|
{ name: '本月 Token', value: '0', subValue: '输入 0 / 输出 0', icon: 'Zap' },
|
||||||
|
],
|
||||||
|
today: { requests: 0, tokens: 0, cost: 0 },
|
||||||
|
cache_stats: { cache_creation_tokens: 0, cache_read_tokens: 0, total_cache_tokens: 0 },
|
||||||
|
token_breakdown: { input: 0, output: 0, cache_creation: 0, cache_read: 0 },
|
||||||
|
monthly_cost: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = mountDashboard()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(root.textContent).toContain('$110.00')
|
||||||
|
expect(root.textContent).toContain('套餐额度 $100.00 · 钱包余额 $10.00')
|
||||||
|
})
|
||||||
|
})
|
||||||
69
frontend/src/views/user/__tests__/ModelCatalog.spec.ts
Normal file
69
frontend/src/views/user/__tests__/ModelCatalog.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createApp, nextTick, type App } from 'vue'
|
||||||
|
|
||||||
|
import type { PublicGlobalModel } from '@/api/public-models'
|
||||||
|
import UserModelDetailDrawer from '../components/UserModelDetailDrawer.vue'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useClipboard', () => ({
|
||||||
|
useClipboard: () => ({
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||||
|
|
||||||
|
function model(overrides: Partial<PublicGlobalModel> = {}): PublicGlobalModel {
|
||||||
|
return {
|
||||||
|
id: 'gm-test',
|
||||||
|
name: 'gpt-5',
|
||||||
|
display_name: 'GPT 5',
|
||||||
|
is_active: true,
|
||||||
|
default_tiered_pricing: null,
|
||||||
|
default_price_per_request: null,
|
||||||
|
supported_capabilities: ['chat'],
|
||||||
|
config: null,
|
||||||
|
usage_count: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountDrawer(selectedModel: PublicGlobalModel) {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const app = createApp(UserModelDetailDrawer, {
|
||||||
|
open: true,
|
||||||
|
model: selectedModel,
|
||||||
|
'onUpdate:open': vi.fn(),
|
||||||
|
})
|
||||||
|
app.mount(root)
|
||||||
|
mountedApps.push({ app, root })
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const { app, root } of mountedApps.splice(0)) {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
}
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('user model catalog detail drawer', () => {
|
||||||
|
it('does not render model mapping fields for ordinary users', async () => {
|
||||||
|
mountDrawer(model({
|
||||||
|
config: {
|
||||||
|
description: 'User visible description',
|
||||||
|
model_mappings: ['gpt-5-upstream'],
|
||||||
|
provider_model_mappings: [{ name: 'provider-gpt-5' }],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const text = document.body.textContent || ''
|
||||||
|
expect(text).toContain('GPT 5')
|
||||||
|
expect(text).toContain('User visible description')
|
||||||
|
expect(text).not.toContain('模型映射')
|
||||||
|
expect(text).not.toContain('gpt-5-upstream')
|
||||||
|
expect(text).not.toContain('provider-gpt-5')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user