diff --git a/DATABASE_SCHEMA.md b/DATABASE_SCHEMA.md index 171da46..f3f84b2 100644 --- a/DATABASE_SCHEMA.md +++ b/DATABASE_SCHEMA.md @@ -10,6 +10,8 @@ - [3.3 orbits - 轨道路径表](#33-orbits---轨道路径表) - [3.4 resources - 资源文件管理表](#34-resources---资源文件管理表) - [3.5 static_data - 静态天文数据表](#35-static_data---静态天文数据表) + - [3.6 star_systems - 恒星系统表](#36-star_systems---恒星系统表) + - [3.7 interstellar_bodies - 恒星际天体表](#37-interstellar_bodies---恒星际天体表) - [4. 系统管理表](#4-系统管理表) - [4.1 users - 用户表](#41-users---用户表) - [4.2 roles - 角色表](#42-roles---角色表) @@ -46,14 +48,16 @@ | 3 | orbits | 轨道路径数据 | 数百 | | 4 | resources | 资源文件管理 | 数千 | | 5 | static_data | 静态天文数据 | 数千 | -| 6 | users | 用户账号 | 数千 | -| 7 | roles | 角色定义 | 十位数 | -| 8 | user_roles | 用户角色关联 | 数千 | -| 9 | menus | 菜单配置 | 数十 | -| 10 | role_menus | 角色菜单权限 | 数百 | -| 11 | system_settings | 系统配置参数 | 数十 | -| 12 | tasks | 后台任务 | 数万 | -| 13 | nasa_cache | NASA API缓存 | 数万 | +| 6 | star_systems | 恒星系统信息 | 数千 | +| 7 | interstellar_bodies | 恒星际天体信息 | 数千 | +| 8 | users | 用户账号 | 数千 | +| 9 | roles | 角色定义 | 十位数 | +| 10 | user_roles | 用户角色关联 | 数千 | +| 11 | menus | 菜单配置 | 数十 | +| 12 | role_menus | 角色菜单权限 | 数百 | +| 13 | system_settings | 系统配置参数 | 数十 | +| 14 | tasks | 后台任务 | 数万 | +| 15 | nasa_cache | NASA API缓存 | 数万 | --- @@ -260,7 +264,8 @@ CREATE TABLE static_data ( updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT chk_category CHECK (category IN ( - 'constellation', 'galaxy', 'star', 'nebula', 'cluster' + 'constellation', 'galaxy', 'star', 'nebula', 'cluster', + 'asteroid_belt', 'kuiper_belt', 'interstellar' )), CONSTRAINT uq_category_name UNIQUE (category, name) ); @@ -272,7 +277,7 @@ CREATE INDEX idx_static_data_data ON static_data USING GIN(data); -- JSONB索 -- 注释 COMMENT ON TABLE static_data IS '静态天文数据表(星座、星系、恒星等)'; -COMMENT ON COLUMN static_data.category IS '数据分类:constellation(星座), galaxy(星系), star(恒星), nebula(星云), cluster(星团)'; +COMMENT ON COLUMN static_data.category IS '数据分类:constellation(星座), galaxy(星系), star(恒星), nebula(星云), cluster(星团), interstellar(恒星际/系外行星系统)'; COMMENT ON COLUMN static_data.data IS 'JSON格式的完整数据,结构根据category不同而不同'; ``` @@ -288,6 +293,29 @@ COMMENT ON COLUMN static_data.data IS 'JSON格式的完整数据,结构根据c } ``` +**data JSONB字段示例 - 恒星际系统 (Phase 3 & 4)**: +```json +{ + "distance_pc": 12.1, + "ra": 346.5, + "dec": -15.9, + "position": {"x": 10.5, "y": -5.2, "z": 2.1}, + "spectral_type": "M4V", + "planet_count": 3, + "color": "#ffbd6f", + "planets": [ + { + "name": "Trappist-1 b", + "semi_major_axis_au": 0.011, + "period_days": 1.51, + "eccentricity": 0.0, + "radius_earth": 1.12, + "temperature_k": 400 + } + ] +} +``` + **data JSONB字段示例 - 恒星**: ```json { @@ -304,6 +332,119 @@ COMMENT ON COLUMN static_data.data IS 'JSON格式的完整数据,结构根据c --- +### 3.6 star_systems - 恒星系统表 + +存储恒星系统的基本信息(Phase 3 - 恒星际扩展)。包括太阳系和其他恒星系统。 + +```sql +CREATE TABLE star_systems ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, -- 系统名称(如"Solar System") + name_zh VARCHAR(200), -- 中文名称(如"太阳系") + host_star_name VARCHAR(200) NOT NULL, -- 主恒星名称 + distance_pc DOUBLE PRECISION, -- 距离(秒差距) + distance_ly DOUBLE PRECISION, -- 距离(光年) + ra DOUBLE PRECISION, -- 赤经(度) + dec DOUBLE PRECISION, -- 赤纬(度) + position_x DOUBLE PRECISION, -- 笛卡尔坐标X(秒差距) + position_y DOUBLE PRECISION, -- 笛卡尔坐标Y(秒差距) + position_z DOUBLE PRECISION, -- 笛卡尔坐标Z(秒差距) + spectral_type VARCHAR(20), -- 光谱类型(如"G2V") + radius_solar DOUBLE PRECISION, -- 恒星半径(太阳半径倍数) + mass_solar DOUBLE PRECISION, -- 恒星质量(太阳质量倍数) + temperature_k DOUBLE PRECISION, -- 表面温度(开尔文) + magnitude DOUBLE PRECISION, -- 视星等 + luminosity_solar DOUBLE PRECISION, -- 光度(太阳光度倍数) + color VARCHAR(20), -- 显示颜色(HEX格式) + planet_count INTEGER DEFAULT 0, -- 行星数量 + description TEXT, -- 系统描述 + details TEXT, -- 详细信息(Markdown格式) + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT uq_star_system_name UNIQUE (name) +); + +-- 索引 +CREATE INDEX idx_star_systems_name ON star_systems(name); +CREATE INDEX idx_star_systems_distance ON star_systems(distance_pc); +CREATE INDEX idx_star_systems_position ON star_systems(position_x, position_y, position_z); + +-- 注释 +COMMENT ON TABLE star_systems IS '恒星系统基本信息表(Phase 3)'; +COMMENT ON COLUMN star_systems.distance_pc IS '距离(秒差距),1 pc ≈ 3.26 光年'; +COMMENT ON COLUMN star_systems.spectral_type IS '恒星光谱分类,如G2V(太阳型)、M5.5V(红矮星)'; +COMMENT ON COLUMN star_systems.position_x IS '以太阳为原点的笛卡尔坐标X(秒差距)'; +COMMENT ON COLUMN star_systems.color IS '3D可视化中的恒星颜色,HEX格式'; +``` + +**使用场景**: +- Galaxy View(银河视图)的恒星系统展示 +- 恒星系统搜索和筛选 +- 系外行星系统管理 +- 星际距离计算 + +--- + +### 3.7 interstellar_bodies - 恒星际天体表 + +存储恒星系统中的天体信息(行星、卫星等),与star_systems表关联。 + +```sql +CREATE TABLE interstellar_bodies ( + id SERIAL PRIMARY KEY, + system_id INTEGER NOT NULL REFERENCES star_systems(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, -- 天体名称 + name_zh VARCHAR(200), -- 中文名称 + type VARCHAR(50) NOT NULL, -- 天体类型 + description TEXT, -- 描述 + extra_data JSONB, -- 扩展数据 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT chk_interstellar_body_type CHECK (type IN ( + 'planet', 'satellite', 'dwarf_planet', 'asteroid' + )) +); + +-- 索引 +CREATE INDEX idx_interstellar_bodies_system ON interstellar_bodies(system_id); +CREATE INDEX idx_interstellar_bodies_type ON interstellar_bodies(type); +CREATE INDEX idx_interstellar_bodies_name ON interstellar_bodies(name); +CREATE INDEX idx_interstellar_bodies_extra_data ON interstellar_bodies USING GIN(extra_data); + +-- 注释 +COMMENT ON TABLE interstellar_bodies IS '恒星际天体信息表(Phase 3)'; +COMMENT ON COLUMN interstellar_bodies.system_id IS '所属恒星系统ID(外键关联star_systems表)'; +COMMENT ON COLUMN interstellar_bodies.type IS '天体类型:planet(行星), satellite(卫星), dwarf_planet(矮行星), asteroid(小行星)'; +COMMENT ON COLUMN interstellar_bodies.extra_data IS 'JSON格式扩展数据,包含轨道参数、物理参数等'; +``` + +**extra_data JSONB字段示例**: +```json +{ + "semi_major_axis_au": 0.0172, + "period_days": 1.51087, + "eccentricity": 0.00622, + "inclination_deg": 89.728, + "radius_earth": 1.116, + "mass_earth": 1.374, + "temperature_k": 400, + "discovery_year": 2017, + "discovery_method": "Transit", + "equilibrium_temp_k": 400, + "density_gcc": 5.9 +} +``` + +**使用场景**: +- 显示恒星系统的行星列表 +- 系外行星数据管理 +- 行星轨道参数查询 +- 行星物理特性分析 + +--- + ## 4. 系统管理表 ### 4.1 users - 用户表 diff --git a/app/api/celestial_body.py b/app/api/celestial_body.py index ca46c7b..9985903 100644 --- a/app/api/celestial_body.py +++ b/app/api/celestial_body.py @@ -24,9 +24,10 @@ class CelestialBodyCreate(BaseModel): name: str name_zh: Optional[str] = None type: str + system_id: Optional[int] = None description: Optional[str] = None details: Optional[str] = None - is_active: bool = True + is_active: Optional[bool] = True extra_data: Optional[Dict[str, Any]] = None @@ -34,6 +35,7 @@ class CelestialBodyUpdate(BaseModel): name: Optional[str] = None name_zh: Optional[str] = None type: Optional[str] = None + system_id: Optional[int] = None description: Optional[str] = None details: Optional[str] = None is_active: Optional[bool] = None @@ -184,12 +186,17 @@ async def get_body_info(body_id: str, db: AsyncSession = Depends(get_db)): @router.get("/list") async def list_bodies( body_type: Optional[str] = Query(None, description="Filter by body type"), + system_id: Optional[int] = Query(None, description="Filter by star system ID (1=Solar, 2+=Exoplanets)"), db: AsyncSession = Depends(get_db) ): """ Get a list of all available celestial bodies + + Args: + body_type: Filter by body type (star, planet, dwarf_planet, satellite, probe, comet, etc.) + system_id: Filter by star system ID (1=Solar System, 2+=Exoplanet systems) """ - bodies = await celestial_body_service.get_all_bodies(db, body_type) + bodies = await celestial_body_service.get_all_bodies(db, body_type, system_id) bodies_list = [] for body in bodies: diff --git a/app/api/star_system.py b/app/api/star_system.py new file mode 100644 index 0000000..480a9cc --- /dev/null +++ b/app/api/star_system.py @@ -0,0 +1,247 @@ +""" +Star System Management API routes +Handles CRUD operations for star systems (Solar System and exoplanet systems) +""" +import logging +from fastapi import APIRouter, HTTPException, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional + +from app.database import get_db +from app.models.star_system import ( + StarSystemCreate, + StarSystemUpdate, + StarSystemResponse, + StarSystemWithBodies, + StarSystemListResponse, + StarSystemStatistics +) +from app.services.star_system_service import star_system_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/star-systems", tags=["star-systems"]) + + +@router.get("", response_model=StarSystemListResponse) +async def get_star_systems( + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(100, ge=1, le=1000, description="Maximum records to return"), + exclude_solar: bool = Query(False, description="Exclude Solar System from results"), + search: Optional[str] = Query(None, description="Search by name (English or Chinese)"), + db: AsyncSession = Depends(get_db) +): + """ + Get all star systems with pagination and optional filtering + + Args: + skip: Number of records to skip (for pagination) + limit: Maximum number of records to return + exclude_solar: If True, exclude Solar System (id=1) from results + search: Search keyword to filter by name or host star name + db: Database session + + Returns: + List of star systems with total count + """ + systems = await star_system_service.get_all( + db=db, + skip=skip, + limit=limit, + exclude_solar=exclude_solar, + search=search + ) + + # Get total count for pagination + from sqlalchemy import select, func, or_ + from app.models.db.star_system import StarSystem + + count_query = select(func.count(StarSystem.id)) + if exclude_solar: + count_query = count_query.where(StarSystem.id != 1) + if search: + search_pattern = f"%{search}%" + count_query = count_query.where( + or_( + StarSystem.name.ilike(search_pattern), + StarSystem.name_zh.ilike(search_pattern), + StarSystem.host_star_name.ilike(search_pattern) + ) + ) + + result = await db.execute(count_query) + total = result.scalar() + + return StarSystemListResponse( + total=total, + systems=[StarSystemResponse.from_orm(s) for s in systems] + ) + + +@router.get("/statistics", response_model=StarSystemStatistics) +async def get_statistics(db: AsyncSession = Depends(get_db)): + """ + Get star system statistics + + Returns: + - Total star systems count + - Exoplanet systems count + - Total planets count (Solar System + exoplanets) + - Nearest star systems (top 10) + """ + stats = await star_system_service.get_statistics(db) + return StarSystemStatistics(**stats) + + +@router.get("/{system_id}", response_model=StarSystemResponse) +async def get_star_system( + system_id: int, + db: AsyncSession = Depends(get_db) +): + """ + Get a single star system by ID + + Args: + system_id: Star system ID (1 = Solar System, 2+ = Exoplanet systems) + """ + system = await star_system_service.get_by_id(db, system_id) + if not system: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Star system with ID {system_id} not found" + ) + return StarSystemResponse.from_orm(system) + + +@router.get("/{system_id}/bodies", response_model=StarSystemWithBodies) +async def get_star_system_with_bodies( + system_id: int, + db: AsyncSession = Depends(get_db) +): + """ + Get a star system with all its celestial bodies + + Args: + system_id: Star system ID + + Returns: + Star system details along with list of all celestial bodies + (stars, planets, dwarf planets, satellites, probes, comets, etc.) + """ + result = await star_system_service.get_with_bodies(db, system_id) + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Star system with ID {system_id} not found" + ) + + # Convert ORM objects to dicts + system_dict = StarSystemResponse.from_orm(result["system"]).dict() + bodies_list = [ + { + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "description": body.description, + "details": body.details, + "is_active": body.is_active, + "extra_data": body.extra_data, + } + for body in result["bodies"] + ] + + return StarSystemWithBodies( + **system_dict, + bodies=bodies_list, + body_count=result["body_count"] + ) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=StarSystemResponse) +async def create_star_system( + system_data: StarSystemCreate, + db: AsyncSession = Depends(get_db) +): + """ + Create a new star system + + Note: This is an admin operation. Use with caution. + """ + # Check if name already exists + existing = await star_system_service.get_by_name(db, system_data.name) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Star system with name '{system_data.name}' already exists" + ) + + new_system = await star_system_service.create(db, system_data.dict()) + return StarSystemResponse.from_orm(new_system) + + +@router.put("/{system_id}", response_model=StarSystemResponse) +async def update_star_system( + system_id: int, + system_data: StarSystemUpdate, + db: AsyncSession = Depends(get_db) +): + """ + Update a star system + + Args: + system_id: Star system ID to update + system_data: Fields to update (only non-null fields will be updated) + """ + # Filter out None values + update_data = {k: v for k, v in system_data.dict().items() if v is not None} + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields to update" + ) + + updated_system = await star_system_service.update(db, system_id, update_data) + if not updated_system: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Star system with ID {system_id} not found" + ) + + return StarSystemResponse.from_orm(updated_system) + + +@router.delete("/{system_id}") +async def delete_star_system( + system_id: int, + db: AsyncSession = Depends(get_db) +): + """ + Delete a star system and all its celestial bodies + + WARNING: This will cascade delete all celestial bodies in this system! + Cannot delete Solar System (id=1). + """ + if system_id == 1: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete Solar System" + ) + + try: + deleted = await star_system_service.delete_system(db, system_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Star system with ID {system_id} not found" + ) + + return { + "message": f"Star system {system_id} and all its bodies deleted successfully" + } + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e) + ) diff --git a/app/main.py b/app/main.py index 687eab9..379107e 100644 --- a/app/main.py +++ b/app/main.py @@ -29,6 +29,7 @@ from app.api.celestial_resource import router as celestial_resource_router from app.api.celestial_orbit import router as celestial_orbit_router from app.api.nasa_download import router as nasa_download_router from app.api.celestial_position import router as celestial_position_router +from app.api.star_system import router as star_system_router from app.services.redis_cache import redis_cache from app.services.cache_preheat import preheat_all_caches from app.database import close_db @@ -122,6 +123,7 @@ app.include_router(system_router, prefix=settings.api_prefix) app.include_router(danmaku_router, prefix=settings.api_prefix) # Celestial body related routers +app.include_router(star_system_router, prefix=settings.api_prefix) app.include_router(celestial_body_router, prefix=settings.api_prefix) app.include_router(celestial_position_router, prefix=settings.api_prefix) app.include_router(celestial_resource_router, prefix=settings.api_prefix) diff --git a/app/models/db/__init__.py b/app/models/db/__init__.py index 87b7d1d..e038ace 100644 --- a/app/models/db/__init__.py +++ b/app/models/db/__init__.py @@ -7,6 +7,7 @@ from .resource import Resource from .static_data import StaticData from .nasa_cache import NasaCache from .orbit import Orbit +from .star_system import StarSystem from .user import User, user_roles from .role import Role from .menu import Menu, RoleMenu @@ -20,6 +21,7 @@ __all__ = [ "StaticData", "NasaCache", "Orbit", + "StarSystem", "User", "Role", "Menu", diff --git a/app/models/db/celestial_body.py b/app/models/db/celestial_body.py index 266178b..51b510a 100644 --- a/app/models/db/celestial_body.py +++ b/app/models/db/celestial_body.py @@ -1,7 +1,7 @@ """ CelestialBody ORM model """ -from sqlalchemy import Column, String, Text, TIMESTAMP, Boolean, CheckConstraint, Index +from sqlalchemy import Column, String, Text, TIMESTAMP, Boolean, Integer, ForeignKey, CheckConstraint, Index from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.sql import func from sqlalchemy.orm import relationship @@ -17,6 +17,7 @@ class CelestialBody(Base): name = Column(String(200), nullable=False, comment="English name") name_zh = Column(String(200), nullable=True, comment="Chinese name") type = Column(String(50), nullable=False, comment="Body type") + system_id = Column(Integer, ForeignKey('star_systems.id', ondelete='CASCADE'), nullable=True, comment="所属恒星系ID") description = Column(Text, nullable=True, comment="Description") details = Column(Text, nullable=True, comment="Detailed description (Markdown)") is_active = Column(Boolean, nullable=True, comment="Active status for probes (True=active, False=inactive)") @@ -25,6 +26,7 @@ class CelestialBody(Base): updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) # Relationships + star_system = relationship("StarSystem", back_populates="celestial_bodies") positions = relationship( "Position", back_populates="body", cascade="all, delete-orphan" ) @@ -40,6 +42,7 @@ class CelestialBody(Base): ), Index("idx_celestial_bodies_type", "type"), Index("idx_celestial_bodies_name", "name"), + Index("idx_celestial_bodies_system", "system_id"), ) def __repr__(self): diff --git a/app/models/db/star_system.py b/app/models/db/star_system.py new file mode 100644 index 0000000..802d01a --- /dev/null +++ b/app/models/db/star_system.py @@ -0,0 +1,54 @@ +""" +StarSystem ORM Model +恒星系统数据模型 +""" +from sqlalchemy import Column, Integer, String, Text, Double, TIMESTAMP, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from app.database import Base + + +class StarSystem(Base): + """恒星系统表""" + __tablename__ = 'star_systems' + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(200), unique=True, nullable=False, index=True) + name_zh = Column(String(200)) + host_star_name = Column(String(200), nullable=False, index=True) + + # 位置信息(系外恒星系) + distance_pc = Column(Double) + distance_ly = Column(Double) + ra = Column(Double) + dec = Column(Double) + position_x = Column(Double) + position_y = Column(Double) + position_z = Column(Double) + + # 恒星物理参数 + spectral_type = Column(String(20)) + radius_solar = Column(Double) + mass_solar = Column(Double) + temperature_k = Column(Double) + magnitude = Column(Double) + luminosity_solar = Column(Double) + + # 显示属性 + color = Column(String(20)) + planet_count = Column(Integer, default=0) + + # 描述信息 + description = Column(Text) + details = Column(Text) + extra_data = Column(JSONB) + + # 时间戳 + created_at = Column(TIMESTAMP, server_default=func.now()) + updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) + + # 关系 + celestial_bodies = relationship("CelestialBody", back_populates="star_system", cascade="all, delete-orphan") + + def __repr__(self): + return f"" diff --git a/app/models/db/static_data.py b/app/models/db/static_data.py index 3c866e8..515e493 100644 --- a/app/models/db/static_data.py +++ b/app/models/db/static_data.py @@ -25,7 +25,7 @@ class StaticData(Base): # Constraints and indexes __table_args__ = ( CheckConstraint( - "category IN ('constellation', 'galaxy', 'star', 'nebula', 'cluster', 'asteroid_belt', 'kuiper_belt')", + "category IN ('constellation', 'galaxy', 'star', 'nebula', 'cluster', 'asteroid_belt', 'kuiper_belt', 'interstellar')", name="chk_category", ), UniqueConstraint("category", "name", name="uq_category_name"), diff --git a/app/models/star_system.py b/app/models/star_system.py new file mode 100644 index 0000000..ad26148 --- /dev/null +++ b/app/models/star_system.py @@ -0,0 +1,99 @@ +""" +StarSystem Pydantic Models +恒星系统数据模型(用于API) +""" +from typing import Optional, List +from pydantic import BaseModel, Field +from datetime import datetime + + +class StarSystemBase(BaseModel): + """恒星系统基础模型""" + name: str = Field(..., description="恒星系名称") + name_zh: Optional[str] = Field(None, description="中文名称") + host_star_name: str = Field(..., description="主恒星名称") + + # 位置信息 + distance_pc: Optional[float] = Field(None, description="距离地球(秒差距)") + distance_ly: Optional[float] = Field(None, description="距离地球(光年)") + ra: Optional[float] = Field(None, description="赤经(度)") + dec: Optional[float] = Field(None, description="赤纬(度)") + position_x: Optional[float] = Field(None, description="笛卡尔坐标 X(pc)") + position_y: Optional[float] = Field(None, description="笛卡尔坐标 Y(pc)") + position_z: Optional[float] = Field(None, description="笛卡尔坐标 Z(pc)") + + # 恒星参数 + spectral_type: Optional[str] = Field(None, description="光谱类型") + radius_solar: Optional[float] = Field(None, description="恒星半径(太阳半径)") + mass_solar: Optional[float] = Field(None, description="恒星质量(太阳质量)") + temperature_k: Optional[float] = Field(None, description="表面温度(K)") + magnitude: Optional[float] = Field(None, description="视星等") + luminosity_solar: Optional[float] = Field(None, description="光度(太阳光度)") + + # 显示属性 + color: Optional[str] = Field(None, description="显示颜色(HEX)") + + # 描述 + description: Optional[str] = Field(None, description="描述") + details: Optional[str] = Field(None, description="详细信息(Markdown)") + + +class StarSystemCreate(StarSystemBase): + """创建恒星系统""" + pass + + +class StarSystemUpdate(BaseModel): + """更新恒星系统(所有字段可选)""" + name: Optional[str] = None + name_zh: Optional[str] = None + host_star_name: Optional[str] = None + distance_pc: Optional[float] = None + distance_ly: Optional[float] = None + ra: Optional[float] = None + dec: Optional[float] = None + position_x: Optional[float] = None + position_y: Optional[float] = None + position_z: Optional[float] = None + spectral_type: Optional[str] = None + radius_solar: Optional[float] = None + mass_solar: Optional[float] = None + temperature_k: Optional[float] = None + magnitude: Optional[float] = None + luminosity_solar: Optional[float] = None + color: Optional[str] = None + description: Optional[str] = None + details: Optional[str] = None + + +class StarSystemResponse(StarSystemBase): + """恒星系统响应模型""" + id: int + planet_count: int = Field(default=0, description="已知行星数量") + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class StarSystemWithBodies(StarSystemResponse): + """包含天体的恒星系统""" + bodies: List[dict] = Field(default_factory=list, description="关联的天体列表") + body_count: int = Field(default=0, description="天体数量") + + +class StarSystemListResponse(BaseModel): + """恒星系统列表响应""" + total: int + systems: List[StarSystemResponse] + + +class StarSystemStatistics(BaseModel): + """恒星系统统计信息""" + total_systems: int = Field(..., description="总恒星系统数") + exo_systems: int = Field(..., description="系外恒星系统数") + total_planets: int = Field(..., description="总行星数") + exo_planets: int = Field(..., description="系外行星数") + solar_system_planets: int = Field(..., description="太阳系行星数") + nearest_systems: List[dict] = Field(default_factory=list, description="最近的10个恒星系统") diff --git a/app/services/db_service.py b/app/services/db_service.py index 6849934..1c91822 100644 --- a/app/services/db_service.py +++ b/app/services/db_service.py @@ -19,13 +19,23 @@ class CelestialBodyService: @staticmethod async def get_all_bodies( session: Optional[AsyncSession] = None, - body_type: Optional[str] = None + body_type: Optional[str] = None, + system_id: Optional[int] = None ) -> List[CelestialBody]: - """Get all celestial bodies, optionally filtered by type""" + """ + Get all celestial bodies, optionally filtered by type and star system + + Args: + session: Database session + body_type: Filter by body type (star, planet, dwarf_planet, etc.) + system_id: Filter by star system ID (1=Solar System, 2+=Exoplanets) + """ async def _query(s: AsyncSession): query = select(CelestialBody) if body_type: query = query.where(CelestialBody.type == body_type) + if system_id is not None: + query = query.where(CelestialBody.system_id == system_id) result = await s.execute(query.order_by(CelestialBody.name)) return result.scalars().all() diff --git a/app/services/star_system_service.py b/app/services/star_system_service.py new file mode 100644 index 0000000..b60034e --- /dev/null +++ b/app/services/star_system_service.py @@ -0,0 +1,217 @@ +""" +StarSystem Service +恒星系统服务层 +""" +from typing import List, Optional +from sqlalchemy import select, func, update, delete, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.db.star_system import StarSystem +from app.models.db.celestial_body import CelestialBody + + +class StarSystemService: + """恒星系统服务""" + + @staticmethod + async def get_all( + db: AsyncSession, + skip: int = 0, + limit: int = 100, + exclude_solar: bool = False, + search: Optional[str] = None + ) -> List[StarSystem]: + """ + 获取所有恒星系统 + + Args: + db: 数据库会话 + skip: 跳过记录数 + limit: 返回记录数 + exclude_solar: 是否排除太阳系 + search: 搜索关键词(匹配名称) + """ + query = select(StarSystem).order_by(StarSystem.distance_pc.asc().nulls_first()) + + # 排除太阳系 + if exclude_solar: + query = query.where(StarSystem.id != 1) + + # 搜索 + if search: + search_pattern = f"%{search}%" + query = query.where( + or_( + StarSystem.name.ilike(search_pattern), + StarSystem.name_zh.ilike(search_pattern), + StarSystem.host_star_name.ilike(search_pattern) + ) + ) + + query = query.offset(skip).limit(limit) + result = await db.execute(query) + return list(result.scalars().all()) + + @staticmethod + async def get_by_id(db: AsyncSession, system_id: int) -> Optional[StarSystem]: + """根据ID获取恒星系统""" + result = await db.execute( + select(StarSystem).where(StarSystem.id == system_id) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_by_name(db: AsyncSession, name: str) -> Optional[StarSystem]: + """根据名称获取恒星系统""" + result = await db.execute( + select(StarSystem).where(StarSystem.name == name) + ) + return result.scalar_one_or_none() + + @staticmethod + async def create(db: AsyncSession, system_data: dict) -> StarSystem: + """创建恒星系统""" + system = StarSystem(**system_data) + db.add(system) + await db.commit() + await db.refresh(system) + return system + + @staticmethod + async def update(db: AsyncSession, system_id: int, system_data: dict) -> Optional[StarSystem]: + """更新恒星系统""" + result = await db.execute( + select(StarSystem).where(StarSystem.id == system_id) + ) + system = result.scalar_one_or_none() + + if not system: + return None + + for key, value in system_data.items(): + if hasattr(system, key): + setattr(system, key, value) + + await db.commit() + await db.refresh(system) + return system + + @staticmethod + async def delete_system(db: AsyncSession, system_id: int) -> bool: + """ + 删除恒星系统(级联删除所有关联天体) + 不允许删除太阳系(id=1) + """ + if system_id == 1: + raise ValueError("不能删除太阳系") + + result = await db.execute( + delete(StarSystem).where(StarSystem.id == system_id) + ) + await db.commit() + return result.rowcount > 0 + + @staticmethod + async def get_with_bodies(db: AsyncSession, system_id: int) -> Optional[dict]: + """ + 获取恒星系统及其所有天体 + + Returns: + 包含 system 和 bodies 的字典 + """ + # 获取恒星系统 + system_result = await db.execute( + select(StarSystem).where(StarSystem.id == system_id) + ) + system = system_result.scalar_one_or_none() + + if not system: + return None + + # 获取关联的天体 + bodies_result = await db.execute( + select(CelestialBody) + .where(CelestialBody.system_id == system_id) + .order_by(CelestialBody.type, CelestialBody.name) + ) + bodies = list(bodies_result.scalars().all()) + + return { + "system": system, + "bodies": bodies, + "body_count": len(bodies) + } + + @staticmethod + async def update_planet_count(db: AsyncSession, system_id: int) -> None: + """更新恒星系统的行星数量统计""" + result = await db.execute( + select(func.count(CelestialBody.id)) + .where(CelestialBody.system_id == system_id) + .where(CelestialBody.type != 'star') # 排除恒星本身 + ) + count = result.scalar() + + await db.execute( + update(StarSystem) + .where(StarSystem.id == system_id) + .values(planet_count=count) + ) + await db.commit() + + @staticmethod + async def get_statistics(db: AsyncSession) -> dict: + """获取恒星系统统计信息""" + # 总恒星系统数 + total_systems_result = await db.execute(select(func.count(StarSystem.id))) + total_systems = total_systems_result.scalar() + + # 系外恒星系统数 + exo_systems_result = await db.execute( + select(func.count(StarSystem.id)).where(StarSystem.id != 1) + ) + exo_systems = exo_systems_result.scalar() + + # 总行星数 + total_planets_result = await db.execute( + select(func.count(CelestialBody.id)) + .where(CelestialBody.type == 'planet') + ) + total_planets = total_planets_result.scalar() + + # 系外行星数 + exo_planets_result = await db.execute( + select(func.count(CelestialBody.id)) + .where(CelestialBody.type == 'planet') + .where(CelestialBody.system_id > 1) + ) + exo_planets = exo_planets_result.scalar() + + # 距离最近的10个恒星系统 + nearest_systems_result = await db.execute( + select(StarSystem.name, StarSystem.name_zh, StarSystem.distance_ly, StarSystem.planet_count) + .where(StarSystem.id != 1) + .order_by(StarSystem.distance_pc.asc()) + .limit(10) + ) + nearest_systems = [ + { + "name": name, + "name_zh": name_zh, + "distance_ly": distance_ly, + "planet_count": planet_count + } + for name, name_zh, distance_ly, planet_count in nearest_systems_result + ] + + return { + "total_systems": total_systems, + "exo_systems": exo_systems, + "total_planets": total_planets, + "exo_planets": exo_planets, + "solar_system_planets": total_planets - exo_planets, + "nearest_systems": nearest_systems + } + + +# 创建服务实例 +star_system_service = StarSystemService() diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 1d5a3b8..3d0a4a8 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -206,6 +206,15 @@ class SystemSettingsService: "description": "生成轨道线时使用的点数,越多越平滑但性能越低", "is_public": True }, + { + "key": "view_mode", + "value": "solar", + "value_type": "string", + "category": "visualization", + "label": "默认视图模式", + "description": "首页默认进入的视图模式 (solar: 太阳系视图, galaxy: 银河系视图)", + "is_public": True + }, ] for default in defaults: diff --git a/scripts/add_star_systems_menu.sql b/scripts/add_star_systems_menu.sql new file mode 100644 index 0000000..e5b7c75 --- /dev/null +++ b/scripts/add_star_systems_menu.sql @@ -0,0 +1,54 @@ +-- 添加恒星系统管理菜单项 +-- 将其放在天体数据管理之前(sort_order=0) + +-- 首先调整天体数据管理的sort_order,从1改为2 +UPDATE menus SET sort_order = 2 WHERE id = 3 AND name = 'celestial_bodies'; + +-- 添加恒星系统管理菜单(sort_order=1,在天体数据管理之前) +INSERT INTO menus ( + parent_id, + name, + title, + icon, + path, + component, + sort_order, + is_active, + description +) VALUES ( + 2, -- parent_id: 数据管理 + 'star_systems', + '恒星系统管理', + 'StarOutlined', + '/admin/star-systems', + 'StarSystems', + 1, -- sort_order: 在天体数据管理(2)之前 + true, + '管理太阳系和系外恒星系统' +) ON CONFLICT DO NOTHING; + +-- 获取新插入的菜单ID并为管理员角色授权 +DO $$ +DECLARE + menu_id INT; + admin_role_id INT; +BEGIN + -- 获取刚插入的菜单ID + SELECT id INTO menu_id FROM menus WHERE name = 'star_systems'; + + -- 获取管理员角色ID(通常是1) + SELECT id INTO admin_role_id FROM roles WHERE name = 'admin' LIMIT 1; + + -- 为管理员角色授权 + IF menu_id IS NOT NULL AND admin_role_id IS NOT NULL THEN + INSERT INTO role_menus (role_id, menu_id) + VALUES (admin_role_id, menu_id) + ON CONFLICT DO NOTHING; + END IF; +END $$; + +-- 验证结果 +SELECT id, name, title, path, parent_id, sort_order +FROM menus +WHERE parent_id = 2 +ORDER BY sort_order, id; diff --git a/scripts/fetch_interstellar_data.py b/scripts/fetch_interstellar_data.py new file mode 100644 index 0000000..77661ba --- /dev/null +++ b/scripts/fetch_interstellar_data.py @@ -0,0 +1,177 @@ +""" +Fetch Interstellar Data (Nearby Stars & Exoplanets) +Phase 3: Interstellar Expansion + +This script fetches data from the NASA Exoplanet Archive using astroquery. +It retrieves the nearest stars (within 100pc) and their planetary system details. +The data is stored in the `static_data` table with category 'interstellar'. +""" + +import asyncio +import os +import sys +import math +from sqlalchemy import select, text, func +from sqlalchemy.dialects.postgresql import insert + +# Add backend directory to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.database import get_db +from app.models.db.static_data import StaticData + +# Try to import astroquery/astropy, handle if missing +try: + from astroquery.ipac.nexsci.nasa_exoplanet_archive import NasaExoplanetArchive + from astropy.coordinates import SkyCoord + from astropy import units as u +except ImportError: + print("❌ Error: astroquery or astropy not installed.") + print(" Please run: pip install astroquery astropy") + sys.exit(1) + +async def fetch_and_store_interstellar_data(): + print("🌌 Fetching Interstellar Data (Phase 3)...") + + # 1. Query NASA Exoplanet Archive + # We query the Planetary Systems (PS) table + # sy_dist: System Distance [pc] + # ra, dec: Coordinates [deg] + # sy_pnum: Number of Planets + # st_spectype: Spectral Type + # st_rad: Stellar Radius [Solar Radii] + # st_mass: Stellar Mass [Solar Mass] + # st_teff: Effective Temperature [K] + # pl_name: Planet Name + # pl_orbsmax: Semi-Major Axis [AU] + # pl_orbper: Orbital Period [days] + # pl_orbeccen: Eccentricity + # pl_rade: Planet Radius [Earth Radii] + + print(" Querying NASA Exoplanet Archive (this may take a while)...") + try: + # We fetch systems within 100 parsecs + table = NasaExoplanetArchive.query_criteria( + table="ps", + select="hostname, sy_dist, ra, dec, sy_pnum, st_spectype, st_rad, st_mass, st_teff, pl_name, pl_orbsmax, pl_orbper, pl_orbeccen, pl_rade, pl_eqt", + where="sy_dist < 50", # Limit to 50pc for initial Phase 3 to keep it fast and relevant + order="sy_dist" + ) + print(f" ✅ Fetched {len(table)} records.") + except Exception as e: + print(f" ❌ Query failed: {e}") + return + + # 2. Process Data + # We need to group planets by host star + systems = {} + + print(" Processing data...") + for row in table: + hostname = str(row['hostname']) + + # Helper function to safely get value from potential Quantity object + def get_val(obj): + if hasattr(obj, 'value'): + return obj.value + return obj + + if hostname not in systems: + # Coordinate conversion: Spherical (RA/Dec/Dist) -> Cartesian (X/Y/Z) + dist_pc = float(get_val(row['sy_dist'])) + ra_deg = float(get_val(row['ra'])) + dec_deg = float(get_val(row['dec'])) + + # Convert to Cartesian (X, Y, Z) in Parsecs + # Z is up (towards North Celestial Pole?) - Standard Astropy conversion + c = SkyCoord(ra=ra_deg*u.deg, dec=dec_deg*u.deg, distance=dist_pc*u.pc) + x = c.cartesian.x.value + y = c.cartesian.y.value + z = c.cartesian.z.value + + # Determine color based on Spectral Type (simplified) + spectype = str(row['st_spectype']) if row['st_spectype'] else 'G' + color = '#FFFFFF' # Default + if 'O' in spectype: color = '#9db4ff' + elif 'B' in spectype: color = '#aabfff' + elif 'A' in spectype: color = '#cad8ff' + elif 'F' in spectype: color = '#fbf8ff' + elif 'G' in spectype: color = '#fff4e8' + elif 'K' in spectype: color = '#ffddb4' + elif 'M' in spectype: color = '#ffbd6f' + + systems[hostname] = { + "category": "interstellar", + "name": hostname, + "name_zh": hostname, # Placeholder, maybe need translation map later + "data": { + "distance_pc": dist_pc, + "ra": ra_deg, + "dec": dec_deg, + "position": {"x": x, "y": y, "z": z}, + "spectral_type": spectype, + "radius_solar": float(get_val(row['st_rad'])) if get_val(row['st_rad']) is not None else 1.0, + "mass_solar": float(get_val(row['st_mass'])) if get_val(row['st_mass']) is not None else 1.0, + "temperature_k": float(get_val(row['st_teff'])) if get_val(row['st_teff']) is not None else 5700, + "planet_count": int(get_val(row['sy_pnum'])), + "color": color, + "planets": [] + } + } + + # Add planet info + planet = { + "name": str(row['pl_name']), + "semi_major_axis_au": float(get_val(row['pl_orbsmax'])) if get_val(row['pl_orbsmax']) is not None else 0.0, + "period_days": float(get_val(row['pl_orbper'])) if get_val(row['pl_orbper']) is not None else 0.0, + "eccentricity": float(get_val(row['pl_orbeccen'])) if get_val(row['pl_orbeccen']) is not None else 0.0, + "radius_earth": float(get_val(row['pl_rade'])) if get_val(row['pl_rade']) is not None else 1.0, + "temperature_k": float(get_val(row['pl_eqt'])) if get_val(row['pl_eqt']) is not None else None + } + systems[hostname]["data"]["planets"].append(planet) + + print(f" Processed {len(systems)} unique star systems.") + + # 3. Store in Database + print(" Storing in database...") + + # Helper to clean NaN values for JSON compatibility + def clean_nan(obj): + if isinstance(obj, float): + return None if math.isnan(obj) else obj + elif isinstance(obj, dict): + return {k: clean_nan(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [clean_nan(v) for v in obj] + return obj + + async for session in get_db(): + try: + count = 0 + for hostname, info in systems.items(): + # Clean data + cleaned_data = clean_nan(info["data"]) + + # Use UPSERT + stmt = insert(StaticData).values( + category=info["category"], + name=info["name"], + name_zh=info["name_zh"], + data=cleaned_data + ).on_conflict_do_update( + constraint="uq_category_name", + set_={"data": cleaned_data, "updated_at": func.now()} + ) + await session.execute(stmt) + count += 1 + + await session.commit() + print(f" ✅ Successfully stored {count} interstellar systems.") + except Exception as e: + await session.rollback() + print(f" ❌ Database error: {e}") + finally: + break + +if __name__ == "__main__": + asyncio.run(fetch_and_store_interstellar_data()) diff --git a/scripts/migrate_interstellar_data.py b/scripts/migrate_interstellar_data.py new file mode 100755 index 0000000..d21cd9b --- /dev/null +++ b/scripts/migrate_interstellar_data.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +迁移 static_data 中的 interstellar 数据到 star_systems 和 celestial_bodies 表 +包含自动中文名翻译功能 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import select, func, update +from sqlalchemy.dialects.postgresql import insert +from app.database import AsyncSessionLocal +from app.models.db.static_data import StaticData +from app.models.db.star_system import StarSystem +from app.models.db.celestial_body import CelestialBody + + +# 恒星名称中文翻译字典(常见恒星) +STAR_NAME_ZH = { + 'Proxima Cen': '比邻星', + "Barnard's star": '巴纳德星', + 'eps Eri': '天苑四', + 'Lalande 21185': '莱兰21185', + '61 Cyg A': '天鹅座61 A', + '61 Cyg B': '天鹅座61 B', + 'tau Cet': '天仓五', + 'Kapteyn': '开普敦星', + 'Lacaille 9352': '拉卡伊9352', + 'Ross 128': '罗斯128', + 'Wolf 359': '狼359', + 'Sirius': '天狼星', + 'Alpha Centauri': '南门二', + 'TRAPPIST-1': 'TRAPPIST-1', + 'Kepler-442': '开普勒-442', + 'Kepler-452': '开普勒-452', + 'Gliese 581': '格利泽581', + 'Gliese 667C': '格利泽667C', + 'HD 40307': 'HD 40307', +} + +# 常见恒星系后缀翻译 +SYSTEM_SUFFIX_ZH = { + 'System': '系统', + 'system': '系统', +} + + +def translate_star_name(english_name: str) -> str: + """ + 翻译恒星名称为中文 + 优先使用字典,否则保留英文名 + """ + # 直接匹配 + if english_name in STAR_NAME_ZH: + return STAR_NAME_ZH[english_name] + + # 移除常见后缀尝试匹配 + base_name = english_name.replace(' A', '').replace(' B', '').replace(' C', '').strip() + if base_name in STAR_NAME_ZH: + suffix = english_name.replace(base_name, '').strip() + return STAR_NAME_ZH[base_name] + suffix + + # Kepler/TRAPPIST 等编号星 + if english_name.startswith('Kepler-'): + return f'开普勒-{english_name.split("-")[1]}' + if english_name.startswith('TRAPPIST-'): + return f'TRAPPIST-{english_name.split("-")[1]}' + if english_name.startswith('Gliese '): + return f'格利泽{english_name.split(" ")[1]}' + if english_name.startswith('GJ '): + return f'GJ {english_name.split(" ")[1]}' + if english_name.startswith('HD '): + return f'HD {english_name.split(" ")[1]}' + if english_name.startswith('HIP '): + return f'HIP {english_name.split(" ")[1]}' + + # 默认返回英文名 + return english_name + + +def translate_system_name(english_name: str) -> str: + """翻译恒星系名称""" + if ' System' in english_name: + star_name = english_name.replace(' System', '').strip() + star_name_zh = translate_star_name(star_name) + return f'{star_name_zh}系统' + return translate_star_name(english_name) + + +def translate_planet_name(english_name: str) -> str: + """ + 翻译系外行星名称 + 格式:恒星名 + 行星字母 + """ + # 分离恒星名和行星字母 + parts = english_name.rsplit(' ', 1) + if len(parts) == 2: + star_name, planet_letter = parts + star_name_zh = translate_star_name(star_name) + return f'{star_name_zh} {planet_letter}' + return english_name + + +async def deduplicate_planets(planets: list) -> list: + """ + 去除重复的行星记录 + 保留字段最完整的记录 + """ + if not planets: + return [] + + planet_map = {} + for planet in planets: + name = planet.get('name', '') + if not name: + continue + + if name not in planet_map: + planet_map[name] = planet + else: + # 比较字段完整度 + existing = planet_map[name] + existing_fields = sum(1 for v in existing.values() if v is not None and v != '') + current_fields = sum(1 for v in planet.values() if v is not None and v != '') + + if current_fields > existing_fields: + planet_map[name] = planet + + return list(planet_map.values()) + + +async def migrate_star_systems(): + """迁移恒星系统数据""" + async with AsyncSessionLocal() as session: + print("=" * 60) + print("开始迁移系外恒星系数据...") + print("=" * 60) + + # 读取所有 interstellar 数据 + result = await session.execute( + select(StaticData) + .where(StaticData.category == 'interstellar') + .order_by(StaticData.name) + ) + interstellar_data = result.scalars().all() + + print(f"\n📊 共找到 {len(interstellar_data)} 个恒星系统") + + migrated_systems = 0 + migrated_planets = 0 + skipped_systems = 0 + + for star_data in interstellar_data: + try: + data = star_data.data + star_name = star_data.name + + # 翻译中文名 + star_name_zh = translate_star_name(star_name) + system_name = f"{star_name} System" + system_name_zh = translate_system_name(system_name) + + # 创建恒星系统记录 + system = StarSystem( + name=system_name, + name_zh=system_name_zh, + host_star_name=star_name, + distance_pc=data.get('distance_pc'), + distance_ly=data.get('distance_ly'), + ra=data.get('ra'), + dec=data.get('dec'), + position_x=data.get('position', {}).get('x') if 'position' in data else None, + position_y=data.get('position', {}).get('y') if 'position' in data else None, + position_z=data.get('position', {}).get('z') if 'position' in data else None, + spectral_type=data.get('spectral_type'), + radius_solar=data.get('radius_solar'), + mass_solar=data.get('mass_solar'), + temperature_k=data.get('temperature_k'), + magnitude=data.get('magnitude'), + color=data.get('color', '#FFFFFF'), + planet_count=0, # 将在迁移行星后更新 + description=f"距离地球 {data.get('distance_ly', 0):.2f} 光年的恒星系统。" + ) + + session.add(system) + await session.flush() # 获取 system.id + + print(f"\n✅ 恒星系: {system_name} ({system_name_zh})") + print(f" 距离: {data.get('distance_pc', 0):.2f} pc (~{data.get('distance_ly', 0):.2f} ly)") + + # 处理行星数据 + planets = data.get('planets', []) + if planets: + # 去重 + unique_planets = await deduplicate_planets(planets) + print(f" 行星: {len(planets)} 条记录 → {len(unique_planets)} 颗独立行星(去重 {len(planets) - len(unique_planets)} 条)") + + # 迁移行星 + for planet_data in unique_planets: + planet_name = planet_data.get('name', '') + if not planet_name: + continue + + planet_name_zh = translate_planet_name(planet_name) + + # 创建系外行星记录 + planet = CelestialBody( + id=f"exo-{system.id}-{planet_name.replace(' ', '-')}", # 生成唯一ID + name=planet_name, + name_zh=planet_name_zh, + type='planet', + system_id=system.id, + description=f"{system_name_zh}的系外行星。", + extra_data={ + 'semi_major_axis_au': planet_data.get('semi_major_axis_au'), + 'period_days': planet_data.get('period_days'), + 'eccentricity': planet_data.get('eccentricity'), + 'radius_earth': planet_data.get('radius_earth'), + 'mass_earth': planet_data.get('mass_earth'), + 'temperature_k': planet_data.get('temperature_k'), + } + ) + + session.add(planet) + migrated_planets += 1 + print(f" • {planet_name} ({planet_name_zh})") + + # 更新恒星系的行星数量 + system.planet_count = len(unique_planets) + + migrated_systems += 1 + + # 每100个系统提交一次 + if migrated_systems % 100 == 0: + await session.commit() + print(f"\n💾 已提交 {migrated_systems} 个恒星系统...") + + except Exception as e: + print(f"\n❌ 错误:迁移 {star_name} 失败 - {str(e)[:200]}") + skipped_systems += 1 + # 简单回滚,继续下一个 + try: + await session.rollback() + except: + pass + continue + + # 最终提交 + await session.commit() + + print("\n" + "=" * 60) + print("迁移完成!") + print("=" * 60) + print(f"✅ 成功迁移恒星系: {migrated_systems}") + print(f"✅ 成功迁移行星: {migrated_planets}") + print(f"⚠️ 跳过的恒星系: {skipped_systems}") + print(f"📊 平均每个恒星系: {migrated_planets / migrated_systems:.1f} 颗行星") + + +async def update_solar_system_count(): + """更新太阳系的天体数量""" + async with AsyncSessionLocal() as session: + result = await session.execute( + select(func.count(CelestialBody.id)) + .where(CelestialBody.system_id == 1) + ) + count = result.scalar() + + await session.execute( + update(StarSystem) + .where(StarSystem.id == 1) + .values(planet_count=count - 1) # 减去太阳本身 + ) + await session.commit() + + print(f"\n✅ 更新太阳系天体数量: {count} (不含太阳: {count - 1})") + + +async def verify_migration(): + """验证迁移结果""" + async with AsyncSessionLocal() as session: + print("\n" + "=" * 60) + print("验证迁移结果...") + print("=" * 60) + + # 统计恒星系 + result = await session.execute(select(func.count(StarSystem.id))) + system_count = result.scalar() + print(f"\n📊 恒星系统总数: {system_count}") + + # 统计各系统的行星数量 + result = await session.execute( + select(StarSystem.name, StarSystem.name_zh, StarSystem.planet_count) + .order_by(StarSystem.planet_count.desc()) + .limit(10) + ) + print("\n🏆 行星最多的恒星系(前10):") + for name, name_zh, count in result: + print(f" {name} ({name_zh}): {count} 颗行星") + + # 统计天体类型分布 + result = await session.execute( + select(CelestialBody.type, CelestialBody.system_id, func.count(CelestialBody.id)) + .group_by(CelestialBody.type, CelestialBody.system_id) + .order_by(CelestialBody.system_id, CelestialBody.type) + ) + print("\n📈 天体类型分布:") + for type_, system_id, count in result: + system_name = "太阳系" if system_id == 1 else f"系外恒星系" + print(f" {system_name} - {type_}: {count}") + + +async def main(): + """主函数""" + print("\n" + "=" * 60) + print("Cosmo 系外恒星系数据迁移工具") + print("=" * 60) + + try: + # 执行迁移 + await migrate_star_systems() + + # 更新太阳系统计 + await update_solar_system_count() + + # 验证结果 + await verify_migration() + + print("\n✅ 所有操作完成!") + + except Exception as e: + print(f"\n❌ 迁移失败: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/update_category_constraint.sql b/scripts/update_category_constraint.sql index c571f5f..99f5ce2 100644 --- a/scripts/update_category_constraint.sql +++ b/scripts/update_category_constraint.sql @@ -1,6 +1,17 @@ --- Remove the old constraint +-- Update check constraint for static_data table to include 'interstellar' +-- Run this manually via: python backend/scripts/run_sql.py backend/scripts/update_category_constraint.sql + ALTER TABLE static_data DROP CONSTRAINT IF EXISTS chk_category; --- Add the updated constraint -ALTER TABLE static_data ADD CONSTRAINT chk_category -CHECK (category IN ('constellation', 'galaxy', 'star', 'nebula', 'cluster', 'asteroid_belt', 'kuiper_belt')); +ALTER TABLE static_data +ADD CONSTRAINT chk_category +CHECK (category IN ( + 'constellation', + 'galaxy', + 'star', + 'nebula', + 'cluster', + 'asteroid_belt', + 'kuiper_belt', + 'interstellar' +)); \ No newline at end of file