{copy.subtitle}
+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 = () => (
{copy.subtitle}
+