diff --git a/backend/.env.example b/backend/.env.example index a2e1529..ca51799 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,12 @@ QWEN_API_KEY=your-qwen-api-key QWEN_STOCK_MODEL=qwen-plus +# 管理后台登录 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-this-password +ADMIN_TOKEN_SECRET=change-this-random-secret +ADMIN_TOKEN_TTL_SECONDS=43200 + # 数据与运行时(云端 B/S 生产使用 Postgres) AKSHARE_TIMEOUT=30 DB_MODE=postgres diff --git a/backend/app/api/api.py b/backend/app/api/api.py index b56eb4a..3aeb9bb 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.endpoints import health +from app.api.endpoints import auth, health from app.core.config import settings @@ -8,18 +8,23 @@ def _legacy_sqlite_routes_enabled() -> bool: return settings.DB_MODE != "postgres" or settings.ENABLE_LEGACY_SQLITE_MODULES +def _include_data_display_routes(router: APIRouter) -> None: + from app.api.endpoints import admin, data_hub, market + + router.include_router(market.router, prefix="/market", tags=["market"]) + router.include_router(admin.router, prefix="/admin", tags=["admin"]) + router.include_router(data_hub.router, prefix="/data-hub", tags=["data-hub"]) + + def _include_legacy_sqlite_routes(router: APIRouter) -> None: from app.api.endpoints import ( - admin, ai, analysis, batch_import, charts, data_dev, - data_hub, database, factors, - market, preset_tasks, sectors, stock_screener, @@ -31,8 +36,6 @@ def _include_legacy_sqlite_routes(router: APIRouter) -> None: router.include_router(sectors.router, prefix="/sectors", tags=["sectors"]) router.include_router(ai.router, prefix="/ai", tags=["ai"]) router.include_router(charts.router, prefix="/charts", tags=["charts"]) - router.include_router(market.router, prefix="/market", tags=["market"]) - router.include_router(admin.router, prefix="/admin", tags=["admin"]) router.include_router(analysis.router, prefix="/analysis", tags=["analysis"]) router.include_router(database.router, prefix="/database", tags=["database"]) router.include_router(data_dev.router, prefix="/data-dev", tags=["data-dev"]) @@ -41,12 +44,13 @@ def _include_legacy_sqlite_routes(router: APIRouter) -> None: router.include_router(strategy.router, prefix="/strategy", tags=["strategy"]) router.include_router(factors.router, prefix="/factors", tags=["factors"]) router.include_router(stock_screener.router, prefix="/screener", tags=["screener"]) - router.include_router(data_hub.router, prefix="/data-hub", tags=["data-hub"]) def create_api_router(include_legacy_sqlite_routes: bool | None = None) -> APIRouter: router = APIRouter() router.include_router(health.router, prefix="/health", tags=["health"]) + router.include_router(auth.router, prefix="/auth", tags=["auth"]) + _include_data_display_routes(router) if include_legacy_sqlite_routes is None: include_legacy_sqlite_routes = _legacy_sqlite_routes_enabled() diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index 73147d8..c4935ea 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, BackgroundTasks +from fastapi import APIRouter, BackgroundTasks, Depends +from app.core.admin_auth import require_admin from app.services.scheduler_service import scheduler_service -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_admin)]) @router.post("/fetch-history") async def trigger_history_fetch(background_tasks: BackgroundTasks): diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py new file mode 100644 index 0000000..3a0bcc1 --- /dev/null +++ b/backend/app/api/endpoints/auth.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException, status + +from app.core.admin_auth import authenticate_admin, create_admin_token, require_admin +from app.core.config import settings + +router = APIRouter() + + +class AdminLoginRequest(BaseModel): + username: str + password: str + + +class AdminLoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + username: str + + +@router.post("/admin/login", response_model=AdminLoginResponse) +async def admin_login(payload: AdminLoginRequest) -> AdminLoginResponse: + if not authenticate_admin(payload.username, payload.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid admin credentials.", + ) + + return AdminLoginResponse( + access_token=create_admin_token(payload.username), + expires_in=settings.ADMIN_TOKEN_TTL_SECONDS, + username=payload.username, + ) + + +@router.get("/admin/me") +async def admin_me(username: str = Depends(require_admin)) -> dict[str, str]: + return {"username": username} diff --git a/backend/app/core/admin_auth.py b/backend/app/core/admin_auth.py new file mode 100644 index 0000000..0bbae9d --- /dev/null +++ b/backend/app/core/admin_auth.py @@ -0,0 +1,99 @@ +import base64 +import binascii +import hashlib +import hmac +import json +import secrets +import time +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.config import settings + +_BEARER = HTTPBearer(auto_error=False) + + +def _encode_json(data: dict[str, object]) -> str: + raw = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _decode_json(value: str) -> dict[str, object]: + padding = "=" * (-len(value) % 4) + raw = base64.urlsafe_b64decode(f"{value}{padding}") + return json.loads(raw.decode("utf-8")) + + +def _sign(value: str, secret: str) -> str: + digest = hmac.new(secret.encode("utf-8"), value.encode("ascii"), hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def _token_secret() -> str: + return settings.ADMIN_TOKEN_SECRET or settings.ADMIN_PASSWORD + + +def _ensure_admin_configured() -> None: + if not settings.ADMIN_PASSWORD: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Admin login is not configured.", + ) + + +def authenticate_admin(username: str, password: str) -> bool: + _ensure_admin_configured() + return secrets.compare_digest(username, settings.ADMIN_USERNAME) and secrets.compare_digest( + password, + settings.ADMIN_PASSWORD, + ) + + +def create_admin_token(username: str, now: int | None = None) -> str: + _ensure_admin_configured() + issued_at = int(time.time()) if now is None else now + payload = { + "sub": username, + "iat": issued_at, + "exp": issued_at + settings.ADMIN_TOKEN_TTL_SECONDS, + } + encoded_payload = _encode_json(payload) + signature = _sign(encoded_payload, _token_secret()) + return f"{encoded_payload}.{signature}" + + +def verify_admin_token(token: str, now: int | None = None) -> str: + _ensure_admin_configured() + try: + encoded_payload, signature = token.split(".", 1) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token.") from exc + + expected_signature = _sign(encoded_payload, _token_secret()) + if not secrets.compare_digest(signature, expected_signature): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token.") + + try: + payload = _decode_json(encoded_payload) + username = str(payload["sub"]) + expires_at = int(payload["exp"]) + except (KeyError, TypeError, ValueError, UnicodeDecodeError, binascii.Error) as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token.") from exc + + current_time = int(time.time()) if now is None else now + if current_time >= expires_at: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin token has expired.") + if not secrets.compare_digest(username, settings.ADMIN_USERNAME): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token.") + + return username + + +def require_admin( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_BEARER)], +) -> str: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin login required.") + return verify_admin_token(credentials.credentials) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0321d6c..a1e7339 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -53,6 +53,12 @@ def assemble_operation_allowlist(cls, v: Union[str, List[str]]) -> Union[List[st elif isinstance(v, (list, str)): return v raise ValueError(v) + + # Admin authentication + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD: str = "" + ADMIN_TOKEN_SECRET: str = "" + ADMIN_TOKEN_TTL_SECONDS: int = 60 * 60 * 12 # Database mode: production defaults to Postgres. Legacy SQLite is opt-in. DB_MODE: str = "postgres" diff --git a/backend/app/services/market_service.py b/backend/app/services/market_service.py index f12022b..c77430a 100644 --- a/backend/app/services/market_service.py +++ b/backend/app/services/market_service.py @@ -4,6 +4,7 @@ import math import calendar as pycalendar import hashlib +import requests from datetime import datetime, date, timedelta from typing import Any, Dict, List, Optional, Sequence from functools import lru_cache @@ -19,6 +20,43 @@ logger = logging.getLogger(__name__) +EASTMONEY_A_SPOT_URLS = [ + "https://82.push2.eastmoney.com/api/qt/clist/get", + "https://push2.eastmoney.com/api/qt/clist/get", +] +EASTMONEY_A_SPOT_FIELDS = ( + "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18," + "f20,f21,f23,f24,f25,f22,f11,f62,f128,f136,f115,f152" +) +EASTMONEY_A_SPOT_HEADERS = { + "Accept": "application/json,text/plain,*/*", + "Referer": "https://quote.eastmoney.com/center/gridlist.html", + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36" + ), +} +EASTMONEY_A_SPOT_COLUMN_MAP = { + "f2": "最新价", + "f3": "涨跌幅", + "f4": "涨跌额", + "f5": "成交量", + "f6": "成交额", + "f7": "振幅", + "f8": "换手率", + "f9": "市盈率-动态", + "f10": "量比", + "f12": "代码", + "f14": "名称", + "f15": "最高", + "f16": "最低", + "f17": "今开", + "f18": "昨收", + "f20": "总市值", + "f21": "流通市值", + "f23": "市净率", +} + def _find_column(df: pd.DataFrame, candidates: Sequence[str]) -> Optional[str]: """Find the first matching column name from a list of candidates.""" @@ -43,6 +81,72 @@ def _find_column(df: pd.DataFrame, candidates: Sequence[str]) -> Optional[str]: return None +def _fetch_eastmoney_a_spot_direct(page_size: int = 100, timeout: int = 12) -> pd.DataFrame: + base_params = { + "pn": "1", + "pz": str(page_size), + "po": "1", + "np": "1", + "ut": "bd1d9ddb04089700cf9c27f6f7426281", + "fltt": "2", + "invt": "2", + "fid": "f12", + "fs": "m:0 t:6,m:0 t:80,m:1 t:2,m:1 t:23,m:0 t:81 s:2048", + "fields": EASTMONEY_A_SPOT_FIELDS, + } + rows: list[dict[str, Any]] = [] + total = 0 + page = 1 + + while page == 1 or (total and len(rows) < total): + params = {**base_params, "pn": str(page)} + data_json: dict[str, Any] | None = None + last_error: Exception | None = None + + for url in EASTMONEY_A_SPOT_URLS: + try: + response = requests.get( + url, + params=params, + headers=EASTMONEY_A_SPOT_HEADERS, + timeout=timeout, + ) + response.raise_for_status() + data_json = response.json() + break + except Exception as exc: + last_error = exc + + if data_json is None: + if page == 1: + raise RuntimeError(f"EastMoney direct request failed: {last_error}") + logger.warning("EastMoney direct page %s failed, using %s partial rows: %s", page, len(rows), last_error) + break + + data = data_json.get("data") or {} + diff = data.get("diff") or [] + if not isinstance(diff, list) or not diff: + break + + total = int(data.get("total") or len(diff)) + rows.extend(item for item in diff if isinstance(item, dict)) + + if len(diff) < page_size: + break + page += 1 + if page > 1: + time.sleep(0.15) + + if not rows: + return pd.DataFrame() + + df = pd.DataFrame(rows).rename(columns=EASTMONEY_A_SPOT_COLUMN_MAP) + for column in ("代码", "名称"): + if column not in df.columns: + df[column] = "" + return df + + # Common column name mappings for reuse COLUMN_MAPPINGS = { "code": ["代码", "股票代码", "symbol", "证券代码", "ts_code", "code"], @@ -258,18 +362,11 @@ def get_market_overview() -> Dict[str, Any]: indices_data = db.get_market_indices_realtime() stocks = db.get_all_stocks_realtime() - # 如果数据库没有数据,直接从API获取 + # Overview must stay fast for page rendering. Full-market stock fetches are heavy + # and unreliable on some networks, so this endpoint only uses realtime cache. if not stocks or len(stocks) == 0: - if MarketService._external_fetch_enabled(): - try: - stocks = MarketService.get_all_stocks() - logger.info(f"Fetched {len(stocks)} stocks from API for market overview") - except Exception as e: - logger.warning(f"Failed to fetch stocks from API: {e}") - stocks = [] - else: - logger.info("External market fetch disabled; using cache-only stocks for market overview") - stocks = [] + logger.info("No cached realtime stocks for market overview; returning neutral breadth data") + stocks = [] # 如果指数数据为空,尝试获取 if not indices_data or len(indices_data) == 0: @@ -490,8 +587,19 @@ def fetch_with_retry(fetch_func, name, max_retries=2): # Priority 1: Try East Money interface with retry df = fetch_with_retry(ak.stock_zh_a_spot_em, "EastMoney", max_retries=2) - - # Priority 2: Try hot rank as a lightweight alternative + + # Priority 2: Direct EastMoney fallback with browser headers and controlled paging + if df is None or df.empty: + logger.info("Trying direct EastMoney A-share spot fallback...") + try: + direct_df = _fetch_eastmoney_a_spot_direct() + if direct_df is not None and not direct_df.empty: + logger.info("Successfully fetched %s stocks from direct EastMoney fallback", len(direct_df)) + df = direct_df + except Exception as e: + logger.warning("Direct EastMoney fallback failed: %s", e) + + # Priority 3: Try hot rank as a lightweight alternative if df is None or df.empty: logger.info("Trying stock_hot_rank_em as alternative...") try: @@ -509,11 +617,6 @@ def fetch_with_retry(fetch_func, name, max_retries=2): df = hot_df.rename(columns=col_map) except Exception as e: logger.warning(f"Hot rank failed: {e}") - - # Priority 3: Fallback to alternative interface (slower) - if df is None or df.empty: - logger.info("Trying alternative stock_zh_a_spot interface (slow)...") - df = fetch_with_retry(ak.stock_zh_a_spot, "Sina", max_retries=1) if df is None or df.empty: logger.warning("No stock data available from any interface") diff --git a/backend/tests/test_admin_auth.py b/backend/tests/test_admin_auth.py new file mode 100644 index 0000000..dc4aee7 --- /dev/null +++ b/backend/tests/test_admin_auth.py @@ -0,0 +1,99 @@ +import unittest + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from app.api.api import create_api_router +from app.core import admin_auth +from app.core.config import settings + + +class AdminAuthTests(unittest.TestCase): + def setUp(self): + self.original_username = settings.ADMIN_USERNAME + self.original_password = settings.ADMIN_PASSWORD + self.original_secret = settings.ADMIN_TOKEN_SECRET + self.original_ttl = settings.ADMIN_TOKEN_TTL_SECONDS + settings.ADMIN_USERNAME = "admin" + settings.ADMIN_PASSWORD = "secret-password" + settings.ADMIN_TOKEN_SECRET = "test-token-secret" + settings.ADMIN_TOKEN_TTL_SECONDS = 3600 + + def tearDown(self): + settings.ADMIN_USERNAME = self.original_username + settings.ADMIN_PASSWORD = self.original_password + settings.ADMIN_TOKEN_SECRET = self.original_secret + settings.ADMIN_TOKEN_TTL_SECONDS = self.original_ttl + + def test_admin_login_issues_token_and_me_accepts_it(self): + app = FastAPI() + app.include_router(create_api_router(include_legacy_sqlite_routes=False), prefix="/api/v1") + client = TestClient(app) + + login_response = client.post( + "/api/v1/auth/admin/login", + json={"username": "admin", "password": "secret-password"}, + ) + + self.assertEqual(200, login_response.status_code) + payload = login_response.json() + self.assertEqual("bearer", payload["token_type"]) + self.assertEqual("admin", payload["username"]) + self.assertIsInstance(payload["access_token"], str) + + me_response = client.get( + "/api/v1/auth/admin/me", + headers={"Authorization": f"Bearer {payload['access_token']}"}, + ) + + self.assertEqual(200, me_response.status_code) + self.assertEqual({"username": "admin"}, me_response.json()) + + def test_admin_login_rejects_wrong_password(self): + app = FastAPI() + app.include_router(create_api_router(include_legacy_sqlite_routes=False), prefix="/api/v1") + client = TestClient(app) + + response = client.post( + "/api/v1/auth/admin/login", + json={"username": "admin", "password": "wrong"}, + ) + + self.assertEqual(401, response.status_code) + + def test_admin_login_rejects_when_password_is_not_configured(self): + settings.ADMIN_PASSWORD = "" + app = FastAPI() + app.include_router(create_api_router(include_legacy_sqlite_routes=False), prefix="/api/v1") + client = TestClient(app) + + response = client.post( + "/api/v1/auth/admin/login", + json={"username": "admin", "password": "secret-password"}, + ) + + self.assertEqual(503, response.status_code) + + def test_require_admin_rejects_missing_and_accepts_valid_bearer_token(self): + app = FastAPI() + + @app.get("/private") + def private_route(_username: str = Depends(admin_auth.require_admin)): + return {"ok": True} + + client = TestClient(app) + + missing_response = client.get("/private") + self.assertEqual(401, missing_response.status_code) + + invalid_response = client.get("/private", headers={"Authorization": "Bearer not-a-token"}) + self.assertEqual(401, invalid_response.status_code) + + token = admin_auth.create_admin_token("admin") + valid_response = client.get("/private", headers={"Authorization": f"Bearer {token}"}) + self.assertEqual(200, valid_response.status_code) + self.assertEqual({"ok": True}, valid_response.json()) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_api_router_modes.py b/backend/tests/test_api_router_modes.py index d463bd9..e4fd861 100644 --- a/backend/tests/test_api_router_modes.py +++ b/backend/tests/test_api_router_modes.py @@ -10,6 +10,12 @@ def test_postgres_mode_exposes_health_without_legacy_sqlite_routes(self): self.assertIn("/health/health", paths) self.assertIn("/health/storage", paths) + self.assertIn("/auth/admin/login", paths) + self.assertIn("/auth/admin/me", paths) + self.assertIn("/market/overview", paths) + self.assertIn("/market/hot-concepts", paths) + self.assertIn("/data-hub/datasets", paths) + self.assertIn("/admin/task-status", paths) self.assertNotIn("/health/health/storage", paths) self.assertNotIn("/stocks/search", paths) self.assertNotIn("/database/tables", paths) @@ -19,6 +25,7 @@ def test_legacy_mode_keeps_existing_routers_available(self): paths = {route.path for route in router.routes} self.assertIn("/health/health", paths) + self.assertIn("/auth/admin/login", paths) self.assertIn("/stocks/search", paths) self.assertIn("/database/tables", paths) diff --git a/backend/tests/test_market_overview_fast_path.py b/backend/tests/test_market_overview_fast_path.py new file mode 100644 index 0000000..5d855e9 --- /dev/null +++ b/backend/tests/test_market_overview_fast_path.py @@ -0,0 +1,36 @@ +import unittest +from unittest.mock import patch + +from app.services.market_service import MarketService + + +class MarketOverviewFastPathTests(unittest.TestCase): + def test_market_overview_does_not_fetch_all_stocks_when_realtime_cache_is_empty(self): + indices = [ + { + "name": "上证指数", + "code": "sh000001", + "price": 3000.0, + "change_amount": 1.2, + "change_percent": 0.04, + } + ] + + with ( + patch("app.services.market_service.db.get_market_indices_realtime", return_value=[]), + patch("app.services.market_service.db.get_all_stocks_realtime", return_value=[]), + patch.object(MarketService, "_fetch_main_indices", return_value=indices), + patch.object(MarketService, "get_all_stocks") as get_all_stocks, + ): + overview = MarketService.get_market_overview() + + get_all_stocks.assert_not_called() + self.assertEqual(indices, overview["indices"]) + self.assertEqual( + {"score": 50.0, "status": "中性", "advancing": 0, "declining": 0, "unchanged": 0}, + overview["sentiment"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_market_service_eastmoney_fallback.py b/backend/tests/test_market_service_eastmoney_fallback.py new file mode 100644 index 0000000..a39a712 --- /dev/null +++ b/backend/tests/test_market_service_eastmoney_fallback.py @@ -0,0 +1,78 @@ +import unittest +from unittest.mock import patch + +from app.services.market_service import MarketService + + +class FakeEastMoneyResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +class MarketServiceEastMoneyFallbackTests(unittest.TestCase): + def setUp(self): + MarketService._get_cached_all_stocks.clear_cache() + + def tearDown(self): + MarketService._get_cached_all_stocks.clear_cache() + + def test_all_stocks_uses_direct_eastmoney_fallback_when_akshare_spot_fails(self): + payload = { + "data": { + "total": 1, + "diff": [ + { + "f2": 12.34, + "f3": 5.67, + "f5": 1000, + "f6": 1234000, + "f7": 8.9, + "f8": 1.23, + "f9": 18.5, + "f10": 1.1, + "f12": "600000", + "f14": "浦发银行", + "f20": 123000000, + "f21": 120000000, + "f23": 0.9, + } + ], + } + } + + with ( + patch("app.services.market_service.ak.stock_zh_a_spot_em", side_effect=RuntimeError("blocked")), + patch("app.services.market_service.ak.stock_hot_rank_em", side_effect=RuntimeError("blocked")), + patch("app.services.market_service.ak.stock_zh_a_spot", side_effect=RuntimeError("blocked")), + patch("requests.get", return_value=FakeEastMoneyResponse(payload)) as get, + ): + stocks = MarketService.get_all_stocks() + + self.assertEqual(1, len(stocks)) + self.assertEqual("600000", stocks[0]["code"]) + self.assertEqual("浦发银行", stocks[0]["name"]) + self.assertEqual(12.34, stocks[0]["price"]) + self.assertEqual(5.67, stocks[0]["change_percent"]) + get.assert_called() + + def test_all_stocks_does_not_call_slow_sina_spot_when_fast_sources_fail(self): + with ( + patch("app.services.market_service.ak.stock_zh_a_spot_em", side_effect=RuntimeError("blocked")), + patch("app.services.market_service._fetch_eastmoney_a_spot_direct", side_effect=RuntimeError("blocked")), + patch("app.services.market_service.ak.stock_hot_rank_em", side_effect=RuntimeError("blocked")), + patch("app.services.market_service.ak.stock_zh_a_spot") as slow_sina, + ): + stocks = MarketService.get_all_stocks() + + self.assertEqual([], stocks) + slow_sina.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 28bc9d8..9e8aacb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import React, { Suspense, lazy } from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import { RequireAdmin } from "./components/RequireAdmin"; import { TaskProgress } from "./components/TaskProgress"; import { ToastProvider } from "./components/Toast"; @@ -40,6 +41,14 @@ const MarketPulse = lazy(() => const LiveTrading = lazy(() => import("./pages/LiveTrading").then((m) => ({ default: m.LiveTrading })) ); +const DataProcessingAnalysis = lazy(() => + import("./pages/DataProcessingAnalysis").then((m) => ({ + default: m.DataProcessingAnalysis, + })) +); +const AdminLogin = lazy(() => + import("./pages/AdminLogin").then((m) => ({ default: m.AdminLogin })) +); const PageFallback: React.FC = () => (
Loading...
@@ -65,6 +74,15 @@ export default function App() { } /> } /> } /> + } /> + + + + } + /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a2d90ef..ca7c9d3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,6 +2,8 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; import { Stock, Sector, StockFilterResponse, AIAnalysis, DailyChartData, IntradayChartData, MarketSector, MarketStock, TaskStatus, HotConceptItem, ThsHotItem, LianbanLadderResponse, RunSentimentResponse, SentimentItem, AIStockAnalyzeResponse, ConceptIntradayKlineItem, ConceptLeaderStock, StockCandidate, StockFundamentals, MessageStreamResponse, MarketCalendarEvent, CalendarRefreshResponse, MarketOverview, Strategy, StrategyResult, StrategyExecutionResult, SaveStrategyRequest, StartStrategyRequest } from '../types'; const API_URL = import.meta.env.VITE_API_URL || '/api/v1'; +const ADMIN_TOKEN_STORAGE_KEY = 'stockpro_admin_token'; +export const ADMIN_AUTH_CHANGED_EVENT = 'stockpro_admin_auth_changed'; // Retry configuration const retryConfig = { @@ -17,6 +19,36 @@ export interface GenericApiResponse { [key: string]: unknown; } +export interface AdminLoginResponse { + access_token: string; + token_type: 'bearer'; + expires_in: number; + username: string; +} + +export interface AdminProfile { + username: string; +} + +export const getAdminToken = (): string | null => { + if (typeof window === 'undefined') return null; + return window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY); +}; + +export const setAdminToken = (token: string): void => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token); + window.dispatchEvent(new Event(ADMIN_AUTH_CHANGED_EVENT)); +}; + +export const clearAdminToken = (): void => { + if (typeof window === 'undefined') return; + window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); + window.dispatchEvent(new Event(ADMIN_AUTH_CHANGED_EVENT)); +}; + +export const hasAdminToken = (): boolean => Boolean(getAdminToken()); + export interface PresetTaskParam { name: string; type: string; @@ -242,10 +274,22 @@ export const apiClient = axios.create({ }, }); +apiClient.interceptors.request.use((config) => { + const token = getAdminToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + // Response interceptor for automatic retry with exponential backoff apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { + if (error.response?.status === 401) { + clearAdminToken(); + } + const config = error.config as RetryableRequestConfig | undefined; if (!config) { return Promise.reject(error); @@ -275,6 +319,17 @@ apiClient.interceptors.response.use( } ); +export const adminLogin = async (username: string, password: string): Promise => { + const response = await apiClient.post('/auth/admin/login', { username, password }); + setAdminToken(response.data.access_token); + return response.data; +}; + +export const getAdminProfile = async (): Promise => { + const response = await apiClient.get('/auth/admin/me'); + return response.data; +}; + export const getMarketOverview = async (): Promise => { const response = await apiClient.get('/market/overview'); return response.data; diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index bfbd783..613d197 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -44,6 +44,7 @@ export const Navigation: React.FC = ({ orientation = 'horizonta title: language === 'zh' ? '数据中台' : 'Data Hub', items: [ { id: 'dashboard', to: '/', label: language === 'zh' ? '总览看板' : 'Dashboard', Icon: LayoutDashboard }, + { id: 'admin-data', to: '/data', label: language === 'zh' ? '管理后台' : 'Admin', Icon: ShieldCheck }, ], }, { diff --git a/frontend/src/components/RequireAdmin.tsx b/frontend/src/components/RequireAdmin.tsx new file mode 100644 index 0000000..dadc855 --- /dev/null +++ b/frontend/src/components/RequireAdmin.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { clearAdminToken, getAdminProfile, hasAdminToken } from '../api/client'; +import { Loader2 } from 'lucide-react'; + +interface RequireAdminProps { + children: React.ReactNode; +} + +export const RequireAdmin: React.FC = ({ children }) => { + const location = useLocation(); + const [state, setState] = React.useState<'checking' | 'allowed' | 'denied'>( + hasAdminToken() ? 'checking' : 'denied' + ); + + React.useEffect(() => { + let cancelled = false; + + if (!hasAdminToken()) { + setState('denied'); + return; + } + + setState('checking'); + getAdminProfile() + .then(() => { + if (!cancelled) setState('allowed'); + }) + .catch(() => { + clearAdminToken(); + if (!cancelled) setState('denied'); + }); + + return () => { + cancelled = true; + }; + }, [location.pathname, location.search]); + + if (state === 'denied') { + const redirect = `${location.pathname}${location.search}`; + return ; + } + + if (state === 'checking') { + return ( +
+
+ + Checking admin session... +
+
+ ); + } + + return <>{children}; +}; diff --git a/frontend/src/components/TaskProgress.tsx b/frontend/src/components/TaskProgress.tsx index 3d6ba03..1b0d3ec 100644 --- a/frontend/src/components/TaskProgress.tsx +++ b/frontend/src/components/TaskProgress.tsx @@ -1,11 +1,27 @@ import React, { useState, useCallback } from 'react'; -import { getTaskStatus } from '../api/client'; +import { ADMIN_AUTH_CHANGED_EVENT, getTaskStatus, hasAdminToken } from '../api/client'; import { TaskStatus } from '../types'; import { Loader2, RefreshCw } from 'lucide-react'; import { usePolling } from '../hooks/usePolling'; export const TaskProgress: React.FC = () => { const [status, setStatus] = useState(null); + const [shouldPoll, setShouldPoll] = useState(hasAdminToken()); + + React.useEffect(() => { + const syncAdminState = () => { + const nextShouldPoll = hasAdminToken(); + setShouldPoll(nextShouldPoll); + if (!nextShouldPoll) setStatus(null); + }; + + window.addEventListener(ADMIN_AUTH_CHANGED_EVENT, syncAdminState); + window.addEventListener('storage', syncAdminState); + return () => { + window.removeEventListener(ADMIN_AUTH_CHANGED_EVENT, syncAdminState); + window.removeEventListener('storage', syncAdminState); + }; + }, []); const fetchStatus = useCallback(async () => { const data = await getTaskStatus(); @@ -14,7 +30,7 @@ export const TaskProgress: React.FC = () => { const { error, consecutiveErrors, manualRefresh } = usePolling({ fetchFn: fetchStatus, - shouldPoll: true, + shouldPoll, onSuccess: (data) => setStatus(data), onError: (err) => console.error("Failed to fetch task status", err), initialInterval: 2000, diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 0b9ed6b..d39d8e4 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -1,7 +1,144 @@ type TranslationMap = Record -const zhCN: TranslationMap = {} -const enUS: TranslationMap = {} +const zhCN: TranslationMap = { + 'common.billion': '亿', + 'common.failed': '操作失败', + 'common.loading': '加载中...', + 'common.more': '更多', + 'common.no_data_available': '暂无数据', + 'common.refresh': '刷新', + 'layout.loading_market': '加载市场数据...', + 'home.title': '实时大盘', + 'home.indices': '市场指数', + 'home.top_gainer': '强势股', + 'home.sentiment': '市场情绪', + 'home.volume_amount': '成交额', + 'home.alerts': '短线指标', + 'home.hot_sectors': '热门板块', + 'home.view_all': '查看全部', + 'market.title': '市场概览与分析', + 'market.hot_concepts': '热门概念板块', + 'market.ths_hot': '同花顺热榜', + 'market.lianban': '连板梯队', + 'market.select_date': '选择日期', + 'market.realtime': '实时行情', + 'market.rank': '排名', + 'market.concept': '概念', + 'market.change': '涨跌幅', + 'market.flow': '资金流', + 'market.intraday': '分时走势', + 'market.leaders': '龙头股', + 'market.loading_data': '加载数据', + 'market.no_data': '暂无数据', + 'market.constituents': '成分股', + 'market.select_concept': '选择概念', + 'market.code': '代码', + 'market.security': '证券', + 'market.price': '价格', + 'market.amount': '成交额', + 'market.turnover': '换手率', + 'market.no_analysis': '暂无分析', + 'market.yesterday': '昨日', + 'market.today': '今日', + 'ai.title': '智能选股', + 'chart.select_stock_hint': '请选择股票', + 'chart.intraday_trend': '分时趋势', + 'chart.daily_k': '日 K 线', + 'calendar.today': '今天', + 'calendar.load_failed': '加载日历失败', + 'calendar.refresh_failed': '刷新失败', + 'calendar.refresh_success': '刷新成功', + 'calendar.enter_dates': '请输入日期范围', + 'calendar.ai_generation_success': 'AI 生成成功', + 'calendar.ai_generation_failed': 'AI 生成失败', + 'calendar.events_count': '个事件', + 'calendar.calendar_view': '日历视图', + 'calendar.list_view': '列表视图', + 'calendar.near_two_weeks': '近两周', + 'calendar.this_month': '本月', + 'calendar.all_events': '全部事件', + 'calendar.ai_generate': 'AI 生成', + 'calendar.generating': '生成中...', + 'calendar.generate': '生成', + 'calendar.cancel': '取消', + 'calendar.loading': '加载中...', + 'calendar.no_events': '暂无事件', + 'calendar.category': '分类', + 'calendar.source': '来源', + 'calendar.details': '详情', + 'nav.market': '市场', + 'nav.news': '消息', +} + +const enUS: TranslationMap = { + 'common.billion': 'B', + 'common.failed': 'Failed', + 'common.loading': 'Loading...', + 'common.more': 'more', + 'common.no_data_available': 'No data', + 'common.refresh': 'Refresh', + 'layout.loading_market': 'Loading market...', + 'home.title': 'Dashboard', + 'home.indices': 'Market Indices', + 'home.top_gainer': 'Top Movers', + 'home.sentiment': 'Sentiment', + 'home.volume_amount': 'Turnover', + 'home.alerts': 'Short-term Signals', + 'home.hot_sectors': 'Hot Sectors', + 'home.view_all': 'View all', + 'market.title': 'Market Overview', + 'market.hot_concepts': 'Hot Concepts', + 'market.ths_hot': 'THS Hot', + 'market.lianban': 'Limit-up Ladder', + 'market.select_date': 'Select date', + 'market.realtime': 'Realtime', + 'market.rank': 'Rank', + 'market.concept': 'Concept', + 'market.change': 'Change', + 'market.flow': 'Flow', + 'market.intraday': 'Intraday', + 'market.leaders': 'Leaders', + 'market.loading_data': 'Loading data', + 'market.no_data': 'No data', + 'market.constituents': 'Constituents', + 'market.select_concept': 'Select concept', + 'market.code': 'Code', + 'market.security': 'Security', + 'market.price': 'Price', + 'market.amount': 'Amount', + 'market.turnover': 'Turnover', + 'market.no_analysis': 'No analysis', + 'market.yesterday': 'Yesterday', + 'market.today': 'Today', + 'ai.title': 'AI Screener', + 'chart.select_stock_hint': 'Select a stock', + 'chart.intraday_trend': 'Intraday Trend', + 'chart.daily_k': 'Daily K', + 'calendar.today': 'Today', + 'calendar.load_failed': 'Failed to load calendar', + 'calendar.refresh_failed': 'Refresh failed', + 'calendar.refresh_success': 'Refresh succeeded', + 'calendar.enter_dates': 'Enter dates', + 'calendar.ai_generation_success': 'AI generation succeeded', + 'calendar.ai_generation_failed': 'AI generation failed', + 'calendar.events_count': 'events', + 'calendar.calendar_view': 'Calendar view', + 'calendar.list_view': 'List view', + 'calendar.near_two_weeks': 'Next two weeks', + 'calendar.this_month': 'This month', + 'calendar.all_events': 'All events', + 'calendar.ai_generate': 'AI generate', + 'calendar.generating': 'Generating...', + 'calendar.generate': 'Generate', + 'calendar.cancel': 'Cancel', + 'calendar.loading': 'Loading...', + 'calendar.no_events': 'No events', + 'calendar.category': 'Category', + 'calendar.source': 'Source', + 'calendar.details': 'Details', + 'nav.market': 'Market', + 'nav.news': 'News', +} const dictionaries: Record = { zh: zhCN, diff --git a/frontend/src/pages/AdminLogin.tsx b/frontend/src/pages/AdminLogin.tsx new file mode 100644 index 0000000..cec005b --- /dev/null +++ b/frontend/src/pages/AdminLogin.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { AlertCircle, KeyRound, Loader2, LogIn, ShieldCheck, User } from 'lucide-react'; +import { adminLogin, clearAdminToken, getAdminProfile, hasAdminToken } from '../api/client'; +import { useStore } from '../stores/useStore'; + +const getRedirectTarget = (search: string): string => { + const redirect = new URLSearchParams(search).get('redirect') || '/data'; + if (!redirect.startsWith('/') || redirect.startsWith('//')) return '/data'; + if (redirect.startsWith('/admin-login')) return '/data'; + return redirect; +}; + +export const AdminLogin: React.FC = () => { + const { language } = useStore(); + const navigate = useNavigate(); + const location = useLocation(); + const redirectTarget = React.useMemo(() => getRedirectTarget(location.search), [location.search]); + + const [username, setUsername] = React.useState('admin'); + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(''); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [isCheckingSession, setIsCheckingSession] = React.useState(hasAdminToken()); + + React.useEffect(() => { + let cancelled = false; + + if (!hasAdminToken()) { + setIsCheckingSession(false); + return; + } + + getAdminProfile() + .then(() => { + if (!cancelled) navigate(redirectTarget, { replace: true }); + }) + .catch(() => { + clearAdminToken(); + if (!cancelled) setIsCheckingSession(false); + }); + + return () => { + cancelled = true; + }; + }, [navigate, redirectTarget]); + + const copy = { + title: language === 'zh' ? '管理员登录' : 'Admin Sign In', + subtitle: language === 'zh' ? 'StockPro 控制台' : 'StockPro Console', + username: language === 'zh' ? '账号' : 'Username', + password: language === 'zh' ? '密码' : 'Password', + submit: language === 'zh' ? '登录' : 'Sign in', + signingIn: language === 'zh' ? '登录中...' : 'Signing in...', + checking: language === 'zh' ? '正在校验会话...' : 'Checking session...', + invalid: language === 'zh' ? '账号或密码不正确' : 'Invalid username or password', + notConfigured: language === 'zh' ? '管理员密码尚未在服务器配置' : 'Admin password is not configured', + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setIsSubmitting(true); + + try { + await adminLogin(username.trim(), password); + navigate(redirectTarget, { replace: true }); + } catch (err: unknown) { + const status = typeof err === 'object' && err !== null && 'response' in err + ? (err as { response?: { status?: number } }).response?.status + : undefined; + setError(status === 503 ? copy.notConfigured : copy.invalid); + } finally { + setIsSubmitting(false); + } + }; + + if (isCheckingSession) { + return ( +
+
+ + {copy.checking} +
+
+ ); + } + + return ( +
+
+
+
+
+
+ +
+
+

{copy.subtitle}

+

{copy.title}

+
+
+
+ +
+ + + + + {error && ( +
+ + {error} +
+ )} + + +
+
+
+ ); +}; diff --git a/frontend/src/pages/DataProcessingAnalysis.tsx b/frontend/src/pages/DataProcessingAnalysis.tsx index d333b8c..1b5ba1a 100644 --- a/frontend/src/pages/DataProcessingAnalysis.tsx +++ b/frontend/src/pages/DataProcessingAnalysis.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { MainLayout } from '@/components/MainLayout'; import { useStore } from '@/stores/useStore'; +import { clearAdminToken } from '@/api/client'; +import { useNavigate } from 'react-router-dom'; import { Database, Workflow, @@ -11,6 +13,7 @@ import { FileCode2, Download, Package, + LogOut, } from 'lucide-react'; import { DataHubDatasetPanel } from '@/components/DataHubDatasetPanel'; import { DataHubJobsPanel } from '@/components/DataHubJobsPanel'; @@ -27,20 +30,34 @@ type LegacyTab = 'batchimport' | 'datadev' | 'database' | 'sql' | 'repair'; export const DataProcessingAnalysis: React.FC = () => { const { language } = useStore(); + const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('assets'); const [legacyTab, setLegacyTab] = useState('batchimport'); const title = language === 'zh' ? '数据中台' : 'Data Hub'; + const handleLogout = () => { + clearAdminToken(); + navigate('/admin-login', { replace: true }); + }; return (
-
-
Data Hub V1
-
- 当前以“数据资产 -> 生产任务 -> 质量治理 -> 特征服务”为主线。旧版入口保留在“兼容入口”页签。 +
+
+
Data Hub V1
+
+ 当前以“数据资产 -> 生产任务 -> 质量治理 -> 特征服务”为主线。旧版入口保留在“兼容入口”页签。 +
+
diff --git a/frontend/tests/e2e/app.spec.ts b/frontend/tests/e2e/app.spec.ts index b82675f..a618c83 100644 --- a/frontend/tests/e2e/app.spec.ts +++ b/frontend/tests/e2e/app.spec.ts @@ -124,6 +124,17 @@ async function mockApi(page: Page) { const method = request.method().toUpperCase(); const path = url.pathname.replace(/^\/api\/v1/, ''); + if (method === 'POST' && path === '/auth/admin/login') { + return route.fulfill( + json({ + access_token: 'mock-admin-token', + token_type: 'bearer', + expires_in: 3600, + username: 'admin', + }) + ); + } + if (method === 'GET' && path === '/auth/admin/me') return route.fulfill(json({ username: 'admin' })); if (method === 'GET' && path === '/market/overview') return route.fulfill(json(marketOverviewFixture)); if (method === 'GET' && path === '/admin/task-status') { return route.fulfill(json({ is_running: false, total: 0, processed: 0, message: '', task_id: null })); @@ -218,6 +229,51 @@ async function mockApi(page: Page) { if (method === 'GET' && path === '/batch-import/status') { return route.fulfill(json({ is_running: false, progress: 0, task_id: null, total: 0, processed: 0 })); } + if (method === 'GET' && path === '/data-hub/datasets') { + return route.fulfill( + json({ + status: 'success', + data: [ + { + id: 'stock_history', + name: '行情历史', + table: 'stock_history', + exists: true, + row_count: 1000, + fields: ['code', 'date', 'close'], + primary_keys: ['code', 'date'], + refresh_frequency: 'daily', + dependencies: [], + latest_snapshot: '2026-04-01', + freshness_status: 'green', + }, + ], + }) + ); + } + if (method === 'GET' && path === '/data-hub/datasets/stock_history/freshness') { + return route.fulfill( + json({ + status: 'success', + data: { + dataset: { + id: 'stock_history', + name: '行情历史', + table: 'stock_history', + exists: true, + row_count: 1000, + fields: ['code', 'date', 'close'], + primary_keys: ['code', 'date'], + refresh_frequency: 'daily', + dependencies: [], + latest_snapshot: '2026-04-01', + freshness_status: 'green', + }, + recent_jobs: [], + }, + }) + ); + } if (method === 'GET' && path === '/batch-import/ma-data/stats') { return route.fulfill(json({ success: true, stats: { stock_count: 10, record_count: 1000, start_date: '2026-01-01', end_date: '2026-04-01' } })); } @@ -290,6 +346,19 @@ test('所有页面路由可访问并完成基础渲染', async ({ page }) => { expect(pageErrors, pageErrors.join('\n')).toEqual([]); }); +test('管理员登录后可访问数据中台', async ({ page }) => { + await page.goto('/data', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('h1')).toContainText('管理员登录'); + + await page.getByLabel('账号').fill('admin'); + await page.getByLabel('密码').fill('secret-password'); + await page.getByRole('button', { name: '登录' }).click(); + + await expect(page.locator('header h2')).toContainText('数据中台', { timeout: 15000 }); + await expect(page.getByText('数据资产注册表')).toBeVisible(); + await expect(page.getByText('行情历史').first()).toBeVisible(); +}); + test('消息流页面可切换所有 tab', async ({ page }) => { await page.goto('/news?tab=abnormal'); await expect(page.getByText('触发异动')).toBeVisible(); diff --git a/frontend/tests/e2e/real-backend.spec.ts b/frontend/tests/e2e/real-backend.spec.ts index 0f45597..bc64001 100644 --- a/frontend/tests/e2e/real-backend.spec.ts +++ b/frontend/tests/e2e/real-backend.spec.ts @@ -137,7 +137,24 @@ test('DataDev 任务 CRUD + 运行 + 日志可用', async ({ request }) => { }); test('后台任务状态接口可访问', async ({ request }) => { - const resp = await request.get('/api/v1/admin/task-status'); + const adminPassword = process.env.E2E_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD; + test.skip(!adminPassword, 'Set E2E_ADMIN_PASSWORD or ADMIN_PASSWORD to test protected admin APIs.'); + + const loginResp = await request.post('/api/v1/auth/admin/login', { + data: { + username: process.env.E2E_ADMIN_USERNAME || process.env.ADMIN_USERNAME || 'admin', + password: adminPassword, + }, + }); + expect(loginResp.ok()).toBeTruthy(); + const loginData = (await loginResp.json()) as { access_token?: unknown }; + expect(typeof loginData.access_token).toBe('string'); + + const resp = await request.get('/api/v1/admin/task-status', { + headers: { + Authorization: `Bearer ${loginData.access_token}`, + }, + }); expect(resp.ok()).toBeTruthy(); const data = (await resp.json()) as {