feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权

This commit is contained in:
2026-08-14 22:37:18 +08:00
parent 0f8b9a7255
commit 4c2ea5521d
47 changed files with 2937 additions and 893 deletions

View File

@@ -17,6 +17,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .backtest.engine import BacktestConfig, run_backtest
from .auth import require_user
from .backtest.strategies import build_strategy
from .config import settings
from .data import fetcher, repository
@@ -48,7 +49,7 @@ from .screener import engine, market_sync
from .screener.engine import DataNotReadyError
from .screener.llm import ScreenerError, parse_conditions
router = APIRouter(prefix="/api")
router = APIRouter(prefix="/api", dependencies=[Depends(require_user)])
def _series_to_jsonable(s: pd.Series) -> list[float | None]:
@@ -66,11 +67,6 @@ def _rows_to_bars(rows) -> list[Bar]:
return [Bar(ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume) for r in rows]
@router.get("/health")
async def health() -> dict:
return {"status": "ok"}
@router.get("/candles/{symbol}", response_model=list[CandleOut])
async def get_candles(
symbol: str,

105
backend/app/auth.py Normal file
View File

@@ -0,0 +1,105 @@
"""密码校验、数据库会话与 FastAPI 鉴权依赖。"""
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import Cookie, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from .config import settings
from .db import get_session
from .models import AuthSession, User
password_hasher = PasswordHasher(
time_cost=2,
memory_cost=19_456,
parallelism=1,
hash_len=32,
salt_len=16,
)
# 不存在的用户名也执行一次 Argon2降低用户名枚举与计时攻击差异。
_DUMMY_HASH = password_hasher.hash("not-a-real-password")
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def hash_password(password: str) -> str:
return password_hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return password_hasher.verify(password_hash, password)
except (VerificationError, InvalidHashError):
return False
def verify_dummy_password(password: str) -> None:
verify_password(_DUMMY_HASH, password)
def new_session_token() -> str:
return secrets.token_urlsafe(48)
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def session_expiry() -> datetime:
return utcnow() + timedelta(hours=settings.auth_session_hours)
def unauthorized(detail: str = "登录状态无效或已过期") -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Session"},
)
async def get_auth_session(
token: str,
db: AsyncSession,
) -> AuthSession | None:
now = utcnow()
stmt = (
select(AuthSession)
.options(selectinload(AuthSession.user))
.where(
AuthSession.token_hash == token_digest(token),
AuthSession.revoked_at.is_(None),
AuthSession.expires_at > now,
)
)
auth_session = (await db.execute(stmt)).scalar_one_or_none()
if auth_session is None or not auth_session.user.is_active:
return None
# 避免每个 API 请求都写数据库;最多每 5 分钟刷新一次活动时间。
if auth_session.last_seen_at < now - timedelta(minutes=5):
auth_session.last_seen_at = now
await db.commit()
return auth_session
async def require_user(
stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name),
db: AsyncSession = Depends(get_session),
) -> User:
if not stock_session:
raise unauthorized()
auth_session = await get_auth_session(stock_session, db)
if auth_session is None:
raise unauthorized()
return auth_session.user

155
backend/app/auth_api.py Normal file
View File

@@ -0,0 +1,155 @@
"""只登录、不注册的鉴权 API。"""
from __future__ import annotations
from datetime import timedelta
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from .auth import (
get_auth_session,
new_session_token,
require_user,
session_expiry,
token_digest,
utcnow,
verify_dummy_password,
verify_password,
)
from .config import settings
from .db import get_session
from .models import AuthSession, User
from .schemas import CurrentUserOut, LoginRequest, LoginResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
def set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=settings.auth_cookie_name,
value=token,
max_age=settings.auth_session_hours * 60 * 60,
path="/api",
secure=settings.auth_cookie_secure,
httponly=True,
samesite="strict",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(
key=settings.auth_cookie_name,
path="/api",
secure=settings.auth_cookie_secure,
httponly=True,
samesite="strict",
)
@router.post("/login", response_model=LoginResponse)
async def login(
payload: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_session),
) -> LoginResponse:
now = utcnow()
username = payload.username.strip()
user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()
if user is None:
verify_dummy_password(payload.password)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if not user.is_active:
verify_dummy_password(payload.password)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
if user.locked_until is not None and user.locked_until > now:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail=f"登录失败次数过多,请在 {user.locked_until.isoformat()} 后重试",
)
if not verify_password(user.password_hash, payload.password):
user.failed_login_count += 1
if user.failed_login_count >= settings.auth_max_failed_logins:
user.failed_login_count = 0
user.locked_until = now + timedelta(minutes=settings.auth_lock_minutes)
user.updated_at = now
await db.commit()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = now
user.updated_at = now
# 登录时顺便清理过期或已吊销会话,避免单用户长期运行累积垃圾数据。
await db.execute(
delete(AuthSession).where(
(AuthSession.expires_at <= now) | (AuthSession.revoked_at.is_not(None))
)
)
token = new_session_token()
expires_at = session_expiry()
db.add(
AuthSession(
user_id=user.id,
token_hash=token_digest(token),
created_at=now,
expires_at=expires_at,
last_seen_at=now,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", "")[:512] or None,
)
)
await db.commit()
set_session_cookie(response, token)
response.headers["Cache-Control"] = "no-store"
return LoginResponse(user=CurrentUserOut.model_validate(user), expires_at=expires_at)
@router.get("/me", response_model=CurrentUserOut)
async def me(user: User = Depends(require_user)) -> CurrentUserOut:
return CurrentUserOut.model_validate(user)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
response: Response,
stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name),
db: AsyncSession = Depends(get_session),
) -> Response:
if stock_session:
await db.execute(
update(AuthSession)
.where(AuthSession.token_hash == token_digest(stock_session))
.values(revoked_at=utcnow())
)
await db.commit()
clear_session_cookie(response)
response.status_code = status.HTTP_204_NO_CONTENT
response.headers["Cache-Control"] = "no-store"
return response
@router.post("/logout-all", status_code=status.HTTP_204_NO_CONTENT)
async def logout_all(
response: Response,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_session),
) -> Response:
await db.execute(
update(AuthSession)
.where(AuthSession.user_id == user.id, AuthSession.revoked_at.is_(None))
.values(revoked_at=utcnow())
)
await db.commit()
clear_session_cookie(response)
response.status_code = status.HTTP_204_NO_CONTENT
response.headers["Cache-Control"] = "no-store"
return response

View File

@@ -0,0 +1 @@
"""服务器侧管理命令。"""

View File

@@ -0,0 +1,79 @@
"""创建唯一后台用户或重置其密码,不提供 HTTP 注册入口。"""
from __future__ import annotations
import argparse
import asyncio
import getpass
from datetime import datetime, timezone
from sqlalchemy import delete, select
from app.auth import hash_password
from app.config import settings
from app.db import async_session, engine
from app.models import AuthSession, User
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="创建后台用户或重置密码")
parser.add_argument("--username", default="admin", help="登录用户名,默认 admin")
return parser.parse_args()
async def upsert_user(username: str, password: str) -> str:
now = datetime.now(timezone.utc)
async with async_session() as db:
user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()
if user is None:
user = User(
username=username,
password_hash=hash_password(password),
password_algo="argon2id",
is_active=True,
failed_login_count=0,
password_changed_at=now,
created_at=now,
updated_at=now,
)
db.add(user)
action = "created"
else:
user.password_hash = hash_password(password)
user.password_algo = "argon2id"
user.is_active = True
user.failed_login_count = 0
user.locked_until = None
user.password_changed_at = now
user.updated_at = now
await db.execute(delete(AuthSession).where(AuthSession.user_id == user.id))
action = "updated"
await db.commit()
return action
async def async_main() -> None:
args = parse_args()
username = args.username.strip()
if not username or len(username) > 64:
raise SystemExit("用户名长度必须为 1-64 个字符")
password = getpass.getpass("Password: ")
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
raise SystemExit("两次密码输入不一致")
if len(password) < settings.auth_min_password_length:
raise SystemExit(f"密码至少需要 {settings.auth_min_password_length} 个字符")
try:
action = await upsert_user(username, password)
print(f"User {username!r} {action}. All previous sessions were revoked.")
finally:
await engine.dispose()
def main() -> None:
asyncio.run(async_main())
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,5 @@
"""应用配置pydantic-settings。可由 .env / 环境变量覆盖。"""
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -7,8 +8,18 @@ class Settings(BaseSettings):
app_name: str = "Stock Backtest"
# 默认 SQLite 零配置;切 Postgres/TimescaleDB 只改这一行
database_url: str = "sqlite+aiosqlite:///./stock.db"
# 开发、测试、生产统一使用 PostgreSQL避免不同数据库行为产生偏差。
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/stock"
# ---- 鉴权 ----
auth_cookie_name: str = "stock_session"
auth_cookie_secure: bool = False # 生产 HTTPS 必须覆盖为 true
auth_session_hours: int = 12
auth_max_failed_logins: int = 5
auth_lock_minutes: int = 15
auth_min_password_length: int = 12
cors_origins: str = "http://localhost:5173"
expose_api_docs: bool = True
# 真实数据源
tushare_token: str = "" # Tushare Pro token主数据源
@@ -33,5 +44,17 @@ class Settings(BaseSettings):
commission_min: float = 5.0 # 最低 5 元
slippage_rate: float = 0.0005 # 滑点近似(按价格比例)
@model_validator(mode="after")
def require_postgresql(self) -> "Settings":
if not self.database_url.startswith("postgresql+asyncpg://"):
raise ValueError("DATABASE_URL 必须使用 postgresql+asyncpg://,本项目不再支持 SQLite")
if self.auth_session_hours <= 0:
raise ValueError("AUTH_SESSION_HOURS 必须大于 0")
return self
@property
def allowed_origins(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
settings = Settings()

View File

@@ -1,22 +1,22 @@
"""FastAPI 入口。
启动时自动建表MVP 用 create_all阶段1 切 Alembic 迁移,含 TimescaleDB hypertable
"""
"""FastAPI 入口。数据库结构统一由 Alembic 管理。"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .api import router
from .db import Base, engine
from . import models # noqa: F401 —— 注册 ORM 到 Base.metadata
from .auth_api import router as auth_router
from .config import settings
from .db import engine
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
yield
await engine.dispose()
app = FastAPI(
@@ -24,20 +24,28 @@ app = FastAPI(
description="历史回测 + 回放式模拟平台A 股为主,不做实盘)",
version="0.1.0",
lifespan=lifespan,
docs_url="/docs" if settings.expose_api_docs else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.expose_api_docs else None,
)
# 开发期允许前端 dev server 跨域;上线收窄 origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(router)
@app.get("/api/health")
async def health() -> dict:
return {"status": "ok"}
@app.get("/")
async def root() -> dict:
return {"name": "Stock Backtest API", "docs": "/docs"}

View File

@@ -9,8 +9,8 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
@@ -130,3 +130,45 @@ class TradeCalendar(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trade_date: Mapped[str] = mapped_column(String(8), unique=True, index=True) # YYYYMMDD
class User(Base):
"""后台登录用户。系统不提供注册接口,只能通过服务器命令创建或改密。"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
password_algo: Mapped[str] = mapped_column(String(16), default="argon2id")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
password_changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
sessions: Mapped[list["AuthSession"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class AuthSession(Base):
"""服务端会话。数据库只保存随机 Token 的 SHA-256 摘要。"""
__tablename__ = "auth_sessions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
ip_address: Mapped[str | None] = mapped_column(String(45))
user_agent: Mapped[str | None] = mapped_column(String(512))
user: Mapped[User] = relationship(back_populates="sessions")

View File

@@ -202,3 +202,21 @@ class PreviewResponse(BaseModel):
candles: list[CandleOut]
indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict)
# indicators 形如 {"ma": {"ma5": [...], ...}, "macd": {"dif": ...}, "kdj": {...}, "rsi": {...}, "boll": {...}}
# ---------- Auth ----------
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=1024)
class CurrentUserOut(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class LoginResponse(BaseModel):
user: CurrentUserOut
expires_at: datetime