Add handoff task document for PWA, chats, and connections
This commit is contained in:
parent
cf5460c5c7
commit
bc8f4a0582
52
DOC/api/PWA_FCM_SETUP.md
Normal file
52
DOC/api/PWA_FCM_SETUP.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Настройка PWA + FCM для веб-клиента (Chrome/Edge/Firefox/Safari iOS)
|
||||
|
||||
## 1) Что нужно создать в Firebase
|
||||
1. Создать проект Firebase.
|
||||
2. Включить Cloud Messaging.
|
||||
3. Создать Web App и получить конфиг:
|
||||
- apiKey
|
||||
- authDomain
|
||||
- projectId
|
||||
- messagingSenderId
|
||||
- appId
|
||||
4. В Cloud Messaging -> Web Push certificates сгенерировать VAPID key.
|
||||
5. Для серверной отправки взять **Server key** (legacy) или настроить HTTP v1 (service account).
|
||||
|
||||
## 2) Куда вставить токены в клиенте
|
||||
Файл: `shine-UI/index.html` и `shine-UI/firebase-messaging-sw.js`.
|
||||
|
||||
Заполнить:
|
||||
- `window.__SHINE_FIREBASE_CONFIG__`
|
||||
- `window.__SHINE_FIREBASE_VAPID_KEY__`
|
||||
- `FIREBASE_CONFIG` (в service worker)
|
||||
|
||||
## 3) Куда вставить серверный ключ FCM
|
||||
Файл: `src/main/resources/application.properties`
|
||||
|
||||
Добавить:
|
||||
```
|
||||
fcm.server.key=YOUR_FCM_SERVER_KEY
|
||||
```
|
||||
|
||||
## 4) PWA требования
|
||||
1. Открывать сайт только по HTTPS (или localhost).
|
||||
2. Разрешить уведомления в браузере.
|
||||
3. Убедиться, что `manifest.webmanifest` доступен.
|
||||
4. Убедиться, что `firebase-messaging-sw.js` зарегистрирован.
|
||||
|
||||
## 5) Safari / iPhone (iOS)
|
||||
- Нужен iOS 16.4+.
|
||||
- Пользователь должен добавить сайт на Home Screen.
|
||||
- После запуска PWA с Home Screen дать разрешение на уведомления.
|
||||
- Без Home Screen web push в Safari iOS не работает.
|
||||
|
||||
## 6) Проверка
|
||||
1. Логин в приложении.
|
||||
2. Клиент вызывает `UpsertPushToken` и отправляет FCM токен на сервер.
|
||||
3. Вызов `SendDirectMessage` пользователю без активной WS доставки.
|
||||
4. Сервер шлет push через FCM.
|
||||
|
||||
## 7) Поддержка разных браузеров
|
||||
- Chrome/Edge/Opera/Android Browser: FCM web push поддерживается нативно.
|
||||
- Firefox: поддержка web push есть, но тестировать отдельно (поведение токенов отличается).
|
||||
- Safari macOS/iOS: web push есть, но требуется PWA режим и Apple-ограничения.
|
||||
181
TASKS/CHAT_PWA_HANDOFF.md
Normal file
181
TASKS/CHAT_PWA_HANDOFF.md
Normal file
@ -0,0 +1,181 @@
|
||||
# Анонс для исполнителя (кратко)
|
||||
|
||||
Ниже описан текущий статус ветки по функциям:
|
||||
- PWA + FCM уведомления
|
||||
- личные сообщения (вторая слева вкладка, "Личные сообщения")
|
||||
- связи / близкие друзья (центральная вкладка)
|
||||
- server-push события (в том числе закрытие сессии)
|
||||
|
||||
## Что нужно проверить и довести до рабочего состояния
|
||||
1. **PWA/FCM**: регистрация service worker, получение FCM token, отправка token на сервер, получение foreground/background уведомлений.
|
||||
2. **Личные сообщения**: отправка первого сообщения любому пользователю, входящие по WS, ACK, fallback в FCM, дедупликация на клиенте.
|
||||
3. **Связи / близкие друзья**: поиск пользователя, добавление в близкие, обновление графа связей сразу после добавления.
|
||||
4. **SessionRevoked**: если сессию закрыли с другого устройства, клиент должен корректно завершить локальную сессию и показать понятное состояние.
|
||||
|
||||
## Главная цель handoff
|
||||
Сделать так, чтобы все описанные сценарии стабильно работали end-to-end без ручных "подпинок".
|
||||
|
||||
---
|
||||
|
||||
# Подробное ТЗ и технические детали
|
||||
|
||||
## 1) Архитектура протокола и общие принципы
|
||||
|
||||
### 1.1 Формат обмена
|
||||
Используется JSON-over-WebSocket.
|
||||
- Для запрос-ответ: `op + requestId + payload`
|
||||
- Для server-push событий: те же поля, но `event=true`, `requestId` используется как eventId.
|
||||
|
||||
### 1.2 ID сообщений/событий
|
||||
ID генерируются в формате:
|
||||
- `prefix-yyyyMMdd-HHmmss-SSS-random10`
|
||||
|
||||
Реализация: `NetIdGenerator`.
|
||||
|
||||
### 1.3 Роли каналов доставки
|
||||
- **WS** — приоритетная доставка в активные сессии.
|
||||
- **FCM** — fallback, если ACK по WS не получен или WS-сессия отсутствует.
|
||||
|
||||
---
|
||||
|
||||
## 2) PWA + FCM
|
||||
|
||||
## 2.1 Что уже добавлено
|
||||
### Клиент
|
||||
- `shine-UI/manifest.webmanifest`
|
||||
- `shine-UI/firebase-messaging-sw.js`
|
||||
- `shine-UI/js/services/pwa-push-service.js`
|
||||
- `shine-UI/index.html` содержит placeholders:
|
||||
- `window.__SHINE_FIREBASE_CONFIG__`
|
||||
- `window.__SHINE_FIREBASE_VAPID_KEY__`
|
||||
|
||||
### Сервер
|
||||
- API `UpsertPushToken`
|
||||
- Таблица `user_push_tokens`
|
||||
- Серверный FCM sender (legacy HTTP): `FcmPushSender`
|
||||
- Конфиг: `fcm.server.key` в `application.properties`
|
||||
|
||||
## 2.2 Как должно работать (целевое поведение)
|
||||
1. Клиент после авторизации регистрирует SW.
|
||||
2. Просит permission на notifications.
|
||||
3. Получает FCM token.
|
||||
4. Если token новый/изменился — отправляет `UpsertPushToken`.
|
||||
5. Сервер сохраняет token за конкретной сессией.
|
||||
|
||||
## 2.3 Что проверить/доделать
|
||||
- Синхронизация Firebase config между `index.html` и `firebase-messaging-sw.js`.
|
||||
- Работа iOS Safari (PWA через Home Screen).
|
||||
- Поведение при смене token.
|
||||
- Надёжность `FcmPushSender` (таймауты, логирование ошибок ответа).
|
||||
|
||||
---
|
||||
|
||||
## 3) Вторая слева вкладка: Личные сообщения / Чаты
|
||||
|
||||
## 3.1 Продуктовая логика (как должно быть)
|
||||
1. Открытие списка диалогов на вкладке "Личные сообщения".
|
||||
2. Кнопка `+` открывает поиск пользователя по префиксу логина (`SearchUsers`).
|
||||
3. **Первое сообщение можно отправить любому пользователю**, даже если он не контакт.
|
||||
4. Если пользователь не в контактах — в чате показывается действие "Добавить в контакты".
|
||||
5. Входящее сообщение может прийти:
|
||||
- напрямую по WS (`IncomingDirectMessage`)
|
||||
- через FCM (fallback)
|
||||
6. На клиенте дедупликация должна быть по `messageId`.
|
||||
|
||||
## 3.2 Что уже сделано технически
|
||||
### Сервер
|
||||
- `SendDirectMessage`
|
||||
- `AckIncomingMessage`
|
||||
- `direct_messages` таблица + DAO
|
||||
- доставка по активным WS-сессиям + ожидание ACK + fallback в FCM
|
||||
|
||||
### Клиент
|
||||
- `authService.sendDirectMessage(...)`
|
||||
- `authService.ackIncomingMessage(...)`
|
||||
- WS client поддерживает server events (`onEvent`)
|
||||
- В `app.js` обработка `IncomingDirectMessage`
|
||||
- В `state.js` добавлены `incomingDedup`, `addIncomingMessage`
|
||||
|
||||
## 3.3 Что глючит/что проверить
|
||||
- Стабильность отображения новых чатов, если сообщение пришло от неизвестного пользователя.
|
||||
- Согласованность списка диалогов и фактических сообщений.
|
||||
- Поведение при быстрых дубликатах WS + FCM.
|
||||
- Поведение при сетевых обрывах/повторном коннекте.
|
||||
|
||||
---
|
||||
|
||||
## 4) Центральная вкладка: Связи / Близкие друзья
|
||||
|
||||
## 4.1 Целевое поведение
|
||||
1. Экран "Связи" загружает граф друзей (`GetUserConnectionsGraph`).
|
||||
2. Кнопка "Добавить близкого друга" открывает модалку:
|
||||
- поле логина/префикса
|
||||
- кнопка "Поиск"
|
||||
- кнопка "Назад"
|
||||
3. Поиск идёт через `SearchUsers`.
|
||||
4. По клику на найденного — подтверждение "Добавить? Да/Нет".
|
||||
5. При "Да" вызывается `AddCloseFriend`, после успеха:
|
||||
- закрыть модалку
|
||||
- вернуться на экран связей
|
||||
- обновить граф.
|
||||
|
||||
## 4.2 Что уже сделано
|
||||
- UI-flow модалки реализован на `network-view.js`.
|
||||
- API `AddCloseFriend` добавлен на сервере.
|
||||
- `ConnectionsStateDAO.upsertRelation(...)` добавлен для upsert связи FRIEND.
|
||||
|
||||
## 4.3 Что проверить/доделать
|
||||
- Валидация edge-cases (добавление самого себя, несуществующий логин).
|
||||
- Корректность отрисовки графа после добавления.
|
||||
- Согласованность данных с будущей "настоящей" записью в blockchain (сейчас MVP upsert в `connections_state`).
|
||||
|
||||
---
|
||||
|
||||
## 5) SessionRevoked и мультисессии
|
||||
|
||||
## 5.1 Целевое поведение
|
||||
Если с другой сессии закрыли текущую сессию:
|
||||
1. сервер шлёт событие `SessionRevoked`
|
||||
2. клиент чистит локальную авторизацию
|
||||
3. клиент возвращается в состояние неавторизованного входа.
|
||||
|
||||
## 5.2 Что проверить
|
||||
- Что событие приходит до закрытия socket.
|
||||
- Что UX не "зависает" на промежуточном экране.
|
||||
|
||||
---
|
||||
|
||||
## 6) API-операции (быстрый список)
|
||||
|
||||
### Уже используемые/добавленные в ветке
|
||||
- `SearchUsers`
|
||||
- `ListContacts`
|
||||
- `GetUserConnectionsGraph`
|
||||
- `AddCloseFriend`
|
||||
- `UpsertPushToken`
|
||||
- `SendDirectMessage`
|
||||
- `AckIncomingMessage`
|
||||
- `CloseActiveSession` (с server event `SessionRevoked`)
|
||||
|
||||
---
|
||||
|
||||
## 7) Минимальный чек-лист тестирования для нового исполнителя
|
||||
|
||||
1. Авторизация пользователя A и B в разных браузерах/устройствах.
|
||||
2. A отправляет первое сообщение B (без контактов) — должно уйти.
|
||||
3. B получает по WS, отправляется ACK, fallback FCM не должен дублировать.
|
||||
4. Выключить WS у B и проверить fallback FCM.
|
||||
5. Вкладка "Связи": добавить близкого друга через поиск, проверить обновление графа.
|
||||
6. Закрыть сессию B с другого устройства и проверить `SessionRevoked` UX.
|
||||
7. Перезапуск клиента: проверка повторной регистрации push-токена только при изменении.
|
||||
|
||||
---
|
||||
|
||||
## 8) Важное ограничение текущей реализации
|
||||
|
||||
Некоторые части реализованы как MVP и требуют стабилизации:
|
||||
- местами UI/состояние могут рассинхронизироваться;
|
||||
- fallback WS->FCM может требовать донастройки таймингов и ретраев;
|
||||
- `AddCloseFriend` сейчас пишет прямое состояние связи (upsert), а не полный blockchain-поток создания блоков.
|
||||
|
||||
Это ожидаемо для handoff: задача следующего исполнителя — довести до production-стабильности.
|
||||
30
shine-UI/firebase-messaging-sw.js
Normal file
30
shine-UI/firebase-messaging-sw.js
Normal file
@ -0,0 +1,30 @@
|
||||
/* global importScripts, firebase */
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.12.2/firebase-messaging-compat.js');
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||
|
||||
// Заполните теми же значениями, что и в shine-UI/index.html
|
||||
const FIREBASE_CONFIG = {
|
||||
apiKey: '',
|
||||
authDomain: '',
|
||||
projectId: '',
|
||||
messagingSenderId: '',
|
||||
appId: '',
|
||||
};
|
||||
|
||||
if (FIREBASE_CONFIG.apiKey && firebase && firebase.messaging) {
|
||||
if (!firebase.apps.length) {
|
||||
firebase.initializeApp(FIREBASE_CONFIG);
|
||||
}
|
||||
const messaging = firebase.messaging();
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
const title = payload?.notification?.title || 'Новое сообщение';
|
||||
const options = {
|
||||
body: payload?.notification?.body || '',
|
||||
data: payload?.data || {},
|
||||
};
|
||||
self.registration.showNotification(title, options);
|
||||
});
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<title>Shine UI Demo</title>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=20260403081123" />
|
||||
<link rel="stylesheet" href="./styles/layout.css?v=20260403081123" />
|
||||
@ -11,10 +12,21 @@
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<main id="app-screen" class="screen-content"></main>
|
||||
<div id="page-label-slot" class="page-label-slot"></div>
|
||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js"></script>
|
||||
<script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-messaging-compat.js"></script>
|
||||
<script>
|
||||
window.__SHINE_FIREBASE_CONFIG__ = {
|
||||
apiKey: '',
|
||||
authDomain: '',
|
||||
projectId: '',
|
||||
messagingSenderId: '',
|
||||
appId: ''
|
||||
};
|
||||
window.__SHINE_FIREBASE_VAPID_KEY__ = '';
|
||||
</script>
|
||||
<script type="module" src="./js/app.js?v=20260403081123"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260403081123';
|
||||
import { renderToolbar } from './components/toolbar.js?v=20260403081123';
|
||||
import { renderPageLabel } from './components/page-label.js?v=20260403081123';
|
||||
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js?v=20260403081123';
|
||||
import { initPwaPush } from './services/pwa-push-service.js?v=20260403081123';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@ -10,7 +10,8 @@ import {
|
||||
setSessionResetHandler,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
togglePageLabel,
|
||||
addIncomingMessage,
|
||||
setContacts,
|
||||
} from './state.js?v=20260403081123';
|
||||
|
||||
import * as startView from './pages/start-view.js?v=20260403081123';
|
||||
@ -77,7 +78,6 @@ const routes = {
|
||||
};
|
||||
|
||||
const screenEl = document.getElementById('app-screen');
|
||||
const labelEl = document.getElementById('page-label-slot');
|
||||
const toolbarEl = document.getElementById('toolbar-slot');
|
||||
|
||||
let currentCleanup = null;
|
||||
@ -140,16 +140,9 @@ function renderApp() {
|
||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||
|
||||
labelEl.innerHTML = '';
|
||||
toolbarEl.innerHTML = '';
|
||||
|
||||
if (showAppChrome) {
|
||||
labelEl.append(
|
||||
renderPageLabel(page.pageMeta.title, page.pageMeta.id, state.pageLabelCollapsed, () => {
|
||||
togglePageLabel();
|
||||
renderApp();
|
||||
}),
|
||||
);
|
||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
}
|
||||
@ -161,6 +154,10 @@ async function tryAutoLogin() {
|
||||
const resumed = await authService.resumeSession(state.session.login, state.session.sessionId);
|
||||
authorizeSession(resumed);
|
||||
await refreshSessions();
|
||||
try {
|
||||
const contacts = await authService.listContacts();
|
||||
setContacts(contacts.contacts || []);
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
@ -174,7 +171,30 @@ async function init() {
|
||||
setSessionResetHandler(() => {
|
||||
navigate('start-view');
|
||||
});
|
||||
|
||||
authService.onEvent('SessionRevoked', async () => {
|
||||
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
|
||||
});
|
||||
|
||||
authService.onEvent('IncomingDirectMessage', async (evt) => {
|
||||
const payload = evt?.payload || {};
|
||||
const fromLogin = payload.fromLogin || 'unknown';
|
||||
const messageId = payload.messageId || '';
|
||||
const eventId = payload.eventId || evt?.requestId || '';
|
||||
const added = addIncomingMessage(fromLogin, payload.text || '', messageId);
|
||||
if (added && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification(`Сообщение от ${fromLogin}`, { body: payload.text || '' });
|
||||
} catch {}
|
||||
}
|
||||
if (eventId) {
|
||||
try { await authService.ackIncomingMessage(eventId, messageId); } catch {}
|
||||
}
|
||||
});
|
||||
await tryAutoLogin();
|
||||
if (state.session.isAuthorized) {
|
||||
await initPwaPush({ authService });
|
||||
}
|
||||
|
||||
if (!window.location.hash) {
|
||||
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||
import { directMessages } from '../mock-data.js?v=20260403081123';
|
||||
import { addChatMessage, getChatMessages } from '../state.js?v=20260403081123';
|
||||
import { addChatMessage, getChatMessages, authService, state } from '../state.js?v=20260403081123';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
|
||||
@ -18,7 +18,11 @@ function renderLog(list, chatId) {
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const chatId = route.params.chatId || 'u1';
|
||||
const contact = directMessages.find((d) => d.id === chatId) || directMessages[0];
|
||||
const contact = directMessages.find((d) => d.id === chatId) || {
|
||||
id: chatId,
|
||||
name: chatId,
|
||||
initials: (chatId[0] || '?').toUpperCase(),
|
||||
};
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
@ -30,6 +34,23 @@ export function render({ navigate, route }) {
|
||||
})
|
||||
);
|
||||
|
||||
const isContact = state.contacts.includes(chatId);
|
||||
if (!isContact) {
|
||||
const warning = document.createElement('div');
|
||||
warning.className = 'card stack';
|
||||
warning.innerHTML = '<p class="meta-muted">Пользователь не в контактах. Можно писать ему сразу (MVP).</p>';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'primary-btn';
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Добавить в контакты';
|
||||
btn.addEventListener('click', () => {
|
||||
state.contacts = [...state.contacts, chatId];
|
||||
warning.remove();
|
||||
});
|
||||
warning.append(btn);
|
||||
screen.append(warning);
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap';
|
||||
|
||||
@ -43,12 +64,22 @@ export function render({ navigate, route }) {
|
||||
<button class="primary-btn" type="submit">Отправить</button>
|
||||
`;
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const input = form.elements.message;
|
||||
addChatMessage(chatId, input.value);
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
addChatMessage(chatId, text);
|
||||
input.value = '';
|
||||
renderLog(log, chatId);
|
||||
|
||||
try {
|
||||
await authService.sendDirectMessage(chatId, text);
|
||||
} catch (e) {
|
||||
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
|
||||
renderLog(log, chatId);
|
||||
}
|
||||
});
|
||||
|
||||
renderLog(log, chatId);
|
||||
|
||||
@ -1,18 +1,9 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||
import { contactDirectory, directMessages } from '../mock-data.js?v=20260403081123';
|
||||
import { ensureChat } from '../state.js?v=20260403081123';
|
||||
import { directMessages } from '../mock-data.js?v=20260403081123';
|
||||
import { authService, ensureChat, setContacts, state } from '../state.js?v=20260403081123';
|
||||
|
||||
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
|
||||
|
||||
function getMatches(query) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return [];
|
||||
|
||||
return contactDirectory
|
||||
.filter((contact) => contact.name.toLowerCase().startsWith(normalized))
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
@ -21,7 +12,7 @@ export function render({ navigate }) {
|
||||
input.className = 'input';
|
||||
input.type = 'text';
|
||||
input.name = 'contact';
|
||||
input.placeholder = 'Введите имя контакта';
|
||||
input.placeholder = 'Введите начало логина';
|
||||
input.autocomplete = 'off';
|
||||
input.maxLength = 80;
|
||||
|
||||
@ -43,7 +34,7 @@ export function render({ navigate }) {
|
||||
resultsCard.hidden = false;
|
||||
|
||||
if (!query.trim()) {
|
||||
status.textContent = 'Введите первые буквы имени, чтобы найти контакт.';
|
||||
status.textContent = 'Введите начало логина пользователя.';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -54,16 +45,16 @@ export function render({ navigate }) {
|
||||
|
||||
status.textContent = `Найдено пользователей: ${matches.length}`;
|
||||
|
||||
matches.forEach((contact) => {
|
||||
matches.forEach((login) => {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${contact.initials}</div>
|
||||
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
|
||||
<div>
|
||||
<strong>${contact.name}</strong>
|
||||
<p class="meta-muted" style="margin-top:4px;">${contact.about}</p>
|
||||
<strong>${login}</strong>
|
||||
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
|
||||
</div>
|
||||
<div class="meta-muted">Контакт</div>
|
||||
<div class="meta-muted">Профиль</div>
|
||||
`;
|
||||
resultsList.append(row);
|
||||
});
|
||||
@ -72,38 +63,48 @@ export function render({ navigate }) {
|
||||
const searchButton = document.createElement('button');
|
||||
searchButton.className = 'primary-btn';
|
||||
searchButton.type = 'button';
|
||||
searchButton.textContent = 'Найти';
|
||||
searchButton.addEventListener('click', () => {
|
||||
renderResults(getMatches(input.value), input.value);
|
||||
searchButton.textContent = 'Поиск';
|
||||
searchButton.addEventListener('click', async () => {
|
||||
try {
|
||||
const logins = await authService.searchUsers(input.value.trim());
|
||||
renderResults(logins, input.value);
|
||||
} catch (e) {
|
||||
status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`;
|
||||
resultsCard.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'ghost-btn';
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Добавить';
|
||||
addButton.textContent = 'Открыть чат';
|
||||
addButton.addEventListener('click', () => {
|
||||
if (!latestMatches.length) {
|
||||
status.textContent = 'Сначала выполните поиск, чтобы добавить контакт.';
|
||||
status.textContent = 'Сначала выполните поиск.';
|
||||
resultsCard.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const contact = latestMatches[0];
|
||||
const exists = directMessages.some((item) => item.id === contact.id);
|
||||
const login = latestMatches[0];
|
||||
const exists = directMessages.some((item) => item.id === login);
|
||||
|
||||
if (!exists) {
|
||||
directMessages.unshift({
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
initials: contact.initials,
|
||||
lastMessage: 'Новый контакт добавлен. Можно начинать диалог.',
|
||||
id: login,
|
||||
name: login,
|
||||
initials: (login[0] || '?').toUpperCase(),
|
||||
lastMessage: 'Диалог создан. Пользователь пока не в контактах.',
|
||||
time: 'сейчас',
|
||||
unread: 0,
|
||||
});
|
||||
}
|
||||
|
||||
ensureChat(contact.id);
|
||||
navigate(`chat-view/${contact.id}`);
|
||||
if (!state.contacts.includes(login)) {
|
||||
setContacts([...state.contacts, login]);
|
||||
}
|
||||
|
||||
ensureChat(login);
|
||||
navigate(`chat-view/${login}`);
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
|
||||
@ -1,32 +1,71 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||
import { networkGraph } from '../mock-data.js?v=20260403081123';
|
||||
import { authService, state } from '../state.js?v=20260403081123';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
|
||||
function toPoint(v) {
|
||||
return `${v.x}%`;
|
||||
function makeNode(name, cls = '') {
|
||||
const n = document.createElement('div');
|
||||
n.className = `node ${cls}`.trim();
|
||||
n.innerHTML = `<div class="node-dot">${(name[0] || '?').toUpperCase()}</div><div class="node-label">${name}</div>`;
|
||||
return n;
|
||||
}
|
||||
|
||||
function showHelpModal() {
|
||||
function showAddCloseFriendModal({ onAdded }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="network-help-modal">
|
||||
<div class="modal" id="close-friend-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 style="font-size:18px;">Справка по схеме связей</h3>
|
||||
<p class="meta-muted">В центре находишься ты.</p>
|
||||
<p class="meta-muted">Рядом показаны друзья первого уровня.</p>
|
||||
<p class="meta-muted">Далее могут существовать друзья второго уровня.</p>
|
||||
<p class="meta-muted">При одном нажатии на узел можно показать его связи.</p>
|
||||
<p class="meta-muted">При двойном нажатии узел может переместиться в центр.</p>
|
||||
<p class="meta-muted">При долгом удержании может открываться меню действий.</p>
|
||||
<p class="meta-muted">Логика схемы строится на одном запросе связей пользователя, дальше дерево достраивается на его основе.</p>
|
||||
<button class="primary-btn" id="close-network-help">Понятно</button>
|
||||
<h3 style="font-size:18px;">Добавить близкого друга</h3>
|
||||
<input class="input" id="close-friend-query" placeholder="Логин или начало логина" maxlength="80" />
|
||||
<div class="row" style="gap:8px;">
|
||||
<button class="primary-btn" id="close-friend-search">Поиск</button>
|
||||
<button class="ghost-btn" id="close-friend-back">Назад</button>
|
||||
</div>
|
||||
<div class="stack" id="close-friend-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
root.querySelector('#close-network-help').addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#close-friend-back').addEventListener('click', close);
|
||||
|
||||
root.querySelector('#close-friend-search').addEventListener('click', async () => {
|
||||
const query = root.querySelector('#close-friend-query').value.trim();
|
||||
const holder = root.querySelector('#close-friend-results');
|
||||
holder.innerHTML = '<p class="meta-muted">Поиск...</p>';
|
||||
|
||||
try {
|
||||
const logins = await authService.searchUsers(query);
|
||||
holder.innerHTML = '';
|
||||
if (!logins.length) {
|
||||
holder.innerHTML = '<p class="meta-muted">Пользователи не найдены.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
logins.forEach((login) => {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
|
||||
<div><strong>${login}</strong><p class="meta-muted" style="margin-top:4px;">Пользователь</p></div>
|
||||
<div class="meta-muted">Добавить</div>
|
||||
`;
|
||||
row.addEventListener('click', async () => {
|
||||
const yes = window.confirm(`Добавить ${login} в близкие друзья?`);
|
||||
if (!yes) return;
|
||||
try {
|
||||
await authService.addCloseFriend(login);
|
||||
close();
|
||||
if (typeof onAdded === 'function') await onAdded();
|
||||
} catch (e) {
|
||||
window.alert(`Ошибка добавления: ${e.message || 'unknown'}`);
|
||||
}
|
||||
});
|
||||
holder.append(row);
|
||||
});
|
||||
} catch (e) {
|
||||
holder.innerHTML = `<p class="meta-muted">Ошибка поиска: ${e.message || 'unknown'}</p>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -34,44 +73,68 @@ export function render() {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const header = renderHeader({
|
||||
title: 'Связи',
|
||||
rightActions: [{ label: 'Справка', onClick: showHelpModal }],
|
||||
});
|
||||
|
||||
const board = document.createElement('div');
|
||||
board.className = 'network-board';
|
||||
|
||||
const lines = networkGraph.peers
|
||||
.map(
|
||||
(peer) =>
|
||||
`<line x1="${toPoint(networkGraph.center)}" y1="${networkGraph.center.y}%" x2="${peer.x}%" y2="${peer.y}%" stroke="rgba(125,170,255,0.55)" stroke-width="1.5"/>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
board.innerHTML = `<svg class="network-svg" viewBox="0 0 100 100" preserveAspectRatio="none">${lines}</svg>`;
|
||||
|
||||
const centerNode = document.createElement('div');
|
||||
centerNode.className = 'node center';
|
||||
centerNode.style.left = `${networkGraph.center.x}%`;
|
||||
centerNode.style.top = `${networkGraph.center.y}%`;
|
||||
centerNode.innerHTML = `<div class="node-dot">${networkGraph.center.initials}</div><div class="node-label">${networkGraph.center.name}</div>`;
|
||||
|
||||
board.append(centerNode);
|
||||
|
||||
networkGraph.peers.forEach((peer) => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'node';
|
||||
node.style.left = `${peer.x}%`;
|
||||
node.style.top = `${peer.y}%`;
|
||||
node.innerHTML = `<div class="node-dot">${peer.initials}</div><div class="node-label">${peer.name}</div>`;
|
||||
board.append(node);
|
||||
});
|
||||
board.style.height = 'calc(100dvh - 170px)';
|
||||
|
||||
const note = document.createElement('p');
|
||||
note.className = 'meta-muted';
|
||||
note.textContent = 'Схема статичная для демо, архитектура подготовлена под дальнейшую интерактивность.';
|
||||
note.textContent = 'Загрузка связей...';
|
||||
|
||||
screen.append(header, board, note);
|
||||
const load = async (centerLogin) => {
|
||||
try {
|
||||
const graph = await authService.getUserConnectionsGraph(centerLogin || state.session.login);
|
||||
board.innerHTML = '';
|
||||
const center = makeNode(graph.login || state.session.login, 'center');
|
||||
center.style.left = '50%';
|
||||
center.style.top = '50%';
|
||||
board.append(center);
|
||||
|
||||
const all = [...new Set([...(graph.outFriends || []), ...(graph.inFriends || [])])];
|
||||
const left = all.slice(0, Math.ceil(all.length / 2));
|
||||
const right = all.slice(Math.ceil(all.length / 2));
|
||||
|
||||
const mk = (name, side, idx, total) => {
|
||||
const node = makeNode(name);
|
||||
const y = 15 + ((idx + 1) * 70) / (Math.max(total, 1) + 1);
|
||||
node.style.left = side === 'left' ? '20%' : '80%';
|
||||
node.style.top = `${y}%`;
|
||||
node.addEventListener('click', () => load(name));
|
||||
board.append(node);
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', '50');
|
||||
line.setAttribute('y1', '50');
|
||||
line.setAttribute('x2', side === 'left' ? '20' : '80');
|
||||
line.setAttribute('y2', String(y));
|
||||
line.setAttribute('stroke', 'rgba(125,170,255,0.6)');
|
||||
line.setAttribute('stroke-width', '1.5');
|
||||
return line;
|
||||
};
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'network-svg');
|
||||
svg.setAttribute('viewBox', '0 0 100 100');
|
||||
svg.setAttribute('preserveAspectRatio', 'none');
|
||||
|
||||
left.forEach((name, i) => svg.append(mk(name, 'left', i, left.length)));
|
||||
right.forEach((name, i) => svg.append(mk(name, 'right', i, right.length)));
|
||||
board.prepend(svg);
|
||||
|
||||
note.textContent = 'Нажмите на узел, чтобы перестроить связи вокруг выбранного пользователя.';
|
||||
} catch (e) {
|
||||
note.textContent = `Ошибка загрузки связей: ${e.message || 'unknown'}`;
|
||||
}
|
||||
};
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'primary-btn';
|
||||
addBtn.type = 'button';
|
||||
addBtn.textContent = 'Добавить близкого друга';
|
||||
addBtn.addEventListener('click', () => showAddCloseFriendModal({ onAdded: () => load() }));
|
||||
|
||||
load();
|
||||
|
||||
screen.append(renderHeader({ title: 'Связи' }), addBtn, board, note);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||
import { profile } from '../mock-data.js?v=20260403081123';
|
||||
import { state } from '../state.js?v=20260403081123';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@ -40,7 +41,7 @@ export function render({ navigate }) {
|
||||
</div>
|
||||
<div>
|
||||
<h2 style="font-size:22px; margin-bottom:2px;">${profile.name}</h2>
|
||||
<p class="meta-muted">${profile.login}</p>
|
||||
<p class="meta-muted">${state.session.login || profile.login}</p>
|
||||
</div>
|
||||
<div class="stack" style="gap:8px;">
|
||||
<div class="card" style="padding:10px;"><span class="meta-muted">Телефон:</span> ${profile.phone}</div>
|
||||
|
||||
@ -235,6 +235,54 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
|
||||
onEvent(op, handler) {
|
||||
return this.ws.onEvent(op, handler);
|
||||
}
|
||||
|
||||
async upsertPushToken({ tokenId, token, provider = 'fcm', platform = 'web', userAgent = navigator.userAgent || '' }) {
|
||||
const response = await this.ws.request('UpsertPushToken', { tokenId, token, provider, platform, userAgent });
|
||||
if (response.status !== 200) throw opError('UpsertPushToken', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async sendDirectMessage(toLogin, text) {
|
||||
const response = await this.ws.request('SendDirectMessage', { toLogin, text });
|
||||
if (response.status !== 200) throw opError('SendDirectMessage', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async ackIncomingMessage(eventId, messageId) {
|
||||
const response = await this.ws.request('AckIncomingMessage', { eventId, messageId });
|
||||
if (response.status !== 200) throw opError('AckIncomingMessage', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async listContacts() {
|
||||
const response = await this.ws.request('ListContacts', {});
|
||||
if (response.status !== 200) throw opError('ListContacts', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
|
||||
async addCloseFriend(toLogin) {
|
||||
const response = await this.ws.request('AddCloseFriend', { toLogin });
|
||||
if (response.status !== 200) throw opError('AddCloseFriend', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getUserConnectionsGraph(login) {
|
||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async searchUsers(prefix) {
|
||||
const response = await this.ws.request('SearchUsers', { prefix });
|
||||
if (response.status !== 200) throw opError('SearchUsers', response);
|
||||
return response.payload?.logins || [];
|
||||
}
|
||||
|
||||
async reportClientError(details) {
|
||||
try {
|
||||
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
|
||||
|
||||
51
shine-UI/js/services/pwa-push-service.js
Normal file
51
shine-UI/js/services/pwa-push-service.js
Normal file
@ -0,0 +1,51 @@
|
||||
const LS_KEY = 'shine-ui-fcm-token-v1';
|
||||
|
||||
export async function initPwaPush({ authService }) {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
try {
|
||||
await navigator.serviceWorker.register('./firebase-messaging-sw.js');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.firebase || !window.firebase.messaging) return;
|
||||
|
||||
try {
|
||||
const config = window.__SHINE_FIREBASE_CONFIG__ || null;
|
||||
if (!config) return;
|
||||
if (!window.firebase.apps.length) {
|
||||
window.firebase.initializeApp(config);
|
||||
}
|
||||
const messaging = window.firebase.messaging();
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return;
|
||||
|
||||
const vapidKey = window.__SHINE_FIREBASE_VAPID_KEY__ || '';
|
||||
const token = await messaging.getToken({ vapidKey });
|
||||
if (!token) return;
|
||||
|
||||
const prev = localStorage.getItem(LS_KEY);
|
||||
if (prev === token) return;
|
||||
|
||||
localStorage.setItem(LS_KEY, token);
|
||||
const tokenId = `tok-${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
await authService.upsertPushToken({
|
||||
tokenId,
|
||||
token,
|
||||
provider: 'fcm',
|
||||
platform: 'web',
|
||||
userAgent: navigator.userAgent || '',
|
||||
});
|
||||
|
||||
messaging.onMessage((payload) => {
|
||||
const title = payload?.notification?.title || 'Новое сообщение';
|
||||
const body = payload?.notification?.body || '';
|
||||
try {
|
||||
new Notification(title, { body });
|
||||
} catch {}
|
||||
});
|
||||
} catch {
|
||||
// silent for MVP
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,7 @@ export class WsJsonClient {
|
||||
this.ws = null;
|
||||
this.pending = new Map();
|
||||
this.openPromise = null;
|
||||
this.eventListeners = new Map();
|
||||
}
|
||||
|
||||
async open() {
|
||||
@ -53,6 +54,7 @@ export class WsJsonClient {
|
||||
});
|
||||
}).finally(() => {
|
||||
this.openPromise = null;
|
||||
this.eventListeners = new Map();
|
||||
});
|
||||
|
||||
return this.openPromise;
|
||||
@ -116,14 +118,40 @@ export class WsJsonClient {
|
||||
}
|
||||
|
||||
const requestId = data?.requestId;
|
||||
if (!requestId) return;
|
||||
const isEvent = data?.event === true || (requestId && !this.pending.has(requestId));
|
||||
if (isEvent) {
|
||||
this.emitEvent(data?.op || '', data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requestId) return;
|
||||
const slot = this.pending.get(requestId);
|
||||
if (!slot) return;
|
||||
this.pending.delete(requestId);
|
||||
slot.resolve(data);
|
||||
}
|
||||
|
||||
onEvent(op, callback) {
|
||||
if (!op || typeof callback !== 'function') return () => {};
|
||||
if (!this.eventListeners.has(op)) {
|
||||
this.eventListeners.set(op, new Set());
|
||||
}
|
||||
const set = this.eventListeners.get(op);
|
||||
set.add(callback);
|
||||
return () => {
|
||||
set.delete(callback);
|
||||
if (!set.size) this.eventListeners.delete(op);
|
||||
};
|
||||
}
|
||||
|
||||
emitEvent(op, data) {
|
||||
const listeners = this.eventListeners.get(op);
|
||||
if (!listeners) return;
|
||||
listeners.forEach((cb) => {
|
||||
try { cb(data); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
failPending(message) {
|
||||
const pendingOps = [...this.pending.values()]
|
||||
.map((slot) => slot.op)
|
||||
|
||||
@ -41,6 +41,8 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
const storedSession = withStoredSession ? loadStoredSession() : null;
|
||||
return {
|
||||
chats: clone(chatMessages),
|
||||
contacts: [],
|
||||
incomingDedup: {},
|
||||
notificationsTab: 'replies',
|
||||
pageLabelCollapsed: false,
|
||||
session: {
|
||||
@ -121,6 +123,20 @@ export function addChatMessage(chatId, text) {
|
||||
getChatMessages(chatId).push({ from: 'out', text: message });
|
||||
}
|
||||
|
||||
|
||||
export function addIncomingMessage(chatId, text, messageId = '') {
|
||||
const msg = text?.trim();
|
||||
if (!msg) return false;
|
||||
if (messageId && state.incomingDedup[messageId]) return false;
|
||||
if (messageId) state.incomingDedup[messageId] = true;
|
||||
getChatMessages(chatId).push({ from: 'in', text: msg, messageId });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setContacts(list) {
|
||||
state.contacts = Array.isArray(list) ? [...list] : [];
|
||||
}
|
||||
|
||||
export function togglePageLabel() {
|
||||
state.pageLabelCollapsed = !state.pageLabelCollapsed;
|
||||
}
|
||||
|
||||
20
shine-UI/manifest.webmanifest
Normal file
20
shine-UI/manifest.webmanifest
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Shine UI",
|
||||
"short_name": "Shine",
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b1020",
|
||||
"theme_color": "#0b1020",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./img/logo.jpg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"src": "./img/logo.jpg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -19,7 +19,7 @@ body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 118px;
|
||||
bottom: 74px;
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
@ -29,13 +29,6 @@ body {
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.page-label-slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 99px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.toolbar-slot {
|
||||
position: absolute;
|
||||
|
||||
@ -328,6 +328,54 @@ public final class DatabaseInitializer {
|
||||
ON message_stats (to_login);
|
||||
""");
|
||||
|
||||
// 9) direct_messages
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||
message_id TEXT NOT NULL PRIMARY KEY,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_direct_messages_to_login
|
||||
ON direct_messages (to_login, created_at_ms);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_direct_messages_from_login
|
||||
ON direct_messages (from_login, created_at_ms);
|
||||
""");
|
||||
|
||||
// 10) user_push_tokens
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS user_push_tokens (
|
||||
token_id TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
user_agent TEXT,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login
|
||||
ON user_push_tokens (login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session
|
||||
ON user_push_tokens (login, session_id);
|
||||
""");
|
||||
|
||||
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,4 +125,32 @@ public final class ConnectionsStateDAO {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
public void upsertRelation(Connection c,
|
||||
String login,
|
||||
int relType,
|
||||
String toLogin,
|
||||
String toBchName,
|
||||
Integer toBlockNumber,
|
||||
byte[] toBlockHash) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(login, rel_type, to_login) DO UPDATE SET
|
||||
to_bch_name=excluded.to_bch_name,
|
||||
to_block_number=excluded.to_block_number,
|
||||
to_block_hash=excluded.to_block_hash
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, relType);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, toBchName);
|
||||
if (toBlockNumber == null) ps.setNull(5, java.sql.Types.INTEGER); else ps.setInt(5, toBlockNumber);
|
||||
ps.setBytes(6, toBlockHash);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.DirectMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class DirectMessagesDAO {
|
||||
private static volatile DirectMessagesDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private DirectMessagesDAO() {}
|
||||
|
||||
public static DirectMessagesDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DirectMessagesDAO.class) {
|
||||
if (instance == null) instance = new DirectMessagesDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void insert(DirectMessageEntry entry) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO direct_messages (
|
||||
message_id, from_login, to_login, text, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getMessageId());
|
||||
ps.setString(2, entry.getFromLogin());
|
||||
ps.setString(3, entry.getToLogin());
|
||||
ps.setString(4, entry.getText());
|
||||
ps.setLong(5, entry.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
return ps.executeQuery().next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
116
shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java
Normal file
116
shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java
Normal file
@ -0,0 +1,116 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.PushTokenEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class PushTokensDAO {
|
||||
private static volatile PushTokensDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private PushTokensDAO() {}
|
||||
|
||||
public static PushTokensDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (PushTokensDAO.class) {
|
||||
if (instance == null) instance = new PushTokensDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void upsert(PushTokenEntry entry) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO user_push_tokens (
|
||||
token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(token_id) DO UPDATE SET
|
||||
login=excluded.login,
|
||||
session_id=excluded.session_id,
|
||||
provider=excluded.provider,
|
||||
token=excluded.token,
|
||||
platform=excluded.platform,
|
||||
user_agent=excluded.user_agent,
|
||||
updated_at_ms=excluded.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getTokenId());
|
||||
ps.setString(2, entry.getLogin());
|
||||
ps.setString(3, entry.getSessionId());
|
||||
ps.setString(4, entry.getProvider());
|
||||
ps.setString(5, entry.getToken());
|
||||
ps.setString(6, entry.getPlatform());
|
||||
ps.setString(7, entry.getUserAgent());
|
||||
ps.setLong(8, entry.getUpdatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<PushTokenEntry> listByLogin(String login) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
|
||||
FROM user_push_tokens
|
||||
WHERE login = ?
|
||||
ORDER BY updated_at_ms DESC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PushTokenEntry> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
PushTokenEntry e = new PushTokenEntry();
|
||||
e.setTokenId(rs.getString("token_id"));
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setSessionId(rs.getString("session_id"));
|
||||
e.setProvider(rs.getString("provider"));
|
||||
e.setToken(rs.getString("token"));
|
||||
e.setPlatform(rs.getString("platform"));
|
||||
e.setUserAgent(rs.getString("user_agent"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<PushTokenEntry> listByLoginAndSession(String login, String sessionId) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
|
||||
FROM user_push_tokens
|
||||
WHERE login = ? AND session_id = ?
|
||||
ORDER BY updated_at_ms DESC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setString(2, sessionId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PushTokenEntry> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
PushTokenEntry e = new PushTokenEntry();
|
||||
e.setTokenId(rs.getString("token_id"));
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setSessionId(rs.getString("session_id"));
|
||||
e.setProvider(rs.getString("provider"));
|
||||
e.setToken(rs.getString("token"));
|
||||
e.setPlatform(rs.getString("platform"));
|
||||
e.setUserAgent(rs.getString("user_agent"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DirectMessageEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private String text;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class PushTokenEntry {
|
||||
private String tokenId;
|
||||
private String login;
|
||||
private String sessionId;
|
||||
private String provider;
|
||||
private String token;
|
||||
private String platform;
|
||||
private String userAgent;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getTokenId() { return tokenId; }
|
||||
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getSessionId() { return sessionId; }
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
|
||||
public String getProvider() { return provider; }
|
||||
public void setProvider(String provider) { this.provider = provider; }
|
||||
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
|
||||
public String getPlatform() { return platform; }
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
|
||||
public String getUserAgent() { return userAgent; }
|
||||
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
|
||||
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
}
|
||||
@ -51,6 +51,18 @@ import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckIncomingMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
|
||||
|
||||
// --- NEW: Ping ---
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
|
||||
@ -96,6 +108,14 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()),
|
||||
Map.entry("AckIncomingMessage", new Net_AckIncomingMessage_Handler()),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("Ping", new Net_Ping_Handler()),
|
||||
@ -134,6 +154,14 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class),
|
||||
Map.entry("AckIncomingMessage", Net_AckIncomingMessage_Request.class),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("Ping", Net_Ping_Request.class),
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||
@ -10,6 +12,8 @@ import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.ws.WsConnectionUtils;
|
||||
import shine.db.dao.ActiveSessionsDAO;
|
||||
@ -32,6 +36,7 @@ import java.sql.SQLException;
|
||||
public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||
@ -122,6 +127,14 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
|
||||
|
||||
if (ctxToClose == null) return;
|
||||
|
||||
String eventId = NetIdGenerator.eventId("evt");
|
||||
ObjectNode payload = MAPPER.createObjectNode();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("sessionId", targetSessionId);
|
||||
payload.put("reason", "closed_from_another_device");
|
||||
payload.put("timeMs", System.currentTimeMillis());
|
||||
WsEventSender.sendEvent(ctxToClose, "SessionRevoked", eventId, payload);
|
||||
|
||||
if (isCurrentSession && ctxToClose == currentCtx) {
|
||||
new Thread(() -> {
|
||||
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ConnectionsStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_AddCloseFriend_Request req = (Net_AddCloseFriend_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getToLogin() == null || req.getToLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin обязателен");
|
||||
}
|
||||
|
||||
String from = ctx.getLogin();
|
||||
String toLogin = req.getToLogin().trim();
|
||||
if (from.equalsIgnoreCase(toLogin)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Нельзя добавить себя");
|
||||
}
|
||||
|
||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||
String canonicalTo = findCanonicalLogin(c, toLogin);
|
||||
if (canonicalTo == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
String targetBch = findPrimaryBlockchain(c, canonicalTo);
|
||||
if (targetBch == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "У пользователя нет blockchain");
|
||||
}
|
||||
|
||||
ConnectionsStateDAO.getInstance().upsertRelation(
|
||||
c,
|
||||
from,
|
||||
MsgSubType.CONNECTION_FRIEND,
|
||||
canonicalTo,
|
||||
targetBch,
|
||||
0,
|
||||
new byte[32]
|
||||
);
|
||||
|
||||
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(from);
|
||||
resp.setToLogin(canonicalTo);
|
||||
resp.setRelation("close_friend");
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
private String findCanonicalLogin(Connection c, String login) throws Exception {
|
||||
String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("login") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String findPrimaryBlockchain(Connection c, String login) throws Exception {
|
||||
String sql = "SELECT blockchain_name FROM blockchain_state WHERE login=? ORDER BY blockchain_name LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("blockchain_name") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ConnectionsStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
String login = (req.getLogin() == null || req.getLogin().isBlank()) ? ctx.getLogin() : req.getLogin().trim();
|
||||
|
||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||
List<String> out = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, login, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> in = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, login, MsgSubType.CONNECTION_FRIEND);
|
||||
|
||||
Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setOutFriends(out);
|
||||
resp.setInFriends(in);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ConnectionsStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_ListContacts_Request req = (Net_ListContacts_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
|
||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT);
|
||||
Net_ListContacts_Response resp = new Net_ListContacts_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(ctx.getLogin());
|
||||
resp.setContacts(contacts);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_AddCloseFriend_Request extends Net_Request {
|
||||
private String toLogin;
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_AddCloseFriend_Response extends Net_Response {
|
||||
private String login;
|
||||
private String toLogin;
|
||||
private String relation;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public String getRelation() { return relation; }
|
||||
public void setRelation(String relation) { this.relation = relation; }
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetUserConnectionsGraph_Request extends Net_Request {
|
||||
private String login;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<String> outFriends = new ArrayList<>();
|
||||
private List<String> inFriends = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<String> getOutFriends() { return outFriends; }
|
||||
public void setOutFriends(List<String> outFriends) { this.outFriends = outFriends; }
|
||||
public List<String> getInFriends() { return inFriends; }
|
||||
public void setInFriends(List<String> inFriends) { this.inFriends = inFriends; }
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_ListContacts_Request extends Net_Request {
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_ListContacts_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<String> contacts = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<String> getContacts() { return contacts; }
|
||||
public void setContacts(List<String> contacts) { this.contacts = contacts; }
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class DeliveryTracker {
|
||||
private static final DeliveryTracker INSTANCE = new DeliveryTracker();
|
||||
|
||||
public static DeliveryTracker getInstance() { return INSTANCE; }
|
||||
|
||||
private final ConcurrentHashMap<String, CompletableFuture<Boolean>> waiters = new ConcurrentHashMap<>();
|
||||
|
||||
private DeliveryTracker() {}
|
||||
|
||||
public CompletableFuture<Boolean> register(String eventId) {
|
||||
CompletableFuture<Boolean> f = new CompletableFuture<>();
|
||||
waiters.put(eventId, f);
|
||||
return f;
|
||||
}
|
||||
|
||||
public void ack(String eventId) {
|
||||
CompletableFuture<Boolean> f = waiters.remove(eventId);
|
||||
if (f != null) f.complete(true);
|
||||
}
|
||||
|
||||
public void fail(String eventId) {
|
||||
CompletableFuture<Boolean> f = waiters.remove(eventId);
|
||||
if (f != null) f.complete(false);
|
||||
}
|
||||
|
||||
public void remove(String eventId) {
|
||||
waiters.remove(eventId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
|
||||
public class Net_AckIncomingMessage_Handler implements JsonMessageHandler {
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_AckIncomingMessage_Request req = (Net_AckIncomingMessage_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getEventId() != null && !req.getEventId().isBlank()) {
|
||||
DeliveryTracker.getInstance().ack(req.getEventId());
|
||||
}
|
||||
|
||||
Net_AckIncomingMessage_Response resp = new Net_AckIncomingMessage_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response;
|
||||
import server.logic.ws_protocol.JSON.push.FcmPushSender;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.DirectMessagesDAO;
|
||||
import shine.db.dao.PushTokensDAO;
|
||||
import shine.db.entities.DirectMessageEntry;
|
||||
import shine.db.entities.PushTokenEntry;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getToLogin() == null || req.getToLogin().isBlank() || req.getText() == null || req.getText().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin и text обязательны");
|
||||
}
|
||||
|
||||
String from = ctx.getLogin();
|
||||
String to = req.getToLogin().trim();
|
||||
String text = req.getText().trim();
|
||||
|
||||
if (!canSend(from, to)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NO_PERMISSION", "Можно писать только контактам или тем, кто уже писал вам");
|
||||
}
|
||||
|
||||
String messageId = NetIdGenerator.eventId("msg");
|
||||
DirectMessageEntry entry = new DirectMessageEntry();
|
||||
entry.setMessageId(messageId);
|
||||
entry.setFromLogin(from);
|
||||
entry.setToLogin(to);
|
||||
entry.setText(text);
|
||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||
DirectMessagesDAO.getInstance().insert(entry);
|
||||
|
||||
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
|
||||
List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
|
||||
|
||||
int wsDelivered = 0;
|
||||
int fcmDelivered = 0;
|
||||
|
||||
Set<String> activeSessionIds = new HashSet<>();
|
||||
for (ConnectionContext targetCtx : activeSessions) {
|
||||
activeSessionIds.add(targetCtx.getSessionId());
|
||||
String eventId = NetIdGenerator.eventId("evt");
|
||||
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
|
||||
|
||||
ObjectNode payload = MAPPER.createObjectNode();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("messageId", messageId);
|
||||
payload.put("fromLogin", from);
|
||||
payload.put("toLogin", to);
|
||||
payload.put("text", text);
|
||||
payload.put("timeMs", entry.getCreatedAtMs());
|
||||
|
||||
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
|
||||
boolean acked = false;
|
||||
if (sent) {
|
||||
try {
|
||||
acked = waiter.get(1200, TimeUnit.MILLISECONDS);
|
||||
} catch (Exception ignored) {
|
||||
acked = false;
|
||||
}
|
||||
}
|
||||
DeliveryTracker.getInstance().remove(eventId);
|
||||
if (acked) {
|
||||
wsDelivered++;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (PushTokenEntry token : tokens) {
|
||||
if (!targetCtx.getSessionId().equals(token.getSessionId())) continue;
|
||||
boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId);
|
||||
if (pushed) {
|
||||
fcmDelivered++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (PushTokenEntry token : tokens) {
|
||||
if (activeSessionIds.contains(token.getSessionId())) continue;
|
||||
boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId);
|
||||
if (pushed) fcmDelivered++;
|
||||
}
|
||||
|
||||
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setMessageId(messageId);
|
||||
resp.setDeliveredWsSessions(wsDelivered);
|
||||
resp.setDeliveredFcmSessions(fcmDelivered);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private boolean canSend(String from, String to) {
|
||||
return from != null && !from.isBlank() && to != null && !to.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.PushTokensDAO;
|
||||
import shine.db.entities.PushTokenEntry;
|
||||
|
||||
public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_UpsertPushToken_Request req = (Net_UpsertPushToken_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getTokenId() == null || req.getTokenId().isBlank() || req.getToken() == null || req.getToken().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "tokenId и token обязательны");
|
||||
}
|
||||
|
||||
PushTokenEntry e = new PushTokenEntry();
|
||||
e.setTokenId(req.getTokenId().trim());
|
||||
e.setLogin(ctx.getLogin());
|
||||
e.setSessionId((req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim());
|
||||
e.setProvider(req.getProvider() == null || req.getProvider().isBlank() ? "fcm" : req.getProvider().trim());
|
||||
e.setToken(req.getToken().trim());
|
||||
e.setPlatform(req.getPlatform());
|
||||
e.setUserAgent(req.getUserAgent());
|
||||
e.setUpdatedAtMs(System.currentTimeMillis());
|
||||
PushTokensDAO.getInstance().upsert(e);
|
||||
|
||||
Net_UpsertPushToken_Response resp = new Net_UpsertPushToken_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setTokenId(e.getTokenId());
|
||||
resp.setUpdatedAtMs(e.getUpdatedAtMs());
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_AckIncomingMessage_Request extends Net_Request {
|
||||
private String eventId;
|
||||
private String messageId;
|
||||
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String eventId) { this.eventId = eventId; }
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_AckIncomingMessage_Response extends Net_Response {
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_SendDirectMessage_Request extends Net_Request {
|
||||
private String toLogin;
|
||||
private String text;
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_SendDirectMessage_Response extends Net_Response {
|
||||
private String messageId;
|
||||
private int deliveredWsSessions;
|
||||
private int deliveredFcmSessions;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
public int getDeliveredWsSessions() { return deliveredWsSessions; }
|
||||
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
|
||||
public int getDeliveredFcmSessions() { return deliveredFcmSessions; }
|
||||
public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; }
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_UpsertPushToken_Request extends Net_Request {
|
||||
private String tokenId;
|
||||
private String sessionId;
|
||||
private String provider;
|
||||
private String token;
|
||||
private String platform;
|
||||
private String userAgent;
|
||||
|
||||
public String getTokenId() { return tokenId; }
|
||||
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
|
||||
public String getSessionId() { return sessionId; }
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
public String getProvider() { return provider; }
|
||||
public void setProvider(String provider) { this.provider = provider; }
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
public String getPlatform() { return platform; }
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
public String getUserAgent() { return userAgent; }
|
||||
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_UpsertPushToken_Response extends Net_Response {
|
||||
private String tokenId;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getTokenId() { return tokenId; }
|
||||
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package server.logic.ws_protocol.JSON.push;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
public final class FcmPushSender {
|
||||
private static final Logger log = LoggerFactory.getLogger(FcmPushSender.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
|
||||
private FcmPushSender() {}
|
||||
|
||||
public static boolean sendNotification(String token, String title, String body, String messageId) {
|
||||
try {
|
||||
String serverKey = AppConfig.getInstance().getStringOrEmpty("fcm.server.key");
|
||||
if (serverKey.isBlank()) {
|
||||
log.warn("fcm.server.key is empty, skip FCM send");
|
||||
return false;
|
||||
}
|
||||
|
||||
ObjectNode root = MAPPER.createObjectNode();
|
||||
root.put("to", token);
|
||||
ObjectNode notif = root.putObject("notification");
|
||||
notif.put("title", title);
|
||||
notif.put("body", body);
|
||||
ObjectNode data = root.putObject("data");
|
||||
data.put("messageId", messageId);
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create("https://fcm.googleapis.com/fcm/send"))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.header("Authorization", "key=" + serverKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(root.toString()))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
return resp.statusCode() >= 200 && resp.statusCode() < 300;
|
||||
} catch (Exception e) {
|
||||
log.warn("FCM send failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package server.logic.ws_protocol.JSON.push;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
|
||||
public final class WsEventSender {
|
||||
private static final Logger log = LoggerFactory.getLogger(WsEventSender.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private WsEventSender() {}
|
||||
|
||||
public static boolean sendEvent(ConnectionContext ctx, String op, String eventId, ObjectNode payload) {
|
||||
if (ctx == null) return false;
|
||||
Session session = ctx.getWsSession();
|
||||
if (session == null || !session.isOpen()) return false;
|
||||
try {
|
||||
ObjectNode root = MAPPER.createObjectNode();
|
||||
root.put("op", op);
|
||||
root.put("requestId", eventId);
|
||||
root.put("status", 200);
|
||||
root.put("ok", true);
|
||||
root.put("event", true);
|
||||
root.set("payload", payload == null ? MAPPER.createObjectNode() : payload);
|
||||
session.getRemote().sendString(root.toString());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to send ws event op={} sessionId={}", op, ctx.getSessionId(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package server.logic.ws_protocol.JSON.utils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public final class NetIdGenerator {
|
||||
private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS");
|
||||
|
||||
private NetIdGenerator() {}
|
||||
|
||||
public static String eventId(String prefix) {
|
||||
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
return (prefix == null || prefix.isBlank() ? "evt" : prefix)
|
||||
+ "-" + now.format(FMT)
|
||||
+ "-" + randomSuffix(10);
|
||||
}
|
||||
|
||||
private static String randomSuffix(int len) {
|
||||
StringBuilder sb = new StringBuilder(len);
|
||||
for (int i = 0; i < len; i++) {
|
||||
sb.append(ALPHABET.charAt(ThreadLocalRandom.current().nextInt(ALPHABET.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@ -12,3 +12,6 @@ server.info.physicalRegion=
|
||||
server.info.description=
|
||||
server.info.origin=
|
||||
server.info.extraInfo=
|
||||
|
||||
# FCM (legacy HTTP)
|
||||
fcm.server.key=
|
||||
|
||||
Loading…
Reference in New Issue
Block a user