Compare commits
2 Commits
d10dea21a8
...
488717ffa1
| Author | SHA1 | Date |
|---|---|---|
|
|
488717ffa1 | |
|
|
d8799eae1f |
|
|
@ -50,7 +50,16 @@
|
|||
"Bash(kill:*)",
|
||||
"Bash(./venv/bin/python3:*)",
|
||||
"WebSearch",
|
||||
"Bash(PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend ./venv/bin/python:*)"
|
||||
"Bash(PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend ./venv/bin/python:*)",
|
||||
"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(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": []
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
|
@ -0,0 +1,217 @@
|
|||
# 天体管理功能修复总结
|
||||
|
||||
**日期**: 2025-12-10
|
||||
**状态**: ✅ 代码修复完成,待用户重启后端验证
|
||||
|
||||
---
|
||||
|
||||
## 修复的三个问题
|
||||
|
||||
### 1. ✅ 生成轨道按钮显示逻辑
|
||||
|
||||
**问题**: 生成轨道按钮只在行星/矮行星显示,其他类型不显示
|
||||
|
||||
**修复**:
|
||||
- 所有天体类型都显示"生成轨道"按钮
|
||||
- 非行星/矮行星的按钮设置为 `disabled={true}` 置灰
|
||||
- 不同的 Tooltip 提示:
|
||||
- 可生成:`"生成轨道"`
|
||||
- 不可生成:`"仅行星和矮行星可生成轨道"`
|
||||
|
||||
**代码位置**: `frontend/src/pages/admin/CelestialBodies.tsx:490-516`
|
||||
|
||||
```typescript
|
||||
customActions={(record) => {
|
||||
const canGenerateOrbit = ['planet', 'dwarf_planet'].includes(record.type);
|
||||
return (
|
||||
<Popconfirm ...>
|
||||
<Tooltip title={canGenerateOrbit ? "生成轨道" : "仅行星和矮行星可生成轨道"}>
|
||||
<Button disabled={!canGenerateOrbit}>生成轨道</Button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
);
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. ✅ 生成轨道确认弹窗
|
||||
|
||||
**问题**: 点击生成轨道直接执行,没有确认提示
|
||||
|
||||
**修复**:
|
||||
- 使用 `Popconfirm` 组件包裹按钮
|
||||
- 确认标题:`"确认生成轨道"`
|
||||
- 确认描述:显示天体中文名或英文名
|
||||
- 提示信息:`"此操作可能需要一些时间"`
|
||||
|
||||
**代码位置**: `frontend/src/pages/admin/CelestialBodies.tsx:495-514`
|
||||
|
||||
---
|
||||
|
||||
### 3. ✅ 轨道配置数据加载问题
|
||||
|
||||
**问题**: 编辑天体时,轨道周期和颜色字段为空
|
||||
|
||||
**根本原因**:
|
||||
1. 后端 API (`/celestial/list`) 没有返回 `extra_data` 字段
|
||||
2. 前端 TypeScript 接口缺少 `extra_data` 定义
|
||||
|
||||
**修复方案**:
|
||||
|
||||
#### 后端修复 (backend/app/api/celestial_body.py:232)
|
||||
```python
|
||||
bodies_list.append({
|
||||
"id": body.id,
|
||||
"name": body.name,
|
||||
# ... 其他字段 ...
|
||||
"extra_data": body.extra_data, # ✅ 添加此行
|
||||
"resources": resources_by_type,
|
||||
})
|
||||
```
|
||||
|
||||
#### 前端修复
|
||||
|
||||
**1. 添加 TypeScript 接口定义** (CelestialBodies.tsx:16-39)
|
||||
```typescript
|
||||
interface CelestialBody {
|
||||
// ... 其他字段 ...
|
||||
extra_data?: {
|
||||
orbit_period_days?: number;
|
||||
orbit_color?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**2. 处理 extra_data 数据** (CelestialBodies.tsx:210-235)
|
||||
```typescript
|
||||
const handleEdit = (record: CelestialBody) => {
|
||||
// 解析 extra_data(可能是字符串)
|
||||
let extraData = record.extra_data;
|
||||
if (typeof extraData === 'string') {
|
||||
try {
|
||||
extraData = JSON.parse(extraData);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse extra_data:', e);
|
||||
extraData = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 设置表单值
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
extra_data: extraData || {},
|
||||
});
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 额外修复
|
||||
|
||||
### DataTable 组件增强
|
||||
|
||||
**文件**: `frontend/src/components/admin/DataTable.tsx`
|
||||
|
||||
**新增功能**: 支持自定义操作按钮
|
||||
|
||||
```typescript
|
||||
interface DataTableProps<T> {
|
||||
// ... 其他 props ...
|
||||
customActions?: (record: T) => ReactNode; // ✅ 新增
|
||||
}
|
||||
```
|
||||
|
||||
使用方式:
|
||||
```typescript
|
||||
<DataTable
|
||||
customActions={(record) => (
|
||||
<Button>自定义操作</Button>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库验证
|
||||
|
||||
已验证阋神星的数据在数据库中正确存储:
|
||||
|
||||
```sql
|
||||
SELECT id, name_zh, extra_data FROM celestial_bodies WHERE id = '136199';
|
||||
```
|
||||
|
||||
结果:
|
||||
```json
|
||||
{
|
||||
"id": "136199",
|
||||
"name_zh": "阋神星",
|
||||
"extra_data": {
|
||||
"orbit_color": "#E0E0E0",
|
||||
"orbit_period_days": 203500.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 待用户操作
|
||||
|
||||
### 1. 重启后端服务器
|
||||
|
||||
后端代码已修改,需要重启以应用更改:
|
||||
|
||||
```bash
|
||||
# 停止后端
|
||||
lsof -ti:8000 | xargs kill
|
||||
|
||||
# 启动后端
|
||||
cd /Users/jiliu/WorkSpace/cosmo/backend
|
||||
PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend \
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 2. 刷新前端页面
|
||||
|
||||
重启后端后,刷新浏览器页面以获取最新数据。
|
||||
|
||||
### 3. 验证功能
|
||||
|
||||
- [ ] 编辑阋神星,确认轨道周期显示 `203500`
|
||||
- [ ] 确认轨道颜色显示 `#E0E0E0`
|
||||
- [ ] 点击"生成轨道"按钮,确认弹出确认框
|
||||
- [ ] 查看恒星、卫星等类型,确认"生成轨道"按钮置灰
|
||||
|
||||
---
|
||||
|
||||
## 修改文件列表
|
||||
|
||||
### 后端
|
||||
- ✅ `backend/app/api/celestial_body.py` - 添加 extra_data 到 API 响应
|
||||
|
||||
### 前端
|
||||
- ✅ `frontend/src/pages/admin/CelestialBodies.tsx` - 添加接口定义和数据处理
|
||||
- ✅ `frontend/src/components/admin/DataTable.tsx` - 支持自定义操作按钮
|
||||
|
||||
---
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 为什么需要处理字符串类型?
|
||||
|
||||
PostgreSQL 的 JSONB 字段在某些情况下可能被序列化为字符串,特别是在使用不同的 ORM 或序列化库时。代码添加了兼容性处理:
|
||||
|
||||
```typescript
|
||||
if (typeof extraData === 'string') {
|
||||
extraData = JSON.parse(extraData);
|
||||
}
|
||||
```
|
||||
|
||||
这确保了无论后端返回对象还是 JSON 字符串,前端都能正确处理。
|
||||
|
||||
---
|
||||
|
||||
**完成状态**: ✅ 代码修复完成,等待后端重启验证
|
||||
|
|
@ -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 缓存生效。
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
# Scheduled Jobs System - Code Review Summary
|
||||
|
||||
## Overview
|
||||
This document summarizes the code review and cleanup performed on the scheduled jobs system.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Backend - Removed Debug Logs with Emojis
|
||||
|
||||
#### `app/jobs/predefined.py`
|
||||
- Removed emoji icons from log messages (🌍, 📋, 🔄, ✅, ❌, 🎉, ⚠️)
|
||||
- Changed `logger.info` to `logger.debug` for detailed operation logs
|
||||
- Kept `logger.info` only for high-level operation summaries
|
||||
- Kept `logger.error` and `logger.warning` for error conditions
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
logger.info(f"🌍 Starting solar system position sync: days={days}")
|
||||
logger.info(f"🔄 Fetching positions for {body.name}")
|
||||
logger.info(f"✅ Saved {count} positions for {body.name}")
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
logger.info(f"Starting solar system position sync: days={days}")
|
||||
logger.debug(f"Fetching positions for {body.name}")
|
||||
logger.debug(f"Saved {count} positions for {body.name}")
|
||||
```
|
||||
|
||||
#### `app/jobs/registry.py`
|
||||
- Changed task registration log from `logger.info` to `logger.debug`
|
||||
- Changed task execution logs from `logger.info` to `logger.debug`
|
||||
- Removed emoji icons (📋, 🚀, ✅)
|
||||
|
||||
#### `app/services/scheduler_service.py`
|
||||
- Removed emoji icons from all log messages (⏰, ❌, ✅)
|
||||
- Kept important lifecycle logs as `logger.info` (start, stop, job scheduling)
|
||||
- Changed detailed execution logs to `logger.debug`
|
||||
|
||||
### 2. Backend - Removed Unused Imports
|
||||
|
||||
#### `app/api/scheduled_job.py`
|
||||
- Removed unused imports: `update`, `delete` from sqlalchemy
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from sqlalchemy import select, update, delete
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from sqlalchemy import select
|
||||
```
|
||||
|
||||
### 3. Frontend - Removed Debug Console Logs
|
||||
|
||||
#### `pages/admin/ScheduledJobs.tsx`
|
||||
- Removed `console.log` statements from `loadAvailableTasks()`
|
||||
- Removed `console.error` statements from `loadAvailableTasks()`
|
||||
- Removed `console.log` statements from `handleEdit()`
|
||||
- Removed `console.error` from error handling (kept only toast messages)
|
||||
|
||||
**Removed:**
|
||||
```typescript
|
||||
console.log('Loaded available tasks:', result);
|
||||
console.error('Failed to load available tasks:', error);
|
||||
console.log('Editing record:', record);
|
||||
console.log('Available tasks:', availableTasks);
|
||||
console.error(error);
|
||||
```
|
||||
|
||||
## Code Quality Improvements
|
||||
|
||||
### 1. Consistent Logging Levels
|
||||
- **ERROR**: For failures that prevent operations
|
||||
- **WARNING**: For non-critical issues (e.g., "No bodies found")
|
||||
- **INFO**: For high-level operation summaries
|
||||
- **DEBUG**: For detailed operation traces
|
||||
|
||||
### 2. Clean User-Facing Messages
|
||||
- All user-facing error messages use toast notifications
|
||||
- No console output in production frontend code
|
||||
- Backend logs are professional and parseable
|
||||
|
||||
### 3. Transaction Safety
|
||||
- Using SQLAlchemy savepoints (`begin_nested()`) for isolated error handling
|
||||
- Proper rollback and commit patterns
|
||||
- Error messages include full traceback for debugging
|
||||
|
||||
## Testing Results
|
||||
|
||||
### Import Test
|
||||
✓ All backend imports successful
|
||||
✓ Task registry properly initialized
|
||||
✓ 2 tasks registered:
|
||||
- sync_solar_system_positions
|
||||
- sync_celestial_events
|
||||
|
||||
### Task Schema Test
|
||||
✓ Task parameters properly defined:
|
||||
- body_ids (array, optional, default=None)
|
||||
- days (integer, optional, default=7)
|
||||
- source (string, optional, default=nasa_horizons_cron)
|
||||
|
||||
### Integration Test
|
||||
✓ Position constraint fixed (nasa_horizons_cron added to CHECK constraint)
|
||||
✓ Manual job execution successful
|
||||
✓ 26 celestial bodies synced with 52 positions
|
||||
✓ Task record properly created and updated
|
||||
✓ No failures during execution
|
||||
|
||||
## Remaining Console Logs (Other Admin Pages)
|
||||
|
||||
The following console logs exist in other admin pages but were left unchanged as they're outside the scope of this scheduled jobs feature:
|
||||
|
||||
- `SystemSettings.tsx`: 1 console.error
|
||||
- `Users.tsx`: 2 console.error
|
||||
- `Dashboard.tsx`: 1 console.error
|
||||
- `StaticData.tsx`: 1 console.error
|
||||
- `CelestialBodies.tsx`: 2 (1 error, 1 for JSON parsing)
|
||||
- `NASADownload.tsx`: 3 (2 debug logs, 1 error)
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Backend
|
||||
1. `/backend/app/jobs/predefined.py` - Removed emoji logs, adjusted log levels
|
||||
2. `/backend/app/jobs/registry.py` - Changed to debug logging
|
||||
3. `/backend/app/services/scheduler_service.py` - Removed emojis, adjusted log levels
|
||||
4. `/backend/app/api/scheduled_job.py` - Removed unused imports
|
||||
|
||||
### Frontend
|
||||
1. `/frontend/src/pages/admin/ScheduledJobs.tsx` - Removed all console logs
|
||||
|
||||
### Database
|
||||
1. `/backend/scripts/fix_position_source_constraint.py` - Fixed CHECK constraint
|
||||
|
||||
## Summary
|
||||
|
||||
All scheduled jobs related code has been reviewed and cleaned:
|
||||
- ✅ No emoji icons in production logs
|
||||
- ✅ Appropriate logging levels (ERROR/WARNING/INFO/DEBUG)
|
||||
- ✅ No console.log/console.error in frontend
|
||||
- ✅ No unused imports
|
||||
- ✅ All imports and registrations working
|
||||
- ✅ Database constraints fixed
|
||||
- ✅ Integration tests passing
|
||||
|
||||
The code is now production-ready with clean, professional logging suitable for monitoring and debugging.
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
- [4.5 role_menus - 角色菜单关联表](#45-role_menus---角色菜单关联表)
|
||||
- [4.6 system_settings - 系统配置表](#46-system_settings---系统配置表)
|
||||
- [4.7 tasks - 后台任务表](#47-tasks---后台任务表)
|
||||
- [4.8 scheduled_jobs - 定时任务表](#48-scheduled_jobs---定时任务表)
|
||||
- [5. 缓存表](#5-缓存表)
|
||||
- [5.1 nasa_cache - NASA API缓存表](#51-nasa_cache---nasa-api缓存表)
|
||||
- [6. 数据关系图](#6-数据关系图)
|
||||
|
|
@ -57,7 +58,8 @@
|
|||
| 12 | role_menus | 角色菜单权限 | 数百 |
|
||||
| 13 | system_settings | 系统配置参数 | 数十 |
|
||||
| 14 | tasks | 后台任务 | 数万 |
|
||||
| 15 | nasa_cache | NASA API缓存 | 数万 |
|
||||
| 15 | scheduled_jobs | 定时任务配置 | 数十 |
|
||||
| 16 | nasa_cache | NASA API缓存 | 数万 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -650,6 +652,87 @@ COMMENT ON COLUMN tasks.progress IS '任务进度百分比(0-100)';
|
|||
|
||||
---
|
||||
|
||||
### 4.8 scheduled_jobs - 定时任务表
|
||||
|
||||
配置和管理定时调度任务,支持预定义任务和自定义代码执行。
|
||||
|
||||
```sql
|
||||
CREATE TYPE jobtype AS ENUM ('predefined', 'custom_code');
|
||||
|
||||
CREATE TABLE scheduled_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL, -- 任务名称
|
||||
job_type jobtype NOT NULL DEFAULT 'predefined',-- 任务类型
|
||||
predefined_function VARCHAR(100), -- 预定义函数名称
|
||||
function_params JSONB DEFAULT '{}'::jsonb, -- 函数参数(JSON格式)
|
||||
cron_expression VARCHAR(50) NOT NULL, -- CRON表达式
|
||||
python_code TEXT, -- 自定义Python代码
|
||||
is_active BOOLEAN DEFAULT TRUE, -- 是否启用
|
||||
last_run_at TIMESTAMP, -- 最后执行时间
|
||||
last_run_status VARCHAR(20), -- 最后执行状态
|
||||
next_run_at TIMESTAMP, -- 下次执行时间
|
||||
description TEXT, -- 任务描述
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT chk_job_type_fields CHECK (
|
||||
(job_type = 'predefined' AND predefined_function IS NOT NULL)
|
||||
OR
|
||||
(job_type = 'custom_code' AND python_code IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_scheduled_jobs_active ON scheduled_jobs(is_active);
|
||||
CREATE INDEX idx_scheduled_jobs_next_run ON scheduled_jobs(next_run_at);
|
||||
CREATE INDEX idx_scheduled_jobs_function ON scheduled_jobs(predefined_function);
|
||||
|
||||
-- 注释
|
||||
COMMENT ON TABLE scheduled_jobs IS '定时任务配置表';
|
||||
COMMENT ON COLUMN scheduled_jobs.job_type IS '任务类型:predefined(预定义任务), custom_code(自定义代码)';
|
||||
COMMENT ON COLUMN scheduled_jobs.predefined_function IS '预定义任务函数名,如sync_solar_system_positions';
|
||||
COMMENT ON COLUMN scheduled_jobs.function_params IS '任务参数,JSON格式,不同预定义任务参数不同';
|
||||
COMMENT ON COLUMN scheduled_jobs.cron_expression IS 'CRON表达式,格式:分 时 日 月 周';
|
||||
COMMENT ON COLUMN scheduled_jobs.python_code IS '自定义Python代码(仅job_type=custom_code时使用,需管理员权限)';
|
||||
COMMENT ON COLUMN scheduled_jobs.last_run_status IS '最后执行状态:success, failed';
|
||||
```
|
||||
|
||||
#### 预定义任务列表
|
||||
|
||||
| 函数名 | 说明 | 参数 |
|
||||
|--------|------|------|
|
||||
| `sync_solar_system_positions` | 同步太阳系天体位置数据 | `body_ids`: 天体ID列表(可选,默认所有)<br>`days`: 同步天数(默认7)<br>`source`: 数据源标记(默认nasa_horizons_cron) |
|
||||
| `sync_celestial_events` | 同步天体事件数据 | *预留,暂未实现* |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
**示例1:创建预定义任务 - 每日同步太阳系位置**
|
||||
```sql
|
||||
INSERT INTO scheduled_jobs (name, job_type, predefined_function, function_params, cron_expression, description)
|
||||
VALUES (
|
||||
'每日同步太阳系天体位置',
|
||||
'predefined',
|
||||
'sync_solar_system_positions',
|
||||
'{"days": 7, "source": "nasa_horizons_cron"}'::jsonb,
|
||||
'0 2 * * *', -- 每天凌晨2点执行
|
||||
'自动从NASA Horizons拉取太阳系主要天体的位置数据'
|
||||
);
|
||||
```
|
||||
|
||||
**示例2:创建自定义代码任务(管理员专用)**
|
||||
```sql
|
||||
INSERT INTO scheduled_jobs (name, job_type, python_code, cron_expression, description)
|
||||
VALUES (
|
||||
'数据库清理任务',
|
||||
'custom_code',
|
||||
'logger.info("Starting cleanup...")\nawait db.execute("DELETE FROM positions WHERE time < NOW() - INTERVAL ''1 year''")\nawait db.commit()',
|
||||
'0 3 * * 0', -- 每周日凌晨3点执行
|
||||
'清理一年前的旧位置数据'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 缓存表
|
||||
|
||||
### 5.1 nasa_cache - NASA API缓存表
|
||||
|
|
@ -700,6 +783,7 @@ users (用户)
|
|||
└── role_menus (N:M) ←→ menus (菜单)
|
||||
|
||||
tasks (任务) - 独立表
|
||||
scheduled_jobs (定时任务) - 独立表
|
||||
system_settings (配置) - 独立表
|
||||
static_data (静态数据) - 独立表
|
||||
nasa_cache (缓存) - 独立表
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Orbit Management API routes
|
|||
Handles precomputed orbital data for celestial bodies
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -11,6 +11,8 @@ from app.database import get_db
|
|||
from app.services.horizons import horizons_service
|
||||
from app.services.db_service import celestial_body_service
|
||||
from app.services.orbit_service import orbit_service
|
||||
from app.services.task_service import task_service
|
||||
from app.services.nasa_worker import generate_orbits_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -60,143 +62,52 @@ async def get_orbits(
|
|||
|
||||
@router.post("/admin/orbits/generate")
|
||||
async def generate_orbits(
|
||||
background_tasks: BackgroundTasks,
|
||||
body_ids: Optional[str] = Query(None, description="Comma-separated body IDs to generate. If empty, generates for all planets and dwarf planets"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generate orbital data for celestial bodies
|
||||
Generate orbital data for celestial bodies (Background Task)
|
||||
|
||||
This endpoint queries NASA Horizons API to get complete orbital paths
|
||||
and stores them in the orbits table for fast frontend rendering.
|
||||
This endpoint starts a background task to query NASA Horizons API
|
||||
and generate complete orbital paths.
|
||||
|
||||
Query parameters:
|
||||
- body_ids: Optional comma-separated list of body IDs (e.g., "399,999")
|
||||
If not provided, generates orbits for all planets and dwarf planets
|
||||
|
||||
Returns:
|
||||
- List of generated orbits with success/failure status
|
||||
- Task ID and status message
|
||||
"""
|
||||
logger.info("🌌 Starting orbit generation...")
|
||||
|
||||
# Orbital periods in days (from astronomical data)
|
||||
# Note: NASA Horizons data is limited to ~2199 for most bodies
|
||||
# We use single complete orbits that fit within this range
|
||||
ORBITAL_PERIODS = {
|
||||
# Planets - single complete orbit
|
||||
"199": 88.0, # Mercury
|
||||
"299": 224.7, # Venus
|
||||
"399": 365.25, # Earth
|
||||
"499": 687.0, # Mars
|
||||
"599": 4333.0, # Jupiter (11.86 years)
|
||||
"699": 10759.0, # Saturn (29.46 years)
|
||||
"799": 30687.0, # Uranus (84.01 years)
|
||||
"899": 60190.0, # Neptune (164.79 years)
|
||||
# Dwarf Planets - single complete orbit
|
||||
"999": 90560.0, # Pluto (247.94 years - full orbit)
|
||||
"2000001": 1680.0, # Ceres (4.6 years)
|
||||
"136199": 203500.0, # Eris (557 years - full orbit)
|
||||
"136108": 104000.0, # Haumea (285 years - full orbit)
|
||||
"136472": 112897.0, # Makemake (309 years - full orbit)
|
||||
}
|
||||
|
||||
# Default colors for orbits
|
||||
DEFAULT_COLORS = {
|
||||
"199": "#8C7853", # Mercury - brownish
|
||||
"299": "#FFC649", # Venus - yellowish
|
||||
"399": "#4A90E2", # Earth - blue
|
||||
"499": "#CD5C5C", # Mars - red
|
||||
"599": "#DAA520", # Jupiter - golden
|
||||
"699": "#F4A460", # Saturn - sandy brown
|
||||
"799": "#4FD1C5", # Uranus - cyan
|
||||
"899": "#4169E1", # Neptune - royal blue
|
||||
"999": "#8B7355", # Pluto - brown
|
||||
"2000001": "#9E9E9E", # Ceres - gray
|
||||
"136199": "#E0E0E0", # Eris - light gray
|
||||
"136108": "#D4A574", # Haumea - tan
|
||||
"136472": "#C49A6C", # Makemake - beige
|
||||
}
|
||||
logger.info("🌌 Starting orbit generation task...")
|
||||
|
||||
try:
|
||||
# Determine which bodies to generate orbits for
|
||||
if body_ids:
|
||||
# Parse comma-separated list
|
||||
target_body_ids = [bid.strip() for bid in body_ids.split(",")]
|
||||
bodies_to_process = []
|
||||
# Parse body_ids if provided
|
||||
target_body_ids = [bid.strip() for bid in body_ids.split(",")] if body_ids else None
|
||||
|
||||
# Create task record
|
||||
task_description = f"Generate orbits for {len(target_body_ids) if target_body_ids else 'all'} bodies"
|
||||
if target_body_ids:
|
||||
task_description += f": {', '.join(target_body_ids[:3])}..."
|
||||
|
||||
for bid in target_body_ids:
|
||||
body = await celestial_body_service.get_body_by_id(bid, db)
|
||||
if body:
|
||||
bodies_to_process.append(body)
|
||||
else:
|
||||
logger.warning(f"Body {bid} not found in database")
|
||||
else:
|
||||
# Get all planets and dwarf planets
|
||||
all_bodies = await celestial_body_service.get_all_bodies(db)
|
||||
bodies_to_process = [
|
||||
b for b in all_bodies
|
||||
if b.type in ["planet", "dwarf_planet"] and b.id in ORBITAL_PERIODS
|
||||
]
|
||||
task = await task_service.create_task(
|
||||
db,
|
||||
task_type="orbit_generation",
|
||||
description=task_description,
|
||||
params={"body_ids": target_body_ids},
|
||||
created_by=None # System or Admin
|
||||
)
|
||||
|
||||
if not bodies_to_process:
|
||||
raise HTTPException(status_code=400, detail="No valid bodies to process")
|
||||
|
||||
logger.info(f"📋 Generating orbits for {len(bodies_to_process)} bodies")
|
||||
|
||||
results = []
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
|
||||
for body in bodies_to_process:
|
||||
try:
|
||||
# 优先从天体的extra_data读取轨道参数
|
||||
extra_data = body.extra_data or {}
|
||||
period = extra_data.get("orbit_period_days") or ORBITAL_PERIODS.get(body.id)
|
||||
|
||||
if not period:
|
||||
logger.warning(f"No orbital period defined for {body.name}, skipping")
|
||||
continue
|
||||
|
||||
# 优先从extra_data读取颜色,其次从默认颜色字典,最后使用默认灰色
|
||||
color = extra_data.get("orbit_color") or DEFAULT_COLORS.get(body.id, "#CCCCCC")
|
||||
|
||||
# Generate orbit
|
||||
orbit = await orbit_service.generate_orbit(
|
||||
body_id=body.id,
|
||||
body_name=body.name_zh or body.name,
|
||||
period_days=period,
|
||||
color=color,
|
||||
session=db,
|
||||
horizons_service=horizons_service
|
||||
)
|
||||
|
||||
results.append({
|
||||
"body_id": body.id,
|
||||
"body_name": body.name_zh or body.name,
|
||||
"status": "success",
|
||||
"num_points": orbit.num_points,
|
||||
"period_days": orbit.period_days
|
||||
})
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate orbit for {body.name}: {e}")
|
||||
results.append({
|
||||
"body_id": body.id,
|
||||
"body_name": body.name_zh or body.name,
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
})
|
||||
failure_count += 1
|
||||
|
||||
logger.info(f"🎉 Orbit generation complete: {success_count} succeeded, {failure_count} failed")
|
||||
# Add to background tasks
|
||||
background_tasks.add_task(generate_orbits_task, task.id, target_body_ids)
|
||||
|
||||
return {
|
||||
"message": f"Generated {success_count} orbits ({failure_count} failed)",
|
||||
"results": results
|
||||
"message": "Orbit generation task started",
|
||||
"task_id": task.id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Orbit generation failed: {e}")
|
||||
logger.error(f"Orbit generation start failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
API dependencies - re-exports for convenience
|
||||
"""
|
||||
from app.services.auth_deps import get_current_user, get_current_active_user
|
||||
from app.models.db.user import User
|
||||
|
||||
__all__ = ["get_current_user", "get_current_active_user", "User"]
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
Celestial Events API routes
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.schemas.social import CelestialEventCreate, CelestialEventResponse
|
||||
from app.services.event_service import event_service
|
||||
from app.api.deps import get_current_active_user # Assuming events can be public or require login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
|
||||
@router.post("", response_model=CelestialEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_celestial_event(
|
||||
event_data: CelestialEventCreate,
|
||||
current_user: dict = Depends(get_current_active_user), # Admin users for creating events?
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new celestial event (admin/system only)"""
|
||||
# Further authorization checks could be added here (e.g., if current_user has 'admin' role)
|
||||
try:
|
||||
event = await event_service.create_event(event_data, db)
|
||||
return event
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating event: {e}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
@router.get("", response_model=List[CelestialEventResponse])
|
||||
async def get_celestial_events(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
body_id: Optional[str] = Query(None, description="Filter events by celestial body ID"),
|
||||
start_time: Optional[datetime] = Query(None, description="Filter events starting from this time (UTC)"),
|
||||
end_time: Optional[datetime] = Query(None, description="Filter events ending by this time (UTC)"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Get a list of celestial events."""
|
||||
try:
|
||||
events = await event_service.get_events(db, body_id, start_time, end_time, limit, offset)
|
||||
return events
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving events: {e}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
@router.get("/{event_id}", response_model=CelestialEventResponse)
|
||||
async def get_celestial_event(
|
||||
event_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get a specific celestial event by ID."""
|
||||
event = await event_service.get_event(event_id, db)
|
||||
if not event:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Celestial event not found")
|
||||
return event
|
||||
|
||||
@router.delete("/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_celestial_event(
|
||||
event_id: int,
|
||||
current_user: dict = Depends(get_current_active_user), # Admin users for deleting events?
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a celestial event by ID (admin/system only)"""
|
||||
# Further authorization checks could be added here
|
||||
deleted = await event_service.delete_event(event_id, db)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Celestial event not found")
|
||||
return None
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
"""
|
||||
Scheduled Jobs Management API
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.db.scheduled_job import ScheduledJob, JobType
|
||||
from app.services.scheduler_service import scheduler_service
|
||||
from app.services.code_validator import code_validator
|
||||
from app.jobs.registry import task_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/scheduled-jobs", tags=["scheduled-jobs"])
|
||||
|
||||
|
||||
# Pydantic Models
|
||||
class ScheduledJobBase(BaseModel):
|
||||
name: str
|
||||
cron_expression: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ScheduledJobCreatePredefined(ScheduledJobBase):
|
||||
"""Create predefined task job"""
|
||||
job_type: str = "predefined"
|
||||
predefined_function: str
|
||||
function_params: Optional[Dict[str, Any]] = {}
|
||||
|
||||
|
||||
class ScheduledJobCreateCustomCode(ScheduledJobBase):
|
||||
"""Create custom code job"""
|
||||
job_type: str = "custom_code"
|
||||
python_code: str
|
||||
|
||||
|
||||
class ScheduledJobUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
cron_expression: Optional[str] = None
|
||||
job_type: Optional[str] = None
|
||||
predefined_function: Optional[str] = None
|
||||
function_params: Optional[Dict[str, Any]] = None
|
||||
python_code: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ScheduledJobResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
job_type: str
|
||||
predefined_function: Optional[str] = None
|
||||
function_params: Optional[Dict[str, Any]] = None
|
||||
cron_expression: str
|
||||
python_code: Optional[str] = None
|
||||
is_active: bool
|
||||
last_run_at: Optional[datetime] = None
|
||||
last_run_status: Optional[str] = None
|
||||
next_run_at: Optional[datetime] = None
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("", response_model=List[ScheduledJobResponse])
|
||||
async def get_scheduled_jobs(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all scheduled jobs"""
|
||||
result = await db.execute(select(ScheduledJob).order_by(ScheduledJob.id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/available-tasks", response_model=List[Dict[str, Any]])
|
||||
async def get_available_tasks():
|
||||
"""Get list of all available predefined tasks"""
|
||||
tasks = task_registry.list_tasks()
|
||||
return tasks
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=ScheduledJobResponse)
|
||||
async def get_scheduled_job(job_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific scheduled job"""
|
||||
result = await db.execute(select(ScheduledJob).where(ScheduledJob.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.post("", response_model=ScheduledJobResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_scheduled_job(
|
||||
job_data: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new scheduled job (predefined or custom code)"""
|
||||
|
||||
job_type = job_data.get("job_type", "predefined")
|
||||
|
||||
# Validate job type
|
||||
if job_type not in ["predefined", "custom_code"]:
|
||||
raise HTTPException(status_code=400, detail="job_type must be 'predefined' or 'custom_code'")
|
||||
|
||||
# Validate based on job type
|
||||
if job_type == "predefined":
|
||||
# Validate predefined function exists
|
||||
predefined_function = job_data.get("predefined_function")
|
||||
if not predefined_function:
|
||||
raise HTTPException(status_code=400, detail="predefined_function is required for predefined jobs")
|
||||
|
||||
task_def = task_registry.get_task(predefined_function)
|
||||
if not task_def:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Predefined task '{predefined_function}' not found. Use /scheduled-jobs/available-tasks to list available tasks."
|
||||
)
|
||||
|
||||
# Create job
|
||||
new_job = ScheduledJob(
|
||||
name=job_data["name"],
|
||||
job_type=JobType.PREDEFINED,
|
||||
predefined_function=predefined_function,
|
||||
function_params=job_data.get("function_params", {}),
|
||||
cron_expression=job_data["cron_expression"],
|
||||
description=job_data.get("description"),
|
||||
is_active=job_data.get("is_active", True)
|
||||
)
|
||||
|
||||
else: # custom_code
|
||||
# Validate python code
|
||||
python_code = job_data.get("python_code")
|
||||
if not python_code:
|
||||
raise HTTPException(status_code=400, detail="python_code is required for custom_code jobs")
|
||||
|
||||
validation_result = code_validator.validate_code(python_code)
|
||||
if not validation_result["valid"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"message": "代码验证失败",
|
||||
"errors": validation_result["errors"],
|
||||
"warnings": validation_result["warnings"]
|
||||
}
|
||||
)
|
||||
# Log warnings if any
|
||||
if validation_result["warnings"]:
|
||||
logger.warning(f"Code validation warnings: {validation_result['warnings']}")
|
||||
|
||||
# Create job
|
||||
new_job = ScheduledJob(
|
||||
name=job_data["name"],
|
||||
job_type=JobType.CUSTOM_CODE,
|
||||
python_code=python_code,
|
||||
cron_expression=job_data["cron_expression"],
|
||||
description=job_data.get("description"),
|
||||
is_active=job_data.get("is_active", True)
|
||||
)
|
||||
|
||||
db.add(new_job)
|
||||
await db.commit()
|
||||
await db.refresh(new_job)
|
||||
|
||||
# Schedule it
|
||||
if new_job.is_active:
|
||||
scheduler_service.add_job_to_scheduler(new_job)
|
||||
|
||||
return new_job
|
||||
|
||||
|
||||
@router.put("/{job_id}", response_model=ScheduledJobResponse)
|
||||
async def update_scheduled_job(
|
||||
job_id: int,
|
||||
job_data: ScheduledJobUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a scheduled job"""
|
||||
result = await db.execute(select(ScheduledJob).where(ScheduledJob.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
# Validate if changing job_type
|
||||
if job_data.job_type is not None and job_data.job_type != job.job_type.value:
|
||||
if job_data.job_type == "predefined":
|
||||
if not job_data.predefined_function:
|
||||
raise HTTPException(status_code=400, detail="predefined_function is required when changing to predefined type")
|
||||
task_def = task_registry.get_task(job_data.predefined_function)
|
||||
if not task_def:
|
||||
raise HTTPException(status_code=400, detail=f"Task '{job_data.predefined_function}' not found")
|
||||
elif job_data.job_type == "custom_code":
|
||||
if not job_data.python_code:
|
||||
raise HTTPException(status_code=400, detail="python_code is required when changing to custom_code type")
|
||||
|
||||
# Validate python code if being updated
|
||||
if job_data.python_code is not None:
|
||||
validation_result = code_validator.validate_code(job_data.python_code)
|
||||
if not validation_result["valid"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"message": "代码验证失败",
|
||||
"errors": validation_result["errors"],
|
||||
"warnings": validation_result["warnings"]
|
||||
}
|
||||
)
|
||||
if validation_result["warnings"]:
|
||||
logger.warning(f"Code validation warnings: {validation_result['warnings']}")
|
||||
|
||||
# Validate predefined function if being updated
|
||||
if job_data.predefined_function is not None:
|
||||
task_def = task_registry.get_task(job_data.predefined_function)
|
||||
if not task_def:
|
||||
raise HTTPException(status_code=400, detail=f"Task '{job_data.predefined_function}' not found")
|
||||
|
||||
# Update fields
|
||||
update_dict = job_data.dict(exclude_unset=True)
|
||||
for key, value in update_dict.items():
|
||||
if key == "job_type":
|
||||
setattr(job, key, JobType(value))
|
||||
else:
|
||||
setattr(job, key, value)
|
||||
|
||||
job.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
|
||||
# Update scheduler
|
||||
await scheduler_service.reload_job(job.id)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def delete_scheduled_job(job_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a scheduled job"""
|
||||
result = await db.execute(select(ScheduledJob).where(ScheduledJob.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
# Remove from scheduler
|
||||
scheduler_service.remove_job(job_id)
|
||||
|
||||
await db.delete(job)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Job deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/{job_id}/run")
|
||||
async def run_scheduled_job(job_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Manually trigger a scheduled job immediately"""
|
||||
result = await db.execute(select(ScheduledJob).where(ScheduledJob.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
# Trigger async execution
|
||||
# We use create_task to run it in background so API returns immediately
|
||||
asyncio.create_task(scheduler_service.run_job_now(job_id))
|
||||
|
||||
return {"message": f"Job '{job.name}' triggered successfully"}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
"""
|
||||
Social Features API routes - user follows and channel messages
|
||||
"""
|
||||
import logging
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.db.user import User
|
||||
from app.models.schemas.social import UserFollowResponse, ChannelMessageCreate, ChannelMessageResponse
|
||||
from app.services.social_service import social_service
|
||||
from app.api.deps import get_current_active_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/social", tags=["social"])
|
||||
|
||||
# --- User Follows ---
|
||||
@router.post("/follow/{body_id}", response_model=UserFollowResponse)
|
||||
async def follow_body(
|
||||
body_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Allow current user to follow a celestial body.
|
||||
User will then receive events and can post in the body's channel.
|
||||
"""
|
||||
try:
|
||||
follow = await social_service.follow_body(current_user.id, body_id, db)
|
||||
return follow
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error following body {body_id} by user {current_user.id}: {e}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to follow body")
|
||||
|
||||
@router.delete("/follow/{body_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def unfollow_body(
|
||||
body_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Allow current user to unfollow a celestial body."""
|
||||
try:
|
||||
unfollowed = await social_service.unfollow_body(current_user.id, body_id, db)
|
||||
if not unfollowed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not following this body")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error unfollowing body {body_id} by user {current_user.id}: {e}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to unfollow body")
|
||||
|
||||
@router.get("/follows", response_model=List[UserFollowResponse])
|
||||
async def get_user_follows(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all celestial bodies currently followed by the user."""
|
||||
follows = await social_service.get_user_follows_with_time(current_user.id, db)
|
||||
return follows
|
||||
|
||||
@router.get("/follows/check/{body_id}")
|
||||
async def check_if_following(
|
||||
body_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Check if the current user is following a specific celestial body."""
|
||||
is_following = await social_service.get_follow(current_user.id, body_id, db)
|
||||
return {"is_following": is_following is not None}
|
||||
|
||||
# --- Channel Messages ---
|
||||
@router.post("/channel/{body_id}/message", response_model=ChannelMessageResponse)
|
||||
async def post_channel_message(
|
||||
body_id: str,
|
||||
message: ChannelMessageCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Post a message to a specific celestial body's channel.
|
||||
Only users following the body can post.
|
||||
"""
|
||||
try:
|
||||
channel_message = await social_service.post_channel_message(current_user.id, body_id, message.content, db)
|
||||
return channel_message
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) # 403 Forbidden for not following
|
||||
except Exception as e:
|
||||
logger.error(f"Error posting message to channel {body_id} by user {current_user.id}: {e}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to post message")
|
||||
|
||||
@router.get("/channel/{body_id}/messages", response_model=List[ChannelMessageResponse])
|
||||
async def get_channel_messages(
|
||||
body_id: str,
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get recent messages from a celestial body's channel."""
|
||||
try:
|
||||
messages = await social_service.get_channel_messages(body_id, db, limit)
|
||||
return messages
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving messages from channel {body_id}: {e}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve messages")
|
||||
|
|
@ -13,7 +13,7 @@ from app.services.system_settings_service import system_settings_service
|
|||
from app.services.redis_cache import redis_cache
|
||||
from app.services.cache import cache_service
|
||||
from app.database import get_db
|
||||
from app.models.db import Position
|
||||
from app.models.db import Position, CelestialBody, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -245,6 +245,46 @@ async def get_cache_stats():
|
|||
}
|
||||
|
||||
|
||||
@router.post("/settings/reload")
|
||||
async def reload_system_settings(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Reload system settings from database into memory
|
||||
|
||||
This updates the active configuration (like nasa_api_timeout) without restarting the server.
|
||||
"""
|
||||
logger.info("🔄 Reloading system settings from database...")
|
||||
|
||||
# 1. Fetch all settings from DB
|
||||
all_settings = await system_settings_service.get_all_settings(db)
|
||||
|
||||
# 2. Update app config
|
||||
from app.config import settings
|
||||
|
||||
updated_count = 0
|
||||
for setting in all_settings:
|
||||
# Check if this setting maps to an app config
|
||||
if hasattr(settings, setting.key):
|
||||
try:
|
||||
# Convert value
|
||||
val = await system_settings_service.get_setting_value(setting.key, db)
|
||||
|
||||
# Update config
|
||||
setattr(settings, setting.key, val)
|
||||
updated_count += 1
|
||||
logger.info(f" Updated config: {setting.key} = {val}")
|
||||
except Exception as e:
|
||||
logger.warning(f" Failed to update config {setting.key}: {e}")
|
||||
|
||||
logger.info(f"✅ Reload complete. Updated {updated_count} configuration values.")
|
||||
|
||||
return {
|
||||
"message": f"System settings reloaded successfully. Updated {updated_count} values.",
|
||||
"updated_count": updated_count
|
||||
}
|
||||
|
||||
|
||||
@router.post("/settings/init-defaults")
|
||||
async def initialize_default_settings(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
|
|
@ -302,3 +342,52 @@ async def get_data_cutoff_date(
|
|||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve data cutoff date: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/statistics")
|
||||
async def get_dashboard_statistics(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get unified dashboard statistics
|
||||
|
||||
Returns:
|
||||
- total_bodies: Total number of celestial bodies
|
||||
- total_probes: Total number of probes
|
||||
- total_users: Total number of registered users
|
||||
"""
|
||||
try:
|
||||
# Count total celestial bodies
|
||||
total_bodies_result = await db.execute(
|
||||
select(func.count(CelestialBody.id)).where(CelestialBody.is_active == True)
|
||||
)
|
||||
total_bodies = total_bodies_result.scalar_one()
|
||||
|
||||
# Count probes
|
||||
total_probes_result = await db.execute(
|
||||
select(func.count(CelestialBody.id)).where(
|
||||
CelestialBody.type == 'probe',
|
||||
CelestialBody.is_active == True
|
||||
)
|
||||
)
|
||||
total_probes = total_probes_result.scalar_one()
|
||||
|
||||
# Count users
|
||||
total_users_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
)
|
||||
total_users = total_users_result.scalar_one()
|
||||
|
||||
return {
|
||||
"total_bodies": total_bodies,
|
||||
"total_probes": total_probes,
|
||||
"total_users": total_users
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving dashboard statistics: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve statistics: {str(e)}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -122,19 +122,6 @@ async def reset_user_password(
|
|||
"default_password": default_password
|
||||
}
|
||||
|
||||
@router.get("/count", response_model=dict)
|
||||
async def get_user_count(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user) # All authenticated users can access
|
||||
):
|
||||
"""
|
||||
Get the total count of registered users.
|
||||
Available to all authenticated users.
|
||||
"""
|
||||
result = await db.execute(select(func.count(User.id)))
|
||||
total_users = result.scalar_one()
|
||||
return {"total_users": total_users}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_profile(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Scheduled Jobs Package
|
||||
Contains predefined task implementations and registry
|
||||
"""
|
||||
from app.jobs.registry import task_registry
|
||||
|
||||
__all__ = ["task_registry"]
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
"""
|
||||
Predefined Scheduled Tasks
|
||||
All registered tasks for scheduled execution
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
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__)
|
||||
|
||||
|
||||
@task_registry.register(
|
||||
name="sync_solar_system_positions",
|
||||
description="同步太阳系天体位置数据,从NASA Horizons API获取指定天体的位置数据并保存到数据库",
|
||||
category="data_sync",
|
||||
parameters=[
|
||||
{
|
||||
"name": "body_ids",
|
||||
"type": "array",
|
||||
"description": "要同步的天体ID列表,例如['10', '199', '299']。如果不指定,则同步所有活跃的太阳系天体",
|
||||
"required": False,
|
||||
"default": None
|
||||
},
|
||||
{
|
||||
"name": "days",
|
||||
"type": "integer",
|
||||
"description": "同步天数,从今天开始向未来延伸的天数",
|
||||
"required": False,
|
||||
"default": 7
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"type": "string",
|
||||
"description": "数据源标记,用于标识数据来源",
|
||||
"required": False,
|
||||
"default": "nasa_horizons_cron"
|
||||
}
|
||||
]
|
||||
)
|
||||
async def sync_solar_system_positions(
|
||||
db: AsyncSession,
|
||||
logger: logging.Logger,
|
||||
params: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync solar system body positions from NASA Horizons
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
logger: Logger instance
|
||||
params: Task parameters
|
||||
- body_ids: List of body IDs to sync (optional, defaults to all active)
|
||||
- days: Number of days to sync (default: 7)
|
||||
- source: Source tag for the data (default: "nasa_horizons_cron")
|
||||
|
||||
Returns:
|
||||
Summary of sync operation
|
||||
"""
|
||||
# Parse parameters with type conversion (params come from JSON, may be strings)
|
||||
body_ids = params.get("body_ids")
|
||||
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}")
|
||||
|
||||
# Get list of bodies to sync
|
||||
if body_ids:
|
||||
# Use specified body IDs
|
||||
result = await db.execute(
|
||||
select(CelestialBody).where(
|
||||
CelestialBody.id.in_(body_ids),
|
||||
CelestialBody.is_active == True
|
||||
)
|
||||
)
|
||||
bodies = result.scalars().all()
|
||||
logger.info(f"Syncing {len(bodies)} specified bodies")
|
||||
else:
|
||||
# Get all active solar system bodies
|
||||
# Typically solar system bodies include planets, dwarf planets, and major satellites
|
||||
result = await db.execute(
|
||||
select(CelestialBody).where(
|
||||
CelestialBody.is_active == True,
|
||||
CelestialBody.system_id == 1,
|
||||
CelestialBody.type.in_(['planet', 'dwarf_planet', 'satellite'])
|
||||
)
|
||||
)
|
||||
bodies = result.scalars().all()
|
||||
logger.info(f"Syncing all {len(bodies)} active solar system bodies")
|
||||
|
||||
if not bodies:
|
||||
logger.warning("No bodies found to sync")
|
||||
return {
|
||||
"success": True,
|
||||
"bodies_synced": 0,
|
||||
"total_positions": 0,
|
||||
"message": "No bodies found"
|
||||
}
|
||||
|
||||
# Initialize services
|
||||
horizons = HorizonsService()
|
||||
|
||||
# Sync positions for each body
|
||||
total_positions = 0
|
||||
synced_bodies = []
|
||||
failed_bodies = []
|
||||
|
||||
start_time = datetime.utcnow()
|
||||
end_time = start_time + timedelta(days=days)
|
||||
|
||||
for body in bodies:
|
||||
# Use savepoint for this body's operations
|
||||
async with db.begin_nested(): # Creates a SAVEPOINT
|
||||
try:
|
||||
logger.debug(f"Fetching positions for {body.name} ({body.id})")
|
||||
|
||||
# Fetch positions from NASA Horizons
|
||||
positions = await horizons.get_body_positions(
|
||||
body_id=body.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
step="1d" # Daily positions
|
||||
)
|
||||
|
||||
# Save positions to database (upsert logic)
|
||||
count = 0
|
||||
for pos in positions:
|
||||
# Use PostgreSQL's INSERT ... ON CONFLICT to handle duplicates
|
||||
stmt = insert(Position).values(
|
||||
body_id=body.id,
|
||||
time=pos.time,
|
||||
x=pos.x,
|
||||
y=pos.y,
|
||||
z=pos.z,
|
||||
vx=getattr(pos, 'vx', None),
|
||||
vy=getattr(pos, 'vy', None),
|
||||
vz=getattr(pos, 'vz', None),
|
||||
source=source
|
||||
)
|
||||
|
||||
# On conflict (body_id, time), update the existing record
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=['body_id', 'time'],
|
||||
set_={
|
||||
'x': pos.x,
|
||||
'y': pos.y,
|
||||
'z': pos.z,
|
||||
'vx': getattr(pos, 'vx', None),
|
||||
'vy': getattr(pos, 'vy', None),
|
||||
'vz': getattr(pos, 'vz', None),
|
||||
'source': source
|
||||
}
|
||||
)
|
||||
|
||||
await db.execute(stmt)
|
||||
count += 1
|
||||
|
||||
# Savepoint will auto-commit if no exception
|
||||
total_positions += count
|
||||
synced_bodies.append(body.name)
|
||||
logger.debug(f"Saved {count} positions for {body.name}")
|
||||
|
||||
except Exception as e:
|
||||
# Savepoint will auto-rollback on exception
|
||||
logger.error(f"Failed to sync {body.name}: {str(e)}")
|
||||
failed_bodies.append({"body": body.name, "error": str(e)})
|
||||
# Continue to next body
|
||||
|
||||
# Summary
|
||||
result = {
|
||||
"success": len(failed_bodies) == 0,
|
||||
"bodies_synced": len(synced_bodies),
|
||||
"total_positions": total_positions,
|
||||
"synced_bodies": synced_bodies,
|
||||
"failed_bodies": failed_bodies,
|
||||
"time_range": f"{start_time.date()} to {end_time.date()}",
|
||||
"source": source
|
||||
}
|
||||
|
||||
logger.info(f"Sync completed: {len(synced_bodies)} bodies, {total_positions} positions")
|
||||
return result
|
||||
|
||||
|
||||
@task_registry.register(
|
||||
name="fetch_close_approach_events",
|
||||
description="从NASA SBDB获取小行星/彗星近距离飞掠事件,并保存到数据库",
|
||||
category="data_sync",
|
||||
parameters=[
|
||||
{
|
||||
"name": "body_ids",
|
||||
"type": "array",
|
||||
"description": "要查询的天体ID列表,例如['399', '499']表示地球和火星。如果不指定,默认只查询地球(399)",
|
||||
"required": False,
|
||||
"default": None
|
||||
},
|
||||
{
|
||||
"name": "days_ahead",
|
||||
"type": "integer",
|
||||
"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 fetch_close_approach_events(
|
||||
db: AsyncSession,
|
||||
logger: logging.Logger,
|
||||
params: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch close approach events from NASA SBDB and save to database
|
||||
|
||||
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
|
||||
- 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 fetch operation
|
||||
"""
|
||||
# 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
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"""
|
||||
Task Registry System for Scheduled Jobs
|
||||
|
||||
This module provides a decorator-based registration system for predefined tasks.
|
||||
Tasks are registered with their metadata, parameters schema, and execution function.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Callable, Any, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskParameter(BaseModel):
|
||||
"""Task parameter definition"""
|
||||
name: str = Field(..., description="Parameter name")
|
||||
type: str = Field(..., description="Parameter type (string, integer, array, boolean)")
|
||||
description: str = Field(..., description="Parameter description")
|
||||
required: bool = Field(default=False, description="Whether parameter is required")
|
||||
default: Any = Field(default=None, description="Default value")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskDefinition:
|
||||
"""Registered task definition"""
|
||||
name: str
|
||||
function: Callable
|
||||
description: str
|
||||
parameters: List[TaskParameter] = field(default_factory=list)
|
||||
category: str = "general"
|
||||
|
||||
|
||||
class TaskRegistry:
|
||||
"""Registry for predefined scheduled tasks"""
|
||||
|
||||
def __init__(self):
|
||||
self._tasks: Dict[str, TaskDefinition] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
parameters: Optional[List[Dict[str, Any]]] = None,
|
||||
category: str = "general"
|
||||
):
|
||||
"""
|
||||
Decorator to register a task function
|
||||
|
||||
Usage:
|
||||
@task_registry.register(
|
||||
name="sync_positions",
|
||||
description="Sync celestial body positions",
|
||||
parameters=[
|
||||
{"name": "days", "type": "integer", "description": "Days to sync", "default": 7}
|
||||
]
|
||||
)
|
||||
async def sync_positions_task(db, logger, params):
|
||||
# Task implementation
|
||||
pass
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
# Parse parameters
|
||||
param_list = []
|
||||
if parameters:
|
||||
for p in parameters:
|
||||
param_list.append(TaskParameter(**p))
|
||||
|
||||
# Register the task
|
||||
task_def = TaskDefinition(
|
||||
name=name,
|
||||
function=func,
|
||||
description=description,
|
||||
parameters=param_list,
|
||||
category=category
|
||||
)
|
||||
self._tasks[name] = task_def
|
||||
logger.debug(f"Registered task: {name}")
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def get_task(self, name: str) -> Optional[TaskDefinition]:
|
||||
"""Get a task definition by name"""
|
||||
return self._tasks.get(name)
|
||||
|
||||
def list_tasks(self) -> List[Dict[str, Any]]:
|
||||
"""List all registered tasks with their metadata"""
|
||||
return [
|
||||
{
|
||||
"name": task.name,
|
||||
"description": task.description,
|
||||
"category": task.category,
|
||||
"parameters": [
|
||||
{
|
||||
"name": p.name,
|
||||
"type": p.type,
|
||||
"description": p.description,
|
||||
"required": p.required,
|
||||
"default": p.default
|
||||
}
|
||||
for p in task.parameters
|
||||
]
|
||||
}
|
||||
for task in self._tasks.values()
|
||||
]
|
||||
|
||||
async def execute_task(
|
||||
self,
|
||||
name: str,
|
||||
db: Any,
|
||||
logger: logging.Logger,
|
||||
params: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Execute a registered task
|
||||
|
||||
Args:
|
||||
name: Task function name
|
||||
db: Database session
|
||||
logger: Logger instance
|
||||
params: Task parameters from function_params JSONB field
|
||||
|
||||
Returns:
|
||||
Task execution result
|
||||
|
||||
Raises:
|
||||
ValueError: If task not found
|
||||
"""
|
||||
task_def = self.get_task(name)
|
||||
if not task_def:
|
||||
raise ValueError(f"Task '{name}' not found in registry")
|
||||
|
||||
# Merge default parameters
|
||||
merged_params = {}
|
||||
for param in task_def.parameters:
|
||||
if param.name in params:
|
||||
merged_params[param.name] = params[param.name]
|
||||
elif param.default is not None:
|
||||
merged_params[param.name] = param.default
|
||||
elif param.required:
|
||||
raise ValueError(f"Required parameter '{param.name}' not provided")
|
||||
|
||||
# Execute the task function
|
||||
logger.debug(f"Executing task '{name}' with params: {merged_params}")
|
||||
result = await task_def.function(db=db, logger=logger, params=merged_params)
|
||||
logger.debug(f"Task '{name}' completed successfully")
|
||||
return result
|
||||
|
||||
|
||||
# Global task registry instance
|
||||
task_registry = TaskRegistry()
|
||||
|
|
@ -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
|
||||
|
|
@ -30,8 +31,12 @@ from app.api.celestial_orbit import router as celestial_orbit_router
|
|||
from app.api.nasa_download import router as nasa_download_router
|
||||
from app.api.celestial_position import router as celestial_position_router
|
||||
from app.api.star_system import router as star_system_router
|
||||
from app.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
|
||||
from app.database import close_db
|
||||
|
||||
# Configure logging
|
||||
|
|
@ -47,6 +52,7 @@ if log_level == logging.WARNING:
|
|||
logging.getLogger("app.services.cache").setLevel(logging.ERROR)
|
||||
logging.getLogger("app.services.redis_cache").setLevel(logging.ERROR)
|
||||
logging.getLogger("app.api.celestial_position").setLevel(logging.WARNING)
|
||||
logging.getLogger("apscheduler").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -80,6 +86,9 @@ async def lifespan(app: FastAPI):
|
|||
# Preheat caches (load from database to Redis)
|
||||
await preheat_all_caches()
|
||||
|
||||
# Start Scheduler
|
||||
scheduler_service.start()
|
||||
|
||||
logger.info("✓ Application started successfully")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
|
@ -89,6 +98,9 @@ async def lifespan(app: FastAPI):
|
|||
logger.info("=" * 60)
|
||||
logger.info("Shutting down Cosmo Backend API...")
|
||||
|
||||
# Stop Scheduler
|
||||
scheduler_service.shutdown()
|
||||
|
||||
# Disconnect Redis
|
||||
await redis_cache.disconnect()
|
||||
|
||||
|
|
@ -116,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)
|
||||
|
|
@ -134,6 +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)
|
||||
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"
|
||||
|
|
@ -182,4 +201,4 @@ if __name__ == "__main__":
|
|||
port=8000,
|
||||
reload=True,
|
||||
log_level="info",
|
||||
)
|
||||
)
|
||||
|
|
@ -13,6 +13,8 @@ from .role import Role
|
|||
from .menu import Menu, RoleMenu
|
||||
from .system_settings import SystemSettings
|
||||
from .task import Task
|
||||
from .user_follow import UserFollow
|
||||
from .celestial_event import CelestialEvent
|
||||
|
||||
__all__ = [
|
||||
"CelestialBody",
|
||||
|
|
@ -29,4 +31,6 @@ __all__ = [
|
|||
"SystemSettings",
|
||||
"user_roles",
|
||||
"Task",
|
||||
"UserFollow",
|
||||
"CelestialEvent",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class CelestialBody(Base):
|
|||
id = Column(String(50), primary_key=True, comment="JPL Horizons ID or custom ID")
|
||||
name = Column(String(200), nullable=False, comment="English name")
|
||||
name_zh = Column(String(200), nullable=True, comment="Chinese name")
|
||||
short_name = Column(String(50), nullable=True, comment="NASA SBDB API short name (e.g., Juptr for Jupiter)")
|
||||
type = Column(String(50), nullable=False, comment="Body type")
|
||||
system_id = Column(Integer, ForeignKey('star_systems.id', ondelete='CASCADE'), nullable=True, comment="所属恒星系ID")
|
||||
description = Column(Text, nullable=True, comment="Description")
|
||||
|
|
@ -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"<CelestialBody(id='{self.id}', name='{self.name}', type='{self.type}')>"
|
||||
return f"<CelestialBody(id='{self.id}', name='{self.name}', type='{self.type}')>"
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
Celestial Event ORM model
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, TIMESTAMP, Text, JSON, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class CelestialEvent(Base):
|
||||
"""Celestial event model (e.g., close approaches, oppositions)"""
|
||||
|
||||
__tablename__ = "celestial_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
body_id = Column(String(50), ForeignKey("celestial_bodies.id", ondelete="CASCADE"), nullable=False)
|
||||
title = Column(String(200), nullable=False)
|
||||
event_type = Column(String(50), nullable=False) # 'approach', 'opposition', 'conjunction'
|
||||
event_time = Column(TIMESTAMP, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
details = Column(JSON, nullable=True) # JSONB for PostgreSQL, JSON for SQLite/other
|
||||
source = Column(String(50), default='nasa_sbdb') # 'nasa_sbdb', 'calculated'
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
# Relationship to celestial body
|
||||
body = relationship("CelestialBody", foreign_keys=[body_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CelestialEvent(id={self.id}, title='{self.title}', body_id='{self.body_id}')>"
|
||||
|
|
@ -40,7 +40,7 @@ class Position(Base):
|
|||
# Constraints and indexes
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"source IN ('nasa_horizons', 'calculated', 'user_defined', 'imported')",
|
||||
"source IN ('nasa_horizons', 'nasa_horizons_cron', 'calculated', 'user_defined', 'imported')",
|
||||
name="chk_source",
|
||||
),
|
||||
Index("idx_positions_body_time", "body_id", "time", postgresql_using="btree"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
Scheduled Job ORM model
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, TIMESTAMP, Boolean, Text, Enum, CheckConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class JobType(str, enum.Enum):
|
||||
"""Job type enumeration"""
|
||||
PREDEFINED = "predefined"
|
||||
CUSTOM_CODE = "custom_code"
|
||||
|
||||
|
||||
class ScheduledJob(Base):
|
||||
"""Scheduled jobs configuration"""
|
||||
|
||||
__tablename__ = "scheduled_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="Task name")
|
||||
job_type = Column(
|
||||
Enum(JobType, values_callable=lambda obj: [e.value for e in obj]),
|
||||
nullable=False,
|
||||
default=JobType.PREDEFINED,
|
||||
comment="Job type: predefined or custom_code"
|
||||
)
|
||||
predefined_function = Column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="Predefined function name (required if job_type=predefined)"
|
||||
)
|
||||
function_params = Column(
|
||||
JSONB,
|
||||
nullable=True,
|
||||
default={},
|
||||
comment="JSON parameters for predefined function"
|
||||
)
|
||||
cron_expression = Column(String(50), nullable=False, comment="CRON expression")
|
||||
python_code = Column(Text, nullable=True, comment="Dynamic Python code (only for custom_code type)")
|
||||
is_active = Column(Boolean, default=True, comment="Active status")
|
||||
last_run_at = Column(TIMESTAMP, nullable=True, comment="Last execution time")
|
||||
last_run_status = Column(String(20), nullable=True, comment="Last execution status")
|
||||
next_run_at = Column(TIMESTAMP, nullable=True, comment="Next scheduled execution time")
|
||||
description = Column(Text, nullable=True, comment="Description")
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(job_type = 'predefined' AND predefined_function IS NOT NULL) OR (job_type = 'custom_code' AND python_code IS NOT NULL)",
|
||||
name="chk_job_type_fields"
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScheduledJob(id={self.id}, name='{self.name}', cron='{self.cron_expression}')>"
|
||||
|
|
@ -34,6 +34,7 @@ class User(Base):
|
|||
|
||||
# Relationships
|
||||
roles = relationship("Role", secondary=user_roles, back_populates="users")
|
||||
follows = relationship("UserFollow", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"""
|
||||
User Follows ORM model
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, TIMESTAMP, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserFollow(Base):
|
||||
"""User follows celestial body model"""
|
||||
|
||||
__tablename__ = "user_follows"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
body_id = Column(String(50), ForeignKey("celestial_bodies.id", ondelete="CASCADE"), primary_key=True)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="follows")
|
||||
# Note: No back_populates to CelestialBody as we don't need reverse lookup
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserFollow(user_id={self.user_id}, body_id='{self.body_id}')>"
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# --- Simple Body Info Schema ---
|
||||
class BodyInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
name_zh: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
# --- User Follows Schemas ---
|
||||
class UserFollowBase(BaseModel):
|
||||
body_id: str
|
||||
|
||||
|
||||
class UserFollowCreate(UserFollowBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserFollowResponse(UserFollowBase):
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
# Extended fields with body details
|
||||
id: str
|
||||
name: str
|
||||
name_zh: Optional[str] = None
|
||||
type: str
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
# --- Channel Message Schemas ---
|
||||
class ChannelMessageBase(BaseModel):
|
||||
content: str = Field(..., max_length=500, description="Message content")
|
||||
|
||||
|
||||
class ChannelMessageCreate(ChannelMessageBase):
|
||||
pass
|
||||
|
||||
|
||||
class ChannelMessageResponse(ChannelMessageBase):
|
||||
user_id: int
|
||||
username: str
|
||||
body_id: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
# Allow ORM mode for compatibility if we ever fetch from DB,
|
||||
# though these are primarily Redis-based
|
||||
pass
|
||||
|
||||
# --- Celestial Event Schemas ---
|
||||
class CelestialEventBase(BaseModel):
|
||||
body_id: str
|
||||
title: str
|
||||
event_type: str
|
||||
event_time: datetime
|
||||
description: Optional[str] = None
|
||||
details: Optional[dict] = None
|
||||
source: Optional[str] = None
|
||||
|
||||
class CelestialEventCreate(CelestialEventBase):
|
||||
pass
|
||||
|
||||
class CelestialEventResponse(CelestialEventBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
body: Optional[BodyInfo] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
"""
|
||||
Python Code Validator for Scheduled Jobs
|
||||
验证用户提交的 Python 代码安全性和语法正确性
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
class PythonCodeValidator:
|
||||
"""验证Python代码的安全性和有效性"""
|
||||
|
||||
# 危险的内置函数和模块
|
||||
DANGEROUS_BUILTINS = {
|
||||
'eval', 'exec', 'compile', '__import__',
|
||||
'open', 'file', 'input', 'raw_input',
|
||||
'execfile', 'reload',
|
||||
}
|
||||
|
||||
# 危险的模块
|
||||
DANGEROUS_MODULES = {
|
||||
'os', 'sys', 'subprocess', 'socket',
|
||||
'shutil', 'pickle', 'multiprocessing',
|
||||
'threading', 'ctypes', 'importlib',
|
||||
}
|
||||
|
||||
# 允许的模块(白名单)
|
||||
ALLOWED_MODULES = {
|
||||
'asyncio', 'datetime', 'math', 'json',
|
||||
'logging', 'typing', 'collections',
|
||||
'app.services', 'app.models', 'sqlalchemy',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_syntax(code: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证Python代码语法
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
ast.parse(code)
|
||||
return True, ""
|
||||
except SyntaxError as e:
|
||||
return False, f"语法错误 (第{e.lineno}行): {e.msg}"
|
||||
except Exception as e:
|
||||
return False, f"代码解析错误: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def check_dangerous_functions(code: str) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
检查是否使用了危险函数
|
||||
|
||||
Returns:
|
||||
(is_safe, dangerous_items)
|
||||
"""
|
||||
dangerous_found = []
|
||||
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# 检查函数调用
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name):
|
||||
if node.func.id in PythonCodeValidator.DANGEROUS_BUILTINS:
|
||||
dangerous_found.append(f"危险函数: {node.func.id}")
|
||||
|
||||
# 检查模块导入
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
module_name = alias.name.split('.')[0]
|
||||
if module_name in PythonCodeValidator.DANGEROUS_MODULES:
|
||||
if not any(module_name.startswith(allowed) for allowed in PythonCodeValidator.ALLOWED_MODULES):
|
||||
dangerous_found.append(f"危险模块导入: {alias.name}")
|
||||
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module:
|
||||
module_name = node.module.split('.')[0]
|
||||
if module_name in PythonCodeValidator.DANGEROUS_MODULES:
|
||||
if not any(module_name.startswith(allowed) for allowed in PythonCodeValidator.ALLOWED_MODULES):
|
||||
dangerous_found.append(f"危险模块导入: from {node.module}")
|
||||
|
||||
return len(dangerous_found) == 0, dangerous_found
|
||||
|
||||
except Exception as e:
|
||||
return False, [f"代码分析错误: {str(e)}"]
|
||||
|
||||
@staticmethod
|
||||
def validate_code(code: str) -> Dict:
|
||||
"""
|
||||
完整的代码验证
|
||||
|
||||
Returns:
|
||||
{
|
||||
"valid": bool,
|
||||
"errors": List[str],
|
||||
"warnings": List[str]
|
||||
}
|
||||
"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# 1. 检查代码是否为空
|
||||
if not code or not code.strip():
|
||||
errors.append("代码不能为空")
|
||||
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||
|
||||
# 2. 语法验证
|
||||
syntax_valid, syntax_error = PythonCodeValidator.validate_syntax(code)
|
||||
if not syntax_valid:
|
||||
errors.append(syntax_error)
|
||||
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||
|
||||
# 3. 安全检查
|
||||
is_safe, dangerous_items = PythonCodeValidator.check_dangerous_functions(code)
|
||||
if not is_safe:
|
||||
errors.extend(dangerous_items)
|
||||
|
||||
# 4. 检查代码长度
|
||||
if len(code) > 10000: # 10KB limit
|
||||
warnings.append("代码过长,可能影响性能")
|
||||
|
||||
# 5. 检查是否包含无限循环风险
|
||||
if re.search(r'while\s+True\s*:', code):
|
||||
warnings.append("检测到 'while True',请确保有退出条件")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings
|
||||
}
|
||||
|
||||
|
||||
# 导出验证器实例
|
||||
code_validator = PythonCodeValidator()
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
Event Service - Manages celestial events
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models.db.celestial_event import CelestialEvent
|
||||
from app.models.schemas.social import CelestialEventCreate, CelestialEventResponse
|
||||
from app.models.db.celestial_body import CelestialBody
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EventService:
|
||||
async def create_event(self, event_data: CelestialEventCreate, db: AsyncSession) -> CelestialEvent:
|
||||
"""Create a new celestial event"""
|
||||
event = CelestialEvent(**event_data.dict())
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
logger.info(f"Created celestial event: {event.title} for {event.body_id}")
|
||||
return event
|
||||
|
||||
async def get_event(self, event_id: int, db: AsyncSession) -> Optional[CelestialEvent]:
|
||||
"""Get a specific celestial event by ID"""
|
||||
result = await db.execute(select(CelestialEvent).where(CelestialEvent.id == event_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_events(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
body_id: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[CelestialEvent]:
|
||||
"""Get a list of celestial events, with optional filters"""
|
||||
query = select(CelestialEvent).options(selectinload(CelestialEvent.body))
|
||||
if body_id:
|
||||
query = query.where(CelestialEvent.body_id == body_id)
|
||||
if start_time:
|
||||
query = query.where(CelestialEvent.event_time >= start_time)
|
||||
if end_time:
|
||||
query = query.where(CelestialEvent.event_time <= end_time)
|
||||
|
||||
query = query.order_by(CelestialEvent.event_time).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def delete_event(self, event_id: int, db: AsyncSession) -> bool:
|
||||
"""Delete a celestial event by ID"""
|
||||
event = await self.get_event(event_id, db)
|
||||
if event:
|
||||
await db.delete(event)
|
||||
await db.commit()
|
||||
logger.info(f"Deleted celestial event: {event.title} (ID: {event.id})")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def delete_events_for_body_before(self, body_id: str, before_time: datetime, db: AsyncSession) -> int:
|
||||
"""Delete old events for a specific body before a given time"""
|
||||
result = await db.execute(
|
||||
delete(CelestialEvent).where(
|
||||
CelestialEvent.body_id == body_id,
|
||||
CelestialEvent.event_time < before_time
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return result.rowcount
|
||||
|
||||
event_service = EventService()
|
||||
|
|
@ -7,10 +7,12 @@ import logging
|
|||
import re
|
||||
import httpx
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import AsyncSession # Added this import
|
||||
import json
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.celestial import Position, CelestialBody
|
||||
from app.config import settings
|
||||
from app.services.redis_cache import redis_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -62,7 +64,7 @@ class HorizonsService:
|
|||
|
||||
return response.text
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching raw data for {body_id}: {str(e)}")
|
||||
logger.error(f"Error fetching raw data for {body_id}: {repr(e)}")
|
||||
raise
|
||||
|
||||
async def get_body_positions(
|
||||
|
|
@ -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,10 +153,28 @@ 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}: {str(e)}")
|
||||
logger.error(f"Error querying Horizons for body {body_id}: {repr(e)}")
|
||||
raise
|
||||
|
||||
def _parse_vectors(self, text: str) -> list[Position]:
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
"""
|
||||
NASA SBDB (Small-Body Database) Close-Approach Data API Service
|
||||
Fetches close approach events for asteroids and comets
|
||||
API Docs: https://ssd-api.jpl.nasa.gov/doc/cad.html
|
||||
"""
|
||||
import logging
|
||||
import httpx
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime, timedelta
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NasaSbdbService:
|
||||
"""NASA Small-Body Database Close-Approach Data API client"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = "https://ssd-api.jpl.nasa.gov/cad.api"
|
||||
self.timeout = settings.nasa_api_timeout or 30
|
||||
|
||||
async def get_close_approaches(
|
||||
self,
|
||||
date_min: Optional[str] = None,
|
||||
date_max: Optional[str] = None,
|
||||
dist_max: Optional[str] = "0.2", # Max distance in AU (Earth-Moon distance ~0.0026 AU)
|
||||
body: Optional[str] = None,
|
||||
sort: str = "date",
|
||||
limit: Optional[int] = None,
|
||||
fullname: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Query NASA SBDB Close-Approach Data API
|
||||
|
||||
Args:
|
||||
date_min: Minimum approach date (YYYY-MM-DD or 'now')
|
||||
date_max: Maximum approach date (YYYY-MM-DD)
|
||||
dist_max: Maximum approach distance in AU (default 0.2 AU)
|
||||
body: Filter by specific body (e.g., 'Earth')
|
||||
sort: Sort by 'date', 'dist', 'dist-min', etc.
|
||||
limit: Maximum number of results
|
||||
fullname: Return full designation names
|
||||
|
||||
Returns:
|
||||
List of close approach events
|
||||
"""
|
||||
params = {
|
||||
"dist-max": dist_max,
|
||||
"sort": sort,
|
||||
"fullname": "true" if fullname else "false"
|
||||
}
|
||||
|
||||
if date_min:
|
||||
params["date-min"] = date_min
|
||||
if date_max:
|
||||
params["date-max"] = date_max
|
||||
if body:
|
||||
params["body"] = body
|
||||
if limit:
|
||||
params["limit"] = str(limit)
|
||||
|
||||
logger.info(f"Querying NASA SBDB for close approaches: {params}")
|
||||
|
||||
# Use proxy if configured
|
||||
proxies = settings.proxy_dict
|
||||
if proxies:
|
||||
logger.info(f"Using proxy for NASA SBDB API")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout, proxies=proxies) as client:
|
||||
response = await client.get(self.base_url, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if "data" not in data:
|
||||
logger.warning("No data field in NASA SBDB response")
|
||||
return []
|
||||
|
||||
# Parse response
|
||||
fields = data.get("fields", [])
|
||||
rows = data.get("data", [])
|
||||
|
||||
events = []
|
||||
for row in rows:
|
||||
event = dict(zip(fields, row))
|
||||
events.append(event)
|
||||
|
||||
logger.info(f"Retrieved {len(events)} close approach events from NASA SBDB")
|
||||
return events
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"NASA SBDB API HTTP error: {e.response.status_code} - {e.response.text}")
|
||||
return []
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"NASA SBDB API timeout after {self.timeout}s")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying NASA SBDB: {e}")
|
||||
return []
|
||||
|
||||
def parse_event_to_celestial_event(self, sbdb_event: Dict[str, Any], approach_body: str = "Earth") -> Dict[str, Any]:
|
||||
"""
|
||||
Parse NASA SBDB event data to CelestialEvent format
|
||||
|
||||
Args:
|
||||
sbdb_event: Event data from NASA SBDB API
|
||||
approach_body: Name of the body being approached (e.g., "Earth", "Mars")
|
||||
|
||||
SBDB fields typically include:
|
||||
- des: Object designation
|
||||
- orbit_id: Orbit ID
|
||||
- jd: Julian Date of close approach
|
||||
- cd: Calendar date (YYYY-MMM-DD HH:MM)
|
||||
- dist: Nominal approach distance (AU)
|
||||
- dist_min: Minimum approach distance (AU)
|
||||
- dist_max: Maximum approach distance (AU)
|
||||
- v_rel: Relative velocity (km/s)
|
||||
- v_inf: Velocity at infinity (km/s)
|
||||
- t_sigma_f: Time uncertainty (formatted string)
|
||||
- h: Absolute magnitude
|
||||
- fullname: Full object name (if requested)
|
||||
"""
|
||||
try:
|
||||
# Extract fields
|
||||
designation = sbdb_event.get("des", "Unknown")
|
||||
fullname = sbdb_event.get("fullname", designation)
|
||||
cd = sbdb_event.get("cd", "") # Calendar date string
|
||||
dist = sbdb_event.get("dist", "") # Nominal distance in AU
|
||||
dist_min = sbdb_event.get("dist_min", "")
|
||||
v_rel = sbdb_event.get("v_rel", "")
|
||||
# Note: NASA API doesn't return the approach body, so we use the parameter
|
||||
body = approach_body
|
||||
|
||||
# Parse date (format: YYYY-MMM-DD HH:MM)
|
||||
event_time = datetime.strptime(cd, "%Y-%b-%d %H:%M")
|
||||
|
||||
# Create title
|
||||
title = f"{fullname} Close Approach to {body}"
|
||||
|
||||
# Create description
|
||||
desc_parts = [
|
||||
f"Asteroid/Comet {fullname} will make a close approach to {body}.",
|
||||
f"Nominal distance: {dist} AU",
|
||||
]
|
||||
if dist_min:
|
||||
desc_parts.append(f"Minimum distance: {dist_min} AU")
|
||||
if v_rel:
|
||||
desc_parts.append(f"Relative velocity: {v_rel} km/s")
|
||||
|
||||
description = " ".join(desc_parts)
|
||||
|
||||
# Store all technical details in JSONB
|
||||
details = {
|
||||
"designation": designation,
|
||||
"orbit_id": sbdb_event.get("orbit_id"),
|
||||
"julian_date": sbdb_event.get("jd"),
|
||||
"nominal_dist_au": dist,
|
||||
"dist_min_au": dist_min,
|
||||
"dist_max_au": sbdb_event.get("dist_max"),
|
||||
"relative_velocity_km_s": v_rel,
|
||||
"v_inf": sbdb_event.get("v_inf"),
|
||||
"time_sigma": sbdb_event.get("t_sigma_f"),
|
||||
"absolute_magnitude": sbdb_event.get("h"),
|
||||
"approach_body": body
|
||||
}
|
||||
|
||||
return {
|
||||
"body_id": designation, # Will need to map to celestial_bodies.id
|
||||
"title": title,
|
||||
"event_type": "approach",
|
||||
"event_time": event_time,
|
||||
"description": description,
|
||||
"details": details,
|
||||
"source": "nasa_sbdb"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing SBDB event: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
nasa_sbdb_service = NasaSbdbService()
|
||||
|
|
@ -1,13 +1,20 @@
|
|||
"""
|
||||
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
|
||||
from typing import List, Optional
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
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__)
|
||||
|
||||
|
|
@ -20,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
|
||||
|
|
@ -102,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)
|
||||
|
||||
|
|
@ -112,9 +119,225 @@ 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):
|
||||
"""
|
||||
Background task to generate orbits
|
||||
|
||||
Args:
|
||||
task_id: ID of the task record to update
|
||||
body_ids: List of body IDs to generate. If None, generates for all bodies with orbital params.
|
||||
"""
|
||||
logger.info(f"🚀 Starting background orbit generation task {task_id}")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await task_service.update_task(
|
||||
db, task_id, status="running", started_at=datetime.utcnow(), progress=0
|
||||
)
|
||||
|
||||
bodies_to_process = []
|
||||
|
||||
if body_ids:
|
||||
for bid in body_ids:
|
||||
body = await celestial_body_service.get_body_by_id(bid, db)
|
||||
if body:
|
||||
bodies_to_process.append(body)
|
||||
else:
|
||||
bodies_to_process = await celestial_body_service.get_all_bodies(db)
|
||||
|
||||
valid_bodies = []
|
||||
for body in bodies_to_process:
|
||||
extra_data = body.extra_data or {}
|
||||
if extra_data.get("orbit_period_days"):
|
||||
valid_bodies.append(body)
|
||||
elif body_ids and body.id in body_ids:
|
||||
logger.warning(f"Body {body.name} ({body.id}) missing 'orbit_period_days', skipping.")
|
||||
|
||||
total_bodies = len(valid_bodies)
|
||||
if total_bodies == 0:
|
||||
await task_service.update_task(
|
||||
db, task_id, status="completed", progress=100,
|
||||
result={"message": "No bodies with 'orbit_period_days' found to process"}
|
||||
)
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
results = []
|
||||
|
||||
for i, body in enumerate(valid_bodies):
|
||||
try:
|
||||
progress = int((i / total_bodies) * 100)
|
||||
await task_service.update_task(db, task_id, progress=progress)
|
||||
|
||||
extra_data = body.extra_data or {}
|
||||
period = float(extra_data.get("orbit_period_days"))
|
||||
color = extra_data.get("orbit_color", "#CCCCCC")
|
||||
|
||||
orbit = await orbit_service.generate_orbit(
|
||||
body_id=body.id,
|
||||
body_name=body.name_zh or body.name,
|
||||
period_days=period,
|
||||
color=color,
|
||||
session=db,
|
||||
horizons_service=horizons_service
|
||||
)
|
||||
|
||||
results.append({
|
||||
"body_id": body.id,
|
||||
"body_name": body.name_zh or body.name,
|
||||
"status": "success",
|
||||
"num_points": orbit.num_points
|
||||
})
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate orbit for {body.name}: {e}")
|
||||
results.append({
|
||||
"body_id": body.id,
|
||||
"body_name": body.name_zh or body.name,
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
})
|
||||
failure_count += 1
|
||||
|
||||
await task_service.update_task(
|
||||
db,
|
||||
task_id,
|
||||
status="completed",
|
||||
progress=100,
|
||||
completed_at=datetime.utcnow(),
|
||||
result={
|
||||
"total": total_bodies,
|
||||
"success": success_count,
|
||||
"failed": failure_count,
|
||||
"details": results
|
||||
}
|
||||
)
|
||||
logger.info(f"🏁 Orbit generation task {task_id} completed")
|
||||
|
||||
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), 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))
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ class OrbitService:
|
|||
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
|
||||
|
|
@ -161,7 +161,7 @@ class OrbitService:
|
|||
# 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)
|
||||
|
|
@ -177,8 +177,8 @@ class OrbitService:
|
|||
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
|
||||
|
|
@ -186,19 +186,19 @@ class OrbitService:
|
|||
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,
|
||||
|
|
@ -215,7 +215,7 @@ class OrbitService:
|
|||
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(
|
||||
|
|
@ -227,12 +227,13 @@ class OrbitService:
|
|||
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}: {e}")
|
||||
logger.error(f" Failed to generate orbit for {body_name}: {repr(e)}")
|
||||
raise
|
||||
|
||||
|
||||
# Singleton instance
|
||||
orbit_service = OrbitService()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
"""
|
||||
Planetary Events Service - Calculate astronomical events using Skyfield
|
||||
Computes conjunctions, oppositions, and other events for major solar system bodies
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from skyfield.api import load, wgs84
|
||||
from skyfield import almanac
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlanetaryEventsService:
|
||||
"""Service for calculating planetary astronomical events"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Skyfield ephemeris and timescale"""
|
||||
self.ts = None
|
||||
self.eph = None
|
||||
self._initialized = False
|
||||
|
||||
def _ensure_initialized(self):
|
||||
"""Lazy load ephemeris data (downloads ~30MB on first run)"""
|
||||
if not self._initialized:
|
||||
logger.info("Loading Skyfield ephemeris (DE421)...")
|
||||
self.ts = load.timescale()
|
||||
self.eph = load('de421.bsp') # Covers 1900-2050
|
||||
self._initialized = True
|
||||
logger.info("Skyfield ephemeris loaded successfully")
|
||||
|
||||
def get_planet_mapping(self) -> Dict[str, Dict[str, str]]:
|
||||
"""
|
||||
Map database body IDs to Skyfield names
|
||||
|
||||
Returns:
|
||||
Dictionary mapping body_id to Skyfield ephemeris names
|
||||
"""
|
||||
return {
|
||||
'10': {'skyfield': 'sun', 'name': 'Sun', 'name_zh': '太阳'},
|
||||
'199': {'skyfield': 'mercury', 'name': 'Mercury', 'name_zh': '水星'},
|
||||
'299': {'skyfield': 'venus', 'name': 'Venus', 'name_zh': '金星'},
|
||||
'399': {'skyfield': 'earth', 'name': 'Earth', 'name_zh': '地球'},
|
||||
'301': {'skyfield': 'moon', 'name': 'Moon', 'name_zh': '月球'},
|
||||
'499': {'skyfield': 'mars', 'name': 'Mars', 'name_zh': '火星'},
|
||||
'599': {'skyfield': 'jupiter barycenter', 'name': 'Jupiter', 'name_zh': '木星'},
|
||||
'699': {'skyfield': 'saturn barycenter', 'name': 'Saturn', 'name_zh': '土星'},
|
||||
'799': {'skyfield': 'uranus barycenter', 'name': 'Uranus', 'name_zh': '天王星'},
|
||||
'899': {'skyfield': 'neptune barycenter', 'name': 'Neptune', 'name_zh': '海王星'},
|
||||
}
|
||||
|
||||
def calculate_oppositions_conjunctions(
|
||||
self,
|
||||
body_ids: Optional[List[str]] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
days_ahead: int = 365
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Calculate oppositions and conjunctions for specified bodies
|
||||
|
||||
Args:
|
||||
body_ids: List of body IDs to calculate (default: all major planets)
|
||||
start_date: Start date (default: today)
|
||||
end_date: End date (default: start_date + days_ahead)
|
||||
days_ahead: Days to look ahead if end_date not specified
|
||||
|
||||
Returns:
|
||||
List of event dictionaries
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
|
||||
# Set time range
|
||||
if start_date is None:
|
||||
start_date = datetime.utcnow()
|
||||
if end_date is None:
|
||||
end_date = start_date + timedelta(days=days_ahead)
|
||||
|
||||
t0 = self.ts.utc(start_date.year, start_date.month, start_date.day)
|
||||
t1 = self.ts.utc(end_date.year, end_date.month, end_date.day)
|
||||
|
||||
logger.info(f"Calculating planetary events from {start_date.date()} to {end_date.date()}")
|
||||
|
||||
# Get planet mapping
|
||||
planet_map = self.get_planet_mapping()
|
||||
|
||||
# Default to major planets (exclude Sun, Moon)
|
||||
if body_ids is None:
|
||||
body_ids = ['199', '299', '499', '599', '699', '799', '899']
|
||||
|
||||
# Earth as reference point
|
||||
earth = self.eph['earth']
|
||||
|
||||
events = []
|
||||
|
||||
for body_id in body_ids:
|
||||
if body_id not in planet_map:
|
||||
logger.warning(f"Body ID {body_id} not in planet mapping, skipping")
|
||||
continue
|
||||
|
||||
planet_info = planet_map[body_id]
|
||||
skyfield_name = planet_info['skyfield']
|
||||
|
||||
try:
|
||||
planet = self.eph[skyfield_name]
|
||||
|
||||
# Calculate oppositions and conjunctions
|
||||
f = almanac.oppositions_conjunctions(self.eph, planet)
|
||||
times, event_types = almanac.find_discrete(t0, t1, f)
|
||||
|
||||
for ti, event_type in zip(times, event_types):
|
||||
event_time = ti.utc_datetime()
|
||||
|
||||
# Convert timezone-aware datetime to naive UTC for database
|
||||
# Database expects TIMESTAMP (timezone-naive)
|
||||
event_time = event_time.replace(tzinfo=None)
|
||||
|
||||
# event_type: 0 = conjunction, 1 = opposition
|
||||
is_conjunction = (event_type == 0)
|
||||
|
||||
event_name = '合' if is_conjunction else '冲'
|
||||
event_type_en = 'conjunction' if is_conjunction else 'opposition'
|
||||
|
||||
# Calculate separation angle
|
||||
earth_pos = earth.at(ti)
|
||||
planet_pos = planet.at(ti)
|
||||
separation = earth_pos.separation_from(planet_pos)
|
||||
|
||||
# Create event data
|
||||
event = {
|
||||
'body_id': body_id,
|
||||
'title': f"{planet_info['name_zh']} {event_name} ({planet_info['name']} {event_type_en.capitalize()})",
|
||||
'event_type': event_type_en,
|
||||
'event_time': event_time,
|
||||
'description': f"{planet_info['name_zh']}将发生{event_name}现象。" +
|
||||
(f"与地球的角距离约{separation.degrees:.2f}°。" if is_conjunction else "处于冲日位置,是观测的最佳时机。"),
|
||||
'details': {
|
||||
'event_subtype': event_name,
|
||||
'separation_degrees': round(separation.degrees, 4),
|
||||
'planet_name': planet_info['name'],
|
||||
'planet_name_zh': planet_info['name_zh'],
|
||||
},
|
||||
'source': 'skyfield_calculation'
|
||||
}
|
||||
|
||||
events.append(event)
|
||||
logger.debug(f"Found {event_type_en}: {planet_info['name']} at {event_time}")
|
||||
|
||||
except KeyError:
|
||||
logger.error(f"Planet {skyfield_name} not found in ephemeris")
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating events for {body_id}: {e}")
|
||||
|
||||
logger.info(f"Calculated {len(events)} planetary events")
|
||||
return events
|
||||
|
||||
def calculate_planetary_distances(
|
||||
self,
|
||||
body_pairs: List[tuple],
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
days_ahead: int = 365,
|
||||
threshold_degrees: float = 5.0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Calculate close approaches between planet pairs
|
||||
|
||||
Args:
|
||||
body_pairs: List of (body_id1, body_id2) tuples to check
|
||||
start_date: Start date
|
||||
end_date: End date
|
||||
days_ahead: Days to look ahead
|
||||
threshold_degrees: Only report if closer than this angle
|
||||
|
||||
Returns:
|
||||
List of close approach events
|
||||
"""
|
||||
self._ensure_initialized()
|
||||
|
||||
if start_date is None:
|
||||
start_date = datetime.utcnow()
|
||||
if end_date is None:
|
||||
end_date = start_date + timedelta(days=days_ahead)
|
||||
|
||||
planet_map = self.get_planet_mapping()
|
||||
events = []
|
||||
|
||||
# Sample every day
|
||||
current = start_date
|
||||
while current <= end_date:
|
||||
t = self.ts.utc(current.year, current.month, current.day)
|
||||
|
||||
for body_id1, body_id2 in body_pairs:
|
||||
if body_id1 not in planet_map or body_id2 not in planet_map:
|
||||
continue
|
||||
|
||||
try:
|
||||
planet1 = self.eph[planet_map[body_id1]['skyfield']]
|
||||
planet2 = self.eph[planet_map[body_id2]['skyfield']]
|
||||
|
||||
pos1 = planet1.at(t)
|
||||
pos2 = planet2.at(t)
|
||||
separation = pos1.separation_from(pos2)
|
||||
|
||||
if separation.degrees < threshold_degrees:
|
||||
# Use naive UTC datetime for database
|
||||
event_time = current.replace(tzinfo=None) if hasattr(current, 'tzinfo') else current
|
||||
|
||||
event = {
|
||||
'body_id': body_id1, # Primary body
|
||||
'title': f"{planet_map[body_id1]['name_zh']}与{planet_map[body_id2]['name_zh']}接近",
|
||||
'event_type': 'close_approach',
|
||||
'event_time': event_time,
|
||||
'description': f"{planet_map[body_id1]['name_zh']}与{planet_map[body_id2]['name_zh']}的角距离约{separation.degrees:.2f}°,这是较为罕见的天象。",
|
||||
'details': {
|
||||
'body_id_secondary': body_id2,
|
||||
'separation_degrees': round(separation.degrees, 4),
|
||||
'planet1_name': planet_map[body_id1]['name'],
|
||||
'planet2_name': planet_map[body_id2]['name'],
|
||||
},
|
||||
'source': 'skyfield_calculation'
|
||||
}
|
||||
events.append(event)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating distance for {body_id1}-{body_id2}: {e}")
|
||||
|
||||
current += timedelta(days=1)
|
||||
|
||||
logger.info(f"Found {len(events)} close approach events")
|
||||
return events
|
||||
|
||||
|
||||
# Singleton instance
|
||||
planetary_events_service = PlanetaryEventsService()
|
||||
|
|
@ -148,6 +148,51 @@ class RedisCache:
|
|||
logger.error(f"Redis get_stats error: {e}")
|
||||
return {"connected": False, "error": str(e)}
|
||||
|
||||
# List operations for channel messages
|
||||
async def rpush(self, key: str, value: str) -> int:
|
||||
"""Push value to the right end of list"""
|
||||
if not self._connected or not self.client:
|
||||
return 0
|
||||
try:
|
||||
result = await self.client.rpush(key, value)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Redis rpush error for key '{key}': {e}")
|
||||
return 0
|
||||
|
||||
async def ltrim(self, key: str, start: int, stop: int) -> bool:
|
||||
"""Trim list to specified range"""
|
||||
if not self._connected or not self.client:
|
||||
return False
|
||||
try:
|
||||
await self.client.ltrim(key, start, stop)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Redis ltrim error for key '{key}': {e}")
|
||||
return False
|
||||
|
||||
async def lrange(self, key: str, start: int, stop: int) -> list:
|
||||
"""Get range of elements from list"""
|
||||
if not self._connected or not self.client:
|
||||
return []
|
||||
try:
|
||||
result = await self.client.lrange(key, start, stop)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Redis lrange error for key '{key}': {e}")
|
||||
return []
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> bool:
|
||||
"""Set expiration time for key"""
|
||||
if not self._connected or not self.client:
|
||||
return False
|
||||
try:
|
||||
await self.client.expire(key, seconds)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Redis expire error for key '{key}': {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
redis_cache = RedisCache()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
"""
|
||||
Scheduler Service
|
||||
Manages APScheduler and dynamic task execution
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.db.scheduled_job import ScheduledJob, JobType
|
||||
from app.models.db.task import Task
|
||||
from app.services.task_service import task_service
|
||||
from app.jobs.registry import task_registry
|
||||
# Import predefined tasks to register them
|
||||
import app.jobs.predefined # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
def __init__(self):
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self.jobs = {}
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler"""
|
||||
if not self.scheduler.running:
|
||||
self.scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
# Load jobs from DB
|
||||
asyncio.create_task(self.load_jobs())
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the scheduler"""
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
logger.info("Scheduler stopped")
|
||||
|
||||
async def load_jobs(self):
|
||||
"""Load active jobs from database and schedule them"""
|
||||
logger.info("Loading scheduled jobs from database...")
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(ScheduledJob).where(ScheduledJob.is_active == True))
|
||||
jobs = result.scalars().all()
|
||||
|
||||
for job in jobs:
|
||||
self.add_job_to_scheduler(job)
|
||||
|
||||
logger.info(f"Loaded {len(jobs)} scheduled jobs")
|
||||
|
||||
def add_job_to_scheduler(self, job: ScheduledJob):
|
||||
"""Add a single job to APScheduler"""
|
||||
try:
|
||||
# Remove existing job if any (to update)
|
||||
if str(job.id) in self.jobs:
|
||||
self.scheduler.remove_job(str(job.id))
|
||||
|
||||
# Create trigger from cron expression
|
||||
# Cron format: "minute hour day month day_of_week"
|
||||
# APScheduler expects kwargs, so we need to parse or use from_crontab if strictly standard
|
||||
# But CronTrigger.from_crontab is standard.
|
||||
trigger = CronTrigger.from_crontab(job.cron_expression)
|
||||
|
||||
self.scheduler.add_job(
|
||||
self.execute_job,
|
||||
trigger,
|
||||
args=[job.id],
|
||||
id=str(job.id),
|
||||
name=job.name,
|
||||
replace_existing=True
|
||||
)
|
||||
self.jobs[str(job.id)] = job
|
||||
logger.info(f"Scheduled job '{job.name}' (ID: {job.id}) with cron: {job.cron_expression}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule job '{job.name}': {e}")
|
||||
|
||||
async def execute_job(self, job_id: int):
|
||||
"""
|
||||
Execute either a predefined task or dynamic python code for a job.
|
||||
This runs in the scheduler's event loop.
|
||||
"""
|
||||
logger.info(f"Executing job ID: {job_id}")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Fetch job details again to get latest configuration
|
||||
result = await session.execute(select(ScheduledJob).where(ScheduledJob.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
|
||||
if not job:
|
||||
logger.error(f"Job {job_id} not found")
|
||||
return
|
||||
|
||||
# Validate job configuration
|
||||
if job.job_type == JobType.PREDEFINED and not job.predefined_function:
|
||||
logger.error(f"Job {job_id} is predefined type but has no function name")
|
||||
return
|
||||
elif job.job_type == JobType.CUSTOM_CODE and not job.python_code:
|
||||
logger.error(f"Job {job_id} is custom_code type but has no code")
|
||||
return
|
||||
|
||||
# Create a Task record for this execution history
|
||||
task_record = await task_service.create_task(
|
||||
session,
|
||||
task_type="scheduled_job",
|
||||
description=f"Scheduled execution of '{job.name}'",
|
||||
params={"job_id": job.id, "job_type": job.job_type.value},
|
||||
created_by=None # System
|
||||
)
|
||||
|
||||
# Update Task to running
|
||||
await task_service.update_task(session, task_record.id, status="running", started_at=datetime.utcnow(), progress=0)
|
||||
|
||||
# Update Job last run time
|
||||
job.last_run_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
# Execute based on job type
|
||||
if job.job_type == JobType.PREDEFINED:
|
||||
# Execute predefined task from registry
|
||||
logger.debug(f"Executing predefined task: {job.predefined_function}")
|
||||
result_val = await task_registry.execute_task(
|
||||
name=job.predefined_function,
|
||||
db=session,
|
||||
logger=logger,
|
||||
params=job.function_params or {}
|
||||
)
|
||||
else:
|
||||
# Execute custom Python code (legacy support)
|
||||
logger.debug(f"Executing custom code for job: {job.name}")
|
||||
# Prepare execution context
|
||||
# We inject useful services and variables
|
||||
context = {
|
||||
"db": session,
|
||||
"logger": logger,
|
||||
"task_id": task_record.id,
|
||||
"asyncio": asyncio,
|
||||
# Import commonly used services here if needed, or let code import them
|
||||
}
|
||||
|
||||
# Wrap code in an async function to allow await
|
||||
# Indent code to fit inside the wrapper
|
||||
indented_code = "\n".join([" " + line for line in job.python_code.split("\n")])
|
||||
wrapper_code = f"async def _dynamic_func():\n{indented_code}"
|
||||
|
||||
# Execute definition
|
||||
exec(wrapper_code, context)
|
||||
|
||||
# Execute the function
|
||||
_func = context["_dynamic_func"]
|
||||
result_val = await _func()
|
||||
|
||||
# Success
|
||||
await task_service.update_task(
|
||||
session,
|
||||
task_record.id,
|
||||
status="completed",
|
||||
progress=100,
|
||||
completed_at=datetime.utcnow(),
|
||||
result={"output": str(result_val) if result_val else "Success"}
|
||||
)
|
||||
job.last_run_status = "success"
|
||||
logger.info(f"Job '{job.name}' completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
# Failure
|
||||
import traceback
|
||||
error_msg = f"{str(e)}\n{traceback.format_exc()}"
|
||||
logger.error(f"Job '{job.name}' failed: {e}")
|
||||
|
||||
# Rollback the current transaction
|
||||
await session.rollback()
|
||||
|
||||
# Start a new transaction to update task status
|
||||
try:
|
||||
await task_service.update_task(
|
||||
session,
|
||||
task_record.id,
|
||||
status="failed",
|
||||
error_message=error_msg,
|
||||
completed_at=datetime.utcnow()
|
||||
)
|
||||
job.last_run_status = "failed"
|
||||
# Commit the failed task update in new transaction
|
||||
await session.commit()
|
||||
except Exception as update_error:
|
||||
logger.error(f"Failed to update task status: {update_error}")
|
||||
await session.rollback()
|
||||
|
||||
else:
|
||||
# Success - commit only if no exception
|
||||
await session.commit()
|
||||
|
||||
async def reload_job(self, job_id: int):
|
||||
"""Reload a specific job from DB (after update)"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(ScheduledJob).where(ScheduledJob.id == job_id))
|
||||
job = result.scalar_one_or_none()
|
||||
if job:
|
||||
if job.is_active:
|
||||
self.add_job_to_scheduler(job)
|
||||
else:
|
||||
self.remove_job(job_id)
|
||||
|
||||
def remove_job(self, job_id: int):
|
||||
"""Remove job from scheduler"""
|
||||
if str(job_id) in self.jobs:
|
||||
if self.scheduler.get_job(str(job_id)):
|
||||
self.scheduler.remove_job(str(job_id))
|
||||
del self.jobs[str(job_id)]
|
||||
logger.info(f"Removed job ID: {job_id}")
|
||||
|
||||
async def run_job_now(self, job_id: int):
|
||||
"""Manually trigger a job immediately"""
|
||||
return await self.execute_job(job_id)
|
||||
|
||||
|
||||
# Singleton
|
||||
scheduler_service = SchedulerService()
|
||||
|
|
@ -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()
|
||||
|
|
@ -40,6 +40,30 @@ class TaskService:
|
|||
|
||||
return task
|
||||
|
||||
async def update_task(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
task_id: int,
|
||||
**kwargs
|
||||
):
|
||||
"""Generic task update"""
|
||||
stmt = (
|
||||
update(Task)
|
||||
.where(Task.id == task_id)
|
||||
.values(**kwargs)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
# Update Redis if relevant fields changed
|
||||
if "status" in kwargs or "progress" in kwargs:
|
||||
await self._update_redis(
|
||||
task_id,
|
||||
kwargs.get("progress", 0),
|
||||
kwargs.get("status", "running"),
|
||||
error=kwargs.get("error_message")
|
||||
)
|
||||
|
||||
async def update_progress(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
-- Migration: 添加 nasa_horizons_cron 到 positions 表的 source 约束
|
||||
-- Date: 2025-12-11
|
||||
|
||||
-- 1. 删除旧的约束
|
||||
ALTER TABLE positions DROP CONSTRAINT IF EXISTS chk_source;
|
||||
|
||||
-- 2. 添加新的约束(包含 nasa_horizons_cron)
|
||||
ALTER TABLE positions ADD CONSTRAINT chk_source
|
||||
CHECK (source IN ('nasa_horizons', 'nasa_horizons_cron', 'calculated', 'user_defined', 'imported'));
|
||||
|
||||
-- 3. 验证约束
|
||||
SELECT conname, pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'positions'::regclass
|
||||
AND conname = 'chk_source';
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
-- Migration: Add Predefined Task Support to scheduled_jobs
|
||||
-- Date: 2025-12-11
|
||||
-- Purpose: Transition from dynamic code execution to predefined task system
|
||||
|
||||
-- 1. Create job_type ENUM type
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'jobtype') THEN
|
||||
CREATE TYPE jobtype AS ENUM ('predefined', 'custom_code');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2. Add new columns
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD COLUMN IF NOT EXISTS job_type jobtype DEFAULT 'custom_code',
|
||||
ADD COLUMN IF NOT EXISTS predefined_function VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS function_params JSONB DEFAULT '{}'::jsonb;
|
||||
|
||||
-- 3. Update existing rows to custom_code type (preserve backward compatibility)
|
||||
UPDATE scheduled_jobs
|
||||
SET job_type = 'custom_code'
|
||||
WHERE job_type IS NULL;
|
||||
|
||||
-- 4. Make job_type NOT NULL after setting defaults
|
||||
ALTER TABLE scheduled_jobs
|
||||
ALTER COLUMN job_type SET NOT NULL;
|
||||
|
||||
-- 5. Set default for job_type to 'predefined' for new records
|
||||
ALTER TABLE scheduled_jobs
|
||||
ALTER COLUMN job_type SET DEFAULT 'predefined';
|
||||
|
||||
-- 6. Add check constraint
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD CONSTRAINT chk_job_type_fields
|
||||
CHECK (
|
||||
(job_type = 'predefined' AND predefined_function IS NOT NULL)
|
||||
OR
|
||||
(job_type = 'custom_code' AND python_code IS NOT NULL)
|
||||
);
|
||||
|
||||
-- 7. Add comment on columns
|
||||
COMMENT ON COLUMN scheduled_jobs.job_type IS 'Job type: predefined or custom_code';
|
||||
COMMENT ON COLUMN scheduled_jobs.predefined_function IS 'Predefined function name (required if job_type=predefined)';
|
||||
COMMENT ON COLUMN scheduled_jobs.function_params IS 'JSON parameters for predefined function';
|
||||
COMMENT ON COLUMN scheduled_jobs.python_code IS 'Dynamic Python code (only for custom_code type)';
|
||||
|
||||
-- 8. Verify the changes
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'scheduled_jobs'
|
||||
ORDER BY ordinal_position;
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
## 修复定时任务参数类型错误
|
||||
|
||||
### 问题描述
|
||||
在定时任务执行时报错:
|
||||
```
|
||||
"error": "unsupported type for timedelta days component: str"
|
||||
```
|
||||
|
||||
### 原因分析
|
||||
当任务参数从数据库的JSON字段读取时,所有参数都会被解析为字符串类型。例如:
|
||||
- `"days_ahead": 365` 会变成 `"days_ahead": "365"` (字符串)
|
||||
- `"calculate_close_approaches": false` 会变成 `"calculate_close_approaches": "false"` (字符串)
|
||||
|
||||
而代码中直接使用这些参数,导致类型不匹配错误。
|
||||
|
||||
### 修复内容
|
||||
|
||||
在 `/Users/jiliu/WorkSpace/cosmo/backend/app/jobs/predefined.py` 中修复了三个任务的参数类型转换:
|
||||
|
||||
#### 1. `sync_solar_system_positions` (第71-74行)
|
||||
```python
|
||||
# 修复前
|
||||
body_ids = params.get("body_ids")
|
||||
days = params.get("days", 7)
|
||||
source = params.get("source", "nasa_horizons_cron")
|
||||
|
||||
# 修复后
|
||||
body_ids = params.get("body_ids")
|
||||
days = int(params.get("days", 7))
|
||||
source = str(params.get("source", "nasa_horizons_cron"))
|
||||
```
|
||||
|
||||
#### 2. `fetch_close_approach_events` (第264-269行)
|
||||
```python
|
||||
# 修复前
|
||||
body_ids = params.get("body_ids") or ["399"]
|
||||
days_ahead = params.get("days_ahead", 30)
|
||||
dist_max = params.get("dist_max", "30")
|
||||
limit = params.get("limit", 100)
|
||||
clean_old_events = params.get("clean_old_events", True)
|
||||
|
||||
# 修复后
|
||||
body_ids = params.get("body_ids") or ["399"]
|
||||
days_ahead = int(params.get("days_ahead", 30))
|
||||
dist_max = str(params.get("dist_max", "30")) # Keep as string for API
|
||||
limit = int(params.get("limit", 100))
|
||||
clean_old_events = bool(params.get("clean_old_events", True))
|
||||
```
|
||||
|
||||
#### 3. `calculate_planetary_events` (第490-495行)
|
||||
```python
|
||||
# 修复前
|
||||
body_ids = params.get("body_ids")
|
||||
days_ahead = params.get("days_ahead", 365)
|
||||
calculate_close_approaches = params.get("calculate_close_approaches", False)
|
||||
threshold_degrees = params.get("threshold_degrees", 5.0)
|
||||
clean_old_events = params.get("clean_old_events", True)
|
||||
|
||||
# 修复后
|
||||
body_ids = params.get("body_ids")
|
||||
days_ahead = int(params.get("days_ahead", 365))
|
||||
calculate_close_approaches = bool(params.get("calculate_close_approaches", False))
|
||||
threshold_degrees = float(params.get("threshold_degrees", 5.0))
|
||||
clean_old_events = bool(params.get("clean_old_events", True))
|
||||
```
|
||||
|
||||
### 测试验证
|
||||
|
||||
测试结果:
|
||||
- ✓ 字符串参数(模拟数据库JSON):成功执行,计算16个事件,保存14个
|
||||
- ✓ 原生类型参数(直接调用):成功执行,正确跳过重复事件
|
||||
|
||||
### 使用说明
|
||||
|
||||
现在可以在数据库中正常配置定时任务了。参数会自动进行类型转换:
|
||||
|
||||
```sql
|
||||
INSERT INTO tasks (name, description, category, parameters, status, schedule_config)
|
||||
VALUES (
|
||||
'calculate_planetary_events',
|
||||
'计算太阳系主要天体的合、冲等事件',
|
||||
'data_sync',
|
||||
'{
|
||||
"days_ahead": 365,
|
||||
"clean_old_events": true,
|
||||
"calculate_close_approaches": false
|
||||
}'::json,
|
||||
'active',
|
||||
'{
|
||||
"type": "cron",
|
||||
"cron": "0 2 * * 0"
|
||||
}'::json
|
||||
);
|
||||
```
|
||||
|
||||
所有参数现在都会被正确转换为对应的类型。
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
-- Add Scheduled Job for Fetching Close Approach Events
|
||||
-- This uses the predefined task: fetch_close_approach_events
|
||||
--
|
||||
-- 参数说明:
|
||||
-- - days_ahead: 30 (查询未来30天的事件)
|
||||
-- - dist_max: "30" (30 AU,海王星轨道范围)
|
||||
-- - approach_body: "Earth" (接近地球的天体)
|
||||
-- - limit: 200 (最多返回200个事件)
|
||||
-- - clean_old_events: true (清理过期事件)
|
||||
--
|
||||
-- Cron表达式: '0 2 * * 0' (每周日UTC 02:00执行)
|
||||
--
|
||||
-- 注意: 任务会自动创建不存在的天体记录(小行星/彗星)
|
||||
|
||||
INSERT INTO "public"."scheduled_jobs"
|
||||
("name", "job_type", "predefined_function", "function_params", "cron_expression", "description", "is_active")
|
||||
VALUES
|
||||
(
|
||||
'每周天体事件拉取 (Close Approaches)',
|
||||
'predefined',
|
||||
'fetch_close_approach_events',
|
||||
'{
|
||||
"days_ahead": 30,
|
||||
"dist_max": "30",
|
||||
"approach_body": "Earth",
|
||||
"limit": 200,
|
||||
"clean_old_events": true
|
||||
}'::jsonb,
|
||||
'0 2 * * 0',
|
||||
'每周日UTC 02:00从NASA SBDB拉取未来30天内距离地球30AU以内(海王星轨道范围)的小行星/彗星接近事件',
|
||||
true
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
-- Add Celestial Events Menu
|
||||
-- 添加天体事件展示菜单到数据管理菜单下
|
||||
|
||||
-- First check if menu already exists
|
||||
DO $$
|
||||
DECLARE
|
||||
menu_exists BOOLEAN;
|
||||
BEGIN
|
||||
SELECT EXISTS(SELECT 1 FROM menus WHERE name = 'celestial_events') INTO menu_exists;
|
||||
|
||||
IF NOT menu_exists THEN
|
||||
INSERT INTO "public"."menus"
|
||||
("name", "title", "icon", "path", "component", "parent_id", "sort_order", "is_active")
|
||||
VALUES
|
||||
(
|
||||
'celestial_events',
|
||||
'天体事件',
|
||||
'CalendarOutlined',
|
||||
'/admin/celestial-events',
|
||||
NULL,
|
||||
2, -- parent_id = 2 (数据管理)
|
||||
4, -- sort_order = 4 (在NASA数据下载之后)
|
||||
true
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Get the menu ID for role assignment
|
||||
DO $$
|
||||
DECLARE
|
||||
menu_id_var INTEGER;
|
||||
admin_role_id INTEGER;
|
||||
user_role_id INTEGER;
|
||||
BEGIN
|
||||
-- Get the celestial_events menu ID
|
||||
SELECT id INTO menu_id_var FROM menus WHERE name = 'celestial_events';
|
||||
|
||||
-- Get role IDs
|
||||
SELECT id INTO admin_role_id FROM roles WHERE name = 'admin';
|
||||
SELECT id INTO user_role_id FROM roles WHERE name = 'user';
|
||||
|
||||
-- Assign menu to admin role
|
||||
IF menu_id_var IS NOT NULL AND admin_role_id IS NOT NULL THEN
|
||||
INSERT INTO role_menus (role_id, menu_id)
|
||||
VALUES (admin_role_id, menu_id_var)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- Assign menu to user role (users can view events)
|
||||
IF menu_id_var IS NOT NULL AND user_role_id IS NOT NULL THEN
|
||||
INSERT INTO role_menus (role_id, menu_id)
|
||||
VALUES (user_role_id, menu_id_var)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
-- Add calculate_planetary_events task to the scheduled tasks
|
||||
-- This task will calculate planetary events (conjunctions, oppositions) using Skyfield
|
||||
|
||||
-- Example 1: Calculate events for all major planets (365 days ahead)
|
||||
INSERT INTO tasks (name, description, category, parameters, status, schedule_config)
|
||||
VALUES (
|
||||
'calculate_planetary_events',
|
||||
'计算太阳系主要天体的合、冲等事件(每周执行一次)',
|
||||
'data_sync',
|
||||
'{
|
||||
"days_ahead": 365,
|
||||
"clean_old_events": true,
|
||||
"calculate_close_approaches": false
|
||||
}'::json,
|
||||
'active',
|
||||
'{
|
||||
"type": "cron",
|
||||
"cron": "0 2 * * 0"
|
||||
}'::json
|
||||
)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
parameters = EXCLUDED.parameters,
|
||||
schedule_config = EXCLUDED.schedule_config;
|
||||
|
||||
-- Example 2: Calculate events for inner planets only (30 days ahead, with close approaches)
|
||||
-- INSERT INTO tasks (name, description, category, parameters, status, schedule_config)
|
||||
-- VALUES (
|
||||
-- 'calculate_inner_planetary_events',
|
||||
-- '计算内行星事件(包括近距离接近)',
|
||||
-- 'data_sync',
|
||||
-- '{
|
||||
-- "body_ids": ["199", "299", "399", "499"],
|
||||
-- "days_ahead": 30,
|
||||
-- "clean_old_events": true,
|
||||
-- "calculate_close_approaches": true,
|
||||
-- "threshold_degrees": 5.0
|
||||
-- }'::json,
|
||||
-- 'active',
|
||||
-- '{
|
||||
-- "type": "cron",
|
||||
-- "cron": "0 3 * * *"
|
||||
-- }'::json
|
||||
-- )
|
||||
-- ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Query to check the task was added
|
||||
SELECT id, name, description, status, parameters, schedule_config
|
||||
FROM tasks
|
||||
WHERE name = 'calculate_planetary_events';
|
||||
|
||||
-- Query to view calculated events
|
||||
-- SELECT
|
||||
-- ce.id,
|
||||
-- ce.title,
|
||||
-- ce.event_type,
|
||||
-- ce.event_time,
|
||||
-- cb.name as body_name,
|
||||
-- ce.details,
|
||||
-- ce.created_at
|
||||
-- FROM celestial_events ce
|
||||
-- JOIN celestial_bodies cb ON ce.body_id = cb.id
|
||||
-- WHERE ce.source = 'skyfield_calculation'
|
||||
-- ORDER BY ce.event_time;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"""
|
||||
Simple migration to add predefined task columns
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.database import engine
|
||||
|
||||
|
||||
async def run_simple_migration():
|
||||
"""Add the new columns to scheduled_jobs table"""
|
||||
async with engine.begin() as conn:
|
||||
print("🔄 Adding new columns to scheduled_jobs table...")
|
||||
|
||||
# Add job_type column
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD COLUMN job_type jobtype DEFAULT 'custom_code'::jobtype NOT NULL
|
||||
"""))
|
||||
print("✅ Added job_type column")
|
||||
except Exception as e:
|
||||
print(f"⚠️ job_type column: {e}")
|
||||
|
||||
# Add predefined_function column
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD COLUMN predefined_function VARCHAR(100)
|
||||
"""))
|
||||
print("✅ Added predefined_function column")
|
||||
except Exception as e:
|
||||
print(f"⚠️ predefined_function column: {e}")
|
||||
|
||||
# Add function_params column
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD COLUMN function_params JSONB DEFAULT '{}'::jsonb
|
||||
"""))
|
||||
print("✅ Added function_params column")
|
||||
except Exception as e:
|
||||
print(f"⚠️ function_params column: {e}")
|
||||
|
||||
# Set default for future records to 'predefined'
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ALTER COLUMN job_type SET DEFAULT 'predefined'::jobtype
|
||||
"""))
|
||||
print("✅ Set default job_type to 'predefined'")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Setting default: {e}")
|
||||
|
||||
# Add check constraint
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
DROP CONSTRAINT IF EXISTS chk_job_type_fields
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD CONSTRAINT chk_job_type_fields
|
||||
CHECK (
|
||||
(job_type = 'predefined' AND predefined_function IS NOT NULL)
|
||||
OR
|
||||
(job_type = 'custom_code' AND python_code IS NOT NULL)
|
||||
)
|
||||
"""))
|
||||
print("✅ Added check constraint")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Check constraint: {e}")
|
||||
|
||||
print("\n📋 Final table structure:")
|
||||
result = await conn.execute(text("""
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'scheduled_jobs'
|
||||
ORDER BY ordinal_position
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
for row in rows:
|
||||
print(f" - {row[0]}: {row[1]} (nullable: {row[2]})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_simple_migration())
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
-- 1. 重建定时任务表 (增加 python_code 支持动态逻辑)
|
||||
DROP TABLE IF EXISTS "public"."scheduled_jobs" CASCADE;
|
||||
|
||||
CREATE TABLE "public"."scheduled_jobs" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"name" VARCHAR(100) NOT NULL, -- 任务名称
|
||||
"cron_expression" VARCHAR(50) NOT NULL, -- CRON表达式
|
||||
"python_code" TEXT, -- 【核心】可执行的Python业务代码
|
||||
"is_active" BOOLEAN DEFAULT TRUE, -- 启停状态
|
||||
"last_run_at" TIMESTAMP, -- 上次执行时间
|
||||
"last_run_status" VARCHAR(20), -- 上次执行结果
|
||||
"next_run_at" TIMESTAMP, -- 下次预计执行时间
|
||||
"description" TEXT, -- 描述
|
||||
"created_at" TIMESTAMP DEFAULT NOW(),
|
||||
"updated_at" TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX "idx_scheduled_jobs_active" ON "public"."scheduled_jobs" ("is_active");
|
||||
|
||||
-- 注释
|
||||
COMMENT ON TABLE "public"."scheduled_jobs" IS '定时任务调度配置表(支持动态Python代码)';
|
||||
COMMENT ON COLUMN "public"."scheduled_jobs"."python_code" IS '直接执行的Python代码体,上下文中可使用 db, logger 等变量';
|
||||
|
||||
-- 插入默认任务:每日同步位置
|
||||
INSERT INTO "public"."scheduled_jobs"
|
||||
("name", "cron_expression", "description", "is_active", "python_code")
|
||||
VALUES
|
||||
(
|
||||
'每日全量位置同步',
|
||||
'0 0 * * *',
|
||||
'每天UTC 0点同步所有活跃天体的最新位置数据',
|
||||
true,
|
||||
'# 这是一个动态任务示例
|
||||
# 可用变量: db (AsyncSession), logger (Logger)
|
||||
from app.services.db_service import celestial_body_service, position_service
|
||||
from app.services.horizons import horizons_service
|
||||
from datetime import datetime
|
||||
|
||||
logger.info("开始执行每日位置同步...")
|
||||
|
||||
# 获取所有活跃天体
|
||||
bodies = await celestial_body_service.get_all_bodies(db)
|
||||
active_bodies = [b for b in bodies if b.is_active]
|
||||
|
||||
count = 0
|
||||
now = datetime.utcnow()
|
||||
|
||||
for body in active_bodies:
|
||||
try:
|
||||
# 获取当天位置
|
||||
positions = await horizons_service.get_body_positions(
|
||||
body_id=body.id,
|
||||
start_time=now,
|
||||
end_time=now
|
||||
)
|
||||
|
||||
if positions:
|
||||
# 这里的 save_positions 需要自己实现或确保 db_service 中有对应方法支持 list
|
||||
# 假设我们循环 save_position 或者 db_service 已有批量接口
|
||||
# 为简单起见,这里演示循环调用
|
||||
for p in positions:
|
||||
await position_service.save_position(
|
||||
body_id=body.id,
|
||||
time=p.time,
|
||||
x=p.x,
|
||||
y=p.y,
|
||||
z=p.z,
|
||||
source="nasa_horizons_cron",
|
||||
session=db
|
||||
)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"同步 {body.name} 失败: {e}")
|
||||
|
||||
logger.info(f"同步完成,共更新 {count} 个天体")
|
||||
# 脚本最后一行表达式的值会被作为 result 存储
|
||||
f"Synced {count} bodies"
|
||||
'
|
||||
);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
-- Add short_name column to celestial_bodies table
|
||||
-- This field stores NASA SBDB API abbreviated names for planets
|
||||
|
||||
-- Add column
|
||||
ALTER TABLE celestial_bodies
|
||||
ADD COLUMN IF NOT EXISTS short_name VARCHAR(50);
|
||||
|
||||
COMMENT ON COLUMN celestial_bodies.short_name IS 'NASA SBDB API short name (e.g., Juptr for Jupiter)';
|
||||
|
||||
-- Update short_name for 8 major planets
|
||||
UPDATE celestial_bodies SET short_name = 'Merc' WHERE id = '199' AND name = 'Mercury';
|
||||
UPDATE celestial_bodies SET short_name = 'Venus' WHERE id = '299' AND name = 'Venus';
|
||||
UPDATE celestial_bodies SET short_name = 'Earth' WHERE id = '399' AND name = 'Earth';
|
||||
UPDATE celestial_bodies SET short_name = 'Mars' WHERE id = '499' AND name = 'Mars';
|
||||
UPDATE celestial_bodies SET short_name = 'Juptr' WHERE id = '599' AND name = 'Jupiter';
|
||||
UPDATE celestial_bodies SET short_name = 'Satrn' WHERE id = '699' AND name = 'Saturn';
|
||||
UPDATE celestial_bodies SET short_name = 'Urnus' WHERE id = '799' AND name = 'Uranus';
|
||||
UPDATE celestial_bodies SET short_name = 'Neptn' WHERE id = '899' AND name = 'Neptune';
|
||||
|
||||
-- Verify the updates
|
||||
SELECT id, name, name_zh, short_name
|
||||
FROM celestial_bodies
|
||||
WHERE short_name IS NOT NULL
|
||||
ORDER BY CAST(id AS INTEGER);
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
-- Add unique constraint to celestial_events table to prevent duplicate events
|
||||
-- This ensures that the same event (same body, type, and time) cannot be inserted twice
|
||||
|
||||
-- Step 1: Remove duplicate events (keep the earliest created_at)
|
||||
WITH duplicates AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY body_id, event_type, DATE_TRUNC('minute', event_time)
|
||||
ORDER BY created_at ASC
|
||||
) AS rn
|
||||
FROM celestial_events
|
||||
)
|
||||
DELETE FROM celestial_events
|
||||
WHERE id IN (
|
||||
SELECT id FROM duplicates WHERE rn > 1
|
||||
);
|
||||
|
||||
-- Step 2: Add unique constraint
|
||||
-- Note: We truncate to minute precision for event_time to handle slight variations
|
||||
-- Create a unique index instead of constraint to allow custom handling
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_celestial_events_unique
|
||||
ON celestial_events (
|
||||
body_id,
|
||||
event_type,
|
||||
DATE_TRUNC('minute', event_time)
|
||||
);
|
||||
|
||||
-- Note: For the exact timestamp constraint, use this instead:
|
||||
-- CREATE UNIQUE INDEX IF NOT EXISTS idx_celestial_events_unique_exact
|
||||
-- ON celestial_events (body_id, event_type, event_time);
|
||||
|
||||
-- Verify the constraint was added
|
||||
SELECT
|
||||
indexname,
|
||||
indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'celestial_events' AND indexname = 'idx_celestial_events_unique';
|
||||
|
||||
-- Check for remaining duplicates
|
||||
SELECT
|
||||
body_id,
|
||||
event_type,
|
||||
DATE_TRUNC('minute', event_time) as event_time_minute,
|
||||
COUNT(*) as count
|
||||
FROM celestial_events
|
||||
GROUP BY body_id, event_type, DATE_TRUNC('minute', event_time)
|
||||
HAVING COUNT(*) > 1;
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
-- 添加新菜单:个人信息 和 我的天体
|
||||
-- 这两个菜单对普通用户也开放
|
||||
|
||||
-- 1. 添加"个人信息"菜单(普通用户可访问)
|
||||
INSERT INTO menus (name, title, path, icon, parent_id, sort_order, is_active, roles)
|
||||
VALUES (
|
||||
'user-profile',
|
||||
'个人信息',
|
||||
'/admin/user-profile',
|
||||
'users',
|
||||
NULL,
|
||||
15,
|
||||
true,
|
||||
ARRAY['user', 'admin']::varchar[]
|
||||
)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
path = EXCLUDED.path,
|
||||
icon = EXCLUDED.icon,
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
roles = EXCLUDED.roles;
|
||||
|
||||
-- 2. 添加"我的天体"菜单(普通用户可访问)
|
||||
INSERT INTO menus (name, title, path, icon, parent_id, sort_order, is_active, roles)
|
||||
VALUES (
|
||||
'my-celestial-bodies',
|
||||
'我的天体',
|
||||
'/admin/my-celestial-bodies',
|
||||
'planet',
|
||||
NULL,
|
||||
16,
|
||||
true,
|
||||
ARRAY['user', 'admin']::varchar[]
|
||||
)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
path = EXCLUDED.path,
|
||||
icon = EXCLUDED.icon,
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
roles = EXCLUDED.roles;
|
||||
|
||||
-- 3. 添加"修改密码"菜单(普通用户和管理员都可访问)
|
||||
-- 注意:修改密码功能通过用户下拉菜单访问,不需要在侧边栏显示
|
||||
-- 但是我们仍然需要在数据库中记录这个菜单以便权限管理
|
||||
INSERT INTO menus (name, title, path, icon, parent_id, sort_order, is_active, roles)
|
||||
VALUES (
|
||||
'change-password',
|
||||
'修改密码',
|
||||
'/admin/change-password',
|
||||
'settings',
|
||||
NULL,
|
||||
17,
|
||||
true,
|
||||
ARRAY['user', 'admin']::varchar[]
|
||||
)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
path = EXCLUDED.path,
|
||||
icon = EXCLUDED.icon,
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
roles = EXCLUDED.roles;
|
||||
|
||||
-- 4. 调整其他菜单的排序(可选)
|
||||
-- 如果需要调整现有菜单的顺序,可以更新 sort_order
|
||||
UPDATE menus SET sort_order = 18 WHERE name = 'settings' AND sort_order < 18;
|
||||
|
||||
-- 5. 查看更新后的菜单列表
|
||||
SELECT id, name, title, path, icon, parent_id, sort_order, is_active, roles
|
||||
FROM menus
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order;
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Check the current state of scheduled_jobs table
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.database import engine
|
||||
|
||||
|
||||
async def check_table():
|
||||
"""Check current table structure"""
|
||||
async with engine.begin() as conn:
|
||||
# Check if table exists
|
||||
result = await conn.execute(text("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'scheduled_jobs'
|
||||
)
|
||||
"""))
|
||||
exists = result.scalar()
|
||||
|
||||
if not exists:
|
||||
print("❌ Table 'scheduled_jobs' does not exist yet")
|
||||
print("💡 You need to run: alembic upgrade head")
|
||||
return
|
||||
|
||||
# Get table structure
|
||||
result = await conn.execute(text("""
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'scheduled_jobs'
|
||||
ORDER BY ordinal_position
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
print("✅ Table 'scheduled_jobs' exists")
|
||||
print("\n📋 Current table structure:")
|
||||
for row in rows:
|
||||
default = row[3] if row[3] else 'NULL'
|
||||
print(f" - {row[0]}: {row[1]} (nullable: {row[2]}, default: {default})")
|
||||
|
||||
# Check for enum type
|
||||
result = await conn.execute(text("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM pg_type
|
||||
WHERE typname = 'jobtype'
|
||||
)
|
||||
"""))
|
||||
enum_exists = result.scalar()
|
||||
|
||||
if enum_exists:
|
||||
print("\n✅ ENUM type 'jobtype' exists")
|
||||
else:
|
||||
print("\n❌ ENUM type 'jobtype' does NOT exist")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_table())
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
-- Clean up duplicate celestial events
|
||||
-- This script removes duplicate events and adds a unique index to prevent future duplicates
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Step 1: Show current duplicate count
|
||||
SELECT
|
||||
'Duplicate events before cleanup' as status,
|
||||
COUNT(*) as total_duplicates
|
||||
FROM (
|
||||
SELECT
|
||||
body_id,
|
||||
event_type,
|
||||
DATE_TRUNC('minute', event_time) as event_time_minute,
|
||||
COUNT(*) as cnt
|
||||
FROM celestial_events
|
||||
GROUP BY body_id, event_type, DATE_TRUNC('minute', event_time)
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicates;
|
||||
|
||||
-- Step 2: Remove duplicate events (keep the earliest created_at)
|
||||
WITH duplicates AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY body_id, event_type, DATE_TRUNC('minute', event_time)
|
||||
ORDER BY created_at ASC
|
||||
) AS rn
|
||||
FROM celestial_events
|
||||
)
|
||||
DELETE FROM celestial_events
|
||||
WHERE id IN (
|
||||
SELECT id FROM duplicates WHERE rn > 1
|
||||
)
|
||||
RETURNING id;
|
||||
|
||||
-- Step 3: Add unique index to prevent future duplicates
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_celestial_events_unique
|
||||
ON celestial_events (
|
||||
body_id,
|
||||
event_type,
|
||||
DATE_TRUNC('minute', event_time)
|
||||
);
|
||||
|
||||
-- Step 4: Verify no duplicates remain
|
||||
SELECT
|
||||
'Duplicate events after cleanup' as status,
|
||||
COUNT(*) as total_duplicates
|
||||
FROM (
|
||||
SELECT
|
||||
body_id,
|
||||
event_type,
|
||||
DATE_TRUNC('minute', event_time) as event_time_minute,
|
||||
COUNT(*) as cnt
|
||||
FROM celestial_events
|
||||
GROUP BY body_id, event_type, DATE_TRUNC('minute', event_time)
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicates;
|
||||
|
||||
-- Step 5: Show summary statistics
|
||||
SELECT
|
||||
source,
|
||||
COUNT(*) as total_events,
|
||||
COUNT(DISTINCT body_id) as unique_bodies,
|
||||
MIN(event_time) as earliest_event,
|
||||
MAX(event_time) as latest_event
|
||||
FROM celestial_events
|
||||
GROUP BY source
|
||||
ORDER BY source;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Verify the index was created
|
||||
SELECT
|
||||
indexname,
|
||||
indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'celestial_events' AND indexname = 'idx_celestial_events_unique';
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
"""
|
||||
Fix enum type and add columns
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.database import engine
|
||||
|
||||
|
||||
async def fix_enum_and_migrate():
|
||||
"""Fix enum type and add columns"""
|
||||
async with engine.begin() as conn:
|
||||
# First check enum values
|
||||
result = await conn.execute(text("""
|
||||
SELECT enumlabel
|
||||
FROM pg_enum
|
||||
WHERE enumtypid = 'jobtype'::regtype
|
||||
ORDER BY enumsortorder
|
||||
"""))
|
||||
enum_values = [row[0] for row in result.fetchall()]
|
||||
print(f"Current enum values: {enum_values}")
|
||||
|
||||
# Add missing enum values if needed
|
||||
if 'predefined' not in enum_values:
|
||||
await conn.execute(text("ALTER TYPE jobtype ADD VALUE 'predefined'"))
|
||||
print("✅ Added 'predefined' to enum")
|
||||
|
||||
if 'custom_code' not in enum_values:
|
||||
await conn.execute(text("ALTER TYPE jobtype ADD VALUE 'custom_code'"))
|
||||
print("✅ Added 'custom_code' to enum")
|
||||
|
||||
# Now add columns in separate transaction
|
||||
async with engine.begin() as conn:
|
||||
print("\n🔄 Adding columns to scheduled_jobs table...")
|
||||
|
||||
# Add job_type column
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD COLUMN job_type jobtype DEFAULT 'custom_code'::jobtype NOT NULL
|
||||
"""))
|
||||
print("✅ Added job_type column")
|
||||
except Exception as e:
|
||||
if "already exists" in str(e):
|
||||
print("ℹ️ job_type column already exists")
|
||||
else:
|
||||
raise
|
||||
|
||||
# Add predefined_function column
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD COLUMN predefined_function VARCHAR(100)
|
||||
"""))
|
||||
print("✅ Added predefined_function column")
|
||||
except Exception as e:
|
||||
if "already exists" in str(e):
|
||||
print("ℹ️ predefined_function column already exists")
|
||||
else:
|
||||
raise
|
||||
|
||||
# Add function_params column
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD COLUMN function_params JSONB DEFAULT '{}'::jsonb
|
||||
"""))
|
||||
print("✅ Added function_params column")
|
||||
except Exception as e:
|
||||
if "already exists" in str(e):
|
||||
print("ℹ️ function_params column already exists")
|
||||
else:
|
||||
raise
|
||||
|
||||
# Set defaults and constraints in separate transaction
|
||||
async with engine.begin() as conn:
|
||||
# Set default for future records
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ALTER COLUMN job_type SET DEFAULT 'predefined'::jobtype
|
||||
"""))
|
||||
print("✅ Set default job_type to 'predefined'")
|
||||
|
||||
# Drop and recreate check constraint
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
DROP CONSTRAINT IF EXISTS chk_job_type_fields
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE scheduled_jobs
|
||||
ADD CONSTRAINT chk_job_type_fields
|
||||
CHECK (
|
||||
(job_type = 'predefined' AND predefined_function IS NOT NULL)
|
||||
OR
|
||||
(job_type = 'custom_code' AND python_code IS NOT NULL)
|
||||
)
|
||||
"""))
|
||||
print("✅ Added check constraint")
|
||||
|
||||
print("\n📋 Final table structure:")
|
||||
result = await conn.execute(text("""
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'scheduled_jobs'
|
||||
ORDER BY ordinal_position
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
for row in rows:
|
||||
print(f" - {row[0]}: {row[1]} (nullable: {row[2]})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_enum_and_migrate())
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
Fix positions table CHECK constraint to include 'nasa_horizons_cron'
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.database import engine
|
||||
|
||||
|
||||
async def fix_constraint():
|
||||
"""Fix positions table source constraint"""
|
||||
async with engine.begin() as conn:
|
||||
print("🔍 Checking current constraint...")
|
||||
|
||||
# Check current constraint definition
|
||||
result = await conn.execute(text("""
|
||||
SELECT pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_source' AND conrelid = 'positions'::regclass;
|
||||
"""))
|
||||
current = result.fetchone()
|
||||
if current:
|
||||
print(f"📋 Current constraint: {current[0]}")
|
||||
else:
|
||||
print("⚠️ No constraint found!")
|
||||
|
||||
print("\n🔧 Dropping old constraint...")
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE positions DROP CONSTRAINT IF EXISTS chk_source;
|
||||
"""))
|
||||
print("✅ Old constraint dropped")
|
||||
|
||||
print("\n🆕 Creating new constraint with 'nasa_horizons_cron'...")
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE positions ADD CONSTRAINT chk_source
|
||||
CHECK (source IN ('nasa_horizons', 'nasa_horizons_cron', 'calculated', 'user_defined', 'imported'));
|
||||
"""))
|
||||
print("✅ New constraint created")
|
||||
|
||||
# Verify new constraint
|
||||
result = await conn.execute(text("""
|
||||
SELECT pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_source' AND conrelid = 'positions'::regclass;
|
||||
"""))
|
||||
new_constraint = result.fetchone()
|
||||
if new_constraint:
|
||||
print(f"\n✅ New constraint: {new_constraint[0]}")
|
||||
|
||||
print("\n🎉 Constraint update completed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_constraint())
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"""
|
||||
Optimize orbit data by downsampling excessively detailed orbits
|
||||
灶神星(Vesta)的轨道数据被过度采样了(31,825个点),降采样到合理的数量
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from app.database import engine
|
||||
|
||||
|
||||
async def optimize_vesta_orbit():
|
||||
"""Downsample Vesta orbit from 31,825 points to ~1,326 points (every 24th point)"""
|
||||
async with engine.begin() as conn:
|
||||
# Get current Vesta orbit data
|
||||
result = await conn.execute(text("""
|
||||
SELECT points, num_points
|
||||
FROM orbits
|
||||
WHERE body_id = '2000004'
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
print("❌ Vesta orbit not found")
|
||||
return
|
||||
|
||||
points = row[0] # JSONB array
|
||||
current_count = row[1]
|
||||
|
||||
print(f"当前Vesta轨道点数: {current_count}")
|
||||
print(f"实际数组长度: {len(points)}")
|
||||
|
||||
# Downsample: take every 24th point (0.04 days * 24 ≈ 1 day per point)
|
||||
downsampled = points[::24]
|
||||
new_count = len(downsampled)
|
||||
|
||||
print(f"降采样后点数: {new_count}")
|
||||
print(f"数据大小减少: {current_count - new_count} 点")
|
||||
print(f"降采样比例: {current_count / new_count:.1f}x")
|
||||
|
||||
# Calculate size reduction
|
||||
import json
|
||||
old_size = len(json.dumps(points))
|
||||
new_size = len(json.dumps(downsampled))
|
||||
print(f"JSON大小: {old_size:,} -> {new_size:,} bytes ({old_size/new_size:.1f}x)")
|
||||
|
||||
# Update database
|
||||
await conn.execute(text("""
|
||||
UPDATE orbits
|
||||
SET points = :points, num_points = :num_points
|
||||
WHERE body_id = '2000004'
|
||||
"""), {"points": json.dumps(downsampled), "num_points": new_count})
|
||||
|
||||
print("✅ Vesta轨道数据已优化")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(optimize_vesta_orbit())
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
-- Phase 5 Database Schema Changes (Updated)
|
||||
-- Run this script to add tables for Celestial Events and User Follows
|
||||
-- Note: Channel messages are now stored in Redis, so no table is created for them.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Celestial Events Table
|
||||
CREATE TABLE IF NOT EXISTS "public"."celestial_events" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"body_id" VARCHAR(50) NOT NULL REFERENCES "public"."celestial_bodies"("id") ON DELETE CASCADE,
|
||||
"title" VARCHAR(200) NOT NULL,
|
||||
"event_type" VARCHAR(50) NOT NULL, -- 'approach' (close approach), 'opposition' (冲日), etc.
|
||||
"event_time" TIMESTAMP NOT NULL,
|
||||
"description" TEXT,
|
||||
"details" JSONB, -- Store distance (nominal_dist_au), v_rel, etc.
|
||||
"source" VARCHAR(50) DEFAULT 'nasa_sbdb',
|
||||
"created_at" TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_celestial_events_body_id" ON "public"."celestial_events" ("body_id");
|
||||
CREATE INDEX "idx_celestial_events_time" ON "public"."celestial_events" ("event_time");
|
||||
COMMENT ON TABLE "public"."celestial_events" IS '天体动态事件表 (如飞掠、冲日)';
|
||||
|
||||
-- 2. User Follows Table (Relationships)
|
||||
CREATE TABLE IF NOT EXISTS "public"."user_follows" (
|
||||
"user_id" INTEGER NOT NULL REFERENCES "public"."users"("id") ON DELETE CASCADE,
|
||||
"body_id" VARCHAR(50) NOT NULL REFERENCES "public"."celestial_bodies"("id") ON DELETE CASCADE,
|
||||
"created_at" TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY ("user_id", "body_id")
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_user_follows_user" ON "public"."user_follows" ("user_id");
|
||||
COMMENT ON TABLE "public"."user_follows" IS '用户关注天体关联表';
|
||||
|
||||
-- 3. Ensure 'icon' is in resources check constraint (Idempotent check)
|
||||
-- Dropping and recreating constraint is the safest way to ensure 'icon' is present if it wasn't
|
||||
ALTER TABLE "public"."resources" DROP CONSTRAINT IF EXISTS "chk_resource_type";
|
||||
ALTER TABLE "public"."resources" ADD CONSTRAINT "chk_resource_type"
|
||||
CHECK (resource_type IN ('texture', 'model', 'icon', 'thumbnail', 'data'));
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""
|
||||
Run database migration for scheduled_jobs table
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
async def run_migration():
|
||||
"""Run the migration SQL script"""
|
||||
# Read the migration file
|
||||
migration_file = Path(__file__).parent.parent / "migrations" / "add_predefined_jobs_support.sql"
|
||||
|
||||
with open(migration_file, 'r') as f:
|
||||
sql = f.read()
|
||||
|
||||
# Connect to database
|
||||
conn = await asyncpg.connect(
|
||||
user='postgres',
|
||||
password='cosmo2024',
|
||||
database='cosmo_db',
|
||||
host='localhost',
|
||||
port=5432
|
||||
)
|
||||
|
||||
try:
|
||||
print("🔄 Running migration: add_predefined_jobs_support.sql")
|
||||
|
||||
# Execute the migration
|
||||
await conn.execute(sql)
|
||||
|
||||
print("✅ Migration completed successfully!")
|
||||
|
||||
# Verify the changes
|
||||
result = await conn.fetch("""
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'scheduled_jobs'
|
||||
ORDER BY ordinal_position
|
||||
""")
|
||||
|
||||
print("\n📋 Current scheduled_jobs table structure:")
|
||||
for row in result:
|
||||
print(f" - {row['column_name']}: {row['data_type']} (nullable: {row['is_nullable']})")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_migration())
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
"""
|
||||
Run database migration for scheduled_jobs table
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.database import engine
|
||||
|
||||
|
||||
async def run_migration():
|
||||
"""Run the migration SQL script"""
|
||||
# Read the migration file
|
||||
migration_file = Path(__file__).parent.parent / "migrations" / "add_predefined_jobs_support.sql"
|
||||
|
||||
with open(migration_file, 'r') as f:
|
||||
sql_content = f.read()
|
||||
|
||||
# Split SQL into individual statements
|
||||
# Remove comments and split by semicolon
|
||||
statements = []
|
||||
current_stmt = []
|
||||
in_do_block = False
|
||||
|
||||
for line in sql_content.split('\n'):
|
||||
stripped = line.strip()
|
||||
|
||||
# Skip comments
|
||||
if stripped.startswith('--') or not stripped:
|
||||
continue
|
||||
|
||||
# Handle DO blocks specially
|
||||
if stripped.startswith('DO $$'):
|
||||
in_do_block = True
|
||||
current_stmt.append(line)
|
||||
elif stripped == 'END $$;':
|
||||
current_stmt.append(line)
|
||||
statements.append('\n'.join(current_stmt))
|
||||
current_stmt = []
|
||||
in_do_block = False
|
||||
elif in_do_block or not stripped.endswith(';'):
|
||||
current_stmt.append(line)
|
||||
else:
|
||||
# Regular statement ending with ;
|
||||
current_stmt.append(line)
|
||||
statements.append('\n'.join(current_stmt))
|
||||
current_stmt = []
|
||||
|
||||
async with engine.begin() as conn:
|
||||
print("🔄 Running migration: add_predefined_jobs_support.sql")
|
||||
|
||||
# Execute each statement separately
|
||||
for i, stmt in enumerate(statements):
|
||||
if stmt.strip():
|
||||
try:
|
||||
print(f" Executing statement {i+1}/{len(statements)}...")
|
||||
await conn.execute(text(stmt))
|
||||
except Exception as e:
|
||||
# Some statements might fail if already applied, that's okay
|
||||
print(f" ⚠️ Statement {i+1} warning: {e}")
|
||||
|
||||
print("✅ Migration completed successfully!")
|
||||
|
||||
# Verify the changes
|
||||
result = await conn.execute(text("""
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'scheduled_jobs'
|
||||
ORDER BY ordinal_position
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
print("\n📋 Current scheduled_jobs table structure:")
|
||||
for row in rows:
|
||||
print(f" - {row[0]}: {row[1]} (nullable: {row[2]})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_migration())
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"""
|
||||
Update existing job to use predefined task and add new event sync job
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import text, update
|
||||
from app.database import engine
|
||||
from app.models.db.scheduled_job import ScheduledJob, JobType
|
||||
|
||||
|
||||
async def update_jobs():
|
||||
"""Update existing job and add new event sync job"""
|
||||
async with engine.begin() as conn:
|
||||
print("🔄 Updating scheduled jobs...")
|
||||
|
||||
# 1. Update existing job to use predefined task
|
||||
result = await conn.execute(text("""
|
||||
UPDATE scheduled_jobs
|
||||
SET
|
||||
job_type = 'predefined',
|
||||
predefined_function = 'sync_solar_system_positions',
|
||||
function_params = '{"days": 7, "source": "nasa_horizons_cron"}'::jsonb,
|
||||
description = '每日同步太阳系天体位置数据(使用内置任务)'
|
||||
WHERE id = 1
|
||||
RETURNING id, name
|
||||
"""))
|
||||
updated = result.fetchone()
|
||||
if updated:
|
||||
print(f"✅ Updated job ID {updated[0]}: {updated[1]} -> predefined task")
|
||||
|
||||
# 2. Add new celestial events sync job (disabled)
|
||||
result = await conn.execute(text("""
|
||||
INSERT INTO scheduled_jobs (
|
||||
name,
|
||||
job_type,
|
||||
predefined_function,
|
||||
function_params,
|
||||
cron_expression,
|
||||
description,
|
||||
is_active
|
||||
)
|
||||
VALUES (
|
||||
'天体事件同步',
|
||||
'predefined',
|
||||
'sync_celestial_events',
|
||||
'{"days_ahead": 30}'::jsonb,
|
||||
'0 3 * * *',
|
||||
'每日凌晨3点同步未来30天的天体事件(预留功能,暂未实现)',
|
||||
false
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, name
|
||||
"""))
|
||||
new_job = result.fetchone()
|
||||
if new_job:
|
||||
print(f"✅ Added new job ID {new_job[0]}: {new_job[1]} (disabled)")
|
||||
else:
|
||||
print("ℹ️ Event sync job already exists")
|
||||
|
||||
# 3. Show all jobs
|
||||
print("\n📋 Current scheduled jobs:")
|
||||
result = await conn.execute(text("""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
job_type,
|
||||
predefined_function,
|
||||
is_active,
|
||||
cron_expression
|
||||
FROM scheduled_jobs
|
||||
ORDER BY id
|
||||
"""))
|
||||
|
||||
for row in result.fetchall():
|
||||
status = "🟢 启用" if row[4] else "🔴 禁用"
|
||||
job_type_display = "内置任务" if row[2] == 'predefined' else "自定义代码"
|
||||
print(f" {status} ID {row[0]}: {row[1]}")
|
||||
print(f" 类型: {job_type_display} | 函数: {row[3]} | CRON: {row[5]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(update_jobs())
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""
|
||||
Test NASA SBDB API body parameter format
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
async def test_body_param():
|
||||
"""Test different body parameter formats"""
|
||||
|
||||
test_cases = [
|
||||
("Earth (name)", "Earth"),
|
||||
("399 (Horizons ID)", "399"),
|
||||
("Mars (name)", "Mars"),
|
||||
("499 (Mars Horizons ID)", "499"),
|
||||
]
|
||||
|
||||
for name, body_value in test_cases:
|
||||
params = {
|
||||
"date-min": "2025-12-15",
|
||||
"date-max": "2025-12-16",
|
||||
"body": body_value,
|
||||
"limit": "1"
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, proxies={}) as client:
|
||||
response = await client.get(
|
||||
"https://ssd-api.jpl.nasa.gov/cad.api",
|
||||
params=params
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
count = data.get("count", 0)
|
||||
print(f"{name:30} -> 返回 {count:3} 个结果 ✓")
|
||||
else:
|
||||
print(f"{name:30} -> HTTP {response.status_code} ✗")
|
||||
except Exception as e:
|
||||
print(f"{name:30} -> 错误: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_body_param())
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""
|
||||
Test NASA SBDB service directly
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from app.services.nasa_sbdb_service import nasa_sbdb_service
|
||||
|
||||
async def test_nasa_sbdb():
|
||||
"""Test NASA SBDB API directly"""
|
||||
|
||||
# Calculate date range
|
||||
date_min = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
date_max = (datetime.utcnow() + timedelta(days=365)).strftime("%Y-%m-%d")
|
||||
|
||||
print(f"Querying NASA SBDB for close approaches...")
|
||||
print(f"Date range: {date_min} to {date_max}")
|
||||
print(f"Max distance: 1.0 AU")
|
||||
|
||||
events = await nasa_sbdb_service.get_close_approaches(
|
||||
date_min=date_min,
|
||||
date_max=date_max,
|
||||
dist_max="1.0",
|
||||
body="Earth",
|
||||
limit=10,
|
||||
fullname=True
|
||||
)
|
||||
|
||||
print(f"\nRetrieved {len(events)} events from NASA SBDB")
|
||||
|
||||
if events:
|
||||
print("\nFirst 3 events:")
|
||||
for i, event in enumerate(events[:3], 1):
|
||||
print(f"\n{i}. {event.get('des', 'Unknown')}")
|
||||
print(f" Full name: {event.get('fullname', 'N/A')}")
|
||||
print(f" Date: {event.get('cd', 'N/A')}")
|
||||
print(f" Distance: {event.get('dist', 'N/A')} AU")
|
||||
print(f" Velocity: {event.get('v_rel', 'N/A')} km/s")
|
||||
|
||||
# Test parsing
|
||||
parsed = nasa_sbdb_service.parse_event_to_celestial_event(event)
|
||||
if parsed:
|
||||
print(f" ✓ Parsed successfully")
|
||||
print(f" Title: {parsed['title']}")
|
||||
print(f" Body ID: {parsed['body_id']}")
|
||||
else:
|
||||
print(f" ✗ Failed to parse")
|
||||
else:
|
||||
print("No events found")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_nasa_sbdb())
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
"""
|
||||
Test script for Phase 5 features
|
||||
Tests social features (follows, channel messages) and event system
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
BASE_URL = "http://localhost:8000/api"
|
||||
|
||||
# Test user credentials (assuming these exist from previous tests)
|
||||
TEST_USER = {
|
||||
"username": "testuser",
|
||||
"password": "testpass123"
|
||||
}
|
||||
|
||||
async def get_auth_token():
|
||||
"""Login and get JWT token"""
|
||||
async with httpx.AsyncClient(timeout=30.0, proxies={}) as client:
|
||||
# Try to register first (in case user doesn't exist)
|
||||
register_response = await client.post(
|
||||
f"{BASE_URL}/auth/register",
|
||||
json={
|
||||
"username": TEST_USER["username"],
|
||||
"password": TEST_USER["password"],
|
||||
"email": "test@example.com"
|
||||
}
|
||||
)
|
||||
|
||||
# If register fails (user exists), try to login
|
||||
if register_response.status_code != 200:
|
||||
response = await client.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
json={
|
||||
"username": TEST_USER["username"],
|
||||
"password": TEST_USER["password"]
|
||||
}
|
||||
)
|
||||
else:
|
||||
response = register_response
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("access_token")
|
||||
else:
|
||||
print(f"Login failed: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
async def test_follow_operations(token):
|
||||
"""Test user follow operations"""
|
||||
print("\n=== Testing Follow Operations ===")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, proxies={}) as client:
|
||||
# Test: Follow a celestial body (Mars)
|
||||
print("\n1. Following Mars (499)...")
|
||||
response = await client.post(
|
||||
f"{BASE_URL}/social/follow/499",
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code in [200, 400]: # 400 if already following
|
||||
print(f"Response: {response.json()}")
|
||||
|
||||
# Test: Get user's follows
|
||||
print("\n2. Getting user follows...")
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/social/follows",
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
follows = response.json()
|
||||
print(f"Following {len(follows)} bodies:")
|
||||
for follow in follows[:5]: # Show first 5
|
||||
print(f" - Body ID: {follow['body_id']}, Since: {follow['created_at']}")
|
||||
|
||||
# Test: Check if following Mars
|
||||
print("\n3. Checking if following Mars...")
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/social/follows/check/499",
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
print(f"Response: {response.json()}")
|
||||
|
||||
return response.status_code == 200
|
||||
|
||||
async def test_channel_messages(token):
|
||||
"""Test channel message operations"""
|
||||
print("\n=== Testing Channel Messages ===")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, proxies={}) as client:
|
||||
# Test: Post a message to Mars channel
|
||||
print("\n1. Posting message to Mars channel...")
|
||||
message_data = {
|
||||
"content": f"Test message at {datetime.now().isoformat()}"
|
||||
}
|
||||
response = await client.post(
|
||||
f"{BASE_URL}/social/channel/499/message",
|
||||
headers=headers,
|
||||
json=message_data
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
print(f"Response: {response.json()}")
|
||||
elif response.status_code == 403:
|
||||
print("Error: User is not following this body (need to follow first)")
|
||||
|
||||
# Test: Get channel messages
|
||||
print("\n2. Getting Mars channel messages...")
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/social/channel/499/messages?limit=10",
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
messages = response.json()
|
||||
print(f"Found {len(messages)} messages:")
|
||||
for msg in messages[-3:]: # Show last 3
|
||||
print(f" - {msg['username']}: {msg['content'][:50]}...")
|
||||
|
||||
return response.status_code == 200
|
||||
|
||||
async def test_celestial_events(token):
|
||||
"""Test celestial event operations"""
|
||||
print("\n=== Testing Celestial Events ===")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, proxies={}) as client:
|
||||
# Test: Get upcoming events
|
||||
print("\n1. Getting upcoming celestial events...")
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/events?limit=10",
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
events = response.json()
|
||||
print(f"Found {len(events)} events:")
|
||||
for event in events[:5]: # Show first 5
|
||||
print(f" - {event['title']} at {event['event_time']}")
|
||||
print(f" Type: {event['event_type']}, Source: {event['source']}")
|
||||
|
||||
# Test: Get events for a specific body
|
||||
print("\n2. Getting events for Mars (499)...")
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/events?body_id=499&limit=5",
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
events = response.json()
|
||||
print(f"Found {len(events)} events for Mars")
|
||||
|
||||
return response.status_code == 200
|
||||
|
||||
async def test_scheduled_tasks(token):
|
||||
"""Test scheduled task functionality"""
|
||||
print("\n=== Testing Scheduled Tasks ===")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0, proxies={}) as client:
|
||||
# Test: Get available tasks
|
||||
print("\n1. Getting available scheduled tasks...")
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/scheduled-jobs/available-tasks",
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
tasks = response.json()
|
||||
print(f"Found {len(tasks)} available tasks")
|
||||
|
||||
# Find our Phase 5 task
|
||||
phase5_task = None
|
||||
for task in tasks:
|
||||
if task['name'] == 'fetch_close_approach_events':
|
||||
phase5_task = task
|
||||
print(f"\nFound Phase 5 task: {task['name']}")
|
||||
print(f" Description: {task['description']}")
|
||||
print(f" Category: {task['category']}")
|
||||
break
|
||||
|
||||
if phase5_task:
|
||||
# Test: Create a scheduled job for this task
|
||||
print("\n2. Creating a scheduled job for fetch_close_approach_events...")
|
||||
job_data = {
|
||||
"name": "Test Phase 5 Close Approach Events",
|
||||
"job_type": "predefined",
|
||||
"predefined_function": "fetch_close_approach_events",
|
||||
"function_params": {
|
||||
"days_ahead": 30,
|
||||
"dist_max": "0.2",
|
||||
"approach_body": "Earth",
|
||||
"limit": 50,
|
||||
"clean_old_events": False
|
||||
},
|
||||
"cron_expression": "0 0 * * *", # Daily at midnight
|
||||
"description": "Test job for Phase 5",
|
||||
"is_active": False # Don't activate for test
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"{BASE_URL}/scheduled-jobs",
|
||||
headers=headers,
|
||||
json=job_data
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
|
||||
if response.status_code == 201:
|
||||
job = response.json()
|
||||
job_id = job['id']
|
||||
print(f"Created job with ID: {job_id}")
|
||||
|
||||
# Test: Run the job immediately
|
||||
print(f"\n3. Triggering job {job_id} to run now...")
|
||||
print(" (This may take 30-60 seconds...)")
|
||||
response = await client.post(
|
||||
f"{BASE_URL}/scheduled-jobs/{job_id}/run",
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
print(f"Response: {response.json()}")
|
||||
|
||||
# Wait a bit and check job status
|
||||
print("\n4. Waiting 60 seconds for job to complete...")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
# Get job status
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/scheduled-jobs/{job_id}",
|
||||
headers=headers
|
||||
)
|
||||
if response.status_code == 200:
|
||||
job_status = response.json()
|
||||
print(f"Job status: {job_status.get('last_run_status')}")
|
||||
print(f"Last run at: {job_status.get('last_run_at')}")
|
||||
|
||||
# Check if events were created
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/events?limit=10",
|
||||
headers=headers
|
||||
)
|
||||
if response.status_code == 200:
|
||||
events = response.json()
|
||||
print(f"\nEvents in database: {len(events)}")
|
||||
for event in events[:3]:
|
||||
print(f" - {event['title']}")
|
||||
|
||||
# Clean up: delete the test job
|
||||
await client.delete(
|
||||
f"{BASE_URL}/scheduled-jobs/{job_id}",
|
||||
headers=headers
|
||||
)
|
||||
print(f"\nCleaned up test job {job_id}")
|
||||
|
||||
return True
|
||||
else:
|
||||
print(f"Error triggering job: {response.text}")
|
||||
else:
|
||||
print(f"Error creating job: {response.text}")
|
||||
|
||||
return False
|
||||
|
||||
async def main():
|
||||
"""Main test function"""
|
||||
print("=" * 60)
|
||||
print("Phase 5 Feature Testing")
|
||||
print("=" * 60)
|
||||
|
||||
# Get authentication token
|
||||
print("\nAuthenticating...")
|
||||
token = await get_auth_token()
|
||||
if not token:
|
||||
print("ERROR: Failed to authenticate. Please ensure test user exists.")
|
||||
print("You may need to create a test user first.")
|
||||
return
|
||||
|
||||
print(f"✓ Authentication successful")
|
||||
|
||||
# Run tests
|
||||
results = {
|
||||
"follow_operations": await test_follow_operations(token),
|
||||
"channel_messages": await test_channel_messages(token),
|
||||
"celestial_events": await test_celestial_events(token),
|
||||
"scheduled_tasks": await test_scheduled_tasks(token)
|
||||
}
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Summary")
|
||||
print("=" * 60)
|
||||
for test_name, passed in results.items():
|
||||
status = "✓ PASS" if passed else "✗ FAIL"
|
||||
print(f"{status} - {test_name}")
|
||||
|
||||
total_passed = sum(results.values())
|
||||
total_tests = len(results)
|
||||
print(f"\nTotal: {total_passed}/{total_tests} tests passed")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
|
@ -0,0 +1,111 @@
|
|||
## 移除天体事件列表中的小行星编号列
|
||||
|
||||
### 修改内容
|
||||
|
||||
在 `/Users/jiliu/WorkSpace/cosmo/frontend/src/pages/admin/CelestialEvents.tsx` 中进行了以下修改:
|
||||
|
||||
#### 1. 移除小行星编号列(第143-148行已删除)
|
||||
```typescript
|
||||
// 删除了这一列
|
||||
{
|
||||
title: '小行星编号',
|
||||
dataIndex: ['details', 'designation'],
|
||||
width: 150,
|
||||
render: (designation) => designation || '-',
|
||||
},
|
||||
```
|
||||
|
||||
**原因:**
|
||||
- 现在有多种事件类型(合、冲、近距离接近等)
|
||||
- 小行星编号只对某些接近事件有意义
|
||||
- 对于行星合冲事件,这个字段没有实际价值
|
||||
|
||||
#### 2. 更新事件类型支持(第97-119行)
|
||||
|
||||
添加了 `close_approach` 事件类型的显示:
|
||||
|
||||
```typescript
|
||||
const getEventTypeColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'approach': 'blue',
|
||||
'close_approach': 'magenta', // 新增
|
||||
'eclipse': 'purple',
|
||||
'conjunction': 'cyan',
|
||||
'opposition': 'orange',
|
||||
'transit': 'green',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
const getEventTypeLabel = (type: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
'approach': '接近',
|
||||
'close_approach': '近距离接近', // 新增
|
||||
'eclipse': '食',
|
||||
'conjunction': '合',
|
||||
'opposition': '冲',
|
||||
'transit': '凌',
|
||||
};
|
||||
return labelMap[type] || type;
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. 更新事件类型过滤器(第169-175行)
|
||||
|
||||
在过滤器中添加了 `close_approach` 选项:
|
||||
|
||||
```typescript
|
||||
filters: [
|
||||
{ text: '接近', value: 'approach' },
|
||||
{ text: '近距离接近', value: 'close_approach' }, // 新增
|
||||
{ text: '食', value: 'eclipse' },
|
||||
{ text: '合', value: 'conjunction' },
|
||||
{ text: '冲', value: 'opposition' },
|
||||
],
|
||||
```
|
||||
|
||||
#### 4. 简化搜索功能(第76-84行)
|
||||
|
||||
移除了对小行星编号的搜索支持:
|
||||
|
||||
```typescript
|
||||
// 修改前:搜索标题、描述、小行星编号
|
||||
item.title.toLowerCase().includes(lowerKeyword) ||
|
||||
item.description?.toLowerCase().includes(lowerKeyword) ||
|
||||
item.details?.designation?.toLowerCase().includes(lowerKeyword)
|
||||
|
||||
// 修改后:只搜索标题和描述
|
||||
item.title.toLowerCase().includes(lowerKeyword) ||
|
||||
item.description?.toLowerCase().includes(lowerKeyword)
|
||||
```
|
||||
|
||||
### 当前列布局
|
||||
|
||||
更新后的事件列表包含以下列:
|
||||
|
||||
1. **ID** - 事件ID
|
||||
2. **事件标题** - 完整的事件标题
|
||||
3. **目标天体** - 关联的天体(带过滤器)
|
||||
4. **事件类型** - 事件类型标签(带过滤器)
|
||||
5. **事件时间** - 事件发生时间
|
||||
6. **距离 (AU)** - 对于接近事件显示距离
|
||||
7. **数据源** - 事件数据来源
|
||||
8. **操作** - 删除按钮
|
||||
|
||||
### 支持的事件类型
|
||||
|
||||
现在支持以下事件类型,每种都有对应的颜色标签:
|
||||
|
||||
- 🔵 **接近 (approach)** - 小行星/彗星接近事件(来自NASA SBDB)
|
||||
- 🟣 **近距离接近 (close_approach)** - 行星间近距离接近(来自Skyfield计算)
|
||||
- 🟣 **食 (eclipse)** - 日食/月食
|
||||
- 🔵 **合 (conjunction)** - 行星合日(来自Skyfield计算)
|
||||
- 🟠 **冲 (opposition)** - 行星冲日(来自Skyfield计算)
|
||||
- 🟢 **凌 (transit)** - 行星凌日
|
||||
|
||||
### 效果
|
||||
|
||||
- ✓ 界面更简洁,去除了对大部分事件无意义的列
|
||||
- ✓ 专注于通用信息:目标天体、事件类型、时间
|
||||
- ✓ 支持所有事件类型的显示和过滤
|
||||
- ✓ 如需要查看详细信息(如小行星编号),可以查看事件的完整描述或详情
|
||||
|
|
@ -6,12 +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 { 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';
|
||||
|
|
@ -35,6 +40,19 @@ export function Router() {
|
|||
{/* Main app (3D visualization) */}
|
||||
<Route path="/" element={<App />} />
|
||||
|
||||
{/* User routes (protected) */}
|
||||
<Route
|
||||
path="/user"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="profile" element={<UserProfile />} />
|
||||
<Route path="follow" element={<MyCelestialBodies />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin routes (protected) */}
|
||||
<Route
|
||||
path="/admin"
|
||||
|
|
@ -44,14 +62,16 @@ export function Router() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="change-password" element={<ChangePassword />} />
|
||||
<Route path="celestial-bodies" element={<CelestialBodies />} />
|
||||
<Route path="celestial-events" element={<CelestialEvents />} />
|
||||
<Route path="star-systems" element={<StarSystems />} />
|
||||
<Route path="static-data" element={<StaticData />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="nasa-data" element={<NASADownload />} />
|
||||
<Route path="tasks" element={<Tasks />} />
|
||||
<Route path="scheduled-jobs" element={<ScheduledJobs />} />
|
||||
<Route path="settings" element={<SystemSettings />} />
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface CelestialBodyProps {
|
|||
body: CelestialBodyType;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
onBodySelect?: (body: CelestialBodyType) => void;
|
||||
}
|
||||
|
||||
// Saturn Rings component - multiple rings for band effect
|
||||
|
|
@ -77,13 +78,14 @@ function SaturnRings() {
|
|||
}
|
||||
|
||||
// Planet component with texture
|
||||
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false }: {
|
||||
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false, onBodySelect }: {
|
||||
body: CelestialBodyType;
|
||||
size: number;
|
||||
emissive: string;
|
||||
emissiveIntensity: number;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
onBodySelect?: (body: CelestialBodyType) => void;
|
||||
}) {
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
const position = body.positions[0];
|
||||
|
|
@ -137,11 +139,12 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
|
|||
hasOffset={renderPosition.hasOffset}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
onBodySelect={onBodySelect}
|
||||
/>;
|
||||
}
|
||||
|
||||
// Irregular Comet Nucleus - potato-like shape
|
||||
function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Texture | null }) {
|
||||
function IrregularNucleus({ size, texture, onClick }: { size: number; texture: THREE.Texture | null; onClick?: (e: any) => void }) {
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
|
||||
// Create irregular geometry by deforming a sphere
|
||||
|
|
@ -178,7 +181,13 @@ function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Text
|
|||
});
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef} geometry={geometry}>
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
geometry={geometry}
|
||||
onClick={onClick}
|
||||
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
|
||||
>
|
||||
{texture ? (
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
|
|
@ -275,7 +284,7 @@ function CometComa({ radius }: { radius: number }) {
|
|||
}
|
||||
|
||||
// Separate component to handle texture loading
|
||||
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false }: {
|
||||
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false, onBodySelect }: {
|
||||
body: CelestialBodyType;
|
||||
size: number;
|
||||
emissive: string;
|
||||
|
|
@ -287,6 +296,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
hasOffset: boolean;
|
||||
allBodies: CelestialBodyType[];
|
||||
isSelected?: boolean;
|
||||
onBodySelect?: (body: CelestialBodyType) => void;
|
||||
}) {
|
||||
// Load texture if path is provided
|
||||
const texture = texturePath ? useTexture(texturePath) : null;
|
||||
|
|
@ -317,16 +327,30 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
);
|
||||
}, [body.name, body.name_zh, offsetDesc, distance, labelColor]);
|
||||
|
||||
// Handle click event
|
||||
const handleClick = (e: any) => {
|
||||
e.stopPropagation();
|
||||
if (onBodySelect) {
|
||||
onBodySelect(body);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
||||
{/* Use irregular nucleus for comets, regular sphere for others */}
|
||||
{body.type === 'comet' ? (
|
||||
<>
|
||||
<IrregularNucleus size={size} texture={texture} />
|
||||
<IrregularNucleus size={size} texture={texture} onClick={handleClick} />
|
||||
<CometComa radius={size} />
|
||||
</>
|
||||
) : (
|
||||
<mesh ref={meshRef} renderOrder={0}>
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
renderOrder={0}
|
||||
onClick={handleClick}
|
||||
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
|
||||
>
|
||||
<sphereGeometry args={[size, 32, 32]} />
|
||||
{texture ? (
|
||||
<meshStandardMaterial
|
||||
|
|
@ -435,7 +459,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
);
|
||||
}
|
||||
|
||||
export function CelestialBody({ body, allBodies, isSelected = false }: CelestialBodyProps) {
|
||||
export function CelestialBody({ body, allBodies, isSelected = false, onBodySelect }: CelestialBodyProps) {
|
||||
// Get the current position (use the first position for now)
|
||||
const position = body.positions[0];
|
||||
if (!position) return null;
|
||||
|
|
@ -489,6 +513,7 @@ export function CelestialBody({ body, allBodies, isSelected = false }: Celestial
|
|||
emissiveIntensity={appearance.emissiveIntensity}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
onBodySelect={onBodySelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,13 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
|
|||
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||
{body.name_zh || body.name}
|
||||
</h2>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border ${
|
||||
isProbe
|
||||
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
|
||||
: 'bg-[#238636]/20 border-[#238636]/40 text-[#4ade80]'
|
||||
}`}>
|
||||
{isProbe ? '探测器' : '天体'}
|
||||
</span>
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -86,16 +93,9 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
|
|||
className="text-gray-400 hover:text-white transition-colors p-1 rounded-full hover:bg-white/10"
|
||||
title="查看详细信息"
|
||||
>
|
||||
<Eye size={16} />
|
||||
<Eye size={18} />
|
||||
</button>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border ${
|
||||
isProbe
|
||||
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
|
||||
: 'bg-[#238636]/20 border-[#238636]/40 text-[#4ade80]'
|
||||
}`}>
|
||||
{isProbe ? '探测器' : '天体'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 line-clamp-2 leading-relaxed">
|
||||
{body.description || '暂无描述'}
|
||||
|
|
@ -122,7 +122,7 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
|
|||
className="px-3 py-2.5 rounded-lg bg-cyan-950/30 text-cyan-400 border border-cyan-500/20 hover:bg-cyan-500/10 hover:border-cyan-500/50 transition-all flex items-center justify-center gap-2 text-[10px] font-mono uppercase tracking-widest group/btn h-[52px]"
|
||||
title="连接 JPL Horizons System"
|
||||
>
|
||||
<Radar size={12} className="group-hover/btn:animate-spin-slow" />
|
||||
<Radar size={14} className="group-hover/btn:animate-spin-slow" />
|
||||
<span>JPL Horizons</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,16 +15,18 @@ interface ProbeProps {
|
|||
body: CelestialBody;
|
||||
allBodies: CelestialBody[];
|
||||
isSelected?: boolean;
|
||||
onBodySelect?: (body: CelestialBody) => void;
|
||||
}
|
||||
|
||||
// Separate component for each probe type to properly use hooks
|
||||
function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, resourceScale = 1.0 }: {
|
||||
function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, resourceScale = 1.0, onBodySelect }: {
|
||||
body: CelestialBody;
|
||||
modelPath: string;
|
||||
allBodies: CelestialBody[];
|
||||
isSelected?: boolean;
|
||||
onError: () => void;
|
||||
resourceScale?: number;
|
||||
onBodySelect?: (body: CelestialBody) => void;
|
||||
}) {
|
||||
const groupRef = useRef<Group>(null);
|
||||
const position = body.positions[0];
|
||||
|
|
@ -116,6 +118,14 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
|||
// Get offset description if this probe has one
|
||||
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
||||
|
||||
// Handle click event
|
||||
const handleClick = (e: any) => {
|
||||
e.stopPropagation();
|
||||
if (onBodySelect) {
|
||||
onBodySelect(body);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate label texture
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const labelTexture = useMemo(() => {
|
||||
|
|
@ -128,7 +138,13 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
|||
}, [body.name, body.name_zh, offsetDesc, distance]);
|
||||
|
||||
return (
|
||||
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
|
||||
<group
|
||||
position={[scaledPos.x, scaledPos.z, scaledPos.y]}
|
||||
ref={groupRef}
|
||||
onClick={handleClick}
|
||||
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
|
||||
>
|
||||
<primitive
|
||||
object={configuredScene}
|
||||
scale={optimalScale}
|
||||
|
|
@ -160,7 +176,12 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
|||
}
|
||||
|
||||
// Fallback component when model is not available
|
||||
function ProbeFallback({ body, allBodies, isSelected = false }: { body: CelestialBody; allBodies: CelestialBody[]; isSelected?: boolean }) {
|
||||
function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect }: {
|
||||
body: CelestialBody;
|
||||
allBodies: CelestialBody[];
|
||||
isSelected?: boolean;
|
||||
onBodySelect?: (body: CelestialBody) => void;
|
||||
}) {
|
||||
const position = body.positions[0];
|
||||
|
||||
// Use smart render position calculation
|
||||
|
|
@ -176,6 +197,14 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
|
|||
// Get offset description if this probe has one
|
||||
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
||||
|
||||
// Handle click event
|
||||
const handleClick = (e: any) => {
|
||||
e.stopPropagation();
|
||||
if (onBodySelect) {
|
||||
onBodySelect(body);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate label texture
|
||||
const labelTexture = useMemo(() => {
|
||||
return createLabelTexture(
|
||||
|
|
@ -187,7 +216,12 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
|
|||
}, [body.name, body.name_zh, offsetDesc, distance]);
|
||||
|
||||
return (
|
||||
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
||||
<group
|
||||
position={[scaledPos.x, scaledPos.z, scaledPos.y]}
|
||||
onClick={handleClick}
|
||||
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
||||
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.15, 16, 16]} />
|
||||
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
|
||||
|
|
@ -218,7 +252,7 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
|
|||
);
|
||||
}
|
||||
|
||||
export function Probe({ body, allBodies, isSelected = false }: ProbeProps) {
|
||||
export function Probe({ body, allBodies, isSelected = false, onBodySelect }: ProbeProps) {
|
||||
const position = body.positions[0];
|
||||
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
|
||||
const [loadError, setLoadError] = useState<boolean>(false);
|
||||
|
|
@ -270,10 +304,18 @@ export function Probe({ body, allBodies, isSelected = false }: ProbeProps) {
|
|||
|
||||
// Use model if available and no load error, otherwise use fallback
|
||||
if (modelPath && !loadError) {
|
||||
return <ProbeModel body={body} modelPath={modelPath} allBodies={allBodies} isSelected={isSelected} resourceScale={resourceScale} onError={() => {
|
||||
setLoadError(true);
|
||||
}} />;
|
||||
return <ProbeModel
|
||||
body={body}
|
||||
modelPath={modelPath}
|
||||
allBodies={allBodies}
|
||||
isSelected={isSelected}
|
||||
resourceScale={resourceScale}
|
||||
onBodySelect={onBodySelect}
|
||||
onError={() => {
|
||||
setLoadError(true);
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <ProbeFallback body={body} allBodies={allBodies} isSelected={isSelected} />;
|
||||
return <ProbeFallback body={body} allBodies={allBodies} isSelected={isSelected} onBodySelect={onBodySelect} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
body={body}
|
||||
allBodies={bodies}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
onBodySelect={onBodySelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -163,6 +164,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
|||
body={body}
|
||||
allBodies={bodies}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
onBodySelect={onBodySelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ export function DataTable<T extends object>({
|
|||
...columns,
|
||||
];
|
||||
|
||||
// Check if an action column already exists in the provided columns
|
||||
const hasExistingActionColumn = columns.some(col => col.key === 'action');
|
||||
|
||||
// Add status column if onStatusChange is provided
|
||||
if (onStatusChange) {
|
||||
tableColumns.push({
|
||||
|
|
@ -66,8 +69,9 @@ export function DataTable<T extends object>({
|
|||
});
|
||||
}
|
||||
|
||||
// Add operations column if onEdit or onDelete is provided
|
||||
if (onEdit || onDelete || customActions) {
|
||||
// Add operations column if onEdit or onDelete or customActions is provided
|
||||
// and if there isn't already an 'action' column explicitly defined by the parent
|
||||
if (!hasExistingActionColumn && (onEdit || onDelete || customActions)) {
|
||||
tableColumns.push({
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Avatar, Dropdown, Modal, Form, Input, Button, message } from 'antd';
|
||||
import { Layout, Menu, Avatar, Dropdown } from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
|
|
@ -17,9 +17,12 @@ import {
|
|||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ControlOutlined,
|
||||
LockOutlined,
|
||||
IdcardOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { authAPI, request } from '../../utils/request';
|
||||
import { authAPI } from '../../utils/request';
|
||||
import { auth } from '../../utils/auth';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
|
|
@ -35,17 +38,14 @@ const iconMap: Record<string, any> = {
|
|||
settings: <SettingOutlined />,
|
||||
users: <TeamOutlined />,
|
||||
sliders: <ControlOutlined />,
|
||||
profile: <IdcardOutlined />,
|
||||
star: <StarOutlined />,
|
||||
};
|
||||
|
||||
export function AdminLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [menus, setMenus] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [profileForm] = Form.useForm();
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [userProfile, setUserProfile] = useState<any>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = auth.getUser();
|
||||
|
|
@ -56,6 +56,16 @@ export function AdminLayout() {
|
|||
loadMenus();
|
||||
}, []);
|
||||
|
||||
// Redirect to first menu if on root path
|
||||
useEffect(() => {
|
||||
if (menus.length > 0 && (location.pathname === '/admin' || location.pathname === '/user')) {
|
||||
const firstMenu = menus[0];
|
||||
if (firstMenu.path) {
|
||||
navigate(firstMenu.path, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [menus, location.pathname, navigate]);
|
||||
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
const { data } = await authAPI.getMenus();
|
||||
|
|
@ -101,57 +111,12 @@ export function AdminLayout() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleProfileClick = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/users/me');
|
||||
setUserProfile(data);
|
||||
profileForm.setFieldsValue({
|
||||
username: data.username,
|
||||
email: data.email || '',
|
||||
full_name: data.full_name || '',
|
||||
});
|
||||
setProfileModalOpen(true);
|
||||
} catch (error) {
|
||||
toast.error('获取用户信息失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileUpdate = async (values: any) => {
|
||||
try {
|
||||
await request.put('/users/me/profile', {
|
||||
full_name: values.full_name,
|
||||
email: values.email || null,
|
||||
});
|
||||
toast.success('个人信息更新成功');
|
||||
setProfileModalOpen(false);
|
||||
// Update local user info
|
||||
const updatedUser = { ...user, full_name: values.full_name, email: values.email };
|
||||
auth.setUser(updatedUser);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (values: any) => {
|
||||
try {
|
||||
await request.put('/users/me/password', {
|
||||
old_password: values.old_password,
|
||||
new_password: values.new_password,
|
||||
});
|
||||
toast.success('密码修改成功');
|
||||
setPasswordModalOpen(false);
|
||||
passwordForm.resetFields();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || '密码修改失败');
|
||||
}
|
||||
};
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人信息',
|
||||
onClick: handleProfileClick,
|
||||
key: 'change-password',
|
||||
icon: <LockOutlined />,
|
||||
label: '修改密码',
|
||||
onClick: () => navigate('/admin/change-password'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
|
|
@ -225,108 +190,6 @@ export function AdminLayout() {
|
|||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
{/* Profile Modal */}
|
||||
<Modal
|
||||
title="个人信息"
|
||||
open={profileModalOpen}
|
||||
onCancel={() => setProfileModalOpen(false)}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
<Form
|
||||
form={profileForm}
|
||||
layout="vertical"
|
||||
onFinish={handleProfileUpdate}
|
||||
>
|
||||
<Form.Item label="用户名" name="username">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="昵称"
|
||||
name="full_name"
|
||||
rules={[{ max: 50, message: '昵称最长50个字符' }]}
|
||||
>
|
||||
<Input placeholder="请输入昵称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" style={{ marginRight: 8 }}>
|
||||
保存
|
||||
</Button>
|
||||
<Button onClick={() => setPasswordModalOpen(true)}>
|
||||
修改密码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Password Change Modal */}
|
||||
<Modal
|
||||
title="修改密码"
|
||||
open={passwordModalOpen}
|
||||
onCancel={() => {
|
||||
setPasswordModalOpen(false);
|
||||
passwordForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={450}
|
||||
>
|
||||
<Form
|
||||
form={passwordForm}
|
||||
layout="vertical"
|
||||
onFinish={handlePasswordChange}
|
||||
>
|
||||
<Form.Item
|
||||
label="当前密码"
|
||||
name="old_password"
|
||||
rules={[{ required: true, message: '请输入当前密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入当前密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="new_password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码至少6位' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请输入新密码(至少6位)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="确认新密码"
|
||||
name="confirm_password"
|
||||
dependencies={['new_password']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('new_password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="请再次输入新密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
确认修改
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -341,20 +341,14 @@ export function CelestialBodies() {
|
|||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await request.post(
|
||||
await request.post(
|
||||
`/celestial/admin/orbits/generate?body_ids=${record.id}`
|
||||
);
|
||||
|
||||
if (response.data.results && response.data.results.length > 0) {
|
||||
const result = response.data.results[0];
|
||||
if (result.status === 'success') {
|
||||
toast.success(`轨道生成成功!共 ${result.num_points} 个点`);
|
||||
} else {
|
||||
toast.error(`轨道生成失败:${result.error}`);
|
||||
}
|
||||
}
|
||||
// 提示用户任务已启动
|
||||
toast.success('轨道生成任务已启动,请前往"系统任务"查看进度', 5000);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || '轨道生成失败');
|
||||
toast.error(error.response?.data?.detail || '轨道生成任务启动失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -512,7 +506,7 @@ export function CelestialBodies() {
|
|||
return (
|
||||
<Popconfirm
|
||||
title="确认生成轨道"
|
||||
description={`确定要为 ${record.name_zh || record.name} 生成轨道吗?此操作可能需要一些时间。`}
|
||||
description={`确定要为 ${record.name_zh || record.name} 生成轨道吗?`}
|
||||
onConfirm={() => handleGenerateOrbit(record)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
|
|
@ -832,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 (
|
||||
<Form.Item label="资源配置">
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{resourceTypes.map(({ key, label }) => (
|
||||
{resourceTypes.map(({ key, label, type }) => (
|
||||
<div key={key}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{label}</div>
|
||||
|
||||
<Upload
|
||||
beforeUpload={(file) => onUpload(file, key)}
|
||||
showUploadList={false}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||||
上传 {label.split(' ')[0]}
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
{currentResources?.[key] && currentResources[key].length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{currentResources[key].map((res: any) => (
|
||||
<div key={res.id} style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Tag color="blue">{res.file_path}</Tag>
|
||||
<span style={{ fontSize: 12, color: '#888' }}>
|
||||
({(res.file_size / 1024).toFixed(2)} KB)
|
||||
</span>
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={() => onDelete(res.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
{type === 'image' && currentResources?.[key] && currentResources[key].length > 0 ? (
|
||||
// Image preview for icon
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<img
|
||||
src={`/upload/${currentResources[key][0].file_path}`}
|
||||
alt="Icon preview"
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
objectFit: 'contain',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
backgroundColor: '#fafafa'
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Upload
|
||||
beforeUpload={(file) => onUpload(file, key)}
|
||||
showUploadList={false}
|
||||
disabled={uploading}
|
||||
accept="image/*"
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||||
更换图标
|
||||
</Button>
|
||||
</Upload>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Popconfirm
|
||||
title="确认删除图标?"
|
||||
onConfirm={() => onDelete(currentResources[key][0].id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
{key === 'model' && (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>显示缩放:</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
defaultValue={res.extra_data?.scale || 1.0}
|
||||
style={{ width: 80 }}
|
||||
placeholder="1.0"
|
||||
onChange={(value) => {
|
||||
// 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('更新失败');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: '#999' }}>
|
||||
(推荐: Webb=0.3, 旅行者=1.5)
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
删除图标
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
({(currentResources[key][0].file_size / 1024).toFixed(2)} KB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : type === 'image' ? (
|
||||
// No icon uploaded yet
|
||||
<Upload
|
||||
beforeUpload={(file) => onUpload(file, key)}
|
||||
showUploadList={false}
|
||||
disabled={uploading}
|
||||
accept="image/*"
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||||
上传图标
|
||||
</Button>
|
||||
</Upload>
|
||||
) : (
|
||||
// File upload for texture/model
|
||||
<>
|
||||
<Upload
|
||||
beforeUpload={(file) => onUpload(file, key)}
|
||||
showUploadList={false}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||||
上传 {label.split(' ')[0]}
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
{currentResources?.[key] && currentResources[key].length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{currentResources[key].map((res: any) => (
|
||||
<div key={res.id} style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Tag color="blue">{res.file_path}</Tag>
|
||||
<span style={{ fontSize: 12, color: '#888' }}>
|
||||
({(res.file_size / 1024).toFixed(2)} KB)
|
||||
</span>
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={() => onDelete(res.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
{key === 'model' && (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>显示缩放:</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
defaultValue={res.extra_data?.scale || 1.0}
|
||||
style={{ width: 80 }}
|
||||
placeholder="1.0"
|
||||
onChange={(value) => {
|
||||
// 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('更新失败');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: '#999' }}>
|
||||
(推荐: Webb=0.3, 旅行者=1.5)
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* Celestial Events Management Page
|
||||
* 天体事件管理页面
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface CelestialEvent {
|
||||
id: number;
|
||||
body_id: string;
|
||||
title: string;
|
||||
event_type: string;
|
||||
event_time: string;
|
||||
description: string;
|
||||
details: {
|
||||
designation?: string;
|
||||
nominal_dist_au?: string;
|
||||
dist_min_au?: string;
|
||||
relative_velocity_km_s?: string;
|
||||
absolute_magnitude?: string;
|
||||
approach_body?: string;
|
||||
};
|
||||
source: string;
|
||||
created_at: string;
|
||||
body?: {
|
||||
id: string;
|
||||
name: string;
|
||||
name_zh?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function CelestialEvents() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<CelestialEvent[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<CelestialEvent[]>([]);
|
||||
const [bodyFilters, setBodyFilters] = useState<{ text: string; value: string }[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: result } = await request.get('/events', {
|
||||
params: { limit: 500 }
|
||||
});
|
||||
setData(result || []);
|
||||
setFilteredData(result || []);
|
||||
|
||||
// Generate body filters from data
|
||||
const uniqueBodies = new Map<string, { id: string; name: string; name_zh?: string }>();
|
||||
result?.forEach((event: CelestialEvent) => {
|
||||
if (event.body && !uniqueBodies.has(event.body.id)) {
|
||||
uniqueBodies.set(event.body.id, event.body);
|
||||
}
|
||||
});
|
||||
|
||||
const filters = Array.from(uniqueBodies.values()).map(body => ({
|
||||
text: body.name_zh || body.name,
|
||||
value: body.id
|
||||
}));
|
||||
setBodyFilters(filters);
|
||||
} catch (error) {
|
||||
toast.error('加载事件数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (keyword: string) => {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
const filtered = data.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(lowerKeyword) ||
|
||||
item.description?.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
setFilteredData(filtered);
|
||||
};
|
||||
|
||||
const handleDelete = async (record: CelestialEvent) => {
|
||||
try {
|
||||
await request.delete(`/events/${record.id}`);
|
||||
toast.success('删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'approach': 'blue',
|
||||
'close_approach': 'magenta',
|
||||
'eclipse': 'purple',
|
||||
'conjunction': 'cyan',
|
||||
'opposition': 'orange',
|
||||
'transit': 'green',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
const getEventTypeLabel = (type: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
'approach': '接近',
|
||||
'close_approach': '近距离接近',
|
||||
'eclipse': '食',
|
||||
'conjunction': '合',
|
||||
'opposition': '冲',
|
||||
'transit': '凌',
|
||||
};
|
||||
return labelMap[type] || type;
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<CelestialEvent> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
width: 70,
|
||||
sorter: (a, b) => a.id - b.id,
|
||||
},
|
||||
{
|
||||
title: '事件标题',
|
||||
dataIndex: 'title',
|
||||
width: 300,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '目标天体',
|
||||
dataIndex: 'body',
|
||||
width: 120,
|
||||
render: (body) => {
|
||||
if (!body) return '-';
|
||||
return (
|
||||
<Tag color="geekblue">
|
||||
{body.name_zh || body.name}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
filters: bodyFilters,
|
||||
onFilter: (value, record) => record.body_id === value,
|
||||
},
|
||||
{
|
||||
title: '事件类型',
|
||||
dataIndex: 'event_type',
|
||||
width: 120,
|
||||
render: (type) => (
|
||||
<Tag color={getEventTypeColor(type)}>
|
||||
{getEventTypeLabel(type)}
|
||||
</Tag>
|
||||
),
|
||||
filters: [
|
||||
{ text: '接近', value: 'approach' },
|
||||
{ text: '近距离接近', value: 'close_approach' },
|
||||
{ text: '食', value: 'eclipse' },
|
||||
{ text: '合', value: 'conjunction' },
|
||||
{ text: '冲', value: 'opposition' },
|
||||
],
|
||||
onFilter: (value, record) => record.event_type === value,
|
||||
},
|
||||
{
|
||||
title: '事件时间',
|
||||
dataIndex: 'event_time',
|
||||
width: 160,
|
||||
render: (time) => formatDateTime(time),
|
||||
sorter: (a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime(),
|
||||
defaultSortOrder: 'ascend',
|
||||
},
|
||||
{
|
||||
title: '距离 (AU)',
|
||||
dataIndex: ['details', 'nominal_dist_au'],
|
||||
width: 120,
|
||||
render: (dist) => dist ? parseFloat(dist).toFixed(4) : '-',
|
||||
sorter: (a, b) => {
|
||||
const distA = parseFloat(a.details?.nominal_dist_au || '999');
|
||||
const distB = parseFloat(b.details?.nominal_dist_au || '999');
|
||||
return distA - distB;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '相对速度 (km/s)',
|
||||
dataIndex: ['details', 'relative_velocity_km_s'],
|
||||
width: 140,
|
||||
render: (vel) => vel ? parseFloat(vel).toFixed(2) : '-',
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
ellipsis: true,
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
width: 120,
|
||||
render: (source) => (
|
||||
<Tag>{source}</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
title="天体事件"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
total={filteredData.length}
|
||||
onSearch={handleSearch}
|
||||
onDelete={handleDelete}
|
||||
rowKey="id"
|
||||
pageSize={20}
|
||||
showAdd={false}
|
||||
showEdit={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Change Password Page
|
||||
* 修改密码页面
|
||||
*/
|
||||
import { Form, Input, Button, Card } from 'antd';
|
||||
import { LockOutlined } from '@ant-design/icons';
|
||||
import { request } from '../../utils/request';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
export function ChangePassword() {
|
||||
const [form] = Form.useForm();
|
||||
const toast = useToast();
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
await request.put('/users/me/password', {
|
||||
old_password: values.old_password,
|
||||
new_password: values.new_password,
|
||||
});
|
||||
toast.success('密码修改成功');
|
||||
form.resetFields();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || '密码修改失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<Card title="修改密码" bordered={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="当前密码"
|
||||
name="old_password"
|
||||
rules={[{ required: true, message: '请输入当前密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入当前密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="新密码"
|
||||
name="new_password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度至少6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认新密码"
|
||||
name="confirm_password"
|
||||
dependencies={['new_password']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('new_password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
修改密码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,28 +7,32 @@ import { useEffect, useState } from 'react';
|
|||
import { request } from '../../utils/request';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface DashboardStats {
|
||||
total_bodies: number;
|
||||
total_probes: number;
|
||||
total_users: number;
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const [totalUsers, setTotalUsers] = useState<number | null>(null);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserCount = async () => {
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Assuming '/users/count' is the new endpoint we just created in the backend
|
||||
const response = await request.get('/users/count');
|
||||
setTotalUsers(response.data.total_users);
|
||||
const response = await request.get('/system/statistics');
|
||||
setStats(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user count:', error);
|
||||
toast.error('无法获取用户总数');
|
||||
setTotalUsers(0); // Set to 0 or handle error display
|
||||
console.error('Failed to fetch statistics:', error);
|
||||
toast.error('无法获取统计数据');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchUserCount();
|
||||
}, []); // Run once on mount
|
||||
fetchStatistics();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -38,7 +42,8 @@ export function Dashboard() {
|
|||
<Card>
|
||||
<Statistic
|
||||
title="天体总数"
|
||||
value={18} // Currently hardcoded
|
||||
value={stats?.total_bodies ?? '-'}
|
||||
loading={loading}
|
||||
prefix={<GlobalOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
|
|
@ -47,7 +52,8 @@ export function Dashboard() {
|
|||
<Card>
|
||||
<Statistic
|
||||
title="探测器"
|
||||
value={7} // Currently hardcoded
|
||||
value={stats?.total_probes ?? '-'}
|
||||
loading={loading}
|
||||
prefix={<RocketOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
|
|
@ -56,7 +62,7 @@ export function Dashboard() {
|
|||
<Card>
|
||||
<Statistic
|
||||
title="注册用户数"
|
||||
value={totalUsers !== null ? totalUsers : '-'}
|
||||
value={stats?.total_users ?? '-'}
|
||||
loading={loading}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* My Celestial Bodies Page (User Follow)
|
||||
* 我的天体页面 - 左侧是关注的天体列表,右侧是天体详情和事件
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Row, Col, Card, List, Tag, Button, Empty, Descriptions, Table, Space } from 'antd';
|
||||
import { StarFilled, RocketOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../../utils/request';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface CelestialBody {
|
||||
id: string;
|
||||
name: string;
|
||||
name_zh: string;
|
||||
type: string;
|
||||
is_active: boolean;
|
||||
followed_at?: string;
|
||||
}
|
||||
|
||||
interface CelestialEvent {
|
||||
id: number;
|
||||
title: string;
|
||||
event_type: string;
|
||||
event_time: string;
|
||||
description: string;
|
||||
details: any;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function MyCelestialBodies() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [followedBodies, setFollowedBodies] = useState<CelestialBody[]>([]);
|
||||
const [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
|
||||
const [bodyEvents, setBodyEvents] = useState<CelestialEvent[]>([]);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadFollowedBodies();
|
||||
}, []);
|
||||
|
||||
const loadFollowedBodies = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await request.get('/social/follows');
|
||||
setFollowedBodies(data || []);
|
||||
// 如果有数据,默认选中第一个
|
||||
if (data && data.length > 0) {
|
||||
handleSelectBody(data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('加载关注列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectBody = async (body: CelestialBody) => {
|
||||
setSelectedBody(body);
|
||||
setEventsLoading(true);
|
||||
try {
|
||||
const { data } = await request.get(`/events`, {
|
||||
params: {
|
||||
body_id: body.id,
|
||||
limit: 100
|
||||
}
|
||||
});
|
||||
setBodyEvents(data || []);
|
||||
} catch (error) {
|
||||
toast.error('加载天体事件失败');
|
||||
setBodyEvents([]);
|
||||
} finally {
|
||||
setEventsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnfollow = async (bodyId: string) => {
|
||||
try {
|
||||
await request.delete(`/social/follow/${bodyId}`);
|
||||
toast.success('已取消关注');
|
||||
|
||||
// 重新加载列表
|
||||
await loadFollowedBodies();
|
||||
|
||||
// 如果取消关注的是当前选中的天体,清空右侧显示
|
||||
if (selectedBody?.id === bodyId) {
|
||||
setSelectedBody(null);
|
||||
setBodyEvents([]);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('取消关注失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getBodyTypeLabel = (type: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
'star': '恒星',
|
||||
'planet': '行星',
|
||||
'dwarf_planet': '矮行星',
|
||||
'satellite': '卫星',
|
||||
'comet': '彗星',
|
||||
'asteroid': '小行星',
|
||||
'probe': '探测器',
|
||||
};
|
||||
return labelMap[type] || type;
|
||||
};
|
||||
|
||||
const getBodyTypeColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'star': 'gold',
|
||||
'planet': 'blue',
|
||||
'dwarf_planet': 'cyan',
|
||||
'satellite': 'geekblue',
|
||||
'comet': 'purple',
|
||||
'asteroid': 'volcano',
|
||||
'probe': 'magenta',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
const getEventTypeLabel = (type: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
'approach': '接近',
|
||||
'close_approach': '近距离接近',
|
||||
'eclipse': '食',
|
||||
'conjunction': '合',
|
||||
'opposition': '冲',
|
||||
'transit': '凌',
|
||||
};
|
||||
return labelMap[type] || type;
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'approach': 'blue',
|
||||
'close_approach': 'magenta',
|
||||
'eclipse': 'purple',
|
||||
'conjunction': 'cyan',
|
||||
'opposition': 'orange',
|
||||
'transit': 'green',
|
||||
};
|
||||
return colorMap[type] || 'default';
|
||||
};
|
||||
|
||||
const eventColumns: ColumnsType<CelestialEvent> = [
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'event_type',
|
||||
key: 'event_type',
|
||||
width: 200,
|
||||
render: (type) => (
|
||||
<Tag color={getEventTypeColor(type)}>
|
||||
{getEventTypeLabel(type)}
|
||||
</Tag>
|
||||
),
|
||||
filters: [
|
||||
{ text: '接近', value: 'approach' },
|
||||
{ text: '近距离接近', value: 'close_approach' },
|
||||
{ text: '食', value: 'eclipse' },
|
||||
{ text: '合', value: 'conjunction' },
|
||||
{ text: '冲', value: 'opposition' },
|
||||
{ text: '凌', value: 'transit' },
|
||||
],
|
||||
onFilter: (value, record) => record.event_type === value,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'event_time',
|
||||
key: 'event_time',
|
||||
width: 180,
|
||||
render: (time) => new Date(time).toLocaleString('zh-CN'),
|
||||
sorter: (a, b) => new Date(a.event_time).getTime() - new Date(b.event_time).getTime(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Row gutter={16} style={{ height: 'calc(100vh - 150px)' }}>
|
||||
{/* 左侧:关注的天体列表 */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<StarFilled style={{ color: '#faad14' }} />
|
||||
<span>我的天体</span>
|
||||
<Tag color="blue">{followedBodies.length}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button size="small" onClick={loadFollowedBodies} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
bordered={false}
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
bodyStyle={{ height: 'calc(100% - 57px)', overflowY: 'auto', padding: 0 }}
|
||||
>
|
||||
{followedBodies.length === 0 && !loading ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="还没有关注任何天体"
|
||||
style={{ marginTop: 60 }}
|
||||
>
|
||||
<p style={{ color: '#999', margin: '8px 0' }}>
|
||||
在主页面点击天体,查看详情后可以关注
|
||||
</p>
|
||||
</Empty>
|
||||
) : (
|
||||
<List
|
||||
dataSource={followedBodies}
|
||||
loading={loading}
|
||||
renderItem={(body) => (
|
||||
<List.Item
|
||||
key={body.id}
|
||||
onClick={() => handleSelectBody(body)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedBody?.id === body.id ? '#f0f5ff' : 'transparent',
|
||||
padding: '12px 16px',
|
||||
transition: 'background-color 0.3s',
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="unfollow"
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<StarFilled />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnfollow(body.id);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<StarFilled style={{ color: '#faad14', fontSize: 20 }} />}
|
||||
title={
|
||||
<Space>
|
||||
<span>{body.name_zh || body.name}</span>
|
||||
<Tag color={getBodyTypeColor(body.type)} style={{ marginLeft: 4 }}>
|
||||
{getBodyTypeLabel(body.type)}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
body.followed_at
|
||||
? `关注于 ${new Date(body.followed_at).toLocaleDateString('zh-CN')}`
|
||||
: body.name_zh ? body.name : undefined
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 右侧:天体详情和事件 */}
|
||||
<Col span={16}>
|
||||
{selectedBody ? (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%', height: '100%' }}>
|
||||
{/* 天体资料 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<RocketOutlined />
|
||||
<span>{selectedBody.name_zh || selectedBody.name}</span>
|
||||
<Tag color={getBodyTypeColor(selectedBody.type)}>
|
||||
{getBodyTypeLabel(selectedBody.type)}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
bordered={false}
|
||||
>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="ID">{selectedBody.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{getBodyTypeLabel(selectedBody.type)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="中文名">
|
||||
{selectedBody.name_zh || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="英文名">
|
||||
{selectedBody.name}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={selectedBody.is_active ? 'success' : 'default'}>
|
||||
{selectedBody.is_active ? '活跃' : '已归档'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关注时间">
|
||||
{selectedBody.followed_at
|
||||
? new Date(selectedBody.followed_at).toLocaleString('zh-CN')
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 天体事件列表 */}
|
||||
<Card
|
||||
title="相关天体事件"
|
||||
bordered={false}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Table
|
||||
columns={eventColumns}
|
||||
dataSource={bodyEvents}
|
||||
rowKey="id"
|
||||
loading={eventsLoading}
|
||||
size="small"
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无相关事件"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => (
|
||||
<div style={{ padding: '8px 16px' }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>描述:</strong>
|
||||
{record.description}
|
||||
</p>
|
||||
{record.details && (
|
||||
<p style={{ margin: '8px 0 0', color: '#666' }}>
|
||||
<strong>详情:</strong>
|
||||
{JSON.stringify(record.details, null, 2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="请从左侧选择一个天体查看详情"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
|
@ -244,7 +244,7 @@ export function NASADownload() {
|
|||
body_ids: selectedBodies,
|
||||
dates: datesToDownload
|
||||
});
|
||||
toast.success('后台下载任务已启动,请前往“系统任务”查看进度');
|
||||
toast.success('批量下载任务已启动,请前往“系统任务”查看进度');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('请求失败');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,586 @@
|
|||
/**
|
||||
* Scheduled Jobs Management Page
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Switch, Button, Space, Popconfirm, Tag, Tooltip, Badge, Tabs, Select, Row, Col, Card, Alert } from 'antd';
|
||||
import { PlayCircleOutlined, EditOutlined, DeleteOutlined, QuestionCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface ScheduledJob {
|
||||
id: number;
|
||||
name: string;
|
||||
job_type: 'predefined' | 'custom_code';
|
||||
predefined_function?: string;
|
||||
function_params?: Record<string, any>;
|
||||
cron_expression: string;
|
||||
python_code?: string;
|
||||
is_active: boolean;
|
||||
description: string;
|
||||
last_run_at: string | null;
|
||||
last_run_status: 'success' | 'failed' | null;
|
||||
next_run_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AvailableTask {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
parameters: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
default: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ScheduledJobs() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<ScheduledJob[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<ScheduledJob[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<ScheduledJob | null>(null);
|
||||
const [activeTabKey, setActiveTabKey] = useState('basic');
|
||||
const [availableTasks, setAvailableTasks] = useState<AvailableTask[]>([]);
|
||||
const [selectedTask, setSelectedTask] = useState<AvailableTask | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const toast = useToast();
|
||||
|
||||
const jobType = Form.useWatch('job_type', form);
|
||||
const predefinedFunction = Form.useWatch('predefined_function', form);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadAvailableTasks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// When predefined function changes, update selected task
|
||||
if (predefinedFunction && availableTasks.length > 0) {
|
||||
const task = availableTasks.find(t => t.name === predefinedFunction);
|
||||
setSelectedTask(task || null);
|
||||
|
||||
// Set default parameter values only if not editing
|
||||
if (task && !editingRecord) {
|
||||
const defaultParams: Record<string, any> = {};
|
||||
task.parameters.forEach(param => {
|
||||
if (param.default !== null && param.default !== undefined) {
|
||||
defaultParams[param.name] = param.default;
|
||||
}
|
||||
});
|
||||
form.setFieldsValue({ function_params: defaultParams });
|
||||
} else if (task && editingRecord) {
|
||||
// When editing, just set the selected task, don't override params
|
||||
setSelectedTask(task);
|
||||
}
|
||||
} else {
|
||||
setSelectedTask(null);
|
||||
}
|
||||
}, [predefinedFunction, availableTasks]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: result } = await request.get('/scheduled-jobs');
|
||||
setData(result || []);
|
||||
setFilteredData(result || []);
|
||||
} catch (error) {
|
||||
toast.error('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvailableTasks = async () => {
|
||||
try {
|
||||
const { data: result } = await request.get('/scheduled-jobs/available-tasks');
|
||||
setAvailableTasks(result || []);
|
||||
} catch (error) {
|
||||
toast.error('加载可用任务列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (keyword: string) => {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
const filtered = data.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lowerKeyword) ||
|
||||
item.description?.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
setFilteredData(filtered);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingRecord(null);
|
||||
setSelectedTask(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
job_type: 'predefined',
|
||||
is_active: true,
|
||||
function_params: {}
|
||||
});
|
||||
setActiveTabKey('basic');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: ScheduledJob) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
function_params: record.function_params || {}
|
||||
});
|
||||
setActiveTabKey('basic');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (record: ScheduledJob) => {
|
||||
try {
|
||||
await request.delete(`/scheduled-jobs/${record.id}`);
|
||||
toast.success('删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunNow = async (record: ScheduledJob) => {
|
||||
try {
|
||||
await request.post(`/scheduled-jobs/${record.id}/run`);
|
||||
toast.success('定时任务已触发,请前往"系统任务"中查看进度');
|
||||
setTimeout(loadData, 1000);
|
||||
} catch (error) {
|
||||
toast.error('触发失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// Clean up data based on job_type
|
||||
if (values.job_type === 'predefined') {
|
||||
delete values.python_code;
|
||||
} else {
|
||||
delete values.predefined_function;
|
||||
delete values.function_params;
|
||||
}
|
||||
|
||||
if (editingRecord) {
|
||||
await request.put(`/scheduled-jobs/${editingRecord.id}`, values);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
await request.post('/scheduled-jobs', values);
|
||||
toast.success('创建成功');
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.detail) {
|
||||
const detail = error.response.data.detail;
|
||||
if (typeof detail === 'object' && detail.message) {
|
||||
toast.error(detail.message);
|
||||
} else {
|
||||
toast.error(detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ScheduledJob> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'name',
|
||||
width: 200,
|
||||
render: (text, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{text}</div>
|
||||
{record.description && (
|
||||
<div style={{ fontSize: 12, color: '#888' }}>{record.description}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'job_type',
|
||||
width: 120,
|
||||
render: (type) => (
|
||||
<Tag color={type === 'predefined' ? 'blue' : 'purple'}>
|
||||
{type === 'predefined' ? '内置任务' : '自定义代码'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '任务函数',
|
||||
dataIndex: 'predefined_function',
|
||||
width: 200,
|
||||
render: (func, record) => {
|
||||
if (record.job_type === 'predefined') {
|
||||
return <Tag color="cyan">{func}</Tag>;
|
||||
}
|
||||
return <span style={{ color: '#ccc' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Cron 表达式',
|
||||
dataIndex: 'cron_expression',
|
||||
width: 130,
|
||||
render: (text) => <Tag color="green">{text}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
width: 80,
|
||||
render: (active) => (
|
||||
<Badge status={active ? 'success' : 'default'} text={active ? '启用' : '禁用'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上次执行',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
{record.last_run_at ? (
|
||||
<>
|
||||
<div>{new Date(record.last_run_at).toLocaleString()}</div>
|
||||
<Tag color={record.last_run_status === 'success' ? 'green' : 'red'}>
|
||||
{record.last_run_status === 'success' ? '成功' : '失败'}
|
||||
</Tag>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>从未执行</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="立即执行">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => handleRunNow(record)}
|
||||
style={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
style={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确认删除该任务?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="定时任务管理"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
total={filteredData.length}
|
||||
onSearch={handleSearch}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
rowKey="id"
|
||||
pageSize={10}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑任务' : '新增任务'}
|
||||
open={isModalOpen}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
width={900}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
|
||||
{/* 基础配置 Tab */}
|
||||
<Tabs.TabPane tab="基础配置" key="basic">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="任务名称"
|
||||
rules={[{ required: true, message: '请输入任务名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:每日数据同步" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="job_type"
|
||||
label="任务类型"
|
||||
rules={[{ required: true, message: '请选择任务类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '内置任务', value: 'predefined' },
|
||||
{ label: '自定义代码', value: 'custom_code' }
|
||||
]}
|
||||
onChange={() => {
|
||||
// Clear related fields when type changes
|
||||
form.setFieldsValue({
|
||||
predefined_function: undefined,
|
||||
function_params: {},
|
||||
python_code: undefined
|
||||
});
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="cron_expression"
|
||||
label={
|
||||
<Space>
|
||||
<span>Cron 表达式</span>
|
||||
<Tooltip title="格式:分 时 日 月 周 (例如: 0 0 * * * 表示每天零点)">
|
||||
<QuestionCircleOutlined style={{ color: '#888' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
rules={[{ required: true, message: '请输入 Cron 表达式' }]}
|
||||
>
|
||||
<Input placeholder="0 0 * * *" style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="is_active"
|
||||
label="是否启用"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="任务描述" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 内置任务配置 */}
|
||||
{jobType === 'predefined' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="predefined_function"
|
||||
label="选择预定义任务"
|
||||
rules={[{ required: true, message: '请选择预定义任务' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择任务"
|
||||
options={availableTasks.map(task => ({
|
||||
label: `${task.name} - ${task.description}`,
|
||||
value: task.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{selectedTask && (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<InfoCircleOutlined />
|
||||
<span>任务参数配置</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Alert
|
||||
message={selectedTask.description}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{selectedTask.parameters.map(param => (
|
||||
<Form.Item
|
||||
key={param.name}
|
||||
name={['function_params', param.name]}
|
||||
label={
|
||||
<Space>
|
||||
<span>{param.name}</span>
|
||||
{!param.required && <Tag color="orange">可选</Tag>}
|
||||
</Space>
|
||||
}
|
||||
tooltip={param.description}
|
||||
rules={[
|
||||
{ required: param.required, message: `请输入${param.name}` }
|
||||
]}
|
||||
>
|
||||
{param.type === 'integer' ? (
|
||||
<Input type="number" placeholder={`默认: ${param.default}`} />
|
||||
) : param.type === 'boolean' ? (
|
||||
<Switch />
|
||||
) : param.type === 'array' ? (
|
||||
<Select mode="tags" placeholder="输入后回车添加" />
|
||||
) : (
|
||||
<Input placeholder={`默认: ${param.default || '无'}`} />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* Python 代码 Tab - 仅在自定义代码模式下显示 */}
|
||||
{jobType === 'custom_code' && (
|
||||
<Tabs.TabPane tab="Python 代码" key="code">
|
||||
<Alert
|
||||
message="自定义代码执行环境"
|
||||
description={
|
||||
<div>
|
||||
<p>可用变量:</p>
|
||||
<ul style={{ marginBottom: 0 }}>
|
||||
<li><code>db</code>: AsyncSession - 数据库会话</li>
|
||||
<li><code>logger</code>: Logger - 日志记录器</li>
|
||||
<li><code>task_id</code>: int - 任务ID</li>
|
||||
<li><code>asyncio</code>: 异步IO模块</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
⚠️ 注意:代码在异步环境中执行,可以使用 <code>await</code>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
name="python_code"
|
||||
rules={[{ required: jobType === 'custom_code', message: '请输入执行脚本' }]}
|
||||
>
|
||||
<CodeEditor
|
||||
placeholder={`# 动态任务脚本示例
|
||||
# 可用变量: db (AsyncSession), logger (Logger), task_id (int)
|
||||
# 必须是异步环境,可以使用 await
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
logger.info(f"任务开始执行: {datetime.now()}")
|
||||
|
||||
# 你的业务逻辑...
|
||||
|
||||
return "执行成功"`}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Tabs.TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple Code Editor with Line Numbers
|
||||
const CodeEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = ''
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (e: any) => void;
|
||||
placeholder?: string;
|
||||
}) => {
|
||||
const displayValue = value || placeholder;
|
||||
const lineCount = displayValue.split('\n').length;
|
||||
const lineNumbers = Array.from({ length: lineCount }, (_, i) => i + 1).join('\n');
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRight: '1px solid #d9d9d9',
|
||||
color: '#999',
|
||||
textAlign: 'right',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '1.5',
|
||||
fontSize: '14px',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{lineNumbers}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
resize: 'none',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '1.5',
|
||||
fontSize: '14px',
|
||||
padding: '4px 8px',
|
||||
flex: 1,
|
||||
backgroundColor: '#fafafa',
|
||||
color: value ? '#333' : '#999'
|
||||
}}
|
||||
rows={20}
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
/**
|
||||
* System Settings Management Page
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Switch, Select, Button, Card, Descriptions, Badge, Space, Popconfirm, Alert, Divider } from 'antd';
|
||||
import { ReloadOutlined, ClearOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined, ClearOutlined, WarningOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
|
|
@ -39,6 +36,7 @@ export function SystemSettings() {
|
|||
const [editingRecord, setEditingRecord] = useState<SystemSetting | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -148,6 +146,19 @@ export function SystemSettings() {
|
|||
}
|
||||
};
|
||||
|
||||
// Reload system settings
|
||||
const handleReloadSettings = async () => {
|
||||
setReloading(true);
|
||||
try {
|
||||
const { data } = await request.post('/system/settings/reload');
|
||||
toast.success(data.message);
|
||||
} catch (error) {
|
||||
toast.error('重载配置失败');
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<SystemSetting> = [
|
||||
{
|
||||
title: '参数键',
|
||||
|
|
@ -233,24 +244,23 @@ export function SystemSettings() {
|
|||
title={
|
||||
<Space>
|
||||
<ClearOutlined />
|
||||
<span>缓存管理</span>
|
||||
<span>系统维护</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<Alert
|
||||
title="清除缓存会清空所有内存缓存和 Redis 缓存,包括:"
|
||||
title="系统维护操作说明"
|
||||
description={
|
||||
<div>
|
||||
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
|
||||
<li>* 位置数据缓存(当前位置和历史位置)</li>
|
||||
<li>* NASA API 响应缓存</li>
|
||||
<li>* 所有其他临时缓存数据</li>
|
||||
<li><strong>清除缓存:</strong>清空所有内存缓存和 Redis 缓存,下次查询可能会较慢。</li>
|
||||
<li><strong>重载配置:</strong>从数据库重新加载系统参数到内存,使配置修改立即生效(无需重启)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
|
@ -258,7 +268,7 @@ export function SystemSettings() {
|
|||
<Space>
|
||||
<Popconfirm
|
||||
title="确认清除所有缓存?"
|
||||
description="此操作会清空所有缓存数据,下次查询可能会较慢"
|
||||
description="此操作会清空所有缓存数据"
|
||||
onConfirm={handleClearCache}
|
||||
okText="确认清除"
|
||||
cancelText="取消"
|
||||
|
|
@ -273,6 +283,15 @@ export function SystemSettings() {
|
|||
清除所有缓存
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={handleReloadSettings}
|
||||
loading={reloading}
|
||||
>
|
||||
重载系统配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
|
|
@ -364,6 +383,16 @@ export function SystemSettings() {
|
|||
<InputNumber style={{ width: '100%' }} step={valueType === 'float' ? 0.1 : 1} />
|
||||
</Form.Item>
|
||||
);
|
||||
} else if (valueType === 'json') {
|
||||
return (
|
||||
<Form.Item
|
||||
name="value"
|
||||
label="参数值"
|
||||
rules={[{ required: true, message: '请输入参数值' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="JSON 格式数据" />
|
||||
</Form.Item>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Form.Item
|
||||
|
|
@ -371,7 +400,7 @@ export function SystemSettings() {
|
|||
label="参数值"
|
||||
rules={[{ required: true, message: '请输入参数值' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder={valueType === 'json' ? 'JSON 格式数据' : '参数值'} />
|
||||
<Input placeholder="参数值" />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Tag, Progress, Button, Modal, Descriptions, Badge, Typography } from 'antd';
|
||||
import { Tag, Progress, Button, Modal, Descriptions, Badge, Typography, Space } from 'antd';
|
||||
import { ReloadOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
|
|
@ -127,10 +127,6 @@ export function Tasks() {
|
|||
|
||||
return (
|
||||
<div className="p-0">
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
title="系统任务列表"
|
||||
columns={columns}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* User Profile Page
|
||||
* 个人信息页面
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, Card, Avatar, Space, Descriptions, Row, Col } from 'antd';
|
||||
import { UserOutlined, MailOutlined, IdcardOutlined } from '@ant-design/icons';
|
||||
import { request } from '../../utils/request';
|
||||
import { auth } from '../../utils/auth';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
export function UserProfile() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userProfile, setUserProfile] = useState<any>(null);
|
||||
const toast = useToast();
|
||||
const user = auth.getUser();
|
||||
|
||||
useEffect(() => {
|
||||
loadUserProfile();
|
||||
}, []);
|
||||
|
||||
const loadUserProfile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await request.get('/users/me');
|
||||
setUserProfile(data);
|
||||
form.setFieldsValue({
|
||||
email: data.email || '',
|
||||
full_name: data.full_name || '',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error('获取用户信息失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await request.put('/users/me/profile', {
|
||||
full_name: values.full_name,
|
||||
email: values.email || null,
|
||||
});
|
||||
toast.success('个人信息更新成功');
|
||||
|
||||
// Update local user info
|
||||
const updatedUser = { ...user, full_name: values.full_name, email: values.email };
|
||||
auth.setUser(updatedUser);
|
||||
|
||||
// Reload profile
|
||||
await loadUserProfile();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || '更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
{/* User Avatar and Basic Info Card */}
|
||||
<Card bordered={false} loading={loading}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Avatar size={100} icon={<UserOutlined />} />
|
||||
<h2 style={{ marginTop: 24, marginBottom: 8 }}>
|
||||
{userProfile?.full_name || userProfile?.username || '用户'}
|
||||
</h2>
|
||||
<p style={{ color: '#999', marginBottom: 24 }}>
|
||||
@{userProfile?.username}
|
||||
</p>
|
||||
|
||||
{userProfile && (
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="角色">
|
||||
{userProfile.role === 'admin' ? '管理员' : '普通用户'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{new Date(userProfile.created_at).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={16}>
|
||||
{/* Edit Profile Form */}
|
||||
<Card title="编辑个人信息" bordered={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="姓名"
|
||||
name="full_name"
|
||||
rules={[{ required: true, message: '请输入姓名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<IdcardOutlined />}
|
||||
placeholder="请输入您的姓名"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="请输入邮箱地址(可选)"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} size="large" block>
|
||||
保存修改
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ export const API_BASE_URL = getBaseUrl();
|
|||
// Create axios instance
|
||||
export const request = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
timeout: 60000, // Increased timeout to 3 minutes for long-running tasks like orbit generation
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 658 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 676 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.4 KiB |