diff --git a/.DS_Store b/.DS_Store index cc6a312..c6c7610 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a876929..ac9aa93 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/PHASE5_PLAN.md b/PHASE5_PLAN.md new file mode 100644 index 0000000..20babfd --- /dev/null +++ b/PHASE5_PLAN.md @@ -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 缓存生效。 \ No newline at end of file diff --git a/backend/app/api/celestial_position.py b/backend/app/api/celestial_position.py index c249156..1fe5029 100644 --- a/backend/app/api/celestial_position.py +++ b/backend/app/api/celestial_position.py @@ -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,8 +205,25 @@ 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") - # Fall through to query Horizons below + 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) start_str = start_dt.isoformat() if start_dt else "now" @@ -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) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..6d578c5 --- /dev/null +++ b/backend/app/api/deps.py @@ -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"] diff --git a/backend/app/api/event.py b/backend/app/api/event.py new file mode 100644 index 0000000..2ac9a10 --- /dev/null +++ b/backend/app/api/event.py @@ -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 diff --git a/backend/app/api/social.py b/backend/app/api/social.py new file mode 100644 index 0000000..cbfc04b --- /dev/null +++ b/backend/app/api/social.py @@ -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") \ No newline at end of file diff --git a/backend/app/api/system.py b/backend/app/api/system.py index 8e91f0e..e9ecf27 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -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)}" + ) + diff --git a/backend/app/api/user.py b/backend/app/api/user.py index ed625d7..40f47a0 100644 --- a/backend/app/api/user.py +++ b/backend/app/api/user.py @@ -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( diff --git a/backend/app/jobs/predefined.py b/backend/app/jobs/predefined.py index 77d6e89..480195b 100644 --- a/backend/app/jobs/predefined.py +++ b/backend/app/jobs/predefined.py @@ -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") - return { - "success": False, - "message": "This task is reserved for future implementation", - "events_synced": 0 + # 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, + "error": str(e), + "total_events_calculated": total_events_calculated, + "total_events_saved": total_events_saved, + "total_events_failed": total_events_failed + } diff --git a/backend/app/main.py b/backend/app/main.py index 0570156..c630f88 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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" @@ -192,4 +201,4 @@ if __name__ == "__main__": port=8000, reload=True, log_level="info", - ) + ) \ No newline at end of file diff --git a/backend/app/models/db/__init__.py b/backend/app/models/db/__init__.py index e038ace..b8c34d6 100644 --- a/backend/app/models/db/__init__.py +++ b/backend/app/models/db/__init__.py @@ -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", ] diff --git a/backend/app/models/db/celestial_body.py b/backend/app/models/db/celestial_body.py index 51b510a..e7ff0e5 100644 --- a/backend/app/models/db/celestial_body.py +++ b/backend/app/models/db/celestial_body.py @@ -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") @@ -24,7 +25,7 @@ class CelestialBody(Base): extra_data = Column(JSONB, nullable=True, comment="Extended metadata (JSON)") created_at = Column(TIMESTAMP, server_default=func.now()) updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) - + # Relationships star_system = relationship("StarSystem", back_populates="celestial_bodies") positions = relationship( @@ -34,6 +35,7 @@ class CelestialBody(Base): "Resource", back_populates="body", cascade="all, delete-orphan" ) + # Constraints __table_args__ = ( CheckConstraint( @@ -46,4 +48,4 @@ class CelestialBody(Base): ) def __repr__(self): - return f"" + return f"" \ No newline at end of file diff --git a/backend/app/models/db/celestial_event.py b/backend/app/models/db/celestial_event.py new file mode 100644 index 0000000..89a6359 --- /dev/null +++ b/backend/app/models/db/celestial_event.py @@ -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"" diff --git a/backend/app/models/db/user.py b/backend/app/models/db/user.py index 3be873f..cc2dc19 100644 --- a/backend/app/models/db/user.py +++ b/backend/app/models/db/user.py @@ -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"" + return f"" \ No newline at end of file diff --git a/backend/app/models/db/user_follow.py b/backend/app/models/db/user_follow.py new file mode 100644 index 0000000..99f7173 --- /dev/null +++ b/backend/app/models/db/user_follow.py @@ -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"" diff --git a/backend/app/models/schemas/social.py b/backend/app/models/schemas/social.py new file mode 100644 index 0000000..9d22e79 --- /dev/null +++ b/backend/app/models/schemas/social.py @@ -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 diff --git a/backend/app/services/event_service.py b/backend/app/services/event_service.py new file mode 100644 index 0000000..ea92439 --- /dev/null +++ b/backend/app/services/event_service.py @@ -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() diff --git a/backend/app/services/horizons.py b/backend/app/services/horizons.py index 89f2f61..4e8ecf0 100644 --- a/backend/app/services/horizons.py +++ b/backend/app/services/horizons.py @@ -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 """ + # 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 + + 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: - # Set default times - 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 - + # 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,185 +192,47 @@ 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() lines = data_block.split('\n') - + for line in lines: parts = [p.strip() for p in line.split(',')] if len(parts) < 5: continue - + 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 - + x = float(parts[2]) y = float(parts[3]) z = float(parts[4]) - + # Velocity if available (indices 5, 6, 7) vx = float(parts[5]) if len(parts) > 5 else None vy = float(parts[6]) if len(parts) > 6 else None vz = float(parts[7]) if len(parts) > 7 else None - + pos = Position( time=time_obj, - x=x, - y=y, + x=x, + y=y, z=z, vx=vx, vy=vy, 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() \ No newline at end of file diff --git a/backend/app/services/nasa_sbdb_service.py b/backend/app/services/nasa_sbdb_service.py new file mode 100644 index 0000000..eae407e --- /dev/null +++ b/backend/app/services/nasa_sbdb_service.py @@ -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() diff --git a/backend/app/services/nasa_worker.py b/backend/app/services/nasa_worker.py index 9876c7b..b7f74cb 100644 --- a/backend/app/services/nasa_worker.py +++ b/backend/app/services/nasa_worker.py @@ -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)) diff --git a/backend/app/services/orbit_service.py b/backend/app/services/orbit_service.py index 2cfafab..f78a8cd 100644 --- a/backend/app/services/orbit_service.py +++ b/backend/app/services/orbit_service.py @@ -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() - diff --git a/backend/app/services/planetary_events_service.py b/backend/app/services/planetary_events_service.py new file mode 100644 index 0000000..4f8bf0d --- /dev/null +++ b/backend/app/services/planetary_events_service.py @@ -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() diff --git a/backend/app/services/redis_cache.py b/backend/app/services/redis_cache.py index 52066da..b6ba735 100644 --- a/backend/app/services/redis_cache.py +++ b/backend/app/services/redis_cache.py @@ -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() diff --git a/backend/app/services/social_service.py b/backend/app/services/social_service.py new file mode 100644 index 0000000..a8dca54 --- /dev/null +++ b/backend/app/services/social_service.py @@ -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() diff --git a/backend/de421.bsp b/backend/de421.bsp new file mode 100644 index 0000000..ed5b583 Binary files /dev/null and b/backend/de421.bsp differ diff --git a/backend/scripts/FIX_PARAMETER_TYPES.md b/backend/scripts/FIX_PARAMETER_TYPES.md new file mode 100644 index 0000000..8591dc2 --- /dev/null +++ b/backend/scripts/FIX_PARAMETER_TYPES.md @@ -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 +); +``` + +所有参数现在都会被正确转换为对应的类型。 diff --git a/backend/scripts/add_event_fetch_job.sql b/backend/scripts/add_event_fetch_job.sql new file mode 100644 index 0000000..e12e77f --- /dev/null +++ b/backend/scripts/add_event_fetch_job.sql @@ -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; \ No newline at end of file diff --git a/backend/scripts/add_events_menu.sql b/backend/scripts/add_events_menu.sql new file mode 100644 index 0000000..88b5ced --- /dev/null +++ b/backend/scripts/add_events_menu.sql @@ -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 $$; diff --git a/backend/scripts/add_planetary_events_job.sql b/backend/scripts/add_planetary_events_job.sql new file mode 100644 index 0000000..9baccc2 --- /dev/null +++ b/backend/scripts/add_planetary_events_job.sql @@ -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; diff --git a/backend/scripts/add_short_name_column.sql b/backend/scripts/add_short_name_column.sql new file mode 100644 index 0000000..fdcbb0c --- /dev/null +++ b/backend/scripts/add_short_name_column.sql @@ -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); diff --git a/backend/scripts/add_unique_constraint_events.sql b/backend/scripts/add_unique_constraint_events.sql new file mode 100644 index 0000000..f453fa2 --- /dev/null +++ b/backend/scripts/add_unique_constraint_events.sql @@ -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; diff --git a/backend/scripts/add_user_menus.sql b/backend/scripts/add_user_menus.sql new file mode 100644 index 0000000..942499e --- /dev/null +++ b/backend/scripts/add_user_menus.sql @@ -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; diff --git a/backend/scripts/cleanup_duplicate_events.sql b/backend/scripts/cleanup_duplicate_events.sql new file mode 100644 index 0000000..d978bb9 --- /dev/null +++ b/backend/scripts/cleanup_duplicate_events.sql @@ -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'; diff --git a/backend/scripts/optimize_vesta_orbit.py b/backend/scripts/optimize_vesta_orbit.py new file mode 100644 index 0000000..cadf92c --- /dev/null +++ b/backend/scripts/optimize_vesta_orbit.py @@ -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()) diff --git a/backend/scripts/phase5_schema.sql b/backend/scripts/phase5_schema.sql new file mode 100644 index 0000000..81977e3 --- /dev/null +++ b/backend/scripts/phase5_schema.sql @@ -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; \ No newline at end of file diff --git a/backend/test_nasa_body_param.py b/backend/test_nasa_body_param.py new file mode 100644 index 0000000..f735400 --- /dev/null +++ b/backend/test_nasa_body_param.py @@ -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()) diff --git a/backend/test_nasa_sbdb.py b/backend/test_nasa_sbdb.py new file mode 100644 index 0000000..0c0dc4b --- /dev/null +++ b/backend/test_nasa_sbdb.py @@ -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()) diff --git a/backend/test_phase5.py b/backend/test_phase5.py new file mode 100644 index 0000000..3e01809 --- /dev/null +++ b/backend/test_phase5.py @@ -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()) diff --git a/backend/upload/icon/土星小.jpeg b/backend/upload/icon/土星小.jpeg new file mode 100644 index 0000000..baab038 Binary files /dev/null and b/backend/upload/icon/土星小.jpeg differ diff --git a/backend/upload/icon/木星小.jpeg b/backend/upload/icon/木星小.jpeg new file mode 100644 index 0000000..3c3d828 Binary files /dev/null and b/backend/upload/icon/木星小.jpeg differ diff --git a/frontend/REMOVE_ASTEROID_COLUMN.md b/frontend/REMOVE_ASTEROID_COLUMN.md new file mode 100644 index 0000000..4062e94 --- /dev/null +++ b/frontend/REMOVE_ASTEROID_COLUMN.md @@ -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 = { + 'approach': 'blue', + 'close_approach': 'magenta', // 新增 + 'eclipse': 'purple', + 'conjunction': 'cyan', + 'opposition': 'orange', + 'transit': 'green', + }; + return colorMap[type] || 'default'; +}; + +const getEventTypeLabel = (type: string) => { + const labelMap: Record = { + '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)** - 行星凌日 + +### 效果 + +- ✓ 界面更简洁,去除了对大部分事件无意义的列 +- ✓ 专注于通用信息:目标天体、事件类型、时间 +- ✓ 支持所有事件类型的显示和过滤 +- ✓ 如需要查看详细信息(如小行星编号),可以查看事件的完整描述或详情 diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 8db67bc..1a5322a 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -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) */} } /> + {/* User routes (protected) */} + + + + } + > + } /> + } /> + + {/* Admin routes (protected) */} } > - } /> } /> + } /> } /> + } /> } /> } /> } /> } /> } /> - } /> {/* Add route */} + } /> } /> diff --git a/frontend/src/components/CelestialBody.tsx b/frontend/src/components/CelestialBody.tsx index bde531c..0cb0f92 100644 --- a/frontend/src/components/CelestialBody.tsx +++ b/frontend/src/components/CelestialBody.tsx @@ -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(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(null); // Create irregular geometry by deforming a sphere @@ -178,7 +181,13 @@ function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Text }); return ( - + { e.stopPropagation(); document.body.style.cursor = 'pointer'; }} + onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }} + > {texture ? ( 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 ( {/* Use irregular nucleus for comets, regular sphere for others */} {body.type === 'comet' ? ( <> - + ) : ( - + { e.stopPropagation(); document.body.style.cursor = 'pointer'; }} + onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }} + > {texture ? ( ); } diff --git a/frontend/src/components/FocusInfo.tsx b/frontend/src/components/FocusInfo.tsx index 6847dcb..8922968 100644 --- a/frontend/src/components/FocusInfo.tsx +++ b/frontend/src/components/FocusInfo.tsx @@ -77,6 +77,13 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp

{body.name_zh || body.name}

+ + {isProbe ? '探测器' : '天体'} + {onViewDetails && ( )} - - {isProbe ? '探测器' : '天体'} -

{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" > - + JPL Horizons diff --git a/frontend/src/components/Probe.tsx b/frontend/src/components/Probe.tsx index 796a62d..bb3522e 100644 --- a/frontend/src/components/Probe.tsx +++ b/frontend/src/components/Probe.tsx @@ -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(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 ( - + { e.stopPropagation(); document.body.style.cursor = 'pointer'; }} + onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }} + > 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 ( - + { e.stopPropagation(); document.body.style.cursor = 'pointer'; }} + onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }} + > @@ -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(undefined); const [loadError, setLoadError] = useState(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 { - setLoadError(true); - }} />; + return { + setLoadError(true); + }} + />; } - return ; + return ; } diff --git a/frontend/src/components/Scene.tsx b/frontend/src/components/Scene.tsx index f1fa98c..75c602a 100644 --- a/frontend/src/components/Scene.tsx +++ b/frontend/src/components/Scene.tsx @@ -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} /> ))} diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx index 70cf1ae..598b9f5 100644 --- a/frontend/src/pages/admin/AdminLayout.tsx +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -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 = { settings: , users: , sliders: , + profile: , + star: , }; export function AdminLayout() { const [collapsed, setCollapsed] = useState(false); const [menus, setMenus] = useState([]); 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(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: , - label: '个人信息', - onClick: handleProfileClick, + key: 'change-password', + icon: , + label: '修改密码', + onClick: () => navigate('/admin/change-password'), }, { type: 'divider', @@ -225,108 +190,6 @@ export function AdminLayout() { - - {/* Profile Modal */} - setProfileModalOpen(false)} - footer={null} - width={500} - > -

- - - - - - - - - - - - - -
- - - {/* Password Change Modal */} - { - setPasswordModalOpen(false); - passwordForm.resetFields(); - }} - footer={null} - width={450} - > -
- - - - - - - ({ - validator(_, value) { - if (!value || getFieldValue('new_password') === value) { - return Promise.resolve(); - } - return Promise.reject(new Error('两次输入的密码不一致')); - }, - }), - ]} - > - - - - - -
-
); } diff --git a/frontend/src/pages/admin/CelestialBodies.tsx b/frontend/src/pages/admin/CelestialBodies.tsx index 2ccf420..6a00ee7 100644 --- a/frontend/src/pages/admin/CelestialBodies.tsx +++ b/frontend/src/pages/admin/CelestialBodies.tsx @@ -826,86 +826,153 @@ 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 ( - {resourceTypes.map(({ key, label }) => ( + {resourceTypes.map(({ key, label, type }) => (
{label}
- onUpload(file, key)} - showUploadList={false} - disabled={uploading} - > - - - - {currentResources?.[key] && currentResources[key].length > 0 && ( -
- {currentResources[key].map((res: any) => ( -
-
- {res.file_path} - - ({(res.file_size / 1024).toFixed(2)} KB) - - onDelete(res.id)} - okText="删除" - cancelText="取消" + {type === 'image' && currentResources?.[key] && currentResources[key].length > 0 ? ( + // Image preview for icon +
+ Icon preview +
+ onUpload(file, key)} + showUploadList={false} + disabled={uploading} + accept="image/*" + > + + +
+ onDelete(currentResources[key][0].id)} + okText="删除" + cancelText="取消" + > + - -
- {key === 'model' && ( -
- - 显示缩放: - { - // Update scale in resource - const newScale = value || 1.0; - request.put(`/celestial/resources/${res.id}`, { - extra_data: { ...res.extra_data, scale: newScale } - }).then(() => { - toast.success('缩放参数已更新'); - }).catch(() => { - toast.error('更新失败'); - }); - }} - /> - - (推荐: Webb=0.3, 旅行者=1.5) - - -
- )} + 删除图标 + +
- ))} +
+ ({(currentResources[key][0].file_size / 1024).toFixed(2)} KB) +
+
+ ) : type === 'image' ? ( + // No icon uploaded yet + onUpload(file, key)} + showUploadList={false} + disabled={uploading} + accept="image/*" + > + + + ) : ( + // File upload for texture/model + <> + onUpload(file, key)} + showUploadList={false} + disabled={uploading} + > + + + + {currentResources?.[key] && currentResources[key].length > 0 && ( +
+ {currentResources[key].map((res: any) => ( +
+
+ {res.file_path} + + ({(res.file_size / 1024).toFixed(2)} KB) + + onDelete(res.id)} + okText="删除" + cancelText="取消" + > + + +
+ {key === 'model' && ( +
+ + 显示缩放: + { + // Update scale in resource + const newScale = value || 1.0; + request.put(`/celestial/resources/${res.id}`, { + extra_data: { ...res.extra_data, scale: newScale } + }).then(() => { + toast.success('缩放参数已更新'); + }).catch(() => { + toast.error('更新失败'); + }); + }} + /> + + (推荐: Webb=0.3, 旅行者=1.5) + + +
+ )} +
+ ))} +
+ )} + )}
))} diff --git a/frontend/src/pages/admin/CelestialEvents.tsx b/frontend/src/pages/admin/CelestialEvents.tsx new file mode 100644 index 0000000..05afdac --- /dev/null +++ b/frontend/src/pages/admin/CelestialEvents.tsx @@ -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([]); + const [filteredData, setFilteredData] = useState([]); + 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(); + 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 = { + 'approach': 'blue', + 'close_approach': 'magenta', + 'eclipse': 'purple', + 'conjunction': 'cyan', + 'opposition': 'orange', + 'transit': 'green', + }; + return colorMap[type] || 'default'; + }; + + const getEventTypeLabel = (type: string) => { + const labelMap: Record = { + '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 = [ + { + 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 ( + + {body.name_zh || body.name} + + ); + }, + filters: bodyFilters, + onFilter: (value, record) => record.body_id === value, + }, + { + title: '事件类型', + dataIndex: 'event_type', + width: 120, + render: (type) => ( + + {getEventTypeLabel(type)} + + ), + 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) => ( + {source} + ), + }, + ]; + + return ( + + ); +} diff --git a/frontend/src/pages/admin/ChangePassword.tsx b/frontend/src/pages/admin/ChangePassword.tsx new file mode 100644 index 0000000..af3a8cc --- /dev/null +++ b/frontend/src/pages/admin/ChangePassword.tsx @@ -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 ( +
+ +
+ + } + placeholder="请输入当前密码" + autoComplete="current-password" + /> + + + + } + placeholder="请输入新密码(至少6位)" + autoComplete="new-password" + /> + + + ({ + validator(_, value) { + if (!value || getFieldValue('new_password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + } + placeholder="请再次输入新密码" + autoComplete="new-password" + /> + + + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/admin/Dashboard.tsx b/frontend/src/pages/admin/Dashboard.tsx index 570373b..1484802 100644 --- a/frontend/src/pages/admin/Dashboard.tsx +++ b/frontend/src/pages/admin/Dashboard.tsx @@ -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(null); + const [stats, setStats] = useState(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 (
@@ -38,7 +42,8 @@ export function Dashboard() { } /> @@ -47,7 +52,8 @@ export function Dashboard() { } /> @@ -56,7 +62,7 @@ export function Dashboard() { } /> diff --git a/frontend/src/pages/admin/MyCelestialBodies.tsx b/frontend/src/pages/admin/MyCelestialBodies.tsx new file mode 100644 index 0000000..d1e3ae3 --- /dev/null +++ b/frontend/src/pages/admin/MyCelestialBodies.tsx @@ -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([]); + const [selectedBody, setSelectedBody] = useState(null); + const [bodyEvents, setBodyEvents] = useState([]); + 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 = { + 'star': '恒星', + 'planet': '行星', + 'dwarf_planet': '矮行星', + 'satellite': '卫星', + 'comet': '彗星', + 'asteroid': '小行星', + 'probe': '探测器', + }; + return labelMap[type] || type; + }; + + const getBodyTypeColor = (type: string) => { + const colorMap: Record = { + '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 = { + 'approach': '接近', + 'close_approach': '近距离接近', + 'eclipse': '食', + 'conjunction': '合', + 'opposition': '冲', + 'transit': '凌', + }; + return labelMap[type] || type; + }; + + const getEventTypeColor = (type: string) => { + const colorMap: Record = { + 'approach': 'blue', + 'close_approach': 'magenta', + 'eclipse': 'purple', + 'conjunction': 'cyan', + 'opposition': 'orange', + 'transit': 'green', + }; + return colorMap[type] || 'default'; + }; + + const eventColumns: ColumnsType = [ + { + title: '事件', + dataIndex: 'title', + key: 'title', + ellipsis: true, + width: '40%', + }, + { + title: '类型', + dataIndex: 'event_type', + key: 'event_type', + width: 200, + render: (type) => ( + + {getEventTypeLabel(type)} + + ), + 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 ( + + {/* 左侧:关注的天体列表 */} + + + + 我的天体 + {followedBodies.length} + + } + extra={ + + } + bordered={false} + style={{ height: '100%', overflow: 'hidden' }} + bodyStyle={{ height: 'calc(100% - 57px)', overflowY: 'auto', padding: 0 }} + > + {followedBodies.length === 0 && !loading ? ( + +

+ 在主页面点击天体,查看详情后可以关注 +

+
+ ) : ( + ( + handleSelectBody(body)} + style={{ + cursor: 'pointer', + backgroundColor: selectedBody?.id === body.id ? '#f0f5ff' : 'transparent', + padding: '12px 16px', + transition: 'background-color 0.3s', + }} + actions={[ + , + ]} + > + } + title={ + + {body.name_zh || body.name} + + {getBodyTypeLabel(body.type)} + + + } + description={ + body.followed_at + ? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}` + : body.name_zh ? body.name : undefined + } + /> + + )} + /> + )} +
+ + + {/* 右侧:天体详情和事件 */} + + {selectedBody ? ( + + {/* 天体资料 */} + + + {selectedBody.name_zh || selectedBody.name} + + {getBodyTypeLabel(selectedBody.type)} + + + } + bordered={false} + > + + {selectedBody.id} + + {getBodyTypeLabel(selectedBody.type)} + + + {selectedBody.name_zh || '-'} + + + {selectedBody.name} + + + + {selectedBody.is_active ? '活跃' : '已归档'} + + + + {selectedBody.followed_at + ? new Date(selectedBody.followed_at).toLocaleString('zh-CN') + : '-'} + + +
+ + {/* 天体事件列表 */} + + `共 ${total} 条`, + }} + locale={{ + emptyText: ( + + ), + }} + expandable={{ + expandedRowRender: (record) => ( +
+

+ 描述: + {record.description} +

+ {record.details && ( +

+ 详情: + {JSON.stringify(record.details, null, 2)} +

+ )} +
+ ), + }} + /> + + + ) : ( + + + + )} + + + ); +} diff --git a/frontend/src/pages/admin/UserProfile.tsx b/frontend/src/pages/admin/UserProfile.tsx new file mode 100644 index 0000000..d194fe6 --- /dev/null +++ b/frontend/src/pages/admin/UserProfile.tsx @@ -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(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 ( + +
+ {/* User Avatar and Basic Info Card */} + +
+ } /> +

+ {userProfile?.full_name || userProfile?.username || '用户'} +

+

+ @{userProfile?.username} +

+ + {userProfile && ( + + + {userProfile.role === 'admin' ? '管理员' : '普通用户'} + + + {new Date(userProfile.created_at).toLocaleString('zh-CN')} + + + )} +
+
+ + + + {/* Edit Profile Form */} + +
+ + } + placeholder="请输入您的姓名" + size="large" + /> + + + + } + placeholder="请输入邮箱地址(可选)" + size="large" + /> + + + + + + +
+ + + ); +} diff --git a/terminal/.DS_Store b/terminal/.DS_Store new file mode 100644 index 0000000..eca4958 Binary files /dev/null and b/terminal/.DS_Store differ diff --git a/terminal/天体订阅页.png b/terminal/design/天体订阅页.png similarity index 100% rename from terminal/天体订阅页.png rename to terminal/design/天体订阅页.png diff --git a/terminal/天体详情页面.png b/terminal/design/天体详情页面.png similarity index 100% rename from terminal/天体详情页面.png rename to terminal/design/天体详情页面.png diff --git a/terminal/登录连接页.png b/terminal/design/登录连接页.png similarity index 100% rename from terminal/登录连接页.png rename to terminal/design/登录连接页.png diff --git a/terminal/航天器详情页.png b/terminal/design/航天器详情页.png similarity index 100% rename from terminal/航天器详情页.png rename to terminal/design/航天器详情页.png diff --git a/terminal/设置界面.png b/terminal/design/设置界面.png similarity index 100% rename from terminal/设置界面.png rename to terminal/design/设置界面.png diff --git a/terminal/频道消息页.png b/terminal/design/频道消息页.png similarity index 100% rename from terminal/频道消息页.png rename to terminal/design/频道消息页.png diff --git a/资源文件/.DS_Store b/资源文件/.DS_Store new file mode 100644 index 0000000..7b77bcc Binary files /dev/null and b/资源文件/.DS_Store differ diff --git a/资源文件/卫星小.jpeg b/资源文件/卫星小.jpeg new file mode 100644 index 0000000..8e5ba35 Binary files /dev/null and b/资源文件/卫星小.jpeg differ diff --git a/资源文件/哈雷彗星小.jpeg b/资源文件/哈雷彗星小.jpeg new file mode 100644 index 0000000..cb0d0a9 Binary files /dev/null and b/资源文件/哈雷彗星小.jpeg differ diff --git a/资源文件/土星小.jpeg b/资源文件/土星小.jpeg new file mode 100644 index 0000000..baab038 Binary files /dev/null and b/资源文件/土星小.jpeg differ diff --git a/资源文件/地球-图标小.jpeg b/资源文件/地球-图标小.jpeg new file mode 100644 index 0000000..b2233a5 Binary files /dev/null and b/资源文件/地球-图标小.jpeg differ diff --git a/资源文件/地球小.jpeg b/资源文件/地球小.jpeg new file mode 100644 index 0000000..b3e99b1 Binary files /dev/null and b/资源文件/地球小.jpeg differ diff --git a/资源文件/太阳小.jpeg b/资源文件/太阳小.jpeg new file mode 100644 index 0000000..ea4f367 Binary files /dev/null and b/资源文件/太阳小.jpeg differ diff --git a/资源文件/彗星小.jpeg b/资源文件/彗星小.jpeg new file mode 100644 index 0000000..97b692b Binary files /dev/null and b/资源文件/彗星小.jpeg differ diff --git a/资源文件/恒星小.jpeg b/资源文件/恒星小.jpeg new file mode 100644 index 0000000..d31c628 Binary files /dev/null and b/资源文件/恒星小.jpeg differ diff --git a/资源文件/星系小.jpeg b/资源文件/星系小.jpeg new file mode 100644 index 0000000..d50af01 Binary files /dev/null and b/资源文件/星系小.jpeg differ diff --git a/资源文件/木星-2小.jpeg b/资源文件/木星-2小.jpeg new file mode 100644 index 0000000..13a8b97 Binary files /dev/null and b/资源文件/木星-2小.jpeg differ diff --git a/资源文件/木星小.jpeg b/资源文件/木星小.jpeg new file mode 100644 index 0000000..3c3d828 Binary files /dev/null and b/资源文件/木星小.jpeg differ diff --git a/资源文件/泥泥星/.DS_Store b/资源文件/泥泥星/.DS_Store new file mode 100644 index 0000000..3d127b2 Binary files /dev/null and b/资源文件/泥泥星/.DS_Store differ diff --git a/资源文件/泥泥星/NiNi - 3D.png b/资源文件/泥泥星/NiNi - 3D.png new file mode 100644 index 0000000..708ca54 Binary files /dev/null and b/资源文件/泥泥星/NiNi - 3D.png differ diff --git a/资源文件/泥泥星/NiNi - 三视图.jpeg b/资源文件/泥泥星/NiNi - 三视图.jpeg new file mode 100644 index 0000000..06a7c54 Binary files /dev/null and b/资源文件/泥泥星/NiNi - 三视图.jpeg differ diff --git a/资源文件/泥泥星/NiNi - 分解图.jpeg b/资源文件/泥泥星/NiNi - 分解图.jpeg new file mode 100644 index 0000000..93c6593 Binary files /dev/null and b/资源文件/泥泥星/NiNi - 分解图.jpeg differ diff --git a/资源文件/泥泥星/NiNi - 平面.png b/资源文件/泥泥星/NiNi - 平面.png new file mode 100644 index 0000000..52e0172 Binary files /dev/null and b/资源文件/泥泥星/NiNi - 平面.png differ diff --git a/资源文件/泥泥星/NiNi character 3d model.glb b/资源文件/泥泥星/NiNi character 3d model.glb new file mode 100644 index 0000000..4ff288b Binary files /dev/null and b/资源文件/泥泥星/NiNi character 3d model.glb differ diff --git a/资源文件/海王星小.jpeg b/资源文件/海王星小.jpeg new file mode 100644 index 0000000..711fbde Binary files /dev/null and b/资源文件/海王星小.jpeg differ diff --git a/资源文件/火星小.jpeg b/资源文件/火星小.jpeg new file mode 100644 index 0000000..3078909 Binary files /dev/null and b/资源文件/火星小.jpeg differ diff --git a/资源文件/火星车小.jpeg b/资源文件/火星车小.jpeg new file mode 100644 index 0000000..09efc45 Binary files /dev/null and b/资源文件/火星车小.jpeg differ diff --git a/资源文件/知识卡/Ephemeral_Jewel_Saturn_Reborn.pdf b/资源文件/知识卡/Ephemeral_Jewel_Saturn_Reborn.pdf new file mode 100644 index 0000000..81d9d4c Binary files /dev/null and b/资源文件/知识卡/Ephemeral_Jewel_Saturn_Reborn.pdf differ diff --git a/资源文件/知识卡/Expedition_Neptune.pdf b/资源文件/知识卡/Expedition_Neptune.pdf new file mode 100644 index 0000000..bc4c6d5 Binary files /dev/null and b/资源文件/知识卡/Expedition_Neptune.pdf differ diff --git a/资源文件/知识卡/Jupiter_Architect_Guardian.pdf b/资源文件/知识卡/Jupiter_Architect_Guardian.pdf new file mode 100644 index 0000000..77aac48 Binary files /dev/null and b/资源文件/知识卡/Jupiter_Architect_Guardian.pdf differ diff --git a/资源文件/知识卡/Mercury_A_World_of_Extremes.pdf b/资源文件/知识卡/Mercury_A_World_of_Extremes.pdf new file mode 100644 index 0000000..720f59e Binary files /dev/null and b/资源文件/知识卡/Mercury_A_World_of_Extremes.pdf differ diff --git a/资源文件/知识卡/Moon_Core_Revealed.pdf b/资源文件/知识卡/Moon_Core_Revealed.pdf new file mode 100644 index 0000000..a82f331 Binary files /dev/null and b/资源文件/知识卡/Moon_Core_Revealed.pdf differ diff --git a/资源文件/知识卡/The_Blue_Past_of_the_Red_Planet.pdf b/资源文件/知识卡/The_Blue_Past_of_the_Red_Planet.pdf new file mode 100644 index 0000000..0b8b293 Binary files /dev/null and b/资源文件/知识卡/The_Blue_Past_of_the_Red_Planet.pdf differ diff --git a/资源文件/知识卡/Unlocking_the_Ice_Giant.pdf b/资源文件/知识卡/Unlocking_the_Ice_Giant.pdf new file mode 100644 index 0000000..0ba82bb Binary files /dev/null and b/资源文件/知识卡/Unlocking_the_Ice_Giant.pdf differ diff --git a/资源文件/知识卡/Venus_Mysteries_Unveiled_Deep_Space_Exploration.pdf b/资源文件/知识卡/Venus_Mysteries_Unveiled_Deep_Space_Exploration.pdf new file mode 100644 index 0000000..a4257fc Binary files /dev/null and b/资源文件/知识卡/Venus_Mysteries_Unveiled_Deep_Space_Exploration.pdf differ diff --git a/资源文件/知识卡/pluto.png b/资源文件/知识卡/pluto.png new file mode 100644 index 0000000..08b2e68 Binary files /dev/null and b/资源文件/知识卡/pluto.png differ diff --git a/资源文件/知识卡/土星.png b/资源文件/知识卡/土星.png new file mode 100644 index 0000000..8a0cc94 Binary files /dev/null and b/资源文件/知识卡/土星.png differ diff --git a/资源文件/知识卡/地球.png b/资源文件/知识卡/地球.png new file mode 100644 index 0000000..1061c5c Binary files /dev/null and b/资源文件/知识卡/地球.png differ diff --git a/资源文件/知识卡/地球大气深度探索.pdf b/资源文件/知识卡/地球大气深度探索.pdf new file mode 100644 index 0000000..652f3d1 Binary files /dev/null and b/资源文件/知识卡/地球大气深度探索.pdf differ diff --git a/资源文件/知识卡/天王星.png b/资源文件/知识卡/天王星.png new file mode 100644 index 0000000..39a07d2 Binary files /dev/null and b/资源文件/知识卡/天王星.png differ diff --git a/资源文件/知识卡/新视野号揭秘冥王星起源.pdf b/资源文件/知识卡/新视野号揭秘冥王星起源.pdf new file mode 100644 index 0000000..af77c9c Binary files /dev/null and b/资源文件/知识卡/新视野号揭秘冥王星起源.pdf differ diff --git a/资源文件/知识卡/月球.png b/资源文件/知识卡/月球.png new file mode 100644 index 0000000..1f418fc Binary files /dev/null and b/资源文件/知识卡/月球.png differ diff --git a/资源文件/知识卡/木星.png b/资源文件/知识卡/木星.png new file mode 100644 index 0000000..672f264 Binary files /dev/null and b/资源文件/知识卡/木星.png differ diff --git a/资源文件/知识卡/水星.png b/资源文件/知识卡/水星.png new file mode 100644 index 0000000..0a12931 Binary files /dev/null and b/资源文件/知识卡/水星.png differ diff --git a/资源文件/知识卡/海王星.png b/资源文件/知识卡/海王星.png new file mode 100644 index 0000000..9c31136 Binary files /dev/null and b/资源文件/知识卡/海王星.png differ diff --git a/资源文件/知识卡/火星.png b/资源文件/知识卡/火星.png new file mode 100644 index 0000000..f22e759 Binary files /dev/null and b/资源文件/知识卡/火星.png differ diff --git a/资源文件/知识卡/金星.png b/资源文件/知识卡/金星.png new file mode 100644 index 0000000..91dea1b Binary files /dev/null and b/资源文件/知识卡/金星.png differ diff --git a/资源文件/航天飞机小.jpeg b/资源文件/航天飞机小.jpeg new file mode 100644 index 0000000..d1fe2a4 Binary files /dev/null and b/资源文件/航天飞机小.jpeg differ diff --git a/资源文件/行星小.jpeg b/资源文件/行星小.jpeg new file mode 100644 index 0000000..47d449d Binary files /dev/null and b/资源文件/行星小.jpeg differ diff --git a/资源文件/通信卫星小.jpeg b/资源文件/通信卫星小.jpeg new file mode 100644 index 0000000..d09d0a3 Binary files /dev/null and b/资源文件/通信卫星小.jpeg differ diff --git a/资源文件/金星(图标)小.jpeg b/资源文件/金星(图标)小.jpeg new file mode 100644 index 0000000..ef1816c Binary files /dev/null and b/资源文件/金星(图标)小.jpeg differ