Redesign sensitive info protection settings

This commit is contained in:
fawney19
2026-05-14 11:14:20 +08:00
parent 91955ad1e0
commit 509bd30252
71 changed files with 3254 additions and 884 deletions

View File

@@ -9,8 +9,8 @@ use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::handlers::admin::users::{
default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
generate_admin_user_api_key_plaintext, hash_admin_user_api_key, masked_user_api_key_display,
normalize_admin_optional_api_key_name, normalize_admin_user_api_formats,
normalize_admin_user_string_list,
normalize_admin_feature_settings, normalize_admin_optional_api_key_name,
normalize_admin_user_api_formats, normalize_admin_user_string_list,
};
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
use crate::GatewayError;
@@ -137,6 +137,10 @@ pub(super) async fn build_admin_create_api_key_response(
"设置 auto_delete_on_expiry 前必须提供 expires_at",
));
}
let feature_settings = match normalize_admin_feature_settings(payload.feature_settings) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let plaintext_key = generate_admin_user_api_key_plaintext();
let Some(key_encrypted) = state.encrypt_catalog_secret_with_fallbacks(&plaintext_key) else {
@@ -180,6 +184,14 @@ pub(super) async fn build_admin_create_api_key_response(
Some(wallet) => wallet,
None => return Ok(build_admin_api_keys_data_unavailable_response()),
};
let created = if feature_settings.is_some() {
state
.set_standalone_api_key_feature_settings(&created.api_key_id, feature_settings.clone())
.await?
.unwrap_or(created)
} else {
created
};
Ok(attach_admin_audit_response(
Json(json!({
@@ -196,6 +208,7 @@ pub(super) async fn build_admin_create_api_key_response(
"allowed_models": created.allowed_models,
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
"auto_delete_on_expiry": created.auto_delete_on_expiry,
"feature_settings": created.feature_settings,
"wallet": serialize_admin_system_users_export_wallet(Some(&wallet)),
"message": "独立余额Key创建成功请妥善保存完整密钥后续将无法查看",
}))
@@ -245,6 +258,14 @@ pub(super) async fn build_admin_update_api_key_response(
let null_auto_delete_on_expiry =
patch.contains("auto_delete_on_expiry") && patch.is_null("auto_delete_on_expiry");
let (field_presence, payload) = patch.into_parts();
let feature_settings = if field_presence.contains("feature_settings") {
match normalize_admin_feature_settings(payload.feature_settings.flatten()) {
Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
}
} else {
None
};
if null_unlimited_balance {
return Ok(build_admin_api_keys_bad_request_response(
"unlimited_balance 必须是布尔值",
@@ -387,6 +408,14 @@ pub(super) async fn build_admin_update_api_key_response(
else {
return Ok(build_admin_api_keys_data_unavailable_response());
};
let updated = if let Some(feature_settings) = feature_settings {
state
.set_standalone_api_key_feature_settings(&api_key_id, feature_settings)
.await?
.unwrap_or(updated)
} else {
updated
};
if wallet.is_none() {
wallet = state

View File

@@ -10,7 +10,7 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use serde_json::{json, Value};
const ADMIN_API_KEYS_DATA_UNAVAILABLE_DETAIL: &str = "Admin standalone API key data unavailable";
@@ -27,6 +27,7 @@ pub(super) struct AdminStandaloneApiKeyCreateRequest {
pub(super) expire_days: Option<i32>,
pub(super) expires_at: Option<String>,
pub(super) auto_delete_on_expiry: Option<bool>,
pub(super) feature_settings: Option<Value>,
}
#[derive(Debug, Default, serde::Deserialize)]
@@ -42,6 +43,7 @@ pub(super) struct AdminStandaloneApiKeyUpdateRequest {
pub(super) expire_days: Option<i32>,
pub(super) expires_at: Option<String>,
pub(super) auto_delete_on_expiry: Option<bool>,
pub(super) feature_settings: Option<Option<Value>>,
}
pub(super) type AdminStandaloneApiKeyUpdatePatch =
@@ -164,6 +166,7 @@ pub(super) fn build_admin_api_key_list_item_payload(
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
"updated_at": format_optional_unix_secs_iso8601(record.updated_at_unix_secs),
"auto_delete_on_expiry": record.auto_delete_on_expiry,
"feature_settings": record.feature_settings,
"wallet": serialize_admin_system_users_export_wallet(wallet),
})
}
@@ -193,6 +196,7 @@ pub(super) fn build_admin_api_key_detail_payload(
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
"updated_at": format_optional_unix_secs_iso8601(record.updated_at_unix_secs),
"auto_delete_on_expiry": record.auto_delete_on_expiry,
"feature_settings": record.feature_settings,
"wallet": serialize_admin_system_users_export_wallet(wallet),
})
}

View File

@@ -275,6 +275,7 @@ impl<'a> AdminAppState<'a> {
"rate_limit": user.rate_limit,
"rate_limit_mode": user.rate_limit_mode.clone(),
"model_capability_settings": user.model_capability_settings.clone(),
"feature_settings": user.feature_settings.clone(),
"group_ids": group_ids,
"group_names": group_names,
"unlimited": wallet
@@ -334,6 +335,10 @@ impl<'a> AdminAppState<'a> {
"force_capabilities".to_string(),
json!(key.force_capabilities.clone()),
),
(
"feature_settings".to_string(),
json!(key.feature_settings.clone()),
),
("is_active".to_string(), json!(key.is_active)),
(
"expires_at".to_string(),

View File

@@ -10,7 +10,7 @@ use crate::handlers::admin::shared::{
};
use crate::handlers::admin::system::shared::configs::apply_admin_system_config_update;
use crate::handlers::admin::users::{
hash_admin_user_api_key, normalize_admin_list_policy_mode,
hash_admin_user_api_key, normalize_admin_feature_settings, normalize_admin_list_policy_mode,
normalize_admin_rate_limit_policy_mode, normalize_admin_user_api_formats,
normalize_admin_user_string_list,
};
@@ -2104,6 +2104,11 @@ impl<'a> AdminAppState<'a> {
user.get("model_capability_settings"),
"model_capability_settings"
));
let feature_settings = invalid_value!(imported_optional_json_object(
user.get("feature_settings"),
"feature_settings"
)
.and_then(normalize_admin_feature_settings));
let wallet_payload = match user.get("wallet") {
Some(Value::Object(map)) => Some(map),
Some(Value::Null) | None => None,
@@ -2221,6 +2226,14 @@ impl<'a> AdminAppState<'a> {
)
.await?;
}
if user.contains_key("feature_settings") {
let _ = self
.update_user_feature_settings(
&existing.id,
feature_settings.clone(),
)
.await?;
}
if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
@@ -2284,6 +2297,11 @@ impl<'a> AdminAppState<'a> {
)
.await?;
}
if user.contains_key("feature_settings") {
let _ = self
.update_user_feature_settings(&created.id, feature_settings.clone())
.await?;
}
let created = if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
@@ -2402,6 +2420,11 @@ impl<'a> AdminAppState<'a> {
"total_cost_usd"
))
.unwrap_or(0.0);
let feature_settings = invalid_value!(imported_optional_json_object(
key.get("feature_settings"),
"feature_settings"
)
.and_then(normalize_admin_feature_settings));
if let Some(existing_key) = existing_api_keys_by_hash.get(&key_hash).cloned() {
match merge_mode {
@@ -2450,6 +2473,15 @@ impl<'a> AdminAppState<'a> {
force_capabilities.clone(),
)
.await?;
if key.contains_key("feature_settings") {
let _ = self
.set_user_api_key_feature_settings(
&user_id,
&existing_key.api_key_id,
feature_settings.clone(),
)
.await?;
}
let _ = self
.set_user_api_key_active(
&user_id,
@@ -2503,6 +2535,15 @@ impl<'a> AdminAppState<'a> {
json!({ "detail": "Admin system data unavailable" }),
)));
};
if key.contains_key("feature_settings") {
let _ = self
.set_user_api_key_feature_settings(
&user_id,
&created.api_key_id,
feature_settings.clone(),
)
.await?;
}
existing_api_keys_by_hash.insert(key_hash, created);
stats.api_keys.created += 1;
}
@@ -2596,6 +2637,11 @@ impl<'a> AdminAppState<'a> {
"total_cost_usd"
))
.unwrap_or(0.0);
let feature_settings = invalid_value!(imported_optional_json_object(
key.get("feature_settings"),
"feature_settings"
)
.and_then(normalize_admin_feature_settings));
let wallet_payload = match key.get("wallet") {
Some(Value::Object(map)) => Some(map),
Some(Value::Null) | None => None,
@@ -2643,6 +2689,14 @@ impl<'a> AdminAppState<'a> {
let _ = self
.set_standalone_api_key_active(&existing_key.api_key_id, is_active)
.await?;
if key.contains_key("feature_settings") {
let _ = self
.set_standalone_api_key_feature_settings(
&existing_key.api_key_id,
feature_settings.clone(),
)
.await?;
}
if key.contains_key("expires_at")
|| key.contains_key("auto_delete_on_expiry")
|| key.contains_key("force_capabilities")
@@ -2697,6 +2751,14 @@ impl<'a> AdminAppState<'a> {
json!({ "detail": "Admin system data unavailable" }),
)));
};
if key.contains_key("feature_settings") {
let _ = self
.set_standalone_api_key_feature_settings(
&created.api_key_id,
feature_settings.clone(),
)
.await?;
}
self.sync_imported_api_key_wallet(
&created.api_key_id,
&wallet_target,

View File

@@ -421,6 +421,16 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn update_user_feature_settings(
&self,
user_id: &str,
settings: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, GatewayError> {
self.app
.update_user_feature_settings(user_id, settings)
.await
}
pub(crate) async fn count_user_pending_refunds(
&self,
user_id: &str,
@@ -619,6 +629,17 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn set_standalone_api_key_feature_settings(
&self,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.app
.set_standalone_api_key_feature_settings(api_key_id, feature_settings)
.await
}
pub(crate) async fn set_user_api_key_active(
&self,
user_id: &str,
@@ -666,6 +687,18 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn set_user_api_key_feature_settings(
&self,
user_id: &str,
api_key_id: &str,
feature_settings: Option<serde_json::Value>,
) -> Result<Option<aether_data::repository::auth::StoredAuthApiKeyExportRecord>, GatewayError>
{
self.app
.set_user_api_key_feature_settings(user_id, api_key_id, feature_settings)
.await
}
pub(crate) async fn delete_user_api_key(
&self,
user_id: &str,

View File

@@ -57,7 +57,7 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
},
AdminModuleDefinition {
name: "chat_pii_redaction",
display_name: "敏感信息替换保护",
display_name: "敏感信息保护",
description: "发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。",
category: "security",
env_key: "CHAT_PII_REDACTION_AVAILABLE",

View File

@@ -44,6 +44,7 @@ pub(super) fn build_admin_user_api_key_detail_payload(
"total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"feature_settings": record.feature_settings,
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),

View File

@@ -1,6 +1,7 @@
use super::super::super::{
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
build_admin_users_read_only_response, AdminCreateUserApiKeyRequest,
build_admin_users_read_only_response, normalize_admin_feature_settings,
AdminCreateUserApiKeyRequest,
};
use super::super::helpers::{
attach_audit_response, default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
@@ -74,6 +75,16 @@ pub(crate) async fn build_admin_create_user_api_key_response(
)
.into_response());
}
let feature_settings = match normalize_admin_feature_settings(payload.feature_settings) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response());
}
};
let name = match normalize_admin_optional_api_key_name(payload.name) {
Ok(Some(value)) => value,
@@ -161,6 +172,21 @@ pub(crate) async fn build_admin_create_user_api_key_response(
} else {
created
};
let created = if feature_settings.is_some() {
match state
.set_user_api_key_feature_settings(
&user_id,
&created.api_key_id,
feature_settings.clone(),
)
.await?
{
Some(updated) => updated,
None => created,
}
} else {
created
};
Ok(attach_audit_response(
Json(json!({
@@ -173,6 +199,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
"last_used_at": format_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(created.created_at_unix_secs),
"feature_settings": created.feature_settings,
"message": "API Key创建成功请妥善保存完整密钥",
}))
.into_response(),

View File

@@ -63,6 +63,7 @@ pub(crate) async fn build_admin_list_user_api_keys_response(
"total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"feature_settings": record.feature_settings,
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),

View File

@@ -1,6 +1,6 @@
use super::super::super::{
build_admin_users_bad_request_response, build_admin_users_read_only_response,
AdminUpdateUserApiKeyRequest,
normalize_admin_feature_settings, AdminUpdateUserApiKeyRequest,
};
use super::super::helpers::{
attach_audit_response, build_admin_user_api_key_detail_payload,
@@ -52,6 +52,20 @@ pub(crate) async fn build_admin_update_user_api_key_response(
.into_response());
}
};
let feature_settings = if let Some(feature_settings) = payload.feature_settings {
match normalize_admin_feature_settings(feature_settings) {
Ok(value) => Some(value),
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response());
}
}
} else {
None
};
let name = match normalize_admin_optional_api_key_name(payload.name) {
Ok(value) => value,
Err(detail) => {
@@ -83,7 +97,7 @@ pub(crate) async fn build_admin_update_user_api_key_response(
let Some(updated) = state
.update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
user_id,
user_id: user_id.clone(),
api_key_id: api_key_id.clone(),
name,
rate_limit: payload.rate_limit,
@@ -97,6 +111,14 @@ pub(crate) async fn build_admin_update_user_api_key_response(
)
.into_response());
};
let updated = if let Some(feature_settings) = feature_settings {
state
.set_user_api_key_feature_settings(&user_id, &api_key_id, feature_settings)
.await?
.unwrap_or(updated)
} else {
updated
};
let is_locked = state
.list_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))

View File

@@ -1,8 +1,8 @@
use super::super::{
admin_default_user_initial_gift, build_admin_users_read_only_response,
disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_optional_user_email,
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
validate_admin_user_password, AdminCreateUserRequest,
disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_feature_settings,
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
normalize_admin_username, validate_admin_user_password, AdminCreateUserRequest,
};
use super::support::{admin_user_password_policy, build_admin_user_payload_with_groups};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -66,6 +66,16 @@ pub(in super::super) async fn build_admin_create_user_response(
.into_response())
}
};
let feature_settings = match normalize_admin_feature_settings(payload.feature_settings) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
};
let email = match normalize_admin_optional_user_email(payload.email.as_deref()) {
Ok(value) => value,
@@ -210,16 +220,21 @@ pub(in super::super) async fn build_admin_create_user_response(
.replace_user_groups_for_user(&user.id, &group_ids)
.await?;
}
let feature_settings = if feature_settings.is_some() {
state
.update_user_feature_settings(&user.id, feature_settings.clone())
.await?
.or(feature_settings)
} else {
None
};
let mut payload =
build_admin_user_payload_with_groups(&user, None, None, payload.unlimited, &groups);
payload["feature_settings"] = feature_settings.unwrap_or(Value::Null);
Ok(attach_admin_audit_response(
Json(build_admin_user_payload_with_groups(
&user,
None,
None,
payload.unlimited,
&groups,
))
.into_response(),
Json(payload).into_response(),
"admin_user_created",
"create_user",
"user",

View File

@@ -144,12 +144,16 @@ pub(in super::super) async fn build_admin_get_user_response(
let unlimited = wallet
.as_ref()
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
Ok(Json(build_admin_user_payload_with_groups(
let mut payload = build_admin_user_payload_with_groups(
&user,
export_row.as_ref().and_then(|row| row.rate_limit),
export_row.as_ref().map(|row| row.rate_limit_mode.as_str()),
unlimited,
&groups,
))
.into_response())
);
payload["feature_settings"] = export_row
.as_ref()
.and_then(|row| row.feature_settings.clone())
.unwrap_or(serde_json::Value::Null);
Ok(Json(payload).into_response())
}

View File

@@ -103,6 +103,7 @@ pub(super) fn build_admin_user_export_payload(
"allowed_models_mode": row.allowed_models_mode,
"rate_limit": row.rate_limit,
"rate_limit_mode": row.rate_limit_mode,
"feature_settings": row.feature_settings,
"unlimited": unlimited,
"is_active": row.is_active,
"created_at": format_optional_datetime_iso8601(created_at),

View File

@@ -1,8 +1,9 @@
use super::super::{
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
normalize_admin_username, validate_admin_user_password, AdminUpdateUserPatch,
normalize_admin_feature_settings, normalize_admin_optional_user_email,
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
validate_admin_user_password, AdminUpdateUserPatch,
};
use super::support::{
admin_user_id_from_detail_path, admin_user_password_policy,
@@ -17,7 +18,7 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use serde_json::{json, Value};
pub(in super::super) async fn build_admin_update_user_response(
state: &AdminAppState<'_>,
@@ -69,6 +70,20 @@ pub(in super::super) async fn build_admin_update_user_response(
}
};
let (field_presence, payload) = patch.into_parts();
let feature_settings = if field_presence.contains("feature_settings") {
match normalize_admin_feature_settings(payload.feature_settings.flatten()) {
Ok(value) => Some(value),
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
}
} else {
None
};
let email = match payload.email.as_deref() {
Some(value) => match normalize_admin_optional_user_email(Some(value)) {
@@ -175,7 +190,8 @@ pub(in super::super) async fn build_admin_update_user_response(
|| payload.password.is_some()
|| role.is_some()
|| payload.is_active.is_some()
|| group_ids.is_some();
|| group_ids.is_some()
|| feature_settings.is_some();
if needs_auth_user_write && !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法更新用户",
@@ -293,6 +309,11 @@ pub(in super::super) async fn build_admin_update_user_response(
}
}
}
if let Some(feature_settings) = feature_settings {
state
.update_user_feature_settings(&user_id, feature_settings)
.await?;
}
let Some(user) = state.find_user_auth_by_id(&user_id).await? else {
return Ok((
@@ -313,15 +334,20 @@ pub(in super::super) async fn build_admin_update_user_response(
let groups = state.list_user_groups_for_user(&user_id).await?;
let rate_limit = export_row.as_ref().and_then(|row| row.rate_limit);
let mut payload = build_admin_user_payload_with_groups(
&user,
rate_limit,
export_row.as_ref().map(|row| row.rate_limit_mode.as_str()),
unlimited,
&groups,
);
payload["feature_settings"] = export_row
.as_ref()
.and_then(|row| row.feature_settings.clone())
.unwrap_or(Value::Null);
Ok(attach_admin_audit_response(
Json(build_admin_user_payload_with_groups(
&user,
rate_limit,
export_row.as_ref().map(|row| row.rate_limit_mode.as_str()),
unlimited,
&groups,
))
.into_response(),
Json(payload).into_response(),
"admin_user_updated",
"update_user",
"user",

View File

@@ -53,6 +53,7 @@ pub(crate) use self::shared::{
normalize_admin_list_policy_mode, normalize_admin_rate_limit_policy_mode,
normalize_admin_user_api_formats, normalize_admin_user_string_list,
};
pub(crate) use crate::handlers::shared::normalize_feature_settings as normalize_admin_feature_settings;
pub(crate) async fn maybe_build_local_admin_users_response(
request: AdminRouteRequest<'_>,

View File

@@ -7,7 +7,7 @@ use axum::{
Json,
};
use regex::Regex;
use serde_json::json;
use serde_json::{json, Value};
#[derive(Debug, serde::Deserialize)]
pub(super) struct AdminCreateUserApiKeyRequest {
@@ -35,6 +35,8 @@ pub(super) struct AdminCreateUserApiKeyRequest {
pub(super) is_standalone: Option<bool>,
#[serde(default)]
pub(super) auto_delete_on_expiry: Option<bool>,
#[serde(default)]
pub(super) feature_settings: Option<Value>,
}
#[derive(Debug, serde::Deserialize)]
@@ -45,6 +47,8 @@ pub(super) struct AdminUpdateUserApiKeyRequest {
pub(super) rate_limit: Option<i32>,
#[serde(default)]
pub(super) concurrent_limit: Option<i32>,
#[serde(default)]
pub(super) feature_settings: Option<Option<Value>>,
}
#[derive(Debug, serde::Deserialize)]
@@ -67,6 +71,8 @@ pub(super) struct AdminCreateUserRequest {
pub(super) unlimited: bool,
#[serde(default)]
pub(super) group_ids: Vec<String>,
#[serde(default)]
pub(super) feature_settings: Option<Value>,
}
#[derive(Debug, serde::Deserialize)]
@@ -85,6 +91,8 @@ pub(super) struct AdminUpdateUserRequest {
pub(super) group_ids: Vec<String>,
#[serde(default)]
pub(super) is_active: Option<bool>,
#[serde(default)]
pub(super) feature_settings: Option<Option<Value>>,
}
pub(super) type AdminUpdateUserPatch = AdminTypedObjectPatch<AdminUpdateUserRequest>;

View File

@@ -174,6 +174,7 @@ pub(crate) fn build_auth_wallet_summary_payload(
fn build_auth_me_payload(
user: &aether_data::repository::users::StoredUserAuthRecord,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
feature_settings: Option<serde_json::Value>,
) -> serde_json::Value {
let billing = build_auth_wallet_summary_payload(wallet);
let has_password = user
@@ -194,6 +195,7 @@ fn build_auth_me_payload(
"last_login_at": user.last_login_at.map(|value| value.to_rfc3339()),
"auth_source": user.auth_source,
"has_password": has_password,
"feature_settings": feature_settings,
})
}
@@ -336,9 +338,19 @@ pub(crate) async fn handle_auth_me(
.await
.ok()
.flatten();
let feature_settings = match state.read_user_feature_settings(&auth.user.id).await {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user feature settings lookup failed: {err:?}"),
false,
)
}
};
build_auth_json_response(
http::StatusCode::OK,
build_auth_me_payload(&auth.user, wallet.as_ref()),
build_auth_me_payload(&auth.user, wallet.as_ref(), feature_settings),
None,
)
}

View File

@@ -10,8 +10,9 @@ use serde::Deserialize;
use serde_json::json;
use crate::handlers::shared::{
api_key_placeholder_display, generate_gateway_api_key_plaintext,
masked_gateway_api_key_display, normalize_optional_api_key_concurrent_limit,
api_key_placeholder_display, deserialize_optional_json_patch,
generate_gateway_api_key_plaintext, masked_gateway_api_key_display, normalize_feature_settings,
normalize_optional_api_key_concurrent_limit,
};
use super::{
@@ -31,6 +32,8 @@ struct UsersMeCreateApiKeyRequest {
rate_limit: Option<i32>,
#[serde(default)]
concurrent_limit: Option<i32>,
#[serde(default)]
feature_settings: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
@@ -41,6 +44,8 @@ struct UsersMeUpdateApiKeyRequest {
rate_limit: Option<i32>,
#[serde(default)]
concurrent_limit: Option<i32>,
#[serde(default, deserialize_with = "deserialize_optional_json_patch")]
feature_settings: Option<Option<serde_json::Value>>,
}
#[derive(Debug, Deserialize)]
@@ -156,6 +161,7 @@ fn build_users_me_api_key_list_payload(
"concurrent_limit": record.concurrent_limit,
"allowed_providers": record.allowed_providers,
"force_capabilities": record.force_capabilities,
"feature_settings": record.feature_settings,
})
}
@@ -172,6 +178,7 @@ fn build_users_me_api_key_detail_payload(
"is_locked": is_locked,
"allowed_providers": record.allowed_providers,
"force_capabilities": record.force_capabilities,
"feature_settings": record.feature_settings,
"rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"last_used_at": format_users_me_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
@@ -529,6 +536,12 @@ pub(super) async fn handle_users_me_api_key_create(
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let feature_settings = match normalize_feature_settings(payload.feature_settings) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let plaintext_key = generate_users_me_api_key_plaintext();
let Some(key_encrypted) = encrypt_catalog_secret_with_fallbacks(state, &plaintext_key) else {
@@ -569,6 +582,28 @@ pub(super) async fn handle_users_me_api_key_create(
}) else {
return build_users_me_api_key_writer_unavailable_response();
};
let created = if feature_settings.is_some() {
match state
.set_user_api_key_feature_settings(
&auth.user.id,
&created.api_key_id,
feature_settings.clone(),
)
.await
{
Ok(Some(record)) => record,
Ok(None) => return build_users_me_api_key_writer_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user api key feature settings update failed: {err:?}"),
false,
)
}
}
} else {
created
};
Json(json!({
"id": created.api_key_id,
@@ -579,6 +614,7 @@ pub(super) async fn handle_users_me_api_key_create(
"is_locked": false,
"rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"feature_settings": created.feature_settings,
"last_used_at": format_users_me_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
"created_at": format_users_me_optional_unix_secs_iso8601(created.created_at_unix_secs),
"total_requests": created.total_requests,
@@ -650,6 +686,15 @@ pub(super) async fn handle_users_me_api_key_update(
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let feature_settings = match payload.feature_settings {
Some(value) => match normalize_feature_settings(value) {
Ok(value) => Some(value),
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
},
None => None,
};
let Some(updated) = (match state
.update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
@@ -672,6 +717,28 @@ pub(super) async fn handle_users_me_api_key_update(
}) else {
return build_users_me_api_key_writer_unavailable_response();
};
let updated = if let Some(feature_settings) = feature_settings {
match state
.set_user_api_key_feature_settings(
&auth.user.id,
&snapshot.api_key_id,
feature_settings,
)
.await
{
Ok(Some(record)) => record,
Ok(None) => return build_users_me_api_key_writer_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user api key feature settings update failed: {err:?}"),
false,
)
}
}
} else {
updated
};
let mut payload =
build_users_me_api_key_detail_payload(state, &updated, snapshot.api_key_is_locked);

View File

@@ -7,6 +7,8 @@ use axum::{
use serde::Deserialize;
use serde_json::json;
use crate::handlers::shared::{deserialize_optional_json_patch, normalize_feature_settings};
use super::{
auth_password_policy_level, build_auth_error_response, resolve_authenticated_local_user,
validate_auth_register_password, AppState, GatewayPublicRequestContext,
@@ -21,6 +23,8 @@ struct UsersMeUpdateProfileRequest {
email: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_json_patch")]
feature_settings: Option<Option<serde_json::Value>>,
}
#[derive(Debug, Deserialize)]
@@ -60,6 +64,15 @@ pub(super) async fn handle_users_me_detail_put(
let email = normalize_users_me_optional_non_empty_string(payload.email);
let username = normalize_users_me_optional_non_empty_string(payload.username);
let feature_settings = match payload.feature_settings {
Some(value) => match normalize_feature_settings(value) {
Ok(value) => Some(value),
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
},
None => None,
};
if let Some(email) = email.as_deref() {
match state
@@ -111,7 +124,24 @@ pub(super) async fn handle_users_me_detail_put(
.update_local_auth_user_profile(&auth.user.id, email, username)
.await
{
Ok(Some(_)) => Json(json!({ "message": "个人信息更新成功" })).into_response(),
Ok(Some(_)) => {
if let Some(feature_settings) = feature_settings {
match state
.update_user_feature_settings(&auth.user.id, feature_settings)
.await
{
Ok(_) => {}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user feature settings update failed: {err:?}"),
false,
)
}
}
}
Json(json!({ "message": "个人信息更新成功" })).into_response()
}
Ok(None) => build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
USERS_ME_PROFILE_STORAGE_UNAVAILABLE_DETAIL,

View File

@@ -34,7 +34,8 @@ pub(crate) use self::email_templates::{
};
pub(crate) use self::external_models::OFFICIAL_EXTERNAL_MODEL_PROVIDERS;
pub(crate) use self::normalize::{
normalize_json_array, normalize_json_object, normalize_string_list,
deserialize_optional_json_patch, normalize_feature_settings, normalize_json_array,
normalize_json_object, normalize_string_list,
};
pub(crate) use self::payloads::{
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,

View File

@@ -1,5 +1,7 @@
use std::collections::BTreeSet;
use serde_json::{Map, Value};
pub(crate) fn normalize_string_list(values: Option<Vec<String>>) -> Option<Vec<String>> {
let mut out = Vec::new();
let mut seen = BTreeSet::new();
@@ -42,3 +44,65 @@ pub(crate) fn normalize_json_array(
_ => Err(format!("{field_name} 必须是 JSON 数组")),
}
}
pub(crate) fn normalize_feature_settings(value: Option<Value>) -> Result<Option<Value>, String> {
let Some(mut value) = value else {
return Ok(None);
};
match value {
Value::Null => Ok(None),
Value::Object(ref mut settings) => {
normalize_chat_pii_redaction_feature_settings(settings)?;
if settings.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
_ => Err("feature_settings 必须是对象".to_string()),
}
}
pub(crate) fn deserialize_optional_json_patch<'de, D>(
deserializer: D,
) -> Result<Option<Option<Value>>, D::Error>
where
D: serde::Deserializer<'de>,
{
<Option<Value> as serde::Deserialize>::deserialize(deserializer).map(Some)
}
fn normalize_chat_pii_redaction_feature_settings(
settings: &mut Map<String, Value>,
) -> Result<(), String> {
let Some(value) = settings.get_mut("chat_pii_redaction") else {
return Ok(());
};
match value {
Value::Null => {
settings.remove("chat_pii_redaction");
Ok(())
}
Value::Object(feature) => {
normalize_chat_pii_redaction_feature_object(feature)?;
if feature.is_empty() {
settings.remove("chat_pii_redaction");
}
Ok(())
}
_ => Err("chat_pii_redaction 必须是对象".to_string()),
}
}
fn normalize_chat_pii_redaction_feature_object(
feature: &mut Map<String, Value>,
) -> Result<(), String> {
for key in ["enabled", "inject_model_instruction"] {
if let Some(value) = feature.get(key) {
if !value.is_boolean() {
return Err(format!("chat_pii_redaction.{key} 必须是布尔值"));
}
}
}
Ok(())
}