增加了定时任务
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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 缓存生效。
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}')>"
|
||||
|
|
@ -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}')>"
|
||||
|
|
@ -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}')>"
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
);
|
||||
```
|
||||
|
||||
所有参数现在都会被正确转换为对应的类型。
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 $$;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
|
@ -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)** - 行星凌日
|
||||
|
||||
### 效果
|
||||
|
||||
- ✓ 界面更简洁,去除了对大部分事件无意义的列
|
||||
- ✓ 专注于通用信息:目标天体、事件类型、时间
|
||||
- ✓ 支持所有事件类型的显示和过滤
|
||||
- ✓ 如需要查看详细信息(如小行星编号),可以查看事件的完整描述或详情
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 447 KiB After Width: | Height: | Size: 447 KiB |
|
Before Width: | Height: | Size: 658 KiB After Width: | Height: | Size: 658 KiB |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 559 KiB |
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 676 KiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 401 KiB After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.1 MiB |
|
After Width: | Height: | Size: 5.6 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 6.0 MiB |
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 5.8 MiB |