← Назад к вопросам
Сервис записи к врачу с напоминаниями
3.0 Senior🔥 221 комментариев
#Архитектура и паттерны#Базы данных и SQL#Брокеры сообщений и очереди
Условие
Спроектируйте и опишите архитектуру сервиса с API для записи пациента на прием к врачу с напоминанием за 2 часа до приема.
Требования:
- Создание записи на прием (пациент, врач, дата/время)
- Автоматическое напоминание за 2 часа до приема
- Отмена записи
- Получение списка записей пациента
Вопросы для обсуждения:
- Как реализовать отложенные напоминания?
- Какую очередь использовать (Bull, RabbitMQ, Redis)?
- Как обрабатывать отмену напоминания при отмене записи?
- Структура базы данных
- Обработка ошибок при отправке напоминания
Что проверяется
- Проектирование систем
- Работа с очередями
- Cron jobs vs очереди
- Обработка edge cases
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Решение: Архитектура сервиса записи к врачу с напоминаниями
1. Архитектура системы
Компоненты:
┌─────────────┐
│ API │ (Express/Fastify)
│ Endpoints │
└──────┬──────┘
│
├──► AppointmentService (бизнес-логика)
│
├──► ReminderQueue (Bull/Redis)
│
├──► Database (PostgreSQL)
│
└──► NotificationService (SMS/Push/Email)
Основные микросервисы:
- API Gateway: обработка запросов пациентов и врачей
- AppointmentService: управление записями
- ReminderWorker: обработка задач из очереди
- NotificationService: отправка напоминаний
2. Структура базы данных
-- Таблица пациентов
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) NOT NULL UNIQUE,
email VARCHAR(255),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Таблица врачей
CREATE TABLE doctors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
specialty VARCHAR(100),
phone VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Таблица записей на приём
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
doctor_id UUID NOT NULL REFERENCES doctors(id),
appointment_at TIMESTAMPTZ NOT NULL,
status VARCHAR(50) DEFAULT 'scheduled', -- scheduled, completed, cancelled
reminder_sent BOOLEAN DEFAULT FALSE,
reminder_sent_at TIMESTAMPTZ,
cancellation_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(doctor_id, appointment_at),
INDEX(patient_id),
INDEX(appointment_at),
INDEX(status)
);
-- Таблица истории напоминаний
CREATE TABLE reminder_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
appointment_id UUID NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
sent_at TIMESTAMPTZ,
status VARCHAR(50), -- pending, sent, failed, cancelled
error_message TEXT,
retry_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
3. Реализация на Node.js
API Endpoints
// routes/appointments.ts
import { Router, Request, Response } from 'express';
import { AppointmentService } from '../services/AppointmentService';
import { ReminderQueue } from '../queue/ReminderQueue';
const router = Router();
const appointmentService = new AppointmentService();
const reminderQueue = new ReminderQueue();
// Создание записи
router.post('/appointments', async (req: Request, res: Response) => {
try {
const { patientId, doctorId, appointmentTime } = req.body;
// Валидация
if (new Date(appointmentTime) <= new Date()) {
return res.status(400).json({ error: 'Appointment time must be in future' });
}
const appointment = await appointmentService.createAppointment({
patientId,
doctorId,
appointmentAt: appointmentTime
});
// Добавляем задачу напоминания в очередь
const reminderTime = new Date(appointmentTime).getTime() - 2 * 60 * 60 * 1000;
await reminderQueue.addReminder(appointment.id, reminderTime);
res.status(201).json(appointment);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Получение записей пациента
router.get('/patients/:patientId/appointments', async (req: Request, res: Response) => {
try {
const { patientId } = req.params;
const appointments = await appointmentService.getPatientAppointments(patientId);
res.json(appointments);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Отмена записи
router.delete('/appointments/:appointmentId', async (req: Request, res: Response) => {
try {
const { appointmentId } = req.params;
const { reason } = req.body;
// Отменяем запись
await appointmentService.cancelAppointment(appointmentId, reason);
// Удаляем задачу напоминания из очереди
await reminderQueue.cancelReminder(appointmentId);
res.json({ message: 'Appointment cancelled' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;
AppointmentService
// services/AppointmentService.ts
import { db } from '../database';
import { v4 as uuidv4 } from 'uuid';
export class AppointmentService {
async createAppointment(data: {
patientId: string;
doctorId: string;
appointmentAt: Date;
}) {
const appointmentId = uuidv4();
const result = await db.query(`
INSERT INTO appointments (id, patient_id, doctor_id, appointment_at, status)
VALUES ($1, $2, $3, $4, 'scheduled')
RETURNING *
`, [appointmentId, data.patientId, data.doctorId, data.appointmentAt]);
return result.rows[0];
}
async getPatientAppointments(patientId: string) {
const result = await db.query(`
SELECT a.*, d.name as doctor_name, d.specialty
FROM appointments a
JOIN doctors d ON a.doctor_id = d.id
WHERE a.patient_id = $1 AND a.status = 'scheduled'
ORDER BY a.appointment_at ASC
`, [patientId]);
return result.rows;
}
async cancelAppointment(appointmentId: string, reason: string) {
await db.query(`
UPDATE appointments
SET status = 'cancelled', cancellation_reason = $2, updated_at = NOW()
WHERE id = $1
`, [appointmentId, reason]);
}
async markReminderSent(appointmentId: string) {
await db.query(`
UPDATE appointments
SET reminder_sent = TRUE, reminder_sent_at = NOW()
WHERE id = $1
`, [appointmentId]);
}
}
Очередь напоминаний (Bull + Redis)
// queue/ReminderQueue.ts
import Queue from 'bull';
import { NotificationService } from '../services/NotificationService';
import { AppointmentService } from '../services/AppointmentService';
const reminderQueue = new Queue('appointment-reminders', {
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || '6379')
},
defaultJobOptions: {
attempts: 3, // 3 попытки отправки
backoff: {
type: 'exponential',
delay: 2000 // 2 сек, 4 сек, 8 сек
},
removeOnComplete: true
}
});
const notificationService = new NotificationService();
const appointmentService = new AppointmentService();
// Обработчик задач из очереди
reminderQueue.process(async (job) => {
const { appointmentId } = job.data;
try {
// Получаем данные записи
const appointment = await getAppointment(appointmentId);
if (!appointment || appointment.status !== 'scheduled') {
console.log(`Skipping reminder for cancelled appointment: ${appointmentId}`);
return;
}
// Отправляем напоминание
await notificationService.sendReminder({
patientId: appointment.patient_id,
phone: appointment.patient_phone,
doctorName: appointment.doctor_name,
appointmentTime: appointment.appointment_at
});
// Отмечаем, что напоминание отправлено
await appointmentService.markReminderSent(appointmentId);
console.log(`Reminder sent for appointment: ${appointmentId}`);
} catch (error) {
console.error(`Failed to send reminder for appointment ${appointmentId}:`, error);
throw error; // Bull перепопытается
}
});
// Обработка ошибок
reminderQueue.on('failed', async (job, error) => {
console.error(`Job ${job.id} failed after ${job.attemptsMade} attempts:`, error);
// Логируем в БД для анализа
await db.query(`
INSERT INTO reminder_history (appointment_id, status, error_message, retry_count)
VALUES ($1, 'failed', $2, $3)
`, [job.data.appointmentId, error.message, job.attemptsMade]);
});
export class ReminderQueue {
async addReminder(appointmentId: string, reminderTime: number) {
const delay = reminderTime - Date.now();
if (delay <= 0) {
throw new Error('Reminder time must be in the future');
}
// Добавляем задачу с отсроченным выполнением
await reminderQueue.add(
{ appointmentId },
{
delay,
jobId: `reminder-${appointmentId}` // уникальный ID для отмены
}
);
}
async cancelReminder(appointmentId: string) {
const job = await reminderQueue.getJob(`reminder-${appointmentId}`);
if (job) {
await job.remove();
}
}
}
export default reminderQueue;
NotificationService
// services/NotificationService.ts
import twilio from 'twilio';
export class NotificationService {
private twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
async sendReminder(data: {
patientId: string;
phone: string;
doctorName: string;
appointmentTime: Date;
}) {
const time = data.appointmentTime.toLocaleString('ru-RU');
const message = `Напоминание: у вас прием у врача ${data.doctorName} в ${time}. До приема осталось 2 часа.`;
try {
// Отправляем SMS
await this.twilioClient.messages.create({
body: message,
from: process.env.TWILIO_PHONE_NUMBER,
to: data.phone
});
console.log(`SMS sent to ${data.phone}`);
} catch (error) {
console.error(`Failed to send SMS to ${data.phone}:`, error);
// Пытаемся отправить email как fallback
try {
await this.sendEmailReminder(data, message);
} catch (emailError) {
throw new Error(`Both SMS and email failed: ${error}`);
}
}
}
private async sendEmailReminder(data: any, message: string) {
// Реализация отправки email через nodemailer, SendGrid и т.д.
console.log(`Email sent to patient ${data.patientId}`);
}
}
4. Решение ключевых вопросов
Как реализовать отложенные напоминания?
Вариант 1: Bull Queue (рекомендуется)
- Задачи хранятся в Redis с точным временем выполнения
- Автоматически повторяет при ошибках
- Масштабируется горизонтально
- Гарантирует выполнение
Вариант 2: Cron jobs (НЕ рекомендуется)
- Запускается каждую минуту/час
- Требует логики для проверки времени
- Неточный timing
- Сложнее с масштабированием
// Плохой пример с cron:
schedule.scheduleJob('* * * * *', async () => {
const appointments = await db.query(`
SELECT * FROM appointments
WHERE status = 'scheduled'
AND appointment_at <= NOW() + INTERVAL '2 hours'
AND reminder_sent = FALSE
`);
// Проблема: что если сервис упадет? Напоминания потеряны
});
Какую очередь использовать?
| Очередь | Плюсы | Минусы | Когда использовать |
|---|---|---|---|
| Bull | Простой API, Redis, надежен | Требует Redis | ✅ Стартапы, MVP |
| RabbitMQ | Надежный, feature-rich | Сложнее настраивать | Enterprise системы |
| AWS SQS | Масштабируется, managed | Стоит денег | Cloud-only решения |
| Kafka | High throughput | Overkill для напоминаний | High-load системы |
Вывод: для этой задачи Bull + Redis идеален.
Обработка отмены при отмене записи
// Критично: использовать транзакции
async cancelAppointment(appointmentId: string, reason: string) {
const client = await db.connect();
try {
await client.query('BEGIN');
// Шаг 1: Отмечаем запись как отменённую
await client.query(`
UPDATE appointments
SET status = 'cancelled', cancellation_reason = $2
WHERE id = $1
`, [appointmentId, reason]);
// Шаг 2: Удаляем задачу из очереди
await reminderQueue.cancelReminder(appointmentId);
// Шаг 3: Логируем отмену напоминания
await client.query(`
INSERT INTO reminder_history (appointment_id, status)
VALUES ($1, 'cancelled')
`, [appointmentId]);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
Обработка ошибок при отправке
// Стратегия retry с exponential backoff
reminderQueue.on('failed', async (job, error) => {
const { appointmentId } = job.data;
const maxAttempts = 3;
if (job.attemptsMade >= maxAttempts) {
// Финальный отказ: уведомляем админ
await notificationService.alertAdmin({
appointmentId,
error: error.message,
attempts: job.attemptsMade
});
// Записываем в БД для ручной обработки
await db.query(`
UPDATE appointments
SET status = 'reminder_failed'
WHERE id = $1
`, [appointmentId]);
}
});
5. Полный flow
1. Пациент создает запись → POST /appointments
2. API создает запись в БД → status = 'scheduled'
3. API добавляет задачу в Bull (задержка = за 2 часа)
4. Bull выполняет задачу в нужное время
5. Worker отправляет SMS/Email
6. Отмечаем reminder_sent = TRUE
Если пациент отменяет запись:
7. DELETE /appointments/{id}
8. API отмечает status = 'cancelled'
9. API удаляет задачу из очереди
10. Напоминание не отправляется
6. Дополнительные оптимизации
Batch processing:
// Если много одновременных напоминаний
reminderQueue.process(100, async (job) => {
// обрабатываем параллельно до 100 задач
});
Мониторинг:
// Отслеживаем задачи в Redis
const jobCounts = await reminderQueue.getJobCounts();
console.log(jobCounts); // { active, completed, failed, delayed }
Dead Letter Queue:
reminderQueue.on('failed', async (job) => {
if (job.attemptsMade >= 3) {
// Перемещаем в DLQ для анализа
await deadLetterQueue.add(job.data);
}
});