БЛОК 4: КОММУНИКАЦИЯ И ВЗАИМОДЕЙСТВИЕ
4.1. Система внутренних сообщений
Раздел ТЗ: Коммуникация между пользователями
Суть проблемы:
Не описана архитектура чата, не определены правила общения, отсутствует модерация сообщений, не указаны ограничения для разных тарифов.
Предлагаемое решение:
4.1.1. Архитектура системы сообщений
Технический стек:
Real-time коммуникация:
- WebSocket (Socket.io) для мгновенной доставки сообщений
- Redis Pub/Sub для масштабирования на несколько серверов
- PostgreSQL для хранения истории сообщений
- S3/CloudStorage для файлов и медиа
Структура:
┌─────────────┐ WebSocket ┌──────────────┐
│ Client │ ←──────────────────→ │ WS Server │
└─────────────┘ └──────────────┘
↓
┌──────────────┐
│ Redis Pub/Sub│
└──────────────┘
↓
┌─────────────┴─────────────┐
↓ ↓
┌──────────────┐ ┌──────────────┐
│ WS Server 1 │ │ WS Server 2 │
└──────────────┘ └──────────────┘
↓ ↓
┌────────────────────────────────┐
│ PostgreSQL │
│ (история сообщений) │
└────────────────────────────────┘
База данных:
-- Таблица диалогов (conversations)
CREATE TABLE conversations (
id UUID PRIMARY KEY,
listing_id UUID REFERENCES listings(id), -- объявление, по которому начался диалог
-- Участники
investor_id UUID REFERENCES users(id),
entrepreneur_id UUID REFERENCES users(id),
-- Статус
status VARCHAR(20) DEFAULT 'active', -- 'active', 'archived', 'blocked'
-- Метаданные
last_message_id UUID,
last_message_at TIMESTAMP,
unread_count_investor INTEGER DEFAULT 0,
unread_count_entrepreneur INTEGER DEFAULT 0,
-- Настройки
is_muted_by_investor BOOLEAN DEFAULT false,
is_muted_by_entrepreneur BOOLEAN DEFAULT false,
is_pinned_by_investor BOOLEAN DEFAULT false,
is_pinned_by_entrepreneur BOOLEAN DEFAULT false,
-- Модерация
is_flagged BOOLEAN DEFAULT false,
flagged_reason TEXT,
flagged_by UUID REFERENCES users(id),
flagged_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
-- Ограничения
UNIQUE(listing_id, investor_id, entrepreneur_id),
CHECK(investor_id != entrepreneur_id)
);
-- Таблица сообщений
CREATE TABLE messages (
id UUID PRIMARY KEY,
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
-- Отправитель
sender_id UUID REFERENCES users(id),
sender_role VARCHAR(20), -- 'investor' или 'entrepreneur'
-- Содержимое
message_type VARCHAR(20) DEFAULT 'text', -- 'text', 'file', 'image', 'system'
content TEXT, -- текст сообщения или JSON для системных сообщений
-- Файлы
attachments JSONB, -- [{url, name, size, type}, ...]
-- Статус
is_read BOOLEAN DEFAULT false,
read_at TIMESTAMP,
is_edited BOOLEAN DEFAULT false,
edited_at TIMESTAMP,
is_deleted BOOLEAN DEFAULT false,
deleted_at TIMESTAMP,
-- Модерация
is_flagged BOOLEAN DEFAULT false,
flagged_reason TEXT,
moderation_status VARCHAR(20), -- 'pending', 'approved', 'rejected'
-- Метаданные
metadata JSONB, -- дополнительные данные (например, для системных сообщений)
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Индексы
CREATE INDEX idx_conversations_investor ON conversations(investor_id, status, last_message_at DESC);
CREATE INDEX idx_conversations_entrepreneur ON conversations(entrepreneur_id, status, last_message_at DESC);
CREATE INDEX idx_conversations_listing ON conversations(listing_id);
CREATE INDEX idx_messages_conversation ON messages(conversation_id, created_at DESC);
CREATE INDEX idx_messages_unread ON messages(conversation_id, is_read) WHERE is_read = false;
CREATE INDEX idx_messages_moderation ON messages(moderation_status) WHERE moderation_status = 'pending';
-- Таблица для отслеживания онлайн-статуса
CREATE TABLE user_presence (
user_id UUID PRIMARY KEY REFERENCES users(id),
status VARCHAR(20) DEFAULT 'offline', -- 'online', 'away', 'offline'
last_seen TIMESTAMP DEFAULT NOW(),
last_activity TIMESTAMP DEFAULT NOW(),
device_info JSONB, -- информация об устройстве
updated_at TIMESTAMP DEFAULT NOW()
);
-- Таблица для типовых ответов (шаблонов)
CREATE TABLE message_templates (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
title VARCHAR(100),
content TEXT,
category VARCHAR(50), -- 'greeting', 'follow_up', 'rejection', 'custom'
usage_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
4.1.2. Правила и ограничения общения
Матрица ограничений по тарифам:
| Функция | Free Investor | Premium Investor | Free Entrepreneur | Premium Entrepreneur |
|---|---|---|---|---|
| Инициация диалога | Только через отклик | Без ограничений | Ответ на отклики | Без ограничений |
| Количество активных диалогов | 5 | Безлимит | 10 | Безлимит |
| Сообщений в день | 20 | Безлимит | 50 | Безлимит |
| Вложения | Только изображения (до 5 МБ) | Все типы (до 50 МБ) | Только изображения (до 5 МБ) | Все типы (до 100 МБ) |
| Шаблоны ответов | — | 5 шаблонов | — | 20 шаблонов |
| История сообщений | 30 дней | Безлимит | 90 дней | Безлимит |
| Экспорт переписки | — | ✓ (PDF/CSV) | — | ✓ (PDF/CSV) |
| Приоритет доставки | Стандартный | Высокий | Стандартный | Высокий |
| Уведомления | Email + SMS + Push | Email + SMS + Push |
Правила инициации диалога:
def can_start_conversation(sender, recipient, listing):
"""Проверяет, может ли пользователь начать диалог"""
# 1. Проверка ролей
if sender.role == recipient.role:
return False, "Нельзя начать диалог с пользователем той же роли"
# 2. Проверка блокировок
if is_blocked(sender.id, recipient.id):
return False, "Пользователь заблокирован"
# 3. Проверка существующего диалога
existing_conversation = get_conversation(sender.id, recipient.id, listing.id)
if existing_conversation:
return True, existing_conversation.id # возвращаем существующий диалог
# 4. Проверка лимитов для Free-пользователей
if sender.tier == "free":
active_conversations = count_active_conversations(sender.id)
max_conversations = 5 if sender.role == "investor" else 10
if active_conversations >= max_conversations:
return False, f"Достигнут лимит активных диалогов ({max_conversations}). Перейдите на Premium для безлимитного общения."
# 5. Проверка прав инициации для инвесторов
if sender.role == "investor":
# Free-инвесторы могут писать только через отклик
if sender.tier == "free":
response = get_response(sender.id, listing.id)
if not response or response.status != "approved":
return False, "Free-инвесторы могут начать диалог только после одобрения отклика предпринимателем"
# Проверка лимита откликов
if sender.tier == "free":
monthly_responses = count_monthly_responses(sender.id)
if monthly_responses >= 3:
return False, "Достигнут месячный лимит откликов (3). Перейдите на Premium для безлимитных откликов."
# 6. Проверка настроек приватности получателя
if recipient.privacy_settings.get("only_verified_users") and not sender.is_verified:
return False, "Пользователь принимает сообщения только от верифицированных пользователей"
# 7. Все проверки пройдены
return True, None
def can_send_message(sender, conversation):
"""Проверяет, может ли пользователь отправить сообщение"""
# 1. Проверка статуса диалога
if conversation.status == "blocked":
return False, "Диалог заблокирован"
if conversation.status == "archived":
return False, "Диалог архивирован. Разархивируйте для продолжения общения."
# 2. Проверка дневного лимита для Free-пользователей
if sender.tier == "free":
daily_messages = count_daily_messages(sender.id)
max_messages = 20 if sender.role == "investor" else 50
if daily_messages >= max_messages:
return False, f"Достигнут дневной лимит сообщений ({max_messages}). Лимит обновится через {get_time_until_reset()}."
# 3. Проверка rate limiting (защита от спама)
recent_messages = get_recent_messages(sender.id, conversation.id, minutes=1)
if len(recent_messages) >= 10:
return False, "Слишком много сообщений за короткий период. Подождите минуту."
# 4. Все проверки пройдены
return True, None
4.1.3. Модерация сообщений
Автоматическая модерация:
class MessageModerator:
def __init__(self):
# Загружаем списки запрещенных слов
self.profanity_list = load_profanity_list()
self.spam_patterns = load_spam_patterns()
self.scam_keywords = load_scam_keywords()
# ML-модель для классификации сообщений
self.classifier = load_ml_classifier()
def moderate_message(self, message):
"""Модерирует сообщение перед отправкой"""
flags = []
risk_score = 0
# 1. Проверка на мат и оскорбления
profanity_check = self.check_profanity(message.content)
if profanity_check.found:
flags.append("profanity")
risk_score += 50
# 2. Проверка на спам
spam_check = self.check_spam(message.content)
if spam_check.is_spam:
flags.append("spam")
risk_score += 40
# 3. Проверка на мошенничество
scam_check = self.check_scam(message.content)
if scam_check.is_scam:
flags.append("scam")
risk_score += 80
# 4. Проверка контактных данных (запрещено в первых сообщениях)
if self.contains_contact_info(message.content):
message_position = get_message_position(message.conversation_id)
if message_position <= 3: # первые 3 сообщения
flags.append("early_contact_sharing")
risk_score += 30
# 5. Проверка внешних ссылок
links = extract_links(message.content)
if links:
for link in links:
if not is_whitelisted_domain(link):
flags.append("external_link")
risk_score += 20
# 6. ML-классификация
ml_prediction = self.classifier.predict(message.content)
if ml_prediction.category == "inappropriate":
flags.append("ml_flagged")
risk_score += ml_prediction.confidence * 50
# 7. Проверка на массовую рассылку
if self.is_mass_message(message.sender_id, message.content):
flags.append("mass_message")
risk_score += 60
# Принятие решения
if risk_score >= 100:
return {
"action": "block",
"reason": "Сообщение заблокировано автоматически",
"flags": flags,
"risk_score": risk_score
}
elif risk_score >= 60:
return {
"action": "manual_review",
"reason": "Сообщение отправлено на ручную модерацию",
"flags": flags,
"risk_score": risk_score
}
else:
return {
"action": "approve",
"flags": flags,
"risk_score": risk_score
}
def check_profanity(self, text):
"""Проверяет текст на мат и оскорбления"""
text_lower = text.lower()
found_words = []
for word in self.profanity_list:
if word in text_lower:
found_words.append(word)
# Проверка на обход фильтра (замена букв)
normalized_text = normalize_text(text_lower)
for word in self.profanity_list:
if word in normalized_text:
found_words.append(word)
return {
"found": len(found_words) > 0,
"words": found_words,
"count": len(found_words)
}
def check_spam(self, text):
"""Проверяет текст на спам"""
spam_indicators = 0
# Паттерны спама
spam_patterns = [
r'ЗАРАБОТОК БЕЗ ВЛОЖЕНИЙ',
r'ГАРАНТИРОВАННЫЙ ДОХОД',
r'БЫСТРЫЕ ДЕНЬГИ',
r'ПАССИВНЫЙ ДОХОД',
r'ФИНАНСОВАЯ ПИРАМИДА',
r'MLM',
r'СЕТЕВОЙ МАРКЕТИНГ',
# ... другие паттерны
]
for pattern in spam_patterns:
if re.search(pattern, text, re.IGNORECASE):
spam_indicators += 1
# Проверка на чрезмерное использование CAPS
caps_ratio = sum(1 for c in text if c.isupper()) / len(text) if len(text) > 0 else 0
if caps_ratio > 0.5:
spam_indicators += 1
# Проверка на чрезмерное использование эмодзи
emoji_count = count_emojis(text)
if emoji_count > 10:
spam_indicators += 1
# Проверка на повторяющиеся символы
if re.search(r'(.)\1{5,}', text):
spam_indicators += 1
return {
"is_spam": spam_indicators >= 2,
"indicators": spam_indicators
}
def check_scam(self, text):
"""Проверяет текст на признаки мошенничества"""
scam_score = 0
# Ключевые слова мошенничества
scam_keywords = [
'предоплата',
'аванс',
'переведите деньги',
'отправьте средства',
'комиссия за рассмотрение',
'гарантийный взнос',
'страховой депозит',
# ... другие ключевые слова
]
text_lower = text.lower()
for keyword in scam_keywords:
if keyword in text_lower:
scam_score += 20
# Проверка на просьбу денег в первых сообщениях
money_request_patterns = [
r'переведите?\s+\d+',
r'отправьте?\s+\d+',
r'заплатите?\s+\d+',
r'\d+\s*(рублей|руб|₽|долларов|usd|\$)'
]
for pattern in money_request_patterns:
if re.search(pattern, text_lower):
scam_score += 30
return {
"is_scam": scam_score >= 40,
"score": scam_score
}
def contains_contact_info(self, text):
"""Проверяет наличие контактной информации"""
patterns = [
r'\+?\d{1,3}[-.\s]?\(?\d{1,4}\)?[-.\s]?\d{1,4}[-.\s]?\d{1,9}', # телефон
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', # email
r'@[A-Za-z0-9_]{3,}', # Telegram/социальные сети
r'whatsapp',
r'viber',
r'skype',
]
for pattern in patterns:
if re.search(pattern, text, re.IGNORECASE):
return True
return False
def is_mass_message(self, sender_id, content):
"""Проверяет, является ли сообщение массовой рассылкой"""
# Получаем последние сообщения пользователя за час
recent_messages = get_recent_messages(sender_id, hours=1)
# Проверяем на идентичные или очень похожие сообщения
similar_count = 0
for msg in recent_messages:
similarity = calculate_similarity(content, msg.content)
if similarity > 0.9: # 90% схожести
similar_count += 1
# Если отправлено более 5 похожих сообщений за час
return similar_count >= 5
Ручная модерация:
# Интерфейс для модераторов
class MessageModerationQueue:
def get_pending_messages(self, moderator_id):
"""Получает очередь сообщений на модерацию"""
messages = db.query("""
SELECT
m.*,
c.investor_id,
c.entrepreneur_id,
u1.name as sender_name,
u2.name as recipient_name
FROM messages m
JOIN conversations c ON m.conversation_id = c.id
JOIN users u1 ON m.sender_id = u1.id
JOIN users u2 ON (
CASE
WHEN m.sender_id = c.investor_id THEN c.entrepreneur_id
ELSE c.investor_id
END
) = u2.id
WHERE m.moderation_status = 'pending'
ORDER BY
-- Приоритет по risk_score
(m.metadata->>'risk_score')::int DESC,
m.created_at ASC
LIMIT 50
""")
return messages
def moderate_message(self, message_id, moderator_id, action, reason=None):
"""Модерирует сообщение"""
if action == "approve":
# Одобряем сообщение
db.execute("""
UPDATE messages
SET
moderation_status = 'approved',
is_flagged = false,
updated_at = NOW()
WHERE id = :message_id
""", message_id=message_id)
# Отправляем сообщение получателю
send_message_to_recipient(message_id)
elif action == "reject":
# Отклоняем сообщение
db.execute("""
UPDATE messages
SET
moderation_status = 'rejected',
is_flagged = true,
flagged_reason = :reason,
updated_at = NOW()
WHERE id = :message_id
""", message_id=message_id, reason=reason)
# Уведомляем отправителя
notify_sender_message_rejected(message_id, reason)
# Если это повторное нарушение, применяем санкции
sender_id = get_message_sender(message_id)
violation_count = count_violations(sender_id)
if violation_count >= 3:
apply_sanctions(sender_id, "repeated_violations")
elif action == "block_user":
# Блокируем пользователя
sender_id = get_message_sender(message_id)
block_user(sender_id, reason, moderator_id)
# Отклоняем сообщение
self.moderate_message(message_id, moderator_id, "reject", reason)
# Логируем действие модератора
log_moderation_action(moderator_id, message_id, action, reason)
4.1.4. Функциональность чата
Основные возможности:
// WebSocket клиент
class ChatClient {
constructor(userId, token) {
this.userId = userId;
this.token = token;
this.socket = null;
this.conversations = new Map();
}
connect() {
this.socket = io('wss://api.platform.com/chat', {
auth: { token: this.token },
transports: ['websocket']
});
// Обработчики событий
this.socket.on('connect', () => {
console.log('Connected to chat server');
this.updatePresence('online');
});
this.socket.on('disconnect', () => {
console.log('Disconnected from chat server');
});
// Получение нового сообщения
this.socket.on('message:new', (message) => {
this.handleNewMessage(message);
});
// Сообщение прочитано
this.socket.on('message:read', (data) => {
this.handleMessageRead(data);
});
// Пользователь печатает
this.socket.on('user:typing', (data) => {
this.handleUserTyping(data);
});
// Обновление онлайн-статуса
this.socket.on('user:presence', (data) => {
this.handlePresenceUpdate(data);
});
}
// Отправка сообщения
async sendMessage(conversationId, content, attachments = []) {
// Проверка лимитов на клиенте
const canSend = await this.checkSendLimits(conversationId);
if (!canSend.allowed) {
throw new Error(canSend.reason);
}
// Оптимистичное обновление UI
const tempMessage = {
id: generateTempId(),
conversationId,
senderId: this.userId,
content,
attachments,
status: 'sending',
createdAt: new Date().toISOString()
};
this.addMessageToConversation(conversationId, tempMessage);
// Отправка на сервер
try {
const response = await this.socket.emitWithAck('message:send', {
conversationId,
content,
attachments
});
// Обновляем временное сообщение реальным
this.updateMessage(tempMessage.id, {
id: response.messageId,
status: 'sent'
});
return response;
} catch (error) {
// Помечаем сообщение как ошибочное
this.updateMessage(tempMessage.id, {
status: 'error',
error: error.message
});
throw error;
}
}
// Отправка "печатает..."
sendTypingIndicator(conversationId) {
this.socket.emit('user:typing', {
conversationId,
userId: this.userId
});
}
// Отметка сообщений как прочитанных
markAsRead(conversationId, messageIds) {
this.socket.emit('messages:read', {
conversationId,
messageIds
});
}
// Обновление онлайн-статуса
updatePresence(status) {
this.socket.emit('user:presence', {
userId: this.userId,
status // 'online', 'away', 'offline'
});
}
// Загрузка истории сообщений
async loadMessages(conversationId, before = null, limit = 50) {
const response = await fetch(
`/api/v1/conversations/${conversationId}/messages?` +
`before=${before || ''}&limit=${limit}`,
{
headers: {
'Authorization': `Bearer ${this.token}`
}
}
);
const data = await response.json();
// Добавляем сообщения в локальное хранилище
data.messages.forEach(msg => {
this.addMessageToConversation(conversationId, msg);
});
return data;
}
// Обработка нового сообщения
handleNewMessage(message) {
// Добавляем в локальное хранилище
this.addMessageToConversation(message.conversationId, message);
// Обновляем UI
this.emit('message:received', message);
// Показываем уведомление, если чат не открыт
if (!this.isConversationActive(message.conversationId)) {
this.showNotification(message);
}
// Автоматически отмечаем как прочитанное, если чат открыт
if (this.isConversationActive(message.conversationId)) {
this.markAsRead(message.conversationId, [message.id]);
}
}
// Проверка лимитов отправки
async checkSendLimits(conversationId) {
const response = await fetch(
`/api/v1/conversations/${conversationId}/check-limits`,
{
headers: {
'Authorization': `Bearer ${this.token}`
}
}
);
return await response.json();
}
// Отправка файла
async uploadFile(file, conversationId) {
// Проверка размера файла
const maxSize = this.userTier === 'premium' ? 50 * 1024 * 1024 : 5 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error(`Файл слишком большой. Максимум: ${maxSize / 1024 / 1024} МБ`);
}
// Проверка типа файла
const allowedTypes = this.userTier === 'premium'
? ['image/*', 'application/pdf', 'application/msword', 'application/vnd.ms-excel', 'video/*']
: ['image/*'];
if (!this.isFileTypeAllowed(file.type, allowedTypes)) {
throw new Error('Тип файла не поддерживается');
}
// Загрузка файла
const formData = new FormData();
formData.append('file', file);
formData.append('conversationId', conversationId);
const response = await fetch('/api/v1/files/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
},
body: formData
});
const data = await response.json();
return data.fileUrl;
}
}
UI компоненты:
// Компонент списка диалогов
function ConversationsList() {
const [conversations, setConversations] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all'); // 'all', 'unread', 'archived'
useEffect(() => {
loadConversations();
}, [filter]);
const loadConversations = async () => {
try {
const response = await api.get('/api/v1/conversations', {
params: { filter }
});
setConversations(response.data);
} catch (error) {
console.error('Error loading conversations:', error);
} finally {
setLoading(false);
}
};
return (
<div className="conversations-list">
<div className="list-header">
<h2>Сообщения</h2>
<div className="filters">
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
Все
</button>
<button
className={filter === 'unread' ? 'active' : ''}
onClick={() => setFilter('unread')}
>
Непрочитанные
{unreadCount > 0 && <span className="badge">{unreadCount}</span>}
</button>
<button
className={filter === 'archived' ? 'active' : ''}
onClick={() => setFilter('archived')}
>
Архив
</button>
</div>
</div>
<div className="conversations">
{loading ? (
<SkeletonLoader />
) : conversations.length === 0 ? (
<EmptyState
icon="💬"
title="Нет сообщений"
description="Начните диалог с инвестором или предпринимателем"
/>
) : (
conversations.map(conv => (
<ConversationItem
key={conv.id}
conversation={conv}
onClick={() => openConversation(conv.id)}
/>
))
)}
</div>
</div>
);
}
// Компонент элемента диалога
function ConversationItem({ conversation, onClick }) {
const { otherUser, lastMessage, unreadCount, isPinned } = conversation;
return (
<div
className={`conversation-item ${unreadCount > 0 ? 'unread' : ''}`}
onClick={onClick}
>
<div className="avatar">
<img src={otherUser.avatar} alt={otherUser.name} />
{otherUser.isOnline && <span className="online-indicator" />}
</div>
<div className="content">
<div className="header">
<span className="name">
{otherUser.name}
{otherUser.isVerified && <VerifiedBadge />}
</span>
<span className="time">{formatTime(lastMessage.createdAt)}</span>
</div>
<div className="message">
<p className="text">
{lastMessage.type === 'text'
? truncate(lastMessage.content, 50)
: getMessageTypeLabel(lastMessage.type)
}
</p>
{unreadCount > 0 && (
<span className="unread-badge">{unreadCount}</span>
)}
</div>
</div>
{isPinned && <PinIcon />}
</div>
);
}
// Компонент окна чата
function ChatWindow({ conversationId }) {
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [otherUserTyping, setOtherUserTyping] = useState(false);
const [attachments, setAttachments] = useState([]);
const messagesEndRef = useRef(null);
useEffect(() => {
loadMessages();
// Подписываемся на новые сообщения
chatClient.on('message:received', handleNewMessage);
chatClient.on('user:typing', handleUserTyping);
return () => {
chatClient.off('message:received', handleNewMessage);
chatClient.off('user:typing', handleUserTyping);
};
}, [conversationId]);
const loadMessages = async () => {
const data = await chatClient.loadMessages(conversationId);
setMessages(data.messages);
scrollToBottom();
};
const handleSendMessage = async () => {
if (!inputValue.trim() && attachments.length === 0) return;
try {
await chatClient.sendMessage(conversationId, inputValue, attachments);
setInputValue('');
setAttachments([]);
scrollToBottom();
} catch (error) {
showError(error.message);
}
};
const handleInputChange = (e) => {
setInputValue(e.target.value);
// Отправляем индикатор "печатает..."
if (!isTyping) {
setIsTyping(true);
chatClient.sendTypingIndicator(conversationId);
// Сбрасываем через 3 секунды
setTimeout(() => setIsTyping(false), 3000);
}
};
const handleFileUpload = async (files) => {
for (const file of files) {
try {
const url = await chatClient.uploadFile(file, conversationId);
setAttachments(prev => [...prev, { url, name: file.name, type: file.type }]);
} catch (error) {
showError(error.message);
}
}
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
return (
<div className="chat-window">
<ChatHeader conversation={conversation} />
<div className="messages-container">
{messages.map(message => (
<MessageBubble
key={message.id}
message={message}
isMine={message.senderId === currentUserId}
/>
))}
{otherUserTyping && (
<TypingIndicator userName={otherUser.name} />
)}
<div ref={messagesEndRef} />
</div>
<ChatInput
value={inputValue}
onChange={handleInputChange}
onSend={handleSendMessage}
onFileUpload={handleFileUpload}
attachments={attachments}
onRemoveAttachment={(index) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
}}
/>
</div>
);
}
// Компонент сообщения
function MessageBubble({ message, isMine }) {
return (
<div className={`message-bubble ${isMine ? 'mine' : 'theirs'}`}>
<div className="bubble-content">
{message.content && (
<p className="text">{message.content}</p>
)}
{message.attachments?.length > 0 && (
<div className="attachments">
{message.attachments.map((attachment, index) => (
<AttachmentPreview
key={index}
attachment={attachment}
/>
))}
</div>
)}
<div className="message-footer">
<span className="time">{formatTime(message.createdAt)}</span>
{isMine && (
<MessageStatus status={message.status} />
)}
</div>
</div>
</div>
);
}
// Компонент ввода сообщения
function ChatInput({ value, onChange, onSend, onFileUpload, attachments, onRemoveAttachment }) {
const fileInputRef = useRef(null);
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
return (
<div className="chat-input">
{attachments.length > 0 && (
<div className="attachments-preview">
{attachments.map((attachment, index) => (
<div key={index} className="attachment-item">
<AttachmentPreview attachment={attachment} />
<button
className="remove-btn"
onClick={() => onRemoveAttachment(index)}
>
×
</button>
</div>
))}
</div>
)}
<div className="input-container">
<button
className="attach-btn"
onClick={() => fileInputRef.current?.click()}
title="Прикрепить файл"
>
📎
</button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
multiple
onChange={(e) => onFileUpload(Array.from(e.target.files))}
/>
<textarea
className="message-input"
placeholder="Введите сообщение..."
value={value}
onChange={onChange}
onKeyPress={handleKeyPress}
rows={1}
/>
<button
className="send-btn"
onClick={onSend}
disabled={!value.trim() && attachments.length === 0}
>
Отправить
</button>
</div>
{userTier === 'free' && (
<div className="limits-info">
Отправлено сегодня: {dailyMessageCount} из {maxDailyMessages}
</div>
)}
</div>
);
}
Примеры use-cases:
UC-4.1: Инвестор начинает диалог с предпринимателем
1. Инвестор просматривает объявление предпринимателя
2. Нажимает кнопку "Откликнуться" или "Написать сообщение"
3. Система проверяет:
- Есть ли уже диалог по этому объявлению?
- Не достигнут ли лимит активных диалогов? (для Free)
- Не заблокирован ли пользователь?
4. Если Free-инвестор:
- Показывается форма отклика:
"Расскажите, почему вас заинтересовало это предложение"
[Текстовое поле]
[Отправить отклик]
- После отправки:
"Ваш отклик отправлен предпринимателю.
Вы сможете начать диалог после одобрения отклика."
5. Если Premium-инвестор:
- Сразу открывается окно чата
- Можно написать первое сообщение
- Предприниматель получает уведомление
6. Предприниматель видит отклик/сообщение:
- Может просмотреть профиль инвестора
- Может одобрить отклик (для Free-инвесторов)
- Может ответить на сообщение (для Premium-инвесторов)
7. После одобрения/ответа открывается полноценный диалог
UC-4.2: Пользователь отправляет сообщение с вложением
1. Пользователь открывает диалог
2. Нажимает кнопку "Прикрепить файл" (📎)
3. Выбирает файл с компьютера
4. Система проверяет:
- Размер файла (5 МБ для Free, 50 МБ для Premium)
- Тип файла (только изображения для Free, все типы для Premium)
5. Если проверка не пройдена:
- Показывается ошибка:
"Файл слишком большой. Максимум: 5 МБ
[Перейти на Premium] для загрузки файлов до 50 МБ"
6. Если проверка пройдена:
- Файл загружается на сервер
- Показывается прогресс-бар загрузки
- После загрузки появляется превью файла
7. Пользователь может:
- Добавить текст к сообщению
- Прикрепить еще файлы
- Удалить прикрепленный файл
8. Нажимает "Отправить"
9. Сообщение с вложением отправляется:
- Получатель видит превью файла
- Может скачать файл
- Может открыть в полном размере (для изображений)
UC-4.3: Модератор проверяет подозрительное сообщение
1. Пользователь отправляет сообщение с подозрительным содержимым:
"Переведите 50,000 руб на карту для рассмотрения заявки"
2. Система автоматически модерирует:
- Обнаруживает ключевые слова мошенничества
- Обнаруживает просьбу денег
- Рассчитывает risk_score = 110 (высокий риск)
3. Сообщение блокируется автоматически:
- Не доставляется получателю
- Отправитель видит:
"Ваше сообщение заблокировано модерацией.
Причина: Подозрение на мошенничество
Если вы считаете это ошибкой, обратитесь в поддержку."
4. Модератор получает уведомление:
- В админ-панели появляется алерт
- Видит заблокированное сообщение и контекст диалога
5. Модератор проверяет:
- Читает предыдущие сообщения в диалоге
- Проверяет профиль отправителя
- Проверяет историю нарушений
6. Принимает решение:
- Если это действительно мошенничество:
* Подтверждает блокировку
* Блокирует аккаунт отправителя
* Уведомляет получателя о попытке мошенничества
- Если это ложное срабатывание:
* Одобряет сообщение
* Оно доставляется получателю
* Корректирует алгоритм модерации
7. Отправитель получает уведомление о решении:
- Если заблокирован: причина блокировки
- Если одобрено: сообщение доставлено
UC-4.4: Free-пользователь достигает дневного лимита сообщений
1. Free-инвестор отправил 20 сообщений за день (лимит)
2. Пытается отправить 21-е сообщение
3. Система блокирует отправку:
- Кнопка "Отправить" становится неактивной
- Показывается сообщение:
"Вы достигли дневного лимита сообщений (20/20)
Лимит обновится через 8 часов 23 минуты
[Перейти на Premium] - безлимитные сообщения за ₽4,990/мес
[Напомнить позже]"
4. Если пользователь нажимает "Перейти на Premium":
- Переход на страницу оплаты
- После оплаты мгновенное снятие ограничений
- Возврат в чат с возможностью отправки
5. Если нажимает "Напомнить позже":
- Push-уведомление в момент сброса лимита
- Email с предложением Premium (скидка 20%)
6. В момент сброса лимита (00:00 по МСК):
- Счетчик обнуляется
- Пользователь может снова отправлять сообщения
- Приходит уведомление: "Ваш дневной лимит сообщений обновлен"
Приоритет: [CRITICAL] — MVP
Обоснование решения:
- Коммуникация: Чат — основной канал взаимодействия между инвесторами и предпринимателями
- Real-time: WebSocket обеспечивает мгновенную доставку сообщений
- Безопасность: Многоуровневая модерация защищает от мошенничества и спама
- Монетизация: Ограничения для Free-пользователей стимулируют переход на Premium
Риски, которые закрывает:
- Мошенничество через сообщения
- Спам и массовые рассылки
- Утечка контактов за пределы платформы
- Низкое качество коммуникации
Влияние на метрики:
- Конверсия отклик → диалог: 60-70%
- Конверсия диалог → сделка: 15-20%
- Среднее время ответа: <2 часа для Premium, <4 часа для Free
- Engagement: +50% (пользователи возвращаются для проверки сообщений)
- Конверсия Free → Premium: +15% из-за лимитов сообщений
Связанные изменения:
- Раздел «Уведомления» — добавить алерты о новых сообщениях
- Раздел «Профиль» — добавить настройки приватности чата
- Раздел «Админ-панель» — добавить интерфейс модерации сообщений
- Раздел «Инфраструктура» — добавить WebSocket серверы и Redis
—
4.2. Система уведомлений
Раздел ТЗ: Уведомления
Суть проблемы:
Не описаны типы уведомлений, каналы доставки, настройки пользователя, приоритизация уведомлений.
Предлагаемое решение:
4.2.1. Типы и каналы уведомлений
Матрица уведомлений:
| Событие | Push | SMS | In-App | Приоритет | Настраивается | |
|---|---|---|---|---|---|---|
| Сообщения | ||||||
| Новое сообщение | ✓ | ✓ | Premium | ✓ | Высокий | ✓ |
| Непрочитанные сообщения (дайджест) | ✓ | — | — | — | Средний | ✓ |
| Отклики и объявления | ||||||
| Новый отклик на объявление | ✓ | ✓ | Premium | ✓ | Высокий | ✓ |
| Отклик одобрен | ✓ | ✓ | — | ✓ | Высокий | ✓ |
| Отклик отклонен | ✓ | — | — | ✓ | Средний | ✓ |
| Объявление опубликовано | ✓ | ✓ | — | ✓ | Средний | — |
| Объявление на модерации | ✓ | — | — | ✓ | Низкий | — |
| Объявление одобрено | ✓ | ✓ | — | ✓ | Высокий | — |
| Объявление отклонено | ✓ | ✓ | — | ✓ | Высокий | — |
| Объявление истекает через 7 дней | ✓ | — | — | ✓ | Средний | ✓ |
| Рекомендации | ||||||
| Новое релевантное объявление | ✓ | ✓ | — | ✓ | Средний | ✓ |
| Дайджест рекомендаций | ✓ | — | — | — | Низкий | ✓ |
| Верификация | ||||||
| Верификация одобрена | ✓ | ✓ | — | ✓ | Высокий | — |
| Верификация отклонена | ✓ | ✓ | — | ✓ | Высокий | — |
| Требуются дополнительные документы | ✓ | ✓ | — | ✓ | Высокий | — |
| Верификация истекает | ✓ | — | — | ✓ | Средний | — |
| Подписка и оплата | ||||||
| Подписка продлена | ✓ | — | — | ✓ | Средний | — |
| Подписка истекает через 7 дней | ✓ | ✓ | Premium | ✓ | Высокий | — |
| Подписка истекла | ✓ | ✓ | — | ✓ | Высокий | — |
| Платеж не прошел | ✓ | ✓ | Premium | ✓ | Критический | — |
| Счет выставлен | ✓ | — | — | ✓ | Средний | — |
| Безопасность | ||||||
| Вход с нового устройства | ✓ | ✓ | Premium | ✓ | Высокий | — |
| Изменение пароля | ✓ | — | Premium | ✓ | Высокий | — |
| Подозрительная активность | ✓ | ✓ | Premium | ✓ | Критический | — |
| Отзывы и рейтинг | ||||||
| Новый отзыв | ✓ | ✓ | — | ✓ | Средний | ✓ |
| Запрос оставить отзыв | ✓ | — | — | ✓ | Низкий | ✓ |
| Система | ||||||
| Технические работы | ✓ | ✓ | — | ✓ | Высокий | — |
| Новые функции | ✓ | — | — | ✓ | Низкий | ✓ |
| Обновления политики | ✓ | — | — | ✓ | Средний | — |
База данных:
-- Таблица уведомлений
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
-- Тип и категория
type VARCHAR(50), -- 'message_new', 'response_new', 'listing_approved', etc.
category VARCHAR(50), -- 'messages', 'responses', 'listings', 'verification', etc.
priority VARCHAR(20) DEFAULT 'medium', -- 'critical', 'high', 'medium', 'low'
-- Содержимое
title VARCHAR(200),
body TEXT,
data JSONB, -- дополнительные данные (ссылки, ID объектов, etc.)
-- Действие
action_url VARCHAR(500), -- URL для перехода при клике
action_label VARCHAR(50), -- текст кнопки действия
-- Статус
is_read BOOLEAN DEFAULT false,
read_at TIMESTAMP,
is_clicked BOOLEAN DEFAULT false,
clicked_at TIMESTAMP,
-- Каналы доставки
channels JSONB, -- ['email', 'push', 'sms', 'in_app']
delivery_status JSONB, -- {email: 'sent', push: 'delivered', ...}
-- Группировка (для объединения похожих уведомлений)
group_key VARCHAR(100), -- например, 'messages:conversation_id'
-- Временные метки
created_at TIMESTAMP DEFAULT NOW(),
scheduled_at TIMESTAMP, -- для отложенных уведомлений
sent_at TIMESTAMP,
expires_at TIMESTAMP -- для временных уведомлений
);
-- Индексы
CREATE INDEX idx_notifications_user ON notifications(user_id, is_read, created_at DESC);
CREATE INDEX idx_notifications_scheduled ON notifications(scheduled_at) WHERE scheduled_at IS NOT NULL AND sent_at IS NULL;
CREATE INDEX idx_notifications_group ON notifications(user_id, group_key, created_at DESC);
-- Таблица настроек уведомлений пользователя
CREATE TABLE notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id),
-- Настройки по категориям
preferences JSONB,
/* Структура:
{
"messages": {
"email": true,
"push": true,
"sms": false,
"in_app": true,
"frequency": "instant" // 'instant', 'digest_daily', 'digest_weekly', 'disabled'
},
"responses": { ... },
...
}
*/
-- Режим "Не беспокоить"
do_not_disturb BOOLEAN DEFAULT false,
dnd_start_time TIME, -- например, 22:00
dnd_end_time TIME, -- например, 08:00
dnd_days INTEGER[], -- дни недели (1-7)
-- Дайджесты
digest_email_time TIME DEFAULT '09:00', -- время отправки дайджеста
digest_email_days INTEGER[] DEFAULT ARRAY[1,2,3,4,5], -- рабочие дни
updated_at TIMESTAMP DEFAULT NOW()
);
-- Таблица для отслеживания доставки
CREATE TABLE notification_delivery_log (
id UUID PRIMARY KEY,
notification_id UUID REFERENCES notifications(id),
channel VARCHAR(20), -- 'email', 'push', 'sms'
-- Статус доставки
status VARCHAR(20), -- 'pending', 'sent', 'delivered', 'failed', 'bounced'
provider VARCHAR(50), -- 'sendgrid', 'firebase', 'twilio'
provider_message_id VARCHAR(200),
-- Ошибки
error_code VARCHAR(50),
error_message TEXT,
-- Метрики
sent_at TIMESTAMP,
delivered_at TIMESTAMP,
opened_at TIMESTAMP,
clicked_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
4.2.2. Логика отправки уведомлений
Сервис уведомлений:
class NotificationService:
def __init__(self):
self.email_provider = SendGridProvider()
self.push_provider = FirebaseProvider()
self.sms_provider = TwilioProvider()
async def send_notification(self, user_id, notification_type, data):
"""Отправляет уведомление пользователю"""
# 1. Получаем настройки пользователя
preferences = await self.get_user_preferences(user_id)
# 2. Проверяем, включены ли уведомления этого типа
if not self.is_notification_enabled(notification_type, preferences):
logger.info(f"Notification {notification_type} disabled for user {user_id}")
return
# 3. Проверяем режим "Не беспокоить"
if self.is_do_not_disturb_active(preferences):
# Откладываем уведомление
await self.schedule_notification(user_id, notification_type, data,
scheduled_at=preferences.dnd_end_time)
return
# 4. Создаем уведомление в БД
notification = await self.create_notification(user_id, notification_type, data)
# 5. Определяем каналы доставки
channels = self.get_delivery_channels(notification_type, preferences)
# 6. Проверяем группировку (объединение похожих уведомлений)
if self.should_group_notification(notification):
await self.group_notification(notification)
return
# 7. Отправляем по каждому каналу
delivery_tasks = []
if 'email' in channels:
delivery_tasks.append(self.send_email(notification))
if 'push' in channels:
delivery_tasks.append(self.send_push(notification))
if 'sms' in channels:
# SMS только для Premium и только для критических уведомлений
user = await get_user(user_id)
if user.tier == 'premium' or notification.priority == 'critical':
delivery_tasks.append(self.send_sms(notification))
# In-app уведомление всегда создается
await self.create_in_app_notification(notification)
# Отправляем параллельно
results = await asyncio.gather(*delivery_tasks, return_exceptions=True)
# 8. Логируем результаты доставки
await self.log_delivery_results(notification.id, results)
return notification
def is_notification_enabled(self, notification_type, preferences):
"""Проверяет, включен ли тип уведомления"""
# Получаем категорию уведомления
category = self.get_notification_category(notification_type)
# Проверяем настройки категории
category_prefs = preferences.get(category, {})
# Проверяем частоту
frequency = category_prefs.get('frequency', 'instant')
if frequency == 'disabled':
return False
# Для дайджестов уведомление будет отправлено позже
if frequency in ['digest_daily', 'digest_weekly']:
# Добавляем в очередь дайджеста
self.add_to_digest_queue(user_id, notification_type, data)
return False
return True
def is_do_not_disturb_active(self, preferences):
"""Проверяет, активен ли режим 'Не беспокоить'"""
if not preferences.get('do_not_disturb', False):
return False
now = datetime.now()
current_time = now.time()
current_day = now.isoweekday()
# Проверяем день недели
dnd_days = preferences.get('dnd_days', [])
if dnd_days and current_day not in dnd_days:
return False
# Проверяем время
dnd_start = preferences.get('dnd_start_time')
dnd_end = preferences.get('dnd_end_time')
if dnd_start and dnd_end:
if dnd_start < dnd_end:
# Обычный диапазон (например, 22:00 - 08:00 следующего дня)
return dnd_start <= current_time <= dnd_end
else:
# Диапазон через полночь
return current_time >= dnd_start or current_time <= dnd_end
return False
def should_group_notification(self, notification):
"""Определяет, нужно ли группировать уведомление"""
# Группируем только определенные типы уведомлений
groupable_types = [
'message_new',
'response_new',
'listing_view'
]
if notification.type not in groupable_types:
return False
# Проверяем, есть ли недавние похожие уведомления
recent_similar = self.get_recent_similar_notifications(
notification.user_id,
notification.group_key,
minutes=15
)
return len(recent_similar) > 0
async def group_notification(self, notification):
"""Группирует уведомление с похожими"""
# Находим существующее групповое уведомление
group_notification = await self.get_or_create_group_notification(
notification.user_id,
notification.group_key
)
# Обновляем счетчик
group_notification.data['count'] = group_notification.data.get('count', 0) + 1
# Обновляем заголовок
if notification.type == 'message_new':
count = group_notification.data['count']
group_notification.title = f"У вас {count} новых сообщений"
await group_notification.save()
# Отправляем обновленное уведомление
await self.update_push_notification(group_notification)
async def send_email(self, notification):
"""Отправляет email-уведомление"""
try:
user = await get_user(notification.user_id)
# Генерируем HTML из шаблона
html = await self.render_email_template(notification)
# Отправляем через провайдера
result = await self.email_provider.send(
to=user.email,
subject=notification.title,
html=html,
metadata={
'notification_id': notification.id,
'user_id': user.id,
'type': notification.type
}
)
return {
'channel': 'email',
'status': 'sent',
'provider_id': result.message_id
}
except Exception as e:
logger.error(f"Failed to send email: {e}")
return {
'channel': 'email',
'status': 'failed',
'error': str(e)
}
async def send_push(self, notification):
"""Отправляет push-уведомление"""
try:
# Получаем токены устройств пользователя
devices = await self.get_user_devices(notification.user_id)
if not devices:
return {
'channel': 'push',
'status': 'skipped',
'reason': 'no_devices'
}
# Формируем payload
payload = {
'title': notification.title,
'body': notification.body,
'data': notification.data,
'click_action': notification.action_url,
'icon': self.get_notification_icon(notification.type),
'badge': await self.get_unread_count(notification.user_id)
}
# Отправляем на все устройства
results = await self.push_provider.send_multicast(
tokens=[d.token for d in devices],
notification=payload
)
return {
'channel': 'push',
'status': 'sent',
'success_count': results.success_count,
'failure_count': results.failure_count
}
except Exception as e:
logger.error(f"Failed to send push: {e}")
return {
'channel': 'push',
'status': 'failed',
'error': str(e)
}
async def send_sms(self, notification):
"""Отправляет SMS-уведомление"""
try:
user = await get_user(notification.user_id)
if not user.phone or not user.phone_verified:
return {
'channel': 'sms',
'status': 'skipped',
'reason': 'no_verified_phone'
}
# Формируем текст SMS (максимум 160 символов)
sms_text = self.format_sms_text(notification)
# Отправляем через провайдера
result = await self.sms_provider.send(
to=user.phone,
body=sms_text
)
return {
'channel': 'sms',
'status': 'sent',
'provider_id': result.sid
}
except Exception as e:
logger.error(f"Failed to send SMS: {e}")
return {
'channel': 'sms',
'status': 'failed',
'error': str(e)
}
async def create_in_app_notification(self, notification):
"""Создает in-app уведомление"""
# Сохраняем в БД
await notification.save()
# Отправляем через WebSocket, если пользователь онлайн
if await self.is_user_online(notification.user_id):
await self.send_websocket_notification(notification)
return {
'channel': 'in_app',
'status': 'created'
}
async def send_digest(self, user_id, frequency='daily'):
"""Отправляет дайджест уведомлений"""
# Получаем накопленные уведомления
notifications = await self.get_digest_notifications(user_id, frequency)
if not notifications:
return
# Группируем по категориям
grouped = self.group_by_category(notifications)
# Генерируем HTML дайджеста
html = await self.render_digest_template(grouped)
# Отправляем email
user = await get_user(user_id)
await self.email_provider.send(
to=user.email,
subject=f"Ваш {'ежедневный' if frequency == 'daily' else 'еженедельный'} дайджест",
html=html
)
# Помечаем уведомления как отправленные
await self.mark_notifications_sent(notifications)
Шаблоны уведомлений:
# Конфигурация уведомлений
NOTIFICATION_TEMPLATES = {
'message_new': {
'title': lambda data: f"Новое сообщение от {data['sender_name']}",
'body': lambda data: truncate(data['message_text'], 100),
'action_url': lambda data: f"/messages/{data['conversation_id']}",
'action_label': "Ответить",
'icon': "💬",
'category': "messages",
'priority': "high",
'group_key': lambda data: f"messages:{data['conversation_id']}"
},
'response_new': {
'title': lambda data: f"Новый отклик на объявление '{data['listing_title']}'",
'body': lambda data: f"{data['investor_name']} откликнулся на ваше объявление",
'action_url': lambda data: f"/listings/{data['listing_id']}/responses/{data['response_id']}",
'action_label': "Посмотреть",
'icon': "📬",
'category': "responses",
'priority': "high"
},
'listing_approved': {
'title': "Ваше объявление опубликовано",
'body': lambda data: f"Объявление '{data['listing_title']}' прошло модерацию и опубликовано",
'action_url': lambda data: f"/listings/{data['listing_id']}",
'action_label': "Посмотреть",
'icon': "✅",
'category': "listings",
'priority': "high"
},
'listing_rejected': {
'title': "Ваше объявление отклонено",
'body': lambda data: f"Объявление '{data['listing_title']}' не прошло модерацию. Причина: {data['reason']}",
'action_url': lambda data: f"/listings/{data['listing_id']}/edit",
'action_label': "Исправить",
'icon': "❌",
'category': "listings",
'priority': "high"
},
'recommendation_new': {
'title': "Новое предложение для вас",
'body': lambda data: f"Объявление '{data['listing_title']}' может вас заинтересовать",
'action_url': lambda data: f"/listings/{data['listing_id']}",
'action_label': "Посмотреть",
'icon': "✨",
'category': "recommendations",
'priority': "medium"
},
'verification_approved': {
'title': "Верификация пройдена",
'body': "Ваш профиль верифицирован. Теперь вы можете пользоваться всеми возможностями платформы.",
'action_url': "/profile",
'action_label': "Посмотреть профиль",
'icon': "✓",
'category': "verification",
'priority': "high"
},
'subscription_expiring': {
'title': "Ваша подписка истекает",
'body': lambda data: f"Подписка Premium истекает через {data['days_left']} дней",
'action_url': "/billing",
'action_label': "Продлить",
'icon': "⚠️",
'category': "billing",
'priority': "high"
},
'payment_failed': {
'title': "Не удалось списать оплату",
'body': lambda data: f"Платеж на сумму {data['amount']} ₽ не прошел. Проверьте данные карты.",
'action_url': "/billing/payment-methods",
'action_label': "Обновить",
'icon': "💳",
'category': "billing",
'priority': "critical"
},
'review_new': {
'title': lambda data: f"{data['author_name']} оставил отзыв",
'body': lambda data: f"Рейтинг: {'⭐' * int(data['rating'])} ({data['rating']}/5)",
'action_url': lambda data: f"/profile/reviews#{data['review_id']}",
'action_label': "Посмотреть",
'icon': "⭐",
'category': "reviews",
'priority': "medium"
},
'security_login_new_device': {
'title': "Вход с нового устройства",
'body': lambda data: f"Обнаружен вход с {data['device']} из {data['location']}",
'action_url': "/settings/security",
'action_label': "Проверить",
'icon': "🔐",
'category': "security",
'priority': "high"
}
}
API endpoints:
// API для работы с уведомлениями
// Получить список уведомлений
GET /api/v1/notifications
Query params:
- page: номер страницы
- limit: количество на странице
- filter: 'all', 'unread', 'read'
- category: фильтр по категории
Response:
{
"data": [
{
"id": "uuid",
"type": "message_new",
"category": "messages",
"priority": "high",
"title": "Новое сообщение от Иван Иванов",
"body": "Здравствуйте, меня заинтересовало ваше предложение...",
"icon": "💬",
"actionUrl": "/messages/conversation-id",
"actionLabel": "Ответить",
"isRead": false,
"createdAt": "2024-01-15T10:30:00Z"
}
],
"meta": {
"total": 45,
"unreadCount": 12,
"page": 1,
"totalPages": 3
}
}
// Отметить уведомление как прочитанное
POST /api/v1/notifications/:id/read
// Отметить все уведомления как прочитанные
POST /api/v1/notifications/read-all
// Удалить уведомление
DELETE /api/v1/notifications/:id
// Получить настройки уведомлений
GET /api/v1/notifications/preferences
Response:
{
"messages": {
"email": true,
"push": true,
"sms": false,
"inApp": true,
"frequency": "instant"
},
"responses": { ... },
"doNotDisturb": {
"enabled": false,
"startTime": "22:00",
"endTime": "08:00",
"days": [1, 2, 3, 4, 5]
},
"digest": {
"enabled": true,
"time": "09:00",
"days": [1, 2, 3, 4, 5]
}
}
// Обновить настройки уведомлений
PUT /api/v1/notifications/preferences
Body:
{
"messages": {
"email": true,
"push": true,
"frequency": "instant"
}
}
// Получить количество непрочитанных
GET /api/v1/notifications/unread-count
Response:
{
"total": 12,
"byCategory": {
"messages": 5,
"responses": 3,
"recommendations": 4
}
}
UI компоненты:
// Компонент центра уведомлений
function NotificationCenter() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState('all');
useEffect(() => {
loadNotifications();
// Подписываемся на новые уведомления через WebSocket
socket.on('notification:new', handleNewNotification);
// Обновляем счетчик непрочитанных каждые 30 секунд
const interval = setInterval(updateUnreadCount, 30000);
return () => {
socket.off('notification:new', handleNewNotification);
clearInterval(interval);
};
}, [filter]);
const loadNotifications = async () => {
const response = await api.get('/api/v1/notifications', {
params: { filter }
});
setNotifications(response.data.data);
setUnreadCount(response.data.meta.unreadCount);
};
const handleNewNotification = (notification) => {
// Добавляем в начало списка
setNotifications(prev => [notification, ...prev]);
setUnreadCount(prev => prev + 1);
// Показываем toast-уведомление
showToast(notification);
// Воспроизводим звук (если включено)
if (settings.soundEnabled) {
playNotificationSound();
}
};
const markAsRead = async (notificationId) => {
await api.post(`/api/v1/notifications/${notificationId}/read`);
setNotifications(prev =>
prev.map(n =>
n.id === notificationId ? { ...n, isRead: true } : n
)
);
setUnreadCount(prev => Math.max(0, prev - 1));
};
const markAllAsRead = async () => {
await api.post('/api/v1/notifications/read-all');
setNotifications(prev =>
prev.map(n => ({ ...n, isRead: true }))
);
setUnreadCount(0);
};
return (
<div className="notification-center">
{/* Кнопка открытия */}
<button
className="notification-bell"
onClick={() => setIsOpen(!isOpen)}
>
🔔
{unreadCount > 0 && (
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</button>
{/* Выпадающая панель */}
{isOpen && (
<div className="notification-dropdown">
<div className="dropdown-header">
<h3>Уведомления</h3>
<div className="actions">
<button onClick={markAllAsRead}>
Отметить все как прочитанные
</button>
<Link to="/notifications/settings">
⚙️ Настройки
</Link>
</div>
</div>
<div className="filters">
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => setFilter('all')}
>
Все
</button>
<button
className={filter === 'unread' ? 'active' : ''}
onClick={() => setFilter('unread')}
>
Непрочитанные ({unreadCount})
</button>
</div>
<div className="notifications-list">
{notifications.length === 0 ? (
<EmptyState
icon="🔔"
title="Нет уведомлений"
description="Здесь будут отображаться ваши уведомления"
/>
) : (
notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={() => markAsRead(notification.id)}
onClick={() => {
markAsRead(notification.id);
navigate(notification.actionUrl);
setIsOpen(false);
}}
/>
))
)}
</div>
<div className="dropdown-footer">
<Link to="/notifications">Посмотреть все</Link>
</div>
</div>
)}
</div>
);
}
// Компонент элемента уведомления
function NotificationItem({ notification, onRead, onClick }) {
return (
<div
className={`notification-item ${notification.isRead ? 'read' : 'unread'}`}
onClick={onClick}
>
<div className="icon">{notification.icon}</div>
<div className="content">
<div className="header">
<h4>{notification.title}</h4>
<span className="time">{formatTimeAgo(notification.createdAt)}</span>
</div>
<p className="body">{notification.body}</p>
{notification.actionLabel && (
<button className="action-btn">
{notification.actionLabel}
</button>
)}
</div>
{!notification.isRead && (
<button
className="mark-read-btn"
onClick={(e) => {
e.stopPropagation();
onRead();
}}
title="Отметить как прочитанное"
>
✓
</button>
)}
</div>
);
}
// Компонент настроек уведомлений
function NotificationSettings() {
const [preferences, setPreferences] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadPreferences();
}, []);
const loadPreferences = async () => {
const response = await api.get('/api/v1/notifications/preferences');
setPreferences(response.data);
setLoading(false);
};
const updatePreference = async (category, channel, value) => {
const updated = {
...preferences,
[category]: {
...preferences[category],
[channel]: value
}
};
setPreferences(updated);
await api.put('/api/v1/notifications/preferences', updated);
};
if (loading) return <Loader />;
return (
<div className="notification-settings">
<h2>Настройки уведомлений</h2>
{Object.entries(CATEGORIES).map(([key, category]) => (
<div key={key} className="category-section">
<h3>{category.title}</h3>
<p className="description">{category.description}</p>
<div className="channels">
<label>
<input
type="checkbox"
checked={preferences[key]?.email}
onChange={(e) => updatePreference(key, 'email', e.target.checked)}
/>
Email
</label>
<label>
<input
type="checkbox"
checked={preferences[key]?.push}
onChange={(e) => updatePreference(key, 'push', e.target.checked)}
/>
Push-уведомления
</label>
{userTier === 'premium' && (
<label>
<input
type="checkbox"
checked={preferences[key]?.sms}
onChange={(e) => updatePreference(key, 'sms', e.target.checked)}
/>
SMS
</label>
)}
<label>
<input
type="checkbox"
checked={preferences[key]?.inApp}
onChange={(e) => updatePreference(key, 'inApp', e.target.checked)}
/>
В приложении
</label>
</div>
<div className="frequency">
<label>Частота:</label>
<select
value={preferences[key]?.frequency}
onChange={(e) => updatePreference(key, 'frequency', e.target.value)}
>
<option value="instant">Мгновенно</option>
<option value="digest_daily">Ежедневный дайджест</option>
<option value="digest_weekly">Еженедельный дайджест</option>
<option value="disabled">Отключено</option>
</select>
</div>
</div>
))}
<div className="dnd-section">
<h3>Режим "Не беспокоить"</h3>
<label>
<input
type="checkbox"
checked={preferences.doNotDisturb?.enabled}
onChange={(e) => updateDNDSetting('enabled', e.target.checked)}
/>
Включить режим "Не беспокоить"
</label>
{preferences.doNotDisturb?.enabled && (
<>
<div className="time-range">
<label>
С:
<input
type="time"
value={preferences.doNotDisturb.startTime}
onChange={(e) => updateDNDSetting('startTime', e.target.value)}
/>
</label>
<label>
До:
<input
type="time"
value={preferences.doNotDisturb.endTime}
onChange={(e) => updateDNDSetting('endTime', e.target.value)}
/>
</label>
</div>
<div className="days">
<label>Дни недели:</label>
{WEEKDAYS.map(day => (
<label key={day.value}>
<input
type="checkbox"
checked={preferences.doNotDisturb.days?.includes(day.value)}
onChange={(e) => toggleDNDDay(day.value, e.target.checked)}
/>
{day.label}
</label>
))}
</div>
</>
)}
</div>
<div className="digest-section">
<h3>Дайджест уведомлений</h3>
<label>
<input
type="checkbox"
checked={preferences.digest?.enabled}
onChange={(e) => updateDigestSetting('enabled', e.target.checked)}
/>
Получать дайджест по email
</label>
{preferences.digest?.enabled && (
<>
<label>
Время отправки:
<input
type="time"
value={preferences.digest.time}
onChange={(e) => updateDigestSetting('time', e.target.value)}
/>
</label>
<div className="days">
<label>Дни недели:</label>
{WEEKDAYS.map(day => (
<label key={day.value}>
<input
type="checkbox"
checked={preferences.digest.days?.includes(day.value)}
onChange={(e) => toggleDigestDay(day.value, e.target.checked)}
/>
{day.label}
</label>
))}
</div>
</>
)}
</div>
</div>
);
}
const CATEGORIES = {
messages: {
title: "Сообщения",
description: "Уведомления о новых сообщениях в чате"
},
responses: {
title: "Отклики",
description: "Уведомления об откликах на ваши объявления"
},
listings: {
title: "Объявления",
description: "Уведомления о статусе ваших объявлений"
},
recommendations: {
title: "Рекомендации",
description: "Уведомления о новых релевантных объявлениях"
},
verification: {
title: "Верификация",
description: "Уведомления о статусе верификации"
},
billing: {
title: "Подписка и оплата",
description: "Уведомления о платежах и подписке"
},
reviews: {
title: "Отзывы",
description: "Уведомления о новых отзывах"
},
security: {
title: "Безопасность",
description: "Уведомления о безопасности аккаунта"
}
};
const WEEKDAYS = [
{ value: 1, label: 'Пн' },
{ value: 2, label: 'Вт' },
{ value: 3, label: 'Ср' },
{ value: 4, label: 'Чт' },
{ value: 5, label: 'Пт' },
{ value: 6, label: 'Сб' },
{ value: 7, label: 'Вс' }
];
Примеры use-cases:
UC-4.5: Инвестор получает уведомление о новом сообщении
1. Предприниматель отправляет сообщение инвестору
2. Система создает уведомление:
- Тип: message_new
- Приоритет: high
- Каналы: email, push, in-app
3. Проверяет настройки инвестора:
- Email: включен, частота: мгновенно
- Push: включен
- SMS: выключен (не Premium)
- In-app: включен
4. Проверяет режим "Не беспокоить":
- Текущее время: 23:00
- DND активен с 22:00 до 08:00
- Push и SMS откладываются до 08:00
- Email и in-app отправляются сразу (низкий приоритет DND)
5. Отправляет уведомления:
Email:
- Тема: "Новое сообщение от Иван Петров"
- Тело: превью сообщения + кнопка "Ответить"
- Отправлено через SendGrid
In-app:
- Появляется в центре уведомлений
- Счетчик непрочитанных увеличивается
- Если пользователь онлайн, показывается toast
Push:
- Отложен до 08:00 (DND)
6. В 08:00 отправляется отложенное push-уведомление
7. Инвестор кликает на уведомление:
- Открывается чат
- Уведомление отмечается как прочитанное
- Счетчик непрочитанных уменьшается
UC-4.6: Пользователь настраивает дайджест уведомлений
1. Пользователь заходит в "Настройки" → "Уведомления"
2. Видит настройки по категориям:
Сообщения:
[✓] Email [✓] Push [ ] SMS [✓] В приложении
Частота: [Мгновенно ▼]
Рекомендации:
[✓] Email [ ] Push [ ] SMS [✓] В приложении
Частота: [Ежедневный дайджест ▼]
3. Меняет настройки для рекомендаций:
- Частота: "Ежедневный дайджест"
- Время: 09:00
- Дни: Пн, Вт, Ср, Чт, Пт
4. Сохраняет настройки
5. Теперь уведомления о рекомендациях:
- Не отправляются мгновенно
- Накапливаются в очереди дайджеста
- Отправляются один раз в день в 09:00 по рабочим дням
6. На следующий день в 09:00 пользователь получает email:
"Ваш ежедневный дайджест"
Новые рекомендации (5):
- Объявление 1: "Инвестиции в IT-стартап"
- Объявление 2: "Партнерство в e-commerce"
- ...
[Посмотреть все рекомендации]
UC-4.7: Система отправляет критическое уведомление
1. Система обнаруживает неудачную попытку списания оплаты
2. Создает критическое уведомление:
- Тип: payment_failed
- Приоритет: critical
- Каналы: email, push, SMS, in-app
3. Проверяет настройки пользователя:
- Все каналы включены
- DND активен
4. Критические уведомления игнорируют DND:
- Отправляются немедленно по всем каналам
- Даже если пользователь отключил категорию
5. Отправляет уведомления:
Email:
"Не удалось списать оплату"
"Ваша подписка Premium будет приостановлена через 3 дня"
[Обновить способ оплаты]
Push:
"💳 Платеж не прошел"
"Проверьте данные карты"
SMS (для Premium):
"Платформа: Не удалось списать 4990р. Обновите карту: platform.com/billing"
In-app:
Красный баннер в верхней части всех страниц
6. Пользователь видит уведомления на всех устройствах
7. Кликает на уведомление и переходит к обновлению способа оплаты
8. После успешного обновления:
- Критическое уведомление скрывается
- Отправляется подтверждение: "Способ оплаты обновлен"
Приоритет: [CRITICAL] — MVP
Обоснование решения:
- Engagement: Уведомления возвращают пользователей на платформу
- Конверсия: Своевременные уведомления увеличивают скорость ответа
- Персонализация: Настраиваемые уведомления улучшают UX
- Retention: Дайджесты поддерживают связь с неактивными пользователями
Риски, которые закрывает:
- Пропущенные важные события (сообщения, отклики)
- Спам-уведомления (гибкие настройки)
- Низкая вовлеченность (push-уведомления)
- Отток пользователей (напоминания о платформе)
Влияние на метрики:
- Retention (D7): +30% за счет push-уведомлений
- Время ответа на сообщения: -40%
- Конверсия отклик → диалог: +25%
- Engagement: +45% (пользователи возвращаются по уведомлениям)
- Churn rate: -20% (напоминания о подписке)
Связанные изменения:
- Раздел «Профиль» — добавить настройки уведомлений
- Раздел «Инфраструктура» — добавить сервисы отправки (SendGrid, Firebase, Twilio)
- Раздел «Аналитика» — добавить метрики по уведомлениям (open rate, click rate)
- Раздел «Мобильное приложение» — интеграция push-уведомлений




