diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..f6a279d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,642 @@ +# Imager API 文档 + +Base URL: `http://:/api` + +--- + +## 相册 + +### GET /api/albums +获取全量相册列表(从内存缓存)。 + +**参数**:无 + +**响应**:`AlbumInfo[]` + +```json +[ + { + "id": "uuid", + "title": "相册标题", + "document": "文件夹名", + "description": "描述", + "cover_image_id": "uuid", + "image_count": 100, + "created_at": "2024-01-01T00:00:00", + "tags": ["标签1", "标签2"], + "persons": ["人物1"] + } +] +``` + +--- + +### GET /api/search +组合搜索(关键词/标签/人物过滤)。 + +**参数**(query): + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| q | string | - | 标题关键词模糊匹配 | +| tag | string | - | 标签精确匹配 | +| person | string | - | 人物精确或模糊匹配 | +| page | int | 1 | 页码 | +| limit | int | 50 | 每页条数(最大 200) | + +**响应**:`AlbumInfo[]` + +--- + +### GET /api/album/random +从缓存随机返回一个相册。 + +**参数**:无 + +**响应**:`AlbumInfo` + +--- + +### GET /api/album/{id} +获取相册详情(含图片列表、标签、人物)。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| id | uuid | 相册 ID | + +**响应**:`AlbumWithImages` + +```json +{ + "album": { + "id": "uuid", + "title": "标题", + "description": "描述", + "cover_image_id": "uuid", + "image_count": 100, + "created_at": "...", + "updated_at": "..." + }, + "images": [ + { + "id": "uuid", + "album_id": "uuid", + "filename": "1.jpg", + "original_url": "...", + "title": "图片标题", + "description": null, + "file_size": 123456, + "width": 1920, + "height": 1080, + "mime_type": "image/jpeg", + "created_at": "..." + } + ], + "image_count": 100, + "tags": ["标签1", "标签2"], + "persons": ["人物1"] +} +``` + +--- + +### POST /api/albums +创建新相册。 + +**请求体**: + +```json +{ + "title": "相册标题", + "document": "文件夹名", + "description": "描述(可选)" +} +``` + +**响应**(201 Created): + +```json +{ + "id": "uuid" +} +``` + +--- + +## 图片 + +### GET /api/image/{id} +获取图片原始二进制数据。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| id | uuid | 图片 ID | + +**响应**:二进制图片数据,Content-Type 按实际 mime 类型。带 ETag,Cache-Control: public, max-age=31536000。 + +--- + +## 收藏 + +### GET /api/favorites +获取当前用户的收藏列表。 + +**参数**:无(用户固定为 default_user) + +**响应**:`FavoriteWithImage[]` + +--- + +### GET /api/favorites/status/{id} +检查某图片的收藏状态。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| id | uuid | 图片 ID | + +--- + +### POST /api/favorites +添加收藏。 + +**请求体**: + +```json +{ + "image_id": "uuid", + "album_id": "uuid" +} +``` + +**响应**(201 Created): + +```json +{ + "id": "uuid" +} +``` + +--- + +### DELETE /api/favorites/{id} +按收藏 ID 取消收藏。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| id | uuid | 收藏 ID | + +--- + +### DELETE /api/favorites/image/{id} +按图片 ID 取消收藏。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| id | uuid | 图片 ID | + +--- + +## 人物 + +### GET /api/people +从内存缓存获取所有人名列表。 + +**参数**:无 + +**响应**: + +```json +{ + "total": 100, + "persons": [ + { + "id": 1, + "name": "人物名", + "display_name": "显示名", + "total_albums": 10, + "total_images": 50, + "aliases": ["别名1", "别名2"] + } + ] +} +``` + +--- + +### GET /api/people/search +从内存缓存搜索人名。 + +**参数**(query): + +| 参数 | 类型 | 说明 | +|------|------|------| +| q | string | 搜索关键词 | + +**响应**:同上 `PersonListResponse` + +--- + +### GET /api/people/{name} +获取人物详情及关联相册。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| name | string | 人物名称 | + +**响应**:`PersonWithAlbums` + +```json +{ + "album_count": 10, + "person": { + "id": 1, + "name": "人物名", + "display_name": "显示名", + "total_albums": 10, + "total_images": 50, + "original_names": null, + "created_at": "", + "updated_at": "" + }, + "albums": [/* AlbumInfo[] */] +} +``` + +--- + +### GET /api/people/{id}/aliases +查询人物别名。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| id | int | 人物 ID | + +--- + +## 标签 / 元数据 + +### GET /api/metadata/stats +从内存缓存计算元数据统计。 + +**参数**:无 + +**响应**: + +```json +{ + "total_persons": 100, + "total_aliases": 50, + "total_albums": 10000, + "total_images": 50000, + "total_tags": 2000 +} +``` + +--- + +### GET /api/tags/search +按标签搜索相册。 + +**参数**(query): + +| 参数 | 类型 | 说明 | +|------|------|------| +| tag | string | **必填**,标签名 | + +**响应**:`AlbumInfo[]` + +--- + +### GET /api/tags/popular +获取热门标签。 + +**参数**(query): + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| limit | int | 50 | 返回数量 | + +**响应**: + +```json +{ + "total": 50, + "tags": [ + { "tag": "标签名", "count": 1000, "album_count": 1000 } + ] +} +``` + +--- + +## 推荐 + +### GET /api/recommend/tags +标签自动补全推荐。 + +**参数**(query): + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| q | string | - | 输入前缀 | +| limit | int | 10 | 返回数量 | + +--- + +### GET /api/recommend/persons +人物自动补全推荐。 + +**参数**(query): + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| q | string | - | 输入名称 | +| limit | int | 10 | 返回数量 | + +--- + +### GET /api/recommend/related +关联推荐(标签共现 + 共现人物)。 + +**参数**(query): + +| 参数 | 类型 | 说明 | +|------|------|------| +| context | string | **必填**,标签或人名 | +| limit | int | 默认 15 | + +--- + +### GET /api/recommend/popular-persons +获取热门人物排行榜。 + +**参数**(query): + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| limit | int | 20(最大 50) | 返回数量 | + +**响应**: + +```json +[ + { + "name": "人物名", + "display_name": "显示名", + "total_albums": 10, + "total_images": 50, + "top_tags": ["标签1", "标签2"], + "aliases": ["别名1"] + } +] +``` + +--- + +## 管线 + +### POST /api/pipeline/run +手动触发完整管线。 + +**参数**:无 + +**响应**: + +```json +{ + "success": true, + "tags_cleaned": 0, + "short_tags_removed": 0, + "person_like_tags_removed": 0, + "dirty_albums_detected": 0, + "non_persons_deleted": 0, + "stop_word_persons_removed": 0, + "new_albums_found": 0, + "persons_updated": 0, + "tags_updated": 0, + "persons_merged": 0, + "aliases_moved": 0, + "tags_removed_person": 0, + "empty_albums_deleted": 0, + "dup_albums_deleted": 0, + "search_db_synced": 0, + "message": "管线执行完毕:..." +} +``` + +--- + +### POST /api/pipeline/reparse +重置所有相册为未处理状态,重新解析全部标签和人物并同步到 search.db。 + +**参数**:无 + +**响应**: + +```json +{ + "success": true, + "albums_parsed": 100, + "persons_updated": 80, + "tags_updated": 200, + "synced_to_search_db": 100, + "message": "重新解析完成:..." +} +``` + +--- + +### POST /api/pipeline/reparse-persons +重置所有相册,重新解析全部人物(清空 search.db 的人物关联后运行完整管线)。 + +**参数**:无 + +**响应**: + +```json +{ + "success": true, + "albums_parsed": 100, + "persons_updated": 80, + "persons_merged": 5, + "aliases_moved": 3, + "non_persons_deleted": 2, + "stop_word_persons_removed": 1, + "synced_to_search_db": 100, + "message": "人物重新解析完成:..." +} +``` + +--- + +### POST /api/pipeline/reparse-tags +重置所有相册,重新解析全部标签(清空 search.db 的标签关联后运行完整管线)。 + +**参数**:无 + +**响应**: + +```json +{ + "success": true, + "albums_parsed": 100, + "tags_updated": 200, + "short_tags_removed": 5, + "synced_to_search_db": 100, + "message": "标签重新解析完成:..." +} +``` + +--- + +## 词典 + +### GET /api/dict/stats +查看标签词典状态。 + +**参数**:无 + +**响应**: + +```json +{ + "stats": "标签词典: ...", + "categories": [ + { "name": "style", "label": "风格", "count": 50 } + ], + "total_tags": 2000, + "total_stop_words": 20, + "person_prefixes": ["prefix1"], + "excluded_person_names": ["name1"], + "included_person_names": ["name2"] +} +``` + +--- + +### POST /api/dict/reload +从 search.db 的 dict 表热重载词典到内存。 + +**参数**:无 + +**响应**: + +```json +{ + "success": true, + "message": "词典已从 search.db 重载: ..." +} +``` + +--- + +### POST /api/dict/update +批量更新 dict 表条目,并自动重载内存词典。 + +**请求体**(数组): + +```json +[ + { + "word": "写真", + "type_": "tag", + "category": "topic", + "freq": null, + "merged_to": null + }, + { + "word": "张三", + "type_": "person", + "category": null, + "freq": null, + "merged_to": null + } +] +``` + +**type_ 可选值**:`tag`、`person`、`stop_word`、`person_prefix`、`excluded_person` + +**响应**: + +```json +{ + "success": true, + "message": "更新 2 条字典条目,词典已从 search.db 重载: ...", + "updated": 2 +} +``` + +--- + +## 缓存 + +### POST /api/cache/refresh +手动刷新所有内存缓存(AlbumsCache / TagCache / PersonCache)。 + +**参数**:无 + +**响应**: + +```json +{ + "success": true, + "message": "缓存已刷新" +} +``` + +--- + +## 迁移 + +### GET /api/migration/stats +查看当前数据库迁移状态。 + +**参数**:无 + +--- + +### POST /api/migration/run +执行从旧数据库的数据迁移。 + +**参数**(query): + +| 参数 | 类型 | 说明 | +|------|------|------| +| path | string | **必填**,旧数据库文件路径 | + +--- + +## 队列 + +### GET /api/queue/stats +获取任务队列状态。 + +**参数**:无 + +--- + +## 静态页面 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | 首页(相册列表) | +| GET | `/album` | 相册详情页(需 ?id=xxx 参数) | +| GET | `/assets/*` | 静态资源文件 | + +--- + +> 所有 API 响应均为 JSON 格式(仅 `/api/image/{id}` 返回二进制)。无认证鉴权。 diff --git a/src/db/album.rs b/src/db/album.rs index c1363fd..084569f 100644 --- a/src/db/album.rs +++ b/src/db/album.rs @@ -226,6 +226,13 @@ impl Database { Ok(id) } + /// 将所有相册重置为未处理状态(processed_at = NULL),下次管线会重新解析全部相册 + pub async fn reset_all_processed_at(&self) -> usize { + sqlx::query("UPDATE albums SET processed_at = NULL") + .execute(&self.pool).await.unwrap_or_default() + .rows_affected() as usize + } + pub async fn delete_album(&self, album_id: Uuid) -> Result<(), sqlx::Error> { let _guard = self.write_lock.lock().await; let aid = album_id.to_string(); diff --git a/src/main.rs b/src/main.rs index cc913f3..0222382 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,9 +216,15 @@ async fn main() -> std::io::Result<()> { println!(" - GET /api/migration/stats - 迁移状态"); println!(" - POST /api/migration/run?path=persons.db - 执行迁移"); println!(" - GET /api/queue/stats - 队列状态"); + println!("管线接口:"); + println!(" - POST /api/pipeline/run - 执行管线(增量)"); + println!(" - POST /api/pipeline/reparse - 重置并重新解析全部相册(标签+人物)"); + println!(" - POST /api/pipeline/reparse-persons - 重置并重新解析全部人物"); + println!(" - POST /api/pipeline/reparse-tags - 重置并重新解析全部标签"); println!("词典管理:"); println!(" - GET /api/dict/stats - 词典状态"); println!(" - POST /api/dict/reload - 热重载词典"); + println!(" - POST /api/dict/update - 批量更新 dict 表(自动重载内存词典)"); println!("缓存管理:"); println!(" - POST /api/cache/refresh - 手动刷新内存缓存"); println!("按 Ctrl+C 停止服务"); @@ -270,11 +276,15 @@ async fn main() -> std::io::Result<()> { .route("/api/migration/run", web::post().to(routes::migration_run)) // 管线 API .route("/api/pipeline/run", web::post().to(routes::pipeline_run)) + .route("/api/pipeline/reparse", web::post().to(routes::pipeline_reparse)) + .route("/api/pipeline/reparse-persons", web::post().to(routes::pipeline_reparse_persons)) + .route("/api/pipeline/reparse-tags", web::post().to(routes::pipeline_reparse_tags)) // 队列 API .route("/api/queue/stats", web::get().to(routes::get_queue_stats)) // 词典 API .route("/api/dict/stats", web::get().to(routes::dict_stats)) .route("/api/dict/reload", web::post().to(routes::dict_reload)) + .route("/api/dict/update", web::post().to(routes::dict_update)) // 缓存管理 API .route("/api/cache/refresh", web::post().to(routes::cache_refresh)) // 静态文件 diff --git a/src/routes/dict.rs b/src/routes/dict.rs index db1d637..bebfe14 100644 --- a/src/routes/dict.rs +++ b/src/routes/dict.rs @@ -41,3 +41,42 @@ pub async fn dict_reload(search_db: web::Data>) -> impl Responder "message": msg, })) } + +/// dict 条目请求体 +#[derive(serde::Deserialize)] +pub struct DictEntryInput { + pub word: String, + pub type_: String, + pub category: Option, + pub freq: Option, + pub merged_to: Option, +} + +/// POST /api/dict/update — 更新 dict 表(新增或覆盖条目) +pub async fn dict_update( + search_db: web::Data>, + body: web::Json>, +) -> impl Responder { + let entries = body.into_inner(); + let mut inserted = 0usize; + for e in &entries { + match search_db.upsert_dict_entry( + &e.word, &e.type_, e.category.as_deref(), e.freq, e.merged_to.as_deref(), + ).await { + Ok(_) => inserted += 1, + Err(err) => { + return HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "message": format!("更新词条 '{}' 失败: {}", e.word, err), + })); + } + } + } + // 自动重载内存词典 + let msg = tag_dict::reload_from_search_db(search_db.get_ref()).await; + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": format!("更新 {} 条字典条目,{}", inserted, msg), + "updated": inserted, + })) +} diff --git a/src/routes/pipeline.rs b/src/routes/pipeline.rs index 8f45187..122dee3 100644 --- a/src/routes/pipeline.rs +++ b/src/routes/pipeline.rs @@ -37,4 +37,111 @@ pub async fn pipeline_run( result.persons_merged, result.aliases_moved, result.tags_removed_person, synced ) })) +} + +/// POST /api/pipeline/reparse — 重置所有相册为未处理状态,重新解析全部标签和人物并同步到 search.db +pub async fn pipeline_reparse( + db: web::Data>, + search_db: web::Data>, +) -> impl Responder { + // 1. 清除 search.db 中的旧关联(不影响 tags/persons 字典) + sqlx::query("DELETE FROM album_tags").execute(&search_db.pool).await.ok(); + sqlx::query("DELETE FROM album_persons").execute(&search_db.pool).await.ok(); + + // 2. 重置所有相册为未处理 + let reset_count = db.reset_all_processed_at().await; + tracing::info!("[reparse] 重置 {} 个相册的 processed_at", reset_count); + + // 3. 重新运行完整管线+同步 + let result = engine::pipeline::run_pipeline(db.get_ref(), search_db.get_ref()).await; + let synced = engine::pipeline::sync_albums_to_search_db(db.get_ref(), search_db.get_ref()).await; + + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "albums_parsed": result.new_albums_found, + "persons_updated": result.persons_updated, + "tags_updated": result.tags_updated, + "synced_to_search_db": synced, + "message": format!( + "重新解析完成:重置{}个相册,解析{}个,更新人物{}个,标签{}个,同步{}个到search.db", + reset_count, result.new_albums_found, + result.persons_updated, result.tags_updated, synced, + ) + })) +} + +/// POST /api/pipeline/reparse-persons — 重置所有相册,重新解析全部人物 +/// +/// 流程: +/// 清空 search.db 的 album_persons → 重置所有相册 processed_at → +/// 运行完整管线(人物相关逻辑全部执行)→ 全量同步到 search.db +pub async fn pipeline_reparse_persons( + db: web::Data>, + search_db: web::Data>, +) -> impl Responder { + // 1. 清除 search.db 中的人物关联(保留 tags 不变) + sqlx::query("DELETE FROM album_persons").execute(&search_db.pool).await.ok(); + + // 2. 重置所有相册为未处理(触发管线重新解析) + let reset_count = db.reset_all_processed_at().await; + tracing::info!("[reparse-persons] 重置 {} 个相册的 processed_at", reset_count); + + // 3. 重新运行完整管线(Phase ② 人物清理 + Phase ③ 解析 + Phase ④ 人物合并 + Phase C 重计) + let result = engine::pipeline::run_pipeline(db.get_ref(), search_db.get_ref()).await; + + // 4. 全量同步到 search.db(tags 和 persons 都会同步,但 album_persons 已清空确保了干净状态) + let synced = engine::pipeline::sync_albums_to_search_db(db.get_ref(), search_db.get_ref()).await; + + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "albums_parsed": result.new_albums_found, + "persons_updated": result.persons_updated, + "persons_merged": result.persons_merged, + "aliases_moved": result.aliases_moved, + "non_persons_deleted": result.non_persons_deleted, + "stop_word_persons_removed": result.stop_word_persons_removed, + "synced_to_search_db": synced, + "message": format!( + "人物重新解析完成:重置{}个相册,解析{}个,更新人物{}个,合并{}个,迁移别名{}个,删除非人物{},清除重叠{},同步{}个到search.db", + reset_count, result.new_albums_found, + result.persons_updated, result.persons_merged, result.aliases_moved, + result.non_persons_deleted, result.stop_word_persons_removed, synced, + ) + })) +} + +/// POST /api/pipeline/reparse-tags — 重置所有相册,重新解析全部标签 +/// +/// 流程: +/// 清空 search.db 的 album_tags → 重置所有相册 processed_at → +/// 运行完整管线(标签相关逻辑全部执行)→ 全量同步到 search.db +pub async fn pipeline_reparse_tags( + db: web::Data>, + search_db: web::Data>, +) -> impl Responder { + // 1. 清除 search.db 中的标签关联(保留 persons 不变) + sqlx::query("DELETE FROM album_tags").execute(&search_db.pool).await.ok(); + + // 2. 重置所有相册为未处理(触发管线重新解析) + let reset_count = db.reset_all_processed_at().await; + tracing::info!("[reparse-tags] 重置 {} 个相册的 processed_at", reset_count); + + // 3. 重新运行完整管线(Phase ③ 解析会重新提取全部标签) + let result = engine::pipeline::run_pipeline(db.get_ref(), search_db.get_ref()).await; + + // 4. 全量同步到 search.db + let synced = engine::pipeline::sync_albums_to_search_db(db.get_ref(), search_db.get_ref()).await; + + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "albums_parsed": result.new_albums_found, + "tags_updated": result.tags_updated, + "short_tags_removed": result.short_tags_removed, + "synced_to_search_db": synced, + "message": format!( + "标签重新解析完成:重置{}个相册,解析{}个,更新标签{}个,移除短标签{},同步{}个到search.db", + reset_count, result.new_albums_found, + result.tags_updated, result.short_tags_removed, synced, + ) + })) } \ No newline at end of file diff --git a/src/search_db/mod.rs b/src/search_db/mod.rs index 61434d0..8997a38 100644 --- a/src/search_db/mod.rs +++ b/src/search_db/mod.rs @@ -295,6 +295,26 @@ impl SearchDb { self.load_dict().await } + /// 插入或更新一条 dict 条目(word 为 PK,相同 word 会覆盖) + pub async fn upsert_dict_entry(&self, word: &str, type_: &str, category: Option<&str>, freq: Option, merged_to: Option<&str>) -> Result<(), sqlx::Error> { + let rows = sqlx::query( + r#"SELECT COUNT(*) as cnt FROM dict WHERE word = ?"# + ).bind(word).fetch_one(&self.pool).await?; + let exists: i64 = rows.get("cnt"); + if exists > 0 { + sqlx::query( + "UPDATE dict SET type = ?, category = ?, freq = ?, merged_to = ? WHERE word = ?" + ).bind(type_).bind(category).bind(freq).bind(merged_to).bind(word) + .execute(&self.pool).await?; + } else { + sqlx::query( + "INSERT INTO dict (word, type, category, freq, merged_to) VALUES (?, ?, ?, ?, ?)" + ).bind(word).bind(type_).bind(category).bind(freq).bind(merged_to) + .execute(&self.pool).await?; + } + Ok(()) + } + pub async fn clear_all(&self) -> Result<(), sqlx::Error> { let mut tx = self.pool.begin().await?; sqlx::query("DELETE FROM album_tags").execute(&mut *tx).await?;