321 lines
11 KiB
Rust
321 lines
11 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Extension, Path, State},
|
|
http::StatusCode,
|
|
};
|
|
use serde_json::Value;
|
|
use shared_contracts::admin::{
|
|
ADMIN_ACTION_PERMISSIONS, ADMIN_TAB_PERMISSIONS, AdminAccountListResponse, AdminAccountPayload,
|
|
AdminAccountRole, AdminCreateAccountRequest, AdminCreateAccountResponse,
|
|
AdminUpdateAccountRequest, AdminUpdateAccountResponse,
|
|
};
|
|
use spacetime_client::{
|
|
AdminAccountCreateRecordInput, AdminAccountRecord, AdminAccountUpdateRecordInput,
|
|
SpacetimeClientError,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
admin::{AuthenticatedAdmin, hash_admin_password},
|
|
api_response::json_success_body,
|
|
http_error::AppError,
|
|
request_context::RequestContext,
|
|
state::AppState,
|
|
};
|
|
|
|
const ADMIN_USERNAME_MIN_CHARS: usize = 3;
|
|
const ADMIN_USERNAME_MAX_CHARS: usize = 64;
|
|
const ADMIN_DISPLAY_NAME_MAX_CHARS: usize = 64;
|
|
const ADMIN_PASSWORD_MIN_CHARS: usize = 6;
|
|
const ADMIN_PASSWORD_MAX_CHARS: usize = 128;
|
|
|
|
pub async fn admin_list_accounts(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let runtime = state.admin_runtime().ok_or_else(|| {
|
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
|
})?;
|
|
let mut accounts = vec![AdminAccountPayload {
|
|
account_id: runtime.subject().to_string(),
|
|
username: runtime.username().to_string(),
|
|
display_name: runtime.display_name().to_string(),
|
|
account_role: AdminAccountRole::Owner,
|
|
tab_permissions: ADMIN_TAB_PERMISSIONS
|
|
.iter()
|
|
.map(|permission| (*permission).to_string())
|
|
.collect(),
|
|
action_permissions: ADMIN_ACTION_PERMISSIONS
|
|
.iter()
|
|
.map(|permission| (*permission).to_string())
|
|
.collect(),
|
|
enabled: true,
|
|
token_version: 1,
|
|
created_by: "environment".to_string(),
|
|
updated_by: "environment".to_string(),
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
}];
|
|
accounts.extend(
|
|
state
|
|
.spacetime_client()
|
|
.list_admin_accounts()
|
|
.await
|
|
.map_err(map_admin_account_error)?
|
|
.into_iter()
|
|
.map(admin_account_payload),
|
|
);
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
AdminAccountListResponse { accounts },
|
|
))
|
|
}
|
|
|
|
pub async fn admin_create_account(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminCreateAccountRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_owner(&admin)?;
|
|
let username = normalize_admin_account_username(&payload.username)?;
|
|
let owner_username = state
|
|
.admin_runtime()
|
|
.map(|runtime| runtime.username().trim().to_ascii_lowercase())
|
|
.unwrap_or_default();
|
|
if username == owner_username {
|
|
return Err(AppError::from_status(StatusCode::CONFLICT).with_message("后台用户名已存在"));
|
|
}
|
|
let display_name = normalize_display_name(&payload.display_name)?;
|
|
validate_password(&payload.password)?;
|
|
let tab_permissions = normalize_tab_permissions(payload.tab_permissions)?;
|
|
let action_permissions = normalize_action_permissions(payload.action_permissions)?;
|
|
let password_hash = hash_admin_password(&payload.password).await?;
|
|
|
|
let account = state
|
|
.spacetime_client()
|
|
.create_admin_account(AdminAccountCreateRecordInput {
|
|
account_id: format!("admin-account-{}", Uuid::new_v4()),
|
|
username,
|
|
display_name,
|
|
password_hash,
|
|
tab_permissions,
|
|
action_permissions,
|
|
enabled: payload.enabled,
|
|
created_by: admin.session().subject.clone(),
|
|
})
|
|
.await
|
|
.map_err(map_admin_account_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
AdminCreateAccountResponse {
|
|
account: admin_account_payload(account),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn admin_update_account(
|
|
State(state): State<AppState>,
|
|
Path(account_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(admin): Extension<AuthenticatedAdmin>,
|
|
Json(payload): Json<AdminUpdateAccountRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_owner(&admin)?;
|
|
let account_id = account_id.trim().to_string();
|
|
if account_id.is_empty() {
|
|
return Err(
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台账号 ID 不能为空")
|
|
);
|
|
}
|
|
if state
|
|
.admin_runtime()
|
|
.is_some_and(|runtime| runtime.subject() == account_id.as_str())
|
|
{
|
|
return Err(
|
|
AppError::from_status(StatusCode::FORBIDDEN).with_message("owner 账号不能通过后台修改")
|
|
);
|
|
}
|
|
let display_name = normalize_display_name(&payload.display_name)?;
|
|
let tab_permissions = normalize_tab_permissions(payload.tab_permissions)?;
|
|
let action_permissions = normalize_action_permissions(payload.action_permissions)?;
|
|
let password_hash = match payload.password {
|
|
Some(password) => {
|
|
validate_password(&password)?;
|
|
Some(hash_admin_password(&password).await?)
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
let account = state
|
|
.spacetime_client()
|
|
.update_admin_account(AdminAccountUpdateRecordInput {
|
|
account_id,
|
|
display_name,
|
|
password_hash,
|
|
tab_permissions,
|
|
action_permissions,
|
|
enabled: payload.enabled,
|
|
updated_by: admin.session().subject.clone(),
|
|
})
|
|
.await
|
|
.map_err(map_admin_account_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
AdminUpdateAccountResponse {
|
|
account: admin_account_payload(account),
|
|
},
|
|
))
|
|
}
|
|
|
|
fn require_owner(admin: &AuthenticatedAdmin) -> Result<(), AppError> {
|
|
if admin.session().account_role == AdminAccountRole::Owner {
|
|
return Ok(());
|
|
}
|
|
Err(AppError::from_status(StatusCode::FORBIDDEN).with_message("仅 owner 可管理后台账号"))
|
|
}
|
|
|
|
pub(crate) fn normalize_admin_account_username(raw: &str) -> Result<String, AppError> {
|
|
let username = raw.trim().to_ascii_lowercase();
|
|
let char_count = username.chars().count();
|
|
if !(ADMIN_USERNAME_MIN_CHARS..=ADMIN_USERNAME_MAX_CHARS).contains(&char_count)
|
|
|| !username
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
|
{
|
|
return Err(
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台用户名格式无效")
|
|
);
|
|
}
|
|
Ok(username)
|
|
}
|
|
|
|
fn normalize_display_name(raw: &str) -> Result<String, AppError> {
|
|
let display_name = raw.trim().to_string();
|
|
if display_name.is_empty() || display_name.chars().count() > ADMIN_DISPLAY_NAME_MAX_CHARS {
|
|
return Err(
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台显示名称格式无效")
|
|
);
|
|
}
|
|
Ok(display_name)
|
|
}
|
|
|
|
fn validate_password(password: &str) -> Result<(), AppError> {
|
|
let char_count = password.chars().count();
|
|
if !(ADMIN_PASSWORD_MIN_CHARS..=ADMIN_PASSWORD_MAX_CHARS).contains(&char_count) {
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("后台密码长度必须为 6 至 128 个字符"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_tab_permissions(permissions: Vec<String>) -> Result<Vec<String>, AppError> {
|
|
normalize_permissions(
|
|
permissions,
|
|
&ADMIN_TAB_PERMISSIONS,
|
|
"后台 Tab 权限包含未知值",
|
|
)
|
|
}
|
|
|
|
fn normalize_action_permissions(permissions: Vec<String>) -> Result<Vec<String>, AppError> {
|
|
normalize_permissions(
|
|
permissions,
|
|
&ADMIN_ACTION_PERMISSIONS,
|
|
"后台操作权限包含未知值",
|
|
)
|
|
}
|
|
|
|
fn normalize_permissions(
|
|
permissions: Vec<String>,
|
|
allowed_permissions: &[&str],
|
|
unknown_message: &str,
|
|
) -> Result<Vec<String>, AppError> {
|
|
let requested = permissions
|
|
.into_iter()
|
|
.map(|permission| permission.trim().to_string())
|
|
.collect::<std::collections::HashSet<_>>();
|
|
if requested
|
|
.iter()
|
|
.any(|permission| !allowed_permissions.contains(&permission.as_str()))
|
|
{
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message(unknown_message));
|
|
}
|
|
Ok(allowed_permissions
|
|
.iter()
|
|
.filter(|permission| requested.contains(**permission))
|
|
.map(|permission| (*permission).to_string())
|
|
.collect())
|
|
}
|
|
|
|
fn admin_account_payload(account: AdminAccountRecord) -> AdminAccountPayload {
|
|
AdminAccountPayload {
|
|
account_id: account.account_id,
|
|
username: account.username,
|
|
display_name: account.display_name,
|
|
account_role: AdminAccountRole::Member,
|
|
tab_permissions: account.tab_permissions,
|
|
action_permissions: account.action_permissions,
|
|
enabled: account.enabled,
|
|
token_version: account.token_version,
|
|
created_by: account.created_by,
|
|
updated_by: account.updated_by,
|
|
created_at: account.created_at,
|
|
updated_at: account.updated_at,
|
|
}
|
|
}
|
|
|
|
fn map_admin_account_error(error: SpacetimeClientError) -> AppError {
|
|
let message = error.to_string();
|
|
let status = if message.contains("已存在") {
|
|
StatusCode::CONFLICT
|
|
} else if message.contains("不存在") {
|
|
StatusCode::NOT_FOUND
|
|
} else if message.contains("不能为空") || message.contains("无效") {
|
|
StatusCode::BAD_REQUEST
|
|
} else {
|
|
StatusCode::BAD_GATEWAY
|
|
};
|
|
AppError::from_status(status).with_message(message)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tab_permissions_are_validated_deduplicated_and_ordered() {
|
|
assert_eq!(
|
|
normalize_tab_permissions(vec![
|
|
"tracking".to_string(),
|
|
"dashboard".to_string(),
|
|
"tracking".to_string(),
|
|
])
|
|
.expect("permissions should normalize"),
|
|
["dashboard", "tracking"]
|
|
);
|
|
assert!(normalize_tab_permissions(vec!["accounts".to_string()]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn action_permissions_are_separate_from_tab_permissions() {
|
|
assert_eq!(
|
|
normalize_action_permissions(vec!["profile-wallet-consumption-reconcile".to_string(),])
|
|
.expect("action permission should normalize"),
|
|
["profile-wallet-consumption-reconcile"]
|
|
);
|
|
assert!(normalize_action_permissions(vec!["recharge-orders".to_string()]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn usernames_are_ascii_and_lowercase() {
|
|
assert_eq!(
|
|
normalize_admin_account_username(" Operator_1 ").unwrap(),
|
|
"operator_1"
|
|
);
|
|
assert!(normalize_admin_account_username("管理员").is_err());
|
|
assert!(normalize_admin_account_username(&"a".repeat(65)).is_err());
|
|
}
|
|
}
|