perf: sync protocol and core hardening updates
This commit is contained in:
parent
152fed3b87
commit
4390ebf5a9
283 changed files with 29231 additions and 2295 deletions
51
internal/domain/album_group.go
Normal file
51
internal/domain/album_group.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrAlbumGroupReservationInvalid 表示相册分组预留缺少发送者、目标、
|
||||
// random_id 或 proposed grouped_id。RPC 边界通常会更早拦截这些输入;
|
||||
// domain/store 仍 fail-fast,避免坏绑定进入持久层。
|
||||
var ErrAlbumGroupReservationInvalid = errors.New("album group reservation invalid")
|
||||
|
||||
// AlbumGroupReservationRequest 在任何相册 item 落库或上传媒体解析前,原子地把
|
||||
// 一组 random_id 绑定到同一个 grouped_id。Peer 是幂等作用域的一部分:同一发送者
|
||||
// 可以在不同会话中复用 random_id,而不会互相污染相册分组。
|
||||
type AlbumGroupReservationRequest struct {
|
||||
SenderUserID int64
|
||||
Peer Peer
|
||||
Items []AlbumGroupReservationItem
|
||||
ProposedGroupedID int64
|
||||
}
|
||||
|
||||
// AlbumGroupReservationItem 把 random_id 与该 item 的不可变客户端意图绑定。
|
||||
// IntentHash 是在媒体解析/服务端派生字段产生前计算的 SHA-256;相同 random_id
|
||||
// 若携带不同意图必须报冲突,不能借旧 album reservation 绕过发送幂等校验。
|
||||
type AlbumGroupReservationItem struct {
|
||||
RandomID int64
|
||||
IntentHash []byte
|
||||
}
|
||||
|
||||
// Validate 校验持久层必须依赖的最小不变量。同一批内重复 random_id 与发送幂等
|
||||
// 冲突同义,必须显式失败,不能静默去重后改变客户端请求的消息条数。
|
||||
func (r AlbumGroupReservationRequest) Validate() error {
|
||||
if r.SenderUserID <= 0 || r.Peer.ID <= 0 || r.ProposedGroupedID == 0 || len(r.Items) == 0 {
|
||||
return ErrAlbumGroupReservationInvalid
|
||||
}
|
||||
if r.Peer.Type != PeerTypeUser && r.Peer.Type != PeerTypeChannel {
|
||||
return ErrAlbumGroupReservationInvalid
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(r.Items))
|
||||
for _, item := range r.Items {
|
||||
if item.RandomID == 0 || len(item.IntentHash) != sha256.Size {
|
||||
return ErrAlbumGroupReservationInvalid
|
||||
}
|
||||
if _, exists := seen[item.RandomID]; exists {
|
||||
return ErrMessageRandomIDDuplicate
|
||||
}
|
||||
seen[item.RandomID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ const (
|
|||
MaxChannelDifferenceLimit = 100
|
||||
// MaxChannelDifferenceTooLongMessages limits the latest message snapshot returned by channelDifferenceTooLong.
|
||||
MaxChannelDifferenceTooLongMessages = 100
|
||||
// MaxChannelUpdateRetentionBatch bounds one channel durable-log pruning transaction.
|
||||
// Retention advances the recoverable floor only through rows actually deleted in that transaction.
|
||||
MaxChannelUpdateRetentionBatch = 10000
|
||||
// MaxChannelParticipantsLimit limits a single participants page.
|
||||
MaxChannelParticipantsLimit = 200
|
||||
// MaxChannelParticipantsOffset bounds channels.getParticipants deep OFFSET work.
|
||||
|
|
@ -1184,6 +1187,23 @@ type DirtyChannel struct {
|
|||
Pts int
|
||||
}
|
||||
|
||||
// ChannelUpdateRetentionCheckpoint is the durable recovery boundary for one channel.
|
||||
// Events with pts <= RetainedThroughPts may be absent; callers below that floor must receive
|
||||
// channelDifferenceTooLong. LatestEventDate/LatestPts survive event pruning and keep account-level
|
||||
// dirty-channel nudges reconstructable after the hot event rows have been removed.
|
||||
type ChannelUpdateRetentionCheckpoint struct {
|
||||
ChannelID int64
|
||||
RetainedThroughPts int
|
||||
LatestEventDate int
|
||||
LatestPts int
|
||||
}
|
||||
|
||||
// ChannelUpdateRetentionResult describes one bounded, atomic prune operation.
|
||||
type ChannelUpdateRetentionResult struct {
|
||||
Checkpoint ChannelUpdateRetentionCheckpoint
|
||||
Deleted int
|
||||
}
|
||||
|
||||
// CreateChannelRequest creates a broadcast channel or megagroup.
|
||||
type CreateChannelRequest struct {
|
||||
CreatorUserID int64
|
||||
|
|
@ -1372,14 +1392,20 @@ type DeleteChannelResult struct {
|
|||
|
||||
// SendChannelMessageRequest sends one channel/supergroup message.
|
||||
type SendChannelMessageRequest struct {
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
MentionUserIDs []int64
|
||||
SkipDeliveryUserIDs []int64
|
||||
UserID int64
|
||||
ChannelID int64
|
||||
RandomID int64
|
||||
// IdempotencyFingerprint is the SHA-256 of the immutable client send intent. RPC callers
|
||||
// provide a raw-TL per-item value; internal callers leave it empty for the store fallback.
|
||||
IdempotencyFingerprint []byte
|
||||
// IdempotencyPreflighted is trusted internal execution metadata; see the private-send
|
||||
// equivalent. It is deliberately excluded from the durable fingerprint.
|
||||
IdempotencyPreflighted bool
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
MentionUserIDs []int64
|
||||
SkipDeliveryUserIDs []int64
|
||||
// SkipRecipientLookup lets high-level realtime fan-out use the online member
|
||||
// read model instead of forcing store.SendChannelMessage to synchronously
|
||||
// return an active-member recipient list after commit.
|
||||
|
|
@ -1405,13 +1431,26 @@ type SendChannelMessageRequest struct {
|
|||
// 虚拟频道 id;SavedPeer 是订阅者子会话分组键(订阅者发=自己,管理员回复=目标订阅者);
|
||||
// SenderUserID 是实际发件人。发件权限(订阅者身份/管理员)在 RPC 层校验,store 只校验 monoforum 存在。
|
||||
type SendMonoforumMessageRequest struct {
|
||||
MonoforumID int64
|
||||
SenderUserID int64
|
||||
SavedPeer Peer
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Date int
|
||||
MonoforumID int64
|
||||
SenderUserID int64
|
||||
SavedPeer Peer
|
||||
RandomID int64
|
||||
IdempotencyFingerprint []byte
|
||||
IdempotencyPreflighted bool
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Date int
|
||||
}
|
||||
|
||||
// ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one
|
||||
// monoforum sub-dialog send (SavedPeer is the subscriber scope). Lookup is read-only and must
|
||||
// never re-run membership/permission checks or allocate pts/message ids.
|
||||
type ChannelSendReplayRequest struct {
|
||||
ChannelID int64
|
||||
SenderUserID int64
|
||||
SavedPeer Peer
|
||||
RandomID int64
|
||||
IdempotencyFingerprint []byte
|
||||
}
|
||||
|
||||
// MonoforumHistoryFilter 按订阅者子会话拉取 monoforum 私信历史。
|
||||
|
|
@ -1520,7 +1559,11 @@ type SendChannelMessageResult struct {
|
|||
Event ChannelUpdateEvent
|
||||
Recipients []int64
|
||||
Duplicate bool
|
||||
Discussion *SendChannelDiscussionResult
|
||||
// ReplayDeleteEvent is the existing durable channel delete event paired
|
||||
// with a deleted exact-random_id replay. It must be returned only to the
|
||||
// caller echo and must never be fanned out as a fresh event.
|
||||
ReplayDeleteEvent *ChannelUpdateEvent
|
||||
Discussion *SendChannelDiscussionResult
|
||||
// MentionUserIDs 是本条消息解析出的被 @ 成员;在线 fanout 按它为
|
||||
// 每个接收者投影 message.mentioned/media_unread。
|
||||
MentionUserIDs []int64
|
||||
|
|
|
|||
55
internal/domain/login_code_delivery.go
Normal file
55
internal/domain/login_code_delivery.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram!
|
||||
|
||||
This code can be used to log in to your Telegram account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
|
||||
// LoginCodeDeliveryRequest describes one durable 777000 login-code delivery.
|
||||
// PhoneCodeHash is an opaque idempotency token and must never be persisted in
|
||||
// plaintext; store implementations persist only its SHA-256 digest.
|
||||
type LoginCodeDeliveryRequest struct {
|
||||
UserID int64
|
||||
PhoneCodeHash string
|
||||
Code string
|
||||
Date int
|
||||
// ExpiresAt is the unix second after which the compact idempotency receipt
|
||||
// may be reclaimed. It must cover the corresponding code's usable lifetime.
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryResult returns the immutable first delivery. Created is
|
||||
// false when the same phone_code_hash was already committed and replayed.
|
||||
type LoginCodeDeliveryResult struct {
|
||||
Message Message
|
||||
Created bool
|
||||
}
|
||||
|
||||
// OfficialLoginCodeMessage builds the account-visible incoming message from
|
||||
// Telegram's official notification account. Persistence assigns ID, UID and
|
||||
// Pts atomically.
|
||||
func OfficialLoginCodeMessage(userID int64, code string, date int) (Message, error) {
|
||||
if userID <= 0 || IsSystemUserID(userID) || strings.TrimSpace(code) == "" || len(code) > 64 || date < 0 || date > math.MaxInt32 {
|
||||
return Message{}, fmt.Errorf("%w: user=%d code_length=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, len(code), date)
|
||||
}
|
||||
body := fmt.Sprintf(officialLoginCodeMessageTemplate, code)
|
||||
codeOffset := len("Login code: ")
|
||||
return Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
|
||||
Date: date,
|
||||
Body: body,
|
||||
Entities: []MessageEntity{
|
||||
{Type: MessageEntityBold, Offset: 0, Length: len("Login code:")},
|
||||
{Type: MessageEntityBold, Offset: codeOffset, Length: len(code)},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package domain
|
|||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。
|
||||
|
|
@ -81,6 +82,27 @@ type UploadedFileRef struct {
|
|||
MD5 string // small file 客户端 md5_checksum(hex),可校验;big file 为空
|
||||
}
|
||||
|
||||
// UploadedMediaKind identifies the durable object materialized from a one-shot upload file id.
|
||||
type UploadedMediaKind string
|
||||
|
||||
const (
|
||||
UploadedMediaPhoto UploadedMediaKind = "photo"
|
||||
UploadedMediaDocument UploadedMediaKind = "document"
|
||||
)
|
||||
|
||||
// UploadedMediaReceipt makes InputMediaUploaded* replayable after transient upload parts have
|
||||
// been consumed. IntentHash binds the file id to the complete materialization intent (kind, part
|
||||
// metadata and document spec); MediaID points at the immutable Photo/Document returned on every
|
||||
// exact replay.
|
||||
type UploadedMediaReceipt struct {
|
||||
OwnerUserID int64
|
||||
FileID int64
|
||||
IntentHash []byte
|
||||
Kind UploadedMediaKind
|
||||
MediaID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// DocumentSpec 描述从上传文件创建 Document 的元数据(来自 InputMediaUploadedDocument)。
|
||||
type DocumentSpec struct {
|
||||
MimeType string
|
||||
|
|
|
|||
|
|
@ -260,8 +260,18 @@ type SendPrivateTextRequest struct {
|
|||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
RecipientBlocked bool
|
||||
TTLPeriod int
|
||||
ViaBotID int64
|
||||
// IdempotencyFingerprint 是调用边界对原始、不可变发送请求计算的 SHA-256。
|
||||
// RPC 层应优先填入原始 TL 请求指纹,避免链接预览、骰子结果、上传媒体
|
||||
// 等服务端派生字段让合法重放看起来不同;内部调用留空时 store 会基于
|
||||
// domain command 的不可变字段生成等价指纹。
|
||||
IdempotencyFingerprint []byte
|
||||
// IdempotencyPreflighted is internal execution metadata. A trusted caller sets it only
|
||||
// after a read-only replay lookup returned absent, allowing the app/store layers to avoid
|
||||
// repeating the same indexed lookup. The transactional unique-key path still fences a
|
||||
// concurrent first writer; this flag is never part of the durable request fingerprint.
|
||||
IdempotencyPreflighted bool
|
||||
TTLPeriod int
|
||||
ViaBotID int64
|
||||
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
|
||||
GroupedID int64
|
||||
// Effect 消息特效 id(私聊专属,0 表无特效;调用方已对 catalog 校验过合法性)。
|
||||
|
|
@ -275,6 +285,16 @@ type SendPrivateTextRequest struct {
|
|||
RichMessage *MessageRichMessage
|
||||
}
|
||||
|
||||
// PrivateSendReplayRequest identifies one already-committed private send without carrying any
|
||||
// mutable or resolver-derived message fields. The fingerprint is computed at the original
|
||||
// request boundary and must be a complete SHA-256 value.
|
||||
type PrivateSendReplayRequest struct {
|
||||
SenderUserID int64
|
||||
RecipientUserID int64
|
||||
RandomID int64
|
||||
IdempotencyFingerprint []byte
|
||||
}
|
||||
|
||||
// SendPrivateTextResult 描述一次私聊文本发送的双端结果。
|
||||
type SendPrivateTextResult struct {
|
||||
SenderMessage Message
|
||||
|
|
@ -282,6 +302,10 @@ type SendPrivateTextResult struct {
|
|||
SenderEvent UpdateEvent
|
||||
RecipientEvent UpdateEvent
|
||||
Duplicate bool
|
||||
// ReplayDeleteEvent is the already-durable sender-side deletion that must
|
||||
// follow the first-send snapshot in an exact random_id replay. It never
|
||||
// represents a newly allocated event.
|
||||
ReplayDeleteEvent *UpdateEvent
|
||||
}
|
||||
|
||||
// SetPrivateChatThemeRequest changes the shared theme token for a private dialog.
|
||||
|
|
@ -351,12 +375,13 @@ type ForwardPrivateMessagesRequest struct {
|
|||
|
||||
// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。
|
||||
type ForwardPrivateMessagesResult struct {
|
||||
OwnerUserID int64
|
||||
SenderMessages []Message
|
||||
RecipientMessages []Message
|
||||
SenderEvents []UpdateEvent
|
||||
RecipientEvents []UpdateEvent
|
||||
Duplicates []bool
|
||||
OwnerUserID int64
|
||||
SenderMessages []Message
|
||||
RecipientMessages []Message
|
||||
SenderEvents []UpdateEvent
|
||||
RecipientEvents []UpdateEvent
|
||||
Duplicates []bool
|
||||
ReplayDeleteEvents []*UpdateEvent
|
||||
}
|
||||
|
||||
// ReadHistoryRequest 是账号视角的 messages.readHistory 命令。
|
||||
|
|
|
|||
|
|
@ -3,13 +3,30 @@ package domain
|
|||
import "errors"
|
||||
|
||||
var (
|
||||
ErrMessageIDInvalid = errors.New("message id invalid")
|
||||
ErrMessageEmpty = errors.New("message empty")
|
||||
ErrMessageAuthorRequired = errors.New("message author required")
|
||||
ErrMessageNotModified = errors.New("message not modified")
|
||||
ErrMessageNotReadYet = errors.New("message not read yet")
|
||||
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
|
||||
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
|
||||
ErrMessageIDInvalid = errors.New("message id invalid")
|
||||
ErrMessageEmpty = errors.New("message empty")
|
||||
ErrMessageAuthorRequired = errors.New("message author required")
|
||||
ErrMessageNotModified = errors.New("message not modified")
|
||||
ErrMessageNotReadYet = errors.New("message not read yet")
|
||||
// ErrMessageRandomIDDuplicate 表示同一发送者重复使用 random_id,且本次
|
||||
// 不可变请求载荷与首次成功发送不一致。完全相同的重放不返回此错误,
|
||||
// 而是复用首次发送结果。
|
||||
ErrMessageRandomIDDuplicate = errors.New("message random id duplicate")
|
||||
// ErrLoginCodeDeliveryInvalid rejects malformed durable 777000 delivery
|
||||
// commands before allocating message/pts facts.
|
||||
ErrLoginCodeDeliveryInvalid = errors.New("login code delivery invalid")
|
||||
// ErrLoginCodeDeliveryConflict means one phone_code_hash digest was reused
|
||||
// for a different account or code. It must fail closed rather than expose or
|
||||
// overwrite the first account's immutable receipt.
|
||||
ErrLoginCodeDeliveryConflict = errors.New("login code delivery conflict")
|
||||
// ErrLoginCodeDeliveryCommitAmbiguous means PostgreSQL lost the commit
|
||||
// acknowledgement and an independent receipt probe could not prove whether
|
||||
// the durable 777000 transaction committed. Callers must retain the opaque
|
||||
// code record until TTL expiry; deleting it could invalidate a committed but
|
||||
// undisclosed delivery and make a retry impossible to reconcile.
|
||||
ErrLoginCodeDeliveryCommitAmbiguous = errors.New("login code delivery commit ambiguous")
|
||||
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
|
||||
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
|
||||
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH:收藏夹子会话置顶
|
||||
// 数量达到 MaxPinnedSavedDialogs 上限。
|
||||
ErrPinnedSavedDialogsTooMuch = errors.New("pinned saved dialogs too much")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue