"""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, }