2026-06-07 21:51:01 +08:00
|
|
|
"""Admin API(仅 owner)。
|
|
|
|
|
|
|
|
|
|
- 源管理 CRUD
|
|
|
|
|
- 手动触发抓取 / 重译
|
|
|
|
|
- 源健康看板
|
|
|
|
|
- 翻译配额管理
|
2026-06-14 16:04:45 +08:00
|
|
|
- API Push ingest token 管理(生成/列/撤销)
|
2026-06-07 21:51:01 +08:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-14 16:04:45 +08:00
|
|
|
import logging
|
2026-06-07 21:51:01 +08:00
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
2026-06-14 16:04:45 +08:00
|
|
|
from pydantic import BaseModel, Field
|
2026-06-07 21:51:01 +08:00
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.deps import require_owner
|
2026-06-14 16:04:45 +08:00
|
|
|
from app.core.security import generate_api_token
|
2026-06-07 21:51:01 +08:00
|
|
|
from app.database import get_session
|
2026-06-14 16:04:45 +08:00
|
|
|
from app.models.api_token import TOKEN_PURPOSE_INGEST, TOKEN_PURPOSE_MOBILE, ApiToken
|
2026-06-07 21:51:01 +08:00
|
|
|
from app.models.article import Article
|
|
|
|
|
from app.models.source import Source
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.schemas.source import SourceIn, SourceOut, SourceUpdate
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_owner)])
|
|
|
|
|
|
2026-06-14 16:04:45 +08:00
|
|
|
logger = logging.getLogger("news.admin")
|
|
|
|
|
|
2026-06-07 21:51:01 +08:00
|
|
|
|
|
|
|
|
# === Source CRUD ===
|
|
|
|
|
@router.get("/sources", response_model=list[SourceOut])
|
|
|
|
|
async def list_sources_all(session: AsyncSession = Depends(get_session)):
|
2026-06-07 23:22:56 +08:00
|
|
|
result = await session.execute(select(Source).order_by(Source.id))
|
|
|
|
|
|
|
|
|
|
rows = result.scalars()
|
2026-06-07 21:51:01 +08:00
|
|
|
return [SourceOut.model_validate(s) for s in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sources", response_model=SourceOut, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
async def create_source(body: SourceIn, session: AsyncSession = Depends(get_session)):
|
|
|
|
|
src = Source(
|
|
|
|
|
name=body.name,
|
|
|
|
|
slug=body.slug,
|
|
|
|
|
kind=body.kind,
|
2026-06-14 16:15:21 +08:00
|
|
|
url=body.url,
|
2026-06-07 21:51:01 +08:00
|
|
|
detail_selector=body.detail_selector,
|
|
|
|
|
region=body.region,
|
|
|
|
|
language_src=body.language_src,
|
|
|
|
|
priority=body.priority,
|
|
|
|
|
fetch_interval_min=body.fetch_interval_min,
|
|
|
|
|
translate_to=body.translate_to,
|
|
|
|
|
enabled=body.enabled,
|
|
|
|
|
headers_json=body.headers_json,
|
2026-06-09 14:35:54 +08:00
|
|
|
blocklist_tags=body.blocklist_tags or [],
|
2026-06-07 21:51:01 +08:00
|
|
|
)
|
|
|
|
|
session.add(src)
|
|
|
|
|
try:
|
|
|
|
|
await session.commit()
|
|
|
|
|
except IntegrityError as e:
|
|
|
|
|
await session.rollback()
|
|
|
|
|
raise HTTPException(status.HTTP_409_CONFLICT, f"slug '{body.slug}' already exists") from e
|
|
|
|
|
await session.refresh(src)
|
|
|
|
|
return SourceOut.model_validate(src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/sources/{source_id}", response_model=SourceOut)
|
|
|
|
|
async def update_source(
|
|
|
|
|
source_id: int,
|
|
|
|
|
body: SourceUpdate,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
):
|
2026-06-07 23:25:53 +08:00
|
|
|
result = await session.execute(select(Source).where(Source.id == source_id))
|
|
|
|
|
src = result.scalar_one_or_none()
|
2026-06-07 21:51:01 +08:00
|
|
|
if not src:
|
|
|
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Source not found")
|
|
|
|
|
for k, v in body.model_dump(exclude_unset=True).items():
|
|
|
|
|
setattr(src, k, v)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(src)
|
|
|
|
|
return SourceOut.model_validate(src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
async def delete_source(source_id: int, session: AsyncSession = Depends(get_session)):
|
2026-06-07 23:25:53 +08:00
|
|
|
result = await session.execute(select(Source).where(Source.id == source_id))
|
|
|
|
|
src = result.scalar_one_or_none()
|
2026-06-07 21:51:01 +08:00
|
|
|
if not src:
|
|
|
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Source not found")
|
|
|
|
|
await session.delete(src)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# === 手动触发 ===
|
|
|
|
|
class TriggerResponse(BaseModel):
|
|
|
|
|
triggered: bool
|
|
|
|
|
detail: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/refresh/{source_id}", response_model=TriggerResponse)
|
|
|
|
|
async def refresh_source(
|
|
|
|
|
source_id: int,
|
|
|
|
|
background: BackgroundTasks,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
):
|
2026-06-07 23:25:53 +08:00
|
|
|
result = await session.execute(select(Source).where(Source.id == source_id))
|
|
|
|
|
src = result.scalar_one_or_none()
|
2026-06-07 21:51:01 +08:00
|
|
|
if not src:
|
|
|
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Source not found")
|
|
|
|
|
if not src.enabled:
|
|
|
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Source disabled")
|
|
|
|
|
|
|
|
|
|
# 走 background,不等结果
|
|
|
|
|
from app.workers.pipeline import fetch_one_source
|
|
|
|
|
|
|
|
|
|
background.add_task(fetch_one_source, source_id)
|
|
|
|
|
return TriggerResponse(triggered=True, detail=f"queued fetch for {src.slug}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _run_fetch(source_id: int) -> None:
|
|
|
|
|
"""(deprecated) 走 background 用的薄包装,见 refresh_source。"""
|
|
|
|
|
from app.workers.pipeline import fetch_one_source
|
|
|
|
|
|
|
|
|
|
await fetch_one_source(source_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/translation/rerun/{article_id}", response_model=TriggerResponse)
|
|
|
|
|
async def rerun_translation(
|
|
|
|
|
article_id: int,
|
|
|
|
|
background: BackgroundTasks,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
):
|
2026-06-07 23:25:53 +08:00
|
|
|
result = await session.execute(select(Article).where(Article.id == article_id))
|
|
|
|
|
art = result.scalar_one_or_none()
|
2026-06-07 21:51:01 +08:00
|
|
|
if not art:
|
|
|
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
|
2026-06-14 20:27:53 +08:00
|
|
|
# 短新闻(API Push)是中文原生,无需翻译
|
|
|
|
|
# 前端"重译"按钮已对短新闻隐藏(v-if="isOwner && !isShort"),
|
|
|
|
|
# 这里加 guard 是后端兜底,防止有人直接 curl 调接口绕过前端
|
|
|
|
|
if art.is_short_news:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
"短新闻(API Push)无需翻译,translation_status 固定为 n/a",
|
|
|
|
|
)
|
2026-06-07 21:51:01 +08:00
|
|
|
art.translation_status = "pending"
|
|
|
|
|
art.title_zh = None
|
|
|
|
|
art.body_zh_text = None
|
|
|
|
|
art.body_zh_html = None
|
|
|
|
|
art.translated_at = None
|
|
|
|
|
art.translation_engine = None
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
from app.workers.pipeline import translate_article
|
|
|
|
|
|
|
|
|
|
background.add_task(translate_article, article_id)
|
|
|
|
|
return TriggerResponse(triggered=True, detail=f"queued translation for article {article_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# === 健康看板 ===
|
|
|
|
|
class HealthOut(BaseModel):
|
|
|
|
|
source_id: int
|
|
|
|
|
slug: str
|
|
|
|
|
name: str
|
|
|
|
|
enabled: bool
|
|
|
|
|
last_fetched_at: datetime | None
|
|
|
|
|
last_status: str | None
|
|
|
|
|
consecutive_failures: int
|
|
|
|
|
fetch_interval_min: int
|
|
|
|
|
article_count_24h: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/health", response_model=list[HealthOut])
|
|
|
|
|
async def health(session: AsyncSession = Depends(get_session)):
|
2026-06-07 23:22:56 +08:00
|
|
|
result = await session.execute(select(Source).order_by(Source.priority.desc()))
|
|
|
|
|
rows = result.scalars()
|
2026-06-07 21:51:01 +08:00
|
|
|
out: list[HealthOut] = []
|
|
|
|
|
for s in rows:
|
|
|
|
|
c24 = (
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(func.count(Article.id)).where(
|
|
|
|
|
Article.source_id == s.id,
|
|
|
|
|
Article.fetched_at >= datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
- timedelta(hours=24),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
).scalar_one()
|
|
|
|
|
out.append(
|
|
|
|
|
HealthOut(
|
|
|
|
|
source_id=s.id,
|
|
|
|
|
slug=s.slug,
|
|
|
|
|
name=s.name,
|
|
|
|
|
enabled=s.enabled,
|
|
|
|
|
last_fetched_at=s.last_fetched_at,
|
|
|
|
|
last_status=s.last_status,
|
|
|
|
|
consecutive_failures=s.consecutive_failures,
|
|
|
|
|
fetch_interval_min=s.fetch_interval_min,
|
|
|
|
|
article_count_24h=c24 or 0,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# === 翻译配额(管理员视图) ===
|
|
|
|
|
class QuotaReset(BaseModel):
|
|
|
|
|
used_chars: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/translation/quota/reset")
|
|
|
|
|
async def reset_quota(payload: QuotaReset) -> dict[str, Any]:
|
|
|
|
|
from app.redis_client import get_redis
|
|
|
|
|
|
|
|
|
|
r = get_redis()
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
key = f"translation:month:{now:%Y%m}"
|
|
|
|
|
await r.set(key, payload.used_chars)
|
|
|
|
|
return {"key": key, "value": payload.used_chars}
|
2026-06-13 18:22:40 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# === 活跃 IP 看板(防 token 泄漏 / 排查异地登录)===
|
|
|
|
|
class ActiveIpItem(BaseModel):
|
|
|
|
|
ip: str
|
|
|
|
|
last_seen_unix: int
|
|
|
|
|
last_seen_iso: str
|
|
|
|
|
idle_seconds: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ActiveIpList(BaseModel):
|
|
|
|
|
items: list[ActiveIpItem]
|
|
|
|
|
limit: int
|
|
|
|
|
idle_days: int
|
|
|
|
|
count: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/active-ips", response_model=ActiveIpList)
|
|
|
|
|
async def list_active_ips_admin():
|
|
|
|
|
from app.config import settings
|
|
|
|
|
from app.services.active_ip import list_active_ips
|
|
|
|
|
|
|
|
|
|
items = await list_active_ips()
|
|
|
|
|
return ActiveIpList(
|
|
|
|
|
items=[ActiveIpItem(**i) for i in items],
|
|
|
|
|
limit=settings.site_max_active_ips,
|
|
|
|
|
idle_days=settings.site_active_ip_idle_days,
|
|
|
|
|
count=len(items),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KickIpRequest(BaseModel):
|
|
|
|
|
ip: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KickIpResponse(BaseModel):
|
|
|
|
|
kicked: bool
|
|
|
|
|
ip: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/active-ips/kick", response_model=KickIpResponse)
|
|
|
|
|
async def kick_active_ip(payload: KickIpRequest):
|
|
|
|
|
"""owner 强制剔除某个 IP。下次该 IP 登录时会再次被算作"新 IP"。"""
|
|
|
|
|
from app.services.active_ip import remove_active_ip
|
|
|
|
|
|
|
|
|
|
ok = await remove_active_ip(payload.ip)
|
|
|
|
|
return KickIpResponse(kicked=ok, ip=payload.ip)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/active-ips/{ip}", response_model=KickIpResponse)
|
|
|
|
|
async def delete_active_ip(ip: str):
|
|
|
|
|
from app.services.active_ip import remove_active_ip
|
|
|
|
|
|
|
|
|
|
ok = await remove_active_ip(ip)
|
|
|
|
|
return KickIpResponse(kicked=ok, ip=ip)
|
2026-06-14 16:04:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# === API Push ingest token 管理 ===
|
|
|
|
|
# 给 owner 在 /admin/sources 详情页用:为某个 api_push source 颁发 ingest token,
|
|
|
|
|
# 或撤销已有 token。raw token 只在生成时一次性返回。
|
|
|
|
|
class IngestTokenCreate(BaseModel):
|
|
|
|
|
"""POST /admin/sources/{id}/ingest-tokens 请求体。"""
|
|
|
|
|
|
|
|
|
|
name: str = Field(default="default", min_length=1, max_length=64)
|
|
|
|
|
expires_days: int | None = Field(default=None, ge=1, le=3650)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class IngestTokenOut(BaseModel):
|
|
|
|
|
"""生成/列出时的响应(列出时不返 raw_token)。"""
|
|
|
|
|
|
|
|
|
|
id: int
|
|
|
|
|
source_id: int
|
|
|
|
|
name: str
|
|
|
|
|
purpose: str
|
|
|
|
|
created_at: datetime
|
|
|
|
|
expires_at: datetime | None = None
|
|
|
|
|
revoked_at: datetime | None = None
|
|
|
|
|
last_used_at: datetime | None = None
|
|
|
|
|
# 仅生成时填充
|
|
|
|
|
raw_token: str | None = None
|
|
|
|
|
|
|
|
|
|
class Config:
|
|
|
|
|
from_attributes = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/sources/{source_id}/ingest-tokens",
|
|
|
|
|
response_model=IngestTokenOut,
|
|
|
|
|
status_code=status.HTTP_201_CREATED,
|
|
|
|
|
)
|
|
|
|
|
async def create_ingest_token(
|
|
|
|
|
source_id: int,
|
|
|
|
|
body: IngestTokenCreate,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
user: User = Depends(require_owner),
|
|
|
|
|
):
|
|
|
|
|
"""为某个 api_push source 颁发一个 ingest token。
|
|
|
|
|
|
|
|
|
|
- raw_token 仅本次返回(后续只能列/撤销)
|
|
|
|
|
- token 绑定 source_id(只能推送到这个 source)
|
|
|
|
|
- 限速:由 .env INGEST_RATE_PER_SEC 全局生效
|
|
|
|
|
"""
|
|
|
|
|
# 验证 source 存在且是 api_push 类型
|
|
|
|
|
src = (
|
|
|
|
|
await session.execute(select(Source).where(Source.id == source_id))
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if not src:
|
|
|
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Source not found")
|
|
|
|
|
if src.kind.value != "api_push":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
f"source kind={src.kind.value} cannot have ingest token; need api_push",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
raw, hashed = generate_api_token()
|
|
|
|
|
expires = (
|
|
|
|
|
datetime.now(timezone.utc) + timedelta(days=body.expires_days)
|
|
|
|
|
if body.expires_days
|
|
|
|
|
else None
|
|
|
|
|
)
|
|
|
|
|
tok = ApiToken(
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
name=body.name,
|
|
|
|
|
token_hash=hashed,
|
|
|
|
|
purpose=TOKEN_PURPOSE_INGEST,
|
|
|
|
|
source_id=src.id,
|
|
|
|
|
expires_at=expires,
|
|
|
|
|
)
|
|
|
|
|
session.add(tok)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(tok)
|
|
|
|
|
return IngestTokenOut(
|
|
|
|
|
id=tok.id,
|
|
|
|
|
source_id=tok.source_id,
|
|
|
|
|
name=tok.name,
|
|
|
|
|
purpose=tok.purpose,
|
|
|
|
|
created_at=tok.created_at,
|
|
|
|
|
expires_at=tok.expires_at,
|
|
|
|
|
revoked_at=tok.revoked_at,
|
|
|
|
|
last_used_at=tok.last_used_at,
|
|
|
|
|
raw_token=raw, # 只此一次!
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/sources/{source_id}/ingest-tokens",
|
|
|
|
|
response_model=list[IngestTokenOut],
|
|
|
|
|
)
|
|
|
|
|
async def list_ingest_tokens(
|
|
|
|
|
source_id: int,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
):
|
|
|
|
|
"""列出某个 source 的所有 ingest token(含已撤销)。"""
|
|
|
|
|
rows = (
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(ApiToken)
|
|
|
|
|
.where(
|
|
|
|
|
ApiToken.source_id == source_id,
|
|
|
|
|
ApiToken.purpose == TOKEN_PURPOSE_INGEST,
|
|
|
|
|
)
|
|
|
|
|
.order_by(ApiToken.id.desc())
|
|
|
|
|
)
|
|
|
|
|
).scalars()
|
|
|
|
|
return [
|
|
|
|
|
IngestTokenOut(
|
|
|
|
|
id=t.id,
|
|
|
|
|
source_id=t.source_id,
|
|
|
|
|
name=t.name,
|
|
|
|
|
purpose=t.purpose,
|
|
|
|
|
created_at=t.created_at,
|
|
|
|
|
expires_at=t.expires_at,
|
|
|
|
|
revoked_at=t.revoked_at,
|
|
|
|
|
last_used_at=t.last_used_at,
|
|
|
|
|
raw_token=None, # 列出时不返 raw
|
|
|
|
|
)
|
|
|
|
|
for t in rows
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
"/ingest-tokens/{token_id}",
|
|
|
|
|
response_model=dict,
|
|
|
|
|
status_code=status.HTTP_200_OK,
|
|
|
|
|
)
|
|
|
|
|
async def revoke_ingest_token(
|
|
|
|
|
token_id: int,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
):
|
|
|
|
|
"""撤销某个 ingest token。撤销后立即生效(下次推送返 401)。"""
|
|
|
|
|
tok = (
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(ApiToken).where(
|
|
|
|
|
ApiToken.id == token_id,
|
|
|
|
|
ApiToken.purpose == TOKEN_PURPOSE_INGEST,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if not tok:
|
|
|
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ingest token not found")
|
|
|
|
|
if tok.revoked_at:
|
|
|
|
|
return {"id": tok.id, "revoked_at": tok.revoked_at, "already_revoked": True}
|
|
|
|
|
tok.revoked_at = datetime.now(timezone.utc)
|
|
|
|
|
await session.commit()
|
|
|
|
|
logger.info(
|
|
|
|
|
"ingest token revoked: id=%s source_id=%s user_id=%s",
|
|
|
|
|
tok.id, tok.source_id, tok.user_id,
|
|
|
|
|
)
|
|
|
|
|
return {"id": tok.id, "revoked_at": tok.revoked_at, "already_revoked": False}
|