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:
parent
c4924b4da8
commit
69c2bc114b
4 changed files with 199 additions and 2 deletions
|
|
@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from app.config import settings
|
||||
from app.database import engine
|
||||
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.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(tickets.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(api_v1.router)
|
||||
|
||||
app.add_route("/api/metrics", metrics_endpoint, methods=["GET"])
|
||||
|
||||
|
|
|
|||
153
backend/app/routers/api_v1.py
Normal file
153
backend/app/routers/api_v1.py
Normal 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)
|
||||
|
|
@ -120,4 +120,6 @@ export const api = {
|
|||
body: JSON.stringify({ tickets }),
|
||||
},
|
||||
),
|
||||
|
||||
rotateApiKey: () => request<{ api_key: string }>('/api/v1/me/api-key', { method: 'PATCH' }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { api, getToken, User } from '../api/client'
|
|||
|
||||
export default function Settings() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [rotating, setRotating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
|
|
@ -13,6 +15,26 @@ export default function Settings() {
|
|||
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 (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
|
||||
<Link to="/dashboard" style={{ marginBottom: 16, display: 'block' }}>
|
||||
|
|
@ -24,7 +46,26 @@ export default function Settings() {
|
|||
{user && (
|
||||
<div>
|
||||
<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 <API_Key></code> для доступа к API.
|
||||
Документация: <Link to="/api/docs">/api/docs</Link>
|
||||
</p>
|
||||
<p><strong>План:</strong> {user.plan}</p>
|
||||
<p><strong>Дата регистрации:</strong> {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue