Start server-side channel read RPC handlers and simplify API spec

This commit is contained in:
ai5590 2026-03-30 14:32:15 +03:00
parent eb5593c7be
commit 9723696b2c
20 changed files with 1604 additions and 45 deletions

View File

@ -0,0 +1,208 @@
# 06. Channels Read API
## Человеко-читаемое объяснение
Эти 3 функции — это **чтение данных каналов** для UI:
1. `ListSubscriptionsFeed` — отдает данные для экрана списка каналов:
- ваши каналы (личный + созданные вами),
- каналы пользователей, на кого вы подписаны,
- отдельные каналы, на которые вы подписаны напрямую.
2. `GetChannelMessages` — отдает полную ленту одного канала (пока без курсоров, загружается сразу целиком),
включая версии сообщений, лайки и ответы.
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
предки, фокус-сообщение, потомки.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
---
## 1) ListSubscriptionsFeed
### Request
```json
{
"op": "ListSubscriptionsFeed",
"requestId": "req-1",
"payload": {
"login": "Alice",
"limit": 200
}
}
```
### Response (success)
```json
{
"op": "ListSubscriptionsFeed",
"requestId": "req-1",
"status": 200,
"ok": true,
"payload": {
"login": "Alice",
"ownedChannels": [
{
"channel": {
"ownerLogin": "Alice",
"ownerBlockchainName": "alice-001",
"channelName": "0",
"personal": true,
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
},
"messagesCount": 120,
"lastMessage": {
"messageRef": { "blockNumber": 921, "blockHash": "..." },
"text": "последняя версия текста",
"createdAtMs": 1760000000000,
"authorLogin": "Alice",
"authorBlockchainName": "alice-001"
}
}
],
"followedUsersChannels": [
{
"channel": {
"ownerLogin": "Bob",
"ownerBlockchainName": "bob-001",
"channelName": "0",
"personal": true,
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
},
"messagesCount": 540,
"lastMessage": {
"messageRef": { "blockNumber": 922, "blockHash": "..." },
"text": "последняя версия текста",
"createdAtMs": 1760000100000,
"authorLogin": "Bob",
"authorBlockchainName": "bob-001"
}
}
],
"followedChannels": [
{
"channel": {
"ownerLogin": "Carl",
"ownerBlockchainName": "carl-001",
"channelName": "market",
"personal": false,
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
},
"messagesCount": 90,
"lastMessage": {
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
"text": "актуальный текст",
"createdAtMs": 1760001000000,
"authorLogin": "Carl",
"authorBlockchainName": "carl-001"
}
}
]
}
}
```
---
## 2) GetChannelMessages
### Request
```json
{
"op": "GetChannelMessages",
"requestId": "req-2",
"payload": {
"channel": {
"ownerBlockchainName": "bob-001",
"channelRootBlockNumber": 123,
"channelRootBlockHash": "..."
},
"limit": 200,
"sort": "asc"
}
}
```
### Response (success)
```json
{
"op": "GetChannelMessages",
"requestId": "req-2",
"status": 200,
"ok": true,
"payload": {
"channel": {
"ownerLogin": "Bob",
"ownerBlockchainName": "bob-001",
"channelName": "news",
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
},
"messages": [
{
"messageRef": { "blockNumber": 140, "blockHash": "..." },
"authorLogin": "Bob",
"authorBlockchainName": "bob-001",
"createdAtMs": 1760000000000,
"text": "текущая версия",
"likesCount": 12,
"repliesCount": 3,
"versionsTotal": 4,
"versions": [
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
{ "versionIndex": 2, "blockNumber": 155, "blockHash": "...", "text": "v2", "createdAtMs": 1760001000000 },
{ "versionIndex": 3, "blockNumber": 170, "blockHash": "...", "text": "v3", "createdAtMs": 1760002000000 },
{ "versionIndex": 4, "blockNumber": 199, "blockHash": "...", "text": "v4", "createdAtMs": 1760003000000 }
]
}
]
}
}
```
---
## 3) GetMessageThread
### Request
```json
{
"op": "GetMessageThread",
"requestId": "req-3",
"payload": {
"message": {
"blockchainName": "bob-001",
"blockNumber": 333,
"blockHash": "..."
},
"depthUp": 20,
"depthDown": 2,
"limitChildrenPerNode": 50
}
}
```
### Response (success)
```json
{
"op": "GetMessageThread",
"requestId": "req-3",
"status": 200,
"ok": true,
"payload": {
"ancestors": [MessageNode],
"focus": MessageNode,
"descendants": [MessageNodeTree]
}
}
```
---
## Reason codes
- `bad_fields`
- `user_not_found`
- `channel_not_found`
- `message_not_found`
- `limit_too_large`
- `channel_name_already_exists`
- `internal_error`

View File

@ -39,6 +39,7 @@ import * as contactSearchView from './pages/contact-search-view.js?v=20260330001
import * as chatView from './pages/chat-view.js?v=20260330001044'; import * as chatView from './pages/chat-view.js?v=20260330001044';
import * as channelsList from './pages/channels-list.js?v=20260330001044'; import * as channelsList from './pages/channels-list.js?v=20260330001044';
import * as channelView from './pages/channel-view.js?v=20260330001044'; import * as channelView from './pages/channel-view.js?v=20260330001044';
import * as addChannelView from './pages/add-channel-view.js?v=20260330001044';
import * as networkView from './pages/network-view.js?v=20260330001044'; import * as networkView from './pages/network-view.js?v=20260330001044';
import * as notificationsView from './pages/notifications-view.js?v=20260330001044'; import * as notificationsView from './pages/notifications-view.js?v=20260330001044';
@ -69,6 +70,7 @@ const routes = {
'chat-view': chatView, 'chat-view': chatView,
'channels-list': channelsList, 'channels-list': channelsList,
'channel-view': channelView, 'channel-view': channelView,
'add-channel-view': addChannelView,
'network-view': networkView, 'network-view': networkView,
'notifications-view': notificationsView, 'notifications-view': notificationsView,
}; };

View File

@ -147,35 +147,80 @@ export const chatMessages = {
export const channels = [ export const channels = [
{ {
id: 'ch1', id: 'ch0',
name: 'Новости продукта', name: 'Личный канал',
initials: 'НП', initials: 'ЛК',
description: 'Официальный канал команды Shine с релизами и обновлениями.', ownerLogin: '@shine.alex',
lastMessage: 'Опубликовали обзор нового демо-прототипа мобильного интерфейса.', ownerName: 'Вы',
description: 'Ваш основной канал (нулевой).',
lastMessage: 'Добро пожаловать в личный канал.',
time: '16:05', time: '16:05',
unread: 14, messagesCount: 14,
kind: 'own-personal',
},
{
id: 'ch1',
name: 'Команда продукта',
initials: 'КП',
ownerLogin: '@shine.alex',
ownerName: 'Вы',
description: 'Канал команды, который вы создали.',
lastMessage: 'Обновили roadmap на апрель.',
time: '15:42',
messagesCount: 8,
kind: 'own',
}, },
{ {
id: 'ch2', id: 'ch2',
name: 'Анекдоты дня', name: 'Новости Bob',
initials: 'АД', initials: 'NB',
description: 'Лёгкий развлекательный канал с короткими шутками и мемами.', ownerLogin: '@bob',
lastMessage: 'Новый пост: как дизайнер, разработчик и дедлайн зашли в бар.', ownerName: 'Bob',
description: 'Основной канал пользователя Bob.',
lastMessage: 'Вышел новый дайджест разработчика.',
time: '15:20', time: '15:20',
unread: 3, messagesCount: 5,
kind: 'followed-user-channel',
}, },
{ {
id: 'ch3', id: 'ch3',
name: 'Новости рынка', name: 'Стендап команды Bob',
initials: 'НР', initials: 'SB',
description: 'Короткие ежедневные сводки по рынку, технологиям и сообществам.', ownerLogin: '@bob',
lastMessage: 'В ленте свежая подборка новостей и главных событий дня.', ownerName: 'Bob',
description: 'Второй канал пользователя Bob.',
lastMessage: 'Перенесли созвон на 19:30.',
time: 'вчера', time: 'вчера',
unread: 0, messagesCount: 11,
kind: 'followed-user-channel',
},
{
id: 'ch4',
name: 'Анекдоты дня',
initials: 'АД',
ownerLogin: '@fun.club',
ownerName: 'Fun Club',
description: 'Публичный развлекательный канал по подписке.',
lastMessage: 'Сегодня в выпуске 5 новых шуток.',
time: 'вчера',
messagesCount: 33,
kind: 'subscribed',
}, },
]; ];
export const channelPosts = { export const channelPosts = {
ch0: [
{
id: 'p0-1',
title: 'Первый личный пост',
body: 'Этот канал всегда ваш и стоит в списке первым.',
},
{
id: 'p0-2',
title: 'Планы',
body: 'Сюда удобно сохранять личные заметки и объявления.',
},
],
ch1: [ ch1: [
{ {
id: 'p1', id: 'p1',

View File

@ -0,0 +1,38 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
export const pageMeta = { id: 'add-channel-view', title: 'Добавить канал' };
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Добавить канал',
leftAction: { label: '←', onClick: () => navigate('channels-list') },
})
);
const form = document.createElement('form');
form.className = 'card stack';
form.innerHTML = `
<label for="channel-name">Имя канала</label>
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Новости команды" required />
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
<button type="submit" class="primary-btn">Создать</button>
</div>
`;
form.addEventListener('submit', (event) => {
event.preventDefault();
navigate('channels-list');
});
form.querySelector('#cancel-create-channel').addEventListener('click', () => {
navigate('channels-list');
});
screen.append(form);
return screen;
}

View File

@ -7,6 +7,7 @@ export function render({ navigate, route }) {
const channelId = route.params.channelId || 'ch1'; const channelId = route.params.channelId || 'ch1';
const channel = channels.find((c) => c.id === channelId) || channels[0]; const channel = channels.find((c) => c.id === channelId) || channels[0];
const posts = channelPosts[channelId] || []; const posts = channelPosts[channelId] || [];
const isOwnChannel = channel.ownerLogin === '@shine.alex';
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -23,9 +24,13 @@ export function render({ navigate, route }) {
head.innerHTML = ` head.innerHTML = `
<strong># ${channel.name}</strong> <strong># ${channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p> <p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
<p class="meta-muted" style="margin-top:8px;">Публичный канал, режим только чтение</p> <p class="meta-muted" style="margin-top:8px;">Владелец: ${channel.ownerName}</p>
`; `;
const actionButton = document.createElement('button');
actionButton.className = isOwnChannel ? 'primary-btn' : 'secondary-btn';
actionButton.textContent = isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
const feed = document.createElement('div'); const feed = document.createElement('div');
feed.className = 'stack'; feed.className = 'stack';
@ -36,6 +41,6 @@ export function render({ navigate, route }) {
feed.append(card); feed.append(card);
}); });
screen.append(head, feed); screen.append(head, actionButton, feed);
return screen; return screen;
} }

View File

@ -3,40 +3,124 @@ import { channels } from '../mock-data.js?v=20260330001044';
export const pageMeta = { id: 'channels-list', title: 'Каналы' }; export const pageMeta = { id: 'channels-list', title: 'Каналы' };
function openSimpleSubscribeModal(kindLabel) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channels-subscribe-modal">
<div class="modal-card stack">
<h3 style="font-size:18px;">${kindLabel}</h3>
<label class="meta-muted" for="subscribe-input">Введите идентификатор</label>
<input id="subscribe-input" class="input" placeholder="@login или #канал" />
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<button class="secondary-btn" id="sub-cancel">Отмена</button>
<button class="primary-btn" id="sub-submit">Подписаться</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#sub-cancel').addEventListener('click', close);
root.querySelector('#sub-submit').addEventListener('click', close);
}
function renderChannelRow(channel, navigate) {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
<div class="avatar">${channel.initials}</div>
<div>
<strong># ${channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
<p class="meta-muted" style="margin-top:6px; color:#d8e3ff;">${channel.lastMessage}</p>
<p class="meta-muted" style="margin-top:6px;">Владелец: ${channel.ownerName}</p>
</div>
<div style="display:grid; justify-items:end; gap:6px;">
<span class="badge alt" style="padding:4px 8px; font-size:10px;">Канал</span>
<span class="meta-muted">${channel.time}</span>
<span class="unread">${channel.messagesCount}</span>
</div>
`;
row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
return row;
}
function renderSection(title, items, navigate) {
const wrap = document.createElement('section');
wrap.className = 'stack';
const header = document.createElement('h3');
header.className = 'section-title';
header.textContent = title;
wrap.append(header);
items.forEach((channel) => {
wrap.append(renderChannelRow(channel, navigate));
});
return wrap;
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
screen.append(renderHeader({ title: 'Каналы' })); screen.append(
renderHeader({
title: 'Каналы',
rightActions: [
{ label: 'Подписаться на человека', onClick: () => openSimpleSubscribeModal('Подписка на человека') },
{ label: 'Подписаться на канал', onClick: () => openSimpleSubscribeModal('Подписка на канал') },
],
})
);
const search = document.createElement('div'); const ownChannels = channels
search.className = 'card'; .filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own')
search.textContent = 'Найти канал'; .sort((a, b) => {
search.style.color = 'var(--text-muted)'; if (a.kind === 'own-personal') return -1;
if (b.kind === 'own-personal') return 1;
return a.name.localeCompare(b.name, 'ru');
});
const followedUserChannels = channels.filter((channel) => channel.kind === 'followed-user-channel');
const subscribedChannels = channels.filter((channel) => channel.kind === 'subscribed');
const listWrap = document.createElement('div');
listWrap.className = 'channels-scroll-wrap';
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack'; list.className = 'stack channels-groups';
channels.forEach((channel) => { list.append(renderSection('Мои каналы', ownChannels, navigate));
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
<div class="avatar">${channel.initials}</div>
<div>
<strong># ${channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
<p class="meta-muted" style="margin-top:6px; color:#d8e3ff;">${channel.lastMessage}</p>
</div>
<div style="display:grid; justify-items:end; gap:6px;">
<span class="badge alt" style="padding:4px 8px; font-size:10px;">Канал</span>
<span class="meta-muted">${channel.time}</span>
${channel.unread ? `<span class="unread">${channel.unread}</span>` : '<span></span>'}
</div>
`;
row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
list.append(row);
});
screen.append(search, list); const dividerOne = document.createElement('hr');
dividerOne.className = 'channels-divider';
list.append(dividerOne);
list.append(renderSection('Каналы пользователей, на кого вы подписаны', followedUserChannels, navigate));
const dividerTwo = document.createElement('hr');
dividerTwo.className = 'channels-divider';
list.append(dividerTwo);
list.append(renderSection('Каналы, на которые вы подписаны', subscribedChannels, navigate));
const addChannelButton = document.createElement('button');
addChannelButton.className = 'primary-btn';
addChannelButton.textContent = 'Добавить канал';
addChannelButton.addEventListener('click', () => navigate('add-channel-view'));
list.append(addChannelButton);
const scrollHint = document.createElement('div');
scrollHint.className = 'channels-scroll-hint';
listWrap.append(list, scrollHint);
screen.append(listWrap);
return screen; return screen;
} }

View File

@ -57,6 +57,6 @@ export function resolveToolbarActive(pageId) {
return 'profile-view'; return 'profile-view';
} }
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view') return 'channels-list'; if (pageId === 'channel-view' || pageId === 'add-channel-view') return 'channels-list';
return 'profile-view'; return 'profile-view';
} }

View File

@ -728,3 +728,38 @@
border-radius: 16px; border-radius: 16px;
padding: 14px; padding: 14px;
} }
.section-title {
font-size: 14px;
font-weight: 700;
color: #dbe7ff;
margin: 4px 2px;
}
.channels-scroll-wrap {
position: relative;
max-height: 58vh;
overflow-y: auto;
padding-right: 12px;
}
.channels-groups {
min-height: min-content;
}
.channels-divider {
border: 0;
border-top: 1px solid rgba(255, 255, 255, 0.14);
margin: 6px 0 8px;
}
.channels-scroll-hint {
position: absolute;
top: 4px;
right: 0;
width: 4px;
height: calc(100% - 8px);
border-radius: 999px;
background: linear-gradient(180deg, rgba(83, 216, 251, 0.55), rgba(83, 216, 251, 0.15));
pointer-events: none;
}

View File

@ -45,6 +45,12 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUser
// --- NEW: connections friends lists --- // --- NEW: connections friends lists ---
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
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;
// --- 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;
@ -85,6 +91,9 @@ public final class JsonHandlerRegistry {
// --- connections --- // --- connections ---
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()), Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
// --- system --- // --- system ---
Map.entry("Ping", new Net_Ping_Handler()), Map.entry("Ping", new Net_Ping_Handler()),
@ -119,6 +128,9 @@ public final class JsonHandlerRegistry {
// --- connections --- // --- connections ---
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class), Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
// --- system --- // --- system ---
Map.entry("Ping", Net_Ping_Request.class), Map.entry("Ping", Net_Ping_Request.class),

View File

@ -5,6 +5,7 @@ import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType; import blockchain.MsgSubType;
import blockchain.body.BodyHasLine; import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget; import blockchain.body.BodyHasTarget;
import blockchain.body.CreateChannelBody;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.Base64Ws; import server.logic.ws_protocol.Base64Ws;
@ -25,6 +26,9 @@ import shine.db.entities.BlockEntry;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
import java.util.Arrays; import java.util.Arrays;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
/** /**
@ -128,6 +132,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии"; case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
case "bad_prev_line_hash" -> "Некорректный prevLineHash"; case "bad_prev_line_hash" -> "Некорректный prevLineHash";
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
case "channel_name_already_exists" -> "Канал с таким именем уже существует";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
default -> "Ошибка: " + code; default -> "Ошибка: " + code;
}; };
@ -228,6 +233,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
} }
if (block.body instanceof CreateChannelBody createChannelBody) {
try {
if (channelNameExists(blockchainName, createChannelBody.channelName)) {
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
}
} catch (Exception e) {
log.error("AddBlock: channel_name_check_failed (blockchainName={}, channelName={})",
blockchainName, createChannelBody.channelName, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
}
}
// 4.2) запрет дырок: blockNumber строго last+1 // 4.2) запрет дырок: blockNumber строго last+1
int expectedBlockNumber = serverLastNum + 1; int expectedBlockNumber = serverLastNum + 1;
if (block.blockNumber != expectedBlockNumber) { if (block.blockNumber != expectedBlockNumber) {
@ -378,6 +395,32 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return Base64Ws.decode(b64); return Base64Ws.decode(b64);
} }
private boolean channelNameExists(String blockchainName, String channelName) throws Exception {
String sql = """
SELECT block_bytes
FROM blocks
WHERE bch_name = ? AND msg_type = 0 AND msg_sub_type = 1
""";
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
byte[] bytes = rs.getBytes("block_bytes");
try {
BchBlockEntry entry = new BchBlockEntry(bytes);
if (entry.body instanceof CreateChannelBody ccb) {
if (ccb.channelName.equalsIgnoreCase(channelName)) return true;
}
} catch (Exception ignored) {
// ignore bad historic rows, uniqueness check is best effort
}
}
}
}
return false;
}
private static long safeAdd(long a, long b) { private static long safeAdd(long a, long b) {
long r = a + b; long r = a + b;
if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow"); if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow");

View File

@ -0,0 +1,240 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import blockchain.BchBlockEntry;
import blockchain.body.BodyRecord;
import blockchain.body.CreateChannelBody;
import blockchain.body.TextBody;
import shine.db.MsgSubType;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
final class ChannelsReadSupport {
static final int MSG_TYPE_TEXT = 1;
static final int MSG_TYPE_TECH = 0;
private ChannelsReadSupport() {}
static String canonicalLogin(Connection c, String anyCaseLogin) throws SQLException {
String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, anyCaseLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("login") : null;
}
}
}
static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return "0";
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
byte[] bytes = rs.getBytes("block_bytes");
BchBlockEntry e = new BchBlockEntry(bytes);
BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) return ccb.channelName;
return null;
} catch (Exception ignored) {
return null;
}
}
}
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, lineCode);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
}
}
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
ORDER BY block_number DESC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, lineCode);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PostBlock pb = new PostBlock();
pb.login = rs.getString("login");
pb.bchName = rs.getString("bch_name");
pb.blockNumber = rs.getInt("block_number");
pb.blockHash = rs.getBytes("block_hash");
pb.blockBytes = rs.getBytes("block_bytes");
return pb;
}
}
}
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number DESC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
ps.setString(4, ownerBch);
ps.setInt(5, originalBlockNumber);
ps.setBytes(6, originalHash);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PostBlock pb = new PostBlock();
pb.login = rs.getString("login");
pb.bchName = rs.getString("bch_name");
pb.blockNumber = rs.getInt("block_number");
pb.blockHash = rs.getBytes("block_hash");
pb.blockBytes = rs.getBytes("block_bytes");
return pb;
}
}
}
static TextInfo parseTextAndTime(byte[] blockBytes) {
try {
BchBlockEntry e = new BchBlockEntry(blockBytes);
TextInfo ti = new TextInfo();
ti.createdAtMs = e.timeMs;
if (e.body instanceof TextBody tb) {
ti.text = tb.message;
}
return ti;
} catch (Exception ex) {
return new TextInfo();
}
}
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
String order = asc ? "ASC" : "DESC";
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
ORDER BY block_number """ + order + " LIMIT ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, lineCode);
ps.setInt(5, limit);
try (ResultSet rs = ps.executeQuery()) {
List<PostBlock> out = new ArrayList<>();
while (rs.next()) {
PostBlock pb = new PostBlock();
pb.login = rs.getString("login");
pb.bchName = rs.getString("bch_name");
pb.blockNumber = rs.getInt("block_number");
pb.blockHash = rs.getBytes("block_hash");
pb.blockBytes = rs.getBytes("block_bytes");
out.add(pb);
}
return out;
}
}
}
static List<PostBlock> versionsForPost(Connection c, String ownerBch, int originalBlock, byte[] originalHash) throws SQLException {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number ASC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
ps.setString(4, ownerBch);
ps.setInt(5, originalBlock);
ps.setBytes(6, originalHash);
try (ResultSet rs = ps.executeQuery()) {
List<PostBlock> out = new ArrayList<>();
while (rs.next()) {
PostBlock pb = new PostBlock();
pb.login = rs.getString("login");
pb.bchName = rs.getString("bch_name");
pb.blockNumber = rs.getInt("block_number");
pb.blockHash = rs.getBytes("block_hash");
pb.blockBytes = rs.getBytes("block_bytes");
out.add(pb);
}
return out;
}
}
}
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return new int[] {0, 0};
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")};
}
}
}
static byte[] hexToBytes(String s) {
if (s == null) return null;
String x = s.trim();
if ((x.length() & 1) != 0) throw new IllegalArgumentException("hex length must be even");
byte[] out = new byte[x.length() / 2];
for (int i = 0; i < out.length; i++) {
int hi = Character.digit(x.charAt(i * 2), 16);
int lo = Character.digit(x.charAt(i * 2 + 1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex");
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
static String toHex(byte[] bytes) {
if (bytes == null) return null;
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
static final class PostBlock {
String login;
String bchName;
int blockNumber;
byte[] blockHash;
byte[] blockBytes;
}
static final class TextInfo {
String text = "";
long createdAtMs = 0L;
}
}

View File

@ -0,0 +1,115 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetChannelMessages_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetChannelMessages_Request req = (Net_GetChannelMessages_Request) baseRequest;
if (req.getChannel() == null
|| req.getChannel().getOwnerBlockchainName() == null
|| req.getChannel().getOwnerBlockchainName().isBlank()
|| req.getChannel().getChannelRootBlockNumber() == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
}
int limit = req.getLimit() == null ? 30 : req.getLimit();
if (limit <= 0 || limit > 1000) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
}
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
try (Connection c = SqliteDbController.getInstance().getConnection()) {
String ownerBch = req.getChannel().getOwnerBlockchainName();
int lineCode = req.getChannel().getChannelRootBlockNumber();
Net_GetChannelMessages_Response resp = new Net_GetChannelMessages_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel();
channel.setOwnerBlockchainName(ownerBch);
channel.setOwnerLogin(ownerBch.contains("-") ? ownerBch.substring(0, ownerBch.indexOf('-')) : ownerBch);
channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode));
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(lineCode);
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
channel.setChannelRoot(rootRef);
resp.setChannel(channel);
List<ChannelsReadSupport.PostBlock> posts = ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc);
List<Net_GetChannelMessages_Response.MessageItem> items = new ArrayList<>();
for (ChannelsReadSupport.PostBlock post : posts) {
Net_GetChannelMessages_Response.MessageItem item = new Net_GetChannelMessages_Response.MessageItem();
Net_GetChannelMessages_Response.BlockRef msgRef = new Net_GetChannelMessages_Response.BlockRef();
msgRef.setBlockNumber(post.blockNumber);
msgRef.setBlockHash(ChannelsReadSupport.toHex(post.blockHash));
item.setMessageRef(msgRef);
item.setAuthorLogin(post.login);
item.setAuthorBlockchainName(post.bchName);
List<Net_GetChannelMessages_Response.VersionItem> versionsOut = new ArrayList<>();
int index = 1;
ChannelsReadSupport.TextInfo postText = ChannelsReadSupport.parseTextAndTime(post.blockBytes);
Net_GetChannelMessages_Response.VersionItem v1 = new Net_GetChannelMessages_Response.VersionItem();
v1.setVersionIndex(index++);
v1.setBlockNumber(post.blockNumber);
v1.setBlockHash(ChannelsReadSupport.toHex(post.blockHash));
v1.setText(postText.text);
v1.setCreatedAtMs(postText.createdAtMs);
versionsOut.add(v1);
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, ownerBch, post.blockNumber, post.blockHash);
for (ChannelsReadSupport.PostBlock edit : edits) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem();
ve.setVersionIndex(index++);
ve.setBlockNumber(edit.blockNumber);
ve.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash));
ve.setText(editText.text);
ve.setCreatedAtMs(editText.createdAtMs);
versionsOut.add(ve);
}
item.setVersions(versionsOut);
item.setVersionsTotal(versionsOut.size());
Net_GetChannelMessages_Response.VersionItem lastV = versionsOut.get(versionsOut.size() - 1);
item.setText(lastV.getText());
item.setCreatedAtMs(postText.createdAtMs);
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
item.setLikesCount(stats[0]);
item.setRepliesCount(stats[1]);
items.add(item);
}
resp.setMessages(items);
return resp;
} catch (Exception e) {
log.error("GetChannelMessages failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
}

View File

@ -0,0 +1,224 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.channels.entyties.Net_GetChannelMessages_Response;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.SqliteDbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class Net_GetMessageThread_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageThread_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest;
if (req.getMessage() == null || req.getMessage().getBlockchainName() == null || req.getMessage().getBlockNumber() == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
}
int depthUp = req.getDepthUp() == null ? 20 : Math.max(0, req.getDepthUp());
int depthDown = req.getDepthDown() == null ? 2 : Math.max(0, req.getDepthDown());
int childLimit = req.getLimitChildrenPerNode() == null ? 50 : Math.max(1, req.getLimitChildrenPerNode());
try (Connection c = SqliteDbController.getInstance().getConnection()) {
PostRow focusRow = findByNumber(c, req.getMessage().getBlockchainName(), req.getMessage().getBlockNumber());
if (focusRow == null) {
return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение не найдено");
}
Net_GetMessageThread_Response resp = new Net_GetMessageThread_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setFocus(toNode(c, focusRow));
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
PostRow cur = focusRow;
for (int i = 0; i < depthUp; i++) {
if (cur.toBlockNumber == null || cur.toBchName == null) break;
PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
if (parent == null) break;
ancestors.add(0, toNode(c, parent));
cur = parent;
}
resp.setAncestors(ancestors);
resp.setDescendants(loadChildren(c, focusRow, depthDown, childLimit));
return resp;
} catch (Exception e) {
log.error("GetMessageThread failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit) throws Exception {
if (depthDown <= 0) return List.of();
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
for (PostRow row : replies) {
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
t.setNode(toNode(c, row));
t.setChildren(loadChildren(c, row, depthDown - 1, childLimit));
out.add(t);
}
return out;
}
private List<PostRow> findReplies(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
FROM blocks
WHERE msg_type=1 AND msg_sub_type=?
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number ASC
LIMIT ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, MsgSubType.TEXT_REPLY);
ps.setString(2, toBchName);
ps.setInt(3, toBlockNumber);
ps.setBytes(4, toBlockHash);
ps.setInt(5, limit);
try (ResultSet rs = ps.executeQuery()) {
List<PostRow> out = new ArrayList<>();
while (rs.next()) out.add(mapRow(rs));
return out;
}
}
}
private PostRow findByNumber(Connection c, String bchName, int blockNumber) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
FROM blocks
WHERE bch_name=? AND block_number=?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bchName);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
private PostRow mapRow(ResultSet rs) throws Exception {
PostRow row = new PostRow();
row.login = rs.getString("login");
row.bchName = rs.getString("bch_name");
row.blockNumber = rs.getInt("block_number");
row.blockHash = rs.getBytes("block_hash");
row.blockBytes = rs.getBytes("block_bytes");
row.toBchName = rs.getString("to_bch_name");
row.toBlockNumber = (Integer) rs.getObject("to_block_number");
row.toBlockHash = rs.getBytes("to_block_hash");
row.lineCode = (Integer) rs.getObject("line_code");
row.msgSubType = rs.getInt("msg_sub_type");
return row;
}
private Net_GetMessageThread_Response.MessageNode toNode(Connection c, PostRow row) throws Exception {
Net_GetMessageThread_Response.MessageNode node = new Net_GetMessageThread_Response.MessageNode();
Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef();
ref.setBlockNumber(row.blockNumber);
ref.setBlockHash(ChannelsReadSupport.toHex(row.blockHash));
node.setMessageRef(ref);
node.setAuthorLogin(row.login);
node.setAuthorBlockchainName(row.bchName);
ChannelsReadSupport.TextInfo base = ChannelsReadSupport.parseTextAndTime(row.blockBytes);
node.setCreatedAtMs(base.createdAtMs);
List<Net_GetChannelMessages_Response.VersionItem> versions = new ArrayList<>();
Net_GetChannelMessages_Response.VersionItem first = new Net_GetChannelMessages_Response.VersionItem();
first.setVersionIndex(1);
first.setBlockNumber(row.blockNumber);
first.setBlockHash(ChannelsReadSupport.toHex(row.blockHash));
first.setText(base.text);
first.setCreatedAtMs(base.createdAtMs);
versions.add(first);
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
v.setVersionIndex(versions.size() + 1);
v.setBlockNumber(edit.blockNumber);
v.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash));
v.setText(et.text);
v.setCreatedAtMs(et.createdAtMs);
versions.add(v);
}
node.setVersions(versions);
node.setVersionsTotal(versions.size());
node.setText(versions.get(versions.size() - 1).getText());
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]);
if (row.lineCode != null && row.lineCode >= 0) {
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
ci.setOwnerBlockchainName(row.bchName);
Net_GetChannelMessages_Response.BlockRef root = new Net_GetChannelMessages_Response.BlockRef();
root.setBlockNumber(row.lineCode);
root.setBlockHash(null);
ci.setChannelRoot(root);
node.setChannelInfo(ci);
}
return node;
}
private List<PostRow> findEdits(Connection c, String bch, int targetBlock, byte[] targetHash, int subType) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
FROM blocks
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number ASC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setInt(2, subType);
ps.setString(3, bch);
ps.setInt(4, targetBlock);
ps.setBytes(5, targetHash);
try (ResultSet rs = ps.executeQuery()) {
List<PostRow> out = new ArrayList<>();
while (rs.next()) out.add(mapRow(rs));
return out;
}
}
}
private static final class PostRow {
String login;
String bchName;
int blockNumber;
byte[] blockHash;
byte[] blockBytes;
String toBchName;
Integer toBlockNumber;
byte[] toBlockHash;
Integer lineCode;
int msgSubType;
}
}

View File

@ -0,0 +1,168 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.SqliteDbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_ListSubscriptionsFeed_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_ListSubscriptionsFeed_Request req = (Net_ListSubscriptionsFeed_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login");
}
try (Connection c = SqliteDbController.getInstance().getConnection()) {
String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim());
if (canonicalLogin == null) {
return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден");
}
Net_ListSubscriptionsFeed_Response resp = new Net_ListSubscriptionsFeed_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(canonicalLogin);
List<ChannelKey> own = loadOwnChannels(c, canonicalLogin);
List<ChannelKey> followedUsersChannels = loadFollowedChannels(c, canonicalLogin, true);
List<ChannelKey> followedChannels = loadFollowedChannels(c, canonicalLogin, false);
resp.setOwnedChannels(buildSummaries(c, own));
resp.setFollowedUsersChannels(buildSummaries(c, followedUsersChannels));
resp.setFollowedChannels(buildSummaries(c, followedChannels));
return resp;
} catch (Exception e) {
log.error("ListSubscriptionsFeed failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private List<Net_ListSubscriptionsFeed_Response.ChannelSummary> buildSummaries(Connection c, List<ChannelKey> keys) throws Exception {
List<Net_ListSubscriptionsFeed_Response.ChannelSummary> out = new ArrayList<>();
for (ChannelKey key : keys) {
Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary();
Net_ListSubscriptionsFeed_Response.ChannelRef channelRef = new Net_ListSubscriptionsFeed_Response.ChannelRef();
channelRef.setOwnerLogin(key.ownerLogin);
channelRef.setOwnerBlockchainName(key.ownerBch);
channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber));
channelRef.setPersonal(key.rootNumber == 0);
Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
rootRef.setBlockNumber(key.rootNumber);
rootRef.setBlockHash(ChannelsReadSupport.toHex(key.rootHash));
channelRef.setChannelRoot(rootRef);
row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) {
ChannelsReadSupport.PostBlock actual = ChannelsReadSupport.loadLastVersion(c, key.ownerBch, lastPost.blockNumber, lastPost.blockHash);
if (actual == null) actual = lastPost;
ChannelsReadSupport.TextInfo textInfo = ChannelsReadSupport.parseTextAndTime(actual.blockBytes);
Net_ListSubscriptionsFeed_Response.LastMessage lm = new Net_ListSubscriptionsFeed_Response.LastMessage();
Net_ListSubscriptionsFeed_Response.BlockRef msgRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
msgRef.setBlockNumber(actual.blockNumber);
msgRef.setBlockHash(ChannelsReadSupport.toHex(actual.blockHash));
lm.setMessageRef(msgRef);
lm.setText(textInfo.text);
lm.setCreatedAtMs(textInfo.createdAtMs);
lm.setAuthorLogin(actual.login);
lm.setAuthorBlockchainName(actual.bchName);
row.setLastMessage(lm);
}
out.add(row);
}
return out;
}
private List<ChannelKey> loadOwnChannels(Connection c, String canonicalLogin) throws Exception {
List<ChannelKey> out = new ArrayList<>();
String bchSql = "SELECT blockchain_name FROM blockchain_state WHERE login=? ORDER BY blockchain_name";
try (PreparedStatement bchPs = c.prepareStatement(bchSql)) {
bchPs.setString(1, canonicalLogin);
try (ResultSet bchRs = bchPs.executeQuery()) {
while (bchRs.next()) {
String bch = bchRs.getString("blockchain_name");
out.add(new ChannelKey(canonicalLogin, bch, 0, new byte[32]));
String chSql = "SELECT block_number,block_hash FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type=? ORDER BY block_number";
try (PreparedStatement chPs = c.prepareStatement(chSql)) {
chPs.setString(1, bch);
chPs.setInt(2, ChannelsReadSupport.MSG_TYPE_TECH);
chPs.setInt(3, 1);
try (ResultSet chRs = chPs.executeQuery()) {
while (chRs.next()) {
out.add(new ChannelKey(canonicalLogin, bch, chRs.getInt("block_number"), chRs.getBytes("block_hash")));
}
}
}
}
}
}
return out;
}
private List<ChannelKey> loadFollowedChannels(Connection c, String canonicalLogin, boolean onlyUserRoots) throws Exception {
List<ChannelKey> out = new ArrayList<>();
String sql = """
SELECT cs.to_login, cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number, cs.to_block_hash
FROM connections_state cs
WHERE cs.login=? AND cs.rel_type=?
ORDER BY cs.to_login, cs.to_bch_name, root_number
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, canonicalLogin);
ps.setInt(2, MsgSubType.CONNECTION_FOLLOW);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
int rootNumber = rs.getInt("root_number");
if (onlyUserRoots && rootNumber != 0) continue;
if (!onlyUserRoots && rootNumber == 0) continue;
out.add(new ChannelKey(
rs.getString("to_login"),
rs.getString("to_bch_name"),
rootNumber,
rs.getBytes("to_block_hash")
));
}
}
}
return out;
}
private static final class ChannelKey {
final String ownerLogin;
final String ownerBch;
final int rootNumber;
final byte[] rootHash;
private ChannelKey(String ownerLogin, String ownerBch, int rootNumber, byte[] rootHash) {
this.ownerLogin = ownerLogin;
this.ownerBch = ownerBch;
this.rootNumber = rootNumber;
this.rootHash = rootHash;
}
}
}

View File

@ -0,0 +1,33 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetChannelMessages_Request extends Net_Request {
private ChannelSelector channel;
private Integer limit;
private String sort;
public ChannelSelector getChannel() { return channel; }
public void setChannel(ChannelSelector channel) { this.channel = channel; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public String getSort() { return sort; }
public void setSort(String sort) { this.sort = sort; }
public static class ChannelSelector {
private String ownerBlockchainName;
private Integer channelRootBlockNumber;
private String channelRootBlockHash;
public String getOwnerBlockchainName() { return ownerBlockchainName; }
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
public Integer getChannelRootBlockNumber() { return channelRootBlockNumber; }
public void setChannelRootBlockNumber(Integer channelRootBlockNumber) { this.channelRootBlockNumber = channelRootBlockNumber; }
public String getChannelRootBlockHash() { return channelRootBlockHash; }
public void setChannelRootBlockHash(String channelRootBlockHash) { this.channelRootBlockHash = channelRootBlockHash; }
}
}

View File

@ -0,0 +1,109 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_GetChannelMessages_Response extends Net_Response {
private Channel channel;
private List<MessageItem> messages = new ArrayList<>();
public Channel getChannel() { return channel; }
public void setChannel(Channel channel) { this.channel = channel; }
public List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
public static class Channel {
private String ownerLogin;
private String ownerBlockchainName;
private String channelName;
private BlockRef channelRoot;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getOwnerBlockchainName() { return ownerBlockchainName; }
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
public BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
}
public static class MessageItem {
private BlockRef messageRef;
private String authorLogin;
private String authorBlockchainName;
private long createdAtMs;
private String text;
private int likesCount;
private int repliesCount;
private int versionsTotal;
private List<VersionItem> versions = new ArrayList<>();
public BlockRef getMessageRef() { return messageRef; }
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
public String getAuthorLogin() { return authorLogin; }
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
public String getAuthorBlockchainName() { return authorBlockchainName; }
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public int getLikesCount() { return likesCount; }
public void setLikesCount(int likesCount) { this.likesCount = likesCount; }
public int getRepliesCount() { return repliesCount; }
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
public int getVersionsTotal() { return versionsTotal; }
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
public List<VersionItem> getVersions() { return versions; }
public void setVersions(List<VersionItem> versions) { this.versions = versions; }
}
public static class VersionItem {
private int versionIndex;
private int blockNumber;
private String blockHash;
private String text;
private long createdAtMs;
public int getVersionIndex() { return versionIndex; }
public void setVersionIndex(int versionIndex) { this.versionIndex = versionIndex; }
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
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; }
}
public static class BlockRef {
private int blockNumber;
private String blockHash;
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
}
}

View File

@ -0,0 +1,37 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetMessageThread_Request extends Net_Request {
private MessageSelector message;
private Integer depthUp;
private Integer depthDown;
private Integer limitChildrenPerNode;
public MessageSelector getMessage() { return message; }
public void setMessage(MessageSelector message) { this.message = message; }
public Integer getDepthUp() { return depthUp; }
public void setDepthUp(Integer depthUp) { this.depthUp = depthUp; }
public Integer getDepthDown() { return depthDown; }
public void setDepthDown(Integer depthDown) { this.depthDown = depthDown; }
public Integer getLimitChildrenPerNode() { return limitChildrenPerNode; }
public void setLimitChildrenPerNode(Integer limitChildrenPerNode) { this.limitChildrenPerNode = limitChildrenPerNode; }
public static class MessageSelector {
private String blockchainName;
private Integer blockNumber;
private String blockHash;
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public Integer getBlockNumber() { return blockNumber; }
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
}
}

View File

@ -0,0 +1,50 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_GetMessageThread_Response extends Net_Response {
private List<MessageNode> ancestors = new ArrayList<>();
private MessageNode focus;
private List<MessageNodeTree> descendants = new ArrayList<>();
public List<MessageNode> getAncestors() { return ancestors; }
public void setAncestors(List<MessageNode> ancestors) { this.ancestors = ancestors; }
public MessageNode getFocus() { return focus; }
public void setFocus(MessageNode focus) { this.focus = focus; }
public List<MessageNodeTree> getDescendants() { return descendants; }
public void setDescendants(List<MessageNodeTree> descendants) { this.descendants = descendants; }
public static class MessageNode extends Net_GetChannelMessages_Response.MessageItem {
private ChannelInfo channelInfo;
public ChannelInfo getChannelInfo() { return channelInfo; }
public void setChannelInfo(ChannelInfo channelInfo) { this.channelInfo = channelInfo; }
}
public static class ChannelInfo {
private String ownerBlockchainName;
private Net_GetChannelMessages_Response.BlockRef channelRoot;
public String getOwnerBlockchainName() { return ownerBlockchainName; }
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
public Net_GetChannelMessages_Response.BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(Net_GetChannelMessages_Response.BlockRef channelRoot) { this.channelRoot = channelRoot; }
}
public static class MessageNodeTree {
private MessageNode node;
private List<MessageNodeTree> children = new ArrayList<>();
public MessageNode getNode() { return node; }
public void setNode(MessageNode node) { this.node = node; }
public List<MessageNodeTree> getChildren() { return children; }
public void setChildren(List<MessageNodeTree> children) { this.children = children; }
}
}

View File

@ -0,0 +1,14 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_ListSubscriptionsFeed_Request extends Net_Request {
private String login;
private Integer limit;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
}

View File

@ -0,0 +1,97 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_ListSubscriptionsFeed_Response extends Net_Response {
private String login;
private List<ChannelSummary> ownedChannels = new ArrayList<>();
private List<ChannelSummary> followedUsersChannels = new ArrayList<>();
private List<ChannelSummary> followedChannels = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<ChannelSummary> getOwnedChannels() { return ownedChannels; }
public void setOwnedChannels(List<ChannelSummary> ownedChannels) { this.ownedChannels = ownedChannels; }
public List<ChannelSummary> getFollowedUsersChannels() { return followedUsersChannels; }
public void setFollowedUsersChannels(List<ChannelSummary> followedUsersChannels) { this.followedUsersChannels = followedUsersChannels; }
public List<ChannelSummary> getFollowedChannels() { return followedChannels; }
public void setFollowedChannels(List<ChannelSummary> followedChannels) { this.followedChannels = followedChannels; }
public static class ChannelSummary {
private ChannelRef channel;
private int messagesCount;
private LastMessage lastMessage;
public ChannelRef getChannel() { return channel; }
public void setChannel(ChannelRef channel) { this.channel = channel; }
public int getMessagesCount() { return messagesCount; }
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
public LastMessage getLastMessage() { return lastMessage; }
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
}
public static class ChannelRef {
private String ownerLogin;
private String ownerBlockchainName;
private String channelName;
private boolean personal;
private BlockRef channelRoot;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getOwnerBlockchainName() { return ownerBlockchainName; }
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
public boolean isPersonal() { return personal; }
public void setPersonal(boolean personal) { this.personal = personal; }
public BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
}
public static class LastMessage {
private BlockRef messageRef;
private String text;
private long createdAtMs;
private String authorLogin;
private String authorBlockchainName;
public BlockRef getMessageRef() { return messageRef; }
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
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; }
public String getAuthorLogin() { return authorLogin; }
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
public String getAuthorBlockchainName() { return authorBlockchainName; }
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
}
public static class BlockRef {
private int blockNumber;
private String blockHash;
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
}
}