Initial AlphaPulse implementation: news crawler and Binance klines

- Backend (FastAPI, SQLAlchemy 2, Alembic): single-admin cookie auth,
  news/crawler/market APIs with SSE and WebSocket streaming
- Crawler (Scrapy + Scrapy-Redis): wscn_news, wscn_live, cls_depth,
  cls_telegraph spiders, idempotent upserts with revisions, raw response
  archiving, per-source subprocess supervision, restart and backoff
- Market worker: full-history Binance spot/USD-M kline backfill,
  realtime WS stream, hourly and on-reconnect catch-up
- Frontend (Vue 3, Element Plus, UnoCSS, KLineChart): news feed,
  crawler console with live logs, Binance-style chart page
- Deploy: docker compose, nginx site, migrate and daily backup scripts
This commit is contained in:
eezd committed 2026-10-13 13:32:55 +08:00
commit aabc81ba5b
88 files changed
+12603

No files matched your search

Whitespace-only changes.
+59
View File
@@ -0,0 +1,59 @@
import getpass
import sys
from datetime import UTC, datetime, timedelta
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import HTTPException, Request, WebSocket, status
from alphapulse.core.config import get_settings
COOKIE_NAME = "ap_token"
_hasher = PasswordHasher()
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
def create_token(username: str) -> str:
settings = get_settings()
expire = datetime.now(UTC) + timedelta(days=settings.jwt_expire_days)
return jwt.encode({"sub": username, "exp": expire}, settings.jwt_secret, algorithm="HS256")
def _username_from_token(token: str | None) -> str | None:
if not token:
return None
try:
payload = jwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None
return payload["sub"]
def require_admin(request: Request) -> str:
username = _username_from_token(request.cookies.get(COOKIE_NAME))
if username != get_settings().admin_username:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未登录或登录已过期")
return username
def websocket_admin(websocket: WebSocket) -> str | None:
# WebSocket 握手阶段不能用 HTTPException,由调用方自行关闭连接
username = _username_from_token(websocket.cookies.get(COOKIE_NAME))
return username if username == get_settings().admin_username else None
if __name__ == "__main__":
# 用法:python -m alphapulse.core.auth hash-password,输出写入 .env 的 ADMIN_PASSWORD_HASH
if sys.argv[1:] != ["hash-password"]:
sys.exit("用法:python -m alphapulse.core.auth hash-password")
password = getpass.getpass("新密码:")
if password != getpass.getpass("再次输入:"):
sys.exit("两次输入不一致")
print(_hasher.hash(password))
+20
View File
@@ -0,0 +1,20 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# 容器内由 compose 的 env_file 注入;只有一套配置,见仓库根目录 .env
model_config = SettingsConfigDict(extra="ignore")
database_url: str
redis_url: str
jwt_secret: str
admin_username: str
admin_password_hash: str = ""
jwt_expire_days: int = 7
@lru_cache
def get_settings() -> Settings:
return Settings()
+19
View File
@@ -0,0 +1,19 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from alphapulse.core.config import get_settings
class Base(DeclarativeBase):
pass
engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
+8
View File
@@ -0,0 +1,8 @@
from redis.asyncio import Redis
from alphapulse.core.config import get_settings
# DB 14 与 AFFiNE 共用:所有 key 必须带此前缀,禁止 FLUSHDB
KEY_PREFIX = "ap:"
redis = Redis.from_url(get_settings().redis_url, decode_responses=True)