@dieugene/users-controller
Version:
Модуль для управления глобальным реестром пользователей сервиса и их данными через различные каналы коммуникации
356 lines (304 loc) • 15.2 kB
JavaScript
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 базы данных, хранящей объекты с различной пользовательской информацией
Основные методы:
- получили данные канала - вернуть идентификатор
- получить данные пользователя по каналу
- получить другие данные пользователя
- назначить новый идентификатор
*/