feat(admin): unify system data management and aggregate import/export

This commit is contained in:
fawney19
2026-05-16 02:09:01 +08:00
parent 48da062251
commit f59cf1090d
16 changed files with 872 additions and 206 deletions

View File

@@ -63,6 +63,14 @@ pub(super) fn classify_admin_system_family_route(
"admin:system",
false,
))
} else if method == http::Method::GET && normalized_path == "/api/admin/system/data/export" {
Some(classified(
"admin_proxy",
"system_manage",
"data_export",
"admin:system",
false,
))
} else if method == http::Method::POST && normalized_path == "/api/admin/system/config/import" {
Some(classified(
"admin_proxy",
@@ -79,6 +87,14 @@ pub(super) fn classify_admin_system_family_route(
"admin:system",
false,
))
} else if method == http::Method::POST && normalized_path == "/api/admin/system/data/import" {
Some(classified(
"admin_proxy",
"system_manage",
"data_import",
"admin:system",
false,
))
} else if method == http::Method::POST && normalized_path == "/api/admin/system/smtp/test" {
Some(classified(
"admin_proxy",

View File

@@ -156,12 +156,32 @@ fn classifies_admin_system_users_export_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn classifies_admin_system_data_export_as_admin_proxy_route() {
let headers = headers(&[]);
let uri: Uri = "/api/admin/system/data/export"
.parse()
.expect("uri should parse");
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
assert_eq!(decision.route_family.as_deref(), Some("system_manage"));
assert_eq!(decision.route_kind.as_deref(), Some("data_export"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("admin:system")
);
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn classifies_admin_system_maintenance_write_routes_as_admin_proxy_route() {
let headers = headers(&[]);
let cases = [
("/api/admin/system/config/import", "config_import"),
("/api/admin/system/users/import", "users_import"),
("/api/admin/system/data/import", "data_import"),
("/api/admin/system/smtp/test", "smtp_test"),
("/api/admin/system/cleanup", "cleanup"),
("/api/admin/system/purge/config", "purge_config"),

View File

@@ -1,3 +1,4 @@
use super::ADMIN_SYSTEM_DATA_EXPORT_VERSION;
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::system::shared::configs::is_sensitive_admin_system_config_key;
use crate::handlers::admin::system::shared::export::{
@@ -308,6 +309,20 @@ impl<'a> AdminAppState<'a> {
}))
}
pub(crate) async fn build_admin_system_data_export_payload(
&self,
) -> Result<serde_json::Value, GatewayError> {
let config_data = self.build_admin_system_config_export_payload().await?;
let user_data = self.build_admin_system_users_export_payload().await?;
Ok(json!({
"version": ADMIN_SYSTEM_DATA_EXPORT_VERSION,
"exported_at": Utc::now().to_rfc3339(),
"config_data": config_data,
"user_data": user_data,
}))
}
fn build_admin_system_users_export_api_key_payload(
&self,
key: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,

View File

@@ -1,4 +1,6 @@
use super::AdminAppState;
use super::{
AdminAppState, ADMIN_SYSTEM_DATA_EXPORT_VERSION, ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES,
};
use crate::api::ai::admin_endpoint_signature_parts;
use crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdatePatch;
use crate::handlers::admin::provider::shared::payloads::{
@@ -56,6 +58,26 @@ fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, Value) {
)
}
fn build_admin_system_data_import_part_body(
root: &Map<String, Value>,
field_name: &str,
merge_mode: AdminImportMergeMode,
) -> Result<Bytes, (http::StatusCode, Value)> {
let mut part = match root.get(field_name) {
Some(Value::Object(map)) => map.clone(),
Some(_) => return Err(invalid_request(format!("{field_name} 必须是对象"))),
None => return Err(invalid_request(format!("{field_name} 为必填字段"))),
};
let merge_mode_value = serde_json::to_value(merge_mode)
.map_err(|err| invalid_request(format!("merge_mode 序列化失败: {err}")))?;
part.insert("merge_mode".to_string(), merge_mode_value);
serde_json::to_vec(&Value::Object(part))
.map(Bytes::from)
.map_err(|err| invalid_request(format!("{field_name} 序列化失败: {err}")))
}
fn trim_required(value: &str, field_name: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -893,6 +915,91 @@ fn normalize_imported_wallet_target(
}
impl<'a> AdminAppState<'a> {
pub(crate) async fn import_admin_system_data(
&self,
request_body: &Bytes,
operator_id: Option<&str>,
) -> Result<Result<Value, (http::StatusCode, Value)>, GatewayError> {
if !self.has_global_model_data_reader()
|| !self.has_global_model_data_writer()
|| !self.has_provider_catalog_data_reader()
|| !self.has_provider_catalog_data_writer()
|| !self.has_auth_user_write_capability()
|| !self.has_auth_wallet_write_capability()
|| !self.has_auth_api_key_writer()
{
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
}
if request_body.len() > ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES {
return Ok(Err(invalid_request("请求体大小不能超过 20MB")));
}
let root = match serde_json::from_slice::<Value>(request_body) {
Ok(Value::Object(map)) => map,
_ => return Ok(Err(invalid_request("请求数据验证失败"))),
};
let version = root
.get("version")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| invalid_request("version 为必填字段"));
let version = match version {
Ok(value) => value,
Err(err) => return Ok(Err(err)),
};
if version != ADMIN_SYSTEM_DATA_EXPORT_VERSION {
return Ok(Err(invalid_request(format!(
"不支持的聚合数据版本: {version},支持的版本: {ADMIN_SYSTEM_DATA_EXPORT_VERSION}"
))));
}
let merge_mode = match serde_json::from_value::<AdminImportMergeMode>(
root.get("merge_mode").cloned().unwrap_or(Value::Null),
) {
Ok(value) => value,
Err(_) => {
return Ok(Err(invalid_request(
"merge_mode 仅支持 skip / overwrite / error",
)))
}
};
let config_body =
match build_admin_system_data_import_part_body(&root, "config_data", merge_mode) {
Ok(value) => value,
Err(err) => return Ok(Err(err)),
};
let users_body =
match build_admin_system_data_import_part_body(&root, "user_data", merge_mode) {
Ok(value) => value,
Err(err) => return Ok(Err(err)),
};
let config_result = match self.import_admin_system_config(&config_body).await? {
Ok(payload) => payload,
Err(err) => return Ok(Err(err)),
};
let users_result = match self
.import_admin_system_users(&users_body, operator_id)
.await?
{
Ok(payload) => payload,
Err(err) => return Ok(Err(err)),
};
Ok(Ok(json!({
"message": "聚合数据导入成功",
"config": config_result,
"users": users_result,
})))
}
pub(crate) async fn import_admin_system_config(
&self,
request_body: &Bytes,

View File

@@ -8,6 +8,9 @@ mod modules;
mod proxy_nodes;
mod templates;
const ADMIN_SYSTEM_DATA_EXPORT_VERSION: &str = "1.0";
const ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES: usize = 20 * 1024 * 1024;
impl<'a> AdminAppState<'a> {
pub(crate) async fn upsert_system_config_json_value(
&self,

View File

@@ -180,6 +180,55 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
));
}
if decision.route_kind.as_deref() == Some("data_export")
&& request_method == http::Method::GET
&& request_path == "/api/admin/system/data/export"
{
return Ok(Some(attach_admin_audit_response(
Json(state.build_admin_system_data_export_payload().await?).into_response(),
"admin_system_data_exported",
"export_system_data",
"system_data_export",
"global",
)));
}
if decision.route_kind.as_deref() == Some("data_import")
&& request_method == http::Method::POST
&& request_path == "/api/admin/system/data/import"
{
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求数据验证失败" })),
)
.into_response(),
));
};
return Ok(Some(
match state
.import_admin_system_data(
request_body,
decision
.admin_principal
.as_ref()
.map(|principal| principal.user_id.as_str()),
)
.await?
{
Ok(payload) => attach_admin_audit_response(
Json(payload).into_response(),
"admin_system_data_imported",
"import_system_data",
"system_data_import",
"global",
),
Err((status, payload)) => (status, Json(payload)).into_response(),
},
));
}
if decision.route_kind.as_deref() == Some("smtp_test")
&& request_method == http::Method::POST
&& request_path == "/api/admin/system/smtp/test"

View File

@@ -249,6 +249,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("provider_oauth_manage"), http::Method::POST, Some("device_poll"))
| (Some("system_manage"), http::Method::POST, Some("config_import"))
| (Some("system_manage"), http::Method::POST, Some("users_import"))
| (Some("system_manage"), http::Method::POST, Some("data_import"))
| (Some("system_manage"), http::Method::PUT, Some("settings_set"))
| (Some("system_manage"), http::Method::PUT, Some("config_set"))
| (Some("system_manage"), http::Method::PUT, Some("email_template_set"))

View File

@@ -259,6 +259,7 @@ fn admin_system_owns_system_route_helpers() {
"build_admin_api_formats_payload",
"build_admin_system_config_export_payload",
"build_admin_system_users_export_payload",
"build_admin_system_data_export_payload",
"build_admin_system_configs_payload",
"build_admin_system_config_detail_payload",
"apply_admin_system_config_update",
@@ -287,6 +288,7 @@ fn admin_system_owns_system_route_helpers() {
"build_admin_api_formats_payload",
"build_admin_system_config_export_payload",
"build_admin_system_users_export_payload",
"build_admin_system_data_export_payload",
"build_admin_system_configs_payload",
"build_admin_system_config_detail_payload",
"apply_admin_system_config_update",
@@ -315,6 +317,7 @@ fn admin_system_owns_system_route_helpers() {
"pub(crate) async fn build_admin_system_settings_payload",
"pub(crate) async fn build_admin_system_config_export_payload",
"pub(crate) async fn build_admin_system_users_export_payload",
"pub(crate) async fn build_admin_system_data_export_payload",
"pub(crate) fn build_admin_system_configs_payload",
"pub(crate) async fn build_admin_system_config_detail_payload",
"pub(crate) async fn apply_admin_system_config_update",
@@ -447,6 +450,7 @@ fn admin_system_owns_system_route_helpers() {
"pub(crate) async fn reset_admin_email_template",
"pub(crate) async fn build_admin_system_config_export_payload",
"pub(crate) async fn build_admin_system_users_export_payload",
"pub(crate) async fn build_admin_system_data_export_payload",
] {
assert!(
request_system.contains(pattern),
@@ -511,6 +515,7 @@ fn admin_system_shared_configs_split_export_owners() {
for pattern in [
"pub(crate) async fn build_admin_system_config_export_payload(",
"pub(crate) async fn build_admin_system_users_export_payload(",
"pub(crate) async fn build_admin_system_data_export_payload(",
] {
assert!(
request_system.contains(pattern),

View File

@@ -839,6 +839,7 @@ async fn gateway_handles_admin_system_unavailable_write_routes_locally_with_trus
let unavailable_paths = [
"/api/admin/system/config/import",
"/api/admin/system/users/import",
"/api/admin/system/data/import",
];
let local_paths = [
"/api/admin/system/cleanup",
@@ -862,7 +863,11 @@ async fn gateway_handles_admin_system_unavailable_write_routes_locally_with_trus
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"path={path}"
);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], DETAIL, "path={path}");
}