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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user