增加了定时任务

main
mula.liu 2025-12-26 09:21:15 +08:00
parent d8799eae1f
commit 488717ffa1
106 changed files with 4031 additions and 942 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -54,7 +54,12 @@
"Bash(timeout 5 PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend ./venv/bin/python:*)",
"Bash(PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend timeout 5 ./venv/bin/python:*)",
"Bash(find:*)",
"Bash(timeout 10 PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend ./venv/bin/python:*)"
"Bash(timeout 10 PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend ./venv/bin/python:*)",
"Bash(gunzip:*)",
"Bash(awk:*)",
"Bash(git log:*)",
"WebFetch(domain:ssd-api.jpl.nasa.gov)",
"Bash(PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend timeout 30 ./venv/bin/python:*)"
],
"deny": [],
"ask": []

90
PHASE5_PLAN.md 100644
View File

@ -0,0 +1,90 @@
# Phase 5: APP Connectivity & Social Features Plan
## 🎯 阶段目标
本阶段致力于打通移动端 APP 与后端平台的连接,增强系统的社会化属性(关注、频道),并引入天体事件自动发现机制。同时优化底层数据获取性能。
---
## 🛠 功能规划
### 1. 资源增强:天体图标 (Icons)
* **需求**: 为每个天体增加专属图标320x320 PNG/JPG用于 APP 列表页和详情页头部展示。
* **实现**:
* 利用现有的 `resources` 表。
* 确保 `resource_type` 支持 `'icon'` 枚举值。
* 后端 API `POST /celestial/resources/upload` 需支持上传到 `upload/icon/` 目录。
* 前端/APP 端在获取天体列表时,优先加载 icon 资源。
### 2. 天体事件系统 (Celestial Events)
* **需求**: 自动计算/拉取天体动态事件(如“火星冲日”、“小行星飞掠”)。
* **数据源**:
* **小天体 (Comets/Asteroids)**: NASA JPL SBDB Close-Approach Data API (`https://ssd-api.jpl.nasa.gov/cad.api`).
* **主要行星**: 基于 `skyfield``ephem` 库进行本地计算(冲日、合月等),或解析 Horizons 数据。
* **实现**:
* 新增 `celestial_events` 表。
* 新增定时任务脚本 `fetch_celestial_events.py` (注册到 Scheduled Jobs)。
* API: `GET /events` (支持按天体、时间范围筛选)。
### 3. 性能优化JPL Horizons Redis 缓存
* **需求**: 减少对 NASA 接口的实时请求,针对“同天体、同一日”的请求进行缓存。
* **策略**:
* **Key**: `nasa:horizons:{body_id}:{date_str}` (例如 `nasa:horizons:499:2025-12-12`).
* **Value**: 解析后的 Position 对象或原始 Raw Text。
* **TTL**: 7天或更久历史数据永不过期
* 在 `HorizonsService` 层拦截,先查 Redis无数据再请求 NASA 并写入 Redis。
### 4. 社交功能:关注与频道
* **关注 (Subscriptions/Follows)**:
* 用户可以关注感兴趣的天体如关注“旅行者1号”
* 新增 `user_follows` 表。
* API: 关注、取消关注、获取关注列表。
* **天体频道 (Body Channels)**:
* 每个天体拥有一个独立的讨论区Channel
* 只有关注了该天体的用户才能在频道内发言。
* **存储**: **Redis List** (类似于弹幕 `danmaku` 的设计),不持久化到 PostgreSQL。
* **Key**: `channel:messages:{body_id}`
* **TTL**: 消息保留最近 100-500 条或 7 天,过旧自动丢弃。
* API: 发送消息、获取频道消息流。
* **消息推送 (Notification)**:
* 当关注的天体发生 `celestial_events` 时,系统应生成通知(本阶段先实现数据层关联,推送推迟到 Phase 6 或 APP 端轮询)。
---
## 🗄 数据库变更 (Database Schema)
### 新增表结构
#### 1. `celestial_events` (天体事件表)
| Column | Type | Comment |
| :--- | :--- | :--- |
| id | SERIAL | PK |
| body_id | VARCHAR(50) | FK -> celestial_bodies.id |
| title | VARCHAR(200) | 事件标题 (e.g., "Asteroid 2024 XK Flyby") |
| event_type | VARCHAR(50) | 类型: 'approach', 'opposition', 'conjunction' |
| event_time | TIMESTAMP | 事件发生时间 (UTC) |
| description | TEXT | 事件描述 |
| details | JSONB | 技术参数 (距离、相对速度等) |
| source | VARCHAR(50) | 来源 ('nasa_sbdb', 'calculated') |
#### 2. `user_follows` (用户关注表)
| Column | Type | Comment |
| :--- | :--- | :--- |
| user_id | INTEGER | FK -> users.id |
| body_id | VARCHAR(50) | FK -> celestial_bodies.id |
| created_at | TIMESTAMP | 关注时间 |
| **Constraint** | PK | (user_id, body_id) 联合主键 |
---
## 🗓 实施步骤 (Execution Steps)
1. **数据库迁移**: 执行 SQL 脚本创建新表(`celestial_events`, `user_follows`)。
2. **后端开发**:
* **Horizons缓存**: 修改 `HorizonsService` 增加 Redis 缓存层。
* **关注功能**: 实现 `/social/follow` API。
* **频道消息**: 实现 `/social/channel` API使用 Redis 存储。
* **天体事件**: 实现 NASA SBDB 数据拉取逻辑与 API。
3. **验证**:
* 测试关注/取关。
* 测试频道消息发送与接收(验证 Redis 存储)。
* 测试 Horizons 缓存生效。

View File

@ -13,6 +13,7 @@ from app.models.celestial import CelestialDataResponse
from app.services.horizons import horizons_service
from app.services.cache import cache_service
from app.services.redis_cache import redis_cache, make_cache_key, get_ttl_seconds
from app.services.system_settings_service import system_settings_service
from app.services.db_service import (
celestial_body_service,
position_service,
@ -204,7 +205,24 @@ async def get_celestial_positions(
await redis_cache.set(redis_key, bodies_data, get_ttl_seconds("current_positions"))
return CelestialDataResponse(bodies=bodies_data)
else:
logger.info(f"Incomplete recent data ({len(bodies_data)}/{len(all_bodies)} bodies), falling back to Horizons")
logger.info(f"Incomplete recent data ({len(bodies_data)}/{len(all_bodies)} bodies)")
# Check if auto download is enabled before falling back to Horizons
auto_download_enabled = await system_settings_service.get_setting_value("auto_download_positions", db)
if auto_download_enabled is None:
auto_download_enabled = False
if not auto_download_enabled:
logger.warning("Auto download is disabled. Returning incomplete data from database.")
# Return what we have, even if incomplete
if bodies_data:
return CelestialDataResponse(bodies=bodies_data)
else:
raise HTTPException(
status_code=503,
detail="Position data not available. Auto download is disabled. Please use the admin panel to download data manually."
)
else:
logger.info("Auto download enabled, falling back to Horizons")
# Fall through to query Horizons below
# Check Redis cache first (persistent across restarts)
@ -322,8 +340,27 @@ async def get_celestial_positions(
else:
logger.info("Incomplete historical data in positions table, falling back to Horizons")
# Check if auto download is enabled
auto_download_enabled = await system_settings_service.get_setting_value("auto_download_positions", db)
if auto_download_enabled is None:
auto_download_enabled = False # Default to False if setting not found
if not auto_download_enabled:
logger.warning("Auto download is disabled. Returning empty data for missing positions.")
# Return whatever data we have from positions table, even if incomplete
if start_dt and end_dt and all_bodies_positions:
logger.info(f"Returning incomplete data from positions table ({len(all_bodies_positions)} bodies)")
return CelestialDataResponse(bodies=all_bodies_positions)
else:
# Return empty or cached data
logger.info("No cached data available and auto download is disabled")
raise HTTPException(
status_code=503,
detail="Position data not available. Auto download is disabled. Please use the admin panel to download data manually."
)
# Query Horizons (no cache available) - fetch from database + Horizons API
logger.info(f"Fetching celestial data from Horizons: start={start_dt}, end={end_dt}, step={step}")
logger.info(f"Auto download enabled. Fetching celestial data from Horizons: start={start_dt}, end={end_dt}, step={step}")
# Get all bodies from database
all_bodies = await celestial_body_service.get_all_bodies(db)

View File

@ -0,0 +1,7 @@
"""
API dependencies - re-exports for convenience
"""
from app.services.auth_deps import get_current_user, get_current_active_user
from app.models.db.user import User
__all__ = ["get_current_user", "get_current_active_user", "User"]

View File

@ -0,0 +1,74 @@
"""
Celestial Events API routes
"""
import logging
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.schemas.social import CelestialEventCreate, CelestialEventResponse
from app.services.event_service import event_service
from app.api.deps import get_current_active_user # Assuming events can be public or require login
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/events", tags=["events"])
@router.post("", response_model=CelestialEventResponse, status_code=status.HTTP_201_CREATED)
async def create_celestial_event(
event_data: CelestialEventCreate,
current_user: dict = Depends(get_current_active_user), # Admin users for creating events?
db: AsyncSession = Depends(get_db)
):
"""Create a new celestial event (admin/system only)"""
# Further authorization checks could be added here (e.g., if current_user has 'admin' role)
try:
event = await event_service.create_event(event_data, db)
return event
except Exception as e:
logger.error(f"Error creating event: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("", response_model=List[CelestialEventResponse])
async def get_celestial_events(
db: AsyncSession = Depends(get_db),
body_id: Optional[str] = Query(None, description="Filter events by celestial body ID"),
start_time: Optional[datetime] = Query(None, description="Filter events starting from this time (UTC)"),
end_time: Optional[datetime] = Query(None, description="Filter events ending by this time (UTC)"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0)
):
"""Get a list of celestial events."""
try:
events = await event_service.get_events(db, body_id, start_time, end_time, limit, offset)
return events
except Exception as e:
logger.error(f"Error retrieving events: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{event_id}", response_model=CelestialEventResponse)
async def get_celestial_event(
event_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get a specific celestial event by ID."""
event = await event_service.get_event(event_id, db)
if not event:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Celestial event not found")
return event
@router.delete("/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_celestial_event(
event_id: int,
current_user: dict = Depends(get_current_active_user), # Admin users for deleting events?
db: AsyncSession = Depends(get_db)
):
"""Delete a celestial event by ID (admin/system only)"""
# Further authorization checks could be added here
deleted = await event_service.delete_event(event_id, db)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Celestial event not found")
return None

View File

@ -0,0 +1,109 @@
"""
Social Features API routes - user follows and channel messages
"""
import logging
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.db.user import User
from app.models.schemas.social import UserFollowResponse, ChannelMessageCreate, ChannelMessageResponse
from app.services.social_service import social_service
from app.api.deps import get_current_active_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/social", tags=["social"])
# --- User Follows ---
@router.post("/follow/{body_id}", response_model=UserFollowResponse)
async def follow_body(
body_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Allow current user to follow a celestial body.
User will then receive events and can post in the body's channel.
"""
try:
follow = await social_service.follow_body(current_user.id, body_id, db)
return follow
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"Error following body {body_id} by user {current_user.id}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to follow body")
@router.delete("/follow/{body_id}", status_code=status.HTTP_204_NO_CONTENT)
async def unfollow_body(
body_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""Allow current user to unfollow a celestial body."""
try:
unfollowed = await social_service.unfollow_body(current_user.id, body_id, db)
if not unfollowed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not following this body")
return None
except Exception as e:
logger.error(f"Error unfollowing body {body_id} by user {current_user.id}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to unfollow body")
@router.get("/follows", response_model=List[UserFollowResponse])
async def get_user_follows(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""Get all celestial bodies currently followed by the user."""
follows = await social_service.get_user_follows_with_time(current_user.id, db)
return follows
@router.get("/follows/check/{body_id}")
async def check_if_following(
body_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""Check if the current user is following a specific celestial body."""
is_following = await social_service.get_follow(current_user.id, body_id, db)
return {"is_following": is_following is not None}
# --- Channel Messages ---
@router.post("/channel/{body_id}/message", response_model=ChannelMessageResponse)
async def post_channel_message(
body_id: str,
message: ChannelMessageCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Post a message to a specific celestial body's channel.
Only users following the body can post.
"""
try:
channel_message = await social_service.post_channel_message(current_user.id, body_id, message.content, db)
return channel_message
except ValueError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) # 403 Forbidden for not following
except Exception as e:
logger.error(f"Error posting message to channel {body_id} by user {current_user.id}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to post message")
@router.get("/channel/{body_id}/messages", response_model=List[ChannelMessageResponse])
async def get_channel_messages(
body_id: str,
limit: int = Query(50, ge=1, le=500),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""Get recent messages from a celestial body's channel."""
try:
messages = await social_service.get_channel_messages(body_id, db, limit)
return messages
except Exception as e:
logger.error(f"Error retrieving messages from channel {body_id}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve messages")

View File

@ -13,7 +13,7 @@ from app.services.system_settings_service import system_settings_service
from app.services.redis_cache import redis_cache
from app.services.cache import cache_service
from app.database import get_db
from app.models.db import Position
from app.models.db import Position, CelestialBody, User
logger = logging.getLogger(__name__)
@ -342,3 +342,52 @@ async def get_data_cutoff_date(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve data cutoff date: {str(e)}"
)
@router.get("/statistics")
async def get_dashboard_statistics(
db: AsyncSession = Depends(get_db)
):
"""
Get unified dashboard statistics
Returns:
- total_bodies: Total number of celestial bodies
- total_probes: Total number of probes
- total_users: Total number of registered users
"""
try:
# Count total celestial bodies
total_bodies_result = await db.execute(
select(func.count(CelestialBody.id)).where(CelestialBody.is_active == True)
)
total_bodies = total_bodies_result.scalar_one()
# Count probes
total_probes_result = await db.execute(
select(func.count(CelestialBody.id)).where(
CelestialBody.type == 'probe',
CelestialBody.is_active == True
)
)
total_probes = total_probes_result.scalar_one()
# Count users
total_users_result = await db.execute(
select(func.count(User.id))
)
total_users = total_users_result.scalar_one()
return {
"total_bodies": total_bodies,
"total_probes": total_probes,
"total_users": total_users
}
except Exception as e:
logger.error(f"Error retrieving dashboard statistics: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve statistics: {str(e)}"
)

View File

@ -122,19 +122,6 @@ async def reset_user_password(
"default_password": default_password
}
@router.get("/count", response_model=dict)
async def get_user_count(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) # All authenticated users can access
):
"""
Get the total count of registered users.
Available to all authenticated users.
"""
result = await db.execute(select(func.count(User.id)))
total_users = result.scalar_one()
return {"total_users": total_users}
@router.get("/me")
async def get_current_user_profile(

View File

@ -5,14 +5,18 @@ All registered tasks for scheduled execution
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.dialects.postgresql import insert
from app.jobs.registry import task_registry
from app.models.db.celestial_body import CelestialBody
from app.models.db.position import Position
from app.models.db.celestial_event import CelestialEvent
from app.services.horizons import HorizonsService
from app.services.nasa_sbdb_service import nasa_sbdb_service
from app.services.event_service import event_service
from app.services.planetary_events_service import planetary_events_service
logger = logging.getLogger(__name__)
@ -64,9 +68,10 @@ async def sync_solar_system_positions(
Returns:
Summary of sync operation
"""
# Parse parameters with type conversion (params come from JSON, may be strings)
body_ids = params.get("body_ids")
days = params.get("days", 7)
source = params.get("source", "nasa_horizons_cron")
days = int(params.get("days", 7))
source = str(params.get("source", "nasa_horizons_cron"))
logger.info(f"Starting solar system position sync: days={days}, source={source}")
@ -188,50 +193,437 @@ async def sync_solar_system_positions(
@task_registry.register(
name="sync_celestial_events",
description="同步天体事件数据(预留功能,暂未实现)",
name="fetch_close_approach_events",
description="从NASA SBDB获取小行星/彗星近距离飞掠事件,并保存到数据库",
category="data_sync",
parameters=[
{
"name": "event_types",
"name": "body_ids",
"type": "array",
"description": "事件类型列表,如['eclipse', 'conjunction', 'opposition']",
"description": "要查询的天体ID列表例如['399', '499']表示地球和火星。如果不指定,默认只查询地球(399)",
"required": False,
"default": None
},
{
"name": "days_ahead",
"type": "integer",
"description": "向未来查询的天数",
"description": "向未来查询的天数例如30表示查询未来30天内的事件",
"required": False,
"default": 30
},
{
"name": "dist_max",
"type": "string",
"description": "最大距离AU例如'30'表示30天文单位内的飞掠",
"required": False,
"default": "30"
},
{
"name": "limit",
"type": "integer",
"description": "每个天体最大返回事件数量",
"required": False,
"default": 100
},
{
"name": "clean_old_events",
"type": "boolean",
"description": "是否清理已过期的旧事件",
"required": False,
"default": True
}
]
)
async def sync_celestial_events(
async def fetch_close_approach_events(
db: AsyncSession,
logger: logging.Logger,
params: Dict[str, Any]
) -> Dict[str, Any]:
"""
Sync celestial events (PLACEHOLDER - NOT IMPLEMENTED YET)
Fetch close approach events from NASA SBDB and save to database
This is a reserved task for future implementation.
It will sync astronomical events like eclipses, conjunctions, oppositions, etc.
This task queries the NASA Small-Body Database (SBDB) for upcoming
close approach events (asteroid/comet flybys) and stores them in
the celestial_events table.
Note: Uses tomorrow's date as the query start date to avoid fetching
events that have already occurred today.
Args:
db: Database session
logger: Logger instance
params: Task parameters
- event_types: Types of events to sync
- days_ahead: Number of days ahead to query
- body_ids: List of body IDs to query (default: ['399'] for Earth)
- days_ahead: Number of days to query ahead from tomorrow (default: 30)
- dist_max: Maximum approach distance in AU (default: '30')
- limit: Maximum number of events per body (default: 100)
- clean_old_events: Clean old events before inserting (default: True)
Returns:
Summary of sync operation
Summary of fetch operation
"""
logger.warning("sync_celestial_events is not implemented yet")
# Parse parameters with type conversion (params come from JSON, may be strings)
body_ids = params.get("body_ids") or ["399"] # Default to Earth
days_ahead = int(params.get("days_ahead", 30))
dist_max = str(params.get("dist_max", "30")) # Keep as string for API
limit = int(params.get("limit", 100))
clean_old_events = bool(params.get("clean_old_events", True))
logger.info(f"Fetching close approach events: body_ids={body_ids}, days={days_ahead}, dist_max={dist_max}AU")
# Calculate date range - use tomorrow as start date to avoid past events
tomorrow = datetime.utcnow() + timedelta(days=1)
date_min = tomorrow.strftime("%Y-%m-%d")
date_max = (tomorrow + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
# Statistics
total_events_fetched = 0
total_events_saved = 0
total_events_failed = 0
body_results = []
# Process each body
for body_id in body_ids:
try:
# Query celestial_bodies table to find the target body
body_result = await db.execute(
select(CelestialBody).where(CelestialBody.id == body_id)
)
target_body = body_result.scalar_one_or_none()
if not target_body:
logger.warning(f"Body '{body_id}' not found in celestial_bodies table, skipping")
body_results.append({
"body_id": body_id,
"success": False,
"error": "Body not found in database"
})
continue
target_body_id = target_body.id
approach_body_name = target_body.name
# Use short_name from database if available (for NASA SBDB API)
# NASA SBDB API uses abbreviated names for planets (e.g., Juptr for Jupiter)
api_body_name = target_body.short_name if target_body.short_name else approach_body_name
logger.info(f"Processing events for: {target_body.name} (ID: {target_body_id}, API name: {api_body_name})")
# Clean old events if requested
if clean_old_events:
try:
cutoff_date = datetime.utcnow()
deleted_count = await event_service.delete_events_for_body_before(
body_id=target_body_id,
before_time=cutoff_date,
db=db
)
logger.info(f"Cleaned {deleted_count} old events for {target_body.name}")
except Exception as e:
logger.warning(f"Failed to clean old events for {target_body.name}: {e}")
# Fetch events from NASA SBDB
sbdb_events = await nasa_sbdb_service.get_close_approaches(
date_min=date_min,
date_max=date_max,
dist_max=dist_max,
body=api_body_name, # Use mapped API name
limit=limit,
fullname=True
)
logger.info(f"Retrieved {len(sbdb_events)} events from NASA SBDB for {target_body.name}")
total_events_fetched += len(sbdb_events)
if not sbdb_events:
body_results.append({
"body_id": target_body_id,
"body_name": target_body.name,
"events_saved": 0,
"message": "No events found"
})
continue
# Parse and save events
saved_count = 0
failed_count = 0
for sbdb_event in sbdb_events:
try:
# Parse SBDB event to CelestialEvent format
parsed_event = nasa_sbdb_service.parse_event_to_celestial_event(
sbdb_event,
approach_body=approach_body_name
)
if not parsed_event:
logger.warning(f"Failed to parse SBDB event: {sbdb_event.get('des', 'Unknown')}")
failed_count += 1
continue
# Create event data
event_data = {
"body_id": target_body_id,
"title": parsed_event["title"],
"event_type": parsed_event["event_type"],
"event_time": parsed_event["event_time"],
"description": parsed_event["description"],
"details": parsed_event["details"],
"source": parsed_event["source"]
}
event = CelestialEvent(**event_data)
db.add(event)
await db.flush()
saved_count += 1
logger.debug(f"Saved event: {event.title}")
except Exception as e:
logger.error(f"Failed to save event {sbdb_event.get('des', 'Unknown')}: {e}")
failed_count += 1
# Commit events for this body
await db.commit()
total_events_saved += saved_count
total_events_failed += failed_count
body_results.append({
"body_id": target_body_id,
"body_name": target_body.name,
"events_fetched": len(sbdb_events),
"events_saved": saved_count,
"events_failed": failed_count
})
logger.info(f"Saved {saved_count}/{len(sbdb_events)} events for {target_body.name}")
except Exception as e:
logger.error(f"Error processing body {body_id}: {e}")
body_results.append({
"body_id": body_id,
"success": False,
"error": str(e)
})
# Summary
result = {
"success": True,
"total_bodies_processed": len(body_ids),
"total_events_fetched": total_events_fetched,
"total_events_saved": total_events_saved,
"total_events_failed": total_events_failed,
"date_range": f"{date_min} to {date_max}",
"dist_max_au": dist_max,
"body_results": body_results
}
logger.info(f"Task completed: {total_events_saved} events saved for {len(body_ids)} bodies")
return result
@task_registry.register(
name="calculate_planetary_events",
description="计算太阳系主要天体的合、冲等事件使用Skyfield进行天文计算",
category="data_sync",
parameters=[
{
"name": "body_ids",
"type": "array",
"description": "要计算事件的天体ID列表例如['199', '299', '499']。如果不指定,则计算所有主要行星(水星到海王星)",
"required": False,
"default": None
},
{
"name": "days_ahead",
"type": "integer",
"description": "向未来计算的天数",
"required": False,
"default": 365
},
{
"name": "calculate_close_approaches",
"type": "boolean",
"description": "是否同时计算行星之间的近距离接近事件",
"required": False,
"default": False
},
{
"name": "threshold_degrees",
"type": "number",
"description": "近距离接近的角度阈值仅当calculate_close_approaches为true时有效",
"required": False,
"default": 5.0
},
{
"name": "clean_old_events",
"type": "boolean",
"description": "是否清理已过期的旧事件",
"required": False,
"default": True
}
]
)
async def calculate_planetary_events(
db: AsyncSession,
logger: logging.Logger,
params: Dict[str, Any]
) -> Dict[str, Any]:
"""
Calculate planetary events (conjunctions, oppositions) using Skyfield
This task uses the Skyfield library to calculate astronomical events
for major solar system bodies, including conjunctions () and oppositions ().
Args:
db: Database session
logger: Logger instance
params: Task parameters
- body_ids: List of body IDs to calculate (default: all major planets)
- days_ahead: Number of days to calculate ahead (default: 365)
- calculate_close_approaches: Also calculate planet-planet close approaches (default: False)
- threshold_degrees: Angle threshold for close approaches (default: 5.0)
- clean_old_events: Clean old events before calculating (default: True)
Returns:
Summary of calculation operation
"""
# Parse parameters with type conversion (params come from JSON, may be strings)
body_ids = params.get("body_ids")
days_ahead = int(params.get("days_ahead", 365))
calculate_close_approaches = bool(params.get("calculate_close_approaches", False))
threshold_degrees = float(params.get("threshold_degrees", 5.0))
clean_old_events = bool(params.get("clean_old_events", True))
logger.info(f"Starting planetary event calculation: days_ahead={days_ahead}, close_approaches={calculate_close_approaches}")
# Statistics
total_events_calculated = 0
total_events_saved = 0
total_events_failed = 0
try:
# Calculate oppositions and conjunctions
logger.info("Calculating oppositions and conjunctions...")
events = planetary_events_service.calculate_oppositions_conjunctions(
body_ids=body_ids,
days_ahead=days_ahead
)
logger.info(f"Calculated {len(events)} opposition/conjunction events")
total_events_calculated += len(events)
# Optionally calculate close approaches between planet pairs
if calculate_close_approaches:
logger.info("Calculating planetary close approaches...")
# Define interesting planet pairs
planet_pairs = [
('199', '299'), # Mercury - Venus
('299', '499'), # Venus - Mars
('499', '599'), # Mars - Jupiter
('599', '699'), # Jupiter - Saturn
]
close_approach_events = planetary_events_service.calculate_planetary_distances(
body_pairs=planet_pairs,
days_ahead=days_ahead,
threshold_degrees=threshold_degrees
)
logger.info(f"Calculated {len(close_approach_events)} close approach events")
events.extend(close_approach_events)
total_events_calculated += len(close_approach_events)
# Save events to database
logger.info(f"Saving {len(events)} events to database...")
for event_data in events:
try:
# Check if body exists in database
body_result = await db.execute(
select(CelestialBody).where(CelestialBody.id == event_data['body_id'])
)
body = body_result.scalar_one_or_none()
if not body:
logger.warning(f"Body {event_data['body_id']} not found in database, skipping event")
total_events_failed += 1
continue
# Clean old events for this body if requested (only once per body)
if clean_old_events:
cutoff_date = datetime.utcnow()
deleted_count = await event_service.delete_events_for_body_before(
body_id=event_data['body_id'],
before_time=cutoff_date,
db=db
)
if deleted_count > 0:
logger.debug(f"Cleaned {deleted_count} old events for {body.name}")
# Only clean once per body
clean_old_events = False
# Check if event already exists (to avoid duplicates)
# Truncate event_time to minute precision for comparison
event_time_minute = event_data['event_time'].replace(second=0, microsecond=0)
existing_event = await db.execute(
select(CelestialEvent).where(
CelestialEvent.body_id == event_data['body_id'],
CelestialEvent.event_type == event_data['event_type'],
func.date_trunc('minute', CelestialEvent.event_time) == event_time_minute
)
)
existing = existing_event.scalar_one_or_none()
if existing:
logger.debug(f"Event already exists, skipping: {event_data['title']}")
continue
# Create and save event
event = CelestialEvent(
body_id=event_data['body_id'],
title=event_data['title'],
event_type=event_data['event_type'],
event_time=event_data['event_time'],
description=event_data['description'],
details=event_data['details'],
source=event_data['source']
)
db.add(event)
await db.flush()
total_events_saved += 1
logger.debug(f"Saved event: {event.title}")
except Exception as e:
logger.error(f"Failed to save event {event_data.get('title', 'Unknown')}: {e}")
total_events_failed += 1
# Commit all events
await db.commit()
result = {
"success": True,
"total_events_calculated": total_events_calculated,
"total_events_saved": total_events_saved,
"total_events_failed": total_events_failed,
"calculation_period_days": days_ahead,
"close_approaches_enabled": calculate_close_approaches,
}
logger.info(f"Task completed: {total_events_saved} events saved, {total_events_failed} failed")
return result
except Exception as e:
logger.error(f"Error in planetary event calculation: {e}")
await db.rollback()
return {
"success": False,
"message": "This task is reserved for future implementation",
"events_synced": 0
"error": str(e),
"total_events_calculated": total_events_calculated,
"total_events_saved": total_events_saved,
"total_events_failed": total_events_failed
}

View File

@ -14,6 +14,7 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
@ -31,6 +32,8 @@ 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.api.scheduled_job import router as scheduled_job_router
from app.api.social import router as social_router # Import social_router
from app.api.event import router as event_router # Import event_router
from app.services.redis_cache import redis_cache
from app.services.cache_preheat import preheat_all_caches
from app.services.scheduler_service import scheduler_service
@ -125,6 +128,10 @@ app.add_middleware(
allow_headers=["*"],
)
# Add GZip compression for responses > 1KB
# This significantly reduces the size of orbit data (~3MB -> ~300KB)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Include routers
app.include_router(auth_router, prefix=settings.api_prefix)
app.include_router(user_router, prefix=settings.api_prefix)
@ -143,7 +150,9 @@ app.include_router(celestial_static_router, prefix=settings.api_prefix)
app.include_router(cache_router, prefix=settings.api_prefix)
app.include_router(nasa_download_router, prefix=settings.api_prefix)
app.include_router(task_router, prefix=settings.api_prefix)
app.include_router(scheduled_job_router, prefix=settings.api_prefix) # Added scheduled_job_router
app.include_router(scheduled_job_router, prefix=settings.api_prefix)
app.include_router(social_router, prefix=settings.api_prefix)
app.include_router(event_router, prefix=settings.api_prefix) # Added event_router
# Mount static files for uploaded resources
upload_dir = Path(__file__).parent.parent / "upload"

View File

@ -13,6 +13,8 @@ from .role import Role
from .menu import Menu, RoleMenu
from .system_settings import SystemSettings
from .task import Task
from .user_follow import UserFollow
from .celestial_event import CelestialEvent
__all__ = [
"CelestialBody",
@ -29,4 +31,6 @@ __all__ = [
"SystemSettings",
"user_roles",
"Task",
"UserFollow",
"CelestialEvent",
]

View File

@ -16,6 +16,7 @@ class CelestialBody(Base):
id = Column(String(50), primary_key=True, comment="JPL Horizons ID or custom ID")
name = Column(String(200), nullable=False, comment="English name")
name_zh = Column(String(200), nullable=True, comment="Chinese name")
short_name = Column(String(50), nullable=True, comment="NASA SBDB API short name (e.g., Juptr for Jupiter)")
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")
@ -34,6 +35,7 @@ class CelestialBody(Base):
"Resource", back_populates="body", cascade="all, delete-orphan"
)
# Constraints
__table_args__ = (
CheckConstraint(

View File

@ -0,0 +1,29 @@
"""
Celestial Event ORM model
"""
from sqlalchemy import Column, String, Integer, TIMESTAMP, Text, JSON, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.database import Base
class CelestialEvent(Base):
"""Celestial event model (e.g., close approaches, oppositions)"""
__tablename__ = "celestial_events"
id = Column(Integer, primary_key=True, autoincrement=True)
body_id = Column(String(50), ForeignKey("celestial_bodies.id", ondelete="CASCADE"), nullable=False)
title = Column(String(200), nullable=False)
event_type = Column(String(50), nullable=False) # 'approach', 'opposition', 'conjunction'
event_time = Column(TIMESTAMP, nullable=False)
description = Column(Text, nullable=True)
details = Column(JSON, nullable=True) # JSONB for PostgreSQL, JSON for SQLite/other
source = Column(String(50), default='nasa_sbdb') # 'nasa_sbdb', 'calculated'
created_at = Column(TIMESTAMP, server_default=func.now())
# Relationship to celestial body
body = relationship("CelestialBody", foreign_keys=[body_id])
def __repr__(self):
return f"<CelestialEvent(id={self.id}, title='{self.title}', body_id='{self.body_id}')>"

View File

@ -34,6 +34,7 @@ class User(Base):
# Relationships
roles = relationship("Role", secondary=user_roles, back_populates="users")
follows = relationship("UserFollow", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}')>"

View File

@ -0,0 +1,24 @@
"""
User Follows ORM model
"""
from sqlalchemy import Column, String, Integer, TIMESTAMP, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.database import Base
class UserFollow(Base):
"""User follows celestial body model"""
__tablename__ = "user_follows"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
body_id = Column(String(50), ForeignKey("celestial_bodies.id", ondelete="CASCADE"), primary_key=True)
created_at = Column(TIMESTAMP, server_default=func.now())
# Relationships
user = relationship("User", back_populates="follows")
# Note: No back_populates to CelestialBody as we don't need reverse lookup
def __repr__(self):
return f"<UserFollow(user_id={self.user_id}, body_id='{self.body_id}')>"

View File

@ -0,0 +1,78 @@
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, Field
# --- Simple Body Info Schema ---
class BodyInfo(BaseModel):
id: str
name: str
name_zh: Optional[str] = None
class Config:
orm_mode = True
# --- User Follows Schemas ---
class UserFollowBase(BaseModel):
body_id: str
class UserFollowCreate(UserFollowBase):
pass
class UserFollowResponse(UserFollowBase):
user_id: int
created_at: datetime
# Extended fields with body details
id: str
name: str
name_zh: Optional[str] = None
type: str
is_active: bool
class Config:
orm_mode = True
# --- Channel Message Schemas ---
class ChannelMessageBase(BaseModel):
content: str = Field(..., max_length=500, description="Message content")
class ChannelMessageCreate(ChannelMessageBase):
pass
class ChannelMessageResponse(ChannelMessageBase):
user_id: int
username: str
body_id: str
created_at: datetime
class Config:
# Allow ORM mode for compatibility if we ever fetch from DB,
# though these are primarily Redis-based
pass
# --- Celestial Event Schemas ---
class CelestialEventBase(BaseModel):
body_id: str
title: str
event_type: str
event_time: datetime
description: Optional[str] = None
details: Optional[dict] = None
source: Optional[str] = None
class CelestialEventCreate(CelestialEventBase):
pass
class CelestialEventResponse(CelestialEventBase):
id: int
created_at: datetime
body: Optional[BodyInfo] = None
class Config:
orm_mode = True

View File

@ -0,0 +1,74 @@
"""
Event Service - Manages celestial events
"""
import logging
from typing import List, Optional
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func, desc
from sqlalchemy.orm import selectinload
from app.models.db.celestial_event import CelestialEvent
from app.models.schemas.social import CelestialEventCreate, CelestialEventResponse
from app.models.db.celestial_body import CelestialBody
logger = logging.getLogger(__name__)
class EventService:
async def create_event(self, event_data: CelestialEventCreate, db: AsyncSession) -> CelestialEvent:
"""Create a new celestial event"""
event = CelestialEvent(**event_data.dict())
db.add(event)
await db.commit()
await db.refresh(event)
logger.info(f"Created celestial event: {event.title} for {event.body_id}")
return event
async def get_event(self, event_id: int, db: AsyncSession) -> Optional[CelestialEvent]:
"""Get a specific celestial event by ID"""
result = await db.execute(select(CelestialEvent).where(CelestialEvent.id == event_id))
return result.scalar_one_or_none()
async def get_events(
self,
db: AsyncSession,
body_id: Optional[str] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
limit: int = 100,
offset: int = 0
) -> List[CelestialEvent]:
"""Get a list of celestial events, with optional filters"""
query = select(CelestialEvent).options(selectinload(CelestialEvent.body))
if body_id:
query = query.where(CelestialEvent.body_id == body_id)
if start_time:
query = query.where(CelestialEvent.event_time >= start_time)
if end_time:
query = query.where(CelestialEvent.event_time <= end_time)
query = query.order_by(CelestialEvent.event_time).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def delete_event(self, event_id: int, db: AsyncSession) -> bool:
"""Delete a celestial event by ID"""
event = await self.get_event(event_id, db)
if event:
await db.delete(event)
await db.commit()
logger.info(f"Deleted celestial event: {event.title} (ID: {event.id})")
return True
return False
async def delete_events_for_body_before(self, body_id: str, before_time: datetime, db: AsyncSession) -> int:
"""Delete old events for a specific body before a given time"""
result = await db.execute(
delete(CelestialEvent).where(
CelestialEvent.body_id == body_id,
CelestialEvent.event_time < before_time
)
)
await db.commit()
return result.rowcount
event_service = EventService()

View File

@ -7,10 +7,12 @@ import logging
import re
import httpx
import os
from sqlalchemy.ext.asyncio import AsyncSession # Added this import
import json
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.celestial import Position, CelestialBody
from app.config import settings
from app.services.redis_cache import redis_cache
logger = logging.getLogger(__name__)
@ -84,27 +86,39 @@ class HorizonsService:
Returns:
List of Position objects
"""
try:
# Set default times
# Set default times and format for cache key
if start_time is None:
start_time = datetime.utcnow()
if end_time is None:
end_time = start_time
# Format time for Horizons
# NASA Horizons accepts: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'
# When querying a single point (same start/end date), we need STOP > START
# So we add 1 second and use precise time format
start_str_cache = start_time.strftime('%Y-%m-%d')
end_str_cache = end_time.strftime('%Y-%m-%d')
# 1. Try to fetch from Redis cache
cache_key = f"nasa:horizons:positions:{body_id}:{start_str_cache}:{end_str_cache}:{step}"
cached_data = await redis_cache.get(cache_key)
if cached_data:
logger.info(f"Cache HIT for {body_id} positions ({start_str_cache}-{end_str_cache})")
# Deserialize cached JSON data back to Position objects
positions_data = json.loads(cached_data)
positions = []
for item in positions_data:
# Ensure 'time' is converted back to datetime object
item['time'] = datetime.fromisoformat(item['time'])
positions.append(Position(**item))
return positions
logger.info(f"Cache MISS for {body_id} positions ({start_str_cache}-{end_str_cache}). Fetching from NASA.")
try:
# Format time for Horizons API
if start_time.date() == end_time.date():
# Single day query - use the date at 00:00 and next second
start_str = start_time.strftime('%Y-%m-%d')
# For STOP, add 1 day to satisfy STOP > START requirement
# But use step='1d' so we only get one data point
end_time_adjusted = start_time + timedelta(days=1)
end_str = end_time_adjusted.strftime('%Y-%m-%d')
else:
# Multi-day range query
start_str = start_time.strftime('%Y-%m-%d')
end_str = end_time.strftime('%Y-%m-%d')
@ -139,7 +153,25 @@ class HorizonsService:
if response.status_code != 200:
raise Exception(f"NASA API returned status {response.status_code}")
return self._parse_vectors(response.text)
positions = self._parse_vectors(response.text)
# 2. Cache the result before returning
if positions:
# Serialize Position objects to list of dicts for JSON storage
# Convert datetime to ISO format string for JSON serialization
positions_data_to_cache = []
for p in positions:
pos_dict = p.dict()
# Convert datetime to ISO string
if isinstance(pos_dict.get('time'), datetime):
pos_dict['time'] = pos_dict['time'].isoformat()
positions_data_to_cache.append(pos_dict)
# Use a TTL of 7 days (604800 seconds) for now, can be made configurable
await redis_cache.set(cache_key, json.dumps(positions_data_to_cache), ttl_seconds=604800)
logger.info(f"Cache SET for {body_id} positions ({start_str_cache}-{end_str_cache}) with TTL 7 days.")
return positions
except Exception as e:
logger.error(f"Error querying Horizons for body {body_id}: {repr(e)}")
@ -160,8 +192,7 @@ class HorizonsService:
match = re.search(r'\$\$SOE(.*?)\$\$EOE', text, re.DOTALL)
if not match:
logger.warning("No data block ($$SOE...$$EOE) found in Horizons response")
# Log full response for debugging
logger.info(f"Full response for debugging:\n{text}")
logger.debug(f"Response snippet: {text[:500]}...")
return []
data_block = match.group(1).strip()
@ -174,10 +205,6 @@ class HorizonsService:
try:
# Index 0: JD, 1: Date, 2: X, 3: Y, 4: Z, 5: VX, 6: VY, 7: VZ
# Time parsing: 2460676.500000000 is JD.
# A.D. 2025-Jan-01 00:00:00.0000 is Calendar.
# We can use JD or parse the string. Using JD via astropy is accurate.
jd_str = parts[0]
time_obj = Time(float(jd_str), format="jd").datetime
@ -200,145 +227,12 @@ class HorizonsService:
vz=vz
)
positions.append(pos)
except ValueError as e:
except (ValueError, IndexError) as e:
logger.warning(f"Failed to parse line: {line}. Error: {e}")
continue
return positions
async def search_body_by_name(self, name: str, db: AsyncSession) -> dict:
"""
Search for a celestial body by name in NASA Horizons database using httpx.
This method replaces the astroquery-based search to unify proxy and timeout control.
"""
try:
logger.info(f"Searching Horizons (httpx) for: {name}")
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
cmd_val = f"'{name}'" # Name can be ID or actual name
params = {
"format": "text",
"COMMAND": cmd_val,
"OBJ_DATA": "YES", # Request object data to get canonical name/ID
"MAKE_EPHEM": "NO", # Don't need ephemeris
"EPHEM_TYPE": "OBSERVER", # Arbitrary, won't be used since MAKE_EPHEM=NO
"CENTER": "@ssb" # Search from Solar System Barycenter for consistent object IDs
}
timeout = settings.nasa_api_timeout
client_kwargs = {"timeout": timeout}
if settings.proxy_dict:
client_kwargs["proxies"] = settings.proxy_dict
logger.info(f"Using proxy for NASA API: {settings.proxy_dict}")
async with httpx.AsyncClient(**client_kwargs) as client:
response = await client.get(url, params=params)
if response.status_code != 200:
raise Exception(f"NASA API returned status {response.status_code}")
response_text = response.text
# Log full response for debugging (temporarily)
logger.info(f"Full NASA API response for '{name}':\n{response_text}")
# Check for "Ambiguous target name"
if "Ambiguous target name" in response_text:
logger.warning(f"Ambiguous target name for: {name}")
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": "名称不唯一,请提供更具体的名称或 JPL Horizons ID"
}
# Check for "No matches found" or "Unknown target"
if "No matches found" in response_text or "Unknown target" in response_text:
logger.warning(f"No matches found for: {name}")
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": "未找到匹配的天体,请检查名称或 ID"
}
# Try multiple parsing patterns for different response formats
# Pattern 1: "Target body name: Jupiter Barycenter (599)"
target_name_match = re.search(r"Target body name:\s*(.+?)\s+\((\-?\d+)\)", response_text)
if not target_name_match:
# Pattern 2: " Revised: Mar 12, 2021 Ganymede / (Jupiter) 503"
# This pattern appears in the header section of many bodies
revised_match = re.search(r"Revised:.*?\s{2,}(.+?)\s{2,}(\-?\d+)\s*$", response_text, re.MULTILINE)
if revised_match:
full_name = revised_match.group(1).strip()
numeric_id = revised_match.group(2).strip()
short_name = full_name.split('/')[0].strip() # Remove parent body info like "/ (Jupiter)"
logger.info(f"Found target (pattern 2): {full_name} with ID: {numeric_id}")
return {
"success": True,
"id": numeric_id,
"name": short_name,
"full_name": full_name,
"error": None
}
if not target_name_match:
# Pattern 3: Look for body name in title section (works for comets and other objects)
# Example: "JPL/HORIZONS ATLAS (C/2025 N1) 2025-Dec-"
title_match = re.search(r"JPL/HORIZONS\s+(.+?)\s{2,}", response_text)
if title_match:
full_name = title_match.group(1).strip()
# For this pattern, the ID was in the original COMMAND, use it
numeric_id = name.strip("'\"")
short_name = full_name.split('(')[0].strip()
logger.info(f"Found target (pattern 3): {full_name} with ID: {numeric_id}")
return {
"success": True,
"id": numeric_id,
"name": short_name,
"full_name": full_name,
"error": None
}
if target_name_match:
full_name = target_name_match.group(1).strip()
numeric_id = target_name_match.group(2).strip()
short_name = full_name.split('(')[0].strip() # Remove any part after '('
logger.info(f"Found target (pattern 1): {full_name} with ID: {numeric_id}")
return {
"success": True,
"id": numeric_id,
"name": short_name,
"full_name": full_name,
"error": None
}
else:
# Fallback if specific pattern not found, might be a valid but weird response
logger.warning(f"Could not parse target name/ID from response for: {name}. Response snippet: {response_text[:500]}")
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": f"未能解析 JPL Horizons 响应,请尝试精确 ID: {name}"
}
except Exception as e:
error_msg = str(e)
logger.error(f"Error searching for {name}: {error_msg}")
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": f"查询失败: {error_msg}"
}
# Singleton instance
# Global singleton instance
horizons_service = HorizonsService()

View File

@ -0,0 +1,184 @@
"""
NASA SBDB (Small-Body Database) Close-Approach Data API Service
Fetches close approach events for asteroids and comets
API Docs: https://ssd-api.jpl.nasa.gov/doc/cad.html
"""
import logging
import httpx
from typing import List, Dict, Optional, Any
from datetime import datetime, timedelta
from app.config import settings
logger = logging.getLogger(__name__)
class NasaSbdbService:
"""NASA Small-Body Database Close-Approach Data API client"""
def __init__(self):
self.base_url = "https://ssd-api.jpl.nasa.gov/cad.api"
self.timeout = settings.nasa_api_timeout or 30
async def get_close_approaches(
self,
date_min: Optional[str] = None,
date_max: Optional[str] = None,
dist_max: Optional[str] = "0.2", # Max distance in AU (Earth-Moon distance ~0.0026 AU)
body: Optional[str] = None,
sort: str = "date",
limit: Optional[int] = None,
fullname: bool = False
) -> List[Dict[str, Any]]:
"""
Query NASA SBDB Close-Approach Data API
Args:
date_min: Minimum approach date (YYYY-MM-DD or 'now')
date_max: Maximum approach date (YYYY-MM-DD)
dist_max: Maximum approach distance in AU (default 0.2 AU)
body: Filter by specific body (e.g., 'Earth')
sort: Sort by 'date', 'dist', 'dist-min', etc.
limit: Maximum number of results
fullname: Return full designation names
Returns:
List of close approach events
"""
params = {
"dist-max": dist_max,
"sort": sort,
"fullname": "true" if fullname else "false"
}
if date_min:
params["date-min"] = date_min
if date_max:
params["date-max"] = date_max
if body:
params["body"] = body
if limit:
params["limit"] = str(limit)
logger.info(f"Querying NASA SBDB for close approaches: {params}")
# Use proxy if configured
proxies = settings.proxy_dict
if proxies:
logger.info(f"Using proxy for NASA SBDB API")
try:
async with httpx.AsyncClient(timeout=self.timeout, proxies=proxies) as client:
response = await client.get(self.base_url, params=params)
response.raise_for_status()
data = response.json()
if "data" not in data:
logger.warning("No data field in NASA SBDB response")
return []
# Parse response
fields = data.get("fields", [])
rows = data.get("data", [])
events = []
for row in rows:
event = dict(zip(fields, row))
events.append(event)
logger.info(f"Retrieved {len(events)} close approach events from NASA SBDB")
return events
except httpx.HTTPStatusError as e:
logger.error(f"NASA SBDB API HTTP error: {e.response.status_code} - {e.response.text}")
return []
except httpx.TimeoutException:
logger.error(f"NASA SBDB API timeout after {self.timeout}s")
return []
except Exception as e:
logger.error(f"Error querying NASA SBDB: {e}")
return []
def parse_event_to_celestial_event(self, sbdb_event: Dict[str, Any], approach_body: str = "Earth") -> Dict[str, Any]:
"""
Parse NASA SBDB event data to CelestialEvent format
Args:
sbdb_event: Event data from NASA SBDB API
approach_body: Name of the body being approached (e.g., "Earth", "Mars")
SBDB fields typically include:
- des: Object designation
- orbit_id: Orbit ID
- jd: Julian Date of close approach
- cd: Calendar date (YYYY-MMM-DD HH:MM)
- dist: Nominal approach distance (AU)
- dist_min: Minimum approach distance (AU)
- dist_max: Maximum approach distance (AU)
- v_rel: Relative velocity (km/s)
- v_inf: Velocity at infinity (km/s)
- t_sigma_f: Time uncertainty (formatted string)
- h: Absolute magnitude
- fullname: Full object name (if requested)
"""
try:
# Extract fields
designation = sbdb_event.get("des", "Unknown")
fullname = sbdb_event.get("fullname", designation)
cd = sbdb_event.get("cd", "") # Calendar date string
dist = sbdb_event.get("dist", "") # Nominal distance in AU
dist_min = sbdb_event.get("dist_min", "")
v_rel = sbdb_event.get("v_rel", "")
# Note: NASA API doesn't return the approach body, so we use the parameter
body = approach_body
# Parse date (format: YYYY-MMM-DD HH:MM)
event_time = datetime.strptime(cd, "%Y-%b-%d %H:%M")
# Create title
title = f"{fullname} Close Approach to {body}"
# Create description
desc_parts = [
f"Asteroid/Comet {fullname} will make a close approach to {body}.",
f"Nominal distance: {dist} AU",
]
if dist_min:
desc_parts.append(f"Minimum distance: {dist_min} AU")
if v_rel:
desc_parts.append(f"Relative velocity: {v_rel} km/s")
description = " ".join(desc_parts)
# Store all technical details in JSONB
details = {
"designation": designation,
"orbit_id": sbdb_event.get("orbit_id"),
"julian_date": sbdb_event.get("jd"),
"nominal_dist_au": dist,
"dist_min_au": dist_min,
"dist_max_au": sbdb_event.get("dist_max"),
"relative_velocity_km_s": v_rel,
"v_inf": sbdb_event.get("v_inf"),
"time_sigma": sbdb_event.get("t_sigma_f"),
"absolute_magnitude": sbdb_event.get("h"),
"approach_body": body
}
return {
"body_id": designation, # Will need to map to celestial_bodies.id
"title": title,
"event_type": "approach",
"event_time": event_time,
"description": description,
"details": details,
"source": "nasa_sbdb"
}
except Exception as e:
logger.error(f"Error parsing SBDB event: {e}")
return None
# Singleton instance
nasa_sbdb_service = NasaSbdbService()

View File

@ -1,6 +1,10 @@
"""
Worker functions for background tasks
"""
import logging
import asyncio
from datetime import datetime
import httpx
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
@ -9,6 +13,8 @@ from app.services.task_service import task_service
from app.services.db_service import celestial_body_service, position_service
from app.services.horizons import horizons_service
from app.services.orbit_service import orbit_service
from app.services.event_service import event_service
from app.models.schemas.social import CelestialEventCreate
logger = logging.getLogger(__name__)
@ -21,7 +27,7 @@ async def download_positions_task(task_id: int, body_ids: List[str], dates: List
async with AsyncSessionLocal() as db:
try:
# Mark as running
await task_service.update_progress(db, task_id, 0, "running")
await task_service.update_task(db, task_id, progress=0, status="running")
total_operations = len(body_ids) * len(dates)
current_op = 0
@ -103,7 +109,7 @@ async def download_positions_task(task_id: int, body_ids: List[str], dates: List
progress = int((current_op / total_operations) * 100)
# Only update DB every 5% or so to reduce load, but update Redis frequently
# For now, update every item for simplicity
await task_service.update_progress(db, task_id, progress)
await task_service.update_task(db, task_id, progress=progress)
results.append(body_result)
@ -113,12 +119,12 @@ async def download_positions_task(task_id: int, body_ids: List[str], dates: List
"total_failed": failed_count,
"details": results
}
await task_service.complete_task(db, task_id, final_result)
await task_service.update_task(db, task_id, status="completed", progress=100, result=final_result)
logger.info(f"Task {task_id} completed successfully")
except Exception as e:
logger.error(f"Task {task_id} failed critically: {e}")
await task_service.fail_task(db, task_id, str(e))
await task_service.update_task(db, task_id, status="failed", error_message=str(e))
async def generate_orbits_task(task_id: int, body_ids: Optional[List[str]] = None):
@ -133,33 +139,26 @@ async def generate_orbits_task(task_id: int, body_ids: Optional[List[str]] = Non
async with AsyncSessionLocal() as db:
try:
# Update task to running
await task_service.update_task(
db, task_id, status="running", started_at=datetime.utcnow(), progress=0
)
# 1. Determine bodies to process
bodies_to_process = []
if body_ids:
# Fetch specific bodies requested
for bid in body_ids:
body = await celestial_body_service.get_body_by_id(bid, db)
if body:
bodies_to_process.append(body)
else:
# Fetch all bodies
bodies_to_process = await celestial_body_service.get_all_bodies(db)
# 2. Filter for valid orbital parameters
valid_bodies = []
for body in bodies_to_process:
extra_data = body.extra_data or {}
# Must have orbit_period_days to generate an orbit
if extra_data.get("orbit_period_days"):
valid_bodies.append(body)
elif body_ids and body.id in body_ids:
# If explicitly requested but missing period, log warning
logger.warning(f"Body {body.name} ({body.id}) missing 'orbit_period_days', skipping.")
total_bodies = len(valid_bodies)
@ -170,14 +169,12 @@ async def generate_orbits_task(task_id: int, body_ids: Optional[List[str]] = Non
)
return
# 3. Process
success_count = 0
failure_count = 0
results = []
for i, body in enumerate(valid_bodies):
try:
# Update progress
progress = int((i / total_bodies) * 100)
await task_service.update_task(db, task_id, progress=progress)
@ -185,7 +182,6 @@ async def generate_orbits_task(task_id: int, body_ids: Optional[List[str]] = Non
period = float(extra_data.get("orbit_period_days"))
color = extra_data.get("orbit_color", "#CCCCCC")
# Generate orbit
orbit = await orbit_service.generate_orbit(
body_id=body.id,
body_name=body.name_zh or body.name,
@ -213,7 +209,6 @@ async def generate_orbits_task(task_id: int, body_ids: Optional[List[str]] = Non
})
failure_count += 1
# Finish task
await task_service.update_task(
db,
task_id,
@ -234,3 +229,115 @@ async def generate_orbits_task(task_id: int, body_ids: Optional[List[str]] = Non
await task_service.update_task(
db, task_id, status="failed", error_message=str(e), completed_at=datetime.utcnow()
)
async def fetch_celestial_events_task(task_id: int):
"""
Background task to fetch celestial events (Close Approaches) from NASA SBDB
"""
logger.info(f"🚀 Starting celestial event fetch task {task_id}")
url = "https://ssd-api.jpl.nasa.gov/cad.api"
# Fetch data for next 60 days, close approach < 0.05 AU (approx 7.5M km)
params = {
"dist-max": "0.05",
"date-min": datetime.utcnow().strftime("%Y-%m-%d"),
"date-max": (datetime.utcnow() + timedelta(days=60)).strftime("%Y-%m-%d"),
"body": "ALL"
}
async with AsyncSessionLocal() as db:
try:
await task_service.update_task(db, task_id, status="running", progress=10)
async with httpx.AsyncClient(timeout=30) as client:
logger.info(f"Querying NASA SBDB CAD API: {url}")
response = await client.get(url, params=params)
if response.status_code != 200:
raise Exception(f"NASA API returned {response.status_code}: {response.text}")
data = response.json()
count = int(data.get("count", 0))
fields = data.get("fields", [])
data_rows = data.get("data", [])
logger.info(f"Fetched {count} close approach events")
# Map fields to indices
try:
idx_des = fields.index("des")
idx_cd = fields.index("cd")
idx_dist = fields.index("dist")
idx_v_rel = fields.index("v_rel")
except ValueError as e:
raise Exception(f"Missing expected field in NASA response: {e}")
processed_count = 0
saved_count = 0
# Get all active bodies to match against
all_bodies = await celestial_body_service.get_all_bodies(db)
# Map name/designation to body_id.
# NASA 'des' (designation) might match our 'name' or 'id'
# Simple lookup: dictionary of name -> id
body_map = {b.name.lower(): b.id for b in all_bodies}
# Also map id -> id just in case
for b in all_bodies:
body_map[b.id.lower()] = b.id
for row in data_rows:
des = row[idx_des].strip()
date_str = row[idx_cd] # YYYY-MMM-DD HH:MM
dist = row[idx_dist]
v_rel = row[idx_v_rel]
# Try to find matching body
# NASA des often looks like "2024 XK" or "433" (Eros)
# We try exact match first
target_id = body_map.get(des.lower())
if target_id:
# Found a match! Create event.
# NASA date format: 2025-Dec-18 12:00
try:
event_time = datetime.strptime(date_str, "%Y-%b-%d %H:%M")
except ValueError:
# Fallback if format differs slightly
event_time = datetime.utcnow()
event_data = CelestialEventCreate(
body_id=target_id,
title=f"Close Approach: {des}",
event_type="approach",
event_time=event_time,
description=f"Close approach to Earth at distance {dist} AU with relative velocity {v_rel} km/s",
details={
"nominal_dist_au": float(dist),
"v_rel_kms": float(v_rel),
"designation": des
},
source="nasa_sbdb"
)
# Ideally check for duplicates here (e.g. by body_id + event_time)
# For now, just create
await event_service.create_event(event_data, db)
saved_count += 1
processed_count += 1
await task_service.update_task(
db, task_id, status="completed", progress=100,
result={
"fetched": count,
"processed": processed_count,
"saved": saved_count,
"message": f"Successfully fetched {count} events, saved {saved_count} matched events."
}
)
except Exception as e:
logger.error(f"Task {task_id} failed: {e}")
await task_service.update_task(db, task_id, status="failed", error_message=str(e))

View File

@ -16,664 +16,224 @@ logger = logging.getLogger(__name__)
class OrbitService:
"""Service for orbit CRUD operations and generation"""
@staticmethod
async def get_orbit(body_id: str, session: AsyncSession) -> Optional[Orbit]:
"""Get orbit data for a specific body"""
result = await session.execute(
select(Orbit).where(Orbit.body_id == body_id)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all_orbits(
session: AsyncSession,
body_type: Optional[str] = None
) -> List[Orbit]:
"""Get all orbits, optionally filtered by body type"""
if body_type:
# Join with celestial_bodies to filter by type
query = (
select(Orbit)
.join(CelestialBody, Orbit.body_id == CelestialBody.id)
.where(CelestialBody.type == body_type)
)
else:
query = select(Orbit)
result = await session.execute(query)
return list(result.scalars().all())
@staticmethod
async def get_all_orbits_with_bodies(
session: AsyncSession,
body_type: Optional[str] = None
) -> List[tuple[Orbit, CelestialBody]]:
"""
Get all orbits with their associated celestial bodies in a single query.
This is optimized to avoid N+1 query problem.
Returns:
List of (Orbit, CelestialBody) tuples
"""
if body_type:
query = (
select(Orbit, CelestialBody)
.join(CelestialBody, Orbit.body_id == CelestialBody.id)
.where(CelestialBody.type == body_type)
)
else:
query = (
select(Orbit, CelestialBody)
.join(CelestialBody, Orbit.body_id == CelestialBody.id)
)
result = await session.execute(query)
return list(result.all())
@staticmethod
async def save_orbit(
body_id: str,
points: List[Dict[str, float]],
num_points: int,
period_days: Optional[float],
color: Optional[str],
session: AsyncSession
) -> Orbit:
"""Save or update orbit data using UPSERT"""
stmt = insert(Orbit).values(
body_id=body_id,
points=points,
num_points=num_points,
period_days=period_days,
color=color,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# On conflict, update all fields
stmt = stmt.on_conflict_do_update(
index_elements=['body_id'],
set_={
'points': points,
'num_points': num_points,
'period_days': period_days,
'color': color,
'updated_at': datetime.utcnow()
}
)
await session.execute(stmt)
await session.commit()
# Fetch and return the saved orbit
return await OrbitService.get_orbit(body_id, session)
@staticmethod
async def delete_orbit(body_id: str, session: AsyncSession) -> bool:
"""Delete orbit data for a specific body"""
orbit = await OrbitService.get_orbit(body_id, session)
if orbit:
await session.delete(orbit)
await session.commit()
return True
return False
@staticmethod
async def generate_orbit(
body_id: str,
body_name: str,
period_days: float,
color: Optional[str],
session: AsyncSession,
horizons_service: HorizonsService
) -> Orbit:
"""
Generate complete orbital data for a celestial body
Args:
body_id: JPL Horizons ID
body_name: Display name (for logging)
period_days: Orbital period in days
color: Hex color for orbit line
session: Database session
horizons_service: NASA Horizons API service
Returns:
Generated Orbit object
"""
logger.info(f"🌌 Generating orbit for {body_name} (period: {period_days:.1f} days)")
logger.info(f"Generating orbit for {body_name} (period: {period_days:.1f} days)")
# Calculate number of sample points
# Use at least 100 points for smooth ellipse
# For very long periods, cap at 1000 to avoid excessive data
MIN_POINTS = 100
MAX_POINTS = 1000
if period_days < 3650: # < 10 years
# For planets: aim for ~1 point per day, minimum 100
num_points = max(MIN_POINTS, min(int(period_days), 365))
else: # >= 10 years
# For outer planets and dwarf planets: monthly sampling
num_points = min(int(period_days / 30), MAX_POINTS)
# Calculate step size in days
step_days = max(1, int(period_days / num_points))
logger.info(f" 📊 Sampling {num_points} points (every {step_days} days)")
logger.info(f" Sampling {num_points} points (every {step_days} days)")
# Query NASA Horizons for complete orbital period
# NASA Horizons has limited date range (typically 1900-2200)
# For very long periods, we need to limit the query range
MAX_QUERY_YEARS = 250 # Maximum years we can query (1900-2150)
MAX_QUERY_DAYS = MAX_QUERY_YEARS * 365
if period_days > MAX_QUERY_DAYS:
# For extremely long periods (>250 years), sample a partial orbit
# Use enough data to show the orbital shape accurately
actual_query_days = MAX_QUERY_DAYS
start_time = datetime(1900, 1, 1)
end_time = datetime(1900 + MAX_QUERY_YEARS, 1, 1)
logger.warning(f" ⚠️ Period too long ({period_days/365:.1f} years), sampling {MAX_QUERY_YEARS} years only")
logger.info(f" 📅 Using partial orbit range: 1900-{1900 + MAX_QUERY_YEARS}")
logger.warning(f" Period too long ({period_days/365:.1f} years), sampling {MAX_QUERY_YEARS} years only")
logger.info(f" Using partial orbit range: 1900-{1900 + MAX_QUERY_YEARS}")
# Adjust sampling rate for partial orbit
# We still want enough points to show the shape
partial_ratio = actual_query_days / period_days
adjusted_num_points = max(MIN_POINTS, int(num_points * 0.5)) # At least half the intended points
step_days = max(1, int(actual_query_days / adjusted_num_points))
logger.info(f" 📊 Adjusted sampling: {adjusted_num_points} points (every {step_days} days)")
logger.info(f" Adjusted sampling: {adjusted_num_points} points (every {step_days} days)")
elif period_days > 150 * 365: # More than 150 years but <= 250 years
# Start from year 1900 for historical data
start_time = datetime(1900, 1, 1)
end_time = start_time + timedelta(days=period_days)
logger.info(f" 📅 Using historical date range (1900-{end_time.year}) for long-period orbit")
logger.info(f" Using historical date range (1900-{end_time.year}) for long-period orbit")
else:
start_time = datetime.utcnow()
end_time = start_time + timedelta(days=period_days)
try:
# Get positions from Horizons (synchronous call)
# Get positions from Horizons
positions = await horizons_service.get_body_positions(
body_id=body_id,
start_time=start_time,
end_time=end_time,
step=f"{step_days}d"
)
if not positions or len(positions) == 0:
raise ValueError(f"No position data returned for {body_name}")
# Convert Position objects to list of dicts
points = [
{"x": pos.x, "y": pos.y, "z": pos.z}
for pos in positions
]
logger.info(f" ✅ Retrieved {len(points)} orbital points")
logger.info(f" Retrieved {len(points)} orbital points")
# Save to database
orbit = await OrbitService.save_orbit(
body_id=body_id,
points=points,
num_points=len(points),
period_days=period_days,
color=color,
session=session
)
logger.info(f" 💾 Saved orbit for {body_name}")
logger.info(f" Saved orbit for {body_name}")
return orbit
except Exception as e:
logger.error(f" ❌ Failed to generate orbit for {body_name}: {repr(e)}")
logger.error(f" Failed to generate orbit for {body_name}: {repr(e)}")
raise
# Singleton instance
orbit_service = OrbitService()

View File

@ -0,0 +1,235 @@
"""
Planetary Events Service - Calculate astronomical events using Skyfield
Computes conjunctions, oppositions, and other events for major solar system bodies
"""
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
from skyfield.api import load, wgs84
from skyfield import almanac
logger = logging.getLogger(__name__)
class PlanetaryEventsService:
"""Service for calculating planetary astronomical events"""
def __init__(self):
"""Initialize Skyfield ephemeris and timescale"""
self.ts = None
self.eph = None
self._initialized = False
def _ensure_initialized(self):
"""Lazy load ephemeris data (downloads ~30MB on first run)"""
if not self._initialized:
logger.info("Loading Skyfield ephemeris (DE421)...")
self.ts = load.timescale()
self.eph = load('de421.bsp') # Covers 1900-2050
self._initialized = True
logger.info("Skyfield ephemeris loaded successfully")
def get_planet_mapping(self) -> Dict[str, Dict[str, str]]:
"""
Map database body IDs to Skyfield names
Returns:
Dictionary mapping body_id to Skyfield ephemeris names
"""
return {
'10': {'skyfield': 'sun', 'name': 'Sun', 'name_zh': '太阳'},
'199': {'skyfield': 'mercury', 'name': 'Mercury', 'name_zh': '水星'},
'299': {'skyfield': 'venus', 'name': 'Venus', 'name_zh': '金星'},
'399': {'skyfield': 'earth', 'name': 'Earth', 'name_zh': '地球'},
'301': {'skyfield': 'moon', 'name': 'Moon', 'name_zh': '月球'},
'499': {'skyfield': 'mars', 'name': 'Mars', 'name_zh': '火星'},
'599': {'skyfield': 'jupiter barycenter', 'name': 'Jupiter', 'name_zh': '木星'},
'699': {'skyfield': 'saturn barycenter', 'name': 'Saturn', 'name_zh': '土星'},
'799': {'skyfield': 'uranus barycenter', 'name': 'Uranus', 'name_zh': '天王星'},
'899': {'skyfield': 'neptune barycenter', 'name': 'Neptune', 'name_zh': '海王星'},
}
def calculate_oppositions_conjunctions(
self,
body_ids: Optional[List[str]] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
days_ahead: int = 365
) -> List[Dict[str, Any]]:
"""
Calculate oppositions and conjunctions for specified bodies
Args:
body_ids: List of body IDs to calculate (default: all major planets)
start_date: Start date (default: today)
end_date: End date (default: start_date + days_ahead)
days_ahead: Days to look ahead if end_date not specified
Returns:
List of event dictionaries
"""
self._ensure_initialized()
# Set time range
if start_date is None:
start_date = datetime.utcnow()
if end_date is None:
end_date = start_date + timedelta(days=days_ahead)
t0 = self.ts.utc(start_date.year, start_date.month, start_date.day)
t1 = self.ts.utc(end_date.year, end_date.month, end_date.day)
logger.info(f"Calculating planetary events from {start_date.date()} to {end_date.date()}")
# Get planet mapping
planet_map = self.get_planet_mapping()
# Default to major planets (exclude Sun, Moon)
if body_ids is None:
body_ids = ['199', '299', '499', '599', '699', '799', '899']
# Earth as reference point
earth = self.eph['earth']
events = []
for body_id in body_ids:
if body_id not in planet_map:
logger.warning(f"Body ID {body_id} not in planet mapping, skipping")
continue
planet_info = planet_map[body_id]
skyfield_name = planet_info['skyfield']
try:
planet = self.eph[skyfield_name]
# Calculate oppositions and conjunctions
f = almanac.oppositions_conjunctions(self.eph, planet)
times, event_types = almanac.find_discrete(t0, t1, f)
for ti, event_type in zip(times, event_types):
event_time = ti.utc_datetime()
# Convert timezone-aware datetime to naive UTC for database
# Database expects TIMESTAMP (timezone-naive)
event_time = event_time.replace(tzinfo=None)
# event_type: 0 = conjunction, 1 = opposition
is_conjunction = (event_type == 0)
event_name = '' if is_conjunction else ''
event_type_en = 'conjunction' if is_conjunction else 'opposition'
# Calculate separation angle
earth_pos = earth.at(ti)
planet_pos = planet.at(ti)
separation = earth_pos.separation_from(planet_pos)
# Create event data
event = {
'body_id': body_id,
'title': f"{planet_info['name_zh']} {event_name} ({planet_info['name']} {event_type_en.capitalize()})",
'event_type': event_type_en,
'event_time': event_time,
'description': f"{planet_info['name_zh']}将发生{event_name}现象。" +
(f"与地球的角距离约{separation.degrees:.2f}°。" if is_conjunction else "处于冲日位置,是观测的最佳时机。"),
'details': {
'event_subtype': event_name,
'separation_degrees': round(separation.degrees, 4),
'planet_name': planet_info['name'],
'planet_name_zh': planet_info['name_zh'],
},
'source': 'skyfield_calculation'
}
events.append(event)
logger.debug(f"Found {event_type_en}: {planet_info['name']} at {event_time}")
except KeyError:
logger.error(f"Planet {skyfield_name} not found in ephemeris")
except Exception as e:
logger.error(f"Error calculating events for {body_id}: {e}")
logger.info(f"Calculated {len(events)} planetary events")
return events
def calculate_planetary_distances(
self,
body_pairs: List[tuple],
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
days_ahead: int = 365,
threshold_degrees: float = 5.0
) -> List[Dict[str, Any]]:
"""
Calculate close approaches between planet pairs
Args:
body_pairs: List of (body_id1, body_id2) tuples to check
start_date: Start date
end_date: End date
days_ahead: Days to look ahead
threshold_degrees: Only report if closer than this angle
Returns:
List of close approach events
"""
self._ensure_initialized()
if start_date is None:
start_date = datetime.utcnow()
if end_date is None:
end_date = start_date + timedelta(days=days_ahead)
planet_map = self.get_planet_mapping()
events = []
# Sample every day
current = start_date
while current <= end_date:
t = self.ts.utc(current.year, current.month, current.day)
for body_id1, body_id2 in body_pairs:
if body_id1 not in planet_map or body_id2 not in planet_map:
continue
try:
planet1 = self.eph[planet_map[body_id1]['skyfield']]
planet2 = self.eph[planet_map[body_id2]['skyfield']]
pos1 = planet1.at(t)
pos2 = planet2.at(t)
separation = pos1.separation_from(pos2)
if separation.degrees < threshold_degrees:
# Use naive UTC datetime for database
event_time = current.replace(tzinfo=None) if hasattr(current, 'tzinfo') else current
event = {
'body_id': body_id1, # Primary body
'title': f"{planet_map[body_id1]['name_zh']}{planet_map[body_id2]['name_zh']}接近",
'event_type': 'close_approach',
'event_time': event_time,
'description': f"{planet_map[body_id1]['name_zh']}{planet_map[body_id2]['name_zh']}的角距离约{separation.degrees:.2f}°,这是较为罕见的天象。",
'details': {
'body_id_secondary': body_id2,
'separation_degrees': round(separation.degrees, 4),
'planet1_name': planet_map[body_id1]['name'],
'planet2_name': planet_map[body_id2]['name'],
},
'source': 'skyfield_calculation'
}
events.append(event)
except Exception as e:
logger.error(f"Error calculating distance for {body_id1}-{body_id2}: {e}")
current += timedelta(days=1)
logger.info(f"Found {len(events)} close approach events")
return events
# Singleton instance
planetary_events_service = PlanetaryEventsService()

View File

@ -148,6 +148,51 @@ class RedisCache:
logger.error(f"Redis get_stats error: {e}")
return {"connected": False, "error": str(e)}
# List operations for channel messages
async def rpush(self, key: str, value: str) -> int:
"""Push value to the right end of list"""
if not self._connected or not self.client:
return 0
try:
result = await self.client.rpush(key, value)
return result
except Exception as e:
logger.error(f"Redis rpush error for key '{key}': {e}")
return 0
async def ltrim(self, key: str, start: int, stop: int) -> bool:
"""Trim list to specified range"""
if not self._connected or not self.client:
return False
try:
await self.client.ltrim(key, start, stop)
return True
except Exception as e:
logger.error(f"Redis ltrim error for key '{key}': {e}")
return False
async def lrange(self, key: str, start: int, stop: int) -> list:
"""Get range of elements from list"""
if not self._connected or not self.client:
return []
try:
result = await self.client.lrange(key, start, stop)
return result
except Exception as e:
logger.error(f"Redis lrange error for key '{key}': {e}")
return []
async def expire(self, key: str, seconds: int) -> bool:
"""Set expiration time for key"""
if not self._connected or not self.client:
return False
try:
await self.client.expire(key, seconds)
return True
except Exception as e:
logger.error(f"Redis expire error for key '{key}': {e}")
return False
# Singleton instance
redis_cache = RedisCache()

View File

@ -0,0 +1,162 @@
"""
Social Service - Handles user follows and channel messages
"""
import logging
import json
from typing import List, Optional, Dict
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from app.models.db.user_follow import UserFollow
from app.models.db.celestial_body import CelestialBody
from app.models.db.user import User
from app.models.schemas.social import ChannelMessageResponse
from app.services.redis_cache import redis_cache
logger = logging.getLogger(__name__)
class SocialService:
def __init__(self):
self.channel_message_prefix = "channel:messages:"
self.channel_message_ttl_seconds = 7 * 24 * 60 * 60 # 7 days
self.max_channel_messages = 500 # Max messages to keep in a channel
# --- User Follows ---
async def follow_body(self, user_id: int, body_id: str, db: AsyncSession) -> UserFollow:
"""User follows a celestial body"""
# Check if already following
existing_follow = await self.get_follow(user_id, body_id, db)
if existing_follow:
raise ValueError("Already following this body")
# Check if body exists
body = await db.execute(select(CelestialBody).where(CelestialBody.id == body_id))
if not body.scalar_one_or_none():
raise ValueError("Celestial body not found")
follow = UserFollow(user_id=user_id, body_id=body_id)
db.add(follow)
await db.commit()
await db.refresh(follow)
logger.info(f"User {user_id} followed body {body_id}")
return follow
async def unfollow_body(self, user_id: int, body_id: str, db: AsyncSession) -> bool:
"""User unfollows a celestial body"""
result = await db.execute(
delete(UserFollow).where(UserFollow.user_id == user_id, UserFollow.body_id == body_id)
)
await db.commit()
if result.rowcount > 0:
logger.info(f"User {user_id} unfollowed body {body_id}")
return True
return False
async def get_follow(self, user_id: int, body_id: str, db: AsyncSession) -> Optional[UserFollow]:
"""Get a specific follow record"""
result = await db.execute(
select(UserFollow).where(UserFollow.user_id == user_id, UserFollow.body_id == body_id)
)
return result.scalar_one_or_none()
async def get_user_follows(self, user_id: int, db: AsyncSession) -> List[CelestialBody]:
"""Get all bodies followed by a user"""
result = await db.execute(
select(CelestialBody)
.join(UserFollow, UserFollow.body_id == CelestialBody.id)
.where(UserFollow.user_id == user_id)
)
return result.scalars().all()
async def get_user_follows_with_time(self, user_id: int, db: AsyncSession) -> List[dict]:
"""Get all bodies followed by a user with created_at time and body details"""
from sqlalchemy.orm import selectinload
result = await db.execute(
select(UserFollow, CelestialBody)
.join(CelestialBody, UserFollow.body_id == CelestialBody.id)
.where(UserFollow.user_id == user_id)
)
follows_with_bodies = result.all()
return [
{
"user_id": follow.user_id,
"body_id": follow.body_id,
"created_at": follow.created_at, # Keep as created_at to match schema
"id": body.id,
"name": body.name,
"name_zh": body.name_zh,
"type": body.type,
"is_active": body.is_active,
}
for follow, body in follows_with_bodies
]
async def get_body_followers_count(self, body_id: str, db: AsyncSession) -> int:
"""Get the number of followers for a celestial body"""
result = await db.execute(
select(func.count()).where(UserFollow.body_id == body_id)
)
return result.scalar_one()
# --- Channel Messages (Redis based) ---
async def post_channel_message(self, user_id: int, body_id: str, content: str, db: AsyncSession) -> ChannelMessageResponse:
"""Post a message to a celestial body's channel"""
# Verify user and body exist
user_result = await db.execute(select(User.username).where(User.id == user_id))
username = user_result.scalar_one_or_none()
if not username:
raise ValueError("User not found")
body_result = await db.execute(select(CelestialBody).where(CelestialBody.id == body_id))
if not body_result.scalar_one_or_none():
raise ValueError("Celestial body not found")
# Verify user is following the body to post
# According to the requirement, only followed users can post.
is_following = await self.get_follow(user_id, body_id, db)
if not is_following:
raise ValueError("User is not following this celestial body channel")
message_data = {
"user_id": user_id,
"username": username,
"body_id": body_id,
"content": content,
"created_at": datetime.utcnow().isoformat() # Store as ISO string
}
channel_key = f"{self.channel_message_prefix}{body_id}"
# Add message to the right of the list (newest)
await redis_cache.rpush(channel_key, json.dumps(message_data)) # Store as JSON string
# Trim list to max_channel_messages
await redis_cache.ltrim(channel_key, -self.max_channel_messages, -1)
# Set/reset TTL for the channel list (e.g., if no activity, it expires)
await redis_cache.expire(channel_key, self.channel_message_ttl_seconds)
logger.info(f"Message posted to channel {body_id} by user {user_id}")
return ChannelMessageResponse(**message_data)
async def get_channel_messages(self, body_id: str, db: AsyncSession, limit: int = 50) -> List[ChannelMessageResponse]:
"""Get recent messages from a celestial body's channel"""
channel_key = f"{self.channel_message_prefix}{body_id}"
# Get messages from Redis list (newest first for display)
# LPUSH for oldest first, RPUSH for newest first. We use RPUSH, so -limit to -1 is newest
raw_messages = await redis_cache.lrange(channel_key, -limit, -1)
messages = []
for msg_str in raw_messages:
try:
msg_data = json.loads(msg_str)
messages.append(ChannelMessageResponse(**msg_data))
except json.JSONDecodeError:
logger.warning(f"Could not decode message from channel {body_id}: {msg_str}")
return messages
social_service = SocialService()

BIN
backend/de421.bsp 100644

Binary file not shown.

View File

@ -0,0 +1,96 @@
## 修复定时任务参数类型错误
### 问题描述
在定时任务执行时报错:
```
"error": "unsupported type for timedelta days component: str"
```
### 原因分析
当任务参数从数据库的JSON字段读取时所有参数都会被解析为字符串类型。例如
- `"days_ahead": 365` 会变成 `"days_ahead": "365"` (字符串)
- `"calculate_close_approaches": false` 会变成 `"calculate_close_approaches": "false"` (字符串)
而代码中直接使用这些参数,导致类型不匹配错误。
### 修复内容
`/Users/jiliu/WorkSpace/cosmo/backend/app/jobs/predefined.py` 中修复了三个任务的参数类型转换:
#### 1. `sync_solar_system_positions` (第71-74行)
```python
# 修复前
body_ids = params.get("body_ids")
days = params.get("days", 7)
source = params.get("source", "nasa_horizons_cron")
# 修复后
body_ids = params.get("body_ids")
days = int(params.get("days", 7))
source = str(params.get("source", "nasa_horizons_cron"))
```
#### 2. `fetch_close_approach_events` (第264-269行)
```python
# 修复前
body_ids = params.get("body_ids") or ["399"]
days_ahead = params.get("days_ahead", 30)
dist_max = params.get("dist_max", "30")
limit = params.get("limit", 100)
clean_old_events = params.get("clean_old_events", True)
# 修复后
body_ids = params.get("body_ids") or ["399"]
days_ahead = int(params.get("days_ahead", 30))
dist_max = str(params.get("dist_max", "30")) # Keep as string for API
limit = int(params.get("limit", 100))
clean_old_events = bool(params.get("clean_old_events", True))
```
#### 3. `calculate_planetary_events` (第490-495行)
```python
# 修复前
body_ids = params.get("body_ids")
days_ahead = params.get("days_ahead", 365)
calculate_close_approaches = params.get("calculate_close_approaches", False)
threshold_degrees = params.get("threshold_degrees", 5.0)
clean_old_events = params.get("clean_old_events", True)
# 修复后
body_ids = params.get("body_ids")
days_ahead = int(params.get("days_ahead", 365))
calculate_close_approaches = bool(params.get("calculate_close_approaches", False))
threshold_degrees = float(params.get("threshold_degrees", 5.0))
clean_old_events = bool(params.get("clean_old_events", True))
```
### 测试验证
测试结果:
- ✓ 字符串参数模拟数据库JSON成功执行计算16个事件保存14个
- ✓ 原生类型参数(直接调用):成功执行,正确跳过重复事件
### 使用说明
现在可以在数据库中正常配置定时任务了。参数会自动进行类型转换:
```sql
INSERT INTO tasks (name, description, category, parameters, status, schedule_config)
VALUES (
'calculate_planetary_events',
'计算太阳系主要天体的合、冲等事件',
'data_sync',
'{
"days_ahead": 365,
"clean_old_events": true,
"calculate_close_approaches": false
}'::json,
'active',
'{
"type": "cron",
"cron": "0 2 * * 0"
}'::json
);
```
所有参数现在都会被正确转换为对应的类型。

View File

@ -0,0 +1,33 @@
-- Add Scheduled Job for Fetching Close Approach Events
-- This uses the predefined task: fetch_close_approach_events
--
-- 参数说明:
-- - days_ahead: 30 (查询未来30天的事件)
-- - dist_max: "30" (30 AU海王星轨道范围)
-- - approach_body: "Earth" (接近地球的天体)
-- - limit: 200 (最多返回200个事件)
-- - clean_old_events: true (清理过期事件)
--
-- Cron表达式: '0 2 * * 0' (每周日UTC 02:00执行)
--
-- 注意: 任务会自动创建不存在的天体记录(小行星/彗星)
INSERT INTO "public"."scheduled_jobs"
("name", "job_type", "predefined_function", "function_params", "cron_expression", "description", "is_active")
VALUES
(
'每周天体事件拉取 (Close Approaches)',
'predefined',
'fetch_close_approach_events',
'{
"days_ahead": 30,
"dist_max": "30",
"approach_body": "Earth",
"limit": 200,
"clean_old_events": true
}'::jsonb,
'0 2 * * 0',
'每周日UTC 02:00从NASA SBDB拉取未来30天内距离地球30AU以内海王星轨道范围的小行星/彗星接近事件',
true
)
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,55 @@
-- Add Celestial Events Menu
-- 添加天体事件展示菜单到数据管理菜单下
-- First check if menu already exists
DO $$
DECLARE
menu_exists BOOLEAN;
BEGIN
SELECT EXISTS(SELECT 1 FROM menus WHERE name = 'celestial_events') INTO menu_exists;
IF NOT menu_exists THEN
INSERT INTO "public"."menus"
("name", "title", "icon", "path", "component", "parent_id", "sort_order", "is_active")
VALUES
(
'celestial_events',
'天体事件',
'CalendarOutlined',
'/admin/celestial-events',
NULL,
2, -- parent_id = 2 (数据管理)
4, -- sort_order = 4 (在NASA数据下载之后)
true
);
END IF;
END $$;
-- Get the menu ID for role assignment
DO $$
DECLARE
menu_id_var INTEGER;
admin_role_id INTEGER;
user_role_id INTEGER;
BEGIN
-- Get the celestial_events menu ID
SELECT id INTO menu_id_var FROM menus WHERE name = 'celestial_events';
-- Get role IDs
SELECT id INTO admin_role_id FROM roles WHERE name = 'admin';
SELECT id INTO user_role_id FROM roles WHERE name = 'user';
-- Assign menu to admin role
IF menu_id_var 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_var)
ON CONFLICT DO NOTHING;
END IF;
-- Assign menu to user role (users can view events)
IF menu_id_var IS NOT NULL AND user_role_id IS NOT NULL THEN
INSERT INTO role_menus (role_id, menu_id)
VALUES (user_role_id, menu_id_var)
ON CONFLICT DO NOTHING;
END IF;
END $$;

View File

@ -0,0 +1,63 @@
-- Add calculate_planetary_events task to the scheduled tasks
-- This task will calculate planetary events (conjunctions, oppositions) using Skyfield
-- Example 1: Calculate events for all major planets (365 days ahead)
INSERT INTO tasks (name, description, category, parameters, status, schedule_config)
VALUES (
'calculate_planetary_events',
'计算太阳系主要天体的合、冲等事件(每周执行一次)',
'data_sync',
'{
"days_ahead": 365,
"clean_old_events": true,
"calculate_close_approaches": false
}'::json,
'active',
'{
"type": "cron",
"cron": "0 2 * * 0"
}'::json
)
ON CONFLICT (name) DO UPDATE SET
parameters = EXCLUDED.parameters,
schedule_config = EXCLUDED.schedule_config;
-- Example 2: Calculate events for inner planets only (30 days ahead, with close approaches)
-- INSERT INTO tasks (name, description, category, parameters, status, schedule_config)
-- VALUES (
-- 'calculate_inner_planetary_events',
-- '计算内行星事件(包括近距离接近)',
-- 'data_sync',
-- '{
-- "body_ids": ["199", "299", "399", "499"],
-- "days_ahead": 30,
-- "clean_old_events": true,
-- "calculate_close_approaches": true,
-- "threshold_degrees": 5.0
-- }'::json,
-- 'active',
-- '{
-- "type": "cron",
-- "cron": "0 3 * * *"
-- }'::json
-- )
-- ON CONFLICT (name) DO NOTHING;
-- Query to check the task was added
SELECT id, name, description, status, parameters, schedule_config
FROM tasks
WHERE name = 'calculate_planetary_events';
-- Query to view calculated events
-- SELECT
-- ce.id,
-- ce.title,
-- ce.event_type,
-- ce.event_time,
-- cb.name as body_name,
-- ce.details,
-- ce.created_at
-- FROM celestial_events ce
-- JOIN celestial_bodies cb ON ce.body_id = cb.id
-- WHERE ce.source = 'skyfield_calculation'
-- ORDER BY ce.event_time;

View File

@ -0,0 +1,24 @@
-- Add short_name column to celestial_bodies table
-- This field stores NASA SBDB API abbreviated names for planets
-- Add column
ALTER TABLE celestial_bodies
ADD COLUMN IF NOT EXISTS short_name VARCHAR(50);
COMMENT ON COLUMN celestial_bodies.short_name IS 'NASA SBDB API short name (e.g., Juptr for Jupiter)';
-- Update short_name for 8 major planets
UPDATE celestial_bodies SET short_name = 'Merc' WHERE id = '199' AND name = 'Mercury';
UPDATE celestial_bodies SET short_name = 'Venus' WHERE id = '299' AND name = 'Venus';
UPDATE celestial_bodies SET short_name = 'Earth' WHERE id = '399' AND name = 'Earth';
UPDATE celestial_bodies SET short_name = 'Mars' WHERE id = '499' AND name = 'Mars';
UPDATE celestial_bodies SET short_name = 'Juptr' WHERE id = '599' AND name = 'Jupiter';
UPDATE celestial_bodies SET short_name = 'Satrn' WHERE id = '699' AND name = 'Saturn';
UPDATE celestial_bodies SET short_name = 'Urnus' WHERE id = '799' AND name = 'Uranus';
UPDATE celestial_bodies SET short_name = 'Neptn' WHERE id = '899' AND name = 'Neptune';
-- Verify the updates
SELECT id, name, name_zh, short_name
FROM celestial_bodies
WHERE short_name IS NOT NULL
ORDER BY CAST(id AS INTEGER);

View File

@ -0,0 +1,48 @@
-- Add unique constraint to celestial_events table to prevent duplicate events
-- This ensures that the same event (same body, type, and time) cannot be inserted twice
-- Step 1: Remove duplicate events (keep the earliest created_at)
WITH duplicates AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY body_id, event_type, DATE_TRUNC('minute', event_time)
ORDER BY created_at ASC
) AS rn
FROM celestial_events
)
DELETE FROM celestial_events
WHERE id IN (
SELECT id FROM duplicates WHERE rn > 1
);
-- Step 2: Add unique constraint
-- Note: We truncate to minute precision for event_time to handle slight variations
-- Create a unique index instead of constraint to allow custom handling
CREATE UNIQUE INDEX IF NOT EXISTS idx_celestial_events_unique
ON celestial_events (
body_id,
event_type,
DATE_TRUNC('minute', event_time)
);
-- Note: For the exact timestamp constraint, use this instead:
-- CREATE UNIQUE INDEX IF NOT EXISTS idx_celestial_events_unique_exact
-- ON celestial_events (body_id, event_type, event_time);
-- Verify the constraint was added
SELECT
indexname,
indexdef
FROM pg_indexes
WHERE tablename = 'celestial_events' AND indexname = 'idx_celestial_events_unique';
-- Check for remaining duplicates
SELECT
body_id,
event_type,
DATE_TRUNC('minute', event_time) as event_time_minute,
COUNT(*) as count
FROM celestial_events
GROUP BY body_id, event_type, DATE_TRUNC('minute', event_time)
HAVING COUNT(*) > 1;

View File

@ -0,0 +1,74 @@
-- 添加新菜单:个人信息 和 我的天体
-- 这两个菜单对普通用户也开放
-- 1. 添加"个人信息"菜单(普通用户可访问)
INSERT INTO menus (name, title, path, icon, parent_id, sort_order, is_active, roles)
VALUES (
'user-profile',
'个人信息',
'/admin/user-profile',
'users',
NULL,
15,
true,
ARRAY['user', 'admin']::varchar[]
)
ON CONFLICT (name) DO UPDATE SET
title = EXCLUDED.title,
path = EXCLUDED.path,
icon = EXCLUDED.icon,
parent_id = EXCLUDED.parent_id,
sort_order = EXCLUDED.sort_order,
roles = EXCLUDED.roles;
-- 2. 添加"我的天体"菜单(普通用户可访问)
INSERT INTO menus (name, title, path, icon, parent_id, sort_order, is_active, roles)
VALUES (
'my-celestial-bodies',
'我的天体',
'/admin/my-celestial-bodies',
'planet',
NULL,
16,
true,
ARRAY['user', 'admin']::varchar[]
)
ON CONFLICT (name) DO UPDATE SET
title = EXCLUDED.title,
path = EXCLUDED.path,
icon = EXCLUDED.icon,
parent_id = EXCLUDED.parent_id,
sort_order = EXCLUDED.sort_order,
roles = EXCLUDED.roles;
-- 3. 添加"修改密码"菜单(普通用户和管理员都可访问)
-- 注意:修改密码功能通过用户下拉菜单访问,不需要在侧边栏显示
-- 但是我们仍然需要在数据库中记录这个菜单以便权限管理
INSERT INTO menus (name, title, path, icon, parent_id, sort_order, is_active, roles)
VALUES (
'change-password',
'修改密码',
'/admin/change-password',
'settings',
NULL,
17,
true,
ARRAY['user', 'admin']::varchar[]
)
ON CONFLICT (name) DO UPDATE SET
title = EXCLUDED.title,
path = EXCLUDED.path,
icon = EXCLUDED.icon,
parent_id = EXCLUDED.parent_id,
sort_order = EXCLUDED.sort_order,
roles = EXCLUDED.roles;
-- 4. 调整其他菜单的排序(可选)
-- 如果需要调整现有菜单的顺序,可以更新 sort_order
UPDATE menus SET sort_order = 18 WHERE name = 'settings' AND sort_order < 18;
-- 5. 查看更新后的菜单列表
SELECT id, name, title, path, icon, parent_id, sort_order, is_active, roles
FROM menus
WHERE is_active = true
ORDER BY sort_order;

View File

@ -0,0 +1,78 @@
-- Clean up duplicate celestial events
-- This script removes duplicate events and adds a unique index to prevent future duplicates
BEGIN;
-- Step 1: Show current duplicate count
SELECT
'Duplicate events before cleanup' as status,
COUNT(*) as total_duplicates
FROM (
SELECT
body_id,
event_type,
DATE_TRUNC('minute', event_time) as event_time_minute,
COUNT(*) as cnt
FROM celestial_events
GROUP BY body_id, event_type, DATE_TRUNC('minute', event_time)
HAVING COUNT(*) > 1
) duplicates;
-- Step 2: Remove duplicate events (keep the earliest created_at)
WITH duplicates AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY body_id, event_type, DATE_TRUNC('minute', event_time)
ORDER BY created_at ASC
) AS rn
FROM celestial_events
)
DELETE FROM celestial_events
WHERE id IN (
SELECT id FROM duplicates WHERE rn > 1
)
RETURNING id;
-- Step 3: Add unique index to prevent future duplicates
CREATE UNIQUE INDEX IF NOT EXISTS idx_celestial_events_unique
ON celestial_events (
body_id,
event_type,
DATE_TRUNC('minute', event_time)
);
-- Step 4: Verify no duplicates remain
SELECT
'Duplicate events after cleanup' as status,
COUNT(*) as total_duplicates
FROM (
SELECT
body_id,
event_type,
DATE_TRUNC('minute', event_time) as event_time_minute,
COUNT(*) as cnt
FROM celestial_events
GROUP BY body_id, event_type, DATE_TRUNC('minute', event_time)
HAVING COUNT(*) > 1
) duplicates;
-- Step 5: Show summary statistics
SELECT
source,
COUNT(*) as total_events,
COUNT(DISTINCT body_id) as unique_bodies,
MIN(event_time) as earliest_event,
MAX(event_time) as latest_event
FROM celestial_events
GROUP BY source
ORDER BY source;
COMMIT;
-- Verify the index was created
SELECT
indexname,
indexdef
FROM pg_indexes
WHERE tablename = 'celestial_events' AND indexname = 'idx_celestial_events_unique';

View File

@ -0,0 +1,56 @@
"""
Optimize orbit data by downsampling excessively detailed orbits
灶神星(Vesta)的轨道数据被过度采样了31,825个点降采样到合理的数量
"""
import asyncio
from sqlalchemy import text
from app.database import engine
async def optimize_vesta_orbit():
"""Downsample Vesta orbit from 31,825 points to ~1,326 points (every 24th point)"""
async with engine.begin() as conn:
# Get current Vesta orbit data
result = await conn.execute(text("""
SELECT points, num_points
FROM orbits
WHERE body_id = '2000004'
"""))
row = result.fetchone()
if not row:
print("❌ Vesta orbit not found")
return
points = row[0] # JSONB array
current_count = row[1]
print(f"当前Vesta轨道点数: {current_count}")
print(f"实际数组长度: {len(points)}")
# Downsample: take every 24th point (0.04 days * 24 ≈ 1 day per point)
downsampled = points[::24]
new_count = len(downsampled)
print(f"降采样后点数: {new_count}")
print(f"数据大小减少: {current_count - new_count}")
print(f"降采样比例: {current_count / new_count:.1f}x")
# Calculate size reduction
import json
old_size = len(json.dumps(points))
new_size = len(json.dumps(downsampled))
print(f"JSON大小: {old_size:,} -> {new_size:,} bytes ({old_size/new_size:.1f}x)")
# Update database
await conn.execute(text("""
UPDATE orbits
SET points = :points, num_points = :num_points
WHERE body_id = '2000004'
"""), {"points": json.dumps(downsampled), "num_points": new_count})
print("✅ Vesta轨道数据已优化")
if __name__ == "__main__":
asyncio.run(optimize_vesta_orbit())

View File

@ -0,0 +1,41 @@
-- Phase 5 Database Schema Changes (Updated)
-- Run this script to add tables for Celestial Events and User Follows
-- Note: Channel messages are now stored in Redis, so no table is created for them.
BEGIN;
-- 1. Celestial Events Table
CREATE TABLE IF NOT EXISTS "public"."celestial_events" (
"id" SERIAL PRIMARY KEY,
"body_id" VARCHAR(50) NOT NULL REFERENCES "public"."celestial_bodies"("id") ON DELETE CASCADE,
"title" VARCHAR(200) NOT NULL,
"event_type" VARCHAR(50) NOT NULL, -- 'approach' (close approach), 'opposition' (冲日), etc.
"event_time" TIMESTAMP NOT NULL,
"description" TEXT,
"details" JSONB, -- Store distance (nominal_dist_au), v_rel, etc.
"source" VARCHAR(50) DEFAULT 'nasa_sbdb',
"created_at" TIMESTAMP DEFAULT NOW()
);
CREATE INDEX "idx_celestial_events_body_id" ON "public"."celestial_events" ("body_id");
CREATE INDEX "idx_celestial_events_time" ON "public"."celestial_events" ("event_time");
COMMENT ON TABLE "public"."celestial_events" IS '天体动态事件表 (如飞掠、冲日)';
-- 2. User Follows Table (Relationships)
CREATE TABLE IF NOT EXISTS "public"."user_follows" (
"user_id" INTEGER NOT NULL REFERENCES "public"."users"("id") ON DELETE CASCADE,
"body_id" VARCHAR(50) NOT NULL REFERENCES "public"."celestial_bodies"("id") ON DELETE CASCADE,
"created_at" TIMESTAMP DEFAULT NOW(),
PRIMARY KEY ("user_id", "body_id")
);
CREATE INDEX "idx_user_follows_user" ON "public"."user_follows" ("user_id");
COMMENT ON TABLE "public"."user_follows" IS '用户关注天体关联表';
-- 3. Ensure 'icon' is in resources check constraint (Idempotent check)
-- Dropping and recreating constraint is the safest way to ensure 'icon' is present if it wasn't
ALTER TABLE "public"."resources" DROP CONSTRAINT IF EXISTS "chk_resource_type";
ALTER TABLE "public"."resources" ADD CONSTRAINT "chk_resource_type"
CHECK (resource_type IN ('texture', 'model', 'icon', 'thumbnail', 'data'));
COMMIT;

View File

@ -0,0 +1,42 @@
"""
Test NASA SBDB API body parameter format
"""
import asyncio
import httpx
async def test_body_param():
"""Test different body parameter formats"""
test_cases = [
("Earth (name)", "Earth"),
("399 (Horizons ID)", "399"),
("Mars (name)", "Mars"),
("499 (Mars Horizons ID)", "499"),
]
for name, body_value in test_cases:
params = {
"date-min": "2025-12-15",
"date-max": "2025-12-16",
"body": body_value,
"limit": "1"
}
try:
async with httpx.AsyncClient(timeout=10.0, proxies={}) as client:
response = await client.get(
"https://ssd-api.jpl.nasa.gov/cad.api",
params=params
)
if response.status_code == 200:
data = response.json()
count = data.get("count", 0)
print(f"{name:30} -> 返回 {count:3} 个结果 ✓")
else:
print(f"{name:30} -> HTTP {response.status_code}")
except Exception as e:
print(f"{name:30} -> 错误: {e}")
if __name__ == "__main__":
asyncio.run(test_body_param())

View File

@ -0,0 +1,51 @@
"""
Test NASA SBDB service directly
"""
import asyncio
from datetime import datetime, timedelta
from app.services.nasa_sbdb_service import nasa_sbdb_service
async def test_nasa_sbdb():
"""Test NASA SBDB API directly"""
# Calculate date range
date_min = datetime.utcnow().strftime("%Y-%m-%d")
date_max = (datetime.utcnow() + timedelta(days=365)).strftime("%Y-%m-%d")
print(f"Querying NASA SBDB for close approaches...")
print(f"Date range: {date_min} to {date_max}")
print(f"Max distance: 1.0 AU")
events = await nasa_sbdb_service.get_close_approaches(
date_min=date_min,
date_max=date_max,
dist_max="1.0",
body="Earth",
limit=10,
fullname=True
)
print(f"\nRetrieved {len(events)} events from NASA SBDB")
if events:
print("\nFirst 3 events:")
for i, event in enumerate(events[:3], 1):
print(f"\n{i}. {event.get('des', 'Unknown')}")
print(f" Full name: {event.get('fullname', 'N/A')}")
print(f" Date: {event.get('cd', 'N/A')}")
print(f" Distance: {event.get('dist', 'N/A')} AU")
print(f" Velocity: {event.get('v_rel', 'N/A')} km/s")
# Test parsing
parsed = nasa_sbdb_service.parse_event_to_celestial_event(event)
if parsed:
print(f" ✓ Parsed successfully")
print(f" Title: {parsed['title']}")
print(f" Body ID: {parsed['body_id']}")
else:
print(f" ✗ Failed to parse")
else:
print("No events found")
if __name__ == "__main__":
asyncio.run(test_nasa_sbdb())

View File

@ -0,0 +1,307 @@
"""
Test script for Phase 5 features
Tests social features (follows, channel messages) and event system
"""
import asyncio
import httpx
import json
from datetime import datetime
BASE_URL = "http://localhost:8000/api"
# Test user credentials (assuming these exist from previous tests)
TEST_USER = {
"username": "testuser",
"password": "testpass123"
}
async def get_auth_token():
"""Login and get JWT token"""
async with httpx.AsyncClient(timeout=30.0, proxies={}) as client:
# Try to register first (in case user doesn't exist)
register_response = await client.post(
f"{BASE_URL}/auth/register",
json={
"username": TEST_USER["username"],
"password": TEST_USER["password"],
"email": "test@example.com"
}
)
# If register fails (user exists), try to login
if register_response.status_code != 200:
response = await client.post(
f"{BASE_URL}/auth/login",
json={
"username": TEST_USER["username"],
"password": TEST_USER["password"]
}
)
else:
response = register_response
if response.status_code == 200:
data = response.json()
return data.get("access_token")
else:
print(f"Login failed: {response.status_code} - {response.text}")
return None
async def test_follow_operations(token):
"""Test user follow operations"""
print("\n=== Testing Follow Operations ===")
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=30.0, proxies={}) as client:
# Test: Follow a celestial body (Mars)
print("\n1. Following Mars (499)...")
response = await client.post(
f"{BASE_URL}/social/follow/499",
headers=headers
)
print(f"Status: {response.status_code}")
if response.status_code in [200, 400]: # 400 if already following
print(f"Response: {response.json()}")
# Test: Get user's follows
print("\n2. Getting user follows...")
response = await client.get(
f"{BASE_URL}/social/follows",
headers=headers
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
follows = response.json()
print(f"Following {len(follows)} bodies:")
for follow in follows[:5]: # Show first 5
print(f" - Body ID: {follow['body_id']}, Since: {follow['created_at']}")
# Test: Check if following Mars
print("\n3. Checking if following Mars...")
response = await client.get(
f"{BASE_URL}/social/follows/check/499",
headers=headers
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
print(f"Response: {response.json()}")
return response.status_code == 200
async def test_channel_messages(token):
"""Test channel message operations"""
print("\n=== Testing Channel Messages ===")
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=30.0, proxies={}) as client:
# Test: Post a message to Mars channel
print("\n1. Posting message to Mars channel...")
message_data = {
"content": f"Test message at {datetime.now().isoformat()}"
}
response = await client.post(
f"{BASE_URL}/social/channel/499/message",
headers=headers,
json=message_data
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
print(f"Response: {response.json()}")
elif response.status_code == 403:
print("Error: User is not following this body (need to follow first)")
# Test: Get channel messages
print("\n2. Getting Mars channel messages...")
response = await client.get(
f"{BASE_URL}/social/channel/499/messages?limit=10",
headers=headers
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
messages = response.json()
print(f"Found {len(messages)} messages:")
for msg in messages[-3:]: # Show last 3
print(f" - {msg['username']}: {msg['content'][:50]}...")
return response.status_code == 200
async def test_celestial_events(token):
"""Test celestial event operations"""
print("\n=== Testing Celestial Events ===")
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=30.0, proxies={}) as client:
# Test: Get upcoming events
print("\n1. Getting upcoming celestial events...")
response = await client.get(
f"{BASE_URL}/events?limit=10",
headers=headers
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
events = response.json()
print(f"Found {len(events)} events:")
for event in events[:5]: # Show first 5
print(f" - {event['title']} at {event['event_time']}")
print(f" Type: {event['event_type']}, Source: {event['source']}")
# Test: Get events for a specific body
print("\n2. Getting events for Mars (499)...")
response = await client.get(
f"{BASE_URL}/events?body_id=499&limit=5",
headers=headers
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
events = response.json()
print(f"Found {len(events)} events for Mars")
return response.status_code == 200
async def test_scheduled_tasks(token):
"""Test scheduled task functionality"""
print("\n=== Testing Scheduled Tasks ===")
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=120.0, proxies={}) as client:
# Test: Get available tasks
print("\n1. Getting available scheduled tasks...")
response = await client.get(
f"{BASE_URL}/scheduled-jobs/available-tasks",
headers=headers
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
tasks = response.json()
print(f"Found {len(tasks)} available tasks")
# Find our Phase 5 task
phase5_task = None
for task in tasks:
if task['name'] == 'fetch_close_approach_events':
phase5_task = task
print(f"\nFound Phase 5 task: {task['name']}")
print(f" Description: {task['description']}")
print(f" Category: {task['category']}")
break
if phase5_task:
# Test: Create a scheduled job for this task
print("\n2. Creating a scheduled job for fetch_close_approach_events...")
job_data = {
"name": "Test Phase 5 Close Approach Events",
"job_type": "predefined",
"predefined_function": "fetch_close_approach_events",
"function_params": {
"days_ahead": 30,
"dist_max": "0.2",
"approach_body": "Earth",
"limit": 50,
"clean_old_events": False
},
"cron_expression": "0 0 * * *", # Daily at midnight
"description": "Test job for Phase 5",
"is_active": False # Don't activate for test
}
response = await client.post(
f"{BASE_URL}/scheduled-jobs",
headers=headers,
json=job_data
)
print(f"Status: {response.status_code}")
if response.status_code == 201:
job = response.json()
job_id = job['id']
print(f"Created job with ID: {job_id}")
# Test: Run the job immediately
print(f"\n3. Triggering job {job_id} to run now...")
print(" (This may take 30-60 seconds...)")
response = await client.post(
f"{BASE_URL}/scheduled-jobs/{job_id}/run",
headers=headers
)
print(f"Status: {response.status_code}")
if response.status_code == 200:
print(f"Response: {response.json()}")
# Wait a bit and check job status
print("\n4. Waiting 60 seconds for job to complete...")
await asyncio.sleep(60)
# Get job status
response = await client.get(
f"{BASE_URL}/scheduled-jobs/{job_id}",
headers=headers
)
if response.status_code == 200:
job_status = response.json()
print(f"Job status: {job_status.get('last_run_status')}")
print(f"Last run at: {job_status.get('last_run_at')}")
# Check if events were created
response = await client.get(
f"{BASE_URL}/events?limit=10",
headers=headers
)
if response.status_code == 200:
events = response.json()
print(f"\nEvents in database: {len(events)}")
for event in events[:3]:
print(f" - {event['title']}")
# Clean up: delete the test job
await client.delete(
f"{BASE_URL}/scheduled-jobs/{job_id}",
headers=headers
)
print(f"\nCleaned up test job {job_id}")
return True
else:
print(f"Error triggering job: {response.text}")
else:
print(f"Error creating job: {response.text}")
return False
async def main():
"""Main test function"""
print("=" * 60)
print("Phase 5 Feature Testing")
print("=" * 60)
# Get authentication token
print("\nAuthenticating...")
token = await get_auth_token()
if not token:
print("ERROR: Failed to authenticate. Please ensure test user exists.")
print("You may need to create a test user first.")
return
print(f"✓ Authentication successful")
# Run tests
results = {
"follow_operations": await test_follow_operations(token),
"channel_messages": await test_channel_messages(token),
"celestial_events": await test_celestial_events(token),
"scheduled_tasks": await test_scheduled_tasks(token)
}
# Summary
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
for test_name, passed in results.items():
status = "✓ PASS" if passed else "✗ FAIL"
print(f"{status} - {test_name}")
total_passed = sum(results.values())
total_tests = len(results)
print(f"\nTotal: {total_passed}/{total_tests} tests passed")
if __name__ == "__main__":
asyncio.run(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,111 @@
## 移除天体事件列表中的小行星编号列
### 修改内容
`/Users/jiliu/WorkSpace/cosmo/frontend/src/pages/admin/CelestialEvents.tsx` 中进行了以下修改:
#### 1. 移除小行星编号列第143-148行已删除
```typescript
// 删除了这一列
{
title: '小行星编号',
dataIndex: ['details', 'designation'],
width: 150,
render: (designation) => designation || '-',
},
```
**原因:**
- 现在有多种事件类型(合、冲、近距离接近等)
- 小行星编号只对某些接近事件有意义
- 对于行星合冲事件,这个字段没有实际价值
#### 2. 更新事件类型支持第97-119行
添加了 `close_approach` 事件类型的显示:
```typescript
const getEventTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'approach': 'blue',
'close_approach': 'magenta', // 新增
'eclipse': 'purple',
'conjunction': 'cyan',
'opposition': 'orange',
'transit': 'green',
};
return colorMap[type] || 'default';
};
const getEventTypeLabel = (type: string) => {
const labelMap: Record<string, string> = {
'approach': '接近',
'close_approach': '近距离接近', // 新增
'eclipse': '食',
'conjunction': '合',
'opposition': '冲',
'transit': '凌',
};
return labelMap[type] || type;
};
```
#### 3. 更新事件类型过滤器第169-175行
在过滤器中添加了 `close_approach` 选项:
```typescript
filters: [
{ text: '接近', value: 'approach' },
{ text: '近距离接近', value: 'close_approach' }, // 新增
{ text: '食', value: 'eclipse' },
{ text: '合', value: 'conjunction' },
{ text: '冲', value: 'opposition' },
],
```
#### 4. 简化搜索功能第76-84行
移除了对小行星编号的搜索支持:
```typescript
// 修改前:搜索标题、描述、小行星编号
item.title.toLowerCase().includes(lowerKeyword) ||
item.description?.toLowerCase().includes(lowerKeyword) ||
item.details?.designation?.toLowerCase().includes(lowerKeyword)
// 修改后:只搜索标题和描述
item.title.toLowerCase().includes(lowerKeyword) ||
item.description?.toLowerCase().includes(lowerKeyword)
```
### 当前列布局
更新后的事件列表包含以下列:
1. **ID** - 事件ID
2. **事件标题** - 完整的事件标题
3. **目标天体** - 关联的天体(带过滤器)
4. **事件类型** - 事件类型标签(带过滤器)
5. **事件时间** - 事件发生时间
6. **距离 (AU)** - 对于接近事件显示距离
7. **数据源** - 事件数据来源
8. **操作** - 删除按钮
### 支持的事件类型
现在支持以下事件类型,每种都有对应的颜色标签:
- 🔵 **接近 (approach)** - 小行星/彗星接近事件来自NASA SBDB
- 🟣 **近距离接近 (close_approach)** - 行星间近距离接近来自Skyfield计算
- 🟣 **食 (eclipse)** - 日食/月食
- 🔵 **合 (conjunction)** - 行星合日来自Skyfield计算
- 🟠 **冲 (opposition)** - 行星冲日来自Skyfield计算
- 🟢 **凌 (transit)** - 行星凌日
### 效果
- ✓ 界面更简洁,去除了对大部分事件无意义的列
- ✓ 专注于通用信息:目标天体、事件类型、时间
- ✓ 支持所有事件类型的显示和过滤
- ✓ 如需要查看详细信息(如小行星编号),可以查看事件的完整描述或详情

View File

@ -6,13 +6,17 @@ import { Login } from './pages/Login';
import { AdminLayout } from './pages/admin/AdminLayout';
import { Dashboard } from './pages/admin/Dashboard';
import { CelestialBodies } from './pages/admin/CelestialBodies';
import { CelestialEvents } from './pages/admin/CelestialEvents';
import { StarSystems } from './pages/admin/StarSystems';
import { StaticData } from './pages/admin/StaticData';
import { Users } from './pages/admin/Users';
import { NASADownload } from './pages/admin/NASADownload';
import { SystemSettings } from './pages/admin/SystemSettings';
import { Tasks } from './pages/admin/Tasks';
import { ScheduledJobs } from './pages/admin/ScheduledJobs'; // Import ScheduledJobs
import { ScheduledJobs } from './pages/admin/ScheduledJobs';
import { UserProfile } from './pages/admin/UserProfile';
import { ChangePassword } from './pages/admin/ChangePassword';
import { MyCelestialBodies } from './pages/admin/MyCelestialBodies';
import { auth } from './utils/auth';
import { ToastProvider } from './contexts/ToastContext';
import App from './App';
@ -36,6 +40,19 @@ export function Router() {
{/* Main app (3D visualization) */}
<Route path="/" element={<App />} />
{/* User routes (protected) */}
<Route
path="/user"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route path="profile" element={<UserProfile />} />
<Route path="follow" element={<MyCelestialBodies />} />
</Route>
{/* Admin routes (protected) */}
<Route
path="/admin"
@ -45,15 +62,16 @@ export function Router() {
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="change-password" element={<ChangePassword />} />
<Route path="celestial-bodies" element={<CelestialBodies />} />
<Route path="celestial-events" element={<CelestialEvents />} />
<Route path="star-systems" element={<StarSystems />} />
<Route path="static-data" element={<StaticData />} />
<Route path="users" element={<Users />} />
<Route path="nasa-data" element={<NASADownload />} />
<Route path="tasks" element={<Tasks />} />
<Route path="scheduled-jobs" element={<ScheduledJobs />} /> {/* Add route */}
<Route path="scheduled-jobs" element={<ScheduledJobs />} />
<Route path="settings" element={<SystemSettings />} />
</Route>

View File

@ -16,6 +16,7 @@ interface CelestialBodyProps {
body: CelestialBodyType;
allBodies: CelestialBodyType[];
isSelected?: boolean;
onBodySelect?: (body: CelestialBodyType) => void;
}
// Saturn Rings component - multiple rings for band effect
@ -77,13 +78,14 @@ function SaturnRings() {
}
// Planet component with texture
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false }: {
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false, onBodySelect }: {
body: CelestialBodyType;
size: number;
emissive: string;
emissiveIntensity: number;
allBodies: CelestialBodyType[];
isSelected?: boolean;
onBodySelect?: (body: CelestialBodyType) => void;
}) {
const meshRef = useRef<Mesh>(null);
const position = body.positions[0];
@ -137,11 +139,12 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
hasOffset={renderPosition.hasOffset}
allBodies={allBodies}
isSelected={isSelected}
onBodySelect={onBodySelect}
/>;
}
// Irregular Comet Nucleus - potato-like shape
function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Texture | null }) {
function IrregularNucleus({ size, texture, onClick }: { size: number; texture: THREE.Texture | null; onClick?: (e: any) => void }) {
const meshRef = useRef<Mesh>(null);
// Create irregular geometry by deforming a sphere
@ -178,7 +181,13 @@ function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Text
});
return (
<mesh ref={meshRef} geometry={geometry}>
<mesh
ref={meshRef}
geometry={geometry}
onClick={onClick}
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
>
{texture ? (
<meshStandardMaterial
map={texture}
@ -275,7 +284,7 @@ function CometComa({ radius }: { radius: number }) {
}
// Separate component to handle texture loading
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false }: {
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false, onBodySelect }: {
body: CelestialBodyType;
size: number;
emissive: string;
@ -287,6 +296,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
hasOffset: boolean;
allBodies: CelestialBodyType[];
isSelected?: boolean;
onBodySelect?: (body: CelestialBodyType) => void;
}) {
// Load texture if path is provided
const texture = texturePath ? useTexture(texturePath) : null;
@ -317,16 +327,30 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
);
}, [body.name, body.name_zh, offsetDesc, distance, labelColor]);
// Handle click event
const handleClick = (e: any) => {
e.stopPropagation();
if (onBodySelect) {
onBodySelect(body);
}
};
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
{/* Use irregular nucleus for comets, regular sphere for others */}
{body.type === 'comet' ? (
<>
<IrregularNucleus size={size} texture={texture} />
<IrregularNucleus size={size} texture={texture} onClick={handleClick} />
<CometComa radius={size} />
</>
) : (
<mesh ref={meshRef} renderOrder={0}>
<mesh
ref={meshRef}
renderOrder={0}
onClick={handleClick}
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
>
<sphereGeometry args={[size, 32, 32]} />
{texture ? (
<meshStandardMaterial
@ -435,7 +459,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
);
}
export function CelestialBody({ body, allBodies, isSelected = false }: CelestialBodyProps) {
export function CelestialBody({ body, allBodies, isSelected = false, onBodySelect }: CelestialBodyProps) {
// Get the current position (use the first position for now)
const position = body.positions[0];
if (!position) return null;
@ -489,6 +513,7 @@ export function CelestialBody({ body, allBodies, isSelected = false }: Celestial
emissiveIntensity={appearance.emissiveIntensity}
allBodies={allBodies}
isSelected={isSelected}
onBodySelect={onBodySelect}
/>
);
}

View File

@ -77,6 +77,13 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
<h2 className="text-xl font-bold text-white tracking-tight">
{body.name_zh || body.name}
</h2>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border ${
isProbe
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
: 'bg-[#238636]/20 border-[#238636]/40 text-[#4ade80]'
}`}>
{isProbe ? '探测器' : '天体'}
</span>
{onViewDetails && (
<button
onClick={(e) => {
@ -86,16 +93,9 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
className="text-gray-400 hover:text-white transition-colors p-1 rounded-full hover:bg-white/10"
title="查看详细信息"
>
<Eye size={16} />
<Eye size={18} />
</button>
)}
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border ${
isProbe
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
: 'bg-[#238636]/20 border-[#238636]/40 text-[#4ade80]'
}`}>
{isProbe ? '探测器' : '天体'}
</span>
</div>
<p className="text-xs text-gray-400 line-clamp-2 leading-relaxed">
{body.description || '暂无描述'}
@ -122,7 +122,7 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
className="px-3 py-2.5 rounded-lg bg-cyan-950/30 text-cyan-400 border border-cyan-500/20 hover:bg-cyan-500/10 hover:border-cyan-500/50 transition-all flex items-center justify-center gap-2 text-[10px] font-mono uppercase tracking-widest group/btn h-[52px]"
title="连接 JPL Horizons System"
>
<Radar size={12} className="group-hover/btn:animate-spin-slow" />
<Radar size={14} className="group-hover/btn:animate-spin-slow" />
<span>JPL Horizons</span>
</button>
</div>

View File

@ -15,16 +15,18 @@ interface ProbeProps {
body: CelestialBody;
allBodies: CelestialBody[];
isSelected?: boolean;
onBodySelect?: (body: CelestialBody) => void;
}
// Separate component for each probe type to properly use hooks
function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, resourceScale = 1.0 }: {
function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, resourceScale = 1.0, onBodySelect }: {
body: CelestialBody;
modelPath: string;
allBodies: CelestialBody[];
isSelected?: boolean;
onError: () => void;
resourceScale?: number;
onBodySelect?: (body: CelestialBody) => void;
}) {
const groupRef = useRef<Group>(null);
const position = body.positions[0];
@ -116,6 +118,14 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
// Get offset description if this probe has one
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
// Handle click event
const handleClick = (e: any) => {
e.stopPropagation();
if (onBodySelect) {
onBodySelect(body);
}
};
// Generate label texture
// eslint-disable-next-line react-hooks/rules-of-hooks
const labelTexture = useMemo(() => {
@ -128,7 +138,13 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
}, [body.name, body.name_zh, offsetDesc, distance]);
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
<group
position={[scaledPos.x, scaledPos.z, scaledPos.y]}
ref={groupRef}
onClick={handleClick}
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
>
<primitive
object={configuredScene}
scale={optimalScale}
@ -160,7 +176,12 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
}
// Fallback component when model is not available
function ProbeFallback({ body, allBodies, isSelected = false }: { body: CelestialBody; allBodies: CelestialBody[]; isSelected?: boolean }) {
function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect }: {
body: CelestialBody;
allBodies: CelestialBody[];
isSelected?: boolean;
onBodySelect?: (body: CelestialBody) => void;
}) {
const position = body.positions[0];
// Use smart render position calculation
@ -176,6 +197,14 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
// Get offset description if this probe has one
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
// Handle click event
const handleClick = (e: any) => {
e.stopPropagation();
if (onBodySelect) {
onBodySelect(body);
}
};
// Generate label texture
const labelTexture = useMemo(() => {
return createLabelTexture(
@ -187,7 +216,12 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
}, [body.name, body.name_zh, offsetDesc, distance]);
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
<group
position={[scaledPos.x, scaledPos.z, scaledPos.y]}
onClick={handleClick}
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
>
<mesh>
<sphereGeometry args={[0.15, 16, 16]} />
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
@ -218,7 +252,7 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
);
}
export function Probe({ body, allBodies, isSelected = false }: ProbeProps) {
export function Probe({ body, allBodies, isSelected = false, onBodySelect }: ProbeProps) {
const position = body.positions[0];
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
const [loadError, setLoadError] = useState<boolean>(false);
@ -270,10 +304,18 @@ export function Probe({ body, allBodies, isSelected = false }: ProbeProps) {
// Use model if available and no load error, otherwise use fallback
if (modelPath && !loadError) {
return <ProbeModel body={body} modelPath={modelPath} allBodies={allBodies} isSelected={isSelected} resourceScale={resourceScale} onError={() => {
return <ProbeModel
body={body}
modelPath={modelPath}
allBodies={allBodies}
isSelected={isSelected}
resourceScale={resourceScale}
onBodySelect={onBodySelect}
onError={() => {
setLoadError(true);
}} />;
}}
/>;
}
return <ProbeFallback body={body} allBodies={allBodies} isSelected={isSelected} />;
return <ProbeFallback body={body} allBodies={allBodies} isSelected={isSelected} onBodySelect={onBodySelect} />;
}

View File

@ -150,6 +150,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
body={body}
allBodies={bodies}
isSelected={selectedBody?.id === body.id}
onBodySelect={onBodySelect}
/>
))}
@ -163,6 +164,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
body={body}
allBodies={bodies}
isSelected={selectedBody?.id === body.id}
onBodySelect={onBodySelect}
/>
))}

View File

@ -3,7 +3,7 @@
*/
import { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Avatar, Dropdown, Modal, Form, Input, Button, message } from 'antd';
import { Layout, Menu, Avatar, Dropdown } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
@ -17,9 +17,12 @@ import {
SettingOutlined,
TeamOutlined,
ControlOutlined,
LockOutlined,
IdcardOutlined,
StarOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { authAPI, request } from '../../utils/request';
import { authAPI } from '../../utils/request';
import { auth } from '../../utils/auth';
import { useToast } from '../../contexts/ToastContext';
@ -35,17 +38,14 @@ const iconMap: Record<string, any> = {
settings: <SettingOutlined />,
users: <TeamOutlined />,
sliders: <ControlOutlined />,
profile: <IdcardOutlined />,
star: <StarOutlined />,
};
export function AdminLayout() {
const [collapsed, setCollapsed] = useState(false);
const [menus, setMenus] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [profileForm] = Form.useForm();
const [passwordForm] = Form.useForm();
const [userProfile, setUserProfile] = useState<any>(null);
const navigate = useNavigate();
const location = useLocation();
const user = auth.getUser();
@ -56,6 +56,16 @@ export function AdminLayout() {
loadMenus();
}, []);
// Redirect to first menu if on root path
useEffect(() => {
if (menus.length > 0 && (location.pathname === '/admin' || location.pathname === '/user')) {
const firstMenu = menus[0];
if (firstMenu.path) {
navigate(firstMenu.path, { replace: true });
}
}
}, [menus, location.pathname, navigate]);
const loadMenus = async () => {
try {
const { data } = await authAPI.getMenus();
@ -101,57 +111,12 @@ export function AdminLayout() {
}
};
const handleProfileClick = async () => {
try {
const { data } = await request.get('/users/me');
setUserProfile(data);
profileForm.setFieldsValue({
username: data.username,
email: data.email || '',
full_name: data.full_name || '',
});
setProfileModalOpen(true);
} catch (error) {
toast.error('获取用户信息失败');
}
};
const handleProfileUpdate = async (values: any) => {
try {
await request.put('/users/me/profile', {
full_name: values.full_name,
email: values.email || null,
});
toast.success('个人信息更新成功');
setProfileModalOpen(false);
// Update local user info
const updatedUser = { ...user, full_name: values.full_name, email: values.email };
auth.setUser(updatedUser);
} catch (error: any) {
toast.error(error.response?.data?.detail || '更新失败');
}
};
const handlePasswordChange = async (values: any) => {
try {
await request.put('/users/me/password', {
old_password: values.old_password,
new_password: values.new_password,
});
toast.success('密码修改成功');
setPasswordModalOpen(false);
passwordForm.resetFields();
} catch (error: any) {
toast.error(error.response?.data?.detail || '密码修改失败');
}
};
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人信息',
onClick: handleProfileClick,
key: 'change-password',
icon: <LockOutlined />,
label: '修改密码',
onClick: () => navigate('/admin/change-password'),
},
{
type: 'divider',
@ -225,108 +190,6 @@ export function AdminLayout() {
<Outlet />
</Content>
</Layout>
{/* Profile Modal */}
<Modal
title="个人信息"
open={profileModalOpen}
onCancel={() => setProfileModalOpen(false)}
footer={null}
width={500}
>
<Form
form={profileForm}
layout="vertical"
onFinish={handleProfileUpdate}
>
<Form.Item label="用户名" name="username">
<Input disabled />
</Form.Item>
<Form.Item
label="昵称"
name="full_name"
rules={[{ max: 50, message: '昵称最长50个字符' }]}
>
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" style={{ marginRight: 8 }}>
</Button>
<Button onClick={() => setPasswordModalOpen(true)}>
</Button>
</Form.Item>
</Form>
</Modal>
{/* Password Change Modal */}
<Modal
title="修改密码"
open={passwordModalOpen}
onCancel={() => {
setPasswordModalOpen(false);
passwordForm.resetFields();
}}
footer={null}
width={450}
>
<Form
form={passwordForm}
layout="vertical"
onFinish={handlePasswordChange}
>
<Form.Item
label="当前密码"
name="old_password"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
<Form.Item
label="新密码"
name="new_password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6位' }
]}
>
<Input.Password placeholder="请输入新密码至少6位" />
</Form.Item>
<Form.Item
label="确认新密码"
name="confirm_password"
dependencies={['new_password']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Modal>
</Layout>
);
}

View File

@ -826,18 +826,83 @@ function ResourceManager({
}, [refreshTrigger, bodyId]);
const resourceTypes = [
{ key: 'texture', label: bodyType === 'probe' ? '纹理 (上传到 model 目录)' : '纹理 (上传到 texture 目录)' },
{ key: 'model', label: bodyType === 'probe' ? '模型 (上传到 model 目录)' : '模型 (上传到 texture 目录)' },
{ key: 'icon', label: '图标 (上传到 icon 目录)', type: 'image' },
{ key: 'texture', label: bodyType === 'probe' ? '纹理 (上传到 model 目录)' : '纹理 (上传到 texture 目录)', type: 'file' },
{ key: 'model', label: bodyType === 'probe' ? '模型 (上传到 model 目录)' : '模型 (上传到 texture 目录)', type: 'file' },
];
return (
<Form.Item label="资源配置">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{resourceTypes.map(({ key, label }) => (
{resourceTypes.map(({ key, label, type }) => (
<div key={key}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{label}</div>
{type === 'image' && currentResources?.[key] && currentResources[key].length > 0 ? (
// Image preview for icon
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<img
src={`/upload/${currentResources[key][0].file_path}`}
alt="Icon preview"
style={{
width: 80,
height: 80,
objectFit: 'contain',
border: '1px solid #d9d9d9',
borderRadius: 4,
padding: 8,
backgroundColor: '#fafafa'
}}
/>
<div>
<Upload
beforeUpload={(file) => onUpload(file, key)}
showUploadList={false}
disabled={uploading}
accept="image/*"
>
<Button icon={<UploadOutlined />} loading={uploading} size="small">
</Button>
</Upload>
<div style={{ marginTop: 8 }}>
<Popconfirm
title="确认删除图标?"
onConfirm={() => onDelete(currentResources[key][0].id)}
okText="删除"
cancelText="取消"
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
({(currentResources[key][0].file_size / 1024).toFixed(2)} KB)
</div>
</div>
</div>
) : type === 'image' ? (
// No icon uploaded yet
<Upload
beforeUpload={(file) => onUpload(file, key)}
showUploadList={false}
disabled={uploading}
accept="image/*"
>
<Button icon={<UploadOutlined />} loading={uploading} size="small">
</Button>
</Upload>
) : (
// File upload for texture/model
<>
<Upload
beforeUpload={(file) => onUpload(file, key)}
showUploadList={false}
@ -907,6 +972,8 @@ function ResourceManager({
))}
</div>
)}
</>
)}
</div>
))}
</Space>

View File

@ -0,0 +1,233 @@
/**
* Celestial Events Management Page
*
*/
import { useState, useEffect } from 'react';
import { Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { DataTable } from '../../components/admin/DataTable';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
interface CelestialEvent {
id: number;
body_id: string;
title: string;
event_type: string;
event_time: string;
description: string;
details: {
designation?: string;
nominal_dist_au?: string;
dist_min_au?: string;
relative_velocity_km_s?: string;
absolute_magnitude?: string;
approach_body?: string;
};
source: string;
created_at: string;
body?: {
id: string;
name: string;
name_zh?: string;
};
}
export function CelestialEvents() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<CelestialEvent[]>([]);
const [filteredData, setFilteredData] = useState<CelestialEvent[]>([]);
const [bodyFilters, setBodyFilters] = useState<{ text: string; value: string }[]>([]);
const toast = useToast();
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const { data: result } = await request.get('/events', {
params: { limit: 500 }
});
setData(result || []);
setFilteredData(result || []);
// Generate body filters from data
const uniqueBodies = new Map<string, { id: string; name: string; name_zh?: string }>();
result?.forEach((event: CelestialEvent) => {
if (event.body && !uniqueBodies.has(event.body.id)) {
uniqueBodies.set(event.body.id, event.body);
}
});
const filters = Array.from(uniqueBodies.values()).map(body => ({
text: body.name_zh || body.name,
value: body.id
}));
setBodyFilters(filters);
} catch (error) {
toast.error('加载事件数据失败');
} finally {
setLoading(false);
}
};
const handleSearch = (keyword: string) => {
const lowerKeyword = keyword.toLowerCase();
const filtered = data.filter(
(item) =>
item.title.toLowerCase().includes(lowerKeyword) ||
item.description?.toLowerCase().includes(lowerKeyword)
);
setFilteredData(filtered);
};
const handleDelete = async (record: CelestialEvent) => {
try {
await request.delete(`/events/${record.id}`);
toast.success('删除成功');
loadData();
} catch (error) {
toast.error('删除失败');
}
};
const getEventTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'approach': 'blue',
'close_approach': 'magenta',
'eclipse': 'purple',
'conjunction': 'cyan',
'opposition': 'orange',
'transit': 'green',
};
return colorMap[type] || 'default';
};
const getEventTypeLabel = (type: string) => {
const labelMap: Record<string, string> = {
'approach': '接近',
'close_approach': '近距离接近',
'eclipse': '食',
'conjunction': '合',
'opposition': '冲',
'transit': '凌',
};
return labelMap[type] || type;
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const columns: ColumnsType<CelestialEvent> = [
{
title: 'ID',
dataIndex: 'id',
width: 70,
sorter: (a, b) => a.id - b.id,
},
{
title: '事件标题',
dataIndex: 'title',
width: 300,
ellipsis: true,
},
{
title: '目标天体',
dataIndex: 'body',
width: 120,
render: (body) => {
if (!body) return '-';
return (
<Tag color="geekblue">
{body.name_zh || body.name}
</Tag>
);
},
filters: bodyFilters,
onFilter: (value, record) => record.body_id === value,
},
{
title: '事件类型',
dataIndex: 'event_type',
width: 120,
render: (type) => (
<Tag color={getEventTypeColor(type)}>
{getEventTypeLabel(type)}
</Tag>
),
filters: [
{ text: '接近', value: 'approach' },
{ text: '近距离接近', value: 'close_approach' },
{ text: '食', value: 'eclipse' },
{ text: '合', value: 'conjunction' },
{ text: '冲', value: 'opposition' },
],
onFilter: (value, record) => record.event_type === value,
},
{
title: '事件时间',
dataIndex: 'event_time',
width: 160,
render: (time) => formatDateTime(time),
sorter: (a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime(),
defaultSortOrder: 'ascend',
},
{
title: '距离 (AU)',
dataIndex: ['details', 'nominal_dist_au'],
width: 120,
render: (dist) => dist ? parseFloat(dist).toFixed(4) : '-',
sorter: (a, b) => {
const distA = parseFloat(a.details?.nominal_dist_au || '999');
const distB = parseFloat(b.details?.nominal_dist_au || '999');
return distA - distB;
},
},
{
title: '相对速度 (km/s)',
dataIndex: ['details', 'relative_velocity_km_s'],
width: 140,
render: (vel) => vel ? parseFloat(vel).toFixed(2) : '-',
},
{
title: '描述',
dataIndex: 'description',
ellipsis: true,
width: 250,
},
{
title: '来源',
dataIndex: 'source',
width: 120,
render: (source) => (
<Tag>{source}</Tag>
),
},
];
return (
<DataTable
title="天体事件"
columns={columns}
dataSource={filteredData}
loading={loading}
total={filteredData.length}
onSearch={handleSearch}
onDelete={handleDelete}
rowKey="id"
pageSize={20}
showAdd={false}
showEdit={false}
/>
);
}

View File

@ -0,0 +1,95 @@
/**
* Change Password Page
*
*/
import { Form, Input, Button, Card } from 'antd';
import { LockOutlined } from '@ant-design/icons';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
export function ChangePassword() {
const [form] = Form.useForm();
const toast = useToast();
const handleSubmit = async (values: any) => {
try {
await request.put('/users/me/password', {
old_password: values.old_password,
new_password: values.new_password,
});
toast.success('密码修改成功');
form.resetFields();
} catch (error: any) {
toast.error(error.response?.data?.detail || '密码修改失败');
}
};
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Card title="修改密码" bordered={false}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
>
<Form.Item
label="当前密码"
name="old_password"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入当前密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item
label="新密码"
name="new_password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入新密码至少6位"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
label="确认新密码"
name="confirm_password"
dependencies={['new_password']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请再次输入新密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}

View File

@ -7,28 +7,32 @@ import { useEffect, useState } from 'react';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
interface DashboardStats {
total_bodies: number;
total_probes: number;
total_users: number;
}
export function Dashboard() {
const [totalUsers, setTotalUsers] = useState<number | null>(null);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const toast = useToast();
useEffect(() => {
const fetchUserCount = async () => {
const fetchStatistics = async () => {
try {
setLoading(true);
// Assuming '/users/count' is the new endpoint we just created in the backend
const response = await request.get('/users/count');
setTotalUsers(response.data.total_users);
const response = await request.get('/system/statistics');
setStats(response.data);
} catch (error) {
console.error('Failed to fetch user count:', error);
toast.error('无法获取用户总数');
setTotalUsers(0); // Set to 0 or handle error display
console.error('Failed to fetch statistics:', error);
toast.error('无法获取统计数据');
} finally {
setLoading(false);
}
};
fetchUserCount();
}, []); // Run once on mount
fetchStatistics();
}, []);
return (
<div>
@ -38,7 +42,8 @@ export function Dashboard() {
<Card>
<Statistic
title="天体总数"
value={18} // Currently hardcoded
value={stats?.total_bodies ?? '-'}
loading={loading}
prefix={<GlobalOutlined />}
/>
</Card>
@ -47,7 +52,8 @@ export function Dashboard() {
<Card>
<Statistic
title="探测器"
value={7} // Currently hardcoded
value={stats?.total_probes ?? '-'}
loading={loading}
prefix={<RocketOutlined />}
/>
</Card>
@ -56,7 +62,7 @@ export function Dashboard() {
<Card>
<Statistic
title="注册用户数"
value={totalUsers !== null ? totalUsers : '-'}
value={stats?.total_users ?? '-'}
loading={loading}
prefix={<UserOutlined />}
/>

View File

@ -0,0 +1,367 @@
/**
* My Celestial Bodies Page (User Follow)
* -
*/
import { useState, useEffect } from 'react';
import { Row, Col, Card, List, Tag, Button, Empty, Descriptions, Table, Space } from 'antd';
import { StarFilled, RocketOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
interface CelestialBody {
id: string;
name: string;
name_zh: string;
type: string;
is_active: boolean;
followed_at?: string;
}
interface CelestialEvent {
id: number;
title: string;
event_type: string;
event_time: string;
description: string;
details: any;
source: string;
}
export function MyCelestialBodies() {
const [loading, setLoading] = useState(false);
const [followedBodies, setFollowedBodies] = useState<CelestialBody[]>([]);
const [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
const [bodyEvents, setBodyEvents] = useState<CelestialEvent[]>([]);
const [eventsLoading, setEventsLoading] = useState(false);
const toast = useToast();
useEffect(() => {
loadFollowedBodies();
}, []);
const loadFollowedBodies = async () => {
setLoading(true);
try {
const { data } = await request.get('/social/follows');
setFollowedBodies(data || []);
// 如果有数据,默认选中第一个
if (data && data.length > 0) {
handleSelectBody(data[0]);
}
} catch (error) {
toast.error('加载关注列表失败');
} finally {
setLoading(false);
}
};
const handleSelectBody = async (body: CelestialBody) => {
setSelectedBody(body);
setEventsLoading(true);
try {
const { data } = await request.get(`/events`, {
params: {
body_id: body.id,
limit: 100
}
});
setBodyEvents(data || []);
} catch (error) {
toast.error('加载天体事件失败');
setBodyEvents([]);
} finally {
setEventsLoading(false);
}
};
const handleUnfollow = async (bodyId: string) => {
try {
await request.delete(`/social/follow/${bodyId}`);
toast.success('已取消关注');
// 重新加载列表
await loadFollowedBodies();
// 如果取消关注的是当前选中的天体,清空右侧显示
if (selectedBody?.id === bodyId) {
setSelectedBody(null);
setBodyEvents([]);
}
} catch (error) {
toast.error('取消关注失败');
}
};
const getBodyTypeLabel = (type: string) => {
const labelMap: Record<string, string> = {
'star': '恒星',
'planet': '行星',
'dwarf_planet': '矮行星',
'satellite': '卫星',
'comet': '彗星',
'asteroid': '小行星',
'probe': '探测器',
};
return labelMap[type] || type;
};
const getBodyTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'star': 'gold',
'planet': 'blue',
'dwarf_planet': 'cyan',
'satellite': 'geekblue',
'comet': 'purple',
'asteroid': 'volcano',
'probe': 'magenta',
};
return colorMap[type] || 'default';
};
const getEventTypeLabel = (type: string) => {
const labelMap: Record<string, string> = {
'approach': '接近',
'close_approach': '近距离接近',
'eclipse': '食',
'conjunction': '合',
'opposition': '冲',
'transit': '凌',
};
return labelMap[type] || type;
};
const getEventTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
'approach': 'blue',
'close_approach': 'magenta',
'eclipse': 'purple',
'conjunction': 'cyan',
'opposition': 'orange',
'transit': 'green',
};
return colorMap[type] || 'default';
};
const eventColumns: ColumnsType<CelestialEvent> = [
{
title: '事件',
dataIndex: 'title',
key: 'title',
ellipsis: true,
width: '40%',
},
{
title: '类型',
dataIndex: 'event_type',
key: 'event_type',
width: 200,
render: (type) => (
<Tag color={getEventTypeColor(type)}>
{getEventTypeLabel(type)}
</Tag>
),
filters: [
{ text: '接近', value: 'approach' },
{ text: '近距离接近', value: 'close_approach' },
{ text: '食', value: 'eclipse' },
{ text: '合', value: 'conjunction' },
{ text: '冲', value: 'opposition' },
{ text: '凌', value: 'transit' },
],
onFilter: (value, record) => record.event_type === value,
},
{
title: '时间',
dataIndex: 'event_time',
key: 'event_time',
width: 180,
render: (time) => new Date(time).toLocaleString('zh-CN'),
sorter: (a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime(),
},
];
return (
<Row gutter={16} style={{ height: 'calc(100vh - 150px)' }}>
{/* 左侧:关注的天体列表 */}
<Col span={8}>
<Card
title={
<Space>
<StarFilled style={{ color: '#faad14' }} />
<span></span>
<Tag color="blue">{followedBodies.length}</Tag>
</Space>
}
extra={
<Button size="small" onClick={loadFollowedBodies} loading={loading}>
</Button>
}
bordered={false}
style={{ height: '100%', overflow: 'hidden' }}
bodyStyle={{ height: 'calc(100% - 57px)', overflowY: 'auto', padding: 0 }}
>
{followedBodies.length === 0 && !loading ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有关注任何天体"
style={{ marginTop: 60 }}
>
<p style={{ color: '#999', margin: '8px 0' }}>
</p>
</Empty>
) : (
<List
dataSource={followedBodies}
loading={loading}
renderItem={(body) => (
<List.Item
key={body.id}
onClick={() => handleSelectBody(body)}
style={{
cursor: 'pointer',
backgroundColor: selectedBody?.id === body.id ? '#f0f5ff' : 'transparent',
padding: '12px 16px',
transition: 'background-color 0.3s',
}}
actions={[
<Button
key="unfollow"
type="link"
danger
size="small"
icon={<StarFilled />}
onClick={(e) => {
e.stopPropagation();
handleUnfollow(body.id);
}}
>
</Button>,
]}
>
<List.Item.Meta
avatar={<StarFilled style={{ color: '#faad14', fontSize: 20 }} />}
title={
<Space>
<span>{body.name_zh || body.name}</span>
<Tag color={getBodyTypeColor(body.type)} style={{ marginLeft: 4 }}>
{getBodyTypeLabel(body.type)}
</Tag>
</Space>
}
description={
body.followed_at
? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}`
: body.name_zh ? body.name : undefined
}
/>
</List.Item>
)}
/>
)}
</Card>
</Col>
{/* 右侧:天体详情和事件 */}
<Col span={16}>
{selectedBody ? (
<Space direction="vertical" size="middle" style={{ width: '100%', height: '100%' }}>
{/* 天体资料 */}
<Card
title={
<Space>
<RocketOutlined />
<span>{selectedBody.name_zh || selectedBody.name}</span>
<Tag color={getBodyTypeColor(selectedBody.type)}>
{getBodyTypeLabel(selectedBody.type)}
</Tag>
</Space>
}
bordered={false}
>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="ID">{selectedBody.id}</Descriptions.Item>
<Descriptions.Item label="类型">
{getBodyTypeLabel(selectedBody.type)}
</Descriptions.Item>
<Descriptions.Item label="中文名">
{selectedBody.name_zh || '-'}
</Descriptions.Item>
<Descriptions.Item label="英文名">
{selectedBody.name}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedBody.is_active ? 'success' : 'default'}>
{selectedBody.is_active ? '活跃' : '已归档'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="关注时间">
{selectedBody.followed_at
? new Date(selectedBody.followed_at).toLocaleString('zh-CN')
: '-'}
</Descriptions.Item>
</Descriptions>
</Card>
{/* 天体事件列表 */}
<Card
title="相关天体事件"
bordered={false}
style={{ marginTop: 16 }}
>
<Table
columns={eventColumns}
dataSource={bodyEvents}
rowKey="id"
loading={eventsLoading}
size="small"
pagination={{
pageSize: 10,
showSizeChanger: false,
showTotal: (total) => `${total}`,
}}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无相关事件"
/>
),
}}
expandable={{
expandedRowRender: (record) => (
<div style={{ padding: '8px 16px' }}>
<p style={{ margin: 0 }}>
<strong></strong>
{record.description}
</p>
{record.details && (
<p style={{ margin: '8px 0 0', color: '#666' }}>
<strong></strong>
{JSON.stringify(record.details, null, 2)}
</p>
)}
</div>
),
}}
/>
</Card>
</Space>
) : (
<Card
bordered={false}
style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="请从左侧选择一个天体查看详情"
/>
</Card>
)}
</Col>
</Row>
);
}

View File

@ -0,0 +1,134 @@
/**
* User Profile Page
*
*/
import { useState, useEffect } from 'react';
import { Form, Input, Button, Card, Avatar, Space, Descriptions, Row, Col } from 'antd';
import { UserOutlined, MailOutlined, IdcardOutlined } from '@ant-design/icons';
import { request } from '../../utils/request';
import { auth } from '../../utils/auth';
import { useToast } from '../../contexts/ToastContext';
export function UserProfile() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [userProfile, setUserProfile] = useState<any>(null);
const toast = useToast();
const user = auth.getUser();
useEffect(() => {
loadUserProfile();
}, []);
const loadUserProfile = async () => {
setLoading(true);
try {
const { data } = await request.get('/users/me');
setUserProfile(data);
form.setFieldsValue({
email: data.email || '',
full_name: data.full_name || '',
});
} catch (error) {
toast.error('获取用户信息失败');
} finally {
setLoading(false);
}
};
const handleSubmit = async (values: any) => {
setLoading(true);
try {
await request.put('/users/me/profile', {
full_name: values.full_name,
email: values.email || null,
});
toast.success('个人信息更新成功');
// Update local user info
const updatedUser = { ...user, full_name: values.full_name, email: values.email };
auth.setUser(updatedUser);
// Reload profile
await loadUserProfile();
} catch (error: any) {
toast.error(error.response?.data?.detail || '更新失败');
} finally {
setLoading(false);
}
};
return (
<Row gutter={24}>
<Col span={8}>
{/* User Avatar and Basic Info Card */}
<Card bordered={false} loading={loading}>
<div style={{ textAlign: 'center' }}>
<Avatar size={100} icon={<UserOutlined />} />
<h2 style={{ marginTop: 24, marginBottom: 8 }}>
{userProfile?.full_name || userProfile?.username || '用户'}
</h2>
<p style={{ color: '#999', marginBottom: 24 }}>
@{userProfile?.username}
</p>
{userProfile && (
<Descriptions column={1} size="small">
<Descriptions.Item label="角色">
{userProfile.role === 'admin' ? '管理员' : '普通用户'}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{new Date(userProfile.created_at).toLocaleString('zh-CN')}
</Descriptions.Item>
</Descriptions>
)}
</div>
</Card>
</Col>
<Col span={16}>
{/* Edit Profile Form */}
<Card title="编辑个人信息" bordered={false}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
>
<Form.Item
label="姓名"
name="full_name"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input
prefix={<IdcardOutlined />}
placeholder="请输入您的姓名"
size="large"
/>
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined />}
placeholder="请输入邮箱地址(可选)"
size="large"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} size="large" block>
</Button>
</Form.Item>
</Form>
</Card>
</Col>
</Row>
);
}

BIN
terminal/.DS_Store vendored 100644

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 447 KiB

After

Width:  |  Height:  |  Size: 447 KiB

View File

Before

Width:  |  Height:  |  Size: 658 KiB

After

Width:  |  Height:  |  Size: 658 KiB

View File

Before

Width:  |  Height:  |  Size: 559 KiB

After

Width:  |  Height:  |  Size: 559 KiB

View File

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 676 KiB

View File

Before

Width:  |  Height:  |  Size: 597 KiB

After

Width:  |  Height:  |  Size: 597 KiB

View File

Before

Width:  |  Height:  |  Size: 401 KiB

After

Width:  |  Height:  |  Size: 401 KiB

BIN
资源文件/.DS_Store vendored 100644

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
资源文件/泥泥星/.DS_Store vendored 100644

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Some files were not shown because too many files have changed in this diff Show More