Сервер: добавить новые TEXT типы и STATUS_ACTION

This commit is contained in:
AidarKC
2026-08-09 18:59:59 +04:00
parent 43f54c90d2
commit d512442325
24 changed files with 1479 additions and 48 deletions
@@ -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",
@@ -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;
}
@@ -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());
}
}
@@ -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) {
@@ -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());
}
}
@@ -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;
@@ -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 — лучше добавить новые значения,
// не трогая уже занятые коды.
@@ -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()) {
@@ -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("""
@@ -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<PostBlock> 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;
@@ -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<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
for (ChannelsReadSupport.PostBlock edit : edits) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
@@ -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);
@@ -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;
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.5.4
server.version=1.4.5
server.version=1.4.6
+20 -3
View File
@@ -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 использует тот же формат полного снимка профиля:
@@ -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.
## Примечание
+1 -1
View File
@@ -9,7 +9,7 @@
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
- `msg_type=1`
- `subType=70`
- `subType=90`
- `TEXT_CHANNEL_META`
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
+31 -3
View File
@@ -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`.
@@ -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` нет.
- Если нужно изменить смысл статуса, пишется новое статусное событие.
+1 -1
View File
@@ -5,7 +5,7 @@
## Блок
- `msg_type = 1`
- `subType = 70`
- `subType = 90`
- `msgVersion = 1`
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
+10
View File
@@ -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`.
+7 -5
View File
@@ -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)
Журнал изменений документации.
## Смежная документация
@@ -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-логике каналов.
@@ -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/<login>` открывает именно этот последний `entrypoint`.
Если `entrypoint` в канале `0` нет, тогда:
- `SHiNE/<login>` открывает обычную ленту публикаций канала `0`.
## Правила открытия каналов и ссылок
Согласована следующая логика:
- `SHiNE/<login>`
- главная страница пользователя, то есть канал `0`;
- `SHiNE/<login>/<channel>`
- основная ссылка канала.
### Как открывается `SHiNE/<login>/<channel>`
Если у канала есть хотя бы один `TEXT_ENTRYPOINT`, тогда:
- по умолчанию открывается последний актуальный `entrypoint`;
- UI показывает, сколько после него было новых сообщений;
- UI показывает кнопку перехода в конец канала.
Если `entrypoint` нет:
- открывается обычная лента канала.
Для подписанного пользователя:
- логичнее вести его в место новых непрочитанных сообщений;
- а не всегда принудительно открывать entrypoint;
- при этом сверху можно показывать кнопку `Открыть entrypoint`.
### Ссылки на конкретные сообщения
Остаются обычные формы:
- `SHiNE/<login>/<channel>/<messageNumber>`
- `SHiNE/<login>/<channel>/<messageNumber>/<hash>`
Если нужно показать старую конкретную версию `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` отложен в отдельный черновик и в первую итерацию не входит.