Вступление

Пол года назад у меня была простая идея: большинство соискателей проводят часы на hh.ru, листая сотни вакансий, но редко находят то, что идеально подходит. Чем больше вакансий, тем дольше поиск. Тем выше риск упустить что-то стоящее. Я подумал — а что если создать платформу, которая использует ИИ для умного анализа?

Так родилась идея HHBro.ru — приложение, которое не просто показывает вакансии с hh.ru, а анализирует каждую через призму вашего резюме и находит идеальные совпадения.

Это был проект, который я разрабатывал в одиночку — от концепции до деплоя. Без финансирования, без команды, только идеи и энтузиазм.

Важное замечание: Как соло-проект без инвестиций, HHBro развивается медленнее, чем хотелось бы. Есть недостатки и баги, есть фичи, которые я хочу добавить, но на которые пока нет времени. Но я полон идей и энтузиазма! В этой статье я поделюсь не только тем, что получилось, но и тем, как я подошел к разработке, какие решения принимал, и как планирую развивать проект дальше.


Проблема, которую я решал

Почему я начал этот проект

  1. Информационная перегрузка — на hh.ru тысячи вакансий, но релевантных единицы

  2. Потеря времени — ручная фильтрация занимает часы

  3. Субъективная оценка — человек может пропустить идеальное совпадение из-за неудачной формулировки

  4. Нет интеллектуального анализа — стандартные фильтры не учитывают контекст и требования

  5. И самое важное - НЕТ ВОЗМОЖНОСТИ ЗАРАНЕЕ ПОНЯТЬ, ПОЧЕМУ НЕЙРОСЕТЬ РАБОТОДАТЕЛЯ ТЕБЯ ОТСЕЕТ ЗА ПЕРВЫЕ 5 МИНУТ.

Что я хотел достичь

  • Автоматизировать анализ вакансий через ИИ

  • Показать соискателю только релевантные должности

  • Дать скор совпадения (match_score) для каждой вакансии

  • Генерировать персонализированные сопроводительные письма

  • Сохранять историю всех проверенных вакансий


Архитектура проекта

Технологический стек

Я выбрал надежные и проверенные технологии:

Backend:

  • Laravel 12 — современ��ый, с автоматической регистрацией команд и боутстрэпом

  • Apiato Framework — контейнеризированная архитектура для модульности

  • PostgreSQL — надежная база данных

  • Laravel Passport — для безопасной авторизации

  • GigaChat API — мощный русскоязычный ИИ для анализа

Frontend:

  • Vue 3 Composition API — реактивность и современный стиль

  • Vite — быстрый бандлер для разработки

  • Tailwind CSS 3 — утилитарные классы для стилей

  • Responsive дизайн — мобильная и десктопная версии

Infrastructure:

  • Laradock + Docker — моя локальная разработка

  • Docker Compose — оркестрация контейнеров

Выбирая стек, я руководствовался простотой и надежностью. Не использовал микросервисы или Kubernetes — это усложнило бы разработку в одиночку. Laravel идеально подходит для solo разработчика благодаря мощным инструментам и активному коммьюнити.

Архитектура приложения

app/Containers/
├── HeadHunter/          # Интеграция с API hh.ru
├── Vacancy/             # Логика вакансий и фильтрации
│   ├── Actions/         # FilterVacanciesAction, SearchVacanciesAction
│   ├── Tasks/           # FetchVacanciesFromHHTask, StoreVacancyTask
│   └── Models/          # Vacancy, VacancyData, VacancyHistory
├── Resume/              # Управление резюме пользователя
├── CoverLetter/         # Генерация сопроводительных писем
├── Credit/              # Система кредитов за использование
└── SavedSearch/         # Сохраненные поиски с автопоиском

Архитектурные принципы, которых я придерживался:

  • Container-based — каждый модуль содержит св��и Models, Actions, Tasks, Routes, Migrations

  • Single Responsibility — каждый класс отвечает за одно

  • Dependency Injection — инверсия управления контролем

  • Database Transactions — ACID гарантии для критичных операций

  • Clean Code — читаемость и поддерживаемость кода для будущих доработок


Ключевые фичи и их реализация

1. Умная фильтрация вакансий через ИИ

Проблема: Как оценить, подходит ли вакансия соискателю объективно?

Решение:

// FilterVacanciesAction.php
foreach ($vacancies as $vacancy) {
    $matchData = $this->aiService->filterVacancy($resume, $vacancy);

    $vacancy->update([
        'match_score' => $matchData['match_score'],  // 0-100
        'match_data' => $matchData,
        'is_filtered' => true,
    ]);

    // Сохраняем в историю только если есть результат
    if ($matchData['match_score'] > 0) {
        VacancyHistory::updateOrCreate(
            ['hh_vacancy_id' => $vacancy->hh_vacancy_id, 'resume_id' => $resume->id],
            ['match_score' => $matchData['match_score'], 'match_data' => $matchData]
        );
    }
}

Ключевые моменты:

  • GigaChat анализирует каждую вакансию vs резюме

  • Match score (0-100): 90-100 отлично, 75-89 хорошо, 60-74 приемлемо, <60 не подходит

  • История сохраняется с уникальным индексом (hh_vacancy_id, resume_id) для обновления при повторной проверке

  • К сожалению, эта нейросеть отказывается анализировать некоторые вакансии, по причине ведомым только ему, ибо даже он сам не может объяснить, чем описание вакансии нарушает его правила, благо таких случаев не много, и за них баланс не списывается.

2. Генерация персонализированных сопроводительных писем

Проблема: Написание письма занимает много времени, особенно если нужно делать их для каждой вакансии.

Решение:

// GenerateCoverLetterAction.php
$content = $this->aiService->generateCoverLetter($resume, $vacancy);

DB::transaction(function () use ($resume, $vacancy, $content) {
    // Сохраняем в cover_letters
    CoverLetter::updateOrCreate(
        ['user_id' => $userId, 'vacancy_id' => $vacancyId],
        ['content' => $content, 'status' => 'draft']
    );

    // И в vacancy_history для быстрого доступа
    VacancyHistory::updateOrCreate(
        ['hh_vacancy_id' => $vacancy->hh_vacancy_id, 'resume_id' => $resume->id],
        ['cover_letter' => $content]
    );
});

Преимущества:

  • Письма хранятся дублированно: в cover_letters для управления, в vacancy_history для быстрого доступа

  • При повторной генерации письмо обновляется (updateOrCreate)

  • Пользователь получает draft, который может отредактировать

3. Система кредитов

Проблема: Как монетизировать, не создавая подписку, которую люди будут избегать?

Решение: Система кредитов (credits):

  • Фильтрация вакансий: 15 CR

  • Генерация сопроводительного письма: 7 CR

  • Пользователь видит баланс и может покупать кредиты по мере необходимости

$cost = (int)config('credits.costs.vacancy_filtering', 10);
$this->deductCredits->run($userId, $cost, 'vacancy_filtering');

4. Автопоиск — поиск по сохраненным критериям (в процессе реализации)

Идея: Пользователь сохраняет критерии поиска (должность, зарплата, город), а система автоматически проверяет новые вакансии каждый день.

Frontend:

// SavedSearch.vue
<div v-if="savedSearches" class="space-y-3">
  <div v-for="search in savedSearches" :key="search.id" class="flex justify-between items-center">
    <span>{{ search.title }}</span>
    <div class="flex gap-2">
      <button @click="triggerAutoSearch(search)">Проверить</button>
      <button @click="deleteSearch(search.id)">Удалить</button>
    </div>
  </div>
</div>

Backend:

// SearchVacanciesAction.php
public function run(int $userId, array $filters): Collection
{
    $query = Vacancy::query()->where('user_id', $userId);

    if ($filters['salary_from']) {
        $query->whereHas('data', fn($q) =>
            $q->where('salary->from', '>=', $filters['salary_from'])
        );
    }

    return $query->with(['data'])->get();
}

5. История вакансий с полной информацией

Таблица vacancy_history:

- id
- user_id
- resume_id
- vacancy_id
- hh_vacancy_id (уникальное ключевое поле с resume_id)
- match_score (0-100)
- match_data (JSON: skills, requirements, analysis)
- cover_letter (сопроводительное письмо)
- vacancy_snapshot (снимок вакансии на момент проверки)
- created_at, updated_at

Уникальный индекс: (hh_vacancy_id, resume_id) — гарантирует, что для одной пары вакансия+резюме есть только один record.


Технические решения и вызовы

Вызов 1: Миграция на Laravel 12

Проблема: Laravel 12 совершенно новый, многие привычные файлы удалены.

Что изменилось:

  • ❌ Нет app/Console/Kernel.php — команды автоматически регистрируются из app/Console/Commands/

  • ❌ Нет app/Http/Middleware/ — middleware регистрируется в bootstrap/app.php

  • ✅ Есть bootstrap/providers.php — для custom service providers

Решение: Изучил документацию фреймворка и адаптировал все команды согласно новой структуре.

Вызов 2: Работа с Apiato контейнерами

Проблема: Миграции в Apiato живут в app/Containers/*/Data/Migrations/, а не в database/migrations/.

Решение: Артисан команды php artisan make:migration автоматически кладут миграции в правильное место. Я использовал Docker для запуска:

docker compose exec workspace php artisan migrate

Вызов 3: Уникальность в vacancy_history

Проблема: Когда вакансия проверяется, нужно обновлять, а не создавать новый record.

Решение:

VacancyHistory::updateOrCreate(
    ['hh_vacancy_id' => $vacancy->hh_vacancy_id, 'resume_id' => $resume->id],
    ['match_score' => $matchData['match_score'], ...]
);

И добавили уникальный индекс в миграции:

$table->unique(['hh_vacancy_id', 'resume_id']);

Миграция автоматически удаляет дубликаты перед добавлением индекса:

DB::statement('
    DELETE FROM vacancy_history
    WHERE id NOT IN (
        SELECT MAX(id)
        FROM vacancy_history
        GROUP BY hh_vacancy_id, resume_id
    )
');

Вызов 4: Сортировка вакансий на frontend

Проблема: Нужно сортировать по разным критериям (релевантность, зарплата, дата, расстояние), некоторые данные могут быть пропущены.

Решение: Computed property в Vue с helper функциями:

const filteredVacancies = computed(() => {
    let result = [...vacancies.value];
    switch (sortOrder.value) {
        case 'salary_asc':
            result.sort((a, b) => getSalaryValue(a) - getSalaryValue(b));
            break;
        case 'distance':
            result.sort((a, b) => getDistance(a) - getDistance(b));
            break;
    }
    return result;
});

function getSalaryValue(vacancy) {
    const salary = vacancy.data?.salary;
    if (!salary || salary.from === undefined || salary.from === null) return Infinity;
    return salary.from;
}

function getDistance(vacancy) {
    const areaId = vacancy.data?.area?.id;
    return areaId ? areaId : Infinity;  // Missing sort to end
}

Вызов 5: Отображение относительного времени

Проблема: Показываем "2 дня назад", но при сортировке по дате выводилось "-1 дня назад".

Решение: Валидация даты с fallback на "сейчас":

function formatPublishedDate(dateString) {
    if (!dateString) return '';

    const date = new Date(dateString);
    const timestamp = date.getTime();

    if (isNaN(timestamp) || timestamp <= 0) return 'сейчас';

    const now = new Date();
    const diffMs = now - date;

    if (diffMs < 0 || diffMs < 1000) return 'сейчас';

    // ... расчет дней/часов/минут
    return formatRelativeTime(diffMs);
}

Вызовы solo разработчика

Разработка в одиночку — это особый опыт. Вот с чем я сталкивался:

1. Временные ограничения

  • Когда я — разработчик, дизайнер, PM, QA и DevOps одновременно, на каждую фичу требуется больше времени

  • Некоторые идеи приходится отложить, потому что на них просто нет часов

  • Приходится выбирать: делать хорошо одну фичу или плохо десять

Как я это решал: Фокусировался на MVP (минимальном жизнеспособном продукте). Основной функционал работает надежно, остальное — в backlog.

2. Отсутствие финансирования

  • Все инструменты, которые я использую, — бесплатные или очень дешевые

  • Не могу позволить себе сложную инфраструктуру (Kubernetes, микросервисы)

  • Сервера стоят денег, поэтому я оптимизирую расход ресурсов

Как я это решал: Монолит на Laravel вместо микросервисов, PostgreSQL вместо облака, Docker локально вместо K8s. Все работает и стоит в разы дешевле.

3. Отсутствие команды для feedback

  • Нет тимлидов, которые бы ревьюили код

  • Нет дизайнеров, которые бы критиковали UI

  • Нет PM, который проверит логику бизнеса

Как я это решал: Писал чистый код с самого начала, документировал свои решения, тестировал функционал вручную. ДОкументация помогает не забыть о новых фишках фреймворка.

4. Баги и недостатки

  • Я могу пропустить edge case, потому что тестирую в одиночку

  • Производительность может падать в моментах пиковой нагрузки

  • UI может быть не очень интуитивным, потому что я смотрел на него слишком долго

Честность: Я стараюсь находить и фиксить баги, но не все успеваю. Если вы найдете проблему — дайте мне знать в комментариях!

5. Выгорание

  • Когда ты один, ответственность за все лежит только на тебе

  • Не с кем обсудить сложные архитектурные решения

  • Мотивация может упасть, когда ты не видишь прогресса

Как я борюсь: Делю разработку на спринты, праздную маленькие победы, помню, почему я начал этот проект.


Итоговая архитектура системы

┌─────────────────────────────────────────────────────────────┐
│                     Frontend (Vue 3)                         │
│  VacanciesSearch → VacanciesList → VacancyCard              │
└────────────────────────┬────────────────────────────────────┘
                         │ API Requests
                         ↓
┌─────────────────────────────────────────────────────────────┐
│                 API Routes (Laravel)                         │
│  /api/vacancies/filter                                       │
│  /api/vacancies/search                                       │
│  /api/cover-letters/generate                                 │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ↓
┌─────────────────────────────────────────────────────────────┐
│                  Actions & Tasks                             │
│  FilterVacanciesAction                                       │
│  SearchVacanciesAction                                       │
│  GenerateCoverLetterAction                                   │
│  FetchVacanciesFromHHTask                                    │
└────────────────────────┬────────────────────────────────────┘
                         │
        ┌────────────────┼────────────────┐
        ↓                ↓                ↓
   ┌─────────┐  ┌──────────────┐  ┌─────────────┐
   │  Models │  │  Database    │  │ External AI │
   │ Vacancy │  │ vacancy_*    │  │  (GigaChat) │
   │ Resume  │  │ resume_*     │  │             │
   │ History │  │ cover_*      │  └─────────────┘
   │ Credits │  └──────────────┘
   └─────────┘

Разделы и функционал приложения

1. 🏠 Главная страница (Dashboard - в плане)

Что это? Первое, что видит пользователь при входе. Показывает общую статистику и быстрый доступ ко всем функциям.

Функционал:

  • Баланс кредитов и история расходования

  • Активное резюме (краткая информация)

  • Количество проверенных вакансий

  • Последние совпадения (TOP 5 с лучшим match_score)

  • Быстрые действия (загрузить новые вакансии, создать резюме)

Технические детали:

// DashboardController
public function show(int $userId): array
{
    $user = User::find($userId);
    return [
        'credits' => $user->credits_balance,
        'resume' => $user->resumes()->where('is_active', true)->first(),
        'vacancies_checked' => $user->vacancy_history()->count(),
        'best_matches' => $user->vacancy_history()
            ->where('match_score', '>=', 75)
            ->orderByDesc('match_score')
            ->limit(5)
            ->get(),
    ];
}

2. 📋 Раздел "Мои резюме" (Resumes - в плане)

Что это? Управление резюме пользователя. Вы можете загружать, редактировать и выбирать активное резюме.

Функционал:

  • Загрузка резюме (парсинг из PDF или текст вручную)

  • Просмотр резюме с анализом (ключевые навыки, опыт, образование)

  • Выбор активного резюме (используется для фильтрации)

  • Редактирование резюме прямо в приложении

  • Сортировка резюме по дате создания или обновления

  • Автосохранение порядка резюме (для быстрого переключения)

Frontend (Vue 3):

<template>
  <div class="resumes-container">
    <button @click="openUploadModal">+ Загрузить новое резюме</button>

    <div v-for="resume in resumes" :key="resume.id" class="resume-card">
      <h3>{{ resume.title }}</h3>
      <p>{{ resume.skills.length }} навыков</p>
      <p>{{ resume.experience_years }} лет опыта</p>

      <button
        v-if="!resume.is_active"
        @click="setActive(resume.id)"
      >
        Сделать активным
      </button>
      <span v-else class="badge">✓ Активно</span>

      <button @click="editResume(resume.id)">Редактировать</button>
      <button @click="deleteResume(resume.id)">Удалить</button>
    </div>
  </div>
</template>

Backend (Laravel Action):

namespace App\Containers\AppSection\Resume\Actions;

final class UpdateSortOrderAction extends ParentAction
{
    public function run(int $userId, array $resumeIds): void
    {
        foreach ($resumeIds as $index => $resumeId) {
            Resume::query()
                ->where('id', $resumeId)
                ->where('user_id', $userId)
                ->update(['vacancy_search_order' => $index]);
        }
    }
}

3. 🎯 Раздел "Вакансии" (Vacancies)

Что это? Основной раздел приложения. Здесь происходит поиск и анализ вакансий через ИИ.

Функционал:

3.1 Автопоиск (Search)

  • Поиск по релевнатности резюме и вакансий

  • Интеграция с hh.ru API для получения актуальных вакансий

  • Система итеративно обходит большой объем вакансий

  • Оценивает чанками по 50 загруженные вакансии

  • Анализирует

  • Оставляет только те, что выше указанного уровня совпадения, пока не отберет 50 наиболее топовых.

// SearchVacanciesAction
final class SearchVacanciesAction extends ParentAction
{
    public function run(int $userId, array $filters): Collection
    {
        // Получаем вакансии с hh.ru API
        $hhVacancies = $this->callApiTask->searchVacancies($filters);

        // Сохраняем в БД
        foreach ($hhVacancies as $hhVacancy) {
            $vacancy = Vacancy::updateOrCreate(
                ['hh_vacancy_id' => $hhVacancy['id'], 'user_id' => $userId],
                ['data' => $hhVacancy, 'is_filtered' => false]
            );
            $vacancies[] = $vacancy;
        }

        return collect($vacancies);
    }
}

3.2 AI Фильтрация (Filter)

  • Анализ каждой вакансии через GigaChat (будут и аналоги)

  • Присвоение match_score (0-100)

  • Обновление истории проверок

  • Генерация структурированной информации о совпадениях

// FilterVacanciesAction
foreach ($vacancies as $vacancy) {
    $matchData = $this->aiService->filterVacancy($resume, $vacancy);

    $vacancy->update([
        'match_score' => $matchData['match_score'],
        'match_data' => $matchData,  // JSON: skills match, salary, location
        'is_filtered' => true,
    ]);

    if ($matchData['match_score'] > 0) {
        VacancyHistory::updateOrCreate(
            ['hh_vacancy_id' => $vacancy->hh_vacancy_id, 'resume_id' => $resume->id],
            [
                'match_score' => $matchData['match_score'],
                'match_data' => $matchData,
                'vacancy_snapshot' => $vacancy->toArray(),
            ]
        );
    }
}

3.3 Вакансии (List)

  • Карточки вакансий с preview

  • Динамическая сортировка:

    • По релевантности (match_score ↓)

    • По зарплате (↑ или ↓)

    • По дате публикации (↓)

    • По расстоянию (↑)

  • Фильтр по match_score (скрыть вакансии с низким score)

  • Быстрый доступ на hh.ru

Vue компоненты:

<!-- VacanciesList.vue -->
- v-for loop по filteredVacancies
- Сортировка через computed property
- Фильтр match_score через slider
- Для каждой вакансии:
  - VacancyCard (основная информация)
    - VacancyCardHeader (заголовок, зарплата, skills)
    - VacancyCardBody (описание, требования)
    - VacancyCardFooter (действия)

4. 💌 Раздел "Письмо" (Cover Letters)

Что это? Управление автоматически генерируемыми сопроводительными письмами. Пишет письма за вас через ИИ.

Функционал:

  • Генерация письма для выбранной вакансии

  • Редактирование письма в интерфейсе

  • Сохранение черновиков (статус: draft, sent)

  • История всех написанных писем

  • Быстрое копирование в буфер обмена

  • Отправка письма вместе с откликом на hh.ru

Как это работает:

  1. Пользователь открывает вакансию

  2. Нажимает "Сгенерировать письмо"

  3. Система вычитает CR

  4. GigaChat анализирует: резюме + требования вакансии → генерирует персональное письмо

  5. Письмо сохраняется в cover_letters и в vacancy_history

  6. Пользователь видит draft, может отредактировать

  7. Может скопировать и отправить на hh.ru или откликнуться прямо из приложения.

// GenerateCoverLetterAction
public function run(int $userId, int $vacancyId): CoverLetter
{
    $vacancy = Vacancy::find($vacancyId);
    $resume = Resume::where('user_id', $userId)->where('is_active', true)->firstOrFail();

    // Вычитаем кредиты
    $this->deductCredits->run($userId, 20, 'cover_letter_generation');

    // Генерируем письмо через AI
    $content = $this->aiService->generateCoverLetter($resume, $vacancy);

    return DB::transaction(function () use ($userId, $vacancyId, $resume, $vacancy, $content) {
        // Сохраняем в cover_letters
        $coverLetter = CoverLetter::updateOrCreate(
            ['user_id' => $userId, 'vacancy_id' => $vacancyId],
            ['content' => $content, 'status' => 'draft']
        );

        // И в vacancy_history для быстрого доступа
        VacancyHistory::updateOrCreate(
            ['hh_vacancy_id' => $vacancy->hh_vacancy_id, 'resume_id' => $resume->id],
            ['cover_letter' => $content]
        );

        return $coverLetter;
    });
}

5. 🔍 Блок "Сохраненные поиски" (Saved Searches)

Что это? Автоматический поиск вакансий по вашим критериям.

Функционал:

  • Выбор поискового запроса с hh (должность, зарплата, город, опыт)

  • Указание резюме для анализа

  • Автоматическая проверка новых вакансий (планируется ежедневно или по расписанию)

  • История автопоисков с результатами

  • Включение/отключение сохраненного поиска

  • Сортировка поисков по дате или релевантности

Frontend:

<!-- SavedSearch.vue -->
<template>
  <div class="saved-searches">
    <button @click="openCreateModal">+ Создать поиск</button>

    <div v-for="search in savedSearches" :key="search.id" class="search-card">
      <h3>{{ search.title }}</h3>
      <p class="criteria">
        {{ search.position }} • {{ search.salary_from }} ₽ • {{ search.city }}
      </p>
      <p class="last-checked">Проверено: {{ formatDate(search.last_checked) }}</p>
      <p class="found">Найдено: {{ search.found_count }} вакансий</p>

      <div class="actions">
        <button @click="triggerSearch(search.id)">Проверить сейчас</button>
        <button @click="editSearch(search.id)">Редактировать</button>
        <button @click="viewResults(search.id)">История</button>
        <button @click="deleteSearch(search.id)">Удалить</button>
      </div>
    </div>
  </div>
</template>

Backend:

// SearchVacanciesAction
final class SearchVacanciesAction extends ParentAction
{
    public function run(int $userId, array $filters): Collection
    {
        $query = Vacancy::query()->where('user_id', $userId);

        if (isset($filters['position'])) {
            $query->whereHas('data', fn($q) =>
                $q->where('title', 'ilike', "%{$filters['position']}%")
            );
        }

        if (isset($filters['salary_from'])) {
            $query->whereHas('data', fn($q) =>
                $q->where('salary->from', '>=', $filters['salary_from'])
            );
        }

        if (isset($filters['area'])) {
            $query->whereHas('data', fn($q) =>
                $q->where('area->id', $filters['area'])
            );
        }

        return $query->with(['data'])->get();
    }
}

6. 📊 Раздел "История" (Vacancy History)

Что это? Полная история всех проверенных вакансий. Одна запись = одна вакансия проверена резюме.

Структура записи:

{
  "id": 1,
  "user_id": 42,
  "resume_id": 5,
  "vacancy_id": 123,
  "hh_vacancy_id": 128244402,
  "match_score": 85,
  "match_data": {
    "skills_match": ["Python", "Django", "PostgreSQL"],
    "salary_match": "120k - 180k",
    "location_match": "Moscow, Remote possible",
    "analysis": "Отличное совпадение по технологиям..."
  },
  "cover_letter": "Уважаемый работодатель...",
  "vacancy_snapshot": { /* полные данные вакансии на момент проверки */ },
  "created_at": "2025-12-01 10:30:00",
  "updated_at": "2025-12-01 15:45:00"
}

Функционал:

  • Просмотр всех проверенных вакансий

  • Фильтр по match_score, дате, компании

  • Поиск по названию вакансии

  • Просмотр сохраненного письма

  • Просмотр снимка вакансии (как выглядела на момент проверки)

  • Экспорт в CSV или PDF

Database Query:

// Получить историю для пользователя
$history = VacancyHistory::query()
    ->where('user_id', $userId)
    ->with(['resume', 'vacancy'])
    ->where('match_score', '>=', $minScore)
    ->orderByDesc('match_score')
    ->paginate(50);

7. 💳 Раздел "Кредиты" (Credits)

Что это? Система платежей и баланса кредитов. Пользователь покупает кредиты и расходует их на операции.

Функционал:

  • Текущий баланс кредитов

  • История транзакций (покупки + расходование)

  • Тарифы:

    • Фильтрация 50 вакансий: 15 CR (~150р)

    • Генерация письма: 5 CR (~50р)

  • Различные способы пополнения

  • Рекомендация купить кредиты при недостатке баланса

// DeductCreditsAction
final class DeductCreditsAction extends ParentAction
{
    public function run(int $userId, int $amount, string $type, ?int $entityId = null): bool
    {
        $user = User::find($userId);

        if ($user->credits_balance < $amount) {
            throw new RuntimeException('Insufficient credits');
        }

        return DB::transaction(function () use ($user, $amount, $type, $entityId) {
            $user->decrement('credits_balance', $amount);

            CreditTransaction::create([
                'user_id' => $user->id,
                'amount' => -$amount,
                'type' => $type,
                'entity_id' => $entityId,
                'balance_before' => $user->credits_balance + $amount,
                'balance_after' => $user->credits_balance,
            ]);

            return true;
        });
    }
}

8. ⚙️ Раздел "Настройки" (Settings)

Что это? Персональные настройки пользователя и приватность.

Функционал:

  • Изменение пароля

  • Двухфакторная аутентификация (2FA)

  • Notification preferences (email уведомления)

  • Управление API ключами (для интеграций)

  • Экспорт личных данных

  • Удаление аккаунта

  • История входов и подозрительная активность


Результаты и метрики

За время разработки я:

  • ✅ Создал полнофункциональное приложение с умной фильтрацией

  • ✅ Интегрировался с API hh.ru для получения вакансий в реальном времени

  • ✅ Подключил GigaChat для анализа через ИИ

  • ✅ Реализовал систему кредитов для монетизации

  • ✅ Построил модульную архитектуру на Apiato для масштабирования

  • ✅ Развернул на Docker для надежности

  • И все это, мать его, за 3 недели и ящик энергетиков:)

Ключевые числа:

  • ~500 строк кода для фильтрации и анализа

  • ~200 строк Vue компонентов

  • ~10 миграций для построения схемы БД

  • 1 уникальная идея = 50 дней разработки


Чему я научился

1. Фреймворк важен, но идея важнее

Laravel 12 отлично работает, Apiato отличная архитектура, но без четкой идеи это все не имеет значения. Я начал с проблемы (потеря времени на поиск работы) и выстроил все вокруг ее решения.

2. Интеграции с внешним ИИ требуют надежности

GigaChat API может быть медленным или недоступным. Я добавил:

  • Обработку ошибок и retry логику

  • Логирование всех запросов

  • Graceful degradation (показываю результаты без match_score, если ИИ упал)

3. Уникальные индексы спасают жизнь

Когда я добавил updateOrCreate, я осознал, что нужны уникальные индексы. Миграция, которая очищает дубликаты перед добавлением индекса — это спасение.

4. История данных важна

Сохранение в vacancy_history полного снимка вакансии (vacancy_snapshot) позволяет мне:

  • Видеть, как менялись требования работодателя

  • Анализировать тренды на рынке

  • Отладить проблемы в фильтрации

5. Документация = будущее вам спасибо

Laravel 12 специфики, комментарии в коде, структурированный контроллер — все это окупается в 10 раз при поддержке.


Что дальше

Планы на развитие:

  1. Расширение интеграций — поддержка других job boards (Rabota.ru, Superjob)

  2. ML модели — собственные модели вместо GigaChat для меньших затрат

  3. Аналитика — показываем пользователю тренды на рынке труда

  4. Мобильное приложение — нативное iOS/Android

  5. Соц. сеть соискателей — делиться опытом интервью, хм, было бы прикольно.


Заключение

Создание HHBro показало мне, что:главная ценность не в технологии, а в решении реальной проблемы.

Я мог использовать микросервисы, Kubernetes и GraphQL, но вместо этого выбрал:

  • Монолит на Laravel (проще масштабировать)

  • REST API (понимают все)

  • PostgreSQL (надежна и быстра)

  • Vue 3 (простой и мощный frontend)

И это сработало. Соискатели получили инструмент, который экономит часы их времени. Я получил опыт, данные и базу пользователей.

Если вы стартапер: начните с простого, валидируйте идею, потом усложняйте архитектуру. Не наоборот. Не нужна команда и финансирование, чтобы запустить что-то интересное — нужна идея и энтузиазм.

Спасибо за внимание! Если у вас вопросы по архитектуре, интеграциям или Laravel — добро пожаловать в комментарии.