feat: progressive multi-round face matching + pending person API
- Identity agent: per-face max matching, multi-round with derived seeds from high-confidence faces, angle diversity filter (cosine sim < 0.90) - Pending person API: POST /file/:file_uuid/pending-person + GET /file/:file_uuid/pending-persons with status=pending, source=manual - Update API docs (07_identity.md)
This commit is contained in:
+447
-62
@@ -93,15 +93,38 @@ pub async fn bind_identity(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Capture old identity_id before bind
|
||||
let old_identity_id: Option<i32> = sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
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();
|
||||
|
||||
let old_identity_id: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -110,16 +133,27 @@ pub async fn bind_identity(
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
// 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(state.db.pool())
|
||||
.await
|
||||
let result = if use_id_field {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND id = $3",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
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(&face_identifier)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -127,6 +161,67 @@ pub async fn bind_identity(
|
||||
)
|
||||
})?;
|
||||
|
||||
let trace_id: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2 LIMIT 1",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2 LIMIT 1",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
// Update Qdrant + TKG if trace_id exists
|
||||
if let Some(tid) = trace_id {
|
||||
// 1. Update Qdrant payload
|
||||
let face_db = crate::core::db::FaceEmbeddingDb::new();
|
||||
if let Err(e) = face_db
|
||||
.update_identity_by_trace(&req.file_uuid, tid, &uuid_clean)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[bind] Failed to update Qdrant identity_uuid for trace {}: {}",
|
||||
tid, e
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Update TKG face_track node (dual-field design)
|
||||
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')",
|
||||
@@ -144,10 +239,10 @@ pub async fn bind_identity(
|
||||
crate::api::middleware::AuthSource::ApiKey => "api_key",
|
||||
};
|
||||
let before = serde_json::json!({
|
||||
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_before": old_identity_id
|
||||
"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": req.face_id, "identity_id_after": identity_id
|
||||
"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)",
|
||||
@@ -161,7 +256,6 @@ pub async fn bind_identity(
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
|
||||
// Sync identity JSON file
|
||||
if let Err(e) =
|
||||
crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean)
|
||||
.await
|
||||
@@ -177,7 +271,7 @@ pub async fn bind_identity(
|
||||
success: true,
|
||||
message: format!(
|
||||
"Bound face {} of {} to {}",
|
||||
req.face_id, req.file_uuid, name
|
||||
face_identifier, req.file_uuid, name
|
||||
),
|
||||
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
|
||||
}))
|
||||
@@ -193,15 +287,38 @@ pub async fn unbind_identity(
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
let history_table = crate::core::db::schema::table_name("identity_history");
|
||||
|
||||
// Capture old identity_id before unbind
|
||||
let old_identity_id: Option<i32> = sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
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();
|
||||
|
||||
let old_identity_id: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -210,14 +327,25 @@ pub async fn unbind_identity(
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
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(state.db.pool())
|
||||
.await
|
||||
let result = if use_id_field {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -225,15 +353,85 @@ pub async fn unbind_identity(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Phase 2.3: Also update TKG node (find face_track_id first)
|
||||
let trace_id_opt: Option<i32> = sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
let trace_id: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2 LIMIT 1",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2 LIMIT 1",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
// Clear Qdrant + TKG if trace_id exists
|
||||
if let Some(tid) = trace_id {
|
||||
// 1. Clear Qdrant payload
|
||||
let face_db = crate::core::db::FaceEmbeddingDb::new();
|
||||
if let Err(e) = face_db
|
||||
.clear_identity_by_trace(&req.file_uuid, tid)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[unbind] Failed to clear Qdrant identity_uuid for trace {}: {}",
|
||||
tid, e
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Update TKG face_track node (restore stranger_ref)
|
||||
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' \
|
||||
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;
|
||||
}
|
||||
|
||||
let trace_id_opt: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
@@ -251,9 +449,7 @@ pub async fn unbind_identity(
|
||||
.await;
|
||||
}
|
||||
|
||||
// Record history if there was a binding
|
||||
if let Some(identity_id) = old_identity_id {
|
||||
// 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
|
||||
@@ -262,7 +458,6 @@ pub async fn unbind_identity(
|
||||
.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",
|
||||
@@ -270,10 +465,10 @@ pub async fn unbind_identity(
|
||||
crate::api::middleware::AuthSource::ApiKey => "api_key",
|
||||
};
|
||||
let before = serde_json::json!({
|
||||
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_before": old_identity_id
|
||||
"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": req.face_id, "identity_id_after": null
|
||||
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_after": 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)",
|
||||
@@ -315,7 +510,7 @@ pub async fn unbind_identity(
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!("Unbound face {} from {}", req.face_id, req.file_uuid),
|
||||
message: format!("Unbound face {} from {}", face_identifier, req.file_uuid),
|
||||
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
|
||||
}))
|
||||
}
|
||||
@@ -933,14 +1128,14 @@ pub async fn get_identity_traces(
|
||||
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 / NULLIF(v.fps, 0)::numeric, 1)::float8 AS first_sec,
|
||||
ROUND(MAX(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1)::float8 AS last_sec,
|
||||
COALESCE(ROUND(MIN(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1), 0)::float8 AS first_sec,
|
||||
COALESCE(ROUND(MAX(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1), 0)::float8 AS last_sec,
|
||||
ROUND(AVG(fd.confidence)::numeric, 4)::float8 AS avg_confidence
|
||||
FROM {} fd
|
||||
LEFT JOIN dev.videos v ON fd.file_uuid = v.file_uuid
|
||||
WHERE fd.identity_id = $1
|
||||
GROUP BY trace_id, v.fps
|
||||
ORDER BY trace_id
|
||||
LEFT JOIN videos v ON fd.file_uuid = v.file_uuid
|
||||
WHERE fd.identity_id = $1 AND fd.trace_id IS NOT NULL
|
||||
GROUP BY fd.file_uuid, fd.trace_id, v.fps
|
||||
ORDER BY fd.trace_id
|
||||
LIMIT $2 OFFSET $3"#,
|
||||
fd_table
|
||||
))
|
||||
@@ -953,7 +1148,7 @@ pub async fn get_identity_traces(
|
||||
|
||||
// Get total count for pagination
|
||||
let total: (i64,) = sqlx::query_as(&format!(
|
||||
"SELECT COUNT(*) FROM (SELECT 1 FROM {} fd WHERE trace_id) sub",
|
||||
"SELECT COUNT(*) FROM (SELECT 1 FROM {} fd WHERE fd.identity_id = $1 AND fd.trace_id IS NOT NULL GROUP BY fd.trace_id) sub",
|
||||
fd_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
@@ -1864,6 +2059,188 @@ pub async fn bind_history(
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 fd_table = crate::core::db::schema::table_name("face_detections");
|
||||
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 face_detections
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = ANY($3)",
|
||||
fd_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&file_uuid)
|
||||
.bind(&req.trace_ids)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to bind traces: {}", 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 fd_table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
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()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
for (id, uuid, name, created_at) in rows {
|
||||
let trace_count: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id = $1 AND file_uuid = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(id)
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
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))
|
||||
@@ -1892,4 +2269,12 @@ pub fn identity_binding_routes() -> Router<crate::api::types::AppState> {
|
||||
.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),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user