Files
diary-news/backend/app/api/admin.py

430 lines
13 KiB
Python
Raw Normal View History

"""Admin API(仅 owner)。
- 源管理 CRUD
- 手动触发抓取 / 重译
- 源健康看板
- 翻译配额管理
- API Push ingest token 管理(生成//撤销)
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_owner
from app.core.security import generate_api_token
from app.database import get_session
from app.models.api_token import TOKEN_PURPOSE_INGEST, TOKEN_PURPOSE_MOBILE, ApiToken
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)])
logger = logging.getLogger("news.admin")
# === Source CRUD ===
@router.get("/sources", response_model=list[SourceOut])
async def list_sources_all(session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Source).order_by(Source.id))
rows = result.scalars()
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,
feat(ingest): API Push 前端层 + 文档 + 端到端联通 后端(支持 api_push source 创建/调度): - schemas/source.py:SourceIn.url 改成 str(允许 api_push 的 api-push:// 占位) - admin.py create_source 简化 url 传递 - workers/__main__.py:_rebuild_jobs 跳过 api_push 源(它是被动接收,不抓取) - workers/pipeline.py:run_once 也加同条件,api_push 不进抓取循环 前端: - api/articles.ts:ArticleListItem 加 is_short_news(required)/source_ref; ArticleDetail 加 external_id;导出 IngestTokenOut;adminApi 加 list/create/revoke ingest token 三个方法 - views/Feed.vue:卡片根 class 短新闻加 short-card(淡蓝底 #f6f9fc + 左侧 3px 蓝色色条 #4f9eff);元信息栏加 📰 短讯 角标;长新闻摘要 body_zh_text 截前 200 字,短新闻不截取保留换行(white-space: pre-wrap); 短新闻不显示 AI 插图 - views/ArticleDetail.vue:tag 行加 📰 短讯 + source_ref 角标;短新闻 路径下隐藏翻译状态/重译/原文链接按钮;正文区短新闻直接渲染 body_zh_text,跳过译文/原文/AI 配图卡片;Angel + 美团双评论卡片 都保留 - views/AdminSources.vue:kind 加 api_push 选项;api_push 源 URL 字段 变只读占位、隐藏抓取间隔;列表操作列加 🔑 Token 按钮; 弹窗支持生成(raw_token 一次性显示 + 复制)/列表/撤销 文档: - docs/api-push.md:调用方契约 + 三层去重 + 限速 + lifecycle + owner 操作手册 + curl/Python 示例 + 重试策略 + 故障排查 - README.md:关键特性加 API Push;API 概览加 /api/v1/ingest 和 3 个 /admin/.../ingest-tokens 端点
2026-06-14 16:15:21 +08:00
url=body.url,
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,
blocklist_tags=body.blocklist_tags or [],
)
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),
):
result = await session.execute(select(Source).where(Source.id == source_id))
src = result.scalar_one_or_none()
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)):
result = await session.execute(select(Source).where(Source.id == source_id))
src = result.scalar_one_or_none()
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),
):
result = await session.execute(select(Source).where(Source.id == source_id))
src = result.scalar_one_or_none()
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),
):
result = await session.execute(select(Article).where(Article.id == article_id))
art = result.scalar_one_or_none()
if not art:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
# 短新闻(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",
)
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)):
result = await session.execute(select(Source).order_by(Source.priority.desc()))
rows = result.scalars()
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}
# === 活跃 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)
# === 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}