UNPKG

@dieugene/users-controller

Version:

Модуль для управления глобальным реестром пользователей сервиса и их данными через различные каналы коммуникации

356 lines (304 loc) 15.2 kB
const ydb_serverless = require("@dieugene/ydb-serverless"); const I = require("@dieugene/utils"); String.prototype.format = function() { let formatted = this; for (let i = 0; i < arguments.length; i++) { let regexp = new RegExp('\\{'+i+'\\}', 'gi'); formatted = formatted.replace(regexp, arguments[i]+''); } return formatted; }; function init(users_database = process.env.GLOBAL_DB_ADDRESS) { UsersGlobal.init(users_database); UserData.init(users_database); } const { v4: uuidv4 } = require('uuid'); console.log('USERS-CONTROLLER MODULE EXECUTION', uuidv4()); /* Модуль работы с реестром пользователей */ const UsersGlobal = (function () { let ydb; function init(users_database = process.env.GLOBAL_DB_ADDRESS) { if (!ydb) ydb = ydb_serverless.init(users_database); } async function getUserUuid(channelType = '', inChannelId = '') { if (!ydb) return console.log('⛔ Users database is not initialized'); console.log('USER CONTROLLER :: GETTING USER UUID :: channelType -', channelType, ' :: inChannelId -', inChannelId); let query = ` DECLARE $channel_type as Utf8; DECLARE $in_channel_id as Utf8; SELECT id FROM users_global VIEW channel_type_and_id_index AS ind WHERE ind.channel_type = $channel_type AND ind.in_channel_id = $in_channel_id`, result = await ydb.execute(query, { '$channel_type': channelType, '$in_channel_id': inChannelId + '' }); console.log('USER CONTROLLER :: GETTING USER UUID :: Result :: ', JSON.stringify(result)); return !!result.length ? result[0].id : undefined; } async function setUser(channelType = '', inChannelId = '') { if (!ydb) return console.log('⛔ Users database is not initialized'); let userUuid = uuidv4(), query = ` DECLARE $id as Utf8; DECLARE $channel_type as Utf8; DECLARE $in_channel_id as Utf8; DECLARE $global_user_id as Utf8; UPSERT INTO users_global (id, channel_type, in_channel_id, global_user_id, created_on) VALUES ($id, $channel_type, $in_channel_id, $global_user_id, CurrentUtcDatetime());`; await ydb.apply(query, { '$channel_type': channelType, '$in_channel_id': inChannelId + '', '$global_user_id': userUuid, '$id': userUuid }); return userUuid; } async function getUuidData(user_uuid) { let query = ` DECLARE $id as Utf8; SELECT channel_type, in_channel_id FROM users_global WHERE id = $id`, result = await ydb.execute(query, { "$id": user_uuid }); if (I.arr.isEmpty(result)) return null; else { let { channel_type: ct, in_channel_id } = result[0]; return { channel_type: ct.split("::")[0], channel_id: ct.split("::")[1], in_channel_id } } } async function setGlobalUserUuid(channelType = '', inChannelId = '', userUuid = '') { if (!ydb) return console.log('⛔ Users database is not initialized'); let query = ` DECLARE $channel_type as Utf8; DECLARE $in_channel_id as Utf8; DECLARE $global_user_id as Utf8; $channel_type = '{0}'; $in_channel_id = '{1}'; $global_user_id = '{2}'; UPDATE users_global SET global_user_id = $global_user_id WHERE channel_type = $channel_type AND in_channel_id = $in_channel_id;`.format(channelType, inChannelId, userUuid); await ydb.apply(query); } async function getChannelUsers(channelType = '') { // todo А если пользователей больше 1000. Нужно листать if (!ydb) return console.log('⛔ Users database is not initialized'); let query = ` DECLARE $channel_type as Utf8; SELECT id FROM users_global VIEW channel_type_index AS ind WHERE ind.channel_type = $channel_type`, result = await ydb.execute(query, { '$channel_type': channelType }); return result.map(r => r.id) } return { init, getUserUuid, getChannelUsers, setUser, getUuidData } })(); /* Модуль работы с пользовательскими данными */ const user_data_cache = new Map(); const UserData = (function () { const db = require("@dieugene/key-value-db"); let data_db; function init(database = process.env.GLOBAL_DB_ADDRESS) { if (!data_db) data_db = db.init("USERS_DATA", { database }); } function get_forced(forced = false) { return forced || I.env_log_has('USERS_DATA_CACHE_OFF') } async function set_user_data(user_uuid, data = {}) { if (!data_db) return console.log('⛔ Users data db is not initialized'); user_data_cache.set(user_uuid, data); return await data_db.set(user_uuid, data); } /** * * @param user_data_list * @param data_schema * @returns {Promise<void>} */ async function set_user_data_list(user_data_list = [], data_schema = {id: 'uuid', data: 'none'}) { let data_list = user_data_list.map(user_data => ({ id: user_data[data_schema.id], data: data_schema.data === 'none' ? user_data : user_data[data_schema.data] })); await data_db.set.bulk(data_list); } async function get_user_data(user_uuid, forced = false) { if (!data_db) return console.log('⛔ Users data db is not initialized'); if (!user_data_cache.has(user_uuid) || get_forced(forced)) { I.log_if('USERS-CONTROLLER', '[users-controller get_user_data forced reading]', 'current value:', JSON.stringify(user_data_cache.get(user_uuid))); user_data_cache.set(user_uuid, await data_db.get(user_uuid, {})); } return user_data_cache.get(user_uuid); } async function get_user_data_list(user_uuid_list = [], same_sorted = false) { if (!data_db) return console.log('⛔ Users data db is not initialized'); let result = await data_db.get.list(user_uuid_list); return same_sorted ? user_uuid_list.map(user_uuid => result.find(r => r.id === user_uuid)?.data) : result.map(r => r.data) } return { init, set_user_data, get_user_data, get_user_data_list, set_user_data_list } })(); /* Специальный модуль и таблица для пакетной обработки данных пользователей При инициализации создается таблица (если не создана) с одним полем - user_uuid Старт работы: - Получить весь список пользователей системы - записать их в таблицу - брать из таблицы данные пакетами - после обработки - удалять соответствующие данные Решение обусловлено ценовой оптимизацией операций, т.к. в данном случае будет большое количество операций как чтения, так и записи. Создание таблиц с индексированными полями будет приводить к росту затрат на запись. */ const UserProcessing = (function () { let ydb, table_name; async function init(domain = '', users_database = process.env.GLOBAL_DB_ADDRESS) { if (!!ydb) return; ydb = ydb_serverless.init(users_database); // todo Таки решить, что делать с такими процессинговыми таблицами. // Приводим domain к требуемому формату: нижний регистр, пробелы и дефисы заменяем на подчеркивания const sanitizedDomain = domain.toLowerCase().replace(/[\s-]/g, '_'); table_name = "users_processing_" + sanitizedDomain; /*let query = ` CREATE TABLE ${table_name} ( user_uuid Utf8, PRIMARY KEY (user_uuid) ) WITH ( AUTO_PARTITIONING_BY_SIZE = ENABLED, AUTO_PARTITIONING_BY_LOAD = DISABLED, AUTO_PARTITIONING_PARTITION_SIZE_MB = 2048, AUTO_PARTITIONING_MIN_PARTITIONS_COUNT = 1, KEY_BLOOM_FILTER = DISABLED );`; await ydb.apply(query)*/ } async function set(uuid_list = []) { if (!ydb) return console.log('⛔ Users processing database is not initialized'); if (uuid_list.length === 0) return console.log('⛔ No data to process'); let data = uuid_list.map(user_uuid => ({ user_uuid })); await ydb.upsert(table_name, data); } async function set_channel_users(channel = '') { if (!ydb) return console.log('⛔ Users processing database is not initialized'); let query = ` DECLARE $channel_type as Utf8; UPSERT INTO ${table_name} SELECT id AS user_uuid FROM users_global VIEW channel_type_index AS ind WHERE ind.channel_type = $channel_type`; await ydb.apply(query, { '$channel_type': channel }) } async function get(limit = 50) { if (!ydb) return console.log('⛔ Users processing database is not initialized'); let query = `SELECT * FROM ${table_name} LIMIT ` + limit, result = await ydb.execute(query); return result.length > 0 ? result.map(r => r.user_uuid) : [] } async function del(id_list = []) { if (!ydb) return console.log('⛔ Users processing database is not initialized'); let query = `DECLARE $id_list as List<Utf8>; $id_struc = ListMap($id_list, ($v)->{RETURN AsStruct($v as N)}); DELETE FROM ${table_name} ON SELECT user_uuid FROM ${table_name} as a INNER JOIN AS_TABLE($id_struc) as b ON a.user_uuid = b.N`; await ydb.apply(query, { unfold_arrays: true, '$id_list': id_list }); } return { init, set, set_channel_users, get, del } })(); /* Модуль работы с пользователями телеграм-ботов */ const TelegramUsers = (function () { async function getTelegramUserUuid(bot_id, user_id) { let channelType = I.hash('telegram', bot_id), uuid = await UsersGlobal.getUserUuid(channelType, user_id + ''); if (!uuid) uuid = await UsersGlobal.setUser(channelType, user_id + ''); return uuid; } async function get_channel_data(user_uuid) { return await UsersGlobal.getUuidData(user_uuid); } async function get_user_uuid(ctx_or_bot_id, tgId) { let bot_id = (typeof ctx_or_bot_id === 'object') ? I.tg.get.bot.id(ctx_or_bot_id) : ctx_or_bot_id; tgId = tgId ?? I.tg.get.user.id(ctx_or_bot_id); return !!tgId ? await getTelegramUserUuid(bot_id, tgId) : undefined; } function set_telegram_data(user_data, ctx) { let telegram_id = ctx.from.id; // Присваиваем/обновляем все данные из Telegram user_data.telegram_data = { telegram_id, first_name: ctx.from.first_name || null, last_name: ctx.from.last_name || null, username: ctx.from.username || null, language_code: ctx.from.language_code || null, is_bot: ctx.from.is_bot || false, is_premium: ctx.from.is_premium || false, updated_at: I.get_seconds_now() }; if (!user_data.telegram_id) user_data.telegram_id = telegram_id; return user_data } function stringify_user_data(userData) { let {first_name, last_name, username} = userData, result = [first_name, last_name].filter(s => !!s).join(' '); if (username) result += `(@${username})`; return result } async function get_bot_users(botId) { return await UsersGlobal.getChannelUsers(I.hash('telegram', botId.split(':')[0])); } async function start_user_processing(botId) { await UserProcessing.set_channel_users(I.hash('telegram', botId.split(':')[0])) } async function get_next_batch(limit = 50) { return await UserProcessing.get(limit) } return { stringify_user_data, get_user_uuid, get_bot_users, set_telegram_data, start_user_processing, get_next_batch, /** @deprecated */ getTgUserUuid: get_user_uuid } })(); module.exports = { init, globals: UsersGlobal, data: UserData, processing: UserProcessing, set_user_data: async (user_uuid, data = {}) => await UserData.set_user_data(user_uuid, data), get_user_data: async (user_uuid, forced = false) => await UserData.get_user_data(user_uuid, forced), get_user_data_list: async (user_data_list, same_sorted = false) => await UserData.get_user_data_list(user_data_list, same_sorted), set_user_data_list: async (user_data_list, data_schema = {id: 'uuid', data: 'none'}) => await UserData.set_user_data_list(user_data_list, data_schema), tg: TelegramUsers }; /* Пользователи могут взаимодействовать с системой через разные каналы, в которых у него свои идентификаторы и метаданные. Пользователю присваивается глобальный ID, и разные каналы коммуникаций связываются с этим ID. Особенность задачи в том, что при подключении каждого нового канала коммуникаций пользователь считается "новым", пока не будет выполнена связь с другими каналами. Когда пользователь выполняет привязку (через предоставление своего глобального ID), идентификатор "нового" канала переписывается На данном этапе работа с глобальным списком пользователей строится на основе двух хранилищ: - строковой таблицы, соединяющей идентификаторы канала (тип, идентификатор, глобальный идентификатор, дата регистрации) - key-value базы данных, хранящей объекты с различной пользовательской информацией Основные методы: - получили данные канала - вернуть идентификатор - получить данные пользователя по каналу - получить другие данные пользователя - назначить новый идентификатор */