This commit is contained in:
2026-10-02 13:12:49 +08:00
parent 2d4bdd0f85
commit a8ab1dfcc6
38 changed files with 10385 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
//! 帳戶控制器:`/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);
}
}