chore: убрать файлы LocalPro Finder из Freelancer Match

This commit is contained in:
2026-07-03 16:18:48 +00:00
parent 4cb0da58ff
commit 80c7775cc9
11 changed files with 0 additions and 1056 deletions
-22
View File
@@ -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"])
-107
View File
@@ -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,
}
-121
View File
@@ -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()]
-77
View File
@@ -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
-78
View File
@@ -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
-87
View File
@@ -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()]
-78
View File
@@ -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
-141
View File
@@ -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"}
-92
View File
@@ -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
-55
View File
@@ -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:
-198
View File
@@ -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 недель):** Бейджи, модерация, аналитика