初步实现了3阶段

main
mula.liu 2025-12-06 17:06:10 +08:00
parent c10efe0588
commit acc1b16d9e
16 changed files with 1395 additions and 20 deletions

View File

@ -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 - 用户表

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"<StarSystem(id={self.id}, name='{self.name}', planet_count={self.planet_count})>"

View File

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

View File

@ -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="笛卡尔坐标 Xpc")
position_y: Optional[float] = Field(None, description="笛卡尔坐标 Ypc")
position_z: Optional[float] = Field(None, description="笛卡尔坐标 Zpc")
# 恒星参数
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个恒星系统")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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