Add WS push events, PWA/FCM scaffolding, and direct messaging MVP
This commit is contained in:
parent
cf5460c5c7
commit
32c046233b
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-ограничения.
|
||||||
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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<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>
|
<title>Shine UI Demo</title>
|
||||||
<link rel="stylesheet" href="./styles/main.css?v=20260403081123" />
|
<link rel="stylesheet" href="./styles/main.css?v=20260403081123" />
|
||||||
<link rel="stylesheet" href="./styles/layout.css?v=20260403081123" />
|
<link rel="stylesheet" href="./styles/layout.css?v=20260403081123" />
|
||||||
@ -11,10 +12,21 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<main id="app-screen" class="screen-content"></main>
|
<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 id="toolbar-slot" class="toolbar-slot"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="modal-root"></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>
|
<script type="module" src="./js/app.js?v=20260403081123"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260403081123';
|
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260403081123';
|
||||||
import { renderToolbar } from './components/toolbar.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 { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js?v=20260403081123';
|
||||||
|
import { initPwaPush } from './services/pwa-push-service.js?v=20260403081123';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
@ -10,7 +10,8 @@ import {
|
|||||||
setSessionResetHandler,
|
setSessionResetHandler,
|
||||||
state,
|
state,
|
||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
togglePageLabel,
|
addIncomingMessage,
|
||||||
|
setContacts,
|
||||||
} from './state.js?v=20260403081123';
|
} from './state.js?v=20260403081123';
|
||||||
|
|
||||||
import * as startView from './pages/start-view.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 screenEl = document.getElementById('app-screen');
|
||||||
const labelEl = document.getElementById('page-label-slot');
|
|
||||||
const toolbarEl = document.getElementById('toolbar-slot');
|
const toolbarEl = document.getElementById('toolbar-slot');
|
||||||
|
|
||||||
let currentCleanup = null;
|
let currentCleanup = null;
|
||||||
@ -140,16 +140,9 @@ function renderApp() {
|
|||||||
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||||
|
|
||||||
labelEl.innerHTML = '';
|
|
||||||
toolbarEl.innerHTML = '';
|
toolbarEl.innerHTML = '';
|
||||||
|
|
||||||
if (showAppChrome) {
|
if (showAppChrome) {
|
||||||
labelEl.append(
|
|
||||||
renderPageLabel(page.pageMeta.title, page.pageMeta.id, state.pageLabelCollapsed, () => {
|
|
||||||
togglePageLabel();
|
|
||||||
renderApp();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
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);
|
const resumed = await authService.resumeSession(state.session.login, state.session.sessionId);
|
||||||
authorizeSession(resumed);
|
authorizeSession(resumed);
|
||||||
await refreshSessions();
|
await refreshSessions();
|
||||||
|
try {
|
||||||
|
const contacts = await authService.listContacts();
|
||||||
|
setContacts(contacts.contacts || []);
|
||||||
|
} catch {}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isSessionInvalidError(error)) {
|
if (isSessionInvalidError(error)) {
|
||||||
await terminateCurrentSession({
|
await terminateCurrentSession({
|
||||||
@ -174,7 +171,30 @@ async function init() {
|
|||||||
setSessionResetHandler(() => {
|
setSessionResetHandler(() => {
|
||||||
navigate('start-view');
|
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();
|
await tryAutoLogin();
|
||||||
|
if (state.session.isAuthorized) {
|
||||||
|
await initPwaPush({ authService });
|
||||||
|
}
|
||||||
|
|
||||||
if (!window.location.hash) {
|
if (!window.location.hash) {
|
||||||
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
|
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js?v=20260403081123';
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
import { directMessages } from '../mock-data.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: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
|
|
||||||
@ -18,7 +18,11 @@ function renderLog(list, chatId) {
|
|||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
const chatId = route.params.chatId || 'u1';
|
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');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
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">Пользователь не в контактах. Можно отвечать, если он уже писал вам.</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');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'chat-wrap';
|
wrap.className = 'chat-wrap';
|
||||||
|
|
||||||
@ -43,12 +64,22 @@ export function render({ navigate, route }) {
|
|||||||
<button class="primary-btn" type="submit">Отправить</button>
|
<button class="primary-btn" type="submit">Отправить</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
form.addEventListener('submit', (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const input = form.elements.message;
|
const input = form.elements.message;
|
||||||
addChatMessage(chatId, input.value);
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
addChatMessage(chatId, text);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.sendDirectMessage(chatId, text);
|
||||||
|
} catch (e) {
|
||||||
|
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
|
||||||
|
renderLog(log, chatId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId);
|
||||||
|
|||||||
@ -1,18 +1,9 @@
|
|||||||
import { renderHeader } from '../components/header.js?v=20260403081123';
|
import { renderHeader } from '../components/header.js?v=20260403081123';
|
||||||
import { contactDirectory, directMessages } from '../mock-data.js?v=20260403081123';
|
import { directMessages } from '../mock-data.js?v=20260403081123';
|
||||||
import { ensureChat } from '../state.js?v=20260403081123';
|
import { authService, ensureChat, setContacts, state } from '../state.js?v=20260403081123';
|
||||||
|
|
||||||
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
|
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
@ -21,7 +12,7 @@ export function render({ navigate }) {
|
|||||||
input.className = 'input';
|
input.className = 'input';
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.name = 'contact';
|
input.name = 'contact';
|
||||||
input.placeholder = 'Введите имя контакта';
|
input.placeholder = 'Введите начало логина';
|
||||||
input.autocomplete = 'off';
|
input.autocomplete = 'off';
|
||||||
input.maxLength = 80;
|
input.maxLength = 80;
|
||||||
|
|
||||||
@ -43,7 +34,7 @@ export function render({ navigate }) {
|
|||||||
resultsCard.hidden = false;
|
resultsCard.hidden = false;
|
||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
status.textContent = 'Введите первые буквы имени, чтобы найти контакт.';
|
status.textContent = 'Введите начало логина пользователя.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,16 +45,16 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
status.textContent = `Найдено пользователей: ${matches.length}`;
|
status.textContent = `Найдено пользователей: ${matches.length}`;
|
||||||
|
|
||||||
matches.forEach((contact) => {
|
matches.forEach((login) => {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item';
|
row.className = 'list-item';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar">${contact.initials}</div>
|
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>${contact.name}</strong>
|
<strong>${login}</strong>
|
||||||
<p class="meta-muted" style="margin-top:4px;">${contact.about}</p>
|
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-muted">Контакт</div>
|
<div class="meta-muted">Профиль</div>
|
||||||
`;
|
`;
|
||||||
resultsList.append(row);
|
resultsList.append(row);
|
||||||
});
|
});
|
||||||
@ -72,38 +63,48 @@ export function render({ navigate }) {
|
|||||||
const searchButton = document.createElement('button');
|
const searchButton = document.createElement('button');
|
||||||
searchButton.className = 'primary-btn';
|
searchButton.className = 'primary-btn';
|
||||||
searchButton.type = 'button';
|
searchButton.type = 'button';
|
||||||
searchButton.textContent = 'Найти';
|
searchButton.textContent = 'Поиск';
|
||||||
searchButton.addEventListener('click', () => {
|
searchButton.addEventListener('click', async () => {
|
||||||
renderResults(getMatches(input.value), input.value);
|
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');
|
const addButton = document.createElement('button');
|
||||||
addButton.className = 'ghost-btn';
|
addButton.className = 'ghost-btn';
|
||||||
addButton.type = 'button';
|
addButton.type = 'button';
|
||||||
addButton.textContent = 'Добавить';
|
addButton.textContent = 'Открыть чат';
|
||||||
addButton.addEventListener('click', () => {
|
addButton.addEventListener('click', () => {
|
||||||
if (!latestMatches.length) {
|
if (!latestMatches.length) {
|
||||||
status.textContent = 'Сначала выполните поиск, чтобы добавить контакт.';
|
status.textContent = 'Сначала выполните поиск.';
|
||||||
resultsCard.hidden = false;
|
resultsCard.hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contact = latestMatches[0];
|
const login = latestMatches[0];
|
||||||
const exists = directMessages.some((item) => item.id === contact.id);
|
const exists = directMessages.some((item) => item.id === login);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
directMessages.unshift({
|
directMessages.unshift({
|
||||||
id: contact.id,
|
id: login,
|
||||||
name: contact.name,
|
name: login,
|
||||||
initials: contact.initials,
|
initials: (login[0] || '?').toUpperCase(),
|
||||||
lastMessage: 'Новый контакт добавлен. Можно начинать диалог.',
|
lastMessage: 'Диалог создан. Пользователь пока не в контактах.',
|
||||||
time: 'сейчас',
|
time: 'сейчас',
|
||||||
unread: 0,
|
unread: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureChat(contact.id);
|
if (!state.contacts.includes(login)) {
|
||||||
navigate(`chat-view/${contact.id}`);
|
setContacts([...state.contacts, login]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureChat(login);
|
||||||
|
navigate(`chat-view/${login}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
|
|||||||
@ -1,77 +1,75 @@
|
|||||||
import { renderHeader } from '../components/header.js?v=20260403081123';
|
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: 'Связи' };
|
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||||
|
|
||||||
function toPoint(v) {
|
function makeNode(name, cls = '') {
|
||||||
return `${v.x}%`;
|
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>`;
|
||||||
function showHelpModal() {
|
return n;
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="network-help-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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
root.querySelector('#close-network-help').addEventListener('click', () => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render() {
|
export function render() {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
|
|
||||||
const header = renderHeader({
|
|
||||||
title: 'Связи',
|
|
||||||
rightActions: [{ label: 'Справка', onClick: showHelpModal }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const board = document.createElement('div');
|
const board = document.createElement('div');
|
||||||
board.className = 'network-board';
|
board.className = 'network-board';
|
||||||
|
board.style.height = 'calc(100dvh - 170px)';
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
const note = document.createElement('p');
|
const note = document.createElement('p');
|
||||||
note.className = 'meta-muted';
|
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'}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
screen.append(renderHeader({ title: 'Связи' }), board, note);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,6 +235,47 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
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 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) {
|
async reportClientError(details) {
|
||||||
try {
|
try {
|
||||||
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
|
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.ws = null;
|
||||||
this.pending = new Map();
|
this.pending = new Map();
|
||||||
this.openPromise = null;
|
this.openPromise = null;
|
||||||
|
this.eventListeners = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
async open() {
|
async open() {
|
||||||
@ -53,6 +54,7 @@ export class WsJsonClient {
|
|||||||
});
|
});
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.openPromise = null;
|
this.openPromise = null;
|
||||||
|
this.eventListeners = new Map();
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.openPromise;
|
return this.openPromise;
|
||||||
@ -116,14 +118,40 @@ export class WsJsonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestId = data?.requestId;
|
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);
|
const slot = this.pending.get(requestId);
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
this.pending.delete(requestId);
|
this.pending.delete(requestId);
|
||||||
slot.resolve(data);
|
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) {
|
failPending(message) {
|
||||||
const pendingOps = [...this.pending.values()]
|
const pendingOps = [...this.pending.values()]
|
||||||
.map((slot) => slot.op)
|
.map((slot) => slot.op)
|
||||||
|
|||||||
@ -41,6 +41,8 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
const storedSession = withStoredSession ? loadStoredSession() : null;
|
const storedSession = withStoredSession ? loadStoredSession() : null;
|
||||||
return {
|
return {
|
||||||
chats: clone(chatMessages),
|
chats: clone(chatMessages),
|
||||||
|
contacts: [],
|
||||||
|
incomingDedup: {},
|
||||||
notificationsTab: 'replies',
|
notificationsTab: 'replies',
|
||||||
pageLabelCollapsed: false,
|
pageLabelCollapsed: false,
|
||||||
session: {
|
session: {
|
||||||
@ -121,6 +123,20 @@ export function addChatMessage(chatId, text) {
|
|||||||
getChatMessages(chatId).push({ from: 'out', text: message });
|
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() {
|
export function togglePageLabel() {
|
||||||
state.pageLabelCollapsed = !state.pageLabelCollapsed;
|
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;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 118px;
|
bottom: 74px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 14px 14px 24px;
|
padding: 14px 14px 24px;
|
||||||
}
|
}
|
||||||
@ -29,13 +29,6 @@ body {
|
|||||||
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
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 {
|
.toolbar-slot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -328,6 +328,54 @@ public final class DatabaseInitializer {
|
|||||||
ON message_stats (to_login);
|
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);
|
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,16 @@ 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_GetChannelMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_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.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_ListContacts_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_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 ---
|
// --- NEW: Ping ---
|
||||||
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
|
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
|
||||||
@ -96,6 +106,13 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||||
|
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||||
|
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_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 ---
|
// --- system ---
|
||||||
Map.entry("Ping", new Net_Ping_Handler()),
|
Map.entry("Ping", new Net_Ping_Handler()),
|
||||||
@ -134,6 +151,13 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||||
|
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||||
|
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_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 ---
|
// --- system ---
|
||||||
Map.entry("Ping", Net_Ping_Request.class),
|
Map.entry("Ping", Net_Ping_Request.class),
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package server.logic.ws_protocol.JSON.handlers.auth;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
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_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response;
|
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.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.logic.ws_protocol.WireCodes;
|
||||||
import server.ws.WsConnectionUtils;
|
import server.ws.WsConnectionUtils;
|
||||||
import shine.db.dao.ActiveSessionsDAO;
|
import shine.db.dao.ActiveSessionsDAO;
|
||||||
@ -32,6 +36,7 @@ import java.sql.SQLException;
|
|||||||
public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
|
public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class);
|
private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
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;
|
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) {
|
if (isCurrentSession && ctxToClose == currentCtx) {
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
|
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
|
||||||
|
|||||||
@ -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_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,141 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
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;
|
||||||
|
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.MsgSubType;
|
||||||
|
import shine.db.dao.ConnectionsStateDAO;
|
||||||
|
import shine.db.dao.DirectMessagesDAO;
|
||||||
|
import shine.db.dao.PushTokensDAO;
|
||||||
|
import shine.db.entities.DirectMessageEntry;
|
||||||
|
import shine.db.entities.PushTokenEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
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 Logger log = LoggerFactory.getLogger(Net_SendDirectMessage_Handler.class);
|
||||||
|
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) throws Exception {
|
||||||
|
if (from.equalsIgnoreCase(to)) return true;
|
||||||
|
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||||
|
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, from, MsgSubType.CONNECTION_CONTACT);
|
||||||
|
if (contacts.stream().anyMatch(v -> v.equalsIgnoreCase(to))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (DirectMessagesDAO.getInstance().existsFromTo(to, from)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("canSend fallback false due error", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.description=
|
||||||
server.info.origin=
|
server.info.origin=
|
||||||
server.info.extraInfo=
|
server.info.extraInfo=
|
||||||
|
|
||||||
|
# FCM (legacy HTTP)
|
||||||
|
fcm.server.key=
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user