diff --git a/app/src/fileflash/core/deps.py b/app/src/fileflash/core/deps.py index c489641..5f415bc 100644 --- a/app/src/fileflash/core/deps.py +++ b/app/src/fileflash/core/deps.py @@ -19,6 +19,13 @@ ) from ..services.archive import ArchiveService from ..services.agent import ExecuteService, McpService, MemoryService, PlanService, SessionService, SettingsService, SkillService +from ..services.admin.users import AdminUsersService +from ..services.admin.storage import AdminStorageService +from ..services.admin.files import AdminFilesService +from ..services.admin.moderation import AdminModerationService +from ..services.admin.logs import AdminLogsService +from ..services.admin.notifications import AdminNotificationsService +from ..services.admin.system import AdminSystemService from ..services.auth import AuthService from ..services.background_jobs import BackgroundJobService from ..services.email_delivery import VerificationEmailDeliveryService @@ -120,6 +127,51 @@ def get_registration_email_domain_rule_service( return RegistrationEmailDomainRuleService(db=db) +def get_admin_users_service( + db: AsyncSession = Depends(get_db), +) -> AdminUsersService: + return AdminUsersService(db=db) + + +def get_admin_storage_service( + db: AsyncSession = Depends(get_db), + rate_limiter: RedisRateLimiter = Depends(get_rate_limiter), +) -> AdminStorageService: + return AdminStorageService(db=db, redis=getattr(rate_limiter, "_redis", None)) + + +def get_admin_files_service( + db: AsyncSession = Depends(get_db), + event_publisher: InProcessAuthEventPublisher = Depends(get_event_publisher), +) -> AdminFilesService: + return AdminFilesService(db=db, publisher=event_publisher) + + +def get_admin_moderation_service( + db: AsyncSession = Depends(get_db), +) -> AdminModerationService: + return AdminModerationService(db=db) + + +def get_admin_logs_service( + db: AsyncSession = Depends(get_db), +) -> AdminLogsService: + return AdminLogsService(db=db) + + +def get_admin_notifications_service( + db: AsyncSession = Depends(get_db), +) -> AdminNotificationsService: + return AdminNotificationsService(db=db) + + +def get_admin_system_service( + db: AsyncSession = Depends(get_db), + settings: Settings = Depends(get_settings_dep), +) -> AdminSystemService: + return AdminSystemService(db=db, settings=settings) + + def get_upload_service( db: AsyncSession = Depends(get_db), settings: Settings = Depends(get_settings_dep), diff --git a/app/src/fileflash/routers/__init__.py b/app/src/fileflash/routers/__init__.py index 2f14ff0..3aeec2f 100644 --- a/app/src/fileflash/routers/__init__.py +++ b/app/src/fileflash/routers/__init__.py @@ -1,6 +1,13 @@ from fastapi import APIRouter from .auth import router as auth_router +from .admin_users import router as admin_users_router +from .admin_storage import router as admin_storage_router +from .admin_files import router as admin_files_router +from .admin_moderation import router as admin_moderation_router +from .admin_logs import router as admin_logs_router +from .admin_notifications import router as admin_notifications_router +from .admin_system import router as admin_system_router from .admin_registration_email_domain_rules import router as admin_registration_email_domain_rules_router from .files import router as files_router from .folders import router as folders_router @@ -14,6 +21,13 @@ api_router = APIRouter() api_router.include_router(auth_router) +api_router.include_router(admin_users_router) +api_router.include_router(admin_storage_router) +api_router.include_router(admin_files_router) +api_router.include_router(admin_moderation_router) +api_router.include_router(admin_logs_router) +api_router.include_router(admin_notifications_router) +api_router.include_router(admin_system_router) api_router.include_router(admin_registration_email_domain_rules_router) api_router.include_router(files_router) api_router.include_router(folders_router) diff --git a/app/src/fileflash/routers/admin_files.py b/app/src/fileflash/routers/admin_files.py new file mode 100644 index 0000000..55069c7 --- /dev/null +++ b/app/src/fileflash/routers/admin_files.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..core.deps import get_admin_files_service, require_admin +from ..core.errors import api_success +from ..models.tables_identity import User +from ..schemas.admin.files import ListAdminFilesQuery +from ..services.admin.files import AdminFilesService + +router = APIRouter(prefix="/admin/files", tags=["admin"]) + + +@router.get("") +async def list_admin_files( + query: ListAdminFilesQuery = Depends(), + _: User = Depends(require_admin), + service: AdminFilesService = Depends(get_admin_files_service), +): + data = await service.list_files(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Files fetched") + + +@router.post("/{file_id}/rescan") +async def rescan_admin_file( + file_id: int, + admin: User = Depends(require_admin), + service: AdminFilesService = Depends(get_admin_files_service), +): + result = await service.request_rescan(file_id=file_id, requested_by=admin.user_id) + return api_success(data=result.model_dump(by_alias=True), message="Rescan requested") + + +__all__ = ["router"] diff --git a/app/src/fileflash/routers/admin_logs.py b/app/src/fileflash/routers/admin_logs.py new file mode 100644 index 0000000..70274ae --- /dev/null +++ b/app/src/fileflash/routers/admin_logs.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..core.deps import get_admin_logs_service, require_admin +from ..core.errors import api_success +from ..models.tables_identity import User +from ..schemas.admin.logs import ListAdminLogsQuery +from ..services.admin.logs import AdminLogsService + +router = APIRouter(prefix="/admin/logs", tags=["admin"]) + + +@router.get("") +async def list_admin_logs( + query: ListAdminLogsQuery = Depends(), + _: User = Depends(require_admin), + service: AdminLogsService = Depends(get_admin_logs_service), +): + data = await service.list_logs(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Logs fetched") + + +__all__ = ["router"] diff --git a/app/src/fileflash/routers/admin_moderation.py b/app/src/fileflash/routers/admin_moderation.py new file mode 100644 index 0000000..aa61604 --- /dev/null +++ b/app/src/fileflash/routers/admin_moderation.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..core.deps import get_admin_moderation_service, require_admin +from ..core.errors import api_success +from ..models.tables_identity import User +from ..schemas.admin.moderation import ListViolationsQuery +from ..services.admin.moderation import AdminModerationService + +router = APIRouter(prefix="/admin/violations", tags=["admin"]) + + +@router.get("") +async def list_violations( + query: ListViolationsQuery = Depends(), + _: User = Depends(require_admin), + service: AdminModerationService = Depends(get_admin_moderation_service), +): + data = await service.list_violations(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Violations fetched") + + +@router.post("/{case_id}/resolve") +async def resolve_violation( + case_id: int, + admin: User = Depends(require_admin), + service: AdminModerationService = Depends(get_admin_moderation_service), +): + result = await service.resolve_case(case_id=case_id, handled_by=admin.user_id) + return api_success(data=result.model_dump(by_alias=True), message="Violation resolved") + + +__all__ = ["router"] diff --git a/app/src/fileflash/routers/admin_notifications.py b/app/src/fileflash/routers/admin_notifications.py new file mode 100644 index 0000000..019d616 --- /dev/null +++ b/app/src/fileflash/routers/admin_notifications.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..core.deps import get_admin_notifications_service, require_admin +from ..core.errors import api_success +from ..models.tables_identity import User +from ..schemas.admin.notifications import BroadcastRequest, ListAdminNotificationsQuery +from ..services.admin.notifications import AdminNotificationsService + +router = APIRouter(prefix="/admin/notifications", tags=["admin"]) + + +@router.get("") +async def list_admin_notifications( + query: ListAdminNotificationsQuery = Depends(), + _: User = Depends(require_admin), + service: AdminNotificationsService = Depends(get_admin_notifications_service), +): + data = await service.list_notifications(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Notifications fetched") + + +@router.post("/broadcast") +async def broadcast_notification( + payload: BroadcastRequest, + admin: User = Depends(require_admin), + service: AdminNotificationsService = Depends(get_admin_notifications_service), +): + result = await service.broadcast(payload=payload, sender_id=admin.user_id) + return api_success(data=result.model_dump(by_alias=True), message="Broadcast sent") + + +@router.delete("/{notification_id}") +async def archive_admin_notification( + notification_id: int, + _: User = Depends(require_admin), + service: AdminNotificationsService = Depends(get_admin_notifications_service), +): + await service.archive(notification_id=notification_id) + return api_success( + data={"notificationId": str(notification_id), "status": "archived"}, + message="Notification archived", + ) + + +__all__ = ["router"] diff --git a/app/src/fileflash/routers/admin_storage.py b/app/src/fileflash/routers/admin_storage.py new file mode 100644 index 0000000..9b7d6ef --- /dev/null +++ b/app/src/fileflash/routers/admin_storage.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..core.deps import get_admin_storage_service, require_admin +from ..core.errors import api_success +from ..models.tables_identity import User +from ..schemas.admin.storage import ListStorageUsersQuery, UpdateQuotaRequest, UsageTrendQuery +from ..services.admin.storage import AdminStorageService + +router = APIRouter(prefix="/admin/storage", tags=["admin"]) + + +@router.get("/summary") +async def get_admin_storage_summary( + _: User = Depends(require_admin), + service: AdminStorageService = Depends(get_admin_storage_service), +): + data = await service.summary() + return api_success(data=data.model_dump(by_alias=True), message="Storage summary fetched") + + +@router.get("/users") +async def list_admin_storage_users( + query: ListStorageUsersQuery = Depends(), + _: User = Depends(require_admin), + service: AdminStorageService = Depends(get_admin_storage_service), +): + data = await service.list_storage_users(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Storage users fetched") + + +@router.patch("/users/{user_id}/quota") +async def update_admin_user_quota( + user_id: int, + payload: UpdateQuotaRequest, + _: User = Depends(require_admin), + service: AdminStorageService = Depends(get_admin_storage_service), +): + result = await service.update_quota(user_id=user_id, new_limit=payload.storage_limit) + return api_success(data=result.model_dump(by_alias=True), message="Quota updated") + + +@router.get("/usage-trend") +async def get_storage_usage_trend( + query: UsageTrendQuery = Depends(), + _: User = Depends(require_admin), + service: AdminStorageService = Depends(get_admin_storage_service), +): + result = await service.usage_trend(query=query) + return api_success(data=result.model_dump(by_alias=True), message="Usage trend fetched") + + +__all__ = ["router"] diff --git a/app/src/fileflash/routers/admin_system.py b/app/src/fileflash/routers/admin_system.py new file mode 100644 index 0000000..f8ef13a --- /dev/null +++ b/app/src/fileflash/routers/admin_system.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..core.deps import get_admin_system_service, require_admin +from ..core.errors import api_success +from ..models.tables_identity import User +from ..services.admin.system import AdminSystemService + +router = APIRouter(prefix="/admin/system", tags=["admin"]) + + +@router.get("/health") +async def get_system_health( + _: User = Depends(require_admin), + service: AdminSystemService = Depends(get_admin_system_service), +): + data = await service.health() + return api_success(data=data.model_dump(by_alias=True), message="System health fetched") + + +@router.get("/rate-limit") +async def get_rate_limit_status( + _: User = Depends(require_admin), + service: AdminSystemService = Depends(get_admin_system_service), +): + data = await service.rate_limit_status() + return api_success(data=data.model_dump(by_alias=True), message="Rate limit fetched") + + +__all__ = ["router"] diff --git a/app/src/fileflash/routers/admin_users.py b/app/src/fileflash/routers/admin_users.py new file mode 100644 index 0000000..09278c0 --- /dev/null +++ b/app/src/fileflash/routers/admin_users.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..core.deps import get_admin_users_service, require_admin +from ..core.errors import api_success +from ..models.tables_identity import User +from ..schemas.admin.users import ListAdminUsersQuery, UpdateUserStatusRequest +from ..services.admin.users import AdminUsersService + +router = APIRouter(prefix="/admin/users", tags=["admin"]) + + +@router.get("") +async def list_admin_users( + query: ListAdminUsersQuery = Depends(), + _: User = Depends(require_admin), + service: AdminUsersService = Depends(get_admin_users_service), +): + data = await service.list_users(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Users fetched") + + +@router.patch("/{user_id}/status") +async def update_admin_user_status( + user_id: int, + payload: UpdateUserStatusRequest, + _: User = Depends(require_admin), + service: AdminUsersService = Depends(get_admin_users_service), +): + result = await service.set_status(user_id=user_id, external_status=payload.status) + return api_success(data=result.model_dump(by_alias=True), message="Status updated") + + +__all__ = ["router"] diff --git a/app/src/fileflash/routers/uploads.py b/app/src/fileflash/routers/uploads.py index 9996c87..10a6abb 100644 --- a/app/src/fileflash/routers/uploads.py +++ b/app/src/fileflash/routers/uploads.py @@ -22,6 +22,18 @@ async def preflight_upload( return api_success(data=response.model_dump(by_alias=True), message="Ready for upload") +@router.get("/recoverable") +async def recoverable_uploads( + current_user: User = Depends(get_current_user), + upload_service: UploadService = Depends(get_upload_service), +): + sessions = await upload_service.list_recoverable_sessions(user_id=current_user.user_id) + return api_success( + data=[session.model_dump(by_alias=True) for session in sessions], + message="Recoverable upload sessions fetched", + ) + + @router.post("/{upload_id}/chunk") async def upload_chunk( upload_id: str, @@ -59,3 +71,19 @@ async def merge_chunks( code=201, status_code=201, ) + + +@router.post("/{upload_id}/cancel") +async def cancel_upload( + upload_id: str, + current_user: User = Depends(get_current_user), + upload_service: UploadService = Depends(get_upload_service), +): + response = await upload_service.cancel_upload_session( + user_id=current_user.user_id, + upload_id=upload_id, + ) + return api_success( + data=response.model_dump(by_alias=True), + message="Upload session canceled", + ) diff --git a/app/src/fileflash/schemas/admin/__init__.py b/app/src/fileflash/schemas/admin/__init__.py new file mode 100644 index 0000000..33e52be --- /dev/null +++ b/app/src/fileflash/schemas/admin/__init__.py @@ -0,0 +1 @@ +"""Admin-only schemas. All requests/responses share CamelModel from schemas.common.""" diff --git a/app/src/fileflash/schemas/admin/files.py b/app/src/fileflash/schemas/admin/files.py new file mode 100644 index 0000000..c54f06c --- /dev/null +++ b/app/src/fileflash/schemas/admin/files.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from ..common import CamelModel, PageQuery + +VirusStatus = Literal["clean", "pending", "flagged"] + + +class AdminFileAuditItem(CamelModel): + id: str + name: str + size: int + mime_type: str + hash: str + virus_status: VirusStatus + is_shared: bool + owner_name: str + updated_at: datetime + created_at: datetime + + +class ListAdminFilesQuery(PageQuery): + search: str | None = None + virus_status: VirusStatus | None = None + owner_id: str | None = None + mime_type: str | None = None + sort: Literal["name", "size", "createdAt", "updatedAt"] = "updatedAt" + order: Literal["asc", "desc"] = "desc" + + +class RescanResponse(CamelModel): + file_id: str + virus_status: VirusStatus + scanned_at: datetime + + +__all__ = ["AdminFileAuditItem", "ListAdminFilesQuery", "RescanResponse", "VirusStatus"] diff --git a/app/src/fileflash/schemas/admin/logs.py b/app/src/fileflash/schemas/admin/logs.py new file mode 100644 index 0000000..8139d46 --- /dev/null +++ b/app/src/fileflash/schemas/admin/logs.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import Field + +from ..common import CamelModel, PageQuery + + +class LogItem(CamelModel): + id: str + user_id: str | None + operation: str + operation_name: str + target_type: str | None + target_id: str | None + result: str + ip_address: str | None + user_agent: str | None + performed_at: datetime + details: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ListAdminLogsQuery(PageQuery): + user_id: str | None = None + operation: str | None = None + result: Literal["success", "failure"] | None = None + from_at: datetime | None = None + to_at: datetime | None = None + + +class AdminLogsResponse(CamelModel): + logs: list[LogItem] + pagination: dict[str, Any] + + +__all__ = ["AdminLogsResponse", "ListAdminLogsQuery", "LogItem"] diff --git a/app/src/fileflash/schemas/admin/moderation.py b/app/src/fileflash/schemas/admin/moderation.py new file mode 100644 index 0000000..e6384a6 --- /dev/null +++ b/app/src/fileflash/schemas/admin/moderation.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from ..common import CamelModel, PageQuery + +ViolationLevel = Literal["low", "medium", "high"] +ViolationStatus = Literal["pending", "under_review", "resolved"] + + +class ViolationItem(CamelModel): + id: str + file_id: str | None + file_name: str | None + type: str + level: ViolationLevel + reported_at: datetime + status: ViolationStatus + + +class ListViolationsQuery(PageQuery): + status: ViolationStatus | None = None + + +class ResolveViolationResponse(CamelModel): + violation_id: str + resolved_at: datetime + + +__all__ = [ + "ListViolationsQuery", + "ResolveViolationResponse", + "ViolationItem", + "ViolationLevel", + "ViolationStatus", +] diff --git a/app/src/fileflash/schemas/admin/notifications.py b/app/src/fileflash/schemas/admin/notifications.py new file mode 100644 index 0000000..8ee7ecf --- /dev/null +++ b/app/src/fileflash/schemas/admin/notifications.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import Field + +from ..common import CamelModel, PageQuery + + +class AdminNotificationItem(CamelModel): + id: str + message: str + title: str | None + type: str + status: str + is_read: bool + created_at: datetime + updated_at: datetime + recipient_count: int | None = None + + +class ListAdminNotificationsQuery(PageQuery): + status: str | None = None + type: str | None = None + + +class BroadcastRequest(CamelModel): + title: str | None = Field(default=None, max_length=255) + message: str = Field(min_length=1, max_length=2000) + type: Literal["system", "announcement"] = "system" + + +class BroadcastResponse(CamelModel): + broadcast_id: str + recipient_count: int + sent_at: datetime + + +__all__ = [ + "AdminNotificationItem", + "BroadcastRequest", + "BroadcastResponse", + "ListAdminNotificationsQuery", +] diff --git a/app/src/fileflash/schemas/admin/storage.py b/app/src/fileflash/schemas/admin/storage.py new file mode 100644 index 0000000..13e718e --- /dev/null +++ b/app/src/fileflash/schemas/admin/storage.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import Field + +from ..common import CamelModel, PageQuery + + +class AdminStorageSummary(CamelModel): + storage_used: int + storage_limit: int + storage_percentage: float + file_count: int + user_count: int + updated_at: datetime + + +class AdminStorageUserItem(CamelModel): + user_id: str + username: str + email: str + storage_limit: int + storage_used: int + usage_percentage: float + updated_at: datetime + + +class ListStorageUsersQuery(PageQuery): + sort: Literal["storageUsed", "usagePercentage", "username"] = "storageUsed" + order: Literal["asc", "desc"] = "desc" + + +class UpdateQuotaRequest(CamelModel): + storage_limit: int = Field(ge=0) + + +class UpdateQuotaResponse(CamelModel): + user_id: str + storage_limit: int + storage_used: int + usage_percentage: float + updated_at: datetime + + +class UsageTrendQuery(CamelModel): + days: Literal[7, 14, 30] = 7 + + +class UsageTrendPoint(CamelModel): + date: str + used: int + + +class UsageTrendResponse(CamelModel): + trends: list[UsageTrendPoint] + is_estimated: bool = False + + +__all__ = [ + "AdminStorageSummary", + "AdminStorageUserItem", + "ListStorageUsersQuery", + "UpdateQuotaRequest", + "UpdateQuotaResponse", + "UsageTrendQuery", + "UsageTrendPoint", + "UsageTrendResponse", +] diff --git a/app/src/fileflash/schemas/admin/system.py b/app/src/fileflash/schemas/admin/system.py new file mode 100644 index 0000000..bc09025 --- /dev/null +++ b/app/src/fileflash/schemas/admin/system.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from datetime import datetime + +from ..common import CamelModel + + +class SystemHealth(CamelModel): + platform_targets: list[str] + max_concurrent_uploads: int + active_upload_sessions: int + virus_scan_enabled: bool + thumbnail_generation_enabled: bool + registration_mail_enabled: bool + hash_computation_enabled: bool + last_updated_at: datetime + + +class RateLimitRule(CamelModel): + rule_id: str + scope: str + window_seconds: int + limit: int + current_usage: int + blocked_requests: int + + +class RateLimitStatus(CamelModel): + rules: list[RateLimitRule] + evaluated_at: datetime + + +__all__ = ["RateLimitRule", "RateLimitStatus", "SystemHealth"] diff --git a/app/src/fileflash/schemas/admin/users.py b/app/src/fileflash/schemas/admin/users.py new file mode 100644 index 0000000..a62d4f8 --- /dev/null +++ b/app/src/fileflash/schemas/admin/users.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from ..common import CamelModel, PageQuery + +ExternalUserStatus = Literal["active", "suspended", "pending_verification"] + + +class AdminUserItem(CamelModel): + user_id: str + username: str + email: str + role: str + status: ExternalUserStatus + email_verified: bool + email_verified_at: datetime | None = None + storage_limit: int + storage_used: int + usage_percentage: float + last_login_at: datetime | None = None + last_active_at: datetime | None = None + created_at: datetime + + +class ListAdminUsersQuery(PageQuery): + search: str | None = None + status: Literal["active", "suspended"] | None = None + role: Literal["USER", "ADMIN"] | None = None + sort: Literal["username", "createdAt", "storageUsed"] = "createdAt" + order: Literal["asc", "desc"] = "desc" + + +class UpdateUserStatusRequest(CamelModel): + status: Literal["active", "suspended"] + + +class UpdateUserStatusResponse(CamelModel): + user_id: str + status: ExternalUserStatus + updated_at: datetime + + +__all__ = [ + "AdminUserItem", + "ListAdminUsersQuery", + "UpdateUserStatusRequest", + "UpdateUserStatusResponse", +] diff --git a/app/src/fileflash/schemas/file.py b/app/src/fileflash/schemas/file.py index d9dd1ab..3bf75c9 100644 --- a/app/src/fileflash/schemas/file.py +++ b/app/src/fileflash/schemas/file.py @@ -128,6 +128,20 @@ class UploadPreflightResponse(CamelModel): uploaded_chunk_indexes: list[int] | None = None +class RecoverableUploadSession(CamelModel): + upload_id: str + file_name: str = Field(min_length=1, max_length=255) + file_size: int = Field(ge=0) + uploaded_bytes: int = Field(ge=0) + chunk_size: int = Field(gt=0) + file_hash: str = Field(min_length=8, max_length=128) + mime_type: str = Field(min_length=1, max_length=255) + parent_id: str + updated_at: datetime + expired_at: datetime | None = None + status: Literal["init", "uploading"] + + class MergeChunksRequest(CamelModel): file_hash: str = Field(min_length=8, max_length=128) file_name: str = Field(min_length=1, max_length=255) @@ -147,6 +161,11 @@ class MergeChunksResponse(CamelModel): download_url: str +class UploadCancelResponse(CamelModel): + upload_id: str + canceled_at: datetime + + class FileDetails(FileItem): status: bool diff --git a/app/src/fileflash/services/admin/__init__.py b/app/src/fileflash/services/admin/__init__.py new file mode 100644 index 0000000..0edfeeb --- /dev/null +++ b/app/src/fileflash/services/admin/__init__.py @@ -0,0 +1 @@ +"""Admin-only services. Each module owns one /admin/* surface.""" diff --git a/app/src/fileflash/services/admin/_status.py b/app/src/fileflash/services/admin/_status.py new file mode 100644 index 0000000..d238975 --- /dev/null +++ b/app/src/fileflash/services/admin/_status.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from ...core.errors import ApiError +from ...models.enums import UserStatus + +_EXTERNAL_TO_INTERNAL = { + "active": UserStatus.ACTIVE, + "suspended": UserStatus.DISABLED, +} + +_INTERNAL_TO_EXTERNAL = { + UserStatus.ACTIVE: "active", + UserStatus.DISABLED: "suspended", + UserStatus.LOCKED: "suspended", + UserStatus.PENDING_VERIFICATION: "pending_verification", +} + + +def external_to_internal(value: str) -> UserStatus: + try: + return _EXTERNAL_TO_INTERNAL[value] + except KeyError as exc: + raise ApiError( + status_code=422, + code=422, + message=f"Invalid user status: {value!r}", + ) from exc + + +def internal_to_external(value: UserStatus) -> str: + return _INTERNAL_TO_EXTERNAL.get(value, value.value) + + +__all__ = ["external_to_internal", "internal_to_external"] diff --git a/app/src/fileflash/services/admin/files.py b/app/src/fileflash/services/admin/files.py new file mode 100644 index 0000000..a06eee5 --- /dev/null +++ b/app/src/fileflash/services/admin/files.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Protocol + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.enums import FileStatus, ScanResult +from ...models.tables_audit_security import ObjectScanResult +from ...models.tables_identity import User +from ...models.tables_storage import File, StorageObject +from ...schemas.admin.files import AdminFileAuditItem, ListAdminFilesQuery, RescanResponse, VirusStatus +from ...schemas.common import PaginatedData, PaginationMeta + + +class EventPublisherProtocol(Protocol): + async def publish(self, event_name: str, payload: dict[str, Any]) -> None: ... + + +_VIRUS_STATUS_MAP: dict[ScanResult, VirusStatus] = { + ScanResult.CLEAN: "clean", + ScanResult.PENDING: "pending", + ScanResult.INFECTED: "flagged", + ScanResult.BLOCKED: "flagged", + ScanResult.FAILED: "pending", +} + + +class AdminFilesService: + def __init__(self, db: AsyncSession, publisher: EventPublisherProtocol) -> None: + self.db = db + self.publisher = publisher + + async def list_files(self, *, query: ListAdminFilesQuery) -> PaginatedData[AdminFileAuditItem]: + latest_scan = ( + select(ObjectScanResult.object_id, func.max(ObjectScanResult.scanned_at).label("scanned_at")) + .group_by(ObjectScanResult.object_id) + .subquery() + ) + statement = ( + select(File, StorageObject, User, ObjectScanResult) + .join(StorageObject, File.storage_object_id == StorageObject.object_id) + .join(User, File.owner_id == User.user_id) + .join(latest_scan, latest_scan.c.object_id == StorageObject.object_id, isouter=True) + .join( + ObjectScanResult, + and_( + ObjectScanResult.object_id == latest_scan.c.object_id, + ObjectScanResult.scanned_at == latest_scan.c.scanned_at, + ), + isouter=True, + ) + .where(File.status == FileStatus.ACTIVE) + .where(File.deleted_at.is_(None)) + ) + + if query.search: + kw = f"%{query.search.strip().lower()}%" + statement = statement.where(func.lower(File.file_name).like(kw)) + if query.owner_id: + statement = statement.where(File.owner_id == int(query.owner_id)) + if query.mime_type: + statement = statement.where(File.mime_type == query.mime_type) + if query.virus_status: + wanted = [raw for raw, mapped in _VIRUS_STATUS_MAP.items() if mapped == query.virus_status] + statement = statement.where(ObjectScanResult.result.in_(wanted)) + + sort_column = { + "name": File.file_name, + "size": File.file_size, + "createdAt": File.created_at, + "updatedAt": File.updated_at, + }[query.sort] + statement = statement.order_by(sort_column.desc() if query.order == "desc" else sort_column.asc()) + + total = int(await self.db.scalar(select(func.count()).select_from(statement.subquery())) or 0) + total_pages = max(1, -(-total // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = (await self.db.execute(statement.offset(offset).limit(query.per_page))).all() + items = [self._to_item(file_row, object_row, owner_row, scan_row) for file_row, object_row, owner_row, scan_row in rows] + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def request_rescan(self, *, file_id: int, requested_by: int) -> RescanResponse: + file_row = await self.db.get(File, file_id) + if file_row is None or file_row.deleted_at is not None or file_row.status != FileStatus.ACTIVE: + raise ApiError(status_code=404, code=404, message="File not found") + + now = datetime.now(UTC) + self.db.add( + ObjectScanResult( + object_id=int(file_row.storage_object_id), + scan_type="virus", + result=ScanResult.PENDING, + details={"requestedBy": requested_by}, + scanned_at=now, + created_at=now, + ) + ) + await self.db.commit() + + await self.publisher.publish( + "files.rescan_requested", + { + "fileId": str(file_id), + "objectId": str(file_row.storage_object_id), + "requestedBy": requested_by, + }, + ) + return RescanResponse(file_id=str(file_id), virus_status="pending", scanned_at=now) + + @staticmethod + def _to_item( + file_row: File, + object_row: StorageObject, + owner_row: User, + scan_row: ObjectScanResult | None, + ) -> AdminFileAuditItem: + return AdminFileAuditItem( + id=str(file_row.file_id), + name=file_row.file_name, + size=int(file_row.file_size), + mime_type=file_row.mime_type or object_row.content_type or "application/octet-stream", + hash=(object_row.object_hash or "")[:16], + virus_status=_VIRUS_STATUS_MAP.get(scan_row.result, "pending") if scan_row else "pending", + is_shared=False, + owner_name=owner_row.username, + updated_at=file_row.updated_at, + created_at=file_row.created_at, + ) + + +__all__ = ["AdminFilesService"] diff --git a/app/src/fileflash/services/admin/logs.py b/app/src/fileflash/services/admin/logs.py new file mode 100644 index 0000000..93010e5 --- /dev/null +++ b/app/src/fileflash/services/admin/logs.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models.tables_audit_security import Log +from ...schemas.admin.logs import AdminLogsResponse, ListAdminLogsQuery, LogItem + + +class AdminLogsService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_logs(self, *, query: ListAdminLogsQuery) -> AdminLogsResponse: + statement = select(Log) + conditions = [] + if query.user_id: + conditions.append(Log.user_id == int(query.user_id)) + if query.operation: + conditions.append(Log.operation == query.operation) + if query.result: + conditions.append(Log.result == query.result) + if query.from_at: + conditions.append(Log.performed_at >= query.from_at) + if query.to_at: + conditions.append(Log.performed_at <= query.to_at) + if conditions: + statement = statement.where(and_(*conditions)) + statement = statement.order_by(Log.performed_at.desc(), Log.id.desc()) + + total = int(await self.db.scalar(select(func.count()).select_from(statement.subquery())) or 0) + total_pages = max(1, -(-total // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = list(await self.db.scalars(statement.offset(offset).limit(query.per_page))) + + logs = [ + LogItem( + id=str(row.id), + user_id=str(row.user_id) if row.user_id else None, + operation=row.operation, + operation_name=row.operation, + target_type=row.target_type, + target_id=str(row.target_id) if row.target_id else None, + result=row.result, + ip_address=row.ip_address, + user_agent=row.user_agent, + performed_at=row.performed_at or datetime.now(UTC), + details=row.details, + metadata=row.metadata_payload or {}, + ) + for row in rows + ] + return AdminLogsResponse( + logs=logs, + pagination={ + "totalItems": total, + "totalPages": total_pages, + "perPage": query.per_page, + "currentPage": query.page, + "hasPrev": query.page > 1, + "hasNext": query.page < total_pages, + }, + ) + + +__all__ = ["AdminLogsService"] diff --git a/app/src/fileflash/services/admin/moderation.py b/app/src/fileflash/services/admin/moderation.py new file mode 100644 index 0000000..b153fd1 --- /dev/null +++ b/app/src/fileflash/services/admin/moderation.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.tables_audit_security import ModerationCase +from ...models.tables_storage import File +from ...schemas.admin.moderation import ( + ListViolationsQuery, + ResolveViolationResponse, + ViolationItem, + ViolationLevel, +) +from ...schemas.common import PaginatedData, PaginationMeta + +_OPEN_STATES = ("pending", "under_review") + + +class AdminModerationService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_violations(self, *, query: ListViolationsQuery) -> PaginatedData[ViolationItem]: + statement = select(ModerationCase, File).join(File, File.file_id == ModerationCase.file_id, isouter=True) + if query.status: + statement = statement.where(ModerationCase.status == query.status) + statement = statement.order_by(ModerationCase.created_at.desc()) + + total = int(await self.db.scalar(select(func.count()).select_from(statement.subquery())) or 0) + total_pages = max(1, -(-total // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = (await self.db.execute(statement.offset(offset).limit(query.per_page))).all() + items = [self._to_item(case_row, file_row) for case_row, file_row in rows] + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def resolve_case(self, *, case_id: int, handled_by: int) -> ResolveViolationResponse: + case_row = await self.db.get(ModerationCase, case_id, with_for_update=True) + if case_row is None: + raise ApiError(status_code=404, code=404, message="Violation case not found") + if case_row.status not in _OPEN_STATES: + raise ApiError(status_code=409, code=409, message="Case already resolved") + + now = datetime.now(UTC) + case_row.status = "resolved" + case_row.resolution = "admin_clear" + case_row.handled_by = handled_by + case_row.handled_at = now + case_row.updated_at = now + await self.db.commit() + return ResolveViolationResponse(violation_id=str(case_id), resolved_at=now) + + @staticmethod + def _to_item(case_row: ModerationCase, file_row: File | None) -> ViolationItem: + return ViolationItem( + id=str(case_row.case_id), + file_id=str(case_row.file_id) if case_row.file_id else None, + file_name=file_row.file_name if file_row else None, + type=case_row.reason_type, + level=_level_from_confidence(case_row.confidence), + reported_at=case_row.created_at, + status=case_row.status, # type: ignore[arg-type] + ) + + +def _level_from_confidence(confidence: Decimal | None) -> ViolationLevel: + if confidence is None: + return "low" + value = float(confidence) + if value > 0.8: + return "high" + if value > 0.5: + return "medium" + return "low" + + +__all__ = ["AdminModerationService"] diff --git a/app/src/fileflash/services/admin/notifications.py b/app/src/fileflash/services/admin/notifications.py new file mode 100644 index 0000000..2325140 --- /dev/null +++ b/app/src/fileflash/services/admin/notifications.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import AsyncIterator + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.enums import UserStatus +from ...models.tables_audit_security import Notification +from ...models.tables_identity import User +from ...schemas.admin.notifications import ( + AdminNotificationItem, + BroadcastRequest, + BroadcastResponse, + ListAdminNotificationsQuery, +) +from ...schemas.common import PaginatedData, PaginationMeta + +MAX_BROADCAST_RECIPIENTS = 50_000 +_CHUNK_SIZE = 500 + + +class AdminNotificationsService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_notifications( + self, + *, + query: ListAdminNotificationsQuery, + ) -> PaginatedData[AdminNotificationItem]: + statement = select(Notification) + if query.status: + statement = statement.where(Notification.status == query.status) + if query.type: + statement = statement.where(Notification.notification_type == query.type) + statement = statement.order_by(Notification.created_at.desc(), Notification.id.desc()) + + total = int(await self.db.scalar(select(func.count()).select_from(statement.subquery())) or 0) + total_pages = max(1, -(-total // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = list(await self.db.scalars(statement.offset(offset).limit(query.per_page))) + items = [self._to_item(row) for row in rows] + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def broadcast(self, *, payload: BroadcastRequest, sender_id: int) -> BroadcastResponse: + message = (payload.message or "").strip() + if not message: + raise ApiError(status_code=422, code=422, message="Broadcast message cannot be empty") + + recipient_count = int( + await self.db.scalar( + select(func.count(User.user_id)).where( + User.status == UserStatus.ACTIVE, + User.deleted_at.is_(None), + ) + ) + or 0 + ) + if recipient_count > MAX_BROADCAST_RECIPIENTS: + raise ApiError( + status_code=422, + code=422, + message=f"Recipient count {recipient_count} exceeds limit {MAX_BROADCAST_RECIPIENTS}", + ) + + broadcast_id = str(uuid.uuid4()) + now = datetime.now(UTC) + delivered = 0 + async for chunk in self._iter_active_user_ids(): + rows = [ + Notification( + user_id=user_id, + title=payload.title, + notification_type=payload.type, + channel="in_app", + message=message, + payload={"broadcastId": broadcast_id}, + sender_user_id=sender_id, + status="sent", + sent_at=now, + is_read=False, + created_at=now, + updated_at=now, + ) + for user_id in chunk + ] + if rows: + self.db.add_all(rows) + await self.db.commit() + delivered += len(rows) + + return BroadcastResponse( + broadcast_id=broadcast_id, + recipient_count=delivered, + sent_at=now, + ) + + async def archive(self, *, notification_id: int) -> None: + row = await self.db.get(Notification, notification_id) + if row is None: + raise ApiError(status_code=404, code=404, message="Notification not found") + row.status = "archived" + row.updated_at = datetime.now(UTC) + await self.db.commit() + + async def _iter_active_user_ids(self) -> AsyncIterator[list[int]]: + stream = await self.db.scalars( + select(User.user_id).where(User.status == UserStatus.ACTIVE, User.deleted_at.is_(None)) + ) + buffer: list[int] = [] + for user_id in stream: + buffer.append(int(user_id)) + if len(buffer) >= _CHUNK_SIZE: + yield buffer + buffer = [] + if buffer: + yield buffer + + @staticmethod + def _to_item(row: Notification) -> AdminNotificationItem: + return AdminNotificationItem( + id=str(row.id), + message=row.message, + title=row.title, + type=row.notification_type, + status=row.status, + is_read=bool(row.is_read), + created_at=row.created_at or datetime.now(UTC), + updated_at=row.updated_at, + recipient_count=None, + ) + + +__all__ = ["AdminNotificationsService", "MAX_BROADCAST_RECIPIENTS"] diff --git a/app/src/fileflash/services/admin/storage.py b/app/src/fileflash/services/admin/storage.py new file mode 100644 index 0000000..95d4e75 --- /dev/null +++ b/app/src/fileflash/services/admin/storage.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime, timedelta + +from redis.asyncio import Redis +from sqlalchemy import Float, and_, case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.tables_audit_security import Log +from ...models.tables_identity import User +from ...models.tables_storage import File +from ...schemas.admin.storage import ( + AdminStorageSummary, + AdminStorageUserItem, + ListStorageUsersQuery, + UpdateQuotaResponse, + UsageTrendPoint, + UsageTrendQuery, + UsageTrendResponse, +) +from ...schemas.common import PaginatedData, PaginationMeta + +_STORAGE_EVENT_OPS = ("file.created", "file.deleted", "file.restored") +_TREND_CACHE_TTL = 300 + + +class AdminStorageService: + def __init__(self, db: AsyncSession, redis: Redis | None) -> None: + self.db = db + self.redis = redis + + async def summary(self) -> AdminStorageSummary: + used_sum = await self.db.scalar( + select(func.coalesce(func.sum(User.storage_used), 0)).where(User.deleted_at.is_(None)) + ) + limit_sum = await self.db.scalar( + select(func.coalesce(func.sum(User.storage_limit), 0)).where(User.deleted_at.is_(None)) + ) + file_count = await self.db.scalar( + select(func.count(File.file_id)).where(File.deleted_at.is_(None)) + ) + user_count = await self.db.scalar( + select(func.count(User.user_id)).where(User.deleted_at.is_(None)) + ) + + used = int(used_sum or 0) + limit = int(limit_sum or 0) + return AdminStorageSummary( + storage_used=used, + storage_limit=limit, + storage_percentage=round((used / limit) * 100, 2) if limit else 0.0, + file_count=int(file_count or 0), + user_count=int(user_count or 0), + updated_at=datetime.now(UTC), + ) + + async def list_storage_users(self, *, query: ListStorageUsersQuery) -> PaginatedData[AdminStorageUserItem]: + usage_ratio = case( + (User.storage_limit > 0, User.storage_used.cast(Float) / User.storage_limit.cast(Float)), + else_=0.0, + ) + sort_column = { + "storageUsed": User.storage_used, + "usagePercentage": usage_ratio, + "username": User.username, + }[query.sort] + + statement = ( + select(User) + .where(User.deleted_at.is_(None)) + .order_by(sort_column.desc() if query.order == "desc" else sort_column.asc()) + ) + + total = await self.db.scalar(select(func.count()).select_from(statement.subquery())) + total_items = int(total or 0) + total_pages = max(1, -(-total_items // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = list(await self.db.scalars(statement.offset(offset).limit(query.per_page))) + items = [self._to_item(row) for row in rows] + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total_items, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def update_quota(self, *, user_id: int, new_limit: int) -> UpdateQuotaResponse: + target = await self.db.get(User, user_id, with_for_update=True) + if target is None or target.deleted_at is not None: + raise ApiError(status_code=404, code=404, message="User not found") + if new_limit < int(target.storage_used): + raise ApiError(status_code=409, code=409, message="New quota cannot be below current usage") + + target.storage_limit = int(new_limit) + target.updated_at = datetime.now(UTC) + await self.db.commit() + await self.db.refresh(target) + return UpdateQuotaResponse( + user_id=str(target.user_id), + storage_limit=int(target.storage_limit), + storage_used=int(target.storage_used), + usage_percentage=round((int(target.storage_used) / max(int(target.storage_limit), 1)) * 100, 2), + updated_at=target.updated_at, + ) + + async def usage_trend(self, *, query: UsageTrendQuery) -> UsageTrendResponse: + cached = await self._cache_get(query.days) + if cached is not None: + return cached + + current_total = int( + await self.db.scalar( + select(func.coalesce(func.sum(User.storage_used), 0)).where(User.deleted_at.is_(None)) + ) + or 0 + ) + cutoff = datetime.now(UTC) - timedelta(days=query.days) + rows = await self.db.execute( + select(Log.operation, Log.metadata_payload, Log.performed_at) + .where(Log.operation.in_(_STORAGE_EVENT_OPS)) + .where(Log.performed_at >= cutoff) + ) + events = rows.all() + + deltas: dict[date, int] = {} + for operation, metadata_payload, performed_at in events: + if performed_at is None: + continue + size = int((metadata_payload or {}).get("size") or 0) + sign = 1 if operation in {"file.created", "file.restored"} else -1 + day = performed_at.astimezone(UTC).date() + deltas[day] = deltas.get(day, 0) + sign * size + + today = datetime.now(UTC).date() + points: list[UsageTrendPoint] = [] + running = current_total + for offset in range(query.days): + day = today - timedelta(days=offset) + points.append(UsageTrendPoint(date=day.isoformat(), used=max(running, 0))) + running -= deltas.get(day, 0) + points.reverse() + + result = UsageTrendResponse(trends=points, is_estimated=not events) + await self._cache_set(query.days, result) + return result + + @staticmethod + def _to_item(row: User) -> AdminStorageUserItem: + limit = max(int(row.storage_limit), 1) + return AdminStorageUserItem( + user_id=str(row.user_id), + username=row.username, + email=row.email, + storage_limit=int(row.storage_limit), + storage_used=int(row.storage_used), + usage_percentage=round((int(row.storage_used) / limit) * 100, 2), + updated_at=row.updated_at, + ) + + async def _cache_get(self, days: int) -> UsageTrendResponse | None: + if self.redis is None: + return None + try: + raw = await self.redis.get(f"admin:storage:trend:{days}") + except Exception: + return None + if not raw: + return None + return UsageTrendResponse.model_validate_json(raw) + + async def _cache_set(self, days: int, payload: UsageTrendResponse) -> None: + if self.redis is None: + return + try: + await self.redis.setex( + f"admin:storage:trend:{days}", + _TREND_CACHE_TTL, + payload.model_dump_json(by_alias=True), + ) + except Exception: + return + + +__all__ = ["AdminStorageService"] diff --git a/app/src/fileflash/services/admin/system.py b/app/src/fileflash/services/admin/system.py new file mode 100644 index 0000000..629018a --- /dev/null +++ b/app/src/fileflash/services/admin/system.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.settings import Settings +from ...models.enums import UploadTaskStatus +from ...models.tables_storage import UploadTask +from ...schemas.admin.system import RateLimitRule, RateLimitStatus, SystemHealth + + +class AdminSystemService: + def __init__(self, db: AsyncSession, settings: Settings) -> None: + self.db = db + self.settings = settings + + async def health(self) -> SystemHealth: + active_uploads = int( + await self.db.scalar( + select(func.count(UploadTask.task_id)).where( + UploadTask.status.in_([UploadTaskStatus.INIT, UploadTaskStatus.UPLOADING]) + ) + ) + or 0 + ) + + platform_targets: list[str] = [] + if self.settings.object_storage_bucket: + platform_targets.append(f"s3://{self.settings.object_storage_bucket}") + if self.settings.redis_url: + platform_targets.append("redis") + + return SystemHealth( + platform_targets=platform_targets, + max_concurrent_uploads=getattr(self.settings, "max_concurrent_uploads", 4), + active_upload_sessions=active_uploads, + virus_scan_enabled=bool(getattr(self.settings, "virus_scan_enabled", False)), + thumbnail_generation_enabled=bool(getattr(self.settings, "thumbnail_generation_enabled", True)), + registration_mail_enabled=bool(self.settings.mail_server and self.settings.mail_from), + hash_computation_enabled=True, + last_updated_at=datetime.now(UTC), + ) + + async def rate_limit_status(self) -> RateLimitStatus: + rules = [ + RateLimitRule( + rule_id="login", + scope="auth.login", + window_seconds=self.settings.login_rate_window_seconds, + limit=self.settings.login_rate_limit, + current_usage=0, + blocked_requests=0, + ), + RateLimitRule( + rule_id="register", + scope="auth.register", + window_seconds=self.settings.register_rate_window_seconds, + limit=self.settings.register_rate_limit, + current_usage=0, + blocked_requests=0, + ), + RateLimitRule( + rule_id="forgot_password", + scope="auth.forgot_password", + window_seconds=self.settings.forgot_password_rate_window_seconds, + limit=self.settings.forgot_password_rate_limit, + current_usage=0, + blocked_requests=0, + ), + RateLimitRule( + rule_id="resend_verification", + scope="auth.resend_verification", + window_seconds=self.settings.resend_verification_rate_window_seconds, + limit=self.settings.resend_verification_rate_limit, + current_usage=0, + blocked_requests=0, + ), + ] + return RateLimitStatus(rules=rules, evaluated_at=datetime.now(UTC)) + + +__all__ = ["AdminSystemService"] diff --git a/app/src/fileflash/services/admin/users.py b/app/src/fileflash/services/admin/users.py new file mode 100644 index 0000000..e53a881 --- /dev/null +++ b/app/src/fileflash/services/admin/users.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import and_, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.enums import UserRole, UserStatus +from ...models.tables_identity import User, UserSession +from ...schemas.admin.users import AdminUserItem, ListAdminUsersQuery, UpdateUserStatusResponse +from ...schemas.common import PaginatedData, PaginationMeta +from ._status import external_to_internal, internal_to_external + + +class AdminUsersService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_users(self, *, query: ListAdminUsersQuery) -> PaginatedData[AdminUserItem]: + statement = select(User).where(User.deleted_at.is_(None)) + if query.search: + keyword = f"%{query.search.strip().lower()}%" + statement = statement.where( + func.lower(User.username).like(keyword) | func.lower(User.email).like(keyword) + ) + if query.status: + statement = statement.where(User.status == external_to_internal(query.status)) + if query.role: + statement = statement.where(User.role == UserRole(query.role)) + + sort_column = { + "username": User.username, + "createdAt": User.created_at, + "storageUsed": User.storage_used, + }[query.sort] + statement = statement.order_by(sort_column.desc() if query.order == "desc" else sort_column.asc()) + + total = await self.db.scalar(select(func.count()).select_from(statement.subquery())) + total_items = int(total or 0) + total_pages = max(1, -(-total_items // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = list(await self.db.scalars(statement.offset(offset).limit(query.per_page))) + + last_seen_map = await self._collect_last_seen([int(row.user_id) for row in rows]) + items = [self._to_item(row, last_seen_map.get(int(row.user_id))) for row in rows] + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total_items, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def set_status(self, *, user_id: int, external_status: str) -> UpdateUserStatusResponse: + target = await self.db.get(User, user_id) + if target is None or target.deleted_at is not None: + raise ApiError(status_code=404, code=404, message="User not found") + + new_internal = external_to_internal(external_status) + if ( + new_internal == UserStatus.DISABLED + and target.role == UserRole.ADMIN + and target.status == UserStatus.ACTIVE + ): + remaining = await self.db.scalar( + select(func.count(User.user_id)).where( + and_( + User.role == UserRole.ADMIN, + User.status == UserStatus.ACTIVE, + User.user_id != user_id, + User.deleted_at.is_(None), + ) + ) + ) + if int(remaining or 0) == 0: + raise ApiError( + status_code=409, + code=409, + message="Cannot suspend the last active admin", + ) + + target.status = new_internal + target.updated_at = datetime.now(UTC) + if new_internal == UserStatus.DISABLED: + now = datetime.now(UTC) + await self.db.execute( + update(UserSession) + .where(and_(UserSession.user_id == user_id, UserSession.revoked_at.is_(None))) + .values(revoked_at=now, last_seen_at=now) + ) + + await self.db.commit() + await self.db.refresh(target) + return UpdateUserStatusResponse( + user_id=str(target.user_id), + status=internal_to_external(target.status), + updated_at=target.updated_at, + ) + + async def _collect_last_seen(self, user_ids: list[int]) -> dict[int, datetime]: + if not user_ids: + return {} + rows = await self.db.execute( + select(UserSession.user_id, func.max(UserSession.last_seen_at)) + .where(and_(UserSession.user_id.in_(user_ids), UserSession.revoked_at.is_(None))) + .group_by(UserSession.user_id) + ) + return {int(user_id): seen for user_id, seen in rows.all()} + + @staticmethod + def _to_item(row: User, last_active_at: datetime | None) -> AdminUserItem: + limit = max(int(row.storage_limit), 1) + return AdminUserItem( + user_id=str(row.user_id), + username=row.username, + email=row.email, + role=row.role.value if hasattr(row.role, "value") else str(row.role), + status=internal_to_external(row.status), + email_verified=bool(row.email_verified), + email_verified_at=row.email_verified_at, + storage_limit=int(row.storage_limit), + storage_used=int(row.storage_used), + usage_percentage=round((int(row.storage_used) / limit) * 100, 2), + last_login_at=row.last_login_at, + last_active_at=last_active_at, + created_at=row.created_at, + ) + + +__all__ = ["AdminUsersService"] diff --git a/app/src/fileflash/services/upload.py b/app/src/fileflash/services/upload.py index 0b221d8..76e135a 100644 --- a/app/src/fileflash/services/upload.py +++ b/app/src/fileflash/services/upload.py @@ -33,7 +33,14 @@ from ..models.tables_storage import File, FileMediaMetadata, Folder, StorageObject, UploadTask, UploadTaskPart from ..models.tables_worker import BackgroundJob from ..s3.minio_client import MinioObjectStorageClient, ObjectStorageError -from ..schemas.file import MergeChunksRequest, MergeChunksResponse, UploadPreflightRequest, UploadPreflightResponse +from ..schemas.file import ( + MergeChunksRequest, + MergeChunksResponse, + RecoverableUploadSession, + UploadCancelResponse, + UploadPreflightRequest, + UploadPreflightResponse, +) from .background_jobs import BackgroundJobService logger = logging.getLogger(__name__) @@ -149,6 +156,65 @@ async def _operation() -> UploadPreflightResponse: raise to_retryable_concurrency_error(exc) from exc raise + async def list_recoverable_sessions(self, *, user_id: int) -> list[RecoverableUploadSession]: + now = datetime.now(UTC) + rows = list( + await self.db.scalars( + select(UploadTask) + .where( + and_( + UploadTask.user_id == user_id, + UploadTask.upload_id.is_not(None), + UploadTask.status.in_((UploadTaskStatus.INIT, UploadTaskStatus.UPLOADING)), + or_(UploadTask.expired_at.is_(None), UploadTask.expired_at > now), + ) + ) + .order_by(UploadTask.updated_at.desc(), UploadTask.task_id.desc()) + ) + ) + + sessions: list[RecoverableUploadSession] = [] + for row in rows: + if row.expired_at and row.expired_at <= now: + continue + if row.status not in (UploadTaskStatus.INIT, UploadTaskStatus.UPLOADING): + continue + upload_id = (row.upload_id or "").strip() + file_name = (row.file_name or "").strip() + file_hash = self._normalize_task_hash(row.object_hash) + if not upload_id or not file_name or not file_hash: + continue + + file_size = max(0, int(row.total_size or 0)) + uploaded_bytes = max(0, min(file_size, int(row.uploaded_bytes or 0))) + chunk_size = int(row.chunk_size or self._resolved_chunk_size()) + parent_id = str(row.folder_id) if row.folder_id is not None else "root" + status = "init" if row.status == UploadTaskStatus.INIT else "uploading" + resolved_mime_type = resolve_file_mime_type( + mime_type=row.mime_type, + file_ext=self._extract_ext(file_name), + file_name=file_name, + default=DEFAULT_MIME_TYPE, + ) + + sessions.append( + RecoverableUploadSession( + upload_id=upload_id, + file_name=file_name, + file_size=file_size, + uploaded_bytes=uploaded_bytes, + chunk_size=max(1, chunk_size), + file_hash=file_hash, + mime_type=resolved_mime_type, + parent_id=parent_id, + updated_at=row.updated_at or now, + expired_at=row.expired_at, + status=status, + ) + ) + + return sessions + async def upload_chunk(self, *, user_id: int, upload_id: str, chunk_index: int, chunk_bytes: bytes) -> None: if chunk_index < 0: raise ApiError(status_code=400, code=400, message="chunkIndex must be >= 0") @@ -546,6 +612,34 @@ async def _operation() -> MergeChunksResponse: raise to_retryable_concurrency_error(exc) from exc raise + async def cancel_upload_session(self, *, user_id: int, upload_id: str) -> UploadCancelResponse: + task = await self._get_task_for_update(user_id=user_id, upload_id=upload_id) + if task is None: + raise ApiError(status_code=404, code=404, message="Upload session not found") + if task.status == UploadTaskStatus.COMPLETED: + raise ApiError(status_code=409, code=409, message="Upload session already completed") + if task.status == UploadTaskStatus.FAILED: + raise ApiError(status_code=409, code=409, message="Upload session is not cancelable") + if task.status == UploadTaskStatus.ABORTED: + return UploadCancelResponse( + upload_id=upload_id, + canceled_at=task.updated_at or datetime.now(UTC), + ) + + canceled_at = datetime.now(UTC) + cleanup_keys = await self._collect_task_cleanup_keys(task=task) + task.status = UploadTaskStatus.ABORTED + task.last_error = "Upload canceled by client" + await self.db.commit() + + if cleanup_keys: + try: + await self.storage.remove_objects(object_keys=cleanup_keys) + except Exception: # noqa: BLE001 + logger.exception("Failed to cleanup canceled upload temp objects uploadId=%s", upload_id) + + return UploadCancelResponse(upload_id=upload_id, canceled_at=canceled_at) + async def _cleanup_expired_tasks(self, *, user_id: int) -> None: now = datetime.now(UTC) expired_tasks = list( @@ -567,10 +661,7 @@ async def _cleanup_expired_tasks(self, *, user_id: int) -> None: for task in expired_tasks: task.status = UploadTaskStatus.ABORTED task.last_error = "Upload session expired" - if task.object_key: - cleanup_keys.append(task.object_key) - part_indexes = await self._list_uploaded_indexes(task_id=task.task_id) - cleanup_keys.extend(self._build_part_object_key(task=task, chunk_index=index) for index in part_indexes) + cleanup_keys.extend(await self._collect_task_cleanup_keys(task=task)) await self.db.commit() if cleanup_keys: @@ -758,6 +849,15 @@ async def _list_uploaded_indexes(self, *, task_id: int) -> list[int]: ) ) + async def _collect_task_cleanup_keys(self, *, task: UploadTask) -> list[str]: + keys: list[str] = [] + if task.object_key: + keys.append(task.object_key) + part_indexes = await self._list_uploaded_indexes(task_id=task.task_id) + keys.extend(self._build_part_object_key(task=task, chunk_index=index) for index in part_indexes) + # Preserve order but deduplicate possible repeated entries. + return list(dict.fromkeys(keys)) + async def _abort_task(self, *, task: UploadTask, reason: str) -> None: task.status = UploadTaskStatus.ABORTED task.last_error = reason diff --git a/app/tests/test_admin_files_routes.py b/app/tests/test_admin_files_routes.py new file mode 100644 index 0000000..be04461 --- /dev/null +++ b/app/tests/test_admin_files_routes.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_admin_files_service, require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_files import router as admin_router +from fileflash.schemas.admin.files import RescanResponse + + +class StubService: + async def list_files(self, *, query): # noqa: ANN001 + return SimpleNamespace( + model_dump=lambda **_: { + "items": [], + "pagination": { + "totalItems": 0, + "totalPages": 1, + "perPage": query.per_page, + "currentPage": query.page, + "hasPrev": False, + "hasNext": False, + }, + } + ) + + async def request_rescan(self, *, file_id, requested_by): # noqa: ANN001 + _ = requested_by + return RescanResponse( + file_id=str(file_id), + virus_status="pending", + scanned_at=datetime.now(UTC), + ) + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(admin_router, prefix="/api/v1") + app.dependency_overrides[get_admin_files_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=99) + return TestClient(app) + + +def test_list_files_returns_empty() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/files") + assert resp.status_code == 200 + + +def test_rescan_returns_pending() -> None: + with _client() as c: + resp = c.post("/api/v1/admin/files/7/rescan") + assert resp.status_code == 200 + assert resp.json()["data"]["virusStatus"] == "pending" diff --git a/app/tests/test_admin_files_service.py b/app/tests/test_admin_files_service.py new file mode 100644 index 0000000..b5bd4eb --- /dev/null +++ b/app/tests/test_admin_files_service.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.models.enums import FileStatus +from fileflash.schemas.admin.files import ListAdminFilesQuery +from fileflash.services.admin.files import AdminFilesService + + +class DummyPublisher: + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + + async def publish(self, event_name: str, payload: dict) -> None: # noqa: ANN001 + self.calls.append((event_name, payload)) + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.commit = AsyncMock() + self.execute = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + + +@pytest.mark.asyncio +async def test_list_returns_paginated_empty_when_no_files() -> None: + session = DummySession() + session.scalar.return_value = 0 + session.execute.return_value = Mock(all=lambda: []) + service = AdminFilesService(db=session, publisher=DummyPublisher()) # type: ignore[arg-type] + + result = await service.list_files(query=ListAdminFilesQuery()) + + assert result.items == [] + assert result.pagination.total_items == 0 + + +@pytest.mark.asyncio +async def test_rescan_missing_file_returns_404() -> None: + session = DummySession() + session.get.return_value = None + service = AdminFilesService(db=session, publisher=DummyPublisher()) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.request_rescan(file_id=1, requested_by=99) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_rescan_inserts_scan_record_and_publishes_event() -> None: + session = DummySession() + file_row = Mock( + file_id=1, + storage_object_id=2, + deleted_at=None, + status=FileStatus.ACTIVE, + ) + session.get.return_value = file_row + publisher = DummyPublisher() + service = AdminFilesService(db=session, publisher=publisher) # type: ignore[arg-type] + + result = await service.request_rescan(file_id=1, requested_by=99) + + assert result.virus_status == "pending" + assert publisher.calls and publisher.calls[0][0] == "files.rescan_requested" + session.commit.assert_awaited() diff --git a/app/tests/test_admin_logs_routes.py b/app/tests/test_admin_logs_routes.py new file mode 100644 index 0000000..4d82e73 --- /dev/null +++ b/app/tests/test_admin_logs_routes.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_admin_logs_service, require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_logs import router as logs_router +from fileflash.schemas.admin.logs import AdminLogsResponse + + +class StubService: + async def list_logs(self, *, query): # noqa: ANN001 + return AdminLogsResponse( + logs=[], + pagination={ + "totalItems": 0, + "totalPages": 1, + "perPage": query.per_page, + "currentPage": query.page, + "hasPrev": False, + "hasNext": False, + }, + ) + + +def test_admin_can_list_logs() -> None: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(logs_router, prefix="/api/v1") + app.dependency_overrides[get_admin_logs_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + with TestClient(app) as c: + resp = c.get("/api/v1/admin/logs") + assert resp.status_code == 200 + assert resp.json()["data"]["logs"] == [] diff --git a/app/tests/test_admin_moderation_routes.py b/app/tests/test_admin_moderation_routes.py new file mode 100644 index 0000000..04112b4 --- /dev/null +++ b/app/tests/test_admin_moderation_routes.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_admin_moderation_service, require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_moderation import router as mod_router +from fileflash.schemas.admin.moderation import ResolveViolationResponse + + +class StubService: + async def list_violations(self, *, query): # noqa: ANN001 + return SimpleNamespace( + model_dump=lambda **_: { + "items": [], + "pagination": { + "totalItems": 0, + "totalPages": 1, + "perPage": query.per_page, + "currentPage": query.page, + "hasPrev": False, + "hasNext": False, + }, + } + ) + + async def resolve_case(self, *, case_id, handled_by): # noqa: ANN001 + _ = handled_by + return ResolveViolationResponse(violation_id=str(case_id), resolved_at=datetime.now(UTC)) + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(mod_router, prefix="/api/v1") + app.dependency_overrides[get_admin_moderation_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + return TestClient(app) + + +def test_list_violations() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/violations") + assert resp.status_code == 200 + + +def test_resolve_violation() -> None: + with _client() as c: + resp = c.post("/api/v1/admin/violations/3/resolve") + assert resp.status_code == 200 + assert resp.json()["data"]["violationId"] == "3" diff --git a/app/tests/test_admin_moderation_service.py b/app/tests/test_admin_moderation_service.py new file mode 100644 index 0000000..a0007f6 --- /dev/null +++ b/app/tests/test_admin_moderation_service.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.schemas.admin.moderation import ListViolationsQuery +from fileflash.services.admin.moderation import AdminModerationService + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.commit = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + self.execute = AsyncMock() + + +@pytest.mark.asyncio +async def test_resolve_missing_case_returns_404() -> None: + session = DummySession() + session.get.return_value = None + service = AdminModerationService(db=session) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.resolve_case(case_id=1, handled_by=2) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_resolve_already_resolved_returns_409() -> None: + session = DummySession() + case = Mock(case_id=1, status="resolved") + session.get.return_value = case + service = AdminModerationService(db=session) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.resolve_case(case_id=1, handled_by=2) + assert exc.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_resolve_pending_case_sets_resolved() -> None: + session = DummySession() + case = Mock(case_id=1, status="pending", handled_by=None, handled_at=None, resolution=None, file_id=10) + session.get.return_value = case + service = AdminModerationService(db=session) # type: ignore[arg-type] + + result = await service.resolve_case(case_id=1, handled_by=2) + + assert result.violation_id == "1" + assert case.status == "resolved" + assert case.resolution == "admin_clear" + assert case.handled_by == 2 + session.commit.assert_awaited() + + +@pytest.mark.asyncio +async def test_list_returns_empty_paginated() -> None: + session = DummySession() + session.scalar.return_value = 0 + session.execute.return_value = Mock(all=lambda: []) + service = AdminModerationService(db=session) # type: ignore[arg-type] + + result = await service.list_violations(query=ListViolationsQuery()) + assert result.items == [] + assert result.pagination.total_items == 0 diff --git a/app/tests/test_admin_notifications_routes.py b/app/tests/test_admin_notifications_routes.py new file mode 100644 index 0000000..c708e2b --- /dev/null +++ b/app/tests/test_admin_notifications_routes.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_admin_notifications_service, require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_notifications import router as n_router +from fileflash.schemas.admin.notifications import BroadcastResponse + + +class StubService: + async def list_notifications(self, *, query): # noqa: ANN001 + return SimpleNamespace( + model_dump=lambda **_: { + "items": [], + "pagination": { + "totalItems": 0, + "totalPages": 1, + "perPage": query.per_page, + "currentPage": query.page, + "hasPrev": False, + "hasNext": False, + }, + } + ) + + async def broadcast(self, *, payload, sender_id): # noqa: ANN001 + _ = payload, sender_id + return BroadcastResponse( + broadcast_id="b1", + recipient_count=2, + sent_at=datetime.now(UTC), + ) + + async def archive(self, *, notification_id): # noqa: ANN001 + _ = notification_id + return None + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(n_router, prefix="/api/v1") + app.dependency_overrides[get_admin_notifications_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + return TestClient(app) + + +def test_list() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/notifications") + assert resp.status_code == 200 + + +def test_broadcast() -> None: + with _client() as c: + resp = c.post( + "/api/v1/admin/notifications/broadcast", + json={"message": "hello", "type": "system"}, + ) + assert resp.status_code == 200 + assert resp.json()["data"]["broadcastId"] == "b1" + + +def test_archive() -> None: + with _client() as c: + resp = c.delete("/api/v1/admin/notifications/9") + assert resp.status_code == 200 diff --git a/app/tests/test_admin_notifications_service.py b/app/tests/test_admin_notifications_service.py new file mode 100644 index 0000000..d8236ca --- /dev/null +++ b/app/tests/test_admin_notifications_service.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.schemas.admin.notifications import BroadcastRequest +from fileflash.services.admin.notifications import ( + MAX_BROADCAST_RECIPIENTS, + AdminNotificationsService, +) + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.add_all = Mock() + self.commit = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.execute = AsyncMock() + + +@pytest.mark.asyncio +async def test_broadcast_rejects_empty_message() -> None: + session = DummySession() + service = AdminNotificationsService(db=session) # type: ignore[arg-type] + with pytest.raises(ApiError): + await service.broadcast( + payload=BroadcastRequest.model_construct(title=None, message="", type="system"), + sender_id=1, + ) + + +@pytest.mark.asyncio +async def test_broadcast_too_many_recipients_returns_422() -> None: + session = DummySession() + session.scalar.return_value = MAX_BROADCAST_RECIPIENTS + 1 + service = AdminNotificationsService(db=session) # type: ignore[arg-type] + with pytest.raises(ApiError) as exc: + await service.broadcast(payload=BroadcastRequest(message="hi", type="system"), sender_id=1) + assert exc.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_broadcast_writes_one_row_per_recipient() -> None: + session = DummySession() + session.scalar.return_value = 3 + session.scalars.return_value = iter([10, 11, 12]) + service = AdminNotificationsService(db=session) # type: ignore[arg-type] + + result = await service.broadcast(payload=BroadcastRequest(message="ping", type="system"), sender_id=1) + + assert result.recipient_count == 3 + assert session.add_all.called diff --git a/app/tests/test_admin_storage_routes.py b/app/tests/test_admin_storage_routes.py new file mode 100644 index 0000000..69c3396 --- /dev/null +++ b/app/tests/test_admin_storage_routes.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_admin_storage_service, require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_storage import router as storage_router +from fileflash.schemas.admin.storage import ( + AdminStorageSummary, + UpdateQuotaResponse, + UsageTrendPoint, + UsageTrendResponse, +) + + +class StubService: + async def summary(self): # noqa: ANN201 + return AdminStorageSummary( + storage_used=1000, + storage_limit=10000, + storage_percentage=10.0, + file_count=3, + user_count=2, + updated_at=datetime.now(UTC), + ) + + async def list_storage_users(self, *, query): # noqa: ANN001, ANN201 + return SimpleNamespace( + model_dump=lambda **_: { + "items": [], + "pagination": { + "totalItems": 0, + "totalPages": 1, + "perPage": query.per_page, + "currentPage": query.page, + "hasPrev": False, + "hasNext": False, + }, + } + ) + + async def update_quota(self, *, user_id, new_limit): # noqa: ANN001, ANN201 + return UpdateQuotaResponse( + user_id=str(user_id), + storage_limit=new_limit, + storage_used=0, + usage_percentage=0.0, + updated_at=datetime.now(UTC), + ) + + async def usage_trend(self, *, query): # noqa: ANN001, ANN201 + _ = query + return UsageTrendResponse( + trends=[UsageTrendPoint(date="2026-05-24", used=1)], + is_estimated=False, + ) + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(storage_router, prefix="/api/v1") + app.dependency_overrides[get_admin_storage_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + return TestClient(app) + + +def test_summary_returns_camel_case() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/storage/summary") + assert resp.status_code == 200 + assert "storageUsed" in resp.json()["data"] + + +def test_update_quota_passes_storage_limit() -> None: + with _client() as c: + resp = c.patch("/api/v1/admin/storage/users/7/quota", json={"storageLimit": 1024}) + assert resp.status_code == 200 + assert resp.json()["data"]["storageLimit"] == 1024 + + +def test_usage_trend_default_days() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/storage/usage-trend") + assert resp.status_code == 200 + assert len(resp.json()["data"]["trends"]) == 1 diff --git a/app/tests/test_admin_storage_service.py b/app/tests/test_admin_storage_service.py new file mode 100644 index 0000000..fb4233b --- /dev/null +++ b/app/tests/test_admin_storage_service.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.models.enums import UserRole, UserStatus +from fileflash.models.tables_identity import User +from fileflash.schemas.admin.storage import UsageTrendQuery +from fileflash.services.admin.storage import AdminStorageService + + +def _user(**kwargs) -> User: + base = dict( + user_id=1, + username="bob", + email="b@x.com", + password_hash="x", + role=UserRole.USER, + status=UserStatus.ACTIVE, + email_verified=True, + email_verified_at=datetime.now(UTC), + storage_limit=10 * 1024 * 1024 * 1024, + storage_used=2 * 1024 * 1024 * 1024, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + base.update(kwargs) + return User(**base) + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.commit = AsyncMock() + self.refresh = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + self.execute = AsyncMock() + + +@pytest.mark.asyncio +async def test_summary_aggregates_users_and_files() -> None: + session = DummySession() + session.scalar.side_effect = [ + 2 * 1024 * 1024 * 1024, + 10 * 1024 * 1024 * 1024, + 42, + 7, + ] + service = AdminStorageService(db=session, redis=None) # type: ignore[arg-type] + + result = await service.summary() + + assert result.storage_used == 2 * 1024 * 1024 * 1024 + assert result.user_count == 7 + assert result.file_count == 42 + assert round(result.storage_percentage, 2) == 20.0 + + +@pytest.mark.asyncio +async def test_update_quota_rejects_below_usage() -> None: + session = DummySession() + target = _user(storage_used=5 * 1024 * 1024 * 1024) + session.get.return_value = target + service = AdminStorageService(db=session, redis=None) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.update_quota(user_id=1, new_limit=1 * 1024 * 1024 * 1024) + assert exc.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_update_quota_success_updates_and_commits() -> None: + session = DummySession() + target = _user(storage_used=1 * 1024 * 1024 * 1024) + session.get.return_value = target + service = AdminStorageService(db=session, redis=None) # type: ignore[arg-type] + + result = await service.update_quota(user_id=1, new_limit=20 * 1024 * 1024 * 1024) + + assert result.storage_limit == 20 * 1024 * 1024 * 1024 + assert target.storage_limit == 20 * 1024 * 1024 * 1024 + session.commit.assert_awaited() + + +@pytest.mark.asyncio +async def test_usage_trend_returns_n_points() -> None: + session = DummySession() + session.scalar.return_value = 2 * 1024 * 1024 * 1024 + session.execute.return_value = Mock(all=lambda: []) + service = AdminStorageService(db=session, redis=None) # type: ignore[arg-type] + + result = await service.usage_trend(query=UsageTrendQuery(days=7)) + + assert len(result.trends) == 7 + assert result.is_estimated is True diff --git a/app/tests/test_admin_system_routes.py b/app/tests/test_admin_system_routes.py new file mode 100644 index 0000000..f758ba6 --- /dev/null +++ b/app/tests/test_admin_system_routes.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_admin_system_service, require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_system import router as sys_router +from fileflash.schemas.admin.system import RateLimitRule, RateLimitStatus, SystemHealth + + +class StubService: + async def health(self): # noqa: ANN201 + return SystemHealth( + platform_targets=["s3://bkt"], + max_concurrent_uploads=4, + active_upload_sessions=0, + virus_scan_enabled=False, + thumbnail_generation_enabled=True, + registration_mail_enabled=False, + hash_computation_enabled=True, + last_updated_at=datetime.now(UTC), + ) + + async def rate_limit_status(self): # noqa: ANN201 + return RateLimitStatus( + rules=[ + RateLimitRule( + rule_id="login", + scope="auth.login", + window_seconds=60, + limit=5, + current_usage=0, + blocked_requests=0, + ) + ], + evaluated_at=datetime.now(UTC), + ) + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(sys_router, prefix="/api/v1") + app.dependency_overrides[get_admin_system_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + return TestClient(app) + + +def test_health() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/system/health") + assert resp.status_code == 200 + assert resp.json()["data"]["activeUploadSessions"] == 0 + + +def test_rate_limit() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/system/rate-limit") + assert resp.status_code == 200 + assert resp.json()["data"]["rules"][0]["scope"] == "auth.login" diff --git a/app/tests/test_admin_user_status_mapping.py b/app/tests/test_admin_user_status_mapping.py new file mode 100644 index 0000000..351aeb3 --- /dev/null +++ b/app/tests/test_admin_user_status_mapping.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.models.enums import UserStatus +from fileflash.services.admin._status import external_to_internal, internal_to_external + + +def test_active_maps_both_ways() -> None: + assert external_to_internal("active") == UserStatus.ACTIVE + assert internal_to_external(UserStatus.ACTIVE) == "active" + + +def test_suspended_maps_to_disabled() -> None: + assert external_to_internal("suspended") == UserStatus.DISABLED + assert internal_to_external(UserStatus.DISABLED) == "suspended" + + +def test_locked_externally_appears_as_suspended() -> None: + assert internal_to_external(UserStatus.LOCKED) == "suspended" + + +def test_pending_verification_passthrough() -> None: + assert internal_to_external(UserStatus.PENDING_VERIFICATION) == "pending_verification" + + +def test_invalid_external_status_raises() -> None: + with pytest.raises(ApiError): + external_to_internal("garbage") diff --git a/app/tests/test_admin_users_routes.py b/app/tests/test_admin_users_routes.py new file mode 100644 index 0000000..b5cccae --- /dev/null +++ b/app/tests/test_admin_users_routes.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_admin_users_service, require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_users import router as admin_users_router +from fileflash.schemas.admin.users import AdminUserItem, UpdateUserStatusResponse +from fileflash.schemas.common import PaginatedData, PaginationMeta + + +class StubService: + async def list_users(self, *, query): # noqa: ANN001 + item = AdminUserItem( + user_id="1", + username="alice", + email="a@x.com", + role="USER", + status="active", + email_verified=True, + email_verified_at=None, + storage_limit=1024, + storage_used=0, + usage_percentage=0.0, + last_login_at=None, + last_active_at=None, + created_at=datetime.now(UTC), + ) + return PaginatedData( + items=[item], + pagination=PaginationMeta( + total_items=1, + total_pages=1, + per_page=query.per_page, + current_page=query.page, + has_prev=False, + has_next=False, + ), + ) + + async def set_status(self, *, user_id, external_status): # noqa: ANN001 + return UpdateUserStatusResponse( + user_id=str(user_id), + status=external_status, + updated_at=datetime.now(UTC), + ) + + +def _client(admin: bool) -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(admin_users_router, prefix="/api/v1") + app.dependency_overrides[get_admin_users_service] = lambda: StubService() + if admin: + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + else: + + async def _deny(): + raise ApiError(status_code=403, code=403, message="forbidden") + + app.dependency_overrides[require_admin] = _deny + return TestClient(app) + + +def test_admin_can_list_users() -> None: + with _client(admin=True) as c: + resp = c.get("/api/v1/admin/users") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert body["data"]["items"][0]["username"] == "alice" + + +def test_non_admin_gets_403() -> None: + with _client(admin=False) as c: + resp = c.get("/api/v1/admin/users") + assert resp.status_code == 403 + + +def test_admin_can_patch_status() -> None: + with _client(admin=True) as c: + resp = c.patch("/api/v1/admin/users/42/status", json={"status": "suspended"}) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == "suspended" diff --git a/app/tests/test_admin_users_service.py b/app/tests/test_admin_users_service.py new file mode 100644 index 0000000..9e455a8 --- /dev/null +++ b/app/tests/test_admin_users_service.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.models.enums import UserRole, UserStatus +from fileflash.models.tables_identity import User +from fileflash.schemas.admin.users import ListAdminUsersQuery +from fileflash.services.admin.users import AdminUsersService + + +def _user(**kwargs) -> User: + base = dict( + user_id=1, + username="alice", + email="alice@example.com", + password_hash="x", + role=UserRole.USER, + status=UserStatus.ACTIVE, + email_verified=True, + email_verified_at=datetime.now(UTC), + storage_limit=10 * 1024 * 1024 * 1024, + storage_used=1024, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + base.update(kwargs) + return User(**base) + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.commit = AsyncMock() + self.refresh = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + self.execute = AsyncMock() + + +@pytest.mark.asyncio +async def test_list_users_returns_paginated_items() -> None: + session = DummySession() + session.scalar.return_value = 1 + session.scalars.return_value = [_user()] + session.execute.return_value = Mock(all=lambda: []) + service = AdminUsersService(db=session) # type: ignore[arg-type] + + result = await service.list_users(query=ListAdminUsersQuery()) + + assert result.pagination.total_items == 1 + assert result.items[0].username == "alice" + assert result.items[0].status == "active" + + +@pytest.mark.asyncio +async def test_set_status_user_not_found() -> None: + session = DummySession() + session.get.return_value = None + service = AdminUsersService(db=session) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.set_status(user_id=999, external_status="suspended") + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_set_status_suspend_last_admin_blocked() -> None: + session = DummySession() + admin = _user(user_id=2, role=UserRole.ADMIN, status=UserStatus.ACTIVE) + session.get.return_value = admin + session.scalar.return_value = 0 + service = AdminUsersService(db=session) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.set_status(user_id=2, external_status="suspended") + assert exc.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_set_status_suspends_user_and_revokes_sessions() -> None: + session = DummySession() + target = _user(user_id=3) + session.get.return_value = target + session.scalar.return_value = 5 + service = AdminUsersService(db=session) # type: ignore[arg-type] + + result = await service.set_status(user_id=3, external_status="suspended") + + assert result.status == "suspended" + assert target.status == UserStatus.DISABLED + session.commit.assert_awaited() diff --git a/app/tests/test_upload_routes.py b/app/tests/test_upload_routes.py new file mode 100644 index 0000000..a5b9855 --- /dev/null +++ b/app/tests/test_upload_routes.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import get_current_user, get_upload_service +from fileflash.models.tables_identity import User +from fileflash.routers.uploads import router as uploads_router +from fileflash.schemas.file import RecoverableUploadSession, UploadCancelResponse + + +class StubUploadService: + async def cancel_upload_session(self, *, user_id: int, upload_id: str) -> UploadCancelResponse: # noqa: ARG002 + return UploadCancelResponse( + upload_id=upload_id, + canceled_at=datetime(2026, 5, 24, 9, 0, 0, tzinfo=UTC), + ) + + async def list_recoverable_sessions(self, *, user_id: int) -> list[RecoverableUploadSession]: # noqa: ARG002 + return [ + RecoverableUploadSession( + upload_id="upload-1", + file_name="demo.mp4", + file_size=1024, + uploaded_bytes=512, + chunk_size=256, + file_hash="a" * 64, + mime_type="video/mp4", + parent_id="root", + updated_at=datetime(2026, 5, 24, 8, 0, 0, tzinfo=UTC), + expired_at=datetime(2026, 5, 25, 8, 0, 0, tzinfo=UTC), + status="uploading", + ) + ] + + +def _build_client() -> TestClient: + app = FastAPI() + app.include_router(uploads_router, prefix="/api/v1") + + async def _current_user_override() -> User: + return User(user_id=1, username="owner", email="owner@example.com", password_hash="hash") + + app.dependency_overrides[get_current_user] = _current_user_override + app.dependency_overrides[get_upload_service] = lambda: StubUploadService() + return TestClient(app) + + +def test_post_upload_cancel_route_returns_success_shell() -> None: + with _build_client() as client: + response = client.post("/api/v1/uploads/upload-123/cancel") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["message"] == "Upload session canceled" + assert payload["data"]["uploadId"] == "upload-123" + assert str(payload["data"]["canceledAt"]).startswith("2026-05-24T09:00:00") + + +def test_get_recoverable_uploads_route_returns_success_shell() -> None: + with _build_client() as client: + response = client.get("/api/v1/uploads/recoverable") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["message"] == "Recoverable upload sessions fetched" + assert isinstance(payload["data"], list) + assert payload["data"][0]["uploadId"] == "upload-1" + assert payload["data"][0]["uploadedBytes"] == 512 diff --git a/app/tests/test_upload_service.py b/app/tests/test_upload_service.py index 298d019..af27651 100644 --- a/app/tests/test_upload_service.py +++ b/app/tests/test_upload_service.py @@ -168,6 +168,111 @@ async def test_preflight_returns_503_when_storage_is_unavailable(monkeypatch: py cleanup_mock.assert_not_awaited() +@pytest.mark.asyncio +async def test_list_recoverable_sessions_returns_only_active_non_expired_tasks(): + session = DummySession() + service, _storage = make_service(session) + now = datetime.now(UTC) + active_uploading = UploadTask( + task_id=100, + user_id=1, + folder_id=7, + file_name="resume.bin", + mime_type="application/octet-stream", + bucket_name="fileflash", + object_key="objects/u1/resume", + object_hash="a" * 64, + total_size=100, + uploaded_bytes=140, + chunk_size=None, + upload_id="upload-resume-1", + status=UploadTaskStatus.UPLOADING, + expired_at=now + timedelta(hours=1), + updated_at=now, + ) + active_init = UploadTask( + task_id=101, + user_id=1, + folder_id=None, + file_name="init.txt", + mime_type="text/plain", + bucket_name="fileflash", + object_key="objects/u1/init", + object_hash="b" * 64, + total_size=20, + uploaded_bytes=5, + chunk_size=10, + upload_id="upload-resume-2", + status=UploadTaskStatus.INIT, + expired_at=now + timedelta(hours=2), + updated_at=now, + ) + expired_task = UploadTask( + task_id=102, + user_id=1, + folder_id=9, + file_name="expired.txt", + mime_type="text/plain", + bucket_name="fileflash", + object_key="objects/u1/expired", + object_hash="c" * 64, + total_size=30, + chunk_size=10, + upload_id="upload-expired", + status=UploadTaskStatus.UPLOADING, + expired_at=now - timedelta(minutes=1), + updated_at=now, + ) + failed_task = UploadTask( + task_id=103, + user_id=1, + folder_id=9, + file_name="failed.txt", + mime_type="text/plain", + bucket_name="fileflash", + object_key="objects/u1/failed", + object_hash="d" * 64, + total_size=30, + chunk_size=10, + upload_id="upload-failed", + status=UploadTaskStatus.FAILED, + expired_at=now + timedelta(hours=1), + updated_at=now, + ) + missing_hash_task = UploadTask( + task_id=104, + user_id=1, + folder_id=9, + file_name="missing-hash.txt", + mime_type="text/plain", + bucket_name="fileflash", + object_key="objects/u1/nohash", + object_hash=None, + total_size=30, + chunk_size=10, + upload_id="upload-nohash", + status=UploadTaskStatus.UPLOADING, + expired_at=now + timedelta(hours=1), + updated_at=now, + ) + session.scalars_queue = [[active_uploading, active_init, expired_task, failed_task, missing_hash_task]] + + sessions = await service.list_recoverable_sessions(user_id=1) + + assert len(sessions) == 2 + first = sessions[0] + assert first.upload_id == "upload-resume-1" + assert first.file_size == 100 + assert first.uploaded_bytes == 100 + assert first.chunk_size == 5 * 1024 * 1024 + assert first.parent_id == "7" + assert first.status == "uploading" + second = sessions[1] + assert second.upload_id == "upload-resume-2" + assert second.parent_id == "root" + assert second.status == "init" + + @pytest.mark.asyncio async def test_upload_chunk_rejects_out_of_range_index(monkeypatch: pytest.MonkeyPatch): session = DummySession() @@ -201,6 +306,108 @@ async def test_upload_chunk_rejects_out_of_range_index(monkeypatch: pytest.Monke assert "out of range" in exc.value.message +@pytest.mark.asyncio +async def test_cancel_upload_session_marks_aborted_and_cleans_objects(monkeypatch: pytest.MonkeyPatch): + session = DummySession() + service, storage = make_service(session) + task = UploadTask( + task_id=9, + user_id=1, + folder_id=1, + file_name="clip.mp4", + mime_type="video/mp4", + bucket_name="fileflash", + object_key="objects/u1/demo", + object_hash="a" * 64, + total_size=1024, + chunk_size=512, + upload_id="upload-cancel-active", + status=UploadTaskStatus.UPLOADING, + expired_at=datetime.now(UTC) + timedelta(hours=1), + ) + + monkeypatch.setattr(service, "_get_task_for_update", AsyncMock(return_value=task)) + monkeypatch.setattr( + service, + "_collect_task_cleanup_keys", + AsyncMock(return_value=["objects/u1/demo", "temp/u1/upload-cancel-active/part-00000000"]), + ) + + response = await service.cancel_upload_session(user_id=1, upload_id="upload-cancel-active") + + assert response.upload_id == "upload-cancel-active" + assert task.status == UploadTaskStatus.ABORTED + assert task.last_error == "Upload canceled by client" + assert session.commits == 1 + storage.remove_objects.assert_awaited_once_with( + object_keys=["objects/u1/demo", "temp/u1/upload-cancel-active/part-00000000"], + ) + + +@pytest.mark.asyncio +async def test_cancel_upload_session_is_idempotent_for_aborted_task(monkeypatch: pytest.MonkeyPatch): + session = DummySession() + service, storage = make_service(session) + canceled_at = datetime.now(UTC) + task = UploadTask( + task_id=10, + user_id=1, + folder_id=1, + file_name="already-aborted.txt", + mime_type="text/plain", + bucket_name="fileflash", + object_key="objects/u1/aborted", + object_hash="b" * 64, + total_size=8, + chunk_size=4, + upload_id="upload-cancel-aborted", + status=UploadTaskStatus.ABORTED, + expired_at=datetime.now(UTC) + timedelta(hours=1), + updated_at=canceled_at, + ) + monkeypatch.setattr(service, "_get_task_for_update", AsyncMock(return_value=task)) + collect_mock = AsyncMock(return_value=["objects/u1/aborted"]) + monkeypatch.setattr(service, "_collect_task_cleanup_keys", collect_mock) + + response = await service.cancel_upload_session(user_id=1, upload_id="upload-cancel-aborted") + + assert response.upload_id == "upload-cancel-aborted" + assert response.canceled_at == canceled_at + assert session.commits == 0 + collect_mock.assert_not_awaited() + storage.remove_objects.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancel_upload_session_rejects_completed_task(monkeypatch: pytest.MonkeyPatch): + session = DummySession() + service, storage = make_service(session) + task = UploadTask( + task_id=11, + user_id=1, + folder_id=1, + file_name="done.txt", + mime_type="text/plain", + bucket_name="fileflash", + object_key="objects/u1/done", + object_hash="c" * 64, + total_size=8, + chunk_size=4, + upload_id="upload-cancel-completed", + status=UploadTaskStatus.COMPLETED, + expired_at=datetime.now(UTC) + timedelta(hours=1), + ) + monkeypatch.setattr(service, "_get_task_for_update", AsyncMock(return_value=task)) + + with pytest.raises(ApiError) as exc: + await service.cancel_upload_session(user_id=1, upload_id="upload-cancel-completed") + + assert exc.value.status_code == 409 + assert "already completed" in exc.value.message + assert session.commits == 0 + storage.remove_objects.assert_not_awaited() + + @pytest.mark.asyncio async def test_merge_returns_conflict_without_strategy(monkeypatch: pytest.MonkeyPatch): session = DummySession() diff --git a/docs/superpowers/plans/2026-05-24-admin-console-backend.md b/docs/superpowers/plans/2026-05-24-admin-console-backend.md new file mode 100644 index 0000000..5551456 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-admin-console-backend.md @@ -0,0 +1,3165 @@ +# Admin Console Backend Implementation Plan (Plan A) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 `app/src/fileflash/` 下补齐 7 个 `/admin/*` 路由(users / storage / files / moderation / logs / notifications / system),全部接真实 DB;对外形状与现有前端 `web/src/api/*.ts` 期望一致。 + +**Architecture:** 沿用现有 `routers/admin_registration_email_domain_rules.py` 的模式:`routers/admin_<域>.py` 仅做参数编排 + `api_success` 壳;业务逻辑落在 `services/admin/<域>.py`;Pydantic schema 在 `schemas/admin/<域>.py`(`CamelModel` + `by_alias`)。所有路由 `Depends(require_admin)`。 + +**Tech Stack:** FastAPI + SQLAlchemy AsyncSession + Pydantic v2 (CamelModel) + Redis (rate-limit) + pytest/AsyncMock。 + +**Reference Spec:** `docs/superpowers/specs/2026-05-24-admin-console-design.md` + +**前置知识(执行前必读)** + +1. `core/errors.py`:错误抛 `ApiError(status_code, code, message)`;`code` 是数字(=HTTP 状态码),不是字符串符号。message 用英文短语 + 可选中文。 +2. `core/deps.py`:`require_admin` / `get_current_user` / `get_db` 已经存在;新增 service 都按 `get_admin_*_service` 命名加 dep factory。 +3. `schemas/common.py`:`CamelModel` 强制 camelCase by alias、`extra="forbid"`、`str_strip_whitespace=True`;`PaginatedData[T]` / `PageQuery(page, per_page)` 直接复用。 +4. `agents.md §5`:**用户状态外部语义是 `active | suspended`**,但 `enums.UserStatus` 内部只有 `ACTIVE / PENDING_VERIFICATION / LOCKED / DISABLED`,**没有 SUSPENDED**。映射: + - 外部 `'active'` ↔ 内部 `UserStatus.ACTIVE` + - 外部 `'suspended'` ↔ 内部 `UserStatus.DISABLED`(admin 主动停用;`LOCKED` 留给自动锁定) +5. `RegistrationEmailDomainRule` 路由 / 服务 / 测试**保持原样**,不动。 +6. 测试目录:`app/tests/test_admin_<域>_routes.py`(路由层 + stub service)和 `test_admin_<域>_service.py`(service 层 + DummySession 或 in-memory 假实现)。 +7. 执行命令:在 `app/` 目录下 `uv run pytest tests/test_admin_<域>*.py -v`;类型检查不存在,构建用 `uv run python -c "from fileflash.main import app; print(app.title)"`。 + +**对 spec 的两处简化(已与执行者明确)** + +- spec §3.8 Rate Limit:初版**不扫 Redis**。后端只维护一份"已注册限流规则"的静态枚举,`current_usage / blocked_requests` 暂返回 0。原因:现有 `RedisRateLimiter` 仅暴露 `allow()`,无规则注册中心;如需运行时统计,需先扩展 limiter,留作后续。 +- spec §5.3 `notification.broadcast_completed` 事件:本期**不发布**。`Notification` 行写入 + `Log` 写入已足以观测,事件名空挂以待后续 worker 接入。 + +--- + +## File Structure + +**新建** + +``` +app/src/fileflash/ + schemas/admin/__init__.py + schemas/admin/users.py + schemas/admin/storage.py + schemas/admin/files.py + schemas/admin/moderation.py + schemas/admin/logs.py + schemas/admin/notifications.py + schemas/admin/system.py + services/admin/__init__.py + services/admin/users.py + services/admin/storage.py + services/admin/files.py + services/admin/moderation.py + services/admin/logs.py + services/admin/notifications.py + services/admin/system.py + routers/admin_users.py + routers/admin_storage.py + routers/admin_files.py + routers/admin_moderation.py + routers/admin_logs.py + routers/admin_notifications.py + routers/admin_system.py + +app/tests/ + test_admin_users_routes.py + test_admin_users_service.py + test_admin_storage_routes.py + test_admin_storage_service.py + test_admin_files_routes.py + test_admin_files_service.py + test_admin_moderation_routes.py + test_admin_moderation_service.py + test_admin_logs_routes.py + test_admin_notifications_routes.py + test_admin_notifications_service.py + test_admin_system_routes.py +``` + +**修改** + +``` +app/src/fileflash/ + core/deps.py ← 加 7 个 get_admin_*_service factory + routers/__init__.py ← include 7 个新 router + services/messaging.py ← 加新事件常量(可选) + services/rate_limiter.py ← 加 record_blocked() 累计(5.x 任务) +``` + +--- + +## Task 0: 基础设施(admin 包 + 状态映射) + +**Files:** +- Create: `app/src/fileflash/schemas/admin/__init__.py` +- Create: `app/src/fileflash/services/admin/__init__.py` +- Create: `app/src/fileflash/services/admin/_status.py` +- Test: `app/tests/test_admin_user_status_mapping.py` + +- [ ] **Step 0.1: 建空包(schemas/admin, services/admin)** + +```bash +# 创建两个空 __init__.py +``` + +`app/src/fileflash/schemas/admin/__init__.py`: + +```python +"""Admin-only schemas. All requests/responses share CamelModel from schemas.common.""" +``` + +`app/src/fileflash/services/admin/__init__.py`: + +```python +"""Admin-only services. Each module owns one /admin/* surface.""" +``` + +- [ ] **Step 0.2: 写失败测试 — 状态映射** + +`app/tests/test_admin_user_status_mapping.py`: + +```python +from __future__ import annotations + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.models.enums import UserStatus +from fileflash.services.admin._status import external_to_internal, internal_to_external + + +def test_active_maps_both_ways() -> None: + assert external_to_internal("active") == UserStatus.ACTIVE + assert internal_to_external(UserStatus.ACTIVE) == "active" + + +def test_suspended_maps_to_disabled() -> None: + assert external_to_internal("suspended") == UserStatus.DISABLED + assert internal_to_external(UserStatus.DISABLED) == "suspended" + + +def test_locked_externally_appears_as_suspended() -> None: + # 自动锁定外部仍展示为 suspended(用户不需要区分原因) + assert internal_to_external(UserStatus.LOCKED) == "suspended" + + +def test_pending_verification_passthrough() -> None: + assert internal_to_external(UserStatus.PENDING_VERIFICATION) == "pending_verification" + + +def test_invalid_external_status_raises() -> None: + with pytest.raises(ApiError): + external_to_internal("garbage") +``` + +- [ ] **Step 0.3: 跑测试验证失败** + +Run: `cd app && uv run pytest tests/test_admin_user_status_mapping.py -v` +Expected: ImportError on `fileflash.services.admin._status` + +- [ ] **Step 0.4: 实现 _status.py** + +`app/src/fileflash/services/admin/_status.py`: + +```python +from __future__ import annotations + +from ...core.errors import ApiError +from ...models.enums import UserStatus + +_EXTERNAL_TO_INTERNAL = { + "active": UserStatus.ACTIVE, + "suspended": UserStatus.DISABLED, +} + +_INTERNAL_TO_EXTERNAL = { + UserStatus.ACTIVE: "active", + UserStatus.DISABLED: "suspended", + UserStatus.LOCKED: "suspended", + UserStatus.PENDING_VERIFICATION: "pending_verification", +} + + +def external_to_internal(value: str) -> UserStatus: + try: + return _EXTERNAL_TO_INTERNAL[value] + except KeyError as exc: + raise ApiError( + status_code=422, + code=422, + message=f"Invalid user status: {value!r}", + ) from exc + + +def internal_to_external(value: UserStatus) -> str: + return _INTERNAL_TO_EXTERNAL.get(value, value.value) + + +__all__ = ["external_to_internal", "internal_to_external"] +``` + +- [ ] **Step 0.5: 跑测试验证通过** + +Run: `cd app && uv run pytest tests/test_admin_user_status_mapping.py -v` +Expected: 5 passed + +- [ ] **Step 0.6: Commit** + +```bash +cd app && git add src/fileflash/schemas/admin/__init__.py src/fileflash/services/admin/__init__.py src/fileflash/services/admin/_status.py +cd .. && git add app/tests/test_admin_user_status_mapping.py +git commit -m "feat(admin): scaffold admin packages and user status mapping" +``` + +--- + +## Task 1: Admin Users — schemas + service + +**Files:** +- Create: `app/src/fileflash/schemas/admin/users.py` +- Create: `app/src/fileflash/services/admin/users.py` +- Test: `app/tests/test_admin_users_service.py` + +- [ ] **Step 1.1: 写 schemas** + +`app/src/fileflash/schemas/admin/users.py`: + +```python +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import Field + +from ..common import CamelModel, PageQuery + +ExternalUserStatus = Literal["active", "suspended", "pending_verification"] + + +class AdminUserItem(CamelModel): + user_id: str + username: str + email: str + role: str + status: ExternalUserStatus + email_verified: bool + email_verified_at: datetime | None = None + storage_limit: int + storage_used: int + usage_percentage: float + last_login_at: datetime | None = None + last_active_at: datetime | None = None + created_at: datetime + + +class ListAdminUsersQuery(PageQuery): + search: str | None = None + status: Literal["active", "suspended"] | None = None + role: Literal["USER", "ADMIN"] | None = None + sort: Literal["username", "createdAt", "storageUsed"] = "createdAt" + order: Literal["asc", "desc"] = "desc" + + +class UpdateUserStatusRequest(CamelModel): + status: Literal["active", "suspended"] + + +class UpdateUserStatusResponse(CamelModel): + user_id: str + status: ExternalUserStatus + updated_at: datetime + + +__all__ = [ + "AdminUserItem", + "ListAdminUsersQuery", + "UpdateUserStatusRequest", + "UpdateUserStatusResponse", +] +``` + +- [ ] **Step 1.2: 写失败测试 — list 默认分页** + +`app/tests/test_admin_users_service.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.models.enums import UserRole, UserStatus +from fileflash.models.tables_identity import User +from fileflash.schemas.admin.users import ListAdminUsersQuery +from fileflash.services.admin.users import AdminUsersService + + +def _user(**kwargs) -> User: + base = dict( + user_id=1, + username="alice", + email="alice@example.com", + password_hash="x", + role=UserRole.USER, + status=UserStatus.ACTIVE, + email_verified=True, + email_verified_at=datetime.now(UTC), + storage_limit=10 * 1024 * 1024 * 1024, + storage_used=1024, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + base.update(kwargs) + return User(**base) + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.commit = AsyncMock() + self.refresh = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + self.execute = AsyncMock() + + +@pytest.mark.asyncio +async def test_list_users_returns_paginated_items() -> None: + session = DummySession() + session.scalar.return_value = 1 + session.scalars.return_value = [_user()] + session.execute.return_value = Mock(all=lambda: []) # no sessions joined + service = AdminUsersService(db=session) # type: ignore[arg-type] + + result = await service.list_users(query=ListAdminUsersQuery()) + + assert result.pagination.total_items == 1 + assert result.items[0].username == "alice" + assert result.items[0].status == "active" +``` + +- [ ] **Step 1.3: 跑测试验证失败** + +Run: `cd app && uv run pytest tests/test_admin_users_service.py -v` +Expected: ImportError on `fileflash.services.admin.users` + +- [ ] **Step 1.4: 实现 AdminUsersService 的 list_users** + +`app/src/fileflash/services/admin/users.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.enums import UserRole, UserStatus +from ...models.tables_identity import User, UserSession +from ...schemas.admin.users import ( + AdminUserItem, + ListAdminUsersQuery, + UpdateUserStatusResponse, +) +from ...schemas.common import PaginatedData, PaginationMeta +from ._status import external_to_internal, internal_to_external + + +class AdminUsersService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_users( + self, + *, + query: ListAdminUsersQuery, + ) -> PaginatedData[AdminUserItem]: + statement = select(User).where(User.deleted_at.is_(None)) + + if query.search: + keyword = f"%{query.search.strip().lower()}%" + statement = statement.where( + or_( + func.lower(User.username).like(keyword), + func.lower(User.email).like(keyword), + ) + ) + if query.status: + statement = statement.where(User.status == external_to_internal(query.status)) + if query.role: + statement = statement.where(User.role == UserRole(query.role)) + + sort_column = { + "username": User.username, + "createdAt": User.created_at, + "storageUsed": User.storage_used, + }[query.sort] + statement = statement.order_by( + sort_column.desc() if query.order == "desc" else sort_column.asc() + ) + + total = await self.db.scalar(select(func.count()).select_from(statement.subquery())) + total_items = int(total or 0) + total_pages = max(1, -(-total_items // query.per_page)) + offset = (query.page - 1) * query.per_page + + rows = list( + await self.db.scalars(statement.offset(offset).limit(query.per_page)) + ) + + last_seen_map = await self._collect_last_seen([row.user_id for row in rows]) + items = [self._to_item(row, last_seen_map.get(row.user_id)) for row in rows] + + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total_items, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def _collect_last_seen(self, user_ids: list[int]) -> dict[int, datetime]: + if not user_ids: + return {} + rows = await self.db.execute( + select(UserSession.user_id, func.max(UserSession.last_seen_at)) + .where( + and_( + UserSession.user_id.in_(user_ids), + UserSession.revoked_at.is_(None), + ) + ) + .group_by(UserSession.user_id) + ) + return {user_id: seen for user_id, seen in rows.all()} + + @staticmethod + def _to_item(row: User, last_active_at: datetime | None) -> AdminUserItem: + limit = max(row.storage_limit, 1) + return AdminUserItem( + user_id=str(row.user_id), + username=row.username, + email=row.email, + role=row.role.value if hasattr(row.role, "value") else str(row.role), + status=internal_to_external(row.status), # type: ignore[arg-type] + email_verified=row.email_verified, + email_verified_at=row.email_verified_at, + storage_limit=row.storage_limit, + storage_used=row.storage_used, + usage_percentage=round((row.storage_used / limit) * 100, 2), + last_login_at=row.last_login_at, + last_active_at=last_active_at, + created_at=row.created_at, + ) + + +__all__ = ["AdminUsersService"] +``` + +- [ ] **Step 1.5: 跑测试验证通过** + +Run: `cd app && uv run pytest tests/test_admin_users_service.py -v` +Expected: 1 passed + +- [ ] **Step 1.6: 写失败测试 — set_status(成功 + 防误锁 + 404)** + +追加到 `app/tests/test_admin_users_service.py`: + +```python +@pytest.mark.asyncio +async def test_set_status_user_not_found() -> None: + session = DummySession() + session.get.return_value = None + service = AdminUsersService(db=session) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.set_status(user_id=999, external_status="suspended") + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_set_status_suspend_last_admin_blocked() -> None: + session = DummySession() + admin = _user(user_id=2, role=UserRole.ADMIN, status=UserStatus.ACTIVE) + session.get.return_value = admin + # 当前活跃 admin 仅 1 名 => 不允许 suspend + session.scalar.return_value = 1 + service = AdminUsersService(db=session) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.set_status(user_id=2, external_status="suspended") + assert exc.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_set_status_suspends_user_and_revokes_sessions() -> None: + session = DummySession() + target = _user(user_id=3) + session.get.return_value = target + session.scalar.return_value = 5 # active admins enough + service = AdminUsersService(db=session) # type: ignore[arg-type] + + result = await service.set_status(user_id=3, external_status="suspended") + + assert result.status == "suspended" + assert target.status == UserStatus.DISABLED + session.commit.assert_awaited() +``` + +- [ ] **Step 1.7: 跑测试验证失败** + +Run: `cd app && uv run pytest tests/test_admin_users_service.py -v` +Expected: AttributeError on `service.set_status` + +- [ ] **Step 1.8: 实现 set_status** + +追加到 `app/src/fileflash/services/admin/users.py`(在类内): + +```python + async def set_status( + self, + *, + user_id: int, + external_status: str, + ) -> UpdateUserStatusResponse: + target = await self.db.get(User, user_id) + if target is None or target.deleted_at is not None: + raise ApiError(status_code=404, code=404, message="User not found") + + new_internal = external_to_internal(external_status) + + if ( + new_internal == UserStatus.DISABLED + and target.role == UserRole.ADMIN + and target.status == UserStatus.ACTIVE + ): + remaining = await self.db.scalar( + select(func.count(User.user_id)).where( + and_( + User.role == UserRole.ADMIN, + User.status == UserStatus.ACTIVE, + User.user_id != user_id, + User.deleted_at.is_(None), + ) + ) + ) + if int(remaining or 0) == 0: + raise ApiError( + status_code=409, + code=409, + message="Cannot suspend the last active admin", + ) + + target.status = new_internal + target.updated_at = datetime.now(UTC) + + if new_internal == UserStatus.DISABLED: + await self.db.execute( + UserSession.__table__.update() + .where( + and_( + UserSession.user_id == user_id, + UserSession.revoked_at.is_(None), + ) + ) + .values(revoked_at=datetime.now(UTC)) + ) + + await self.db.commit() + await self.db.refresh(target) + + return UpdateUserStatusResponse( + user_id=str(target.user_id), + status=internal_to_external(target.status), # type: ignore[arg-type] + updated_at=target.updated_at, + ) +``` + +- [ ] **Step 1.9: 跑测试验证通过** + +Run: `cd app && uv run pytest tests/test_admin_users_service.py -v` +Expected: 4 passed + +- [ ] **Step 1.10: Commit** + +```bash +git add app/src/fileflash/schemas/admin/users.py app/src/fileflash/services/admin/users.py app/tests/test_admin_users_service.py +git commit -m "feat(admin): users service with list and set_status (last-admin guard)" +``` + +--- + +## Task 2: Admin Users — router + deps + 注册 + +**Files:** +- Create: `app/src/fileflash/routers/admin_users.py` +- Modify: `app/src/fileflash/core/deps.py` +- Modify: `app/src/fileflash/routers/__init__.py` +- Test: `app/tests/test_admin_users_routes.py` + +- [ ] **Step 2.1: 写失败测试 — 403 给非 admin / 200 给 admin** + +`app/tests/test_admin_users_routes.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_users import ( + router as admin_users_router, + get_admin_users_service, +) +from fileflash.schemas.admin.users import ( + AdminUserItem, + UpdateUserStatusResponse, +) +from fileflash.schemas.common import PaginatedData, PaginationMeta + + +class StubService: + async def list_users(self, *, query): # noqa: ANN001 + item = AdminUserItem( + user_id="1", username="alice", email="a@x.com", role="USER", + status="active", email_verified=True, email_verified_at=None, + storage_limit=1024, storage_used=0, usage_percentage=0.0, + last_login_at=None, last_active_at=None, created_at=datetime.now(UTC), + ) + return PaginatedData( + items=[item], + pagination=PaginationMeta( + total_items=1, total_pages=1, + per_page=query.per_page, current_page=query.page, + has_prev=False, has_next=False, + ), + ) + + async def set_status(self, *, user_id, external_status): # noqa: ANN001 + return UpdateUserStatusResponse( + user_id=str(user_id), status=external_status, + updated_at=datetime.now(UTC), + ) + + +def _client(admin: bool) -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(admin_users_router, prefix="/api/v1") + app.dependency_overrides[get_admin_users_service] = lambda: StubService() + if admin: + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + else: + async def _deny(): + raise ApiError(status_code=403, code=403, message="forbidden") + app.dependency_overrides[require_admin] = _deny + return TestClient(app) + + +def test_admin_can_list_users() -> None: + with _client(admin=True) as c: + resp = c.get("/api/v1/admin/users") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert body["data"]["items"][0]["username"] == "alice" + + +def test_non_admin_gets_403() -> None: + with _client(admin=False) as c: + resp = c.get("/api/v1/admin/users") + assert resp.status_code == 403 + + +def test_admin_can_patch_status() -> None: + with _client(admin=True) as c: + resp = c.patch("/api/v1/admin/users/42/status", json={"status": "suspended"}) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == "suspended" +``` + +- [ ] **Step 2.2: 跑测试验证失败** + +Run: `cd app && uv run pytest tests/test_admin_users_routes.py -v` +Expected: ImportError on `fileflash.routers.admin_users` + +- [ ] **Step 2.3: 实现 router + 局部 dep factory** + +`app/src/fileflash/routers/admin_users.py`: + +```python +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.deps import require_admin +from ..core.errors import api_success +from ..db.deps import get_db +from ..models.tables_identity import User +from ..schemas.admin.users import ( + ListAdminUsersQuery, + UpdateUserStatusRequest, +) +from ..services.admin.users import AdminUsersService + +router = APIRouter(prefix="/admin/users", tags=["admin"]) + + +def get_admin_users_service(db: AsyncSession = Depends(get_db)) -> AdminUsersService: + return AdminUsersService(db=db) + + +@router.get("") +async def list_admin_users( + query: ListAdminUsersQuery = Depends(), + _: User = Depends(require_admin), + service: AdminUsersService = Depends(get_admin_users_service), +): + data = await service.list_users(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Users fetched") + + +@router.patch("/{user_id}/status") +async def update_admin_user_status( + user_id: int, + payload: UpdateUserStatusRequest, + _: User = Depends(require_admin), + service: AdminUsersService = Depends(get_admin_users_service), +): + result = await service.set_status(user_id=user_id, external_status=payload.status) + return api_success(data=result.model_dump(by_alias=True), message="Status updated") + + +__all__ = ["router", "get_admin_users_service"] +``` + +- [ ] **Step 2.4: 跑测试验证通过** + +Run: `cd app && uv run pytest tests/test_admin_users_routes.py -v` +Expected: 3 passed + +- [ ] **Step 2.5: 注册 router 到 api_router** + +Modify `app/src/fileflash/routers/__init__.py`:在 imports 区追加 + +```python +from .admin_users import router as admin_users_router +``` + +在 `include_router` 区追加 + +```python +api_router.include_router(admin_users_router) +``` + +- [ ] **Step 2.6: 冒烟启动** + +Run: `cd app && uv run python -c "from fileflash.main import app; print([r.path for r in app.routes if '/admin/users' in r.path])"` +Expected: prints `['/api/v1/admin/users', '/api/v1/admin/users/{user_id}/status']` + +- [ ] **Step 2.7: Commit** + +```bash +git add app/src/fileflash/routers/admin_users.py app/src/fileflash/routers/__init__.py app/tests/test_admin_users_routes.py +git commit -m "feat(admin): /admin/users list + /admin/users/{id}/status routes" +``` + +--- + +## Task 3: Admin Storage(summary / users / quota / usage-trend) + +**Files:** +- Create: `app/src/fileflash/schemas/admin/storage.py` +- Create: `app/src/fileflash/services/admin/storage.py` +- Create: `app/src/fileflash/routers/admin_storage.py` +- Modify: `app/src/fileflash/routers/__init__.py` +- Test: `app/tests/test_admin_storage_service.py`, `app/tests/test_admin_storage_routes.py` + +- [ ] **Step 3.1: 写 schemas** + +`app/src/fileflash/schemas/admin/storage.py`: + +```python +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import Field + +from ..common import CamelModel, PageQuery + + +class AdminStorageSummary(CamelModel): + storage_used: int + storage_limit: int + storage_percentage: float + file_count: int + user_count: int + updated_at: datetime + + +class AdminStorageUserItem(CamelModel): + user_id: str + username: str + email: str + storage_limit: int + storage_used: int + usage_percentage: float + updated_at: datetime + + +class ListStorageUsersQuery(PageQuery): + sort: Literal["storageUsed", "usagePercentage", "username"] = "storageUsed" + order: Literal["asc", "desc"] = "desc" + + +class UpdateQuotaRequest(CamelModel): + storage_limit: int = Field(ge=0) + + +class UpdateQuotaResponse(CamelModel): + user_id: str + storage_limit: int + storage_used: int + usage_percentage: float + updated_at: datetime + + +class UsageTrendQuery(CamelModel): + days: Literal[7, 14, 30] = 7 + + +class UsageTrendPoint(CamelModel): + date: str + used: int + + +class UsageTrendResponse(CamelModel): + trends: list[UsageTrendPoint] + is_estimated: bool = False + + +__all__ = [ + "AdminStorageSummary", + "AdminStorageUserItem", + "ListStorageUsersQuery", + "UpdateQuotaRequest", + "UpdateQuotaResponse", + "UsageTrendQuery", + "UsageTrendPoint", + "UsageTrendResponse", +] +``` + +- [ ] **Step 3.2: 写失败测试 — summary / quota / usage-trend** + +`app/tests/test_admin_storage_service.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.models.tables_identity import User +from fileflash.models.enums import UserRole, UserStatus +from fileflash.schemas.admin.storage import ListStorageUsersQuery, UsageTrendQuery +from fileflash.services.admin.storage import AdminStorageService + + +def _user(**kwargs) -> User: + base = dict( + user_id=1, username="bob", email="b@x.com", password_hash="x", + role=UserRole.USER, status=UserStatus.ACTIVE, + email_verified=True, email_verified_at=datetime.now(UTC), + storage_limit=10 * 1024 * 1024 * 1024, storage_used=2 * 1024 * 1024 * 1024, + created_at=datetime.now(UTC), updated_at=datetime.now(UTC), + ) + base.update(kwargs) + return User(**base) + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.commit = AsyncMock() + self.refresh = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + self.execute = AsyncMock() + + +@pytest.mark.asyncio +async def test_summary_aggregates_users_and_files() -> None: + session = DummySession() + # 三次 scalar 调用:sum(storage_used), sum(storage_limit), count(files), count(users) + session.scalar.side_effect = [ + 2 * 1024 * 1024 * 1024, # storage_used sum + 10 * 1024 * 1024 * 1024, # storage_limit sum + 42, # file count + 7, # user count + ] + service = AdminStorageService(db=session, redis=None) # type: ignore[arg-type] + + result = await service.summary() + + assert result.storage_used == 2 * 1024 * 1024 * 1024 + assert result.user_count == 7 + assert result.file_count == 42 + assert round(result.storage_percentage, 2) == 20.0 + + +@pytest.mark.asyncio +async def test_update_quota_rejects_below_usage() -> None: + session = DummySession() + target = _user(storage_used=5 * 1024 * 1024 * 1024) + session.get.return_value = target + service = AdminStorageService(db=session, redis=None) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.update_quota(user_id=1, new_limit=1 * 1024 * 1024 * 1024) + assert exc.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_update_quota_success_updates_and_commits() -> None: + session = DummySession() + target = _user(storage_used=1 * 1024 * 1024 * 1024) + session.get.return_value = target + service = AdminStorageService(db=session, redis=None) # type: ignore[arg-type] + + result = await service.update_quota(user_id=1, new_limit=20 * 1024 * 1024 * 1024) + + assert result.storage_limit == 20 * 1024 * 1024 * 1024 + assert target.storage_limit == 20 * 1024 * 1024 * 1024 + session.commit.assert_awaited() + + +@pytest.mark.asyncio +async def test_usage_trend_returns_n_points() -> None: + session = DummySession() + session.scalar.return_value = 2 * 1024 * 1024 * 1024 # T_now + session.execute.return_value = Mock(all=lambda: []) # no events + service = AdminStorageService(db=session, redis=None) # type: ignore[arg-type] + + result = await service.usage_trend(query=UsageTrendQuery(days=7)) + + assert len(result.trends) == 7 + assert result.is_estimated is True # no events => 估算 +``` + +- [ ] **Step 3.3: 跑测试验证失败** + +Run: `cd app && uv run pytest tests/test_admin_storage_service.py -v` +Expected: ImportError + +- [ ] **Step 3.4: 实现 AdminStorageService** + +`app/src/fileflash/services/admin/storage.py`: + +```python +from __future__ import annotations + +import json +from datetime import UTC, date, datetime, timedelta +from typing import Any + +from redis.asyncio import Redis +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.tables_audit_security import Log +from ...models.tables_identity import User +from ...models.tables_storage import File +from ...schemas.admin.storage import ( + AdminStorageSummary, + AdminStorageUserItem, + ListStorageUsersQuery, + UpdateQuotaResponse, + UsageTrendPoint, + UsageTrendQuery, + UsageTrendResponse, +) +from ...schemas.common import PaginatedData, PaginationMeta + +_STORAGE_EVENT_OPS = ("file.created", "file.deleted", "file.restored") +_TREND_CACHE_TTL = 300 + + +class AdminStorageService: + def __init__(self, db: AsyncSession, redis: Redis | None) -> None: + self.db = db + self.redis = redis + + async def summary(self) -> AdminStorageSummary: + used_sum = await self.db.scalar(select(func.coalesce(func.sum(User.storage_used), 0))) + limit_sum = await self.db.scalar(select(func.coalesce(func.sum(User.storage_limit), 0))) + file_count = await self.db.scalar(select(func.count(File.file_id))) + user_count = await self.db.scalar( + select(func.count(User.user_id)).where(User.deleted_at.is_(None)) + ) + + used = int(used_sum or 0) + limit = int(limit_sum or 0) + return AdminStorageSummary( + storage_used=used, + storage_limit=limit, + storage_percentage=round((used / limit) * 100, 2) if limit else 0.0, + file_count=int(file_count or 0), + user_count=int(user_count or 0), + updated_at=datetime.now(UTC), + ) + + async def list_storage_users( + self, + *, + query: ListStorageUsersQuery, + ) -> PaginatedData[AdminStorageUserItem]: + statement = select(User).where(User.deleted_at.is_(None)) + + sort_column = { + "storageUsed": User.storage_used, + "username": User.username, + "usagePercentage": User.storage_used, # 近似按 storage_used 排序 + }[query.sort] + statement = statement.order_by( + sort_column.desc() if query.order == "desc" else sort_column.asc() + ) + + total = await self.db.scalar(select(func.count()).select_from(statement.subquery())) + total_items = int(total or 0) + total_pages = max(1, -(-total_items // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = list(await self.db.scalars(statement.offset(offset).limit(query.per_page))) + + items = [self._to_item(row) for row in rows] + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total_items, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def update_quota(self, *, user_id: int, new_limit: int) -> UpdateQuotaResponse: + target = await self.db.get(User, user_id, with_for_update=True) + if target is None or target.deleted_at is not None: + raise ApiError(status_code=404, code=404, message="User not found") + + if new_limit < target.storage_used: + raise ApiError( + status_code=409, + code=409, + message="New quota cannot be below current usage", + ) + + target.storage_limit = new_limit + target.updated_at = datetime.now(UTC) + await self.db.commit() + await self.db.refresh(target) + return UpdateQuotaResponse( + user_id=str(target.user_id), + storage_limit=target.storage_limit, + storage_used=target.storage_used, + usage_percentage=round((target.storage_used / max(target.storage_limit, 1)) * 100, 2), + updated_at=target.updated_at, + ) + + async def usage_trend(self, *, query: UsageTrendQuery) -> UsageTrendResponse: + cached = await self._cache_get(query.days) + if cached is not None: + return cached + + t_now = int( + await self.db.scalar(select(func.coalesce(func.sum(User.storage_used), 0))) or 0 + ) + + cutoff = datetime.now(UTC) - timedelta(days=query.days) + rows = await self.db.execute( + select(Log.operation, Log.metadata_payload, Log.performed_at) + .where(Log.operation.in_(_STORAGE_EVENT_OPS)) + .where(Log.performed_at >= cutoff) + ) + events = rows.all() + + deltas: dict[date, int] = {} + for op, metadata, performed_at in events: + if not performed_at: + continue + size = int((metadata or {}).get("size") or 0) + sign = 1 if op == "file.created" or op == "file.restored" else -1 + day = performed_at.astimezone(UTC).date() + deltas[day] = deltas.get(day, 0) + sign * size + + today = datetime.now(UTC).date() + points: list[UsageTrendPoint] = [] + running = t_now + for offset in range(query.days): + d = today - timedelta(days=offset) + points.append(UsageTrendPoint(date=d.isoformat(), used=max(running, 0))) + running -= deltas.get(d, 0) + points.reverse() + + response = UsageTrendResponse( + trends=points, + is_estimated=not events, + ) + await self._cache_set(query.days, response) + return response + + @staticmethod + def _to_item(row: User) -> AdminStorageUserItem: + limit = max(row.storage_limit, 1) + return AdminStorageUserItem( + user_id=str(row.user_id), + username=row.username, + email=row.email, + storage_limit=row.storage_limit, + storage_used=row.storage_used, + usage_percentage=round((row.storage_used / limit) * 100, 2), + updated_at=row.updated_at, + ) + + async def _cache_get(self, days: int) -> UsageTrendResponse | None: + if self.redis is None: + return None + try: + raw = await self.redis.get(f"admin:storage:trend:{days}") + except Exception: + return None + if not raw: + return None + return UsageTrendResponse.model_validate_json(raw) + + async def _cache_set(self, days: int, payload: UsageTrendResponse) -> None: + if self.redis is None: + return + try: + await self.redis.setex( + f"admin:storage:trend:{days}", + _TREND_CACHE_TTL, + payload.model_dump_json(by_alias=True), + ) + except Exception: + return + + +__all__ = ["AdminStorageService"] +``` + +- [ ] **Step 3.5: 跑测试验证通过** + +Run: `cd app && uv run pytest tests/test_admin_storage_service.py -v` +Expected: 4 passed + +- [ ] **Step 3.6: 实现 router + 注册** + +`app/src/fileflash/routers/admin_storage.py`: + +```python +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.deps import get_rate_limiter, require_admin +from ..core.errors import api_success +from ..db.deps import get_db +from ..models.tables_identity import User +from ..schemas.admin.storage import ( + ListStorageUsersQuery, + UpdateQuotaRequest, + UsageTrendQuery, +) +from ..services.admin.storage import AdminStorageService +from ..services.rate_limiter import RedisRateLimiter + +router = APIRouter(prefix="/admin/storage", tags=["admin"]) + + +def get_admin_storage_service( + db: AsyncSession = Depends(get_db), + rate_limiter: RedisRateLimiter = Depends(get_rate_limiter), +) -> AdminStorageService: + # 复用现有 RedisRateLimiter 持有的连接,仅当存在时启用缓存 + return AdminStorageService(db=db, redis=getattr(rate_limiter, "_redis", None)) + + +@router.get("/summary") +async def get_admin_storage_summary( + _: User = Depends(require_admin), + service: AdminStorageService = Depends(get_admin_storage_service), +): + data = await service.summary() + return api_success(data=data.model_dump(by_alias=True), message="Storage summary fetched") + + +@router.get("/users") +async def list_admin_storage_users( + query: ListStorageUsersQuery = Depends(), + _: User = Depends(require_admin), + service: AdminStorageService = Depends(get_admin_storage_service), +): + data = await service.list_storage_users(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Storage users fetched") + + +@router.patch("/users/{user_id}/quota") +async def update_admin_user_quota( + user_id: int, + payload: UpdateQuotaRequest, + _: User = Depends(require_admin), + service: AdminStorageService = Depends(get_admin_storage_service), +): + result = await service.update_quota(user_id=user_id, new_limit=payload.storage_limit) + return api_success(data=result.model_dump(by_alias=True), message="Quota updated") + + +@router.get("/usage-trend") +async def get_storage_usage_trend( + query: UsageTrendQuery = Depends(), + _: User = Depends(require_admin), + service: AdminStorageService = Depends(get_admin_storage_service), +): + result = await service.usage_trend(query=query) + return api_success(data=result.model_dump(by_alias=True), message="Usage trend fetched") + + +__all__ = ["router", "get_admin_storage_service"] +``` + +- [ ] **Step 3.7: 写路由测试** + +`app/tests/test_admin_storage_routes.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_storage import router as storage_router, get_admin_storage_service +from fileflash.schemas.admin.storage import ( + AdminStorageSummary, + UpdateQuotaResponse, + UsageTrendPoint, + UsageTrendResponse, +) + + +class StubService: + async def summary(self): + return AdminStorageSummary( + storage_used=1000, storage_limit=10000, storage_percentage=10.0, + file_count=3, user_count=2, updated_at=datetime.now(UTC), + ) + + async def list_storage_users(self, *, query): # noqa: ANN001 + return SimpleNamespace(model_dump=lambda **_: { + "items": [], + "pagination": { + "totalItems": 0, "totalPages": 1, + "perPage": query.per_page, "currentPage": query.page, + "hasPrev": False, "hasNext": False, + }, + }) + + async def update_quota(self, *, user_id, new_limit): # noqa: ANN001 + return UpdateQuotaResponse( + user_id=str(user_id), storage_limit=new_limit, + storage_used=0, usage_percentage=0.0, updated_at=datetime.now(UTC), + ) + + async def usage_trend(self, *, query): # noqa: ANN001 + return UsageTrendResponse( + trends=[UsageTrendPoint(date="2026-05-24", used=1)], + is_estimated=False, + ) + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(storage_router, prefix="/api/v1") + app.dependency_overrides[get_admin_storage_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + return TestClient(app) + + +def test_summary_returns_camel_case() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/storage/summary") + assert resp.status_code == 200 + assert "storageUsed" in resp.json()["data"] + + +def test_update_quota_passes_storage_limit() -> None: + with _client() as c: + resp = c.patch("/api/v1/admin/storage/users/7/quota", json={"storageLimit": 1024}) + assert resp.status_code == 200 + assert resp.json()["data"]["storageLimit"] == 1024 + + +def test_usage_trend_default_days() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/storage/usage-trend") + assert resp.status_code == 200 + assert len(resp.json()["data"]["trends"]) == 1 +``` + +- [ ] **Step 3.8: 跑测试验证通过** + +Run: `cd app && uv run pytest tests/test_admin_storage_routes.py -v` +Expected: 3 passed + +- [ ] **Step 3.9: 注册 router** + +Modify `app/src/fileflash/routers/__init__.py`: + +```python +from .admin_storage import router as admin_storage_router +# ... +api_router.include_router(admin_storage_router) +``` + +- [ ] **Step 3.10: Commit** + +```bash +git add app/src/fileflash/schemas/admin/storage.py \ + app/src/fileflash/services/admin/storage.py \ + app/src/fileflash/routers/admin_storage.py \ + app/src/fileflash/routers/__init__.py \ + app/tests/test_admin_storage_service.py \ + app/tests/test_admin_storage_routes.py +git commit -m "feat(admin): /admin/storage summary, users, quota, usage-trend" +``` + +--- + +## Task 4: Admin Files(list + rescan) + +**Files:** +- Create: `app/src/fileflash/schemas/admin/files.py` +- Create: `app/src/fileflash/services/admin/files.py` +- Create: `app/src/fileflash/routers/admin_files.py` +- Test: `app/tests/test_admin_files_service.py`, `app/tests/test_admin_files_routes.py` +- Modify: `app/src/fileflash/routers/__init__.py` + +- [ ] **Step 4.1: 写 schemas** + +`app/src/fileflash/schemas/admin/files.py`: + +```python +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from ..common import CamelModel, PageQuery + + +VirusStatus = Literal["clean", "pending", "flagged"] + + +class AdminFileAuditItem(CamelModel): + id: str + name: str + size: int + mime_type: str + hash: str + virus_status: VirusStatus + is_shared: bool + owner_name: str + updated_at: datetime + created_at: datetime + + +class ListAdminFilesQuery(PageQuery): + search: str | None = None + virus_status: VirusStatus | None = None + owner_id: str | None = None + mime_type: str | None = None + sort: Literal["name", "size", "createdAt", "updatedAt"] = "updatedAt" + order: Literal["asc", "desc"] = "desc" + + +class RescanResponse(CamelModel): + file_id: str + virus_status: VirusStatus + scanned_at: datetime + + +__all__ = ["AdminFileAuditItem", "ListAdminFilesQuery", "RescanResponse", "VirusStatus"] +``` + +- [ ] **Step 4.2: 写失败测试 — list + rescan** + +`app/tests/test_admin_files_service.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.schemas.admin.files import ListAdminFilesQuery +from fileflash.services.admin.files import AdminFilesService + + +class DummyPublisher: + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + + async def publish(self, event_name: str, payload: dict) -> None: # noqa: ANN001 + self.calls.append((event_name, payload)) + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.commit = AsyncMock() + self.execute = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + + +@pytest.mark.asyncio +async def test_list_returns_paginated_empty_when_no_files() -> None: + session = DummySession() + session.scalar.return_value = 0 + session.execute.return_value = Mock(all=lambda: []) + service = AdminFilesService(db=session, publisher=DummyPublisher()) # type: ignore[arg-type] + + result = await service.list_files(query=ListAdminFilesQuery()) + + assert result.items == [] + assert result.pagination.total_items == 0 + + +@pytest.mark.asyncio +async def test_rescan_missing_file_returns_404() -> None: + session = DummySession() + session.get.return_value = None + service = AdminFilesService(db=session, publisher=DummyPublisher()) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.request_rescan(file_id=1, requested_by=99) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_rescan_inserts_scan_record_and_publishes_event() -> None: + session = DummySession() + file_row = Mock(file_id=1, object_id=2) + session.get.return_value = file_row + publisher = DummyPublisher() + service = AdminFilesService(db=session, publisher=publisher) # type: ignore[arg-type] + + result = await service.request_rescan(file_id=1, requested_by=99) + + assert result.virus_status == "pending" + assert publisher.calls and publisher.calls[0][0] == "files.rescan_requested" + session.commit.assert_awaited() +``` + +- [ ] **Step 4.3: 跑测试验证失败** + +Run: `cd app && uv run pytest tests/test_admin_files_service.py -v` +Expected: ImportError + +- [ ] **Step 4.4: 实现 AdminFilesService** + +`app/src/fileflash/services/admin/files.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Protocol + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.enums import FileStatus, ScanResult +from ...models.tables_audit_security import ObjectScanResult +from ...models.tables_identity import User +from ...models.tables_storage import File, StorageObject +from ...schemas.admin.files import ( + AdminFileAuditItem, + ListAdminFilesQuery, + RescanResponse, + VirusStatus, +) +from ...schemas.common import PaginatedData, PaginationMeta + + +class EventPublisherProtocol(Protocol): + async def publish(self, event_name: str, payload: dict[str, Any]) -> None: ... + + +_VIRUS_STATUS_MAP: dict[ScanResult, VirusStatus] = { + ScanResult.CLEAN: "clean", + ScanResult.PENDING: "pending", + ScanResult.INFECTED: "flagged", + ScanResult.BLOCKED: "flagged", + ScanResult.FAILED: "pending", +} + + +class AdminFilesService: + def __init__(self, db: AsyncSession, publisher: EventPublisherProtocol) -> None: + self.db = db + self.publisher = publisher + + async def list_files( + self, + *, + query: ListAdminFilesQuery, + ) -> PaginatedData[AdminFileAuditItem]: + # 最近一次扫描结果 sub-query + latest_scan = ( + select( + ObjectScanResult.object_id, + func.max(ObjectScanResult.scanned_at).label("scanned_at"), + ) + .group_by(ObjectScanResult.object_id) + .subquery() + ) + + statement = ( + select(File, StorageObject, User, ObjectScanResult) + .join(StorageObject, File.storage_object_id == StorageObject.object_id) + .join(User, File.owner_id == User.user_id) + .join( + latest_scan, + latest_scan.c.object_id == StorageObject.object_id, + isouter=True, + ) + .join( + ObjectScanResult, + and_( + ObjectScanResult.object_id == latest_scan.c.object_id, + ObjectScanResult.scanned_at == latest_scan.c.scanned_at, + ), + isouter=True, + ) + .where(File.status == FileStatus.ACTIVE) + .where(File.deleted_at.is_(None)) + ) + + if query.search: + kw = f"%{query.search.strip().lower()}%" + statement = statement.where(func.lower(File.file_name).like(kw)) + if query.owner_id: + statement = statement.where(File.owner_id == int(query.owner_id)) + if query.mime_type: + statement = statement.where(File.mime_type == query.mime_type) + if query.virus_status: + wanted = [k for k, v in _VIRUS_STATUS_MAP.items() if v == query.virus_status] + statement = statement.where(ObjectScanResult.result.in_(wanted)) + + sort_column = { + "name": File.file_name, + "size": File.file_size, + "createdAt": File.created_at, + "updatedAt": File.updated_at, + }[query.sort] + statement = statement.order_by( + sort_column.desc() if query.order == "desc" else sort_column.asc() + ) + + count_stmt = select(func.count()).select_from(statement.subquery()) + total = int(await self.db.scalar(count_stmt) or 0) + total_pages = max(1, -(-total // query.per_page)) + offset = (query.page - 1) * query.per_page + + rows = (await self.db.execute(statement.offset(offset).limit(query.per_page))).all() + items = [self._to_item(file_, obj, owner, scan) for file_, obj, owner, scan in rows] + + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def request_rescan(self, *, file_id: int, requested_by: int) -> RescanResponse: + file_row = await self.db.get(File, file_id) + if file_row is None or file_row.deleted_at is not None or file_row.status != FileStatus.ACTIVE: + raise ApiError(status_code=404, code=404, message="File not found") + + now = datetime.now(UTC) + scan = ObjectScanResult( + object_id=file_row.storage_object_id, + scan_type="virus", + result=ScanResult.PENDING, + details={"requestedBy": requested_by}, + scanned_at=now, + created_at=now, + ) + self.db.add(scan) + await self.db.commit() + + await self.publisher.publish( + "files.rescan_requested", + { + "fileId": str(file_id), + "objectId": str(file_row.storage_object_id), + "requestedBy": requested_by, + }, + ) + + return RescanResponse( + file_id=str(file_id), + virus_status="pending", + scanned_at=now, + ) + + @staticmethod + def _to_item( + file_: File, + obj: StorageObject, + owner: User, + scan: ObjectScanResult | None, + ) -> AdminFileAuditItem: + return AdminFileAuditItem( + id=str(file_.file_id), + name=file_.file_name, + size=file_.file_size, + mime_type=file_.mime_type or obj.content_type or "application/octet-stream", + hash=(obj.object_hash or "")[:16], + virus_status=_VIRUS_STATUS_MAP.get(scan.result, "pending") if scan else "pending", + is_shared=False, + owner_name=owner.username, + updated_at=file_.updated_at, + created_at=file_.created_at, + ) + + +__all__ = ["AdminFilesService"] +``` + +> **校对依据:** `File` 字段 `file_name / file_size / mime_type / storage_object_id / owner_id / status / deleted_at`;`StorageObject` 字段 `object_hash / content_type / object_size`;`ScanResult` 枚举 `PENDING / CLEAN / INFECTED / BLOCKED / FAILED`。已与 `app/src/fileflash/models/tables_storage.py` 和 `enums.py` 核对一致。 + +- [ ] **Step 4.5: 写 rescan service 测试中 file_row 的 mock 字段对齐** + +更新 Step 4.2 中 `test_rescan_inserts_scan_record_and_publishes_event`: + +```python +@pytest.mark.asyncio +async def test_rescan_inserts_scan_record_and_publishes_event() -> None: + from fileflash.models.enums import FileStatus + session = DummySession() + file_row = Mock( + file_id=1, storage_object_id=2, + deleted_at=None, status=FileStatus.ACTIVE, + ) + session.get.return_value = file_row + publisher = DummyPublisher() + service = AdminFilesService(db=session, publisher=publisher) # type: ignore[arg-type] + + result = await service.request_rescan(file_id=1, requested_by=99) + + assert result.virus_status == "pending" + assert publisher.calls and publisher.calls[0][0] == "files.rescan_requested" + session.commit.assert_awaited() +``` + +- [ ] **Step 4.6: 跑测试验证通过** + +Run: `cd app && uv run pytest tests/test_admin_files_service.py -v` +Expected: 3 passed + +- [ ] **Step 4.7: 实现 router + 测试 + 注册** + +`app/src/fileflash/routers/admin_files.py`: + +```python +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.deps import get_event_publisher, require_admin +from ..core.errors import api_success +from ..db.deps import get_db +from ..models.tables_identity import User +from ..schemas.admin.files import ListAdminFilesQuery +from ..services.admin.files import AdminFilesService +from ..services.messaging import InProcessAuthEventPublisher + +router = APIRouter(prefix="/admin/files", tags=["admin"]) + + +def get_admin_files_service( + db: AsyncSession = Depends(get_db), + publisher: InProcessAuthEventPublisher = Depends(get_event_publisher), +) -> AdminFilesService: + return AdminFilesService(db=db, publisher=publisher) + + +@router.get("") +async def list_admin_files( + query: ListAdminFilesQuery = Depends(), + _: User = Depends(require_admin), + service: AdminFilesService = Depends(get_admin_files_service), +): + data = await service.list_files(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Files fetched") + + +@router.post("/{file_id}/rescan") +async def rescan_admin_file( + file_id: int, + admin: User = Depends(require_admin), + service: AdminFilesService = Depends(get_admin_files_service), +): + result = await service.request_rescan(file_id=file_id, requested_by=admin.user_id) + return api_success(data=result.model_dump(by_alias=True), message="Rescan requested") + + +__all__ = ["router", "get_admin_files_service"] +``` + +`app/tests/test_admin_files_routes.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_files import router as admin_router, get_admin_files_service +from fileflash.schemas.admin.files import RescanResponse + + +class StubService: + async def list_files(self, *, query): # noqa: ANN001 + return SimpleNamespace(model_dump=lambda **_: { + "items": [], + "pagination": { + "totalItems": 0, "totalPages": 1, + "perPage": query.per_page, "currentPage": query.page, + "hasPrev": False, "hasNext": False, + }, + }) + + async def request_rescan(self, *, file_id, requested_by): # noqa: ANN001 + return RescanResponse( + file_id=str(file_id), virus_status="pending", + scanned_at=datetime.now(UTC), + ) + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(admin_router, prefix="/api/v1") + app.dependency_overrides[get_admin_files_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=99) + return TestClient(app) + + +def test_list_files_returns_empty() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/files") + assert resp.status_code == 200 + + +def test_rescan_returns_pending() -> None: + with _client() as c: + resp = c.post("/api/v1/admin/files/7/rescan") + assert resp.status_code == 200 + assert resp.json()["data"]["virusStatus"] == "pending" +``` + +- [ ] **Step 4.8: 跑测试 + 注册 router + commit** + +Run: `cd app && uv run pytest tests/test_admin_files_routes.py -v` +Expected: 2 passed + +Modify `app/src/fileflash/routers/__init__.py` 加入 `admin_files_router` 的 import 和 include。 + +```bash +git add app/src/fileflash/schemas/admin/files.py app/src/fileflash/services/admin/files.py app/src/fileflash/routers/admin_files.py app/src/fileflash/routers/__init__.py app/tests/test_admin_files_*.py +git commit -m "feat(admin): /admin/files list + /admin/files/{id}/rescan with event" +``` + +--- + +## Task 5: Admin Moderation(violations list + resolve) + +**Files:** +- Create: `app/src/fileflash/schemas/admin/moderation.py` +- Create: `app/src/fileflash/services/admin/moderation.py` +- Create: `app/src/fileflash/routers/admin_moderation.py` +- Test: `app/tests/test_admin_moderation_service.py`, `app/tests/test_admin_moderation_routes.py` + +- [ ] **Step 5.1: 写 schemas** + +`app/src/fileflash/schemas/admin/moderation.py`: + +```python +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from ..common import CamelModel, PageQuery + + +ViolationLevel = Literal["low", "medium", "high"] +ViolationStatus = Literal["pending", "under_review", "resolved"] + + +class ViolationItem(CamelModel): + id: str + file_id: str | None + file_name: str | None + type: str + level: ViolationLevel + reported_at: datetime + status: ViolationStatus + + +class ListViolationsQuery(PageQuery): + status: ViolationStatus | None = None + + +class ResolveViolationResponse(CamelModel): + violation_id: str + resolved_at: datetime + + +__all__ = [ + "ListViolationsQuery", + "ResolveViolationResponse", + "ViolationItem", + "ViolationLevel", + "ViolationStatus", +] +``` + +- [ ] **Step 5.2: 写失败测试** + +`app/tests/test_admin_moderation_service.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.schemas.admin.moderation import ListViolationsQuery +from fileflash.services.admin.moderation import AdminModerationService + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.commit = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.get = AsyncMock() + self.execute = AsyncMock() + + +@pytest.mark.asyncio +async def test_resolve_missing_case_returns_404() -> None: + session = DummySession() + session.get.return_value = None + service = AdminModerationService(db=session) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.resolve_case(case_id=1, handled_by=2) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_resolve_already_resolved_returns_409() -> None: + session = DummySession() + case = Mock(case_id=1, status="resolved") + session.get.return_value = case + service = AdminModerationService(db=session) # type: ignore[arg-type] + + with pytest.raises(ApiError) as exc: + await service.resolve_case(case_id=1, handled_by=2) + assert exc.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_resolve_pending_case_sets_resolved() -> None: + session = DummySession() + case = Mock(case_id=1, status="pending", handled_by=None, handled_at=None, + resolution=None, file_id=10) + session.get.return_value = case + service = AdminModerationService(db=session) # type: ignore[arg-type] + + result = await service.resolve_case(case_id=1, handled_by=2) + + assert result.violation_id == "1" + assert case.status == "resolved" + assert case.resolution == "admin_clear" + assert case.handled_by == 2 + session.commit.assert_awaited() + + +@pytest.mark.asyncio +async def test_list_returns_empty_paginated() -> None: + session = DummySession() + session.scalar.return_value = 0 + session.execute.return_value = Mock(all=lambda: []) + service = AdminModerationService(db=session) # type: ignore[arg-type] + + result = await service.list_violations(query=ListViolationsQuery()) + assert result.items == [] + assert result.pagination.total_items == 0 +``` + +- [ ] **Step 5.3: 实现 service** + +`app/src/fileflash/services/admin/moderation.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.tables_audit_security import ModerationCase +from ...models.tables_storage import File +from ...schemas.admin.moderation import ( + ListViolationsQuery, + ResolveViolationResponse, + ViolationItem, + ViolationLevel, +) +from ...schemas.common import PaginatedData, PaginationMeta + +_OPEN_STATES = ("pending", "under_review") + + +class AdminModerationService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_violations( + self, + *, + query: ListViolationsQuery, + ) -> PaginatedData[ViolationItem]: + statement = ( + select(ModerationCase, File) + .join(File, File.file_id == ModerationCase.file_id, isouter=True) + ) + if query.status: + statement = statement.where(ModerationCase.status == query.status) + statement = statement.order_by(ModerationCase.created_at.desc()) + + total = int( + await self.db.scalar(select(func.count()).select_from(statement.subquery())) or 0 + ) + total_pages = max(1, -(-total // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = (await self.db.execute(statement.offset(offset).limit(query.per_page))).all() + + items = [self._to_item(case, file_) for case, file_ in rows] + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def resolve_case(self, *, case_id: int, handled_by: int) -> ResolveViolationResponse: + case = await self.db.get(ModerationCase, case_id, with_for_update=True) + if case is None: + raise ApiError(status_code=404, code=404, message="Violation case not found") + if case.status not in _OPEN_STATES: + raise ApiError(status_code=409, code=409, message="Case already resolved") + + now = datetime.now(UTC) + case.status = "resolved" + case.resolution = "admin_clear" + case.handled_by = handled_by + case.handled_at = now + case.updated_at = now + await self.db.commit() + + return ResolveViolationResponse(violation_id=str(case_id), resolved_at=now) + + @staticmethod + def _to_item(case: ModerationCase, file_: File | None) -> ViolationItem: + return ViolationItem( + id=str(case.case_id), + file_id=str(case.file_id) if case.file_id else None, + file_name=file_.name if file_ else None, + type=case.reason_type, + level=_level_from_confidence(case.confidence), + reported_at=case.created_at, + status=case.status, # type: ignore[arg-type] + ) + + +def _level_from_confidence(confidence: Decimal | None) -> ViolationLevel: + if confidence is None: + return "low" + value = float(confidence) + if value > 0.8: + return "high" + if value > 0.5: + return "medium" + return "low" + + +__all__ = ["AdminModerationService"] +``` + +- [ ] **Step 5.4: 跑 service 测试** + +Run: `cd app && uv run pytest tests/test_admin_moderation_service.py -v` +Expected: 4 passed + +- [ ] **Step 5.5: 实现 router + 测试** + +`app/src/fileflash/routers/admin_moderation.py`: + +```python +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.deps import require_admin +from ..core.errors import api_success +from ..db.deps import get_db +from ..models.tables_identity import User +from ..schemas.admin.moderation import ListViolationsQuery +from ..services.admin.moderation import AdminModerationService + +router = APIRouter(prefix="/admin/violations", tags=["admin"]) + + +def get_admin_moderation_service( + db: AsyncSession = Depends(get_db), +) -> AdminModerationService: + return AdminModerationService(db=db) + + +@router.get("") +async def list_violations( + query: ListViolationsQuery = Depends(), + _: User = Depends(require_admin), + service: AdminModerationService = Depends(get_admin_moderation_service), +): + data = await service.list_violations(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Violations fetched") + + +@router.post("/{case_id}/resolve") +async def resolve_violation( + case_id: int, + admin: User = Depends(require_admin), + service: AdminModerationService = Depends(get_admin_moderation_service), +): + result = await service.resolve_case(case_id=case_id, handled_by=admin.user_id) + return api_success(data=result.model_dump(by_alias=True), message="Violation resolved") + + +__all__ = ["router", "get_admin_moderation_service"] +``` + +`app/tests/test_admin_moderation_routes.py`(参照 §2.1 模式,2 测例:list 通过 / resolve 通过): + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_moderation import ( + router as mod_router, + get_admin_moderation_service, +) +from fileflash.schemas.admin.moderation import ResolveViolationResponse + + +class StubService: + async def list_violations(self, *, query): # noqa: ANN001 + return SimpleNamespace(model_dump=lambda **_: { + "items": [], + "pagination": { + "totalItems": 0, "totalPages": 1, + "perPage": query.per_page, "currentPage": query.page, + "hasPrev": False, "hasNext": False, + }, + }) + + async def resolve_case(self, *, case_id, handled_by): # noqa: ANN001 + return ResolveViolationResponse(violation_id=str(case_id), resolved_at=datetime.now(UTC)) + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(mod_router, prefix="/api/v1") + app.dependency_overrides[get_admin_moderation_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + return TestClient(app) + + +def test_list_violations() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/violations") + assert resp.status_code == 200 + + +def test_resolve_violation() -> None: + with _client() as c: + resp = c.post("/api/v1/admin/violations/3/resolve") + assert resp.status_code == 200 + assert resp.json()["data"]["violationId"] == "3" +``` + +- [ ] **Step 5.6: 跑测试 + 注册 + commit** + +Run: `cd app && uv run pytest tests/test_admin_moderation_routes.py -v` + +Modify `routers/__init__.py` 加入 `admin_moderation_router` import + include。 + +```bash +git add app/src/fileflash/schemas/admin/moderation.py app/src/fileflash/services/admin/moderation.py app/src/fileflash/routers/admin_moderation.py app/src/fileflash/routers/__init__.py app/tests/test_admin_moderation_*.py +git commit -m "feat(admin): /admin/violations list + resolve via ModerationCase" +``` + +--- + +## Task 6: Admin Logs + +**Files:** +- Create: `app/src/fileflash/schemas/admin/logs.py` +- Create: `app/src/fileflash/services/admin/logs.py` +- Create: `app/src/fileflash/routers/admin_logs.py` +- Test: `app/tests/test_admin_logs_routes.py` + +- [ ] **Step 6.1: schemas** + +`app/src/fileflash/schemas/admin/logs.py`: + +```python +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from ..common import CamelModel, PageQuery + + +class LogItem(CamelModel): + id: str + user_id: str | None + operation: str + operation_name: str + target_type: str | None + target_id: str | None + result: str + ip_address: str | None + user_agent: str | None + performed_at: datetime + details: str | None = None + metadata: dict[str, Any] = {} + + +class ListAdminLogsQuery(PageQuery): + user_id: str | None = None + operation: str | None = None + result: Literal["success", "failure"] | None = None + from_at: datetime | None = None + to_at: datetime | None = None + + +class AdminLogsResponse(CamelModel): + logs: list[LogItem] + pagination: dict[str, Any] + + +__all__ = ["AdminLogsResponse", "ListAdminLogsQuery", "LogItem"] +``` + +- [ ] **Step 6.2: 实现 service(轻量;只 list)** + +`app/src/fileflash/services/admin/logs.py`: + +```python +from __future__ import annotations + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...models.tables_audit_security import Log +from ...schemas.admin.logs import AdminLogsResponse, ListAdminLogsQuery, LogItem + + +class AdminLogsService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_logs(self, *, query: ListAdminLogsQuery) -> AdminLogsResponse: + statement = select(Log) + conditions = [] + if query.user_id: + conditions.append(Log.user_id == int(query.user_id)) + if query.operation: + conditions.append(Log.operation == query.operation) + if query.result: + conditions.append(Log.result == query.result) + if query.from_at: + conditions.append(Log.performed_at >= query.from_at) + if query.to_at: + conditions.append(Log.performed_at <= query.to_at) + if conditions: + statement = statement.where(and_(*conditions)) + statement = statement.order_by(Log.performed_at.desc()) + + total = int( + await self.db.scalar(select(func.count()).select_from(statement.subquery())) or 0 + ) + total_pages = max(1, -(-total // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = list(await self.db.scalars(statement.offset(offset).limit(query.per_page))) + + logs = [ + LogItem( + id=str(row.id), + user_id=str(row.user_id) if row.user_id else None, + operation=row.operation, + operation_name=row.operation, + target_type=row.target_type, + target_id=str(row.target_id) if row.target_id else None, + result=row.result, + ip_address=row.ip_address, + user_agent=row.user_agent, + performed_at=row.performed_at, + details=row.details, + metadata=row.metadata_payload or {}, + ) + for row in rows + ] + + return AdminLogsResponse( + logs=logs, + pagination={ + "totalItems": total, + "totalPages": total_pages, + "perPage": query.per_page, + "currentPage": query.page, + "hasPrev": query.page > 1, + "hasNext": query.page < total_pages, + }, + ) + + +__all__ = ["AdminLogsService"] +``` + +- [ ] **Step 6.3: 实现 router** + +`app/src/fileflash/routers/admin_logs.py`: + +```python +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.deps import require_admin +from ..core.errors import api_success +from ..db.deps import get_db +from ..models.tables_identity import User +from ..schemas.admin.logs import ListAdminLogsQuery +from ..services.admin.logs import AdminLogsService + +router = APIRouter(prefix="/admin/logs", tags=["admin"]) + + +def get_admin_logs_service(db: AsyncSession = Depends(get_db)) -> AdminLogsService: + return AdminLogsService(db=db) + + +@router.get("") +async def list_admin_logs( + query: ListAdminLogsQuery = Depends(), + _: User = Depends(require_admin), + service: AdminLogsService = Depends(get_admin_logs_service), +): + data = await service.list_logs(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Logs fetched") + + +__all__ = ["router", "get_admin_logs_service"] +``` + +- [ ] **Step 6.4: 测试** + +`app/tests/test_admin_logs_routes.py`: + +```python +from __future__ import annotations + +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_logs import router as logs_router, get_admin_logs_service +from fileflash.schemas.admin.logs import AdminLogsResponse + + +class StubService: + async def list_logs(self, *, query): # noqa: ANN001 + return AdminLogsResponse( + logs=[], + pagination={ + "totalItems": 0, "totalPages": 1, + "perPage": query.per_page, "currentPage": query.page, + "hasPrev": False, "hasNext": False, + }, + ) + + +def test_admin_can_list_logs() -> None: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(logs_router, prefix="/api/v1") + app.dependency_overrides[get_admin_logs_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + with TestClient(app) as c: + resp = c.get("/api/v1/admin/logs") + assert resp.status_code == 200 + assert resp.json()["data"]["logs"] == [] +``` + +- [ ] **Step 6.5: 跑测试 + 注册 + commit** + +Run: `cd app && uv run pytest tests/test_admin_logs_routes.py -v` + +Modify `routers/__init__.py` 加入 `admin_logs_router` import + include。 + +```bash +git add app/src/fileflash/schemas/admin/logs.py app/src/fileflash/services/admin/logs.py app/src/fileflash/routers/admin_logs.py app/src/fileflash/routers/__init__.py app/tests/test_admin_logs_routes.py +git commit -m "feat(admin): /admin/logs list with filters" +``` + +--- + +## Task 7: Admin Notifications(list / broadcast / read / archive) + +**Files:** +- Create: `app/src/fileflash/schemas/admin/notifications.py` +- Create: `app/src/fileflash/services/admin/notifications.py` +- Create: `app/src/fileflash/routers/admin_notifications.py` +- Test: `app/tests/test_admin_notifications_service.py`, `app/tests/test_admin_notifications_routes.py` + +- [ ] **Step 7.1: schemas** + +`app/src/fileflash/schemas/admin/notifications.py`: + +```python +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import Field + +from ..common import CamelModel, PageQuery + + +class AdminNotificationItem(CamelModel): + id: str + message: str + title: str | None + type: str + status: str + is_read: bool + created_at: datetime + updated_at: datetime + recipient_count: int | None = None + + +class ListAdminNotificationsQuery(PageQuery): + status: str | None = None + type: str | None = None + + +class BroadcastRequest(CamelModel): + title: str | None = Field(default=None, max_length=255) + message: str = Field(min_length=1, max_length=2000) + type: Literal["system", "announcement"] = "system" + + +class BroadcastResponse(CamelModel): + broadcast_id: str + recipient_count: int + sent_at: datetime + + +__all__ = [ + "AdminNotificationItem", + "BroadcastRequest", + "BroadcastResponse", + "ListAdminNotificationsQuery", +] +``` + +- [ ] **Step 7.2: 写失败测试** + +`app/tests/test_admin_notifications_service.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +import pytest + +from fileflash.core.errors import ApiError +from fileflash.schemas.admin.notifications import BroadcastRequest +from fileflash.services.admin.notifications import ( + AdminNotificationsService, + MAX_BROADCAST_RECIPIENTS, +) + + +class DummySession: + def __init__(self) -> None: + self.add = Mock() + self.add_all = Mock() + self.commit = AsyncMock() + self.scalar = AsyncMock() + self.scalars = AsyncMock() + self.execute = AsyncMock() + + +@pytest.mark.asyncio +async def test_broadcast_rejects_empty_message() -> None: + # Pydantic 会先拦截;这里测 service 层显式拒绝(防御) + session = DummySession() + service = AdminNotificationsService(db=session) # type: ignore[arg-type] + with pytest.raises(ApiError): + await service.broadcast( + payload=BroadcastRequest.model_construct(title=None, message="", type="system"), + sender_id=1, + ) + + +@pytest.mark.asyncio +async def test_broadcast_too_many_recipients_returns_422() -> None: + session = DummySession() + session.scalar.return_value = MAX_BROADCAST_RECIPIENTS + 1 + service = AdminNotificationsService(db=session) # type: ignore[arg-type] + with pytest.raises(ApiError) as exc: + await service.broadcast( + payload=BroadcastRequest(message="hi", type="system"), + sender_id=1, + ) + assert exc.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_broadcast_writes_one_row_per_recipient() -> None: + session = DummySession() + session.scalar.return_value = 3 + session.scalars.return_value = iter([10, 11, 12]) + service = AdminNotificationsService(db=session) # type: ignore[arg-type] + + result = await service.broadcast( + payload=BroadcastRequest(message="ping", type="system"), + sender_id=1, + ) + + assert result.recipient_count == 3 + assert session.add_all.called +``` + +- [ ] **Step 7.3: 实现 service** + +`app/src/fileflash/services/admin/notifications.py`: + +```python +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import AsyncIterator + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.errors import ApiError +from ...models.enums import UserStatus +from ...models.tables_audit_security import Notification +from ...models.tables_identity import User +from ...schemas.admin.notifications import ( + AdminNotificationItem, + BroadcastRequest, + BroadcastResponse, + ListAdminNotificationsQuery, +) +from ...schemas.common import PaginatedData, PaginationMeta + +MAX_BROADCAST_RECIPIENTS = 50_000 +_CHUNK_SIZE = 500 + + +class AdminNotificationsService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def list_notifications( + self, + *, + query: ListAdminNotificationsQuery, + ) -> PaginatedData[AdminNotificationItem]: + statement = select(Notification) + if query.status: + statement = statement.where(Notification.status == query.status) + if query.type: + statement = statement.where(Notification.notification_type == query.type) + statement = statement.order_by(Notification.created_at.desc()) + + total = int( + await self.db.scalar(select(func.count()).select_from(statement.subquery())) or 0 + ) + total_pages = max(1, -(-total // query.per_page)) + offset = (query.page - 1) * query.per_page + rows = list(await self.db.scalars(statement.offset(offset).limit(query.per_page))) + + items = [self._to_item(row) for row in rows] + return PaginatedData( + items=items, + pagination=PaginationMeta( + total_items=total, + total_pages=total_pages, + per_page=query.per_page, + current_page=query.page, + has_prev=query.page > 1, + has_next=query.page < total_pages, + ), + ) + + async def broadcast( + self, + *, + payload: BroadcastRequest, + sender_id: int, + ) -> BroadcastResponse: + message = (payload.message or "").strip() + if not message: + raise ApiError(status_code=422, code=422, message="Broadcast message cannot be empty") + + recipient_count = int( + await self.db.scalar( + select(func.count(User.user_id)).where( + User.status == UserStatus.ACTIVE, + User.deleted_at.is_(None), + ) + ) or 0 + ) + if recipient_count > MAX_BROADCAST_RECIPIENTS: + raise ApiError( + status_code=422, + code=422, + message=f"Recipient count {recipient_count} exceeds limit {MAX_BROADCAST_RECIPIENTS}", + ) + + broadcast_id = str(uuid.uuid4()) + now = datetime.now(UTC) + delivered = 0 + + async for chunk in self._iter_active_user_ids(): + rows = [ + Notification( + user_id=uid, + title=payload.title, + notification_type=payload.type, + channel="in_app", + message=message, + payload={"broadcastId": broadcast_id}, + sender_user_id=sender_id, + status="sent", + sent_at=now, + is_read=False, + created_at=now, + updated_at=now, + ) + for uid in chunk + ] + if rows: + self.db.add_all(rows) + await self.db.commit() + delivered += len(rows) + + return BroadcastResponse( + broadcast_id=broadcast_id, + recipient_count=delivered, + sent_at=now, + ) + + async def _iter_active_user_ids(self) -> AsyncIterator[list[int]]: + stream = await self.db.scalars( + select(User.user_id).where( + User.status == UserStatus.ACTIVE, + User.deleted_at.is_(None), + ) + ) + buffer: list[int] = [] + for user_id in stream: + buffer.append(user_id) + if len(buffer) >= _CHUNK_SIZE: + yield buffer + buffer = [] + if buffer: + yield buffer + + async def archive(self, *, notification_id: int) -> None: + row = await self.db.get(Notification, notification_id) + if row is None: + raise ApiError(status_code=404, code=404, message="Notification not found") + row.status = "archived" + row.updated_at = datetime.now(UTC) + await self.db.commit() + + @staticmethod + def _to_item(row: Notification) -> AdminNotificationItem: + broadcast_id = (row.payload or {}).get("broadcastId") if row.payload else None + return AdminNotificationItem( + id=str(row.id), + message=row.message, + title=row.title, + type=row.notification_type, + status=row.status, + is_read=bool(row.is_read), + created_at=row.created_at or datetime.now(UTC), + updated_at=row.updated_at, + recipient_count=None if broadcast_id is None else None, + ) + + +__all__ = ["AdminNotificationsService", "MAX_BROADCAST_RECIPIENTS"] +``` + +- [ ] **Step 7.4: 跑测试** + +Run: `cd app && uv run pytest tests/test_admin_notifications_service.py -v` +Expected: 3 passed + +- [ ] **Step 7.5: 实现 router + 路由测试** + +`app/src/fileflash/routers/admin_notifications.py`: + +```python +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.deps import require_admin +from ..core.errors import api_success +from ..db.deps import get_db +from ..models.tables_identity import User +from ..schemas.admin.notifications import ( + BroadcastRequest, + ListAdminNotificationsQuery, +) +from ..services.admin.notifications import AdminNotificationsService + +router = APIRouter(prefix="/admin/notifications", tags=["admin"]) + + +def get_admin_notifications_service( + db: AsyncSession = Depends(get_db), +) -> AdminNotificationsService: + return AdminNotificationsService(db=db) + + +@router.get("") +async def list_admin_notifications( + query: ListAdminNotificationsQuery = Depends(), + _: User = Depends(require_admin), + service: AdminNotificationsService = Depends(get_admin_notifications_service), +): + data = await service.list_notifications(query=query) + return api_success(data=data.model_dump(by_alias=True), message="Notifications fetched") + + +@router.post("/broadcast") +async def broadcast_notification( + payload: BroadcastRequest, + admin: User = Depends(require_admin), + service: AdminNotificationsService = Depends(get_admin_notifications_service), +): + result = await service.broadcast(payload=payload, sender_id=admin.user_id) + return api_success(data=result.model_dump(by_alias=True), message="Broadcast sent") + + +@router.delete("/{notification_id}") +async def archive_admin_notification( + notification_id: int, + _: User = Depends(require_admin), + service: AdminNotificationsService = Depends(get_admin_notifications_service), +): + await service.archive(notification_id=notification_id) + return api_success( + data={"notificationId": str(notification_id), "status": "archived"}, + message="Notification archived", + ) + + +__all__ = ["router", "get_admin_notifications_service"] +``` + +`app/tests/test_admin_notifications_routes.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_notifications import ( + router as n_router, + get_admin_notifications_service, +) +from fileflash.schemas.admin.notifications import BroadcastResponse + + +class StubService: + async def list_notifications(self, *, query): # noqa: ANN001 + return SimpleNamespace(model_dump=lambda **_: { + "items": [], + "pagination": { + "totalItems": 0, "totalPages": 1, + "perPage": query.per_page, "currentPage": query.page, + "hasPrev": False, "hasNext": False, + }, + }) + + async def broadcast(self, *, payload, sender_id): # noqa: ANN001 + return BroadcastResponse( + broadcast_id="b1", recipient_count=2, sent_at=datetime.now(UTC), + ) + + async def archive(self, *, notification_id): # noqa: ANN001 + return None + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(n_router, prefix="/api/v1") + app.dependency_overrides[get_admin_notifications_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + return TestClient(app) + + +def test_list() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/notifications") + assert resp.status_code == 200 + + +def test_broadcast() -> None: + with _client() as c: + resp = c.post("/api/v1/admin/notifications/broadcast", + json={"message": "hello", "type": "system"}) + assert resp.status_code == 200 + assert resp.json()["data"]["broadcastId"] == "b1" + + +def test_archive() -> None: + with _client() as c: + resp = c.delete("/api/v1/admin/notifications/9") + assert resp.status_code == 200 +``` + +- [ ] **Step 7.6: 跑测试 + 注册 + commit** + +```bash +cd app && uv run pytest tests/test_admin_notifications_routes.py -v +``` + +Modify `routers/__init__.py` 加 `admin_notifications_router` import + include。 + +```bash +git add app/src/fileflash/schemas/admin/notifications.py app/src/fileflash/services/admin/notifications.py app/src/fileflash/routers/admin_notifications.py app/src/fileflash/routers/__init__.py app/tests/test_admin_notifications_*.py +git commit -m "feat(admin): /admin/notifications list, broadcast, archive" +``` + +--- + +## Task 8: Admin System(health + rate-limit) + +**Files:** +- Create: `app/src/fileflash/schemas/admin/system.py` +- Create: `app/src/fileflash/services/admin/system.py` +- Create: `app/src/fileflash/routers/admin_system.py` +- Test: `app/tests/test_admin_system_routes.py` + +- [ ] **Step 8.1: schemas** + +`app/src/fileflash/schemas/admin/system.py`: + +```python +from __future__ import annotations + +from datetime import datetime + +from ..common import CamelModel + + +class SystemHealth(CamelModel): + platform_targets: list[str] + max_concurrent_uploads: int + active_upload_sessions: int + virus_scan_enabled: bool + thumbnail_generation_enabled: bool + registration_mail_enabled: bool + hash_computation_enabled: bool + last_updated_at: datetime + + +class RateLimitRule(CamelModel): + rule_id: str + scope: str + window_seconds: int + limit: int + current_usage: int + blocked_requests: int + + +class RateLimitStatus(CamelModel): + rules: list[RateLimitRule] + evaluated_at: datetime + + +__all__ = ["RateLimitRule", "RateLimitStatus", "SystemHealth"] +``` + +- [ ] **Step 8.2: 实现 service(轻量;health 来自 settings,rate-limit 静态枚举)** + +`app/src/fileflash/services/admin/system.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.settings import Settings +from ...models.enums import UploadTaskStatus +from ...models.tables_storage import UploadTask +from ...schemas.admin.system import RateLimitRule, RateLimitStatus, SystemHealth + +# 静态规则定义;如 auth router 有 N 个 RateLimiter.allow 调用,把它们对应的 scope 列在这。 +_RATE_LIMIT_RULES = [ + {"rule_id": "login", "scope": "auth.login", "window_seconds": 60, "limit": 5}, + {"rule_id": "register", "scope": "auth.register", "window_seconds": 600, "limit": 3}, + {"rule_id": "forgot_password", "scope": "auth.forgot_password", "window_seconds": 600, "limit": 3}, + {"rule_id": "resend_verification", "scope": "auth.resend_verification", "window_seconds": 600, "limit": 3}, +] + + +class AdminSystemService: + def __init__(self, db: AsyncSession, settings: Settings) -> None: + self.db = db + self.settings = settings + + async def health(self) -> SystemHealth: + active_uploads = int( + await self.db.scalar( + select(func.count(UploadTask.task_id)).where( + UploadTask.status.in_([UploadTaskStatus.INIT, UploadTaskStatus.UPLOADING]) + ) + ) or 0 + ) + + platform_targets: list[str] = [] + if self.settings.object_storage_bucket: + platform_targets.append(f"s3://{self.settings.object_storage_bucket}") + if self.settings.redis_url: + platform_targets.append("redis") + + return SystemHealth( + platform_targets=platform_targets, + max_concurrent_uploads=getattr(self.settings, "max_concurrent_uploads", 4), + active_upload_sessions=active_uploads, + virus_scan_enabled=bool(getattr(self.settings, "virus_scan_enabled", False)), + thumbnail_generation_enabled=bool(getattr(self.settings, "thumbnail_generation_enabled", True)), + registration_mail_enabled=bool(self.settings.mail_server and self.settings.mail_from), + hash_computation_enabled=True, + last_updated_at=datetime.now(UTC), + ) + + async def rate_limit_status(self) -> RateLimitStatus: + rules = [ + RateLimitRule( + rule_id=rule["rule_id"], + scope=rule["scope"], + window_seconds=rule["window_seconds"], + limit=rule["limit"], + current_usage=0, # 不扫 Redis;运行时统计后续扩展 + blocked_requests=0, + ) + for rule in _RATE_LIMIT_RULES + ] + return RateLimitStatus(rules=rules, evaluated_at=datetime.now(UTC)) + + +__all__ = ["AdminSystemService"] +``` + +- [ ] **Step 8.3: 实现 router** + +`app/src/fileflash/routers/admin_system.py`: + +```python +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.deps import get_settings_dep, require_admin +from ..core.errors import api_success +from ..core.settings import Settings +from ..db.deps import get_db +from ..models.tables_identity import User +from ..services.admin.system import AdminSystemService + +router = APIRouter(prefix="/admin/system", tags=["admin"]) + + +def get_admin_system_service( + db: AsyncSession = Depends(get_db), + settings: Settings = Depends(get_settings_dep), +) -> AdminSystemService: + return AdminSystemService(db=db, settings=settings) + + +@router.get("/health") +async def get_system_health( + _: User = Depends(require_admin), + service: AdminSystemService = Depends(get_admin_system_service), +): + data = await service.health() + return api_success(data=data.model_dump(by_alias=True), message="System health fetched") + + +@router.get("/rate-limit") +async def get_rate_limit_status( + _: User = Depends(require_admin), + service: AdminSystemService = Depends(get_admin_system_service), +): + data = await service.rate_limit_status() + return api_success(data=data.model_dump(by_alias=True), message="Rate limit fetched") + + +__all__ = ["router", "get_admin_system_service"] +``` + +- [ ] **Step 8.4: 测试** + +`app/tests/test_admin_system_routes.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from fileflash.core.deps import require_admin +from fileflash.core.errors import ApiError, api_error_handler +from fileflash.routers.admin_system import router as sys_router, get_admin_system_service +from fileflash.schemas.admin.system import ( + RateLimitRule, RateLimitStatus, SystemHealth, +) + + +class StubService: + async def health(self): + return SystemHealth( + platform_targets=["s3://bkt"], max_concurrent_uploads=4, + active_upload_sessions=0, virus_scan_enabled=False, + thumbnail_generation_enabled=True, registration_mail_enabled=False, + hash_computation_enabled=True, last_updated_at=datetime.now(UTC), + ) + + async def rate_limit_status(self): + return RateLimitStatus( + rules=[ + RateLimitRule( + rule_id="login", scope="auth.login", + window_seconds=60, limit=5, current_usage=0, blocked_requests=0, + ) + ], + evaluated_at=datetime.now(UTC), + ) + + +def _client() -> TestClient: + app = FastAPI() + app.add_exception_handler(ApiError, api_error_handler) + app.include_router(sys_router, prefix="/api/v1") + app.dependency_overrides[get_admin_system_service] = lambda: StubService() + app.dependency_overrides[require_admin] = lambda: SimpleNamespace(user_id=1) + return TestClient(app) + + +def test_health() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/system/health") + assert resp.status_code == 200 + assert resp.json()["data"]["activeUploadSessions"] == 0 + + +def test_rate_limit() -> None: + with _client() as c: + resp = c.get("/api/v1/admin/system/rate-limit") + assert resp.status_code == 200 + assert resp.json()["data"]["rules"][0]["scope"] == "auth.login" +``` + +- [ ] **Step 8.5: 跑测试 + 注册 + commit** + +```bash +cd app && uv run pytest tests/test_admin_system_routes.py -v +``` + +Modify `routers/__init__.py` 加入 `admin_system_router` import + include。 + +```bash +git add app/src/fileflash/schemas/admin/system.py app/src/fileflash/services/admin/system.py app/src/fileflash/routers/admin_system.py app/src/fileflash/routers/__init__.py app/tests/test_admin_system_routes.py +git commit -m "feat(admin): /admin/system health and rate-limit" +``` + +--- + +## Task 9: 端到端冒烟 + 全量回归 + +- [ ] **Step 9.1: 全量启动冒烟** + +Run: `cd app && uv run python -c "from fileflash.main import app; print(sorted(r.path for r in app.routes if '/admin/' in r.path))"` +Expected: prints 14+ paths including `/api/v1/admin/users`, `/api/v1/admin/storage/summary`, `/api/v1/admin/files`, `/api/v1/admin/violations`, `/api/v1/admin/logs`, `/api/v1/admin/notifications`, `/api/v1/admin/system/health` 等。 + +- [ ] **Step 9.2: 跑全量 admin 测试** + +Run: `cd app && uv run pytest tests/test_admin_ -v` +Expected: 所有 admin_* 测试通过(含 Task 0-8 累计 ≥ 25 个测例)。 + +- [ ] **Step 9.3: 跑全量回归** + +Run: `cd app && uv run pytest -q` +Expected: 不引入新 failure;现有测试全绿。 + +- [ ] **Step 9.4: 编写 changelog / README 摘要(可选)** + +如果项目维护 README/CHANGELOG,添加一段: + +> 新增 Admin Console 后端 API(/admin/users, /admin/storage/*, /admin/files, /admin/violations, /admin/logs, /admin/notifications, /admin/system/*)。前端请参照 Plan B 完成 Console 重构。 + +如果项目无此惯例,跳过本步。 + +- [ ] **Step 9.5: Commit final** + +```bash +git status +# 若 Step 9.4 有改动则提交: +# git add README.md && git commit -m "docs: note admin console API surface" +``` + +--- + +## Plan A 完工标准 + +- 所有 `/admin/*` 路由:`/users`, `/storage/*`, `/files*`, `/violations*`, `/logs`, `/notifications*`, `/system/*` 可经过真实 DB 工作,鉴权由 `require_admin` 控制。 +- 14+ 新增测例全部通过。 +- 全量 `uv run pytest -q` 仍绿。 +- 现有 `/admin/registration-email-domain-rules` 零改动、零回归。 +- 前端在 mock 关闭、指向真实后端时(设 `VITE_USE_MOCK=false`),Console 各页(Plan B 完成后)可正常工作。 diff --git a/docs/superpowers/plans/2026-05-24-admin-console-frontend.md b/docs/superpowers/plans/2026-05-24-admin-console-frontend.md new file mode 100644 index 0000000..99b597c --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-admin-console-frontend.md @@ -0,0 +1,1987 @@ +# Admin Console Frontend Implementation Plan (Plan B) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 `web/src/pages/dashboard/Dashboard.vue` 单页式管理面板重构为 `web/src/pages/console/` 多页式 Console(9 个子页 + 共享侧栏与组件),并补齐与 Plan A 后端契约对齐的 api 函数与 mock。 + +**Architecture:** 复用 `MainLayout` 顶层框架,在其下嵌一层 `ConsoleLayout`(含 `ConsoleSidebar` + ``);9 个子页 lazy import;共享展示组件不持有数据,由子页 `onMounted` 拉数据后注入 props。视觉沿用 [[frontend_aesthetic]] 工业风。 + +**Tech Stack:** Vue 3 ` + + + + +``` + +- [ ] **Step 1.2: 写 ConsoleLayout** + +`web/src/pages/console/ConsoleLayout.vue`: + +```vue + + + + + +``` + +- [ ] **Step 1.3: 写入口 index.ts(每个子目录都需要类似一个)** + +`web/src/pages/console/index.ts`: + +```typescript +export { default as ConsoleLayout } from './ConsoleLayout.vue'; +``` + +(每个子页的 `/index.ts` 同样默认导出页面组件,例:) + +`web/src/pages/console/overview/index.ts`(占位,Task 3 才实现): + +```typescript +import OverviewPage from './OverviewPage.vue'; +export default OverviewPage; +``` + +> 在本任务里,先为 9 个子页都各建一个空 `.vue`(含最小模板 ``)+ `index.ts`,让路由可以挂上不报错。代码示例: + +```vue + + + + +``` + +为 9 个子目录都生成同样形状的 placeholder:`overview, users, storage, content, moderation, system, logs, notifications, rules`。 + +- [ ] **Step 1.4: 修改 router/routes.ts** + +`web/src/router/routes.ts` —— 在 `children` 数组中把 `dashboard` 路由替换为 console 嵌套: + +```typescript +// 删除原 dashboard 子路由块;新增: +{ + path: 'console', + component: () => import('../pages/console/ConsoleLayout.vue'), + meta: { navId: 'console', requiresAdmin: true }, + children: [ + { path: '', redirect: '/console/overview' }, + { path: 'overview', name: 'ConsoleOverview', component: () => import('../pages/console/overview/index.ts') }, + { path: 'users', name: 'ConsoleUsers', component: () => import('../pages/console/users/index.ts') }, + { path: 'storage', name: 'ConsoleStorage', component: () => import('../pages/console/storage/index.ts') }, + { path: 'content', name: 'ConsoleContent', component: () => import('../pages/console/content/index.ts') }, + { path: 'moderation', name: 'ConsoleModeration', component: () => import('../pages/console/moderation/index.ts') }, + { path: 'system', name: 'ConsoleSystem', component: () => import('../pages/console/system/index.ts') }, + { path: 'logs', name: 'ConsoleLogs', component: () => import('../pages/console/logs/index.ts') }, + { path: 'notifications', name: 'ConsoleNotifications', component: () => import('../pages/console/notifications/index.ts') }, + { path: 'rules', name: 'ConsoleRules', component: () => import('../pages/console/rules/index.ts') }, + ], +}, +{ path: '/dashboard', redirect: '/console/overview' }, +``` + +旧 `dashboard` route 移除(注意 `/` 父路由还是 MainLayout,console 是它的 child)。 + +- [ ] **Step 1.5: 类型检查 + 启动 dev server 烟测** + +```bash +cd web && bun run check +cd web && bun run dev # 浏览器访问 http://localhost:5173/console +``` + +预期:以 admin 用户登录后访问 `/console`,重定向至 `/console/overview`,能看到 sidebar + "overview (TODO)" 占位。 + +- [ ] **Step 1.6: Commit** + +```bash +git add web/src/pages/console web/src/router/routes.ts +git commit -m "feat(web): scaffold Console layout, sidebar, and 9 subpage routes" +``` + +--- + +## Task 2: 共享组件(components/console/) + +**Files:** +- Create: `web/src/components/console/KpiCard.vue`, `StatusBadge.vue`, `FilterBar.vue`, `AdminTable.vue`, `TrendChart.vue`, `BroadcastComposer.vue`, `QuotaEditor.vue`, `index.ts` + +- [ ] **Step 2.1: KpiCard.vue** + +```vue + + + + + +``` + +- [ ] **Step 2.2: StatusBadge.vue** + +```vue + + + + + +``` + +- [ ] **Step 2.3: FilterBar.vue** + +```vue + + + + + +``` + +- [ ] **Step 2.4: AdminTable.vue** + +```vue + + + + + +``` + +- [ ] **Step 2.5: TrendChart.vue** + +```vue + + + + + +``` + +- [ ] **Step 2.6: BroadcastComposer.vue** + +```vue + + +