SHA256
Сервер: добавить новые TEXT типы и STATUS_ACTION
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+165
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
-10
@@ -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) {
|
||||
|
||||
+157
@@ -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()) {
|
||||
|
||||
+67
@@ -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("""
|
||||
|
||||
+40
-7
@@ -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;
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+7
-2
@@ -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
@@ -1,2 +1,2 @@
|
||||
client.version=1.5.4
|
||||
server.version=1.4.5
|
||||
server.version=1.4.6
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Примечание
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
|
||||
|
||||
- `msg_type=1`
|
||||
- `subType=70`
|
||||
- `subType=90`
|
||||
- `TEXT_CHANNEL_META`
|
||||
|
||||
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.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`.
|
||||
|
||||
@@ -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` нет.
|
||||
- Если нужно изменить смысл статуса, пишется новое статусное событие.
|
||||
@@ -5,7 +5,7 @@
|
||||
## Блок
|
||||
|
||||
- `msg_type = 1`
|
||||
- `subType = 70`
|
||||
- `subType = 90`
|
||||
- `msgVersion = 1`
|
||||
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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` отложен в отдельный черновик и в первую итерацию не входит.
|
||||
Reference in New Issue
Block a user