main
mula.liu 2025-11-30 10:43:29 +08:00
parent 4a22d48b19
commit a1a7580d63
8 changed files with 622 additions and 11 deletions

View File

@ -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,

View File

@ -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)):

View File

@ -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."}

View File

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

View File

@ -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,

View File

@ -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:

View File

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

Binary file not shown.