use axum::{ extract::{Path, Query, State}, http::StatusCode, response::Json, routing::{get, post}, Router, }; use serde::{Deserialize, Serialize}; use crate::core::db::{Database, PostgresDb}; use crate::core::person_identity::{ BindIdentityRequest, BindIdentityTraceRequest, Identity, MergeIdentitiesRequest, UnbindIdentityRequest, }; #[derive(Debug, Clone, Serialize)] pub struct ApiResponse { pub success: bool, pub message: String, pub data: Option, } // ============================================================================ // API Handlers // ============================================================================ async fn get_db() -> Result)> { 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, ) -> Result>>, (StatusCode, Json)> { 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, Json(req): Json, ) -> Result>, (StatusCode, Json)> { 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, stranger_id = NULL 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('-', ""); // Sync identity JSON file if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &uuid_clean).await { tracing::warn!( "[bind] Failed to sync 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, ) -> Result>, (StatusCode, Json)> { 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()})), ) })?; // Find the identity_id before unbinding to sync it later let identity_id: Option = sqlx::query_scalar(&format!( "SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2 AND identity_id IS NOT NULL", table )) .bind(&req.file_uuid) .bind(&req.face_id) .fetch_optional(&db) .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()})), ) })?; // Sync the identity JSON if we found an identity if let Some(id) = identity_id { let uuid: Option = sqlx::query_scalar(&format!( "SELECT uuid::text FROM {} WHERE id = $1", id_table )) .bind(id) .fetch_optional(&db) .await .ok() .flatten(); if let Some(identity_uuid) = uuid { if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &identity_uuid) .await { tracing::warn!( "[unbind] Failed to sync identity file for {}: {}", identity_uuid, e ); } } } 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(identity_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { 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(&identity_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, stranger_id = NULL 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()})), ) })?; } // Sync target identity JSON let into_uuid_clean = req.into_uuid.replace('-', ""); if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &into_uuid_clean).await { tracing::warn!( "[merge] Failed to sync target identity file for {}: {}", into_uuid_clean, e ); } // Delete source identity JSON if not keeping history if !keep { let from_uuid_clean = identity_uuid.replace('-', ""); let _ = crate::core::identity::storage::delete_identity_file(&from_uuid_clean); } 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, pub limit: Option, pub offset: Option, } #[derive(Debug, Serialize)] pub struct IdentityTraceInfo { pub file_uuid: String, pub trace_id: i32, pub frame_count: i64, pub first_frame: i32, pub last_frame: i32, pub first_sec: f64, pub last_sec: f64, pub avg_confidence: f64, } #[derive(Debug, Serialize)] pub struct IdentityTracesResponse { pub success: bool, pub identity_uuid: String, pub name: String, pub total: usize, pub page: usize, pub page_size: usize, pub total_faces: i64, pub traces: Vec, } #[derive(Debug, Deserialize)] pub struct TracesQuery { pub page: Option, pub page_size: Option, } pub async fn bind_identity_trace( Path(identity_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let fd_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()})), ) })?; 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": format!("DB error: {}", e)})), ) })?; let (identity_id, name) = identity_row.ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": format!("Identity not found: {}", identity_uuid)})), ) })?; let result = sqlx::query(&format!( "UPDATE {} SET identity_id = $1, stranger_id = NULL WHERE file_uuid = $2 AND trace_id = $3", fd_table )) .bind(identity_id) .bind(&req.file_uuid) .bind(req.trace_id) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Update failed: {}", e)})), ) })?; let uuid_clean = identity_uuid.replace('-', ""); if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &uuid_clean).await { tracing::warn!( "[bind/trace] Failed to sync identity file for {}: {}", uuid_clean, e ); } Ok(Json(ApiResponse { success: true, message: format!( "Bound trace {} of {} to {}", req.trace_id, req.file_uuid, name ), data: Some(serde_json::json!({"rows_affected": result.rows_affected()})), })) } pub async fn get_identity_traces( State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let id_table = crate::core::db::schema::table_name("identities"); let fd_table = crate::core::db::schema::table_name("face_detections"); let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let offset = ((page - 1) as i64) * (page_size as i64); // Get identity name let identity: Option<(i32, String)> = sqlx::query_as(&format!( "SELECT id, name FROM {} WHERE uuid = $1::uuid", id_table )) .bind(&identity_uuid) .fetch_optional(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let (identity_id, name) = identity.ok_or((StatusCode::NOT_FOUND, "Identity not found".to_string()))?; // Get paginated traces for this identity across all files let rows: Vec<(String, i32, i64, i32, i32, f64, f64, f64)> = sqlx::query_as(&format!( r#"SELECT fd.file_uuid::text, fd.trace_id, COUNT(*)::bigint AS frame_count, MIN(fd.frame_number)::int AS first_frame, MAX(fd.frame_number)::int AS last_frame, ROUND(MIN(fd.frame_number)::numeric / 25.0, 1)::float8 AS first_sec, ROUND(MAX(fd.frame_number)::numeric / 25.0, 1)::float8 AS last_sec, ROUND(AVG(fd.confidence)::numeric, 4)::float8 AS avg_confidence FROM {} fd WHERE fd.identity_id = $1 GROUP BY fd.file_uuid, fd.trace_id ORDER BY fd.file_uuid, fd.trace_id LIMIT $2 OFFSET $3"#, fd_table )) .bind(identity_id) .bind(page_size as i64) .bind(offset) .fetch_all(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Get total count for pagination let total: (i64,) = sqlx::query_as(&format!( "SELECT COUNT(*) FROM (SELECT 1 FROM {} fd WHERE fd.identity_id = $1 GROUP BY fd.file_uuid, fd.trace_id) sub", fd_table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let total_traces = total.0 as usize; let total_faces: i64 = rows.iter().map(|r| r.2).sum(); let traces: Vec = rows .into_iter() .map( |( file_uuid, trace_id, frame_count, first_frame, last_frame, first_sec, last_sec, avg_confidence, )| IdentityTraceInfo { file_uuid, trace_id, frame_count, first_frame, last_frame, first_sec, last_sec, avg_confidence, }, ) .collect(); Ok(Json(IdentityTracesResponse { success: true, identity_uuid, name, total: total_traces, page, page_size, total_faces, traces, })) } pub fn identity_binding_routes() -> Router { Router::new() .route("/api/v1/identity/:identity_uuid/bind", post(bind_identity)) .route( "/api/v1/identity/:identity_uuid/bind/trace", post(bind_identity_trace), ) .route( "/api/v1/identity/:identity_uuid/unbind", post(unbind_identity), ) .route( "/api/v1/identity/:identity_uuid/mergeinto", post(merge_identities), ) .route( "/api/v1/identity/:identity_uuid/traces", get(get_identity_traces), ) }