diff --git a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java index a492f29..6fe1d43 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser.java @@ -18,10 +18,11 @@ public final class BodyRecordParser { int key = ((type & 0xFFFF) << 16) | (ver & 0xFFFF); return switch (key) { - case HeaderBody.KEY -> new HeaderBody(bodyBytes); // type=0, ver=1 - case TextBody.KEY -> new TextBody(bodyBytes); // type=1, ver=1 - case ReactionBody.KEY -> new ReactionBody(bodyBytes); // type=2, ver=1 - case ConnectionBody.KEY -> new ConnectionBody(bodyBytes); // type=3, ver=1 + case HeaderBody.KEY -> new HeaderBody(bodyBytes); // type=0, ver=1 заглавие блокчейна + case TextBody.KEY -> new TextBody(bodyBytes); // type=1, ver=1 текст + case ReactionBody.KEY -> new ReactionBody(bodyBytes); // type=2, ver=1 реакции + case ConnectionBody.KEY -> new ConnectionBody(bodyBytes); // type=3, ver=1 связи + case UserParamBody.KEY -> new UserParamBody(bodyBytes); // type=4, ver=1 параметры пользователя default -> throw new IllegalArgumentException(String.format( "Unknown body type/version: type=%d ver=%d (key=0x%08X)", (type & 0xFFFF), (ver & 0xFFFF), key diff --git a/shine-server-blockchain/src/main/java/blockchain/body/UserParamBody.java b/shine-server-blockchain/src/main/java/blockchain/body/UserParamBody.java new file mode 100644 index 0000000..82400e9 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/UserParamBody.java @@ -0,0 +1,221 @@ +package blockchain.body; + +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.Objects; + +/** + * UserParamBody — type=4, ver=1. (Параметр профиля / данные пользователя о себе) + * + * Идея: + * - Это "пользователь сам заявил параметр X со значением Y". + * - Один блок = один параметр (одна пара key/value). + * (Если нужно больше параметров — просто добавляешь несколько блоков подряд). + * + * Формат bodyBytes (BigEndian): + * [2] type=4 + * [2] ver=1 + * + * [2] subType (uint16) + * 1 = TEXT_TEXT (ключ-значение, обе строки UTF-8) + * + * [2] keyLenBytes (uint16) — длина ключа в байтах UTF-8 + * [N] keyUtf8 + * + * [2] valueLenBytes (uint16) — длина значения в байтах UTF-8 + * [M] valueUtf8 + * + * ВАЖНО: + * - длины именно В БАЙТАХ UTF-8 (не в символах) + * - ключ и значение обязаны быть валидным UTF-8 + * - ключ запрещаем пустым/blank (иначе нельзя идентифицировать параметр) + * - значение может быть пустым? (реши сам) + * сейчас: запрещаем пустое (len>0) и запрещаем blank, чтобы не мусорить цепочку + * + * ЛИНИЯ: + * - строго lineIndex=4 (выделенная линия под пользовательские параметры/профиль). + */ +public final class UserParamBody implements BodyRecord { + + public static final short TYPE = 4; + public static final short VER = 1; + + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + // subType: + public static final short SUB_TEXT_TEXT = 1; + + public final short subType; + + /** Название параметра (пример: "firstName", "lastName", "address", "about"). */ + public final String paramKey; + + /** Значение параметра (пример: "Aidar", "Gareev", "..."). */ + public final String paramValue; + + /* ===================================================================== */ + /* ====================== Конструктор из байт =========================== */ + /* ===================================================================== */ + + public UserParamBody(byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + + // минимум: type[2]+ver[2]+subType[2]+keyLen[2]+key[1]+valLen[2]+val[1] + if (bodyBytes.length < 2 + 2 + 2 + 2 + 1 + 2 + 1) { + throw new IllegalArgumentException("UserParamBody too short"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + + short type = bb.getShort(); + short ver = bb.getShort(); + if (type != TYPE || ver != VER) { + throw new IllegalArgumentException("Not UserParamBody: type=" + type + " ver=" + ver); + } + + this.subType = bb.getShort(); + if (this.subType != SUB_TEXT_TEXT) { + throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF)); + } + + int keyLen = Short.toUnsignedInt(bb.getShort()); + if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0"); + if (bb.remaining() < keyLen + 2) throw new IllegalArgumentException("UserParam key payload too short"); + + byte[] keyBytes = new byte[keyLen]; + bb.get(keyBytes); + + int valLen = Short.toUnsignedInt(bb.getShort()); + if (valLen <= 0) throw new IllegalArgumentException("paramValueLen is 0"); + if (bb.remaining() < valLen) throw new IllegalArgumentException("UserParam value payload too short"); + + byte[] valBytes = new byte[valLen]; + bb.get(valBytes); + + // запрет мусора в конце + if (bb.remaining() != 0) { + throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); + } + + this.paramKey = strictUtf8(keyBytes, "paramKey"); + this.paramValue = strictUtf8(valBytes, "paramValue"); + + if (this.paramKey.isBlank()) { + throw new IllegalArgumentException("paramKey is blank"); + } + if (this.paramValue.isBlank()) { + throw new IllegalArgumentException("paramValue is blank"); + } + } + + /* ===================================================================== */ + /* ====================== Конструктор “вручную” ========================= */ + /* ===================================================================== */ + + public UserParamBody(String paramKey, String paramValue) { + Objects.requireNonNull(paramKey, "paramKey == null"); + Objects.requireNonNull(paramValue, "paramValue == null"); + + this.subType = SUB_TEXT_TEXT; + + if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); + if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); + + this.paramKey = paramKey; + this.paramValue = paramValue; + } + + /* ===================================================================== */ + /* ====================== BodyRecord контракт =========================== */ + /* ===================================================================== */ + + @Override public short type() { return TYPE; } + @Override public short version() { return VER; } + @Override public short subType() { return subType; } + + @Override + public short expectedLineIndex() { + return 4; + } + + @Override + public UserParamBody check() { + if (subType != SUB_TEXT_TEXT) + throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF)); + + if (paramKey == null || paramKey.isBlank()) + throw new IllegalArgumentException("paramKey is blank"); + if (paramValue == null || paramValue.isBlank()) + throw new IllegalArgumentException("paramValue is blank"); + + return this; + } + + @Override + public byte[] toBytes() { + if (subType != SUB_TEXT_TEXT) + throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF)); + + byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8); + byte[] valUtf8 = paramValue.getBytes(StandardCharsets.UTF_8); + + if (keyUtf8.length == 0) throw new IllegalArgumentException("paramKey utf8 len is 0"); + if (valUtf8.length == 0) throw new IllegalArgumentException("paramValue utf8 len is 0"); + + if (keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey too long (>65535 bytes)"); + if (valUtf8.length > 65535) throw new IllegalArgumentException("paramValue too long (>65535 bytes)"); + + // type[2]+ver[2]+subType[2] + keyLen[2]+key[N] + valLen[2]+val[M] + int cap = 2 + 2 + 2 + 2 + keyUtf8.length + 2 + valUtf8.length; + + ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); + + bb.putShort(TYPE); + bb.putShort(VER); + + bb.putShort(SUB_TEXT_TEXT); + + bb.putShort((short) keyUtf8.length); + bb.put(keyUtf8); + + bb.putShort((short) valUtf8.length); + bb.put(valUtf8); + + return bb.array(); + } + + @Override + public String toString() { + String st = (subType == SUB_TEXT_TEXT) ? "TEXT_TEXT (1)" : "UNKNOWN"; + + return """ + UserParamBody { + тип записи : USER_PARAM (type=4, ver=1) + ожидаемая линия : 4 + subType : %s + paramKey : "%s" + paramValue : "%s" + } + """.formatted(st, paramKey, paramValue); + } + + /* ===================================================================== */ + /* =========================== Helpers ================================== */ + /* ===================================================================== */ + + private static String strictUtf8(byte[] bytes, String fieldName) { + var decoder = StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + try { + return decoder.decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException e) { + throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); + } + } +} \ No newline at end of file