diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py deleted file mode 100644 index cf2c6c0..0000000 --- a/backend/src/api/routes/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Роуты 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 deleted file mode 100644 index 0145b9e..0000000 --- a/backend/src/api/routes/auth.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Аутентификация — регистрация, логин, 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 deleted file mode 100644 index cc56225..0000000 --- a/backend/src/api/routes/chats.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Чат — WebSocket + REST для сообщений""" - -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update -import uuid - -from ...core.database import async_session_factory, Chat, ChatMessage, ContentType, ChatStatus -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) - - 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 deleted file mode 100644 index d8037d2..0000000 --- a/backend/src/api/routes/diagnosis.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Онлайн-диагностика — 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 deleted file mode 100644 index 688b9e7..0000000 --- a/backend/src/api/routes/price_estimate.py +++ /dev/null @@ -1,78 +0,0 @@ -"""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 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 deleted file mode 100644 index cffa7d9..0000000 --- a/backend/src/api/routes/projects.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Проекты — создание, назначение мастера, статусы""" - -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 deleted file mode 100644 index 04da8a9..0000000 --- a/backend/src/api/routes/ratings.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Рейтинги — расчёт, бейджи, выдача мастеров по рейтингу""" - -from fastapi import APIRouter, Query, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -import uuid - -from ...core.database import async_session_factory, MasterProfile, Review - - -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()) - 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 deleted file mode 100644 index 900ffce..0000000 --- a/backend/src/api/routes/reviews.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Отзывы — создание, модерация, верификация""" - -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 deleted file mode 100644 index 1186bd5..0000000 --- a/backend/src/api/routes/subscriptions.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Подписки — тарифы, оплата, управление""" - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -import uuid - -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} - - -from sqlalchemy import select -from datetime import timedelta, datetime diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 62afcaa..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -version: "3.9" - -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB: freelancer_match - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - backend: - build: ./backend - environment: - DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/freelancer_match - REDIS_URL: redis://redis:6379/0 - SECRET_KEY: ${SECRET_KEY} - OPENAI_API_KEY: ${OPENAI_API_KEY} - ports: - - "8000:8000" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - - frontend: - build: ./frontend - environment: - NEXT_PUBLIC_API_URL: http://backend:8000 - ports: - - "3000:3000" - depends_on: - - backend - -volumes: - pgdata: diff --git a/localpro-finder-spec.md b/localpro-finder-spec.md deleted file mode 100644 index efaeea3..0000000 --- a/localpro-finder-spec.md +++ /dev/null @@ -1,198 +0,0 @@ -# LocalPro Finder — Спецификация фичей (Reviews, Ratings, Chat) - -## Анализ конкурентов - -### TaskRabbit -- **Отзывы:** Только после завершения задачи, верифицированные покупателем -- **Рейтинг:** 5-звёздочный с разбивкой по категориям (качество, пунктуальность, коммуникация) -- **Чат:** Встроенный мессенджер между клиентом и мастером в приложении -- **Безопасность:** Проверка личности и криминального прошлого мастера - -### Thumbtack -- **Отзывы:** Детальные текстовые отзывы с фото работ, верификация через платформу -- **Рейтинг:** 5-звёздочный + "Top Pro" бейдж для лучших мастеров -- **Чат:** Встроенный чат до и после найма мастера - -### HomeAdvisor -- **Отзывы:** Верифицированные отзывы с подтверждением работы, True Cost Guide -- **Рейтинг:** 5-звёздочный + лицензия/страховка мастера -- **Чат:** Через платформу, без показа личных контактов до найма - -### Профи.ру (RU) -- **Отзывы:** Только после работы, проверка каждого отзыва, "Пять с плюсом" бейдж -- **Рейтинг:** 5-звёздочный + количество отзывов влияет на позицию в выдаче -- **Чат:** Мастера пишут сами клиенту, чат внутри платформы - ---- - -## Фича 1: Система отзывов и рейтингов - -### Модель данных -```yaml -Review: - id: uuid - master_id: uuid - client_id: uuid - project_id: uuid (обязательно для верификации) - rating: int(1-5) - categories: - quality: int(1-5) - punctuality: int(1-5) - communication: int(1-5) - professionalism: int(1-5) - text: string(max 2000) - photos: array[media_url] (до 5 фото работ) - verified: bool (только после завершения проекта) - helpful_votes: int - created_at: datetime - updated_at: datetime -``` - -### Правила -- Отзыв можно оставить **только** после завершённого проекта -- Каждый отзыв проходит модерацию (AI + ручная проверка для подозрительных) -- Мастер может ответить на отзыв в течение 7 дней -- Клиент может отредактировать отзыв в течение 48 часов -- Отзывы с фото получают приоритет в выдаче - -### Расчёт рейтинга мастера -```python -rating = (reviews.aggregate(rating) * 0.6 + - reviews.aggregate(quality) * 0.25 + - reviews.count() * 0.15) -# Минимум 3 отзыва для отображения рейтинга -``` - -### Бейджи и уровни -- ⭐ "Новичок" — < 10 отзывов -- ⭐⭐ "Надёжный" — 10+ отзывов, рейтинг > 4.5 -- ⭐⭐⭐ "Профи" — 50+ отзывов, рейтинг > 4.7 -- 🏆 "Мастер года" — топ-3 в категории по отзывам - ---- - -## Фича 2: Встроенный чат - -### Архитектура -```yaml -ChatMessage: - id: uuid - chat_id: uuid (project-based) - sender_id: uuid - content_type: enum[text, image, file, voice] - content: string/blob - reply_to: uuid (reply to message) - read_at: datetime - created_at: datetime - -Chat: - id: uuid - project_id: uuid - master_id: uuid - client_id: uuid - status: enum[active, completed, archived] - last_message_at: datetime -``` - -### Функционал -- **Текстовые сообщения** — мгновенная доставка (WebSocket) -- **Голосовые сообщения** — до 2 минут, конвертация в текст для поиска -- **Фото работ** — мастер может присылать фото процесса/результата -- **Файлы** — договоры, сметы, документы -- **Ответ на сообщение** (reply) -- **Статус прочтения** (двойные галочки) -- **Поиск по чату** — по ключевым словам - -### Правила безопасности -- Контакты мастеров скрыты до начала проекта -- Чат ведётся только в рамках активного проекта -- История сохраняется 2 года после завершения -- Модерация на предмет оскорблений и спама - ---- - -## Фича 3: AI-оценка стоимости (до выезда) - -### Модель -```yaml -PriceEstimate: - project_id: uuid - category: string - location: geo - complexity: enum[simple, medium, complex] - estimated_cost_min: decimal - estimated_cost_max: decimal - confidence: float(0-1) - factors: - - {name: "area", value: sqm} - - {name: "materials_needed": bool} - - {name: "urgency": enum[standard, rush]} -``` - -### Источники данных для обучения -- Исторические цены по категориям и регионам -- Средние чеки конкурентов (TaskRabbit, Профи.ру) -- Региональные коэффициенты стоимости работ -- Сезонность спроса - ---- - -## Фича 4: Онлайн-диагностика проблемы - -### Flow -1. Клиент описывает проблему (текст + фото) -2. AI анализирует и задаёт уточняющие вопросы -3. Мастер получает диагностику перед выездом -4. Мастер подтверждает/корректирует оценку - -### Пример -``` -Клиент: "Потёк кран на кухне, капает" -AI → Вопросы: "Какой тип крана? (однорычажный / с двумя ручками)" -Мастер → "Выезжаю, замена картриджа ~1500₽" -``` - ---- - -## Фича 5: Подписка на обслуживание дома - -### Тарифы -| Пакет | Цена/мес | Включено | -|-------|----------|----------| -| Базовый | 990₽ | 1 выезд/мес, скидка 10% на доп. работы | -| Стандарт | 2490₽ | 3 выезда/мес, приоритетный вызов, скидка 20% | -| Премиум | 4990₽ | Безлимитные выезды, мастер в резерве, скидка 30% | - -### Преимущества подписки -- Фиксированная цена на типовые работы -- Приоритетный выезд (в течение 2 часов) -- Персональный менеджер -- Бесплатная диагностика - ---- - -## Технический стек для реализации - -### Backend -- **Язык:** Python (FastAPI) / Node.js (NestJS) -- **База данных:** PostgreSQL + Redis (кэш рейтингов) -- **Чат:** WebSocket (Socket.IO / Pusher) -- **Хранилище медиа:** S3-compatible (MinIO) - -### Frontend -- **Mobile-first:** React Native / Flutter -- **Web:** Next.js (SSR для SEO) - -### AI/ML -- **Оценка стоимости:** XGBoost + исторические данные -- **Диагностика:** Fine-tuned LLM (Qwen 7B или аналог) -- **Модерация отзывов:** BERT classifier - ---- - -## Приоритеты разработки - -1. **MVP (2 недели):** Чат + базовые отзывы -2. **V1 (4 недели):** Рейтинги + AI-оценка стоимости -3. **V2 (6 недель):** Онлайн-диагностика + подписки -4. **V3 (8 недель):** Бейджи, модерация, аналитика