From c10efe05884b725325490f3a4c3bbc61055078e8 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Sat, 6 Dec 2025 00:36:39 +0800 Subject: [PATCH] Phase 2 --- .env.example | 2 +- CONFIG.md | 2 +- DATABASE_SCHEMA.md | 699 +++++++++++++++++++++++------- app/api/celestial_body.py | 9 +- app/api/celestial_position.py | 17 +- app/api/nasa_download.py | 101 ++++- app/api/system.py | 51 +++ app/config.py | 3 +- app/models/celestial.py | 23 + app/models/db/celestial_body.py | 1 + app/services/horizons.py | 373 ++++++++++------ app/services/nasa_worker.py | 2 +- app/services/orbit_service.py | 2 +- scripts/check_db_status.py | 68 +++ scripts/check_sun_data.py | 50 +++ scripts/fix_sun_data.py | 58 +++ scripts/inspect_sun.py | 39 ++ scripts/reset_positions.py | 53 +++ upload/texture/2k_saturn_ring.jpg | Bin 0 -> 70548 bytes 19 files changed, 1250 insertions(+), 303 deletions(-) create mode 100644 scripts/check_db_status.py create mode 100644 scripts/check_sun_data.py create mode 100644 scripts/fix_sun_data.py create mode 100644 scripts/inspect_sun.py create mode 100644 scripts/reset_positions.py create mode 100644 upload/texture/2k_saturn_ring.jpg diff --git a/.env.example b/.env.example index 079edfe..e2ce5b5 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Application Settings -APP_NAME=Cosmo - Deep Space Explorer +APP_NAME=COSMO - Deep Space Explorer API_PREFIX=/api # CORS Settings (comma-separated list) diff --git a/CONFIG.md b/CONFIG.md index 4b3f589..9294191 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -51,7 +51,7 @@ REDIS_MAX_CONNECTIONS=50 # 最大连接数 ### 3. 应用配置 ```bash -APP_NAME=Cosmo - Deep Space Explorer +APP_NAME=COSMO - Deep Space Explorer API_PREFIX=/api CORS_ORIGINS=["*"] # 开发环境允许所有来源 CACHE_TTL_DAYS=3 # NASA API 缓存天数 diff --git a/DATABASE_SCHEMA.md b/DATABASE_SCHEMA.md index 43cd0b5..171da46 100644 --- a/DATABASE_SCHEMA.md +++ b/DATABASE_SCHEMA.md @@ -1,15 +1,65 @@ -# Cosmo 数据库表结构设计 +# Cosmo 数据库表结构设计文档 -## 数据库信息 -- **数据库类型**: PostgreSQL 15+ -- **数据库名称**: cosmo_db -- **字符集**: UTF8 +## 📋 文档目录 + +- [1. 数据库信息](#1-数据库信息) +- [2. 数据表索引](#2-数据表索引) +- [3. 核心业务表](#3-核心业务表) + - [3.1 celestial_bodies - 天体基本信息表](#31-celestial_bodies---天体基本信息表) + - [3.2 positions - 位置历史表](#32-positions---位置历史表) + - [3.3 orbits - 轨道路径表](#33-orbits---轨道路径表) + - [3.4 resources - 资源文件管理表](#34-resources---资源文件管理表) + - [3.5 static_data - 静态天文数据表](#35-static_data---静态天文数据表) +- [4. 系统管理表](#4-系统管理表) + - [4.1 users - 用户表](#41-users---用户表) + - [4.2 roles - 角色表](#42-roles---角色表) + - [4.3 user_roles - 用户角色关联表](#43-user_roles---用户角色关联表) + - [4.4 menus - 菜单表](#44-menus---菜单表) + - [4.5 role_menus - 角色菜单关联表](#45-role_menus---角色菜单关联表) + - [4.6 system_settings - 系统配置表](#46-system_settings---系统配置表) + - [4.7 tasks - 后台任务表](#47-tasks---后台任务表) +- [5. 缓存表](#5-缓存表) + - [5.1 nasa_cache - NASA API缓存表](#51-nasa_cache---nasa-api缓存表) +- [6. 数据关系图](#6-数据关系图) +- [7. 初始化脚本](#7-初始化脚本) +- [8. 查询示例](#8-查询示例) +- [9. 维护建议](#9-维护建议) --- -## 表结构 +## 1. 数据库信息 -### 1. celestial_bodies - 天体基本信息表 +- **数据库类型**: PostgreSQL 15+ +- **数据库名称**: cosmo_db +- **字符集**: UTF8 +- **时区**: UTC +- **连接池**: 20 (可配置) + +--- + +## 2. 数据表索引 + +| 序号 | 表名 | 说明 | 记录数量级 | +|------|------|------|-----------| +| 1 | celestial_bodies | 天体基本信息 | 数百 | +| 2 | positions | 天体位置历史(时间序列) | 百万级 | +| 3 | orbits | 轨道路径数据 | 数百 | +| 4 | resources | 资源文件管理 | 数千 | +| 5 | static_data | 静态天文数据 | 数千 | +| 6 | users | 用户账号 | 数千 | +| 7 | roles | 角色定义 | 十位数 | +| 8 | user_roles | 用户角色关联 | 数千 | +| 9 | menus | 菜单配置 | 数十 | +| 10 | role_menus | 角色菜单权限 | 数百 | +| 11 | system_settings | 系统配置参数 | 数十 | +| 12 | tasks | 后台任务 | 数万 | +| 13 | nasa_cache | NASA API缓存 | 数万 | + +--- + +## 3. 核心业务表 + +### 3.1 celestial_bodies - 天体基本信息表 存储所有天体的基本信息和元数据。 @@ -18,25 +68,31 @@ CREATE TABLE celestial_bodies ( id VARCHAR(50) PRIMARY KEY, -- JPL Horizons ID 或自定义ID name VARCHAR(200) NOT NULL, -- 英文名称 name_zh VARCHAR(200), -- 中文名称 - type VARCHAR(50) NOT NULL, -- 天体类型: star, planet, moon, probe, comet, asteroid, etc. + type VARCHAR(50) NOT NULL, -- ���体类型 description TEXT, -- 描述 - metadata JSONB, -- 扩展元数据(launch_date, status, mass, radius等) - is_active bool, -- 天体有效状态 + details TEXT, -- 详细信息(Markdown格式) + metadata JSONB, -- 扩展元数据 + is_active BOOLEAN DEFAULT TRUE, -- 天体有效状态 created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() + updated_at TIMESTAMP DEFAULT NOW(), - CONSTRAINT chk_type CHECK (type IN ('star', 'planet', 'moon', 'probe', 'comet', 'asteroid', 'dwarf_planet', 'satellite')) + CONSTRAINT chk_type CHECK (type IN ( + 'star', 'planet', 'dwarf_planet', 'satellite', + 'probe', 'comet', 'asteroid' + )) ); -- 索引 CREATE INDEX idx_celestial_bodies_type ON celestial_bodies(type); CREATE INDEX idx_celestial_bodies_name ON celestial_bodies(name); +CREATE INDEX idx_celestial_bodies_active ON celestial_bodies(is_active); -- 注释 COMMENT ON TABLE celestial_bodies IS '天体基本信息表'; COMMENT ON COLUMN celestial_bodies.id IS 'JPL Horizons ID(如-31代表Voyager 1)或自定义ID'; -COMMENT ON COLUMN celestial_bodies.type IS '天体类型:star(恒星), planet(行星), moon(卫星), probe(探测器), comet(彗星), asteroid(小行星)'; -COMMENT ON COLUMN celestial_bodies.metadata IS 'JSON格式的扩展元数据,例如:{"launch_date": "1977-09-05", "status": "active", "mass": 722, "radius": 2575}'; +COMMENT ON COLUMN celestial_bodies.type IS '天体类型:star(恒星), planet(行星), dwarf_planet(矮行星), satellite(卫星), probe(探测器), comet(彗星), asteroid(小行星)'; +COMMENT ON COLUMN celestial_bodies.details IS '详细信息,支持Markdown格式,在详情面板中展示'; +COMMENT ON COLUMN celestial_bodies.metadata IS 'JSON格式的扩展元数据'; ``` **metadata JSONB字段示例**: @@ -44,39 +100,44 @@ COMMENT ON COLUMN celestial_bodies.metadata IS 'JSON格式的扩展元数据, { "launch_date": "1977-09-05", "status": "active", - "mass": 722, // kg - "radius": 2575, // km - "orbit_period": 365.25, // days - "rotation_period": 24, // hours + "mass_kg": 722, + "radius_km": 2575, + "orbit_period_days": 365.25, + "rotation_period_hours": 24, "discovery_date": "1930-02-18", - "discoverer": "Clyde Tombaugh" + "discoverer": "Clyde Tombaugh", + "surface_temp_k": 288, + "atmosphere": ["N2", "O2"], + "moons": 1 } ``` --- -### 2. positions - 位置历史表(时间序列) +### 3.2 positions - 位置历史表 -存储天体的位置历史数据,支持历史查询和轨迹回放。 +存储天体的位置历史数据,支持历史查询和轨迹回放。这是一个时间序列表,数据量可达百万级。 ```sql CREATE TABLE positions ( id BIGSERIAL PRIMARY KEY, body_id VARCHAR(50) NOT NULL REFERENCES celestial_bodies(id) ON DELETE CASCADE, - time TIMESTAMP NOT NULL, -- 位置时间点 + time TIMESTAMP NOT NULL, -- 位置时间点(UTC) x DOUBLE PRECISION NOT NULL, -- X坐标(AU,日心坐标系) y DOUBLE PRECISION NOT NULL, -- Y坐标(AU) z DOUBLE PRECISION NOT NULL, -- Z坐标(AU) - vx DOUBLE PRECISION, -- X方向速度(可选) + vx DOUBLE PRECISION, -- X方向速度(AU/day,可选) vy DOUBLE PRECISION, -- Y方向速度(可选) vz DOUBLE PRECISION, -- Z方向速度(可选) source VARCHAR(50) DEFAULT 'nasa_horizons', -- 数据来源 created_at TIMESTAMP DEFAULT NOW(), - CONSTRAINT chk_source CHECK (source IN ('nasa_horizons', 'calculated', 'user_defined', 'imported')) + CONSTRAINT chk_source CHECK (source IN ( + 'nasa_horizons', 'calculated', 'user_defined', 'imported' + )) ); --- 索引(非常重要,用于高效查询) +-- 索引(性能关键!) CREATE INDEX idx_positions_body_time ON positions(body_id, time DESC); CREATE INDEX idx_positions_time ON positions(time); CREATE INDEX idx_positions_body_id ON positions(body_id); @@ -93,10 +154,51 @@ COMMENT ON COLUMN positions.source IS '数据来源:nasa_horizons(NASA API), c - 查询某天体在某时间点的位置 - 查询某天体在时间范围内的轨迹 - 支持时间旅行功能(回放历史位置) +- 轨迹可视化 --- -### 3. resources - 资源文件管理表 +### 3.3 orbits - 轨道路径表 + +存储预计算的轨道路径数据,用于3D可视化渲染。 + +```sql +CREATE TABLE orbits ( + id SERIAL PRIMARY KEY, + body_id VARCHAR(50) NOT NULL REFERENCES celestial_bodies(id) ON DELETE CASCADE, + points JSONB NOT NULL, -- 轨道点数组 [{x, y, z}, ...] + num_points INTEGER NOT NULL, -- 轨道点数量 + period_days DOUBLE PRECISION, -- 轨道周期(天) + color VARCHAR(20), -- 轨道线颜色(HEX) + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT uq_orbits_body_id UNIQUE (body_id) +); + +-- 索引 +CREATE INDEX idx_orbits_body_id ON orbits(body_id); +CREATE INDEX idx_orbits_updated_at ON orbits(updated_at); + +-- 注释 +COMMENT ON TABLE orbits IS '轨道路径数据表'; +COMMENT ON COLUMN orbits.points IS 'JSON数组格式的轨道点:[{"x": 1.0, "y": 0.0, "z": 0.0}, ...]'; +COMMENT ON COLUMN orbits.num_points IS '轨道点数量,用于性能优化'; +COMMENT ON COLUMN orbits.color IS '轨道线显示颜色,HEX格式,如#FF5733'; +``` + +**points JSONB字段示例**: +```json +[ + {"x": 1.0, "y": 0.0, "z": 0.0}, + {"x": 0.99, "y": 0.05, "z": 0.01}, + {"x": 0.97, "y": 0.10, "z": 0.02} +] +``` + +--- + +### 3.4 resources - 资源文件管理表 统一管理纹理、3D模型、图标等静态资源。 @@ -106,13 +208,15 @@ CREATE TABLE resources ( body_id VARCHAR(50) REFERENCES celestial_bodies(id) ON DELETE CASCADE, resource_type VARCHAR(50) NOT NULL, -- 资源类型 file_path VARCHAR(500) NOT NULL, -- 相对于upload目录的路径 - file_size INTEGER, -- 文件大小(bytes) + file_size INTEGER, -- 文��大小(bytes) mime_type VARCHAR(100), -- MIME类型 - metadata JSONB, -- 扩展信息(分辨率、格式等) + metadata JSONB, -- 扩展信息 created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), - CONSTRAINT chk_resource_type CHECK (resource_type IN ('texture', 'model', 'icon', 'thumbnail', 'data')) + CONSTRAINT chk_resource_type CHECK (resource_type IN ( + 'texture', 'model', 'icon', 'thumbnail', 'data' + )) ); -- 索引 @@ -123,7 +227,7 @@ CREATE INDEX idx_resources_type ON resources(resource_type); COMMENT ON TABLE resources IS '资源文件管理表(纹理、模型、图标等)'; COMMENT ON COLUMN resources.resource_type IS '资源类型:texture(纹理), model(3D模型), icon(图标), thumbnail(缩略图), data(数据文件)'; COMMENT ON COLUMN resources.file_path IS '相对路径,例如:textures/planets/earth_2k.jpg'; -COMMENT ON COLUMN resources.metadata IS 'JSON格式元数据,例如:{"width": 2048, "height": 1024, "format": "jpg"}'; +COMMENT ON COLUMN resources.metadata IS 'JSON格式元数据'; ``` **metadata JSONB字段示例**: @@ -134,15 +238,16 @@ COMMENT ON COLUMN resources.metadata IS 'JSON格式元数据,例如:{"width" "format": "jpg", "color_space": "sRGB", "model_format": "glb", - "polygon_count": 15000 + "polygon_count": 15000, + "compression": "gzip" } ``` --- -### 4. static_data - 静态数据表 +### 3.5 static_data - 静态天文数据表 -存储星座、星系、恒星等不需要动态计算的静态天文数据。 +存储星座、星系、恒星等不需要动态计算的静态天文数据��� ```sql CREATE TABLE static_data ( @@ -154,7 +259,9 @@ CREATE TABLE static_data ( created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), - CONSTRAINT chk_category CHECK (category IN ('constellation', 'galaxy', 'star', 'nebula', 'cluster')), + CONSTRAINT chk_category CHECK (category IN ( + 'constellation', 'galaxy', 'star', 'nebula', 'cluster' + )), CONSTRAINT uq_category_name UNIQUE (category, name) ); @@ -169,9 +276,7 @@ COMMENT ON COLUMN static_data.category IS '数据分类:constellation(星座), COMMENT ON COLUMN static_data.data IS 'JSON格式的完整数据,结构根据category不同而不同'; ``` -**data JSONB字段示例**: - -**星座数据**: +**data JSONB字段示例 - 星座**: ```json { "stars": [ @@ -183,28 +288,259 @@ COMMENT ON COLUMN static_data.data IS 'JSON格式的完整数据,结构根据c } ``` -**星系数据**: +**data JSONB字段示例 - 恒星**: ```json { - "type": "spiral", - "distance_mly": 2.537, - "ra": 10.68, - "dec": 41.27, - "magnitude": 3.44, - "diameter_kly": 220, - "color": "#88aaff" + "distance_ly": 4.37, + "ra": 219.90, + "dec": -60.83, + "magnitude": -0.27, + "color": "#FFF8E7", + "spectral_type": "G2V", + "mass_solar": 1.0, + "radius_solar": 1.0 } ``` --- -### 5. nasa_cache - NASA API缓存表 +## 4. 系统管理表 + +### 4.1 users - 用户表 + +存储用户账号信息。 + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, -- 用户名(唯一) + password_hash VARCHAR(255) NOT NULL, -- 密码哈希(bcrypt) + email VARCHAR(255) UNIQUE, -- 邮箱地址 + full_name VARCHAR(100), -- 全名 + is_active BOOLEAN DEFAULT TRUE NOT NULL, -- 账号状态 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + last_login_at TIMESTAMP, -- 最后登录时间 + + CONSTRAINT chk_username_length CHECK (LENGTH(username) >= 3) +); + +-- 索引 +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_active ON users(is_active); + +-- 注释 +COMMENT ON TABLE users IS '用户账号表'; +COMMENT ON COLUMN users.password_hash IS '使用bcrypt加密的密码哈希'; +COMMENT ON COLUMN users.is_active IS '账号激活状态,false表示禁用'; +``` + +--- + +### 4.2 roles - 角色表 + +定义系统角色(如admin、user等)。 + +```sql +CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, -- 角色名称(如'admin') + display_name VARCHAR(100) NOT NULL, -- 显示名称 + description TEXT, -- 角色描述 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 索引 +CREATE INDEX idx_roles_name ON roles(name); + +-- 注释 +COMMENT ON TABLE roles IS '角色定义表'; +COMMENT ON COLUMN roles.name IS '角色标识符,如admin、user、guest'; +COMMENT ON COLUMN roles.display_name IS '显示名称,如管理员、普通用户'; +``` + +**预置角色**: +- `admin`: 系统管理员(全部权限) +- `user`: 普通用户(基础权限) + +--- + +### 4.3 user_roles - 用户角色关联表 + +多对多关系:一个用户可以有多个角色,一个角色可以分配给多个用户。 + +```sql +CREATE TABLE user_roles ( + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + + PRIMARY KEY (user_id, role_id) +); + +-- 索引 +CREATE INDEX idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX idx_user_roles_role_id ON user_roles(role_id); + +-- 注释 +COMMENT ON TABLE user_roles IS '用户角色关联表(多对多)'; +``` + +--- + +### 4.4 menus - 菜单表 + +后台管理菜单配置。 + +```sql +CREATE TABLE menus ( + id SERIAL PRIMARY KEY, + parent_id INTEGER REFERENCES menus(id) ON DELETE CASCADE, -- 父菜单ID + name VARCHAR(100) NOT NULL, -- 菜单名称 + title VARCHAR(100) NOT NULL, -- 显示标题 + icon VARCHAR(100), -- 图标名称 + path VARCHAR(255), -- 路由路径 + component VARCHAR(255), -- 组件路径 + sort_order INTEGER DEFAULT 0 NOT NULL, -- 显示顺序 + is_active BOOLEAN DEFAULT TRUE NOT NULL, -- 菜单状态 + description TEXT, -- 菜单描述 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 索引 +CREATE INDEX idx_menus_parent_id ON menus(parent_id); +CREATE INDEX idx_menus_sort_order ON menus(sort_order); +CREATE INDEX idx_menus_active ON menus(is_active); + +-- 注释 +COMMENT ON TABLE menus IS '后台管理菜单配置表'; +COMMENT ON COLUMN menus.parent_id IS '父菜单ID,NULL表示根菜单'; +COMMENT ON COLUMN menus.path IS '前端路由路径,如/admin/celestial-bodies'; +COMMENT ON COLUMN menus.component IS 'Vue/React组件路径'; +``` + +--- + +### 4.5 role_menus - 角色菜单关联表 + +定义角色可访问的菜单(权限控制)。 + +```sql +CREATE TABLE role_menus ( + id SERIAL PRIMARY KEY, + role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + menu_id INTEGER REFERENCES menus(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT uq_role_menu UNIQUE (role_id, menu_id) +); + +-- 索引 +CREATE INDEX idx_role_menus_role_id ON role_menus(role_id); +CREATE INDEX idx_role_menus_menu_id ON role_menus(menu_id); + +-- 注释 +COMMENT ON TABLE role_menus IS '角色菜单权限关联表'; +``` + +--- + +### 4.6 system_settings - 系统配置表 + +存储平台配置参数,支持动态配置。 + +```sql +CREATE TABLE system_settings ( + id SERIAL PRIMARY KEY, + key VARCHAR(100) UNIQUE NOT NULL, -- 配置键 + value TEXT NOT NULL, -- 配置值 + value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- 值类型 + category VARCHAR(50) NOT NULL DEFAULT 'general', -- 分类 + label VARCHAR(200) NOT NULL, -- 显示标签 + description TEXT, -- 描述 + is_public BOOLEAN DEFAULT FALSE, -- 是否前端可访问 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT chk_value_type CHECK (value_type IN ( + 'string', 'int', 'float', 'bool', 'json' + )) +); + +-- 索引 +CREATE INDEX idx_system_settings_key ON system_settings(key); +CREATE INDEX idx_system_settings_category ON system_settings(category); +CREATE INDEX idx_system_settings_public ON system_settings(is_public); + +-- 注释 +COMMENT ON TABLE system_settings IS '系统配置参数表'; +COMMENT ON COLUMN system_settings.key IS '配置键,如timeline_interval_days'; +COMMENT ON COLUMN system_settings.value_type IS '值类型:string, int, float, bool, json'; +COMMENT ON COLUMN system_settings.is_public IS '是否允许前端访问该配置'; +``` + +**配置示例**: +```sql +INSERT INTO system_settings (key, value, value_type, category, label, description, is_public) VALUES +('timeline_interval_days', '7', 'int', 'visualization', '时间轴播放间隔(天)', '时间轴播放模式下的时间间隔', true), +('max_orbit_points', '500', 'int', 'visualization', '最大轨道点数', '轨道可视化的最大点数', true), +('cache_ttl_hours', '24', 'int', 'cache', '缓存过期时间(小时)', 'Redis缓存的默认过期时间', false); +``` + +--- + +### 4.7 tasks - 后台任务表 + +记录后台异步任务的执行状态。 + +```sql +CREATE TABLE tasks ( + id SERIAL PRIMARY KEY, + task_type VARCHAR(50) NOT NULL, -- 任务类型 + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 任务状态 + description VARCHAR(255), -- 任务描述 + params JSON, -- 输入参数 + result JSON, -- 输出结果 + progress INTEGER DEFAULT 0, -- 进度(0-100) + error_message TEXT, -- 错误信息 + created_by INTEGER, -- 创建用户ID + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + started_at TIMESTAMP, -- 开始时间 + completed_at TIMESTAMP, -- 完成时间 + + CONSTRAINT chk_status CHECK (status IN ( + 'pending', 'running', 'completed', 'failed', 'cancelled' + )), + CONSTRAINT chk_progress CHECK (progress >= 0 AND progress <= 100) +); + +-- 索引 +CREATE INDEX idx_tasks_status ON tasks(status); +CREATE INDEX idx_tasks_type ON tasks(task_type); +CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC); + +-- 注释 +COMMENT ON TABLE tasks IS '后台任务表'; +COMMENT ON COLUMN tasks.task_type IS '任务类型,如nasa_download、orbit_calculate'; +COMMENT ON COLUMN tasks.status IS '任务状态:pending(待执行), running(执行中), completed(已完成), failed(失败), cancelled(已取消)'; +COMMENT ON COLUMN tasks.progress IS '任务进度百分比(0-100)'; +``` + +--- + +## 5. 缓存表 + +### 5.1 nasa_cache - NASA API缓存表 持久化NASA Horizons API的响应结果,减少API调用。 ```sql CREATE TABLE nasa_cache ( - cache_key VARCHAR(500) PRIMARY KEY, -- 缓存键(body_id:start:end:step) + cache_key VARCHAR(500) PRIMARY KEY, -- 缓存键 body_id VARCHAR(50), start_time TIMESTAMP, -- 查询起始时间 end_time TIMESTAMP, -- 查询结束时间 @@ -221,42 +557,46 @@ CREATE INDEX idx_nasa_cache_body_id ON nasa_cache(body_id); CREATE INDEX idx_nasa_cache_expires ON nasa_cache(expires_at); CREATE INDEX idx_nasa_cache_time_range ON nasa_cache(body_id, start_time, end_time); --- 自动清理过期缓存(可选,需要pg_cron扩展) --- SELECT cron.schedule('clean_expired_cache', '0 0 * * *', 'DELETE FROM nasa_cache WHERE expires_at < NOW()'); - -- 注释 COMMENT ON TABLE nasa_cache IS 'NASA Horizons API响应缓存表'; -COMMENT ON COLUMN nasa_cache.cache_key IS '缓存键格式:{body_id}:{start}:{end}:{step},例如:-31:2025-11-27:2025-11-28:1d'; +COMMENT ON COLUMN nasa_cache.cache_key IS '缓存键格式:{body_id}:{start}:{end}:{step}'; COMMENT ON COLUMN nasa_cache.data IS 'NASA API的完整JSON响应'; COMMENT ON COLUMN nasa_cache.expires_at IS '缓存过期时间,过期后自动失效'; ``` --- -## 初始化脚本 +## 6. 数据关系图 + +``` +celestial_bodies (天体) + ├── positions (1:N) - 天体位置历史 + ├── orbits (1:1) - 轨道路径 + └── resources (1:N) - 资源文件 + +users (用户) + └── user_roles (N:M) ←→ roles (角色) + └── role_menus (N:M) ←→ menus (菜单) + +tasks (任务) - 独立表 +system_settings (配置) - 独立表 +static_data (静态数据) - 独立表 +nasa_cache (缓存) - 独立表 +``` + +--- + +## 7. 初始化脚本 ### 创建数据库 -```sql --- 连接到PostgreSQL -psql -U postgres --- 创建数据库 -CREATE DATABASE cosmo_db - WITH - ENCODING = 'UTF8' - LC_COLLATE = 'en_US.UTF-8' - LC_CTYPE = 'en_US.UTF-8' - TEMPLATE = template0; - --- 连接到新数据库 -\c cosmo_db - --- 创建必要的扩展(可选) -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID生成 -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- 模糊搜索 +```bash +# 使用Docker容器 +docker exec -it cosmo_postgres psql -U postgres -c "CREATE DATABASE cosmo_db WITH ENCODING='UTF8';" ``` ### 完整建表脚本 + ```sql -- 按依赖顺序创建表 @@ -267,10 +607,12 @@ CREATE TABLE celestial_bodies ( name_zh VARCHAR(200), type VARCHAR(50) NOT NULL, description TEXT, + details TEXT, metadata JSONB, + is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), - CONSTRAINT chk_type CHECK (type IN ('star', 'planet', 'moon', 'probe', 'comet', 'asteroid', 'dwarf_planet', 'satellite')) + CONSTRAINT chk_type CHECK (type IN ('star', 'planet', 'dwarf_planet', 'satellite', 'probe', 'comet', 'asteroid')) ); CREATE INDEX idx_celestial_bodies_type ON celestial_bodies(type); CREATE INDEX idx_celestial_bodies_name ON celestial_bodies(name); @@ -291,10 +633,22 @@ CREATE TABLE positions ( CONSTRAINT chk_source CHECK (source IN ('nasa_horizons', 'calculated', 'user_defined', 'imported')) ); CREATE INDEX idx_positions_body_time ON positions(body_id, time DESC); -CREATE INDEX idx_positions_time ON positions(time); -CREATE INDEX idx_positions_body_id ON positions(body_id); --- 3. 资源管理表 +-- 3. 轨道路径表 +CREATE TABLE orbits ( + id SERIAL PRIMARY KEY, + body_id VARCHAR(50) NOT NULL REFERENCES celestial_bodies(id) ON DELETE CASCADE, + points JSONB NOT NULL, + num_points INTEGER NOT NULL, + period_days DOUBLE PRECISION, + color VARCHAR(20), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + CONSTRAINT uq_orbits_body_id UNIQUE (body_id) +); +CREATE INDEX idx_orbits_body_id ON orbits(body_id); + +-- 4. 资源管理表 CREATE TABLE resources ( id SERIAL PRIMARY KEY, body_id VARCHAR(50) REFERENCES celestial_bodies(id) ON DELETE CASCADE, @@ -307,10 +661,8 @@ CREATE TABLE resources ( updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT chk_resource_type CHECK (resource_type IN ('texture', 'model', 'icon', 'thumbnail', 'data')) ); -CREATE INDEX idx_resources_body_id ON resources(body_id); -CREATE INDEX idx_resources_type ON resources(resource_type); --- 4. 静态数据表 +-- 5. 静态数据表 CREATE TABLE static_data ( id SERIAL PRIMARY KEY, category VARCHAR(50) NOT NULL, @@ -322,11 +674,95 @@ CREATE TABLE static_data ( CONSTRAINT chk_category CHECK (category IN ('constellation', 'galaxy', 'star', 'nebula', 'cluster')), CONSTRAINT uq_category_name UNIQUE (category, name) ); -CREATE INDEX idx_static_data_category ON static_data(category); -CREATE INDEX idx_static_data_name ON static_data(name); -CREATE INDEX idx_static_data_data ON static_data USING GIN(data); --- 5. NASA缓存表 +-- 6. 用户表 +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE, + full_name VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + last_login_at TIMESTAMP +); + +-- 7. 角色表 +CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + display_name VARCHAR(100) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 8. 用户角色关联表 +CREATE TABLE user_roles ( + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (user_id, role_id) +); + +-- 9. 菜单表 +CREATE TABLE menus ( + id SERIAL PRIMARY KEY, + parent_id INTEGER REFERENCES menus(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + title VARCHAR(100) NOT NULL, + icon VARCHAR(100), + path VARCHAR(255), + component VARCHAR(255), + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + description TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 10. 角色菜单关联表 +CREATE TABLE role_menus ( + id SERIAL PRIMARY KEY, + role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE, + menu_id INTEGER REFERENCES menus(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + CONSTRAINT uq_role_menu UNIQUE (role_id, menu_id) +); + +-- 11. 系统配置表 +CREATE TABLE system_settings ( + id SERIAL PRIMARY KEY, + key VARCHAR(100) UNIQUE NOT NULL, + value TEXT NOT NULL, + value_type VARCHAR(20) NOT NULL DEFAULT 'string', + category VARCHAR(50) NOT NULL DEFAULT 'general', + label VARCHAR(200) NOT NULL, + description TEXT, + is_public BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 12. 后台任务表 +CREATE TABLE tasks ( + id SERIAL PRIMARY KEY, + task_type VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + description VARCHAR(255), + params JSON, + result JSON, + progress INTEGER DEFAULT 0, + error_message TEXT, + created_by INTEGER, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + started_at TIMESTAMP, + completed_at TIMESTAMP +); + +-- 13. NASA缓存表 CREATE TABLE nasa_cache ( cache_key VARCHAR(500) PRIMARY KEY, body_id VARCHAR(50), @@ -335,30 +771,13 @@ CREATE TABLE nasa_cache ( step VARCHAR(10), data JSONB NOT NULL, expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - CONSTRAINT chk_time_range CHECK (end_time >= start_time) + created_at TIMESTAMP DEFAULT NOW() ); -CREATE INDEX idx_nasa_cache_body_id ON nasa_cache(body_id); -CREATE INDEX idx_nasa_cache_expires ON nasa_cache(expires_at); -CREATE INDEX idx_nasa_cache_time_range ON nasa_cache(body_id, start_time, end_time); ``` --- -## 数据关系图 - -``` -celestial_bodies (天体) - ├── positions (1:N) - 天体位置历史 - ├── resources (1:N) - 天体资源文件 - └── nasa_cache (1:N) - NASA API缓存 - -static_data (静态数据) - 独立表,不关联celestial_bodies -``` - ---- - -## 查询示例 +## 8. 查询示例 ### 查询某天体的最新位置 ```sql @@ -373,78 +792,64 @@ LEFT JOIN LATERAL ( WHERE b.id = '-31'; ``` -### 查询某天体在时间范围内的轨迹 +### 查询用户的所有菜单权限 ```sql -SELECT time, x, y, z -FROM positions -WHERE body_id = '-31' - AND time BETWEEN '2025-01-01' AND '2025-12-31' -ORDER BY time; +SELECT DISTINCT m.id, m.name, m.title, m.path, m.icon +FROM users u +JOIN user_roles ur ON u.id = ur.user_id +JOIN role_menus rm ON ur.role_id = rm.role_id +JOIN menus m ON rm.menu_id = m.id +WHERE u.id = 1 AND m.is_active = true +ORDER BY m.sort_order; ``` -### 查询所有带纹理的行星 +### 查询所有运行中的任务 ```sql -SELECT b.name, r.file_path -FROM celestial_bodies b -INNER JOIN resources r ON b.id = r.body_id -WHERE b.type = 'planet' AND r.resource_type = 'texture'; -``` - -### 查询所有活跃的探测器 -```sql -SELECT id, name, name_zh, metadata->>'status' as status -FROM celestial_bodies -WHERE type = 'probe' - AND metadata->>'status' = 'active'; +SELECT id, task_type, description, progress, started_at +FROM tasks +WHERE status = 'running' +ORDER BY started_at DESC; ``` --- -## 维护建议 +## 9. 维护建议 -1. **定期清理过期缓存**: +### 定期清理 ```sql +-- 清理过期缓存 DELETE FROM nasa_cache WHERE expires_at < NOW(); + +-- 清理旧任务记录(保留90天) +DELETE FROM tasks WHERE created_at < NOW() - INTERVAL '90 days' AND status IN ('completed', 'failed'); ``` -2. **分析表性能**: +### 性能优化 ```sql +-- 分析表 ANALYZE celestial_bodies; ANALYZE positions; -ANALYZE nasa_cache; -``` -3. **重建索引(如果性能下降)**: -```sql +-- 重建索引 REINDEX TABLE positions; + +-- 清理死元组 +VACUUM FULL positions; ``` -4. **备份数据库**: +### 备份策略 ```bash +# 每日备份 pg_dump -U postgres cosmo_db > backup_$(date +%Y%m%d).sql + +# 增量备份(推荐使用WAL归档) ``` --- -## 扩展建议 +## 文档版本 -### 未来可能需要的表 - -1. **users** - 用户表(如果需要用户系统) -2. **user_favorites** - 用户收藏(收藏的天体) -3. **observation_logs** - 观测日志(用户记录) -4. **simulation_configs** - 模拟配置(用户自定义场景) - -### 性能优化扩展 - -1. **TimescaleDB** - 时间序列优化 -```sql -CREATE EXTENSION IF NOT EXISTS timescaledb; -SELECT create_hypertable('positions', 'time'); -``` - -2. **PostGIS** - 空间数据扩展 -```sql -CREATE EXTENSION IF NOT EXISTS postgis; -ALTER TABLE positions ADD COLUMN geom geometry(POINTZ, 4326); -``` +- **版本**: 2.0 +- **更新日期**: 2025-12-05 +- **对应阶段**: Phase 2 完成 +- **下一步**: Phase 3 - 恒星际扩展 diff --git a/app/api/celestial_body.py b/app/api/celestial_body.py index 78e7360..ca46c7b 100644 --- a/app/api/celestial_body.py +++ b/app/api/celestial_body.py @@ -25,6 +25,7 @@ class CelestialBodyCreate(BaseModel): name_zh: Optional[str] = None type: str description: Optional[str] = None + details: Optional[str] = None is_active: bool = True extra_data: Optional[Dict[str, Any]] = None @@ -34,6 +35,7 @@ class CelestialBodyUpdate(BaseModel): name_zh: Optional[str] = None type: Optional[str] = None description: Optional[str] = None + details: Optional[str] = None is_active: Optional[bool] = None extra_data: Optional[Dict[str, Any]] = None @@ -58,7 +60,8 @@ async def create_celestial_body( @router.get("/search") async def search_celestial_body( - name: str = Query(..., description="Body name or ID to search in NASA Horizons") + name: str = Query(..., description="Body name or ID to search in NASA Horizons"), + db: AsyncSession = Depends(get_db) ): """ Search for a celestial body in NASA Horizons database by name or ID @@ -68,7 +71,7 @@ async def search_celestial_body( logger.info(f"Searching for celestial body: {name}") try: - result = horizons_service.search_body_by_name(name) + result = await horizons_service.search_body_by_name(name, db) if result["success"]: logger.info(f"Found body: {result['full_name']}") @@ -172,6 +175,7 @@ async def get_body_info(body_id: str, db: AsyncSession = Depends(get_db)): name=body.name, type=body.type, description=body.description, + details=body.details, launch_date=extra_data.get("launch_date"), status=extra_data.get("status"), ) @@ -211,6 +215,7 @@ async def list_bodies( "name_zh": body.name_zh, "type": body.type, "description": body.description, + "details": body.details, "is_active": body.is_active, "resources": resources_by_type, "has_resources": len(resources) > 0, diff --git a/app/api/celestial_position.py b/app/api/celestial_position.py index 70222d2..5fa79be 100644 --- a/app/api/celestial_position.py +++ b/app/api/celestial_position.py @@ -76,7 +76,8 @@ async def get_celestial_positions( # Check Redis cache first (persistent across restarts) start_str = "now" end_str = "now" - redis_key = make_cache_key("positions", start_str, end_str, step) + body_ids_str = body_ids if body_ids else "all" + redis_key = make_cache_key("positions", start_str, end_str, step, body_ids_str) redis_cached = await redis_cache.get(redis_key) if redis_cached is not None: logger.info("Cache hit (Redis) for recent positions") @@ -194,7 +195,8 @@ async def get_celestial_positions( # Cache in Redis for persistence across restarts start_str = start_dt.isoformat() if start_dt else "now" end_str = end_dt.isoformat() if end_dt else "now" - redis_key = make_cache_key("positions", start_str, end_str, step) + body_ids_str = body_ids if body_ids else "all" + redis_key = make_cache_key("positions", start_str, end_str, step, body_ids_str) await redis_cache.set(redis_key, bodies_data, get_ttl_seconds("current_positions")) return CelestialDataResponse(bodies=bodies_data) else: @@ -204,7 +206,8 @@ async def get_celestial_positions( # Check Redis cache first (persistent across restarts) start_str = start_dt.isoformat() if start_dt else "now" end_str = end_dt.isoformat() if end_dt else "now" - redis_key = make_cache_key("positions", start_str, end_str, step) + body_ids_str = body_ids if body_ids else "all" # Include body_ids in cache key + redis_key = make_cache_key("positions", start_str, end_str, step, body_ids_str) redis_cached = await redis_cache.get(redis_key) if redis_cached is not None: logger.info("Cache hit (Redis) for positions") @@ -222,7 +225,9 @@ async def get_celestial_positions( # Filter bodies if body_ids specified if body_id_list: + logger.info(f"Filtering bodies from {len(all_bodies)} total. Requested IDs: {body_id_list}") all_bodies = [b for b in all_bodies if b.id in body_id_list] + logger.info(f"After filtering: {len(all_bodies)} bodies. IDs: {[b.id for b in all_bodies]}") use_db_cache = True db_cached_bodies = [] @@ -334,15 +339,15 @@ async def get_celestial_positions( # Special handling for Cassini (mission ended 2017-09-15) elif body.id == "-82": cassini_date = datetime(2017, 9, 15, 11, 58, 0) - pos_data = horizons_service.get_body_positions(body.id, cassini_date, cassini_date, step) + pos_data = await horizons_service.get_body_positions(body.id, cassini_date, cassini_date, step) positions_list = [ {"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z} for p in pos_data ] else: - # Query NASA Horizons for other bodies - pos_data = horizons_service.get_body_positions(body.id, start_dt, end_dt, step) + # Download from NASA Horizons + pos_data = await horizons_service.get_body_positions(body.id, start_dt, end_dt, step) positions_list = [ {"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z} for p in pos_data diff --git a/app/api/nasa_download.py b/app/api/nasa_download.py index 9bd5635..86d87e0 100644 --- a/app/api/nasa_download.py +++ b/app/api/nasa_download.py @@ -217,7 +217,8 @@ async def download_positions( continue # Download from NASA Horizons - positions = horizons_service.get_body_positions( + logger.info(f"Downloading position for body {body_id} on {date_str}") + positions = await horizons_service.get_body_positions( body_id=body_id, start_time=target_date, end_time=target_date, @@ -225,6 +226,7 @@ async def download_positions( ) if positions and len(positions) > 0: + logger.info(f"Received position data for body {body_id}: x={positions[0].x}, y={positions[0].y}, z={positions[0].z}") # Save to database position_data = [{ "time": target_date, @@ -242,6 +244,17 @@ async def download_positions( source="nasa_horizons", session=db ) + logger.info(f"Saved position for body {body_id} on {date_str}") + + # Invalidate caches for this date to ensure fresh data is served + from app.services.redis_cache import redis_cache, make_cache_key + start_str = target_date.isoformat() + end_str = target_date.isoformat() + # Clear both "all bodies" cache and specific body cache + for body_ids_str in ["all", body_id]: + redis_key = make_cache_key("positions", start_str, end_str, "1d", body_ids_str) + await redis_cache.delete(redis_key) + logger.debug(f"Invalidated cache: {redis_key}") body_results["dates"].append({ "date": date_str, @@ -282,3 +295,89 @@ async def download_positions( except Exception as e: logger.error(f"Download failed: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/delete") +async def delete_positions( + request: DownloadPositionRequest, + db: AsyncSession = Depends(get_db) +): + """ + Delete position data for specified bodies on specified dates + + Args: + - body_ids: List of celestial body IDs + - dates: List of dates (YYYY-MM-DD format) + + Returns: + - Summary of deleted data + """ + logger.info(f"Deleting positions for {len(request.body_ids)} bodies on {len(request.dates)} dates") + + try: + total_deleted = 0 + from sqlalchemy import text + + for body_id in request.body_ids: + # Invalidate caches for this body + from app.services.redis_cache import redis_cache, make_cache_key + + # We need to loop dates to delete specific records + for date_str in request.dates: + try: + # Parse date + target_date = datetime.strptime(date_str, "%Y-%m-%d") + # End of day + end_of_day = target_date.replace(hour=23, minute=59, second=59, microsecond=999999) + + # Execute deletion + # Using text() for raw SQL is often simpler for range deletes, + # but ORM is safer. Let's use ORM with execute. + # But since position_service might not have delete, we do it here. + + stmt = text(""" + DELETE FROM positions + WHERE body_id = :body_id + AND time >= :start_time + AND time <= :end_time + """) + + result = await db.execute(stmt, { + "body_id": body_id, + "start_time": target_date, + "end_time": end_of_day + }) + + deleted_count = result.rowcount + total_deleted += deleted_count + + if deleted_count > 0: + logger.info(f"Deleted {deleted_count} records for {body_id} on {date_str}") + + # Invalidate cache for this specific date/body combo + # Note: This is approximate as cache keys might cover ranges + start_str = target_date.isoformat() + end_str = target_date.isoformat() + # Clear both "all bodies" cache and specific body cache + for body_ids_str in ["all", body_id]: + # We try to clear '1d' step cache + redis_key = make_cache_key("positions", start_str, end_str, "1d", body_ids_str) + await redis_cache.delete(redis_key) + + except Exception as e: + logger.error(f"Failed to delete data for {body_id} on {date_str}: {e}") + + await db.commit() + + # Clear general patterns to be safe if ranges were cached + await redis_cache.clear_pattern("positions:*") + + return { + "message": f"Successfully deleted {total_deleted} position records", + "total_deleted": total_deleted + } + + except Exception as e: + await db.rollback() + logger.error(f"Delete failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/api/system.py b/app/api/system.py index d05afeb..4156cd5 100644 --- a/app/api/system.py +++ b/app/api/system.py @@ -3,7 +3,9 @@ System Settings API Routes """ from fastapi import APIRouter, HTTPException, Query, Depends, status from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func from typing import Optional, Dict, Any, List +from datetime import datetime import logging from pydantic import BaseModel @@ -11,6 +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 logger = logging.getLogger(__name__) @@ -251,3 +254,51 @@ async def initialize_default_settings( await db.commit() return {"message": "Default settings initialized successfully"} + + +@router.get("/data-cutoff-date") +async def get_data_cutoff_date( + db: AsyncSession = Depends(get_db) +): + """ + Get the data cutoff date based on the Sun's (ID=10) last available data + + This endpoint returns the latest date for which we have position data + in the database. It's used by the frontend to determine: + - The current date to display on the homepage + - The maximum date for timeline playback + + Returns: + - cutoff_date: ISO format date string (YYYY-MM-DD) + - timestamp: Unix timestamp + - datetime: Full ISO datetime string + """ + try: + # Query the latest position data for the Sun (body_id = 10) + stmt = select(func.max(Position.time)).where( + Position.body_id == '10' + ) + result = await db.execute(stmt) + latest_time = result.scalar_one_or_none() + + if latest_time is None: + # No data available, return current date as fallback + logger.warning("No position data found for Sun (ID=10), using current date as fallback") + latest_time = datetime.utcnow() + + # Format the response + cutoff_date = latest_time.strftime("%Y-%m-%d") + + return { + "cutoff_date": cutoff_date, + "timestamp": int(latest_time.timestamp()), + "datetime": latest_time.isoformat(), + "message": "Data cutoff date retrieved successfully" + } + + except Exception as e: + logger.error(f"Error retrieving data cutoff date: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve data cutoff date: {str(e)}" + ) diff --git a/app/config.py b/app/config.py index 7f1358e..caa994b 100644 --- a/app/config.py +++ b/app/config.py @@ -16,7 +16,7 @@ class Settings(BaseSettings): ) # Application - app_name: str = "Cosmo - Deep Space Explorer" + app_name: str = "COSMO - Deep Space Explorer" api_prefix: str = "/api" # CORS settings - stored as string in env, converted to list @@ -67,6 +67,7 @@ class Settings(BaseSettings): # Proxy settings (for accessing NASA JPL Horizons API in China) http_proxy: str = "" https_proxy: str = "" + nasa_api_timeout: int = 30 @property def proxy_dict(self) -> dict[str, str] | None: diff --git a/app/models/celestial.py b/app/models/celestial.py index e6ca46c..e6d590f 100644 --- a/app/models/celestial.py +++ b/app/models/celestial.py @@ -45,6 +45,7 @@ class BodyInfo(BaseModel): name: str type: Literal["planet", "probe", "star", "dwarf_planet", "satellite", "comet"] description: str + details: str | None = None launch_date: str | None = None status: str | None = None @@ -200,4 +201,26 @@ CELESTIAL_BODIES = { "type": "dwarf_planet", "description": "鸟神星,柯伊伯带中第二亮的天体", }, + # Comets / Interstellar Objects + "1I": { + "name": "1I/'Oumuamua", + "name_zh": "奥陌陌", + "type": "comet", + "description": "原定名 1I/2017 U1,是已知第一颗经过太阳系的星际天体。它于2017年10月18日(UT)在距离地球约0.2 AU(30,000,000 km;19,000,000 mi)处被泛星1号望远镜发现,并在极端双曲线的轨道上运行。", + "status": "active", + }, + "3I": { + "name": "3I/ATLAS", + "name_zh": "3I/ATLAS", + "type": "comet", + "description": "又称C/2025 N1 (ATLAS),是一颗星际彗星,由位于智利里奥乌尔塔多的小行星陆地撞击持续报警系统于2025年7月1日发现", + "status": "active", + }, + "90000030": { + "name": "1P/Halley", + "name_zh": "哈雷彗星", + "type": "comet", + "description": "哈雷彗星(正式名称为1P/Halley)是著名的短周期彗星,每隔75-76年就能从地球上被观测到[5],亦是唯一能用肉眼直接从地球看到的短周期彗星,人的一生中可能经历两次其来访。", + "status": "active", + }, } diff --git a/app/models/db/celestial_body.py b/app/models/db/celestial_body.py index 12d343f..266178b 100644 --- a/app/models/db/celestial_body.py +++ b/app/models/db/celestial_body.py @@ -18,6 +18,7 @@ class CelestialBody(Base): name_zh = Column(String(200), nullable=True, comment="Chinese name") type = Column(String(50), nullable=False, comment="Body type") description = Column(Text, nullable=True, comment="Description") + details = Column(Text, nullable=True, comment="Detailed description (Markdown)") is_active = Column(Boolean, nullable=True, comment="Active status for probes (True=active, False=inactive)") extra_data = Column(JSONB, nullable=True, comment="Extended metadata (JSON)") created_at = Column(TIMESTAMP, server_default=func.now()) diff --git a/app/services/horizons.py b/app/services/horizons.py index ed678be..89fd320 100644 --- a/app/services/horizons.py +++ b/app/services/horizons.py @@ -2,12 +2,12 @@ NASA JPL Horizons data query service """ from datetime import datetime, timedelta -from astroquery.jplhorizons import Horizons from astropy.time import Time import logging import re import httpx import os +from sqlalchemy.ext.asyncio import AsyncSession # Added this import from app.models.celestial import Position, CelestialBody from app.config import settings @@ -21,15 +21,7 @@ class HorizonsService: def __init__(self): """Initialize the service""" self.location = "@sun" # Heliocentric coordinates - - # Set proxy for astroquery if configured - # astroquery uses standard HTTP_PROXY and HTTPS_PROXY environment variables - if settings.http_proxy: - os.environ['HTTP_PROXY'] = settings.http_proxy - logger.info(f"Set HTTP_PROXY for astroquery: {settings.http_proxy}") - if settings.https_proxy: - os.environ['HTTPS_PROXY'] = settings.https_proxy - logger.info(f"Set HTTPS_PROXY for astroquery: {settings.https_proxy}") + # Proxy is handled via settings.proxy_dict in each request async def get_object_data_raw(self, body_id: str) -> str: """ @@ -56,13 +48,13 @@ class HorizonsService: try: # Configure proxy if available - client_kwargs = {"timeout": 5.0} + client_kwargs = {"timeout": settings.nasa_api_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: - logger.info(f"Fetching raw data for body {body_id}") + logger.info(f"Fetching raw data for body {body_id} with timeout {settings.nasa_api_timeout}s") response = await client.get(url, params=params) if response.status_code != 200: @@ -73,7 +65,7 @@ class HorizonsService: logger.error(f"Error fetching raw data for {body_id}: {str(e)}") raise - def get_body_positions( + async def get_body_positions( self, body_id: str, start_time: datetime | None = None, @@ -99,157 +91,254 @@ class HorizonsService: if end_time is None: end_time = start_time - # Convert to astropy Time objects for single point queries - # For ranges, use ISO format strings which Horizons prefers + # 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 - # Create time range - if start_time == end_time: - # Single time point - use JD format - epochs = Time(start_time).jd + 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: - # Time range - use ISO format (YYYY-MM-DD HH:MM) - # Horizons expects this format for ranges - start_str = start_time.strftime('%Y-%m-%d %H:%M') - end_str = end_time.strftime('%Y-%m-%d %H:%M') - epochs = {"start": start_str, "stop": end_str, "step": step} + # Multi-day range query + start_str = start_time.strftime('%Y-%m-%d') + end_str = end_time.strftime('%Y-%m-%d') - logger.info(f"Querying Horizons for body {body_id} from {start_time} to {end_time}") + logger.info(f"Querying Horizons (httpx) for body {body_id} from {start_str} to {end_str}") - # Query JPL Horizons - obj = Horizons(id=body_id, location=self.location, epochs=epochs) - vectors = obj.vectors() + url = "https://ssd.jpl.nasa.gov/api/horizons.api" + cmd_val = f"'{body_id}'" if not body_id.startswith("'") else body_id - # Extract positions - positions = [] - if isinstance(epochs, dict): - # Multiple time points - for i in range(len(vectors)): - pos = Position( - time=Time(vectors["datetime_jd"][i], format="jd").datetime, - x=float(vectors["x"][i]), - y=float(vectors["y"][i]), - z=float(vectors["z"][i]), - ) - positions.append(pos) - else: - # Single time point - pos = Position( - time=start_time, - x=float(vectors["x"][0]), - y=float(vectors["y"][0]), - z=float(vectors["z"][0]), - ) - positions.append(pos) + params = { + "format": "text", + "COMMAND": cmd_val, + "OBJ_DATA": "NO", + "MAKE_EPHEM": "YES", + "EPHEM_TYPE": "VECTORS", + "CENTER": self.location, + "START_TIME": start_str, + "STOP_TIME": end_str, + "STEP_SIZE": step, + "CSV_FORMAT": "YES", + "OUT_UNITS": "AU-D" + } - logger.info(f"Successfully retrieved {len(positions)} positions for body {body_id}") - return positions + # Configure proxy if available + client_kwargs = {"timeout": settings.nasa_api_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}") + + return self._parse_vectors(response.text) except Exception as e: logger.error(f"Error querying Horizons for body {body_id}: {str(e)}") raise - def search_body_by_name(self, name: str) -> dict: + def _parse_vectors(self, text: str) -> list[Position]: """ - Search for a celestial body by name in NASA Horizons database + Parse Horizons CSV output for vector data - Args: - name: Body name or ID to search for + Format looks like: + $$SOE + 2460676.500000000, A.D. 2025-Jan-01 00:00:00.0000, 9.776737278236609E-01, -1.726677228793678E-01, -1.636678733289160E-05, ... + $$EOE + """ + positions = [] - Returns: - Dictionary with search results: - { - "success": bool, - "id": str (extracted or input), - "name": str (short name), - "full_name": str (complete name from NASA), - "error": str (if failed) - } + # Extract data block between $$SOE and $$EOE + 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}") + 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, + z=z, + vx=vx, + vy=vy, + vz=vz + ) + positions.append(pos) + except ValueError 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 for: {name}") + logger.info(f"Searching Horizons (httpx) for: {name}") - # Try to query with the name - obj = Horizons(id=name, location=self.location) - vec = obj.vectors() + url = "https://ssd.jpl.nasa.gov/api/horizons.api" + cmd_val = f"'{name}'" # Name can be ID or actual name - # Get the full target name from response - targetname = vec['targetname'][0] - logger.info(f"Found target: {targetname}") - - # Extract ID and name from targetname - # Possible formats: - # 1. "136472 Makemake (2005 FY9)" - ID at start - # 2. "Voyager 1 (spacecraft) (-31)" - ID in parentheses - # 3. "Mars (499)" - ID in parentheses - # 4. "Parker Solar Probe (spacecraft)" - no ID - # 5. "Hubble Space Telescope (spacecra" - truncated - - numeric_id = None - short_name = None - - # Check if input is already a numeric ID - input_is_numeric = re.match(r'^-?\d+$', name.strip()) - if input_is_numeric: - numeric_id = name.strip() - # Extract name from targetname - # Remove leading ID if present - name_part = re.sub(r'^\d+\s+', '', targetname) - short_name = name_part.split('(')[0].strip() - else: - # Try to extract ID from start of targetname (format: "136472 Makemake") - start_match = re.match(r'^(\d+)\s+(.+)', targetname) - if start_match: - numeric_id = start_match.group(1) - short_name = start_match.group(2).split('(')[0].strip() - else: - # Try to extract ID from parentheses (format: "Name (-31)" or "Name (499)") - id_match = re.search(r'\((-?\d+)\)', targetname) - if id_match: - numeric_id = id_match.group(1) - short_name = targetname.split('(')[0].strip() - else: - # No numeric ID found, use input name as ID - numeric_id = name - short_name = targetname.split('(')[0].strip() - - return { - "success": True, - "id": numeric_id, - "name": short_name, - "full_name": targetname, - "error": None + 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}") - - # Check for specific error types - if 'Ambiguous target name' in error_msg: - return { - "success": False, - "id": None, - "name": None, - "full_name": None, - "error": "名称不唯一,请提供更具体的名称或 JPL Horizons ID" - } - elif 'No matches found' in error_msg or 'Unknown target' in error_msg: - return { - "success": False, - "id": None, - "name": None, - "full_name": None, - "error": "未找到匹配的天体,请检查名称或 ID" - } - else: - return { - "success": False, - "id": None, - "name": None, - "full_name": None, - "error": f"查询失败: {error_msg}" - } - + return { + "success": False, + "id": None, + "name": None, + "full_name": None, + "error": f"查询失败: {error_msg}" + } # Singleton instance -horizons_service = HorizonsService() +horizons_service = HorizonsService() \ No newline at end of file diff --git a/app/services/nasa_worker.py b/app/services/nasa_worker.py index 3ebf8b6..6c62b42 100644 --- a/app/services/nasa_worker.py +++ b/app/services/nasa_worker.py @@ -60,7 +60,7 @@ async def download_positions_task(task_id: int, body_ids: List[str], dates: List success_count += 1 else: # Download - positions = horizons_service.get_body_positions( + positions = await horizons_service.get_body_positions( body_id=body_id, start_time=target_date, end_time=target_date, diff --git a/app/services/orbit_service.py b/app/services/orbit_service.py index 5c7b2a5..ed03798 100644 --- a/app/services/orbit_service.py +++ b/app/services/orbit_service.py @@ -150,7 +150,7 @@ class OrbitService: try: # Get positions from Horizons (synchronous call) - positions = horizons_service.get_body_positions( + positions = await horizons_service.get_body_positions( body_id=body_id, start_time=start_time, end_time=end_time, diff --git a/scripts/check_db_status.py b/scripts/check_db_status.py new file mode 100644 index 0000000..6918b6f --- /dev/null +++ b/scripts/check_db_status.py @@ -0,0 +1,68 @@ +""" +Check database status: bodies, positions, resources +""" +import asyncio +import os +import sys +from datetime import datetime + +# Add backend directory to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.database import get_db +from app.models.db.celestial_body import CelestialBody +from app.models.db.position import Position +from app.models.db.resource import Resource +from sqlalchemy import select, func + +async def check_status(): + """Check database status""" + print("🔍 Checking database status...") + + async for session in get_db(): + try: + # 1. Check Celestial Bodies + stmt = select(func.count(CelestialBody.id)) + result = await session.execute(stmt) + body_count = result.scalar() + print(f"✅ Celestial Bodies: {body_count}") + + # 2. Check Positions + stmt = select(func.count(Position.id)) + result = await session.execute(stmt) + position_count = result.scalar() + print(f"✅ Total Positions: {position_count}") + + # Check positions for Sun (10) and Earth (399) + for body_id in ['10', '399']: + stmt = select(func.count(Position.id)).where(Position.body_id == body_id) + result = await session.execute(stmt) + count = result.scalar() + print(f" - Positions for {body_id}: {count}") + + if count > 0: + # Get latest position date + stmt = select(func.max(Position.time)).where(Position.body_id == body_id) + result = await session.execute(stmt) + latest_date = result.scalar() + print(f" Latest date: {latest_date}") + + # 3. Check Resources + stmt = select(func.count(Resource.id)) + result = await session.execute(stmt) + resource_count = result.scalar() + print(f"✅ Total Resources: {resource_count}") + + # Check resources for Sun (10) + stmt = select(Resource).where(Resource.body_id == '10') + result = await session.execute(stmt) + resources = result.scalars().all() + print(f" - Resources for Sun (10): {len(resources)}") + for r in resources: + print(f" - {r.resource_type}: {r.file_path}") + + finally: + break + +if __name__ == "__main__": + asyncio.run(check_status()) diff --git a/scripts/check_sun_data.py b/scripts/check_sun_data.py new file mode 100644 index 0000000..851c4b8 --- /dev/null +++ b/scripts/check_sun_data.py @@ -0,0 +1,50 @@ +import asyncio +import os +import sys +from datetime import datetime + +# Add backend directory to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.database import get_db +from app.models.db import Position +from sqlalchemy import select, func + +async def check_sun_data(): + """Check data for 2025-12-04 00:00:00""" + async for session in get_db(): + try: + target_time = datetime(2025, 12, 4, 0, 0, 0) + print(f"Checking data for all bodies at {target_time}...") + + # Get all bodies + from app.models.db.celestial_body import CelestialBody + stmt = select(CelestialBody.id, CelestialBody.name, CelestialBody.type).where(CelestialBody.is_active != False) + result = await session.execute(stmt) + all_bodies = result.all() + print(f"Total active bodies: {len(all_bodies)}") + + # Check positions for each + missing_bodies = [] + for body_id, body_name, body_type in all_bodies: + stmt = select(func.count(Position.id)).where( + Position.body_id == body_id, + Position.time == target_time + ) + result = await session.execute(stmt) + count = result.scalar() + if count == 0: + missing_bodies.append(f"{body_name} ({body_id}) [{body_type}]") + + if missing_bodies: + print(f"❌ Missing data for {len(missing_bodies)} bodies:") + for b in missing_bodies: + print(f" - {b}") + else: + print("✅ All active bodies have data for this time!") + + finally: + break + +if __name__ == "__main__": + asyncio.run(check_sun_data()) diff --git a/scripts/fix_sun_data.py b/scripts/fix_sun_data.py new file mode 100644 index 0000000..d44ce6e --- /dev/null +++ b/scripts/fix_sun_data.py @@ -0,0 +1,58 @@ +""" +Fix missing Sun position +""" +import asyncio +import os +import sys +from datetime import datetime + +# Add backend directory to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.database import get_db +from app.models.db import Position + +async def fix_sun_position(): + """Insert missing position for Sun at 2025-12-04 00:00:00""" + async for session in get_db(): + try: + target_time = datetime(2025, 12, 4, 0, 0, 0) + print(f"Fixing Sun position for {target_time}...") + + # Check if it exists first (double check) + from sqlalchemy import select, func + stmt = select(func.count(Position.id)).where( + Position.body_id == '10', + Position.time == target_time + ) + result = await session.execute(stmt) + count = result.scalar() + + if count > 0: + print("✅ Position already exists!") + return + + # Insert + new_pos = Position( + body_id='10', + time=target_time, + x=0.0, + y=0.0, + z=0.0, + vx=0.0, + vy=0.0, + vz=0.0, + source='calculated' + ) + session.add(new_pos) + await session.commit() + print("✅ Successfully inserted Sun position!") + + except Exception as e: + print(f"❌ Error: {e}") + await session.rollback() + finally: + break + +if __name__ == "__main__": + asyncio.run(fix_sun_position()) diff --git a/scripts/inspect_sun.py b/scripts/inspect_sun.py new file mode 100644 index 0000000..039477c --- /dev/null +++ b/scripts/inspect_sun.py @@ -0,0 +1,39 @@ +import asyncio +import os +import sys +from sqlalchemy import select +from datetime import datetime + +# Add backend directory to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.database import get_db +from app.models.db import Position + +async def inspect_sun_positions(): + async for session in get_db(): + try: + # List all positions for Sun + stmt = select(Position.time).where(Position.body_id == '10').order_by(Position.time.desc()).limit(10) + result = await session.execute(stmt) + times = result.scalars().all() + + print("Recent Sun positions:") + for t in times: + print(f" - {t} (type: {type(t)})") + + # Check specifically for 2025-12-04 + target = datetime(2025, 12, 4, 0, 0, 0) + stmt = select(Position).where( + Position.body_id == '10', + Position.time == target + ) + result = await session.execute(stmt) + pos = result.scalar() + print(f"\nExact match for {target}: {pos}") + + finally: + break + +if __name__ == "__main__": + asyncio.run(inspect_sun_positions()) diff --git a/scripts/reset_positions.py b/scripts/reset_positions.py new file mode 100644 index 0000000..3a917cb --- /dev/null +++ b/scripts/reset_positions.py @@ -0,0 +1,53 @@ +""" +Reset position data to fix units (KM -> AU) +""" +import asyncio +import os +import sys + +# Add backend directory to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.database import get_db +from app.models.db import Position +from app.services.redis_cache import redis_cache +from sqlalchemy import text + +async def reset_data(): + """Clear positions and cache to force re-fetch in AU""" + print("🧹 Clearing old data (KM) to prepare for AU...") + + async for session in get_db(): + try: + # Clear positions table + print(" Truncating positions table...") + await session.execute(text("TRUNCATE TABLE positions RESTART IDENTITY CASCADE")) + + # Clear nasa_cache table (if it exists as a table, or if it's just redis?) + # nasa_cache is in db models? + # Let's check models/db directory... + # It seems nasa_cache is a table based on `nasa_cache_service`. + print(" Truncating nasa_cache table...") + try: + await session.execute(text("TRUNCATE TABLE nasa_cache RESTART IDENTITY CASCADE")) + except Exception as e: + print(f" (Note: nasa_cache might not exist or failed: {e})") + + await session.commit() + print("✅ Database tables cleared.") + + # Clear Redis + await redis_cache.connect() + await redis_cache.clear_pattern("positions:*") + await redis_cache.clear_pattern("nasa:*") + print("✅ Redis cache cleared.") + await redis_cache.disconnect() + + except Exception as e: + print(f"❌ Error: {e}") + await session.rollback() + finally: + break + +if __name__ == "__main__": + asyncio.run(reset_data()) diff --git a/upload/texture/2k_saturn_ring.jpg b/upload/texture/2k_saturn_ring.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc9de41988376282d38e74a367f98936d4defb3d GIT binary patch literal 70548 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 literal 0 HcmV?d00001