Files
momentry_core/src/api/identity_binding.rs
T
Accusys b98a362de5 refactor: cargo fmt across 27 files + behavioral fixes
Behavioral changes:
- postgres_db: reorder default processors (cut first), prevent overwriting completed status
- qdrant_db: fix scroll pagination exit condition (next.is_none())
- job_worker: add idempotency guards for face trace / TKG build; better error logging
- main.rs: add LineWriter for stdout buffering

Remaining diff is cargo fmt reformatting (line wrapping, import ordering).
2026-07-11 02:03:28 +08:00

2178 lines
72 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::{Extension, Path, Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::core::db::{Database, PostgresDb, QdrantDb};
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),
}))
}
/// 綁定 identity 到 face trace(V4.0: via Qdrant _faces collection)
pub async fn bind_identity(
State(state): State<crate::api::types::AppState>,
Extension(auth): Extension<crate::api::middleware::UserAuth>,
Path(identity_uuid): Path<String>,
Json(req): Json<BindIdentityRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("[bind_identity] req: {:?}", req);
let id_table = crate::core::db::schema::table_name("identities");
let history_table = crate::core::db::schema::table_name("identity_history");
let uuid_clean = identity_uuid.replace('-', "");
let identity_row: Option<(i32, String)> = sqlx::query_as(&format!(
"SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.fetch_optional(state.db.pool())
.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)})),
)
})?;
let face_identifier = match (&req.face_id, req.id) {
(Some(fid), _) => fid.clone(),
(None, Some(id)) => id.to_string(),
(None, None) => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Either face_id or id is required"})),
));
}
};
let use_id_field = req.id.is_some();
// Get trace_id from request
let trace_id: Option<i32> = req.trace_id;
// Update Qdrant _faces collection with identity_id (trace-scoped)
let qdrant = QdrantDb::new();
if let Some(tid) = trace_id {
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": req.file_uuid}},
{"key": "trace_id", "match": {"value": tid}}
]
});
let payload = serde_json::json!({
"identity_id": identity_id
});
if let Err(e) = qdrant
.update_payload_by_filter("_faces", filter, payload)
.await
{
tracing::warn!("[bind_identity] Failed to update Qdrant payload: {}", e);
} else {
tracing::info!(
"[bind_identity] Updated Qdrant _faces trace {} for {}",
tid,
req.file_uuid
);
}
} else {
tracing::warn!(
"[bind_identity] No trace_id found for {} / {}",
req.file_uuid,
face_identifier
);
}
// Update TKG if trace_id exists
if let Some(tid) = trace_id {
let tkg_table = crate::core::db::schema::table_name("tkg_nodes");
let ext_id = format!("face_track_{}", tid);
let identity_ref = format!("{}:identity_{}", req.file_uuid, identity_id);
let _ = sqlx::query(&format!(
"UPDATE {} SET properties = properties || $1::jsonb - 'stranger_ref' \
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
tkg_table
))
.bind(serde_json::json!({
"identity_uuid": uuid_clean,
"identity_ref": identity_ref
}))
.bind(&req.file_uuid)
.bind(&ext_id)
.execute(state.db.pool())
.await;
}
// Clear bind redo stack
let _ = sqlx::query(&format!(
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
history_table
))
.bind(identity_id)
.execute(state.db.pool())
.await;
// Insert history record
let uid = auth.user_id.to_string();
let usrc = match auth.source {
crate::api::middleware::AuthSource::Jwt => "jwt",
crate::api::middleware::AuthSource::Session => "session",
crate::api::middleware::AuthSource::ApiKey => "api_key",
};
let before = serde_json::json!({
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_before": None::<i32> as Option<i32>
});
let after = serde_json::json!({
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_after": identity_id
});
let _ = sqlx::query(&format!(
"INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'bind', $2, $3, false, $4, $5)",
history_table
))
.bind(identity_id)
.bind(before)
.bind(after)
.bind(&uid)
.bind(usrc)
.execute(state.db.pool())
.await;
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &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 {}",
face_identifier, req.file_uuid, name
),
data: Some(serde_json::json!({"trace_id": trace_id})),
}))
}
/// 解綁 identity 從 face trace(V4.0: via Qdrant _faces collection)
pub async fn unbind_identity(
State(state): State<crate::api::types::AppState>,
Extension(auth): Extension<crate::api::middleware::UserAuth>,
Json(req): Json<UnbindIdentityRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let id_table = crate::core::db::schema::table_name("identities");
let history_table = crate::core::db::schema::table_name("identity_history");
let face_identifier = match (&req.face_id, req.id) {
(Some(fid), _) => fid.clone(),
(None, Some(id)) => id.to_string(),
(None, None) => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Either face_id or id is required"})),
));
}
};
let use_id_field = req.id.is_some();
// Get old binding from Qdrant _faces
let qdrant = QdrantDb::new();
let old_binding: Option<(Option<i32>, Option<i32>)> = if let Some(tid) = req.trace_id {
// Direct trace_id lookup
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": req.file_uuid}},
{"key": "trace_id", "match": {"value": tid}}
]
});
let points = qdrant
.scroll_points("_faces", filter, 1, None)
.await
.unwrap_or_default();
points.0.first().map(|p| {
let identity_id = p["payload"]["identity_id"].as_i64().map(|i| i as i32);
(identity_id, Some(tid))
})
} else if use_id_field {
// id lookup not supported in Qdrant
tracing::warn!("[unbind_identity] id field lookup not supported, use trace_id instead");
None
} else {
// face_id lookup not supported in Qdrant
tracing::warn!(
"[unbind_identity] face_id field lookup not supported, use trace_id instead"
);
None
};
let (old_identity_id, trace_id) = old_binding.unzip();
let trace_id = trace_id.flatten();
// Update Qdrant _faces collection: clear identity_id (trace-scoped)
if let Some(tid) = trace_id {
let qdrant = QdrantDb::new();
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": req.file_uuid}},
{"key": "trace_id", "match": {"value": tid}}
]
});
let payload = serde_json::json!({
"identity_id": serde_json::Value::Null
});
if let Err(e) = qdrant
.update_payload_by_filter("_faces", filter, payload)
.await
{
tracing::warn!("[unbind_identity] Failed to update Qdrant payload: {}", e);
} else {
tracing::info!(
"[unbind_identity] Cleared identity_id in Qdrant _faces trace {} for {}",
tid,
req.file_uuid
);
}
} else {
tracing::warn!(
"[unbind_identity] No trace_id found for {} / {}",
req.file_uuid,
face_identifier
);
}
// Update TKG: restore stranger_ref and remove identity fields
if let Some(tid) = trace_id {
let tkg_table = crate::core::db::schema::table_name("tkg_nodes");
let ext_id = format!("face_track_{}", tid);
let stranger_ref = format!("{}:stranger_trace_{}", req.file_uuid, tid);
let _ = sqlx::query(&format!(
"UPDATE {} SET properties = properties || $1::jsonb - 'identity_uuid' - 'identity_ref' - 'identity_id' - 'identity_name' \
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
tkg_table
))
.bind(serde_json::json!({
"stranger_ref": stranger_ref
}))
.bind(&req.file_uuid)
.bind(&ext_id)
.execute(state.db.pool())
.await;
}
if let Some(identity_id) = old_identity_id {
let _ = sqlx::query(&format!(
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
history_table
))
.bind(identity_id)
.execute(state.db.pool())
.await;
let uid = auth.user_id.to_string();
let usrc = match auth.source {
crate::api::middleware::AuthSource::Jwt => "jwt",
crate::api::middleware::AuthSource::Session => "session",
crate::api::middleware::AuthSource::ApiKey => "api_key",
};
let before = serde_json::json!({
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_before": old_identity_id
});
let after = serde_json::json!({
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_after": serde_json::Value::Null
});
let _ = sqlx::query(&format!(
"INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'unbind', $2, $3, false, $4, $5)",
history_table
))
.bind(identity_id)
.bind(before)
.bind(after)
.bind(&uid)
.bind(usrc)
.execute(state.db.pool())
.await;
// Sync the identity JSON
let uuid: Option<String> = sqlx::query_scalar(&format!(
"SELECT uuid::text FROM {} WHERE id = $1",
id_table
))
.bind(identity_id)
.fetch_optional(state.db.pool())
.await
.ok()
.flatten();
if let Some(identity_uuid) = uuid {
if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(
state.db.pool(),
&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 {}", face_identifier, req.file_uuid),
data: Some(serde_json::json!({"trace_id": trace_id})),
}))
}
fn deep_merge_metadata_fields(
from: &serde_json::Map<String, serde_json::Value>,
into: &mut serde_json::Map<String, serde_json::Value>,
added: &mut Vec<String>,
prefix: &str,
) {
for (key, value) in from.iter() {
if key == "aliases" {
continue;
}
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
if !into.contains_key(key) {
into.insert(key.clone(), value.clone());
added.push(path);
} else if let (Some(from_obj), Some(into_obj)) = (value.as_object(), into[key].as_object())
{
let mut merged = into_obj.clone();
deep_merge_metadata_fields(from_obj, &mut merged, added, &path);
into.insert(key.clone(), serde_json::Value::Object(merged));
}
}
}
fn remove_nested_meta_field(value: &mut serde_json::Value, parts: &[&str]) {
if parts.is_empty() {
return;
}
if parts.len() == 1 {
if let Some(obj) = value.as_object_mut() {
obj.remove(parts[0]);
}
return;
}
if let Some(child) = value.get_mut(parts[0]) {
remove_nested_meta_field(child, &parts[1..]);
if let Some(obj) = value.get(parts[0]).and_then(|v| v.as_object()) {
if obj.is_empty() {
if let Some(parent_obj) = value.as_object_mut() {
parent_obj.remove(parts[0]);
}
}
}
}
}
/// V4.0 合併:將 identity A 合併入 identity B,A 被刪除或標記為 merged
pub async fn merge_identities(
Extension(auth): Extension<crate::api::middleware::UserAuth>,
Path(identity_uuid): Path<String>,
Json(req): Json<MergeIdentitiesRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
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 uuid_clean = identity_uuid.replace('-', "");
let into_uuid_clean = req.into_uuid.replace('-', "");
// Get full source identity snapshot
let source_row: Option<(
i32,
String,
Option<String>,
Option<String>,
String,
Option<i32>,
Option<String>,
Option<serde_json::Value>,
Option<chrono::NaiveDateTime>,
)> = sqlx::query_as(&format!(
"SELECT id, name, identity_type, source, status, tmdb_id, tmdb_profile, metadata, created_at FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.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_type,
from_source,
from_status,
from_tmdb_id,
from_tmdb_profile,
from_metadata,
from_created_at,
) = source_row.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Source identity not found"})),
))?;
// Get full target identity snapshot
let target_row: Option<(i32, String, Option<serde_json::Value>)> = sqlx::query_as(&format!(
"SELECT id, name, metadata FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&into_uuid_clean)
.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_metadata) = target_row.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Target identity not found"})),
))?;
// Get face_ids that will be transferred (from Qdrant _faces)
let qdrant = QdrantDb::new();
let face_filter = serde_json::json!({
"must": [
{"key": "identity_id", "match": {"value": from_id}}
]
});
let points = qdrant
.scroll_all_points("_faces", face_filter, 1000)
.await
.unwrap_or_default();
let face_ids: Vec<(Option<String>, Option<i32>)> = points
.iter()
.map(|p| {
let face_id = p["payload"]
.get("face_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let trace_id = p["payload"]["trace_id"].as_i64().map(|t| t as i32);
(face_id, trace_id)
})
.collect();
let face_id_list: Vec<String> = face_ids.iter().filter_map(|(f, _)| f.clone()).collect();
let trace_id_list: Vec<i32> = face_ids.iter().filter_map(|(_, t)| *t).collect();
let faces_count = face_id_list.len() as i64;
// Get file_uuid for these faces (from Qdrant)
let file_uuid: Option<String> = points
.first()
.and_then(|p| p["payload"]["file_uuid"].as_str())
.map(|s| s.to_string());
// Capture target metadata BEFORE merge
let into_meta_before = into_metadata.clone().unwrap_or(serde_json::json!({}));
// Transfer all face bindings from source → target via Qdrant _faces
let qdrant = QdrantDb::new();
let transfer_filter = serde_json::json!({
"must": [
{"key": "identity_id", "match": {"value": from_id}}
]
});
let transfer_payload = serde_json::json!({
"identity_id": into_id,
"stranger_id": serde_json::Value::Null
});
if let Err(e) = qdrant
.update_payload_by_filter("_faces", transfer_filter, transfer_payload)
.await
{
tracing::warn!("[merge] Qdrant transfer failed: {}", e);
}
// Merge text: source name → target aliases, source aliases → target aliases, source metadata → target metadata
let from_meta = from_metadata.clone();
let into_meta = into_metadata.clone();
let mut merged_meta = into_meta.clone().unwrap_or(serde_json::json!({}));
let from_meta_obj = from_meta
.as_ref()
.and_then(|m| m.as_object())
.cloned()
.unwrap_or_default();
let into_meta_obj = into_meta
.as_ref()
.and_then(|m| m.as_object())
.cloned()
.unwrap_or_default();
// Track aliases added for undo
let mut aliases_added: Vec<crate::core::db::AliasEntry> = Vec::new();
let mut metadata_fields_added: Vec<String> = Vec::new();
// Merge aliases
let mut aliases = into_meta_obj
.get("aliases")
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default();
// Add source name as alias (if not already present)
let from_name_str = from_name.clone();
if !aliases
.iter()
.any(|a| a.get("name").and_then(|n| n.as_str()) == Some(&from_name_str))
{
aliases.push(serde_json::json!({"name": from_name_str, "locale": "en", "source": "merge"}));
aliases_added.push(crate::core::db::AliasEntry {
name: from_name_str,
locale: "en".to_string(),
source: Some("merge".to_string()),
});
}
// Add source aliases (if not already present)
if let Some(from_aliases) = from_meta_obj.get("aliases").and_then(|a| a.as_array()) {
for fa in from_aliases {
let fa_name = fa.get("name").and_then(|n| n.as_str()).unwrap_or("");
let fa_locale = fa.get("locale").and_then(|l| l.as_str()).unwrap_or("en");
if !aliases
.iter()
.any(|a| a.get("name").and_then(|n| n.as_str()) == Some(fa_name))
{
aliases.push(fa.clone());
aliases_added.push(crate::core::db::AliasEntry {
name: fa_name.to_string(),
locale: fa_locale.to_string(),
source: None,
});
}
}
}
merged_meta["aliases"] = serde_json::Value::Array(aliases);
// Deep-merge other metadata fields (recursive for nested objects)
if let Some(ref mut merged_map) = merged_meta.as_object_mut() {
deep_merge_metadata_fields(&from_meta_obj, merged_map, &mut metadata_fields_added, "");
}
// Update target metadata
sqlx::query(&format!(
"UPDATE {} SET metadata = $1 WHERE id = $2",
id_table
))
.bind(&merged_meta)
.bind(into_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
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 _ = crate::core::identity::storage::delete_identity_file(&uuid_clean);
}
// Generate merge_id and store to MongoDB
let merge_id = crate::core::db::IdentityMergeHistoryStore::generate_merge_id();
let now = chrono::Utc::now();
let undo_deadline = now + chrono::Duration::hours(24);
// Create MongoDB history record
let merge_history = crate::core::db::IdentityMergeHistory {
id: None,
merge_id: merge_id.clone(),
source_identity: crate::core::db::IdentitySnapshot {
id: from_id as i64,
uuid: uuid_clean.clone(),
name: from_name.clone(),
identity_type: from_type,
source: from_source,
status: from_status,
tmdb_id: from_tmdb_id.map(|id| id as i64),
tmdb_profile: from_tmdb_profile,
metadata: from_metadata.unwrap_or(serde_json::json!({})),
created_at: from_created_at
.map(|dt| chrono::DateTime::from_naive_utc_and_offset(dt, chrono::Utc)),
face_count: faces_count,
},
target_identity: crate::core::db::TargetIdentitySnapshot {
id: into_id as i64,
uuid: into_uuid_clean.clone(),
name: into_name.clone(),
metadata_before: into_meta_before,
metadata_after: Some(merged_meta.clone()),
},
aliases_added_to_target: aliases_added.clone(),
metadata_fields_added: metadata_fields_added.clone(),
faces_transferred: crate::core::db::FacesTransferred {
file_uuid: file_uuid.unwrap_or_default(),
face_ids: face_id_list,
trace_ids: trace_id_list,
count: faces_count,
},
merge_params: crate::core::db::MergeParams {
keep_history: keep,
cleared_stranger_id: true,
performed_by_user: Some(auth.user_id.to_string()),
},
merged_at: now,
undo_deadline,
undone: false,
undone_at: None,
undone_by: None,
undone_snapshot: None,
undo_expired: false,
};
// Store to MongoDB (non-blocking, warn on failure)
let mongo_store = crate::core::db::IdentityMergeHistoryStore::init().await;
if let Ok(store) = mongo_store {
if let Err(e) = store.store_merge_history(&merge_history).await {
tracing::warn!("[merge] Failed to store merge history in MongoDB: {}", e);
}
} else {
tracing::warn!(
"[merge] Failed to init MongoDB store: {:?}",
mongo_store.err()
);
}
Ok(Json(ApiResponse {
success: true,
message: format!(
"Merged '{}' into '{}' ({} faces transferred, {})",
from_name,
into_name,
faces_count,
if keep {
"history kept"
} else {
"source deleted"
}
),
data: Some(serde_json::json!({
"merge_id": merge_id,
"faces_transferred": faces_count,
"aliases_added": aliases_added.len(),
"metadata_fields_added": metadata_fields_added.len()
})),
}))
}
// ============================================================================
// 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>,
}
/// 綁定 identity 到整個 trace(V4.0: via Qdrant _faces collection)
pub async fn bind_identity_trace(
State(state): State<crate::api::types::AppState>,
Extension(auth): Extension<crate::api::middleware::UserAuth>,
Path(identity_uuid): Path<String>,
Json(req): Json<BindIdentityTraceRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let id_table = crate::core::db::schema::table_name("identities");
let history_table = crate::core::db::schema::table_name("identity_history");
let uuid_clean = identity_uuid.replace('-', "");
let identity_row: Option<(i32, String)> = sqlx::query_as(&format!(
"SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.fetch_optional(state.db.pool())
.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)})),
)
})?;
// Update Qdrant _faces collection with identity_id (trace-scoped)
let qdrant = QdrantDb::new();
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": req.file_uuid}},
{"key": "trace_id", "match": {"value": req.trace_id}}
]
});
let payload = serde_json::json!({
"identity_id": identity_id
});
if let Err(e) = qdrant
.update_payload_by_filter("_faces", filter, payload)
.await
{
tracing::warn!(
"[bind_identity_trace] Failed to update Qdrant payload: {}",
e
);
} else {
tracing::info!(
"[bind_identity_trace] Updated Qdrant _faces trace {} for {}",
req.trace_id,
req.file_uuid
);
}
// Update TKG node properties
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
let external_id = format!("face_track_{}", req.trace_id);
let identity_name: Option<String> =
sqlx::query_scalar(&format!("SELECT name FROM {} WHERE id = $1", id_table))
.bind(identity_id)
.fetch_optional(state.db.pool())
.await
.ok()
.flatten();
let _ = sqlx::query(&format!(
"UPDATE {} SET properties = jsonb_set(\
jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\
'{{identity_name}}', $2::jsonb, false)\
WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4",
nodes_table
))
.bind(identity_id)
.bind(identity_name.as_deref())
.bind(&req.file_uuid)
.bind(&external_id)
.execute(state.db.pool())
.await;
// Clear bind redo stack
let _ = sqlx::query(&format!(
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
history_table
))
.bind(identity_id)
.execute(state.db.pool())
.await;
// Insert history record
let uid = auth.user_id.to_string();
let usrc = match auth.source {
crate::api::middleware::AuthSource::Jwt => "jwt",
crate::api::middleware::AuthSource::Session => "session",
crate::api::middleware::AuthSource::ApiKey => "api_key",
};
let before = serde_json::json!({
"file_uuid": req.file_uuid, "trace_id": req.trace_id, "identity_id_before": None::<i32> as Option<i32>
});
let after = serde_json::json!({
"file_uuid": req.file_uuid, "trace_id": req.trace_id, "identity_id_after": identity_id
});
let _ = sqlx::query(&format!(
"INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'bind_trace', $2, $3, false, $4, $5)",
history_table
))
.bind(identity_id)
.bind(before)
.bind(after)
.bind(&uid)
.bind(usrc)
.execute(state.db.pool())
.await;
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &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!({"trace_id": req.trace_id})),
}))
}
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 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 uuid_clean = identity_uuid.replace('-', "");
let identity: Option<(i32, String)> = sqlx::query_as(&format!(
"SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.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 traces for this identity from Qdrant _faces
let qdrant = QdrantDb::new();
let scroll_filter = serde_json::json!({
"must": [
{"key": "identity_id", "match": {"value": identity_id}}
]
});
let all_points = qdrant
.scroll_all_points("_faces", scroll_filter, 1000)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Group by (file_uuid, trace_id) and aggregate
use std::collections::BTreeMap;
let mut trace_map: BTreeMap<(String, i32), Vec<&serde_json::Value>> = BTreeMap::new();
for point in &all_points {
let payload = &point["payload"];
if let (Some(fu), Some(tid)) = (payload["file_uuid"].as_str(), payload["trace_id"].as_i64())
{
trace_map
.entry((fu.to_string(), tid as i32))
.or_default()
.push(point);
}
}
let total_traces = trace_map.len();
let trace_vec: Vec<((String, i32), Vec<&serde_json::Value>)> = trace_map.into_iter().collect();
let paginated: Vec<_> = trace_vec
.into_iter()
.skip(offset as usize)
.take(page_size)
.collect();
// Look up FPS for each unique file_uuid
let mut fps_cache: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
let video_table = crate::core::db::schema::table_name("videos");
for ((fu, _), _) in &paginated {
if !fps_cache.contains_key(fu) {
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1",
video_table
))
.bind(fu)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(30.0);
fps_cache.insert(fu.clone(), fps);
}
}
let mut traces = Vec::new();
let mut total_faces: i64 = 0;
for ((file_uuid, trace_id), points) in &paginated {
let frame_count = points.len() as i64;
total_faces += frame_count;
let first_frame = points
.iter()
.filter_map(|p| p["payload"]["frame"].as_i64())
.min()
.unwrap_or(0) as i32;
let last_frame = points
.iter()
.filter_map(|p| p["payload"]["frame"].as_i64())
.max()
.unwrap_or(0) as i32;
let avg_confidence = points
.iter()
.filter_map(|p| p["payload"]["confidence"].as_f64())
.sum::<f64>()
/ frame_count as f64;
let fps = fps_cache.get(file_uuid).copied().unwrap_or(30.0);
traces.push(IdentityTraceInfo {
file_uuid: file_uuid.clone(),
trace_id: *trace_id,
frame_count,
first_frame,
last_frame,
first_sec: if fps > 0.0 {
first_frame as f64 / fps
} else {
0.0
},
last_sec: if fps > 0.0 {
last_frame as f64 / fps
} else {
0.0
},
avg_confidence: (avg_confidence * 10000.0).round() / 10000.0,
});
}
traces.sort_by(|a, b| a.trace_id.cmp(&b.trace_id));
Ok(Json(IdentityTracesResponse {
success: true,
identity_uuid,
name,
total: total_traces,
page,
page_size,
total_faces,
traces,
}))
}
/// Undo a merge operation within 24 hours
pub async fn undo_merge(
Extension(auth): Extension<crate::api::middleware::UserAuth>,
Path(merge_id): Path<String>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let id_table = crate::core::db::schema::table_name("identities");
// Get merge history from MongoDB
let mongo_store = crate::core::db::IdentityMergeHistoryStore::init()
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("MongoDB init failed: {}", e)})),
)
})?;
let history = mongo_store
.get_merge_history(&merge_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("MongoDB query failed: {}", e)})),
)
})?;
let history = history.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Merge record not found"})),
))?;
// Check if already undone
if history.undone {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Merge already undone"})),
));
}
// Check 24hr deadline
let now = chrono::Utc::now();
let now_naive = now.naive_utc();
if now > history.undo_deadline {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Undo deadline expired",
"merged_at": history.merged_at.to_rfc3339(),
"undo_deadline": history.undo_deadline.to_rfc3339()
})),
));
}
// Connect to PostgreSQL
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()})),
)
})?;
// Restore source identity (or recreate if keep_history=false)
let mut source_id = history.source_identity.id;
let source_uuid = history.source_identity.uuid.clone();
if history.merge_params.keep_history {
// Source identity still exists, just restore status
sqlx::query(&format!(
"UPDATE {} SET status = 'confirmed', metadata = $1 WHERE id = $2",
id_table
))
.bind(&history.source_identity.metadata)
.bind(source_id as i32)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
} else {
// Need to recreate source identity
let source_uuid_parsed = uuid::Uuid::parse_str(&source_uuid).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Invalid UUID format: {}", e)})),
)
})?;
let new_id: i32 = sqlx::query_scalar(&format!(
"INSERT INTO {} (uuid, name, identity_type, source, status, tmdb_id, tmdb_profile, metadata, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id",
id_table
))
.bind(source_uuid_parsed)
.bind(&history.source_identity.name)
.bind(&history.source_identity.identity_type)
.bind(&history.source_identity.source)
.bind("confirmed")
.bind(history.source_identity.tmdb_id.map(|id| id as i32))
.bind(&history.source_identity.tmdb_profile)
.bind(&history.source_identity.metadata)
.bind(history.source_identity.created_at.map(|dt| dt.naive_utc()).unwrap_or(now_naive))
.fetch_one(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to recreate source identity: {}", e)})),
)
})?;
source_id = new_id as i64;
}
// Restore faces to source identity via Qdrant _faces
let qdrant = QdrantDb::new();
for &tid in &history.faces_transferred.trace_ids {
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": history.faces_transferred.file_uuid}},
{"key": "trace_id", "match": {"value": tid}}
]
});
let payload = serde_json::json!({"identity_id": source_id as i32});
let _ = qdrant
.update_payload_by_filter("_faces", filter, payload)
.await;
}
let faces_reverted_count = history.faces_transferred.count;
// Restore target metadata (precise removal)
let target_meta: Option<serde_json::Value> =
sqlx::query_scalar(&format!("SELECT metadata FROM {} WHERE id = $1", id_table))
.bind(history.target_identity.id as i32)
.fetch_optional(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let mut restored_meta = target_meta.unwrap_or(serde_json::json!({}));
// Remove aliases that were added from source
let aliases_to_remove = &history.aliases_added_to_target;
if let Some(aliases) = restored_meta
.get("aliases")
.and_then(|a| a.as_array())
.cloned()
{
let new_aliases: Vec<serde_json::Value> = aliases
.into_iter()
.filter(|alias| {
let alias_name = alias.get("name").and_then(|n| n.as_str()).unwrap_or("");
let alias_source = alias.get("source").and_then(|s| s.as_str());
!aliases_to_remove.iter().any(|to_remove| {
to_remove.name == alias_name && to_remove.source == Some("merge".to_string())
}) && alias_source != Some("merge")
})
.collect();
if let Some(obj) = restored_meta.as_object_mut() {
obj.insert("aliases".to_string(), serde_json::Value::Array(new_aliases));
}
}
// Remove metadata fields that were added from source (handles nested paths)
for field in &history.metadata_fields_added {
let parts: Vec<&str> = field.split('.').collect();
remove_nested_meta_field(&mut restored_meta, &parts);
}
// Update target metadata
sqlx::query(&format!(
"UPDATE {} SET metadata = $1 WHERE id = $2",
id_table
))
.bind(&restored_meta)
.bind(history.target_identity.id as i32)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
// Sync identity JSON files
let source_uuid_clean = source_uuid.replace('-', "");
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(&db, &source_uuid_clean).await
{
tracing::warn!("[undo] Failed to sync source identity file: {}", e);
}
let target_uuid_clean = history.target_identity.uuid.replace('-', "");
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(&db, &target_uuid_clean).await
{
tracing::warn!("[undo] Failed to sync target identity file: {}", e);
}
// Mark as undone in MongoDB with snapshot for potential redo
let undone_snapshot = crate::core::db::UndoneSnapshot {
source_identity_id: source_id,
source_uuid: source_uuid.clone(),
source_name: history.source_identity.name.clone(),
target_metadata_at_undo: restored_meta.clone(),
};
mongo_store
.mark_as_undone(&merge_id, Some(&auth.user_id.to_string()), undone_snapshot)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to mark as undone: {}", e)})),
)
})?;
Ok(Json(ApiResponse {
success: true,
message: format!(
"Undo merge completed: '{}' restored, {} faces reverted",
history.source_identity.name, faces_reverted_count
),
data: Some(serde_json::json!({
"source_identity_restored": {
"uuid": source_uuid,
"name": history.source_identity.name,
"status": "confirmed"
},
"faces_reverted": faces_reverted_count,
"aliases_removed_from_target": history.aliases_added_to_target.len(),
"metadata_fields_removed_from_target": history.metadata_fields_added.len()
})),
}))
}
/// Query merge history
#[derive(Debug, Deserialize)]
pub struct MergeHistoryQueryParams {
pub source_uuid: Option<String>,
pub target_uuid: Option<String>,
pub merge_id: Option<String>,
pub undone: Option<bool>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct MergeHistoryResponse {
pub success: bool,
pub total: u64,
pub page: u32,
pub page_size: u32,
pub results: Vec<crate::core::db::MergeHistoryEntry>,
}
pub async fn get_merge_history(
Query(params): Query<MergeHistoryQueryParams>,
) -> Result<Json<MergeHistoryResponse>, (StatusCode, Json<serde_json::Value>)> {
let mongo_store = crate::core::db::IdentityMergeHistoryStore::init()
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("MongoDB init failed: {}", e)})),
)
})?;
let query = crate::core::db::MergeHistoryQuery {
source_uuid: params.source_uuid,
target_uuid: params.target_uuid,
merge_id: params.merge_id,
undone: params.undone,
};
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let (results, total) = mongo_store
.query_merge_history(query, page, page_size)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("MongoDB query failed: {}", e)})),
)
})?;
Ok(Json(MergeHistoryResponse {
success: true,
total,
page,
page_size,
results,
}))
}
pub async fn redo_merge(
Extension(auth): Extension<crate::api::middleware::UserAuth>,
Path(merge_id): Path<String>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let id_table = crate::core::db::schema::table_name("identities");
let mongo_store = crate::core::db::IdentityMergeHistoryStore::init()
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("MongoDB init failed: {}", e)})),
)
})?;
let history = mongo_store
.get_merge_history(&merge_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("MongoDB query failed: {}", e)})),
)
})?;
let history = history.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Merge record not found"})),
))?;
if !history.undone {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Merge is not undone, nothing to redo"})),
));
}
let now = chrono::Utc::now();
if now > history.undo_deadline {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Redo deadline expired",
"undo_deadline": history.undo_deadline.to_rfc3339()
})),
));
}
let snapshot = history.undone_snapshot.as_ref().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Undone snapshot not found, cannot redo"})),
))?;
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()})),
)
})?;
// Transfer faces from source back to target via Qdrant _faces
let qdrant = QdrantDb::new();
let filter = serde_json::json!({
"must": [
{"key": "identity_id", "match": {"value": snapshot.source_identity_id as i32}}
]
});
let payload = serde_json::json!({
"identity_id": history.target_identity.id as i32,
"stranger_id": serde_json::Value::Null
});
let _ = qdrant
.update_payload_by_filter("_faces", filter, payload)
.await;
let faces_redone_count = history.faces_transferred.count;
// Build target metadata: start from undone_snapshot (clean state after undo)
let mut target_meta = snapshot.target_metadata_at_undo.clone();
let meta_obj = target_meta.as_object_mut().unwrap();
// Add aliases from source
let source_meta = &history.source_identity.metadata;
let existing_aliases = meta_obj
.get("aliases")
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default();
let mut new_aliases = existing_aliases.clone();
for alias_added in &history.aliases_added_to_target {
let already_exists = new_aliases.iter().any(|a| {
a.get("name").and_then(|n| n.as_str()) == Some(&alias_added.name)
&& a.get("locale").and_then(|l| l.as_str()) == Some(&alias_added.locale)
});
if !already_exists {
new_aliases.push(serde_json::json!({
"name": alias_added.name,
"locale": alias_added.locale,
"source": "merge"
}));
}
}
meta_obj.insert("aliases".into(), serde_json::Value::Array(new_aliases));
// Add metadata fields from source
for field in &history.metadata_fields_added {
if let Some(value) = source_meta.get(field) {
meta_obj.insert(field.clone(), value.clone());
}
}
// Update target metadata
sqlx::query(&format!(
"UPDATE {} SET metadata = $1 WHERE id = $2",
id_table
))
.bind(&target_meta)
.bind(history.target_identity.id as i32)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
// Handle source identity
if history.merge_params.keep_history {
sqlx::query(&format!(
"UPDATE {} SET status = 'merged' WHERE id = $1",
id_table
))
.bind(snapshot.source_identity_id as i32)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
} else {
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table))
.bind(snapshot.source_identity_id as i32)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
}
// Sync identity JSON files
let source_uuid_clean = snapshot.source_uuid.replace('-', "");
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(&db, &source_uuid_clean).await
{
tracing::warn!("[redo] Failed to sync source identity file: {}", e);
}
let target_uuid_clean = history.target_identity.uuid.replace('-', "");
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(&db, &target_uuid_clean).await
{
tracing::warn!("[redo] Failed to sync target identity file: {}", e);
}
// Mark as redone in MongoDB
mongo_store
.mark_as_redone(&merge_id, Some(&auth.user_id.to_string()))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to mark as redone: {}", e)})),
)
})?;
Ok(Json(ApiResponse {
success: true,
message: format!(
"Redo merge completed: '{}' merged into '{}', {} faces transferred",
snapshot.source_name, history.target_identity.name, faces_redone_count
),
data: Some(serde_json::json!({
"merge_id": merge_id,
"faces_transferred": faces_redone_count,
"aliases_added": history.aliases_added_to_target.len(),
"metadata_fields_added": history.metadata_fields_added.len()
})),
}))
}
// ── Bind Undo/Redo/History ──────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct BindUndoRequest {
steps: Option<usize>,
}
#[derive(Debug, Serialize)]
struct BindUndoResponse {
success: bool,
identity_uuid: String,
operation: String,
undone_count: usize,
affected_rows: i64,
}
async fn apply_bind_snapshot(
_pool: &sqlx::PgPool,
_face_table: &str,
snapshot: &serde_json::Value,
identity_id_value: Option<serde_json::Value>,
) -> Result<i64, (StatusCode, Json<serde_json::Value>)> {
let file_uuid = snapshot
.get("file_uuid")
.and_then(|v| v.as_str())
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Missing file_uuid in snapshot"})),
)
})?;
let id_val = match identity_id_value {
Some(val) => val.as_i64(),
None => None,
};
if let Some(trace_id) = snapshot.get("trace_id").and_then(|v| v.as_i64()) {
// trace_id path: update Qdrant _faces collection
let qdrant = QdrantDb::new();
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": trace_id}}
]
});
let payload = serde_json::json!({
"identity_id": id_val
});
qdrant
.update_payload_by_filter("_faces", filter, payload)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
Ok(1)
} else {
// Legacy face_id path not supported - no trace_id in snapshot
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(
serde_json::json!({"error": "Snapshot has no trace_id, legacy face_id path not supported"}),
),
))
}
}
pub async fn bind_undo(
State(state): State<crate::api::types::AppState>,
Extension(auth): Extension<crate::api::middleware::UserAuth>,
Path(identity_uuid): Path<String>,
Json(req): Json<BindUndoRequest>,
) -> Result<Json<BindUndoResponse>, (StatusCode, Json<serde_json::Value>)> {
let uuid_clean = identity_uuid.replace('-', "");
let steps = req.steps.unwrap_or(1).max(1);
let id_table = crate::core::db::schema::table_name("identities");
let history_table = crate::core::db::schema::table_name("identity_history");
let identity_id: i32 = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("DB error: {}", e)})),
)
})?
.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Identity not found"})),
))?;
let records: Vec<(i64, String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT id, operation, before_snapshot FROM {} WHERE identity_id = $1 AND is_undone = false AND operation IN ('bind','unbind','bind_trace') ORDER BY created_at DESC LIMIT $2",
history_table
))
.bind(identity_id)
.bind(steps as i64)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to get history: {}", e)})),
)
})?;
if records.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "No bind undo operations available"})),
));
}
let operation = records.last().unwrap().1.clone();
let before = &records.last().unwrap().2;
let identity_id_before = before.get("identity_id_before").cloned();
let affected_rows =
apply_bind_snapshot(state.db.pool(), "", before, identity_id_before).await?;
// Mark all as undone
for (history_id, _, _) in &records {
let _ = sqlx::query(&format!(
"UPDATE {} SET is_undone = true, undone_at = NOW() WHERE id = $1",
history_table
))
.bind(history_id)
.execute(state.db.pool())
.await;
}
Ok(Json(BindUndoResponse {
success: true,
identity_uuid: uuid_clean,
operation,
undone_count: records.len(),
affected_rows,
}))
}
pub async fn bind_redo(
State(state): State<crate::api::types::AppState>,
Extension(auth): Extension<crate::api::middleware::UserAuth>,
Path(identity_uuid): Path<String>,
Json(req): Json<BindUndoRequest>,
) -> Result<Json<BindUndoResponse>, (StatusCode, Json<serde_json::Value>)> {
let uuid_clean = identity_uuid.replace('-', "");
let steps = req.steps.unwrap_or(1).max(1);
let id_table = crate::core::db::schema::table_name("identities");
let history_table = crate::core::db::schema::table_name("identity_history");
let identity_id: i32 = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("DB error: {}", e)})),
)
})?
.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Identity not found"})),
))?;
let records: Vec<(i64, String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT id, operation, after_snapshot FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace') ORDER BY created_at DESC LIMIT $2",
history_table
))
.bind(identity_id)
.bind(steps as i64)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to get history: {}", e)})),
)
})?;
if records.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "No bind redo operations available"})),
));
}
let operation = records.last().unwrap().1.clone();
let after = &records.last().unwrap().2;
let identity_id_after = after.get("identity_id_after").cloned();
let affected_rows = apply_bind_snapshot(state.db.pool(), "", after, identity_id_after).await?;
// Mark all as redone
for (history_id, _, _) in &records {
let _ = sqlx::query(&format!(
"UPDATE {} SET is_undone = false, undone_at = NULL WHERE id = $1",
history_table
))
.bind(history_id)
.execute(state.db.pool())
.await;
}
Ok(Json(BindUndoResponse {
success: true,
identity_uuid: uuid_clean,
operation,
undone_count: records.len(),
affected_rows,
}))
}
#[derive(Debug, Serialize)]
struct BindHistoryItem {
history_id: i64,
operation: String,
is_undone: bool,
created_at: Option<chrono::DateTime<chrono::Utc>>,
undone_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize)]
struct BindHistoryResponse {
success: bool,
identity_uuid: String,
total: i64,
undo_stack_count: i64,
redo_stack_count: i64,
results: Vec<BindHistoryItem>,
}
#[derive(Debug, Deserialize)]
struct BindHistoryQuery {
limit: Option<usize>,
page: Option<usize>,
}
pub async fn bind_history(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
Query(params): Query<BindHistoryQuery>,
) -> Result<Json<BindHistoryResponse>, (StatusCode, Json<serde_json::Value>)> {
let uuid_clean = identity_uuid.replace('-', "");
let limit = params.limit.unwrap_or(20).max(1).min(100);
let page = params.page.unwrap_or(1).max(1);
let offset = ((page - 1) * limit) as i64;
let id_table = crate::core::db::schema::table_name("identities");
let history_table = crate::core::db::schema::table_name("identity_history");
let identity_id: i32 = sqlx::query_scalar(&format!(
"SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("DB error: {}", e)})),
)
})?
.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Identity not found"})),
))?;
let undo_stack_count: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE identity_id = $1 AND is_undone = false AND operation IN ('bind','unbind','bind_trace')",
history_table
))
.bind(identity_id)
.fetch_one(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("DB error: {}", e)})),
)
})?;
let redo_stack_count: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
history_table
))
.bind(identity_id)
.fetch_one(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("DB error: {}", e)})),
)
})?;
let rows = sqlx::query(&format!(
"SELECT id, operation, is_undone, created_at, undone_at FROM {} WHERE identity_id = $1 AND operation IN ('bind','unbind','bind_trace') ORDER BY created_at DESC LIMIT $2 OFFSET $3",
history_table
))
.bind(identity_id)
.bind(limit as i64)
.bind(offset)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("DB error: {}", e)})),
)
})?;
let results: Vec<BindHistoryItem> = rows
.into_iter()
.map(|r| BindHistoryItem {
history_id: r.get::<i64, _>("id"),
operation: r.get::<String, _>("operation"),
is_undone: r.get::<bool, _>("is_undone"),
created_at: r.get::<Option<chrono::DateTime<chrono::Utc>>, _>("created_at"),
undone_at: r.get::<Option<chrono::DateTime<chrono::Utc>>, _>("undone_at"),
})
.collect();
let total = undo_stack_count + redo_stack_count;
Ok(Json(BindHistoryResponse {
success: true,
identity_uuid: uuid_clean,
total,
undo_stack_count,
redo_stack_count,
results,
}))
}
// ============================================================================
// Pending Person API (file-scoped)
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePendingPersonRequest {
#[serde(default)]
pub trace_ids: Vec<i32>,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingPersonItem {
pub identity_uuid: String,
pub identity_id: i32,
pub name: String,
pub created_at: String,
pub trace_count: i64,
pub bound_traces: Option<Vec<i32>>,
}
/// Create a pending person under a file, optionally binding traces.
pub async fn create_pending_person(
State(state): State<crate::api::types::AppState>,
Extension(_auth): Extension<crate::api::middleware::UserAuth>,
Path(file_uuid): Path<String>,
Json(req): Json<CreatePendingPersonRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let id_table = crate::core::db::schema::table_name("identities");
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
// Auto-generate name if not provided
let name = if let Some(n) = &req.name {
n.clone()
} else {
let count: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND status = 'pending'",
id_table
))
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
format!("Person {}", count + 1)
};
// Create identity with pending status
let identity_row: (i32, String) = sqlx::query_as(&format!(
"INSERT INTO {} (name, identity_type, source, status, file_uuid) VALUES ($1, 'people', 'manual', 'pending', $2) RETURNING id, uuid::text",
id_table
))
.bind(&name)
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to create identity: {}", e)})),
)
})?;
let (identity_id, identity_uuid): (i32, String) = identity_row;
// Bind traces if provided
let bound_traces = if !req.trace_ids.is_empty() {
// Update Qdrant _faces for each trace
let qdrant = QdrantDb::new();
for &tid in &req.trace_ids {
let filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": tid}}
]
});
let payload = serde_json::json!({"identity_id": identity_id});
if let Err(e) = qdrant
.update_payload_by_filter("_faces", filter, payload)
.await
{
tracing::warn!(
"[create_pending_person] Qdrant update failed for trace {}: {}",
tid,
e
);
}
}
// Update TKG nodes
for &tid in &req.trace_ids {
let external_id = format!("face_track_{}", tid);
let _ = sqlx::query(&format!(
"UPDATE {} SET properties = jsonb_set(\
jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\
'{{identity_name}}', $2::jsonb, false)\
WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4",
nodes_table
))
.bind(identity_id)
.bind(&name)
.bind(&file_uuid)
.bind(&external_id)
.execute(state.db.pool())
.await;
}
Some(req.trace_ids.clone())
} else {
None
};
// Sync identity file
let _ =
crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &identity_uuid)
.await;
Ok(Json(ApiResponse {
success: true,
message: format!("Created pending person: {} (uuid: {})", name, identity_uuid),
data: Some(serde_json::json!({
"identity_uuid": identity_uuid,
"identity_id": identity_id,
"name": name,
"bound_traces": bound_traces.map(|v| v.len()).unwrap_or(0),
})),
}))
}
/// List pending persons for a file.
pub async fn list_pending_persons(
State(state): State<crate::api::types::AppState>,
Extension(_auth): Extension<crate::api::middleware::UserAuth>,
Path(file_uuid): Path<String>,
) -> Result<Json<ApiResponse<Vec<PendingPersonItem>>>, (StatusCode, Json<serde_json::Value>)> {
let id_table = crate::core::db::schema::table_name("identities");
let rows: Vec<(i32, String, String, chrono::NaiveDateTime)> = sqlx::query_as(&format!(
"SELECT id, uuid::text, name, created_at FROM {} WHERE file_uuid = $1 AND status = 'pending' ORDER BY created_at DESC",
id_table
))
.bind(&file_uuid)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
// Get trace counts from Qdrant _faces
let qdrant = QdrantDb::new();
let mut items = Vec::new();
for (id, uuid, name, created_at) in rows {
let face_filter = serde_json::json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "identity_id", "match": {"value": id}}
]
});
let points = qdrant
.scroll_all_points("_faces", face_filter, 500)
.await
.unwrap_or_default();
let unique_traces: std::collections::HashSet<i64> = points
.iter()
.filter_map(|p| p["payload"]["trace_id"].as_i64())
.collect();
let trace_count = unique_traces.len() as i64;
items.push(PendingPersonItem {
identity_uuid: uuid,
identity_id: id,
name,
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
trace_count,
bound_traces: None,
});
}
Ok(Json(ApiResponse {
success: true,
message: format!("Found {} pending persons for {}", items.len(), file_uuid),
data: Some(items),
}))
}
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),
)
.route("/api/v1/identity/:identity_uuid/bind/undo", post(bind_undo))
.route("/api/v1/identity/:identity_uuid/bind/redo", post(bind_redo))
.route(
"/api/v1/identity/:identity_uuid/bind/history",
get(bind_history),
)
.route("/api/v1/identity/merge/:merge_id/undo", post(undo_merge))
.route("/api/v1/identity/merge/:merge_id/redo", post(redo_merge))
.route("/api/v1/identity/merge/history", get(get_merge_history))
.route(
"/api/v1/file/:file_uuid/pending-person",
post(create_pending_person),
)
.route(
"/api/v1/file/:file_uuid/pending-persons",
get(list_pending_persons),
)
}