diff --git a/backend/app/api/danmaku.py b/backend/app/api/danmaku.py new file mode 100644 index 0000000..c76c5e0 --- /dev/null +++ b/backend/app/api/danmaku.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, constr +from typing import List + +from app.database import get_db +from app.models.db import User +from app.services.auth_deps import get_current_user +from app.services.danmaku_service import danmaku_service + +router = APIRouter(prefix="/danmaku", tags=["danmaku"]) + +class DanmakuCreate(BaseModel): + text: constr(max_length=20, min_length=1) + +class DanmakuResponse(BaseModel): + id: str + uid: str + username: str + text: str + ts: float + +@router.post("/send", response_model=DanmakuResponse) +async def send_danmaku( + data: DanmakuCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Send a short danmaku message (max 20 chars)""" + try: + result = await danmaku_service.add_danmaku( + user_id=current_user.id, + username=current_user.username, + text=data.text, + db=db + ) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/list", response_model=List[DanmakuResponse]) +async def get_danmaku_list( + db: AsyncSession = Depends(get_db) +): + """Get all active danmaku messages""" + # This endpoint is public (or could be protected if needed) + return await danmaku_service.get_active_danmaku(db) diff --git a/backend/app/main.py b/backend/app/main.py index 33bc153..cbe07d1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ from app.api.routes import router as celestial_router from app.api.auth import router as auth_router from app.api.user import router as user_router from app.api.system import router as system_router +from app.api.danmaku import router as danmaku_router from app.services.redis_cache import redis_cache from app.services.cache_preheat import preheat_all_caches from app.database import close_db @@ -104,6 +105,7 @@ app.include_router(celestial_router, prefix=settings.api_prefix) app.include_router(auth_router, prefix=settings.api_prefix) app.include_router(user_router, prefix=settings.api_prefix) app.include_router(system_router, prefix=settings.api_prefix) +app.include_router(danmaku_router, prefix=settings.api_prefix) # Mount static files for uploaded resources upload_dir = Path(__file__).parent.parent / "upload" diff --git a/backend/app/services/danmaku_service.py b/backend/app/services/danmaku_service.py new file mode 100644 index 0000000..e40655f --- /dev/null +++ b/backend/app/services/danmaku_service.py @@ -0,0 +1,98 @@ +import json +import time +import logging +from typing import List, Dict +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.redis_cache import redis_cache +from app.services.system_settings_service import system_settings_service + +logger = logging.getLogger(__name__) + +class DanmakuService: + def __init__(self): + self.redis_key = "cosmo:danmaku:stream" + self.default_ttl = 86400 # 24 hours fallback + + async def get_ttl(self, db: AsyncSession) -> int: + """Fetch TTL from system settings or use default""" + try: + setting = await system_settings_service.get_setting_by_key("danmaku_ttl", db) + if setting: + return int(setting.value) + except Exception as e: + logger.error(f"Failed to fetch danmaku_ttl: {e}") + return self.default_ttl + + async def add_danmaku(self, user_id: int, username: str, text: str, db: AsyncSession) -> Dict: + """Add a new danmaku message""" + # Validate length (double check server side) + if len(text) > 20: + text = text[:20] + + now = time.time() + ttl = await self.get_ttl(db) + expire_time = now - ttl + + # Create message object + # Add unique timestamp/random to value to ensure uniqueness in Set if user spams same msg? + # Actually ZSET handles unique values. If same user sends "Hi" twice, it updates score. + # To allow same msg multiple times, we can append a unique ID or timestamp to the JSON. + message = { + "uid": str(user_id), + "username": username, + "text": text, + "ts": now, + "id": f"{user_id}_{now}" # Unique ID for React keys + } + + serialized = json.dumps(message) + + # 1. Remove expired messages first + # ZREMRANGEBYSCORE key -inf (now - ttl) + if redis_cache.client: + try: + # Clean up old + await redis_cache.client.zremrangebyscore(self.redis_key, 0, expire_time) + + # Add new + await redis_cache.client.zadd(self.redis_key, {serialized: now}) + + # Optional: Set key expiry to max TTL just in case (but ZADD keeps it alive) + await redis_cache.client.expire(self.redis_key, ttl) + + logger.info(f"Danmaku added by {username}: {text}") + return message + except Exception as e: + logger.error(f"Redis error adding danmaku: {e}") + raise e + else: + logger.warning("Redis not connected, danmaku lost") + return message + + async def get_active_danmaku(self, db: AsyncSession) -> List[Dict]: + """Get all active danmaku messages""" + now = time.time() + ttl = await self.get_ttl(db) + min_score = now - ttl + + if redis_cache.client: + try: + # Get messages from (now - ttl) to +inf + # ZRANGEBYSCORE key min max + results = await redis_cache.client.zrangebyscore(self.redis_key, min_score, "+inf") + + messages = [] + for res in results: + try: + messages.append(json.loads(res)) + except json.JSONDecodeError: + continue + + return messages + except Exception as e: + logger.error(f"Redis error getting danmaku: {e}") + return [] + return [] + +danmaku_service = DanmakuService() diff --git a/backend/scripts/add_danmaku_setting.sql b/backend/scripts/add_danmaku_setting.sql new file mode 100644 index 0000000..73ffb7c --- /dev/null +++ b/backend/scripts/add_danmaku_setting.sql @@ -0,0 +1,4 @@ +-- Add danmaku_ttl setting (default 24 hours = 86400 seconds) +INSERT INTO system_settings (key, value, value_type, category, label, description, is_public) +SELECT 'danmaku_ttl', '86400', 'int', 'platform', '弹幕保留时间', '用户发送的弹幕在系统中保留的时间(秒)', true +WHERE NOT EXISTS (SELECT 1 FROM system_settings WHERE key = 'danmaku_ttl'); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8196ce5..0444c7d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,6 @@ -/** - * Cosmo - Deep Space Explorer - * Main application component - */ import { useState, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { message } from 'antd'; +import { message, Modal, Input } from 'antd'; import { useSpaceData } from './hooks/useSpaceData'; import { useHistoricalData } from './hooks/useHistoricalData'; import { useTrajectory } from './hooks/useTrajectory'; @@ -17,7 +13,9 @@ import { Loading } from './components/Loading'; import { InterstellarTicker } from './components/InterstellarTicker'; import { ControlPanel } from './components/ControlPanel'; import { AuthModal } from './components/AuthModal'; +import { DanmakuLayer } from './components/DanmakuLayer'; import { auth } from './utils/auth'; +import { request } from './utils/request'; import type { CelestialBody } from './types'; // Timeline configuration - will be fetched from backend later @@ -67,6 +65,10 @@ function App() { const [user, setUser] = useState(auth.getUser()); const [showAuthModal, setShowAuthModal] = useState(false); + // Danmaku state + const [isDanmakuInputVisible, setIsDanmakuInputVisible] = useState(false); + const [danmakuText, setDanmakuText] = useState(''); + // Use real-time data or historical data based on mode const { bodies: realTimeBodies, loading: realTimeLoading, error: realTimeError } = useSpaceData(); const { bodies: historicalBodies, loading: historicalLoading, error: historicalError } = useHistoricalData(selectedDate); @@ -116,6 +118,29 @@ function App() { takeScreenshot(nickname); }, [user, takeScreenshot]); + // Danmaku send handler + const handleSendDanmaku = async () => { + if (!danmakuText.trim()) return; + if (danmakuText.length > 20) { + message.warning("弹幕内容不能超过20字"); + return; + } + if (!user) { + message.warning("请先登录"); + setShowAuthModal(true); + return; + } + try { + await request.post('/danmaku/send', { text: danmakuText }); + message.success("发送成功"); + setDanmakuText(''); + setIsDanmakuInputVisible(false); + } catch (e) { + console.error(e); + message.error("发送失败"); + } + }; + // Auth handlers const handleLoginSuccess = (userData: any) => { setUser(userData); @@ -159,6 +184,9 @@ function App() { onNavigateToAdmin={() => navigate('/admin')} /> + {/* Danmaku Layer */} + + {/* Right Control Panel */} setIsSoundOn(!isSoundOn)} showDanmaku={showDanmaku} onToggleDanmaku={() => setShowDanmaku(!showDanmaku)} + onOpenDanmakuInput={() => { + if (!user) { + message.warning("请先登录"); + setShowAuthModal(true); + } else { + setIsDanmakuInputVisible(true); + } + }} onScreenshot={handleScreenshot} /> @@ -179,6 +215,27 @@ function App() { onLoginSuccess={handleLoginSuccess} /> + {/* Danmaku Input Modal */} + setIsDanmakuInputVisible(false)} + okText="发射" + cancelText="取消" + centered + > + setDanmakuText(e.target.value)} + onPressEnter={handleSendDanmaku} + autoFocus + /> + + {/* Probe List Sidebar */} void; showDanmaku: boolean; onToggleDanmaku: () => void; + onOpenDanmakuInput: () => void; onScreenshot: () => void; } @@ -30,6 +32,7 @@ export function ControlPanel({ onToggleSound, showDanmaku, onToggleDanmaku, + onOpenDanmakuInput, onScreenshot, }: ControlPanelProps) { const buttonClass = (isActive: boolean) => ` @@ -77,16 +80,29 @@ export function ControlPanel({ - {/* Danmaku Toggle (Mock) */} - + {/* Danmaku Toggle */} +
+ {showDanmaku && ( + + )} + +
{/* Screenshot Button */}