feat: внешний REST API v1 с аутентификацией по API-ключу

- Добавлен роутер /api/v1/ с аутентификацией через API-ключ
- Endpoints: GET /api/v1/me, PATCH /api/v1/me/api-key,
  GET /api/v1/collections, POST /api/v1/tickets, POST /api/v1/search
- На странице настроек: копирование и сброс API-ключа
This commit is contained in:
ed0ss 2026-06-25 11:43:51 +03:00
parent c4924b4da8
commit 69c2bc114b
4 changed files with 199 additions and 2 deletions

View file

@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.database import engine from app.database import engine
from app.models.base import Base from app.models.base import Base
from app.routers import auth, tickets, search from app.routers import auth, tickets, search, api_v1
from app.middleware import RateLimitMiddleware from app.middleware import RateLimitMiddleware
from app.metrics import MetricsMiddleware, metrics_endpoint from app.metrics import MetricsMiddleware, metrics_endpoint
@ -38,6 +38,7 @@ app.add_middleware(RateLimitMiddleware, requests_per_minute=30)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(tickets.router) app.include_router(tickets.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(api_v1.router)
app.add_route("/api/metrics", metrics_endpoint, methods=["GET"]) app.add_route("/api/metrics", metrics_endpoint, methods=["GET"])

View file

@ -0,0 +1,153 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.api_key import get_user_by_api_key
from app.database import get_db
from app.models.collection import Collection
from app.models.schemas import TicketInput, TicketsUpload, TicketsUploadResponse, UserResponse, CollectionResponse
from app.models.search_schemas import SearchRequest, SearchResponse, SourceItem
from app.models.user import User
from app.services.embedding import embed_query
from app.services.vector_store import search as qdrant_search, get_existing_ticket_ids
from app.services.reranking import rerank
from app.services.llm import generate_answer
from app.services.chunking import build_chunk
from app.services.embedding import embed_texts
from app.services.vector_store import upsert_chunks
router = APIRouter(prefix="/api/v1", tags=["API v1"])
FETCH_K = 20
FINAL_K = 5
@router.get("/me", response_model=UserResponse)
async def get_me(user: User = Depends(get_user_by_api_key)):
return user
@router.patch("/me/api-key")
async def rotate_api_key(
user: User = Depends(get_user_by_api_key),
db: AsyncSession = Depends(get_db),
):
user.api_key = str(uuid.uuid4().hex[:32])
await db.commit()
return {"api_key": user.api_key}
@router.get("/collections", response_model=list[CollectionResponse])
async def list_collections(
user: User = Depends(get_user_by_api_key),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Collection).where(Collection.user_id == user.id).order_by(Collection.created_at.desc())
)
return result.scalars().all()
@router.post("/tickets", response_model=TicketsUploadResponse)
async def upload_tickets(
body: TicketsUpload,
collection_id: uuid.UUID,
user: User = Depends(get_user_by_api_key),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Collection).where(Collection.id == collection_id, Collection.user_id == user.id)
)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
existing_ids = get_existing_ticket_ids(str(user.id), str(collection_id))
processed = 0
skipped = 0
chunks = []
for t in body.tickets:
if t.ticket_id in existing_ids:
skipped += 1
continue
ticket_dict = {
"ticket_id": t.ticket_id,
"category": t.category,
"description": t.description,
"client": t.client,
"messages": [{"role": m.role, "text": m.text} for m in t.messages],
}
chunk = build_chunk(ticket_dict)
if chunk:
chunk["tenant_id"] = str(user.id)
chunk["collection_id"] = str(collection_id)
chunks.append(chunk)
processed += 1
else:
skipped += 1
if chunks:
search_texts = [c["search_text"] for c in chunks]
vectors = await embed_texts(search_texts)
await upsert_chunks(str(user.id), chunks, vectors)
collection.ticket_count += processed
await db.commit()
return TicketsUploadResponse(
processed=processed,
skipped=skipped,
collection_ticket_count=collection.ticket_count,
)
@router.post("/search", response_model=SearchResponse)
async def search(
body: SearchRequest,
user: User = Depends(get_user_by_api_key),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Collection).where(
Collection.id == uuid.UUID(body.collection_id),
Collection.user_id == user.id,
)
)
collection = result.scalar_one_or_none()
if not collection:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
query_vector = await embed_query(body.query)
qdrant_results = await qdrant_search(
tenant_id=str(user.id),
query_vector=query_vector,
collection_id=body.collection_id,
k=FETCH_K,
)
if not qdrant_results:
return SearchResponse(
answer="Недостаточно информации в истории обращений" if body.generate_answer else None,
sources=[],
)
reranked = rerank(body.query, qdrant_results, k=FINAL_K)
sources = [
SourceItem(ticket_id=r["ticket_id"], score=r["score"], category=r["category"], full_text=r["full_text"])
for r in reranked
]
answer = None
if body.generate_answer:
context = "\n\n".join(
f"Тикет №{r['ticket_id']} ({r['category']}):\n{r['full_text']}"
for r in reranked
)
answer = await generate_answer(body.query, context)
return SearchResponse(answer=answer, sources=sources)

View file

@ -120,4 +120,6 @@ export const api = {
body: JSON.stringify({ tickets }), body: JSON.stringify({ tickets }),
}, },
), ),
rotateApiKey: () => request<{ api_key: string }>('/api/v1/me/api-key', { method: 'PATCH' }),
} }

View file

@ -4,6 +4,8 @@ import { api, getToken, User } from '../api/client'
export default function Settings() { export default function Settings() {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [copied, setCopied] = useState(false)
const [rotating, setRotating] = useState(false)
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
@ -13,6 +15,26 @@ export default function Settings() {
api.me().then(setUser) api.me().then(setUser)
}, []) }, [])
async function copyKey() {
if (!user) return
await navigator.clipboard.writeText(user.api_key)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
async function rotateKey() {
if (!confirm('Сгенерировать новый API-ключ? Старый перестанет работать.')) return
setRotating(true)
try {
const res = await api.rotateApiKey()
setUser(prev => prev ? { ...prev, api_key: res.api_key } : null)
} catch (err: any) {
alert('Ошибка: ' + err.message)
} finally {
setRotating(false)
}
}
return ( return (
<div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}> <div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}> <Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
@ -24,7 +46,26 @@ export default function Settings() {
{user && ( {user && (
<div> <div>
<p><strong>Email:</strong> {user.email}</p> <p><strong>Email:</strong> {user.email}</p>
<p><strong>API Key:</strong> <code>{user.api_key}</code></p> <p>
<strong>API Key:</strong>{' '}
<code style={{ fontSize: 13 }}>{user.api_key}</code>
<button onClick={copyKey} style={{ marginLeft: 8, fontSize: 12 }}>
{copied ? 'Скопировано' : 'Копировать'}
</button>
<button onClick={rotateKey} disabled={rotating} style={{ marginLeft: 8, fontSize: 12 }}>
{rotating ? 'Обновление...' : 'Сбросить'}
</button>
</p>
<p>
<strong>REST API:</strong>{' '}
<code style={{ fontSize: 13 }}>
POST /api/v1/search
</code>
</p>
<p style={{ fontSize: 13, color: '#666' }}>
Используйте <code>Authorization: Bearer &lt;API_Key&gt;</code> для доступа к API.
Документация: <Link to="/api/docs">/api/docs</Link>
</p>
<p><strong>План:</strong> {user.plan}</p> <p><strong>План:</strong> {user.plan}</p>
<p><strong>Дата регистрации:</strong> {new Date(user.created_at).toLocaleDateString()}</p> <p><strong>Дата регистрации:</strong> {new Date(user.created_at).toLocaleDateString()}</p>
</div> </div>