Files
momentry_core/src/api/identity_binding.rs
2026-05-17 19:46:35 +08:00

308 lines
9.4 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.
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 BA 被刪除
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),
)
}