diff --git a/backend/app/api/draft.py b/backend/app/api/draft.py new file mode 100644 index 0000000..b9deb41 --- /dev/null +++ b/backend/app/api/draft.py @@ -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)) + diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 021c0ef..b77f6a2 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -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)) + diff --git a/backend/app/main.py b/backend/app/main.py index 866099d..4fdfd3f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/services/s3_service.py b/backend/app/services/s3_service.py new file mode 100644 index 0000000..1cb87a5 --- /dev/null +++ b/backend/app/services/s3_service.py @@ -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() + diff --git a/backend/db/migrations/002_create_claims_draft.sql b/backend/db/migrations/002_create_claims_draft.sql new file mode 100644 index 0000000..b629d0b --- /dev/null +++ b/backend/db/migrations/002_create_claims_draft.sql @@ -0,0 +1,26 @@ +-- Создание таблицы для автосохранения драфтов форм +-- Используется для аналитики: где люди бросают заполнение + +CREATE TABLE IF NOT EXISTS claims_draft ( + id SERIAL PRIMARY KEY, + session_id VARCHAR(255) UNIQUE NOT NULL, -- Уникальный ID сессии браузера + current_step INTEGER NOT NULL, -- Текущий шаг формы (1, 2, 3) + form_data JSONB NOT NULL, -- Данные формы в JSON + user_agent TEXT, -- User-Agent браузера + ip_address VARCHAR(45), -- IP адрес пользователя + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Индексы для быстрого поиска +CREATE INDEX idx_claims_draft_session ON claims_draft(session_id); +CREATE INDEX idx_claims_draft_step ON claims_draft(current_step); +CREATE INDEX idx_claims_draft_created ON claims_draft(created_at DESC); +CREATE INDEX idx_claims_draft_updated ON claims_draft(updated_at DESC); + +-- Комментарии +COMMENT ON TABLE claims_draft IS 'Автосохранение драфтов форм для аналитики'; +COMMENT ON COLUMN claims_draft.session_id IS 'Уникальный ID сессии (из localStorage)'; +COMMENT ON COLUMN claims_draft.current_step IS 'Номер шага где пользователь остановился'; +COMMENT ON COLUMN claims_draft.form_data IS 'Все данные формы в JSON формате'; + diff --git a/frontend/src/components/form/Step1Policy.tsx b/frontend/src/components/form/Step1Policy.tsx index 4fb00eb..b06bcc2 100644 --- a/frontend/src/components/form/Step1Policy.tsx +++ b/frontend/src/components/form/Step1Policy.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Form, Input, Button, message, Upload } from 'antd'; -import { FileProtectOutlined, MailOutlined, UploadOutlined } from '@ant-design/icons'; +import { FileProtectOutlined, UploadOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; interface Props { @@ -71,7 +71,7 @@ export default function Step1Policy({ formData, updateFormData, onNext }: Props) const checkPolicy = async () => { try { - const values = await form.validateFields(['voucher', 'email']); + const values = await form.validateFields(['voucher']); setLoading(true); setPolicyNotFound(false); @@ -82,7 +82,7 @@ export default function Step1Policy({ formData, updateFormData, onNext }: Props) headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voucher: values.voucher, - email: values.email, + email: 'temp@check.com', // Email не требуется на этом шаге }), }); @@ -154,7 +154,7 @@ export default function Step1Policy({ formData, updateFormData, onNext }: Props) > } - placeholder="E1000302538524" + placeholder="E1000-302538524" size="large" onChange={handleVoucherChange} onPaste={handleVoucherPaste} @@ -162,22 +162,6 @@ export default function Step1Policy({ formData, updateFormData, onNext }: Props) /> - - } - placeholder="example@mail.ru" - size="large" - type="email" - /> - - {!policyNotFound && ( diff --git a/frontend/src/components/form/Step3Payment.tsx b/frontend/src/components/form/Step3Payment.tsx index 92af3b1..575a7f0 100644 --- a/frontend/src/components/form/Step3Payment.tsx +++ b/frontend/src/components/form/Step3Payment.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Form, Input, Button, Select, message, Space, Divider } from 'antd'; -import { PhoneOutlined, SafetyOutlined, QrcodeOutlined } from '@ant-design/icons'; +import { PhoneOutlined, SafetyOutlined, QrcodeOutlined, MailOutlined } from '@ant-design/icons'; const { Option } = Select; @@ -139,6 +139,23 @@ export default function Step3Payment({ /> + + } + placeholder="example@mail.ru" + size="large" + type="email" + disabled={isPhoneVerified} + /> + + {!isPhoneVerified && ( <>