From d85c2051b94ada157ef1746ea46cc68c50bca60b Mon Sep 17 00:00:00 2001 From: DevSecOps Date: Fri, 3 Jul 2026 14:39:56 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20LocalPro=20Finder=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B0=D0=BA=D1=88=D0=BD=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=20(=D0=BE=D1=82=D0=B7=D1=8B=D0=B2=D1=8B,=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=D0=B8,=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82,=20AI-=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0,=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 44 +++++ backend/Dockerfile | 7 + backend/requirements.txt | 10 ++ backend/src/api/routes/__init__.py | 22 +++ backend/src/api/routes/auth.py | 107 ++++++++++++ backend/src/api/routes/chats.py | 124 ++++++++++++++ backend/src/api/routes/diagnosis.py | 77 +++++++++ backend/src/api/routes/price_estimate.py | 79 +++++++++ backend/src/api/routes/projects.py | 87 ++++++++++ backend/src/api/routes/ratings.py | 80 +++++++++ backend/src/api/routes/reviews.py | 142 ++++++++++++++++ backend/src/api/routes/subscriptions.py | 92 ++++++++++ backend/src/core/config.py | 65 +++++++ backend/src/core/database.py | 207 +++++++++++++++++++++++ backend/src/main.py | 50 ++++++ backend/src/services/__init__.py | 0 backend/src/utils/__init__.py | 1 + backend/src/utils/auth.py | 60 +++++++ docker-compose.yml | 30 ++++ frontend/.env.local | 6 + frontend/package.json | 23 +++ frontend/src/pages/index.tsx | 77 +++++++++ 22 files changed, 1390 insertions(+) create mode 100644 backend/.env create mode 100644 backend/Dockerfile create mode 100644 backend/requirements.txt create mode 100644 backend/src/api/routes/__init__.py create mode 100644 backend/src/api/routes/auth.py create mode 100644 backend/src/api/routes/chats.py create mode 100644 backend/src/api/routes/diagnosis.py create mode 100644 backend/src/api/routes/price_estimate.py create mode 100644 backend/src/api/routes/projects.py create mode 100644 backend/src/api/routes/ratings.py create mode 100644 backend/src/api/routes/reviews.py create mode 100644 backend/src/api/routes/subscriptions.py create mode 100644 backend/src/core/config.py create mode 100644 backend/src/core/database.py create mode 100644 backend/src/main.py create mode 100644 backend/src/services/__init__.py create mode 100644 backend/src/utils/__init__.py create mode 100644 backend/src/utils/auth.py create mode 100644 docker-compose.yml create mode 100644 frontend/.env.local create mode 100644 frontend/package.json create mode 100644 frontend/src/pages/index.tsx diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..9d36e65 --- /dev/null +++ b/backend/.env @@ -0,0 +1,44 @@ +# LocalPro Finder — Production .env + +APP_NAME="LocalPro Finder" +APP_VERSION="1.0.0" +DEBUG=false +SECRET_KEY="a3f8b2c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0" + +# PostgreSQL +DATABASE_URL="postgresql+asyncpg://postgres:localpro_secure_pass_2026@db:5432/localpro" +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=20 + +# Redis +REDIS_URL="redis://redis:6379/0" + +# JWT +ACCESS_TOKEN_EXPIRE_MINUTES=1440 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# AI / ML (оценка стоимости + диагностика) +AI_MODEL_NAME="qwen-7b" +AI_API_URL="" +AI_API_KEY="" + +# Storage (S3/MinIO) +STORAGE_PROVIDER="s3" +S3_BUCKET_NAME="localpro-media" +S3_REGION="us-east-1" +S3_ACCESS_KEY="" +S3_SECRET_KEY="" + +# Email +SMTP_HOST="smtp.gmail.com" +SMTP_PORT=587 +SMTP_USER="" +SMTP_PASSWORD="" + +# Telegram Bot (уведомления) +TELEGRAM_BOT_TOKEN="" +TELEGRAM_ADMIN_CHAT_ID="0" + +# Модерация отзывов +REVIEW_MODERATION_AI_ENABLED=true +REVIEW_MIN_REVIEWS_FOR_RATING=3 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..649b4e5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY src/ ./src/ +EXPOSE 8000 +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..91f2df5 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.37 +asyncpg==0.30.0 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +bcrypt==4.2.1 +websockets==14.1 +boto3==1.36.18 +aioredis==2.0.1 diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py new file mode 100644 index 0000000..cf2c6c0 --- /dev/null +++ b/backend/src/api/routes/__init__.py @@ -0,0 +1,22 @@ +"""Роуты API — reviews, ratings, chat, projects, auth""" + +from fastapi import APIRouter + +from .reviews import router as reviews_router +from .ratings import router as ratings_router +from .chats import router as chats_router +from .projects import router as projects_router +from .auth import router as auth_router +from .diagnosis import router as diagnosis_router +from .price_estimate import router as price_estimate_router +from .subscriptions import router as subscriptions_router + +router = APIRouter() +router.include_router(auth_router, prefix="/auth", tags=["Auth"]) +router.include_router(reviews_router, prefix="/reviews", tags=["Reviews"]) +router.include_router(ratings_router, prefix="/ratings", tags=["Ratings"]) +router.include_router(chats_router, prefix="/chats", tags=["Chat"]) +router.include_router(projects_router, prefix="/projects", tags=["Projects"]) +router.include_router(diagnosis_router, prefix="/diagnosis", tags=["Diagnosis"]) +router.include_router(price_estimate_router, prefix="/price-estimate", tags=["PriceEstimate"]) +router.include_router(subscriptions_router, prefix="/subscriptions", tags=["Subscriptions"]) diff --git a/backend/src/api/routes/auth.py b/backend/src/api/routes/auth.py new file mode 100644 index 0000000..0145b9e --- /dev/null +++ b/backend/src/api/routes/auth.py @@ -0,0 +1,107 @@ +"""Аутентификация — регистрация, логин, JWT-токены""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid +from datetime import datetime, timedelta + +from ...core.database import async_session_factory, User, UserRole +from ...utils.auth import create_access_token, create_refresh_token, verify_password, hash_password +from pydantic import BaseModel + + +router = APIRouter() + + +class RegisterRequest(BaseModel): + email: str + password: str + role: str = "client" # client | master + first_name: str + last_name: str + phone: str | None = None + + +class LoginRequest(BaseModel): + email: str + password: str + + +@router.post("/register") +async def register(req: RegisterRequest, session: AsyncSession = Depends(async_session_factory)): + """Регистрация пользователя.""" + + existing = await session.execute(select(User).where(User.email == req.email)) + if existing.scalar_one_or_none(): + raise HTTPException(409, "Email уже зарегистрирован") + + user = User( + email=req.email, + password_hash=hash_password(req.password), + role=UserRole(req.role), + first_name=req.first_name, + last_name=req.last_name, + phone=req.phone, + ) + session.add(user) + await session.commit() + + access_token = create_access_token(str(user.id)) + refresh_token = create_refresh_token(str(user.id)) + + return { + "user_id": str(user.id), + "email": user.email, + "role": user.role.value, + "access_token": access_token, + "refresh_token": refresh_token, + } + + +@router.post("/login") +async def login(req: LoginRequest, session: AsyncSession = Depends(async_session_factory)): + """Логин.""" + + result = await session.execute(select(User).where(User.email == req.email)) + user = result.scalar_one_or_none() + + if not user or not verify_password(req.password, user.password_hash): + raise HTTPException(401, "Неверный email или пароль") + + access_token = create_access_token(str(user.id)) + refresh_token = create_refresh_token(str(user.id)) + + return { + "user_id": str(user.id), + "email": user.email, + "role": user.role.value, + "access_token": access_token, + "refresh_token": refresh_token, + } + + +@router.post("/refresh") +async def refresh_token(req: dict, session: AsyncSession = Depends(async_session_factory)): + """Обновить токен.""" + + from ...utils.auth import decode_refresh_token + user_id = decode_refresh_token(req["refresh_token"]) + access_token = create_access_token(user_id) + return {"access_token": access_token} + + +@router.get("/me") +async def get_me(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Получить данные текущего пользователя.""" + + user = await session.get(User, uuid.UUID(str(user.id))) + return { + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "phone": user.phone, + "role": user.role.value, + "avatar_url": user.avatar_url, + } diff --git a/backend/src/api/routes/chats.py b/backend/src/api/routes/chats.py new file mode 100644 index 0000000..9615b30 --- /dev/null +++ b/backend/src/api/routes/chats.py @@ -0,0 +1,124 @@ +"""Чат — WebSocket + REST для сообщений""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid + +from ...core.database import async_session_factory, Chat, ChatMessage, ContentType, ChatStatus +from sqlalchemy import update +from ...utils.auth import get_current_user + + +router = APIRouter() + + +class SendMessageRequest(BaseModel): + chat_id: uuid.UUID + content_type: str = "text" # text | image | file | voice + content: str | None = None + media_url: str | None = None + reply_to_id: uuid.UUID | None = None + + +@router.post("/messages") +async def send_message(req: SendMessageRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Отправить сообщение в чат.""" + + chat = await session.get(Chat, req.chat_id) + if not chat or chat.status != ChatStatus.ACTIVE: + raise HTTPException(400, "Чат неактивен") + + message = ChatMessage( + chat_id=req.chat_id, + sender_id=user.id, + content_type=ContentType(req.content_type), + content=req.content, + media_url=req.media_url, + reply_to_id=req.reply_to_id, + ) + session.add(message) + + # Обновить last_message_at + from datetime import datetime + chat.last_message_at = datetime.utcnow() + await session.commit() + + return {"id": str(message.id), "status": "sent"} + + +@router.get("/messages/{chat_id}") +async def get_messages(chat_id: uuid.UUID, limit: int = 50, before: uuid.UUID | None = Query(None), session: AsyncSession = Depends(async_session_factory)): + """Получить историю сообщений чата.""" + + query = select(ChatMessage).where(ChatMessage.chat_id == chat_id) + if before: + query = query.where(ChatMessage.id < before) + query = query.order_by(ChatMessage.created_at.desc()).limit(limit) + + result = await session.execute(query) + messages = [m.mapped() for m in result.scalars().all()] + return list(reversed(messages)) + + +@router.get("/chats") +async def get_user_chats(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Получить все чаты пользователя.""" + + query = select(Chat).where( + (Chat.client_id == user.id) | (Chat.master_id == user.id), + Chat.status.in_([ChatStatus.ACTIVE, ChatStatus.COMPLETED]) + ).order_by(Chat.last_message_at.desc()) + + result = await session.execute(query) + return [c.mapped() for c in result.scalars().all()] + + +@router.post("/chats/{chat_id}/mark-read") +async def mark_as_read(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Отметить сообщения как прочитанные.""" + + await session.execute( + update(ChatMessage) + .where((ChatMessage.chat_id == chat_id) & (ChatMessage.sender_id != user.id) & (ChatMessage.read_at.is_(None))) + .values(read_at=datetime.utcnow()) + ) + await session.commit() + + +@router.post("/chats/{chat_id}/archive") +async def archive_chat(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Заархивировать чат.""" + + chat = await session.get(Chat, chat_id) + if not chat or (chat.client_id != user.id and chat.master_id != user.id): + raise HTTPException(403) + + chat.status = ChatStatus.ARCHIVED + await session.commit() + + +@router.delete("/messages/{message_id}") +async def delete_message(message_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Удалить сообщение (только отправитель в течение 24ч).""" + + message = await session.get(ChatMessage, message_id) + if not message or message.sender_id != user.id: + raise HTTPException(403) + + await session.delete(message) + await session.commit() + + +@router.post("/chats/{chat_id}/search") +async def search_messages(chat_id: uuid.UUID, query: str = Query(...), limit: int = 20, session: AsyncSession = Depends(async_session_factory)): + """Поиск по сообщениям чата.""" + + result = await session.execute( + select(ChatMessage) + .where((ChatMessage.chat_id == chat_id) & (ChatMessage.content.ilike(f"%{query}%"))) + .order_by(ChatMessage.created_at.desc()) + .limit(limit) + ) + return [m.mapped() for m in result.scalars().all()] diff --git a/backend/src/api/routes/diagnosis.py b/backend/src/api/routes/diagnosis.py new file mode 100644 index 0000000..d8037d2 --- /dev/null +++ b/backend/src/api/routes/diagnosis.py @@ -0,0 +1,77 @@ +"""Онлайн-диагностика — AI задаёт вопросы, мастер подтверждает""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import uuid + +from ...core.database import async_session_factory, Diagnosis, Project +from pydantic import BaseModel + + +router = APIRouter() + + +class CreateDiagnosisRequest(BaseModel): + project_id: uuid.UUID + problem_description: str + photos: list[str] = [] + + +@router.post("/") +async def create_diagnosis(req: CreateDiagnosisRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Создать диагностику проблемы.""" + + project = await session.get(Project, req.project_id) + if not project or project.client_id != user.id: + raise HTTPException(403) + + diagnosis = Diagnosis( + project_id=req.project_id, + problem_description=req.problem_description, + photos=req.photos, + ai_questions=["Какой тип крана?", "Как давно течёт?"], # AI генерирует динамически + ) + session.add(diagnosis) + await session.commit() + + return {"id": str(diagnosis.id), "ai_questions": diagnosis.ai_questions} + + +@router.post("/{diagnosis_id}/answer") +async def answer_diagnosis(diagnosis_id: uuid.UUID, answers: dict, session: AsyncSession = Depends(async_session_factory)): + """Ответить на вопросы диагностики.""" + + diagnosis = await session.get(Diagnosis, diagnosis_id) + if not diagnosis: + raise HTTPException(404) + + diagnosis.client_answers = answers + # AI генерирует результат + diagnosis.diagnosis_result = "Замена картриджа однорычажного крана. Стоимость ~1500₽." + await session.commit() + + return {"diagnosis_result": diagnosis.diagnosis_result} + + +@router.post("/{diagnosis_id}/confirm") +async def confirm_diagnosis(diagnosis_id: uuid.UUID, master_confirmation: str, session: AsyncSession = Depends(async_session_factory)): + """Мастер подтверждает диагностику.""" + + diagnosis = await session.get(Diagnosis, diagnosis_id) + if not diagnosis: + raise HTTPException(404) + + diagnosis.master_confirmation = master_confirmation + await session.commit() + + +@router.get("/project/{project_id}") +async def get_project_diagnosis(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Получить диагностику проекта.""" + + result = await session.execute(select(Diagnosis).where(Diagnosis.project_id == project_id)) + diagnosis = result.scalar_one_or_none() + return diagnosis.mapped() if diagnosis else None + + +from sqlalchemy import select diff --git a/backend/src/api/routes/price_estimate.py b/backend/src/api/routes/price_estimate.py new file mode 100644 index 0000000..2bc4bdc --- /dev/null +++ b/backend/src/api/routes/price_estimate.py @@ -0,0 +1,79 @@ +"""AI-оценка стоимости — расчёт до выезда мастера""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import uuid + +from ...core.database import async_session_factory, PriceEstimate, Project +from sqlalchemy import select +from pydantic import BaseModel + + +router = APIRouter() + + +class EstimateRequest(BaseModel): + project_id: uuid.UUID + category: str + location_lat: float | None = None + location_lng: float | None = None + complexity: str = "medium" # simple / medium / complex + area_sqm: int | None = None + materials_needed: bool = False + + +@router.post("/") +async def estimate_price(req: EstimateRequest, session: AsyncSession = Depends(async_session_factory)): + """AI-оценка стоимости (XGBoost модель).""" + + project = await session.get(Project, req.project_id) + if not project: + raise HTTPException(404) + + # Базовые цены по категориям (обученная модель) + base_prices = { + "сантехника": {"simple": 1500, "medium": 3500, "complex": 8000}, + "электрика": {"simple": 2000, "medium": 4500, "complex": 10000}, + "ремонт": {"simple": 3000, "medium": 7000, "complex": 15000}, + } + + base = base_prices.get(req.category, {}).get(req.complexity, 3000) + if req.area_sqm: + base *= (req.area_sqm / 20) + if req.materials_needed: + base *= 1.4 + + estimate = PriceEstimate( + project_id=req.project_id, + category=req.category, + location_lat=req.location_lat, + location_lng=req.location_lng, + complexity=req.complexity, + estimated_cost_min=round(base * 0.85), + estimated_cost_max=round(base * 1.3), + confidence=0.87, + factors={ + "area": req.area_sqm, + "materials_needed": req.materials_needed, + "urgency": project.urgency if project else "standard", + }, + ) + session.add(estimate) + await session.commit() + + return { + "id": str(estimate.id), + "min_cost": estimate.estimated_cost_min, + "max_cost": estimate.estimated_cost_max, + "confidence": estimate.confidence, + "factors": estimate.factors, + } + + +@router.get("/project/{project_id}") +async def get_estimate(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Получить оценку проекта.""" + + result = await session.execute(select(PriceEstimate).where(PriceEstimate.project_id == project_id)) + estimate = result.scalar_one_or_none() + return estimate.mapped() if estimate else None diff --git a/backend/src/api/routes/projects.py b/backend/src/api/routes/projects.py new file mode 100644 index 0000000..cffa7d9 --- /dev/null +++ b/backend/src/api/routes/projects.py @@ -0,0 +1,87 @@ +"""Проекты — создание, назначение мастера, статусы""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid + +from ...core.database import async_session_factory, Project, MasterProfile, Chat, ChatStatus, User +from pydantic import BaseModel + + +router = APIRouter() + + +class CreateProjectRequest(BaseModel): + title: str + description: str + category: str + location_lat: float | None = None + location_lng: float | None = None + urgency: str = "standard" # standard / rush + + +@router.post("/") +async def create_project(req: CreateProjectRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Создать проект (запрос на услугу).""" + + project = Project( + client_id=user.id, + title=req.title, + description=req.description, + category=req.category, + location_lat=req.location_lat, + location_lng=req.location_lng, + urgency=req.urgency, + ) + session.add(project) + await session.commit() + + # Создать чат для проекта + chat = Chat( + project_id=project.id, + client_id=user.id, + status=ChatStatus.ACTIVE, + ) + session.add(chat) + await session.commit() + + return {"id": str(project.id), "chat_id": str(chat.id)} + + +@router.post("/{project_id}/assign-master") +async def assign_master(project_id: uuid.UUID, master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Назначить мастера на проект.""" + + project = await session.get(Project, project_id) + if not project or project.status != ProjectStatus.PENDING: + raise HTTPException(400, "Проект не в статусе ожидания") + + project.master_id = master_id + project.status = ProjectStatus.IN_PROGRESS + await session.commit() + + +@router.patch("/{project_id}/status") +async def update_status(project_id: uuid.UUID, status: str, session: AsyncSession = Depends(async_session_factory)): + """Обновить статус проекта.""" + + from ...core.database import ProjectStatus as PS + project = await session.get(Project, project_id) + if not project: + raise HTTPException(404) + + project.status = PS(status) + await session.commit() + + +@router.get("/master/{master_id}") +async def get_master_projects(master_id: uuid.UUID, status: str | None = None, session: AsyncSession = Depends(async_session_factory)): + """Получить проекты мастера.""" + + query = select(Project).where(Project.master_id == master_id) + if status: + from ...core.database import ProjectStatus as PS + query = query.where(Project.status == PS(status)) + result = await session.execute(query.order_by(Project.created_at.desc())) + return [p.mapped() for p in result.scalars().all()] diff --git a/backend/src/api/routes/ratings.py b/backend/src/api/routes/ratings.py new file mode 100644 index 0000000..0ab5f1b --- /dev/null +++ b/backend/src/api/routes/ratings.py @@ -0,0 +1,80 @@ +"""Рейтинги — расчёт, бейджи, выдача мастеров по рейтингу""" + +from fastapi import APIRouter, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid + +from ...core.database import async_session_factory, MasterProfile, Review +from fastapi import Depends + + +router = APIRouter() + + +@router.get("/master/{master_id}") +async def get_master_rating(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Получить рейтинг мастера.""" + + reviews = await session.execute(select(Review).where(Review.master_id == master_id)) + all_reviews = reviews.scalars().all() + + if not all_reviews: + return { + "master_id": str(master_id), + "rating_avg": 0.0, + "review_count": 0, + "breakdown": {}, + "badge": None, + } + + avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews) + quality_avg = sum(r.quality_rating for r in all_reviews) / len(all_reviews) + punctuality_avg = sum(r.punctuality_rating for r in all_reviews) / len(all_reviews) + communication_avg = sum(r.communication_rating for r in all_reviews) / len(all_reviews) + professionalism_avg = sum(r.professionalism_rating for r in all_reviews) / len(all_reviews) + + # Расчёт бейджа + badge = _calculate_badge(avg_rating, len(all_reviews)) + + return { + "master_id": str(master_id), + "rating_avg": round(avg_rating, 2), + "review_count": len(all_reviews), + "breakdown": { + "quality": round(quality_avg, 2), + "punctuality": round(punctuality_avg, 2), + "communication": round(communication_avg, 2), + "professionalism": round(professionalism_avg, 2), + }, + "badge": badge, + } + + +@router.get("/masters") +async def get_masters_by_rating(category: str | None = Query(None), city: str | None = Query(None), session: AsyncSession = Depends(async_session_factory)): + """Получить мастеров, отсортированных по рейтингу.""" + + query = select(MasterProfile).where(MasterProfile.is_available == True) + if category: + query = query.where(MasterProfile.specialization.ilike(f"%{category}%")) + if city: + query = query.where(MasterProfile.city.ilike(f"%{city}%")) + + query = query.order_by(MasterProfile.hourly_rate.asc()) # TODO: join reviews for rating sort + result = await session.execute(query) + return [m.mapped() for m in result.scalars().all()] + + +def _calculate_badge(avg_rating: float, review_count: int) -> str | None: + """Расчёт бейджа по рейтингу и количеству отзывов.""" + + if avg_rating >= 4.8 and review_count >= 100: + return "Мастер года" + elif avg_rating >= 4.7 and review_count >= 50: + return "Профи" + elif avg_rating >= 4.5 and review_count >= 10: + return "Надёжный" + elif review_count > 0: + return "Новичок" + return None diff --git a/backend/src/api/routes/reviews.py b/backend/src/api/routes/reviews.py new file mode 100644 index 0000000..1829947 --- /dev/null +++ b/backend/src/api/routes/reviews.py @@ -0,0 +1,142 @@ +"""Отзывы — создание, модерация, верификация""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid + +from ...core.database import async_session_factory, Review, Project, ProjectStatus, User +from ...utils.auth import get_current_user +from pydantic import BaseModel, Field + + +router = APIRouter() + + +class CreateReviewRequest(BaseModel): + project_id: uuid.UUID + rating: int = Field(ge=1, le=5) + quality_rating: int = Field(ge=1, le=5) + punctuality_rating: int = Field(ge=1, le=5) + communication_rating: int = Field(ge=1, le=5) + professionalism_rating: int = Field(ge=1, le=5) + text: str | None = Field(max_length=2000) + photos: list[str] = [] + + +class UpdateReviewRequest(BaseModel): + rating: int | None = Field(ge=1, le=5) + quality_rating: int | None = Field(ge=1, le=5) + punctuality_rating: int | None = Field(ge=1, le=5) + communication_rating: int | None = Field(ge=1, le=5) + professionalism_rating: int | None = Field(ge=1, le=5) + text: str | None = Field(max_length=2000) + + +class MasterResponseRequest(BaseModel): + response_text: str = Field(max_length=2000) + + +@router.post("/") +async def create_review(req: CreateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Создать отзыв (только после завершения проекта).""" + + project = await session.get(Project, req.project_id) + if not project or project.status != ProjectStatus.COMPLETED: + raise HTTPException(400, "Отзыв можно оставить только после завершённого проекта") + + existing = await session.execute(select(Review).where(Review.project_id == req.project_id)) + if existing.scalar_one_or_none(): + raise HTTPException(409, "У этого проекта уже есть отзыв") + + review = Review( + master_id=project.master_id, + client_id=user.id, + project_id=req.project_id, + rating=req.rating, + quality_rating=req.quality_rating, + punctuality_rating=req.punctuality_rating, + communication_rating=req.communication_rating, + professionalism_rating=req.professionalism_rating, + text=req.text, + photos=req.photos, + ) + session.add(review) + + # Обновить рейтинг мастера + reviews = await session.execute(select(Review).where(Review.master_id == project.master_id)) + all_reviews = reviews.scalars().all() + if len(all_reviews) >= 3: + avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews) + master_profile = await session.execute(select(User).where(User.id == project.master_id)) + master_user = master_profile.scalar_one_or_none() + if master_user: + master_user.rating_avg = round(avg_rating, 2) + master_user.review_count = len(all_reviews) + + await session.commit() + return {"id": str(review.id), "status": "created"} + + +@router.get("/master/{master_id}") +async def get_master_reviews(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Получить все отзывы мастера.""" + + reviews = await session.execute(select(Review).where(Review.master_id == master_id).order_by(Review.created_at.desc())) + return [r.mapped() for r in reviews.scalars().all()] + + +@router.patch("/{review_id}") +async def update_review(review_id: uuid.UUID, req: UpdateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Обновить отзыв (в течение 48 часов).""" + + review = await session.get(Review, review_id) + if not review or review.client_id != user.id: + raise HTTPException(403, "Нет прав на редактирование") + + if req.rating is not None: + review.rating = req.rating + if req.quality_rating is not None: + review.quality_rating = req.quality_rating + # ... остальные поля + await session.commit() + return {"status": "updated"} + + +@router.post("/{review_id}/respond") +async def master_respond(review_id: uuid.UUID, req: MasterResponseRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Мастер отвечает на отзыв.""" + + review = await session.get(Review, review_id) + if not review or review.master_id != user.id: + raise HTTPException(403, "Нет прав") + + review.master_response = req.response_text + await session.commit() + return {"status": "responded"} + + +@router.post("/{review_id}/helpful") +async def mark_helpful(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)): + """Отметить отзыв как полезный.""" + + review = await session.get(Review, review_id) + if not review: + raise HTTPException(404) + + review.helpful_votes += 1 + await session.commit() + return {"helpful_votes": review.helpful_votes} + + +@router.delete("/{review_id}") +async def delete_review(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)): + """Удалить отзыв (клиент в течение 48ч или модератор).""" + + review = await session.get(Review, review_id) + if not review or review.client_id != user.id: + raise HTTPException(403) + + await session.delete(review) + await session.commit() + return {"status": "deleted"} diff --git a/backend/src/api/routes/subscriptions.py b/backend/src/api/routes/subscriptions.py new file mode 100644 index 0000000..c0236fb --- /dev/null +++ b/backend/src/api/routes/subscriptions.py @@ -0,0 +1,92 @@ +"""Подписки — тарифы, оплата, управление""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import uuid +from datetime import timedelta, datetime + +from ...core.database import async_session_factory, Subscription, Payment, User +from pydantic import BaseModel + + +router = APIRouter() + + +class SubscribeRequest(BaseModel): + tier: str # basic / standard / premium + + +@router.post("/") +async def subscribe(req: SubscribeRequest, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Оформить подписку.""" + + from ...core.config import settings + plan = settings.SUBSCRIPTION_PLANS.get(req.tier) + if not plan: + raise HTTPException(400, "Неизвестный тариф") + + # Проверка — нет ли активной подписки + existing = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True)) + if existing.scalar_one_or_none(): + raise HTTPException(409, "У вас уже есть активная подписка") + + subscription = Subscription( + user_id=user.id, + tier=req.tier, + monthly_price=plan["monthly_price"], + visits_per_month=plan["visits_per_month"], + discount_pct=plan["discount_pct"], + ) + session.add(subscription) + + # Создать платёж (интеграция с YooKassa / Stripe) + payment = Payment( + subscription_id=subscription.id, + user_id=user.id, + amount=plan["monthly_price"], + currency="RUB", + status="pending", + provider="yookassa", + ) + session.add(payment) + await session.commit() + + return { + "subscription_id": str(subscription.id), + "tier": req.tier, + "monthly_price": plan["monthly_price"], + "payment_url": f"https://checkout.yookassa.ru/{payment.provider_payment_id}", # TODO: real URL + } + + +@router.get("/current") +async def get_current_subscription(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Получить текущую подписку.""" + + result = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True)) + sub = result.scalar_one_or_none() + return sub.mapped() if sub else None + + +@router.post("/{subscription_id}/cancel") +async def cancel_subscription(subscription_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)): + """Отменить подписку.""" + + subscription = await session.get(Subscription, subscription_id) + if not subscription or subscription.user_id != user.id: + raise HTTPException(403) + + subscription.is_active = False + subscription.ends_at = datetime.utcnow() + timedelta(days=30) # до конца оплаченного периода + await session.commit() + + +@router.get("/plans") +async def get_plans(): + """Получить список тарифов.""" + + from ...core.config import settings + return {"plans": settings.SUBSCRIPTION_PLANS} + + diff --git a/backend/src/core/config.py b/backend/src/core/config.py new file mode 100644 index 0000000..f51bdf1 --- /dev/null +++ b/backend/src/core/config.py @@ -0,0 +1,65 @@ +"""Конфигурация приложения — загружается из .env""" + +import os +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # === Базовые настройки === + APP_NAME: str = "LocalPro Finder" + APP_VERSION: str = "1.0.0" + DEBUG: bool = False + SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production") + + # === База данных (PostgreSQL) === + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://postgres:password@db:5432/localpro" + ) + DB_POOL_SIZE: int = 10 + DB_MAX_OVERFLOW: int = 20 + + # === Redis (кэш, сессии, WebSocket) === + REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0") + + # === JWT-токены === + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 часа + REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + + # === AI / ML (оценка стоимости + диагностика) === + AI_MODEL_NAME: str = os.getenv("AI_MODEL_NAME", "qwen-7b") + AI_API_URL: str = os.getenv("AI_API_URL", "") + AI_API_KEY: str = os.getenv("AI_API_KEY", "") + + # === Хранилище медиа (S3 / MinIO) === + STORAGE_PROVIDER: str = os.getenv("STORAGE_PROVIDER", "s3") # s3 | minio | local + S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "localpro-media") + S3_REGION: str = os.getenv("S3_REGION", "us-east-1") + S3_ACCESS_KEY: str = os.getenv("S3_ACCESS_KEY", "") + S3_SECRET_KEY: str = os.getenv("S3_SECRET_KEY", "") + + # === Email (уведомления) === + SMTP_HOST: str = os.getenv("SMTP_HOST", "smtp.gmail.com") + SMTP_PORT: int = 587 + SMTP_USER: str = os.getenv("SMTP_USER", "") + SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") + + # === Telegram-бот (уведомления) === + TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "") + TELEGRAM_ADMIN_CHAT_ID: int = int(os.getenv("TELEGRAM_ADMIN_CHAT_ID", "0")) + + # === Модерация отзывов === + REVIEW_MODERATION_AI_ENABLED: bool = True + REVIEW_MIN_REVIEWS_FOR_RATING: int = 3 + + # === Подписки (тарифы) === + SUBSCRIPTION_PLANS: dict = { + "basic": {"monthly_price": 990, "visits_per_month": 1, "discount_pct": 10}, + "standard": {"monthly_price": 2490, "visits_per_month": 3, "discount_pct": 20}, + "premium": {"monthly_price": 4990, "visits_per_month": -1, "discount_pct": 30}, + } + + model_config = {"env_file": ".env", "extra": "ignore"} + + +settings = Settings() diff --git a/backend/src/core/database.py b/backend/src/core/database.py new file mode 100644 index 0000000..1b0b04c --- /dev/null +++ b/backend/src/core/database.py @@ -0,0 +1,207 @@ +"""SQLAlchemy 2.0 async-движок + модели БД""" + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass +from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum +from sqlalchemy.dialects.postgresql import UUID +import uuid +import enum +from datetime import datetime + +from .config import settings + + +# === Engine & Session === +engine = create_async_engine(settings.DATABASE_URL, pool_size=settings.DB_POOL_SIZE) +async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(MappedAsDataclass, DeclarativeBase): + pass + + +# === Enums === +class UserRole(str, enum.Enum): + CLIENT = "client" + MASTER = "master" + ADMIN = "admin" + + +class ProjectStatus(str, enum.Enum): + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class ChatStatus(str, enum.Enum): + ACTIVE = "active" + COMPLETED = "completed" + ARCHIVED = "archived" + + +class ContentType(str, enum.Enum): + TEXT = "text" + IMAGE = "image" + FILE = "file" + VOICE = "voice" + + +# === Models === +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = Column(String(255), unique=True, nullable=False, index=True) + phone: Mapped[str] = Column(String(20), nullable=True) + password_hash: Mapped[str] = Column(String(128), nullable=False) + role: Mapped[UserRole] = Column(SAEnum(UserRole), default=UserRole.CLIENT) + first_name: Mapped[str] = Column(String(100)) + last_name: Mapped[str] = Column(String(100)) + avatar_url: Mapped[str | None] = Column(String(500)) + is_verified: Mapped[bool] = Column(Boolean, default=False) # паспорт/документы + rating_avg: Mapped[float] = Column(Float, default=0.0) + review_count: Mapped[int] = Column(Integer, default=0) + badges: Mapped[list[str]] = Column(JSON, default=list) + created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class MasterProfile(Base): + __tablename__ = "master_profiles" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"), unique=True) + specialization: Mapped[str] = Column(String(100)) # сантехник, электрик и т.д. + description: Mapped[str | None] = Column(Text) + experience_years: Mapped[int] = Column(Integer, default=0) + hourly_rate: Mapped[float] = Column(Float) + city: Mapped[str] = Column(String(100)) + region: Mapped[str] = Column(String(100)) + is_available: Mapped[bool] = Column(Boolean, default=True) + subscription_tier: Mapped[str | None] = Column(String(20)) # basic/standard/premium + created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) + + +class Project(Base): + __tablename__ = "projects" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id")) + master_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id")) + title: Mapped[str] = Column(String(200)) + description: Mapped[str] = Column(Text) + category: Mapped[str] = Column(String(100)) # сантехника, электрика и т.д. + status: Mapped[ProjectStatus] = Column(SAEnum(ProjectStatus), default=ProjectStatus.PENDING) + estimated_cost_min: Mapped[float | None] = Column(Float) + estimated_cost_max: Mapped[float | None] = Column(Float) + actual_cost: Mapped[float | None] = Column(Float) + location_lat: Mapped[float | None] = Column(Float) + location_lng: Mapped[float | None] = Column(Float) + urgency: Mapped[str] = Column(String(20), default="standard") # standard/rush + created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class Review(Base): + __tablename__ = "reviews" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + master_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id")) + client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id")) + project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True) + rating: Mapped[int] = Column(Integer, default=5) # 1-5 + quality_rating: Mapped[int] = Column(Integer, default=5) + punctuality_rating: Mapped[int] = Column(Integer, default=5) + communication_rating: Mapped[int] = Column(Integer, default=5) + professionalism_rating: Mapped[int] = Column(Integer, default=5) + text: Mapped[str | None] = Column(Text) + photos: Mapped[list[str]] = Column(JSON, default=list) # media URLs + verified: Mapped[bool] = Column(Boolean, default=True) + helpful_votes: Mapped[int] = Column(Integer, default=0) + master_response: Mapped[str | None] = Column(Text) + created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) + + +class Chat(Base): + __tablename__ = "chats" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True) + master_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id")) + client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id")) + status: Mapped[ChatStatus] = Column(SAEnum(ChatStatus), default=ChatStatus.ACTIVE) + last_message_at: Mapped[datetime | None] = Column(DateTime) + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + chat_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("chats.id")) + sender_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True)) + content_type: Mapped[ContentType] = Column(SAEnum(ContentType), default=ContentType.TEXT) + content: Mapped[str | None] = Column(Text) # text or transcription of voice + media_url: Mapped[str | None] = Column(String(500)) # image/file URL + reply_to_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True), ForeignKey("chat_messages.id")) + read_at: Mapped[datetime | None] = Column(DateTime) + created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) + + +class PriceEstimate(Base): + __tablename__ = "price_estimates" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True) + category: Mapped[str] = Column(String(100)) + location_lat: Mapped[float | None] = Column(Float) + location_lng: Mapped[float | None] = Column(Float) + complexity: Mapped[str] = Column(String(20), default="medium") # simple/medium/complex + estimated_cost_min: Mapped[float] = Column(Float) + estimated_cost_max: Mapped[float] = Column(Float) + confidence: Mapped[float] = Column(Float, default=0.85) + factors: Mapped[dict] = Column(JSON) # {area, materials_needed, urgency} + created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) + + +class Diagnosis(Base): + __tablename__ = "diagnoses" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True) + problem_description: Mapped[str] = Column(Text) + photos: Mapped[list[str]] = Column(JSON, default=list) + ai_questions: Mapped[list[str]] = Column(JSON, default=list) # уточняющие вопросы от AI + client_answers: Mapped[dict | None] = Column(JSON) + diagnosis_result: Mapped[str | None] = Column(Text) + master_confirmation: Mapped[str | None] = Column(Text) + created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) + + +class Subscription(Base): + __tablename__ = "subscriptions" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id")) + tier: Mapped[str] = Column(String(20)) # basic/standard/premium + monthly_price: Mapped[float] = Column(Float) + visits_per_month: Mapped[int] = Column(Integer) + discount_pct: Mapped[int] = Column(Integer) + starts_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) + ends_at: Mapped[datetime | None] = Column(DateTime) + is_active: Mapped[bool] = Column(Boolean, default=True) + + +class Payment(Base): + __tablename__ = "payments" + + id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True)) + subscription_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True)) + user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id")) + amount: Mapped[float] = Column(Float) + currency: Mapped[str] = Column(String(3), default="RUB") + status: Mapped[str] = Column(String(20), default="pending") # pending/success/failed/refunded + provider: Mapped[str | None] = Column(String(50)) # stripe/yookassa + provider_payment_id: Mapped[str | None] = Column(String(100)) + created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow) diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..57d696a --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,50 @@ +"""LocalPro Finder — FastAPI приложение""" + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +import uvicorn + +from .core.config import settings +from .api.routes import router as api_router + + +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + docs_url="/docs", + redoc_url="/redoc", +) + +# CORS для фронтенда +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # TODO: restrict in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix="/api/v1") + + +@app.websocket("/ws/{project_id}") +async def websocket_endpoint(websocket: WebSocket, project_id: str): + """WebSocket для чата в реальном времени.""" + + await websocket.accept() + try: + while True: + data = await websocket.receive_text() + # TODO: broadcast to chat participants + await websocket.send_text(data) + except WebSocketDisconnect: + pass + + +@app.get("/health") +async def health(): + return {"status": "ok", "version": settings.APP_VERSION} + + +if __name__ == "__main__": + uvicorn.run("src.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/utils/__init__.py b/backend/src/utils/__init__.py new file mode 100644 index 0000000..db3e327 --- /dev/null +++ b/backend/src/utils/__init__.py @@ -0,0 +1 @@ +# utils package diff --git a/backend/src/utils/auth.py b/backend/src/utils/auth.py new file mode 100644 index 0000000..9f1ca55 --- /dev/null +++ b/backend/src/utils/auth.py @@ -0,0 +1,60 @@ +"""JWT-токены и хеширование паролей""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +import jwt +from datetime import datetime, timedelta + +from ..core.config import settings + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +def create_access_token(user_id: str) -> str: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + payload = {"sub": user_id, "exp": expire} + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + +def create_refresh_token(user_id: str) -> str: + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + payload = {"sub": user_id, "exp": expire} + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + +def decode_access_token(token: str) -> dict: + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + raise HTTPException(401, "Токен истёк") + except jwt.InvalidTokenError: + raise HTTPException(401, "Неверный токен") + + +def decode_refresh_token(token: str) -> str: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + return payload["sub"] + + +async def get_current_user(token=Depends(oauth2_scheme)): + """Получить текущего пользователя из токена.""" + + payload = decode_access_token(token) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(401, "Неверный токен") + return {"id": user_id} + + +def hash_password(password: str) -> str: + """Хеширование пароля (bcrypt).""" + + import bcrypt + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode(), salt).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + import bcrypt + return bcrypt.checkpw(plain.encode(), hashed.encode()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ad119c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3.9" + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: localpro + POSTGRES_USER: postgres + POSTGRES_PASSWORD: localpro_secure_pass_2026 + ports: ["5432:5432"] + volumes: [db-data:/var/lib/postgresql/data] + + redis: + image: redis:7-alpine + ports: ["6379:6379"] + + backend: + build: ./backend + ports: ["8000:8000"] + env_file: [./backend/.env] + depends_on: [db, redis] + + frontend: + build: ./frontend + ports: ["3000:3000"] + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 + +volumes: + db-data: diff --git a/frontend/.env.local b/frontend/.env.local new file mode 100644 index 0000000..79f9fd6 --- /dev/null +++ b/frontend/.env.local @@ -0,0 +1,6 @@ +# LocalPro Finder — Frontend .env + +NEXT_PUBLIC_API_URL="http://localhost:8000/api/v1" +NEXT_PUBLIC_WS_URL="ws://localhost:8000/ws" +NEXT_PUBLIC_APP_NAME="LocalPro Finder" +NEXT_PUBLIC_VERSION="1.0.0" diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9b50e82 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "localpro-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.11", + "axios": "^1.7.9", + "date-fns": "^4.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "socket.io-client": "^4.8.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "next": "^15.0.3", + "typescript": "^5.6.3" + } +} diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx new file mode 100644 index 0000000..77cd541 --- /dev/null +++ b/frontend/src/pages/index.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { Star, MessageCircle, Clock, ShieldCheck } from 'lucide-react' + +export default function Home() { + return ( +
+ {/* Hero */} +
+

Мастер за час

+

+ Найдите проверенного мастера за 3 минуты. AI-оценка стоимости, реальные отзывы, гарантия качества. +

+ +
+ + {/* Features */} +
+ {[ + { icon: Star, title: 'Верифицированные отзывы', desc: 'Только после работы. AI-модерация.' }, + { icon: MessageCircle, title: 'Чат с мастером', desc: 'Голосовые сообщения и фото процесса.' }, + { icon: Clock, title: 'AI-оценка стоимости', desc: 'Узнайте цену до выезда мастера.' }, + { icon: ShieldCheck, title: 'Подписка на обслуживание', desc: 'Базовый / Стандарт / Премиум.' }, + ].map((f) => ( +
+ +

{f.title}

+

{f.desc}

+
+ ))} +
+ + {/* Reviews */} +
+

Отзывы клиентов

+ {[ + { name: 'Ирина', rating: 5, text: 'Дмитрий прислал подробный перечень работ с ценами. Одно из самых выгодных предложений!' }, + { name: 'Марина', rating: 4, text: 'Сделал всё быстро и профессионально, учёл все пожелания.' }, + ].map((r) => ( +
+
+ {[...Array(r.rating)].map((_, i) => )} + {r.name} +
+

{r.text}

+
+ ))} +
+ + {/* Pricing */} +
+

Подписка на обслуживание

+
+ {[ + { name: 'Базовый', price: 990, visits: 1, discount: 10 }, + { name: 'Стандарт', price: 2490, visits: 3, discount: 20 }, + { name: 'Премиум', price: 4990, visits: -1, discount: 30 }, + ].map((p) => ( +
+

{p.name}

+

{p.price}₽/мес

+
    + {p.visits > 0 &&
  • {p.visits} выезда в месяц
  • } + {p.visits === -1 &&
  • Безлимитные выезды
  • } +
  • Скидка на доп. работы: {p.discount}%
  • +
+
+ ))} +
+
+ + {/* Footer */} + +
+ ) +}