mirror of
https://github.com/opengram-server/opengram.git
synced 2026-07-19 12:10:17 +03:00
Initial commit
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import minioHelper from '../utils/minioHelper.js';
|
||||
import { generateFileReference, generateAccessHash, generateDocumentId } from '../utils/telegramIds.js';
|
||||
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
const router = express.Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = 'uploads/attributes';
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedExts = ['.png', '.tgs'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .png and .tgs files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Отдельная конфигурация multer для загрузки ZIP-архивов
|
||||
const zipStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = 'uploads/zip';
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, `${file.fieldname}-${uniqueSuffix}.zip`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadZip = multer({
|
||||
storage: zipStorage,
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // до 50 МБ для ZIP
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ext === '.zip') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .zip files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Приводит имя к единому виду для сопоставления: пробелы и дефисы -> подчёркивания,
|
||||
// убирает известные префиксы и расширение
|
||||
function normalizeName(name) {
|
||||
return String(name || '')
|
||||
.toLowerCase()
|
||||
.replace(/\\+/g, '/')
|
||||
.replace(/.*\//, '') // оставляем только имя файла
|
||||
.replace(/\.(tgs|json|png)$/i, '') // убираем расширение
|
||||
.replace(/^(models?|patterns?)_/, '') // убираем известные префиксы
|
||||
.replace(/[\s-]+/g, '_') // пробелы и дефисы -> подчёркивание
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Ищет в ZIP запись .tgs, устойчиво сопоставляя имена
|
||||
function findZipEntryByName(zipEntries, targetName) {
|
||||
if (!targetName) return { entry: null, reason: 'empty targetName' };
|
||||
const normTarget = normalizeName(targetName);
|
||||
|
||||
const tgsEntries = zipEntries.filter(e => e.entryName.toLowerCase().endsWith('.tgs'));
|
||||
|
||||
// Заранее считаем кандидатов с нормализованными именами файлов
|
||||
const candidates = tgsEntries.map(e => ({
|
||||
entry: e,
|
||||
base: e.entryName.replace(/\\+/g,'/').split('/').pop(),
|
||||
})).map(x => ({ ...x, norm: normalizeName(x.base) }));
|
||||
|
||||
// 1) точное совпадение нормализованных имён
|
||||
let found = candidates.find(c => c.norm === normTarget);
|
||||
if (found) return { entry: found.entry, reason: 'norm exact' };
|
||||
|
||||
// 2) совпадение по началу строки (допускает суффиксы вроде _30)
|
||||
found = candidates.find(c => c.norm.startsWith(normTarget));
|
||||
if (found) return { entry: found.entry, reason: 'norm startsWith' };
|
||||
|
||||
// 3) ослабленное: отбрасываем хвост из _цифр у кандидата и сравниваем
|
||||
found = candidates.find(c => c.norm.replace(/_[0-9]+$/, '') === normTarget);
|
||||
if (found) return { entry: found.entry, reason: 'strip _digits equals' };
|
||||
|
||||
// 4) совпадение по началу после отбрасывания цифрового хвоста
|
||||
found = candidates.find(c => c.norm.replace(/_[0-9]+$/, '').startsWith(normTarget));
|
||||
if (found) return { entry: found.entry, reason: 'strip _digits startsWith' };
|
||||
|
||||
// 5) убираем подчёркивания у целевого имени и сравниваем по началу
|
||||
const targetTight = normTarget.replace(/_/g, '');
|
||||
found = candidates.find(c => c.norm.replace(/_/g,'').startsWith(targetTight));
|
||||
if (found) return { entry: found.entry, reason: 'tight startsWith' };
|
||||
|
||||
return { entry: null, reason: `no match for ${normTarget}` };
|
||||
}
|
||||
|
||||
const toJSON = (data) => {
|
||||
return JSON.parse(JSON.stringify(data, (key, value) => {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
return value;
|
||||
}));
|
||||
};
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const attributeSets = await req.db.collection('StarGiftAttributeSets').find({}).sort({ _id: -1 }).toArray();
|
||||
res.json(toJSON(attributeSets));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', upload.fields([
|
||||
{ name: 'model', maxCount: 1 },
|
||||
{ name: 'pattern', maxCount: 1 }
|
||||
]), async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
giftId,
|
||||
name,
|
||||
modelName,
|
||||
patternName,
|
||||
modelRarity,
|
||||
patternRarity
|
||||
} = req.body;
|
||||
|
||||
if (!giftId) return res.status(400).json({ error: 'Gift ID is required' });
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
|
||||
const attributeSet = {
|
||||
_id: Date.now().toString(),
|
||||
GiftId: parseInt(giftId),
|
||||
Name: name,
|
||||
ModelName: modelName || name + ' Model',
|
||||
PatternName: patternName || name + ' Pattern',
|
||||
Model: null,
|
||||
Pattern: null,
|
||||
CreatedAt: new Date()
|
||||
};
|
||||
|
||||
// Модель (TGS)
|
||||
if (req.files.model && req.files.model[0]) {
|
||||
const modelFile = req.files.model[0];
|
||||
const documentId = generateDocumentId();
|
||||
const accessHash = generateAccessHash();
|
||||
const fileReference = generateFileReference();
|
||||
const fileBuffer = await fs.readFile(modelFile.path);
|
||||
|
||||
try {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
JSON.parse(decompressed.toString('utf8'));
|
||||
} catch (error) {
|
||||
try {
|
||||
JSON.parse(fileBuffer.toString('utf8'));
|
||||
} catch (jsonError) {
|
||||
await fs.unlink(modelFile.path);
|
||||
return res.status(400).json({ error: 'Invalid TGS file for model' });
|
||||
}
|
||||
}
|
||||
|
||||
await minioHelper.uploadFile(modelFile.path, documentId, 'application/x-tgsticker');
|
||||
|
||||
// Сохраняем DocumentId и AccessHash как объекты Long (формат MongoDB)
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
AccessHash: BigInt(accessHash),
|
||||
FileReference: fileReference,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: fileBuffer.length,
|
||||
Name: modelFile.originalname,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null,
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Version: 1,
|
||||
Fingerprint: null,
|
||||
Attributes: null,
|
||||
Attributes2: [
|
||||
{ _t: 'TDocumentAttributeFilename', FileName: modelFile.originalname },
|
||||
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 },
|
||||
{ _t: 'TDocumentAttributeAnimated' },
|
||||
{ _t: 'TDocumentAttributeSticker', Mask: false, Alt: '⭐️', Stickerset: { _t: 'TInputStickerSetEmpty' }, MaskCoords: null }
|
||||
]
|
||||
};
|
||||
|
||||
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
|
||||
await fs.unlink(modelFile.path);
|
||||
attributeSet.Model = { DocumentId: documentId, RarityPermille: parseInt(modelRarity) || 1000 };
|
||||
console.log(`Model uploaded: DocumentId=${documentId}, AccessHash=${accessHash}`);
|
||||
}
|
||||
|
||||
// Узор (PNG или TGS)
|
||||
if (req.files.pattern && req.files.pattern[0]) {
|
||||
const patternFile = req.files.pattern[0];
|
||||
const documentId = generateDocumentId();
|
||||
const accessHash = generateAccessHash();
|
||||
const fileReference = generateFileReference();
|
||||
const ext = path.extname(patternFile.originalname).toLowerCase();
|
||||
let mimeType = 'image/png';
|
||||
let attributes = [
|
||||
{ _t: 'TDocumentAttributeFilename', FileName: patternFile.originalname },
|
||||
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 }
|
||||
];
|
||||
|
||||
const fileBuffer = await fs.readFile(patternFile.path);
|
||||
|
||||
if (ext === '.tgs') {
|
||||
mimeType = 'application/x-tgsticker';
|
||||
try {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
JSON.parse(decompressed.toString('utf8'));
|
||||
} catch (error) {
|
||||
try {
|
||||
JSON.parse(fileBuffer.toString('utf8'));
|
||||
} catch (jsonError) {
|
||||
await fs.unlink(patternFile.path);
|
||||
return res.status(400).json({ error: 'Invalid TGS file for pattern' });
|
||||
}
|
||||
}
|
||||
attributes.push({ _t: 'TDocumentAttributeAnimated' });
|
||||
attributes.push({ _t: 'TDocumentAttributeSticker', Alt: '⭐️', Stickerset: { _t: 'TInputStickerSetEmpty' }, Mask: false });
|
||||
}
|
||||
|
||||
await minioHelper.uploadFile(patternFile.path, documentId, mimeType);
|
||||
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
AccessHash: BigInt(accessHash),
|
||||
FileReference: fileReference,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: mimeType,
|
||||
Size: fileBuffer.length,
|
||||
Name: patternFile.originalname,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null,
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Version: 1,
|
||||
Fingerprint: null,
|
||||
Attributes: null,
|
||||
Attributes2: attributes
|
||||
};
|
||||
|
||||
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
|
||||
await fs.unlink(patternFile.path);
|
||||
attributeSet.Pattern = { DocumentId: documentId, RarityPermille: parseInt(patternRarity) || 1000 };
|
||||
}
|
||||
|
||||
// Фон генерируется на сервере автоматически из 100 цветовых схем,
|
||||
// загружать файлы фона или задавать цвета вручную не требуется
|
||||
|
||||
await req.db.collection('StarGiftAttributeSets').insertOne(attributeSet);
|
||||
res.status(201).json(toJSON(attributeSet));
|
||||
} catch (error) {
|
||||
console.error('Create attribute set error:', error);
|
||||
if (req.files) {
|
||||
const allFiles = [...(req.files.model || []), ...(req.files.pattern || [])];
|
||||
for (const file of allFiles) {
|
||||
await fs.unlink(file.path).catch(() => { });
|
||||
}
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const setId = req.params.id;
|
||||
console.log(`[DELETE] Attempting to delete attribute set with ID: ${setId}`);
|
||||
|
||||
// Сначала пробуем найти по _id как по строке
|
||||
let attributeSet = await req.db.collection('StarGiftAttributeSets').findOne({ _id: setId });
|
||||
|
||||
// Если не нашли, а ID похож на ObjectId, пробуем как ObjectId
|
||||
if (!attributeSet && setId.match(/^[0-9a-fA-F]{24}$/)) {
|
||||
console.log(`Trying with ObjectId format...`);
|
||||
attributeSet = await req.db.collection('StarGiftAttributeSets').findOne({ _id: new ObjectId(setId) });
|
||||
}
|
||||
|
||||
if (!attributeSet) {
|
||||
console.log(`Attribute set not found with ID: ${setId}`);
|
||||
return res.status(404).json({ error: 'Attribute set not found' });
|
||||
}
|
||||
|
||||
console.log(`Found attribute set: ${attributeSet.Name}`);
|
||||
|
||||
const documentIds = [];
|
||||
if (attributeSet.Model) documentIds.push(attributeSet.Model.DocumentId);
|
||||
if (attributeSet.Pattern) documentIds.push(attributeSet.Pattern.DocumentId);
|
||||
// Фон генерируется автоматически, удалять документ не нужно
|
||||
|
||||
for (const docId of documentIds) {
|
||||
await req.db.collection('ReadModel-DocumentReadModel').deleteOne({ DocumentId: docId });
|
||||
try {
|
||||
await minioHelper.deleteFile(docId);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete file ${docId} from MinIO:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем в том же формате _id, в каком запись была найдена
|
||||
await req.db.collection('StarGiftAttributeSets').deleteOne({ _id: attributeSet._id });
|
||||
console.log(`Successfully deleted attribute set: ${attributeSet.Name}`);
|
||||
res.json({ message: 'Attribute set deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /attributes/upload-zip - загрузка ZIP-архива с улучшениями подарка
|
||||
router.post('/upload-zip', uploadZip.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const { giftId } = req.body;
|
||||
if (!giftId) {
|
||||
return res.status(400).json({ error: 'giftId is required' });
|
||||
}
|
||||
|
||||
console.log(`Processing ZIP upload for gift ${giftId}: ${req.file.originalname}`);
|
||||
|
||||
const AdmZip = (await import('adm-zip')).default;
|
||||
const zip = new AdmZip(req.file.path);
|
||||
const zipEntries = zip.getEntries();
|
||||
|
||||
// Ищем info.json
|
||||
const infoEntry = zipEntries.find(entry => entry.entryName === 'info.json' || entry.entryName.endsWith('/info.json'));
|
||||
if (!infoEntry) {
|
||||
return res.status(400).json({ error: 'info.json not found in ZIP archive' });
|
||||
}
|
||||
|
||||
// Разбираем info.json
|
||||
const infoContent = infoEntry.getData().toString('utf8');
|
||||
const info = JSON.parse(infoContent);
|
||||
|
||||
const giftName = info.gift_name || `Gift ${giftId}`;
|
||||
const models = info.models || [];
|
||||
const patterns = info.patterns || [];
|
||||
const backdrops = info.backdrops || [];
|
||||
|
||||
console.log(`Found info.json: gift="${giftName}", models=${models.length}, patterns=${patterns.length}, backdrops=${backdrops.length}`);
|
||||
|
||||
if (models.length === 0 || patterns.length === 0) {
|
||||
return res.status(400).json({ error: 'No models or patterns found in info.json' });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const db = req.db;
|
||||
|
||||
// Формируем комбинации: каждая модель с каждым узором и случайным фоном
|
||||
const maxCombinations = Math.min(models.length * patterns.length, 100); // не более 100 комбинаций
|
||||
console.log(`Creating ${maxCombinations} attribute combinations...`);
|
||||
|
||||
for (let i = 0; i < Math.min(models.length, 20); i++) {
|
||||
const model = models[i];
|
||||
for (let j = 0; j < Math.min(patterns.length, 5); j++) {
|
||||
if (results.length >= 50) break; // общее ограничение
|
||||
|
||||
const pattern = patterns[j];
|
||||
const backdrop = backdrops[Math.floor(Math.random() * backdrops.length)];
|
||||
|
||||
console.log(`\nProcessing combination ${results.length + 1}: ${model.name} + ${pattern.name}`);
|
||||
|
||||
let modelDocId = null;
|
||||
let patternDocId = null;
|
||||
|
||||
// Загружаем файл модели
|
||||
if (model.file) {
|
||||
const { entry: modelEntry, reason } = findZipEntryByName(zipEntries, model.file);
|
||||
if (modelEntry) {
|
||||
const modelBuffer = modelEntry.getData();
|
||||
modelDocId = await uploadDocument(db, modelBuffer, model.file, 'model');
|
||||
console.log(` Model uploaded: ${model.file} -> ${modelDocId} (${reason})`);
|
||||
} else {
|
||||
console.log(` Model file not found: ${model.file} (${reason})`);
|
||||
continue; // пропускаем эту комбинацию
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем файл узора
|
||||
if (pattern.file) {
|
||||
const { entry: patternEntry, reason } = findZipEntryByName(zipEntries, pattern.file);
|
||||
if (patternEntry) {
|
||||
const patternBuffer = patternEntry.getData();
|
||||
patternDocId = await uploadDocument(db, patternBuffer, pattern.file, 'pattern');
|
||||
console.log(` Pattern uploaded: ${pattern.file} -> ${patternDocId} (${reason})`);
|
||||
} else {
|
||||
console.log(` Pattern file not found: ${pattern.file} (${reason})`);
|
||||
continue; // пропускаем эту комбинацию
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём запись набора атрибутов
|
||||
const attributeSet = {
|
||||
_id: Date.now().toString() + '-' + Math.random().toString(36).substring(7), // генерируем уникальный ID
|
||||
GiftId: parseInt(giftId),
|
||||
Name: `${model.name} ${pattern.name}`,
|
||||
Model: modelDocId ? {
|
||||
DocumentId: modelDocId,
|
||||
RarityPermille: Math.round(model.rarity_percent * 10) // переводим проценты в промилле
|
||||
} : null,
|
||||
Pattern: patternDocId ? {
|
||||
DocumentId: patternDocId,
|
||||
RarityPermille: Math.round(pattern.rarity_percent * 10)
|
||||
} : null,
|
||||
Backdrop: backdrop ? {
|
||||
BackdropId: backdrop.id || parseInt(giftId),
|
||||
CenterColor: parseInt(backdrop.center_color?.replace('#', '') || 'FF0000', 16),
|
||||
EdgeColor: parseInt(backdrop.edge_color?.replace('#', '') || '0000FF', 16),
|
||||
PatternColor: parseInt(backdrop.pattern_color?.replace('#', '') || '5AC8FA', 16),
|
||||
TextColor: parseInt(backdrop.text_color?.replace('#', '') || 'FFFFFF', 16),
|
||||
RarityPermille: Math.round(backdrop.rarity_percent * 10)
|
||||
} : null,
|
||||
ModelName: model.name,
|
||||
PatternName: pattern.name,
|
||||
BackdropName: backdrop?.name || 'Random',
|
||||
CreatedAt: new Date() // отметка времени для единообразия
|
||||
};
|
||||
|
||||
// Добавляем запись в StarGiftAttributeSets
|
||||
await db.collection('StarGiftAttributeSets').insertOne(attributeSet);
|
||||
console.log(` Attribute set created: ${model.name} + ${pattern.name}`);
|
||||
|
||||
results.push({
|
||||
model: model.name,
|
||||
pattern: pattern.name,
|
||||
backdrop: backdrop?.name,
|
||||
modelDocId,
|
||||
patternDocId
|
||||
});
|
||||
}
|
||||
if (results.length >= 50) break;
|
||||
}
|
||||
|
||||
// Удаляем загруженный ZIP-файл
|
||||
await fs.unlink(req.file.path);
|
||||
|
||||
console.log(`\nSuccessfully processed ${results.length} upgrades for gift ${giftId}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
giftId: parseInt(giftId),
|
||||
title: giftName,
|
||||
upgradesProcessed: results.length,
|
||||
results
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('ZIP upload error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Вспомогательная функция: сохраняет документ в MongoDB
|
||||
async function uploadDocument(db, fileBuffer, filename, type) {
|
||||
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
||||
|
||||
const document = {
|
||||
_id: `document-${documentId}`,
|
||||
Id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
AccessHash: generateAccessHash(),
|
||||
FileReference: generateFileReference(),
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: fileBuffer.length,
|
||||
DcId: 2,
|
||||
// Attributes2 оставляем пустым: сервер ждёт TL-типы, а не сырой JSON
|
||||
Attributes2: [],
|
||||
// Attributes не задаём массивом — опускаем или ставим null, сервер ждёт byte[].
|
||||
// Здесь поле опущено, чтобы соответствовать схеме
|
||||
Thumbs: [],
|
||||
VideoThumbs: []
|
||||
};
|
||||
|
||||
// Пишем в обе коллекции, чтобы query-server увидел документ
|
||||
await db.collection('eventflow-documentreadmodel').insertOne(document);
|
||||
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
|
||||
|
||||
// Заливаем содержимое в MinIO под именем объекта = DocumentId (без расширения).
|
||||
// Сохраняем буфер во временный файл, загружаем и удаляем его
|
||||
const tmpDir = 'uploads/tmp';
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
const tmpPath = `${tmpDir}/doc_${documentId}.bin`;
|
||||
await fs.writeFile(tmpPath, fileBuffer);
|
||||
try {
|
||||
// отложенный импорт, чтобы избежать циклической зависимости
|
||||
const { uploadFile } = (await import('../utils/minioHelper.js')).default ? (await import('../utils/minioHelper.js')).default : await import('../utils/minioHelper.js');
|
||||
// minioHelper экспортирует default с методами; поддерживаем и default, и именованный экспорт
|
||||
const helper = (await import('../utils/minioHelper.js')).default;
|
||||
if (helper && helper.uploadFile) {
|
||||
await helper.uploadFile(tmpPath, documentId, 'application/x-tgsticker');
|
||||
} else if (uploadFile) {
|
||||
await uploadFile(tmpPath, documentId, 'application/x-tgsticker');
|
||||
}
|
||||
} finally {
|
||||
try { await fs.unlink(tmpPath); } catch {}
|
||||
}
|
||||
|
||||
return documentId;
|
||||
}
|
||||
|
||||
export default router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
import express from 'express';
|
||||
import { MongoClient } from 'mongodb';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017';
|
||||
const DB_NAME = 'tg';
|
||||
|
||||
// Get MongoDB client
|
||||
async function getMongoClient() {
|
||||
const client = new MongoClient(MONGO_URI);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
// Get all frozen accounts
|
||||
router.get('/', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
const frozenAccounts = await frozenCollection.find({}).sort({ CreatedDate: -1 }).toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: frozenAccounts,
|
||||
count: frozenAccounts.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching frozen accounts:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Get frozen account by user ID
|
||||
router.get('/user/:userId', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
const frozenAccount = await frozenCollection.findOne({ UserId: userId });
|
||||
|
||||
if (!frozenAccount) {
|
||||
return res.status(404).json({ success: false, error: 'Frozen account not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: frozenAccount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching frozen account:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Freeze account
|
||||
router.post('/freeze', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
const {
|
||||
userId: userIdRaw,
|
||||
reason,
|
||||
durationDays = 7,
|
||||
moderatorUserId,
|
||||
note
|
||||
} = req.body;
|
||||
|
||||
if (!userIdRaw) {
|
||||
return res.status(400).json({ success: false, error: 'userId is required' });
|
||||
}
|
||||
|
||||
// Преобразуем userId в число
|
||||
const userId = parseInt(userIdRaw);
|
||||
if (isNaN(userId)) {
|
||||
return res.status(400).json({ success: false, error: 'userId must be a valid number' });
|
||||
}
|
||||
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
const usersCollection = db.collection('eventflow-userreadmodel');
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
// Check if user exists
|
||||
let user = await usersCollection.findOne({ UserId: userId });
|
||||
if (!user) {
|
||||
// Try alternative collection
|
||||
user = await db.collection('ReadModel-UserReadModel').findOne({ UserId: userId });
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `User ${userId} not found. Make sure the user is registered in the system.`,
|
||||
hint: 'The user must have logged in at least once to MyTelegram'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already frozen (any status)
|
||||
const existingFrozen = await frozenCollection.findOne({ UserId: userId });
|
||||
|
||||
if (existingFrozen) {
|
||||
// Если уже заморожен с Status: 1 (Active)
|
||||
if (existingFrozen.Status === 1) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'User is already frozen with active status'
|
||||
});
|
||||
}
|
||||
|
||||
// Если запись существует но не активна (Status: 2 или 3), обновляем её
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const freezeUntil = now + (durationDays * 24 * 60 * 60);
|
||||
|
||||
await frozenCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
FreezeSinceDate: now,
|
||||
FreezeUntilDate: freezeUntil,
|
||||
Reason: reason || 4,
|
||||
Status: 1, // Active
|
||||
AppealUrl: 'https://t.me/spambot',
|
||||
ModeratorUserId: moderatorUserId || null,
|
||||
FreezeNote: note || null,
|
||||
LastModifiedDate: new Date(),
|
||||
Metadata: {
|
||||
frozenBy: 'admin_panel',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update user record
|
||||
await usersCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
IsFrozen: true,
|
||||
FreezeSinceDate: now,
|
||||
FreezeUntilDate: freezeUntil,
|
||||
FreezeReason: reason || 4,
|
||||
FreezeAppealUrl: 'https://t.me/spambot'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Account re-frozen successfully (updated existing record)',
|
||||
data: { ...existingFrozen, Status: 1, FreezeSinceDate: now, FreezeUntilDate: freezeUntil }
|
||||
});
|
||||
}
|
||||
|
||||
// Новая заморозка - создаем запись
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const freezeUntil = now + (durationDays * 24 * 60 * 60);
|
||||
|
||||
const frozenDoc = {
|
||||
_id: `frozen-${userId}`,
|
||||
UserId: userId,
|
||||
FreezeSinceDate: now,
|
||||
FreezeUntilDate: freezeUntil,
|
||||
Reason: reason || 4, // Default: TosViolation
|
||||
Status: 1, // Active
|
||||
AppealUrl: 'https://t.me/spambot',
|
||||
ModeratorUserId: moderatorUserId || null,
|
||||
FreezeNote: note || null,
|
||||
CreatedDate: new Date(),
|
||||
Metadata: {
|
||||
frozenBy: 'admin_panel',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
await frozenCollection.insertOne(frozenDoc);
|
||||
|
||||
// Update user record
|
||||
await usersCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
IsFrozen: true,
|
||||
FreezeSinceDate: now,
|
||||
FreezeUntilDate: freezeUntil,
|
||||
FreezeReason: reason || 4,
|
||||
FreezeAppealUrl: 'https://t.me/spambot'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Account frozen successfully',
|
||||
data: frozenDoc
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error freezing account:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Unfreeze account
|
||||
router.post('/unfreeze', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
const { userId: userIdRaw, moderatorUserId, note } = req.body;
|
||||
|
||||
if (!userIdRaw) {
|
||||
return res.status(400).json({ success: false, error: 'userId is required' });
|
||||
}
|
||||
|
||||
const userId = parseInt(userIdRaw);
|
||||
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
const usersCollection = db.collection('eventflow-userreadmodel');
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
// Check if frozen
|
||||
const frozenAccount = await frozenCollection.findOne({ UserId: userId });
|
||||
if (!frozenAccount) {
|
||||
return res.status(404).json({ success: false, error: 'Frozen account not found' });
|
||||
}
|
||||
|
||||
// Update frozen record
|
||||
await frozenCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Status: 3, // Approved
|
||||
LastModifiedDate: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update user record
|
||||
await usersCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
IsFrozen: false
|
||||
},
|
||||
$unset: {
|
||||
FreezeSinceDate: "",
|
||||
FreezeUntilDate: "",
|
||||
FreezeReason: "",
|
||||
FreezeAppealUrl: ""
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Account unfrozen successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error unfreezing account:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Get appeals
|
||||
router.get('/appeals', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
const appealsCollection = db.collection('ReadModel-UserFrozenAppealHistoryReadModel');
|
||||
|
||||
const appeals = await appealsCollection.find({}).sort({ SubmittedDate: -1 }).toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: appeals,
|
||||
count: appeals.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching appeals:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Review appeal
|
||||
router.post('/appeals/:appealId/review', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
const { appealId } = req.params;
|
||||
const { status, moderatorUserId, reviewNote } = req.body;
|
||||
|
||||
if (!status || ![2, 3].includes(status)) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid status (2=Approved, 3=Rejected)' });
|
||||
}
|
||||
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
const appealsCollection = db.collection('ReadModel-UserFrozenAppealHistoryReadModel');
|
||||
|
||||
const appeal = await appealsCollection.findOne({ AppealId: appealId });
|
||||
if (!appeal) {
|
||||
return res.status(404).json({ success: false, error: 'Appeal not found' });
|
||||
}
|
||||
|
||||
// Update appeal
|
||||
await appealsCollection.updateOne(
|
||||
{ AppealId: appealId },
|
||||
{
|
||||
$set: {
|
||||
Status: status,
|
||||
ReviewerModeratorId: moderatorUserId,
|
||||
ReviewNote: reviewNote,
|
||||
ReviewedDate: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// If approved, unfreeze the account
|
||||
if (status === 2) {
|
||||
const usersCollection = db.collection('eventflow-userreadmodel');
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
await frozenCollection.updateOne(
|
||||
{ UserId: appeal.UserId },
|
||||
{ $set: { Status: 3 } } // Approved
|
||||
);
|
||||
|
||||
await usersCollection.updateOne(
|
||||
{ UserId: appeal.UserId },
|
||||
{
|
||||
$set: { IsFrozen: false },
|
||||
$unset: {
|
||||
FreezeSinceDate: "",
|
||||
FreezeUntilDate: "",
|
||||
FreezeReason: "",
|
||||
FreezeAppealUrl: ""
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: status === 2 ? 'Appeal approved' : 'Appeal rejected'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reviewing appeal:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,172 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { MongoClient } from 'mongodb';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for TGS file upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadDir = path.join(__dirname, '../../uploads/frozen');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, 'snowflake-' + Date.now() + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: function (req, file, cb) {
|
||||
// Accept TGS files
|
||||
if (file.mimetype === 'application/x-tgsticker' ||
|
||||
file.originalname.endsWith('.tgs') ||
|
||||
file.mimetype === 'application/gzip') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only TGS files are allowed!'), false);
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 64 * 1024 // 64KB max for TGS
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/frozen-settings
|
||||
* Get current frozen account settings
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
|
||||
|
||||
await client.connect();
|
||||
const db = client.db('tg');
|
||||
const settingsCollection = db.collection('settings');
|
||||
|
||||
// Get frozen settings
|
||||
let settings = await settingsCollection.findOne({ _id: 'frozen-account-settings' });
|
||||
|
||||
if (!settings) {
|
||||
settings = {
|
||||
_id: 'frozen-account-settings',
|
||||
snowflakeEmojiId: null,
|
||||
snowflakeFileName: null,
|
||||
snowflakeUploadedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
await client.close();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings: settings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching frozen settings:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/frozen-settings/upload-snowflake
|
||||
* Upload TGS snowflake sticker
|
||||
*/
|
||||
router.post('/upload-snowflake', upload.single('snowflake'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
|
||||
|
||||
await client.connect();
|
||||
const db = client.db('tg');
|
||||
const settingsCollection = db.collection('settings');
|
||||
|
||||
// Save file info to settings
|
||||
const settings = {
|
||||
_id: 'frozen-account-settings',
|
||||
snowflakeFileName: req.file.filename,
|
||||
snowflakeFilePath: req.file.path,
|
||||
snowflakeUploadedAt: new Date(),
|
||||
snowflakeEmojiId: null // Will be set after uploading to MyTelegram
|
||||
};
|
||||
|
||||
await settingsCollection.replaceOne(
|
||||
{ _id: 'frozen-account-settings' },
|
||||
settings,
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Snowflake TGS uploaded successfully',
|
||||
file: {
|
||||
filename: req.file.filename,
|
||||
size: req.file.size,
|
||||
path: req.file.path
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading snowflake:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/frozen-settings/set-emoji-id
|
||||
* Set the Document ID for frozen snowflake emoji
|
||||
*/
|
||||
router.post('/set-emoji-id', async (req, res) => {
|
||||
try {
|
||||
const { documentId } = req.body;
|
||||
|
||||
if (!documentId || isNaN(documentId)) {
|
||||
return res.status(400).json({ success: false, error: 'Valid documentId is required' });
|
||||
}
|
||||
|
||||
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
|
||||
|
||||
await client.connect();
|
||||
const db = client.db('tg');
|
||||
const settingsCollection = db.collection('settings');
|
||||
|
||||
// Update emoji ID
|
||||
await settingsCollection.updateOne(
|
||||
{ _id: 'frozen-account-settings' },
|
||||
{
|
||||
$set: {
|
||||
snowflakeEmojiId: parseInt(documentId),
|
||||
emojiIdUpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Frozen snowflake emoji ID updated',
|
||||
documentId: parseInt(documentId)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting emoji ID:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,350 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import Joi from 'joi';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import minioHelper from '../utils/minioHelper.js';
|
||||
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
const router = express.Router();
|
||||
|
||||
// --- Configuration ---
|
||||
const UPLOAD_DIR = 'uploads/stickers';
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_MIME_TYPES = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
|
||||
const ALLOWED_EXTENSIONS = ['.json', '.tgs'];
|
||||
|
||||
// --- Multer Setup ---
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
|
||||
cb(null, `sticker-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .json and .tgs files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
const toJSON = (data) => {
|
||||
return JSON.parse(JSON.stringify(data, (key, value) => {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (value && typeof value === 'object' && (value._bsontype === 'Long' || (value.low !== undefined && value.high !== undefined))) {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
}));
|
||||
};
|
||||
|
||||
const generateId = () => Date.now() * 10000 + Math.floor(Math.random() * 10000);
|
||||
const generateAccessHash = () => Date.now() * 100000 + Math.floor(Math.random() * 100000);
|
||||
|
||||
// --- Validation ---
|
||||
const giftSchema = Joi.object({
|
||||
giftId: Joi.alt([Joi.number().integer().positive(), Joi.string().pattern(/^\d+$/)]).required(),
|
||||
|
||||
stars: Joi.number().integer().positive().required(),
|
||||
|
||||
convertStars: Joi.number().integer().positive()
|
||||
.less(Joi.ref('stars'))
|
||||
.messages({
|
||||
'number.less': 'convertStars must be less than stars'
|
||||
})
|
||||
.required(),
|
||||
|
||||
limited: Joi.boolean().default(false),
|
||||
soldOut: Joi.boolean().default(false),
|
||||
birthday: Joi.boolean().default(false),
|
||||
requirePremium: Joi.boolean().default(false),
|
||||
limitedPerUser: Joi.boolean().default(false),
|
||||
|
||||
availabilityTotal: Joi.number().integer().positive().optional(),
|
||||
|
||||
availabilityRemains: Joi.number().integer().min(0).max(Joi.ref('availabilityTotal')).optional(),
|
||||
|
||||
perUserTotal: Joi.when('limitedPerUser', {
|
||||
is: true,
|
||||
then: Joi.number().integer().positive().required(),
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
|
||||
upgradeStars: Joi.number().integer().positive().optional().allow(null),
|
||||
title: Joi.string().max(100).optional().allow(null, ''),
|
||||
resellMinStars: Joi.number().integer().positive().optional().allow(null),
|
||||
|
||||
stickerId: Joi.alt([Joi.number().integer().positive(), Joi.string().pattern(/^\d+$/)]).optional(),
|
||||
|
||||
releasedBy: Joi.object({
|
||||
type: Joi.string().valid('user', 'channel').required(),
|
||||
id: Joi.number().integer().positive().required()
|
||||
}).optional().allow(null),
|
||||
|
||||
// Эти поля не должны устанавливаться вручную
|
||||
firstSaleDate: Joi.forbidden(),
|
||||
lastSaleDate: Joi.forbidden()
|
||||
});
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
// GET All Gifts
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { soldOut, limited, sort = 'stars' } = req.query;
|
||||
const filter = {};
|
||||
if (soldOut !== undefined) filter.SoldOut = soldOut === 'true';
|
||||
if (limited !== undefined) filter.Limited = limited === 'true';
|
||||
|
||||
const sortOptions = {};
|
||||
if (sort === 'stars') sortOptions.Stars = 1;
|
||||
else if (sort === 'date') sortOptions._id = -1;
|
||||
else if (sort === 'availability') sortOptions.AvailabilityRemains = 1;
|
||||
|
||||
const gifts = await req.db.collection('AvailableStarGiftReadModel')
|
||||
.find(filter).sort(sortOptions).toArray();
|
||||
|
||||
res.json(toJSON(gifts));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET Gift by ID
|
||||
router.get('/:giftId', async (req, res) => {
|
||||
try {
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const gift = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: giftId });
|
||||
if (!gift) return res.status(404).json({ error: 'Gift not found' });
|
||||
res.json(toJSON(gift));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST Create Gift
|
||||
router.post('/', upload.single('sticker'), async (req, res) => {
|
||||
let uploadedFilePath = null;
|
||||
try {
|
||||
const giftData = typeof req.body.data === 'string' ? JSON.parse(req.body.data) : req.body;
|
||||
const { error, value } = giftSchema.validate(giftData);
|
||||
if (error) return res.status(400).json({ error: error.details[0].message });
|
||||
|
||||
const safeGiftId = BigInt(value.giftId);
|
||||
const existing = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: safeGiftId });
|
||||
if (existing) return res.status(409).json({ error: 'Gift with this ID already exists' });
|
||||
|
||||
let stickerDocumentId = null;
|
||||
if (req.file) {
|
||||
uploadedFilePath = req.file.path;
|
||||
const fileBuffer = await fs.readFile(uploadedFilePath);
|
||||
|
||||
// Проверяем TGS
|
||||
try {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
JSON.parse(decompressed.toString('utf8'));
|
||||
} catch (e) {
|
||||
try {
|
||||
JSON.parse(fileBuffer.toString('utf8'));
|
||||
} catch (jsonErr) {
|
||||
throw new Error('Invalid TGS file');
|
||||
}
|
||||
}
|
||||
|
||||
const documentId = generateId();
|
||||
const accessHash = generateAccessHash();
|
||||
|
||||
await minioHelper.uploadFile(uploadedFilePath, documentId, 'application/x-tgsticker');
|
||||
|
||||
// Правильный FileReference на 24 байта вместо 8
|
||||
const fileRefBuffer = Buffer.allocUnsafe(24);
|
||||
fileRefBuffer.writeUInt8(2, 0); // версия 2
|
||||
fileRefBuffer.writeUInt8(2, 1); // ID дата-центра
|
||||
fileRefBuffer.writeBigInt64BE(BigInt(documentId), 2); // ID документа (8 байт)
|
||||
fileRefBuffer.writeBigInt64BE(BigInt(accessHash), 10); // access hash (8 байт)
|
||||
fileRefBuffer.writeUInt32BE(Math.floor(Date.now() / 1000), 18); // метка времени (4 байта)
|
||||
fileRefBuffer.writeUInt16BE(Math.floor(Math.random() * 0xFFFF), 22); // случайные данные (2 байта)
|
||||
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`,
|
||||
Id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
Version: 1,
|
||||
AccessHash: accessHash,
|
||||
FileReference: fileRefBuffer,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: fileBuffer.length,
|
||||
Name: `${documentId}`,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null, // null вместо хардкода — Telegram сгенерирует сам
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Attributes: null,
|
||||
Attributes2: [
|
||||
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 },
|
||||
{
|
||||
_t: 'TDocumentAttributeSticker',
|
||||
Alt: '🎁',
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetEmpty'
|
||||
},
|
||||
Mask: false
|
||||
},
|
||||
{ _t: 'TDocumentAttributeAnimated' },
|
||||
{ _t: 'TDocumentAttributeFilename', FileName: 'sticker.tgs' }
|
||||
]
|
||||
};
|
||||
|
||||
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
|
||||
stickerDocumentId = documentId;
|
||||
await fs.unlink(uploadedFilePath);
|
||||
uploadedFilePath = null;
|
||||
} else if (value.stickerId) {
|
||||
stickerDocumentId = BigInt(value.stickerId);
|
||||
}
|
||||
|
||||
const gift = {
|
||||
_id: value.giftId.toString(),
|
||||
GiftId: safeGiftId,
|
||||
Limited: value.limited,
|
||||
SoldOut: value.soldOut,
|
||||
Birthday: value.birthday,
|
||||
RequirePremium: value.requirePremium || false, // берём из value
|
||||
LimitedPerUser: value.limitedPerUser || false, // берём из value
|
||||
Stars: value.stars,
|
||||
ConvertStars: value.convertStars,
|
||||
UpgradeStars: value.upgradeStars || null,
|
||||
AvailabilityTotal: value.availabilityTotal || null,
|
||||
AvailabilityRemains: value.availabilityRemains ?? value.availabilityTotal ?? null,
|
||||
AvailabilityResale: null,
|
||||
FirstSaleDate: null, // null — установится при первой покупке
|
||||
LastSaleDate: null, // null — установится при покупке
|
||||
Title: value.title || null, // null вместо пустой строки
|
||||
ResellMinStars: value.resellMinStars || null,
|
||||
Sticker: stickerDocumentId,
|
||||
ReleasedBy: value.releasedBy ? { // поддержка ReleasedBy
|
||||
Type: value.releasedBy.type,
|
||||
Id: value.releasedBy.id
|
||||
} : null,
|
||||
PerUserTotal: value.limitedPerUser ? value.perUserTotal : null, // берём из value
|
||||
PerUserRemains: value.limitedPerUser ? value.perUserTotal : null, // берём из value
|
||||
LockedUntilDate: null,
|
||||
Version: 1
|
||||
};
|
||||
|
||||
await req.db.collection('AvailableStarGiftReadModel').insertOne(gift);
|
||||
res.status(201).json(toJSON(gift));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create gift error:', error);
|
||||
if (uploadedFilePath) await fs.unlink(uploadedFilePath).catch(() => { });
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT Update Gift
|
||||
router.put('/:giftId', async (req, res) => {
|
||||
try {
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const giftData = req.body;
|
||||
delete giftData.giftId;
|
||||
|
||||
const result = await req.db.collection('AvailableStarGiftReadModel').findOneAndUpdate(
|
||||
{ GiftId: giftId },
|
||||
{ $set: { ...giftData, UpdatedAt: new Date() } },
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) return res.status(404).json({ error: 'Gift not found' });
|
||||
res.json(toJSON(result));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE Gift
|
||||
router.delete('/:giftId', async (req, res) => {
|
||||
try {
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const result = await req.db.collection('AvailableStarGiftReadModel').deleteOne({ GiftId: giftId });
|
||||
if (result.deletedCount === 0) return res.status(404).json({ error: 'Gift not found' });
|
||||
res.json({ message: 'Gift deleted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST Send Gift to User (Admin/Test)
|
||||
router.post('/:giftId/send-to-user', async (req, res) => {
|
||||
try {
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const { userId, fromUserId, message, nameHidden, count } = req.body;
|
||||
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
||||
|
||||
const gift = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: giftId });
|
||||
if (!gift) return res.status(404).json({ error: 'Gift not found' });
|
||||
|
||||
const fromId = fromUserId ? Number(fromUserId) : 0;
|
||||
if (!fromId || fromId <= 0) return res.status(400).json({ success: false, error: 'fromUserId is required (stars will be spent)' });
|
||||
|
||||
const payload = {
|
||||
fromUserId: fromId,
|
||||
toUserId: parseInt(userId),
|
||||
giftId: Number(giftId),
|
||||
count: count || 1,
|
||||
nameHidden: !!nameHidden,
|
||||
canUpgrade: null,
|
||||
message: message || null
|
||||
};
|
||||
|
||||
const adminUrl = process.env.ADMIN_API_URL || 'http://localhost:5000';
|
||||
const adminKey = process.env.ADMIN_API_KEY || '';
|
||||
|
||||
const response = await fetch(`${adminUrl}/api/admin/star-gifts/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-API-Key': adminKey
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
let data = {};
|
||||
try {
|
||||
data = rawText ? JSON.parse(rawText) : {};
|
||||
} catch {
|
||||
data = { error: rawText };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Send gift error:', { status: response.status, body: data });
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, ...data });
|
||||
} catch (error) {
|
||||
console.error('Send gift error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,588 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import Joi from 'joi';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import { Long } from 'mongodb';
|
||||
import minioHelper from '../utils/minioHelper.js';
|
||||
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// File upload configuration
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = 'uploads/stickers';
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, `sticker-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
|
||||
const allowedExts = ['.json', '.tgs'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .json and .tgs files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to handle BigInt and BSON Long serialization
|
||||
const toJSON = (data) => {
|
||||
return JSON.parse(JSON.stringify(data, (key, value) => {
|
||||
// Handle native BigInt
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
// Handle BSON Long objects (which have low, high, unsigned properties)
|
||||
if (value && typeof value === 'object' && (value._bsontype === 'Long' || (value.hasOwnProperty('low') && value.hasOwnProperty('high')))) {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
}));
|
||||
};
|
||||
|
||||
// Validation schemas
|
||||
const giftSchema = Joi.object({
|
||||
// ✅ FIX: Allow string input for large 64-bit IDs
|
||||
giftId: Joi.alt([
|
||||
Joi.number().integer().positive(),
|
||||
Joi.string().pattern(/^\d+$/)
|
||||
]).required(),
|
||||
limited: Joi.boolean().default(false),
|
||||
soldOut: Joi.boolean().default(false),
|
||||
birthday: Joi.boolean().default(false),
|
||||
stars: Joi.number().integer().positive().required(),
|
||||
convertStars: Joi.number().integer().positive().required(),
|
||||
upgradeStars: Joi.number().integer().positive().optional(),
|
||||
availabilityTotal: Joi.number().integer().positive().optional().allow(null),
|
||||
availabilityRemains: Joi.number().integer().min(0).optional().allow(null),
|
||||
firstSaleDate: Joi.number().integer().optional().allow(null),
|
||||
lastSaleDate: Joi.number().integer().optional().allow(null),
|
||||
title: Joi.string().max(100).optional().allow(null, ''),
|
||||
resellMinStars: Joi.number().integer().positive().optional().allow(null)
|
||||
});
|
||||
|
||||
// GET all gifts
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { soldOut, limited, sort = 'stars' } = req.query;
|
||||
|
||||
const filter = {};
|
||||
if (soldOut !== undefined) filter.SoldOut = soldOut === 'true';
|
||||
if (limited !== undefined) filter.Limited = limited === 'true';
|
||||
|
||||
const sortOptions = {};
|
||||
if (sort === 'stars') sortOptions.Stars = 1;
|
||||
else if (sort === 'date') sortOptions._id = -1;
|
||||
else if (sort === 'availability') sortOptions.AvailabilityRemains = 1;
|
||||
|
||||
const gifts = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.find(filter)
|
||||
.sort(sortOptions)
|
||||
.toArray();
|
||||
|
||||
res.json(toJSON(gifts));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET gift by ID
|
||||
router.get('/:giftId', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt for large IDs
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const gift = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOne({ GiftId: giftId });
|
||||
|
||||
if (!gift) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
res.json(toJSON(gift));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create new gift
|
||||
router.post('/', upload.single('sticker'), async (req, res) => {
|
||||
try {
|
||||
// Parse JSON data from form
|
||||
const giftData = typeof req.body.data === 'string'
|
||||
? JSON.parse(req.body.data)
|
||||
: req.body;
|
||||
|
||||
// Validate
|
||||
const { error, value } = giftSchema.validate(giftData);
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
|
||||
// ✅ FIX: Convert to BigInt for DB check
|
||||
const safeGiftId = BigInt(value.giftId);
|
||||
|
||||
// Check if gift ID already exists
|
||||
const existing = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOne({ GiftId: safeGiftId });
|
||||
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Gift with this ID already exists' });
|
||||
}
|
||||
|
||||
// ✅ Upload sticker file to MinIO and create DocumentReadModel
|
||||
let stickerDocumentId = null;
|
||||
if (req.file) {
|
||||
try {
|
||||
console.log(`📤 Uploading sticker: ${req.file.originalname}, Size: ${req.file.size} bytes`);
|
||||
|
||||
// Validate TGS file
|
||||
const fileBuffer = await fs.readFile(req.file.path);
|
||||
|
||||
// Try to decompress, if fails assume it's already JSON
|
||||
let json;
|
||||
try {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
json = JSON.parse(decompressed.toString('utf8'));
|
||||
} catch (error) {
|
||||
// If gunzip fails, try parsing as plain JSON
|
||||
try {
|
||||
json = JSON.parse(fileBuffer.toString('utf8'));
|
||||
console.log('⚠️ File is not gzipped, treating as plain JSON');
|
||||
} catch (jsonError) {
|
||||
throw new Error('Invalid TGS file: not gzipped JSON or plain JSON');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate document ID for sticker
|
||||
const documentId = Date.now() * 10000 + Math.floor(Math.random() * 10000);
|
||||
const accessHash = Date.now() * 100000 + Math.floor(Math.random() * 100000);
|
||||
|
||||
// Upload to MinIO (same format as emoji - root path, no extension)
|
||||
await minioHelper.uploadFile(req.file.path, documentId, 'application/x-tgsticker');
|
||||
console.log(`✅ Uploaded sticker to MinIO: ${documentId}`);
|
||||
|
||||
// Generate FileReference (8 bytes)
|
||||
const fileRefBuffer = Buffer.allocUnsafe(8);
|
||||
fileRefBuffer.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
|
||||
fileRefBuffer.writeUInt32BE(Math.floor(Math.random() * 0xFFFFFFFF), 4);
|
||||
|
||||
// Create DocumentReadModel (same as emoji)
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`, // EventFlow ID format
|
||||
Id: `document-${documentId}`, // EventFlow requires this
|
||||
DocumentId: documentId,
|
||||
Version: 1,
|
||||
AccessHash: accessHash,
|
||||
FileReference: fileRefBuffer,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: fileBuffer.length,
|
||||
Name: `${documentId}`,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: [
|
||||
{
|
||||
Type: 'i',
|
||||
// Minimal valid stripped thumbnail (blue-ish placeholder)
|
||||
Bytes: Buffer.from([0x01, 0x08, 0x08, 0x2a, 0x8c, 0x47, 0x92, 0x48, 0x20])
|
||||
}
|
||||
],
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Attributes: null,
|
||||
Attributes2: [
|
||||
{
|
||||
_t: 'TDocumentAttributeImageSize',
|
||||
W: 512,
|
||||
H: 512
|
||||
},
|
||||
{
|
||||
_t: 'TDocumentAttributeAnimated' // ✅ CRITICAL for TGS stickers!
|
||||
},
|
||||
{
|
||||
_t: 'TDocumentAttributeSticker',
|
||||
Alt: '🎁',
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetShortName',
|
||||
ShortName: 'premium_gifts'
|
||||
},
|
||||
Mask: false
|
||||
},
|
||||
{
|
||||
_t: 'TDocumentAttributeFilename',
|
||||
FileName: 'sticker.tgs'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// NOTE: Use EventFlow naming convention for ReadModel collections
|
||||
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
|
||||
console.log(`✅ Created DocumentReadModel for sticker: ${documentId}`);
|
||||
|
||||
stickerDocumentId = documentId;
|
||||
|
||||
// Cleanup temp file
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
|
||||
// Parse JSON data
|
||||
const giftData = typeof req.body.data === 'string'
|
||||
? JSON.parse(req.body.data)
|
||||
: req.body;
|
||||
|
||||
const updateData = { ...giftData };
|
||||
delete updateData.giftId; // Don't allow changing ID
|
||||
|
||||
// Read new sticker if uploaded - store as Buffer not base64
|
||||
if (req.file) {
|
||||
const fileBuffer = await fs.readFile(req.file.path);
|
||||
updateData.Sticker = fileBuffer; // Store as Binary (Buffer)
|
||||
}
|
||||
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ GiftId: giftId },
|
||||
{
|
||||
$set: {
|
||||
...updateData,
|
||||
UpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
res.json(toJSON(result));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE gift
|
||||
router.delete('/:giftId', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.deleteOne({ GiftId: giftId });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Gift deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH mark as sold out
|
||||
router.patch('/:giftId/soldout', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const { soldOut } = req.body;
|
||||
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ GiftId: giftId },
|
||||
{
|
||||
$set: {
|
||||
SoldOut: soldOut,
|
||||
LastSaleDate: soldOut ? Math.floor(Date.now() / 1000) : null,
|
||||
UpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
res.json(toJSON(result));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE all gifts (clear collection)
|
||||
router.delete('/', async (req, res) => {
|
||||
try {
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.deleteMany({});
|
||||
|
||||
res.json({
|
||||
message: 'All gifts deleted successfully',
|
||||
deletedCount: result.deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH decrease availability
|
||||
router.patch('/:giftId/purchase', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
|
||||
const gift = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOne({ GiftId: giftId });
|
||||
|
||||
if (!gift) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
if (!gift.Limited) {
|
||||
return res.status(400).json({ error: 'Gift is not limited' });
|
||||
}
|
||||
|
||||
if (gift.AvailabilityRemains <= 0) {
|
||||
return res.status(400).json({ error: 'Gift sold out' });
|
||||
}
|
||||
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ GiftId: giftId },
|
||||
{
|
||||
$inc: { AvailabilityRemains: -1 },
|
||||
$set: {
|
||||
UpdatedAt: new Date(),
|
||||
SoldOut: gift.AvailabilityRemains - 1 <= 0
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
res.json(toJSON(result));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST send gift to user (admin function)
|
||||
router.post('/:giftId/send-to-user', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const { userId, fromUserId, message, nameHidden } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
// Get gift from catalog
|
||||
const gift = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOne({ GiftId: giftId });
|
||||
|
||||
if (!gift) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
// Generate unique messageId (small int32 compatible)
|
||||
// Use random number within int32 range: 1 to 2147483647
|
||||
const messageId = Math.floor(Math.random() * 2000000000) + 1000000;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Create received gift record
|
||||
const receivedGift = {
|
||||
_id: `${userId}_${messageId}`,
|
||||
Id: `${userId}_${messageId}`,
|
||||
GiftId: giftId, // ✅ Store as BigInt
|
||||
FromUserId: fromUserId || 0, // 0 = system/admin
|
||||
ToUserId: parseInt(userId),
|
||||
ToPeerId: null,
|
||||
MessageId: messageId,
|
||||
Stars: gift.Stars,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
Message: message || null,
|
||||
NameHidden: nameHidden || false,
|
||||
Saved: true, // Auto-save admin gifts
|
||||
Converted: false,
|
||||
Upgraded: false,
|
||||
Refunded: false,
|
||||
CanUpgrade: gift.UpgradeStars ? true : false,
|
||||
Pinned: false,
|
||||
SavedId: null,
|
||||
UpgradeStars: gift.UpgradeStars,
|
||||
UpgradeMsgId: null,
|
||||
Date: now,
|
||||
ConvertDate: null,
|
||||
UpgradeDate: null,
|
||||
StickerDocumentId: gift.Sticker, // DocumentId for sticker loading
|
||||
Title: gift.Title || '', // Gift title
|
||||
Version: 1
|
||||
};
|
||||
|
||||
// Insert into eventflow-stargiftreadmodel collection
|
||||
await req.db
|
||||
.collection('eventflow-stargiftreadmodel')
|
||||
.insertOne(receivedGift);
|
||||
|
||||
// Create inbox message for the recipient with ALL required fields
|
||||
const inboxMessage = {
|
||||
_id: `message-${userId}_${messageId}`,
|
||||
Id: `message-${userId}_${messageId}`,
|
||||
OwnerPeerId: parseInt(userId),
|
||||
ToPeerId: parseInt(userId),
|
||||
ToPeerType: 1, // User
|
||||
SenderUserId: fromUserId || 0,
|
||||
SenderPeerId: fromUserId || 0,
|
||||
SenderMessageId: 0,
|
||||
MessageId: messageId,
|
||||
Message: '',
|
||||
Date: now,
|
||||
Out: false, // Inbox message
|
||||
Pinned: false,
|
||||
Post: false,
|
||||
Silent: false,
|
||||
Pts: 0, // Will be set by server
|
||||
Views: null,
|
||||
Replies: 0,
|
||||
EditDate: null,
|
||||
EditHide: false,
|
||||
PostAuthor: null,
|
||||
GroupedId: null,
|
||||
ReplyToMsgId: null,
|
||||
TopMsgId: null,
|
||||
FwdHeader: null,
|
||||
Media: null,
|
||||
Media2: null,
|
||||
Entities: null,
|
||||
Entities2: null,
|
||||
ReplyMarkup: null,
|
||||
ReplyMarkup2: null,
|
||||
ReplyTo: null,
|
||||
SendAs: null,
|
||||
Reply: null,
|
||||
MessageType: 1, // Text
|
||||
SendMessageType: 1, // MessageService
|
||||
MessageActionType: 19, // CustomAction
|
||||
MessageActionData: JSON.stringify({
|
||||
_t: 'TMessageActionStarGift',
|
||||
NameHidden: nameHidden || false,
|
||||
Saved: false,
|
||||
Converted: false,
|
||||
Upgraded: false,
|
||||
Refunded: false,
|
||||
CanUpgrade: gift.UpgradeStars ? true : false,
|
||||
Gift: {
|
||||
Id: giftId.toString(), // ✅ Serialize BigInt to string for JSON
|
||||
Stars: gift.Stars,
|
||||
Title: gift.Title
|
||||
},
|
||||
Message: message ? {
|
||||
Text: message,
|
||||
Entities: []
|
||||
} : null,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
UpgradeStars: gift.UpgradeStars
|
||||
}),
|
||||
LinkedChannelId: null,
|
||||
SavedFromMsgId: null,
|
||||
SavedFromPeerId: null,
|
||||
SavedPeerId: null,
|
||||
PollId: null,
|
||||
PostChannelId: null,
|
||||
PostMessageId: null,
|
||||
IsQuickReplyMessage: false,
|
||||
ShortcutId: null,
|
||||
QuickReplyItem: null,
|
||||
BatchId: null,
|
||||
Effect: null,
|
||||
FromScheduled: false,
|
||||
ScheduleDate: null,
|
||||
TtlPeriod: null,
|
||||
Reactions: null,
|
||||
RecentReactions2: null,
|
||||
RecentReactions: null,
|
||||
TopReactors: null,
|
||||
CanSeeList: false,
|
||||
ExpirationTime: null,
|
||||
InvertMedia: false,
|
||||
PublicPosts: false,
|
||||
Hashtags: [],
|
||||
MentionedUserIds: null,
|
||||
TodoId: null,
|
||||
Version: 1
|
||||
};
|
||||
|
||||
// Insert inbox message into ReadModel-MessageReadModel
|
||||
await req.db
|
||||
.collection('ReadModel-MessageReadModel')
|
||||
.insertOne(inboxMessage);
|
||||
|
||||
// Decrease availability if limited
|
||||
if (gift.Limited && gift.AvailabilityRemains > 0) {
|
||||
await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.updateOne(
|
||||
{ GiftId: giftId },
|
||||
{
|
||||
$inc: { AvailabilityRemains: -1 },
|
||||
$set: {
|
||||
UpdatedAt: new Date(),
|
||||
SoldOut: gift.AvailabilityRemains - 1 <= 0
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `Gift ${giftId} sent to user ${userId}`,
|
||||
warning: 'Gift inserted into database. Restart Query Server to see it in user profile.',
|
||||
receivedGift: {
|
||||
userId: parseInt(userId),
|
||||
giftId: giftId.toString(), // ✅ Serialize BigInt
|
||||
messageId: messageId,
|
||||
stars: gift.Stars,
|
||||
title: gift.Title
|
||||
},
|
||||
instructions: {
|
||||
step1: 'Gift has been added to database',
|
||||
step2: 'To make it visible in user profile, restart Query Server:',
|
||||
command: 'docker restart messenger-query-server-1'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Send gift error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,391 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { MongoClient, ObjectId } from 'mongodb';
|
||||
import AdmZip from 'adm-zip';
|
||||
import pako from 'pako';
|
||||
import * as Minio from 'minio';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Клиент MinIO
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'test',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '12345678'
|
||||
});
|
||||
|
||||
const BUCKET_NAME = process.env.MINIO_BUCKET || 'tg-files';
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 100 * 1024 * 1024 } // 100MB
|
||||
});
|
||||
|
||||
const MONGO_URL = process.env.MONGODB_URI || 'mongodb://localhost:27017';
|
||||
const DB_NAME = process.env.DB_NAME || 'tg';
|
||||
|
||||
// Список всех реакций
|
||||
router.get('/', async (req, res) => {
|
||||
const client = new MongoClient(MONGO_URL);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const db = client.db(DB_NAME);
|
||||
const collection = db.collection('reactions');
|
||||
|
||||
const reactions = await collection.find({}).toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reactions: reactions.map(r => ({
|
||||
id: r._id.toString(),
|
||||
emoji: r.emoji,
|
||||
title: r.title,
|
||||
premium: r.premium || false,
|
||||
inactive: r.inactive || false,
|
||||
hasAllAnimations: !!(r.staticIcon && r.appearAnimation && r.selectAnimation && r.activateAnimation && r.effectAnimation),
|
||||
selectAnimation: r.selectAnimationPreview
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching reactions:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch reactions' });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Создать реакцию
|
||||
router.post('/', upload.fields([
|
||||
{ name: 'staticIcon', maxCount: 1 },
|
||||
{ name: 'appearAnimation', maxCount: 1 },
|
||||
{ name: 'selectAnimation', maxCount: 1 },
|
||||
{ name: 'activateAnimation', maxCount: 1 },
|
||||
{ name: 'effectAnimation', maxCount: 1 },
|
||||
{ name: 'aroundAnimation', maxCount: 1 },
|
||||
{ name: 'centerIcon', maxCount: 1 }
|
||||
]), async (req, res) => {
|
||||
const client = new MongoClient(MONGO_URL);
|
||||
|
||||
try {
|
||||
const data = JSON.parse(req.body.data);
|
||||
await client.connect();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
// Загружаем анимации в MinIO и создаём документы
|
||||
const animations = {};
|
||||
const animationFields = [
|
||||
'staticIcon', 'appearAnimation', 'selectAnimation',
|
||||
'activateAnimation', 'effectAnimation', 'aroundAnimation', 'centerIcon'
|
||||
];
|
||||
|
||||
for (const field of animationFields) {
|
||||
if (req.files[field] && req.files[field][0]) {
|
||||
const file = req.files[field][0];
|
||||
const documentId = Date.now() + Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Загружаем в MinIO (file-server ожидает файлы в корне: {documentId})
|
||||
await minioClient.putObject(BUCKET_NAME, `${documentId}`, file.buffer, file.size, {
|
||||
'Content-Type': 'application/x-tgsticker'
|
||||
});
|
||||
|
||||
// Создаём документ в MongoDB
|
||||
const fileReference = Buffer.from([
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
(documentId >> 24) & 0xFF,
|
||||
(documentId >> 16) & 0xFF,
|
||||
(documentId >> 8) & 0xFF,
|
||||
documentId & 0xFF
|
||||
]);
|
||||
|
||||
const documentReadModelId = `document-${documentId}`;
|
||||
const document = {
|
||||
_id: documentReadModelId,
|
||||
Id: documentReadModelId,
|
||||
Version: 1,
|
||||
DocumentId: documentId,
|
||||
AccessHash: documentId,
|
||||
FileReference: fileReference,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: file.size,
|
||||
Name: `${documentId}`,
|
||||
DcId: 2,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null,
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Attributes: null,
|
||||
Attributes2: [{
|
||||
_t: 'TDocumentAttributeSticker',
|
||||
Alt: data.emoji || '😀',
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetEmpty'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
|
||||
animations[field] = documentId;
|
||||
|
||||
// Сохраняем превью для анимации выбора
|
||||
if (field === 'selectAnimation') {
|
||||
try {
|
||||
const decompressed = pako.inflate(file.buffer, { to: 'string' });
|
||||
animations.selectAnimationPreview = JSON.parse(decompressed);
|
||||
} catch (e) {
|
||||
console.log('Could not create preview:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём реакцию
|
||||
const reaction = {
|
||||
emoji: data.emoji,
|
||||
title: data.title,
|
||||
premium: data.premium || false,
|
||||
inactive: data.inactive || false,
|
||||
...animations,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
const result = await db.collection('reactions').insertOne(reaction);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reactionId: result.insertedId.toString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating reaction:', error);
|
||||
res.status(500).json({ error: 'Failed to create reaction' });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Массовая загрузка из ZIP (по папкам: emoji/asset.ext)
|
||||
router.post('/bulk-upload', upload.single('zipFile'), async (req, res) => {
|
||||
const client = new MongoClient(MONGO_URL);
|
||||
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No ZIP file provided' });
|
||||
}
|
||||
|
||||
const premium = req.body.premium === 'true';
|
||||
const zip = new AdmZip(req.file.buffer);
|
||||
const zipEntries = zip.getEntries();
|
||||
|
||||
await client.connect();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
const results = [];
|
||||
const reactionsMap = new Map(); // Map<emoji, { assets: {}, title: string }>
|
||||
|
||||
// 1. Группируем файлы по эмодзи (имени папки)
|
||||
for (const entry of zipEntries) {
|
||||
if (entry.isDirectory || entry.entryName.startsWith('__MACOSX')) continue;
|
||||
|
||||
// Приводим разделители пути к единому виду
|
||||
const entryPath = entry.entryName.replace(/\\/g, '/');
|
||||
const parts = entryPath.split('/');
|
||||
|
||||
// Ожидаем как минимум папку и файл: folder/file.ext
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
// Папка с эмодзи — это непосредственный родитель файла
|
||||
const emojiFolder = parts[parts.length - 2];
|
||||
const filename = parts[parts.length - 1];
|
||||
const nameWithoutExt = filename.split('.')[0];
|
||||
|
||||
// Проверяем тип ассета
|
||||
const validAssets = [
|
||||
'static_icon', 'appear_animation', 'select_animation',
|
||||
'activate_animation', 'effect_animation', 'around_animation', 'center_icon'
|
||||
];
|
||||
|
||||
if (!validAssets.includes(nameWithoutExt)) continue;
|
||||
|
||||
if (!reactionsMap.has(emojiFolder)) {
|
||||
reactionsMap.set(emojiFolder, {
|
||||
emoji: emojiFolder,
|
||||
assets: {}
|
||||
});
|
||||
}
|
||||
|
||||
const reactionData = reactionsMap.get(emojiFolder);
|
||||
reactionData.assets[nameWithoutExt] = {
|
||||
buffer: entry.getData(),
|
||||
filename: filename
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Обрабатываем каждую реакцию
|
||||
for (const [emoji, data] of reactionsMap) {
|
||||
try {
|
||||
console.log(`Processing reaction: ${emoji}`);
|
||||
const animationIds = {};
|
||||
|
||||
// Загружаем ассеты
|
||||
for (const [assetType, fileData] of Object.entries(data.assets)) {
|
||||
const documentId = Date.now() + Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Определяем MIME-тип по расширению
|
||||
const ext = fileData.filename.split('.').pop().toLowerCase();
|
||||
let mimeType = 'application/x-tgsticker'; // по умолчанию для TGS
|
||||
if (ext === 'webm') mimeType = 'video/webm';
|
||||
if (ext === 'webp') mimeType = 'image/webp';
|
||||
if (ext === 'png') mimeType = 'image/png';
|
||||
|
||||
// Загружаем в MinIO
|
||||
await minioClient.putObject(BUCKET_NAME, `${documentId}`, fileData.buffer, fileData.buffer.length, {
|
||||
'Content-Type': mimeType
|
||||
});
|
||||
|
||||
// Создаём DocumentReadModel
|
||||
const fileReference = Buffer.from([
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
(documentId >> 24) & 0xFF,
|
||||
(documentId >> 16) & 0xFF,
|
||||
(documentId >> 8) & 0xFF,
|
||||
documentId & 0xFF
|
||||
]);
|
||||
|
||||
const documentReadModelId = `document-${documentId}`;
|
||||
const document = {
|
||||
_id: documentReadModelId,
|
||||
Id: documentReadModelId,
|
||||
Version: 1,
|
||||
DocumentId: documentId,
|
||||
AccessHash: documentId,
|
||||
FileReference: fileReference,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: mimeType,
|
||||
Size: fileData.buffer.length,
|
||||
Name: `${documentId}`,
|
||||
DcId: 2,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null,
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Attributes: null,
|
||||
Attributes2: [{
|
||||
_t: 'TDocumentAttributeSticker',
|
||||
Alt: emoji,
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetEmpty'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
|
||||
animationIds[assetType] = documentId;
|
||||
|
||||
// Сохраняем превью для анимации выбора
|
||||
if (assetType === 'selectAnimation' && mimeType === 'application/x-tgsticker') {
|
||||
try {
|
||||
const decompressed = pako.inflate(fileData.buffer, { to: 'string' });
|
||||
animationIds.selectAnimationPreview = JSON.parse(decompressed);
|
||||
} catch (e) {
|
||||
console.log(`Could not create preview for ${emoji}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём или обновляем реакцию
|
||||
const reaction = {
|
||||
emoji: emoji,
|
||||
title: emoji,
|
||||
premium: premium,
|
||||
inactive: false,
|
||||
...animationIds,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
// Проверяем, есть ли уже такая реакция
|
||||
const existing = await db.collection('reactions').findOne({ emoji: emoji });
|
||||
if (existing) {
|
||||
await db.collection('reactions').updateOne({ _id: existing._id }, { $set: reaction });
|
||||
} else {
|
||||
await db.collection('reactions').insertOne(reaction);
|
||||
}
|
||||
|
||||
results.push({
|
||||
success: true,
|
||||
emoji: emoji,
|
||||
assets: Object.keys(animationIds)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${emoji}:`, error);
|
||||
results.push({
|
||||
success: false,
|
||||
emoji: emoji,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error bulk uploading reactions:', error);
|
||||
res.status(500).json({ error: 'Failed to process ZIP file' });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить реакцию
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const client = new MongoClient(MONGO_URL);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
const reaction = await db.collection('reactions').findOne({ _id: new ObjectId(req.params.id) });
|
||||
|
||||
if (!reaction) {
|
||||
return res.status(404).json({ error: 'Reaction not found' });
|
||||
}
|
||||
|
||||
// Удаляем документы анимаций из MinIO и MongoDB
|
||||
const animationFields = [
|
||||
'staticIcon', 'appearAnimation', 'selectAnimation',
|
||||
'activateAnimation', 'effectAnimation', 'aroundAnimation', 'centerIcon'
|
||||
];
|
||||
|
||||
for (const field of animationFields) {
|
||||
if (reaction[field]) {
|
||||
try {
|
||||
await minioClient.removeObject(BUCKET_NAME, `${reaction[field]}`);
|
||||
await db.collection('eventflow-documentreadmodel').deleteOne({ DocumentId: reaction[field] });
|
||||
} catch (e) {
|
||||
console.log(`Failed to delete ${field}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем реакцию
|
||||
await db.collection('reactions').deleteOne({ _id: new ObjectId(req.params.id) });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting reaction:', error);
|
||||
res.status(500).json({ error: 'Failed to delete reaction' });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,347 @@
|
||||
import express from 'express';
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Формирует ID уведомления
|
||||
function createNotificationId() {
|
||||
return `notification-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Возвращает текущую метку времени в формате Unix
|
||||
function getCurrentTimestamp() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
// GET /api/service-notifications - получить все шаблоны уведомлений
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const templates = await collection.find({}).sort({ CreatedDate: -1 }).toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: templates.length,
|
||||
templates: templates
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification templates:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/service-notifications/:id - получить конкретный шаблон
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const template = await collection.findOne({ Id: req.params.id });
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: template
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/service-notifications - создать новый шаблон
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { type, title, message, mediaUrl, mediaType, isPopup, isActive } = req.body;
|
||||
|
||||
if (!type || !title || !message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Type, title, and message are required'
|
||||
});
|
||||
}
|
||||
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const notificationId = createNotificationId();
|
||||
const currentDate = getCurrentTimestamp();
|
||||
|
||||
const template = {
|
||||
_id: notificationId,
|
||||
Id: notificationId,
|
||||
Type: type,
|
||||
Title: title,
|
||||
Message: message,
|
||||
MediaUrl: mediaUrl || null,
|
||||
MediaType: mediaType || null,
|
||||
IsPopup: isPopup !== undefined ? isPopup : true,
|
||||
IsActive: isActive !== undefined ? isActive : true,
|
||||
CreatedDate: currentDate,
|
||||
UpdatedDate: null
|
||||
};
|
||||
|
||||
await collection.insertOne(template);
|
||||
|
||||
console.log(`Created notification template: ${notificationId} (${type})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: template
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating notification template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/service-notifications/:id - обновить шаблон
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { type, title, message, mediaUrl, mediaType, isPopup, isActive } = req.body;
|
||||
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const updateFields = {
|
||||
UpdatedDate: getCurrentTimestamp()
|
||||
};
|
||||
|
||||
if (type !== undefined) updateFields.Type = type;
|
||||
if (title !== undefined) updateFields.Title = title;
|
||||
if (message !== undefined) updateFields.Message = message;
|
||||
if (mediaUrl !== undefined) updateFields.MediaUrl = mediaUrl;
|
||||
if (mediaType !== undefined) updateFields.MediaType = mediaType;
|
||||
if (isPopup !== undefined) updateFields.IsPopup = isPopup;
|
||||
if (isActive !== undefined) updateFields.IsActive = isActive;
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ Id: req.params.id },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
const updatedTemplate = await collection.findOne({ Id: req.params.id });
|
||||
|
||||
console.log(`Updated notification template: ${req.params.id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: updatedTemplate
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating notification template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/service-notifications/:id - удалить шаблон
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const result = await collection.deleteOne({ Id: req.params.id });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Deleted notification template: ${req.params.id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Template deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting notification template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/service-notifications/:id/send - отправить уведомление пользователям
|
||||
router.post('/:id/send', async (req, res) => {
|
||||
try {
|
||||
const { userIds, sendToAll } = req.body;
|
||||
|
||||
console.log(`Database name: ${req.db.databaseName}`);
|
||||
console.log(`sendToAll: ${sendToAll}, userIds: ${userIds}`);
|
||||
|
||||
// Загружаем шаблон
|
||||
const templatesCollection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
const template = await templatesCollection.findOne({ Id: req.params.id });
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (!template.IsActive) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Template is not active'
|
||||
});
|
||||
}
|
||||
|
||||
// Готовим данные уведомления
|
||||
const notification = {
|
||||
type: template.Type,
|
||||
message: template.Message,
|
||||
popup: template.IsPopup,
|
||||
mediaUrl: template.MediaUrl,
|
||||
mediaType: template.MediaType,
|
||||
timestamp: getCurrentTimestamp()
|
||||
};
|
||||
|
||||
let targetUserIds = [];
|
||||
|
||||
if (sendToAll) {
|
||||
// Берём все ID пользователей из eventflow-userreadmodel (в EventFlow имена в нижнем регистре)
|
||||
const usersCollection = req.db.collection('eventflow-userreadmodel');
|
||||
const users = await usersCollection.find({}).project({ UserId: 1 }).toArray();
|
||||
console.log(`Found ${users.length} users in database`);
|
||||
console.log(`First 3 users:`, users.slice(0, 3));
|
||||
targetUserIds = users.map(u => u.UserId).filter(id => id != null && id !== 777000 && id !== 569999);
|
||||
|
||||
console.log(`Sending notification to ALL users (${targetUserIds.length} users, excluding system users)`);
|
||||
} else if (userIds && Array.isArray(userIds) && userIds.length > 0) {
|
||||
targetUserIds = userIds.map(id => parseInt(id));
|
||||
|
||||
console.log(`Sending notification to ${targetUserIds.length} specific users`);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Either sendToAll must be true or userIds must be provided'
|
||||
});
|
||||
}
|
||||
|
||||
// Сохраняем события уведомлений в коллекцию для последующей обработки
|
||||
const notificationEventsCollection = req.db.collection('ServiceNotificationEvents');
|
||||
|
||||
const events = targetUserIds.map(userId => ({
|
||||
userId: userId,
|
||||
templateId: template.Id,
|
||||
type: notification.type,
|
||||
message: notification.message,
|
||||
popup: notification.popup,
|
||||
mediaUrl: notification.mediaUrl,
|
||||
mediaType: notification.mediaType,
|
||||
createdAt: new Date(),
|
||||
processed: false
|
||||
}));
|
||||
|
||||
if (events.length > 0) {
|
||||
await notificationEventsCollection.insertMany(events);
|
||||
}
|
||||
|
||||
console.log(`Created ${events.length} notification events for processing`);
|
||||
|
||||
// В реальной реализации здесь запускался бы C#-сервис.
|
||||
// Пока возвращаем успех, а события обработает фоновый воркер.
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Notification queued for ${targetUserIds.length} users`,
|
||||
targetUserCount: targetUserIds.length,
|
||||
notification: notification
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending notification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/service-notifications/events/pending - получить необработанные события уведомлений
|
||||
router.get('/events/pending', async (req, res) => {
|
||||
try {
|
||||
const collection = db.collection('ServiceNotificationEvents');
|
||||
|
||||
const pendingEvents = await collection
|
||||
.find({ processed: false })
|
||||
.sort({ createdAt: 1 })
|
||||
.limit(100)
|
||||
.toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: pendingEvents.length,
|
||||
events: pendingEvents
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching pending events:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/service-notifications/events/:id/mark-processed - пометить событие как обработанное
|
||||
router.post('/events/:id/mark-processed', async (req, res) => {
|
||||
try {
|
||||
const collection = db.collection('ServiceNotificationEvents');
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ _id: new ObjectId(req.params.id) },
|
||||
{
|
||||
$set: {
|
||||
processed: true,
|
||||
processedAt: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Event not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Event marked as processed'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error marking event as processed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,96 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Получить настройки иконки заморозки
|
||||
router.get('/frozen-icon', async (req, res) => {
|
||||
try {
|
||||
// Берём глобальные настройки иконки заморозки из БД
|
||||
const settings = await req.db
|
||||
.collection('global_settings')
|
||||
.findOne({ key: 'frozen_icon' });
|
||||
|
||||
if (!settings) {
|
||||
// Настройки по умолчанию
|
||||
return res.json({
|
||||
type: 'emoji',
|
||||
emoji: '❄️',
|
||||
documentId: null
|
||||
});
|
||||
}
|
||||
|
||||
res.json(settings.value);
|
||||
} catch (error) {
|
||||
console.error('Get frozen icon settings error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить настройки иконки заморозки
|
||||
router.post('/frozen-icon', async (req, res) => {
|
||||
try {
|
||||
const { type, emoji, documentId } = req.body;
|
||||
|
||||
const settings = {
|
||||
type: type || 'emoji',
|
||||
emoji: emoji || '❄️',
|
||||
documentId: documentId || null
|
||||
};
|
||||
|
||||
// Сохраняем глобальные настройки
|
||||
await req.db
|
||||
.collection('global_settings')
|
||||
.updateOne(
|
||||
{ key: 'frozen_icon' },
|
||||
{
|
||||
$set: {
|
||||
key: 'frozen_icon',
|
||||
value: settings,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
// Если выбрана анимация, применяем её ко всем замороженным пользователям
|
||||
if (type === 'animation' && documentId) {
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateMany(
|
||||
{ Restricted: true },
|
||||
{
|
||||
$set: {
|
||||
FrozenAnimationDocumentId: parseInt(documentId)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Applied frozen animation ${documentId} to all restricted users`);
|
||||
} else if (type === 'emoji') {
|
||||
// При переключении на эмодзи убираем анимацию у всех пользователей
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateMany(
|
||||
{ Restricted: true },
|
||||
{
|
||||
$unset: {
|
||||
FrozenAnimationDocumentId: ""
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Removed frozen animation from all restricted users`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Frozen icon settings updated',
|
||||
settings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update frozen icon settings error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,326 @@
|
||||
import express from 'express';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Схема валидации
|
||||
const sponsoredMessageSchema = Joi.object({
|
||||
channelId: Joi.number().integer().positive().required(),
|
||||
title: Joi.string().max(100).required(),
|
||||
message: Joi.string().max(500).required(),
|
||||
url: Joi.string().uri().required(),
|
||||
buttonText: Joi.string().max(50).required(),
|
||||
photoUrl: Joi.string().uri().optional().allow(null, ''),
|
||||
sponsorInfo: Joi.string().max(200).optional().allow(null, ''),
|
||||
additionalInfo: Joi.string().max(200).optional().allow(null, ''),
|
||||
isActive: Joi.boolean().default(true),
|
||||
recommended: Joi.boolean().default(false),
|
||||
canReport: Joi.boolean().default(true),
|
||||
postsBetween: Joi.number().integer().min(1).max(100).default(10),
|
||||
expiresDate: Joi.number().integer().optional().allow(null)
|
||||
});
|
||||
|
||||
// Список всех рекламных сообщений
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { channelId, isActive } = req.query;
|
||||
|
||||
const filter = {};
|
||||
if (channelId) filter.ChannelId = parseInt(channelId);
|
||||
if (isActive !== undefined) filter.IsActive = isActive === 'true';
|
||||
|
||||
const messages = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.find(filter)
|
||||
.sort({ CreatedDate: -1 })
|
||||
.toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: messages.length,
|
||||
messages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching sponsored messages:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Получить рекламное сообщение по ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const message = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.findOne({ Id: req.params.id });
|
||||
|
||||
if (!message) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Создать новое рекламное сообщение
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = sponsoredMessageSchema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const id = `sponsored-${value.channelId}-${now}`;
|
||||
|
||||
const sponsoredMessage = {
|
||||
Id: id,
|
||||
ChannelId: value.channelId,
|
||||
Title: value.title,
|
||||
Message: value.message,
|
||||
Url: value.url,
|
||||
ButtonText: value.buttonText,
|
||||
PhotoUrl: value.photoUrl || null,
|
||||
SponsorInfo: value.sponsorInfo || null,
|
||||
AdditionalInfo: value.additionalInfo || null,
|
||||
IsActive: value.isActive,
|
||||
Recommended: value.recommended,
|
||||
CanReport: value.canReport,
|
||||
PostsBetween: value.postsBetween,
|
||||
CreatedDate: now,
|
||||
ExpiresDate: value.expiresDate || null,
|
||||
DisplayCount: 0,
|
||||
ClickCount: 0,
|
||||
Version: 1
|
||||
};
|
||||
|
||||
await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.insertOne(sponsoredMessage);
|
||||
|
||||
console.log(`Created sponsored message: ${id} for channel ${value.channelId}`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: sponsoredMessage
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить рекламное сообщение
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = sponsoredMessageSchema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
ChannelId: value.channelId,
|
||||
Title: value.title,
|
||||
Message: value.message,
|
||||
Url: value.url,
|
||||
ButtonText: value.buttonText,
|
||||
PhotoUrl: value.photoUrl || null,
|
||||
SponsorInfo: value.sponsorInfo || null,
|
||||
AdditionalInfo: value.additionalInfo || null,
|
||||
IsActive: value.isActive,
|
||||
Recommended: value.recommended,
|
||||
CanReport: value.canReport,
|
||||
PostsBetween: value.postsBetween,
|
||||
ExpiresDate: value.expiresDate || null
|
||||
};
|
||||
|
||||
const result = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.updateOne(
|
||||
{ Id: req.params.id },
|
||||
{ $set: updateData, $inc: { Version: 1 } }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Updated sponsored message: ${req.params.id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sponsored message updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Переключить статус активности
|
||||
router.patch('/:id/toggle', async (req, res) => {
|
||||
try {
|
||||
const message = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.findOne({ Id: req.params.id });
|
||||
|
||||
if (!message) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
const newStatus = !message.IsActive;
|
||||
|
||||
await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.updateOne(
|
||||
{ Id: req.params.id },
|
||||
{ $set: { IsActive: newStatus }, $inc: { Version: 1 } }
|
||||
);
|
||||
|
||||
console.log(`Toggled sponsored message ${req.params.id} to ${newStatus ? 'active' : 'inactive'}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
isActive: newStatus
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить рекламное сообщение
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.deleteOne({ Id: req.params.id });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Deleted sponsored message: ${req.params.id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sponsored message deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Статистика по каналу
|
||||
router.get('/stats/:channelId', async (req, res) => {
|
||||
try {
|
||||
const channelId = parseInt(req.params.channelId);
|
||||
|
||||
const messages = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.find({ ChannelId: channelId })
|
||||
.toArray();
|
||||
|
||||
const stats = {
|
||||
totalMessages: messages.length,
|
||||
activeMessages: messages.filter(m => m.IsActive).length,
|
||||
totalDisplays: messages.reduce((sum, m) => sum + (m.DisplayCount || 0), 0),
|
||||
totalClicks: messages.reduce((sum, m) => sum + (m.ClickCount || 0), 0),
|
||||
clickThroughRate: 0
|
||||
};
|
||||
|
||||
if (stats.totalDisplays > 0) {
|
||||
stats.clickThroughRate = ((stats.totalClicks / stats.totalDisplays) * 100).toFixed(2);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
channelId,
|
||||
stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Учёт перехода по ссылке (вызывается из ссылки отслеживания)
|
||||
router.post('/click/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.updateOne(
|
||||
{ Id: id },
|
||||
{ $inc: { ClickCount: 1 } }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Click tracked for sponsored message: ${id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Click tracked successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,385 @@
|
||||
import express from 'express';
|
||||
import { ObjectId, Long } from 'mongodb';
|
||||
import { v5 as uuidv5, v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Пространство имён EventFlow (получено через рефлексию из EventFlow.Core.GuidFactories)
|
||||
const EVENTFLOW_COMMANDS_NAMESPACE = '4286d89f-7f92-430b-8e00-e468fe3c3f59';
|
||||
|
||||
// POST /api/stars/issue - начислить звёзды пользователю
|
||||
router.post('/issue', async (req, res) => {
|
||||
try {
|
||||
const { userId, amount, reason } = req.body;
|
||||
|
||||
// Проверка входных данных
|
||||
if (!userId || !amount || amount <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid request. userId and positive amount are required.'
|
||||
});
|
||||
}
|
||||
|
||||
const db = req.db;
|
||||
const eventsCollection = db.collection('eventflow.events');
|
||||
const starsCollection = db.collection('eventflow-starsreadmodel');
|
||||
const userCollection = db.collection('eventflow-userreadmodel');
|
||||
|
||||
// Проверяем, существует ли пользователь
|
||||
const user = await userCollection.findOne({ UserId: parseInt(userId) });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Генерируем детерминированный ID на основе пространства имён EventFlow
|
||||
const guid = uuidv5(`stars_${userId}`, EVENTFLOW_COMMANDS_NAMESPACE);
|
||||
const aggregateId = `stars-${guid}`;
|
||||
const documentId = aggregateId;
|
||||
|
||||
console.log(`Generated StarsAggregate ID: ${aggregateId} for user ${userId}`);
|
||||
|
||||
// --- Обновление write-модели EventFlow ---
|
||||
|
||||
// Находим последнее событие агрегата, чтобы определить номер в последовательности
|
||||
const lastEvent = await eventsCollection.findOne(
|
||||
{ AggregateId: aggregateId },
|
||||
{ sort: { AggregateSequenceNumber: -1 }, projection: { AggregateSequenceNumber: 1, Data: 1 } }
|
||||
);
|
||||
|
||||
let currentSequence = lastEvent ? lastEvent.AggregateSequenceNumber : 0;
|
||||
let currentBalance = 0;
|
||||
|
||||
// Разбираем Data, чтобы получить текущий баланс
|
||||
if (lastEvent && lastEvent.Data) {
|
||||
try {
|
||||
const data = JSON.parse(lastEvent.Data);
|
||||
if (data.NewBalance !== undefined) {
|
||||
currentBalance = data.NewBalance;
|
||||
} else if (data.Balance !== undefined) {
|
||||
currentBalance = data.Balance;
|
||||
}
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки разбора
|
||||
}
|
||||
}
|
||||
|
||||
const eventsToInsert = [];
|
||||
const now = new Date();
|
||||
const batchId = uuidv4();
|
||||
const timestampEpoch = Math.floor(now.getTime() / 1000);
|
||||
const baseId = Date.now();
|
||||
|
||||
// 1. Если агрегата ещё нет, создаём StarsAccountCreatedEvent
|
||||
if (currentSequence === 0) {
|
||||
currentSequence++;
|
||||
|
||||
const createEventData = {
|
||||
PeerId: parseInt(userId),
|
||||
RequestInfo: {
|
||||
DeviceType: 1,
|
||||
AddRequestIdToCache: true
|
||||
}
|
||||
};
|
||||
|
||||
const createMetadata = {
|
||||
timestamp: now.toISOString(),
|
||||
aggregate_sequence_number: currentSequence.toString(),
|
||||
aggregate_name: 'StarsAggregate',
|
||||
aggregate_id: aggregateId,
|
||||
event_id: `event-${new ObjectId().toString()}`,
|
||||
timestamp_epoch: timestampEpoch.toString(),
|
||||
batch_id: batchId,
|
||||
source_id: `command-${new ObjectId().toString()}`,
|
||||
event_name: 'StarsAccountCreatedEvent',
|
||||
event_version: '1'
|
||||
};
|
||||
|
||||
eventsToInsert.push({
|
||||
_id: baseId,
|
||||
BatchId: batchId,
|
||||
AggregateId: aggregateId,
|
||||
AggregateName: 'StarsAggregate',
|
||||
AggregateSequenceNumber: currentSequence,
|
||||
Data: JSON.stringify(createEventData),
|
||||
Metadata: JSON.stringify(createMetadata)
|
||||
});
|
||||
|
||||
console.log(`Creating new StarsAggregate for user ${userId}`);
|
||||
}
|
||||
|
||||
// 2. Создаём StarsAddedEvent
|
||||
currentSequence++;
|
||||
const newBalance = currentBalance + parseInt(amount);
|
||||
const transactionId = uuidv4();
|
||||
|
||||
const eventData = {
|
||||
PeerId: parseInt(userId),
|
||||
Amount: parseInt(amount),
|
||||
TransactionId: transactionId,
|
||||
Reason: reason || 'Admin issued stars',
|
||||
NewBalance: newBalance,
|
||||
RequestInfo: {
|
||||
DeviceType: 1,
|
||||
AddRequestIdToCache: true
|
||||
}
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
timestamp: now.toISOString(),
|
||||
aggregate_sequence_number: currentSequence.toString(),
|
||||
aggregate_name: 'StarsAggregate',
|
||||
aggregate_id: aggregateId,
|
||||
event_id: `event-${new ObjectId().toString()}`,
|
||||
timestamp_epoch: timestampEpoch.toString(),
|
||||
batch_id: batchId,
|
||||
source_id: `command-${new ObjectId().toString()}`,
|
||||
event_name: 'StarsAddedEvent',
|
||||
event_version: '1'
|
||||
};
|
||||
|
||||
eventsToInsert.push({
|
||||
_id: baseId + eventsToInsert.length, // сдвигаем, если уже добавили StarsAccountCreatedEvent
|
||||
BatchId: batchId,
|
||||
AggregateId: aggregateId,
|
||||
AggregateName: 'StarsAggregate',
|
||||
AggregateSequenceNumber: currentSequence,
|
||||
Data: JSON.stringify(eventData),
|
||||
Metadata: JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
// Сохраняем события
|
||||
if (eventsToInsert.length > 0) {
|
||||
await eventsCollection.insertMany(eventsToInsert);
|
||||
console.log(`Inserted StarsAddedEvent for user ${userId}: +${amount} stars, new balance: ${newBalance}`);
|
||||
}
|
||||
|
||||
// --- Обновление read-модели (для немедленного отклика интерфейса) ---
|
||||
// Это может дублировать обновление от EventFlow, но повышает отзывчивость UI
|
||||
|
||||
const transaction = {
|
||||
Id: new ObjectId().toString(),
|
||||
Amount: parseInt(amount),
|
||||
Date: now,
|
||||
Type: 'TopUp',
|
||||
Reason: reason || 'Admin issued stars',
|
||||
TransactionId: transactionId
|
||||
};
|
||||
|
||||
// Проверяем, есть ли read-модель (должна быть, если агрегат существует, но может быть не синхронизирована)
|
||||
let starsAccount = await starsCollection.findOne({ _id: documentId });
|
||||
|
||||
if (!starsAccount) {
|
||||
// Создаём новый счёт звёзд в read-модели
|
||||
starsAccount = {
|
||||
_id: documentId,
|
||||
Id: documentId,
|
||||
PeerId: parseInt(userId), // храним как обычный int64, не Long
|
||||
Balance: newBalance, // храним как обычный int64, не Long
|
||||
Transactions: [transaction],
|
||||
Version: Long.fromNumber(1)
|
||||
};
|
||||
await starsCollection.insertOne(starsAccount);
|
||||
console.log(`Created new StarsReadModel with PeerId=${userId} (as int64)`);
|
||||
} else {
|
||||
// Обновляем существующий счёт
|
||||
const currentBalance = starsAccount.Balance instanceof Long
|
||||
? starsAccount.Balance.toNumber()
|
||||
: parseInt(starsAccount.Balance);
|
||||
|
||||
await starsCollection.updateOne(
|
||||
{ _id: documentId },
|
||||
{
|
||||
$set: {
|
||||
Balance: currentBalance + parseInt(amount), // храним как обычный int64
|
||||
PeerId: parseInt(userId) // следим, чтобы PeerId был обычным int64
|
||||
},
|
||||
$push: { Transactions: transaction }
|
||||
}
|
||||
);
|
||||
console.log(`Updated StarsReadModel with PeerId=${userId}, newBalance=${currentBalance + parseInt(amount)}`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: parseInt(userId),
|
||||
userName: user.UserName || null,
|
||||
firstName: user.FirstName || null,
|
||||
newBalance: newBalance.toString(),
|
||||
transaction: {
|
||||
amount: parseInt(amount),
|
||||
reason: reason || 'Admin issued stars',
|
||||
date: transaction.Date
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error issuing stars:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/stars/user/:userId - баланс звёзд и транзакции пользователя
|
||||
router.get('/user/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const db = req.db;
|
||||
|
||||
// Получаем сведения о пользователе
|
||||
const userCollection = db.collection('eventflow-userreadmodel');
|
||||
const user = await userCollection.findOne({ UserId: parseInt(userId) });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Пытаемся найти события StarsAggregate для этого пользователя
|
||||
const eventsCollection = db.collection('eventflow.events');
|
||||
const existingEvent = await eventsCollection.findOne({
|
||||
AggregateName: 'StarsAggregate',
|
||||
'Data': { $regex: `"PeerId":${userId}` }
|
||||
});
|
||||
|
||||
let starsAccount = null;
|
||||
if (existingEvent) {
|
||||
const aggregateId = existingEvent.AggregateId;
|
||||
const starsCollection = db.collection('eventflow-starsreadmodel');
|
||||
starsAccount = await starsCollection.findOne({ _id: aggregateId });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
userId: user.UserId,
|
||||
userName: user.UserName || null,
|
||||
firstName: user.FirstName || null,
|
||||
lastName: user.LastName || null,
|
||||
phoneNumber: user.PhoneNumber || null
|
||||
},
|
||||
balance: starsAccount?.Balance?.toString() || "0",
|
||||
transactions: starsAccount?.Transactions || []
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting user stars:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/stars/recent - последние транзакции звёзд по всем пользователям
|
||||
router.get('/recent', async (req, res) => {
|
||||
try {
|
||||
const { limit = 20 } = req.query;
|
||||
const db = req.db;
|
||||
|
||||
const starsCollection = db.collection('eventflow-starsreadmodel');
|
||||
|
||||
// Собираем последние транзакции
|
||||
const accounts = await starsCollection
|
||||
.find({ Transactions: { $exists: true, $ne: [] } })
|
||||
.limit(parseInt(limit))
|
||||
.sort({ 'Transactions.Date': -1 })
|
||||
.toArray();
|
||||
|
||||
// Разворачиваем транзакции в плоский список
|
||||
const recentTransactions = [];
|
||||
for (const account of accounts) {
|
||||
if (account.Transactions) {
|
||||
for (const tx of account.Transactions.slice(-5)) { // последние 5 на пользователя
|
||||
recentTransactions.push({
|
||||
userId: account.PeerId.toString(),
|
||||
...tx
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по дате
|
||||
recentTransactions.sort((a, b) => new Date(b.Date) - new Date(a.Date));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: recentTransactions.slice(0, parseInt(limit))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting recent transactions:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/stars/notify-gift-received - уведомить пользователя о полученном подарке
|
||||
router.post('/notify-gift-received', async (req, res) => {
|
||||
try {
|
||||
const { recipientUserId, senderUserId, giftId, stars } = req.body;
|
||||
|
||||
if (!recipientUserId || !senderUserId || !giftId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'recipientUserId, senderUserId, and giftId are required'
|
||||
});
|
||||
}
|
||||
|
||||
const db = req.db;
|
||||
|
||||
// Получаем сведения о получателе
|
||||
const userCollection = db.collection('eventflow-userreadmodel');
|
||||
const recipient = await userCollection.findOne({ UserId: parseInt(recipientUserId) });
|
||||
const sender = await userCollection.findOne({ UserId: parseInt(senderUserId) });
|
||||
|
||||
if (!recipient) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Recipient not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Notification requested for user ${recipientUserId}: Gift ${giftId} from user ${senderUserId}`);
|
||||
|
||||
// Само уведомление отправляет MessageDomainEventHandler при обработке
|
||||
// InboxMessageCreatedEvent. Этот эндпоинт нужен для тестов и отладки.
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Notification logged for user ${recipientUserId}`,
|
||||
data: {
|
||||
recipient: {
|
||||
userId: recipient.UserId,
|
||||
userName: recipient.UserName || null,
|
||||
firstName: recipient.FirstName || null
|
||||
},
|
||||
sender: sender ? {
|
||||
userId: sender.UserId,
|
||||
userName: sender.UserName || null,
|
||||
firstName: sender.FirstName || null
|
||||
} : null,
|
||||
giftId: parseInt(giftId),
|
||||
stars: parseInt(stars || 0)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing gift notification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,102 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET statistics
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const giftsCollection = req.db.collection('AvailableStarGiftReadModel');
|
||||
const sentGiftsCollection = req.db.collection('StarGiftReadModel');
|
||||
|
||||
const [
|
||||
totalGifts,
|
||||
limitedGifts,
|
||||
soldOutGifts,
|
||||
totalSentGifts,
|
||||
totalStarsEarned
|
||||
] = await Promise.all([
|
||||
giftsCollection.countDocuments(),
|
||||
giftsCollection.countDocuments({ Limited: true }),
|
||||
giftsCollection.countDocuments({ SoldOut: true }),
|
||||
sentGiftsCollection.countDocuments(),
|
||||
sentGiftsCollection.aggregate([
|
||||
{ $group: { _id: null, total: { $sum: '$Stars' } } }
|
||||
]).toArray()
|
||||
]);
|
||||
|
||||
const mostPopular = await sentGiftsCollection.aggregate([
|
||||
{ $group: { _id: '$GiftId', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
]).toArray();
|
||||
|
||||
res.json({
|
||||
totalGifts,
|
||||
limitedGifts,
|
||||
soldOutGifts,
|
||||
availableGifts: totalGifts - soldOutGifts,
|
||||
totalSentGifts,
|
||||
totalStarsEarned: totalStarsEarned[0]?.total || 0,
|
||||
mostPopular
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET sent gifts statistics
|
||||
router.get('/sent', async (req, res) => {
|
||||
try {
|
||||
const sentGiftsCollection = req.db.collection('StarGiftReadModel');
|
||||
|
||||
const stats = await sentGiftsCollection.aggregate([
|
||||
{
|
||||
$facet: {
|
||||
byDay: [
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$CreatedAt' }
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
stars: { $sum: '$Stars' }
|
||||
}
|
||||
},
|
||||
{ $sort: { _id: -1 } },
|
||||
{ $limit: 30 }
|
||||
],
|
||||
byGift: [
|
||||
{
|
||||
$group: {
|
||||
_id: '$GiftId',
|
||||
count: { $sum: 1 },
|
||||
stars: { $sum: '$Stars' }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } }
|
||||
],
|
||||
conversion: [
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
total: { $sum: 1 },
|
||||
converted: {
|
||||
$sum: { $cond: ['$Converted', 1, 0] }
|
||||
},
|
||||
saved: {
|
||||
$sum: { $cond: ['$Saved', 1, 0] }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]).toArray();
|
||||
|
||||
res.json(stats[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Настраиваем multer для загрузки файлов
|
||||
const upload = multer({
|
||||
dest: 'uploads/',
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // ограничение 10 МБ
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Разрешаем JSON-файлы для анимаций
|
||||
if (file.mimetype === 'application/json' || file.originalname.endsWith('.json')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JSON files are allowed for animations'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузить документ в Telegram
|
||||
router.post('/document', upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const { type, title } = req.body;
|
||||
const filePath = req.file.path;
|
||||
const originalName = req.file.originalname;
|
||||
|
||||
console.log(`Uploading document: ${originalName}, type: ${type}, title: ${title}`);
|
||||
|
||||
try {
|
||||
// Читаем содержимое файла
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
|
||||
// Для JSON-анимаций проверяем содержимое
|
||||
if (type === 'animation' && originalName.endsWith('.json')) {
|
||||
try {
|
||||
const jsonContent = JSON.parse(fileBuffer.toString());
|
||||
if (!jsonContent.v || !jsonContent.layers) {
|
||||
throw new Error('Invalid Lottie JSON: missing required fields (v, layers)');
|
||||
}
|
||||
console.log(`Valid Lottie JSON: version=${jsonContent.v}, layers=${jsonContent.layers?.length}`);
|
||||
} catch (jsonError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid JSON animation: ' + jsonError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Генерируем ID документа (по метке времени, как у остальных документов)
|
||||
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Создаём документ в MongoDB
|
||||
const document = {
|
||||
_id: `document-${documentId}`,
|
||||
Id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
AccessHash: Math.floor(Math.random() * 9223372036854775807), // случайное 64-битное значение
|
||||
FileReference: Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]), // 8 байт
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: type === 'animation' ? 'application/json' : 'application/octet-stream',
|
||||
Size: fileBuffer.length,
|
||||
DcId: 2, // дата-центр для медиа
|
||||
Attributes2: [
|
||||
{
|
||||
ConstructorId: 0x6319d612, // TDocumentAttributeSticker
|
||||
Alt: type === 'animation' ? '❄️' : '📄',
|
||||
Stickerset: {
|
||||
ConstructorId: 0x83cf7966, // TInputStickerSetEmpty
|
||||
}
|
||||
}
|
||||
],
|
||||
Thumbs: [],
|
||||
VideoThumbs: []
|
||||
};
|
||||
|
||||
// Сохраняем документ в MongoDB
|
||||
await req.db
|
||||
.collection('eventflow-documentreadmodel')
|
||||
.insertOne(document);
|
||||
|
||||
// TODO: загрузить файл в хранилище MinIO.
|
||||
// Пока создаём только запись документа.
|
||||
// В продакшене сюда нужно заливать fileBuffer в MinIO по пути `${documentId}`.
|
||||
|
||||
console.log(`Document created: ID=${documentId}, Size=${fileBuffer.length} bytes`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
documentId: documentId,
|
||||
message: `Document uploaded successfully`,
|
||||
size: fileBuffer.length
|
||||
});
|
||||
|
||||
} finally {
|
||||
// Удаляем загруженный файл
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upload document error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,413 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
|
||||
// Список всех пользователей с постраничной разбивкой
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, search = '' } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const filter = {};
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ FirstName: { $regex: search, $options: 'i' } },
|
||||
{ LastName: { $regex: search, $options: 'i' } },
|
||||
{ UserName: { $regex: search, $options: 'i' } },
|
||||
{ Phone: { $regex: search, $options: 'i' } }
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
req.db.collection('eventflow-userreadmodel')
|
||||
.find(filter)
|
||||
.sort({ UserId: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.toArray(),
|
||||
req.db.collection('eventflow-userreadmodel').countDocuments(filter)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Поиск пользователей по телефону или имени пользователя
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { phone, username } = req.query;
|
||||
|
||||
if (!phone && !username) {
|
||||
return res.status(400).json({ error: 'phone or username parameter required' });
|
||||
}
|
||||
|
||||
let query = {};
|
||||
|
||||
if (phone) {
|
||||
// Поиск по номеру телефона
|
||||
query.PhoneNumber = phone;
|
||||
} else if (username) {
|
||||
// Поиск по имени пользователя (без учёта регистра)
|
||||
query.UserName = new RegExp(`^${username}$`, 'i');
|
||||
}
|
||||
|
||||
const users = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.find(query)
|
||||
.limit(10)
|
||||
.toArray();
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
console.error('User search error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить пользователя по ID
|
||||
router.get('/:userId', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Заморозить (ограничить) аккаунт пользователя
|
||||
router.post('/:userId/freeze', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const { reason = 'Account restricted for violating Terms of Service' } = req.body;
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Переводим пользователя в замороженное (ограниченное) состояние
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Restricted: true,
|
||||
RestrictionReason: reason
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${userId} has been frozen`,
|
||||
userId,
|
||||
reason
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Freeze user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Разморозить (снять ограничение) аккаунт пользователя
|
||||
router.post('/:userId/unfreeze', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Снимаем ограничение
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Restricted: false,
|
||||
RestrictionReason: null
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${userId} has been unfrozen`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unfreeze user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить аккаунт пользователя (пометить как удалённый)
|
||||
router.delete('/:userId', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Помечаем пользователя как удалённого
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
IsDeleted: true,
|
||||
Restricted: true,
|
||||
RestrictionReason: 'Account deleted by administrator'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${userId} has been deleted`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Задать анимацию заморозки для пользователя
|
||||
router.post('/:userId/frozen-animation', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const { documentId } = req.body;
|
||||
|
||||
if (!documentId) {
|
||||
return res.status(400).json({ error: 'Document ID is required' });
|
||||
}
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Сохраняем ID документа анимации заморозки
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
FrozenAnimationDocumentId: parseInt(documentId)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Frozen animation set for user ${userId}`,
|
||||
userId,
|
||||
documentId: parseInt(documentId)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Set frozen animation error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Убрать анимацию заморозки у пользователя
|
||||
router.delete('/:userId/frozen-animation', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Убираем анимацию заморозки
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$unset: {
|
||||
FrozenAnimationDocumentId: ""
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Frozen animation removed from user ${userId}`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Remove frozen animation error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Выдать пользователю premium
|
||||
router.post('/:userId/premium', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Выдаём статус premium
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Premium: true,
|
||||
PremiumGifts: true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Premium given to user ${userId}`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Give premium error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Снять premium с пользователя
|
||||
router.delete('/:userId/premium', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Снимаем статус premium
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Premium: false,
|
||||
PremiumGifts: false
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Premium removed from user ${userId}`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Remove premium error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить настройки иконки заморозки
|
||||
router.get('/settings/frozen-icon', async (req, res) => {
|
||||
try {
|
||||
const settings = await req.db
|
||||
.collection('admin-settings')
|
||||
.findOne({ key: 'frozen_icon' });
|
||||
|
||||
res.json({
|
||||
type: settings?.type || 'emoji', // значение 'emoji' или 'animation'
|
||||
emoji: settings?.emoji || '❄️',
|
||||
animationUrl: settings?.animationUrl || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get frozen icon error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить настройки иконки заморозки
|
||||
router.post('/settings/frozen-icon', async (req, res) => {
|
||||
try {
|
||||
const { type, emoji, animationUrl } = req.body;
|
||||
|
||||
await req.db
|
||||
.collection('admin-settings')
|
||||
.updateOne(
|
||||
{ key: 'frozen_icon' },
|
||||
{
|
||||
$set: {
|
||||
key: 'frozen_icon',
|
||||
type: type || 'emoji',
|
||||
emoji: emoji || '❄️',
|
||||
animationUrl: animationUrl || null,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Frozen icon settings updated'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update frozen icon error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,679 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import Joi from 'joi';
|
||||
import * as minioHelper from '../utils/minioHelper.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Настройка загрузки файлов для иконок TGS
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = 'uploads/verification-icons';
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, `verification-icon-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
|
||||
const allowedExts = ['.json', '.tgs'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .json and .tgs files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Схемы валидации
|
||||
const botVerifierSchema = Joi.object({
|
||||
botUserId: Joi.number().integer().positive().required(),
|
||||
iconEmojiId: Joi.number().integer().positive().required(),
|
||||
companyName: Joi.string().min(1).max(200).required(),
|
||||
canModifyCustomDescription: Joi.boolean().default(false)
|
||||
});
|
||||
|
||||
const verificationSchema = Joi.object({
|
||||
targetUserId: Joi.number().integer().positive().required(),
|
||||
botUserId: Joi.number().integer().positive().optional(),
|
||||
customDescription: Joi.string().max(500).optional().allow(null, '')
|
||||
});
|
||||
|
||||
// GET /api/verification/bots - Get all bot verifiers
|
||||
router.get('/bots', async (req, res) => {
|
||||
try {
|
||||
const verifiers = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.find({ IsActive: true })
|
||||
.toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: verifiers
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching bot verifiers:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch bot verifiers'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/bots/:botUserId - Get specific bot verifier
|
||||
router.get('/bots/:botUserId', async (req, res) => {
|
||||
try {
|
||||
const { botUserId } = req.params;
|
||||
|
||||
const verifier = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOne({ BotUserId: parseInt(botUserId), IsActive: true });
|
||||
|
||||
if (!verifier) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot verifier not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: verifier
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching bot verifier:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch bot verifier'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/verification/bots - Create new bot verifier
|
||||
router.post('/bots', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = botVerifierSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
// Check if bot user exists
|
||||
const botUser = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: value.botUserId, Bot: true });
|
||||
|
||||
if (!botUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot user not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if verifier already exists
|
||||
const existing = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOne({ BotUserId: value.botUserId });
|
||||
|
||||
const verifier = {
|
||||
Id: `bot_${value.botUserId}`,
|
||||
BotUserId: value.botUserId,
|
||||
IconEmojiId: value.iconEmojiId,
|
||||
CompanyName: value.companyName,
|
||||
CanModifyCustomDescription: value.canModifyCustomDescription,
|
||||
IsActive: true,
|
||||
CreatedAt: existing?.CreatedAt || new Date(),
|
||||
UpdatedAt: new Date()
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
// Update existing verifier
|
||||
await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.updateOne(
|
||||
{ BotUserId: value.botUserId },
|
||||
{ $set: verifier }
|
||||
);
|
||||
} else {
|
||||
// Create new verifier
|
||||
await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.insertOne(verifier);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: verifier
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating bot verifier:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create bot verifier'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/verification/bots/:botUserId - Update bot verifier
|
||||
router.put('/bots/:botUserId', async (req, res) => {
|
||||
try {
|
||||
const { botUserId } = req.params;
|
||||
const { error, value } = botVerifierSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const result = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ BotUserId: parseInt(botUserId) },
|
||||
{
|
||||
$set: {
|
||||
IconEmojiId: value.iconEmojiId,
|
||||
CompanyName: value.companyName,
|
||||
CanModifyCustomDescription: value.canModifyCustomDescription,
|
||||
UpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot verifier not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating bot verifier:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update bot verifier'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/verification/bots/:botUserId - Deactivate bot verifier
|
||||
router.delete('/bots/:botUserId', async (req, res) => {
|
||||
try {
|
||||
const { botUserId } = req.params;
|
||||
|
||||
const result = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ BotUserId: parseInt(botUserId) },
|
||||
{
|
||||
$set: {
|
||||
IsActive: false,
|
||||
UpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot verifier not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deactivating bot verifier:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to deactivate bot verifier'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/users/:userId - Get user verification status
|
||||
router.get('/users/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
const verification = await req.db
|
||||
.collection('ReadModel-BotVerificationReadModel')
|
||||
.findOne({ TargetType: 1, TargetId: parseInt(userId) });
|
||||
|
||||
if (!verification) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: verification
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user verification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch verification status'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/verification/users/:userId - Set user verification
|
||||
router.post('/users/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { error, value } = verificationSchema.validate({
|
||||
...req.body,
|
||||
targetUserId: parseInt(userId)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
// Check if target user exists
|
||||
const targetUser = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: value.targetUserId });
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Get bot verifier
|
||||
const botUserId = value.botUserId || req.body.botUserId;
|
||||
const verifier = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOne({ BotUserId: botUserId, IsActive: true });
|
||||
|
||||
if (!verifier) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot verifier not found or inactive'
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяем право задавать собственное описание
|
||||
if (value.customDescription && !verifier.CanModifyCustomDescription) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Bot verifier cannot modify custom description'
|
||||
});
|
||||
}
|
||||
|
||||
// По умолчанию описание берётся из CompanyName.
|
||||
// Если задано непустое CustomDescription, оно заменяет описание по умолчанию.
|
||||
const defaultDescription = verifier.CompanyName;
|
||||
const customDescription = value.customDescription && value.customDescription.trim() !== ''
|
||||
? value.customDescription.trim()
|
||||
: null;
|
||||
|
||||
// Update UserReadModel with verification (both collections for compatibility)
|
||||
const updateData = {
|
||||
BotVerifierId: parseInt(verifier.BotUserId),
|
||||
BotVerificationIcon: parseInt(verifier.IconEmojiId)
|
||||
};
|
||||
|
||||
// Check if UserReadModel exists
|
||||
const existingUser = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: value.targetUserId });
|
||||
|
||||
if (!existingUser) {
|
||||
console.log(`UserReadModel does not exist for user ${value.targetUserId}`);
|
||||
console.log(`UserReadModel создаётся Command Server при регистрации или обновлении пользователя`);
|
||||
console.log(`Пока создаём минимальный UserReadModel только с полями верификации`);
|
||||
|
||||
// Создаём минимальный UserReadModel с полями верификации
|
||||
await req.db.collection('ReadModel-UserReadModel').insertOne({
|
||||
_id: `user-${value.targetUserId}`,
|
||||
Id: `user-${value.targetUserId}`,
|
||||
UserId: value.targetUserId,
|
||||
BotVerifierId: parseInt(verifier.BotUserId),
|
||||
BotVerificationIcon: parseInt(verifier.IconEmojiId),
|
||||
Version: 1
|
||||
});
|
||||
console.log(`Created minimal UserReadModel for user ${value.targetUserId}`);
|
||||
} else {
|
||||
// Обновляем существующий UserReadModel и увеличиваем Version, чтобы сбросить кэш
|
||||
const currentVersion = existingUser.Version || 1;
|
||||
await req.db.collection('ReadModel-UserReadModel').updateOne(
|
||||
{ UserId: value.targetUserId },
|
||||
{
|
||||
$set: updateData,
|
||||
$inc: { Version: 1 } // увеличиваем версию, чтобы сбросить кэш Query Server
|
||||
}
|
||||
);
|
||||
console.log(`Updated existing UserReadModel for user ${value.targetUserId} (Version: ${currentVersion} -> ${currentVersion + 1})`);
|
||||
}
|
||||
|
||||
// Также обновляем коллекцию eventflow
|
||||
await req.db.collection('eventflow-userreadmodel').updateOne(
|
||||
{ UserId: value.targetUserId },
|
||||
{ $set: updateData },
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
// Создаём или обновляем BotVerificationReadModel
|
||||
const verificationId = `verification_1_${value.targetUserId}`; // формат: verification_{TargetType}_{TargetId}
|
||||
const verification = {
|
||||
_id: verificationId, // явно задаём _id строкой
|
||||
Id: verificationId,
|
||||
TargetType: 1, // пользователь
|
||||
TargetId: value.targetUserId,
|
||||
BotVerifierId: verifier.BotUserId, // используем BotVerifierId (имя поля в C#-ReadModel)
|
||||
IconEmojiId: verifier.IconEmojiId,
|
||||
Description: defaultDescription, // всегда задаём описание по умолчанию из CompanyName
|
||||
CustomDescription: customDescription, // null или собственный текст
|
||||
VerifiedAt: new Date(),
|
||||
UpdatedAt: new Date()
|
||||
};
|
||||
|
||||
console.log(`Creating verification: Description="${defaultDescription}", CustomDescription=${customDescription ? `"${customDescription}"` : 'null'}`);
|
||||
|
||||
// Удаляем старую запись, если она есть (чтобы избежать конфликта _id)
|
||||
await req.db.collection('ReadModel-BotVerificationReadModel').deleteOne({
|
||||
TargetType: 1,
|
||||
TargetId: value.targetUserId
|
||||
});
|
||||
|
||||
// Вставляем новую запись
|
||||
await req.db.collection('ReadModel-BotVerificationReadModel').insertOne(verification);
|
||||
|
||||
// Сбрасываем кэш UserReadModel, обновляя служебное поле:
|
||||
// это заставляет Query Server перечитать пользователя из MongoDB
|
||||
await req.db.collection('ReadModel-UserReadModel').updateOne(
|
||||
{ UserId: value.targetUserId },
|
||||
{ $set: { _cacheInvalidated: new Date() } }
|
||||
);
|
||||
|
||||
console.log(`Verification created successfully for user ${value.targetUserId}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: verification,
|
||||
warning: 'Please restart messenger-query-server to see changes: docker-compose restart messenger-query-server'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting user verification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to set user verification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/users/:userId/debug - отладочные данные о верификации пользователя
|
||||
router.get('/users/:userId/debug', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
// Проверяем UserReadModel
|
||||
const userReadModel = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: userId });
|
||||
|
||||
// Проверяем BotVerificationReadModel
|
||||
const verification = await req.db.collection('ReadModel-BotVerificationReadModel').findOne({
|
||||
TargetType: 1,
|
||||
TargetId: userId
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userReadModel: {
|
||||
BotVerifierId: userReadModel?.BotVerifierId || null,
|
||||
BotVerificationIcon: userReadModel?.BotVerificationIcon || null
|
||||
},
|
||||
verification: verification || null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error debugging verification:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/verification/users/:userId - Remove user verification
|
||||
router.delete('/users/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// Убираем верификацию из UserReadModel (в обеих коллекциях)
|
||||
const unsetData = {
|
||||
BotVerifierId: "",
|
||||
BotVerificationIcon: ""
|
||||
};
|
||||
|
||||
// Запоминаем текущую версию до обновления
|
||||
const existingUser = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: parseInt(userId) });
|
||||
const currentVersion = existingUser?.Version || 1;
|
||||
|
||||
await Promise.all([
|
||||
req.db.collection('eventflow-userreadmodel').updateOne(
|
||||
{ UserId: parseInt(userId) },
|
||||
{ $unset: unsetData }
|
||||
),
|
||||
req.db.collection('ReadModel-UserReadModel').updateOne(
|
||||
{ UserId: parseInt(userId) },
|
||||
{
|
||||
$unset: unsetData,
|
||||
$inc: { Version: 1 } // увеличиваем версию, чтобы сбросить кэш Query Server
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
console.log(`Removed verification for user ${userId} (Version: ${currentVersion} -> ${currentVersion + 1})`);
|
||||
|
||||
// Удаляем из BotVerificationReadModel
|
||||
await req.db.collection('ReadModel-BotVerificationReadModel').deleteOne({
|
||||
TargetType: 1,
|
||||
TargetId: parseInt(userId)
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Verification removed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing user verification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to remove verification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/verification/upload-icon - загрузка файла иконки TGS
|
||||
router.post('/upload-icon', upload.single('icon'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'No file uploaded'
|
||||
});
|
||||
}
|
||||
|
||||
const filePath = req.file.path;
|
||||
const ext = path.extname(req.file.originalname).toLowerCase();
|
||||
|
||||
if (ext !== '.tgs') {
|
||||
await fs.unlink(filePath);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Only .tgs files are allowed'
|
||||
});
|
||||
}
|
||||
|
||||
// Генерируем уникальный ID документа для этого эмодзи
|
||||
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Загружаем в MinIO (как для эмодзи-паков)
|
||||
await minioHelper.uploadFile(filePath, documentId, 'application/x-tgsticker');
|
||||
|
||||
// Создаём DocumentReadModel (та же структура, что и у эмодзи-паков)
|
||||
const fileReference = Buffer.alloc(8);
|
||||
fileReference.writeBigInt64LE(BigInt(documentId));
|
||||
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`,
|
||||
Id: `document-${documentId}`,
|
||||
Version: 1,
|
||||
DocumentId: documentId,
|
||||
AccessHash: documentId + 100,
|
||||
FileReference: fileReference,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: req.file.size,
|
||||
Name: String(documentId),
|
||||
Attributes2: [
|
||||
{
|
||||
_t: 'TDocumentAttributeCustomEmoji',
|
||||
Free: true,
|
||||
TextColor: false,
|
||||
Alt: '🏆',
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetEmpty'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Используем правильное имя коллекции (соглашение об именовании EventFlow)
|
||||
const collectionName = 'ReadModel-DocumentReadModel';
|
||||
|
||||
const insertResult = await req.db
|
||||
.collection(collectionName)
|
||||
.insertOne(documentReadModel);
|
||||
|
||||
console.log(`DocumentReadModel created in ${collectionName}: documentId=${documentId}, insertedId=${insertResult.insertedId}`);
|
||||
|
||||
// Убеждаемся, что документ создан
|
||||
const verifyDoc = await req.db
|
||||
.collection(collectionName)
|
||||
.findOne({ DocumentId: documentId });
|
||||
|
||||
if (!verifyDoc) {
|
||||
console.error(`Document not found after insert: documentId=${documentId}`);
|
||||
throw new Error('Document verification failed');
|
||||
}
|
||||
|
||||
console.log(`Document verified in MongoDB: documentId=${documentId}`);
|
||||
|
||||
// Удаляем загруженный файл
|
||||
await fs.unlink(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
documentId,
|
||||
size: req.file.size
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading icon:', error);
|
||||
if (req.file) {
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
}
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to upload icon'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/stats - статистика верификации
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const [totalVerifiers, activeVerifiers, totalVerifications, userVerifications] = await Promise.all([
|
||||
req.db.collection('ReadModel-BotVerifierReadModel').countDocuments({}),
|
||||
req.db.collection('ReadModel-BotVerifierReadModel').countDocuments({ IsActive: true }),
|
||||
req.db.collection('ReadModel-BotVerificationReadModel').countDocuments({}),
|
||||
req.db.collection('ReadModel-BotVerificationReadModel').countDocuments({ TargetType: 1 })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalVerifiers,
|
||||
activeVerifiers,
|
||||
totalVerifications,
|
||||
userVerifications,
|
||||
channelVerifications: totalVerifications - userVerifications
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching verification stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch statistics'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/icons/:documentId/check - проверить наличие документа иконки
|
||||
router.get('/icons/:documentId/check', async (req, res) => {
|
||||
try {
|
||||
const documentId = parseInt(req.params.documentId);
|
||||
|
||||
// Проверяем в MongoDB
|
||||
const document = await req.db
|
||||
.collection('ReadModel-DocumentReadModel')
|
||||
.findOne({ DocumentId: documentId });
|
||||
|
||||
// Проверяем в MinIO
|
||||
const fileExistsInMinio = await minioHelper.fileExists(documentId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
documentId,
|
||||
existsInMongoDB: !!document,
|
||||
existsInMinIO: fileExistsInMinio,
|
||||
document: document ? {
|
||||
DocumentId: document.DocumentId,
|
||||
Size: document.Size,
|
||||
MimeType: document.MimeType,
|
||||
DcId: document.DcId
|
||||
} : null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking icon:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to check icon'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,340 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
// Определяем текущий каталог (в ES-модулях нет __dirname по умолчанию)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Загружаем переменные окружения в первую очередь, до остальных импортов
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// Выводим в лог, откуда загружен .env, и проверяем ключи MinIO
|
||||
console.log('Loading .env from:', envPath);
|
||||
console.log('MINIO_ACCESS_KEY:', process.env.MINIO_ACCESS_KEY || 'NOT SET');
|
||||
console.log('MINIO_SECRET_KEY:', process.env.MINIO_SECRET_KEY ? '***' + process.env.MINIO_SECRET_KEY.slice(-4) : 'NOT SET');
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import giftsRouter from './routes/gifts.js';
|
||||
import attributesRouter from './routes/attributes.js';
|
||||
import statsRouter from './routes/stats.js';
|
||||
import emojiPacksRouter from './routes/emojipacks.js';
|
||||
import stickerPacksRouter from './routes/stickerpacks.js';
|
||||
import verificationRouter from './routes/verification.js';
|
||||
import reactionsRouter from './routes/reactions.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import sponsoredMessagesRouter from './routes/sponsoredMessages.js';
|
||||
import serviceNotificationsRouter from './routes/serviceNotifications.js';
|
||||
import settingsRouter from './routes/settings.js';
|
||||
import uploadRouter from './routes/upload.js';
|
||||
import starsRouter from './routes/stars.js';
|
||||
import frozenAccountsRouter from './routes/frozenAccounts.js';
|
||||
import frozenSettingsRouter from './routes/frozenSettings.js';
|
||||
import minioHelper from './utils/minioHelper.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173'
|
||||
}));
|
||||
app.use(express.json({ limit: '500mb' }));
|
||||
app.use(express.urlencoded({ limit: '500mb', extended: true }));
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
// MongoDB Connection
|
||||
let db;
|
||||
const mongoClient = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
|
||||
|
||||
async function connectDB() {
|
||||
try {
|
||||
await mongoClient.connect();
|
||||
db = mongoClient.db(process.env.DB_NAME || 'tg');
|
||||
console.log('Connected to MongoDB');
|
||||
|
||||
// Создаём индексы
|
||||
await db.collection('AvailableStarGiftReadModel').createIndex({ GiftId: 1 }, { unique: true });
|
||||
await db.collection('AvailableStarGiftReadModel').createIndex({ SoldOut: 1, Stars: 1 });
|
||||
await db.collection('ReadModel-UserReadModel').createIndex({ UserId: 1 });
|
||||
await db.collection('ReadModel-UserReadModel').createIndex({ UserName: 1 });
|
||||
await db.collection('ReadModel-UserReadModel').createIndex({ PhoneNumber: 1 });
|
||||
|
||||
// Индексы для кастомных эмодзи
|
||||
await db.collection('custom_emoji_documents').createIndex({ document_id: 1 }, { unique: true });
|
||||
await db.collection('custom_emoji_documents').createIndex({ 'attributes.stickerset_id': 1 });
|
||||
await db.collection('custom_emoji_documents').createIndex({ 'attributes.alt': 1 });
|
||||
// Коллекции read-моделей EventFlow
|
||||
await db.collection('ReadModel-StickerSetReadModel').createIndex({ StickerSetId: 1 }, { unique: true });
|
||||
await db.collection('ReadModel-StickerSetReadModel').createIndex({ ShortName: 1 }, { unique: true });
|
||||
await db.collection('ReadModel-DocumentReadModel').createIndex({ DocumentId: 1 }, { unique: true });
|
||||
await db.collection('ReadModel-DocumentReadModel').createIndex({ _id: 1 });
|
||||
await db.collection('emoji_packs').createIndex({ stickerset_id: 1, emoticon: 1 }, { unique: true });
|
||||
|
||||
// Индексы для рекламных сообщений
|
||||
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ Id: 1 }, { unique: true });
|
||||
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ ChannelId: 1, IsActive: 1 });
|
||||
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ CreatedDate: -1 });
|
||||
|
||||
console.log('Database indexes created');
|
||||
|
||||
// Инициализируем bucket в MinIO
|
||||
try {
|
||||
await minioHelper.ensureBucket();
|
||||
console.log('MinIO storage initialized');
|
||||
} catch (error) {
|
||||
console.warn('MinIO initialization failed (will retry on upload):', error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MongoDB connection error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Прокидываем подключение к БД во все маршруты
|
||||
app.use((req, res, next) => {
|
||||
req.db = db;
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/gifts', giftsRouter);
|
||||
app.use('/api/attributes', attributesRouter);
|
||||
app.use('/api/stats', statsRouter);
|
||||
app.use('/api/emojipacks', emojiPacksRouter);
|
||||
app.use('/api/stickerpacks', stickerPacksRouter);
|
||||
app.use('/api/verification', verificationRouter);
|
||||
app.use('/api/reactions', reactionsRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/sponsored-messages', sponsoredMessagesRouter);
|
||||
app.use('/api/service-notifications', serviceNotificationsRouter);
|
||||
app.use('/api/users/settings', settingsRouter);
|
||||
app.use('/api/upload', uploadRouter);
|
||||
app.use('/api/stars', starsRouter);
|
||||
app.use('/api/frozen-accounts', frozenAccountsRouter);
|
||||
app.use('/api/frozen-settings', frozenSettingsRouter);
|
||||
|
||||
// Быстрый поиск пользователя (устаревший вариант, перенесён в роутер users)
|
||||
app.get('/api/users-legacy/search', async (req, res) => {
|
||||
try {
|
||||
const { phone, username, limit = 10 } = req.query;
|
||||
|
||||
const filter = {};
|
||||
if (phone) filter.PhoneNumber = phone;
|
||||
if (username) filter.UserName = { $regex: username, $options: 'i' };
|
||||
|
||||
const users = await db
|
||||
.collection('ReadModel-UserReadModel')
|
||||
.find(filter)
|
||||
.limit(parseInt(limit))
|
||||
.project({ UserId: 1, PhoneNumber: 1, UserName: 1, FirstName: 1, LastName: 1 })
|
||||
.toArray();
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Список всех пользователей с постраничной разбивкой
|
||||
app.get('/api/users', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 50, search = '' } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
// Формируем фильтр поиска
|
||||
const filter = {};
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ FirstName: { $regex: search, $options: 'i' } },
|
||||
{ LastName: { $regex: search, $options: 'i' } },
|
||||
{ UserName: { $regex: search, $options: 'i' } },
|
||||
{ PhoneNumber: { $regex: search, $options: 'i' } }
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
db.collection('ReadModel-UserReadModel')
|
||||
.find(filter)
|
||||
.sort({ UserId: -1 }) // Сначала новые
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.project({
|
||||
UserId: 1,
|
||||
PhoneNumber: 1,
|
||||
UserName: 1,
|
||||
FirstName: 1,
|
||||
LastName: 1,
|
||||
AccessHash: 1,
|
||||
IsDeleted: 1,
|
||||
Bot: 1
|
||||
})
|
||||
.toArray(),
|
||||
db.collection('ReadModel-UserReadModel').countDocuments(filter)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
users,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
totalPages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Список всех каналов
|
||||
app.get('/api/channels', async (req, res) => {
|
||||
try {
|
||||
const channels = await db.collection('eventflow-channelreadmodel')
|
||||
.find({ IsDeleted: { $ne: true } })
|
||||
.sort({ ChannelId: -1 })
|
||||
.project({
|
||||
ChannelId: 1,
|
||||
Title: 1,
|
||||
UserName: 1,
|
||||
ParticipantsCount: 1,
|
||||
AccessHash: 1,
|
||||
Broadcast: 1,
|
||||
MegaGroup: 1
|
||||
})
|
||||
.toArray();
|
||||
|
||||
// Переименовываем ParticipantsCount в MembersCount для совместимости с фронтендом
|
||||
const formattedChannels = channels.map(ch => ({
|
||||
...ch,
|
||||
MembersCount: ch.ParticipantsCount || 0
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
channels: formattedChannels
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching channels:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Проверка работоспособности сервиса
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
mongodb: db ? 'connected' : 'disconnected',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Эндпоинт для проверки соединения с MinIO
|
||||
app.get('/api/test/minio', async (req, res) => {
|
||||
try {
|
||||
const { Client } = await import('minio');
|
||||
|
||||
// Берём конфигурацию из переменных окружения
|
||||
const config = {
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: false,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
|
||||
};
|
||||
|
||||
const testClient = new Client(config);
|
||||
const bucketName = 'tg-files';
|
||||
|
||||
// Проверяем соединение, запросив список bucket'ов
|
||||
const startTime = Date.now();
|
||||
let buckets;
|
||||
try {
|
||||
buckets = await testClient.listBuckets();
|
||||
} catch (listError) {
|
||||
throw new Error(`Failed to list buckets: ${listError.message}`);
|
||||
}
|
||||
const listTime = Date.now() - startTime;
|
||||
|
||||
// Проверяем, существует ли нужный нам bucket
|
||||
const bucketExists = buckets.some(b => b.name === bucketName);
|
||||
|
||||
// Если bucket'а нет, пробуем его создать
|
||||
let bucketCreated = false;
|
||||
if (!bucketExists) {
|
||||
try {
|
||||
await testClient.makeBucket(bucketName, 'us-east-1');
|
||||
bucketCreated = true;
|
||||
} catch (createError) {
|
||||
// Не критично, просто отражаем это в ответе
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
endPoint: config.endPoint,
|
||||
port: config.port,
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: '***' + config.secretKey.slice(-4)
|
||||
},
|
||||
connection: {
|
||||
status: 'connected',
|
||||
responseTime: `${listTime}ms`
|
||||
},
|
||||
buckets: buckets.map(b => ({
|
||||
name: b.name,
|
||||
creationDate: b.creationDate
|
||||
})),
|
||||
targetBucket: {
|
||||
name: bucketName,
|
||||
exists: bucketExists,
|
||||
created: bucketCreated
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
config: {
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: process.env.MINIO_PORT || '9000',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: '***' + (process.env.MINIO_SECRET_KEY || 'minioadmin').slice(-4)
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Обработка ошибок
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: err.message
|
||||
});
|
||||
});
|
||||
|
||||
// Запуск сервера
|
||||
connectDB().then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Star Gift Admin API running on http://localhost:${PORT}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Корректное завершение работы
|
||||
process.on('SIGINT', async () => {
|
||||
await mongoClient.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Client } from 'minio';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const BUCKET_NAME = 'tg-files';
|
||||
|
||||
// Ленивая инициализация: создаём клиента только при первом обращении
|
||||
let minioClient = null;
|
||||
let minioConfig = null;
|
||||
|
||||
function getMinioClient() {
|
||||
if (!minioClient) {
|
||||
// Конфигурация MinIO (должна совпадать с docker-compose.yml)
|
||||
minioConfig = {
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: false,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'test', // совпадает с .env Docker
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '12345678' // совпадает с .env Docker
|
||||
};
|
||||
|
||||
// При первом использовании выводим конфигурацию (секретный ключ скрываем)
|
||||
console.log('MinIO Configuration:', {
|
||||
endPoint: minioConfig.endPoint,
|
||||
port: minioConfig.port,
|
||||
useSSL: minioConfig.useSSL,
|
||||
accessKey: minioConfig.accessKey,
|
||||
secretKey: '***' + minioConfig.secretKey.slice(-4) // показываем только последние 4 символа
|
||||
});
|
||||
|
||||
minioClient = new Client(minioConfig);
|
||||
}
|
||||
return minioClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие bucket'а и создаёт его при отсутствии
|
||||
*/
|
||||
export async function ensureBucket() {
|
||||
const client = getMinioClient();
|
||||
try {
|
||||
console.log(`Checking if bucket "${BUCKET_NAME}" exists...`);
|
||||
const exists = await client.bucketExists(BUCKET_NAME);
|
||||
|
||||
if (!exists) {
|
||||
console.log(`Bucket "${BUCKET_NAME}" does not exist, creating...`);
|
||||
await client.makeBucket(BUCKET_NAME, 'us-east-1');
|
||||
console.log(`Created MinIO bucket: ${BUCKET_NAME}`);
|
||||
} else {
|
||||
console.log(`Bucket "${BUCKET_NAME}" already exists`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure bucket:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
statusCode: error.statusCode,
|
||||
endpoint: minioConfig.endPoint + ':' + minioConfig.port,
|
||||
bucket: BUCKET_NAME
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает файл в MinIO
|
||||
* @param {string} localFilePath - путь к локальному файлу
|
||||
* @param {number} documentId - ID документа, используется как имя объекта
|
||||
* @param {string} mimeType - MIME-тип файла
|
||||
* @returns {Promise<string>} имя объекта в MinIO
|
||||
*/
|
||||
export async function uploadFile(localFilePath, documentId, mimeType) {
|
||||
const client = getMinioClient();
|
||||
try {
|
||||
// Убеждаемся, что bucket существует
|
||||
await ensureBucket();
|
||||
|
||||
// file-server ожидает файлы в корне без расширения.
|
||||
// Сохраняем под именем {documentId} (без папки и расширения) —
|
||||
// именно так их ищет file-server из MyTelegram
|
||||
const objectName = `${documentId}`;
|
||||
|
||||
// Получаем сведения о файле
|
||||
const stats = await fs.stat(localFilePath);
|
||||
|
||||
// Загружаем в MinIO
|
||||
const metaData = {
|
||||
'Content-Type': mimeType,
|
||||
'document-id': documentId.toString()
|
||||
};
|
||||
|
||||
await client.fPutObject(
|
||||
BUCKET_NAME,
|
||||
objectName,
|
||||
localFilePath,
|
||||
metaData
|
||||
);
|
||||
|
||||
console.log(`Uploaded to MinIO: ${objectName} (${stats.size} bytes)`);
|
||||
|
||||
return objectName;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file to MinIO:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет файл из MinIO
|
||||
* @param {number} documentId - ID документа
|
||||
*/
|
||||
export async function deleteFile(documentId) {
|
||||
const client = getMinioClient();
|
||||
try {
|
||||
// Удаляем из корня без расширения
|
||||
const objectName = `${documentId}`;
|
||||
|
||||
try {
|
||||
await client.removeObject(BUCKET_NAME, objectName);
|
||||
console.log(`Deleted from MinIO: ${objectName}`);
|
||||
} catch (err) {
|
||||
console.warn(`File not found in MinIO: ${objectName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file from MinIO:', error);
|
||||
// Не пробрасываем ошибку — файла может не быть
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, существует ли файл в MinIO
|
||||
* @param {number} documentId - ID документа
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function fileExists(documentId) {
|
||||
const client = getMinioClient();
|
||||
try {
|
||||
// Проверяем в корне без расширения
|
||||
const objectName = `${documentId}`;
|
||||
|
||||
try {
|
||||
await client.statObject(BUCKET_NAME, objectName);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
fileExists,
|
||||
ensureBucket
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate FileReference (24 bytes)
|
||||
* Format: [4 bytes timestamp][20 bytes random]
|
||||
*/
|
||||
export function generateFileReference() {
|
||||
const buffer = Buffer.allocUnsafe(24);
|
||||
|
||||
// First 4 bytes: current UNIX timestamp
|
||||
buffer.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
|
||||
|
||||
// Remaining 20 bytes: cryptographically secure random
|
||||
const randomBytes = crypto.randomBytes(20);
|
||||
randomBytes.copy(buffer, 4);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AccessHash (int64)
|
||||
* Must be cryptographically random
|
||||
*/
|
||||
export function generateAccessHash() {
|
||||
// Generate 8 random bytes
|
||||
const buffer = crypto.randomBytes(8);
|
||||
|
||||
// Convert to signed int64 string for MongoDB
|
||||
return buffer.readBigInt64BE(0).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate DocumentId using timestamp + random
|
||||
* Ensures uniqueness without DB roundtrip
|
||||
*/
|
||||
export function generateDocumentId() {
|
||||
// Timestamp in milliseconds * 10000 + random component
|
||||
return Date.now() * 10000 + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { MongoClient } from 'mongodb';
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
// Определяем текущий каталог (в ES-модулях нет __dirname по умолчанию)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Загружаем переменные окружения
|
||||
const envPath = join(__dirname, '..', '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
const mongoUrl = process.env.MONGODB_URI || 'mongodb://localhost:27017';
|
||||
const dbName = process.env.DB_NAME || 'tg';
|
||||
const adminApiUrl = process.env.ADMIN_API_URL || 'http://localhost:5555';
|
||||
|
||||
let db;
|
||||
let isProcessing = false;
|
||||
|
||||
// Подключаемся к MongoDB
|
||||
async function connectDB() {
|
||||
try {
|
||||
const client = await MongoClient.connect(mongoUrl);
|
||||
db = client.db(dbName);
|
||||
console.log('Service Notification Worker connected to MongoDB');
|
||||
return client;
|
||||
} catch (error) {
|
||||
console.error('MongoDB connection error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Отправляем уведомление через HTTP API
|
||||
async function sendNotificationViaApi(event) {
|
||||
const payload = {
|
||||
userId: event.userId,
|
||||
type: event.type,
|
||||
message: event.message,
|
||||
popup: event.popup,
|
||||
mediaUrl: event.mediaUrl,
|
||||
mediaType: event.mediaType
|
||||
};
|
||||
|
||||
console.log(` Calling Admin API: ${adminApiUrl}/api/service-notifications/send`);
|
||||
console.log(` Payload:`, JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await fetch(`${adminApiUrl}/api/service-notifications/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
console.log(` Response status: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(` API Error: ${errorText}`);
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(` API Response:`, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Обрабатываем необработанные события уведомлений
|
||||
async function processPendingNotifications() {
|
||||
if (isProcessing) {
|
||||
return; // обработка уже идёт
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessing = true;
|
||||
|
||||
const eventsCollection = db.collection('ServiceNotificationEvents');
|
||||
|
||||
// Берём необработанные события (не более 10 за раз)
|
||||
const pendingEvents = await eventsCollection
|
||||
.find({ processed: false })
|
||||
.sort({ createdAt: 1 })
|
||||
.limit(10)
|
||||
.toArray();
|
||||
|
||||
if (pendingEvents.length === 0) {
|
||||
return; // необработанных событий нет
|
||||
}
|
||||
|
||||
console.log(`\nProcessing ${pendingEvents.length} pending notifications...`);
|
||||
|
||||
for (const event of pendingEvents) {
|
||||
try {
|
||||
console.log(` Sending notification to user ${event.userId} (type: ${event.type})`);
|
||||
|
||||
// Отправляем через HTTP API
|
||||
await sendNotificationViaApi(event);
|
||||
|
||||
// Помечаем как обработанное
|
||||
await eventsCollection.updateOne(
|
||||
{ _id: event._id },
|
||||
{
|
||||
$set: {
|
||||
processed: true,
|
||||
processedAt: new Date(),
|
||||
status: 'sent'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(` Notification sent to user ${event.userId}`);
|
||||
} catch (error) {
|
||||
console.error(` Failed to send notification to user ${event.userId}:`, error.message);
|
||||
|
||||
// Помечаем как неудачное
|
||||
await eventsCollection.updateOne(
|
||||
{ _id: event._id },
|
||||
{
|
||||
$set: {
|
||||
processed: true,
|
||||
processedAt: new Date(),
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Processed ${pendingEvents.length} notifications\n`);
|
||||
} catch (error) {
|
||||
console.error('Error processing notifications:', error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Главный цикл воркера
|
||||
async function startWorker() {
|
||||
console.log('Service Notification Worker started');
|
||||
console.log(`Using Admin API: ${adminApiUrl}`);
|
||||
console.log('Checking for pending notifications every 5 seconds...\n');
|
||||
|
||||
await connectDB();
|
||||
|
||||
// Сразу обрабатываем события при запуске
|
||||
await processPendingNotifications();
|
||||
|
||||
// Затем проверяем каждые 5 секунд
|
||||
setInterval(async () => {
|
||||
await processPendingNotifications();
|
||||
}, 5000); // проверка каждые 5 секунд
|
||||
}
|
||||
|
||||
// Корректное завершение работы
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down worker...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\nShutting down worker...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Запускаем воркер
|
||||
startWorker().catch(console.error);
|
||||
Reference in New Issue
Block a user