Align backend authentication and API response contracts

Replace cookie authentication with revocable Bearer sessions, optional one-use PNG challenges, bounded login limits, and short-lived market WebSocket tickets. Keep captcha disabled by default.

Standardize ordinary JSON responses while preserving HTTP statuses, raw OpenAPI, redirects, SSE, and WebSocket semantics. Revoke long connections with their owning session.

Expose official calendar data and collector status to the frontend and research tools, support bilingual news topics, and keep summaries and forward-only labels explicit. Verify the staged snapshot with all 305 backend tests.
This commit is contained in:
eezd committed 2026-10-26 12:04:21 +08:00
1 parent b9d7497a7d
commit f67a8ca402
24 files changed
+1731 -212

No files matched your search

+123 -33
View File
@@ -1,58 +1,148 @@
from typing import Annotated
import base64
import hashlib
import hmac
import io
import secrets
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Request
from PIL import Image, ImageDraw, ImageFont
from pydantic import BaseModel, Field
from redis.exceptions import RedisError
from alphapulse.core.auth import COOKIE_NAME, create_token, require_admin, verify_password
from alphapulse.api.response import ApiResponse, success
from alphapulse.core.auth import (
AUTH_PREFIX, AuthSession, create_token, create_ws_ticket, require_admin,
require_session, revoke_session, unavailable, verify_password,
)
from alphapulse.core.config import get_settings
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.core.redis import redis
router = APIRouter(prefix="/api/auth", tags=["auth"])
MAX_FAILURES = 5
LOCK_SECONDS = 15 * 60
CAPTCHA_SECONDS = 120
CAPTCHA_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
# INCR 与首次设置过期必须是同一个原子操作。
COUNT_SCRIPT = (
"local n=redis.call('INCR',KEYS[1]); "
"if n==1 or n==tonumber(ARGV[2]) then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return n"
)
class LoginIn(BaseModel):
username: str
password: str
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=1024)
captcha_id: str | None = Field(default=None, min_length=1, max_length=128)
code: str | None = Field(default=None, min_length=1, max_length=32)
class TokenOut(BaseModel):
token: str
token_type: Literal["bearer"] = "bearer"
expires_at: datetime
class MeOut(BaseModel):
username: str
is_admin: bool
class CaptchaOut(BaseModel):
enabled: Literal[True] = True
captcha_id: str
image: str
expires_in: int = CAPTCHA_SECONDS
class CaptchaDisabled(BaseModel):
enabled: Literal[False] = False
class TicketOut(BaseModel):
ticket: str
expires_in: int = 30
def captcha_digest(captcha_id: str, code: str) -> str:
return hmac.new(get_settings().jwt_secret.encode(), f"{captcha_id}:{code.upper()}".encode(), hashlib.sha256).hexdigest()
def captcha_image(code: str) -> str:
image = Image.new("RGB", (180, 60), "#f1f5f9")
draw = ImageDraw.Draw(image)
for _ in range(8):
draw.line([(secrets.randbelow(180), secrets.randbelow(60)) for _ in range(2)], fill="#94a3b8", width=1)
font = ImageFont.load_default(size=32)
for index, letter in enumerate(code):
glyph = Image.new("RGBA", (36, 48))
ImageDraw.Draw(glyph).text((4, 3), letter, font=font, fill="#0f172a")
glyph = glyph.rotate(secrets.randbelow(25) - 12, resample=Image.Resampling.BICUBIC)
image.paste(glyph, (index * 34 + 5, secrets.randbelow(7) + 3), glyph)
for _ in range(100):
draw.point((secrets.randbelow(180), secrets.randbelow(60)), fill="#64748b")
output = io.BytesIO()
image.save(output, format="PNG")
return "data:image/png;base64," + base64.b64encode(output.getvalue()).decode()
def too_many(seconds: int) -> HTTPException:
return HTTPException(429, "请求过于频繁,请稍后再试", headers={"Retry-After": str(max(seconds, 1))})
@router.get("/captcha")
async def captcha(request: Request) -> ApiResponse[CaptchaOut | CaptchaDisabled]:
if not get_settings().login_captcha_enabled:
return success(CaptchaDisabled())
key = f"{AUTH_PREFIX}captcha-rate:{request.client.host}"
try:
if await redis.eval(COUNT_SCRIPT, 1, key, 60, 0) > 30:
raise too_many(await redis.ttl(key))
captcha_id = secrets.token_urlsafe(32)
code = "".join(secrets.choice(CAPTCHA_ALPHABET) for _ in range(5))
await redis.set(f"{AUTH_PREFIX}captcha:{captcha_id}", captcha_digest(captcha_id, code), ex=CAPTCHA_SECONDS)
except RedisError as error:
raise unavailable() from error
return success(CaptchaOut(captcha_id=captcha_id, image=captcha_image(code)))
@router.post("/login")
async def login(body: LoginIn, request: Request, response: Response) -> MeOut:
async def login(body: LoginIn, request: Request) -> ApiResponse[TokenOut]:
settings = get_settings()
if not settings.admin_password_hash:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "管理员密码未配置")
fail_key = f"{KEY_PREFIX}login:fail:{request.client.host}"
if int(await redis.get(fail_key) or 0) >= MAX_FAILURES:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "失败次数过多,请 15 分钟后再试")
if body.username != settings.admin_username or not verify_password(body.password, settings.admin_password_hash):
async with redis.pipeline() as pipe:
await pipe.incr(fail_key).expire(fail_key, LOCK_SECONDS).execute()
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "用户名或密码错误")
await redis.delete(fail_key)
response.set_cookie(
COOKIE_NAME,
create_token(body.username),
max_age=settings.jwt_expire_days * 86400,
httponly=True,
secure=True,
samesite="strict",
)
return MeOut(username=body.username)
raise HTTPException(503, "管理员密码未配置")
fail_key = f"{AUTH_PREFIX}login-fail:{request.client.host}"
try:
if int(await redis.get(fail_key) or 0) >= MAX_FAILURES:
raise too_many(await redis.ttl(fail_key))
if settings.login_captcha_enabled:
digest = await redis.getdel(f"{AUTH_PREFIX}captcha:{body.captcha_id}") if body.captcha_id else None
valid_captcha = digest is not None and body.code is not None and hmac.compare_digest(digest, captcha_digest(body.captcha_id, body.code))
if not valid_captcha:
await redis.eval(COUNT_SCRIPT, 1, fail_key, LOCK_SECONDS, MAX_FAILURES)
raise HTTPException(400, "验证码错误、已过期或已使用,请刷新")
if body.username != settings.admin_username or not verify_password(body.password, settings.admin_password_hash):
await redis.eval(COUNT_SCRIPT, 1, fail_key, LOCK_SECONDS, MAX_FAILURES)
raise HTTPException(401, "用户名或密码错误", headers={"WWW-Authenticate": "Bearer"})
token, expires_at = await create_token(body.username)
await redis.delete(fail_key)
except RedisError as error:
raise unavailable() from error
return success(TokenOut(token=token, expires_at=expires_at))
@router.post("/logout")
async def logout(response: Response) -> None:
response.delete_cookie(COOKIE_NAME, httponly=True, secure=True, samesite="strict")
async def logout(session: Annotated[AuthSession, Depends(require_session)]) -> ApiResponse[None]:
await revoke_session(session)
return success(None)
@router.get("/me")
async def me(username: Annotated[str, Depends(require_admin)]) -> MeOut:
return MeOut(username=username)
async def me(session: Annotated[AuthSession, Depends(require_session)]) -> ApiResponse[MeOut]:
return success(MeOut(username=session.username, is_admin=session.username == get_settings().admin_username))
@router.post("/ws-ticket")
async def ws_ticket(session: Annotated[AuthSession, Depends(require_admin)]) -> ApiResponse[TicketOut]:
return success(TicketOut(ticket=await create_ws_ticket(session)))
+80
View File
@@ -0,0 +1,80 @@
import json
from datetime import UTC, date, datetime, time, timedelta
from typing import Annotated
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.api.response import ApiResponse, success
from alphapulse.calendar.catalog import COUNTRIES, FAMILIES
from alphapulse.calendar.service import indicator_history, release_rows
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.models import EconomicRelease, EconomicRevision, EconomicSeriesValue, RawResponse
router = APIRouter(prefix="/api/calendar", tags=["calendar"], dependencies=[Depends(require_admin)])
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
async def calendar_data(session: AsyncSession, start: datetime, end: datetime) -> dict:
rows = list(await session.scalars(select(EconomicRelease).where(
EconomicRelease.scheduled_at >= start, EconomicRelease.scheduled_at < end,
).order_by(EconomicRelease.scheduled_at, EconomicRelease.id)))
raw = await redis.get(f"{KEY_PREFIX}calendar:status")
status = json.loads(raw) if raw else None
now = datetime.now(UTC)
return {"items": [item for row in rows for item in release_rows(row, now)], "worker": status,
"server_time": now, "timezone": "Asia/Shanghai",
"countries": [{"code": code, "name": name} for code, name in COUNTRIES.items()],
"forecast_available": any(row.values.get(key, {}).get("forecast") is not None
for row in rows for key in FAMILIES[row.family].indicators)}
@router.get("")
async def list_calendar(session: Annotated[AsyncSession, Depends(get_session)],
start: date, days: Annotated[int, Query(ge=1, le=31)] = 7) -> ApiResponse[dict]:
begin = datetime.combine(start, time.min, LOCAL_TZ)
return success(await calendar_data(session, begin, begin + timedelta(days=days)))
@router.get("/{release_id}/revisions")
async def revisions(release_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[dict]]:
if await session.get(EconomicRelease, release_id) is None:
raise HTTPException(404, "日历事件不存在")
rows = await session.scalars(select(EconomicRevision).where(EconomicRevision.release_id == release_id)
.order_by(EconomicRevision.revision_no.desc()))
return success([{"revision_no": r.revision_no, "captured_at": r.captured_at,
"payload": r.payload, "raw_response_id": r.raw_response_id} for r in rows])
@router.get("/{release_id}/history")
async def history(release_id: int, indicator: str, session: Annotated[AsyncSession, Depends(get_session)],
limit: Annotated[int, Query(ge=1, le=120)] = 36) -> ApiResponse[list[dict]]:
row = await session.get(EconomicRelease, release_id)
if row is None:
raise HTTPException(404, "日历事件不存在")
if indicator not in FAMILIES[row.family].indicators:
raise HTTPException(400, "指标不属于该事件")
return success(await indicator_history(session, row, indicator, limit))
@router.get("/{release_id}/series")
async def series(release_id: int, indicator: str, session: Annotated[AsyncSession, Depends(get_session)],
limit: Annotated[int, Query(ge=1, le=120)] = 36) -> ApiResponse[list[dict]]:
row = await session.get(EconomicRelease, release_id)
if row is None:
raise HTTPException(404, "日历事件不存在")
if indicator not in FAMILIES[row.family].indicators:
raise HTTPException(400, "指标不属于该事件")
if row.period is None:
return success([])
# 此接口是当前官方序列,采集时间可以晚于所选事件;不得用作历史时点回测输入。
rows = (await session.execute(select(EconomicSeriesValue, RawResponse.url).join(
RawResponse, EconomicSeriesValue.raw_response_id == RawResponse.id,
).where(EconomicSeriesValue.source == row.source, EconomicSeriesValue.indicator == indicator,
EconomicSeriesValue.period <= row.period).order_by(EconomicSeriesValue.period.desc()).limit(limit))).all()
return success([{"period": value.period, "stage": value.stage, "value": value.value,
"captured_at": value.captured_at, "source_url": url} for value, url in rows])
+21 -13
View File
@@ -14,10 +14,12 @@ from pydantic import BaseModel, Field
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.api.response import ApiResponse, success
from alphapulse.ai import llm
from alphapulse.ai.prompt import build_system_prompt, current_context, estimate_tokens
from alphapulse.ai.tools import ToolOutcome, run_tool, tool_specs
from alphapulse.core.auth import require_admin
from alphapulse.core.auth import AuthSession, require_admin
from alphapulse.core.streams import session_events
from alphapulse.core.db import SessionLocal, get_session
from alphapulse.models import AiProvider, ChatMessage, ChatSession, ContextVersion
@@ -108,23 +110,28 @@ async def save_shielded(*messages: ChatMessage) -> None:
@router.get("/session")
async def get_chat_session(session: Annotated[AsyncSession, Depends(get_session)]) -> SessionOut:
async def get_chat_session(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[SessionOut]:
# 先取生成状态再读消息:反过来的话,读消息后生成恰好结束,会返回「未在生成」却缺最后一条
is_generating = generating.locked()
current = await active_session(session)
messages = await session_messages(session, current.id)
system = await build_system_prompt(session)
history = json.dumps([neutral(m) for m in messages], ensure_ascii=False)
return SessionOut(
return success(SessionOut(
id=current.id, created_at=current.created_at,
messages=[MessageOut.model_validate(m, from_attributes=True) for m in messages],
estimated_tokens=estimate_tokens(system) + estimate_tokens(history),
generating=is_generating,
)
))
@router.post("/messages", response_class=EventSourceResponse)
async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
async def send_message(body: SendIn, principal: Annotated[AuthSession, Depends(require_admin)]) -> AsyncIterator[ServerSentEvent]:
async for item in session_events(_message_events(body), principal):
yield item
async def _message_events(body: SendIn) -> AsyncIterator[ServerSentEvent]:
"""事件:prompt、thinking、delta、tool_call、tool_result、skill_draft、context_updated、usage、error、done"""
if generating.locked():
yield event("error", {"message": "正在生成或重置会话,请等待完成"})
@@ -230,7 +237,7 @@ async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
@router.post("/reset")
async def reset(body: ResetIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def reset(body: ResetIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
"""结束当前会话(消息保留在库里,不再展示),可选同时清空 CONTEXT.md(写入空版本,可找回)"""
if generating.locked():
raise HTTPException(status.HTTP_409_CONFLICT, "正在生成或重置会话,请稍后重试")
@@ -239,26 +246,27 @@ async def reset(body: ResetIn, session: Annotated[AsyncSession, Depends(get_sess
if body.clear_context:
session.add(ContextVersion(content="", source="reset"))
await session.commit()
return success(None)
@router.get("/context")
async def get_context(session: Annotated[AsyncSession, Depends(get_session)]) -> ContextOut:
async def get_context(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ContextOut]:
current = await current_context(session)
if current is None:
return ContextOut(id=None, content="", source=None, created_at=None)
return ContextOut.model_validate(current, from_attributes=True)
return success(ContextOut(id=None, content="", source=None, created_at=None))
return success(ContextOut.model_validate(current, from_attributes=True))
@router.put("/context")
async def put_context(body: ContextIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ContextOut:
async def put_context(body: ContextIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ContextOut]:
version = ContextVersion(content=body.content, source="user")
session.add(version)
await session.commit()
await session.refresh(version)
return ContextOut.model_validate(version, from_attributes=True)
return success(ContextOut.model_validate(version, from_attributes=True))
@router.get("/context/versions")
async def context_versions(session: Annotated[AsyncSession, Depends(get_session)]) -> list[ContextOut]:
async def context_versions(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[ContextOut]]:
rows = await session.scalars(select(ContextVersion).order_by(ContextVersion.id.desc()).limit(100))
return [ContextOut.model_validate(v, from_attributes=True) for v in rows]
return success([ContextOut.model_validate(v, from_attributes=True) for v in rows])
+93 -18
View File
@@ -1,7 +1,7 @@
import json
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from datetime import UTC, datetime, timedelta
from typing import Annotated, Literal
from zoneinfo import ZoneInfo
@@ -11,11 +11,15 @@ from pydantic import AwareDatetime, BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.core.auth import require_admin
from alphapulse.api.response import ApiResponse, success
from alphapulse.core.auth import AuthSession, require_admin
from alphapulse.core.streams import session_events
from alphapulse.core.db import get_session
from alphapulse.core.redis import redis
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.core.official_sources import REGIONS
from alphapulse.calendar.catalog import FAMILIES
from alphapulse.crawler.events import CONTROL_CHANNEL, HISTORY_SOURCES, LOG_CHANNEL, STATUS_KEY
from alphapulse.models import CrawlLog, CrawlRun, NewsItem, Source
from alphapulse.models import CrawlLog, CrawlRun, EconomicRelease, NewsItem, Source
router = APIRouter(prefix="/api/crawler", tags=["crawler"], dependencies=[Depends(require_admin)])
@@ -33,6 +37,7 @@ class ProcessStatus(BaseModel):
class SourceOut(BaseModel):
code: str
name: str
region_code: str | None
list_url: str
enabled: bool
interval_seconds: int
@@ -52,10 +57,27 @@ class SourceOut(BaseModel):
total_items: int
class CalendarSourceOut(BaseModel):
code: str
name: str
region_code: str
worker_alive: bool
heartbeat_at: datetime | None
schedule_checked_at: datetime | None
schedule_success_at: datetime | None
latest_result_at: datetime | None
next_event_at: datetime | None
overdue_count: int
history_start: datetime | None
history_end: datetime | None
errors: list[str]
class SourcesOut(BaseModel):
manager_alive: bool
db_size_bytes: int
sources: list[SourceOut]
calendar_sources: list[CalendarSourceOut]
class SourcePatch(BaseModel):
@@ -93,9 +115,51 @@ class RestartIn(BaseModel):
source: str
async def calendar_sources(session: AsyncSession) -> list[CalendarSourceOut]:
now = datetime.now(UTC)
raw = await redis.get(f"{KEY_PREFIX}calendar:status")
heartbeat = json.loads(raw) if raw else None
alive = bool(heartbeat and now - datetime.fromisoformat(heartbeat["at"]) < timedelta(seconds=30))
pending = EconomicRelease.first_received_at.is_(None) & (EconomicRelease.schedule_status == "scheduled")
published = EconomicRelease.first_received_at.is_not(None)
rows = (await session.execute(select(
EconomicRelease.source,
func.max(EconomicRelease.schedule_checked_at).label("schedule_success_at"),
func.max(EconomicRelease.first_received_at).label("latest_result_at"),
func.min(EconomicRelease.scheduled_at).filter(pending & (EconomicRelease.scheduled_at > now)).label("next_event_at"),
func.count().filter(pending & EconomicRelease.scheduled_at.between(now - timedelta(days=45), now)).label("overdue_count"),
func.min(EconomicRelease.scheduled_at).filter(published).label("history_start"),
func.max(EconomicRelease.scheduled_at).filter(published).label("history_end"),
).group_by(EconomicRelease.source))).mappings().all()
stats = {row["source"]: dict(row) for row in rows}
result_errors = {}
for source, error in await session.execute(select(EconomicRelease.source, EconomicRelease.error).where(
EconomicRelease.error.is_not(None), EconomicRelease.schedule_status == "scheduled",
EconomicRelease.scheduled_at >= now - timedelta(days=45),
).order_by(EconomicRelease.last_checked_at.desc())):
result_errors.setdefault(source, error)
codes = {family.source for family in FAMILIES.values()}
sources = list(await session.scalars(select(Source).where(Source.code.in_(codes)).order_by(Source.code)))
output = []
for source in sources:
checks = [s for s in heartbeat["sources"].values() if s["source"] == source.code] if heartbeat else []
errors = [s["error"] for s in checks if s["error"]]
if source.code in result_errors:
errors.append(result_errors[source.code])
data = stats.get(source.code, {})
output.append(CalendarSourceOut(
code=source.code, name=source.name, region_code=REGIONS[source.code], worker_alive=alive,
heartbeat_at=heartbeat["at"] if heartbeat else None,
schedule_checked_at=max((s["checked_at"] for s in checks), default=None),
**{key: data.get(key) for key in ("schedule_success_at", "latest_result_at", "next_event_at", "history_start", "history_end")},
overdue_count=data.get("overdue_count", 0), errors=list(dict.fromkeys(errors)),
))
return output
@router.get("/sources")
async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -> SourcesOut:
sources = (await session.scalars(select(Source).order_by(Source.code))).all()
async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[SourcesOut]:
sources = (await session.scalars(select(Source).where(Source.collector == "news").order_by(Source.code))).all()
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
today_counts = {
code: (new or 0, revised or 0)
@@ -110,12 +174,14 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
)).all())
statuses = await redis.hgetall(STATUS_KEY)
db_size = await session.scalar(select(func.pg_database_size(func.current_database())))
return SourcesOut(
return success(SourcesOut(
manager_alive=bool(statuses),
db_size_bytes=db_size,
calendar_sources=await calendar_sources(session),
sources=[
SourceOut.model_validate({
**s.__dict__,
"region_code": REGIONS.get(s.code),
"supports_history": s.code in HISTORY_SOURCES,
"process": json.loads(statuses[s.code]) if s.code in statuses else None,
"today_new": today_counts.get(s.code, (0, 0))[0],
@@ -124,13 +190,13 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
})
for s in sources
],
)
))
@router.patch("/sources/{code}")
async def patch_source(code: str, body: SourcePatch, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def patch_source(code: str, body: SourcePatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
source = await session.get(Source, code)
if source is None:
if source is None or source.collector != "news":
raise HTTPException(status.HTTP_404_NOT_FOUND, "来源不存在")
for field, value in body.model_dump(exclude_none=True, exclude={"history_target"}).items():
setattr(source, field, value)
@@ -143,6 +209,7 @@ async def patch_source(code: str, body: SourcePatch, session: Annotated[AsyncSes
source.history_target = body.history_target
source.history_done = False
await session.commit()
return success(None)
@router.get("/runs")
@@ -150,11 +217,11 @@ async def list_runs(
session: Annotated[AsyncSession, Depends(get_session)],
source: str | None = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[RunOut]:
) -> ApiResponse[list[RunOut]]:
stmt = select(CrawlRun).order_by(CrawlRun.started_at.desc()).limit(limit)
if source:
stmt = stmt.where(CrawlRun.source_code == source)
return [RunOut.model_validate(r, from_attributes=True) for r in await session.scalars(stmt)]
return success([RunOut.model_validate(r, from_attributes=True) for r in await session.scalars(stmt)])
@router.get("/logs")
@@ -163,7 +230,7 @@ async def list_logs(
source: str | None = None,
before_id: int | None = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
) -> list[LogOut]:
) -> ApiResponse[list[LogOut]]:
"""按时间正序返回 before_id 之前最近的 limit 条"""
stmt = select(CrawlLog).order_by(CrawlLog.id.desc()).limit(limit)
if source:
@@ -171,11 +238,16 @@ async def list_logs(
if before_id:
stmt = stmt.where(CrawlLog.id < before_id)
rows = [LogOut.model_validate(r, from_attributes=True) for r in await session.scalars(stmt)]
return rows[::-1]
return success(rows[::-1])
@router.get("/logs/stream", response_class=EventSourceResponse)
async def stream_logs(source: str | None = None) -> AsyncIterator[ServerSentEvent]:
async def stream_logs(principal: Annotated[AuthSession, Depends(require_admin)], source: str | None = None) -> AsyncIterator[ServerSentEvent]:
async for item in session_events(_stream_logs(source), principal):
yield item
async def _stream_logs(source: str | None = None) -> AsyncIterator[ServerSentEvent]:
async with redis.pubsub() as pubsub:
await pubsub.subscribe(LOG_CHANNEL)
async for message in pubsub.listen():
@@ -187,9 +259,12 @@ async def stream_logs(source: str | None = None) -> AsyncIterator[ServerSentEven
@router.post("/restart")
async def restart(body: RestartIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
if body.source != "all" and await session.get(Source, body.source) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "来源不存在")
async def restart(body: RestartIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
if body.source != "all":
source = await session.get(Source, body.source)
if source is None or source.collector != "news":
raise HTTPException(status.HTTP_404_NOT_FOUND, "来源不存在")
receivers = await redis.publish(CONTROL_CHANNEL, json.dumps({"action": "restart", "source": body.source}))
if receivers == 0:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "采集管理进程未运行")
return success(None)
+20 -9
View File
@@ -1,12 +1,22 @@
from fastapi import Depends, FastAPI
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import HTMLResponse
from fastapi.exceptions import RequestValidationError
from fastapi.responses import RedirectResponse
from starlette.exceptions import HTTPException
from alphapulse.api import auth, chat, crawler, market, news, settings, skills, trading
from alphapulse.api import auth, calendar, chat, crawler, market, news, settings, skills, trading
from alphapulse.api.response import (
ApiResponse, ValidationErrors, http_error, internal_error, success, validation_error,
)
from alphapulse.core.auth import require_admin
# 关闭默认文档路由,改为登录后才能访问
app = FastAPI(title="AlphaPulse", docs_url=None, redoc_url=None, openapi_url=None)
app = FastAPI(
title="AlphaPulse", docs_url=None, redoc_url=None, openapi_url=None,
responses={422: {"model": ApiResponse[ValidationErrors], "description": "请求参数无效"}},
)
app.add_exception_handler(HTTPException, http_error)
app.add_exception_handler(RequestValidationError, validation_error)
app.add_exception_handler(Exception, internal_error)
app.include_router(auth.router)
app.include_router(news.router)
app.include_router(crawler.router)
@@ -16,11 +26,12 @@ app.include_router(settings.router)
app.include_router(skills.router)
app.include_router(chat.router)
app.include_router(trading.router)
app.include_router(calendar.router)
@app.get("/api/health", include_in_schema=False)
async def health() -> dict[str, str]:
return {"status": "ok"}
async def health() -> ApiResponse[dict[str, str]]:
return success({"status": "ok"})
@app.get("/api/openapi.json", include_in_schema=False, dependencies=[Depends(require_admin)])
@@ -28,6 +39,6 @@ async def openapi() -> dict:
return app.openapi()
@app.get("/api/docs", include_in_schema=False, dependencies=[Depends(require_admin)])
async def docs() -> HTMLResponse:
return get_swagger_ui_html(openapi_url="/api/openapi.json", title="AlphaPulse API")
@app.get("/api/docs", include_in_schema=False)
async def docs() -> RedirectResponse:
return RedirectResponse("/docs")
+29 -18
View File
@@ -14,7 +14,8 @@ from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.core.auth import require_admin, websocket_admin
from alphapulse.api.response import ApiResponse, success
from alphapulse.core.auth import WS_PROTOCOL, check_session, require_admin, websocket_admin
from alphapulse.core.db import get_session
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.market.binance import BinanceClient, RateLimited
@@ -110,7 +111,7 @@ async def tradable_symbols(market: str) -> list[dict]:
@router.get("/symbols")
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]:
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[SymbolOut]]:
symbols = (await session.scalars(select(MarketSymbol).order_by(MarketSymbol.is_default.desc(), MarketSymbol.added_at))).all()
states = {(s.market, s.symbol, s.interval): s for s in await session.scalars(select(KlineSyncState))}
now = int(time.time() * 1000)
@@ -124,14 +125,14 @@ async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -
span = max(now - state.earliest_open_time, 1)
return IntervalProgress(done=False, percent=round((state.latest_open_time - state.earliest_open_time) / span * 100, 1))
return [
return success([
SymbolOut.model_validate({**s.__dict__, "progress": {i: progress(s, i) for i in INTERVALS}})
for s in symbols
]
])
@router.post("/symbols", status_code=status.HTTP_201_CREATED)
async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
symbol = body.symbol.strip().upper()
match = next((s for s in await tradable_symbols(body.market) if s["symbol"] == symbol), None)
if match is None:
@@ -141,10 +142,11 @@ async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(ge
await session.commit()
except IntegrityError:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已存在") from None
return success(None)
@router.delete("/symbols/{symbol_id}")
async def delete_symbol(symbol_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def delete_symbol(symbol_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
"""删除交易对及其 K 线(K 线可随时从币安重新补齐)"""
s = await session.get(MarketSymbol, symbol_id)
if s is None:
@@ -153,11 +155,12 @@ async def delete_symbol(symbol_id: int, session: Annotated[AsyncSession, Depends
await session.execute(delete(KlineSyncState).where(KlineSyncState.market == s.market, KlineSyncState.symbol == s.symbol))
await session.delete(s)
await session.commit()
return success(None)
@router.get("/exchange-info")
async def exchange_info(market: Market) -> list[TradableSymbol]:
return [TradableSymbol.model_validate(s) for s in await tradable_symbols(market)]
async def exchange_info(market: Market) -> ApiResponse[list[TradableSymbol]]:
return success([TradableSymbol.model_validate(s) for s in await tradable_symbols(market)])
@router.get("/klines")
@@ -168,7 +171,7 @@ async def klines(
interval: str,
end: int | None = None,
limit: Annotated[int, Query(ge=1, le=MAX_KLINES)] = 500,
) -> list[list[float]]:
) -> ApiResponse[list[list[float]]]:
"""返回 open_time < end 的最近 limit 根,按时间正序:[open_time, open, high, low, close, volume, quote_volume]"""
if interval == "1s":
# 1s 不落库,直接取币安最近数据
@@ -177,7 +180,7 @@ async def klines(
await ensure_symbol(session, market, symbol)
params = {"symbol": symbol, "interval": "1s", "limit": limit, **({"endTime": end - 1} if end else {})}
rows = await call_binance(client.get("spot", "klines", params))
return [[r[0], float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5]), float(r[7])] for r in rows]
return success([[r[0], float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5]), float(r[7])] for r in rows])
if interval not in INTERVALS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"不支持的周期:{interval}")
stmt = (
@@ -189,22 +192,20 @@ async def klines(
if end:
stmt = stmt.where(Kline.open_time < end)
rows = (await session.execute(stmt)).all()
return [[r[0], *(float(v) for v in r[1:])] for r in reversed(rows)]
return success([[r[0], *(float(v) for v in r[1:])] for r in reversed(rows)])
@router.get("/ticker")
async def ticker(session: Annotated[AsyncSession, Depends(get_session)], market: Market, symbol: str) -> dict:
async def ticker(session: Annotated[AsyncSession, Depends(get_session)], market: Market, symbol: str) -> ApiResponse[dict]:
await ensure_symbol(session, market, symbol)
return await call_binance(client.ticker(market, symbol))
return success(await call_binance(client.ticker(market, symbol)))
@ws_router.websocket("/ws")
async def market_ws(websocket: WebSocket) -> None:
"""客户端发送 {"market","symbol","interval"} 切换订阅;服务端推送 {"type":"kline"|"ticker", market, symbol, interval?, data}"""
if websocket_admin(websocket) is None:
await websocket.close(code=4401)
return
await websocket.accept()
principal = await websocket_admin(websocket)
await websocket.accept(subprotocol=WS_PROTOCOL)
pubsub = redis.pubsub()
subscribed = asyncio.Event()
@@ -237,9 +238,18 @@ async def market_ws(websocket: WebSocket) -> None:
"data": json.loads(message["data"]),
}))
async def watch_session() -> None:
while True:
await asyncio.sleep(1)
try:
await check_session(principal)
except HTTPException as error:
await websocket.close(code=4401 if error.status_code == 401 else 1011)
return
# 收、发任一方向结束(客户端断开或出错)就整体收尾;非正常断开时主动关闭,让前端重连,
# 避免转发任务已死而连接看起来仍正常
tasks = [asyncio.create_task(receive()), asyncio.create_task(forward())]
tasks = [asyncio.create_task(receive()), asyncio.create_task(forward()), asyncio.create_task(watch_session())]
try:
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
@@ -249,6 +259,7 @@ async def market_ws(websocket: WebSocket) -> None:
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
await pubsub.aclose()
if websocket.application_state == WebSocketState.CONNECTED and websocket.client_state == WebSocketState.CONNECTED:
await websocket.close(code=1011)
+40 -12
View File
@@ -8,7 +8,9 @@ from pydantic import BaseModel
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.core.auth import require_admin
from alphapulse.api.response import ApiResponse, success
from alphapulse.core.auth import AuthSession, require_admin
from alphapulse.core.streams import session_events
from alphapulse.core.db import get_session
from alphapulse.core.redis import redis
from alphapulse.crawler.events import NEWS_CHANNEL
@@ -17,6 +19,18 @@ from alphapulse.models import NewsItem, NewsRevision
router = APIRouter(prefix="/api/news", tags=["news"], dependencies=[Depends(require_admin)])
EXCERPT_LENGTH = 300
# 只扩展完整的常用主题词;其他输入继续按字面搜索,不翻译或改写任意查询。
NEWS_KEYWORDS = (
("比特币", "bitcoin", "btc"),
("以太坊", "ethereum", "ether", "eth"),
("稳定币", "stablecoin"),
("美联储", "federal reserve", "federal open market committee", "fomc"),
("消费者价格指数", "消费价格指数", "consumer price index", "cpi"),
("生产者价格指数", "producer price index", "ppi"),
("非农", "nonfarm", "employment situation"),
("国内生产总值", "gross domestic product", "gdp"),
("个人消费支出", "personal consumption expenditures", "pce"),
)
class NewsBrief(BaseModel):
@@ -61,10 +75,19 @@ def news_filters(source: list[str] | None, q: str | None, start: datetime | None
if source:
conditions.append(NewsItem.source_code.in_(source))
if q:
# 转义 LIKE 通配符,按字面搜索
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%"
conditions.append(or_(NewsItem.title.ilike(pattern, escape="\\"), NewsItem.content_text.ilike(pattern, escape="\\")))
keywords = next((group for group in NEWS_KEYWORDS if q.casefold() in group), (q,))
matches = []
for keyword in keywords:
if keyword in ("btc", "eth", "ether"):
# 短代码按单词匹配,避免把 method、Ethernet 等普通英文词误当作以太坊。
pattern = f"(?i)(^|[^a-z0-9_]){keyword}([^a-z0-9_]|$)"
matches.extend((NewsItem.title.regexp_match(pattern), NewsItem.content_text.regexp_match(pattern)))
continue
# 转义 LIKE 通配符;主题词扩展和普通查询都按字面匹配。
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%"
matches.extend((NewsItem.title.ilike(pattern, escape="\\"), NewsItem.content_text.ilike(pattern, escape="\\")))
conditions.append(or_(*matches))
if start:
conditions.append(NewsItem.published_at >= start)
if end:
@@ -81,7 +104,7 @@ async def list_news(
end: datetime | None = None,
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=100)] = 30,
) -> NewsPage:
) -> ApiResponse[NewsPage]:
conditions = news_filters(source, q, start, end)
total = await session.scalar(select(func.count()).select_from(NewsItem).where(*conditions))
rows = (await session.execute(
@@ -93,11 +116,16 @@ async def list_news(
)).all()
items = [NewsBrief.model_validate({**news.__dict__, "excerpt": excerpt}) for news, excerpt in rows]
return NewsPage(items=items, total=total)
return success(NewsPage(items=items, total=total))
@router.get("/stream", response_class=EventSourceResponse)
async def stream_news() -> AsyncIterator[ServerSentEvent]:
async def stream_news(principal: Annotated[AuthSession, Depends(require_admin)]) -> AsyncIterator[ServerSentEvent]:
async for item in session_events(_stream_news(), principal):
yield item
async def _stream_news() -> AsyncIterator[ServerSentEvent]:
async with redis.pubsub() as pubsub:
await pubsub.subscribe(NEWS_CHANNEL)
async for message in pubsub.listen():
@@ -106,16 +134,16 @@ async def stream_news() -> AsyncIterator[ServerSentEvent]:
@router.get("/{news_id}")
async def get_news(news_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> NewsDetail:
async def get_news(news_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[NewsDetail]:
news = await session.get(NewsItem, news_id)
if news is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "新闻不存在")
return NewsDetail.model_validate({**news.__dict__, "excerpt": news.content_text[:EXCERPT_LENGTH]})
return success(NewsDetail.model_validate({**news.__dict__, "excerpt": news.content_text[:EXCERPT_LENGTH]}))
@router.get("/{news_id}/revisions")
async def list_revisions(news_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> list[Revision]:
async def list_revisions(news_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[Revision]]:
rows = await session.scalars(
select(NewsRevision).where(NewsRevision.news_id == news_id).order_by(NewsRevision.revision_no.desc())
)
return [Revision.model_validate(r, from_attributes=True) for r in rows]
return success([Revision.model_validate(r, from_attributes=True) for r in rows])
+50
View File
@@ -0,0 +1,50 @@
"""普通 JSON 接口的唯一响应契约;流和 OpenAPI 不经过此包装。"""
import logging
from http import HTTPStatus
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException
logger = logging.getLogger(__name__)
class ApiResponse[T](BaseModel):
code: int
data: T
message: str
def success[T](data: T) -> ApiResponse[T]:
return ApiResponse(code=0, data=data, message="")
class ValidationIssue(BaseModel):
loc: list[str | int]
type: str
msg: str
class ValidationErrors(BaseModel):
errors: list[ValidationIssue]
async def http_error(request: Request, error: HTTPException) -> JSONResponse:
message = error.detail if isinstance(error.detail, str) else HTTPStatus(error.status_code).phrase
return JSONResponse({"code": error.status_code, "data": None, "message": message},
status_code=error.status_code, headers=error.headers)
async def validation_error(request: Request, error: RequestValidationError) -> JSONResponse:
# input/ctx 可能包含密码等原始输入,不进入公开响应。
errors = [{key: item[key] for key in ("loc", "type", "msg")} for item in error.errors()]
return JSONResponse({"code": 422, "data": {"errors": errors}, "message": "请求参数无效"}, status_code=422)
async def internal_error(request: Request, error: Exception) -> JSONResponse:
logger.error("接口处理失败:%s %s", request.method, request.url.path,
exc_info=(type(error), error, error.__traceback__))
return JSONResponse({"code": 500, "data": None, "message": "服务器内部错误"}, status_code=500)
+22 -20
View File
@@ -11,6 +11,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.api.response import ApiResponse, success
from alphapulse.ai import llm
from alphapulse.core.auth import require_admin
from alphapulse.core.crypto import encrypt
@@ -96,13 +97,13 @@ async def commit_unique(session: AsyncSession) -> None:
@router.get("/providers")
async def list_providers(session: Annotated[AsyncSession, Depends(get_session)]) -> list[ProviderOut]:
async def list_providers(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[ProviderOut]]:
rows = await session.scalars(select(AiProvider).order_by(AiProvider.id))
return [ProviderOut.model_validate(p, from_attributes=True) for p in rows]
return success([ProviderOut.model_validate(p, from_attributes=True) for p in rows])
@router.post("/providers", status_code=status.HTTP_201_CREATED)
async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ProviderOut:
async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ProviderOut]:
# 第一套配置自动成为默认
is_default = body.is_default or await session.scalar(select(AiProvider.id).limit(1)) is None
if is_default:
@@ -113,11 +114,11 @@ async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Dep
)
session.add(provider)
await commit_unique(session)
return ProviderOut.model_validate(provider, from_attributes=True)
return success(ProviderOut.model_validate(provider, from_attributes=True))
@router.patch("/providers/{provider_id}")
async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ProviderOut:
async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ProviderOut]:
provider = await get_provider(session, provider_id)
values = body.model_dump(exclude_unset=True, exclude={"api_key", "base_url", "is_default"})
# reasoning_effort 显式传 null 表示清除,其他字段为 null 时忽略
@@ -134,11 +135,11 @@ async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotat
provider.is_default = True
await commit_unique(session)
await session.refresh(provider)
return ProviderOut.model_validate(provider, from_attributes=True)
return success(ProviderOut.model_validate(provider, from_attributes=True))
@router.delete("/providers/{provider_id}")
async def delete_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def delete_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
provider = await get_provider(session, provider_id)
await session.delete(provider)
await session.flush()
@@ -148,10 +149,11 @@ async def delete_provider(provider_id: int, session: Annotated[AsyncSession, Dep
if first:
first.is_default = True
await session.commit()
return success(None)
@router.post("/providers/{provider_id}/test")
async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> TestResult:
async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[TestResult]:
provider = await get_provider(session, provider_id)
started = time.monotonic()
try:
@@ -160,8 +162,8 @@ async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depen
if isinstance(event, llm.Completion):
completion = event
except llm.LlmError as e:
return TestResult(ok=False, elapsed_ms=round((time.monotonic() - started) * 1000), error=str(e))
return TestResult(ok=True, elapsed_ms=round((time.monotonic() - started) * 1000), reply=completion.text[:200])
return success(TestResult(ok=False, elapsed_ms=round((time.monotonic() - started) * 1000), error=str(e)))
return success(TestResult(ok=True, elapsed_ms=round((time.monotonic() - started) * 1000), reply=completion.text[:200]))
# ---------- 模拟盘 ----------
@@ -240,12 +242,12 @@ async def ensure_no_positions(session: AsyncSession, exchange: str) -> None:
@router.get("/exchange")
async def get_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut:
return await exchange_out(session, await session.get(TradingAccount, 1))
async def get_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ExchangeOut]:
return success(await exchange_out(session, await session.get(TradingAccount, 1)))
@router.put("/exchange")
async def put_exchange(body: ExchangeIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut:
async def put_exchange(body: ExchangeIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ExchangeOut]:
account = await editable_account(session)
await ensure_no_positions(session, account.exchange)
exchange = body.exchange or account.exchange
@@ -264,11 +266,11 @@ async def put_exchange(body: ExchangeIn, session: Annotated[AsyncSession, Depend
if credential is not None:
await session.refresh(credential)
await redis.delete(STATUS_KEY)
return await exchange_out(session, account)
return success(await exchange_out(session, account))
@router.delete("/exchange")
async def delete_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut:
async def delete_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ExchangeOut]:
account = await editable_account(session)
await ensure_no_positions(session, account.exchange)
credential = await session.get(TradingCredential, account.exchange)
@@ -278,11 +280,11 @@ async def delete_exchange(session: Annotated[AsyncSession, Depends(get_session)]
if credential is not None:
await session.refresh(credential)
await redis.delete(STATUS_KEY)
return await exchange_out(session, account)
return success(await exchange_out(session, account))
@router.post("/exchange/test")
async def test_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeTestResult:
async def test_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ExchangeTestResult]:
account = await session.get(TradingAccount, 1)
credential = await session.get(TradingCredential, account.exchange) if account else None
if credential is None or credential.api_key_encrypted is None:
@@ -294,8 +296,8 @@ async def test_exchange(session: Annotated[AsyncSession, Depends(get_session)])
dual = await client.position_mode()
except ExchangeError as e:
hint = "(请在所选交易所的模拟交易账户创建 key,确认合约权限及 IP 白名单;Bybit Testnet 和实盘 key 均不可用)" if e.code in (-2015, 10003, 10005, 10010) else ""
return ExchangeTestResult(ok=False, elapsed_ms=round((time.monotonic() - started) * 1000), error=f"{e}{hint}")
return ExchangeTestResult(
return success(ExchangeTestResult(ok=False, elapsed_ms=round((time.monotonic() - started) * 1000), error=f"{e}{hint}"))
return success(ExchangeTestResult(
ok=True, elapsed_ms=round((time.monotonic() - started) * 1000),
available_usdt=info["availableBalance"], wallet_usdt=info["totalWalletBalance"], dual_side_position=dual,
)
))
+18 -15
View File
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.api.response import ApiResponse, success
from alphapulse.ai.skills import SkillError, add_version, create_node, load_tree
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
@@ -89,17 +90,17 @@ def bad_request(e: SkillError) -> HTTPException:
@router.get("/tree")
async def tree(session: Annotated[AsyncSession, Depends(get_session)]) -> list[NodeOut]:
async def tree(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[NodeOut]]:
"""扁平列表,前端按 parent_id 组装成树"""
return [
return success([
NodeOut(**{**i.node.__dict__, "path": i.path, "published_version_no": i.published_version_no,
"latest_version_no": i.latest_version_no, "has_draft": i.has_draft})
for i in await load_tree(session)
]
])
@router.post("/nodes", status_code=status.HTTP_201_CREATED)
async def create(body: NodeIn, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
async def create(body: NodeIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[NodeOut]:
try:
node = await create_node(session, body.parent_id, body.kind, body.name, body.priority)
# 没有初始内容时不建空版本,第一次保存即 v1
@@ -108,11 +109,11 @@ async def create(body: NodeIn, session: Annotated[AsyncSession, Depends(get_sess
except SkillError as e:
raise bad_request(e) from None
await session.commit()
return await node_out(session, node.id)
return success(await node_out(session, node.id))
@router.patch("/nodes/{node_id}")
async def patch(node_id: int, body: NodePatch, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
async def patch(node_id: int, body: NodePatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[NodeOut]:
node = await get_node(session, node_id)
if body.name is not None:
name = body.name.strip()
@@ -129,11 +130,11 @@ async def patch(node_id: int, body: NodePatch, session: Annotated[AsyncSession,
if body.enabled is not None:
node.enabled = body.enabled
await session.commit()
return await node_out(session, node_id)
return success(await node_out(session, node_id))
@router.post("/nodes/{node_id}/move")
async def move(node_id: int, body: MoveIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def move(node_id: int, body: MoveIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
node = await get_node(session, node_id)
if body.parent_id is not None:
parent = await get_node(session, body.parent_id)
@@ -160,10 +161,11 @@ async def move(node_id: int, body: MoveIn, session: Annotated[AsyncSession, Depe
for i, sibling in enumerate(ordered):
sibling.sort_order = i
await session.commit()
return success(None)
@router.delete("/nodes/{node_id}")
async def delete(node_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def delete(node_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
node = await get_node(session, node_id)
if node.kind == "folder" and await session.scalar(select(func.count()).where(SkillNode.parent_id == node_id)):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "文件夹不为空,请先移走或删除其中的节点")
@@ -172,26 +174,27 @@ async def delete(node_id: int, session: Annotated[AsyncSession, Depends(get_sess
await session.flush()
await session.delete(node)
await session.commit()
return success(None)
@router.get("/{skill_id}/versions")
async def versions(skill_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> list[VersionOut]:
async def versions(skill_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[VersionOut]]:
await get_node(session, skill_id, "skill")
rows = await session.scalars(select(SkillVersion).where(SkillVersion.skill_id == skill_id).order_by(SkillVersion.version_no.desc()))
return [VersionOut.model_validate(v, from_attributes=True) for v in rows]
return success([VersionOut.model_validate(v, from_attributes=True) for v in rows])
@router.post("/{skill_id}/versions", status_code=status.HTTP_201_CREATED)
async def save_draft(skill_id: int, body: VersionIn, session: Annotated[AsyncSession, Depends(get_session)]) -> VersionOut:
async def save_draft(skill_id: int, body: VersionIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[VersionOut]:
skill = await get_node(session, skill_id, "skill")
version = await add_version(session, skill, body.content, "user", body.note)
await session.commit()
await session.refresh(version)
return VersionOut.model_validate(version, from_attributes=True)
return success(VersionOut.model_validate(version, from_attributes=True))
@router.post("/{skill_id}/publish")
async def publish(skill_id: int, body: PublishIn, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
async def publish(skill_id: int, body: PublishIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[NodeOut]:
"""发布指定版本;发布旧版本即回退"""
skill = await get_node(session, skill_id, "skill")
version = await session.get(SkillVersion, body.version_id)
@@ -199,4 +202,4 @@ async def publish(skill_id: int, body: PublishIn, session: Annotated[AsyncSessio
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
skill.published_version_id = version.id
await session.commit()
return await node_out(session, skill_id)
return success(await node_out(session, skill_id))
+42 -34
View File
@@ -11,6 +11,7 @@ from sqlalchemy import BigInteger, cast, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.api.response import ApiResponse, success
from alphapulse.ai import llm
from alphapulse.api.crawler import LOCAL_TZ
from alphapulse.api.market import call_binance, client as market_client
@@ -215,7 +216,7 @@ async def get_account(session: AsyncSession) -> TradingAccount:
@router.get("/status")
async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[StatusOut]:
account = await get_account(session)
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
count, input_tokens, output_tokens = (await session.execute(
@@ -226,16 +227,16 @@ async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) ->
).where(TradingAnalysis.started_at >= today)
)).one()
credential = await session.get(TradingCredential, account.exchange)
return StatusOut(
return success(StatusOut(
exchange=account.exchange, configured=bool(credential and credential.api_key_encrypted),
provider_id=account.provider_id, reasoning_effort=account.reasoning_effort,
snapshot=await read_snapshot(),
today=TodayUsage(analyses=count, limit=account.daily_analysis_limit, input_tokens=input_tokens, output_tokens=output_tokens),
)
))
@router.patch("/config")
async def patch_config(body: ConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
async def patch_config(body: ConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[StatusOut]:
account = await get_account(session)
fields = body.model_dump(exclude_unset=True)
if fields.get("provider_id") is not None and await session.get(AiProvider, fields["provider_id"]) is None:
@@ -275,8 +276,8 @@ async def symbol_options(exchange: str) -> list[dict]:
@router.get("/symbol-options")
async def list_symbol_options(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOption]:
return [SymbolOption.model_validate(o) for o in await symbol_options(await active_exchange(session))]
async def list_symbol_options(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[SymbolOption]]:
return success([SymbolOption.model_validate(o) for o in await symbol_options(await active_exchange(session))])
async def current_equity() -> Decimal | None:
@@ -312,22 +313,24 @@ async def set_running(session: AsyncSession, s: TradingSymbol, running: bool) ->
@router.post("/symbols/{symbol}/start")
async def start(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def start(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
await set_running(session, await get_symbol(session, symbol), True)
return success(None)
@router.post("/symbols/{symbol}/stop")
async def stop(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def stop(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
await set_running(session, await get_symbol(session, symbol), False)
return success(None)
@router.patch("/symbols/{symbol}")
async def patch_symbol(symbol: str, body: SymbolConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def patch_symbol(symbol: str, body: SymbolConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
"""设置该币的交易资金:各币合计不超过账户权益(取不到权益时不校验)"""
s = await get_symbol(session, symbol)
capital = body.capital_usdt
if capital == s.capital_usdt:
return
return success(None)
if capital is None and s.running:
raise HTTPException(status.HTTP_409_CONFLICT, "交易中不能清空交易资金")
equity = await current_equity()
@@ -342,17 +345,18 @@ async def patch_symbol(symbol: str, body: SymbolConfigIn, session: Annotated[Asy
session.add(TradingEvent(exchange=await active_exchange(session), symbol=symbol, kind="capital_changed",
message=f"交易资金设为 {capital} USDT" if capital is not None else "清空交易资金"))
await session.commit()
return success(None)
@router.get("/symbols")
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]:
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[SymbolOut]]:
symbols = (await session.scalars(select(TradingSymbol).order_by(TradingSymbol.added_at))).all()
equity = await current_equity()
analyzing = set((await session.scalars(select(TradingAnalysis.symbol).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.status == "running"))).all())
plans = {p.symbol: p for p in await session.scalars(
select(TradingAnalysis).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES)).order_by(TradingAnalysis.id)
)}
return [
return success([
SymbolOut(
symbol=s.symbol, added_at=s.added_at, analysis_requested_at=s.analysis_requested_at,
analyzing=s.symbol in analyzing, running=s.running, running_changed_at=s.running_changed_at, capital_usdt=s.capital_usdt,
@@ -362,11 +366,11 @@ async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -
close_deadline=plans[s.symbol].close_deadline if s.symbol in plans else None,
)
for s in symbols
]
])
@router.post("/symbols", status_code=status.HTTP_201_CREATED)
async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
symbol = body.symbol.strip().upper()
if not any(o["symbol"] == symbol for o in await symbol_options(await active_exchange(session))):
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 不是模拟盘和实盘都在交易的加密货币 USDT 永续合约")
@@ -376,6 +380,7 @@ async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(ge
await session.commit()
except IntegrityError:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在交易列表中") from None
return success(None)
async def fresh_snapshot(symbol: str, action: str) -> dict:
@@ -405,16 +410,17 @@ async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> Tradin
@router.delete("/symbols/{symbol}")
async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
"""只移出列表;分析、订单流水和时间线保留"""
s = await ensure_idle(session, symbol, "删除")
await session.delete(s)
session.add(TradingEvent(exchange=await active_exchange(session), symbol=symbol, kind="symbol_removed", message="移出交易列表"))
await session.commit()
return success(None)
@router.post("/symbols/{symbol}/manual/{action}", status_code=status.HTTP_202_ACCEPTED)
async def manual_action(symbol: str, action: Literal["close", "cancel"], session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def manual_action(symbol: str, action: Literal["close", "cancel"], session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
"""一键平仓(同时停止该币的行情交易)、清除挂单:交给执行器执行,结果写入时间线"""
label = "平仓" if action == "close" else "清除挂单"
s = await get_symbol(session, symbol)
@@ -425,6 +431,7 @@ async def manual_action(symbol: str, action: Literal["close", "cancel"], session
session.add(TradingEvent(exchange=await active_exchange(session), symbol=symbol, kind="manual_requested", level="warn",
message=f"已请求{label},等待交易所确认", data={"action": action}))
await session.commit()
return success(None)
@router.get("/events")
@@ -433,14 +440,14 @@ async def list_events(
symbol: str | None = None,
before: int | None = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[EventOut]:
) -> ApiResponse[list[EventOut]]:
"""按 id 倒序分页;指定 symbol 时同时返回账户级事件(启停等)"""
query = select(TradingEvent).where(TradingEvent.exchange == await active_exchange(session)).order_by(TradingEvent.id.desc()).limit(limit)
if symbol:
query = query.where(or_(TradingEvent.symbol == symbol, TradingEvent.symbol.is_(None)))
if before:
query = query.where(TradingEvent.id < before)
return [EventOut.model_validate(e, from_attributes=True) for e in await session.scalars(query)]
return success([EventOut.model_validate(e, from_attributes=True) for e in await session.scalars(query)])
@router.get("/positions/history")
@@ -453,7 +460,7 @@ async def list_position_history(
source: Literal["program", "manual"] | None = None,
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> PositionHistoryPage:
) -> ApiResponse[PositionHistoryPage]:
"""由当前交易所成交记录还原的已平仓仓位,按平仓时间倒序;start/end 按平仓时间筛选。
个人账户成交量小,每次全量还原后在内存里筛选、统计、分页"""
exchange = await active_exchange(session)
@@ -488,17 +495,17 @@ async def list_position_history(
count=len(items), wins=sum(1 for i in items if i.net > 0),
net=sum((i.net for i in items), Decimal(0)), commission=sum((i.commission for i in items), Decimal(0)),
)
return PositionHistoryPage(summary=summary, total=len(items), items=items[(page - 1) * limit:page * limit])
return success(PositionHistoryPage(summary=summary, total=len(items), items=items[(page - 1) * limit:page * limit]))
@router.get("/positions/history/symbols")
async def list_position_history_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[str]:
async def list_position_history_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[str]]:
"""有过成交的币,供历史仓位筛选"""
return list(await session.scalars(select(TradingFill.symbol).where(TradingFill.exchange == await active_exchange(session)).distinct().order_by(TradingFill.symbol)))
return success(list(await session.scalars(select(TradingFill.symbol).where(TradingFill.exchange == await active_exchange(session)).distinct().order_by(TradingFill.symbol))))
@router.get("/news-signals")
async def list_news_signals(session: Annotated[AsyncSession, Depends(get_session)]) -> NewsSignalsOut:
async def list_news_signals(session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[NewsSignalsOut]:
"""新闻信号前向记录:按标注方向交易的累计统计和最近 50 条"""
rows = (await session.execute(
select(NewsSignal, NewsItem.published_at, NewsItem.source_code, news_text())
@@ -514,7 +521,7 @@ async def list_news_signals(session: Annotated[AsyncSession, Depends(get_session
return None
return {sym: {h: (float(p[h]) / float(p["entry"]) - 1) * 1e4 for h in horizons} for sym, p in prices.items()}
return NewsSignalsOut(
return success(NewsSignalsOut(
start=FORWARD_START, labeled=len(rows), filled=len(filled),
stats=[NewsSignalStat(**r) for r in summarize(filled, SIGNAL_SYMBOLS, horizons)],
recent=[
@@ -522,11 +529,11 @@ async def list_news_signals(session: Annotated[AsyncSession, Depends(get_session
category=s.category, direction=s.direction, returns=returns(s.prices))
for s, published_at, source, title in reversed(rows[-50:])
],
)
))
@router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED)
async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[None]:
"""排队一次手动分析:该币停止交易时为试运行,计划只记录、不下单"""
s = await get_symbol(session, symbol)
if s.capital_usdt is None:
@@ -537,6 +544,7 @@ async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends
s.analysis_requested_at = datetime.now(LOCAL_TZ)
s.analysis_reasons = ["手动分析"]
await session.commit()
return success(None)
@router.get("/analyses")
@@ -544,31 +552,31 @@ async def list_analyses(
session: Annotated[AsyncSession, Depends(get_session)],
symbol: str | None = None,
limit: Annotated[int, Query(ge=1, le=50)] = 10,
) -> list[AnalysisOut]:
) -> ApiResponse[list[AnalysisOut]]:
query = select(TradingAnalysis).where(TradingAnalysis.exchange == await active_exchange(session)).order_by(TradingAnalysis.id.desc()).limit(limit)
if symbol:
query = query.where(TradingAnalysis.symbol == symbol)
return [AnalysisOut.model_validate(a, from_attributes=True) for a in await session.scalars(query)]
return success([AnalysisOut.model_validate(a, from_attributes=True) for a in await session.scalars(query)])
@router.get("/analyses/{analysis_id}")
async def get_analysis(analysis_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> AnalysisDetail:
async def get_analysis(analysis_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[AnalysisDetail]:
analysis = await session.get(TradingAnalysis, analysis_id)
if analysis is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "分析不存在")
return AnalysisDetail.model_validate(analysis, from_attributes=True)
return success(AnalysisDetail.model_validate(analysis, from_attributes=True))
@router.get("/symbols/{symbol}/plan")
async def get_symbol_plan(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> SymbolPlanOut:
async def get_symbol_plan(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[SymbolPlanOut]:
plan = await session.scalar(
select(TradingAnalysis).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("proposed", *LIVE_PLAN_STATUSES)))
.order_by(TradingAnalysis.id.desc()).limit(1)
)
if plan is None:
return SymbolPlanOut(plan=None, orders=[])
return success(SymbolPlanOut(plan=None, orders=[]))
orders = await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id).order_by(TradingOrder.id))
return SymbolPlanOut(
return success(SymbolPlanOut(
plan=AnalysisOut.model_validate(plan, from_attributes=True),
orders=[OrderOut.model_validate(o, from_attributes=True) for o in orders],
)
))