Files
alterdb/src/controllers/accounts.rs
T
2026-10-02 13:12:49 +08:00

204 lines
6.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 帳戶控制器:`/api/accounts` 資源的 REST CRUD。
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::json;
use crate::models::account::{Account, AccountInput};
use crate::state::AppState;
use crate::storage::{self, StorageError};
/// 控制器層錯誤:統一轉為帶 JSON 錯誤體的 HTTP 響應。
#[derive(Debug)]
enum ApiError {
/// 指定的資源不存在(404)
NotFound,
/// 唯一欄位衝突(409)
Conflict,
/// 輸入驗證失敗(400),內為可直接展示的錯誤訊息
BadRequest(String),
/// 存儲層錯誤(503 / 500)
Storage(StorageError),
}
impl From<StorageError> for ApiError {
fn from(err: StorageError) -> Self {
if err.is_unique_violation() {
ApiError::Conflict
} else {
ApiError::Storage(err)
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "帳戶不存在".to_string()),
ApiError::Conflict => (
StatusCode::CONFLICT,
"username 或 email 已被其他帳戶使用".to_string(),
),
ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
ApiError::Storage(err) => {
tracing::error!("帳戶存儲層錯誤:{err}");
if matches!(err, StorageError::Pool(_)) {
(
StatusCode::SERVICE_UNAVAILABLE,
"資料庫暫時不可用".to_string(),
)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, "內部錯誤".to_string())
}
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
/// 列出所有帳戶
async fn list(State(state): State<AppState>) -> Result<Json<Vec<Account>>, ApiError> {
let accounts = storage::account::list(&state.db).await?;
Ok(Json(accounts))
}
/// 建立帳戶;輸入會先經 [`AccountInput::normalized`] 驗證與正規化
async fn create(
State(state): State<AppState>,
Json(payload): Json<AccountInput>,
) -> Result<(StatusCode, Json<Account>), ApiError> {
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
let account = storage::account::create(&state.db, payload).await?;
Ok((StatusCode::CREATED, Json(account)))
}
/// 取得單一帳戶
async fn show(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Account>, ApiError> {
storage::account::get_by_id(&state.db, id)
.await?
.map(Json)
.ok_or(ApiError::NotFound)
}
/// 更新帳戶的全部可編輯欄位(username / email / display_name / is_active)
async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<AccountInput>,
) -> Result<Json<Account>, ApiError> {
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
storage::account::update(&state.db, id, payload)
.await?
.map(Json)
.ok_or(ApiError::NotFound)
}
/// 刪除帳戶
async fn remove(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
match storage::account::delete(&state.db, id).await? {
true => Ok(StatusCode::NO_CONTENT),
false => Err(ApiError::NotFound),
}
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/accounts", get(list).post(create))
.route("/api/accounts/{id}", get(show).put(update).delete(remove))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::testutil;
fn payload(username: &str, email: &str) -> AccountInput {
AccountInput {
username: username.into(),
email: email.into(),
display_name: None,
is_active: None,
}
}
#[tokio::test]
async fn accounts_crud_via_handlers() {
let (pool, path) = testutil::test_pool("accounts-api").await;
let state = State(AppState {
db: pool.clone(),
sessions: crate::session::SessionStore::new(),
});
// 建立:輸入被 trim、display_name 默認同 username → 201
let (status, Json(created)) =
create(state.clone(), Json(payload(" alice ", "alice@example.com")))
.await
.unwrap();
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created.username, "alice");
assert_eq!(created.display_name, "alice");
// 密碼雜湊永不外洩到序列化結果
let json = serde_json::to_value(&created).unwrap();
assert!(json.get("password_hash").is_none());
// 列表包含新帳戶(另有初始化自動建立的 admin 在前)
let Json(accounts) = list(state.clone()).await.unwrap();
assert_eq!(accounts.len(), 2);
assert_eq!(accounts[0].username, "admin");
assert_eq!(accounts[1].id, created.id);
// username 重複 → 409;輸入驗證失敗 → 400
let err = create(state.clone(), Json(payload("alice", "bob@example.com")))
.await
.unwrap_err();
assert_eq!(err.into_response().status(), StatusCode::CONFLICT);
let err = create(state.clone(), Json(payload("", "x@example.com")))
.await
.unwrap_err();
assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
// 查詢:存在 / 不存在 → 200 / 404
let Json(found) = show(state.clone(), Path(created.id)).await.unwrap();
assert_eq!(found.id, created.id);
let err = show(state.clone(), Path(9999)).await.unwrap_err();
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
// 更新:欄位被覆寫
let Json(updated) = update(
state.clone(),
Path(created.id),
Json(AccountInput {
username: "alice".into(),
email: "alice@example.com".into(),
display_name: Some("Alice".into()),
is_active: Some(false),
}),
)
.await
.unwrap();
assert_eq!(updated.display_name, "Alice");
assert!(!updated.is_active);
// 刪除:→ 204,再刪 → 404
assert_eq!(
remove(state.clone(), Path(created.id)).await.unwrap(),
StatusCode::NO_CONTENT
);
let err = remove(state, Path(created.id)).await.unwrap_err();
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
pool.close();
testutil::cleanup(&path);
}
}