perf: sync protocol and core hardening updates

This commit is contained in:
A 2026-07-11 19:48:26 +08:00
parent 152fed3b87
commit 4390ebf5a9
283 changed files with 29231 additions and 2295 deletions

View 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
}

View file

@ -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

View 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
}

View file

@ -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

View file

@ -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 命令。

View file

@ -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")