diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java index 21103a16..35cf71b0 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java @@ -36,6 +36,10 @@ public final class BodyRecordParser { case TextBody.KEY -> { if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || st == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || st == (MsgSubType.TEXT_COURSE & 0xFFFF) + || st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { yield new TextLineBody(subType, version, bodyBytes); @@ -46,12 +50,17 @@ public final class BodyRecordParser { yield new TextReplyBody(subType, version, bodyBytes); } + if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) { + yield new TextRatingBody(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 ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes); case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes); + case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes); default -> throw new IllegalArgumentException(String.format( "Unknown body type/version from header: type=%d ver=%d subType=%d", diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index f5b72745..4588d1b8 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -64,17 +64,29 @@ public final class MsgSubType { */ public static final short TEXT_EDIT_REPLY = 21; + /** RATING — target-based отзыв на конкретный блок. */ + public static final short TEXT_RATING = 30; + /** - * REPOST — репост сообщения в линии канала. + * REPOST — отложенная будущая заготовка репоста сообщения в линии канала. * Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария. */ - public static final short TEXT_REPOST = 30; + public static final short TEXT_REPOST = 50; /** * CHANNEL_META — скрытый технический снимок профиля канала. * Имеет hasLine, использует body как POST: line-поля + UTF-8 текст. */ - public static final short TEXT_CHANNEL_META = 70; + public static final short TEXT_CHANNEL_META = 90; + + /** ENTRYPOINT — входная страница канала (line-based). */ + public static final short TEXT_ENTRYPOINT = 100; + /** EXERCISE — упражнение/комплекс (line-based). */ + public static final short TEXT_EXERCISE = 110; + /** SERVICE — услуга/процедура (line-based). */ + public static final short TEXT_SERVICE = 120; + /** COURSE — курс (line-based). */ + public static final short TEXT_COURSE = 130; /* ===================== REACTION (msg_type=2) ===================== */ @@ -144,4 +156,15 @@ public final class MsgSubType { /** Параметр профиля key/value (обе строки). */ public static final short USER_PARAM_TEXT_TEXT = 1; + + /* ===================== STATUS_ACTION (msg_type=5) ===================== */ + + public static final short STATUS_DONE_ONCE = 10; + public static final short STATUS_INTERESTED = 20; + public static final short STATUS_STARTED = 30; + public static final short STATUS_IN_STUDY = 40; + public static final short STATUS_COMPLETED = 50; + public static final short STATUS_ABANDONED = 60; + public static final short STATUS_LEARNED = 70; + public static final short STATUS_CONFIRMED = 80; } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java new file mode 100644 index 00000000..d9979386 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java @@ -0,0 +1,165 @@ +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; + +/** + * StatusActionBody — type=5, ver=1. + * + * Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение. + * + * Формат bodyBytes (BigEndian): + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * [2] textLenBytes (uint16) + * [M] text UTF-8 + */ +public final class StatusActionBody implements BodyRecord, BodyHasTarget { + + public static final short TYPE = 5; + public static final short VER = 1; + public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); + + public final short subType; + public final short version; + public final String toBlockchainName; + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; + public final String message; + + public StatusActionBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + this.subType = subType; + this.version = version; + + if ((version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF)); + } + if (!isSupportedSubType(subType)) { + throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF)); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short"); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0"); + ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION 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); + this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text"); + + ensureNoTail(bb, "StatusActionBody"); + } + + public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + Objects.requireNonNull(message, "message == null"); + if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType"); + 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.version = VER; + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + this.message = message; + } + + @Override + public StatusActionBody check() { + if (!isSupportedSubType(subType)) { + throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF)); + } + if (toBlockchainName == null || toBlockchainName.isBlank()) { + throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank"); + } + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) { + throw new IllegalArgumentException("toBlockHash32 invalid"); + } + if (message == null) throw new IllegalArgumentException("message is null"); + return this; + } + + @Override + public byte[] toBytes() { + byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); + if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); + + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) { + throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255"); + } + + ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) + .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(); + } + + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } + + private static boolean isSupportedSubType(short subType) { + int st = subType & 0xFFFF; + return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF) + || st == (MsgSubType.STATUS_INTERESTED & 0xFFFF) + || st == (MsgSubType.STATUS_STARTED & 0xFFFF) + || st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF) + || st == (MsgSubType.STATUS_COMPLETED & 0xFFFF) + || st == (MsgSubType.STATUS_ABANDONED & 0xFFFF) + || st == (MsgSubType.STATUS_LEARNED & 0xFFFF) + || st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF); + } + + private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) { + int len = Short.toUnsignedInt(bb.getShort()); + if (len == 0) return ""; + 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 { + return decoder.decode(ByteBuffer.wrap(bytes)).toString(); + } 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()); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java index 2e392f12..8f3b4917 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java @@ -16,12 +16,16 @@ import java.util.Objects; * subType: * - POST (10) * - EDIT_POST (11) - * - REPOST (30) - * - CHANNEL_META (70) + * - REPOST (50) + * - CHANNEL_META (90) + * - ENTRYPOINT (100) + * - EXERCISE (110) + * - SERVICE (120) + * - COURSE (130) * * Формат bodyBytes (BigEndian): * - * POST / CHANNEL_META: + * POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE: * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 @@ -91,7 +95,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) && st != (MsgSubType.TEXT_REPOST & 0xFFFF) - && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + && st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + && st != (MsgSubType.TEXT_EXERCISE & 0xFFFF) + && st != (MsgSubType.TEXT_SERVICE & 0xFFFF) + && st != (MsgSubType.TEXT_COURSE & 0xFFFF)) { throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st); } @@ -159,12 +167,16 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) && st != (MsgSubType.TEXT_REPOST & 0xFFFF) - && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + && st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + && st != (MsgSubType.TEXT_EXERCISE & 0xFFFF) + && st != (MsgSubType.TEXT_SERVICE & 0xFFFF) + && st != (MsgSubType.TEXT_COURSE & 0xFFFF)) { throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META"); } if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) { + if (requiresNonBlankMessage(st) && message.isBlank()) { throw new IllegalArgumentException("message is blank"); } @@ -211,7 +223,11 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) && st != (MsgSubType.TEXT_REPOST & 0xFFFF) - && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + && st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + && st != (MsgSubType.TEXT_EXERCISE & 0xFFFF) + && st != (MsgSubType.TEXT_SERVICE & 0xFFFF) + && st != (MsgSubType.TEXT_COURSE & 0xFFFF)) throw new IllegalArgumentException("Bad TextLineBody subType: " + st); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); @@ -238,8 +254,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge } else { if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null"); - } else if (message == null || message.isBlank()) { + } else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) { throw new IllegalArgumentException("Text message is blank"); + } else if (message == null) { + throw new IllegalArgumentException("Text message is null"); } if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields"); @@ -254,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); int st = subType & 0xFFFF; - if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) { + if (requiresNonBlankMessage(st) && msgUtf8.length == 0) { throw new IllegalArgumentException("Text payload is empty"); } int cap; - if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + if (st == (MsgSubType.TEXT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + || st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + || st == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || st == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || st == (MsgSubType.TEXT_COURSE & 0xFFFF)) { cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { // EDIT_POST @@ -318,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); } + private static boolean requiresNonBlankMessage(int st) { + return st == (MsgSubType.TEXT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_REPOST & 0xFFFF) + || st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + || st == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || st == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || st == (MsgSubType.TEXT_COURSE & 0xFFFF); + } + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) { int len = Short.toUnsignedInt(bb.getShort()); if (len == 0) { diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java new file mode 100644 index 00000000..270a3904 --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextRatingBody.java @@ -0,0 +1,157 @@ +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; + +/** + * TextRatingBody — type=1, ver=1. + * + * subType: + * - RATING (30) + * + * Формат bodyBytes (BigEndian): + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber + * [32] toBlockHash32 + * [2] textLenBytes (uint16) + * [M] text UTF-8 + */ +public final class TextRatingBody 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; + public final short version; + public final String toBlockchainName; + public final int toBlockGlobalNumber; + public final byte[] toBlockHash32; + public final String message; + + public TextRatingBody(short subType, short version, byte[] bodyBytes) { + Objects.requireNonNull(bodyBytes, "bodyBytes == null"); + this.subType = subType; + this.version = version; + + if ((version & 0xFFFF) != (VER & 0xFFFF)) { + throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF)); + } + if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) { + throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING"); + } + + ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short"); + + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0"); + ensureMin(bb, nameLen + 4 + 32 + 2, "RATING 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); + this.message = readStrictUtf8Len16(bb, "TextRatingBody text"); + + ensureNoTail(bb, "TextRatingBody"); + } + + public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + Objects.requireNonNull(message, "message == null"); + 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 (message.isBlank()) throw new IllegalArgumentException("message is blank"); + + this.subType = MsgSubType.TEXT_RATING; + this.version = VER; + this.toBlockchainName = toBlockchainName; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + this.message = message; + } + + @Override + public TextRatingBody check() { + if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) { + throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF)); + } + if (toBlockchainName == null || toBlockchainName.isBlank()) { + throw new IllegalArgumentException("RATING toBlockchainName is blank"); + } + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32 == null || toBlockHash32.length != 32) { + throw new IllegalArgumentException("toBlockHash32 invalid"); + } + if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank"); + 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)"); + + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) { + throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255"); + } + + ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length) + .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(); + } + + @Override public String toBchName() { return toBlockchainName; } + @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } + @Override public byte[] toBlockHashBytes() { return toBlockHash32; } + + 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()); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 7bccfda8..793c49a1 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -32,8 +32,13 @@ public final class DatabaseInitializer { public static final short TEXT_EDIT_POST = 11; public static final short TEXT_REPLY = 20; public static final short TEXT_EDIT_REPLY = 21; - public static final short TEXT_REPOST = 30; - public static final short TEXT_CHANNEL_META = 70; + public static final short TEXT_RATING = 30; + public static final short TEXT_REPOST = 50; + public static final short TEXT_CHANNEL_META = 90; + public static final short TEXT_ENTRYPOINT = 100; + public static final short TEXT_EXERCISE = 110; + public static final short TEXT_SERVICE = 120; + public static final short TEXT_COURSE = 130; public static final short REACTION_LIKE = 1; public static final short REACTION_UNLIKE = 2; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java index 6952cd20..11d3b91b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -30,11 +30,23 @@ public final class MsgSubType { /** EDIT_REPLY — редактирование исходного ответа. */ public static final short TEXT_EDIT_REPLY = 21; - /** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */ - public static final short TEXT_REPOST = 30; + /** RATING — target-based отзыв на конкретный блок. */ + public static final short TEXT_RATING = 30; + + /** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */ + public static final short TEXT_REPOST = 50; /** CHANNEL_META — скрытый технический снимок профиля канала. */ - public static final short TEXT_CHANNEL_META = 70; + public static final short TEXT_CHANNEL_META = 90; + + /** ENTRYPOINT — входная страница канала (line-based). */ + public static final short TEXT_ENTRYPOINT = 100; + /** EXERCISE — упражнение/комплекс (line-based). */ + public static final short TEXT_EXERCISE = 110; + /** SERVICE — услуга/процедура (line-based). */ + public static final short TEXT_SERVICE = 120; + /** COURSE — курс (line-based). */ + public static final short TEXT_COURSE = 130; /* ===================== REACTION (msg_type=2) ===================== */ @@ -123,6 +135,17 @@ public final class MsgSubType { /** Параметр профиля key/value (обе строки). */ public static final short USER_PARAM_TEXT_TEXT = 1; + /* ===================== STATUS_ACTION (msg_type=5) ===================== */ + + public static final short STATUS_DONE_ONCE = 10; + public static final short STATUS_INTERESTED = 20; + public static final short STATUS_STARTED = 30; + public static final short STATUS_IN_STUDY = 40; + public static final short STATUS_COMPLETED = 50; + public static final short STATUS_ABANDONED = 60; + public static final short STATUS_LEARNED = 70; + public static final short STATUS_CONFIRMED = 80; + /* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */ // Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения, // не трогая уже занятые коды. diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java index cd089db4..83882403 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java @@ -13,7 +13,7 @@ import java.util.List; * Возвращает по каждой активной подписке (FOLLOW) + "сам на себя": * - login цели (channelLogin) * - blockchainName цели (channelBchName) - * - count публикаций (TEXT_POST) + * - count публикаций (видимые line-based TEXT-сообщения канала) * - last publication: bytes оригинального блока (для timestamp) * - last publication: bytes актуального блока (edit или orig) — для текста превью * @@ -92,7 +92,7 @@ public final class SubscriptionsDAO { /** * Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой: - * - count публикаций (TEXT_POST) + * - count публикаций (видимые line-based TEXT-сообщения канала) * - последнюю публикацию (orig bytes) + её edit (если есть) * * Поведение при 0 публикаций: @@ -131,7 +131,7 @@ public final class SubscriptionsDAO { ON s.channel_login = b.login AND s.channel_bch_name = b.bch_name WHERE b.msg_type = ? - AND b.msg_sub_type IN (?, ?) + AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?) GROUP BY b.login, b.bch_name ), last_pub AS ( @@ -144,7 +144,7 @@ public final class SubscriptionsDAO { ON s.channel_login = b.login AND s.channel_bch_name = b.bch_name WHERE b.msg_type = ? - AND b.msg_sub_type IN (?, ?) + AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?) GROUP BY b.login, b.bch_name ), last_pub_block AS ( @@ -209,11 +209,19 @@ public final class SubscriptionsDAO { ps.setInt(i++, MSG_TYPE_TEXT); ps.setInt(i++, (int) MsgSubType.TEXT_POST); ps.setInt(i++, (int) MsgSubType.TEXT_REPOST); + ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE); + ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE); + ps.setInt(i++, (int) MsgSubType.TEXT_COURSE); // last_pub ps.setInt(i++, MSG_TYPE_TEXT); ps.setInt(i++, (int) MsgSubType.TEXT_POST); ps.setInt(i++, (int) MsgSubType.TEXT_REPOST); + ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE); + ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE); + ps.setInt(i++, (int) MsgSubType.TEXT_COURSE); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 5b3e6d3f..c487fd4f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -6,6 +6,7 @@ import blockchain.MsgSubType; import blockchain.body.BodyHasLine; import blockchain.body.BodyHasTarget; import blockchain.body.CreateChannelBody; +import blockchain.body.StatusActionBody; import blockchain.body.TextLineBody; import blockchain.body.UserParamBody; import org.slf4j.Logger; @@ -167,6 +168,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; case "channel_name_already_exists" -> "Такое название канала уже занято"; case "repost_disabled" -> "Репосты временно отключены до будущей реализации"; + case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST"; + case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; default -> "Ошибка: " + code; @@ -388,6 +391,34 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); } + if ((block.type & 0xFFFF) == 1 + && (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + try { + String editError = validateEditPostTarget(blockchainName, block); + if (editError != null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, editError, serverLastNum, serverLastHashHex); + } + } catch (Exception e) { + log.error("AddBlock: edit_post_target_check_failed (login={}, blockchainName={}, blockNumber={})", + login, blockchainName, block.blockNumber, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); + } + } + + if ((block.type & 0xFFFF) == 5 + && (block.subType & 0xFFFF) == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) { + try { + String statusError = validateStatusConfirmedTarget(block); + if (statusError != null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, statusError, serverLastNum, serverLastHashHex); + } + } catch (Exception e) { + log.error("AddBlock: status_confirmed_target_check_failed (login={}, blockchainName={}, blockNumber={})", + login, blockchainName, block.blockNumber, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); + } + } + // 4.2) запрет дырок: blockNumber строго last+1 int expectedBlockNumber = serverLastNum + 1; if (block.blockNumber != expectedBlockNumber) { @@ -605,6 +636,42 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { String slug; } + private String validateEditPostTarget(String ownerBch, BchBlockEntry block) throws Exception { + if (!(block.body instanceof TextLineBody editBody)) return null; + Integer targetBlockNumber = editBody.toBlockGlobalNumber(); + byte[] targetHash = editBody.toBlockHashBytes(); + if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body"; + + BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber); + if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) { + return null; + } + if (target.getMsgType() != 1) return null; + if (target.getMsgSubType() == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)) { + return "entrypoint_edit_forbidden"; + } + return null; + } + + private String validateStatusConfirmedTarget(BchBlockEntry block) throws Exception { + if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body"; + String targetBch = statusBody.toBchName(); + Integer targetBlockNumber = statusBody.toBlockGlobalNumber(); + byte[] targetHash = statusBody.toBlockHashBytes(); + if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) { + return "bad_block_body"; + } + + BlockEntry target = blocksDAO.getByNumber(targetBch, targetBlockNumber); + if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) { + return null; + } + if (target.getMsgType() != 5) { + return "status_confirmed_target_must_be_status_action"; + } + return null; + } + private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception { try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 35fbfc29..a3a2d560 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -22,6 +22,7 @@ final class ChannelsReadSupport { static final int MSG_TYPE_TEXT = 1; static final int MSG_TYPE_REACTION = 2; static final int MSG_TYPE_TECH = 0; + static final int MSG_TYPE_STATUS_ACTION = 5; static final String STORIES_CHANNEL_NAME = "stories"; static final String COMMAND_ADD = "add"; static final String COMMAND_REMOVE = "remove"; @@ -126,13 +127,17 @@ final class ChannelsReadSupport { } 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 IN (?, ?) AND line_code=?"; + String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) 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, MsgSubType.TEXT_REPOST); - ps.setInt(5, lineCode); + ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(6, MsgSubType.TEXT_EXERCISE); + ps.setInt(7, MsgSubType.TEXT_SERVICE); + ps.setInt(8, MsgSubType.TEXT_COURSE); + ps.setInt(9, lineCode); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getInt("cnt") : 0; } @@ -143,7 +148,7 @@ final class ChannelsReadSupport { String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number FROM blocks - WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? + WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=? ORDER BY block_number DESC LIMIT 1 """; @@ -152,7 +157,11 @@ final class ChannelsReadSupport { ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(4, MsgSubType.TEXT_REPOST); - ps.setInt(5, lineCode); + ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(6, MsgSubType.TEXT_EXERCISE); + ps.setInt(7, MsgSubType.TEXT_SERVICE); + ps.setInt(8, MsgSubType.TEXT_COURSE); + ps.setInt(9, lineCode); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) return null; PostBlock pb = new PostBlock(); @@ -204,6 +213,10 @@ final class ChannelsReadSupport { ti.text = tlb.message; } else if (e.body instanceof TextReplyBody trb) { ti.text = trb.message; + } else if (e.body instanceof blockchain.body.TextRatingBody trb) { + ti.text = trb.message; + } else if (e.body instanceof blockchain.body.StatusActionBody sab) { + ti.text = sab.message; } else if (e.body instanceof TextBody tb) { ti.text = tb.message; } @@ -218,7 +231,7 @@ final class ChannelsReadSupport { String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number FROM blocks - WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? + WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=? ORDER BY block_number """ + order + " LIMIT ?"; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -226,8 +239,12 @@ final class ChannelsReadSupport { ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); ps.setInt(4, MsgSubType.TEXT_REPOST); - ps.setInt(5, lineCode); - ps.setInt(6, limit); + ps.setInt(5, MsgSubType.TEXT_ENTRYPOINT); + ps.setInt(6, MsgSubType.TEXT_EXERCISE); + ps.setInt(7, MsgSubType.TEXT_SERVICE); + ps.setInt(8, MsgSubType.TEXT_COURSE); + ps.setInt(9, lineCode); + ps.setInt(10, limit); try (ResultSet rs = ps.executeQuery()) { List out = new ArrayList<>(); while (rs.next()) { @@ -589,6 +606,22 @@ final class ChannelsReadSupport { return sb.toString(); } + static boolean isChannelFeedSubType(int subType) { + return subType == MsgSubType.TEXT_POST + || subType == MsgSubType.TEXT_REPOST + || subType == MsgSubType.TEXT_ENTRYPOINT + || subType == MsgSubType.TEXT_EXERCISE + || subType == MsgSubType.TEXT_SERVICE + || subType == MsgSubType.TEXT_COURSE; + } + + static boolean supportsEditPostVersions(int subType) { + return subType == MsgSubType.TEXT_POST + || subType == MsgSubType.TEXT_EXERCISE + || subType == MsgSubType.TEXT_SERVICE + || subType == MsgSubType.TEXT_COURSE; + } + static final class PostBlock { String login; String bchName; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 9a882ea4..882ad706 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -143,7 +143,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { v1.setCreatedAtMs(postText.createdAtMs); versionsOut.add(v1); - if (post.msgSubType == MsgSubType.TEXT_POST) { + if (ChannelsReadSupport.supportsEditPostVersions(post.msgSubType)) { List edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash); for (ChannelsReadSupport.PostBlock edit : edits) { ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index 243ea507..83b045b1 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -208,7 +208,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { first.setCreatedAtMs(base.createdAtMs); versions.add(first); - if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) { + if (row.msgSubType == MsgSubType.TEXT_REPLY || ChannelsReadSupport.supportsEditPostVersions(row.msgSubType)) { 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); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java index a41b9811..4f1c167c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java @@ -58,7 +58,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { AND block_number = ? AND block_hash = ? AND msg_type = ? - AND msg_sub_type = ? + AND msg_sub_type IN (?, ?, ?, ?, ?, ?) %s LIMIT 1 """.formatted(strictChannelMatch ? "AND line_code = ?" : ""); @@ -98,8 +98,13 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex)); existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT); existsPs.setInt(5, MsgSubType.TEXT_POST); + existsPs.setInt(6, MsgSubType.TEXT_REPOST); + existsPs.setInt(7, MsgSubType.TEXT_ENTRYPOINT); + existsPs.setInt(8, MsgSubType.TEXT_EXERCISE); + existsPs.setInt(9, MsgSubType.TEXT_SERVICE); + existsPs.setInt(10, MsgSubType.TEXT_COURSE); if (strictChannelMatch) { - existsPs.setInt(6, expectedRoot); + existsPs.setInt(11, expectedRoot); } boolean exists; diff --git a/VERSION.properties b/VERSION.properties index 64d6dd3f..6e597b10 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ client.version=1.5.4 -server.version=1.4.5 +server.version=1.4.6 diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index c156605f..64584640 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -88,6 +88,8 @@ - `limit_exceeded` - `chain_resync_in_progress` — цепочка временно заблокирована полным resync - `repost_disabled` — репосты временно отключены до будущей реализации +- `entrypoint_edit_forbidden` — `TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST` +- `status_confirmed_target_must_be_status_action` — `STATUS_CONFIRMED` должен ссылаться на статусный блок - `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META` - `internal_error` @@ -104,8 +106,13 @@ - `TEXT_EDIT_POST (11)` - `TEXT_REPLY (20)` - `TEXT_EDIT_REPLY (21)` - - `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled` - - `TEXT_CHANNEL_META (70)` — скрытый технический снимок профиля канала + - `TEXT_RATING (30)` — target-based отзыв на конкретный блок + - `TEXT_REPOST (50)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled` + - `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала + - `TEXT_ENTRYPOINT (100)` — входная страница канала + - `TEXT_EXERCISE (110)` — line-based материал упражнения + - `TEXT_SERVICE (120)` — line-based материал услуги / процедуры + - `TEXT_COURSE (130)` — line-based материал курса 3. **REACTION (type=2)** - `REACTION_LIKE (1)` @@ -135,6 +142,16 @@ 5. **USER_PARAM (type=4)** - `USER_PARAM_TEXT_TEXT (1)` +6. **STATUS_ACTION (type=5)** + - `STATUS_DONE_ONCE (10)` + - `STATUS_INTERESTED (20)` + - `STATUS_STARTED (30)` + - `STATUS_IN_STUDY (40)` + - `STATUS_COMPLETED (50)` + - `STATUS_ABANDONED (60)` + - `STATUS_LEARNED (70)` + - `STATUS_CONFIRMED (80)` + ## 6. Практические payload-форматы для каналов и вложений `AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата. @@ -173,7 +190,7 @@ ### Изменение профиля канала -Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=70)`. +Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`. Текстовое содержимое body использует тот же формат полного снимка профиля: diff --git a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md index 52d25999..af0b71d2 100644 --- a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md +++ b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md @@ -11,14 +11,16 @@ - `12_REACTION_Blocks.md` — реакции (`type=2`). - `13_CONNECTION_Blocks.md` — связи/подписки (`type=3`). - `14_USER_PARAM_Blocks.md` — пользовательские параметры (`type=4`). +- `15_STATUS_ACTION_Blocks.md` — статусные действия (`type=5`). ## Быстрая карта типов - `type=0` — TECH: HEADER, CREATE_CHANNEL. -- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/REPOST. +- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE. - `type=2` — REACTION: LIKE/UNLIKE. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `type=4` — USER_PARAM: key/value-параметры пользователя. +- `type=5` — STATUS_ACTION: DONE_ONCE/INTERESTED/STARTED/IN_STUDY/COMPLETED/ABANDONED/LEARNED/CONFIRMED. ## Примечание diff --git a/docs/Blockchain/02_Channel_Commands.md b/docs/Blockchain/02_Channel_Commands.md index 417040f9..823da7cd 100644 --- a/docs/Blockchain/02_Channel_Commands.md +++ b/docs/Blockchain/02_Channel_Commands.md @@ -9,7 +9,7 @@ Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок: - `msg_type=1` -- `subType=70` +- `subType=90` - `TEXT_CHANNEL_META` Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index 63d68d65..c629eb85 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -1,6 +1,6 @@ # TEXT блоки (`type=1`, `version=1`) -TEXT-тип хранит сообщения и редактирования. +TEXT-тип хранит сообщения, материалы и редактирования. ## Подтипы @@ -21,24 +21,52 @@ TEXT-тип хранит сообщения и редактирования. - target на исходный REPLY + новый текст. - допускается пустой `text` для логического удаления сообщения (без физического удаления блока). -5. `subType=30` — `TEXT_REPOST` +5. `subType=30` — `TEXT_RATING` + - target-based отзыв на конкретный блок; + - содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва; + - не является сообщением линии канала. + +6. `subType=50` — `TEXT_REPOST` - репост сообщения в линию канала; - содержит line-поля + target на оригинальное сообщение + текст комментария; - на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются); - временно отключён для записи через `AddBlock` до будущей реализации репостов. -6. `subType=70` — `TEXT_CHANNEL_META` +7. `subType=90` — `TEXT_CHANNEL_META` - скрытый технический снимок профиля канала; - содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием; - не отображается как обычное сообщение ленты; - применяется сервером к текущему состоянию канала. +8. `subType=100` — `TEXT_ENTRYPOINT` + - входная страница канала; + - line-based сообщение с тем же body, что у `TEXT_POST`; + - не редактируется через `TEXT_EDIT_POST`: новая версия создаётся новым `TEXT_ENTRYPOINT`. + +9. `subType=110` — `TEXT_EXERCISE` + - line-based материал упражнения; + - использует тот же body, что у `TEXT_POST`. + +10. `subType=120` — `TEXT_SERVICE` + - line-based материал услуги / процедуры; + - использует тот же body, что у `TEXT_POST`. + +11. `subType=130` — `TEXT_COURSE` + - line-based материал курса; + - использует тот же body, что у `TEXT_POST`. + Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). ## Правило для edit `EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit. +Важно: + +- `TEXT_EDIT_POST` — технический edit для line-based сообщений канала; +- `TEXT_EDIT_REPLY` — технический edit для reply-сообщений; +- `TEXT_ENTRYPOINT` через `TEXT_EDIT_POST` не редактируется. + ## Пустой text в edit - Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`. diff --git a/docs/Blockchain/15_STATUS_ACTION_Blocks.md b/docs/Blockchain/15_STATUS_ACTION_Blocks.md new file mode 100644 index 00000000..f683be56 --- /dev/null +++ b/docs/Blockchain/15_STATUS_ACTION_Blocks.md @@ -0,0 +1,62 @@ +# STATUS_ACTION блоки (`type=5`, `version=1`) + +`STATUS_ACTION` хранит статусные действия пользователя по отношению к конкретному материалу. + +## Подтипы + +1. `subType=10` — `STATUS_DONE_ONCE` + - выполнил / прошёл один раз. + +2. `subType=20` — `STATUS_INTERESTED` + - заинтересовался материалом. + +3. `subType=30` — `STATUS_STARTED` + - начал. + +4. `subType=40` — `STATUS_IN_STUDY` + - находится в процессе полноценного изучения. + +5. `subType=50` — `STATUS_COMPLETED` + - завершил. + +6. `subType=60` — `STATUS_ABANDONED` + - бросил. + +7. `subType=70` — `STATUS_LEARNED` + - выучил упражнение / комплекс. + +8. `subType=80` — `STATUS_CONFIRMED` + - подтвердил чужой status-блок. + +## Формат body + +Все `STATUS_ACTION` используют один и тот же бинарный body-формат: + +```text +[1] toBlockchainNameLen (uint8) +[N] toBlockchainName UTF-8 +[4] toBlockGlobalNumber +[32] toBlockHash32 +[2] textLenBytes (uint16) +[M] text UTF-8 +``` + +Где: + +- `toBlockchainName` — блокчейн, в котором находится целевой материал; +- `toBlockGlobalNumber` — номер целевого блока; +- `toBlockHash32` — хэш целевого блока; +- `text` — опциональное пояснение пользователя к статусу. + +## Правила + +- Каждый `STATUS_ACTION` всегда является target-based сообщением. +- `STATUS_ACTION` не является line-based сообщением канала. +- Текст может быть пустым: основной смысл задаётся самим `subType`. +- Базовые статусы пользователь ставит сам за себя. +- `STATUS_CONFIRMED` ставится другим человеком на конкретный status-блок. + +## Что не поддерживается + +- Отдельного `EDIT` для `STATUS_ACTION` нет. +- Если нужно изменить смысл статуса, пишется новое статусное событие. diff --git a/docs/Blockchain/16_TEXT_Channel_Meta.md b/docs/Blockchain/16_TEXT_Channel_Meta.md index 51220e23..211b1832 100644 --- a/docs/Blockchain/16_TEXT_Channel_Meta.md +++ b/docs/Blockchain/16_TEXT_Channel_Meta.md @@ -5,7 +5,7 @@ ## Блок - `msg_type = 1` -- `subType = 70` +- `subType = 90` - `msgVersion = 1` - body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index e9b5a317..fe70c227 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,15 @@ # История изменений документации блокчейна +## 2026-08-09 18:55:16 +0400 +- Базовый коммит-ориентир: `43f54c9`. +- Для первой итерации новых контентных типов обновлена карта `TEXT`-подтипов: + - `TEXT_RATING` добавлен как `subType=30` и трактуется как target-based отзыв на конкретный блок; + - `TEXT_REPOST` перенесён на `subType=50` и оставлен как отложенная заготовка; + - `TEXT_CHANNEL_META` перенесён на `subType=90`; + - добавлены line-based `TEXT_ENTRYPOINT (100)`, `TEXT_EXERCISE (110)`, `TEXT_SERVICE (120)`, `TEXT_COURSE (130)`. +- Добавлен новый верхнеуровневый тип `STATUS_ACTION (type=5)` с подтипами `10/20/30/40/50/60/70/80`. +- `CHANNEL_MEMBERSHIP` в текущую реализацию не включён и ведётся отдельно как отложенная тема. + ## 2026-08-04 12:00:00 +0400 - Базовый коммит-ориентир: `391b18a`. - Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 438eb4ff..822b3430 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -17,15 +17,17 @@ Социальные связи (`msg_type=3`). 7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md) Параметры пользователя (`msg_type=4`). -8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md) +8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md) + Статусные действия пользователя (`msg_type=5`). +9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md) Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений. -9. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) +10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) Скрытый `TEXT_CHANNEL_META` для профиля канала. -10. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) +11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) Типы каналов и формат `CreateChannelBody`. -11. [02_Channel_Commands.md](./02_Channel_Commands.md) +12. [02_Channel_Commands.md](./02_Channel_Commands.md) Команды в текстовых сообщениях каналов. -12. [CHANGELOG.md](./CHANGELOG.md) +13. [CHANGELOG.md](./CHANGELOG.md) Журнал изменений документации. ## Смежная документация diff --git a/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md b/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md new file mode 100644 index 00000000..be9c38f4 --- /dev/null +++ b/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md @@ -0,0 +1,60 @@ +# Черновик CHANNEL_MEMBERSHIP для каналов + +## Статус документа + +Этот файл является отдельным отложенным черновиком. + +Важно: + +- тема временно вынесена из основной первой итерации; +- в текущую реализацию не входит; +- документ нужен, чтобы не потерять уже согласованные мысли и вернуться к ним позже. + +## Зачем вынесено отдельно + +`CHANNEL_MEMBERSHIP` пока решено не делать вместе с `TEXT` и `STATUS_ACTION`, чтобы: + +- не перегружать текущую реализацию; +- не смешивать две разные задачи; +- спокойно завершить первую итерацию по контенту и статусам; +- вернуться к membership позже отдельным этапом. + +## Отложенная схема ссылок + +На текущий момент в отложенный черновик заносится такая схема: + +- `JOIN_REQUEST` ссылается на root-блок канала; +- `JOIN_ACCEPTED` ссылается на `JOIN_REQUEST`; +- `LEFT` ссылается на root-блок канала; +- `REMOVED` ссылается на последнее membership-событие этого участника в этом канале, лучше всего на `JOIN_ACCEPTED`. + +## Предварительные подтипы + +Если тема будет возвращена в реализацию, предварительно рассматриваются: + +- `subType=10` — `CHANNEL_JOIN_REQUEST` +- `subType=20` — `CHANNEL_JOIN_ACCEPTED` +- `subType=30` — `CHANNEL_LEFT` +- `subType=40` — `CHANNEL_REMOVED` + +## Что ещё нужно будет отдельно решить позже + +Перед возвратом к теме нужно будет отдельно утвердить: + +- точный байтовый формат `CHANNEL_MEMBERSHIP`; +- в чьём блокчейне пишутся membership-события; +- политику прав доступа: + - кто может писать `JOIN_ACCEPTED`; + - кто может писать `REMOVED`; + - кто и как подтверждает актуальный состав канала; +- как сервер строит read-model текущего состава канала; +- как UI показывает pending-заявки, принятых участников, вышедших и исключённых. + +## Краткий итог + +`CHANNEL_MEMBERSHIP` не отменён, а именно отложен. + +Следующий рекомендуемый шаг: + +- сначала завершить первую итерацию `TEXT + STATUS_ACTION`; +- потом отдельным этапом вернуться к membership-логике каналов. diff --git a/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md b/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md new file mode 100644 index 00000000..ca008a28 --- /dev/null +++ b/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md @@ -0,0 +1,723 @@ +# Черновик дополнений каналов и типов сообщений SHiNE + +## Статус документа + +Этот файл является рабочим черновиком первой итерации. + +Важно: + +- это не финальная спецификация; +- формат блокчейна этим документом пока не меняется автоматически; +- документ фиксирует согласованную на текущий момент концепцию; +- в первую итерацию входят только `TEXT` и `STATUS_ACTION`; +- всё, что касается `CHANNEL_MEMBERSHIP`, вынесено в отдельный отложенный черновик: + - `Черновик_CHANNEL_MEMBERSHIP_для_каналов.md` + +## Цель + +Нужно расширить текущую модель каналов SHiNE так, чтобы канал был не только лентой обычных постов, но и пространством: + +- материалов; +- упражнений; +- услуг / процедур; +- курсов; +- входных страниц каналов; +- истории действий пользователей по этим материалам. + +При этом важно: + +- не ломать существующий блокчейн без отдельного согласованного шага; +- минимально менять верхнеуровневые `type`; +- по возможности переиспользовать уже существующий текстовый тип сообщений; +- отделять собственно контент от событий состояния. + +## Базовая идея первой итерации + +На текущий момент согласована следующая модель: + +- старый `type=1`, который раньше назывался `TEXT`, по коду можно не менять; +- в документации и логике его можно трактовать шире как основной контентный тип сообщений; +- все новые смысловые материалы каналов добавляются как новые `subType` внутри `type=1`; +- отдельным верхнеуровневым `type` в первой итерации становится только: + - `type=5` — действия пользователя по материалам. + +Иначе говоря: + +- `type=1` = сообщения и материалы; +- `type=5` = статусные действия пользователя; +- `type=6` = тема вынесена в отдельный отложенный черновик и в первую итерацию не входит. + +## Текущие старые верхнеуровневые типы + +Они сохраняются: + +- `type=0` — `TECH` +- `type=1` — `TEXT` +- `type=2` — `REACTION` +- `type=3` — `CONNECTION` +- `type=4` — `USER_PARAM` + +## Верхнеуровневые типы первой итерации + +Используются: + +- `0` — `TECH` +- `1` — `TEXT` +- `2` — `REACTION` +- `3` — `CONNECTION` +- `4` — `USER_PARAM` +- `5` — `STATUS_ACTION` + +Отдельно: + +- `6` — `CHANNEL_MEMBERSHIP` + - зарезервирован как следующая тема; + - вынесен в отдельный черновик; + - в текущую реализацию не входит. + +## Подтипы внутри `type=1` + +Согласованная таблица первой итерации: + +- `subType=10` — `TEXT_POST` +- `subType=11` — `TEXT_EDIT_POST` +- `subType=20` — `TEXT_REPLY` +- `subType=21` — `TEXT_EDIT_REPLY` +- `subType=30` — `TEXT_RATING` +- `subType=50` — `TEXT_REPOST` +- `subType=90` — `TEXT_CHANNEL_META` +- `subType=100` — `TEXT_ENTRYPOINT` +- `subType=110` — `TEXT_EXERCISE` +- `subType=120` — `TEXT_SERVICE` +- `subType=130` — `TEXT_COURSE` + +## Принцип форматов внутри `type=1` + +### Line-based сообщения + +Следующие подтипы считаются line-based и используют тот же body, что и обычный `TEXT_POST`: + +- `TEXT_POST` +- `TEXT_REPOST` +- `TEXT_CHANNEL_META` +- `TEXT_ENTRYPOINT` +- `TEXT_EXERCISE` +- `TEXT_SERVICE` +- `TEXT_COURSE` + +### Target-based сообщения + +Следующие подтипы считаются target-based: + +- `TEXT_REPLY` +- `TEXT_EDIT_REPLY` +- `TEXT_RATING` + +### Edit для `TEXT` + +В первой итерации согласовано оставить в коде два технических edit-подтипа: + +- `TEXT_EDIT_POST` + - edit для сообщений линии канала; +- `TEXT_EDIT_REPLY` + - edit для reply-сообщений. + +То есть в документации больше не используется старое упрощённое описание “один общий `TEXT_EDIT`”. + +## Смысл подтипов `type=1` + +### `TEXT_POST` + +Обычный текстовый пост в канале. + +### `TEXT_EDIT_POST` + +Редактирование line-based текстового сообщения канала. + +Принцип: + +- edit всегда ссылается на оригинальный блок; +- edit не должен ссылаться на предыдущий edit; +- фактический тип и правила берутся из оригинального сообщения. + +### `TEXT_REPLY` + +Обычный ответ / комментарий на сообщение. + +Важно: + +- reply остаётся единым; +- отвечать можно на `post`, `rating`, `exercise`, `service`, `course`, `entrypoint`, `status_action`; +- reply сам является target-based сообщением. + +### `TEXT_EDIT_REPLY` + +Редактирование reply-сообщения. + +### `TEXT_RATING` + +Текстовый отзыв / мнение / оценка на конкретный блок. + +Смысл: + +- это не line-based пост; +- это target-based сообщение-отзыв; +- оно всегда ссылается на конкретный блок, который оценивает; +- оценку пользователя как отдельную сущность в эту итерацию не включаем. + +### `TEXT_REPOST` + +Отложенная будущая заготовка. + +На текущем этапе: + +- код зарезервирован; +- бизнес-логика не входит в первую итерацию; +- детальная реализация может быть возвращена позже. + +### `TEXT_CHANNEL_META` + +Специальное скрытое сообщение метаданных канала. + +Через него задаются: + +- красивое имя канала; +- аватар; +- описание; +- и другие общие channel meta. + +Оно: + +- не является обычным пользовательским сообщением; +- не должно показываться в ленте как обычный пост; +- должно парситься по отдельным техническим правилам. + +### `TEXT_ENTRYPOINT` + +Входная / главная страница канала. + +Смысл: + +- это не отдельный верхнеуровневый тип; +- это специальный line-based текстовый материал; +- он служит входной страницей канала; +- он может объяснять структуру канала, давать ссылки, вводить человека в тему. + +Принцип: + +- `TEXT_ENTRYPOINT` не редактируется через `TEXT_EDIT_POST`; +- новая версия создаётся новым сообщением `TEXT_ENTRYPOINT`; +- если в канале несколько `entrypoint`, актуальным считается последний. + +### `TEXT_EXERCISE` + +Упражнение или комплекс упражнений. + +Смысл: + +- материал, который можно выполнять много раз; +- материал, который можно выучить; +- по нему удобно строить статистику выполнений и освоения. + +### `TEXT_SERVICE` + +Услуга / процедура. + +Смысл: + +- материал, который пользователь проходит; +- обычно не “учится выполнять”, а именно получает / проходит; +- прохождение фиксируется отдельным `STATUS_ACTION`. + +### `TEXT_COURSE` + +Курс. + +Смысл: + +- материал, по которому есть путь обучения; +- пользователь может заинтересоваться, начать, учиться, завершить, бросить. + +## Канал `0` + +Согласована новая трактовка канала `0`. + +Канал `0` становится: + +- обычным каналом публикаций пользователя по умолчанию; +- местом его основной ленты; +- местом его главной страницы, если там есть `entrypoint`. + +### Что это значит + +Если у пользователя в канале `0` существует `TEXT_ENTRYPOINT`, тогда: + +- ссылка `SHiNE/` открывает именно этот последний `entrypoint`. + +Если `entrypoint` в канале `0` нет, тогда: + +- `SHiNE/` открывает обычную ленту публикаций канала `0`. + +## Правила открытия каналов и ссылок + +Согласована следующая логика: + +- `SHiNE/` + - главная страница пользователя, то есть канал `0`; +- `SHiNE//` + - основная ссылка канала. + +### Как открывается `SHiNE//` + +Если у канала есть хотя бы один `TEXT_ENTRYPOINT`, тогда: + +- по умолчанию открывается последний актуальный `entrypoint`; +- UI показывает, сколько после него было новых сообщений; +- UI показывает кнопку перехода в конец канала. + +Если `entrypoint` нет: + +- открывается обычная лента канала. + +Для подписанного пользователя: + +- логичнее вести его в место новых непрочитанных сообщений; +- а не всегда принудительно открывать entrypoint; +- при этом сверху можно показывать кнопку `Открыть entrypoint`. + +### Ссылки на конкретные сообщения + +Остаются обычные формы: + +- `SHiNE///` +- `SHiNE////` + +Если нужно показать старую конкретную версию `entrypoint`, даётся ссылка именно на номер нужного сообщения. + +## Новый тип `STATUS_ACTION` + +`type=5` вводится для действий пользователя по контенту. + +## Принцип `STATUS_ACTION` + +Согласовано: + +- `STATUS_ACTION` всегда является target-based сообщением; +- статусное действие всегда ссылается на конкретный блок-материал; +- это не обычный пост в канале; +- из таких сообщений можно собирать виртуальную ленту пользователя. + +### Как трактуется текст в `STATUS_ACTION` + +Основной смысл блока задаётся самим статусным событием. + +Текст внутри такого блока: + +- это комментарий пользователя к действию, если он есть; +- не является главным смыслом записи; +- служит пояснением. + +Примеры: + +- “Начал сегодня” +- “Решил пройти серьёзно” +- “Сделал это после практики” +- “Выучил базовый комплекс” + +## Подтипы `STATUS_ACTION` + +Предлагаемая таблица: + +- `subType=10` — `STATUS_DONE_ONCE` +- `subType=20` — `STATUS_INTERESTED` +- `subType=30` — `STATUS_STARTED` +- `subType=40` — `STATUS_IN_STUDY` +- `subType=50` — `STATUS_COMPLETED` +- `subType=60` — `STATUS_ABANDONED` +- `subType=70` — `STATUS_LEARNED` +- `subType=80` — `STATUS_CONFIRMED` + +## Смысл подтипов `STATUS_ACTION` + +### `STATUS_DONE_ONCE` + +Факт одного выполнения / прохождения. + +Это накопительное событие. + +### `STATUS_INTERESTED` + +Пользователя заинтересовал материал. + +### `STATUS_STARTED` + +Пользователь начал. + +Это начальный статус процесса, но ещё не “устойчивое обучение”. + +### `STATUS_IN_STUDY` + +Пользователь уже полноценно находится в обучении. + +Смысл: + +- не просто попробовал; +- а реально учится; +- это отдельный статус, более сильный, чем `started`. + +### `STATUS_COMPLETED` + +Пользователь завершил курс / прохождение. + +### `STATUS_ABANDONED` + +Пользователь бросил. + +### `STATUS_LEARNED` + +Пользователь выучил упражнение или комплекс и знает, как его делать. + +Это не то же самое, что “сделал один раз”. + +### `STATUS_CONFIRMED` + +Подтверждение чужого status-события. + +Важно: + +- подтверждается не курс вообще; +- не упражнение вообще; +- а конкретный `STATUS_ACTION` конкретного пользователя. + +## Кто ставит статусы + +Согласовано правило: + +- любой базовый статус ставит сам пользователь от своего имени; +- другие люди не ставят статус за него; +- другие люди могут только подтверждать его status-событие через `STATUS_CONFIRMED`. + +То есть: + +- `INTERESTED`, `STARTED`, `IN_STUDY`, `COMPLETED`, `ABANDONED`, `LEARNED`, `DONE_ONCE` + - ставит сам пользователь; +- `CONFIRMED` + - ставят другие люди на конкретный статусный блок. + +### Вес подтверждений + +В базовой версии веса подтверждений не вводятся. + +Но в будущем можно добавить: + +- более значимое подтверждение от создателя курса; +- более значимое подтверждение от создателя упражнения; +- весовые коэффициенты от близких / доверенных людей; +- слабые подтверждения от обычных пользователей. + +## Матрица допустимости статусов по типам контента + +### Для `TEXT_SERVICE` + +Разрешены: + +- `STATUS_DONE_ONCE` +- `STATUS_CONFIRMED` + +Не разрешены: + +- `INTERESTED` +- `STARTED` +- `IN_STUDY` +- `COMPLETED` +- `ABANDONED` +- `LEARNED` + +### Для `TEXT_EXERCISE` + +Разрешены: + +- `STATUS_DONE_ONCE` +- `STATUS_LEARNED` +- `STATUS_CONFIRMED` + +Логика: + +- упражнение можно выполнять много раз; +- упражнение можно выучить; +- `LEARNED` и `DONE_ONCE` не конфликтуют и живут параллельно. + +### Для `TEXT_COURSE` + +Разрешены: + +- `STATUS_INTERESTED` +- `STATUS_STARTED` +- `STATUS_IN_STUDY` +- `STATUS_COMPLETED` +- `STATUS_ABANDONED` +- `STATUS_CONFIRMED` + +### Для `TEXT_ENTRYPOINT` + +Статусные действия не ставятся. + +### Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_RATING`, `TEXT_CHANNEL_META` + +Статусные действия не ставятся. + +## Накопительные и текущие состояния + +Нужно различать: + +- накопительные события; +- текущий статус. + +### Накопительные события + +К ним относится: + +- `STATUS_DONE_ONCE` + +Они могут встречаться сколько угодно раз. + +### Текущий статус + +Для некоторых статусов есть “актуальное состояние”. + +Например: + +- `INTERESTED` +- `STARTED` +- `IN_STUDY` +- `COMPLETED` +- `ABANDONED` +- `LEARNED` + +Текущий статус определяется как последнее событие соответствующей оси. + +### Для упражнения + +У упражнения есть две независимые оси: + +- количественная: сколько раз выполнено; +- качественная: выучено или нет. + +Это значит: + +- `STATUS_DONE_ONCE` накапливается; +- `STATUS_LEARNED` живёт как отдельный качественный статус. + +### Для курса + +У курса текущий статус вычисляется по последнему из: + +- `INTERESTED` +- `STARTED` +- `IN_STUDY` +- `COMPLETED` +- `ABANDONED` + +## Виртуальная лента достижений пользователя + +Согласована важная идея: + +- не нужно создавать отдельный физический канал для истории выполнений пользователя; +- вместо этого строится виртуальная лента из его `STATUS_ACTION`. + +### Что это даёт + +Можно одновременно получить: + +- историю человека; +- статистику по самому материалу. + +Например, по упражнению можно увидеть: + +- сколько разных людей его выполняли; +- сколько всего выполнений было; +- сколько раз конкретный человек его выполнял; +- кто его выучил. + +### Что попадает в виртуальную ленту + +Попадают status-события пользователя: + +- выполненные упражнения; +- выученные упражнения; +- интерес к курсам; +- начатые курсы; +- обучение в процессе; +- завершённые курсы; +- брошенные курсы; +- прохождения услуг; +- подтверждения к этим событиям. + +### Можно ли это обсуждать + +Да. + +Такие статусные сообщения остаются обычными объектами обсуждения: + +- на них можно отвечать; +- их можно комментировать; +- на них можно писать отзывы; +- их можно подтверждать. + +То есть: + +- лента виртуальная; +- но сами записи реальные и обсуждаемые. + +## Как это должно выглядеть в интерфейсе + +### В каналах + +Для разных типов сообщений UI должен понимать роль сообщения. + +Например: + +- у `TEXT_EXERCISE` можно показать кнопки: + - `Выполнил` + - `Выучил` +- у `TEXT_SERVICE`: + - `Прошёл` +- у `TEXT_COURSE`: + - `Интересно` + - `Начал` + - `Учусь` + - `Завершил` + - `Бросил` + +### Для `entrypoint` + +Если канал открывается по основной ссылке и у него есть `entrypoint`, UI: + +- показывает сам последний `entrypoint`; +- показывает число новых сообщений после него; +- показывает кнопку перехода в конец канала. + +Если пользователь уже подписан на канал: + +- открывать лучше место новых непрочитанных сообщений; +- но с кнопкой открытия последнего `entrypoint`. + +### Для виртуальной ленты достижений + +В профиле пользователя UI может показывать: + +- отдельную вкладку / раздел; +- где лента строится из его `STATUS_ACTION`. + +## Что именно не нужно делать в первой итерации + +### Не нужен отдельный физический канал достижений + +Технически не нужен. + +### Не нужны отдельные edit-подтипы для каждого вида контента + +Не нужны: + +- `TEXT_EDIT_EXERCISE` +- `TEXT_EDIT_SERVICE` +- `TEXT_EDIT_COURSE` + +Достаточно текущего разделения: + +- `TEXT_EDIT_POST` +- `TEXT_EDIT_REPLY` + +### Не нужен отдельный верхнеуровневый тип для `entrypoint` + +Он остаётся подтипом `type=1`. + +### Не нужен `EDIT` для status-событий + +`STATUS_ACTION` не редактируются. + +Если нужно изменить смысл, пишется новое событие. + +### Не нужен `CHANNEL_MEMBERSHIP` в первой итерации + +Эта тема отложена в отдельный черновик. + +## Сводная таблица + +### Верхнеуровневые `type` + +| Код | Имя | Статус | +|---|---|---| +| 0 | TECH | старый | +| 1 | TEXT | старый код, новое расширенное смысловое описание | +| 2 | REACTION | старый | +| 3 | CONNECTION | старый | +| 4 | USER_PARAM | старый | +| 5 | STATUS_ACTION | первая итерация | +| 6 | CHANNEL_MEMBERSHIP | вынесено в отдельный отложенный черновик | + +### Подтипы `type=1` + +| subType | Имя | Смысл | +|---|---|---| +| 10 | TEXT_POST | обычный post в линии канала | +| 11 | TEXT_EDIT_POST | edit line-based сообщения | +| 20 | TEXT_REPLY | target-based reply | +| 21 | TEXT_EDIT_REPLY | edit reply | +| 30 | TEXT_RATING | target-based отзыв на конкретный блок | +| 50 | TEXT_REPOST | отложенная будущая заготовка | +| 90 | TEXT_CHANNEL_META | скрытые метаданные канала | +| 100 | TEXT_ENTRYPOINT | входная страница канала | +| 110 | TEXT_EXERCISE | упражнение | +| 120 | TEXT_SERVICE | услуга / процедура | +| 130 | TEXT_COURSE | курс | + +### Подтипы `type=5` + +| subType | Имя | Смысл | +|---|---|---| +| 10 | STATUS_DONE_ONCE | выполнил / прошёл один раз | +| 20 | STATUS_INTERESTED | заинтересовался | +| 30 | STATUS_STARTED | начал | +| 40 | STATUS_IN_STUDY | учится полноценно | +| 50 | STATUS_COMPLETED | завершил | +| 60 | STATUS_ABANDONED | бросил | +| 70 | STATUS_LEARNED | выучил | +| 80 | STATUS_CONFIRMED | подтверждение status-события | + +## Что ещё нужно отдельно утвердить перед реализацией + +Перед началом реальной реализации желательно отдельно утвердить: + +- точный байтовый формат новых line-based `subType` внутри `type=1`; +- точный байтовый формат `TEXT_RATING` как target-based отзыва; +- точный байтовый формат `STATUS_ACTION`; +- правила target-ссылок для status-событий; +- правила target-ссылок для отзывов на status-события; +- поведение удаления: + - можно ли логически удалять `TEXT_EXERCISE` / `TEXT_SERVICE` / `TEXT_COURSE`; + - можно ли удалять старые `entrypoint` или только оставлять их в истории; +- серверные read-model таблицы и индексы. + +## Предварительная оценка готовности к реализации + +На текущий момент концепция уже достаточно зрелая, чтобы начинать проектирование реализации первой итерации. + +Реализовывать это уже можно, если дополнительно утвердить: + +- байтовые форматы; +- правила валидации; +- API чтения новых сущностей; +- UI-матрицу действий по каждому типу контента. + +## Краткий итог + +Согласованная модель первой итерации сейчас такая: + +- почти всё новое содержимое каналов живёт внутри `type=1`; +- `entrypoint` — это специальное текстовое сообщение, а не отдельный верхнеуровневый тип; +- `entrypoint` не редактируется, а версионируется новыми сообщениями; +- канал `0` становится каналом публикаций пользователя и его главной страницей; +- действия пользователя по материалам выносятся в `STATUS_ACTION`; +- `TEXT_RATING` трактуется как target-based отзыв на конкретный блок; +- `CHANNEL_MEMBERSHIP` отложен в отдельный черновик и в первую итерацию не входит.