Да вроде всё работает и тесты проходят.

И блоки добавляются все что надо для MVP
This commit is contained in:
AidarKC 2026-01-22 01:57:02 +03:00
parent 97840a45d6
commit 3f5f94a53f
5 changed files with 566 additions and 310 deletions

View File

@ -1,5 +1,7 @@
package blockchain.body; package blockchain.body;
import blockchain.MsgSubType;
/** /**
* Парсер body выбирает класс по header: type/subType/version, * Парсер body выбирает класс по header: type/subType/version,
* потому что bodyBytes больше НЕ содержат type/subType/version. * потому что bodyBytes больше НЕ содержат type/subType/version.
@ -28,7 +30,23 @@ public final class BodyRecordParser {
throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st); throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st);
} }
case TextBody.KEY -> new TextBody(subType, version, bodyBytes); // TEXT type=1 ver=1: выбираем класс по subType
case TextBody.KEY -> {
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
yield new TextLineBody(subType, version, bodyBytes);
}
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
yield new TextReplyBody(subType, version, bodyBytes);
}
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
}
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes); case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes); case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes); case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);

View File

@ -0,0 +1,265 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TextLineBody type=1, ver=1.
*
* subType:
* - POST (10)
* - EDIT_POST (11)
*
* Формат bodyBytes (BigEndian):
*
* POST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* EDIT_POST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
*/
public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // из header (=1)
// line
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32; // 32 (может быть нули)
public final int thisLineNumber;
// target (только для EDIT_POST)
public final Integer toBlockGlobalNumber; // nullable для POST
public final byte[] toBlockHash32; // nullable для POST
// text
public final String message;
/* ====================== parse from bytes ====================== */
public TextLineBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody version must be 1, got=" + (this.version & 0xFFFF));
}
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST, got subType=" + st);
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
// минимум line + textLen(2)
ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short");
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// нужен target
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
int tgtNum = bb.getInt();
byte[] tgtHash = new byte[32];
bb.get(tgtHash);
this.toBlockGlobalNumber = tgtNum;
this.toBlockHash32 = tgtHash;
} else {
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
this.message = readStrictUtf8Len16(bb, "TextLineBody text");
ensureNoTail(bb, "TextLineBody");
}
/* ====================== manual ctor ====================== */
public TextLineBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
short subType,
Integer toBlockGlobalNumber,
byte[] toBlockHash32,
String message) {
Objects.requireNonNull(message, "message == null");
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST");
}
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = subType;
this.version = VER;
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
this.thisLineNumber = thisLineNumber;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else {
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
this.message = message;
}
@Override
public TextLineBody check() {
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF))
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (prevLineHash32 == null || prevLineHash32.length != 32)
throw new IllegalArgumentException("prevLineHash32 invalid");
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
} else {
if (toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST must not contain target fields");
}
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
int cap;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else {
// EDIT_POST
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
}
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
}
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; }
@Override public int prevLineNumber() { return prevLineNumber; }
@Override public byte[] prevLineHash32() { return Arrays.copyOf(prevLineHash32, 32); }
@Override public int thisLineNumber() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return null; } // по ТЗ: не хранить
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
/* ====================== helpers ====================== */
public boolean isEditPost() {
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
}
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}

View File

@ -0,0 +1,244 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TextReplyBody type=1, ver=1.
*
* subType:
* - REPLY (20)
* - EDIT_REPLY (21)
*
* Форматы bodyBytes (BigEndian):
*
* REPLY:
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*
* EDIT_REPLY:
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
*/
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // (=1)
// target
public final String toBlockchainName; // nullable для EDIT_REPLY
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; // 32
// text
public final String message;
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
}
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
} else {
// EDIT_REPLY: target без имени
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
this.toBlockchainName = null;
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
}
this.message = readStrictUtf8Len16(bb, "TextReplyBody text");
ensureNoTail(bb, "TextReplyBody");
}
public TextReplyBody(short subType,
int toBlockGlobalNumber,
byte[] toBlockHash32,
String toBlockchainName,
String message) {
Objects.requireNonNull(message, "message == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
}
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
this.toBlockchainName = toBlockchainName;
} else {
// EDIT_REPLY: имя не хранить
this.toBlockchainName = null;
}
this.subType = subType;
this.version = VER;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public TextReplyBody check() {
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (toBlockGlobalNumber < 0)
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
} else {
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
}
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255)
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
// EDIT_REPLY
int cap = (4 + 32) + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
/* ====================== BodyHasTarget ====================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
public boolean isEditReply() {
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
}
/* ====================== helpers ====================== */
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
}

View File

@ -14,17 +14,17 @@ import java.sql.Statement;
/** /**
* DatabaseInitializer создание новой SQLite-БД по схеме SHiNE. * DatabaseInitializer создание новой SQLite-БД по схеме SHiNE.
* *
* Таблицы: * В этой версии:
* - solana_users * - создаём ТОЛЬКО таблицы/индексы
* - active_sessions * - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st)
* - users_params *
* - ip_geo_cache * Зачем так:
* - blockchain_state * - триггеры часто ломают совместимость с внешними SQLite-просмотрщиками/сборками
* - blocks * - проще поддерживать/мигрировать
* - connections_state
* - message_stats
*/ */
public class DatabaseInitializer { public final class DatabaseInitializer {
private DatabaseInitializer() {}
/* ===================== TEXT (msg_type=1) ===================== */ /* ===================== TEXT (msg_type=1) ===================== */
@ -46,7 +46,6 @@ public class DatabaseInitializer {
public static final short REACTION_LIKE = 1; public static final short REACTION_LIKE = 1;
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== CONNECTION (msg_type=3) ===================== */
// Приведено к твоему shine.db.MsgSubType:
// FRIEND=10/11, CONTACT=20/21, FOLLOW=30/31 // FRIEND=10/11, CONTACT=20/21, FOLLOW=30/31
public static final short CONNECTION_FRIEND = 10; public static final short CONNECTION_FRIEND = 10;
public static final short CONNECTION_UNFRIEND = 11; public static final short CONNECTION_UNFRIEND = 11;
@ -264,138 +263,6 @@ public class DatabaseInitializer {
ON blocks (bch_name, line_code, this_line_number); ON blocks (bch_name, line_code, this_line_number);
"""); """);
// 6.1) TRIGGER: проверка целостности линии (только если line-поля реально переданы)
/* пока просто отключил этот тригер
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
BEFORE INSERT ON blocks
WHEN
NEW.line_code IS NOT NULL
OR NEW.prev_line_number IS NOT NULL
OR NEW.prev_line_hash IS NOT NULL
OR NEW.this_line_number IS NOT NULL
BEGIN
-- ============================================================
-- LINE-INTEGRITY (BodyHasLine)
--
-- Этот триггер срабатывает ТОЛЬКО если при вставке передали хотя бы одно line-поле.
--
-- Типы, которые МОГУТ быть линейными (BodyHasLine в коде проекта):
-- - TECH (msg_type=0): CreateChannelBody (и т.п. тех-блоки с линией)
-- - TEXT (msg_type=1): TextBody в режиме линии (пост/редактирование поста в канале)
-- - CONNECTION (msg_type=3): ConnectionBody
-- - USER_PARAM (msg_type=4): UserParamBody
--
-- Проверки:
-- 1) Если передали line-поля -> обязаны передать ВСЕ 4:
-- line_code, prev_line_number, prev_line_hash, this_line_number.
-- 2) prev блок линии существует и p.block_hash == NEW.prev_line_hash
-- 3) line_code корректный:
-- - либо NEW.prev_line_number == NEW.line_code (первый шаг после root),
-- - либо у prev блока p.line_code == NEW.line_code
-- 4) this_line_number корректный:
-- - первый шаг после root:
-- TEXT: this=0
-- TECH/CONNECTION/USER_PARAM: this=1
-- - дальше:
-- TEXT: допускаем this = prev.this или prev.this + 1
-- TECH/CONNECTION/USER_PARAM: строго this = prev.this + 1
--
-- Ошибки: RAISE(ABORT, 'LINE_ERR_...') чтобы Java могла понять причину.
-- ============================================================
-- 0) line-поля нельзя у неожиданных типов
SELECT RAISE(ABORT,
'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE: msg_type=' || NEW.msg_type || ' msg_sub_type=' || NEW.msg_sub_type
)
WHERE NOT (NEW.msg_type IN (0, 1, 3, 4));
-- 1) line-поля должны быть заполнены полностью (без частично)
SELECT RAISE(ABORT,
'LINE_ERR_PARTIAL_FIELDS: all of (line_code, prev_line_number, prev_line_hash, this_line_number) must be NOT NULL'
)
WHERE NEW.line_code IS NULL
OR NEW.prev_line_number IS NULL
OR NEW.prev_line_hash IS NULL
OR NEW.this_line_number IS NULL;
-- 2) prev существует?
SELECT RAISE(ABORT,
'LINE_ERR_NO_PREV: bch=' || NEW.bch_name || ' block=' || NEW.block_number || ' prev=' || NEW.prev_line_number
)
WHERE NOT EXISTS(
SELECT 1
FROM blocks p
WHERE p.bch_name = NEW.bch_name
AND p.block_number = NEW.prev_line_number
LIMIT 1
);
-- 3) prev hash совпадает?
SELECT RAISE(ABORT,
'LINE_ERR_PREV_HASH_MISMATCH: bch=' || NEW.bch_name || ' block=' || NEW.block_number || ' prev=' || NEW.prev_line_number
)
WHERE NOT EXISTS(
SELECT 1
FROM blocks p
WHERE p.bch_name = NEW.bch_name
AND p.block_number = NEW.prev_line_number
AND p.block_hash = NEW.prev_line_hash
LIMIT 1
);
-- 4) line_code корректный:
-- либо это первый шаг после root (prev_line_number == line_code),
-- либо prev уже в этой линии (p.line_code == NEW.line_code).
SELECT RAISE(ABORT,
'LINE_ERR_LINE_CODE_MISMATCH: bch=' || NEW.bch_name || ' block=' || NEW.block_number ||
' line_code=' || NEW.line_code || ' prev=' || NEW.prev_line_number
)
WHERE NEW.prev_line_number <> NEW.line_code
AND NOT EXISTS(
SELECT 1
FROM blocks p
WHERE p.bch_name = NEW.bch_name
AND p.block_number = NEW.prev_line_number
AND p.line_code = NEW.line_code
LIMIT 1
);
-- 5) первый шаг после root: this_line_number
SELECT RAISE(ABORT,
'LINE_ERR_FIRST_STEP_BAD_THIS: expected this_line_number=0 for TEXT or =1 for other types'
)
WHERE NEW.prev_line_number = NEW.line_code
AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END);
-- 6) обычный шаг: this_line_number относительно prev
SELECT RAISE(ABORT,
'LINE_ERR_THIS_LINE_BAD_STEP: bch=' || NEW.bch_name || ' block=' || NEW.block_number ||
' this=' || NEW.this_line_number || ' prev=' || NEW.prev_line_number
)
WHERE NEW.prev_line_number <> NEW.line_code
AND NOT EXISTS(
SELECT 1
FROM blocks p
WHERE p.bch_name = NEW.bch_name
AND p.block_number = NEW.prev_line_number
AND p.this_line_number IS NOT NULL
AND (
-- TEXT: допускаем same или +1 (поддерживает edit не увеличивает thisLineNumber)
(NEW.msg_type = 1 AND
(NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1)
)
OR
-- TECH/CONNECTION/USER_PARAM: строго +1
(NEW.msg_type IN (0,3,4) AND
NEW.this_line_number = p.this_line_number + 1
)
)
LIMIT 1
);
END;
""");
*/
// 7) connections_state // 7) connections_state
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS connections_state ( CREATE TABLE IF NOT EXISTS connections_state (
@ -427,59 +294,7 @@ public class DatabaseInitializer {
ON connections_state (login, to_login); ON connections_state (login, to_login);
"""); """);
// 8) Trigger: connection state // 8) message_stats
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 3
BEGIN
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
)
SELECT
NEW.login,
NEW.msg_sub_type,
NEW.to_login,
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash
WHERE NEW.msg_sub_type IN (%d, %d, %d)
AND NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
ON CONFLICT(login, rel_type, to_login)
DO UPDATE SET
to_bch_name = excluded.to_bch_name,
to_block_number = excluded.to_block_number,
to_block_hash = excluded.to_block_hash;
DELETE FROM connections_state
WHERE login = NEW.login
AND to_login = NEW.to_login
AND rel_type = CASE NEW.msg_sub_type
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
ELSE rel_type
END
AND NEW.msg_sub_type IN (%d, %d, %d);
END;
""".formatted(
(int) CONNECTION_FRIEND,
(int) CONNECTION_CONTACT,
(int) CONNECTION_FOLLOW,
(int) CONNECTION_UNFRIEND, (int) CONNECTION_FRIEND,
(int) CONNECTION_UNCONTACT, (int) CONNECTION_CONTACT,
(int) CONNECTION_UNFOLLOW, (int) CONNECTION_FOLLOW,
(int) CONNECTION_UNFRIEND,
(int) CONNECTION_UNCONTACT,
(int) CONNECTION_UNFOLLOW
));
// 9) message_stats
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS message_stats ( CREATE TABLE IF NOT EXISTS message_stats (
to_login TEXT NOT NULL, to_login TEXT NOT NULL,
@ -510,110 +325,8 @@ public class DatabaseInitializer {
ON message_stats (to_login); ON message_stats (to_login);
"""); """);
// 10) Trigger: LIKE // ВАЖНО: триггеры ставим отдельно
st.executeUpdate(""" DatabaseTriggersInstaller.createAllTriggers(st);
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type = %d
BEGIN
INSERT INTO message_stats (
to_login,
to_bch_name,
to_block_number,
to_block_hash,
likes_count,
replies_count,
edits_count
)
SELECT
NEW.to_login,
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
1,
0,
0
WHERE NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL
ON CONFLICT(to_login, to_bch_name, to_block_number, to_block_hash)
DO UPDATE SET
likes_count = message_stats.likes_count + 1;
END;
""".formatted((int) REACTION_LIKE));
// 11) Trigger: REPLY
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d
BEGIN
INSERT INTO message_stats (
to_login,
to_bch_name,
to_block_number,
to_block_hash,
likes_count,
replies_count,
edits_count
)
SELECT
NEW.to_login,
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
0,
1,
0
WHERE NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL
ON CONFLICT(to_login, to_bch_name, to_block_number, to_block_hash)
DO UPDATE SET
replies_count = message_stats.replies_count + 1;
END;
""".formatted((int) TEXT_REPLY));
// 12) Trigger: EDIT
st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d
BEGIN
UPDATE blocks
SET edited_by_block_number = NEW.block_number
WHERE login = NEW.login
AND bch_name = NEW.bch_name
AND block_number = NEW.to_block_number;
INSERT INTO message_stats (
to_login,
to_bch_name,
to_block_number,
to_block_hash,
likes_count,
replies_count,
edits_count
)
SELECT
NEW.to_login,
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
0,
0,
1
WHERE NEW.to_login IS NOT NULL
AND NEW.to_bch_name IS NOT NULL
AND NEW.to_block_number IS NOT NULL
AND NEW.to_block_hash IS NOT NULL
ON CONFLICT(to_login, to_bch_name, to_block_number, to_block_hash)
DO UPDATE SET
edits_count = message_stats.edits_count + 1;
END;
""".formatted((int) TEXT_EDIT));
} }
} }
} }

View File

@ -2,6 +2,7 @@ package server.logic.ws_protocol.JSON.handlers.blockchain;
import blockchain.BchBlockEntry; import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier; import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType;
import blockchain.body.BodyHasLine; import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget; import blockchain.body.BodyHasTarget;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -33,13 +34,12 @@ import java.util.concurrent.locks.ReentrantLock;
* 2) Проверяем: * 2) Проверяем:
* - incoming.blockNumber == last+1 * - incoming.blockNumber == last+1
* - incoming.prevHash32 == last_hash (для genesis last_hash = 32 нулей) * - incoming.prevHash32 == last_hash (для genesis last_hash = 32 нулей)
* 3) Считаем hash32 = SHA-256(preimage) (preimage = block_bytes без signature64) * 3) Проверяем подпись Ed25519.verify(hash32(preimage), signature64, pubKey)
* 4) Проверяем подпись Ed25519.verify(hash32, signature64, pubKey) * 4) Если тип имеет линию:
* 5) Если тип имеет линию: * - если prevLineNumber != null:
* - если prevLineNumber != -1:
* достаём hash блока prevLineNumber из blocks * достаём hash блока prevLineNumber из blocks
* сравниваем с prevLineHash32 из body * сравниваем с prevLineHash32 из body
* 6) Сохраняем блок в blocks + обновляем blockchain_state * 5) Сохраняем блок в blocks + обновляем blockchain_state
* *
* Важно: * Важно:
* - Сетевой протокол AddBlock пока оставляем старые поля (globalNumber/prevGlobalHash), * - Сетевой протокол AddBlock пока оставляем старые поля (globalNumber/prevGlobalHash),
@ -224,17 +224,27 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex);
} }
// 7) линейная проверка (только для типов с линией) // 7) line columns (only for BodyHasLine)
Integer lineCode = null;
Integer prevLineNumber = null; Integer prevLineNumber = null;
byte[] prevLineHash32 = null; byte[] prevLineHash32 = null;
Integer thisLineNumber = null; Integer thisLineNumber = null;
if (block.body instanceof BodyHasLine bl) { if (block.body instanceof BodyHasLine bl) {
lineCode = bl.lineCode();
prevLineNumber = bl.prevLineNumber(); prevLineNumber = bl.prevLineNumber();
prevLineHash32 = bl.prevLineHash32(); prevLineHash32 = bl.prevLineHash32();
thisLineNumber = bl.thisLineNumber(); thisLineNumber = bl.thisLineNumber();
if (prevLineNumber != null && prevLineNumber != -1) { // Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
if (prevLineNumber != null && prevLineNumber == -1) {
prevLineNumber = null;
prevLineHash32 = null;
thisLineNumber = null;
}
// Если prevLineNumber задан проверяем его хэш
if (prevLineNumber != null) {
try { try {
byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber); byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber);
if (dbPrevHash == null) { if (dbPrevHash == null) {
@ -270,6 +280,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
be.setBlockSignature(block.getSignature64()); be.setBlockSignature(block.getSignature64());
// line columns (optional) // line columns (optional)
be.setLineCode(lineCode);
be.setPrevLineNumber(prevLineNumber); be.setPrevLineNumber(prevLineNumber);
be.setPrevLineHash(prevLineHash32); be.setPrevLineHash(prevLineHash32);
be.setThisLineNumber(thisLineNumber); be.setThisLineNumber(thisLineNumber);
@ -282,8 +293,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
be.setToBlockHash(t.toBlockHashBytes()); be.setToBlockHash(t.toBlockHashBytes());
} }
// edit helper (optional): если TEXT_EDIT это "редактирование блока цели" // edit helper (optional): если TEXT_EDIT_* это "редактирование блока цели"
if ((block.type & 0xFFFF) == 1 && (block.subType & 0xFFFF) == 10 && be.getToBlockNumber() != null) { int type = block.type & 0xFFFF;
int sub = block.subType & 0xFFFF;
if (type == 1
&& (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
&& be.getToBlockNumber() != null) {
be.setEditedByBlockNumber(be.getToBlockNumber()); be.setEditedByBlockNumber(be.getToBlockNumber());
} }