update code level

This commit is contained in:
lychang
2026-07-21 03:10:27 +08:00
parent 49c0a0d307
commit f1dba9f24d
6 changed files with 825 additions and 0 deletions

642
docs/api.md Normal file
View File

@@ -0,0 +1,642 @@
# Imager API 文档
Base URL: `http://<host>:<port>/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}` 返回二进制)。无认证鉴权。

View File

@@ -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();

View File

@@ -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))
// 静态文件

View File

@@ -41,3 +41,42 @@ pub async fn dict_reload(search_db: web::Data<Arc<SearchDb>>) -> impl Responder
"message": msg,
}))
}
/// dict 条目请求体
#[derive(serde::Deserialize)]
pub struct DictEntryInput {
pub word: String,
pub type_: String,
pub category: Option<String>,
pub freq: Option<i64>,
pub merged_to: Option<String>,
}
/// POST /api/dict/update — 更新 dict 表(新增或覆盖条目)
pub async fn dict_update(
search_db: web::Data<Arc<SearchDb>>,
body: web::Json<Vec<DictEntryInput>>,
) -> 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,
}))
}

View File

@@ -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<Arc<Database>>,
search_db: web::Data<Arc<SearchDb>>,
) -> 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<Arc<Database>>,
search_db: web::Data<Arc<SearchDb>>,
) -> 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<Arc<Database>>,
search_db: web::Data<Arc<SearchDb>>,
) -> 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,
)
}))
}

View File

@@ -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<i64>, 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?;