From fc6046a663c54625144ee835d296342f8de76368 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 06:37:56 +0800 Subject: [PATCH] feat: add admin file metadata --- apps/admin/schemas.py | 6 +++ apps/admin/services.py | 94 ++++++++++++++++++++++++++++++++++++++++++ apps/admin/views.py | 37 +++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/apps/admin/schemas.py b/apps/admin/schemas.py index b157daf..e87a75e 100644 --- a/apps/admin/schemas.py +++ b/apps/admin/schemas.py @@ -55,3 +55,9 @@ class BatchFilePolicyActionData(BaseModel): action: str downloadLimit: Optional[int] = None download_limit: Optional[int] = None + + +class FileMetadataData(BaseModel): + id: int + note: Optional[str] = None + tags: Optional[list[str]] = None diff --git a/apps/admin/services.py b/apps/admin/services.py index 4f42552..f41da45 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -15,6 +15,11 @@ from core.utils import get_now, hash_password, is_password_hashed class FileService: + FILE_METADATA_KEY_PREFIX = "admin_file_metadata:" + MAX_METADATA_NOTE_LENGTH = 2000 + MAX_METADATA_TAGS = 12 + MAX_METADATA_TAG_LENGTH = 24 + POLICY_ACTIONS = { "extend_24h", "extend_7d", @@ -37,9 +42,13 @@ class FileService: def __init__(self): self.file_storage: FileStorageInterface = storages[settings.file_storage]() + def _file_metadata_key(self, file_id: int) -> str: + return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}" + async def _delete_file_code(self, file_code: FileCodes): if file_code.text is None: await self.file_storage.delete_file(file_code) + await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete() await file_code.delete() async def delete_file(self, file_id: int): @@ -136,6 +145,39 @@ class FileService: await file_code.update_from_dict(update_data).save() return await self.get_file_detail(file_id) + async def get_file_metadata(self, file_id: int) -> dict[str, Any]: + record = await KeyValue.filter(key=self._file_metadata_key(file_id)).first() + return self._normalize_file_metadata(record.value if record else None) + + async def update_file_metadata( + self, + file_id: int, + note: Optional[str], + tags: Optional[list[str]], + update_note: bool, + update_tags: bool, + ) -> dict[str, Any]: + file_code = await FileCodes.filter(id=file_id).first() + if not file_code: + raise HTTPException(status_code=404, detail="文件不存在") + + current_metadata = await self.get_file_metadata(file_id) + next_metadata = dict(current_metadata) + if update_note: + next_metadata["note"] = self._normalize_metadata_note(note) + if update_tags: + next_metadata["tags"] = self._normalize_metadata_tags(tags) + + now = await get_now() + updated_at = now.isoformat() + next_metadata["updatedAt"] = updated_at + next_metadata["updated_at"] = updated_at + await KeyValue.update_or_create( + key=self._file_metadata_key(file_id), + defaults={"value": next_metadata}, + ) + return await self.get_file_detail(file_id) + async def apply_files_policy_action( self, file_ids: list[int], @@ -437,8 +479,60 @@ class FileService: "timeline": timeline, } ) + metadata = await self.get_file_metadata(file_id) + detail.update( + { + "metadata": metadata, + "meta": metadata, + "note": metadata["note"], + "tags": metadata["tags"], + "metadataUpdatedAt": metadata["updatedAt"], + "metadata_updated_at": metadata["updated_at"], + } + ) return detail + def _normalize_metadata_note(self, note: Optional[str]) -> str: + if note is None: + return "" + return str(note).strip()[: self.MAX_METADATA_NOTE_LENGTH] + + def _normalize_metadata_tags(self, tags: Any) -> list[str]: + if not tags: + return [] + if isinstance(tags, str): + tags = [tags] + elif not isinstance(tags, list): + return [] + + normalized_tags = [] + seen_tags = set() + for raw_tag in tags: + tag = str(raw_tag).strip() + if not tag: + continue + tag = tag[: self.MAX_METADATA_TAG_LENGTH] + dedupe_key = tag.lower() + if dedupe_key in seen_tags: + continue + seen_tags.add(dedupe_key) + normalized_tags.append(tag) + if len(normalized_tags) >= self.MAX_METADATA_TAGS: + break + return normalized_tags + + def _normalize_file_metadata(self, metadata: Any) -> dict[str, Any]: + if not isinstance(metadata, dict): + metadata = {} + + updated_at = metadata.get("updatedAt") or metadata.get("updated_at") + return { + "note": self._normalize_metadata_note(metadata.get("note")), + "tags": self._normalize_metadata_tags(metadata.get("tags")), + "updatedAt": updated_at, + "updated_at": updated_at, + } + def _build_file_status_insights( self, file_code: FileCodes, diff --git a/apps/admin/views.py b/apps/admin/views.py index 0106910..a516c6a 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -20,6 +20,7 @@ from apps.admin.schemas import ( BatchUpdateFileData, BatchFilePolicyActionData, FilePolicyActionData, + FileMetadataData, ShareItem, DeleteItem, LoginData, @@ -343,6 +344,42 @@ async def file_detail_post( return APIResponse(detail=detail) +async def update_file_metadata( + data: FileMetadataData, + file_service: FileService, +): + fields_set = data.model_fields_set + update_note = "note" in fields_set + update_tags = "tags" in fields_set + if not update_note and not update_tags: + raise HTTPException(status_code=400, detail="请选择要更新的元数据") + + detail = await file_service.update_file_metadata( + file_id=data.id, + note=data.note, + tags=data.tags, + update_note=update_note, + update_tags=update_tags, + ) + return APIResponse(detail=detail) + + +@admin_api.patch("/file/metadata") +async def file_metadata( + data: FileMetadataData, + file_service: FileService = Depends(get_file_service), +): + return await update_file_metadata(data, file_service) + + +@admin_api.post("/file/metadata") +async def file_metadata_post( + data: FileMetadataData, + file_service: FileService = Depends(get_file_service), +): + return await update_file_metadata(data, file_service) + + @admin_api.get("/config/get") async def get_config( config_service: ConfigService = Depends(get_config_service),