inital
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
182
backend/app/api/admin.py
Normal file
182
backend/app/api/admin.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.news import LLMConfig, NewsSource, SystemLog, RawNews, ProcessedNews
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def verify_admin(authorization: str = Header(...)):
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
if token != settings.admin_token:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin token")
|
||||
|
||||
|
||||
# ── LLM Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
class LLMConfigIn(BaseModel):
|
||||
name: str
|
||||
provider: str
|
||||
api_key: str
|
||||
base_url: str
|
||||
model_name: str
|
||||
|
||||
|
||||
@router.get("/llm-config", dependencies=[Depends(verify_admin)])
|
||||
async def get_llm_config(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(LLMConfig).where(LLMConfig.is_active == True).limit(1))
|
||||
cfg = result.scalar_one_or_none()
|
||||
if not cfg:
|
||||
return None
|
||||
return {
|
||||
"id": cfg.id, "name": cfg.name, "provider": cfg.provider,
|
||||
"api_key": "***" + cfg.api_key[-4:] if len(cfg.api_key) > 4 else "****",
|
||||
"base_url": cfg.base_url, "model_name": cfg.model_name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/llm-config", dependencies=[Depends(verify_admin)])
|
||||
async def save_llm_config(body: LLMConfigIn, db: AsyncSession = Depends(get_db)):
|
||||
await db.execute(
|
||||
LLMConfig.__table__.update().values(is_active=False)
|
||||
)
|
||||
cfg = LLMConfig(**body.model_dump(), is_active=True)
|
||||
db.add(cfg)
|
||||
await db.commit()
|
||||
return {"ok": True, "id": cfg.id}
|
||||
|
||||
|
||||
@router.post("/llm-config/test", dependencies=[Depends(verify_admin)])
|
||||
async def test_llm_config(body: LLMConfigIn):
|
||||
from ..ai.llm_client import LLMClient
|
||||
client = LLMClient(
|
||||
provider=body.provider,
|
||||
api_key=body.api_key,
|
||||
base_url=body.base_url,
|
||||
model=body.model_name,
|
||||
)
|
||||
try:
|
||||
reply = await client.complete(
|
||||
system_prompt="你是一个助手。",
|
||||
user_prompt="请回复'连接正常',不要说其他内容。",
|
||||
)
|
||||
return {"ok": True, "reply": reply}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
# ── News Sources ──────────────────────────────────────────────────────────────
|
||||
|
||||
class SourceIn(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
source_type: str = "rss"
|
||||
language: str = "zh"
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/sources", dependencies=[Depends(verify_admin)])
|
||||
async def get_sources(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(NewsSource).order_by(NewsSource.id))
|
||||
sources = result.scalars().all()
|
||||
return [
|
||||
{"id": s.id, "name": s.name, "url": s.url, "source_type": s.source_type,
|
||||
"language": s.language, "category": s.category, "is_active": s.is_active}
|
||||
for s in sources
|
||||
]
|
||||
|
||||
|
||||
@router.post("/sources", dependencies=[Depends(verify_admin)])
|
||||
async def add_source(body: SourceIn, db: AsyncSession = Depends(get_db)):
|
||||
src = NewsSource(**body.model_dump())
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
return {"ok": True, "id": src.id}
|
||||
|
||||
|
||||
@router.put("/sources/{source_id}", dependencies=[Depends(verify_admin)])
|
||||
async def toggle_source(source_id: int, body: dict, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(NewsSource).where(NewsSource.id == source_id))
|
||||
src = result.scalar_one_or_none()
|
||||
if not src:
|
||||
raise HTTPException(status_code=404)
|
||||
if "is_active" in body:
|
||||
src.is_active = body["is_active"]
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}", dependencies=[Depends(verify_admin)])
|
||||
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(NewsSource).where(NewsSource.id == source_id))
|
||||
src = result.scalar_one_or_none()
|
||||
if src:
|
||||
await db.delete(src)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Trigger & Stats ───────────────────────────────────────────────────────────
|
||||
|
||||
_pipeline_running = False
|
||||
|
||||
|
||||
@router.post("/crawl/trigger", dependencies=[Depends(verify_admin)])
|
||||
async def trigger_crawl():
|
||||
global _pipeline_running
|
||||
if _pipeline_running:
|
||||
return {"ok": False, "message": "Pipeline already running"}
|
||||
_pipeline_running = True
|
||||
asyncio.create_task(_run_pipeline())
|
||||
return {"ok": True, "message": "Pipeline started"}
|
||||
|
||||
|
||||
async def _run_pipeline():
|
||||
global _pipeline_running
|
||||
from ..scheduler import trigger_now
|
||||
try:
|
||||
await trigger_now()
|
||||
finally:
|
||||
_pipeline_running = False
|
||||
|
||||
|
||||
@router.get("/stats", dependencies=[Depends(verify_admin)])
|
||||
async def get_stats(db: AsyncSession = Depends(get_db)):
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
raw_today = (await db.execute(
|
||||
select(func.count(RawNews.id)).where(func.date(RawNews.crawled_at) == today)
|
||||
)).scalar_one()
|
||||
processed_today = (await db.execute(
|
||||
select(func.count(ProcessedNews.id)).where(func.date(ProcessedNews.processed_at) == today)
|
||||
)).scalar_one()
|
||||
featured_today = (await db.execute(
|
||||
select(func.count(ProcessedNews.id))
|
||||
.where(func.date(ProcessedNews.processed_at) == today)
|
||||
.where(ProcessedNews.is_featured == True)
|
||||
)).scalar_one()
|
||||
return {
|
||||
"raw_today": raw_today,
|
||||
"processed_today": processed_today,
|
||||
"featured_today": featured_today,
|
||||
"pipeline_running": _pipeline_running,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logs", dependencies=[Depends(verify_admin)])
|
||||
async def get_logs(limit: int = 100, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(SystemLog).order_by(SystemLog.created_at.desc()).limit(limit)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
return [
|
||||
{"id": l.id, "level": l.level, "event_type": l.event_type,
|
||||
"message": l.message, "created_at": l.created_at.isoformat()}
|
||||
for l in logs
|
||||
]
|
||||
96
backend/app/api/news.py
Normal file
96
backend/app/api/news.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func, distinct
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.news import ProcessedNews, RawNews
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(n: ProcessedNews) -> dict:
|
||||
raw = n.raw_news
|
||||
return {
|
||||
"id": n.id,
|
||||
"title_zh": n.title_zh,
|
||||
"summary": n.summary,
|
||||
"opinion": n.opinion,
|
||||
"keywords": n.keywords or [],
|
||||
"importance_score": n.importance_score,
|
||||
"importance_reason": n.importance_reason,
|
||||
"category": n.category,
|
||||
"is_featured": n.is_featured,
|
||||
"featured_rank": n.featured_rank,
|
||||
"source_name": n.source_name or (raw.source.name if raw and raw.source else ""),
|
||||
"source_url": n.source_url or (raw.url if raw else ""),
|
||||
"published_at": n.published_at.isoformat() if n.published_at else None,
|
||||
"processed_at": n.processed_at.isoformat() if n.processed_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/featured")
|
||||
async def get_featured(
|
||||
news_date: Optional[str] = Query(default=None, alias="date"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
target = date.fromisoformat(news_date) if news_date else date.today()
|
||||
stmt = (
|
||||
select(ProcessedNews)
|
||||
.join(ProcessedNews.raw_news)
|
||||
.where(ProcessedNews.is_featured == True)
|
||||
.where(func.date(ProcessedNews.processed_at) == target)
|
||||
.order_by(ProcessedNews.featured_rank)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
items = result.scalars().all()
|
||||
return {"date": str(target), "items": [_serialize(n) for n in items]}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_news(
|
||||
news_date: Optional[str] = Query(default=None, alias="date"),
|
||||
category: Optional[str] = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
target = date.fromisoformat(news_date) if news_date else date.today()
|
||||
stmt = (
|
||||
select(ProcessedNews)
|
||||
.join(ProcessedNews.raw_news)
|
||||
.where(func.date(ProcessedNews.processed_at) == target)
|
||||
)
|
||||
if category:
|
||||
stmt = stmt.where(ProcessedNews.category == category)
|
||||
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = (await db.execute(count_stmt)).scalar_one()
|
||||
|
||||
stmt = stmt.order_by(ProcessedNews.importance_score.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {"date": str(target), "total": total, "page": page, "items": [_serialize(n) for n in items]}
|
||||
|
||||
|
||||
@router.get("/dates")
|
||||
async def get_dates(db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(
|
||||
func.date(ProcessedNews.processed_at).label("d"),
|
||||
func.count(ProcessedNews.id).label("cnt"),
|
||||
).group_by("d").order_by(func.date(ProcessedNews.processed_at).desc()).limit(30)
|
||||
result = await db.execute(stmt)
|
||||
return [{"date": str(row.d), "count": row.cnt} for row in result]
|
||||
|
||||
|
||||
@router.get("/{news_id}")
|
||||
async def get_news_detail(news_id: int, db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(ProcessedNews).join(ProcessedNews.raw_news).where(ProcessedNews.id == news_id)
|
||||
result = await db.execute(stmt)
|
||||
news = result.scalar_one_or_none()
|
||||
if not news:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return _serialize(news)
|
||||
Reference in New Issue
Block a user