583 lines
18 KiB
Rust
583 lines
18 KiB
Rust
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<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, 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<UnbindIdentityRequest>,
|
||
) -> 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()})),
|
||
)
|
||
})?;
|
||
|
||
// Find the identity_id before unbinding to sync it later
|
||
let identity_id: Option<i64> = 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<String> = 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<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(&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<String>,
|
||
pub limit: Option<i32>,
|
||
pub offset: Option<i32>,
|
||
}
|
||
|
||
#[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<IdentityTraceInfo>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct TracesQuery {
|
||
pub page: Option<usize>,
|
||
pub page_size: Option<usize>,
|
||
}
|
||
|
||
pub async fn bind_identity_trace(
|
||
Path(identity_uuid): Path<String>,
|
||
Json(req): Json<BindIdentityTraceRequest>,
|
||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||
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<crate::api::types::AppState>,
|
||
Path(identity_uuid): Path<String>,
|
||
Query(params): Query<TracesQuery>,
|
||
) -> Result<Json<IdentityTracesResponse>, (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<IdentityTraceInfo> = 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<crate::api::types::AppState> {
|
||
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),
|
||
)
|
||
}
|