feat: identity PATCH update, alias system, name UNIQUE removal

- Add PATCH /api/v1/identity/:identity_uuid endpoint
- Migration 030: remove name UNIQUE, add tmdb_id index
- TMDb upsert: ON CONFLICT (name) -> ON CONFLICT (tmdb_id)
- get_or_create_identity: pre-check by name
- upload_identity: ON CONFLICT (name) -> ON CONFLICT (uuid)
- Search: include aliases in identity text search
- Add scripts/llm_metadata_enhancer.py
- Add DESIGN/IdentityUpdateAndAliasSystem.md
This commit is contained in:
M5Max128
2026-05-22 08:35:28 +08:00
parent e1dbd27333
commit 701e71463d
6 changed files with 524 additions and 16 deletions

View File

@@ -2,7 +2,7 @@ use axum::{
extract::{Multipart, Path, Query, State},
http::StatusCode,
response::{Html, Json},
routing::{get, post},
routing::{get, patch, post},
Router,
};
use serde::{Deserialize, Serialize};
@@ -20,7 +20,9 @@ pub fn identity_routes() -> Router<crate::api::types::AppState> {
)
.route(
"/api/v1/identity/:identity_uuid",
get(get_identity_detail).delete(delete_identity),
get(get_identity_detail)
.delete(delete_identity)
.patch(update_identity),
)
.route(
"/api/v1/identity/:identity_uuid/files",
@@ -785,8 +787,8 @@ async fn upload_identity(
let result = sqlx::query_as::<_, (String,)>(&format!(
"INSERT INTO {} (uuid, name, identity_type, source, status, tmdb_id, tmdb_profile, metadata) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
ON CONFLICT (name) DO UPDATE SET \
source = EXCLUDED.source, status = EXCLUDED.status, \
ON CONFLICT (uuid) DO UPDATE SET \
name = EXCLUDED.name, source = EXCLUDED.source, status = EXCLUDED.status, \
tmdb_id = EXCLUDED.tmdb_id, tmdb_profile = EXCLUDED.tmdb_profile, \
metadata = EXCLUDED.metadata \
RETURNING uuid::text", identities_table
@@ -1167,8 +1169,12 @@ async fn search_identities_by_text(
JOIN {} c ON c.file_uuid = fd.file_uuid
AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0)
AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0)
WHERE i.name ILIKE $1
AND ($2::text IS NULL OR fd.file_uuid = $2)
WHERE (i.name ILIKE $1
OR EXISTS (
SELECT 1 FROM jsonb_array_elements(i.metadata->'aliases') AS a
WHERE a->>'name' ILIKE $1
))
AND ($2::text IS NULL OR fd.file_uuid = $2)
ORDER BY i.name, c.start_time
LIMIT $3"#,
id_table, fd_table, chunk_table
@@ -1222,3 +1228,141 @@ async fn search_identities_by_text(
results,
}))
}
// ── PATCH /api/v1/identity/:identity_uuid ────────────────────
#[derive(Debug, Deserialize)]
struct UpdateIdentityRequest {
name: Option<String>,
metadata: Option<serde_json::Value>,
status: Option<String>,
identity_type: Option<String>,
}
#[derive(Debug, Serialize)]
struct UpdateIdentityResponse {
success: bool,
identity_uuid: String,
updated_fields: Vec<String>,
}
async fn update_identity(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
Json(req): Json<UpdateIdentityRequest>,
) -> Result<Json<UpdateIdentityResponse>, (StatusCode, Json<serde_json::Value>)> {
let uuid_clean = identity_uuid.replace('-', "");
let uuid_parsed = uuid::Uuid::parse_str(&uuid_clean).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"success": false, "error": "Invalid identity_uuid"
})),
)
})?;
let table = crate::core::db::schema::table_name("identities");
let existing: Option<(i32, String)> = sqlx::query_as(&format!(
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
table
))
.bind(uuid_parsed)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"success": false, "error": format!("DB error: {}", e)
})),
)
})?;
let (identity_id, old_name) = existing.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"success": false, "error": "Identity not found"
})),
)
})?;
let mut updated_fields: Vec<String> = Vec::new();
let mut set_clauses: Vec<String> = Vec::new();
if let Some(ref name) = req.name {
set_clauses.push(format!("name = ${}", set_clauses.len() + 1));
updated_fields.push("name".to_string());
}
if let Some(ref metadata) = req.metadata {
set_clauses.push(format!("metadata = ${}::jsonb", set_clauses.len() + 1));
updated_fields.push("metadata".to_string());
}
if let Some(ref status) = req.status {
set_clauses.push(format!("status = ${}", set_clauses.len() + 1));
updated_fields.push("status".to_string());
}
if let Some(ref identity_type) = req.identity_type {
set_clauses.push(format!("identity_type = ${}", set_clauses.len() + 1));
updated_fields.push("identity_type".to_string());
}
if set_clauses.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"success": false, "error": "No fields to update"
})),
));
}
let set_sql = set_clauses.join(", ");
let uuid_param = set_clauses.len() + 1;
let update_sql = format!(
"UPDATE {} SET {} WHERE uuid = ${}::uuid",
table, set_sql, uuid_param
);
let mut query = sqlx::query(&update_sql);
if let Some(ref name) = req.name {
query = query.bind(name);
}
if let Some(ref metadata) = req.metadata {
query = query.bind(metadata);
}
if let Some(ref status) = req.status {
query = query.bind(status);
}
if let Some(ref identity_type) = req.identity_type {
query = query.bind(identity_type);
}
query = query.bind(uuid_parsed);
query.execute(state.db.pool()).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"success": false, "error": format!("Update failed: {}", e)
})),
)
})?;
// Sync identity.json to disk
let _ =
crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean)
.await;
// If name changed, update _index.json
if req.name.is_some() {
let new_name = req.name.as_deref().unwrap_or(&old_name);
let _ = crate::core::identity::storage::update_index(&uuid_clean, new_name);
}
Ok(Json(UpdateIdentityResponse {
success: true,
identity_uuid: uuid_clean,
updated_fields,
}))
}