From 680dd2e5a5bf5793f1e0e26a84bb81e7abbf2fc6 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 07:19:56 +0800 Subject: [PATCH] feat: add admin activity stream --- apps/admin/services.py | 276 ++++++++++++++++++++++++++++++++++++++++- apps/admin/views.py | 50 ++++++++ 2 files changed, 324 insertions(+), 2 deletions(-) diff --git a/apps/admin/services.py b/apps/admin/services.py index d26b9f3..6ba6790 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -18,12 +18,15 @@ from core.utils import get_now, hash_password, is_password_hashed class FileService: FILE_METADATA_KEY_PREFIX = "admin_file_metadata:" FILE_VIEW_PRESETS_KEY = "admin_file_view_presets" + ADMIN_ACTIVITY_KEY = "admin_activity_events" MAX_METADATA_NOTE_LENGTH = 2000 MAX_METADATA_TAGS = 12 MAX_METADATA_TAG_LENGTH = 24 MAX_VIEW_PRESETS = 24 MAX_VIEW_PRESET_NAME_LENGTH = 32 MAX_VIEW_PRESET_KEYWORD_LENGTH = 80 + MAX_ADMIN_ACTIVITIES = 80 + MAX_ADMIN_ACTIVITY_TEXT_LENGTH = 120 POLICY_ACTIONS = { "extend_24h", @@ -67,7 +70,13 @@ class FileService: } def __init__(self): - self.file_storage: FileStorageInterface = storages[settings.file_storage]() + self._file_storage: Optional[FileStorageInterface] = None + + @property + def file_storage(self) -> FileStorageInterface: + if self._file_storage is None: + self._file_storage = storages[settings.file_storage]() + return self._file_storage def _file_metadata_key(self, file_id: int) -> str: return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}" @@ -80,7 +89,15 @@ class FileService: async def delete_file(self, file_id: int): file_code = await FileCodes.get(id=file_id) + target_name = self._build_file_activity_name(file_code) await self._delete_file_code(file_code) + await self.record_admin_activity( + action="file.delete", + target_type="file", + target_id=file_id, + target_name=target_name, + count=1, + ) async def delete_files(self, file_ids: list[int]): unique_ids = list(dict.fromkeys(file_ids)) @@ -100,6 +117,20 @@ class FileService: except Exception as exc: failed.append({"id": file_id, "reason": str(exc)}) + if deleted: + await self.record_admin_activity( + action="files.batch_delete", + target_type="file", + count=len(deleted), + meta={ + "requestedCount": len(file_ids), + "uniqueCount": len(unique_ids), + "deleted": deleted, + "missing": missing, + "failedCount": len(failed), + }, + ) + return { "requestedCount": len(file_ids), "requested_count": len(file_ids), @@ -134,6 +165,21 @@ class FileService: except Exception as exc: failed.append({"id": file_id, "reason": str(exc)}) + if updated: + await self.record_admin_activity( + action="files.batch_update", + target_type="file", + count=len(updated), + meta={ + "fields": sorted(update_data.keys()), + "requestedCount": len(file_ids), + "uniqueCount": len(unique_ids), + "updated": updated, + "missing": missing, + "failedCount": len(failed), + }, + ) + return { "requestedCount": len(file_ids), "requested_count": len(file_ids), @@ -170,6 +216,14 @@ class FileService: ) await file_code.update_from_dict(update_data).save() + await self.record_admin_activity( + action="file.policy_action", + target_type="file", + target_id=file_id, + target_name=self._build_file_activity_name(file_code), + count=1, + meta={"policyAction": action}, + ) return await self.get_file_detail(file_id) async def get_file_metadata(self, file_id: int) -> dict[str, Any]: @@ -203,6 +257,18 @@ class FileService: key=self._file_metadata_key(file_id), defaults={"value": next_metadata}, ) + await self.record_admin_activity( + action="file.metadata_update", + target_type="file", + target_id=file_id, + target_name=self._build_file_activity_name(file_code), + count=1, + meta={ + "updateNote": update_note, + "updateTags": update_tags, + "tagCount": len(next_metadata["tags"]), + }, + ) return await self.get_file_detail(file_id) async def list_file_view_presets(self) -> dict[str, Any]: @@ -229,7 +295,8 @@ class FileService: (index for index, preset in enumerate(presets) if preset["id"] == preset_id), -1, ) - if target_index >= 0: + is_update = target_index >= 0 + if is_update: preset = presets[target_index] next_preset = { **preset, @@ -256,6 +323,14 @@ class FileService: presets.append(next_preset) await self._save_file_view_presets(presets) + await self.record_admin_activity( + action="file.view_preset_update" if is_update else "file.view_preset_create", + target_type="view_preset", + target_id=next_preset["id"], + target_name=next_preset["name"], + count=1, + meta={"filters": normalized_filters}, + ) return next_preset async def delete_file_view_preset(self, preset_id: str) -> dict[str, Any]: @@ -264,11 +339,22 @@ class FileService: raise HTTPException(status_code=400, detail="请选择要删除的视图预设") presets = await self._get_file_view_presets() + deleted_preset = next( + (preset for preset in presets if preset["id"] == preset_id), + None, + ) next_presets = [preset for preset in presets if preset["id"] != preset_id] if len(next_presets) == len(presets): raise HTTPException(status_code=404, detail="视图预设不存在") await self._save_file_view_presets(next_presets) + await self.record_admin_activity( + action="file.view_preset_delete", + target_type="view_preset", + target_id=preset_id, + target_name=(deleted_preset or {}).get("name", ""), + count=1, + ) return { "deleted": preset_id, "deletedPresetId": preset_id, @@ -315,6 +401,21 @@ class FileService: except Exception as exc: failed.append({"id": file_id, "reason": str(exc)}) + if updated: + await self.record_admin_activity( + action="files.batch_policy_action", + target_type="file", + count=len(updated), + meta={ + "policyAction": action, + "requestedCount": len(file_ids), + "uniqueCount": len(unique_ids), + "updated": updated, + "missing": missing, + "failedCount": len(failed), + }, + ) + return { "requestedCount": len(file_ids), "requested_count": len(file_ids), @@ -631,6 +732,177 @@ class FileService: "updated_at": updated_at, } + async def list_admin_activities(self, limit: int = 8) -> dict[str, Any]: + limit = min(max(int(limit or 8), 1), self.MAX_ADMIN_ACTIVITIES) + activities = await self._get_admin_activities() + visible_activities = activities[:limit] + return { + "activities": visible_activities, + "items": visible_activities, + "total": len(activities), + } + + async def record_admin_activity( + self, + action: str, + target_type: str, + target_id: Optional[Any] = None, + target_name: str = "", + count: int = 1, + meta: Optional[dict[str, Any]] = None, + ) -> Optional[dict[str, Any]]: + try: + now = await get_now() + created_at = now.isoformat() + activity = self._normalize_admin_activity( + { + "id": self._build_admin_activity_id( + action=action, + target_type=target_type, + target_id=target_id, + target_name=target_name, + timestamp=now, + ), + "action": action, + "targetType": target_type, + "target_type": target_type, + "targetId": target_id, + "target_id": target_id, + "targetName": target_name, + "target_name": target_name, + "count": count, + "meta": meta or {}, + "createdAt": created_at, + "created_at": created_at, + } + ) + if not activity: + return None + + activities = await self._get_admin_activities() + next_activities = [ + activity, + *[item for item in activities if item["id"] != activity["id"]], + ][: self.MAX_ADMIN_ACTIVITIES] + await self._save_admin_activities(next_activities) + return activity + except Exception: + return None + + async def _get_admin_activities(self) -> list[dict[str, Any]]: + record = await KeyValue.filter(key=self.ADMIN_ACTIVITY_KEY).first() + raw_activities = record.value if record else [] + if isinstance(raw_activities, dict): + raw_activities = ( + raw_activities.get("activities") or raw_activities.get("items") or [] + ) + if not isinstance(raw_activities, list): + return [] + + activities = [] + seen_ids = set() + for raw_activity in raw_activities: + activity = self._normalize_admin_activity(raw_activity) + if not activity or activity["id"] in seen_ids: + continue + seen_ids.add(activity["id"]) + activities.append(activity) + if len(activities) >= self.MAX_ADMIN_ACTIVITIES: + break + + activities.sort(key=lambda item: item.get("createdAt") or "", reverse=True) + return activities + + async def _save_admin_activities(self, activities: list[dict[str, Any]]) -> None: + await KeyValue.update_or_create( + key=self.ADMIN_ACTIVITY_KEY, + defaults={"value": {"activities": activities}}, + ) + + def _normalize_admin_activity(self, activity: Any) -> Optional[dict[str, Any]]: + if not isinstance(activity, dict): + return None + + action = self._normalize_admin_activity_text(activity.get("action")) + target_type = self._normalize_admin_activity_text( + activity.get("targetType") or activity.get("target_type") or "system" + ) + if not action: + return None + + target_name = self._normalize_admin_activity_text( + activity.get("targetName") or activity.get("target_name") + ) + created_at = activity.get("createdAt") or activity.get("created_at") + if isinstance(created_at, datetime): + created_at = created_at.isoformat() + created_at = str(created_at or "") + if not created_at: + return None + + target_id = activity.get("targetId") + if target_id is None: + target_id = activity.get("target_id") + + count = activity.get("count", 1) + try: + count = max(int(count), 1) + except (TypeError, ValueError): + count = 1 + + meta = activity.get("meta") + if not isinstance(meta, dict): + meta = {} + + activity_id = self._normalize_admin_activity_text(activity.get("id")) + if not activity_id: + activity_id = self._build_admin_activity_id( + action=action, + target_type=target_type, + target_id=target_id, + target_name=target_name, + timestamp=None, + seed=created_at, + ) + + return { + "id": activity_id, + "action": action, + "targetType": target_type, + "target_type": target_type, + "targetId": target_id, + "target_id": target_id, + "targetName": target_name, + "target_name": target_name, + "count": count, + "meta": meta, + "createdAt": created_at, + "created_at": created_at, + } + + def _normalize_admin_activity_text(self, value: Any) -> str: + return str(value or "").strip()[: self.MAX_ADMIN_ACTIVITY_TEXT_LENGTH] + + def _build_admin_activity_id( + self, + action: str, + target_type: str, + target_id: Optional[Any], + target_name: str, + timestamp: Optional[datetime], + seed: Optional[str] = None, + ) -> str: + timestamp_seed = ( + str(int(timestamp.timestamp() * 1000)) if timestamp else str(seed or "activity") + ) + digest = hashlib.sha1( + f"{timestamp_seed}:{action}:{target_type}:{target_id}:{target_name}".encode("utf-8") + ).hexdigest()[:10] + return f"act_{timestamp_seed}_{digest}" + + def _build_file_activity_name(self, file_code: FileCodes) -> str: + return (file_code.prefix + file_code.suffix) or file_code.code + async def _get_file_view_presets(self) -> list[dict[str, Any]]: record = await KeyValue.filter(key=self.FILE_VIEW_PRESETS_KEY).first() raw_presets = record.value if record else [] diff --git a/apps/admin/views.py b/apps/admin/views.py index 39d8f3d..ca95389 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -110,6 +110,7 @@ async def dashboard(file_service: FileService = Depends(get_file_service)): else 0, reverse=True, )[:8] + recent_activities = await file_service.list_admin_activities(limit=8) return APIResponse( detail={ "totalFiles": len(all_codes), @@ -140,10 +141,21 @@ async def dashboard(file_service: FileService = Depends(get_file_service)): await build_dashboard_recent_file(file_code) for file_code in recent_file_codes ], + "recentActivities": recent_activities["activities"], + "recent_activities": recent_activities["activities"], } ) +@admin_api.get("/activities") +async def admin_activities( + limit: int = 20, + file_service: FileService = Depends(get_file_service), +): + result = await file_service.list_admin_activities(limit=limit) + return APIResponse(detail=result) + + @admin_api.delete("/file/delete") async def file_delete( data: IDData, @@ -454,9 +466,17 @@ async def get_config( async def update_config( data: dict, config_service: ConfigService = Depends(get_config_service), + file_service: FileService = Depends(get_file_service), ): data.pop("themesChoices", None) await config_service.update_config(data) + await file_service.record_admin_activity( + action="config.update", + target_type="config", + target_name="system", + count=1, + meta={"fields": sorted(data.keys())}, + ) return APIResponse() @@ -491,8 +511,16 @@ async def get_local_lists( async def delete_local_file( item: DeleteItem, local_file_service: LocalFileService = Depends(get_local_file_service), + file_service: FileService = Depends(get_file_service), ): result = await local_file_service.delete_file(item.filename) + await file_service.record_admin_activity( + action="local_file.delete", + target_type="local_file", + target_name=item.filename, + count=1, + meta={"success": bool(result)}, + ) return APIResponse(detail=result) @@ -502,16 +530,29 @@ async def share_local_file( file_service: FileService = Depends(get_file_service), ): share_info = await file_service.share_local_file(item) + await file_service.record_admin_activity( + action="local_file.share", + target_type="file", + target_id=share_info.get("id") if isinstance(share_info, dict) else None, + target_name=item.filename, + count=1, + meta={ + "expireValue": item.expire_value, + "expireStyle": item.expire_style, + }, + ) return APIResponse(detail=share_info) @admin_api.patch("/file/update") async def update_file( data: UpdateFileData, + file_service: FileService = Depends(get_file_service), ): file_code = await FileCodes.filter(id=data.id).first() if not file_code: raise HTTPException(status_code=404, detail="文件不存在") + target_name = file_service._build_file_activity_name(file_code) update_data = {} if data.code is not None and data.code != file_code.code: @@ -533,4 +574,13 @@ async def update_file( update_data["expired_count"] = data.expired_count await file_code.update_from_dict(update_data).save() + if update_data: + await file_service.record_admin_activity( + action="file.update", + target_type="file", + target_id=data.id, + target_name=target_name, + count=1, + meta={"fields": sorted(update_data.keys())}, + ) return APIResponse(detail="更新成功")