308 lines
9.4 KiB
Rust
308 lines
9.4 KiB
Rust
use axum::{
|
||
extract::{Path, Query},
|
||
http::StatusCode,
|
||
response::Json,
|
||
routing::{get, post},
|
||
Router,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::core::db::{Database, PostgresDb};
|
||
use crate::core::person_identity::{
|
||
BindIdentityRequest, Identity, MergeIdentitiesRequest, UnbindIdentityRequest,
|
||
};
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct ApiResponse<T: Serialize> {
|
||
pub success: bool,
|
||
pub message: String,
|
||
pub data: Option<T>,
|
||
}
|
||
|
||
// ============================================================================
|
||
// API Handlers
|
||
// ============================================================================
|
||
|
||
async fn get_db() -> Result<PostgresDb, (StatusCode, Json<serde_json::Value>)> {
|
||
PostgresDb::init().await.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": format!("DB init failed: {}", e) })),
|
||
)
|
||
})
|
||
}
|
||
|
||
/// 獲取 Identity (人物) 列表
|
||
pub async fn list_identities(
|
||
Query(params): Query<ListIdentitiesParams>,
|
||
) -> Result<Json<ApiResponse<Vec<Identity>>>, (StatusCode, Json<serde_json::Value>)> {
|
||
let db = get_db().await?;
|
||
let limit = params.limit.unwrap_or(100);
|
||
let offset = params.offset.unwrap_or(0);
|
||
let search = params.search.unwrap_or_default();
|
||
|
||
let identities = db
|
||
.list_identities(&search, limit, offset)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": e.to_string() })),
|
||
)
|
||
})?;
|
||
|
||
Ok(Json(ApiResponse {
|
||
success: true,
|
||
message: format!("Found {} identities", identities.len()),
|
||
data: Some(identities),
|
||
}))
|
||
}
|
||
|
||
/// V4.0 直接綁定:face_detections.identity_id = identities.id
|
||
pub async fn bind_identity(
|
||
Path(identity_uuid): Path<String>,
|
||
Json(req): Json<BindIdentityRequest>,
|
||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||
let table = crate::core::db::schema::table_name("face_detections");
|
||
let id_table = crate::core::db::schema::table_name("identities");
|
||
|
||
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
|
||
// Get identity_id from identity_uuid
|
||
let identity_row: Option<(i32, String)> = sqlx::query_as(&format!(
|
||
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
|
||
id_table
|
||
))
|
||
.bind(&identity_uuid)
|
||
.fetch_optional(&db)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
|
||
let (identity_id, name) = identity_row.ok_or_else(|| {
|
||
(
|
||
StatusCode::NOT_FOUND,
|
||
Json(serde_json::json!({"error": format!("Identity not found: {}", identity_uuid)})),
|
||
)
|
||
})?;
|
||
|
||
// Direct UPDATE face_detections.identity_id
|
||
let result = sqlx::query(&format!(
|
||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
|
||
table
|
||
))
|
||
.bind(identity_id)
|
||
.bind(&req.file_uuid)
|
||
.bind(&req.face_id)
|
||
.execute(&db)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
|
||
let uuid_clean = identity_uuid.replace('-', "");
|
||
if let Ok(ref db) = PostgresDb::init().await {
|
||
if let Err(e) = crate::core::identity::storage::save_identity_file(db, &uuid_clean).await {
|
||
tracing::warn!("[bind] Failed to save identity file for {}: {}", uuid_clean, e);
|
||
}
|
||
}
|
||
|
||
Ok(Json(ApiResponse {
|
||
success: true,
|
||
message: format!(
|
||
"Bound face {} of {} to {}",
|
||
req.face_id, req.file_uuid, name
|
||
),
|
||
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
|
||
}))
|
||
}
|
||
|
||
/// V4.0 直接解綁:SET face_detections.identity_id = NULL
|
||
pub async fn unbind_identity(
|
||
Json(req): Json<UnbindIdentityRequest>,
|
||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||
let table = crate::core::db::schema::table_name("face_detections");
|
||
|
||
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
|
||
let result = sqlx::query(&format!(
|
||
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2",
|
||
table
|
||
))
|
||
.bind(&req.file_uuid)
|
||
.bind(&req.face_id)
|
||
.execute(&db)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
|
||
Ok(Json(ApiResponse {
|
||
success: true,
|
||
message: format!("Unbound face {} from {}", req.face_id, req.file_uuid),
|
||
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
|
||
}))
|
||
}
|
||
|
||
/// V4.0 合併:將 identity A 合併入 identity B,A 被刪除
|
||
pub async fn merge_identities(
|
||
Path(from_uuid): Path<String>,
|
||
Json(req): Json<MergeIdentitiesRequest>,
|
||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||
let face_table = crate::core::db::schema::table_name("face_detections");
|
||
let id_table = crate::core::db::schema::table_name("identities");
|
||
|
||
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
|
||
// Get IDs for both identities
|
||
let from_row: Option<(i32, String)> = sqlx::query_as(&format!(
|
||
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
|
||
id_table
|
||
))
|
||
.bind(&from_uuid)
|
||
.fetch_optional(&db)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
let (from_id, from_name) = from_row.ok_or((
|
||
StatusCode::NOT_FOUND,
|
||
Json(serde_json::json!({"error": "Source identity not found"})),
|
||
))?;
|
||
|
||
let into_row: Option<(i32, String)> = sqlx::query_as(&format!(
|
||
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
|
||
id_table
|
||
))
|
||
.bind(&req.into_uuid)
|
||
.fetch_optional(&db)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
let (into_id, into_name) = into_row.ok_or((
|
||
StatusCode::NOT_FOUND,
|
||
Json(serde_json::json!({"error": "Target identity not found"})),
|
||
))?;
|
||
|
||
// Transfer all face bindings from source → target
|
||
let updated = sqlx::query(&format!(
|
||
"UPDATE {} SET identity_id = $1 WHERE identity_id = $2",
|
||
face_table
|
||
))
|
||
.bind(into_id)
|
||
.bind(from_id)
|
||
.execute(&db)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
|
||
let keep = req.keep_history.unwrap_or(true);
|
||
|
||
if keep {
|
||
// Mark as merged, keep record
|
||
sqlx::query(&format!(
|
||
"UPDATE {} SET status = 'merged', metadata = COALESCE(metadata, '{{}}'::jsonb) || jsonb_build_object('merged_into', $1) WHERE id = $2",
|
||
id_table
|
||
))
|
||
.bind(&req.into_uuid).bind(from_id)
|
||
.execute(&db).await
|
||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))))?;
|
||
} else {
|
||
// Delete source identity
|
||
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table))
|
||
.bind(from_id)
|
||
.execute(&db)
|
||
.await
|
||
.map_err(|e| {
|
||
(
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({"error": e.to_string()})),
|
||
)
|
||
})?;
|
||
}
|
||
|
||
Ok(Json(ApiResponse {
|
||
success: true,
|
||
message: format!(
|
||
"Merged '{}' into '{}' ({} faces transferred, {})",
|
||
from_name,
|
||
into_name,
|
||
updated.rows_affected(),
|
||
if keep {
|
||
"history kept"
|
||
} else {
|
||
"source deleted"
|
||
}
|
||
),
|
||
data: Some(serde_json::json!({"faces_transferred": updated.rows_affected()})),
|
||
}))
|
||
}
|
||
|
||
// ============================================================================
|
||
// Router Setup
|
||
// ============================================================================
|
||
// Router Setup
|
||
// ============================================================================
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct ListIdentitiesParams {
|
||
pub search: Option<String>,
|
||
pub limit: Option<i32>,
|
||
pub offset: Option<i32>,
|
||
}
|
||
|
||
pub fn identity_binding_routes() -> Router<crate::api::server::AppState> {
|
||
Router::new()
|
||
.route("/api/v1/identity/:identity_uuid/bind", post(bind_identity))
|
||
.route(
|
||
"/api/v1/identity/:identity_uuid/unbind",
|
||
post(unbind_identity),
|
||
)
|
||
.route(
|
||
"/api/v1/identity/:from_uuid/mergeinto",
|
||
post(merge_identities),
|
||
)
|
||
}
|