204 lines
6.7 KiB
Rust
204 lines
6.7 KiB
Rust
//! 帳戶控制器:`/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);
|
||
}
|
||
}
|