Files
tg_tinder_bot/src/services/matchingService.ts
2025-09-18 13:46:35 +09:00

580 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { v4 as uuidv4 } from 'uuid';
import { query, transaction } from '../database/connection';
import { Swipe, SwipeData, SwipeType } from '../models/Swipe';
import { Match, MatchData } from '../models/Match';
import { Profile } from '../models/Profile';
import { ProfileService } from './profileService';
import { NotificationService } from './notificationService';
import { BotError } from '../types';
export class MatchingService {
private profileService: ProfileService;
private notificationService: NotificationService;
constructor() {
this.profileService = new ProfileService();
this.notificationService = new NotificationService();
}
// Выполнить свайп
async performSwipe(telegramId: string, targetTelegramId: string, swipeType: SwipeType): Promise<{
swipe: Swipe;
isMatch: boolean;
match?: Match;
}> {
// Получить профили пользователей
const userProfile = await this.profileService.getProfileByTelegramId(telegramId);
const targetProfile = await this.profileService.getProfileByTelegramId(targetTelegramId); if (!userProfile || !targetProfile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 400);
}
const userId = userProfile.userId;
const targetUserId = targetProfile.userId;
// Проверяем, что пользователь не свайпает сам себя
if (userId === targetUserId) {
throw new BotError('Cannot swipe yourself', 'INVALID_SWIPE');
}
// Проверяем, что свайп еще не был сделан
const existingSwipe = await this.getSwipe(userId, targetUserId);
if (existingSwipe) {
throw new BotError('Already swiped this profile', 'ALREADY_SWIPED');
}
const swipeId = uuidv4();
let isMatch = false;
let match: Match | undefined;
await transaction(async (client) => {
// Создаем свайп
await client.query(`
INSERT INTO swipes (id, user_id, target_user_id, type, created_at)
VALUES ($1, $2, $3, $4, $5)
`, [swipeId, userId, targetUserId, swipeType, new Date()]);
// Если это лайк или суперлайк, проверяем взаимность
if (swipeType === 'like' || swipeType === 'superlike') {
const reciprocalSwipe = await client.query(`
SELECT * FROM swipes
WHERE user_id = $1 AND target_user_id = $2 AND type IN ('like', 'superlike')
`, [targetUserId, userId]);
if (reciprocalSwipe.rows.length > 0) {
// Проверяем, что матч еще не существует
const existingMatch = await client.query(`
SELECT * FROM matches
WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1)
`, [userId, targetUserId]);
if (existingMatch.rows.length === 0) {
isMatch = true;
const matchId = uuidv4();
const isSuperMatch = swipeType === 'superlike' || reciprocalSwipe.rows[0].type === 'superlike';
// Упорядочиваем пользователей для консистентности
const [user1Id, user2Id] = userId < targetUserId ? [userId, targetUserId] : [targetUserId, userId];
// Создаем матч
await client.query(`
INSERT INTO matches (id, user_id_1, user_id_2, created_at, is_active)
VALUES ($1, $2, $3, $4, $5)
`, [matchId, user1Id, user2Id, new Date(), true]);
match = new Match({
id: matchId,
userId1: user1Id,
userId2: user2Id,
createdAt: new Date(),
isActive: true,
isSuperMatch: isSuperMatch,
unreadCount1: 0,
unreadCount2: 0
});
}
}
}
});
const swipe = new Swipe({
id: swipeId,
userId,
targetUserId,
type: swipeType,
timestamp: new Date(),
isMatch
});
// Отправляем уведомления
if (swipeType === 'like' || swipeType === 'superlike') {
this.notificationService.sendLikeNotification(targetTelegramId, telegramId, swipeType === 'superlike');
}
if (isMatch && match) {
this.notificationService.sendMatchNotification(userId, targetUserId);
this.notificationService.sendMatchNotification(targetUserId, userId);
}
return { swipe, isMatch, match };
}
// Получить свайп между двумя пользователями
async getSwipe(userId: string, targetUserId: string): Promise<Swipe | null> {
const result = await query(`
SELECT * FROM swipes
WHERE user_id = $1 AND target_user_id = $2
`, [userId, targetUserId]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToSwipe(result.rows[0]);
}
// Получить свайп между двумя пользователями (псевдоним для getSwipe)
async getSwipeBetweenUsers(userId: string, targetUserId: string): Promise<Swipe | null> {
return this.getSwipe(userId, targetUserId);
}
// Создать свайп (лайк, дислайк или суперлайк)
async createSwipe(userId: string, targetUserId: string, swipeType: SwipeType): Promise<{
swipe: Swipe;
isMatch: boolean;
match?: Match;
}> {
const swipeId = uuidv4();
let isMatch = false;
let match: Match | undefined;
await transaction(async (client) => {
// Создаем свайп
await client.query(`
INSERT INTO swipes (id, user_id, target_user_id, type, created_at)
VALUES ($1, $2, $3, $4, $5)
`, [swipeId, userId, targetUserId, swipeType, new Date()]);
// Если это лайк или суперлайк, проверяем взаимность
if (swipeType === 'like' || swipeType === 'superlike') {
const reciprocalSwipe = await client.query(`
SELECT * FROM swipes
WHERE user_id = $1 AND target_user_id = $2 AND type IN ('like', 'superlike')
`, [targetUserId, userId]);
if (reciprocalSwipe.rows.length > 0) {
// Проверяем, что матч еще не существует
const existingMatch = await client.query(`
SELECT * FROM matches
WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1)
`, [userId, targetUserId]);
if (existingMatch.rows.length === 0) {
isMatch = true;
const matchId = uuidv4();
const isSuperMatch = swipeType === 'superlike' || reciprocalSwipe.rows[0].type === 'superlike';
// Упорядочиваем пользователей для консистентности
const [user1Id, user2Id] = userId < targetUserId ? [userId, targetUserId] : [targetUserId, userId];
// Создаем матч
await client.query(`
INSERT INTO matches (id, user_id_1, user_id_2, created_at, is_active, is_super_match)
VALUES ($1, $2, $3, $4, $5, $6)
`, [matchId, user1Id, user2Id, new Date(), true, isSuperMatch]);
match = new Match({
id: matchId,
userId1: user1Id,
userId2: user2Id,
createdAt: new Date(),
isActive: true,
isSuperMatch: isSuperMatch,
unreadCount1: 0,
unreadCount2: 0
});
// Обновляем свайпы, отмечая что они образуют матч
await client.query(`
UPDATE swipes SET is_match = true
WHERE (user_id = $1 AND target_user_id = $2) OR (user_id = $2 AND target_user_id = $1)
`, [userId, targetUserId]);
}
}
}
});
const swipe = new Swipe({
id: swipeId,
userId,
targetUserId,
type: swipeType,
timestamp: new Date(),
isMatch
});
return { swipe, isMatch, match };
}
// Получить все матчи пользователя по telegram ID
async getUserMatches(telegramId: string, limit: number = 50): Promise<Match[]> {
// Сначала получаем userId по telegramId
const userId = await this.profileService.getUserIdByTelegramId(telegramId);
if (!userId) {
return [];
}
const result = await query(`
SELECT * FROM matches
WHERE (user_id_1 = $1 OR user_id_2 = $1) AND is_active = true
ORDER BY created_at DESC
LIMIT $2
`, [userId, limit]);
return result.rows.map((row: any) => this.mapEntityToMatch(row));
}
// Получить матч по ID
async getMatchById(matchId: string): Promise<Match | null> {
const result = await query(`
SELECT * FROM matches WHERE id = $1
`, [matchId]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToMatch(result.rows[0]);
}
// Получить матч между двумя пользователями
async getMatchBetweenUsers(userId1: string, userId2: string): Promise<Match | null> {
const result = await query(`
SELECT * FROM matches
WHERE ((user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1))
AND is_active = true
`, [userId1, userId2]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToMatch(result.rows[0]);
}
// Размэтчить (деактивировать матч)
async unmatch(userId: string, matchId: string): Promise<boolean> {
const match = await this.getMatchById(matchId);
if (!match || !match.includesUser(userId)) {
throw new BotError('Match not found or access denied', 'MATCH_NOT_FOUND');
}
await query(`
UPDATE matches SET is_active = false WHERE id = $1
`, [matchId]);
return true;
}
// Получить недавние лайки
async getRecentLikes(userId: string, limit: number = 20): Promise<Swipe[]> {
const result = await query(`
SELECT * FROM swipes
WHERE target_user_id = $1 AND type IN ('like', 'superlike') AND is_match = false
ORDER BY created_at DESC
LIMIT $2
`, [userId, limit]);
return result.rows.map((row: any) => this.mapEntityToSwipe(row));
}
// Получить статистику свайпов пользователя за день
async getDailySwipeStats(userId: string): Promise<{
likes: number;
superlikes: number;
passes: number;
total: number;
}> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const result = await query(`
SELECT type, COUNT(*) as count
FROM swipes
WHERE user_id = $1 AND created_at >= $2
GROUP BY type
`, [userId, today]);
const stats = {
likes: 0,
superlikes: 0,
passes: 0,
total: 0
};
result.rows.forEach((row: any) => {
const count = parseInt(row.count);
stats.total += count;
switch (row.type) {
case 'like':
stats.likes = count;
break;
case 'superlike':
stats.superlikes = count;
break;
case 'pass':
stats.passes = count;
break;
}
});
return stats;
}
// Проверить лимиты свайпов
async checkSwipeLimits(userId: string): Promise<{
canLike: boolean;
canSuperLike: boolean;
likesLeft: number;
superLikesLeft: number;
}> {
const stats = await this.getDailySwipeStats(userId);
const likesPerDay = 100; // Из конфига
const superLikesPerDay = 1; // Из конфига
return {
canLike: stats.likes < likesPerDay,
canSuperLike: stats.superlikes < superLikesPerDay,
likesLeft: Math.max(0, likesPerDay - stats.likes),
superLikesLeft: Math.max(0, superLikesPerDay - stats.superlikes)
};
}
// Получить рекомендации для пользователя
async getRecommendations(userId: string, limit: number = 10): Promise<string[]> {
return this.profileService.findCompatibleProfiles(userId, limit)
.then(profiles => profiles.map(p => p.userId));
}
// Преобразование entity в модель Swipe
private mapEntityToSwipe(entity: any): Swipe {
return new Swipe({
id: entity.id,
userId: entity.user_id || entity.swiper_id,
targetUserId: entity.target_user_id || entity.swiped_id,
type: entity.type || 'pass',
timestamp: entity.created_at,
isMatch: entity.is_match
});
}
// Преобразование entity в модель Match
private mapEntityToMatch(entity: any): Match {
return new Match({
id: entity.id,
userId1: entity.user_id_1,
userId2: entity.user_id_2,
createdAt: entity.created_at,
lastMessageAt: entity.last_message_at,
isActive: entity.is_active === true,
isSuperMatch: false, // Определяется из swipes если нужно
unreadCount1: 0,
unreadCount2: 0
});
}
// Получить взаимные лайки (потенциальные матчи)
async getMutualLikes(userId: string): Promise<string[]> {
const result = await query(`
SELECT DISTINCT s1.target_user_id
FROM swipes s1
JOIN swipes s2 ON s1.user_id = s2.target_user_id AND s1.target_user_id = s2.user_id
WHERE s1.user_id = $1
AND s1.type IN ('like', 'superlike')
AND s2.type IN ('like', 'superlike')
AND NOT EXISTS (
SELECT 1 FROM matches m
WHERE (m.user_id_1 = s1.user_id AND m.user_id_2 = s1.target_user_id)
OR (m.user_id_1 = s1.target_user_id AND m.user_id_2 = s1.user_id)
)
`, [userId]);
return result.rows.map((row: any) => row.target_user_id);
}
// Получить следующего кандидата для просмотра
async getNextCandidate(telegramId: string, isNewUser: boolean = false): Promise<Profile | null> {
console.log(`[DEBUG] getNextCandidate вызван для telegramId=${telegramId}, isNewUser=${isNewUser}`);
// Сначала получаем профиль пользователя по telegramId
const userProfile = await this.profileService.getProfileByTelegramId(telegramId);
if (!userProfile) {
console.log(`[ERROR] Профиль пользователя с telegramId=${telegramId} не найден`);
throw new BotError('User profile not found', 'PROFILE_NOT_FOUND');
}
console.log(`[DEBUG] Найден профиль пользователя:`, JSON.stringify({
userId: userProfile.userId,
gender: userProfile.gender,
interestedIn: userProfile.interestedIn,
minAge: userProfile.searchPreferences?.minAge,
maxAge: userProfile.searchPreferences?.maxAge
}));
// Получаем UUID пользователя
const userId = userProfile.userId;
// Определяем, каким должен быть пол показываемых профилей
let targetGender: string;
if (userProfile.interestedIn === 'male' || userProfile.interestedIn === 'female') {
targetGender = userProfile.interestedIn;
} else {
// Если "both" или другое значение, показываем противоположный пол
targetGender = userProfile.gender === 'male' ? 'female' : 'male';
}
console.log(`[DEBUG] Определен целевой пол для поиска: ${targetGender}`);
// Получаем список просмотренных профилей из новой таблицы profile_views
// и добавляем также профили из свайпов для полной совместимости
console.log(`[DEBUG] Запрашиваем просмотренные и свайпнутые профили для userId=${userId}`);
const [viewedProfilesResult, swipedProfilesResult] = await Promise.all([
query(`
SELECT DISTINCT viewed_profile_id
FROM profile_views
WHERE viewer_id = $1
`, [userId]),
query(`
SELECT DISTINCT target_user_id
FROM swipes
WHERE user_id = $1
`, [userId])
]);
console.log(`[DEBUG] Найдено ${viewedProfilesResult.rows.length} просмотренных и ${swipedProfilesResult.rows.length} свайпнутых профилей`);
// Объединяем просмотренные и свайпнутые профили в один список
const viewedUserIds = [
...viewedProfilesResult.rows.map((row: any) => row.viewed_profile_id),
...swipedProfilesResult.rows.map((row: any) => row.target_user_id)
];
// Всегда добавляем самого пользователя в список исключений
viewedUserIds.push(userId);
// Удаляем дубликаты
const uniqueViewedIds = [...new Set(viewedUserIds)];
console.log(`[DEBUG] Всего ${uniqueViewedIds.length} уникальных исключаемых профилей`);
// Формируем параметры запроса
let params: any[] = [];
let excludeCondition: string = '';
// Для новых пользователей исключаем только себя
if (isNewUser || uniqueViewedIds.length <= 1) {
params = [userId];
excludeCondition = 'AND p.user_id != $1';
console.log(`[DEBUG] Режим нового пользователя: исключаем только самого себя`);
} else {
// Для остальных исключаем все просмотренные профили
params = [...uniqueViewedIds];
const placeholders = uniqueViewedIds.map((_: any, i: number) => `$${i + 1}`).join(', ');
excludeCondition = `AND p.user_id NOT IN (${placeholders})`;
console.log(`[DEBUG] Стандартный режим: исключаем ${uniqueViewedIds.length} профилей`);
}
// Выполним предварительный запрос для проверки наличия доступных анкет
const countQuery = `
SELECT COUNT(*) as count
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND p.gender = '${targetGender}'
${excludeCondition}
`;
console.log(`[DEBUG] Проверка наличия подходящих анкет...`);
console.log(`[DEBUG] SQL запрос count: ${countQuery}`);
console.log(`[DEBUG] Параметры count: ${JSON.stringify(params)}`);
const countResult = await query(countQuery, params);
const availableProfilesCount = parseInt(countResult.rows[0]?.count || '0');
console.log(`[DEBUG] Найдено ${availableProfilesCount} доступных профилей`);
// Используем определенный ранее targetGender для поиска
console.log(`[DEBUG] Поиск кандидата для gender=${targetGender}, возраст: ${userProfile.searchPreferences.minAge}-${userProfile.searchPreferences.maxAge}`);
const candidateQuery = `
SELECT p.*, u.telegram_id, u.username, u.first_name, u.last_name
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND p.gender = '${targetGender}'
AND p.age BETWEEN ${userProfile.searchPreferences.minAge} AND ${userProfile.searchPreferences.maxAge}
${excludeCondition}
ORDER BY RANDOM()
LIMIT 1
`;
console.log(`[DEBUG] SQL запрос: ${candidateQuery}`);
console.log(`[DEBUG] Параметры: ${JSON.stringify(params)}`);
const result = await query(candidateQuery, params);
console.log(`[DEBUG] Результаты запроса: найдено ${result.rows.length} профилей`);
if (result.rows.length === 0) {
console.log(`[DEBUG] Подходящие кандидаты не найдены`);
return null;
}
const candidateData = result.rows[0];
console.log(`[DEBUG] Найден подходящий кандидат: ${candidateData.name}, возраст: ${candidateData.age}`);
// Записываем просмотр профиля в новую таблицу profile_views
try {
const viewerTelegramId = telegramId;
const viewedTelegramId = candidateData.telegram_id.toString();
console.log(`[DEBUG] Записываем просмотр профиля: viewer=${viewerTelegramId}, viewed=${viewedTelegramId}`);
// Асинхронно записываем просмотр, но не ждем завершения
this.profileService.recordProfileView(viewerTelegramId, viewedTelegramId, 'browse')
.catch(err => console.error(`[ERROR] Ошибка записи просмотра профиля:`, err));
} catch (err) {
console.error(`[ERROR] Ошибка записи просмотра профиля:`, err);
}
// Используем ProfileService для правильного маппинга данных
const profile = this.profileService.mapEntityToProfile(candidateData);
console.log(`[DEBUG] Профиль преобразован и возвращается клиенту`);
return profile;
}
// VIP функция: поиск кандидатов по цели знакомства
async getCandidatesWithGoal(userProfile: Profile, targetGoal: string): Promise<Profile[]> {
const swipedUsersResult = await query(`
SELECT target_user_id
FROM swipes
WHERE user_id = $1
`, [userProfile.userId]);
const swipedUserIds = swipedUsersResult.rows.map((row: any) => row.target_user_id);
swipedUserIds.push(userProfile.userId); // Исключаем себя
let candidateQuery = `
SELECT DISTINCT p.*, u.telegram_id, u.username, u.first_name, u.last_name
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND u.is_active = true
AND p.gender = $1
AND p.dating_goal = $2
AND p.user_id NOT IN (${swipedUserIds.map((_: any, i: number) => `$${i + 3}`).join(', ')})
ORDER BY p.created_at DESC
LIMIT 50
`;
const params = [userProfile.interestedIn, targetGoal, ...swipedUserIds];
const result = await query(candidateQuery, params);
return result.rows.map((row: any) => this.profileService.mapEntityToProfile(row));
}
}