Вступление
Пол года назад у меня была простая идея: большинство соискателей проводят часы на hh.ru, листая сотни вакансий, но редко находят то, что идеально подходит. Чем больше вакансий, тем дольше поиск. Тем выше риск упустить что-то стоящее. Я подумал — а что если создать платформу, которая использует ИИ для умного анализа?
Так родилась идея HHBro.ru — приложение, которое не просто показывает вакансии с hh.ru, а анализирует каждую через призму вашего резюме и находит идеальные совпадения.
Это был проект, который я разрабатывал в одиночку — от концепции до деплоя. Без финансирования, без команды, только идеи и энтузиазм.
Важное замечание: Как соло-проект без инвестиций, HHBro развивается медленнее, чем хотелось бы. Есть недостатки и баги, есть фичи, которые я хочу добавить, но на которые пока нет времени. Но я полон идей и энтузиазма! В этой статье я поделюсь не только тем, что получилось, но и тем, как я подошел к разработке, какие решения принимал, и как планирую развивать проект дальше.
Проблема, которую я решал
Почему я начал этот проект
Информационная перегрузка — на hh.ru тысячи вакансий, но релевантных единицы
Потеря времени — ручная фильтрация занимает часы
Субъективная оценка — человек может пропустить идеальное совпадение из-за неудачной формулировки
Нет интеллектуального анализа — стандартные фильтры не учитывают контекст и требования
И самое важное - НЕТ ВОЗМОЖНОСТИ ЗАРАНЕЕ ПОНЯТЬ, ПОЧЕМУ НЕЙРОСЕТЬ РАБОТОДАТЕЛЯ ТЕБЯ ОТСЕЕТ ЗА ПЕРВЫЕ 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
Как это работает:
Пользователь открывает вакансию
Нажимает "Сгенерировать письмо"
Система вычитает CR
GigaChat анализирует: резюме + требования вакансии → генерирует персональное письмо
Письмо сохраняется в cover_letters и в vacancy_history
Пользователь видит draft, может отредактировать
Может скопировать и отправить на 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 раз при поддержке.
Что дальше
Планы на развитие:
Расширение интеграций — поддержка других job boards (Rabota.ru, Superjob)
ML модели — собственные модели вместо GigaChat для меньших затрат
Аналитика — показываем пользователю тренды на рынке труда
Мобильное приложение — нативное iOS/Android
Соц. сеть соискателей — делиться опытом интервью, хм, было бы прикольно.
Заключение

Создание HHBro показало мне, что:главная ценность не в технологии, а в решении реальной проблемы.
Я мог использовать микросервисы, Kubernetes и GraphQL, но вместо этого выбрал:
Монолит на Laravel (проще масштабировать)
REST API (понимают все)
PostgreSQL (надежна и быстра)
Vue 3 (простой и мощный frontend)
И это сработало. Соискатели получили инструмент, который экономит часы их времени. Я получил опыт, данные и базу пользователей.
Если вы стартапер: начните с простого, валидируйте идею, потом усложняйте архитектуру. Не наоборот. Не нужна команда и финансирование, чтобы запустить что-то интересное — нужна идея и энтузиазм.
Спасибо за внимание! Если у вас вопросы по архитектуре, интеграциям или Laravel — добро пожаловать в комментарии.
