feat(ingest): API Push 短新闻接口层

- POST /api/v1/ingest:鉴权(X-Ingest-Token) + 限速(每 token 2 篇/秒,
  Redis 滑动桶,INGEST_RATE_PER_SEC 可调) + 三层去重(L1 external_id /
  L2 content_hash / L3 DB UNIQUE 兜底,均带 reason)
- 写入字段:is_short_news=True、translation/format/image_ai_status='n/a'、
  classify_status=(有 tags?'ok':'pending')、commentary_{angel,meituan}_status='pending'、
  body_zh_text=body_text(走统一路径,前端/prompt 不用改)
- services/fetchers/api_push.py:compute_content_hash + synthesize_url +
  normalize_published_at + build_initial_status 纯函数
- schemas/ingest.py:IngestPayload(title 1-200/body 1-5000/tags 去重去空) +
  IngestResponse(article_id/content_hash/status/reason/matched_external_id)
- admin.py:POST/GET/DELETE /admin/sources/{id}/ingest-tokens — owner 生成
  (raw_token 仅一次性返回)、列出、撤销
- schemas/article.py:ArticleListItem 加 is_short_news/source_ref;
  ArticleDetail 加 is_short_news/source_ref/external_id
- main.py:挂 ingest router;config.py + .env.example:ingest_rate_per_sec 默认 2

短新闻由 commit 1 enrichment_loop 自动接管 classify + 双 provider commentary,
跳过 format/image。
This commit is contained in:
xiaji
2026-06-14 16:04:45 +08:00
parent 3091f291b2
commit 07534eb144
8 changed files with 606 additions and 2 deletions

View File

@@ -4,20 +4,24 @@
- 手动触发抓取 / 重译
- 源健康看板
- 翻译配额管理
- 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
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
@@ -25,6 +29,8 @@ 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])
@@ -260,3 +266,156 @@ async def delete_active_ip(ip: str):
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}