feat: 6 улучшений формы - S3 upload, draft, HEIC, email на step3
1. ✅ Placeholder с тире E1000-302538524 - Теперь в placeholder тоже тире 2. ✅ Email перенесен на Step3 - Убран с Step1 (проверка полиса) - Добавлен на Step3 (вместе с телефоном) - Теперь телефон + email + выплата на одном шаге 3. ✅ HEIC формат + мультилоад - Добавлена поддержка .heic, .heif (iPhone формат) - Убран maxCount - неограниченная загрузка - Параметр multiple для множественной загрузки 4. ✅ S3 Upload - Создан s3_service.py для работы с Timeweb S3 - Новый endpoint: POST /api/v1/upload/files - Поддержка мультизагрузки файлов - Автоматическая генерация уникальных имен - Файлы грузятся в S3, не локально 5. ✅ Draft автосохранение - Создана таблица claims_draft в PostgreSQL - Новый API: POST /api/v1/draft/save - GET /api/v1/draft/stats - статистика по шагам - GET /api/v1/draft/list - список последних драфтов - Для аналитики: где люди бросают заполнение 6. ✅ Миграция БД - 002_create_claims_draft.sql применена - Индексы для быстрого поиска - JSONB поле для гибкости данных Backend: - s3_service.py - сервис для S3 - draft.py - API автосохранения - upload.py - обновлен endpoint для S3 - main.py - добавлены роуты и подключения Frontend: - Step1Policy: убран email, добавлен HEIC - Step3Payment: добавлен email после телефона Статус: ✅ Backend подключен к S3, таблица создана, всё работает
This commit is contained in:
190
backend/app/api/draft.py
Normal file
190
backend/app/api/draft.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Draft API Routes - Автосохранение драфтов форм
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import json
|
||||
from ..services.database import db
|
||||
import logging
|
||||
|
||||
router = APIRouter(prefix="/api/v1/draft", tags=["Draft"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DraftSaveRequest(BaseModel):
|
||||
"""Запрос на сохранение драфта"""
|
||||
session_id: str # Уникальный ID сессии пользователя
|
||||
step: int # Текущий шаг формы (1, 2, 3)
|
||||
data: Dict[str, Any] # Данные формы
|
||||
user_agent: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
async def save_draft(request: DraftSaveRequest):
|
||||
"""
|
||||
Автосохранение драфта формы
|
||||
|
||||
Используется для аналитики:
|
||||
- Где пользователи бросают заполнение
|
||||
- Сколько времени проводят на каждом шаге
|
||||
- Какие поля вызывают проблемы
|
||||
"""
|
||||
try:
|
||||
# Сериализуем данные в JSON
|
||||
form_data_json = json.dumps(request.data, ensure_ascii=False)
|
||||
|
||||
# SQL для upsert (insert or update)
|
||||
query = """
|
||||
INSERT INTO claims_draft (
|
||||
session_id,
|
||||
current_step,
|
||||
form_data,
|
||||
user_agent,
|
||||
ip_address,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (session_id)
|
||||
DO UPDATE SET
|
||||
current_step = EXCLUDED.current_step,
|
||||
form_data = EXCLUDED.form_data,
|
||||
user_agent = EXCLUDED.user_agent,
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
result = await db.fetchval(
|
||||
query,
|
||||
request.session_id,
|
||||
request.step,
|
||||
form_data_json,
|
||||
request.user_agent,
|
||||
request.ip_address,
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
logger.info(f"✅ Draft saved: session={request.session_id}, step={request.step}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Драфт сохранен",
|
||||
"draft_id": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Draft save error: {e}")
|
||||
# Не падаем с ошибкой - просто логируем
|
||||
# Автосохранение не должно блокировать пользователя
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Ошибка сохранения драфта"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_draft_stats():
|
||||
"""
|
||||
Статистика по драфтам
|
||||
|
||||
Показывает:
|
||||
- Сколько людей бросают на каждом шаге
|
||||
- Среднее время на шаге
|
||||
- Количество драфтов за период
|
||||
"""
|
||||
try:
|
||||
# Статистика по шагам
|
||||
step_stats_query = """
|
||||
SELECT
|
||||
current_step,
|
||||
COUNT(*) as count,
|
||||
COUNT(DISTINCT session_id) as unique_users
|
||||
FROM claims_draft
|
||||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY current_step
|
||||
ORDER BY current_step
|
||||
"""
|
||||
|
||||
step_stats = await db.fetch(step_stats_query)
|
||||
|
||||
# Общая статистика
|
||||
total_drafts_query = """
|
||||
SELECT COUNT(*) as total
|
||||
FROM claims_draft
|
||||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||
"""
|
||||
|
||||
total = await db.fetchval(total_drafts_query)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"period": "last_7_days",
|
||||
"total_drafts": total,
|
||||
"by_step": [
|
||||
{
|
||||
"step": row["current_step"],
|
||||
"count": row["count"],
|
||||
"unique_users": row["unique_users"]
|
||||
}
|
||||
for row in step_stats
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Draft stats error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_recent_drafts(limit: int = 50):
|
||||
"""
|
||||
Список последних драфтов
|
||||
|
||||
Для просмотра что люди заполняют
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
session_id,
|
||||
current_step,
|
||||
form_data,
|
||||
created_at,
|
||||
updated_at,
|
||||
user_agent,
|
||||
ip_address
|
||||
FROM claims_draft
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $1
|
||||
"""
|
||||
|
||||
drafts = await db.fetch(query, limit)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(drafts),
|
||||
"drafts": [
|
||||
{
|
||||
"id": row["id"],
|
||||
"session_id": row["session_id"],
|
||||
"step": row["current_step"],
|
||||
"data": json.loads(row["form_data"]) if row["form_data"] else {},
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
"updated_at": row["updated_at"].isoformat(),
|
||||
"user_agent": row["user_agent"],
|
||||
"ip_address": row["ip_address"]
|
||||
}
|
||||
for row in drafts
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Draft list error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Upload API Routes - Загрузка файлов с OCR
|
||||
Upload API Routes - Загрузка файлов с OCR и S3
|
||||
"""
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
from typing import List
|
||||
@@ -7,6 +7,7 @@ import httpx
|
||||
import uuid
|
||||
import os
|
||||
from ..config import settings
|
||||
from ..services.s3_service import s3_service
|
||||
import logging
|
||||
|
||||
router = APIRouter(prefix="/api/v1/upload", tags=["Upload"])
|
||||
@@ -152,3 +153,67 @@ async def upload_passport(file: UploadFile = File(...)):
|
||||
logger.error(f"Passport upload error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/files")
|
||||
async def upload_files(files: List[UploadFile] = File(...), folder: str = "claims"):
|
||||
"""
|
||||
Универсальная загрузка файлов в S3
|
||||
Поддерживает множественную загрузку
|
||||
|
||||
Args:
|
||||
files: Список файлов для загрузки
|
||||
folder: Папка в S3 (claims, policies, documents и т.д.)
|
||||
|
||||
Returns:
|
||||
List[dict]: Список загруженных файлов с URLs
|
||||
"""
|
||||
try:
|
||||
uploaded_files = []
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
# Читаем содержимое файла
|
||||
content = await file.read()
|
||||
|
||||
# Загружаем в S3
|
||||
file_url = await s3_service.upload_file(
|
||||
file_content=content,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type or 'application/octet-stream',
|
||||
folder=folder
|
||||
)
|
||||
|
||||
if file_url:
|
||||
uploaded_files.append({
|
||||
"success": True,
|
||||
"filename": file.filename,
|
||||
"url": file_url,
|
||||
"size": len(content),
|
||||
"content_type": file.content_type
|
||||
})
|
||||
else:
|
||||
uploaded_files.append({
|
||||
"success": False,
|
||||
"filename": file.filename,
|
||||
"error": "S3 upload failed"
|
||||
})
|
||||
|
||||
except Exception as file_error:
|
||||
logger.error(f"Error uploading {file.filename}: {file_error}")
|
||||
uploaded_files.append({
|
||||
"success": False,
|
||||
"filename": file.filename,
|
||||
"error": str(file_error)
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"uploaded_count": len([f for f in uploaded_files if f.get("success")]),
|
||||
"total_count": len(files),
|
||||
"files": uploaded_files
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Batch upload error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ from .services.database import db
|
||||
from .services.redis_service import redis_service
|
||||
from .services.rabbitmq_service import rabbitmq_service
|
||||
from .services.policy_service import policy_service
|
||||
from .api import sms, claims, policy, upload
|
||||
from .services.s3_service import s3_service
|
||||
from .api import sms, claims, policy, upload, draft
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
@@ -53,6 +54,12 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ MySQL Policy DB not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем S3 (для загрузки файлов)
|
||||
s3_service.connect()
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ S3 storage not available: {e}")
|
||||
|
||||
logger.info("✅ ERV Platform started successfully!")
|
||||
|
||||
yield
|
||||
@@ -90,6 +97,7 @@ app.include_router(sms.router)
|
||||
app.include_router(claims.router)
|
||||
app.include_router(policy.router)
|
||||
app.include_router(upload.router)
|
||||
app.include_router(draft.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
104
backend/app/services/s3_service.py
Normal file
104
backend/app/services/s3_service.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
S3 Service - Загрузка файлов в S3 (Timeweb Cloud Storage)
|
||||
"""
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from typing import Optional
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class S3Service:
|
||||
"""Сервис для работы с S3 хранилищем"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.bucket = settings.s3_bucket
|
||||
|
||||
def connect(self):
|
||||
"""Подключение к S3"""
|
||||
try:
|
||||
self.client = boto3.client(
|
||||
's3',
|
||||
endpoint_url=settings.s3_endpoint,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
config=Config(signature_version='s3v4'),
|
||||
region_name=settings.s3_region
|
||||
)
|
||||
logger.info(f"✅ S3 connected: {settings.s3_endpoint}/{settings.s3_bucket}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ S3 connection error: {e}")
|
||||
raise
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
file_content: bytes,
|
||||
filename: str,
|
||||
content_type: str = 'application/octet-stream',
|
||||
folder: str = 'uploads'
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Загрузить файл в S3
|
||||
|
||||
Args:
|
||||
file_content: Содержимое файла в bytes
|
||||
filename: Имя файла
|
||||
content_type: MIME тип
|
||||
folder: Папка в bucket
|
||||
|
||||
Returns:
|
||||
URL файла в S3 или None при ошибке
|
||||
"""
|
||||
if not self.client:
|
||||
self.connect()
|
||||
|
||||
try:
|
||||
# Генерируем уникальное имя файла
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
safe_filename = f"{folder}/{timestamp}_{unique_id}_{filename}"
|
||||
|
||||
# Загружаем файл
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket,
|
||||
Key=safe_filename,
|
||||
Body=file_content,
|
||||
ContentType=content_type
|
||||
)
|
||||
|
||||
# Генерируем URL
|
||||
file_url = f"{settings.s3_endpoint}/{self.bucket}/{safe_filename}"
|
||||
|
||||
logger.info(f"✅ File uploaded to S3: {safe_filename}")
|
||||
return file_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ S3 upload error: {e}")
|
||||
return None
|
||||
|
||||
async def delete_file(self, file_key: str) -> bool:
|
||||
"""Удалить файл из S3"""
|
||||
if not self.client:
|
||||
self.connect()
|
||||
|
||||
try:
|
||||
self.client.delete_object(
|
||||
Bucket=self.bucket,
|
||||
Key=file_key
|
||||
)
|
||||
logger.info(f"✅ File deleted from S3: {file_key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ S3 delete error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
s3_service = S3Service()
|
||||
|
||||
Reference in New Issue
Block a user