02 01 25
Добавил поле subType и исправил мелкие баги (все тесты работают) Дальше делать: Описание форматов. Запросы клиент-сервер. Промт на клиента. --- Потом в сервак дописать Синхронизацию серверов.
This commit is contained in:
parent
71f1a6179c
commit
ca55bfca93
@ -22,6 +22,12 @@ package blockchain.body;
|
|||||||
* ДОПОЛНЕНИЕ (ЛИНИИ):
|
* ДОПОЛНЕНИЕ (ЛИНИИ):
|
||||||
* - Каждый тип body знает, в какой lineIndex он ДОЛЖЕН находиться.
|
* - Каждый тип body знает, в какой lineIndex он ДОЛЖЕН находиться.
|
||||||
* Это проверяется в валидаторе блока (уровень B).
|
* Это проверяется в валидаторе блока (уровень B).
|
||||||
|
*
|
||||||
|
* ДОПОЛНЕНИЕ (SUBTYPE):
|
||||||
|
* - У каждого body есть subType (uint16).
|
||||||
|
* - Для HeaderBody он всегда 0 (служебная совместимость).
|
||||||
|
* - Для TextBody это тип сообщения (NEW/REPLY/REPOST).
|
||||||
|
* - Для ReactionBody это тип реакции (LIKE и т.п.).
|
||||||
*/
|
*/
|
||||||
public interface BodyRecord {
|
public interface BodyRecord {
|
||||||
|
|
||||||
@ -31,6 +37,11 @@ public interface BodyRecord {
|
|||||||
/** Версия формата записи (совпадает с version в bodyBytes). */
|
/** Версия формата записи (совпадает с version в bodyBytes). */
|
||||||
short version();
|
short version();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подтип записи (uint16).
|
||||||
|
*/
|
||||||
|
short subType();
|
||||||
|
|
||||||
/** Ожидаемый индекс линии для этого body. */
|
/** Ожидаемый индекс линии для этого body. */
|
||||||
short expectedLineIndex();
|
short expectedLineIndex();
|
||||||
|
|
||||||
@ -39,7 +50,7 @@ public interface BodyRecord {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Сериализовать тело записи в байты (ровно то, что кладётся в block.body).
|
* Сериализовать тело записи в байты (ровно то, что кладётся в block.body).
|
||||||
* Важно: включает type/version.
|
* Важно: включает type/version/subType и весь payload.
|
||||||
*/
|
*/
|
||||||
byte[] toBytes();
|
byte[] toBytes();
|
||||||
}
|
}
|
||||||
@ -8,10 +8,15 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* HeaderBody — type=0, version=1.
|
* HeaderBody — type=0, version=1.
|
||||||
*
|
*
|
||||||
* Полный bodyBytes:
|
* Полный bodyBytes (BigEndian):
|
||||||
* [2] type=0
|
* [2] type=0
|
||||||
* [2] version=1
|
* [2] version=1
|
||||||
* [8] tag ASCII "SHiNE"
|
*
|
||||||
|
* [2] subType (uint16) = 0
|
||||||
|
* (служебное поле для совместимости с единым форматом body,
|
||||||
|
* чтобы ВСЕ body имели subType одинаковым способом)
|
||||||
|
*
|
||||||
|
* [5] tag ASCII "SHiNE"
|
||||||
* [1] loginLength=N (uint8)
|
* [1] loginLength=N (uint8)
|
||||||
* [N] login UTF-8
|
* [N] login UTF-8
|
||||||
*
|
*
|
||||||
@ -23,23 +28,33 @@ public final class HeaderBody implements BodyRecord {
|
|||||||
public static final short TYPE = 0;
|
public static final short TYPE = 0;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
|
/** Для header всегда 0 (служебная совместимость). */
|
||||||
|
public static final short SUBTYPE_COMPAT = 0;
|
||||||
|
|
||||||
public static final String TAG = "SHiNE";
|
public static final String TAG = "SHiNE";
|
||||||
|
|
||||||
public final String tag; // "SHiNE"
|
public final short subType; // всегда 0
|
||||||
|
public final String tag; // "SHiNE"
|
||||||
public final String login;
|
public final String login;
|
||||||
|
|
||||||
/** Десериализация из полного bodyBytes (включая type/version). */
|
/** Десериализация из полного bodyBytes (включая type/version/subType). */
|
||||||
public HeaderBody(byte[] bodyBytes) {
|
public HeaderBody(byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
if (bodyBytes.length < 4) throw new IllegalArgumentException("HeaderBody too short (<4)");
|
if (bodyBytes.length < 4 + 2) throw new IllegalArgumentException("HeaderBody too short (<6)");
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
short type = bb.getShort();
|
short type = bb.getShort();
|
||||||
short ver = bb.getShort();
|
short ver = bb.getShort();
|
||||||
if (type != TYPE || ver != VER)
|
if (type != TYPE || ver != VER)
|
||||||
throw new IllegalArgumentException("Not HeaderBody: type=" + type + " ver=" + ver);
|
throw new IllegalArgumentException("Not HeaderBody: type=" + type + " ver=" + ver);
|
||||||
|
|
||||||
if (bb.remaining() < 8 + 1)
|
this.subType = bb.getShort();
|
||||||
|
if (this.subType != SUBTYPE_COMPAT)
|
||||||
|
throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF));
|
||||||
|
|
||||||
|
// дальше: tag[5] + loginLen[1] минимум
|
||||||
|
if (bb.remaining() < 5 + 1)
|
||||||
throw new IllegalArgumentException("Header payload too short");
|
throw new IllegalArgumentException("Header payload too short");
|
||||||
|
|
||||||
byte[] tagBytes = new byte[5];
|
byte[] tagBytes = new byte[5];
|
||||||
@ -55,17 +70,24 @@ public final class HeaderBody implements BodyRecord {
|
|||||||
byte[] loginBytes = new byte[loginLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(loginBytes);
|
bb.get(loginBytes);
|
||||||
this.login = new String(loginBytes, StandardCharsets.UTF_8);
|
this.login = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// запрещаем мусор в конце
|
||||||
|
if (bb.remaining() != 0) {
|
||||||
|
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создание “вручную” (для генерации первого блока). */
|
/** Создание “вручную” (для генерации первого блока). */
|
||||||
public HeaderBody(String login) {
|
public HeaderBody(String login) {
|
||||||
Objects.requireNonNull(login, "login == null");
|
Objects.requireNonNull(login, "login == null");
|
||||||
|
this.subType = SUBTYPE_COMPAT;
|
||||||
this.tag = TAG;
|
this.tag = TAG;
|
||||||
this.login = login;
|
this.login = login;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public short type() { return TYPE; }
|
@Override public short type() { return TYPE; }
|
||||||
@Override public short version() { return VER; }
|
@Override public short version() { return VER; }
|
||||||
|
@Override public short subType() { return subType; }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public short expectedLineIndex() {
|
public short expectedLineIndex() {
|
||||||
@ -74,6 +96,9 @@ public final class HeaderBody implements BodyRecord {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public HeaderBody check() {
|
public HeaderBody check() {
|
||||||
|
if (subType != SUBTYPE_COMPAT)
|
||||||
|
throw new IllegalArgumentException("HeaderBody subType must be 0");
|
||||||
|
|
||||||
if (login == null || login.isBlank())
|
if (login == null || login.isBlank())
|
||||||
throw new IllegalArgumentException("Login is blank");
|
throw new IllegalArgumentException("Login is blank");
|
||||||
if (!login.matches("^[A-Za-z0-9_]+$"))
|
if (!login.matches("^[A-Za-z0-9_]+$"))
|
||||||
@ -87,14 +112,17 @@ public final class HeaderBody implements BodyRecord {
|
|||||||
if (loginUtf8.length > 255)
|
if (loginUtf8.length > 255)
|
||||||
throw new IllegalArgumentException("Login too long (>255 bytes)");
|
throw new IllegalArgumentException("Login too long (>255 bytes)");
|
||||||
|
|
||||||
int cap = 4 + 8 + 1 + loginUtf8.length;
|
// type[2] + ver[2] + subType[2] + tag[5] + loginLen[1] + login[N]
|
||||||
|
int cap = 2 + 2 + 2 + 5 + 1 + loginUtf8.length;
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
bb.putShort(TYPE);
|
bb.putShort(TYPE);
|
||||||
bb.putShort(VER);
|
bb.putShort(VER);
|
||||||
|
|
||||||
bb.put(TAG.getBytes(StandardCharsets.US_ASCII)); // [8]
|
bb.putShort(SUBTYPE_COMPAT);
|
||||||
|
|
||||||
|
bb.put(TAG.getBytes(StandardCharsets.US_ASCII)); // [5]
|
||||||
bb.put((byte) loginUtf8.length); // [1]
|
bb.put((byte) loginUtf8.length); // [1]
|
||||||
bb.put(loginUtf8); // [N]
|
bb.put(loginUtf8); // [N]
|
||||||
|
|
||||||
@ -107,6 +135,7 @@ public final class HeaderBody implements BodyRecord {
|
|||||||
HeaderBody {
|
HeaderBody {
|
||||||
тип записи : HEADER (type=0, ver=1)
|
тип записи : HEADER (type=0, ver=1)
|
||||||
ожидаемая линия : 0 (genesis)
|
ожидаемая линия : 0 (genesis)
|
||||||
|
subType : 0 (compat)
|
||||||
тег формата : "%s"
|
тег формата : "%s"
|
||||||
login владельца : "%s"
|
login владельца : "%s"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,34 +9,46 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* ReactionBody — type=2, version=1.
|
* ReactionBody — type=2, version=1.
|
||||||
*
|
*
|
||||||
* Сериализация bodyBytes:
|
* Формат bodyBytes (BigEndian):
|
||||||
* [2] type=2
|
* [2] type=2
|
||||||
* [2] ver=1
|
* [2] ver=1
|
||||||
* [4] reactionCode (int32)
|
*
|
||||||
|
* [2] subType (uint16) — подтип реакции (раньше это был reactionCode int32)
|
||||||
|
* 1 = LIKE (лайк)
|
||||||
|
* (в будущем: 2=DISLIKE, 3=LAUGH, 4=WOW ... если захочешь)
|
||||||
|
*
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toBlockchainNameLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toBlockchainName UTF-8
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [32] toBlockHash (raw 32 bytes)
|
* [32] toBlockHash32 (raw 32 bytes)
|
||||||
*
|
*
|
||||||
* ЛИНИЯ:
|
* ЛИНИЯ:
|
||||||
* - строго lineIndex=2
|
* - строго lineIndex=2
|
||||||
*
|
*
|
||||||
* ВАЖНО:
|
* ВАЖНО (MVP):
|
||||||
* - Здесь мы НЕ проверяем, существует ли цель реакции (MVP правило).
|
* - Здесь мы НЕ проверяем, существует ли цель реакции.
|
||||||
|
* - Мы проверяем только корректность формата и целостность полей.
|
||||||
*/
|
*/
|
||||||
public final class ReactionBody implements BodyRecord {
|
public final class ReactionBody implements BodyRecord {
|
||||||
|
|
||||||
public static final short TYPE = 2;
|
public static final short TYPE = 2;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public final int reactionCode;
|
// subType:
|
||||||
|
public static final short SUB_LIKE = 1;
|
||||||
|
|
||||||
|
public final short subType;
|
||||||
|
|
||||||
public final String toBlockchainName;
|
public final String toBlockchainName;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
|
/** Десериализация из полного bodyBytes (включая type/version/subType). */
|
||||||
public ReactionBody(byte[] bodyBytes) {
|
public ReactionBody(byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
if (bodyBytes.length < 4 + 4 + 1 + 1 + 4 + 32) {
|
|
||||||
|
// минимум: type[2]+ver[2]+subType[2]+nameLen[1]+name[1]+global[4]+hash[32]
|
||||||
|
if (bodyBytes.length < 2 + 2 + 2 + 1 + 1 + 4 + 32) {
|
||||||
throw new IllegalArgumentException("ReactionBody too short");
|
throw new IllegalArgumentException("ReactionBody too short");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,11 +59,15 @@ public final class ReactionBody implements BodyRecord {
|
|||||||
if (type != TYPE || ver != VER)
|
if (type != TYPE || ver != VER)
|
||||||
throw new IllegalArgumentException("Not ReactionBody: type=" + type + " ver=" + ver);
|
throw new IllegalArgumentException("Not ReactionBody: type=" + type + " ver=" + ver);
|
||||||
|
|
||||||
this.reactionCode = bb.getInt();
|
this.subType = bb.getShort();
|
||||||
|
if (this.subType != SUB_LIKE) {
|
||||||
|
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
||||||
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
if (bb.remaining() < nameLen + 4 + 32)
|
||||||
|
throw new IllegalArgumentException("ReactionBody payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] nameBytes = new byte[nameLen];
|
||||||
bb.get(nameBytes);
|
bb.get(nameBytes);
|
||||||
@ -61,15 +77,30 @@ public final class ReactionBody implements BodyRecord {
|
|||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
|
|
||||||
|
// запрет мусора в конце
|
||||||
|
if (bb.remaining() != 0) {
|
||||||
|
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReactionBody(int reactionCode, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
/** Создание “вручную”. */
|
||||||
|
public ReactionBody(short subType,
|
||||||
|
String toBlockchainName,
|
||||||
|
int toBlockGlobalNumber,
|
||||||
|
byte[] toBlockHash32) {
|
||||||
|
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
|
||||||
|
if (subType != SUB_LIKE)
|
||||||
|
throw new IllegalArgumentException("Unknown reaction subType: " + (subType & 0xFFFF));
|
||||||
|
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.reactionCode = reactionCode;
|
this.subType = subType;
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toBlockchainName = toBlockchainName;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
@ -77,6 +108,7 @@ public final class ReactionBody implements BodyRecord {
|
|||||||
|
|
||||||
@Override public short type() { return TYPE; }
|
@Override public short type() { return TYPE; }
|
||||||
@Override public short version() { return VER; }
|
@Override public short version() { return VER; }
|
||||||
|
@Override public short subType() { return subType; }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public short expectedLineIndex() {
|
public short expectedLineIndex() {
|
||||||
@ -85,12 +117,16 @@ public final class ReactionBody implements BodyRecord {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ReactionBody check() {
|
public ReactionBody check() {
|
||||||
|
if (subType != SUB_LIKE)
|
||||||
|
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||||
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
throw new IllegalArgumentException("toBlockchainName is blank");
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,12 +136,16 @@ public final class ReactionBody implements BodyRecord {
|
|||||||
if (nameBytes.length == 0 || nameBytes.length > 255)
|
if (nameBytes.length == 0 || nameBytes.length > 255)
|
||||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
||||||
|
|
||||||
int cap = 4 + 4 + 1 + nameBytes.length + 4 + 32;
|
// type[2]+ver[2]+subType[2] + nameLen[1]+name[N] + global[4] + hash[32]
|
||||||
|
int cap = 2 + 2 + 2 + 1 + nameBytes.length + 4 + 32;
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
bb.putShort(TYPE);
|
bb.putShort(TYPE);
|
||||||
bb.putShort(VER);
|
bb.putShort(VER);
|
||||||
bb.putInt(reactionCode);
|
|
||||||
|
bb.putShort(subType);
|
||||||
|
|
||||||
bb.put((byte) nameBytes.length);
|
bb.put((byte) nameBytes.length);
|
||||||
bb.put(nameBytes);
|
bb.put(nameBytes);
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
@ -116,17 +156,19 @@ public final class ReactionBody implements BodyRecord {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
String st = (subType == SUB_LIKE) ? "LIKE (1)" : "UNKNOWN";
|
||||||
|
|
||||||
return """
|
return """
|
||||||
ReactionBody {
|
ReactionBody {
|
||||||
тип записи : REACTION (type=2, ver=1)
|
тип записи : REACTION (type=2, ver=1)
|
||||||
ожидаемая линия : 2
|
ожидаемая линия : 2
|
||||||
код реакции : %d
|
subType : %s
|
||||||
целевой блокчейн : "%s"
|
целевой блокчейн : "%s"
|
||||||
globalNumber цели : %d
|
globalNumber цели : %d
|
||||||
hash цели (hex) : %s
|
hash цели (hex) : %s
|
||||||
}
|
}
|
||||||
""".formatted(
|
""".formatted(
|
||||||
reactionCode,
|
st,
|
||||||
toBlockchainName,
|
toBlockchainName,
|
||||||
toBlockGlobalNumber,
|
toBlockGlobalNumber,
|
||||||
toBlockHashHex()
|
toBlockHashHex()
|
||||||
|
|||||||
@ -5,39 +5,97 @@ import java.nio.ByteOrder;
|
|||||||
import java.nio.charset.CharacterCodingException;
|
import java.nio.charset.CharacterCodingException;
|
||||||
import java.nio.charset.CodingErrorAction;
|
import java.nio.charset.CodingErrorAction;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextBody — type=1, ver=1.
|
* TextBody — type=1, ver=1.
|
||||||
*
|
*
|
||||||
* bodyBytes:
|
* Формат bodyBytes (BigEndian):
|
||||||
* [2] type=1
|
* [2] type=1
|
||||||
* [2] ver=1
|
* [2] ver=1
|
||||||
* [N] utf8 message
|
*
|
||||||
|
* [2] subType (uint16): подтип текстового сообщения
|
||||||
|
* 1 = новое сообщение (начало ветки)
|
||||||
|
* 2 = ответ на сообщение (reply)
|
||||||
|
* 3 = репост (repost)
|
||||||
|
*
|
||||||
|
* [2] textLenBytes (uint16) — длина текста в байтах UTF-8
|
||||||
|
* [N] text UTF-8
|
||||||
|
*
|
||||||
|
* Далее ТОЛЬКО если subType == 2 или subType == 3:
|
||||||
|
* [1] toBlockchainNameLen (uint8)
|
||||||
|
* [N] toBlockchainName UTF-8
|
||||||
|
* [4] toBlockGlobalNumber (int32)
|
||||||
|
* [32] toBlockHash32 (raw 32 bytes)
|
||||||
*
|
*
|
||||||
* ЛИНИЯ:
|
* ЛИНИЯ:
|
||||||
* - строго lineIndex=1
|
* - строго lineIndex=1
|
||||||
|
*
|
||||||
|
* Правила строгого парсинга (чтобы формат не “плыл”):
|
||||||
|
* - subType обязан быть 1/2/3
|
||||||
|
* - textLen обязан быть >0 и <=65535
|
||||||
|
* - text обязан быть валидным UTF-8 и не blank
|
||||||
|
* - для subType=NEW запрещены поля ссылки и запрещены любые “лишние байты” в хвосте
|
||||||
|
* - для subType=REPLY/REPOST хвост обязан быть ровно по формату и без мусора в конце
|
||||||
*/
|
*/
|
||||||
public final class TextBody implements BodyRecord {
|
public final class TextBody implements BodyRecord {
|
||||||
|
|
||||||
public static final short TYPE = 1;
|
public static final short TYPE = 1;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
|
// subType:
|
||||||
|
public static final short SUB_NEW = 1;
|
||||||
|
public static final short SUB_REPLY = 2;
|
||||||
|
public static final short SUB_REPOST = 3;
|
||||||
|
|
||||||
|
/** Подтип текстового сообщения (1/2/3). */
|
||||||
|
public final short subType;
|
||||||
|
|
||||||
|
/** Текст сообщения (строго валидный UTF-8, не пустой/не blank). */
|
||||||
public final String message;
|
public final String message;
|
||||||
|
|
||||||
|
// Заполняются только если subType == SUB_REPLY или SUB_REPOST
|
||||||
|
public final String toBlockchainName;
|
||||||
|
public final int toBlockGlobalNumber;
|
||||||
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
|
/* ===================================================================== */
|
||||||
|
/* ====================== Конструктор из байт =========================== */
|
||||||
|
/* ===================================================================== */
|
||||||
|
|
||||||
|
/** Десериализация из полного bodyBytes (включая type/version). */
|
||||||
public TextBody(byte[] bodyBytes) {
|
public TextBody(byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
if (bodyBytes.length < 5)
|
|
||||||
|
// минимум: type+ver (4) + subType(2) + textLen(2)
|
||||||
|
if (bodyBytes.length < 4 + 2 + 2) {
|
||||||
throw new IllegalArgumentException("TextBody too short");
|
throw new IllegalArgumentException("TextBody too short");
|
||||||
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
short type = bb.getShort();
|
short type = bb.getShort();
|
||||||
short ver = bb.getShort();
|
short ver = bb.getShort();
|
||||||
if (type != TYPE || ver != VER)
|
if (type != TYPE || ver != VER) {
|
||||||
throw new IllegalArgumentException("Not TextBody: type=" + type + " ver=" + ver);
|
throw new IllegalArgumentException("Not TextBody: type=" + type + " ver=" + ver);
|
||||||
|
}
|
||||||
|
|
||||||
byte[] payload = new byte[bb.remaining()];
|
this.subType = bb.getShort();
|
||||||
bb.get(payload);
|
if (this.subType != SUB_NEW && this.subType != SUB_REPLY && this.subType != SUB_REPOST) {
|
||||||
|
throw new IllegalArgumentException("Bad subType: " + (this.subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
int textLen = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (textLen <= 0) {
|
||||||
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
|
}
|
||||||
|
if (bb.remaining() < textLen) {
|
||||||
|
throw new IllegalArgumentException("Text payload too short (len=" + textLen + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] textBytes = new byte[textLen];
|
||||||
|
bb.get(textBytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8
|
var decoder = StandardCharsets.UTF_8
|
||||||
.newDecoder()
|
.newDecoder()
|
||||||
@ -45,25 +103,122 @@ public final class TextBody implements BodyRecord {
|
|||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.message = decoder.decode(ByteBuffer.wrap(payload)).toString();
|
this.message = decoder.decode(ByteBuffer.wrap(textBytes)).toString();
|
||||||
} catch (CharacterCodingException e) {
|
} catch (CharacterCodingException e) {
|
||||||
throw new IllegalArgumentException("Text payload is not valid UTF-8", e);
|
throw new IllegalArgumentException("Text payload is not valid UTF-8", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.message.isBlank())
|
if (this.message.isBlank()) {
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поля ссылки — только для reply/repost
|
||||||
|
if (this.subType == SUB_REPLY || this.subType == SUB_REPOST) {
|
||||||
|
|
||||||
|
if (bb.remaining() < 1) {
|
||||||
|
throw new IllegalArgumentException("Missing toBlockchainNameLen");
|
||||||
|
}
|
||||||
|
|
||||||
|
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
||||||
|
if (bb.remaining() < nameLen + 4 + 32) {
|
||||||
|
throw new IllegalArgumentException("Reply/Repost 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);
|
||||||
|
|
||||||
|
// Запрет мусора в конце
|
||||||
|
if (bb.remaining() != 0) {
|
||||||
|
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// SUB_NEW
|
||||||
|
this.toBlockchainName = null;
|
||||||
|
this.toBlockGlobalNumber = 0;
|
||||||
|
this.toBlockHash32 = null;
|
||||||
|
|
||||||
|
// если кто-то подсунул хвост — лучше упасть, чтобы формат не “плыл”
|
||||||
|
if (bb.remaining() != 0) {
|
||||||
|
throw new IllegalArgumentException("Unexpected tail for subType=NEW, remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================================================================== */
|
||||||
|
/* ====================== Конструкторы “для тестов” ====================== */
|
||||||
|
/* ===================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удобный конструктор для тестов/сборки простого сообщения:
|
||||||
|
* new TextBody(text) == new TextBody(SUB_NEW, text)
|
||||||
|
*/
|
||||||
public TextBody(String message) {
|
public TextBody(String message) {
|
||||||
Objects.requireNonNull(message, "message == null");
|
this(SUB_NEW, message);
|
||||||
if (message.isBlank())
|
|
||||||
throw new IllegalArgumentException("message is blank");
|
|
||||||
this.message = message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сообщение subType=NEW (1). */
|
||||||
|
public TextBody(short subType, String message) {
|
||||||
|
Objects.requireNonNull(message, "message == null");
|
||||||
|
|
||||||
|
if (subType != SUB_NEW) {
|
||||||
|
throw new IllegalArgumentException("This constructor is only for SUB_NEW");
|
||||||
|
}
|
||||||
|
if (message.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("message is blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subType = subType;
|
||||||
|
this.message = message;
|
||||||
|
|
||||||
|
this.toBlockchainName = null;
|
||||||
|
this.toBlockGlobalNumber = 0;
|
||||||
|
this.toBlockHash32 = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сообщение subType=REPLY (2) или subType=REPOST (3) со ссылкой на блок. */
|
||||||
|
public TextBody(short subType,
|
||||||
|
String message,
|
||||||
|
String toBlockchainName,
|
||||||
|
int toBlockGlobalNumber,
|
||||||
|
byte[] toBlockHash32) {
|
||||||
|
|
||||||
|
Objects.requireNonNull(message, "message == null");
|
||||||
|
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||||
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
|
||||||
|
if (subType != SUB_REPLY && subType != SUB_REPOST) {
|
||||||
|
throw new IllegalArgumentException("subType must be SUB_REPLY or SUB_REPOST for this constructor");
|
||||||
|
}
|
||||||
|
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
|
this.subType = subType;
|
||||||
|
this.message = message;
|
||||||
|
this.toBlockchainName = toBlockchainName;
|
||||||
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================== */
|
||||||
|
/* ====================== BodyRecord контракт =========================== */
|
||||||
|
/* ===================================================================== */
|
||||||
|
|
||||||
@Override public short type() { return TYPE; }
|
@Override public short type() { return TYPE; }
|
||||||
@Override public short version() { return VER; }
|
@Override public short version() { return VER; }
|
||||||
|
|
||||||
|
/** ✅ ВАЖНО: теперь BodyRecord требует subType() */
|
||||||
|
@Override public short subType() { return subType; }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public short expectedLineIndex() {
|
public short expectedLineIndex() {
|
||||||
return 1;
|
return 1;
|
||||||
@ -71,36 +226,137 @@ public final class TextBody implements BodyRecord {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TextBody check() {
|
public TextBody check() {
|
||||||
if (message == null || message.isBlank())
|
if (subType != SUB_NEW && subType != SUB_REPLY && subType != SUB_REPOST) {
|
||||||
|
throw new IllegalArgumentException("Bad subType: " + (subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subType == SUB_REPLY || subType == SUB_REPOST) {
|
||||||
|
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||||
|
throw new IllegalArgumentException("toBlockchainName is blank");
|
||||||
|
if (toBlockGlobalNumber < 0)
|
||||||
|
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
} else {
|
||||||
|
// SUB_NEW
|
||||||
|
if (toBlockchainName != null) throw new IllegalArgumentException("toBlockchainName must be null for SUB_NEW");
|
||||||
|
if (toBlockHash32 != null) throw new IllegalArgumentException("toBlockHash32 must be null for SUB_NEW");
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] msg = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msg.length == 0)
|
if (msgUtf8.length == 0) {
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
|
}
|
||||||
|
if (msgUtf8.length > 65535) {
|
||||||
|
throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// base: type+ver + subType + textLen + textBytes
|
||||||
|
int cap = 4 + 2 + 2 + msgUtf8.length;
|
||||||
|
|
||||||
|
byte[] nameBytes = null;
|
||||||
|
|
||||||
|
if (subType == SUB_REPLY || subType == SUB_REPOST) {
|
||||||
|
nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (nameBytes.length == 0 || nameBytes.length > 255) {
|
||||||
|
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
||||||
|
}
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||||
|
throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
}
|
||||||
|
|
||||||
|
cap += 1 + nameBytes.length + 4 + 32;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// SUB_NEW — ссылка запрещена
|
||||||
|
if (toBlockchainName != null || toBlockHash32 != null) {
|
||||||
|
throw new IllegalArgumentException("SUB_NEW must not contain reply/repost fields");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(4 + msg.length).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.putShort(TYPE);
|
bb.putShort(TYPE);
|
||||||
bb.putShort(VER);
|
bb.putShort(VER);
|
||||||
bb.put(msg);
|
|
||||||
|
bb.putShort(subType);
|
||||||
|
|
||||||
|
bb.putShort((short) msgUtf8.length);
|
||||||
|
bb.put(msgUtf8);
|
||||||
|
|
||||||
|
if (subType == SUB_REPLY || subType == SUB_REPOST) {
|
||||||
|
bb.put((byte) nameBytes.length);
|
||||||
|
bb.put(nameBytes);
|
||||||
|
bb.putInt(toBlockGlobalNumber);
|
||||||
|
bb.put(toBlockHash32);
|
||||||
|
}
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
String st = switch (subType) {
|
||||||
|
case SUB_NEW -> "NEW (1)";
|
||||||
|
case SUB_REPLY -> "REPLY (2)";
|
||||||
|
case SUB_REPOST -> "REPOST (3)";
|
||||||
|
default -> "UNKNOWN";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subType == SUB_REPLY || subType == SUB_REPOST) {
|
||||||
|
return """
|
||||||
|
TextBody {
|
||||||
|
тип записи : TEXT (type=1, ver=1)
|
||||||
|
ожидаемая линия : 1
|
||||||
|
subType : %s
|
||||||
|
длина сообщения : %d байт
|
||||||
|
текст сообщения : "%s"
|
||||||
|
ссылка на блок : "%s" #%d
|
||||||
|
hash цели (hex) : %s
|
||||||
|
}
|
||||||
|
""".formatted(
|
||||||
|
st,
|
||||||
|
message.getBytes(StandardCharsets.UTF_8).length,
|
||||||
|
message,
|
||||||
|
toBlockchainName,
|
||||||
|
toBlockGlobalNumber,
|
||||||
|
toBlockHashHex()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return """
|
return """
|
||||||
TextBody {
|
TextBody {
|
||||||
тип записи : TEXT (type=1, ver=1)
|
тип записи : TEXT (type=1, ver=1)
|
||||||
ожидаемая линия : 1
|
ожидаемая линия : 1
|
||||||
|
subType : %s
|
||||||
длина сообщения : %d байт
|
длина сообщения : %d байт
|
||||||
текст сообщения : "%s"
|
текст сообщения : "%s"
|
||||||
}
|
}
|
||||||
""".formatted(
|
""".formatted(
|
||||||
message.getBytes(StandardCharsets.UTF_8).length,
|
st,
|
||||||
message
|
message.getBytes(StandardCharsets.UTF_8).length,
|
||||||
);
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toBlockHashHex() {
|
||||||
|
if (toBlockHash32 == null) return "null";
|
||||||
|
char[] HEX = "0123456789abcdef".toCharArray();
|
||||||
|
char[] out = new char[64];
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
int v = toBlockHash32[i] & 0xFF;
|
||||||
|
out[i * 2] = HEX[v >>> 4];
|
||||||
|
out[i * 2 + 1] = HEX[v & 0x0F];
|
||||||
|
}
|
||||||
|
return new String(out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -107,7 +107,7 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
// =========================================================
|
// =========================================================
|
||||||
if (TestConfig.DEBUG()) TestLog.stepTitle("ШАГ 4: AddBlock REACT#1 (line=2) -> на TEXT#1 (global=1)");
|
if (TestConfig.DEBUG()) TestLog.stepTitle("ШАГ 4: AddBlock REACT#1 (line=2) -> на TEXT#1 (global=1)");
|
||||||
flow.sendNextReaction(
|
flow.sendNextReaction(
|
||||||
1, // reactionCode (пример: 1 = like)
|
(short) 1, // reactionCode (пример: 1 = like)
|
||||||
TestConfig.BCH_NAME(), // toBlockchainName
|
TestConfig.BCH_NAME(), // toBlockchainName
|
||||||
1, // toBlockGlobalNumber = 1 (TEXT#1)
|
1, // toBlockGlobalNumber = 1 (TEXT#1)
|
||||||
text1.hash32, // toBlockHash32 = hash(TEXT#1)
|
text1.hash32, // toBlockHash32 = hash(TEXT#1)
|
||||||
|
|||||||
@ -160,7 +160,7 @@ public final class AddBlockFlow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Шлём следующий REACTION блок в line=2, ссылаясь на конкретный блок. */
|
/** Шлём следующий REACTION блок в line=2, ссылаясь на конкретный блок. */
|
||||||
public BuiltBlock sendNextReaction(int reactionCode,
|
public BuiltBlock sendNextReaction(short reactionCode,
|
||||||
String toBlockchainName,
|
String toBlockchainName,
|
||||||
int toBlockGlobalNumber,
|
int toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
@ -305,7 +305,7 @@ public final class AddBlockFlow {
|
|||||||
int lineBlockNumber,
|
int lineBlockNumber,
|
||||||
byte[] prevGlobalHash32,
|
byte[] prevGlobalHash32,
|
||||||
byte[] prevLineHash32,
|
byte[] prevLineHash32,
|
||||||
int reactionCode,
|
short reactionCode,
|
||||||
String toBlockchainName,
|
String toBlockchainName,
|
||||||
int toBlockGlobalNumber,
|
int toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32) {
|
byte[] toBlockHash32) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user