feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权
This commit is contained in:
@@ -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
105
backend/app/auth.py
Normal 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
155
backend/app/auth_api.py
Normal 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
|
||||
1
backend/app/cli/__init__.py
Normal file
1
backend/app/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""服务器侧管理命令。"""
|
||||
79
backend/app/cli/create_user.py
Normal file
79
backend/app/cli/create_user.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user