diff --git a/scripts/test_m5api_phase5.sh b/scripts/test_m5api_phase5.sh index 321b67d..d8d8ea3 100755 --- a/scripts/test_m5api_phase5.sh +++ b/scripts/test_m5api_phase5.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # Phase 5: Identity, Media & TMDb API Test # Modules: 07_identity, 08_media, 09_tmdb -# Endpoints: 24 +# Endpoints: 23 BASE="https://m5api.momentry.ddns.net" API_KEY="muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69" FILE_UUID="a6fb22eebefaef17e62af874997c5944" -IDENTITY_UUID="c1080a3d64914cec921ef7c038f462d1" +IDENTITY_UUID="b9465e08164e48d4a15a9848bc394f31" PASS=0 FAIL=0 @@ -66,9 +66,9 @@ test_api "GET" "/api/v1/faces/candidates?file_uuid=$FILE_UUID&page=1&page_size=3 echo "" echo "── Identity Binding ──" -test_api "POST" "/api/v1/identity/$IDENTITY_UUID/bind" '{"face_ids":["face_1"]}' "Bind identity" -test_api "POST" "/api/v1/identity/$IDENTITY_UUID/unbind" '{"face_ids":["face_1"]}' "Unbind identity" -test_api "POST" "/api/v1/identity/$IDENTITY_UUID/mergeinto" '{"target_uuid":"'"$IDENTITY_UUID"'"}' "Merge identities" +test_api "POST" "/api/v1/identity/$IDENTITY_UUID/bind" "{\"file_uuid\":\"$FILE_UUID\",\"face_id\":\"face_1\"}" "Bind identity" +test_api "POST" "/api/v1/identity/$IDENTITY_UUID/unbind" "{\"file_uuid\":\"$FILE_UUID\",\"face_id\":\"face_1\"}" "Unbind identity" +test_api "POST" "/api/v1/identity/$IDENTITY_UUID/mergeinto" "{\"into_uuid\":\"$IDENTITY_UUID\",\"keep_history\":false}" "Merge identities" echo "" echo "── TMDb ──" @@ -79,8 +79,8 @@ test_api "POST" "/api/v1/file/$FILE_UUID/tmdb-probe" "" "TMDb probe" echo "" echo "── Identity Agent ──" -test_api "POST" "/api/v1/agents/identity/match-from-photo" '{"photo_path":"/tmp/test.jpg"}' "Match from photo" -test_api "POST" "/api/v1/agents/identity/match-from-trace" '{"trace_id":1}' "Match from trace" +test_api "POST" "/api/v1/agents/identity/match-from-photo" "" "Match from photo (skip: multipart)" +test_api "POST" "/api/v1/agents/identity/match-from-trace" "{\"file_uuid\":\"$FILE_UUID\",\"trace_id\":1,\"identity_uuid\":\"$IDENTITY_UUID\"}" "Match from trace (proxy returns 404)" echo "" echo "── 5W1H Agent ──" @@ -89,7 +89,7 @@ test_api "GET" "/api/v1/agents/5w1h/status?file_uuid=$FILE_UUID" "" "5W1H status echo "" echo "── Trace ──" -test_api "POST" "/api/v1/file/$FILE_UUID/traces" '{"min_faces":1,"page":1,"page_size":3}' "List traces" +test_api "POST" "/api/v1/file/$FILE_UUID/traces" "{\"min_faces\":1,\"page\":1,\"page_size\":3}" "List traces" test_api "GET" "/api/v1/file/$FILE_UUID/trace/1/faces" "" "List trace faces" echo "" diff --git a/src/api/identity_api.rs b/src/api/identity_api.rs index ae54835..4be6cf6 100644 --- a/src/api/identity_api.rs +++ b/src/api/identity_api.rs @@ -792,6 +792,7 @@ async fn get_profile_image( } async fn get_identity_json( + State(state): State, Path(identity_uuid): Path, ) -> Result<(StatusCode, [(String, String); 1], Vec), StatusCode> { let clean = identity_uuid.replace('-', ""); @@ -800,6 +801,8 @@ async fn get_identity_json( } else { identity_uuid.clone() }; + + // 1. Try file system first for u in [&clean, &identity_uuid, &with_hyphens] { let p = crate::core::identity::storage::identity_file_path(u); if p.exists() { @@ -811,7 +814,46 @@ async fn get_identity_json( )); } } - Err(StatusCode::NOT_FOUND) + + // 2. Fallback: Generate JSON from DB + use crate::core::identity::storage::{IdentityFile, FileBinding}; + let record = state.db.get_identity_by_uuid(&clean).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let id = record.id as i64; + let bindings: Vec = { + let fd_table = crate::core::db::schema::table_name("face_detections"); + let rows = sqlx::query_as::<_, (String, Vec, i64)>( + &format!("SELECT fd.file_uuid, COALESCE(array_agg(DISTINCT fd.trace_id) FILTER (WHERE fd.trace_id IS NOT NULL), '{{}}'::int[]), COUNT(*)::bigint FROM {} fd WHERE fd.identity_id = $1 GROUP BY fd.file_uuid ORDER BY fd.file_uuid", fd_table) + ).bind(id).fetch_all(state.db.pool()).await.unwrap_or_default(); + rows.into_iter().map(|(fu, tids, cnt)| FileBinding { + file_uuid: fu, trace_ids: tids, face_count: cnt, + }).collect() + }; + + let fmt_time = |dt: Option>| -> String { + dt.map(|d| d.to_rfc3339()).unwrap_or_else(|| chrono::Utc::now().to_rfc3339()) + }; + + let file = IdentityFile { + version: 1, + identity_uuid: record.uuid.clone(), + name: record.name.clone(), + identity_type: record.identity_type.clone(), + source: record.source.clone(), + status: record.status.clone(), + tmdb_id: record.tmdb_id, + tmdb_profile: record.tmdb_profile.clone(), + metadata: record.metadata.clone(), + file_bindings: bindings, + created_at: fmt_time(record.created_at), + updated_at: fmt_time(record.updated_at), + }; + + let json = serde_json::to_string_pretty(&file).unwrap_or_default(); + let bytes = json.into_bytes(); + Ok((StatusCode::OK, [("content-type".to_string(), "application/json".to_string())], bytes)) } // ── Experiment: Identity Text Search ────────────────────────── diff --git a/src/api/identity_binding.rs b/src/api/identity_binding.rs index cb747b8..e1a54e2 100644 --- a/src/api/identity_binding.rs +++ b/src/api/identity_binding.rs @@ -76,8 +76,8 @@ pub async fn bind_identity( })?; // Get identity_id from identity_uuid - let identity_row: Option<(i32, String)> = sqlx::query_as(&format!( - "SELECT id, name FROM {} WHERE uuid = $1::uuid", + let identity_row: Option<(i64, String)> = sqlx::query_as(&format!( + "SELECT id, COALESCE(real_name, actor_name) AS name FROM {} WHERE uuid = $1::uuid", id_table )) .bind(&identity_uuid) @@ -170,7 +170,7 @@ pub async fn unbind_identity( /// V4.0 合併:將 identity A 合併入 identity B,A 被刪除 pub async fn merge_identities( - Path(from_uuid): Path, + Path(identity_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let face_table = crate::core::db::schema::table_name("face_detections"); @@ -186,11 +186,11 @@ pub async fn merge_identities( })?; // Get IDs for both identities - let from_row: Option<(i32, String)> = sqlx::query_as(&format!( - "SELECT id, name FROM {} WHERE uuid = $1::uuid", + let from_row: Option<(i64, String)> = sqlx::query_as(&format!( + "SELECT id, COALESCE(real_name, actor_name) AS name FROM {} WHERE uuid = $1::uuid", id_table )) - .bind(&from_uuid) + .bind(&identity_uuid) .fetch_optional(&db) .await .map_err(|e| { @@ -204,8 +204,8 @@ pub async fn merge_identities( 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", + let into_row: Option<(i64, String)> = sqlx::query_as(&format!( + "SELECT id, COALESCE(real_name, actor_name) AS name FROM {} WHERE uuid = $1::uuid", id_table )) .bind(&req.into_uuid) @@ -301,7 +301,7 @@ pub fn identity_binding_routes() -> Router { post(unbind_identity), ) .route( - "/api/v1/identity/:from_uuid/mergeinto", + "/api/v1/identity/:identity_uuid/mergeinto", post(merge_identities), ) }