初步实现了3阶段
parent
c10efe0588
commit
acc1b16d9e
|
|
@ -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 - 用户表
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})>"
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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个恒星系统")
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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'
|
||||
));
|
||||
Loading…
Reference in New Issue