//! 帳戶控制器:`/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 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) -> Result>, ApiError> { let accounts = storage::account::list(&state.db).await?; Ok(Json(accounts)) } /// 建立帳戶;輸入會先經 [`AccountInput::normalized`] 驗證與正規化 async fn create( State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json), 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, Path(id): Path, ) -> Result, 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, Path(id): Path, Json(payload): Json, ) -> Result, 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, Path(id): Path, ) -> Result { match storage::account::delete(&state.db, id).await? { true => Ok(StatusCode::NO_CONTENT), false => Err(ApiError::NotFound), } } pub fn routes() -> Router { 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); } }