c2ccf8b1ae
Co-Authored-By: Claude Code <noreply@anthropic.com>
106 lines
5.1 KiB
Python
106 lines
5.1 KiB
Python
"""site_import API(spec §4)——2 端点,两角色可用,_tenant_guard 守租户。
|
||
|
||
- POST /{slug}/site-import:新建(body.url)或复活(body.retry="generate"
|
||
+ body.task_id)。校验次序:鉴权(_tenant_guard 依赖链,401/403/409 门
|
||
连锁)→ 租户存在且未停用(404/403)→ 形态预检(retry 只认 generate 且
|
||
task_id 非空;新建 URL 过 safe_fetch 的 URL 层预检——scheme/host 字面量
|
||
禁段/端口白名单,spec §5「内网/非法地址 POST 即拒」,连接层二次兜底)
|
||
→ task.start。异常映射(Task 7 备忘):ImportRateLimited→429 /
|
||
ConcurrentLimit→409 / LookupError→404(retry 目标不存在)/ ValueError→
|
||
409(非 generate-failed 态),str(exc) 作 detail——任务层的白话文案
|
||
原样透出,API 层不二次翻译(FetchError 已在任务层白话化,同理由)。
|
||
- GET /{slug}/site-import/{task_id}:轮询。task.get 的 (slug, task_id)
|
||
联合索引即越权防线(跨租户持他人 id → None → 404);30 分钟过期同理
|
||
404「任务不存在或已过期」(前端按终态「任务已过期,请重新导入」处理)。
|
||
响应五键(spec §4):status(=任务 state)/ phase / pages(=任务
|
||
pages_fetched)/ draft(仅 done,spec §4 草稿包整包)/ error。
|
||
|
||
启动只做校验+建任务——pipeline 由任务模块的后台 aio task 跑,本层不
|
||
感知 crawler/generator(测试也因此能整体打桩 task.start/task.get)。
|
||
"""
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.platform.db import TenantConfigVersion
|
||
from app.platform.routes.tenant_routes import _get_tenant_or_404, _tenant_guard
|
||
from app.platform.site_import import safe_fetch, task
|
||
|
||
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-site-import"])
|
||
|
||
|
||
class StartRequest(BaseModel):
|
||
url: str = ""
|
||
retry: str = "" # "generate" 复活模式(spec §3.3)
|
||
task_id: str = "" # retry 时指定任务
|
||
|
||
|
||
@router.post("/{slug}/site-import")
|
||
async def start_import(slug: str, body: StartRequest, request: Request,
|
||
user=Depends(_tenant_guard)):
|
||
"""启动/复活导入任务 → {task_id}。校验次序与异常映射见模块 docstring。"""
|
||
engine = request.app.state.platform_engine
|
||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||
tenant = await _get_tenant_or_404(session, slug)
|
||
# 租户配置快照(终审 Important #2:AI 三级链的租户槽位——传给
|
||
# task.start → make_provider;自有 ai.* 配置的租户烧自己配额)
|
||
latest = (await session.execute(
|
||
select(TenantConfigVersion.config_json)
|
||
.where(TenantConfigVersion.tenant_id == tenant.id)
|
||
.order_by(TenantConfigVersion.version.desc())
|
||
.limit(1))).scalar_one_or_none()
|
||
if tenant.status != "active":
|
||
raise HTTPException(
|
||
status_code=403, detail=f"租户 {slug!r} 已停用,无法发起导入")
|
||
|
||
url: str | None = None
|
||
retry_task_id: str | None = None
|
||
if body.retry:
|
||
if body.retry != "generate":
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"retry 只支持 generate(实际 {body.retry!r})")
|
||
if not body.task_id:
|
||
raise HTTPException(
|
||
status_code=400, detail="retry 形态必须带 task_id")
|
||
retry_task_id = body.task_id
|
||
else:
|
||
# URL 层预检单一事实源在 safe_fetch(scheme/禁段字面量/端口)——
|
||
# 私有口内部复用,不另抄一份漂移;不公网形态统一白话 400
|
||
try:
|
||
safe_fetch._validate_url(body.url)
|
||
except safe_fetch.FetchError as exc:
|
||
raise HTTPException(
|
||
status_code=400, detail="仅支持公网网站地址") from exc
|
||
url = body.url
|
||
|
||
try:
|
||
task_id = await task.start(slug, url, retry_task_id=retry_task_id,
|
||
config_data=latest)
|
||
except task.ImportRateLimited as exc:
|
||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||
except task.ConcurrentLimit as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
except LookupError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
return {"task_id": task_id}
|
||
|
||
|
||
@router.get("/{slug}/site-import/{task_id}")
|
||
async def get_import_task(slug: str, task_id: str,
|
||
user=Depends(_tenant_guard)):
|
||
"""轮询任务状态(spec §4 五键;404=不存在/已过期/跨租户)。"""
|
||
t = task.get(slug, task_id)
|
||
if t is None:
|
||
raise HTTPException(status_code=404, detail="任务不存在或已过期")
|
||
return {
|
||
"status": t.state,
|
||
"phase": t.phase,
|
||
"pages": t.pages_fetched,
|
||
"draft": t.draft if t.state == task.TaskState.DONE else None,
|
||
"error": t.error,
|
||
}
|