Добавление всех наработок за период практики.

This commit is contained in:
SkyForces
2026-07-24 00:46:19 +03:00
parent 24943b8a73
commit ae4141ba28
56 changed files with 3987 additions and 136 deletions
+493
View File
@@ -0,0 +1,493 @@
-- Подключение расширения pgcrypto для генерации UUID.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS binding;
CREATE SCHEMA IF NOT EXISTS oauth;
CREATE TABLE IF NOT EXISTS binding.portals (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- member_id - уникальный идентификатор портала из Битрикса.
member_id text NOT NULL UNIQUE,
-- domain - домен портала, например: example.bitrix24.ru.
domain text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS binding.tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
bitrix_user_id bigint NOT NULL,
-- Хеш токена, генерируется сервером.
token_hash bytea NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS binding_tokens_owner_idx
ON binding.tokens (portal_id, bitrix_user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS binding.user_bindings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
bitrix_user_id bigint NOT NULL,
telegram_user_id bigint NOT NULL,
telegram_chat_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (portal_id, bitrix_user_id),
UNIQUE (portal_id, telegram_user_id)
);
CREATE TABLE IF NOT EXISTS oauth.user_credentials (
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
bitrix_user_id bigint NOT NULL,
access_token bytea NOT NULL,
refresh_token bytea NOT NULL,
expires_at timestamptz NOT NULL,
-- Двойной механизм защиты от гонок данных при обновлении токенов.
version bigint NOT NULL DEFAULT 1,
refresh_locked_until timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (portal_id, bitrix_user_id)
);
CREATE OR REPLACE FUNCTION binding.issue_v1(
p_member_id text,
p_domain text,
p_bitrix_user_id bigint,
p_token_hash bytea,
p_token_expires_at timestamptz,
p_access_token bytea,
p_refresh_token bytea,
p_oauth_expires_at timestamptz
)
RETURNS TABLE(token_id uuid, expires_at timestamptz)
LANGUAGE plpgsql
-- Задаем SECURITY DEFINER, чтобы функция выполнялась с правами владельца
-- схемы binding.
SECURITY DEFINER
-- Устанавливаем search_path в pg_catalog, чтобы нельзя было подменить
-- функции в схеме binding или oauth.
SET search_path = pg_catalog
AS $function$
#variable_conflict error
DECLARE
v_portal_id bigint;
v_token_id uuid;
BEGIN
-- Добавляем данные о портале, если его ещё нет,
-- или обновляем домен, если портал уже существует.
INSERT INTO binding.portals(member_id, domain)
VALUES (p_member_id, lower(p_domain))
ON CONFLICT (member_id) DO UPDATE
SET domain = EXCLUDED.domain,
updated_at = now()
RETURNING id INTO v_portal_id;
-- Сохраняем OAuth-данные пользователя, если их ещё нет,
-- или обновляем их, если они уже существуют.
INSERT INTO oauth.user_credentials(
portal_id,
bitrix_user_id,
access_token,
refresh_token,
expires_at
)
VALUES (
v_portal_id,
p_bitrix_user_id,
p_access_token,
p_refresh_token,
p_oauth_expires_at
)
ON CONFLICT (portal_id, bitrix_user_id) DO UPDATE
SET access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
expires_at = EXCLUDED.expires_at,
version = oauth.user_credentials.version + 1,
refresh_locked_until = NULL,
updated_at = now();
-- Отзываем все предыдущие неиспользованные токены пользователя.
UPDATE binding.tokens
SET revoked_at = now()
WHERE portal_id = v_portal_id
AND bitrix_user_id = p_bitrix_user_id
AND consumed_at IS NULL
AND revoked_at IS NULL;
-- Выпускаем новый токен привязки.
INSERT INTO binding.tokens(
portal_id,
bitrix_user_id,
token_hash,
expires_at
)
VALUES (
v_portal_id,
p_bitrix_user_id,
p_token_hash,
p_token_expires_at
)
RETURNING id INTO v_token_id;
-- Возвращаем идентификатор токена и срок его действия.
RETURN QUERY SELECT v_token_id, p_token_expires_at;
END
$function$;
COMMENT ON FUNCTION binding.issue_v1(
text,
text,
bigint,
bytea,
timestamptz,
bytea,
bytea,
timestamptz
)
IS $doc$
Сохраняет OAuth-данные пользователя и выпускает токен привязки.
Гарантии:
- предыдущие неиспользованные токены отзываются;
- при обновлении OAuth-данных увеличивается их версия.
Возвращает:
- token_id - идентификатор токена;
- expires_at - срок действия токена.
$doc$;
CREATE OR REPLACE FUNCTION binding.consume_v1(
p_token_hash bytea,
p_telegram_user_id bigint,
p_telegram_chat_id bigint
)
RETURNS TABLE(
member_id text,
domain text,
bitrix_user_id bigint,
telegram_user_id bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
#variable_conflict error
DECLARE
v_portal_id bigint;
v_bitrix_user_id bigint;
BEGIN
-- UPDATE не позволит двум запросам погасить один токен.
UPDATE binding.tokens AS token
SET consumed_at = now()
WHERE token.token_hash = p_token_hash
AND token.consumed_at IS NULL
AND token.revoked_at IS NULL
AND token.expires_at > now()
RETURNING token.portal_id, token.bitrix_user_id
INTO v_portal_id, v_bitrix_user_id;
-- Если UPDATE не вернул ни одной строки, значит токен недействителен.
IF NOT FOUND THEN
RETURN;
END IF;
-- Удаляем все привязки к Telegram для данного портала и пользователя Битрикса,
-- кроме той, которая соответствует текущему пользователю Битрикса.
DELETE FROM binding.user_bindings AS user_binding
WHERE user_binding.portal_id = v_portal_id
AND user_binding.telegram_user_id = p_telegram_user_id
AND user_binding.bitrix_user_id <> v_bitrix_user_id;
-- Добавляем или обновляем привязку к Telegram для данного портала
-- и пользователя Битрикса.
INSERT INTO binding.user_bindings(
portal_id,
bitrix_user_id,
telegram_user_id,
telegram_chat_id
)
VALUES (
v_portal_id,
v_bitrix_user_id,
p_telegram_user_id,
p_telegram_chat_id
)
ON CONFLICT ON CONSTRAINT user_bindings_pkey DO UPDATE
SET telegram_user_id = EXCLUDED.telegram_user_id,
telegram_chat_id = EXCLUDED.telegram_chat_id,
updated_at = now();
RETURN QUERY
SELECT
portal.member_id,
portal.domain,
v_bitrix_user_id,
p_telegram_user_id
FROM binding.portals AS portal
WHERE portal.id = v_portal_id;
END
$function$;
COMMENT ON FUNCTION binding.consume_v1(
bytea,
bigint,
bigint
)
IS $doc$
Погашает токен привязки и сохраняет привязку к Telegram.
Гарантии:
- токен погашается только один раз;
- если токен недействителен, функция возвращает пустой результат;
- если токен действителен, функция возвращает данные портала и пользователя
Битрикса, а также сохраняет привязку к Telegram;
- если пользователь Битрикса уже был привязан к другому пользователю Telegram,
старая привязка удаляется.
Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- telegram_user_id - идентификатор пользователя Telegram.
$doc$;
CREATE OR REPLACE FUNCTION binding.find_by_telegram_v1(
p_telegram_user_id bigint,
p_member_id text DEFAULT NULL
)
RETURNS TABLE(
member_id text,
domain text,
bitrix_user_id bigint,
telegram_user_id bigint
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
SELECT
portal.member_id,
portal.domain,
user_binding.bitrix_user_id,
user_binding.telegram_user_id
FROM binding.user_bindings AS user_binding
JOIN binding.portals AS portal ON portal.id = user_binding.portal_id
WHERE user_binding.telegram_user_id = p_telegram_user_id
AND (p_member_id IS NULL OR portal.member_id = p_member_id)
ORDER BY user_binding.updated_at DESC
LIMIT 1
$function$;
COMMENT ON FUNCTION binding.find_by_telegram_v1(
bigint,
text
)
IS $doc$
Находит привязку к Telegram по идентификатору пользователя Telegram.
Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- telegram_user_id - идентификатор пользователя Telegram.
Если p_member_id не NULL, то поиск ограничивается указанным порталом.
$doc$;
CREATE OR REPLACE FUNCTION oauth.get_credentials_v1(
p_member_id text,
p_bitrix_user_id bigint
)
RETURNS TABLE(
member_id text,
domain text,
bitrix_user_id bigint,
access_token bytea,
refresh_token bytea,
expires_at timestamptz,
version bigint
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
SELECT
portal.member_id,
portal.domain,
credentials.bitrix_user_id,
credentials.access_token,
credentials.refresh_token,
credentials.expires_at,
credentials.version
FROM oauth.user_credentials AS credentials
JOIN binding.portals AS portal ON portal.id = credentials.portal_id
WHERE portal.member_id = p_member_id
AND credentials.bitrix_user_id = p_bitrix_user_id
$function$;
COMMENT ON FUNCTION oauth.get_credentials_v1(
text,
bigint
)
IS $doc$
Находит OAuth-данные пользователя по идентификатору портала и идентификатору
пользователя Битрикса.
Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- access_token - токен доступа;
- refresh_token - токен обновления;
- expires_at - срок действия токена доступа;
- version - версия данных.
$doc$;
CREATE OR REPLACE FUNCTION oauth.claim_refresh_v1(
p_member_id text,
p_bitrix_user_id bigint,
p_version bigint
)
RETURNS boolean
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
-- Создаем временную таблицу claimed,
-- которая будет содержать результат обновления (CTE).
WITH claimed AS (
UPDATE oauth.user_credentials AS credentials
SET refresh_locked_until = now() + interval '30 seconds'
FROM binding.portals AS portal
WHERE credentials.portal_id = portal.id
AND portal.member_id = p_member_id
AND credentials.bitrix_user_id = p_bitrix_user_id
AND credentials.version = p_version
AND (
credentials.refresh_locked_until IS NULL
OR credentials.refresh_locked_until < now()
)
RETURNING 1
)
SELECT EXISTS(SELECT 1 FROM claimed)
$function$;
COMMENT ON FUNCTION oauth.claim_refresh_v1(
text,
bigint,
bigint
)
IS $doc$
Пытается захватить токен обновления для пользователя.
Возвращает:
- true, если захват успешен;
- false, если захват не удался.
$doc$;
CREATE OR REPLACE FUNCTION oauth.finish_refresh_v1(
p_member_id text,
p_bitrix_user_id bigint,
p_version bigint,
p_access_token bytea,
p_refresh_token bytea,
p_expires_at timestamptz
)
RETURNS boolean
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
WITH updated AS (
UPDATE oauth.user_credentials AS credentials
SET access_token = p_access_token,
refresh_token = p_refresh_token,
expires_at = p_expires_at,
version = credentials.version + 1,
refresh_locked_until = NULL,
updated_at = now()
FROM binding.portals AS portal
WHERE credentials.portal_id = portal.id
AND portal.member_id = p_member_id
AND credentials.bitrix_user_id = p_bitrix_user_id
AND credentials.version = p_version
RETURNING 1
)
SELECT EXISTS(SELECT 1 FROM updated)
$function$;
COMMENT ON FUNCTION oauth.finish_refresh_v1(
text,
bigint,
bigint,
bytea,
bytea,
timestamptz
) IS $doc$
Завершает процесс обновления токена для пользователя.
Возвращает:
- true, если обновление успешно завершено;
- false, если обновление не удалось.
$doc$;
CREATE OR REPLACE FUNCTION oauth.release_refresh_v1(
p_member_id text,
p_bitrix_user_id bigint,
p_version bigint
)
RETURNS void
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
UPDATE oauth.user_credentials AS credentials
SET refresh_locked_until = NULL
FROM binding.portals AS portal
WHERE credentials.portal_id = portal.id
AND portal.member_id = p_member_id
AND credentials.bitrix_user_id = p_bitrix_user_id
AND credentials.version = p_version
$function$;
COMMENT ON FUNCTION oauth.release_refresh_v1(
text,
bigint,
bigint
) IS $doc$
Освобождает токен обновления для пользователя.
Возвращает:
- void.
$doc$;
-- Отзываем все права у PUBLIC.
REVOKE ALL ON ALL TABLES IN SCHEMA binding, oauth FROM PUBLIC;
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA binding, oauth FROM PUBLIC;
-- Даем права на использование схемы и выполнение функций ролям site_role
-- и bot_role.
GRANT USAGE ON SCHEMA binding TO site_role, bot_role;
GRANT USAGE ON SCHEMA oauth TO bot_role;
-- Даем права на выполнение функций ролям site_role и bot_role.
GRANT EXECUTE ON FUNCTION binding.issue_v1(
text, text, bigint, bytea, timestamptz, bytea, bytea, timestamptz
) TO site_role;
GRANT EXECUTE ON FUNCTION binding.consume_v1(bytea, bigint, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION binding.find_by_telegram_v1(bigint, text) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.get_credentials_v1(text, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.claim_refresh_v1(text, bigint, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.finish_refresh_v1(
text, bigint, bigint, bytea, bytea, timestamptz
) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.release_refresh_v1(text, bigint, bigint)
TO bot_role;