diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 3704e49..cfe25b0 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from app.database import get_db from app.models.db import User, Role, Menu -from app.services.auth import verify_password, create_access_token +from app.services.auth import verify_password, create_access_token, hash_password from app.services.auth_deps import get_current_user from app.services.token_service import token_service from app.config import settings @@ -29,6 +29,13 @@ class LoginRequest(BaseModel): password: str +class RegisterRequest(BaseModel): + username: str + password: str + email: str | None = None + full_name: str | None = None + + class LoginResponse(BaseModel): access_token: str token_type: str = "bearer" @@ -53,6 +60,91 @@ class MenuNode(BaseModel): children: list['MenuNode'] | None = None +@router.post("/register", response_model=LoginResponse) +async def register( + register_data: RegisterRequest, + db: AsyncSession = Depends(get_db) +): + """ + Register a new user + """ + # Check if username already exists + result = await db.execute( + select(User).where(User.username == register_data.username) + ) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered" + ) + + # Check if email already exists (if provided) + if register_data.email: + result = await db.execute( + select(User).where(User.email == register_data.email) + ) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Get 'user' role + result = await db.execute( + select(Role).where(Role.name == "user") + ) + user_role = result.scalar_one_or_none() + if not user_role: + # Should not happen if seeded correctly, but fallback handling + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Default role 'user' not found" + ) + + # Create new user + new_user = User( + username=register_data.username, + password_hash=hash_password(register_data.password), + email=register_data.email, + full_name=register_data.full_name, + is_active=True + ) + db.add(new_user) + await db.flush() # Flush to get ID + + # Assign role + from app.models.db.user import user_roles + await db.execute( + user_roles.insert().values( + user_id=new_user.id, + role_id=user_role.id + ) + ) + + await db.commit() + await db.refresh(new_user) + + # Create access token + access_token = create_access_token( + data={"sub": str(new_user.id), "username": new_user.username} + ) + + # Save token to Redis + await token_service.save_token(access_token, new_user.id, new_user.username) + + # Return token and user info (simulate fetch with roles loaded) + return LoginResponse( + access_token=access_token, + user={ + "id": new_user.id, + "username": new_user.username, + "email": new_user.email, + "full_name": new_user.full_name, + "roles": [user_role.name] + } + ) + + @router.post("/login", response_model=LoginResponse) async def login( login_data: LoginRequest, diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 65b3c34..341619a 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -2,10 +2,11 @@ API routes for celestial data """ from datetime import datetime -from fastapi import APIRouter, HTTPException, Query, Depends, UploadFile, File +from fastapi import APIRouter, HTTPException, Query, Depends, UploadFile, File, status from sqlalchemy.ext.asyncio import AsyncSession -from typing import Optional +from typing import Optional, Dict, Any import logging +from pydantic import BaseModel from app.models.celestial import ( CelestialDataResponse, @@ -31,6 +32,77 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/celestial", tags=["celestial"]) +# Pydantic models for CRUD +class CelestialBodyCreate(BaseModel): + id: str + name: str + name_zh: Optional[str] = None + type: str + description: Optional[str] = None + is_active: bool = True + extra_data: Optional[Dict[str, Any]] = None + +class CelestialBodyUpdate(BaseModel): + name: Optional[str] = None + name_zh: Optional[str] = None + type: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + extra_data: Optional[Dict[str, Any]] = None + + +@router.post("/", status_code=status.HTTP_201_CREATED) +async def create_celestial_body( + body_data: CelestialBodyCreate, + db: AsyncSession = Depends(get_db) +): + """Create a new celestial body""" + # Check if exists + existing = await celestial_body_service.get_body_by_id(body_data.id, db) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Body with ID {body_data.id} already exists" + ) + + new_body = await celestial_body_service.create_body(body_data.dict(), db) + return new_body + + +@router.put("/{body_id}") +async def update_celestial_body( + body_id: str, + body_data: CelestialBodyUpdate, + db: AsyncSession = Depends(get_db) +): + """Update a celestial body""" + # Filter out None values + update_data = {k: v for k, v in body_data.dict().items() if v is not None} + + updated = await celestial_body_service.update_body(body_id, update_data, db) + if not updated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Body {body_id} not found" + ) + return updated + + +@router.delete("/{body_id}") +async def delete_celestial_body( + body_id: str, + db: AsyncSession = Depends(get_db) +): + """Delete a celestial body""" + deleted = await celestial_body_service.delete_body(body_id, db) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Body {body_id} not found" + ) + return {"message": "Body deleted successfully"} + + @router.get("/positions", response_model=CelestialDataResponse) async def get_celestial_positions( start_time: Optional[str] = Query( @@ -476,6 +548,7 @@ async def list_bodies( "name_zh": body.name_zh, "type": body.type, "description": body.description, + "is_active": body.is_active, } ) return {"bodies": bodies_list} @@ -519,8 +592,79 @@ async def preheat_cache( raise HTTPException(status_code=500, detail=f"Preheat failed: {str(e)}") +# Static Data CRUD Models +class StaticDataCreate(BaseModel): + category: str + name: str + name_zh: Optional[str] = None + data: Dict[str, Any] + +class StaticDataUpdate(BaseModel): + category: Optional[str] = None + name: Optional[str] = None + name_zh: Optional[str] = None + data: Optional[Dict[str, Any]] = None + + # === Static Data Endpoints === +@router.get("/static/list") +async def list_static_data(db: AsyncSession = Depends(get_db)): + """Get all static data items""" + items = await static_data_service.get_all_items(db) + result = [] + for item in items: + result.append({ + "id": item.id, + "category": item.category, + "name": item.name, + "name_zh": item.name_zh, + "data": item.data + }) + return {"items": result} + + +@router.post("/static", status_code=status.HTTP_201_CREATED) +async def create_static_data( + item_data: StaticDataCreate, + db: AsyncSession = Depends(get_db) +): + """Create new static data""" + new_item = await static_data_service.create_static(item_data.dict(), db) + return new_item + + +@router.put("/static/{item_id}") +async def update_static_data( + item_id: int, + item_data: StaticDataUpdate, + db: AsyncSession = Depends(get_db) +): + """Update static data""" + update_data = {k: v for k, v in item_data.dict().items() if v is not None} + updated = await static_data_service.update_static(item_id, update_data, db) + if not updated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Static data {item_id} not found" + ) + return updated + + +@router.delete("/static/{item_id}") +async def delete_static_data( + item_id: int, + db: AsyncSession = Depends(get_db) +): + """Delete static data""" + deleted = await static_data_service.delete_static(item_id, db) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Static data {item_id} not found" + ) + return {"message": "Deleted successfully"} + @router.get("/static/categories") async def get_static_categories(db: AsyncSession = Depends(get_db)): diff --git a/backend/app/api/user.py b/backend/app/api/user.py new file mode 100644 index 0000000..8e6f7a6 --- /dev/null +++ b/backend/app/api/user.py @@ -0,0 +1,107 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlalchemy import select +from typing import List +from pydantic import BaseModel + +from app.database import get_db +from app.models.db import User +from app.services.auth import hash_password +from app.services.auth_deps import get_current_user # To protect endpoints + +router = APIRouter(prefix="/users", tags=["users"]) + +# Pydantic models +class UserListItem(BaseModel): + id: int + username: str + email: str | None + full_name: str | None + is_active: bool + roles: list[str] + last_login_at: str | None + created_at: str + + class Config: + orm_mode = True + +class UserStatusUpdate(BaseModel): + is_active: bool + +@router.get("/list") +async def get_user_list( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) # Protect this route +): + """Get a list of all users""" + # Ensure only admins can see all users + if "admin" not in [role.name for role in current_user.roles]: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + + result = await db.execute( + select(User).options(selectinload(User.roles)).order_by(User.id) + ) + users = result.scalars().all() + + users_list = [] + for user in users: + users_list.append({ + "id": user.id, + "username": user.username, + "email": user.email, + "full_name": user.full_name, + "is_active": user.is_active, + "roles": [role.name for role in user.roles], + "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None, + "created_at": user.created_at.isoformat() + }) + + return {"users": users_list} + + +@router.put("/{user_id}/status") +async def update_user_status( + user_id: int, + status_update: UserStatusUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Update a user's active status""" + if "admin" not in [role.name for role in current_user.roles]: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + user.is_active = status_update.is_active + await db.commit() + + return {"message": "User status updated successfully"} + + +@router.post("/{user_id}/reset-password") +async def reset_user_password( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Reset a user's password to the default""" + if "admin" not in [role.name for role in current_user.roles]: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + # Hardcoded default password for now. + # TODO: Move to a configurable system parameter. + default_password = "password123" + user.password_hash = hash_password(default_password) + + await db.commit() + + return {"message": f"Password for user {user.username} has been reset."} diff --git a/backend/app/main.py b/backend/app/main.py index 9c37fe5..0e839e6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from fastapi.staticfiles import StaticFiles from app.config import settings from app.api.routes import router as celestial_router from app.api.auth import router as auth_router +from app.api.user import router as user_router from app.services.redis_cache import redis_cache from app.services.cache_preheat import preheat_all_caches from app.database import close_db @@ -85,6 +86,7 @@ app.add_middleware( # Include routers app.include_router(celestial_router, prefix=settings.api_prefix) app.include_router(auth_router, prefix=settings.api_prefix) +app.include_router(user_router, prefix=settings.api_prefix) # Mount static files for uploaded resources upload_dir = Path(__file__).parent.parent / "upload" @@ -92,6 +94,12 @@ upload_dir.mkdir(exist_ok=True) app.mount("/upload", StaticFiles(directory=str(upload_dir)), name="upload") logger.info(f"Static files mounted at /upload -> {upload_dir}") +# Mount public assets directory +public_assets_dir = Path(__file__).parent.parent / "public" / "assets" +public_assets_dir.mkdir(parents=True, exist_ok=True) +app.mount("/public/assets", StaticFiles(directory=str(public_assets_dir)), name="public_assets") +logger.info(f"Public assets mounted at /public/assets -> {public_assets_dir}") + @app.get("/") async def root(): diff --git a/backend/app/services/db_service.py b/backend/app/services/db_service.py index 843cc91..6bd597a 100644 --- a/backend/app/services/db_service.py +++ b/backend/app/services/db_service.py @@ -72,6 +72,63 @@ class CelestialBodyService: async with AsyncSessionLocal() as s: return await _create(s) + @staticmethod + async def update_body( + body_id: str, + update_data: Dict[str, Any], + session: Optional[AsyncSession] = None + ) -> Optional[CelestialBody]: + """Update a celestial body""" + async def _update(s: AsyncSession): + # Query the body + result = await s.execute( + select(CelestialBody).where(CelestialBody.id == body_id) + ) + body = result.scalar_one_or_none() + + if not body: + return None + + # Update fields + for key, value in update_data.items(): + if hasattr(body, key): + setattr(body, key, value) + + await s.commit() + await s.refresh(body) + return body + + if session: + return await _update(session) + else: + async with AsyncSessionLocal() as s: + return await _update(s) + + @staticmethod + async def delete_body( + body_id: str, + session: Optional[AsyncSession] = None + ) -> bool: + """Delete a celestial body""" + async def _delete(s: AsyncSession): + result = await s.execute( + select(CelestialBody).where(CelestialBody.id == body_id) + ) + body = result.scalar_one_or_none() + + if not body: + return False + + await s.delete(body) + await s.commit() + return True + + if session: + return await _delete(session) + else: + async with AsyncSessionLocal() as s: + return await _delete(s) + class PositionService: """Service for position data operations""" @@ -354,6 +411,95 @@ class NasaCacheService: class StaticDataService: """Service for static data operations""" + @staticmethod + async def get_all_items( + session: Optional[AsyncSession] = None + ) -> List[StaticData]: + """Get all static data items""" + async def _query(s: AsyncSession): + result = await s.execute( + select(StaticData).order_by(StaticData.category, StaticData.name) + ) + return result.scalars().all() + + if session: + return await _query(session) + else: + async with AsyncSessionLocal() as s: + return await _query(s) + + @staticmethod + async def create_static( + data: Dict[str, Any], + session: Optional[AsyncSession] = None + ) -> StaticData: + """Create new static data""" + async def _create(s: AsyncSession): + item = StaticData(**data) + s.add(item) + await s.commit() + await s.refresh(item) + return item + + if session: + return await _create(session) + else: + async with AsyncSessionLocal() as s: + return await _create(s) + + @staticmethod + async def update_static( + item_id: int, + update_data: Dict[str, Any], + session: Optional[AsyncSession] = None + ) -> Optional[StaticData]: + """Update static data""" + async def _update(s: AsyncSession): + result = await s.execute( + select(StaticData).where(StaticData.id == item_id) + ) + item = result.scalar_one_or_none() + if not item: + return None + + for key, value in update_data.items(): + if hasattr(item, key): + setattr(item, key, value) + + await s.commit() + await s.refresh(item) + return item + + if session: + return await _update(session) + else: + async with AsyncSessionLocal() as s: + return await _update(s) + + @staticmethod + async def delete_static( + item_id: int, + session: Optional[AsyncSession] = None + ) -> bool: + """Delete static data""" + async def _delete(s: AsyncSession): + result = await s.execute( + select(StaticData).where(StaticData.id == item_id) + ) + item = result.scalar_one_or_none() + if not item: + return False + + await s.delete(item) + await s.commit() + return True + + if session: + return await _delete(session) + else: + async with AsyncSessionLocal() as s: + return await _delete(s) + @staticmethod async def get_by_category( category: str, diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py index 602605b..6b94027 100644 --- a/backend/app/services/token_service.py +++ b/backend/app/services/token_service.py @@ -36,15 +36,15 @@ class TokenService: await redis_cache.set( f"{self.prefix}{token}", json.dumps(token_data), - expire=ttl_seconds + ttl_seconds=ttl_seconds ) # Track user's active tokens (for multi-device support) user_tokens_key = f"{self.user_tokens_prefix}{user_id}" # Add token to user's token set - if redis_cache.redis: - await redis_cache.redis.sadd(user_tokens_key, token) - await redis_cache.redis.expire(user_tokens_key, ttl_seconds) + if redis_cache.client: + await redis_cache.client.sadd(user_tokens_key, token) + await redis_cache.client.expire(user_tokens_key, ttl_seconds) async def get_token_data(self, token: str) -> Optional[dict]: """ @@ -89,10 +89,10 @@ class TokenService: await redis_cache.delete(f"{self.prefix}{token}") # Remove from user's token set - if token_data and redis_cache.redis: + if token_data and redis_cache.client: user_id = token_data.get("user_id") if user_id: - await redis_cache.redis.srem( + await redis_cache.client.srem( f"{self.user_tokens_prefix}{user_id}", token ) @@ -104,12 +104,12 @@ class TokenService: Args: user_id: User ID """ - if not redis_cache.redis: + if not redis_cache.client: return # Get all user's tokens user_tokens_key = f"{self.user_tokens_prefix}{user_id}" - tokens = await redis_cache.redis.smembers(user_tokens_key) + tokens = await redis_cache.client.smembers(user_tokens_key) # Revoke each token for token in tokens: diff --git a/backend/scripts/add_platform_management_menu.sql b/backend/scripts/add_platform_management_menu.sql new file mode 100644 index 0000000..219a53e --- /dev/null +++ b/backend/scripts/add_platform_management_menu.sql @@ -0,0 +1,114 @@ +-- This script adds a new top-level menu "Platform Management" +-- with two sub-menus "User Management" and "Platform Parameters Management". +-- These menus will be assigned to the 'admin' role. + +-- Start Transaction for atomicity +BEGIN; + +-- 1. Find the ID of the 'admin' role +-- Assuming 'admin' role name exists and is unique. +DO $$ +DECLARE + admin_role_id INTEGER; + platform_management_menu_id INTEGER; + user_management_menu_id INTEGER; + platform_parameters_menu_id INTEGER; +BEGIN + SELECT id INTO admin_role_id FROM roles WHERE name = 'admin'; + + IF admin_role_id IS NULL THEN + RAISE EXCEPTION 'Admin role not found. Please ensure the admin role exists.'; + END IF; + + -- 2. Insert the top-level menu: "Platform Management" + -- Check if it already exists to prevent duplicates on re-run + SELECT id INTO platform_management_menu_id FROM menus WHERE name = 'platform_management' AND parent_id IS NULL; + + IF platform_management_menu_id IS NULL THEN + INSERT INTO menus (name, title, icon, path, component, sort_order, is_active, description, created_at, updated_at) + VALUES ( + 'platform_management', + '平台管理', + 'settings', -- Using a generic settings icon for platform management + NULL, -- It's a parent menu, no direct path + NULL, + 3, -- Assuming sort_order 1 & 2 are for Dashboard & Data Management + TRUE, + '管理用户和系统参数', + NOW(), + NOW() + ) RETURNING id INTO platform_management_menu_id; + RAISE NOTICE 'Inserted Platform Management menu with ID: %', platform_management_menu_id; + + -- Assign to admin role + INSERT INTO role_menus (role_id, menu_id, created_at) + VALUES (admin_role_id, platform_management_menu_id, NOW()); + RAISE NOTICE 'Assigned Platform Management to admin role.'; + ELSE + RAISE NOTICE 'Platform Management menu already exists with ID: %', platform_management_menu_id; + END IF; + + + -- 3. Insert sub-menu: "User Management" + -- Check if it already exists + SELECT id INTO user_management_menu_id FROM menus WHERE name = 'user_management' AND parent_id = platform_management_menu_id; + + IF user_management_menu_id IS NULL THEN + INSERT INTO menus (parent_id, name, title, icon, path, component, sort_order, is_active, description, created_at, updated_at) + VALUES ( + platform_management_menu_id, + 'user_management', + '用户管理', + 'users', -- Icon for user management + '/admin/users', -- Admin users page path + 'admin/Users', -- React component path + 1, + TRUE, + '管理系统用户账号', + NOW(), + NOW() + ) RETURNING id INTO user_management_menu_id; + RAISE NOTICE 'Inserted User Management menu with ID: %', user_management_menu_id; + + -- Assign to admin role + INSERT INTO role_menus (role_id, menu_id, created_at) + VALUES (admin_role_id, user_management_menu_id, NOW()); + RAISE NOTICE 'Assigned User Management to admin role.'; + ELSE + RAISE NOTICE 'User Management menu already exists with ID: %', user_management_menu_id; + END IF; + + + -- 4. Insert sub-menu: "Platform Parameters Management" + -- Check if it already exists + SELECT id INTO platform_parameters_menu_id FROM menus WHERE name = 'platform_parameters_management' AND parent_id = platform_management_menu_id; + + IF platform_parameters_menu_id IS NULL THEN + INSERT INTO menus (parent_id, name, title, icon, path, component, sort_order, is_active, description, created_at, updated_at) + VALUES ( + platform_management_menu_id, + 'platform_parameters_management', + '平台参数管理', + 'sliders', -- Icon for parameters/settings + '/admin/settings', -- Admin settings page path + 'admin/Settings', -- React component path + 2, + TRUE, + '管理系统通用配置参数', + NOW(), + NOW() + ) RETURNING id INTO platform_parameters_menu_id; + RAISE NOTICE 'Inserted Platform Parameters Management menu with ID: %', platform_parameters_menu_id; + + -- Assign to admin role + INSERT INTO role_menus (role_id, menu_id, created_at) + VALUES (admin_role_id, platform_parameters_menu_id, NOW()); + RAISE NOTICE 'Assigned Platform Parameters Management to admin role.'; + ELSE + RAISE NOTICE 'Platform Parameters Management menu already exists with ID: %', platform_parameters_menu_id; + END IF; + +END $$; + +-- Commit the transaction +COMMIT; diff --git a/backend/upload/assets/tick_sample.m4a b/backend/upload/assets/tick_sample.m4a new file mode 100644 index 0000000..5938594 Binary files /dev/null and b/backend/upload/assets/tick_sample.m4a differ