Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

19
internal/store/account.go Normal file
View file

@ -0,0 +1,19 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// PasswordStore 持久化账号 2FA/SRP 配置。
type PasswordStore interface {
GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error)
Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error
}
// AccountReactionSettingsStore persists account-level reaction preferences.
type AccountReactionSettingsStore interface {
GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error)
SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error
}

View file

@ -0,0 +1,26 @@
package store
import "context"
// PtsAllocator 分配用户级 pts。实现必须保证同一 user 内单调递增。
type PtsAllocator interface {
NextPts(ctx context.Context, userID int64) (int, error)
CurrentPts(ctx context.Context, userID int64) (int, error)
}
// PtsRangeAllocator 可一次性分配一段连续 pts返回该段最终 pts。
// 批量事件(例如 updateDeleteMessages必须用 pts_count 推进多步时使用它。
type PtsRangeAllocator interface {
NextPtsN(ctx context.Context, userID int64, count int) (int, error)
}
// BoxIDAllocator 分配用户视角 message box id。box_id 允许空洞,但不能回退。
type BoxIDAllocator interface {
NextBoxID(ctx context.Context, userID int64) (int, error)
CurrentBoxID(ctx context.Context, userID int64) (int, error)
}
// CounterSource 用于 Redis 计数器冷启动时从 PostgreSQL durable log 恢复当前值。
type CounterSource interface {
Current(ctx context.Context, userID int64) (int, error)
}

22
internal/store/authkey.go Normal file
View file

@ -0,0 +1,22 @@
package store
import "context"
// AuthKeyData 是一条持久化的 MTProto auth key 记录。
//
// 不依赖 td 协议类型:连接层在边界做 crypto.AuthKey ↔ AuthKeyData 转换。
type AuthKeyData struct {
ID [8]byte // auth_key_idkey 的 SHA1 低 64 位)
Value [256]byte // 2048-bit auth key
ServerSalt int64 // 密钥交换产出的初始 server salt
CreatedAt int64 // unix 秒
// 用户绑定不在此处auth_key 是协议产物授权auth_key↔user + 设备信息)由 authorization 承载P2
}
// AuthKeyStore 持久化 auth key。实现见 store/memory测试替身、store/postgres。
type AuthKeyStore interface {
// Save 保存或覆盖一条 auth key 记录。
Save(ctx context.Context, k AuthKeyData) error
// Get 按 auth_key_id 查询;不存在时 found=false。
Get(ctx context.Context, id [8]byte) (data AuthKeyData, found bool, err error)
}

View file

@ -0,0 +1,15 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// AuthorizationStore 持久化设备授权auth_key ↔ user 绑定)。实现见 store/memory测试替身、store/postgres。
type AuthorizationStore interface {
Bind(ctx context.Context, a domain.Authorization) error
ByAuthKey(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error)
ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error)
Delete(ctx context.Context, authKeyID [8]byte) error
}

140
internal/store/channel.go Normal file
View file

@ -0,0 +1,140 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// ChannelStore persists Telegram channels/supergroups and their single-copy messages.
type ChannelStore interface {
CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error)
GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error)
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
SaveChannelDefaultSendAs(ctx context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error)
GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error)
GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error)
InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error)
JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error)
LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error)
EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error)
EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error)
EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error)
EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error)
EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error)
DeleteChannel(ctx context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error)
CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error)
UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error)
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error)
SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error)
SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error)
SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error)
SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error)
SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error)
SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error)
SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error)
SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error)
ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error)
GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error)
SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error)
GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error)
ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error)
ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ClearRecentMessageReactions(ctx context.Context, userID int64) error
ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error)
UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error
CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error)
EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error)
UpdatePinnedForumTopic(ctx context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error)
ReorderPinnedForumTopics(ctx context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error)
DeleteForumTopicHistory(ctx context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error)
ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error)
GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error)
SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error)
EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error)
DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error)
DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error)
DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error)
UpdatePinnedMessage(ctx context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error)
ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error)
CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error)
ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error)
ListExportedInvites(ctx context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error)
GetExportedInvite(ctx context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error)
EditExportedInvite(ctx context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error)
DeleteExportedInvite(ctx context.Context, req domain.DeleteChannelInviteRequest) error
DeleteRevokedExportedInvites(ctx context.Context, req domain.DeleteRevokedChannelInvitesRequest) error
ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error)
ListInviteImporters(ctx context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error)
PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error)
HideChatJoinRequest(ctx context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error)
HideAllChatJoinRequests(ctx context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error)
ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error)
GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error)
ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error)
ListLeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error)
ListInactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error)
ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error)
ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error)
SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error)
SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, error)
ReorderChannelPinnedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error
SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error)
SetChannelViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error)
ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error)
EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error
ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error)
SearchPublicPosts(ctx context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error)
SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error)
GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error)
ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error)
ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error)
ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error)
ReadChannelMentions(ctx context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error)
ListChannelUnreadReactions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error)
ReadChannelReactions(ctx context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error)
GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error)
ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error)
ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error)
ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
MaxChannelPts(ctx context.Context, channelID int64) (int, error)
MaxChannelMessageID(ctx context.Context, channelID int64) (int, error)
}
// ChannelIDAllocator allocates channel IDs.
type ChannelIDAllocator interface {
NextChannelID(ctx context.Context) (int64, error)
CurrentChannelID(ctx context.Context) (int64, error)
}
// ChannelPtsAllocator allocates channel-scoped pts.
type ChannelPtsAllocator interface {
NextChannelPts(ctx context.Context, channelID int64) (int, error)
CurrentChannelPts(ctx context.Context, channelID int64) (int, error)
}
// ChannelPtsRangeAllocator allocates a range of channel pts and returns the final pts.
type ChannelPtsRangeAllocator interface {
NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error)
}
// ChannelMessageIDAllocator allocates channel-scoped message IDs.
type ChannelMessageIDAllocator interface {
NextChannelMessageID(ctx context.Context, channelID int64) (int, error)
CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error)
}

20
internal/store/code.go Normal file
View file

@ -0,0 +1,20 @@
package store
import (
"context"
"time"
)
// PhoneCode 是一条登录验证码记录(与某次 sendCode 的 phone_code_hash 关联)。
type PhoneCode struct {
Phone string
Code string
}
// CodeStore 暂存登录验证码phone_code_hash → 手机号 + 验证码,带 TTL。
// 实现见 store/memory测试替身、store/redisstore。
type CodeStore interface {
Set(ctx context.Context, phoneCodeHash string, code PhoneCode, ttl time.Duration) error
Get(ctx context.Context, phoneCodeHash string) (PhoneCode, bool, error)
Del(ctx context.Context, phoneCodeHash string) error
}

17
internal/store/contact.go Normal file
View file

@ -0,0 +1,17 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// ContactStore 持久化用户通讯录。
type ContactStore interface {
ListByUser(ctx context.Context, userID int64) (domain.ContactList, error)
Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error)
Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error)
UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error)
Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)
}

32
internal/store/dialog.go Normal file
View file

@ -0,0 +1,32 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// DialogStore 持久化用户会话摘要。
type DialogStore interface {
ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error)
ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error)
Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error
SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error
DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error)
ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error)
ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error)
MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error)
SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error)
ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error
SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error)
ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error)
SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error)
GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error)
UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error
DeleteFolder(ctx context.Context, userID int64, folderID int) error
ReorderFolders(ctx context.Context, userID int64, order []int) error
SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error
EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error
}

View file

@ -0,0 +1,27 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
// DispatchOutboxItem 是待投递给在线 session 的 update 任务。
type DispatchOutboxItem struct {
ID int64
TargetUserID int64
Pts int
EventType domain.UpdateEventType
ExcludeAuthKeyID [8]byte
ExcludeSessionID int64
Attempts int
}
// DispatchOutboxStore 持久化 transactional outbox。
type DispatchOutboxStore interface {
ClaimPending(ctx context.Context, limit int) ([]DispatchOutboxItem, error)
MarkDelivered(ctx context.Context, targetUserID, id int64) error
MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error
DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error)
}

13
internal/store/doc.go Normal file
View file

@ -0,0 +1,13 @@
// Package store 定义存储接口与协议层 DTO不含具体实现。
//
// 布局主包只放接口AuthKeyStore / SessionStore / UserStore / AuthorizationStore /
// CodeStore / UpdateStateStore / UpdateEventStore 等)与协议 DTO三种后端实现各自独立成对称子包
// - store/memory —— 内存实现,测试替身与本地兜底
// - store/postgres —— PostgreSQLpgx + sqlc 生成查询 + golang-migrate 迁移)
// - store/redisstore —— Redisgo-redis
//
// 类型边界:接口签名分两类——
// - 协议产物用 store 自有 DTOAuthKeyData、SessionData、PhoneCode不依赖 tg.*,也非业务实体);
// - 业务实体直接用 domainUserStore / AuthorizationStore / MessageStore / UpdateEventStore
// 收发 domain.User / domain.Authorization / domain.Message / domain.UpdateEvent。
package store

19
internal/store/help.go Normal file
View file

@ -0,0 +1,19 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// AppConfigStore 持久化 help.getAppConfig 数据。
type AppConfigStore interface {
GetAppConfig(ctx context.Context, client string) (domain.AppConfig, bool, error)
UpsertAppConfig(ctx context.Context, cfg domain.AppConfig) error
}
// CountryStore 持久化 help.getCountriesList 数据。
type CountryStore interface {
ListCountries(ctx context.Context, langCode string) (domain.CountriesList, error)
UpsertCountries(ctx context.Context, countries []domain.Country) error
}

View file

@ -0,0 +1,14 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// LangPackStore 持久化客户端语言包。
type LangPackStore interface {
GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error)
GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error)
UpsertPack(ctx context.Context, pack domain.LangPack) error
}

46
internal/store/media.go Normal file
View file

@ -0,0 +1,46 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// MediaStore 持久化媒体元数据上传分片、blob 索引、文档/照片注册表、
// 贴纸集、可用 reaction、头像历史。blob 字节本身由 blob backend 按 object_key 读写,
// 本接口只管 file_blobs 索引行。
type MediaStore interface {
// 上传分片transient组装成 blob 后即清理)。
SaveFilePart(ctx context.Context, part domain.UploadPart) error
LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error)
DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error
// blob 索引。
PutFileBlob(ctx context.Context, blob domain.FileBlob) error
GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error)
// 文档 / 照片注册表。
PutDocument(ctx context.Context, doc domain.Document) error
GetDocument(ctx context.Context, id int64) (domain.Document, bool, error)
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
PutPhoto(ctx context.Context, photo domain.Photo) error
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
// 贴纸集 / 可用 reaction。
PutStickerSet(ctx context.Context, set domain.StickerSet) error
GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error)
GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error)
GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error)
ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error)
CountStickerSets(ctx context.Context) (int, error)
PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error
ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error)
CountAvailableReactions(ctx context.Context) (int, error)
// 头像历史owner = user/channelcurrent = active 中 sort_order 最大者)。
AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error
CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error)
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (ids []int64, total int, err error)
DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,714 @@
package memory
import (
"context"
"errors"
"reflect"
"testing"
"telesrv/internal/domain"
)
func TestChannelRealtimeRecipientsAreCapped(t *testing.T) {
store := NewChannelStore()
memberIDs := make([]int64, domain.MaxChannelRealtimeFanout+25)
for i := range memberIDs {
memberIDs[i] = int64(10_000 + i)
}
created, err := store.CreateChannel(context.Background(), domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "large realtime cap",
Megagroup: true,
MemberUserIDs: memberIDs,
Date: 1_700_000_100,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if got := len(created.Recipients); got != domain.MaxChannelRealtimeFanout {
t.Fatalf("create recipients = %d, want capped %d", got, domain.MaxChannelRealtimeFanout)
}
recipients, err := store.ListActiveChannelMemberIDs(context.Background(), 1, created.Channel.ID, 0)
if err != nil {
t.Fatalf("list active members: %v", err)
}
if got := len(recipients); got != domain.MaxChannelRealtimeFanout {
t.Fatalf("listed active members = %d, want capped %d", got, domain.MaxChannelRealtimeFanout)
}
}
func TestPendingJoinRequestsSummaryAndInviteAdmins(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "pending join requests",
Megagroup: true,
MemberUserIDs: []int64{2, 3, 4},
Date: 1_700_000_150,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
UserID: 1,
ChannelID: channelID,
MemberID: 2,
AdminRights: domain.ChannelAdminRights{
InviteUsers: true,
},
Date: 1_700_000_151,
}); err != nil {
t.Fatalf("promote invite admin: %v", err)
}
if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{
UserID: 1,
ChannelID: channelID,
MemberID: 4,
AdminRights: domain.ChannelAdminRights{
ChangeInfo: true,
},
Date: 1_700_000_152,
}); err != nil {
t.Fatalf("promote change-info admin: %v", err)
}
invite, err := store.ExportInvite(ctx, domain.ExportChannelInviteRequest{
UserID: 1,
ChannelID: channelID,
Title: "approval",
RequestNeeded: true,
Date: 1_700_000_153,
})
if err != nil {
t.Fatalf("export invite: %v", err)
}
for i := 0; i < domain.MaxChannelPendingJoinRecentRequesters+2; i++ {
_, err := store.ImportInvite(ctx, domain.ImportChannelInviteRequest{
UserID: int64(10 + i),
Hash: invite.Invite.Hash,
Date: 1_700_000_160 + i,
})
if !errors.Is(err, domain.ErrInviteRequestSent) {
t.Fatalf("import pending %d err = %v, want ErrInviteRequestSent", i, err)
}
}
pending, err := store.PendingJoinRequests(ctx, channelID, 99)
if err != nil {
t.Fatalf("pending join requests: %v", err)
}
if pending.Count != domain.MaxChannelPendingJoinRecentRequesters+2 || len(pending.RecentRequesters) != domain.MaxChannelPendingJoinRecentRequesters {
t.Fatalf("pending summary = %+v, want bounded recent with full count", pending)
}
if pending.RecentRequesters[0] != 16 || pending.RecentRequesters[len(pending.RecentRequesters)-1] != 12 {
t.Fatalf("recent requesters = %+v, want newest first", pending.RecentRequesters)
}
admins, err := store.ListChannelInviteAdminMemberIDs(ctx, channelID, 0)
if err != nil {
t.Fatalf("invite admins: %v", err)
}
want := []int64{1, 2, 4}
if !reflect.DeepEqual(admins, want) {
t.Fatalf("invite admins = %+v, want %+v", admins, want)
}
}
func TestCommonChannelsOnlySharedMegagroups(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
first, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "common one",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_170,
})
if err != nil {
t.Fatalf("create first common channel: %v", err)
}
second, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "common two",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_171,
})
if err != nil {
t.Fatalf("create second common channel: %v", err)
}
if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "broadcast excluded",
Broadcast: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_172,
}); err != nil {
t.Fatalf("create broadcast channel: %v", err)
}
left, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "left excluded",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_173,
})
if err != nil {
t.Fatalf("create left channel: %v", err)
}
if _, err := store.LeaveChannel(ctx, left.Channel.ID, 2, 1_700_000_174); err != nil {
t.Fatalf("leave channel: %v", err)
}
if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "not shared",
Megagroup: true,
MemberUserIDs: []int64{3},
Date: 1_700_000_175,
}); err != nil {
t.Fatalf("create non-shared channel: %v", err)
}
page, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{
UserID: 1,
TargetUserID: 2,
Limit: 10,
})
if err != nil {
t.Fatalf("list common channels: %v", err)
}
if page.Count != 2 || len(page.Channels) != 2 || page.Channels[0].ID != first.Channel.ID || page.Channels[1].ID != second.Channel.ID {
t.Fatalf("common channels = %+v, want two shared megagroups in id order", page)
}
next, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{
UserID: 1,
TargetUserID: 2,
MaxID: first.Channel.ID,
Limit: 1,
})
if err != nil {
t.Fatalf("list common channels after max id: %v", err)
}
if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].ID != second.Channel.ID {
t.Fatalf("paged common channels = %+v, want second channel with full count", next)
}
countOnly, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{
UserID: 1,
TargetUserID: 2,
CountOnly: true,
})
if err != nil {
t.Fatalf("count common channels: %v", err)
}
if countOnly.Count != 2 || len(countOnly.Channels) != 0 {
t.Fatalf("count-only common channels = %+v, want count without channels", countOnly)
}
}
func TestLeftChannelsReturnsPagedLeftMemberships(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
older, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "older left",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_180,
})
if err != nil {
t.Fatalf("create older channel: %v", err)
}
newer, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "newer left broadcast",
Broadcast: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_181,
})
if err != nil {
t.Fatalf("create newer channel: %v", err)
}
if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "active excluded",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_182,
}); err != nil {
t.Fatalf("create active channel: %v", err)
}
if _, err := store.LeaveChannel(ctx, older.Channel.ID, 2, 1_700_000_183); err != nil {
t.Fatalf("leave older channel: %v", err)
}
if _, err := store.LeaveChannel(ctx, newer.Channel.ID, 2, 1_700_000_184); err != nil {
t.Fatalf("leave newer channel: %v", err)
}
page, err := store.ListLeftChannels(ctx, 2, 0, 1)
if err != nil {
t.Fatalf("list left channels: %v", err)
}
if page.Count != 2 || len(page.Channels) != 1 || page.Channels[0].Channel.ID != newer.Channel.ID || page.Channels[0].Self.Status != domain.ChannelMemberLeft {
t.Fatalf("first left page = %+v, want newest left channel and full count", page)
}
next, err := store.ListLeftChannels(ctx, 2, 1, 1)
if err != nil {
t.Fatalf("list next left channels: %v", err)
}
if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].Channel.ID != older.Channel.ID {
t.Fatalf("second left page = %+v, want older left channel", next)
}
empty, err := store.ListLeftChannels(ctx, 2, 2, 1)
if err != nil {
t.Fatalf("list empty left page: %v", err)
}
if empty.Count != 2 || len(empty.Channels) != 0 {
t.Fatalf("empty left page = %+v, want full count and no chats", empty)
}
if _, err := store.ListLeftChannels(ctx, 2, domain.MaxLeftChannelsOffset+1, 1); !errors.Is(err, domain.ErrChannelInvalid) {
t.Fatalf("huge offset err = %v, want ErrChannelInvalid", err)
}
}
func TestDiscussionGroupLinksAreBidirectionalAndReplaceOldLinks(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "broadcast",
Broadcast: true,
Date: 1_700_000_190,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
firstGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "first group",
Megagroup: true,
Date: 1_700_000_191,
})
if err != nil {
t.Fatalf("create first group: %v", err)
}
secondGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "second group",
Megagroup: true,
Date: 1_700_000_192,
})
if err != nil {
t.Fatalf("create second group: %v", err)
}
if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "broadcast excluded",
Broadcast: true,
Date: 1_700_000_193,
}); err != nil {
t.Fatalf("create excluded broadcast: %v", err)
}
candidates, err := store.ListDiscussionGroups(ctx, 1, 10)
if err != nil {
t.Fatalf("list discussion groups: %v", err)
}
if len(candidates) != 2 || candidates[0].ID != secondGroup.Channel.ID || candidates[1].ID != firstGroup.Channel.ID {
t.Fatalf("discussion candidates = %+v, want creator megagroups newest id first", candidates)
}
linked, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID)
if err != nil {
t.Fatalf("link first group: %v", err)
}
if len(linked.Channels) != 2 {
t.Fatalf("linked changed channels = %+v, want broadcast and group", linked.Channels)
}
gotBroadcast, err := store.GetChannelByID(ctx, broadcast.Channel.ID)
if err != nil {
t.Fatalf("get linked broadcast: %v", err)
}
gotFirst, err := store.GetChannelByID(ctx, firstGroup.Channel.ID)
if err != nil {
t.Fatalf("get linked first group: %v", err)
}
if gotBroadcast.LinkedChatID != firstGroup.Channel.ID || gotFirst.LinkedChatID != broadcast.Channel.ID {
t.Fatalf("first link = broadcast %+v group %+v, want bidirectional ids", gotBroadcast, gotFirst)
}
replaced, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, secondGroup.Channel.ID)
if err != nil {
t.Fatalf("replace discussion group: %v", err)
}
if len(replaced.Channels) != 3 {
t.Fatalf("replace changed channels = %+v, want broadcast, old group, new group", replaced.Channels)
}
gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID)
gotFirst, _ = store.GetChannelByID(ctx, firstGroup.Channel.ID)
gotSecond, err := store.GetChannelByID(ctx, secondGroup.Channel.ID)
if err != nil {
t.Fatalf("get linked second group: %v", err)
}
if gotBroadcast.LinkedChatID != secondGroup.Channel.ID || gotSecond.LinkedChatID != broadcast.Channel.ID || gotFirst.LinkedChatID != 0 {
t.Fatalf("replace link = broadcast %d first %d second %d, want old cleared and new bidirectional",
gotBroadcast.LinkedChatID, gotFirst.LinkedChatID, gotSecond.LinkedChatID)
}
unlinked, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID)
if err != nil {
t.Fatalf("unlink from group side: %v", err)
}
if len(unlinked.Channels) != 2 {
t.Fatalf("unlink changed channels = %+v, want broadcast and group", unlinked.Channels)
}
gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID)
gotSecond, _ = store.GetChannelByID(ctx, secondGroup.Channel.ID)
if gotBroadcast.LinkedChatID != 0 || gotSecond.LinkedChatID != 0 {
t.Fatalf("unlink = broadcast %d second %d, want both cleared", gotBroadcast.LinkedChatID, gotSecond.LinkedChatID)
}
if _, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID); !errors.Is(err, domain.ErrLinkNotModified) {
t.Fatalf("repeat unlink err = %v, want ErrLinkNotModified", err)
}
if _, err := store.SetPreHistoryHidden(ctx, 1, firstGroup.Channel.ID, true); err != nil {
t.Fatalf("hide first group prehistory: %v", err)
}
if _, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID); !errors.Is(err, domain.ErrMegagroupPrehistoryHidden) {
t.Fatalf("hidden group link err = %v, want ErrMegagroupPrehistoryHidden", err)
}
}
func TestChannelDeleteHistoryCapsHugeMaxID(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "bounded delete history",
Megagroup: true,
Date: 1_700_000_200,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
totalMessages := domain.MaxDeleteHistoryBatch + 2
for i := 0; i < totalMessages; i++ {
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: int64(10_000 + i),
Message: "bulk",
Date: 1_700_000_201 + i,
}); err != nil {
t.Fatalf("send channel message %d: %v", i, err)
}
}
first, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: int(^uint(0) >> 1),
ForEveryone: true,
Date: 1_700_001_300,
})
if err != nil {
t.Fatalf("delete first batch: %v", err)
}
if first.Offset != 1 || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch || first.Event.PtsCount != domain.MaxDeleteHistoryBatch {
t.Fatalf("first batch = %+v, want capped page with offset", first)
}
second, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: int(^uint(0) >> 1),
ForEveryone: true,
Date: 1_700_001_301,
})
if err != nil {
t.Fatalf("delete second batch: %v", err)
}
if second.Offset != 0 || len(second.DeletedIDs) != 3 || second.Event.PtsCount != 3 {
t.Fatalf("second batch = %+v, want final bounded page", second)
}
}
func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "monotonic local clear",
Megagroup: true,
Date: 1_700_000_250,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
first, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 30_001,
Message: "first visible",
Date: 1_700_000_251,
})
if err != nil {
t.Fatalf("send first message: %v", err)
}
second, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 30_002,
Message: "second visible",
Date: 1_700_000_252,
})
if err != nil {
t.Fatalf("send second message: %v", err)
}
high, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: second.Message.ID,
Date: 1_700_000_253,
})
if err != nil {
t.Fatalf("clear high watermark: %v", err)
}
if high.AvailableMinID != second.Message.ID {
t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID)
}
stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: first.Message.ID,
Date: 1_700_000_254,
})
if err != nil {
t.Fatalf("clear stale low watermark: %v", err)
}
if stale.AvailableMinID != second.Message.ID {
t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID)
}
history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
if err != nil {
t.Fatalf("list history: %v", err)
}
if len(history.Messages) != 0 {
t.Fatalf("history after stale clear = %+v, want no visible messages", history.Messages)
}
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get channel dialog: %v", err)
}
if len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs)
}
if dialogs.Dialogs[0].TopMessage != 0 || dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID || dialogs.Dialogs[0].UnreadCount != 0 {
t.Fatalf("dialog after stale clear = %+v, want top=0 read=%d unread=0", dialogs.Dialogs[0], second.Message.ID)
}
}
func TestChannelListDialogsDerivesRecipientTopWithoutWriteFanout(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "single copy dialog top",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_300,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if _, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: 2,
ChannelID: created.Channel.ID,
MaxID: created.Message.ID,
Date: 1_700_000_301,
}); err != nil {
t.Fatalf("read initial service message: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 88,
Message: "visible without write fanout",
Date: 1_700_000_302,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
list, err := store.ListChannelDialogs(ctx, 2, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("list recipient channel dialogs: %v", err)
}
if len(list.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want one channel dialog", list.Dialogs)
}
dialog := list.Dialogs[0]
if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date || dialog.UnreadCount != 1 {
t.Fatalf("recipient dialog = %+v, want top sent message and unread=1", dialog)
}
if len(list.Messages) != 1 || list.Messages[0].ID != sent.Message.ID {
t.Fatalf("dialog messages = %+v, want sent top message", list.Messages)
}
}
func TestChannelUnreadExcludesOwnOutgoing(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "own outgoing unread",
Megagroup: true,
Date: 1_700_000_360,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 36_001,
Message: "own outgoing only",
Date: 1_700_000_361,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
store.mu.Lock()
member := store.members[created.Channel.ID][1]
member.ReadInboxMaxID = sent.Message.ID - 1
store.members[created.Channel.ID][1] = member
dialog := store.dialogs[1][created.Channel.ID]
dialog.ReadInboxMaxID = sent.Message.ID - 1
dialog.UnreadCount = 99
store.dialogs[1][created.Channel.ID] = dialog
store.mu.Unlock()
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get channel dialogs: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 0 {
t.Fatalf("dialogs = %+v, want own outgoing excluded from unread", dialogs.Dialogs)
}
read, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: 1,
ChannelID: created.Channel.ID,
MaxID: sent.Message.ID,
Date: 1_700_000_362,
})
if err != nil {
t.Fatalf("read channel history: %v", err)
}
if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 {
t.Fatalf("read result = %+v, want no own-outgoing unread", read)
}
}
func TestChannelReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "visible unread reaction",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_400,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 40_001,
Message: "react to this",
Date: 1_700_000_401,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: 2,
ChannelID: created.Channel.ID,
MessageID: sent.Message.ID,
Reactions: []domain.MessageReaction{{
Type: domain.MessageReactionEmoji,
Emoticon: "\U0001f525",
}},
Date: 1_700_000_402,
}); err != nil {
t.Fatalf("set channel reaction: %v", err)
}
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get owner channel dialogs: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 1 {
t.Fatalf("owner dialogs = %+v, want one unread reaction", dialogs.Dialogs)
}
unread, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{
ChannelID: created.Channel.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("list unread reactions: %v", err)
}
if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID {
t.Fatalf("unread reactions = %+v, want sent message", unread.Messages)
}
if unread.Messages[0].Reactions == nil || !hasUnreadChannelReaction(*unread.Messages[0].Reactions) {
t.Fatalf("unread message reactions = %+v, want unread recent reaction", unread.Messages[0].Reactions)
}
read, err := store.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{
UserID: 1,
ChannelID: created.Channel.ID,
IDs: []int{sent.Message.ID},
})
if err != nil {
t.Fatalf("read channel message contents: %v", err)
}
if !reflect.DeepEqual(read.ClearedUnreadReactionMessageIDs, []int{sent.Message.ID}) {
t.Fatalf("cleared reaction ids = %+v, want [%d]", read.ClearedUnreadReactionMessageIDs, sent.Message.ID)
}
if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || hasUnreadChannelReaction(*read.Messages[0].Reactions) {
t.Fatalf("read messages = %+v, want reaction returned as read", read.Messages)
}
unreadAfter, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{
ChannelID: created.Channel.ID,
Limit: 10,
})
if err != nil {
t.Fatalf("list unread reactions after read contents: %v", err)
}
if len(unreadAfter.Messages) != 0 {
t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter.Messages)
}
dialogsAfter, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get dialogs after read contents: %v", err)
}
if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 {
t.Fatalf("dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs)
}
}
func hasUnreadChannelReaction(reactions domain.ChannelMessageReactions) bool {
for _, recent := range reactions.Recent {
if recent.Unread {
return true
}
}
return false
}

View file

@ -0,0 +1,229 @@
package memory
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestDialogStoreFiltersAndPaginates(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
list := domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000, Pinned: true},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, TopMessage: 8, TopMessageDate: 800},
},
Messages: []domain.Message{
{ID: 10, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Body: "pinned"},
{ID: 9, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, Body: "first"},
{ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, Body: "second"},
},
}
if err := store.SaveList(ctx, userID, list); err != nil {
t.Fatalf("SaveList: %v", err)
}
first, err := store.ListByUser(ctx, userID, domain.DialogFilter{ExcludePinned: true, Limit: 1})
if err != nil {
t.Fatalf("ListByUser first page: %v", err)
}
if first.Count != 2 || len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != 2 || len(first.Messages) != 1 || first.Messages[0].ID != 9 {
t.Fatalf("first page = %+v, want peer 2 with count 2 and top message", first)
}
next, err := store.ListByUser(ctx, userID, domain.DialogFilter{
ExcludePinned: true,
OffsetDate: first.Dialogs[0].TopMessageDate,
OffsetID: first.Dialogs[0].TopMessage,
HasOffsetPeer: true,
OffsetPeer: first.Dialogs[0].Peer,
Limit: 10,
})
if err != nil {
t.Fatalf("ListByUser next page: %v", err)
}
if next.Count != 2 || len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != 3 || len(next.Messages) != 1 || next.Messages[0].ID != 8 {
t.Fatalf("next page = %+v, want peer 3 with count 2 and top message", next)
}
}
func TestDialogStoreFoldersAndCustomFilters(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
contactPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1}
archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2}
strangerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 3}
if err := store.SaveList(ctx, userID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: contactPeer, TopMessage: 10, TopMessageDate: 1000},
{Peer: archivedPeer, TopMessage: 9, TopMessageDate: 900, FolderID: domain.DialogArchiveFolderID},
{Peer: strangerPeer, TopMessage: 8, TopMessageDate: 800, UnreadCount: 1},
},
Users: []domain.User{
{ID: contactPeer.ID, Contact: true},
{ID: archivedPeer.ID, Contact: true},
{ID: strangerPeer.ID},
},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
main, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 10})
if err != nil {
t.Fatalf("ListByUser main: %v", err)
}
if len(main.Dialogs) != 2 || main.Dialogs[0].Peer != contactPeer || main.Dialogs[1].Peer != strangerPeer {
t.Fatalf("main dialogs = %+v, want non-archived dialogs", main.Dialogs)
}
archive, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10})
if err != nil {
t.Fatalf("ListByUser archive: %v", err)
}
if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer != archivedPeer {
t.Fatalf("archive dialogs = %+v, want archived peer", archive.Dialogs)
}
folder := domain.DialogFolder{
ID: 2,
Contacts: true,
ExcludeArchived: true,
IncludePeers: []domain.DialogFolderPeer{{Peer: strangerPeer}},
}
if err := store.UpsertFolder(ctx, userID, folder); err != nil {
t.Fatalf("UpsertFolder: %v", err)
}
custom, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: 2, Folder: &folder, Limit: 10})
if err != nil {
t.Fatalf("ListByUser custom: %v", err)
}
if len(custom.Dialogs) != 2 || custom.Dialogs[0].Peer != contactPeer || custom.Dialogs[1].Peer != strangerPeer {
t.Fatalf("custom dialogs = %+v, want contact plus explicit stranger excluding archived", custom.Dialogs)
}
}
func TestUserStoreStartsAtTimestampBase(t *testing.T) {
ctx := context.Background()
store := NewUserStore()
u, err := store.Create(ctx, domain.User{
AccessHash: 1,
Phone: "15550000001",
FirstName: "Test",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if u.ID != domain.UserIDSequenceBase {
t.Fatalf("user id = %d, want base %d", u.ID, domain.UserIDSequenceBase)
}
}
func TestDialogStoreOffsetDateOnlyKeepsEnterpriseCountAndHash(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
if err := store.SaveList(ctx, userID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, TopMessage: 8, TopMessageDate: 800},
},
Messages: []domain.Message{
{ID: 10, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Body: "first"},
{ID: 9, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, Body: "second"},
{ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, Body: "third"},
},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
all, err := store.ListByUser(ctx, userID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser all: %v", err)
}
page, err := store.ListByUser(ctx, userID, domain.DialogFilter{OffsetDate: 900, Limit: 10})
if err != nil {
t.Fatalf("ListByUser offset date: %v", err)
}
if page.Count != 3 || page.Hash != all.Hash {
t.Fatalf("page summary = count %d hash %d, want full count/hash %d/%d", page.Count, page.Hash, all.Count, all.Hash)
}
if len(page.Dialogs) != 1 || page.Dialogs[0].Peer.ID != 3 || len(page.Messages) != 1 || page.Messages[0].ID != 8 {
t.Fatalf("page = %+v, want only dialog after offset date", page)
}
}
func TestDialogStoreEmptyPageKeepsEnterpriseCountAndHash(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
if err := store.SaveList(ctx, userID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000},
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900},
},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
all, err := store.ListByUser(ctx, userID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser all: %v", err)
}
empty, err := store.ListByUser(ctx, userID, domain.DialogFilter{
OffsetDate: 900,
OffsetID: 9,
HasOffsetPeer: true,
OffsetPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 2},
Limit: 10,
})
if err != nil {
t.Fatalf("ListByUser empty page: %v", err)
}
if empty.Count != 2 || empty.Hash != all.Hash || len(empty.Dialogs) != 0 {
t.Fatalf("empty page = %+v, want no page rows but full count/hash", empty)
}
}
func TestDialogStoreListByPeersReturnsExistingAndPlaceholders(t *testing.T) {
ctx := context.Background()
store := NewDialogStore()
userID := int64(100)
official := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
missing := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}
if err := store.SaveList(ctx, userID, domain.DialogList{
Dialogs: []domain.Dialog{
{Peer: official, TopMessage: 10, TopMessageDate: 1000, UnreadCount: 1},
},
Messages: []domain.Message{
{ID: 10, Peer: official, From: official, Body: "login"},
},
Users: []domain.User{domain.OfficialSystemUser()},
}); err != nil {
t.Fatalf("SaveList: %v", err)
}
got, err := store.ListByPeers(ctx, userID, []domain.Peer{official, missing, official})
if err != nil {
t.Fatalf("ListByPeers: %v", err)
}
if got.Count != 2 || len(got.Dialogs) != 2 {
t.Fatalf("dialogs = %+v, want existing official and missing placeholder", got)
}
if got.Dialogs[0].Peer != official || got.Dialogs[0].TopMessage != 10 {
t.Fatalf("first dialog = %+v, want official top message", got.Dialogs[0])
}
if got.Dialogs[1].Peer != missing || got.Dialogs[1].TopMessage != 0 {
t.Fatalf("second dialog = %+v, want missing placeholder", got.Dialogs[1])
}
if len(got.Messages) != 1 || got.Messages[0].ID != 10 {
t.Fatalf("messages = %+v, want only official top message", got.Messages)
}
if len(got.Users) != 1 || got.Users[0].ID != domain.OfficialSystemUserID {
t.Fatalf("users = %+v, want official user", got.Users)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,531 @@
package memory
import (
"context"
"errors"
"reflect"
"testing"
"telesrv/internal/domain"
)
func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
req := domain.SendPrivateTextRequest{
SenderUserID: 1000000001,
RecipientUserID: 1000000002,
RandomID: 99,
Message: "hello",
Date: 1700000100,
}
got, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if got.SenderMessage.ID != 1 || got.SenderMessage.OwnerUserID != req.SenderUserID || !got.SenderMessage.Out || got.SenderMessage.Pts != 1 {
t.Fatalf("sender message = %+v, want first outgoing box with pts=1", got.SenderMessage)
}
if got.RecipientMessage.ID != 1 || got.RecipientMessage.OwnerUserID != req.RecipientUserID || got.RecipientMessage.Out || got.RecipientMessage.Pts != 1 {
t.Fatalf("recipient message = %+v, want first incoming box with pts=1", got.RecipientMessage)
}
if got.SenderMessage.UID == 0 || got.SenderMessage.UID != got.RecipientMessage.UID {
t.Fatalf("uid = sender %d recipient %d, want shared private message uid", got.SenderMessage.UID, got.RecipientMessage.UID)
}
second, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: 100,
Message: "again",
Date: 1700000110,
})
if err != nil {
t.Fatalf("SendPrivateText second: %v", err)
}
if second.SenderMessage.ID != 2 || second.SenderMessage.Pts != 2 || second.RecipientMessage.ID != 2 || second.RecipientMessage.Pts != 2 {
t.Fatalf("second send = %+v/%+v, want per-owner box_id and pts to advance", second.SenderMessage, second.RecipientMessage)
}
dup, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText duplicate: %v", err)
}
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
t.Fatalf("duplicate = %+v, want original message boxes", dup)
}
senderHistory, err := messages.ListByUser(ctx, req.SenderUserID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(senderHistory.Messages) != 2 || len(recipientHistory.Messages) != 2 {
t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages))
}
}
func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
aliceID := int64(1000000001)
bobID := int64(1000000002)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 101,
Message: "react to me",
Date: 1700000100,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}
res, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
UserID: bobID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
MessageID: sent.RecipientMessage.ID,
Reactions: []domain.MessageReaction{
reaction,
},
Big: true,
Date: 1700000200,
})
if err != nil {
t.Fatalf("SetMessageReactions: %v", err)
}
if len(res.Messages) != 2 {
t.Fatalf("reaction result messages = %d, want both owner boxes", len(res.Messages))
}
aliceReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{
OwnerUserID: aliceID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
IDs: []int{sent.SenderMessage.ID},
})
if err != nil {
t.Fatalf("alice GetMessageReactions: %v", err)
}
if len(aliceReactions.Messages) != 1 || aliceReactions.Messages[0].Reactions == nil {
t.Fatalf("alice reactions = %+v, want one enriched message", aliceReactions)
}
if got := aliceReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].Reaction != reaction || got[0].Count != 1 || got[0].ChosenOrder != 0 {
t.Fatalf("alice reaction counts = %+v, want one peer reaction without chosen order", got)
}
if got := aliceReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || !got[0].Big || got[0].My {
t.Fatalf("alice recent reactions = %+v, want bob non-my big reaction", got)
}
bobReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{
OwnerUserID: bobID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID},
IDs: []int{sent.RecipientMessage.ID},
})
if err != nil {
t.Fatalf("bob GetMessageReactions: %v", err)
}
if got := bobReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].ChosenOrder != 1 {
t.Fatalf("bob reaction counts = %+v, want own chosen order", got)
}
if got := bobReactions.Messages[0].Reactions.Recent; len(got) != 1 || !got[0].My {
t.Fatalf("bob recent reactions = %+v, want my reaction", got)
}
}
func TestMessageStoreSendPrivateTextReplyAndForwardMetadata(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
aliceID := int64(1000000001)
bobID := int64(1000000002)
first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 501,
Message: "first",
Date: 1700000100,
})
if err != nil {
t.Fatalf("seed SendPrivateText: %v", err)
}
reply, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 502,
Message: "reply",
Silent: true,
NoForwards: true,
ReplyTo: &domain.MessageReply{
MessageID: first.SenderMessage.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
QuoteText: "fir",
QuoteOffset: 0,
},
Date: 1700000110,
})
if err != nil {
t.Fatalf("reply SendPrivateText: %v", err)
}
if reply.SenderMessage.ReplyTo == nil || reply.SenderMessage.ReplyTo.MessageID != first.SenderMessage.ID {
t.Fatalf("sender reply = %+v, want sender-side message id", reply.SenderMessage.ReplyTo)
}
if reply.RecipientMessage.ReplyTo == nil || reply.RecipientMessage.ReplyTo.MessageID != first.RecipientMessage.ID {
t.Fatalf("recipient reply = %+v, want translated recipient-side message id", reply.RecipientMessage.ReplyTo)
}
if !reply.SenderMessage.Silent || !reply.SenderMessage.NoForwards {
t.Fatalf("reply flags = silent %v noforwards %v, want true/true", reply.SenderMessage.Silent, reply.SenderMessage.NoForwards)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: 504,
Message: "bad quote offset",
ReplyTo: &domain.MessageReply{
MessageID: first.SenderMessage.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
QuoteText: "fir",
QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1,
},
Date: 1700000115,
}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err)
}
forwarded, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{
OwnerUserID: aliceID,
FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
ToUserID: bobID,
MessageIDs: []int{first.SenderMessage.ID},
RandomIDs: []int64{503},
ReplyTo: &domain.MessageReply{
MessageID: first.SenderMessage.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
},
Date: 1700000120,
})
if err != nil {
t.Fatalf("ForwardPrivateMessages: %v", err)
}
if len(forwarded.SenderMessages) != 1 || forwarded.SenderMessages[0].Forward == nil || forwarded.SenderMessages[0].Forward.From.ID != aliceID {
t.Fatalf("forwarded messages = %+v, want original author header", forwarded.SenderMessages)
}
if forwarded.SenderMessages[0].ReplyTo == nil || forwarded.SenderMessages[0].ReplyTo.MessageID != first.SenderMessage.ID {
t.Fatalf("forward reply = %+v, want target dialog reply header", forwarded.SenderMessages[0].ReplyTo)
}
if _, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{
OwnerUserID: aliceID,
FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID},
ToUserID: aliceID,
MessageIDs: []int{reply.SenderMessage.ID},
RandomIDs: []int64{504},
Date: 1700000130,
}); err != domain.ErrChatForwardsRestricted {
t.Fatalf("forward protected err=%v, want ErrChatForwardsRestricted", err)
}
}
func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
aliceID := int64(1000000001)
bobID := int64(1000000002)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}
for i := 1; i <= 6; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: aliceID,
RecipientUserID: bobID,
RandomID: int64(600 + i),
Message: "history",
Date: 1700000000 + i,
}); err != nil {
t.Fatalf("seed message %d: %v", i, err)
}
}
around, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -3,
Limit: 6,
})
if err != nil {
t.Fatalf("around history: %v", err)
}
if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) {
t.Fatalf("around ids = %v, want unread/newer side plus older context", got)
}
forward, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -3,
Limit: 3,
})
if err != nil {
t.Fatalf("forward history: %v", err)
}
if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) {
t.Fatalf("forward ids = %v, want messages newer than offset", got)
}
hugePositive, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
AddOffset: 1 << 30,
Limit: 3,
})
if err != nil {
t.Fatalf("huge positive add_offset history: %v", err)
}
if len(hugePositive.Messages) != 0 {
t.Fatalf("huge positive add_offset ids = %v, want bounded empty page", messageIDs(hugePositive.Messages))
}
hugeNegative, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -1 << 30,
Limit: 3,
})
if err != nil {
t.Fatalf("huge negative add_offset history: %v", err)
}
if got := messageIDs(hugeNegative.Messages); !sameInts(got, []int{6, 5, 4}) {
t.Fatalf("huge negative add_offset ids = %v, want clamped forward page", got)
}
}
func TestMessageStoreReadHistoryEmitsInboxAndOutboxReceipts(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000001)
recipientID := int64(1000000002)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 101,
Message: "hello",
Date: 1700000100,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: recipientID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID},
Date: 1700000200,
})
if err != nil {
t.Fatalf("ReadHistory: %v", err)
}
if !read.Changed || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.Pts != 2 || read.InboxEvent.MaxID != sent.RecipientMessage.ID {
t.Fatalf("inbox read = %+v, want recipient inbox pts=2 max recipient id", read)
}
if !read.OutboxChanged || read.OutboxUserID != senderID || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID {
t.Fatalf("outbox read = %+v, want sender outbox receipt with sender message id", read)
}
date, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{
OwnerUserID: senderID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
ID: sent.SenderMessage.ID,
})
if err != nil || date != 1700000200 {
t.Fatalf("outbox read date = %d err=%v, want read date", date, err)
}
}
func TestMessageStoreReadMessageContentsReturnsExistingOwnerIDs(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: 1001,
RecipientUserID: 1002,
RandomID: 88,
Message: "voice placeholder",
Date: 1700000300,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
got, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{sent.RecipientMessage.ID, domain.MaxMessageBoxID},
})
if err != nil {
t.Fatalf("ReadMessageContents: %v", err)
}
if !reflect.DeepEqual(got.MessageIDs, []int{sent.RecipientMessage.ID}) {
t.Fatalf("MessageIDs = %v, want existing recipient id", got.MessageIDs)
}
if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{
OwnerUserID: 1002,
IDs: []int{0},
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("invalid id error = %v, want ErrMessageIDInvalid", err)
}
}
func messageIDs(messages []domain.Message) []int {
out := make([]int, 0, len(messages))
for _, msg := range messages {
out = append(out, msg.ID)
}
return out
}
func sameInts(got, want []int) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
func TestMessageStoreEditMessageUpdatesBothBoxes(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000001)
recipientID := int64(1000000002)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 102,
Message: "before",
Date: 1700000100,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: senderID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID},
ID: sent.SenderMessage.ID,
Message: "after",
EditDate: 1700000200,
})
if err != nil {
t.Fatalf("EditMessage: %v", err)
}
if len(edited.Edited) != 2 || edited.Self().Message.Body != "after" || edited.Self().Event.Type != domain.UpdateEventEditMessage {
t.Fatalf("edited = %+v, want both owner boxes and self edit event", edited)
}
recipientHistory, err := messages.ListByUser(ctx, recipientID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after" || recipientHistory.Messages[0].EditDate != 1700000200 {
t.Fatalf("recipient history = %+v, want edited body/date", recipientHistory.Messages)
}
}
func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
senderID := int64(1000000001)
recipientID := int64(1000000002)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}
for i := 0; i < 2; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: int64(100 + i),
Message: "hello",
Date: 1700000200 + i,
}); err != nil {
t.Fatalf("seed send %d: %v", i, err)
}
}
deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: senderID,
Peer: peer,
Date: 1700000300,
})
if err != nil {
t.Fatalf("DeleteHistory: %v", err)
}
if self := deleted.Self(); self.Event.Pts != 4 || self.Event.PtsCount != 2 || len(self.MessageIDs) != 2 {
t.Fatalf("delete result = %+v, want sender delete range pts=4 count=2", self)
}
senderHistory, err := messages.ListByUser(ctx, senderID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
recipientHistory, err := messages.ListByUser(ctx, recipientID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 {
t.Fatalf("history sizes sender=%d recipient=%d, want sender cleared only", len(senderHistory.Messages), len(recipientHistory.Messages))
}
senderDialogs, err := dialogs.ListByUser(ctx, senderID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("sender dialogs after delete: %v", err)
}
if len(senderDialogs.Dialogs) != 0 {
t.Fatalf("sender dialogs = %+v, want dialog deleted after full history delete", senderDialogs.Dialogs)
}
rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: senderID,
RecipientUserID: recipientID,
RandomID: 200,
Message: "rebuilt",
Date: 1700000400,
})
if err != nil {
t.Fatalf("send after delete: %v", err)
}
senderDialogs, err = dialogs.ListByUser(ctx, senderID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("sender dialogs after rebuild: %v", err)
}
if len(senderDialogs.Dialogs) != 1 || senderDialogs.Dialogs[0].Peer != peer || senderDialogs.Dialogs[0].TopMessage != rebuilt.SenderMessage.ID {
t.Fatalf("rebuilt dialogs = %+v, want one dialog with new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID)
}
preservedOwner := int64(1000000003)
preservedPeerID := int64(1000000004)
preservedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: preservedPeerID}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: preservedOwner,
RecipientUserID: preservedPeerID,
RandomID: 300,
Message: "clear but keep dialog",
Date: 1700000500,
}); err != nil {
t.Fatalf("seed preserved send: %v", err)
}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: preservedOwner,
Peer: preservedPeer,
JustClear: true,
Date: 1700000600,
}); err != nil {
t.Fatalf("DeleteHistory just_clear: %v", err)
}
preservedDialogs, err := dialogs.ListByUser(ctx, preservedOwner, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("preserved dialogs: %v", err)
}
if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer || preservedDialogs.Dialogs[0].TopMessage != 0 || len(preservedDialogs.Messages) != 0 {
t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages)
}
}

24
internal/store/message.go Normal file
View file

@ -0,0 +1,24 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// MessageStore 持久化账号视角下的消息。
type MessageStore interface {
Create(ctx context.Context, msg domain.Message) (domain.Message, error)
SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error)
ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error)
ReadHistory(ctx context.Context, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error)
ReadMessageContents(ctx context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error)
GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error)
SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
EditMessage(ctx context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error)
DeleteMessages(ctx context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error)
DeleteHistory(ctx context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error)
GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error)
}

View file

@ -0,0 +1,126 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// PasswordStore 用 PostgreSQL 实现 store.PasswordStore。
type PasswordStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewPasswordStore 基于 pgx 连接池(或事务)创建 PasswordStore。
func NewPasswordStore(db sqlcgen.DBTX) *PasswordStore {
return &PasswordStore{db: db, q: sqlcgen.New(db)}
}
func (s *PasswordStore) GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) {
row, err := s.q.GetPasswordByUser(ctx, userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PasswordSettings{}, false, nil
}
return domain.PasswordSettings{}, false, fmt.Errorf("get account password: %w", err)
}
return domain.PasswordSettings{
HasRecovery: row.HasRecovery,
HasSecureValues: row.HasSecureValues,
HasPassword: row.HasPassword,
Hint: row.Hint,
EmailUnconfirmedPattern: row.EmailUnconfirmedPattern,
LoginEmailPattern: row.LoginEmailPattern,
SecureRandom: append([]byte(nil), row.SecureRandom...),
}, true, nil
}
func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error {
if err := s.q.UpsertPassword(ctx, sqlcgen.UpsertPasswordParams{
UserID: userID,
HasRecovery: settings.HasRecovery,
HasSecureValues: settings.HasSecureValues,
HasPassword: settings.HasPassword,
Hint: settings.Hint,
EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern,
LoginEmailPattern: settings.LoginEmailPattern,
SecureRandom: settings.SecureRandom,
}); err != nil {
return fmt.Errorf("upsert account password: %w", err)
}
return nil
}
func (s *PasswordStore) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews,
default_reaction_type, default_reaction_value,
paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id
FROM account_reaction_settings
WHERE user_id = $1`, userID)
var messagesFrom, storiesFrom, pollVotesFrom string
var defaultType, defaultValue string
var paidKind string
var paidPeerType sql.NullString
var paidPeerID sql.NullInt64
settings := domain.DefaultAccountReactionSettings()
if err := row.Scan(
&messagesFrom, &storiesFrom, &pollVotesFrom, &settings.Notify.ShowPreviews,
&defaultType, &defaultValue, &paidKind, &paidPeerType, &paidPeerID,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountReactionSettings{}, false, nil
}
return domain.AccountReactionSettings{}, false, fmt.Errorf("get account reaction settings: %w", err)
}
settings.Notify.MessagesFrom = domain.ReactionNotifyFrom(messagesFrom)
settings.Notify.StoriesFrom = domain.ReactionNotifyFrom(storiesFrom)
settings.Notify.PollVotesFrom = domain.ReactionNotifyFrom(pollVotesFrom)
settings.DefaultReaction = domain.MessageReaction{Type: domain.MessageReactionType(defaultType), Emoticon: defaultValue}
settings.PaidPrivacy = domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyKind(paidKind)}
if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && paidPeerType.Valid && paidPeerID.Valid {
peer := domain.Peer{Type: domain.PeerType(paidPeerType.String), ID: paidPeerID.Int64}
settings.PaidPrivacy.Peer = &peer
}
return settings, true, nil
}
func (s *PasswordStore) SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error {
var paidPeerType any
var paidPeerID any
if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && settings.PaidPrivacy.Peer != nil {
paidPeerType = string(settings.PaidPrivacy.Peer.Type)
paidPeerID = settings.PaidPrivacy.Peer.ID
}
if _, err := s.db.Exec(ctx, `
INSERT INTO account_reaction_settings (
user_id, messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews,
default_reaction_type, default_reaction_value, paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id) DO UPDATE SET
messages_notify_from = EXCLUDED.messages_notify_from,
stories_notify_from = EXCLUDED.stories_notify_from,
poll_votes_notify_from = EXCLUDED.poll_votes_notify_from,
show_previews = EXCLUDED.show_previews,
default_reaction_type = EXCLUDED.default_reaction_type,
default_reaction_value = EXCLUDED.default_reaction_value,
paid_privacy_kind = EXCLUDED.paid_privacy_kind,
paid_privacy_peer_type = EXCLUDED.paid_privacy_peer_type,
paid_privacy_peer_id = EXCLUDED.paid_privacy_peer_id,
updated_at = now()`,
userID,
string(settings.Notify.MessagesFrom), string(settings.Notify.StoriesFrom), string(settings.Notify.PollVotesFrom), settings.Notify.ShowPreviews,
string(settings.DefaultReaction.Type), settings.DefaultReaction.Emoticon,
string(settings.PaidPrivacy.Kind), paidPeerType, paidPeerID,
); err != nil {
return fmt.Errorf("save account reaction settings: %w", err)
}
return nil
}

View file

@ -0,0 +1,86 @@
package postgres
import (
"context"
"crypto/rand"
"fmt"
"strings"
"testing"
"time"
appauth "telesrv/internal/app/auth"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
func TestAuthSignUpWritesOfficialLoginMessagePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
phone := fmt.Sprintf("1555%d31", time.Now().UnixNano())
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE phone = $1", phone)
})
users := NewUserStore(pool)
dialogs := NewDialogStore(pool)
messages := NewMessageStore(pool)
svc := appauth.NewService(
users,
NewAuthorizationStore(pool),
memory.NewCodeStore(),
nil,
nil,
"12345",
appauth.WithLoginMessages(messages, dialogs),
)
var authKeyID [8]byte
var authKeyBody [256]byte
if _, err := rand.Read(authKeyID[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(authKeyBody[:]); err != nil {
t.Fatal(err)
}
if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{ID: authKeyID, Value: authKeyBody}); err != nil {
t.Fatalf("save auth key: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authKeyID))
})
hash, err := svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345"); err != nil || !needSignUp {
t.Fatalf("SignIn needSignUp = %v err = %v, want need sign-up", needSignUp, err)
}
u, msg, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "PgLogin", "Test")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
if u.Phone != phone || msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") {
t.Fatalf("sign-up user/message = user %+v message %+v, want login message", u, msg)
}
systemUser, found, err := users.ByID(ctx, domain.OfficialSystemUserID)
if err != nil || !found || !systemUser.Verified || !systemUser.Support {
t.Fatalf("official system user = %+v found=%v err=%v, want seeded verified support user", systemUser, found, err)
}
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser: %v", err)
}
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID {
t.Fatalf("dialogs = %+v, want official login dialog", list.Dialogs)
}
if len(list.Messages) != 1 || list.Messages[0].ID != msg.ID || !strings.Contains(list.Messages[0].Body, "Login code: 12345") {
t.Fatalf("messages = %+v, want returned login message", list.Messages)
}
if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID || !list.Users[0].Verified || !list.Users[0].Support {
t.Fatalf("users = %+v, want official support user", list.Users)
}
}

View file

@ -0,0 +1,67 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// AuthKeyStore 用 PostgreSQL 实现 store.AuthKeyStore。
type AuthKeyStore struct {
q *sqlcgen.Queries
}
// NewAuthKeyStore 基于 pgx 连接池(或事务)创建 AuthKeyStore。
func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore {
return &AuthKeyStore{q: sqlcgen.New(db)}
}
// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT
// created_at 交由 DB 默认值now()),故传入的 CreatedAt 不落库。
func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error {
if err := s.q.UpsertAuthKey(ctx, sqlcgen.UpsertAuthKeyParams{
AuthKeyID: authKeyIDToInt64(k.ID),
Body: k.Value[:],
ServerSalt: k.ServerSalt,
}); err != nil {
return fmt.Errorf("upsert auth key: %w", err)
}
return nil
}
// Get 实现 store.AuthKeyStore。不存在时 found=false。
func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
row, err := s.q.GetAuthKey(ctx, authKeyIDToInt64(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeyData{}, false, nil
}
return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err)
}
if len(row.Body) != len(store.AuthKeyData{}.Value) {
return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(row.Body))
}
data := store.AuthKeyData{ID: id, ServerSalt: row.ServerSalt}
copy(data.Value[:], row.Body)
if row.CreatedAt.Valid {
data.CreatedAt = row.CreatedAt.Time.Unix()
}
return data, true, nil
}
// authKeyIDToInt64 把 [8]byte 的 auth_key_id 按小端解释为 int64MTProto 定义即 SHA1 低 64 位)。
func authKeyIDToInt64(id [8]byte) int64 {
return int64(binary.LittleEndian.Uint64(id[:]))
}
func authKeyIDFromInt64(v int64) [8]byte {
var id [8]byte
binary.LittleEndian.PutUint64(id[:], uint64(v))
return id
}

View file

@ -0,0 +1,72 @@
package postgres
import (
"context"
"crypto/rand"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/store"
)
// testPool 连接 TELESRV_TEST_POSTGRES_DSN 指向的库(迁移到最新),未设则跳过。
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
if err := Migrate(dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := Open(context.Background(), dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
// TestAuthKeyStoreRoundTrip 验证 auth_key 落 PG 后,用全新 store 实例(模拟进程重启、无内存缓存)能原样读回。
// 这是「server 重启保住 auth_key」的直接证明。
func TestAuthKeyStoreRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
var id [8]byte
var val [256]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(val[:]); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(id))
})
want := store.AuthKeyData{ID: id, Value: val, ServerSalt: 0x0badf00d}
if err := NewAuthKeyStore(pool).Save(ctx, want); err != nil {
t.Fatalf("save: %v", err)
}
got, found, err := NewAuthKeyStore(pool).Get(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if !found {
t.Fatal("auth key not found after save (重启后丢失)")
}
if got.ID != want.ID || got.Value != want.Value || got.ServerSalt != want.ServerSalt {
t.Fatalf("round trip mismatch: got salt=%#x value[:4]=%x, want salt=%#x value[:4]=%x",
got.ServerSalt, got.Value[:4], want.ServerSalt, want.Value[:4])
}
var missing [8]byte
missing[0] = id[0] ^ 0xff
if _, found, err := NewAuthKeyStore(pool).Get(ctx, missing); err != nil || found {
t.Fatalf("missing key: found=%v err=%v, want found=false err=nil", found, err)
}
}

View file

@ -0,0 +1,93 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// AuthorizationStore 用 PostgreSQL 实现 store.AuthorizationStore。
type AuthorizationStore struct {
q *sqlcgen.Queries
}
// NewAuthorizationStore 基于 pgx 连接池(或事务)创建 AuthorizationStore。
func NewAuthorizationStore(db sqlcgen.DBTX) *AuthorizationStore {
return &AuthorizationStore{q: sqlcgen.New(db)}
}
func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) error {
if err := s.q.UpsertAuthorization(ctx, sqlcgen.UpsertAuthorizationParams{
AuthKeyID: authKeyIDToInt64(a.AuthKeyID),
UserID: a.UserID,
Layer: int32(a.Layer),
DeviceModel: a.DeviceModel,
Platform: a.Platform,
SystemVersion: a.SystemVersion,
ApiID: int32(a.APIID),
AppVersion: a.AppVersion,
Ip: a.IP,
}); err != nil {
return fmt.Errorf("upsert authorization: %w", err)
}
return nil
}
func (s *AuthorizationStore) ByAuthKey(ctx context.Context, id [8]byte) (domain.Authorization, bool, error) {
row, err := s.q.GetAuthorizationByAuthKey(ctx, authKeyIDToInt64(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("get authorization: %w", err)
}
return domain.Authorization{
AuthKeyID: id,
UserID: row.UserID,
Layer: int(row.Layer),
DeviceModel: row.DeviceModel,
Platform: row.Platform,
SystemVersion: row.SystemVersion,
APIID: int(row.ApiID),
AppVersion: row.AppVersion,
IP: row.Ip,
}, true, nil
}
func (s *AuthorizationStore) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) {
rows, err := s.q.ListAuthorizationsByUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list authorizations by user: %w", err)
}
out := make([]domain.Authorization, 0, len(rows))
for _, row := range rows {
out = append(out, authorizationFromRow(row))
}
return out, nil
}
func (s *AuthorizationStore) Delete(ctx context.Context, id [8]byte) error {
if err := s.q.DeleteAuthorization(ctx, authKeyIDToInt64(id)); err != nil {
return fmt.Errorf("delete authorization: %w", err)
}
return nil
}
func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
return domain.Authorization{
AuthKeyID: authKeyIDFromInt64(row.AuthKeyID),
UserID: row.UserID,
Layer: int(row.Layer),
DeviceModel: row.DeviceModel,
Platform: row.Platform,
SystemVersion: row.SystemVersion,
APIID: int(row.ApiID),
AppVersion: row.AppVersion,
IP: row.Ip,
}
}

View file

@ -0,0 +1,288 @@
package postgres
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestBusinessStoresRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
var authID [8]byte
var authBody [256]byte
if _, err := rand.Read(authID[:]); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(authBody[:]); err != nil {
t.Fatal(err)
}
if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{
ID: authID,
Value: authBody,
ServerSalt: 42,
}); err != nil {
t.Fatalf("save auth key: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authID))
})
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 1,
Phone: "+1555" + suffix + "01",
FirstName: "Owner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
if owner.ID < domain.UserIDSequenceBase {
t.Fatalf("owner id = %d, want >= base %d", owner.ID, domain.UserIDSequenceBase)
}
friend, err := users.Create(ctx, domain.User{
AccessHash: 2,
Phone: "+1555" + suffix + "02",
FirstName: "Friend",
})
if err != nil {
t.Fatalf("create friend: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
})
username := "owner_" + suffix
owner, err = users.UpdateUsername(ctx, owner.ID, username)
if err != nil {
t.Fatalf("update owner username: %v", err)
}
if owner.Username != username {
t.Fatalf("owner username = %q, want %q", owner.Username, username)
}
byUsername, found, err := users.ByUsername(ctx, strings.ToUpper(username))
if err != nil || !found || byUsername.ID != owner.ID {
t.Fatalf("by username = user %+v found %v err %v, want owner", byUsername, found, err)
}
if _, err := users.UpdateUsername(ctx, friend.ID, strings.ToUpper(username)); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("duplicate username err = %v, want username occupied", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO contacts (user_id, contact_user_id, mutual)
VALUES ($1, $2, true)
`, owner.ID, friend.ID); err != nil {
t.Fatalf("insert contact: %v", err)
}
contacts, err := NewContactStore(pool).ListByUser(ctx, owner.ID)
if err != nil {
t.Fatalf("list contacts: %v", err)
}
if len(contacts.Contacts) != 1 || contacts.Contacts[0].User.ID != friend.ID || !contacts.Contacts[0].Mutual {
t.Fatalf("contacts = %+v, want friend mutual contact", contacts)
}
if contacts.Hash == 0 {
t.Fatal("contacts hash is zero for non-empty contact list")
}
search, err := users.Search(ctx, owner.ID, "Friend", "", 10)
if err != nil {
t.Fatalf("search users: %v", err)
}
if len(search.MyResults) != 1 || search.MyResults[0].ID != friend.ID || !search.MyResults[0].Contact {
t.Fatalf("search contacts = %+v, want friend in my results", search)
}
if _, err := pool.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, unread_count, pinned)
VALUES ($1, 'user', $2, 10, 2, true)
`, owner.ID, friend.ID); err != nil {
t.Fatalf("insert dialog: %v", err)
}
dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{PinnedOnly: true, Limit: 10})
if err != nil {
t.Fatalf("list dialogs: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer.ID != friend.ID || !dialogs.Dialogs[0].Pinned {
t.Fatalf("dialogs = %+v, want pinned friend dialog", dialogs)
}
emptyDialogsPage, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{
PinnedOnly: true,
OffsetID: dialogs.Dialogs[0].TopMessage,
HasOffsetPeer: true,
OffsetPeer: dialogs.Dialogs[0].Peer,
Limit: 10,
})
if err != nil {
t.Fatalf("list empty dialogs page: %v", err)
}
if len(emptyDialogsPage.Dialogs) != 0 || emptyDialogsPage.Count != dialogs.Count || emptyDialogsPage.Hash != dialogs.Hash {
t.Fatalf("empty dialogs page = %+v, want empty rows with full count/hash from %+v", emptyDialogsPage, dialogs)
}
peerDialogs, err := NewDialogStore(pool).ListByPeers(ctx, owner.ID, []domain.Peer{
{Type: domain.PeerTypeUser, ID: friend.ID},
{Type: domain.PeerTypeUser, ID: owner.ID},
})
if err != nil {
t.Fatalf("list peer dialogs: %v", err)
}
if len(peerDialogs.Dialogs) != 2 || peerDialogs.Dialogs[0].Peer.ID != friend.ID || peerDialogs.Dialogs[0].TopMessage != 10 {
t.Fatalf("peer dialogs = %+v, want friend dialog plus owner placeholder", peerDialogs)
}
if peerDialogs.Dialogs[1].Peer.ID != owner.ID || peerDialogs.Dialogs[1].TopMessage != 0 {
t.Fatalf("placeholder dialog = %+v, want owner empty dialog", peerDialogs.Dialogs[1])
}
if len(peerDialogs.Users) != 2 {
t.Fatalf("peer dialog users = %+v, want both requested users", peerDialogs.Users)
}
wantState := domain.UpdateState{Pts: 11, Qts: 12, Date: 13, Seq: 14}
states := NewUpdateStateStore(pool)
if err := states.Save(ctx, authID, owner.ID, wantState); err != nil {
t.Fatalf("save update state: %v", err)
}
gotState, found, err := states.Get(ctx, authID, owner.ID)
if err != nil {
t.Fatalf("get update state: %v", err)
}
if !found || gotState != wantState {
t.Fatalf("update state = %+v found=%v, want %+v found=true", gotState, found, wantState)
}
if err := states.Delete(ctx, authID, owner.ID); err != nil {
t.Fatalf("delete update state: %v", err)
}
if _, found, err := states.Get(ctx, authID, owner.ID); err != nil || found {
t.Fatalf("state after delete found=%v err=%v, want not found", found, err)
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: authID,
PermAuthKeyID: 12345,
Nonce: 67890,
TempSessionID: 24680,
ExpiresAt: 111,
EncryptedMessage: []byte("binding"),
}); err != nil {
t.Fatalf("save temp auth key binding: %v", err)
}
var bindingCount int
var tempSessionID int64
if err := pool.QueryRow(ctx, "SELECT count(*), coalesce(max(temp_session_id), 0) FROM temp_auth_key_bindings WHERE temp_auth_key_id = $1", authKeyIDToInt64(authID)).Scan(&bindingCount, &tempSessionID); err != nil {
t.Fatalf("count temp auth key binding: %v", err)
}
if bindingCount != 1 || tempSessionID != 24680 {
t.Fatalf("temp auth key binding count/session = %d/%d, want 1/24680", bindingCount, tempSessionID)
}
passwords := NewPasswordStore(pool)
wantPassword := domain.PasswordSettings{
Hint: "dev",
SecureRandom: []byte("secure-random"),
}
if err := passwords.Save(ctx, owner.ID, wantPassword); err != nil {
t.Fatalf("save password settings: %v", err)
}
gotPassword, found, err := passwords.GetByUser(ctx, owner.ID)
if err != nil {
t.Fatalf("get password settings: %v", err)
}
if !found || gotPassword.Hint != wantPassword.Hint || string(gotPassword.SecureRandom) != string(wantPassword.SecureRandom) {
t.Fatalf("password settings = %+v found=%v, want %+v found=true", gotPassword, found, wantPassword)
}
help := NewHelpStore(pool)
client := "tdesktop-test-" + suffix
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM app_configs WHERE client = $1", client)
})
if err := help.UpsertAppConfig(ctx, domain.AppConfig{Client: client, Hash: 9, JSON: []byte(`{"test":true}`)}); err != nil {
t.Fatalf("upsert app config: %v", err)
}
cfg, found, err := help.GetAppConfig(ctx, client)
if err != nil {
t.Fatalf("get app config: %v", err)
}
if !found || cfg.Hash != 9 || string(cfg.JSON) != `{"test": true}` {
t.Fatalf("app config = %+v found=%v, want hash=9 json", cfg, found)
}
countryISO := "T" + suffix[:1]
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM countries WHERE iso2 = $1", countryISO)
})
if err := help.UpsertCountries(ctx, []domain.Country{{
ISO2: countryISO,
DefaultName: "Testland",
CountryCodes: []domain.CountryCode{
{CountryCode: "999", Prefixes: []string{"999"}, Patterns: []string{"XXX"}},
},
}}); err != nil {
t.Fatalf("upsert countries: %v", err)
}
countryList, err := help.ListCountries(ctx, "en")
if err != nil {
t.Fatalf("list countries: %v", err)
}
var foundCountry bool
for _, country := range countryList.Countries {
if country.ISO2 == countryISO && len(country.CountryCodes) == 1 && country.CountryCodes[0].CountryCode == "999" {
foundCountry = true
}
}
if !foundCountry {
t.Fatalf("country %s not found in %+v", countryISO, countryList)
}
langCode := "test-" + suffix
lang := NewLangPackStore(pool)
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM lang_packs WHERE lang_pack = $1 AND lang_code = $2", "tdesktop", langCode)
})
if err := lang.UpsertPack(ctx, domain.LangPack{
LangPack: "tdesktop",
LangCode: langCode,
Version: 7,
Strings: []domain.LangPackString{
{Key: "lng_test", Value: "Test"},
{Key: "lng_items", Pluralized: true, OneValue: "{count} item", OtherValue: "{count} items"},
},
}); err != nil {
t.Fatalf("upsert lang pack: %v", err)
}
pack, err := lang.GetPack(ctx, "tdesktop", langCode, 0)
if err != nil {
t.Fatalf("get lang pack: %v", err)
}
if pack.Version != 7 || len(pack.Strings) != 2 {
t.Fatalf("lang pack = %+v, want version 7 with 2 strings", pack)
}
notModified, err := lang.GetPack(ctx, "tdesktop", langCode, 7)
if err != nil {
t.Fatalf("get lang pack not modified: %v", err)
}
if notModified.Version != 7 || len(notModified.Strings) != 0 {
t.Fatalf("not modified pack = %+v, want version 7 with no strings", notModified)
}
selected, err := lang.GetStrings(ctx, "tdesktop", langCode, []string{"lng_test"})
if err != nil {
t.Fatalf("get lang pack strings: %v", err)
}
if len(selected.Strings) != 1 || selected.Strings[0].Value != "Test" {
t.Fatalf("selected strings = %+v, want lng_test=Test", selected.Strings)
}
}
func randomSuffix(t *testing.T) string {
t.Helper()
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
t.Fatal(err)
}
return fmt.Sprintf("%s", hex.EncodeToString(b[:]))
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,393 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// ContactStore 用 PostgreSQL 实现 store.ContactStore。
type ContactStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewContactStore 基于 pgx 连接池(或事务)创建 ContactStore。
func NewContactStore(db sqlcgen.DBTX) *ContactStore {
return &ContactStore{db: db, q: sqlcgen.New(db)}
}
func (s *ContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
rows, err := s.q.ListContactsByUser(ctx, userID)
if err != nil {
return domain.ContactList{}, fmt.Errorf("list contacts: %w", err)
}
out := domain.ContactList{Contacts: make([]domain.Contact, 0, len(rows))}
for _, row := range rows {
contact, err := contactFromListRow(row)
if err != nil {
return domain.ContactList{}, err
}
out.Contacts = append(out.Contacts, contact)
}
out.Hash = contactListHash(out.Contacts)
return out, nil
}
func (s *ContactStore) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) {
row, err := s.q.GetContact(ctx, sqlcgen.GetContactParams{
UserID: userID,
ContactUserID: contactUserID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Contact{}, false, nil
}
return domain.Contact{}, false, fmt.Errorf("get contact: %w", err)
}
contact, err := contactFromGetRow(row)
if err != nil {
return domain.Contact{}, false, err
}
return contact, true, nil
}
func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
entities, err := encodeMessageEntities(input.NoteEntities)
if err != nil {
return domain.Contact{}, err
}
row, err := s.q.UpsertContact(ctx, sqlcgen.UpsertContactParams{
UserID: userID,
ContactUserID: input.ContactUserID,
ContactPhone: input.Phone,
ContactFirstName: input.FirstName,
ContactLastName: input.LastName,
Note: input.Note,
NoteEntities: entities,
})
if err != nil {
return domain.Contact{}, fmt.Errorf("upsert contact: %w", err)
}
contact, err := contactFromUpsertRow(row)
if err != nil {
return domain.Contact{}, err
}
return contact, nil
}
const upsertContactsManySQL = `
WITH input AS (
SELECT
$1::bigint AS user_id,
i.contact_user_id,
i.contact_phone,
i.contact_first_name,
i.contact_last_name,
i.note,
i.note_entities_json::jsonb AS note_entities,
i.ord
FROM unnest(
$2::bigint[],
$3::text[],
$4::text[],
$5::text[],
$6::text[],
$7::text[]
) WITH ORDINALITY AS i(contact_user_id, contact_phone, contact_first_name, contact_last_name, note, note_entities_json, ord)
),
reverse AS (
SELECT
i.contact_user_id,
EXISTS (
SELECT 1
FROM contacts c
WHERE c.user_id = i.contact_user_id
AND c.contact_user_id = i.user_id
)::boolean AS mutual
FROM input i
),
upserted AS (
INSERT INTO contacts (
user_id,
contact_user_id,
contact_phone,
contact_first_name,
contact_last_name,
note,
note_entities,
mutual
)
SELECT
i.user_id,
i.contact_user_id,
i.contact_phone,
i.contact_first_name,
i.contact_last_name,
i.note,
i.note_entities,
r.mutual
FROM input i
JOIN reverse r ON r.contact_user_id = i.contact_user_id
ON CONFLICT (user_id, contact_user_id) DO UPDATE SET
contact_phone = EXCLUDED.contact_phone,
contact_first_name = EXCLUDED.contact_first_name,
contact_last_name = EXCLUDED.contact_last_name,
note = EXCLUDED.note,
note_entities = EXCLUDED.note_entities,
mutual = contacts.mutual OR EXCLUDED.mutual,
updated_at = now()
RETURNING *
),
reverse_updated AS (
UPDATE contacts c
SET mutual = true,
updated_at = now()
FROM upserted u
WHERE c.user_id = u.contact_user_id
AND c.contact_user_id = $1::bigint
AND NOT c.mutual
RETURNING c.user_id
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed
FROM upserted c
JOIN users u ON u.id = c.contact_user_id
JOIN input i ON i.contact_user_id = c.contact_user_id
ORDER BY i.ord
`
func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) {
if len(inputs) == 0 {
return nil, nil
}
contactUserIDs := make([]int64, 0, len(inputs))
phones := make([]string, 0, len(inputs))
firstNames := make([]string, 0, len(inputs))
lastNames := make([]string, 0, len(inputs))
notes := make([]string, 0, len(inputs))
noteEntities := make([]string, 0, len(inputs))
for _, input := range inputs {
if input.ContactUserID == 0 {
continue
}
raw, err := encodeMessageEntities(input.NoteEntities)
if err != nil {
return nil, err
}
contactUserIDs = append(contactUserIDs, input.ContactUserID)
phones = append(phones, input.Phone)
firstNames = append(firstNames, input.FirstName)
lastNames = append(lastNames, input.LastName)
notes = append(notes, input.Note)
noteEntities = append(noteEntities, string(raw))
}
if len(contactUserIDs) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, upsertContactsManySQL, userID, contactUserIDs, phones, firstNames, lastNames, notes, noteEntities)
if err != nil {
return nil, fmt.Errorf("upsert contacts many: %w", err)
}
defer rows.Close()
out := make([]domain.Contact, 0, len(contactUserIDs))
for rows.Next() {
var (
contactUserID int64
mutual bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
lastSeenAt int64
reverseMutualChanged bool
)
if err := rows.Scan(
&contactUserID,
&mutual,
&contactPhone,
&contactFirstName,
&contactLastName,
&note,
&noteEntitiesJSON,
&id,
&accessHash,
&phone,
&firstName,
&lastName,
&username,
&countryCode,
&verified,
&support,
&lastSeenAt,
&reverseMutualChanged,
); err != nil {
return nil, fmt.Errorf("scan upsert contacts many: %w", err)
}
_ = reverseMutualChanged
entities, err := decodeMessageEntities(noteEntitiesJSON)
if err != nil {
return nil, fmt.Errorf("decode contact note entities: %w", err)
}
out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate upsert contacts many: %w", err)
}
return out, nil
}
func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) {
raw, err := encodeMessageEntities(entities)
if err != nil {
return domain.Contact{}, false, err
}
row, err := s.q.UpdateContactNote(ctx, sqlcgen.UpdateContactNoteParams{
UserID: userID,
ContactUserID: contactUserID,
Note: note,
NoteEntities: raw,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Contact{}, false, nil
}
return domain.Contact{}, false, fmt.Errorf("update contact note: %w", err)
}
contact, err := contactFromUpdateNoteRow(row)
if err != nil {
return domain.Contact{}, false, err
}
return contact, true, nil
}
func (s *ContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
if len(contactUserIDs) == 0 {
return 0, nil
}
count, err := s.q.DeleteContacts(ctx, sqlcgen.DeleteContactsParams{
UserID: userID,
ContactUserIds: contactUserIDs,
})
if err != nil {
return 0, fmt.Errorf("delete contacts: %w", err)
}
return int(count), nil
}
func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, error) {
entities, err := decodeMessageEntities(row.NoteEntitiesJson)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil
}
func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
entities, err := decodeMessageEntities(row.NoteEntitiesJson)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil
}
func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) {
entities, err := decodeMessageEntities(row.NoteEntitiesJson)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil
}
func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) {
entities, err := decodeMessageEntities(row.NoteEntitiesJson)
if err != nil {
return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err)
}
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil
}
func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support bool, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual bool) domain.Contact {
return domain.Contact{
User: domain.User{
ID: id,
AccessHash: accessHash,
Phone: phone,
FirstName: firstName,
LastName: lastName,
Username: username,
CountryCode: countryCode,
Verified: verified,
Support: support,
LastSeenAt: lastSeenAt,
Contact: true,
Mutual: mutual,
},
FirstName: contactFirstName,
LastName: contactLastName,
Phone: contactPhone,
Note: note,
NoteEntities: noteEntities,
Mutual: mutual,
}
}
func contactListHash(contacts []domain.Contact) int64 {
if len(contacts) == 0 {
return 0
}
h := fnv.New64a()
var buf [16]byte
for _, c := range contacts {
binary.LittleEndian.PutUint64(buf[:8], uint64(c.User.ID))
if c.Mutual {
buf[8] = 1
} else {
buf[8] = 0
}
_, _ = h.Write(buf[:9])
_, _ = h.Write([]byte(c.FirstName))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(c.LastName))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(c.Phone))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(c.Note))
_, _ = h.Write([]byte{0})
}
return int64(h.Sum64())
}

View file

@ -0,0 +1,279 @@
package postgres
import (
"context"
"testing"
contactsapp "telesrv/internal/app/contacts"
"telesrv/internal/domain"
)
func TestContactProfilesOwnerScopedRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1900"+suffix+"01", "Owner", "One")
altOwner := createTestUser(t, ctx, users, "+1900"+suffix+"02", "AltOwner", "Two")
friend := createTestUser(t, ctx, users, "+1900"+suffix+"03", "Canonical", "Friend")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, altOwner.ID, friend.ID})
})
contactStore := NewContactStore(pool)
contacts := contactsapp.NewService(contactStore, users)
if _, err := contacts.AddContact(ctx, owner.ID, domain.ContactInput{
ContactUserID: friend.ID,
Phone: "10001",
FirstName: "OwnerRemark",
LastName: "A",
Note: "first note",
}); err != nil {
t.Fatalf("owner add contact: %v", err)
}
if _, err := contacts.AddContact(ctx, altOwner.ID, domain.ContactInput{
ContactUserID: friend.ID,
Phone: "20002",
FirstName: "AltRemark",
LastName: "B",
Note: "alt note",
}); err != nil {
t.Fatalf("alt owner add contact: %v", err)
}
ownerList, _, err := contacts.GetContacts(ctx, owner.ID, 0)
if err != nil {
t.Fatalf("owner contacts: %v", err)
}
altList, _, err := contacts.GetContacts(ctx, altOwner.ID, 0)
if err != nil {
t.Fatalf("alt contacts: %v", err)
}
if got := ownerList.Contacts[0].User.FirstName; got != "OwnerRemark" {
t.Fatalf("owner contact first name = %q, want owner remark", got)
}
if got := altList.Contacts[0].User.FirstName; got != "AltRemark" {
t.Fatalf("alt contact first name = %q, want alt remark", got)
}
if ownerList.Hash == 0 || altList.Hash == 0 || ownerList.Hash == altList.Hash {
t.Fatalf("contact hashes owner=%d alt=%d, want non-zero owner-specific hashes", ownerList.Hash, altList.Hash)
}
beforeHash := ownerList.Hash
updated, err := contacts.UpdateContactNote(ctx, owner.ID, friend.ID, "fresh note", []domain.MessageEntity{
{Type: domain.MessageEntityBold, Offset: 0, Length: 5},
})
if err != nil {
t.Fatalf("update contact note: %v", err)
}
if updated.Note != "fresh note" || len(updated.NoteEntities) != 1 {
t.Fatalf("updated contact = %+v, want note with entity", updated)
}
ownerList, _, err = contacts.GetContacts(ctx, owner.ID, 0)
if err != nil {
t.Fatalf("owner contacts after note: %v", err)
}
if ownerList.Hash == beforeHash {
t.Fatalf("owner contact hash did not change after note update: %d", ownerList.Hash)
}
altList, _, err = contacts.GetContacts(ctx, altOwner.ID, 0)
if err != nil {
t.Fatalf("alt contacts after owner note: %v", err)
}
if altList.Contacts[0].Note != "alt note" {
t.Fatalf("alt contact note = %q, want isolated alt note", altList.Contacts[0].Note)
}
if _, err := contacts.AddContact(ctx, friend.ID, domain.ContactInput{
ContactUserID: owner.ID,
FirstName: "OwnerBack",
}); err != nil {
t.Fatalf("friend reciprocal add: %v", err)
}
ownerContact, found, err := contactStore.Get(ctx, owner.ID, friend.ID)
if err != nil {
t.Fatalf("get owner contact: %v", err)
}
if !found || !ownerContact.Mutual {
t.Fatalf("owner contact = %+v found=%v, want mutual after reciprocal add", ownerContact, found)
}
friendContact, found, err := contactStore.Get(ctx, friend.ID, owner.ID)
if err != nil {
t.Fatalf("get friend contact: %v", err)
}
if !found || !friendContact.Mutual {
t.Fatalf("friend contact = %+v found=%v, want mutual after reciprocal add", friendContact, found)
}
deleted, err := contacts.DeleteContacts(ctx, owner.ID, []int64{friend.ID})
if err != nil {
t.Fatalf("delete owner contact: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted = %d, want 1", deleted)
}
if _, found, err := contactStore.Get(ctx, owner.ID, friend.ID); err != nil || found {
t.Fatalf("owner contact after delete found=%v err=%v, want not found", found, err)
}
friendContact, found, err = contactStore.Get(ctx, friend.ID, owner.ID)
if err != nil {
t.Fatalf("get friend contact after delete: %v", err)
}
if !found || friendContact.Mutual {
t.Fatalf("friend contact after delete = %+v found=%v, want reverse mutual cleared", friendContact, found)
}
}
func TestDialogUserViewUsesContactProfileAndDialogFlags(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
ownerA := createTestUser(t, ctx, users, "+1910"+suffix+"01", "OwnerA", "")
ownerB := createTestUser(t, ctx, users, "+1910"+suffix+"02", "OwnerB", "")
friend := createTestUser(t, ctx, users, "+1910"+suffix+"03", "Shared", "Friend")
other := createTestUser(t, ctx, users, "+1910"+suffix+"04", "Other", "Peer")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{ownerA.ID, ownerB.ID, friend.ID, other.ID})
})
contacts := contactsapp.NewService(NewContactStore(pool), users)
if _, err := contacts.AddContact(ctx, ownerA.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "RemarkA"}); err != nil {
t.Fatalf("owner A add contact: %v", err)
}
if _, err := contacts.AddContact(ctx, ownerB.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "RemarkB"}); err != nil {
t.Fatalf("owner B add contact: %v", err)
}
messages := NewMessageStore(pool)
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: friend.ID,
RecipientUserID: ownerA.ID,
RandomID: 301,
Message: "to owner A",
Date: 1700000301,
}); err != nil {
t.Fatalf("send to owner A: %v", err)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: friend.ID,
RecipientUserID: ownerB.ID,
RandomID: 302,
Message: "to owner B",
Date: 1700000302,
}); err != nil {
t.Fatalf("send to owner B: %v", err)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: other.ID,
RecipientUserID: ownerA.ID,
RandomID: 303,
Message: "second dialog",
Date: 1700000303,
}); err != nil {
t.Fatalf("send second dialog: %v", err)
}
dialogs := NewDialogStore(pool)
listA, err := dialogs.ListByUser(ctx, ownerA.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("list owner A dialogs: %v", err)
}
listB, err := dialogs.ListByUser(ctx, ownerB.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("list owner B dialogs: %v", err)
}
userA, ok := findDialogUserByID(listA.Users, friend.ID)
if !ok || userA.FirstName != "RemarkA" || !userA.Contact {
t.Fatalf("owner A dialog user = %+v found=%v, want RemarkA contact", userA, ok)
}
userB, ok := findDialogUserByID(listB.Users, friend.ID)
if !ok || userB.FirstName != "RemarkB" || !userB.Contact {
t.Fatalf("owner B dialog user = %+v found=%v, want RemarkB contact", userB, ok)
}
friendPeer := domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID}
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
if changed, err := dialogs.SetPinned(ctx, ownerA.ID, friendPeer, true); err != nil || !changed {
t.Fatalf("pin friend changed=%v err=%v, want changed", changed, err)
}
if changed, err := dialogs.SetPinned(ctx, ownerA.ID, otherPeer, true); err != nil || !changed {
t.Fatalf("pin other changed=%v err=%v, want changed", changed, err)
}
if err := dialogs.ReorderPinned(ctx, ownerA.ID, []domain.Peer{otherPeer, friendPeer}, true); err != nil {
t.Fatalf("reorder pinned: %v", err)
}
pinned, err := dialogs.ListByUser(ctx, ownerA.ID, domain.DialogFilter{PinnedOnly: true, Limit: 10})
if err != nil {
t.Fatalf("list pinned dialogs: %v", err)
}
if len(pinned.Dialogs) != 2 || pinned.Dialogs[0].Peer != otherPeer || pinned.Dialogs[0].PinnedOrder != 1 || pinned.Dialogs[1].Peer != friendPeer || pinned.Dialogs[1].PinnedOrder != 2 {
t.Fatalf("pinned dialogs = %+v, want other then friend with stable order", pinned.Dialogs)
}
if changed, err := dialogs.SetUnreadMark(ctx, ownerA.ID, friendPeer, true); err != nil || !changed {
t.Fatalf("mark unread changed=%v err=%v, want changed", changed, err)
}
marks, err := dialogs.ListUnreadMarked(ctx, ownerA.ID)
if err != nil {
t.Fatalf("list unread marks: %v", err)
}
if !containsPeer(marks, friendPeer) {
t.Fatalf("unread marks = %+v, want friend peer", marks)
}
if _, err := dialogs.MarkRead(ctx, ownerA.ID, friendPeer, 0); err != nil {
t.Fatalf("mark read: %v", err)
}
peerDialogs, err := dialogs.ListByPeers(ctx, ownerA.ID, []domain.Peer{friendPeer})
if err != nil {
t.Fatalf("list peer dialogs after read: %v", err)
}
if len(peerDialogs.Dialogs) != 1 || peerDialogs.Dialogs[0].UnreadMark {
t.Fatalf("peer dialog after read = %+v, want unread mark cleared", peerDialogs.Dialogs)
}
if changed, err := dialogs.SetPeerSettingsBarHidden(ctx, ownerA.ID, friendPeer); err != nil || !changed {
t.Fatalf("hide peer settings bar changed=%v err=%v, want changed", changed, err)
}
hidden, err := dialogs.PeerSettingsBarHidden(ctx, ownerA.ID, friendPeer)
if err != nil {
t.Fatalf("peer settings bar hidden: %v", err)
}
if !hidden {
t.Fatal("peer settings bar hidden = false, want true")
}
}
func createTestUser(t *testing.T, ctx context.Context, users *UserStore, phone, firstName, lastName string) domain.User {
t.Helper()
user, err := users.Create(ctx, domain.User{
AccessHash: int64(len(phone) + len(firstName)*100 + len(lastName)*1000),
Phone: phone,
FirstName: firstName,
LastName: lastName,
})
if err != nil {
t.Fatalf("create user %s: %v", phone, err)
}
return user
}
func findDialogUserByID(users []domain.User, id int64) (domain.User, bool) {
for _, user := range users {
if user.ID == id {
return user, true
}
}
return domain.User{}, false
}
func containsPeer(peers []domain.Peer, want domain.Peer) bool {
for _, peer := range peers {
if peer == want {
return true
}
}
return false
}

View file

@ -0,0 +1,127 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
// TestMaxContiguousPtsStopsAtHole 用真实 PG 验证 RecentUserPts 窗口查询 + 连续计算:
// 存在在途空洞时只报告最大连续 pts补洞后回升。
func TestMaxContiguousPtsStopsAtHole(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 1,
Phone: "+1556" + suffix + "01",
FirstName: "Contig",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
events := NewUpdateEventStore(pool)
appendPts := func(pts int) {
if err := events.Append(ctx, owner.ID, domain.UpdateEvent{
Type: domain.UpdateEventNoop,
Pts: pts,
PtsCount: 1,
Date: 1700000000 + pts,
}); err != nil {
t.Fatalf("append pts=%d: %v", pts, err)
}
}
for _, p := range []int{1, 2, 3, 5, 6} { // pts=4 为在途空洞
appendPts(p)
}
if _, err := pool.Exec(ctx, "DELETE FROM user_update_watermarks WHERE user_id = $1", owner.ID); err != nil {
t.Fatalf("delete watermark fallback row: %v", err)
}
got, err := events.MaxContiguousPts(ctx, owner.ID)
if err != nil {
t.Fatalf("MaxContiguousPts: %v", err)
}
if got != 3 {
t.Fatalf("contiguous = %d, want 3止于 pts=4 空洞,最大已提交为 6", got)
}
appendPts(4) // 在途事务提交/补洞
got, err = events.MaxContiguousPts(ctx, owner.ID)
if err != nil {
t.Fatalf("MaxContiguousPts after fill: %v", err)
}
if got != 6 {
t.Fatalf("contiguous after fill = %d, want 6", got)
}
}
func TestAppendWithDispatchWritesEventAndOutboxAtomically(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 2,
Phone: "+1557" + suffix + "01",
FirstName: "Dispatch",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = $1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
event := domain.UpdateEvent{
UserID: owner.ID,
Type: domain.UpdateEventDialogPinned,
Pts: 1,
PtsCount: 1,
Date: 1700000001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002},
Settings: domain.PeerSettings{
ShareContact: true,
},
Bool: true,
}
var excludeAuthKeyID [8]byte
excludeAuthKeyID[0] = 9
if err := NewUpdateEventStore(pool).AppendWithDispatch(ctx, owner.ID, event, excludeAuthKeyID, 77); err != nil {
t.Fatalf("AppendWithDispatch: %v", err)
}
got, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, 0, 10)
if err != nil {
t.Fatalf("ListAfter: %v", err)
}
if len(got) != 1 || got[0].Type != event.Type || got[0].Peer != event.Peer || !got[0].Bool {
t.Fatalf("events = %+v, want dialog pinned event", got)
}
var outbox struct {
pts int
eventType string
excludeAuthKeyID int64
excludeSessionID int64
}
if err := pool.QueryRow(ctx, `
SELECT pts, event_type, exclude_auth_key_id, exclude_session_id
FROM dispatch_outbox
WHERE target_user_id = $1
`, owner.ID).Scan(&outbox.pts, &outbox.eventType, &outbox.excludeAuthKeyID, &outbox.excludeSessionID); err != nil {
t.Fatalf("query dispatch outbox: %v", err)
}
if outbox.pts != 1 || outbox.eventType != string(domain.UpdateEventDialogPinned) || outbox.excludeAuthKeyID != authKeyIDToInt64(excludeAuthKeyID) || outbox.excludeSessionID != 77 {
t.Fatalf("outbox = %+v, want event dispatch excluding current session", outbox)
}
}

View file

@ -0,0 +1,78 @@
package postgres
import (
"context"
"fmt"
"telesrv/internal/store/postgres/sqlcgen"
)
// MessageBoxCounterSource 从 message_boxes durable log 恢复某 owner 的当前最大 box_id。
type MessageBoxCounterSource struct {
q *sqlcgen.Queries
}
// NewMessageBoxCounterSource 创建 Redis BoxIDAllocator 的 PG 恢复源。
func NewMessageBoxCounterSource(db sqlcgen.DBTX) *MessageBoxCounterSource {
return &MessageBoxCounterSource{q: sqlcgen.New(db)}
}
func (s *MessageBoxCounterSource) Current(ctx context.Context, userID int64) (int, error) {
v, err := s.q.MaxMessageBoxID(ctx, userID)
if err != nil {
return 0, fmt.Errorf("max message box id: %w", err)
}
return int(v), nil
}
// ChannelIDCounterSource 从 channels durable 表恢复全局 channel id。
type ChannelIDCounterSource struct {
db sqlcgen.DBTX
}
// NewChannelIDCounterSource 创建 Redis ChannelIDAllocator 的 PG 恢复源。
func NewChannelIDCounterSource(db sqlcgen.DBTX) *ChannelIDCounterSource {
return &ChannelIDCounterSource{db: db}
}
func (s *ChannelIDCounterSource) Current(ctx context.Context, _ int64) (int, error) {
var id int
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&id); err != nil {
return 0, fmt.Errorf("max channel id: %w", err)
}
return id, nil
}
// ChannelPtsCounterSource 从 channel_update_events 恢复某 channel 的当前最大 pts。
type ChannelPtsCounterSource struct {
db sqlcgen.DBTX
}
func NewChannelPtsCounterSource(db sqlcgen.DBTX) *ChannelPtsCounterSource {
return &ChannelPtsCounterSource{db: db}
}
func (s *ChannelPtsCounterSource) Current(ctx context.Context, channelID int64) (int, error) {
var pts int
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts); err != nil {
return 0, fmt.Errorf("max channel pts: %w", err)
}
return pts, nil
}
// ChannelMessageIDCounterSource 从 channel_messages 恢复某 channel 的当前最大 message id。
type ChannelMessageIDCounterSource struct {
db sqlcgen.DBTX
}
func NewChannelMessageIDCounterSource(db sqlcgen.DBTX) *ChannelMessageIDCounterSource {
return &ChannelMessageIDCounterSource{db: db}
}
func (s *ChannelMessageIDCounterSource) Current(ctx context.Context, channelID int64) (int, error) {
var id int
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id); err != nil {
return 0, fmt.Errorf("max channel message id: %w", err)
}
return id, nil
}

View file

@ -0,0 +1,722 @@
package postgres
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// DialogStore 用 PostgreSQL 实现 store.DialogStore。
type DialogStore struct {
q *sqlcgen.Queries
}
// NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。
func NewDialogStore(db sqlcgen.DBTX) *DialogStore {
return &DialogStore{q: sqlcgen.New(db)}
}
func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
limit := filter.Limit
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
offsetPeerID := int64(0)
if filter.HasOffsetPeer {
offsetPeerID = filter.OffsetPeer.ID
}
folderParams := dialogFolderQueryParams(filter.Folder)
summaryRows, err := s.q.ListDialogSummaryByUser(ctx, sqlcgen.ListDialogSummaryByUserParams{
UserID: userID,
HasFolderID: filter.HasFolderID,
FolderID: pgInt32NonNegative(filter.FolderID),
FolderExcludeArchived: folderParams.excludeArchived,
FolderExcludeRead: folderParams.excludeRead,
FolderExcludePeerTypes: folderParams.excludeTypes,
FolderExcludePeerIds: folderParams.excludeIDs,
FolderIncludePeerTypes: folderParams.includeTypes,
FolderIncludePeerIds: folderParams.includeIDs,
FolderPinnedPeerTypes: folderParams.pinnedTypes,
FolderPinnedPeerIds: folderParams.pinnedIDs,
FolderContacts: folderParams.contacts,
FolderNonContacts: folderParams.nonContacts,
PinnedOnly: filter.PinnedOnly,
ExcludePinned: filter.ExcludePinned,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialog summary: %w", err)
}
summary := make([]domain.Dialog, 0, len(summaryRows))
for _, row := range summaryRows {
summary = append(summary, domain.Dialog{
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
})
}
out := domain.DialogList{
Dialogs: make([]domain.Dialog, 0, limit),
Count: len(summary),
Hash: dialogListHash(summary),
}
if len(summary) == 0 {
return out, nil
}
rows, err := s.q.ListDialogsByUser(ctx, sqlcgen.ListDialogsByUserParams{
UserID: userID,
LimitCount: int32(limit),
HasFolderID: filter.HasFolderID,
FolderID: pgInt32NonNegative(filter.FolderID),
FolderExcludeArchived: folderParams.excludeArchived,
FolderExcludeRead: folderParams.excludeRead,
FolderExcludePeerTypes: folderParams.excludeTypes,
FolderExcludePeerIds: folderParams.excludeIDs,
FolderIncludePeerTypes: folderParams.includeTypes,
FolderIncludePeerIds: folderParams.includeIDs,
FolderPinnedPeerTypes: folderParams.pinnedTypes,
FolderPinnedPeerIds: folderParams.pinnedIDs,
FolderContacts: folderParams.contacts,
FolderNonContacts: folderParams.nonContacts,
PinnedOnly: filter.PinnedOnly,
ExcludePinned: filter.ExcludePinned,
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
HasOffsetPeer: filter.HasOffsetPeer,
OffsetPeerID: offsetPeerID,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialogs: %w", err)
}
out.Messages = make([]domain.Message, 0, len(rows))
out.Users = make([]domain.User, 0, len(rows))
seenUsers := map[int64]struct{}{}
for _, row := range rows {
dialog := domain.Dialog{
Peer: domain.Peer{
Type: domain.PeerType(row.PeerType),
ID: row.PeerID,
},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
}
out.Dialogs = append(out.Dialogs, dialog)
if row.PeerUserID != 0 {
if _, ok := seenUsers[row.PeerUserID]; !ok {
seenUsers[row.PeerUserID] = struct{}{}
out.Users = append(out.Users, domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
})
}
}
if row.MessageID != 0 {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.MessageID),
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
Out: row.MessageOutgoing,
Body: row.MessageBody,
Entities: entities,
})
}
}
return out, nil
}
func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if len(peers) == 0 {
return domain.DialogList{}, nil
}
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID == 0 {
continue
}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
if len(peerTypes) == 0 {
return domain.DialogList{}, nil
}
rows, err := s.q.ListDialogsByPeers(ctx, sqlcgen.ListDialogsByPeersParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialogs by peers: %w", err)
}
out := domain.DialogList{
Dialogs: make([]domain.Dialog, 0, len(rows)),
Messages: make([]domain.Message, 0, len(rows)),
Users: make([]domain.User, 0, len(rows)),
}
seenUsers := map[int64]struct{}{}
for _, row := range rows {
dialog := domain.Dialog{
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
}
out.Dialogs = append(out.Dialogs, dialog)
if row.PeerUserID != 0 {
if _, ok := seenUsers[row.PeerUserID]; !ok {
seenUsers[row.PeerUserID] = struct{}{}
out.Users = append(out.Users, domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
LastSeenAt: int(row.PeerLastSeenAt),
Contact: row.PeerContact,
Mutual: row.PeerMutual,
})
}
}
if row.MessageID != 0 {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return domain.DialogList{}, fmt.Errorf("decode message entities: %w", err)
}
out.Messages = append(out.Messages, domain.Message{
ID: int(row.MessageID),
OwnerUserID: row.UserID,
Peer: dialog.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID},
Date: int(row.MessageDate),
Out: row.MessageOutgoing,
Body: row.MessageBody,
Entities: entities,
})
}
}
out.Count = len(out.Dialogs)
out.Hash = dialogListHash(out.Dialogs)
return out, nil
}
func (s *DialogStore) Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error {
if err := s.q.UpsertDialog(ctx, sqlcgen.UpsertDialogParams{
UserID: userID,
PeerType: string(dialog.Peer.Type),
PeerID: dialog.Peer.ID,
TopMessageID: int32(dialog.TopMessage),
TopMessageDate: int32(dialog.TopMessageDate),
ReadInboxMaxID: int32(dialog.ReadInboxMaxID),
ReadOutboxMaxID: int32(dialog.ReadOutboxMaxID),
UnreadCount: int32(dialog.UnreadCount),
UnreadMentionsCount: int32(dialog.UnreadMentions),
UnreadReactionsCount: int32(dialog.UnreadReactions),
Pinned: dialog.Pinned,
UnreadMark: dialog.UnreadMark,
}); err != nil {
return fmt.Errorf("upsert dialog: %w", err)
}
return nil
}
func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error {
data, err := json.Marshal(draft)
if err != nil {
return fmt.Errorf("marshal dialog draft: %w", err)
}
if err := s.q.UpsertDialogDraft(ctx, sqlcgen.UpsertDialogDraftParams{
UserID: userID,
PeerType: string(draft.Peer.Type),
PeerID: draft.Peer.ID,
TopMessageID: int32(draft.TopMessageID),
Date: int32(draft.Date),
DraftJson: data,
}); err != nil {
return fmt.Errorf("upsert dialog draft: %w", err)
}
return nil
}
func (s *DialogStore) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) {
changed, err := s.q.DeleteDialogDraft(ctx, sqlcgen.DeleteDialogDraftParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
TopMessageID: int32(topMessageID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("delete dialog draft: %w", err)
}
return changed, nil
}
func (s *DialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
rows, err := s.q.ListDialogDrafts(ctx, sqlcgen.ListDialogDraftsParams{
UserID: userID,
LimitCount: int32(clampDialogDraftLimit(limit)),
})
if err != nil {
return nil, fmt.Errorf("list dialog drafts: %w", err)
}
return decodeDialogDrafts(rows)
}
func (s *DialogStore) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
rows, err := s.q.ClearDialogDrafts(ctx, sqlcgen.ClearDialogDraftsParams{
UserID: userID,
LimitCount: int32(clampDialogDraftLimit(limit)),
})
if err != nil {
return nil, fmt.Errorf("clear dialog drafts: %w", err)
}
return decodeDialogDrafts(rows)
}
func (s *DialogStore) MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) {
row, err := s.q.MarkDialogRead(ctx, sqlcgen.MarkDialogReadParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
MaxID: pgInt32NonNegative(maxID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID}, nil
}
return domain.ReadHistoryResult{}, fmt.Errorf("mark dialog read: %w", err)
}
return domain.ReadHistoryResult{
OwnerUserID: row.UserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
MaxID: int(row.ReadInboxMaxID),
StillUnreadCount: int(row.UnreadCount),
Changed: row.Changed,
}, nil
}
func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
changed, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
Pinned: pinned,
})
if err != nil {
return false, fmt.Errorf("set dialog pinned: %w", err)
}
return changed, nil
}
func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
peerTypes, peerIDs := peerArrays(order)
if force {
if err := s.q.ClearPinnedDialogsNotInOrder(ctx, sqlcgen.ClearPinnedDialogsNotInOrderParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
}); err != nil {
return fmt.Errorf("clear pinned dialogs not in order: %w", err)
}
}
if len(peerTypes) == 0 {
return nil
}
if err := s.q.ReorderPinnedDialogs(ctx, sqlcgen.ReorderPinnedDialogsParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
}); err != nil {
return fmt.Errorf("reorder pinned dialogs: %w", err)
}
return nil
}
func (s *DialogStore) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) {
changed, err := s.q.SetDialogUnreadMark(ctx, sqlcgen.SetDialogUnreadMarkParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
Unread: unread,
})
if err != nil {
return false, fmt.Errorf("set dialog unread mark: %w", err)
}
return changed, nil
}
func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) {
rows, err := s.q.ListDialogUnreadMarks(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list dialog unread marks: %w", err)
}
out := make([]domain.Peer, 0, len(rows))
for _, row := range rows {
out = append(out, domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID})
}
return out, nil
}
func (s *DialogStore) SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
changed, err := s.q.SetPeerSettingsBarHidden(ctx, sqlcgen.SetPeerSettingsBarHiddenParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
return false, fmt.Errorf("set peer settings bar hidden: %w", err)
}
return changed, nil
}
func (s *DialogStore) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
hidden, err := s.q.GetPeerSettingsBarHidden(ctx, sqlcgen.GetPeerSettingsBarHiddenParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("get peer settings bar hidden: %w", err)
}
return hidden, nil
}
func (s *DialogStore) ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) {
rows, err := s.q.ListDialogFolders(ctx, userID)
if err != nil {
return domain.DialogFolderList{}, fmt.Errorf("list dialog folders: %w", err)
}
tagsEnabled := false
if enabled, err := s.q.GetDialogFolderTags(ctx, userID); err == nil {
tagsEnabled = enabled
} else if !errors.Is(err, pgx.ErrNoRows) {
return domain.DialogFolderList{}, fmt.Errorf("get dialog folder tags: %w", err)
}
out := domain.DialogFolderList{
TagsEnabled: tagsEnabled,
Folders: make([]domain.DialogFolder, 0, len(rows)),
}
for _, row := range rows {
folder, err := decodeDialogFolder(row.FilterJson)
if err != nil {
return domain.DialogFolderList{}, fmt.Errorf("decode dialog folder %d: %w", row.FilterID, err)
}
folder.ID = int(row.FilterID)
folder.IsChatlist = row.IsChatlist
out.Folders = append(out.Folders, folder)
}
return out, nil
}
func (s *DialogStore) GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) {
row, err := s.q.GetDialogFolder(ctx, sqlcgen.GetDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folderID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.DialogFolder{}, false, nil
}
return domain.DialogFolder{}, false, fmt.Errorf("get dialog folder: %w", err)
}
folder, err := decodeDialogFolder(row.FilterJson)
if err != nil {
return domain.DialogFolder{}, false, fmt.Errorf("decode dialog folder: %w", err)
}
folder.ID = int(row.FilterID)
folder.IsChatlist = row.IsChatlist
return folder, true, nil
}
func (s *DialogStore) UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error {
data, err := json.Marshal(folder)
if err != nil {
return fmt.Errorf("marshal dialog folder: %w", err)
}
if err := s.q.UpsertDialogFolder(ctx, sqlcgen.UpsertDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folder.ID),
IsChatlist: folder.IsChatlist,
FilterJson: data,
}); err != nil {
return fmt.Errorf("upsert dialog folder: %w", err)
}
return nil
}
func (s *DialogStore) DeleteFolder(ctx context.Context, userID int64, folderID int) error {
if err := s.q.DeleteDialogFolder(ctx, sqlcgen.DeleteDialogFolderParams{
UserID: userID,
FilterID: pgInt32NonNegative(folderID),
}); err != nil {
return fmt.Errorf("delete dialog folder: %w", err)
}
return nil
}
func (s *DialogStore) ReorderFolders(ctx context.Context, userID int64, order []int) error {
if err := s.q.ReorderDialogFolders(ctx, sqlcgen.ReorderDialogFoldersParams{
UserID: userID,
FilterIds: int32s(order),
}); err != nil {
return fmt.Errorf("reorder dialog folders: %w", err)
}
return nil
}
func (s *DialogStore) SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error {
if err := s.q.SetDialogFolderTags(ctx, sqlcgen.SetDialogFolderTagsParams{
UserID: userID,
TagsEnabled: enabled,
}); err != nil {
return fmt.Errorf("set dialog folder tags: %w", err)
}
return nil
}
func (s *DialogStore) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
folderIDs := make([]int32, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, item := range peers {
if item.Peer.Type == "" || item.Peer.ID == 0 {
continue
}
if _, ok := seen[item.Peer]; ok {
continue
}
seen[item.Peer] = struct{}{}
peerTypes = append(peerTypes, string(item.Peer.Type))
peerIDs = append(peerIDs, item.Peer.ID)
folderIDs = append(folderIDs, pgInt32NonNegative(item.FolderID))
}
if len(peerTypes) == 0 {
return nil
}
if err := s.q.EditDialogPeerFolders(ctx, sqlcgen.EditDialogPeerFoldersParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
FolderIds: folderIDs,
}); err != nil {
return fmt.Errorf("edit dialog peer folders: %w", err)
}
return nil
}
type dialogFolderParams struct {
contacts bool
nonContacts bool
excludeArchived bool
excludeRead bool
includeTypes []string
includeIDs []int64
pinnedTypes []string
pinnedIDs []int64
excludeTypes []string
excludeIDs []int64
}
func dialogFolderQueryParams(folder *domain.DialogFolder) dialogFolderParams {
if folder == nil {
return dialogFolderParams{}
}
includeTypes, includeIDs := folderPeerArrays(folder.IncludePeers)
pinnedTypes, pinnedIDs := folderPeerArrays(folder.PinnedPeers)
excludeTypes, excludeIDs := folderPeerArrays(folder.ExcludePeers)
return dialogFolderParams{
contacts: folder.Contacts,
nonContacts: folder.NonContacts,
excludeArchived: folder.ExcludeArchived,
excludeRead: folder.ExcludeRead,
includeTypes: includeTypes,
includeIDs: includeIDs,
pinnedTypes: pinnedTypes,
pinnedIDs: pinnedIDs,
excludeTypes: excludeTypes,
excludeIDs: excludeIDs,
}
}
func folderPeerArrays(peers []domain.DialogFolderPeer) ([]string, []int64) {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, item := range peers {
peer := item.Peer
if peer.Type == "" || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
return peerTypes, peerIDs
}
func decodeDialogFolder(data string) (domain.DialogFolder, error) {
if data == "" {
return domain.DialogFolder{}, nil
}
var folder domain.DialogFolder
if err := json.Unmarshal([]byte(data), &folder); err != nil {
return domain.DialogFolder{}, err
}
return folder, nil
}
func decodeDialogDrafts(rows []string) ([]domain.DialogDraft, error) {
out := make([]domain.DialogDraft, 0, len(rows))
for _, row := range rows {
draft, err := decodeDialogDraft(row)
if err != nil {
return nil, err
}
out = append(out, draft)
}
return out, nil
}
func decodeDialogDraft(data string) (domain.DialogDraft, error) {
if data == "" {
return domain.DialogDraft{}, nil
}
var draft domain.DialogDraft
if err := json.Unmarshal([]byte(data), &draft); err != nil {
return domain.DialogDraft{}, fmt.Errorf("decode dialog draft: %w", err)
}
if draft.Entities == nil {
draft.Entities = []domain.MessageEntity{}
}
return draft, nil
}
func clampDialogDraftLimit(limit int) int {
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
return domain.MaxDialogDraftsPerUser
}
return limit
}
func peerArrays(peers []domain.Peer) ([]string, []int64) {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
return peerTypes, peerIDs
}
func dialogListHash(dialogs []domain.Dialog) int64 {
if len(dialogs) == 0 {
return 0
}
h := fnv.New64a()
var buf [47]byte
for _, d := range dialogs {
binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID))
binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID))
binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage))
binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate))
binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID))
binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID))
binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount))
binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions))
binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions))
if d.Pinned {
buf[40] = 1
} else {
buf[40] = 0
}
binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder))
if d.UnreadMark {
buf[45] = 1
} else {
buf[45] = 0
}
if d.PeerSettingsBarHidden {
buf[46] = 1
} else {
buf[46] = 0
}
_, _ = h.Write(buf[:])
}
return int64(h.Sum64())
}

View file

@ -0,0 +1,98 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestDialogFoldersRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 11, Phone: "+1666" + suffix + "01", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := users.Create(ctx, domain.User{AccessHash: 22, Phone: "+1666" + suffix + "02", FirstName: "Friend"})
if err != nil {
t.Fatalf("create friend: %v", err)
}
stranger, err := users.Create(ctx, domain.User{AccessHash: 33, Phone: "+1666" + suffix + "03", FirstName: "Stranger"})
if err != nil {
t.Fatalf("create stranger: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, stranger.ID})
})
if _, err := pool.Exec(ctx, `
INSERT INTO contacts (user_id, contact_user_id, mutual)
VALUES ($1, $2, true)
`, owner.ID, friend.ID); err != nil {
t.Fatalf("insert contact: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, unread_count)
VALUES
($1, 'user', $2, 0, 10, 1000, 0),
($1, 'user', $3, 1, 9, 900, 1)
`, owner.ID, friend.ID, stranger.ID); err != nil {
t.Fatalf("insert dialogs: %v", err)
}
dialogs := NewDialogStore(pool)
main, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 10})
if err != nil {
t.Fatalf("list main folder: %v", err)
}
if len(main.Dialogs) != 1 || main.Dialogs[0].Peer.ID != friend.ID {
t.Fatalf("main dialogs = %+v, want friend only", main.Dialogs)
}
archive, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10})
if err != nil {
t.Fatalf("list archive folder: %v", err)
}
if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != stranger.ID || archive.Dialogs[0].FolderID != domain.DialogArchiveFolderID {
t.Fatalf("archive dialogs = %+v, want archived stranger", archive.Dialogs)
}
folder := domain.DialogFolder{
ID: 2,
Title: "Work",
Contacts: true,
ExcludeArchived: true,
IncludePeers: []domain.DialogFolderPeer{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: stranger.ID}, AccessHash: stranger.AccessHash}},
}
if err := dialogs.UpsertFolder(ctx, owner.ID, folder); err != nil {
t.Fatalf("upsert folder: %v", err)
}
if err := dialogs.SetFolderTagsEnabled(ctx, owner.ID, true); err != nil {
t.Fatalf("set tags: %v", err)
}
folders, err := dialogs.ListFolders(ctx, owner.ID)
if err != nil {
t.Fatalf("list folders: %v", err)
}
if !folders.TagsEnabled || len(folders.Folders) != 1 || folders.Folders[0].Title != "Work" {
t.Fatalf("folders = %+v, want tags plus work folder", folders)
}
custom, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: 2, Folder: &folder, Limit: 10})
if err != nil {
t.Fatalf("list custom folder: %v", err)
}
if len(custom.Dialogs) != 1 || custom.Dialogs[0].Peer.ID != friend.ID {
t.Fatalf("custom dialogs = %+v, want contact only because archived explicit peer is excluded", custom.Dialogs)
}
if err := dialogs.EditPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: stranger.ID}, FolderID: domain.DialogMainFolderID}}); err != nil {
t.Fatalf("edit peer folders: %v", err)
}
archive, err = dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10})
if err != nil {
t.Fatalf("list archive after edit: %v", err)
}
if len(archive.Dialogs) != 0 {
t.Fatalf("archive after edit = %+v, want empty", archive.Dialogs)
}
}

View file

@ -0,0 +1,140 @@
package postgres
import (
"context"
"fmt"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// defaultDispatchLease 是 'dispatching' 行被判定租约过期、可被重新 claim 的默认时长。
// 与 docs/message-module.md 的 outbox 背压参数对应;生产由 config 注入覆盖。
const defaultDispatchLease = 30 * time.Second
// DispatchOutboxStore 用 PostgreSQL 实现 transactional outbox。
type DispatchOutboxStore struct {
q *sqlcgen.Queries
leaseSeconds int32
}
// DispatchOutboxOption 调整 DispatchOutboxStore 的 claim 行为。
type DispatchOutboxOption func(*DispatchOutboxStore)
// WithLeaseTimeout 设置租约超时;<=0 时保持默认。
func WithLeaseTimeout(d time.Duration) DispatchOutboxOption {
return func(s *DispatchOutboxStore) {
if d > 0 {
s.leaseSeconds = int32(d / time.Second)
if s.leaseSeconds < 1 {
s.leaseSeconds = 1
}
}
}
}
// NewDispatchOutboxStore 基于 pgx 连接池(或事务)创建 DispatchOutboxStore。
func NewDispatchOutboxStore(db sqlcgen.DBTX, opts ...DispatchOutboxOption) *DispatchOutboxStore {
s := &DispatchOutboxStore{
q: sqlcgen.New(db),
leaseSeconds: int32(defaultDispatchLease / time.Second),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
return s
}
func (s *DispatchOutboxStore) ClaimPending(ctx context.Context, limit int) ([]store.DispatchOutboxItem, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
rows, err := s.q.ClaimDispatchOutbox(ctx, sqlcgen.ClaimDispatchOutboxParams{
LeaseSeconds: s.leaseSeconds,
LimitCount: int32(limit),
})
if err != nil {
return nil, fmt.Errorf("claim dispatch outbox: %w", err)
}
out := make([]store.DispatchOutboxItem, 0, len(rows))
for _, row := range rows {
out = append(out, store.DispatchOutboxItem{
ID: row.ID,
TargetUserID: row.TargetUserID,
Pts: int(row.Pts),
EventType: domain.UpdateEventType(row.EventType),
ExcludeAuthKeyID: authKeyIDFromInt64(row.ExcludeAuthKeyID),
ExcludeSessionID: row.ExcludeSessionID,
Attempts: int(row.Attempts),
})
}
return out, nil
}
// MarkDeliveredBatch 一次性删除一批已投递的 outbox 行(方案 A投递成功即删取代逐条 MarkDelivered。
func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error {
if len(items) == 0 {
return nil
}
targetUserIDs := make([]int64, len(items))
ids := make([]int64, len(items))
for i, it := range items {
targetUserIDs[i] = it.TargetUserID
ids[i] = it.ID
}
if err := s.q.MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{
TargetUserIds: targetUserIDs,
Ids: ids,
}); err != nil {
return fmt.Errorf("mark dispatch delivered batch: %w", err)
}
return nil
}
func (s *DispatchOutboxStore) MarkDelivered(ctx context.Context, targetUserID, id int64) error {
if err := s.q.MarkDispatchDelivered(ctx, sqlcgen.MarkDispatchDeliveredParams{
TargetUserID: targetUserID,
ID: id,
}); err != nil {
return fmt.Errorf("mark dispatch delivered: %w", err)
}
return nil
}
func (s *DispatchOutboxStore) MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error {
if err := s.q.MarkDispatchFailed(ctx, sqlcgen.MarkDispatchFailedParams{
TargetUserID: targetUserID,
ID: id,
LastError: lastError,
}); err != nil {
return fmt.Errorf("mark dispatch failed: %w", err)
}
return nil
}
func (s *DispatchOutboxStore) DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) {
if olderThan <= 0 {
olderThan = 24 * time.Hour
}
if limit <= 0 {
limit = 10000
}
if limit > 100000 {
limit = 100000
}
deleted, err := s.q.DeleteFailedDispatchOutbox(ctx, sqlcgen.DeleteFailedDispatchOutboxParams{
OlderThanSeconds: int32(olderThan / time.Second),
LimitCount: int32(limit),
})
if err != nil {
return 0, fmt.Errorf("delete failed dispatch outbox: %w", err)
}
return int(deleted), nil
}

View file

@ -0,0 +1,141 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// HelpStore 用 PostgreSQL 实现 store.AppConfigStore 和 store.CountryStore。
type HelpStore struct {
q *sqlcgen.Queries
}
// NewHelpStore 基于 pgx 连接池(或事务)创建 HelpStore。
func NewHelpStore(db sqlcgen.DBTX) *HelpStore {
return &HelpStore{q: sqlcgen.New(db)}
}
func (s *HelpStore) GetAppConfig(ctx context.Context, client string) (domain.AppConfig, bool, error) {
row, err := s.q.GetAppConfig(ctx, client)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AppConfig{Client: client, JSON: []byte("{}")}, false, nil
}
return domain.AppConfig{}, false, fmt.Errorf("get app config: %w", err)
}
return domain.AppConfig{
Client: row.Client,
Hash: int(row.Hash),
JSON: []byte(row.ConfigJson),
}, true, nil
}
func (s *HelpStore) UpsertAppConfig(ctx context.Context, cfg domain.AppConfig) error {
if len(cfg.JSON) == 0 {
cfg.JSON = []byte("{}")
}
if err := s.q.UpsertAppConfig(ctx, sqlcgen.UpsertAppConfigParams{
Client: cfg.Client,
Hash: int32(cfg.Hash),
ConfigJson: cfg.JSON,
}); err != nil {
return fmt.Errorf("upsert app config: %w", err)
}
return nil
}
func (s *HelpStore) ListCountries(ctx context.Context, _ string) (domain.CountriesList, error) {
rows, err := s.q.ListCountries(ctx)
if err != nil {
return domain.CountriesList{}, fmt.Errorf("list countries: %w", err)
}
byISO := make(map[string]int)
out := domain.CountriesList{}
for _, row := range rows {
idx, ok := byISO[row.Iso2]
if !ok {
idx = len(out.Countries)
byISO[row.Iso2] = idx
out.Countries = append(out.Countries, domain.Country{
ISO2: row.Iso2,
DefaultName: row.DefaultName,
Name: row.Name,
Hidden: row.Hidden,
})
}
out.Countries[idx].CountryCodes = append(out.Countries[idx].CountryCodes, domain.CountryCode{
CountryCode: row.CountryCode,
Prefixes: append([]string(nil), row.Prefixes...),
Patterns: append([]string(nil), row.Patterns...),
})
}
out.Hash = countriesHash(out.Countries)
return out, nil
}
func (s *HelpStore) UpsertCountries(ctx context.Context, countries []domain.Country) error {
for i, country := range countries {
if err := s.q.UpsertCountry(ctx, sqlcgen.UpsertCountryParams{
Iso2: country.ISO2,
DefaultName: country.DefaultName,
Name: country.Name,
Hidden: country.Hidden,
OrderIndex: int32(i + 1),
}); err != nil {
return fmt.Errorf("upsert country %q: %w", country.ISO2, err)
}
for j, code := range country.CountryCodes {
if err := s.q.UpsertCountryCode(ctx, sqlcgen.UpsertCountryCodeParams{
Iso2: country.ISO2,
CountryCode: code.CountryCode,
Prefixes: code.Prefixes,
Patterns: code.Patterns,
OrderIndex: int32(j + 1),
}); err != nil {
return fmt.Errorf("upsert country code %q/%q: %w", country.ISO2, code.CountryCode, err)
}
}
}
return nil
}
func countriesHash(countries []domain.Country) int {
if len(countries) == 0 {
return 0
}
h := fnv.New32a()
var buf [4]byte
for _, country := range countries {
_, _ = h.Write([]byte(country.ISO2))
_, _ = h.Write([]byte(country.DefaultName))
_, _ = h.Write([]byte(country.Name))
if country.Hidden {
buf[0] = 1
} else {
buf[0] = 0
}
_, _ = h.Write(buf[:1])
for _, code := range country.CountryCodes {
_, _ = h.Write([]byte(code.CountryCode))
binary.LittleEndian.PutUint32(buf[:], uint32(len(code.Prefixes)))
_, _ = h.Write(buf[:])
for _, prefix := range code.Prefixes {
_, _ = h.Write([]byte(prefix))
}
binary.LittleEndian.PutUint32(buf[:], uint32(len(code.Patterns)))
_, _ = h.Write(buf[:])
for _, pattern := range code.Patterns {
_, _ = h.Write([]byte(pattern))
}
}
}
return int(h.Sum32())
}

View file

@ -0,0 +1,169 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// LangPackStore 用 PostgreSQL 实现 store.LangPackStore。
type LangPackStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewLangPackStore 基于 pgx 连接池(或事务)创建 LangPackStore。
func NewLangPackStore(db sqlcgen.DBTX) *LangPackStore {
return &LangPackStore{db: db, q: sqlcgen.New(db)}
}
func (s *LangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
meta, found, err := s.meta(ctx, langPack, langCode)
if err != nil || !found {
return meta, err
}
meta.FromVersion = fromVersion
if meta.Version <= fromVersion {
return meta, nil
}
rows, err := s.q.ListLangPackStrings(ctx, sqlcgen.ListLangPackStringsParams{
LangPack: langPack,
LangCode: langCode,
})
if err != nil {
return domain.LangPack{}, fmt.Errorf("list lang pack strings: %w", err)
}
meta.Strings = make([]domain.LangPackString, 0, len(rows))
for _, row := range rows {
meta.Strings = append(meta.Strings, langPackStringFromListRow(row))
}
return meta, nil
}
func (s *LangPackStore) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
meta, found, err := s.meta(ctx, langPack, langCode)
if err != nil || !found {
return meta, err
}
if len(keys) == 0 {
return s.GetPack(ctx, langPack, langCode, 0)
}
rows, err := s.q.GetLangPackStringsByKeys(ctx, sqlcgen.GetLangPackStringsByKeysParams{
LangPack: langPack,
LangCode: langCode,
Keys: keys,
})
if err != nil {
return domain.LangPack{}, fmt.Errorf("get lang pack strings: %w", err)
}
meta.Strings = make([]domain.LangPackString, 0, len(rows))
for _, row := range rows {
meta.Strings = append(meta.Strings, langPackStringFromKeysRow(row))
}
return meta, nil
}
func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) error {
if txer, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
}); ok {
tx, err := txer.Begin(ctx)
if err != nil {
return fmt.Errorf("begin lang pack upsert: %w", err)
}
q := s.q.WithTx(tx)
if err := upsertPackWith(ctx, q, pack); err != nil {
_ = tx.Rollback(ctx)
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit lang pack upsert: %w", err)
}
return nil
}
return upsertPackWith(ctx, s.q, pack)
}
func upsertPackWith(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPack) error {
if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{
LangPack: pack.LangPack,
LangCode: pack.LangCode,
Version: int32(pack.Version),
StringsCount: int32(len(pack.Strings)),
}); err != nil {
return fmt.Errorf("upsert lang pack meta: %w", err)
}
for _, item := range pack.Strings {
if err := q.UpsertLangPackString(ctx, sqlcgen.UpsertLangPackStringParams{
LangPack: pack.LangPack,
LangCode: pack.LangCode,
Key: item.Key,
Version: int32(pack.Version),
Pluralized: item.Pluralized,
Value: item.Value,
ZeroValue: item.ZeroValue,
OneValue: item.OneValue,
TwoValue: item.TwoValue,
FewValue: item.FewValue,
ManyValue: item.ManyValue,
OtherValue: item.OtherValue,
Deleted: item.Deleted,
}); err != nil {
return fmt.Errorf("upsert lang pack string %q: %w", item.Key, err)
}
}
return nil
}
func (s *LangPackStore) meta(ctx context.Context, langPack, langCode string) (domain.LangPack, bool, error) {
row, err := s.q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{
LangPack: langPack,
LangCode: langCode,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LangPack{LangPack: langPack, LangCode: langCode}, false, nil
}
return domain.LangPack{}, false, fmt.Errorf("get lang pack meta: %w", err)
}
return domain.LangPack{
LangPack: row.LangPack,
LangCode: row.LangCode,
Version: int(row.Version),
}, true, nil
}
func langPackStringFromListRow(row sqlcgen.ListLangPackStringsRow) domain.LangPackString {
return domain.LangPackString{
Key: row.Key,
Value: row.Value,
Pluralized: row.Pluralized,
ZeroValue: row.ZeroValue,
OneValue: row.OneValue,
TwoValue: row.TwoValue,
FewValue: row.FewValue,
ManyValue: row.ManyValue,
OtherValue: row.OtherValue,
Deleted: row.Deleted,
}
}
func langPackStringFromKeysRow(row sqlcgen.GetLangPackStringsByKeysRow) domain.LangPackString {
return domain.LangPackString{
Key: row.Key,
Value: row.Value,
Pluralized: row.Pluralized,
ZeroValue: row.ZeroValue,
OneValue: row.OneValue,
TwoValue: row.TwoValue,
FewValue: row.FewValue,
ManyValue: row.ManyValue,
OtherValue: row.OtherValue,
Deleted: row.Deleted,
}
}

View file

@ -0,0 +1,509 @@
package postgres
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// MediaStore 用 PostgreSQL 实现 store.MediaStore媒体元数据 + blob 索引)。
type MediaStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewMediaStore 基于 pgx 连接池(或事务)创建 MediaStore。
func NewMediaStore(db sqlcgen.DBTX) *MediaStore {
return &MediaStore{db: db, q: sqlcgen.New(db)}
}
// bytesOrEmpty 把 nil []byte 归一为空切片,避免落入 NOT NULL bytea 列时被当作 NULL。
func bytesOrEmpty(b []byte) []byte {
if b == nil {
return []byte{}
}
return b
}
var _ store.MediaStore = (*MediaStore)(nil)
// ---- 上传分片 ----
func (s *MediaStore) SaveFilePart(ctx context.Context, part domain.UploadPart) error {
return s.q.SaveUploadPart(ctx, sqlcgen.SaveUploadPartParams{
OwnerUserID: part.OwnerUserID,
FileID: part.FileID,
Part: int32(part.Part),
TotalParts: int32(part.TotalParts),
IsBig: part.Big,
Bytes: part.Bytes,
})
}
func (s *MediaStore) LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) {
rows, err := s.q.ListUploadParts(ctx, sqlcgen.ListUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID})
if err != nil {
return nil, err
}
out := make([]domain.UploadPart, 0, len(rows))
for _, r := range rows {
out = append(out, domain.UploadPart{
OwnerUserID: ownerUserID,
FileID: fileID,
Part: int(r.Part),
TotalParts: int(r.TotalParts),
Big: r.IsBig,
Bytes: r.Bytes,
})
}
return out, nil
}
func (s *MediaStore) DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error {
return s.q.DeleteUploadParts(ctx, sqlcgen.DeleteUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID})
}
// ---- blob 索引 ----
func (s *MediaStore) PutFileBlob(ctx context.Context, blob domain.FileBlob) error {
backend := string(blob.Backend)
if backend == "" {
backend = string(domain.MediaBackendLocalFS)
}
sha := blob.SHA256
if sha == nil {
sha = []byte{} // 列为 NOT NULLnil []byte 会被 pgx 当作 NULL。
}
return s.q.PutFileBlob(ctx, sqlcgen.PutFileBlobParams{
LocationKey: blob.LocationKey,
Backend: backend,
ObjectKey: blob.ObjectKey,
Size: blob.Size,
Sha256: sha,
MimeType: blob.MimeType,
})
}
func (s *MediaStore) GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) {
row, err := s.q.GetFileBlob(ctx, locationKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.FileBlob{}, false, nil
}
return domain.FileBlob{}, false, err
}
return domain.FileBlob{
LocationKey: row.LocationKey,
Backend: domain.MediaBackend(row.Backend),
ObjectKey: row.ObjectKey,
Size: row.Size,
SHA256: row.Sha256,
MimeType: row.MimeType,
}, true, nil
}
// ---- 文档 ----
func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error {
attrs, err := jsonArrayOrEmpty(doc.Attributes)
if err != nil {
return err
}
thumbs, err := jsonArrayOrEmpty(doc.Thumbs)
if err != nil {
return err
}
return s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{
ID: doc.ID,
AccessHash: doc.AccessHash,
FileReference: bytesOrEmpty(doc.FileReference),
Date: int32(doc.Date),
MimeType: doc.MimeType,
Size: doc.Size,
DcID: int32(doc.DCID),
AttributesJson: attrs,
ThumbsJson: thumbs,
})
}
func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
row, err := s.q.GetDocument(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Document{}, false, nil
}
return domain.Document{}, false, err
}
doc, err := documentFromRow(row)
if err != nil {
return domain.Document{}, false, err
}
return doc, true, nil
}
func (s *MediaStore) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := s.q.GetDocuments(ctx, ids)
if err != nil {
return nil, err
}
out := make([]domain.Document, 0, len(rows))
for _, r := range rows {
doc, err := documentFromRow(sqlcgen.GetDocumentRow(r))
if err != nil {
return nil, err
}
out = append(out, doc)
}
return out, nil
}
func documentFromRow(row sqlcgen.GetDocumentRow) (domain.Document, error) {
attrs, err := decodeDocumentAttributes(row.AttributesJson)
if err != nil {
return domain.Document{}, err
}
thumbs, err := decodePhotoSizes(row.ThumbsJson)
if err != nil {
return domain.Document{}, err
}
return domain.Document{
ID: row.ID,
AccessHash: row.AccessHash,
FileReference: row.FileReference,
Date: int(row.Date),
MimeType: row.MimeType,
Size: row.Size,
DCID: int(row.DcID),
Attributes: attrs,
Thumbs: thumbs,
}, nil
}
// ---- 照片 ----
func (s *MediaStore) PutPhoto(ctx context.Context, photo domain.Photo) error {
sizes, err := jsonArrayOrEmpty(photo.Sizes)
if err != nil {
return err
}
return s.q.PutPhoto(ctx, sqlcgen.PutPhotoParams{
ID: photo.ID,
AccessHash: photo.AccessHash,
FileReference: bytesOrEmpty(photo.FileReference),
Date: int32(photo.Date),
DcID: int32(photo.DCID),
HasStickers: photo.HasStickers,
SizesJson: sizes,
})
}
func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) {
row, err := s.q.GetPhoto(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Photo{}, false, nil
}
return domain.Photo{}, false, err
}
sizes, err := decodePhotoSizes(row.SizesJson)
if err != nil {
return domain.Photo{}, false, err
}
return domain.Photo{
ID: row.ID,
AccessHash: row.AccessHash,
FileReference: row.FileReference,
Date: int(row.Date),
DCID: int(row.DcID),
HasStickers: row.HasStickers,
Sizes: sizes,
}, true, nil
}
// ---- 贴纸集 ----
func (s *MediaStore) PutStickerSet(ctx context.Context, set domain.StickerSet) error {
thumbs, err := jsonArrayOrEmpty(set.Thumbs)
if err != nil {
return err
}
docIDs, err := jsonArrayOrEmpty(set.DocumentIDs)
if err != nil {
return err
}
packs, err := jsonArrayOrEmpty(set.Packs)
if err != nil {
return err
}
kind := string(set.Kind)
if kind == "" {
kind = string(domain.StickerSetKindStickers)
}
return s.q.PutStickerSet(ctx, sqlcgen.PutStickerSetParams{
ID: set.ID,
AccessHash: set.AccessHash,
ShortName: set.ShortName,
Title: set.Title,
Count: int32(set.Count),
Hash: int32(set.Hash),
SetKind: kind,
Official: set.Official,
Animated: set.Animated,
Videos: set.Videos,
Emojis: set.Emojis,
Masks: set.Masks,
Installed: set.Installed,
Archived: set.Archived,
InstalledDate: int32(set.InstalledDate),
ThumbDocumentID: set.ThumbDocumentID,
ThumbsJson: thumbs,
ThumbDcID: int32(set.ThumbDCID),
ThumbVersion: int32(set.ThumbVersion),
DocumentIdsJson: docIDs,
PacksJson: packs,
SortOrder: int32(set.SortOrder),
SystemKey: set.SystemKey,
})
}
func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return stickerSetFromRow(row)
}
func (s *MediaStore) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetByShortName(ctx, shortName)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row))
}
func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) {
row, err := s.q.GetStickerSetBySystemKey(ctx, systemKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StickerSet{}, false, nil
}
return domain.StickerSet{}, false, err
}
return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row))
}
func (s *MediaStore) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
rows, err := s.q.ListStickerSetsByKind(ctx, string(kind))
if err != nil {
return nil, err
}
out := make([]domain.StickerSet, 0, len(rows))
for _, r := range rows {
set, _, err := stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(r))
if err != nil {
return nil, err
}
out = append(out, set)
}
return out, nil
}
func (s *MediaStore) CountStickerSets(ctx context.Context) (int, error) {
n, err := s.q.CountStickerSets(ctx)
return int(n), err
}
func stickerSetFromRow(row sqlcgen.GetStickerSetByIDRow) (domain.StickerSet, bool, error) {
thumbs, err := decodePhotoSizes(row.ThumbsJson)
if err != nil {
return domain.StickerSet{}, false, err
}
docIDs, err := decodeInt64Slice(row.DocumentIdsJson)
if err != nil {
return domain.StickerSet{}, false, err
}
packs, err := decodeStickerPacks(row.PacksJson)
if err != nil {
return domain.StickerSet{}, false, err
}
return domain.StickerSet{
ID: row.ID,
AccessHash: row.AccessHash,
ShortName: row.ShortName,
Title: row.Title,
Count: int(row.Count),
Hash: int(row.Hash),
Kind: domain.StickerSetKind(row.SetKind),
Official: row.Official,
Animated: row.Animated,
Videos: row.Videos,
Emojis: row.Emojis,
Masks: row.Masks,
Installed: row.Installed,
Archived: row.Archived,
InstalledDate: int(row.InstalledDate),
ThumbDocumentID: row.ThumbDocumentID,
Thumbs: thumbs,
ThumbDCID: int(row.ThumbDcID),
ThumbVersion: int(row.ThumbVersion),
DocumentIDs: docIDs,
Packs: packs,
SortOrder: int(row.SortOrder),
SystemKey: row.SystemKey,
}, true, nil
}
// ---- 可用 reaction ----
func (s *MediaStore) PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error {
return s.q.PutAvailableReaction(ctx, sqlcgen.PutAvailableReactionParams{
Reaction: r.Reaction,
Title: r.Title,
Inactive: r.Inactive,
Premium: r.Premium,
StaticIconID: r.StaticIconID,
AppearAnimationID: r.AppearAnimationID,
SelectAnimationID: r.SelectAnimationID,
ActivateAnimationID: r.ActivateAnimationID,
EffectAnimationID: r.EffectAnimationID,
AroundAnimationID: r.AroundAnimationID,
CenterIconID: r.CenterIconID,
SortOrder: int32(r.Order),
})
}
func (s *MediaStore) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) {
rows, err := s.q.ListAvailableReactions(ctx)
if err != nil {
return nil, err
}
out := make([]domain.AvailableReaction, 0, len(rows))
for _, r := range rows {
out = append(out, domain.AvailableReaction{
Reaction: r.Reaction,
Title: r.Title,
Inactive: r.Inactive,
Premium: r.Premium,
StaticIconID: r.StaticIconID,
AppearAnimationID: r.AppearAnimationID,
SelectAnimationID: r.SelectAnimationID,
ActivateAnimationID: r.ActivateAnimationID,
EffectAnimationID: r.EffectAnimationID,
AroundAnimationID: r.AroundAnimationID,
CenterIconID: r.CenterIconID,
Order: int(r.SortOrder),
})
}
return out, nil
}
func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) {
n, err := s.q.CountAvailableReactions(ctx)
return int(n), err
}
// ---- 头像历史 ----
func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error {
next, err := s.q.NextProfilePhotoOrder(ctx, sqlcgen.NextProfilePhotoOrderParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
if err != nil {
return err
}
return s.q.AddProfilePhoto(ctx, sqlcgen.AddProfilePhotoParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
PhotoID: photoID,
Date: int32(date),
SortOrder: next + 1,
})
}
func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) {
id, err := s.q.CurrentProfilePhoto(ctx, sqlcgen.CurrentProfilePhotoParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
return 0, false, err
}
return id, true, nil
}
func (s *MediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
if len(ownerIDs) == 0 {
return map[int64]domain.ProfilePhotoRef{}, nil
}
rows, err := s.q.CurrentProfilePhotosForOwners(ctx, sqlcgen.CurrentProfilePhotosForOwnersParams{
OwnerPeerType: string(ownerType),
OwnerIds: ownerIDs,
})
if err != nil {
return nil, err
}
out := make(map[int64]domain.ProfilePhotoRef, len(rows))
for _, r := range rows {
sizes, err := decodePhotoSizes(r.SizesJson)
if err != nil {
return nil, err
}
out[r.OwnerPeerID] = domain.ProfilePhotoRef{
PhotoID: r.PhotoID,
DCID: int(r.DcID),
Stripped: domain.StrippedFromSizes(sizes),
}
}
return out, nil
}
func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) {
ids, err := s.q.ListProfilePhotos(ctx, sqlcgen.ListProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
MaxID: maxID,
OffsetCount: int32(offset),
LimitCount: int32(limit),
})
if err != nil {
return nil, 0, err
}
total, err := s.q.CountProfilePhotos(ctx, sqlcgen.CountProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
if err != nil {
return nil, 0, err
}
return ids, int(total), nil
}
func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) {
if len(photoIDs) == 0 {
return nil, nil
}
return s.q.DeactivateProfilePhotos(ctx, sqlcgen.DeactivateProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
PhotoIds: photoIDs,
})
}

View file

@ -0,0 +1,88 @@
package postgres
import (
"encoding/json"
"telesrv/internal/domain"
)
// 媒体相关 JSON 编解码domain 值对象 ↔ JSONB 列。domain.* 带 json tag可直接 marshal。
// jsonArrayOrEmpty 把切片序列化为 JSONBnil 序列化为 "[]"(列为 NOT NULL DEFAULT '[]')。
func jsonArrayOrEmpty(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
if string(b) == "null" {
return []byte("[]"), nil
}
return b, nil
}
// encodeMessageMedia 把消息媒体快照序列化为 JSONB无媒体序列化为 "{}"。
func encodeMessageMedia(m *domain.MessageMedia) ([]byte, error) {
if m.IsZero() {
return []byte("{}"), nil
}
return json.Marshal(m)
}
// decodeMessageMedia 把消息行的 media JSONB 文本还原为 *MessageMedia空载荷返回 nil。
func decodeMessageMedia(s string) (*domain.MessageMedia, error) {
if s == "" || s == "{}" || s == "null" {
return nil, nil
}
var m domain.MessageMedia
if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil, err
}
if m.IsZero() {
return nil, nil
}
return &m, nil
}
func decodePhotoSizes(s string) ([]domain.PhotoSize, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []domain.PhotoSize
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}
func decodeDocumentAttributes(s string) ([]domain.DocumentAttribute, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []domain.DocumentAttribute
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}
func decodeInt64Slice(s string) ([]int64, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []int64
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}
func decodeStickerPacks(s string) ([]domain.StickerPack, error) {
if s == "" || s == "[]" || s == "null" {
return nil, nil
}
var out []domain.StickerPack
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}

View file

@ -0,0 +1,210 @@
package postgres
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// TestMediaStoreRoundTrip 验证 MediaStore 各表的写读往返(含 nil bytea 归一、JSONB attributes/sizes、
// 头像历史 current/list/delete、上传分片。直接证明媒体元数据落 PG 后可原样读回。
func TestMediaStoreRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewMediaStore(pool)
const docID = int64(9100000000000000001)
const photoID = int64(9100000000000000002)
const setID = int64(9100000000000000003)
const ownerID = int64(9100000000000000099)
const reactionEmoji = "\U0001f9ea"
cleanupMediaStoreRoundTripRows(t, ctx, pool)
t.Cleanup(func() {
cleanupMediaStoreRoundTripRows(t, context.Background(), pool)
})
// ---- file blobnil sha256 应被归一为空,不报 NOT NULL----
if err := s.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:9100000000000000001",
ObjectKey: "ab/cd/abcdef",
Size: 1234,
MimeType: "application/x-tgsticker",
}); err != nil {
t.Fatalf("put file blob (nil sha256): %v", err)
}
blob, ok, err := s.GetFileBlob(ctx, "doc:9100000000000000001")
if err != nil || !ok {
t.Fatalf("get file blob: ok=%v err=%v", ok, err)
}
if blob.ObjectKey != "ab/cd/abcdef" || blob.Size != 1234 || blob.Backend != domain.MediaBackendLocalFS {
t.Fatalf("file blob mismatch: %+v", blob)
}
// ---- document含 sticker 属性 + thumbs JSONBnil file_reference 路径)----
doc := domain.Document{
ID: docID,
AccessHash: 77,
DCID: 2,
MimeType: "application/x-tgsticker",
Size: 2048,
Attributes: []domain.DocumentAttribute{
{Kind: domain.DocAttrImageSize, W: 512, H: 512},
{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: setID, StickerSetAccessHash: 5},
},
Thumbs: []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1, 2, 3}}},
}
if err := s.PutDocument(ctx, doc); err != nil {
t.Fatalf("put document: %v", err)
}
got, ok, err := s.GetDocument(ctx, docID)
if err != nil || !ok {
t.Fatalf("get document: ok=%v err=%v", ok, err)
}
if got.DCID != 2 || len(got.Attributes) != 2 || len(got.Thumbs) != 1 {
t.Fatalf("document mismatch: %+v", got)
}
if id, hash, ok := got.StickerSetRef(); !ok || id != setID || hash != 5 {
t.Fatalf("document sticker set ref = (%d,%d,%v)", id, hash, ok)
}
docs, err := s.GetDocuments(ctx, []int64{docID})
if err != nil || len(docs) != 1 {
t.Fatalf("get documents: n=%d err=%v", len(docs), err)
}
// ---- photosizes JSONB----
photo := domain.Photo{
ID: photoID,
AccessHash: 88,
DCID: 2,
Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600, Size: 4096}},
}
if err := s.PutPhoto(ctx, photo); err != nil {
t.Fatalf("put photo: %v", err)
}
gotPhoto, ok, err := s.GetPhoto(ctx, photoID)
if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" {
t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto)
}
// ---- sticker set ----
set := domain.StickerSet{
ID: setID,
AccessHash: 5,
ShortName: "telesrv_test_set_9100000000000000003",
Title: "Test Set",
Count: 1,
Kind: domain.StickerSetKindStickers,
Animated: true,
Installed: true,
DocumentIDs: []int64{docID},
Packs: []domain.StickerPack{{Emoticon: "\U0001f600", DocumentIDs: []int64{docID}}},
SystemKey: "test_system_9100000000000000003",
}
if err := s.PutStickerSet(ctx, set); err != nil {
t.Fatalf("put sticker set: %v", err)
}
byID, ok, err := s.GetStickerSetByID(ctx, setID)
if err != nil || !ok || len(byID.DocumentIDs) != 1 || len(byID.Packs) != 1 {
t.Fatalf("get sticker set by id: ok=%v err=%v set=%+v", ok, err, byID)
}
if byShort, ok, _ := s.GetStickerSetByShortName(ctx, set.ShortName); !ok || byShort.ID != setID {
t.Fatalf("get sticker set by short name failed: ok=%v", ok)
}
if bySys, ok, _ := s.GetStickerSetBySystemKey(ctx, set.SystemKey); !ok || bySys.ID != setID {
t.Fatalf("get sticker set by system key failed: ok=%v", ok)
}
// ---- available reaction ----
if err := s.PutAvailableReaction(ctx, domain.AvailableReaction{
Reaction: reactionEmoji, Title: "Test", StaticIconID: docID, SelectAnimationID: docID, Order: 9999,
}); err != nil {
t.Fatalf("put available reaction: %v", err)
}
reactions, err := s.ListAvailableReactions(ctx)
if err != nil {
t.Fatalf("list available reactions: %v", err)
}
foundReaction := false
for _, r := range reactions {
if r.Reaction == reactionEmoji {
foundReaction = true
if r.StaticIconID != docID {
t.Fatalf("reaction static icon id = %d", r.StaticIconID)
}
}
}
if !foundReaction {
t.Fatal("inserted reaction not found in list")
}
// ---- profile photo 历史 ----
if err := s.AddProfilePhoto(ctx, domain.PeerTypeUser, ownerID, photoID, 1700000000); err != nil {
t.Fatalf("add profile photo: %v", err)
}
cur, ok, err := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID)
if err != nil || !ok || cur != photoID {
t.Fatalf("current profile photo = (%d,%v,%v)", cur, ok, err)
}
refs, err := s.CurrentProfilePhotos(ctx, domain.PeerTypeUser, []int64{ownerID})
if err != nil || refs[ownerID].PhotoID != photoID || refs[ownerID].DCID != 2 {
t.Fatalf("current profile photos batch = %+v err=%v", refs, err)
}
ids, total, err := s.ListProfilePhotos(ctx, domain.PeerTypeUser, ownerID, 0, 10, 0)
if err != nil || total < 1 || len(ids) < 1 {
t.Fatalf("list profile photos: ids=%v total=%d err=%v", ids, total, err)
}
deleted, err := s.DeleteProfilePhotos(ctx, domain.PeerTypeUser, ownerID, []int64{photoID})
if err != nil || len(deleted) != 1 {
t.Fatalf("delete profile photos: deleted=%v err=%v", deleted, err)
}
if _, ok, _ := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID); ok {
t.Fatal("profile photo still current after delete")
}
// ---- upload parts ----
if err := s.SaveFilePart(ctx, domain.UploadPart{OwnerUserID: ownerID, FileID: 555, Part: 0, Bytes: []byte("hello")}); err != nil {
t.Fatalf("save file part: %v", err)
}
parts, err := s.LoadFileParts(ctx, ownerID, 555)
if err != nil || len(parts) != 1 || string(parts[0].Bytes) != "hello" {
t.Fatalf("load file parts: parts=%+v err=%v", parts, err)
}
if err := s.DeleteFileParts(ctx, ownerID, 555); err != nil {
t.Fatalf("delete file parts: %v", err)
}
if parts, _ := s.LoadFileParts(ctx, ownerID, 555); len(parts) != 0 {
t.Fatal("file parts not cleared")
}
}
func cleanupMediaStoreRoundTripRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
const docID = int64(9100000000000000001)
const photoID = int64(9100000000000000002)
const setID = int64(9100000000000000003)
const ownerID = int64(9100000000000000099)
const reactionEmoji = "\U0001f9ea"
statements := []struct {
sql string
args []any
}{
{sql: "DELETE FROM upload_parts WHERE owner_user_id = $1 AND file_id = 555", args: []any{ownerID}},
{sql: "DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND photo_id = $2", args: []any{ownerID, photoID}},
{sql: "DELETE FROM available_reactions WHERE reaction IN ($1, 'telesrv-test-😀')", args: []any{reactionEmoji}},
{sql: "DELETE FROM sticker_sets WHERE id = $1 OR short_name = 'telesrv_test_set_9100000000000000003' OR system_key = 'test_system_9100000000000000003'", args: []any{setID}},
{sql: "DELETE FROM file_blobs WHERE location_key = 'doc:9100000000000000001'"},
{sql: "DELETE FROM documents WHERE id = $1", args: []any{docID}},
{sql: "DELETE FROM photos WHERE id = $1", args: []any{photoID}},
}
for _, stmt := range statements {
if _, err := pool.Exec(ctx, stmt.sql, stmt.args...); err != nil {
t.Fatalf("cleanup media store round trip rows: %v", err)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
package postgres
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/domain"
)
// TestSendPrivateTextConcurrentNoPtsGap 是「Redis 分配移出事务」的正确性核心证明:
// N 条消息并发 sender→recipient 发送,全部成功提交后,接收方账号事件 pts 必须严格连续 1..N
// (无空洞、无重复、无丢失),且 MaxContiguousPts == N。
// 用 perUserCounterAllocatormutex 单调自增,忠实复刻 Redis INCR 原子性)驱动分配器,
// 验证事务外分配 + 连续 pts 兜底在高并发下不丢消息,不引入 Redis 依赖。
func TestSendPrivateTextConcurrentNoPtsGap(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 51,
Phone: "+1999" + suffix + "01",
FirstName: "ConcSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 52,
Phone: "+1999" + suffix + "02",
FirstName: "ConcRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
ids := []int64{sender.ID, recipient.ID}
t.Cleanup(func() {
// 按 FK 依赖序清理子表message_boxes.from_user_id 为 RESTRICT再删用户。
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
})
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{}))
const n = 200
base := time.Now().UnixNano()
date := int(time.Now().Unix())
errs := make([]error, n)
recipPts := make([]int, n)
sem := make(chan struct{}, 32)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: base + int64(i),
Message: "concurrent body",
Date: date,
})
errs[i] = err
recipPts[i] = res.RecipientMessage.Pts
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
// 进程内检测 allocator 是否给接收方分配了重复 pts。
seen := map[int]int{}
for i := 0; i < n; i++ {
if prev, ok := seen[recipPts[i]]; ok {
t.Errorf("recipient pts %d 被发送 #%d 和 #%d 同时分配allocator 并发重复)", recipPts[i], prev, i)
}
seen[recipPts[i]] = i
}
// 接收方应有 n 条事件pts 连续 1..n无空洞无重复无丢失
events := NewUpdateEventStore(pool)
got, err := events.ListAfter(ctx, recipient.ID, 0, n+10)
if err != nil {
t.Fatalf("list recipient events: %v", err)
}
if len(got) != n {
t.Fatalf("recipient events = %d, want %d无丢失无重复", len(got), n)
}
for i, ev := range got {
if ev.Pts != i+1 {
t.Fatalf("recipient event[%d].Pts = %d, want %dpts 必须连续无洞)", i, ev.Pts, i+1)
}
}
contig, err := events.MaxContiguousPts(ctx, recipient.ID)
if err != nil {
t.Fatalf("MaxContiguousPts: %v", err)
}
if contig != n {
t.Fatalf("MaxContiguousPts(recipient) = %d, want %d", contig, n)
}
}

View file

@ -0,0 +1,119 @@
package postgres
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/domain"
)
// TestMessageStoreBidirectionalConcurrencyNoDeadlock 验证 watermark/dialog 死锁修复advisory lock
//
// 背景send/read/edit/delete 在一个事务内会按业务顺序锁住收发双方的 user_update_watermarks 与
// channel/private dialog 行。A→B 与 B→A 反向并发时,两个事务以相反顺序竞争同一对用户的这些行
// watermark[A]→watermark[B] vs watermark[B]→watermark[A]dialog(A,B)→dialog(B,A) vs 反向),
// 形成 AB-BA 死锁——PostgreSQL 会检测并 abort 其中一个事务SQLSTATE 40P01表现为操作返回错误。
//
// 修复:每个写事务在任何行锁之前,用事务级 advisory lock 按 user_id 升序锁住涉及的用户
// lockUsersForUpdate把同一对用户的并发写事务串行化。advisory 与行锁处于独立锁空间且升序获取,
// 既不与行锁交叉成新死锁,也消除了 watermark 与 dialog 的 AB-BA。本测试在高并发反向负载下应
// 全部成功、零错误;若死锁回归,会以 40P01 错误形式被捕获。
func TestMessageStoreBidirectionalConcurrencyNoDeadlock(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
a, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1997" + suffix + "01", FirstName: "BidiA"})
if err != nil {
t.Fatalf("create a: %v", err)
}
b, err := users.Create(ctx, domain.User{AccessHash: 72, Phone: "+1997" + suffix + "02", FirstName: "BidiB"})
if err != nil {
t.Fatalf("create b: %v", err)
}
ids := []int64{a.ID, b.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_watermarks WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
})
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{}))
date := int(time.Now().Unix())
var ridMu sync.Mutex
rid := time.Now().UnixNano()
nextRID := func() int64 {
ridMu.Lock()
defer ridMu.Unlock()
rid++
return rid
}
// 预热:双向各发若干条,建立双向 dialog 与未读历史,使后续 read 真正推进 watermark命中行锁
for i := 0; i < 4; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: nextRID(), Message: "warmup a->b", Date: date}); err != nil {
t.Fatalf("warmup a->b: %v", err)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: nextRID(), Message: "warmup b->a", Date: date}); err != nil {
t.Fatalf("warmup b->a: %v", err)
}
}
// 反向并发:每轮同时发起 A→B send、B→A send、A 读 B、B 读 Agoroutine 一起抢同一对用户的行锁。
const rounds = 80
var wg sync.WaitGroup
errCh := make(chan error, rounds*4)
sem := make(chan struct{}, 24)
submit := func(op func() error) {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
if e := op(); e != nil {
errCh <- e
}
}()
}
for r := 0; r < rounds; r++ {
submit(func() error {
_, e := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: nextRID(), Message: "a->b", Date: date})
return e
})
submit(func() error {
_, e := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: nextRID(), Message: "b->a", Date: date})
return e
})
submit(func() error {
_, e := messages.ReadHistory(ctx, domain.ReadHistoryRequest{OwnerUserID: a.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: b.ID}, MaxID: domain.MaxMessageBoxID, Date: date})
return e
})
submit(func() error {
_, e := messages.ReadHistory(ctx, domain.ReadHistoryRequest{OwnerUserID: b.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: a.ID}, MaxID: domain.MaxMessageBoxID, Date: date})
return e
})
}
wg.Wait()
close(errCh)
failed := 0
for e := range errCh {
failed++
if failed <= 5 {
t.Errorf("反向并发操作失败(疑似死锁回归): %v", e)
}
}
if failed > 0 {
t.Fatalf("%d/%d 反向并发操作失败", failed, rounds*4)
}
}

View file

@ -0,0 +1,813 @@
package postgres
import (
"context"
"sync"
"testing"
"telesrv/internal/domain"
)
func TestMessageStoreSendPrivateTextRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 11,
Phone: "+1666" + suffix + "01",
FirstName: "Sender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 22,
Phone: "+1666" + suffix + "02",
FirstName: "Recipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
var originAuthKeyID [8]byte
originAuthKeyID[0] = 5
req := domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 123456,
Message: "hello from pg",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 5}},
Date: 1700000200,
OriginAuthKeyID: originAuthKeyID,
OriginSessionID: 77,
}
got, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if got.SenderMessage.ID != 1 || got.SenderMessage.Pts != 1 || !got.SenderMessage.Out || got.SenderMessage.Peer.ID != recipient.ID {
t.Fatalf("sender message = %+v, want first outgoing box to recipient", got.SenderMessage)
}
if got.RecipientMessage.ID != 1 || got.RecipientMessage.Pts != 1 || got.RecipientMessage.Out || got.RecipientMessage.Peer.ID != sender.ID {
t.Fatalf("recipient message = %+v, want first incoming box from sender", got.RecipientMessage)
}
if got.SenderMessage.UID == 0 || got.SenderMessage.UID != got.RecipientMessage.UID {
t.Fatalf("uid = sender %d recipient %d, want shared private message uid", got.SenderMessage.UID, got.RecipientMessage.UID)
}
senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(senderHistory.Messages) != 1 || len(recipientHistory.Messages) != 1 {
t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages))
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
if err != nil {
t.Fatalf("list recipient events: %v", err)
}
if len(events) != 1 || events[0].Message.ID != got.RecipientMessage.ID || len(events[0].Users) != 1 || events[0].Users[0].ID != sender.ID {
t.Fatalf("recipient events = %+v, want new message with sender user", events)
}
var pendingOutbox int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM dispatch_outbox
WHERE target_user_id = ANY($1::bigint[])
AND status = 'pending'
`, []int64{sender.ID, recipient.ID}).Scan(&pendingOutbox); err != nil {
t.Fatalf("count dispatch outbox: %v", err)
}
if pendingOutbox != 2 {
t.Fatalf("pending outbox = %d, want sender + recipient dispatch rows", pendingOutbox)
}
var excludeAuthKeyID, excludeSessionID int64
if err := pool.QueryRow(ctx, `
SELECT exclude_auth_key_id, exclude_session_id
FROM dispatch_outbox
WHERE target_user_id = $1
`, sender.ID).Scan(&excludeAuthKeyID, &excludeSessionID); err != nil {
t.Fatalf("sender dispatch outbox: %v", err)
}
if excludeAuthKeyID != authKeyIDToInt64(originAuthKeyID) || excludeSessionID != 77 {
t.Fatalf("sender dispatch exclude = auth %d session %d, want origin auth/session", excludeAuthKeyID, excludeSessionID)
}
dup, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText duplicate: %v", err)
}
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
t.Fatalf("duplicate = %+v, want original message boxes", dup)
}
}
func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
alice, err := users.Create(ctx, domain.User{
AccessHash: 91,
Phone: "+1667" + suffix + "01",
FirstName: "Alice",
})
if err != nil {
t.Fatalf("create alice: %v", err)
}
bob, err := users.Create(ctx, domain.User{
AccessHash: 92,
Phone: "+1667" + suffix + "02",
FirstName: "Bob",
})
if err != nil {
t.Fatalf("create bob: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID})
})
messages := NewMessageStore(pool)
for i := 1; i <= 6; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice.ID,
RecipientUserID: bob.ID,
RandomID: int64(700 + i),
Message: "history",
Date: 1700000000 + i,
}); err != nil {
t.Fatalf("seed message %d: %v", i, err)
}
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}
around, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -3,
Limit: 6,
NeedTotalCount: true,
})
if err != nil {
t.Fatalf("around history: %v", err)
}
if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) {
t.Fatalf("around ids = %v, want unread/newer side plus older context", got)
}
if around.Count != 6 {
t.Fatalf("around count = %d, want full dialog count", around.Count)
}
forward, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
OffsetID: 3,
AddOffset: -3,
Limit: 3,
NeedTotalCount: true,
})
if err != nil {
t.Fatalf("forward history: %v", err)
}
if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) {
t.Fatalf("forward ids = %v, want messages newer than offset", got)
}
if forward.Count != 6 {
t.Fatalf("forward count = %d, want full dialog count", forward.Count)
}
}
func TestMessageStoreReadAndEditEmitDurableEvents(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 31,
Phone: "+1666" + suffix + "11",
FirstName: "ReadSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 32,
Phone: "+1666" + suffix + "12",
FirstName: "ReadRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 223344,
Message: "before edit",
Date: 1700000300,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{
OwnerUserID: recipient.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID},
Date: 1700000310,
})
if err != nil {
t.Fatalf("ReadHistory: %v", err)
}
if !read.Changed || read.InboxEvent.Pts != 2 || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.MaxID != sent.RecipientMessage.ID {
t.Fatalf("read inbox = %+v, want recipient pts=2 max recipient id", read)
}
if !read.OutboxChanged || read.OutboxEvent.Pts != 2 || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID {
t.Fatalf("read outbox = %+v, want sender pts=2 max sender id", read)
}
readDate, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{
OwnerUserID: sender.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
ID: sent.SenderMessage.ID,
})
if err != nil || readDate != 1700000310 {
t.Fatalf("outbox read date = %d err=%v, want read date", readDate, err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: sender.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
ID: sent.SenderMessage.ID,
Message: "after edit",
EditDate: 1700000320,
})
if err != nil {
t.Fatalf("EditMessage: %v", err)
}
if self := edited.Self(); self.Event.Pts != 3 || self.Event.Type != domain.UpdateEventEditMessage || self.Message.Body != "after edit" {
t.Fatalf("self edit = %+v, want sender edit event pts=3", self)
}
recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID},
Limit: 10,
})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after edit" || recipientHistory.Messages[0].EditDate != 1700000320 {
t.Fatalf("recipient history = %+v, want edited message visible", recipientHistory.Messages)
}
senderEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 0, 10)
if err != nil {
t.Fatalf("sender events: %v", err)
}
if len(senderEvents) != 3 || senderEvents[1].Type != domain.UpdateEventReadHistoryOutbox || senderEvents[2].Type != domain.UpdateEventEditMessage {
t.Fatalf("sender events = %+v, want new/read_outbox/edit", senderEvents)
}
recipientEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
if err != nil {
t.Fatalf("recipient events: %v", err)
}
if len(recipientEvents) != 3 || recipientEvents[1].Type != domain.UpdateEventReadHistoryInbox || recipientEvents[2].Type != domain.UpdateEventEditMessage || recipientEvents[2].Message.Body != "after edit" {
t.Fatalf("recipient events = %+v, want new/read_inbox/edit with edited body", recipientEvents)
}
}
func TestMessageStoreSendPrivateTextRollbackRecordsPtsNoop(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 31,
Phone: "+1777" + suffix + "01",
FirstName: "GapSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 32,
Phone: "+1777" + suffix + "02",
FirstName: "GapRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 223344,
Message: "seed box",
Date: 1700000210,
}); err != nil {
t.Fatalf("seed SendPrivateText: %v", err)
}
failing := NewMessageStore(pool, WithMessageAllocators(fixedBoxIDAllocator{next: 1}, fixedPtsAllocator{next: 42}))
_, err = failing.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 223345,
Message: "should roll back",
Date: 1700000211,
})
if err == nil {
t.Fatal("SendPrivateText succeeded, want box id conflict")
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 1, 10)
if err != nil {
t.Fatalf("list sender events: %v", err)
}
for _, event := range events {
if event.Pts == 42 && event.Type == domain.UpdateEventNoop {
return
}
}
t.Fatalf("events = %+v, want noop gap at pts=42", events)
}
func TestMessageStoreConcurrentRandomIDIdempotent(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 41,
Phone: "+1888" + suffix + "01",
FirstName: "ConcurrentSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 42,
Phone: "+1888" + suffix + "02",
FirstName: "ConcurrentRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
boxCounters := &perUserCounterAllocator{}
ptsCounters := &perUserCounterAllocator{}
messages := NewMessageStore(pool, WithMessageAllocators(boxCounters, ptsCounters))
req := domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 556677,
Message: "same random id",
Date: 1700000220,
}
const workers = 8
results := make(chan domain.SendPrivateTextResult, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
res, err := messages.SendPrivateText(ctx, req)
if err != nil {
errs <- err
return
}
results <- res
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("SendPrivateText: %v", err)
}
var uid int64
duplicates := 0
successes := 0
for res := range results {
if res.SenderMessage.UID == 0 || res.RecipientMessage.UID == 0 {
t.Fatalf("result = %+v, want populated shared message uid", res)
}
if uid == 0 {
uid = res.SenderMessage.UID
}
if res.SenderMessage.UID != uid || res.RecipientMessage.UID != uid {
t.Fatalf("result = %+v, want same private message uid %d", res, uid)
}
if res.Duplicate {
duplicates++
} else {
successes++
}
}
if successes != 1 || duplicates != workers-1 {
t.Fatalf("successes=%d duplicates=%d, want one insert and duplicate rest", successes, duplicates)
}
var privateCount int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM private_messages
WHERE sender_user_id = $1
AND random_id = $2
`, sender.ID, req.RandomID).Scan(&privateCount); err != nil {
t.Fatalf("count private_messages: %v", err)
}
if privateCount != 1 {
t.Fatalf("private message count = %d, want 1", privateCount)
}
var boxCount int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM message_boxes
WHERE private_message_id = $1
`, uid).Scan(&boxCount); err != nil {
t.Fatalf("count message boxes: %v", err)
}
if boxCount != 2 {
t.Fatalf("message box count = %d, want sender + recipient boxes", boxCount)
}
}
func TestMessageStoreDeleteHistoryRebuildsDialogAndEmitsDeleteUpdates(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1991"+suffix+"01", "DeleteSender", "")
recipient := createTestUser(t, ctx, users, "+1991"+suffix+"02", "DeleteRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
for i := 0; i < 2; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: int64(7000 + i),
Message: "history",
Date: 1700000700 + i,
}); err != nil {
t.Fatalf("seed send %d: %v", i, err)
}
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: sender.ID,
Peer: peer,
Date: 1700000800,
})
if err != nil {
t.Fatalf("DeleteHistory: %v", err)
}
if self := deleted.Self(); self.Event.Pts != 4 || self.Event.PtsCount != 2 || len(self.MessageIDs) != 2 {
t.Fatalf("delete result = %+v, want sender delete range pts=4 count=2 ids", self)
}
senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("sender history: %v", err)
}
recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10})
if err != nil {
t.Fatalf("recipient history: %v", err)
}
if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 {
t.Fatalf("history sizes sender=%d recipient=%d, want sender cleared only", len(senderHistory.Messages), len(recipientHistory.Messages))
}
senderDialogs, err := NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("sender dialogs after delete: %v", err)
}
if len(senderDialogs.Dialogs) != 0 {
t.Fatalf("sender dialogs = %+v, want empty after full history delete", senderDialogs.Dialogs)
}
events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 2, 10)
if err != nil {
t.Fatalf("list sender events: %v", err)
}
if len(events) != 1 || events[0].Type != domain.UpdateEventDeleteMessages || events[0].Pts != 4 || events[0].PtsCount != 2 || len(events[0].MessageIDs) != 2 {
t.Fatalf("events = %+v, want delete messages event pts=4 pts_count=2", events)
}
rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 8000,
Message: "after clear",
Date: 1700000900,
})
if err != nil {
t.Fatalf("send after delete: %v", err)
}
senderDialogs, err = NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("sender dialogs after rebuild: %v", err)
}
if len(senderDialogs.Dialogs) != 1 || senderDialogs.Dialogs[0].Peer != peer || senderDialogs.Dialogs[0].TopMessage != rebuilt.SenderMessage.ID {
t.Fatalf("rebuilt dialogs = %+v, want new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID)
}
revoked, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: sender.ID,
IDs: []int{rebuilt.SenderMessage.ID},
Revoke: true,
Date: 1700001000,
})
if err != nil {
t.Fatalf("DeleteMessages revoke: %v", err)
}
if len(revoked.Deleted) != 2 || !revoked.Changed() {
t.Fatalf("revoked = %+v, want delete events for both owners", revoked)
}
senderHistory, err = messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("sender history after revoke: %v", err)
}
recipientHistory, err = messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10})
if err != nil {
t.Fatalf("recipient history after revoke: %v", err)
}
if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 {
t.Fatalf("history sizes after revoke sender=%d recipient=%d, want new message removed from both owners", len(senderHistory.Messages), len(recipientHistory.Messages))
}
}
func TestMessageStoreDeleteHistoryJustClearPreservesEmptyDialog(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1992"+suffix+"01", "ClearOwner", "")
peerUser := createTestUser(t, ctx, users, "+1992"+suffix+"02", "ClearPeer", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID})
})
messages := NewMessageStore(pool)
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner.ID,
RecipientUserID: peerUser.ID,
RandomID: 9000,
Message: "clear but keep dialog",
Date: 1700001100,
}); err != nil {
t.Fatalf("seed send: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
JustClear: true,
Date: 1700001200,
}); err != nil {
t.Fatalf("DeleteHistory just_clear: %v", err)
}
dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("dialogs after just_clear: %v", err)
}
if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer != peer || dialogs.Dialogs[0].TopMessage != 0 || len(dialogs.Messages) != 0 {
t.Fatalf("dialogs = %+v messages=%+v, want empty dialog preserved after just_clear", dialogs.Dialogs, dialogs.Messages)
}
history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true})
if err != nil {
t.Fatalf("history after just_clear: %v", err)
}
if len(history.Messages) != 0 {
t.Fatalf("history = %+v, want cleared", history.Messages)
}
}
func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1993"+suffix+"01", "BulkOwner", "")
peerUser := createTestUser(t, ctx, users, "+1993"+suffix+"02", "BulkPeer", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID})
})
total := domain.MaxDeleteHistoryBatch + 2
if _, err := pool.Exec(ctx, `
WITH src AS (
SELECT generate_series(1, $3::int) AS g
),
pm AS (
INSERT INTO private_messages (
sender_user_id,
recipient_user_id,
random_id,
message_date,
body,
entities
)
SELECT
$1::bigint,
$2::bigint,
910000000 + g,
1700002000 + g,
'bulk history',
'[]'::jsonb
FROM src
RETURNING id, random_id, message_date
)
INSERT INTO message_boxes (
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
outgoing,
body,
entities,
pts
)
SELECT
$1::bigint,
(random_id - 910000000)::int,
id,
$1::bigint,
'user',
$2::bigint,
$1::bigint,
message_date,
true,
'bulk history',
'[]'::jsonb,
0
FROM pm
`, owner.ID, peerUser.ID, total); err != nil {
t.Fatalf("seed bulk history: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO dialogs (
user_id,
peer_type,
peer_id,
top_message_id,
top_message_date,
read_outbox_max_id,
unread_count
) VALUES ($1, 'user', $2, $3, $4, $3, 0)
`, owner.ID, peerUser.ID, total, 1700002000+total); err != nil {
t.Fatalf("seed dialog: %v", err)
}
messages := NewMessageStore(pool)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID}
first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
MaxID: domain.MaxMessageBoxID,
Date: 1700003000,
})
if err != nil {
t.Fatalf("DeleteHistory first batch: %v", err)
}
self := first.Self()
if first.Offset != 1 || self.Event.Pts != domain.MaxDeleteHistoryBatch || self.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(self.MessageIDs) != domain.MaxDeleteHistoryBatch {
t.Fatalf("first batch = %+v self=%+v, want offset=1 and exactly %d deleted ids", first, self, domain.MaxDeleteHistoryBatch)
}
history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true})
if err != nil {
t.Fatalf("history after first batch: %v", err)
}
if history.Count != 2 || len(history.Messages) != 2 || history.Messages[0].ID != 2 {
t.Fatalf("history after first batch = %+v, want only two oldest messages left", history)
}
second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner.ID,
Peer: peer,
MaxID: domain.MaxMessageBoxID,
Date: 1700003001,
})
if err != nil {
t.Fatalf("DeleteHistory second batch: %v", err)
}
if second.Offset != 0 || second.Self().Event.PtsCount != 2 {
t.Fatalf("second batch = %+v, want final offset=0 pts_count=2", second)
}
}
type fixedBoxIDAllocator struct {
next int
}
func (a fixedBoxIDAllocator) NextBoxID(context.Context, int64) (int, error) {
return a.next, nil
}
func (a fixedBoxIDAllocator) CurrentBoxID(context.Context, int64) (int, error) {
return a.next, nil
}
type fixedPtsAllocator struct {
next int
}
func (a fixedPtsAllocator) NextPts(context.Context, int64) (int, error) {
return a.next, nil
}
func (a fixedPtsAllocator) CurrentPts(context.Context, int64) (int, error) {
return a.next, nil
}
type perUserCounterAllocator struct {
mu sync.Mutex
values map[int64]int
}
func (a *perUserCounterAllocator) NextBoxID(_ context.Context, userID int64) (int, error) {
return a.next(userID), nil
}
func (a *perUserCounterAllocator) CurrentBoxID(_ context.Context, userID int64) (int, error) {
return a.current(userID), nil
}
func (a *perUserCounterAllocator) NextPts(_ context.Context, userID int64) (int, error) {
return a.next(userID), nil
}
func (a *perUserCounterAllocator) CurrentPts(_ context.Context, userID int64) (int, error) {
return a.current(userID), nil
}
func (a *perUserCounterAllocator) next(userID int64) int {
a.mu.Lock()
defer a.mu.Unlock()
if a.values == nil {
a.values = map[int64]int{}
}
a.values[userID]++
return a.values[userID]
}
func (a *perUserCounterAllocator) current(userID int64) int {
a.mu.Lock()
defer a.mu.Unlock()
return a.values[userID]
}
func messageIDs(messages []domain.Message) []int {
out := make([]int, 0, len(messages))
for _, msg := range messages {
out = append(out, msg.ID)
}
return out
}
func sameInts(got, want []int) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}

View file

@ -0,0 +1,181 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
// TestSendPrivateMediaSurvivesUpdateEvent 验证带 media 的私聊消息:
// - 发送后 message_boxes 持久化 media 快照;
// - 接收方经 UpdateEventStore.ListAfter在线 outbox / 离线 getDifference 共用的重建路径)
// 能拿回 media曾因 update event 查询漏选 m.media 导致收件人/离线丢媒体,本测试守护该修复);
// - historyListByUser读取也带 media。
func TestSendPrivateMediaSurvivesUpdateEvent(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1998" + suffix + "01", FirstName: "MediaSender"})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1998" + suffix + "02", FirstName: "MediaRecipient"})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
ids := []int64{sender.ID, recipient.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
})
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{}))
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 9200000000000000001,
AccessHash: 9,
DCID: 2,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: 5, StickerSetAccessHash: 7}},
},
}
res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: time.Now().UnixNano(),
Message: "", // 仅媒体(无 caption
Media: media,
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("send private media: %v", err)
}
// 发送结果双端均带 media。
for name, msg := range map[string]domain.Message{"sender": res.SenderMessage, "recipient": res.RecipientMessage} {
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindDocument || msg.Media.Document == nil || msg.Media.Document.ID != media.Document.ID {
t.Fatalf("%s message media lost: %+v", name, msg.Media)
}
}
// 关键:接收方经更新事件重建(在线推送 / 离线 difference 共用路径)仍带 media。
events := NewUpdateEventStore(pool)
got, err := events.ListAfter(ctx, recipient.ID, 0, 10)
if err != nil {
t.Fatalf("list recipient events: %v", err)
}
var found bool
for _, ev := range got {
if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID != 0 {
found = true
if ev.Message.Media == nil || ev.Message.Media.Document == nil || ev.Message.Media.Document.ID != media.Document.ID {
t.Fatalf("recipient update-event message lost media: %+v", ev.Message.Media)
}
}
}
if !found {
t.Fatal("no new_message event for recipient")
}
// history 读取也带 media。
list, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10})
if err != nil {
t.Fatalf("list by user: %v", err)
}
if len(list.Messages) == 0 || list.Messages[0].Media == nil || list.Messages[0].Media.Document == nil {
t.Fatalf("history message lost media: %+v", list.Messages)
}
}
func TestSendChannelMediaSurvivesDifference(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 63, Phone: "+1998" + suffix + "03", FirstName: "MediaChannelOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 64, Phone: "+1998" + suffix + "04", FirstName: "MediaChannelMember"})
if err != nil {
t.Fatalf("create member: %v", err)
}
var channelID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Media Difference " + suffix,
Megagroup: true,
MemberUserIDs: []int64{member.ID},
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
media := &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: 9200000000000000002,
AccessHash: 10,
DCID: 2,
MimeType: "application/octet-stream",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "telesrv-media.bin"}},
},
}
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: channelID,
RandomID: time.Now().UnixNano(),
Message: "",
Media: media,
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("send channel media: %v", err)
}
if sent.Message.Media == nil || sent.Message.Media.Document == nil || sent.Message.Media.Document.ID != media.Document.ID {
t.Fatalf("send result lost media: %+v", sent.Message.Media)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: member.ID,
ChannelID: channelID,
Pts: created.Event.Pts,
Limit: 10,
})
if err != nil {
t.Fatalf("list channel difference: %v", err)
}
var found bool
for _, msg := range diff.NewMessages {
if msg.ID == sent.Message.ID {
found = true
if msg.Media == nil || msg.Media.Document == nil || msg.Media.Document.ID != media.Document.ID {
t.Fatalf("channel difference message lost media: %+v", msg.Media)
}
}
}
if !found {
t.Fatalf("sent media message %d not found in channel difference: %+v", sent.Message.ID, diff.NewMessages)
}
}

View file

@ -0,0 +1,160 @@
package postgres
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func TestMessagePartitionSeekPlansUseIndexes(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{
AccessHash: 51,
Phone: "+1999" + suffix + "01",
FirstName: "PlanSender",
})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{
AccessHash: 52,
Phone: "+1999" + suffix + "02",
FirstName: "PlanRecipient",
})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
for i := 0; i < 3; i++ {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: int64(9000 + i),
Message: "plan check",
Date: 1700000300 + i,
}); err != nil {
t.Fatalf("seed message %d: %v", i, err)
}
}
if _, err := pool.Exec(ctx, `
UPDATE dispatch_outbox
SET status = 'dispatching',
updated_at = now() - interval '1 minute'
WHERE target_user_id = $1
`, recipient.ID); err != nil {
t.Fatalf("mark dispatch stale: %v", err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin explain tx: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
t.Fatalf("disable seqscan: %v", err)
}
historyPlan := explainText(t, ctx, tx, `
SELECT box_id
FROM message_boxes
WHERE owner_user_id = $1
AND peer_type = 'user'
AND peer_id = $2
AND NOT deleted
AND box_id < $3
ORDER BY box_id DESC
LIMIT 20
`, recipient.ID, sender.ID, 100000)
requirePlanUsesPartitionIndex(t, historyPlan, "message_boxes")
requirePlanNotContains(t, historyPlan, "Append")
updatesPlan := explainText(t, ctx, tx, `
SELECT pts
FROM user_update_events
WHERE user_id = $1
AND pts > $2
ORDER BY pts ASC
LIMIT 100
`, recipient.ID, 0)
requirePlanUsesPartitionIndex(t, updatesPlan, "user_update_events")
requirePlanNotContains(t, updatesPlan, "Append")
dispatchPlan := explainText(t, ctx, tx, `
WITH picked AS (
SELECT target_user_id, id
FROM dispatch_outbox
WHERE (
status = 'pending'
AND next_attempt_at <= now()
)
OR (
status = 'dispatching'
AND updated_at < now() - interval '30 seconds'
)
ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC
LIMIT 100
FOR UPDATE SKIP LOCKED
)
SELECT target_user_id, id
FROM picked
`)
requirePlanContains(t, dispatchPlan, "dispatch_outbox_p")
requirePlanContains(t, dispatchPlan, "Index")
requirePlanNotContains(t, dispatchPlan, "Seq Scan")
}
func explainText(t *testing.T, ctx context.Context, tx pgx.Tx, query string, args ...any) string {
t.Helper()
rows, err := tx.Query(ctx, "EXPLAIN (COSTS OFF) "+query, args...)
if err != nil {
t.Fatalf("explain query: %v", err)
}
defer rows.Close()
var b strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
t.Fatalf("scan explain row: %v", err)
}
b.WriteString(line)
b.WriteByte('\n')
}
if err := rows.Err(); err != nil {
t.Fatalf("read explain rows: %v", err)
}
return b.String()
}
func requirePlanUsesPartitionIndex(t *testing.T, plan, table string) {
t.Helper()
requirePlanContains(t, plan, table+"_p")
requirePlanContains(t, plan, "Index")
requirePlanNotContains(t, plan, "Seq Scan")
}
func requirePlanContains(t *testing.T, plan string, needle string) {
t.Helper()
if !strings.Contains(plan, needle) {
t.Fatalf("plan missing %q:\n%s", needle, plan)
}
}
func requirePlanNotContains(t *testing.T, plan string, needle string) {
t.Helper()
if strings.Contains(plan, needle) {
t.Fatalf("plan contains %q:\n%s", needle, plan)
}
}

View file

@ -0,0 +1,128 @@
// Package postgres 用 PostgreSQL 实现持久化存储接口第一阶段AuthKeyStore
//
// 查询代码由 sqlc 生成于 ./sqlcgen见 telesrv/sqlc.yaml本包在其上实现 store 接口。
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/pgx/v5" // 注册 pgx5:// migrate driver
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/deploy"
)
const defaultMinConns = 16
// PoolOption 调整 pgxpool 连接池配置。
type PoolOption func(*pgxpool.Config)
// WithMaxConns 设置连接池最大连接数;<=0 时保持 pgx 默认。
// 同时把 MinConns 预热到 min(maxConns, 16),降低 TDesktop 启动风暴下的冷连接尾延迟突刺。
func WithMaxConns(n int) PoolOption {
return func(cfg *pgxpool.Config) {
if n <= 0 {
return
}
cfg.MaxConns = int32(n)
minConns := int32(defaultMinConns)
if int32(n) < minConns {
minConns = int32(n)
}
cfg.MinConns = minConns
}
}
// WithMinConns 设置启动时预热的最小连接数;<=0 保持既有配置。
func WithMinConns(n int) PoolOption {
return func(cfg *pgxpool.Config) {
if n <= 0 {
return
}
minConns := int32(n)
if cfg.MaxConns > 0 && minConns > cfg.MaxConns {
minConns = cfg.MaxConns
}
cfg.MinConns = minConns
}
}
// Open 建立 pgxpool 连接池并 ping 验证。
func Open(ctx context.Context, dsn string, opts ...PoolOption) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("pgxpool parse config: %w", err)
}
for _, opt := range opts {
if opt != nil {
opt(cfg)
}
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("pgxpool new: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("pg ping: %w", err)
}
if err := warmMinConns(ctx, pool); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}
func warmMinConns(ctx context.Context, pool *pgxpool.Pool) error {
target := pool.Config().MinConns
if target <= 0 {
return nil
}
conns := make([]*pgxpool.Conn, 0, target)
defer func() {
for _, conn := range conns {
conn.Release()
}
}()
for int32(len(conns)) < target {
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("prewarm pg connection %d/%d: %w", len(conns)+1, target, err)
}
conns = append(conns, conn)
}
return nil
}
// Migrate 用嵌入的迁移脚本将数据库迁移到最新版本。幂等:已最新时返回 nil。
func Migrate(dsn string) error {
src, err := iofs.New(deploy.Migrations, "migrations")
if err != nil {
return fmt.Errorf("iofs source: %w", err)
}
m, err := migrate.NewWithSourceInstance("iofs", src, toPgx5DSN(dsn))
if err != nil {
return fmt.Errorf("migrate new: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}
// toPgx5DSN 把 pgxpool 用的 postgres:// DSN 转成 golang-migrate pgx5 driver 所需的 pgx5:// scheme。
func toPgx5DSN(dsn string) string {
if s, ok := strings.CutPrefix(dsn, "postgres://"); ok {
return "pgx5://" + s
}
if s, ok := strings.CutPrefix(dsn, "postgresql://"); ok {
return "pgx5://" + s
}
return dsn
}

View file

@ -0,0 +1,22 @@
-- name: GetPasswordByUser :one
SELECT
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random
FROM account_passwords
WHERE user_id = $1;
-- name: UpsertPassword :exec
INSERT INTO account_passwords (
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id) DO UPDATE SET
has_recovery = EXCLUDED.has_recovery,
has_secure_values = EXCLUDED.has_secure_values,
has_password = EXCLUDED.has_password,
hint = EXCLUDED.hint,
email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern,
login_email_pattern = EXCLUDED.login_email_pattern,
secure_random = EXCLUDED.secure_random,
updated_at = now();

View file

@ -0,0 +1,10 @@
-- name: GetAuthKey :one
SELECT auth_key_id, body, server_salt, created_at
FROM auth_keys
WHERE auth_key_id = $1;
-- name: UpsertAuthKey :exec
INSERT INTO auth_keys (auth_key_id, body, server_salt)
VALUES ($1, $2, $3)
ON CONFLICT (auth_key_id) DO UPDATE
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt;

View file

@ -0,0 +1,24 @@
-- name: UpsertAuthorization :exec
INSERT INTO authorizations (auth_key_id, user_id, layer, device_model, platform, system_version, api_id, app_version, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (auth_key_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
layer = EXCLUDED.layer,
device_model = EXCLUDED.device_model,
platform = EXCLUDED.platform,
system_version = EXCLUDED.system_version,
api_id = EXCLUDED.api_id,
app_version = EXCLUDED.app_version,
ip = EXCLUDED.ip,
active_at = now();
-- name: GetAuthorizationByAuthKey :one
SELECT * FROM authorizations WHERE auth_key_id = $1;
-- name: ListAuthorizationsByUser :many
SELECT * FROM authorizations
WHERE user_id = $1
ORDER BY active_at DESC, auth_key_id DESC;
-- name: DeleteAuthorization :exec
DELETE FROM authorizations WHERE auth_key_id = $1;

View file

@ -0,0 +1,168 @@
-- name: ListContactsByUser :many
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u.id;
-- name: GetContact :one
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
AND c.contact_user_id = $2;
-- name: UpsertContact :one
WITH reverse AS (
SELECT EXISTS (
SELECT 1
FROM contacts
WHERE user_id = sqlc.arg(contact_user_id)::bigint
AND contact_user_id = sqlc.arg(user_id)::bigint
)::boolean AS mutual
),
upserted AS (
INSERT INTO contacts (
user_id,
contact_user_id,
contact_phone,
contact_first_name,
contact_last_name,
note,
note_entities,
mutual
)
SELECT
sqlc.arg(user_id)::bigint,
sqlc.arg(contact_user_id)::bigint,
sqlc.arg(contact_phone)::text,
sqlc.arg(contact_first_name)::text,
sqlc.arg(contact_last_name)::text,
sqlc.arg(note)::text,
sqlc.arg(note_entities)::jsonb,
reverse.mutual
FROM reverse
ON CONFLICT (user_id, contact_user_id) DO UPDATE SET
contact_phone = EXCLUDED.contact_phone,
contact_first_name = EXCLUDED.contact_first_name,
contact_last_name = EXCLUDED.contact_last_name,
note = EXCLUDED.note,
note_entities = EXCLUDED.note_entities,
mutual = contacts.mutual OR EXCLUDED.mutual,
updated_at = now()
RETURNING *
),
reverse_updated AS (
UPDATE contacts c
SET mutual = true,
updated_at = now()
WHERE c.user_id = sqlc.arg(contact_user_id)::bigint
AND c.contact_user_id = sqlc.arg(user_id)::bigint
AND NOT c.mutual
RETURNING c.user_id
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
FROM upserted c
JOIN users u ON u.id = c.contact_user_id;
-- name: UpdateContactNote :one
WITH updated AS (
UPDATE contacts c
SET note = sqlc.arg(note)::text,
note_entities = sqlc.arg(note_entities)::jsonb,
updated_at = now()
WHERE c.user_id = sqlc.arg(user_id)::bigint
AND c.contact_user_id = sqlc.arg(contact_user_id)::bigint
RETURNING *
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM updated c
JOIN users u ON u.id = c.contact_user_id;
-- name: DeleteContacts :one
WITH deleted AS (
DELETE FROM contacts
WHERE user_id = sqlc.arg(user_id)::bigint
AND contact_user_id = ANY(sqlc.arg(contact_user_ids)::bigint[])
RETURNING contact_user_id
),
reverse_updated AS (
UPDATE contacts c
SET mutual = false,
updated_at = now()
FROM deleted d
WHERE c.user_id = d.contact_user_id
AND c.contact_user_id = sqlc.arg(user_id)::bigint
RETURNING c.user_id
)
SELECT COUNT(*)::int AS deleted_count
FROM deleted;

View file

@ -0,0 +1,773 @@
-- name: ListDialogsByUser :many
WITH base AS (
SELECT
d.user_id,
d.peer_type,
d.peer_id,
d.folder_id,
d.top_message_id,
d.top_message_date,
d.read_inbox_max_id,
d.read_outbox_max_id,
d.unread_count,
d.unread_mentions_count,
d.unread_reactions_count,
d.pinned,
d.pinned_order,
d.unread_mark,
d.hidden_peer_settings_bar,
COALESCE(u.id, 0)::bigint AS peer_user_id,
COALESCE(u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name,
COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name,
COALESCE(u.username, '')::text AS peer_username,
COALESCE(u.country_code, '')::text AS peer_country_code,
COALESCE(u.verified, false)::boolean AS peer_verified,
COALESCE(u.support, false)::boolean AS peer_support,
COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS peer_contact,
COALESCE(c.mutual, false)::boolean AS peer_mutual,
COALESCE(m.box_id, 0)::int AS message_id,
COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id,
COALESCE(m.message_date, 0)::int AS message_date,
COALESCE(m.outgoing, false)::boolean AS message_outgoing,
COALESCE(m.body, '')::text AS message_body,
COALESCE(m.entities::text, '[]')::text AS message_entities_json
FROM dialogs d
LEFT JOIN users u ON d.peer_type = 'user' AND u.id = d.peer_id
LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id
LEFT JOIN message_boxes m ON m.owner_user_id = d.user_id AND m.box_id = d.top_message_id AND NOT m.deleted
WHERE d.user_id = $1
AND (
NOT sqlc.arg(has_folder_id)::boolean
OR (
sqlc.arg(folder_id)::int < 2
AND d.folder_id = sqlc.arg(folder_id)::int
)
OR (
sqlc.arg(folder_id)::int >= 2
AND NOT (sqlc.arg(folder_exclude_archived)::boolean AND d.folder_id = 1)
AND NOT (sqlc.arg(folder_exclude_read)::boolean AND d.unread_count = 0 AND NOT d.unread_mark)
AND NOT EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_exclude_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_exclude_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
AND (
EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_include_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_include_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
OR EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_pinned_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_pinned_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
OR (sqlc.arg(folder_contacts)::boolean AND c.contact_user_id IS NOT NULL)
OR (sqlc.arg(folder_non_contacts)::boolean AND c.contact_user_id IS NULL)
)
)
)
AND (NOT sqlc.arg(pinned_only)::boolean OR d.pinned)
AND (NOT sqlc.arg(exclude_pinned)::boolean OR NOT d.pinned)
),
paged AS (
SELECT *
FROM base
WHERE (
(sqlc.arg(offset_date)::int <= 0 AND sqlc.arg(offset_id)::int <= 0)
OR (
sqlc.arg(offset_date)::int > 0
AND (
top_message_date < sqlc.arg(offset_date)::int
OR (
top_message_date = sqlc.arg(offset_date)::int
AND (
sqlc.arg(offset_id)::int <= 0
OR top_message_id < sqlc.arg(offset_id)::int
OR (
top_message_id = sqlc.arg(offset_id)::int
AND sqlc.arg(has_offset_peer)::boolean
AND peer_id < sqlc.arg(offset_peer_id)::bigint
)
)
)
)
)
OR (
sqlc.arg(offset_date)::int <= 0
AND sqlc.arg(offset_id)::int > 0
AND (
top_message_id < sqlc.arg(offset_id)::int
OR (
top_message_id = sqlc.arg(offset_id)::int
AND sqlc.arg(has_offset_peer)::boolean
AND peer_id < sqlc.arg(offset_peer_id)::bigint
)
)
)
)
)
SELECT
user_id,
peer_type::text AS peer_type,
peer_id::bigint AS peer_id,
folder_id,
top_message_id,
top_message_date,
read_inbox_max_id,
read_outbox_max_id,
unread_count,
unread_mentions_count,
unread_reactions_count,
pinned,
pinned_order,
unread_mark,
hidden_peer_settings_bar,
peer_user_id,
peer_access_hash,
peer_phone,
peer_first_name,
peer_last_name,
peer_username,
peer_country_code,
peer_verified,
peer_support,
peer_last_seen_at,
peer_contact,
peer_mutual,
message_id,
message_from_user_id,
message_date,
message_outgoing,
message_body,
message_entities_json
FROM paged
ORDER BY
pinned DESC,
CASE WHEN pinned THEN COALESCE(NULLIF(pinned_order, 0), 2147483647) ELSE 2147483647 END ASC,
top_message_date DESC,
top_message_id DESC,
peer_id DESC
LIMIT sqlc.arg(limit_count);
-- name: ListDialogSummaryByUser :many
SELECT
d.peer_type,
d.peer_id,
d.folder_id,
d.top_message_id,
d.top_message_date,
d.read_inbox_max_id,
d.read_outbox_max_id,
d.unread_count,
d.unread_mentions_count,
d.unread_reactions_count,
d.pinned,
d.pinned_order,
d.unread_mark,
d.hidden_peer_settings_bar
FROM dialogs d
LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id
WHERE d.user_id = $1
AND (
NOT sqlc.arg(has_folder_id)::boolean
OR (
sqlc.arg(folder_id)::int < 2
AND d.folder_id = sqlc.arg(folder_id)::int
)
OR (
sqlc.arg(folder_id)::int >= 2
AND NOT (sqlc.arg(folder_exclude_archived)::boolean AND d.folder_id = 1)
AND NOT (sqlc.arg(folder_exclude_read)::boolean AND d.unread_count = 0 AND NOT d.unread_mark)
AND NOT EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_exclude_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_exclude_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
AND (
EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_include_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_include_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
OR EXISTS (
SELECT 1
FROM (
SELECT fpt.peer_type, fpi.peer_id
FROM unnest(sqlc.arg(folder_pinned_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord)
JOIN unnest(sqlc.arg(folder_pinned_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord)
) fp
WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id
)
OR (sqlc.arg(folder_contacts)::boolean AND c.contact_user_id IS NOT NULL)
OR (sqlc.arg(folder_non_contacts)::boolean AND c.contact_user_id IS NULL)
)
)
)
AND (NOT sqlc.arg(pinned_only)::boolean OR d.pinned)
AND (NOT sqlc.arg(exclude_pinned)::boolean OR NOT d.pinned)
ORDER BY
d.pinned DESC,
CASE WHEN d.pinned THEN COALESCE(NULLIF(d.pinned_order, 0), 2147483647) ELSE 2147483647 END ASC,
d.top_message_date DESC,
d.top_message_id DESC,
d.peer_id DESC;
-- name: ListDialogsByPeers :many
WITH requested AS (
SELECT
(sqlc.arg(peer_types)::text[])[i] AS peer_type,
(sqlc.arg(peer_ids)::bigint[])[i] AS peer_id,
i::int AS ord
FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(peer_types)::text[])
),
deduped AS (
SELECT DISTINCT ON (peer_type, peer_id)
peer_type,
peer_id,
ord
FROM requested
ORDER BY peer_type, peer_id, ord
),
base AS (
SELECT
sqlc.arg(user_id)::bigint AS user_id,
r.peer_type,
r.peer_id,
COALESCE(d.folder_id, 0)::int AS folder_id,
COALESCE(d.top_message_id, 0)::int AS top_message_id,
COALESCE(d.top_message_date, 0)::int AS top_message_date,
COALESCE(d.read_inbox_max_id, 0)::int AS read_inbox_max_id,
COALESCE(d.read_outbox_max_id, 0)::int AS read_outbox_max_id,
COALESCE(d.unread_count, 0)::int AS unread_count,
COALESCE(d.unread_mentions_count, 0)::int AS unread_mentions_count,
COALESCE(d.unread_reactions_count, 0)::int AS unread_reactions_count,
COALESCE(d.pinned, false)::boolean AS pinned,
COALESCE(d.pinned_order, 0)::int AS pinned_order,
COALESCE(d.unread_mark, false)::boolean AS unread_mark,
COALESCE(d.hidden_peer_settings_bar, false)::boolean AS hidden_peer_settings_bar,
COALESCE(u.id, 0)::bigint AS peer_user_id,
COALESCE(u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name,
COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name,
COALESCE(u.username, '')::text AS peer_username,
COALESCE(u.country_code, '')::text AS peer_country_code,
COALESCE(u.verified, false)::boolean AS peer_verified,
COALESCE(u.support, false)::boolean AS peer_support,
COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS peer_contact,
COALESCE(c.mutual, false)::boolean AS peer_mutual,
COALESCE(m.box_id, 0)::int AS message_id,
COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id,
COALESCE(m.message_date, 0)::int AS message_date,
COALESCE(m.outgoing, false)::boolean AS message_outgoing,
COALESCE(m.body, '')::text AS message_body,
COALESCE(m.entities::text, '[]')::text AS message_entities_json,
r.ord
FROM deduped r
LEFT JOIN dialogs d
ON d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = r.peer_type
AND d.peer_id = r.peer_id
LEFT JOIN users u ON r.peer_type = 'user' AND u.id = r.peer_id
LEFT JOIN contacts c ON r.peer_type = 'user' AND c.user_id = sqlc.arg(user_id)::bigint AND c.contact_user_id = r.peer_id
LEFT JOIN message_boxes m ON m.owner_user_id = sqlc.arg(user_id)::bigint AND m.box_id = d.top_message_id AND NOT m.deleted
)
SELECT
user_id,
peer_type::text AS peer_type,
peer_id::bigint AS peer_id,
folder_id,
top_message_id,
top_message_date,
read_inbox_max_id,
read_outbox_max_id,
unread_count,
unread_mentions_count,
unread_reactions_count,
pinned,
pinned_order,
unread_mark,
hidden_peer_settings_bar,
peer_user_id,
peer_access_hash,
peer_phone,
peer_first_name,
peer_last_name,
peer_username,
peer_country_code,
peer_verified,
peer_support,
peer_last_seen_at,
peer_contact,
peer_mutual,
message_id,
message_from_user_id,
message_date,
message_outgoing,
message_body,
message_entities_json
FROM base
ORDER BY ord;
-- name: UpsertDialog :exec
INSERT INTO dialogs (
user_id,
peer_type,
peer_id,
top_message_id,
top_message_date,
read_inbox_max_id,
read_outbox_max_id,
unread_count,
unread_mentions_count,
unread_reactions_count,
pinned,
unread_mark
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
)
ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET
top_message_id = EXCLUDED.top_message_id,
top_message_date = EXCLUDED.top_message_date,
read_inbox_max_id = EXCLUDED.read_inbox_max_id,
read_outbox_max_id = EXCLUDED.read_outbox_max_id,
unread_count = EXCLUDED.unread_count,
unread_mentions_count = EXCLUDED.unread_mentions_count,
unread_reactions_count = EXCLUDED.unread_reactions_count,
pinned = EXCLUDED.pinned,
unread_mark = EXCLUDED.unread_mark,
updated_at = now();
-- name: UpsertOutboxDialog :exec
INSERT INTO dialogs (
user_id,
peer_type,
peer_id,
top_message_id,
top_message_date,
unread_count
) VALUES (
$1, $2, $3, $4, $5, 0
)
ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET
top_message_id = EXCLUDED.top_message_id,
top_message_date = EXCLUDED.top_message_date,
updated_at = now();
-- name: UpsertInboxDialog :exec
INSERT INTO dialogs (
user_id,
peer_type,
peer_id,
top_message_id,
top_message_date,
unread_count
) VALUES (
$1, $2, $3, $4, $5, 1
)
ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET
top_message_id = EXCLUDED.top_message_id,
top_message_date = EXCLUDED.top_message_date,
unread_count = dialogs.unread_count + 1,
updated_at = now();
-- name: MarkDialogRead :one
WITH target AS (
SELECT
d.user_id,
d.peer_type,
d.peer_id,
d.top_message_id,
d.read_inbox_max_id,
d.unread_count
FROM dialogs d
WHERE d.user_id = $1
AND d.peer_type = $2
AND d.peer_id = $3
),
updated AS (
UPDATE dialogs d
SET
read_inbox_max_id = GREATEST(
d.read_inbox_max_id,
CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE d.top_message_id END
),
unread_count = 0,
unread_mark = false,
unread_mentions_count = 0,
unread_reactions_count = 0,
updated_at = now()
FROM target
WHERE d.user_id = target.user_id
AND d.peer_type = target.peer_type
AND d.peer_id = target.peer_id
RETURNING
d.user_id,
d.peer_type,
d.peer_id,
d.read_inbox_max_id,
d.unread_count,
(
target.unread_count > 0
OR (
CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE target.top_message_id END
) > target.read_inbox_max_id
)::boolean AS changed
)
SELECT
user_id,
peer_type,
peer_id,
read_inbox_max_id,
unread_count,
changed
FROM updated;
-- name: SetDialogPinned :one
WITH next_order AS (
SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value
FROM dialogs
WHERE user_id = sqlc.arg(user_id)::bigint
AND pinned
),
updated AS (
UPDATE dialogs d
SET pinned = sqlc.arg(pinned)::boolean,
pinned_order = CASE
WHEN sqlc.arg(pinned)::boolean THEN
CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END
ELSE 0
END,
updated_at = now()
FROM next_order
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint
RETURNING d.user_id
)
SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed;
-- name: SetDialogUnreadMark :one
WITH updated AS (
UPDATE dialogs d
SET unread_mark = sqlc.arg(unread)::boolean,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint
RETURNING d.user_id
)
SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed;
-- name: ListDialogUnreadMarks :many
SELECT
peer_type,
peer_id
FROM dialogs
WHERE user_id = $1
AND unread_mark
ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC;
-- name: SetPeerSettingsBarHidden :one
WITH updated AS (
UPDATE dialogs d
SET hidden_peer_settings_bar = true,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint
RETURNING d.user_id
)
SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed;
-- name: GetPeerSettingsBarHidden :one
SELECT hidden_peer_settings_bar
FROM dialogs
WHERE user_id = $1
AND peer_type = $2
AND peer_id = $3;
-- name: ReorderPinnedDialogs :exec
WITH requested AS (
SELECT
(sqlc.arg(peer_types)::text[])[i] AS peer_type,
(sqlc.arg(peer_ids)::bigint[])[i] AS peer_id,
i::int AS pos
FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(peer_types)::text[])
),
deduped AS (
SELECT DISTINCT ON (peer_type, peer_id)
peer_type,
peer_id,
pos::int AS ord
FROM requested
ORDER BY peer_type, peer_id, pos
)
UPDATE dialogs d
SET pinned = true,
pinned_order = deduped.ord,
updated_at = now()
FROM deduped
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = deduped.peer_type
AND d.peer_id = deduped.peer_id;
-- name: EditDialogPeerFolders :exec
WITH requested AS (
SELECT
(sqlc.arg(peer_types)::text[])[i] AS peer_type,
(sqlc.arg(peer_ids)::bigint[])[i] AS peer_id,
(sqlc.arg(folder_ids)::int[])[i] AS folder_id
FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(peer_types)::text[])
AND i <= cardinality(sqlc.arg(folder_ids)::int[])
),
deduped AS (
SELECT DISTINCT ON (peer_type, peer_id)
peer_type,
peer_id,
folder_id
FROM requested
WHERE folder_id IN (0, 1)
ORDER BY peer_type, peer_id
)
UPDATE dialogs d
SET folder_id = deduped.folder_id,
updated_at = now()
FROM deduped
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = deduped.peer_type
AND d.peer_id = deduped.peer_id;
-- name: ClearPinnedDialogsNotInOrder :exec
WITH requested AS (
SELECT
(sqlc.arg(peer_types)::text[])[i] AS peer_type,
(sqlc.arg(peer_ids)::bigint[])[i] AS peer_id
FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(peer_types)::text[])
)
UPDATE dialogs d
SET pinned = false,
pinned_order = 0,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.pinned
AND NOT EXISTS (
SELECT 1
FROM requested r
WHERE r.peer_type = d.peer_type
AND r.peer_id = d.peer_id
);
-- name: RefreshDialogAfterMessageDelete :exec
UPDATE dialogs d
SET
top_message_id = sqlc.arg(top_message_id)::int,
top_message_date = sqlc.arg(top_message_date)::int,
unread_count = (
SELECT COUNT(*)::int
FROM message_boxes m
WHERE m.owner_user_id = d.user_id
AND m.peer_type = d.peer_type
AND m.peer_id = d.peer_id
AND NOT m.deleted
AND NOT m.outgoing
AND m.box_id > d.read_inbox_max_id
),
unread_mentions_count = 0,
unread_reactions_count = 0,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint;
-- name: ClearDialogAfterHistoryDelete :exec
UPDATE dialogs d
SET
top_message_id = 0,
top_message_date = 0,
read_inbox_max_id = GREATEST(d.read_inbox_max_id, d.top_message_id),
read_outbox_max_id = GREATEST(d.read_outbox_max_id, d.top_message_id),
unread_count = 0,
unread_mark = false,
unread_mentions_count = 0,
unread_reactions_count = 0,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint;
-- name: DeleteDialogByPeer :exec
DELETE FROM dialogs
WHERE user_id = $1
AND peer_type = $2
AND peer_id = $3;
-- name: ListDialogFolders :many
SELECT
filter_id,
is_chatlist,
filter::text AS filter_json
FROM dialog_filters
WHERE user_id = $1
ORDER BY order_value ASC, filter_id ASC;
-- name: GetDialogFolder :one
SELECT
filter_id,
is_chatlist,
filter::text AS filter_json
FROM dialog_filters
WHERE user_id = $1
AND filter_id = $2;
-- name: UpsertDialogFolder :exec
INSERT INTO dialog_filters (
user_id,
filter_id,
is_chatlist,
filter,
order_value
) VALUES (
$1,
$2,
$3,
sqlc.arg(filter_json)::jsonb,
COALESCE(
(SELECT order_value FROM dialog_filters WHERE user_id = $1 AND filter_id = $2),
(SELECT COALESCE(MAX(order_value), 0) + 1 FROM dialog_filters WHERE user_id = $1)
)
)
ON CONFLICT (user_id, filter_id) DO UPDATE SET
is_chatlist = EXCLUDED.is_chatlist,
filter = EXCLUDED.filter,
updated_at = now();
-- name: DeleteDialogFolder :exec
DELETE FROM dialog_filters
WHERE user_id = $1
AND filter_id = $2;
-- name: ReorderDialogFolders :exec
WITH requested AS (
SELECT filter_id, ord::int AS order_value
FROM unnest(sqlc.arg(filter_ids)::int[]) WITH ORDINALITY AS t(filter_id, ord)
),
deduped AS (
SELECT DISTINCT ON (filter_id)
filter_id,
order_value
FROM requested
WHERE filter_id >= 2
ORDER BY filter_id, order_value
)
UPDATE dialog_filters f
SET order_value = deduped.order_value,
updated_at = now()
FROM deduped
WHERE f.user_id = sqlc.arg(user_id)::bigint
AND f.filter_id = deduped.filter_id;
-- name: GetDialogFolderTags :one
SELECT tags_enabled
FROM dialog_filter_settings
WHERE user_id = $1;
-- name: SetDialogFolderTags :exec
INSERT INTO dialog_filter_settings (
user_id,
tags_enabled
) VALUES (
$1,
$2
)
ON CONFLICT (user_id) DO UPDATE SET
tags_enabled = EXCLUDED.tags_enabled,
updated_at = now();
-- name: UpsertDialogDraft :exec
INSERT INTO dialog_drafts (
user_id,
peer_type,
peer_id,
top_message_id,
date,
draft
) VALUES (
$1,
$2,
$3,
$4,
$5,
sqlc.arg(draft_json)::jsonb
)
ON CONFLICT (user_id, peer_type, peer_id, top_message_id) DO UPDATE SET
date = EXCLUDED.date,
draft = EXCLUDED.draft,
updated_at = now();
-- name: DeleteDialogDraft :one
WITH deleted AS (
DELETE FROM dialog_drafts
WHERE user_id = $1
AND peer_type = $2
AND peer_id = $3
AND top_message_id = $4
RETURNING user_id
)
SELECT EXISTS (SELECT 1 FROM deleted)::boolean AS changed;
-- name: ListDialogDrafts :many
SELECT draft::text AS draft_json
FROM dialog_drafts
WHERE user_id = $1
ORDER BY date DESC, peer_type ASC, peer_id DESC, top_message_id DESC
LIMIT sqlc.arg(limit_count);
-- name: ClearDialogDrafts :many
WITH doomed AS (
SELECT d.user_id, d.peer_type, d.peer_id, d.top_message_id
FROM dialog_drafts d
WHERE d.user_id = $1
ORDER BY d.date DESC, d.peer_type ASC, d.peer_id DESC, d.top_message_id DESC
LIMIT sqlc.arg(limit_count)
),
deleted AS (
DELETE FROM dialog_drafts d
USING doomed
WHERE d.user_id = doomed.user_id
AND d.peer_type = doomed.peer_type
AND d.peer_id = doomed.peer_id
AND d.top_message_id = doomed.top_message_id
RETURNING d.draft::text AS draft_json
)
SELECT draft_json
FROM deleted;

View file

@ -0,0 +1,43 @@
-- name: GetAppConfig :one
SELECT client, hash, config_json::text AS config_json
FROM app_configs
WHERE client = $1;
-- name: UpsertAppConfig :exec
INSERT INTO app_configs (client, hash, config_json)
VALUES ($1, $2, sqlc.arg(config_json)::jsonb)
ON CONFLICT (client) DO UPDATE SET
hash = EXCLUDED.hash,
config_json = EXCLUDED.config_json,
updated_at = now();
-- name: ListCountries :many
SELECT
c.iso2,
c.default_name,
c.name,
c.hidden,
cc.country_code,
cc.prefixes,
cc.patterns
FROM countries c
JOIN country_codes cc ON cc.iso2 = c.iso2
ORDER BY c.order_index, c.iso2, cc.order_index, cc.country_code;
-- name: UpsertCountry :exec
INSERT INTO countries (iso2, default_name, name, hidden, order_index)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (iso2) DO UPDATE SET
default_name = EXCLUDED.default_name,
name = EXCLUDED.name,
hidden = EXCLUDED.hidden,
order_index = EXCLUDED.order_index,
updated_at = now();
-- name: UpsertCountryCode :exec
INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (iso2, country_code) DO UPDATE SET
prefixes = EXCLUDED.prefixes,
patterns = EXCLUDED.patterns,
order_index = EXCLUDED.order_index;

View file

@ -0,0 +1,47 @@
-- name: GetLangPackMeta :one
SELECT lang_pack, lang_code, version, strings_count
FROM lang_packs
WHERE lang_pack = $1 AND lang_code = $2;
-- name: UpsertLangPackMeta :exec
INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (lang_pack, lang_code) DO UPDATE SET
version = EXCLUDED.version,
strings_count = EXCLUDED.strings_count,
updated_at = now();
-- name: UpsertLangPackString :exec
INSERT INTO lang_pack_strings (
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET
version = EXCLUDED.version,
pluralized = EXCLUDED.pluralized,
value = EXCLUDED.value,
zero_value = EXCLUDED.zero_value,
one_value = EXCLUDED.one_value,
two_value = EXCLUDED.two_value,
few_value = EXCLUDED.few_value,
many_value = EXCLUDED.many_value,
other_value = EXCLUDED.other_value,
deleted = EXCLUDED.deleted,
updated_at = now();
-- name: ListLangPackStrings :many
SELECT
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
FROM lang_pack_strings
WHERE lang_pack = $1 AND lang_code = $2 AND NOT deleted
ORDER BY key;
-- name: GetLangPackStringsByKeys :many
SELECT
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
FROM lang_pack_strings
WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY(sqlc.arg(keys)::text[]) AND NOT deleted
ORDER BY key;

View file

@ -0,0 +1,331 @@
-- upload_parts ----------------------------------------------------------------
-- name: SaveUploadPart :exec
INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, bytes)
VALUES (
sqlc.arg(owner_user_id)::bigint,
sqlc.arg(file_id)::bigint,
sqlc.arg(part)::int,
sqlc.arg(total_parts)::int,
sqlc.arg(is_big)::boolean,
sqlc.arg(bytes)::bytea
)
ON CONFLICT (owner_user_id, file_id, part) DO UPDATE SET
total_parts = EXCLUDED.total_parts,
is_big = EXCLUDED.is_big,
bytes = EXCLUDED.bytes;
-- name: ListUploadParts :many
SELECT part, total_parts, is_big, bytes
FROM upload_parts
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND file_id = sqlc.arg(file_id)::bigint
ORDER BY part ASC;
-- name: DeleteUploadParts :exec
DELETE FROM upload_parts
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND file_id = sqlc.arg(file_id)::bigint;
-- file_blobs ------------------------------------------------------------------
-- name: PutFileBlob :exec
INSERT INTO file_blobs (location_key, backend, object_key, size, sha256, mime_type)
VALUES (
sqlc.arg(location_key)::text,
sqlc.arg(backend)::text,
sqlc.arg(object_key)::text,
sqlc.arg(size)::bigint,
sqlc.arg(sha256)::bytea,
sqlc.arg(mime_type)::text
)
ON CONFLICT (location_key) DO UPDATE SET
backend = EXCLUDED.backend,
object_key = EXCLUDED.object_key,
size = EXCLUDED.size,
sha256 = EXCLUDED.sha256,
mime_type = EXCLUDED.mime_type;
-- name: GetFileBlob :one
SELECT location_key, backend, object_key, size, sha256, mime_type
FROM file_blobs
WHERE location_key = sqlc.arg(location_key)::text;
-- documents -------------------------------------------------------------------
-- name: PutDocument :exec
INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs)
VALUES (
sqlc.arg(id)::bigint,
sqlc.arg(access_hash)::bigint,
sqlc.arg(file_reference)::bytea,
sqlc.arg(date)::int,
sqlc.arg(mime_type)::text,
sqlc.arg(size)::bigint,
sqlc.arg(dc_id)::int,
sqlc.arg(attributes_json)::jsonb,
sqlc.arg(thumbs_json)::jsonb
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
file_reference = EXCLUDED.file_reference,
date = EXCLUDED.date,
mime_type = EXCLUDED.mime_type,
size = EXCLUDED.size,
dc_id = EXCLUDED.dc_id,
attributes = EXCLUDED.attributes,
thumbs = EXCLUDED.thumbs;
-- name: GetDocument :one
SELECT id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes::text AS attributes_json,
thumbs::text AS thumbs_json
FROM documents
WHERE id = sqlc.arg(id)::bigint;
-- name: GetDocuments :many
SELECT id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes::text AS attributes_json,
thumbs::text AS thumbs_json
FROM documents
WHERE id = ANY(sqlc.arg(ids)::bigint[]);
-- photos ----------------------------------------------------------------------
-- name: PutPhoto :exec
INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes)
VALUES (
sqlc.arg(id)::bigint,
sqlc.arg(access_hash)::bigint,
sqlc.arg(file_reference)::bytea,
sqlc.arg(date)::int,
sqlc.arg(dc_id)::int,
sqlc.arg(has_stickers)::boolean,
sqlc.arg(sizes_json)::jsonb
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
file_reference = EXCLUDED.file_reference,
date = EXCLUDED.date,
dc_id = EXCLUDED.dc_id,
has_stickers = EXCLUDED.has_stickers,
sizes = EXCLUDED.sizes;
-- name: GetPhoto :one
SELECT id, access_hash, file_reference, date, dc_id, has_stickers,
sizes::text AS sizes_json
FROM photos
WHERE id = sqlc.arg(id)::bigint;
-- sticker_sets ----------------------------------------------------------------
-- name: PutStickerSet :exec
INSERT INTO sticker_sets (
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs, thumb_dc_id, thumb_version, document_ids, packs, sort_order, system_key
) VALUES (
sqlc.arg(id)::bigint,
sqlc.arg(access_hash)::bigint,
sqlc.arg(short_name)::text,
sqlc.arg(title)::text,
sqlc.arg(count)::int,
sqlc.arg(hash)::int,
sqlc.arg(set_kind)::text,
sqlc.arg(official)::boolean,
sqlc.arg(animated)::boolean,
sqlc.arg(videos)::boolean,
sqlc.arg(emojis)::boolean,
sqlc.arg(masks)::boolean,
sqlc.arg(installed)::boolean,
sqlc.arg(archived)::boolean,
sqlc.arg(installed_date)::int,
sqlc.arg(thumb_document_id)::bigint,
sqlc.arg(thumbs_json)::jsonb,
sqlc.arg(thumb_dc_id)::int,
sqlc.arg(thumb_version)::int,
sqlc.arg(document_ids_json)::jsonb,
sqlc.arg(packs_json)::jsonb,
sqlc.arg(sort_order)::int,
sqlc.arg(system_key)::text
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
short_name = EXCLUDED.short_name,
title = EXCLUDED.title,
count = EXCLUDED.count,
hash = EXCLUDED.hash,
set_kind = EXCLUDED.set_kind,
official = EXCLUDED.official,
animated = EXCLUDED.animated,
videos = EXCLUDED.videos,
emojis = EXCLUDED.emojis,
masks = EXCLUDED.masks,
installed = EXCLUDED.installed,
archived = EXCLUDED.archived,
installed_date = EXCLUDED.installed_date,
thumb_document_id = EXCLUDED.thumb_document_id,
thumbs = EXCLUDED.thumbs,
thumb_dc_id = EXCLUDED.thumb_dc_id,
thumb_version = EXCLUDED.thumb_version,
document_ids = EXCLUDED.document_ids,
packs = EXCLUDED.packs,
sort_order = EXCLUDED.sort_order,
system_key = EXCLUDED.system_key;
-- name: GetStickerSetByID :one
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key
FROM sticker_sets
WHERE id = sqlc.arg(id)::bigint;
-- name: GetStickerSetByShortName :one
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key
FROM sticker_sets
WHERE short_name = sqlc.arg(short_name)::text;
-- name: GetStickerSetBySystemKey :one
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key
FROM sticker_sets
WHERE system_key = sqlc.arg(system_key)::text;
-- name: ListStickerSetsByKind :many
SELECT
id, access_hash, short_name, title, count, hash, set_kind,
official, animated, videos, emojis, masks, installed, archived, installed_date,
thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version,
document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key
FROM sticker_sets
WHERE set_kind = sqlc.arg(set_kind)::text
ORDER BY sort_order ASC, id ASC;
-- name: CountStickerSets :one
SELECT count(*)::int AS total FROM sticker_sets;
-- available_reactions ---------------------------------------------------------
-- name: PutAvailableReaction :exec
INSERT INTO available_reactions (
reaction, title, inactive, premium,
static_icon_id, appear_animation_id, select_animation_id,
activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order
) VALUES (
sqlc.arg(reaction)::text,
sqlc.arg(title)::text,
sqlc.arg(inactive)::boolean,
sqlc.arg(premium)::boolean,
sqlc.arg(static_icon_id)::bigint,
sqlc.arg(appear_animation_id)::bigint,
sqlc.arg(select_animation_id)::bigint,
sqlc.arg(activate_animation_id)::bigint,
sqlc.arg(effect_animation_id)::bigint,
sqlc.arg(around_animation_id)::bigint,
sqlc.arg(center_icon_id)::bigint,
sqlc.arg(sort_order)::int
)
ON CONFLICT (reaction) DO UPDATE SET
title = EXCLUDED.title,
inactive = EXCLUDED.inactive,
premium = EXCLUDED.premium,
static_icon_id = EXCLUDED.static_icon_id,
appear_animation_id = EXCLUDED.appear_animation_id,
select_animation_id = EXCLUDED.select_animation_id,
activate_animation_id = EXCLUDED.activate_animation_id,
effect_animation_id = EXCLUDED.effect_animation_id,
around_animation_id = EXCLUDED.around_animation_id,
center_icon_id = EXCLUDED.center_icon_id,
sort_order = EXCLUDED.sort_order;
-- name: ListAvailableReactions :many
SELECT
reaction, title, inactive, premium,
static_icon_id, appear_animation_id, select_animation_id,
activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order
FROM available_reactions
ORDER BY sort_order ASC, reaction ASC;
-- name: CountAvailableReactions :one
SELECT count(*)::int AS total FROM available_reactions;
-- profile_photos --------------------------------------------------------------
-- name: AddProfilePhoto :exec
INSERT INTO profile_photos (owner_peer_type, owner_peer_id, photo_id, date, active, sort_order)
VALUES (
sqlc.arg(owner_peer_type)::text,
sqlc.arg(owner_peer_id)::bigint,
sqlc.arg(photo_id)::bigint,
sqlc.arg(date)::int,
true,
sqlc.arg(sort_order)::bigint
)
ON CONFLICT (owner_peer_type, owner_peer_id, photo_id) DO UPDATE SET
date = EXCLUDED.date,
active = true,
sort_order = EXCLUDED.sort_order;
-- name: NextProfilePhotoOrder :one
SELECT COALESCE(MAX(sort_order), 0)::bigint AS max_order
FROM profile_photos
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint;
-- name: CurrentProfilePhoto :one
SELECT photo_id
FROM profile_photos
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint
AND active
ORDER BY sort_order DESC
LIMIT 1;
-- name: CurrentProfilePhotosForOwners :many
SELECT DISTINCT ON (pp.owner_peer_id)
pp.owner_peer_id,
pp.photo_id,
ph.dc_id,
ph.sizes::text AS sizes_json
FROM profile_photos pp
JOIN photos ph ON ph.id = pp.photo_id
WHERE pp.owner_peer_type = sqlc.arg(owner_peer_type)::text
AND pp.owner_peer_id = ANY(sqlc.arg(owner_ids)::bigint[])
AND pp.active
ORDER BY pp.owner_peer_id, pp.sort_order DESC;
-- name: ListProfilePhotos :many
SELECT photo_id
FROM profile_photos
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint
AND active
AND (sqlc.arg(max_id)::bigint <= 0 OR photo_id < sqlc.arg(max_id)::bigint)
ORDER BY sort_order DESC
OFFSET sqlc.arg(offset_count)::int
LIMIT sqlc.arg(limit_count)::int;
-- name: CountProfilePhotos :one
SELECT count(*)::int AS total
FROM profile_photos
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint
AND active;
-- name: DeactivateProfilePhotos :many
UPDATE profile_photos
SET active = false
WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text
AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint
AND photo_id = ANY(sqlc.arg(photo_ids)::bigint[])
AND active
RETURNING photo_id;

View file

@ -0,0 +1,932 @@
-- name: CreateMessage :one
WITH pm AS (
INSERT INTO private_messages (
sender_user_id,
recipient_user_id,
random_id,
message_date,
body,
entities
) VALUES (
sqlc.arg(from_user_id),
sqlc.arg(owner_user_id),
0,
sqlc.arg(message_date),
sqlc.arg(body),
sqlc.arg(entities_json)::jsonb
)
RETURNING id, sender_user_id
),
box AS (
INSERT INTO message_boxes (
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
outgoing,
body,
entities,
pts
)
SELECT
sqlc.arg(owner_user_id),
sqlc.arg(box_id),
pm.id,
pm.sender_user_id,
sqlc.arg(peer_type),
sqlc.arg(peer_id),
sqlc.arg(from_user_id),
sqlc.arg(message_date),
sqlc.arg(outgoing),
sqlc.arg(body),
sqlc.arg(entities_json)::jsonb,
sqlc.arg(pts)
FROM pm
RETURNING
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
pts
)
SELECT
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities_json,
pts
FROM box;
-- name: CreatePrivateMessage :one
INSERT INTO private_messages (
sender_user_id,
recipient_user_id,
random_id,
message_date,
body,
entities,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
media
) VALUES (
$1, $2, $3, $4, $5, sqlc.arg(entities_json)::jsonb,
sqlc.arg(silent)::boolean,
sqlc.arg(noforwards)::boolean,
sqlc.arg(reply_to_msg_id)::int,
sqlc.arg(reply_to_peer_type)::text,
sqlc.arg(reply_to_peer_id)::bigint,
sqlc.arg(reply_to_top_id)::int,
sqlc.arg(quote_text)::text,
sqlc.arg(quote_entities_json)::jsonb,
sqlc.arg(quote_offset)::int,
sqlc.arg(fwd_from_peer_type)::text,
sqlc.arg(fwd_from_peer_id)::bigint,
sqlc.arg(fwd_from_name)::text,
sqlc.arg(fwd_date)::int,
sqlc.arg(media_json)::jsonb
)
ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING
RETURNING
id,
sender_user_id,
recipient_user_id,
random_id,
message_date,
edit_date,
body,
entities::text AS entities_json;
-- name: GetPrivateMessageByRandomID :one
SELECT
id,
sender_user_id,
recipient_user_id,
random_id,
message_date,
edit_date,
body,
entities::text AS entities_json
FROM private_messages
WHERE sender_user_id = $1
AND random_id = $2
AND random_id <> 0;
-- name: CreateMessageBox :one
INSERT INTO message_boxes (
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
outgoing,
body,
entities,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, sqlc.arg(entities_json)::jsonb,
sqlc.arg(silent)::boolean,
sqlc.arg(noforwards)::boolean,
sqlc.arg(reply_to_msg_id)::int,
sqlc.arg(reply_to_peer_type)::text,
sqlc.arg(reply_to_peer_id)::bigint,
sqlc.arg(reply_to_top_id)::int,
sqlc.arg(quote_text)::text,
sqlc.arg(quote_entities_json)::jsonb,
sqlc.arg(quote_offset)::int,
sqlc.arg(fwd_from_peer_type)::text,
sqlc.arg(fwd_from_peer_id)::bigint,
sqlc.arg(fwd_from_name)::text,
sqlc.arg(fwd_date)::int,
sqlc.arg(pts)::int,
sqlc.arg(media_json)::jsonb
)
RETURNING
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json;
-- name: GetMessageBoxByPrivateMessage :one
SELECT
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
FROM message_boxes
WHERE owner_user_id = $1
AND private_message_id = $2
AND NOT deleted;
-- name: GetMessageBoxForReply :one
SELECT
box_id,
private_message_id,
message_sender_id
FROM message_boxes
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND box_id = sqlc.arg(box_id)::int
AND NOT deleted
LIMIT 1;
-- name: GetMessageBoxesForForward :many
WITH requested AS (
SELECT
id::int AS box_id,
ord::int AS ord
FROM unnest(sqlc.arg(box_ids)::int[]) WITH ORDINALITY AS r(id, ord)
)
SELECT
r.ord,
m.box_id,
m.private_message_id,
m.owner_user_id,
m.message_sender_id,
m.peer_type,
m.peer_id,
m.from_user_id,
m.message_date,
m.edit_date,
m.outgoing,
m.body,
m.entities::text AS entities_json,
m.silent,
m.noforwards,
m.reply_to_msg_id,
m.reply_to_peer_type,
m.reply_to_peer_id,
m.reply_to_top_id,
m.quote_text,
m.quote_entities::text AS quote_entities_json,
m.quote_offset,
m.fwd_from_peer_type,
m.fwd_from_peer_id,
m.fwd_from_name,
m.fwd_date,
m.pts,
m.media::text AS media_json
FROM requested r
JOIN message_boxes m
ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND m.box_id = r.box_id
AND NOT m.deleted
ORDER BY r.ord ASC;
-- name: MaxMessageBoxID :one
SELECT COALESCE(MAX(box_id), 0)::int AS max_box_id
FROM message_boxes
WHERE owner_user_id = $1;
-- name: ListMessagesByUser :many
WITH load_params AS (
SELECT
sqlc.arg(offset_id)::int AS offset_id,
sqlc.arg(offset_date)::int AS offset_date,
sqlc.arg(add_offset)::int AS add_offset,
sqlc.arg(limit_count)::int AS limit_count,
CASE
WHEN sqlc.arg(add_offset)::int >= 0 THEN 'backward'
WHEN sqlc.arg(add_offset)::int + sqlc.arg(limit_count)::int > 0 THEN 'around'
ELSE 'forward'
END::text AS load_type
),
base AS NOT MATERIALIZED (
SELECT
m.box_id,
m.private_message_id,
m.owner_user_id,
m.peer_type,
m.peer_id,
m.from_user_id,
m.message_date,
m.edit_date,
m.outgoing,
m.body,
m.entities::text AS entities_json,
m.silent,
m.noforwards,
m.reply_to_msg_id,
m.reply_to_peer_type,
m.reply_to_peer_id,
m.reply_to_top_id,
m.quote_text,
m.quote_entities::text AS quote_entities_json,
m.quote_offset,
m.fwd_from_peer_type,
m.fwd_from_peer_id,
m.fwd_from_name,
m.fwd_date,
m.pts,
m.media::text AS media_json,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
COALESCE(peer_u.first_name, '')::text AS peer_first_name,
COALESCE(peer_u.last_name, '')::text AS peer_last_name,
COALESCE(peer_u.username, '')::text AS peer_username,
COALESCE(peer_u.country_code, '')::text AS peer_country_code,
COALESCE(peer_u.verified, false)::boolean AS peer_verified,
COALESCE(peer_u.support, false)::boolean AS peer_support,
COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at,
COALESCE(from_u.id, 0)::bigint AS from_user_user_id,
COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash,
COALESCE(from_u.phone, '')::text AS from_user_phone,
COALESCE(from_u.first_name, '')::text AS from_user_first_name,
COALESCE(from_u.last_name, '')::text AS from_user_last_name,
COALESCE(from_u.username, '')::text AS from_user_username,
COALESCE(from_u.country_code, '')::text AS from_user_country_code,
COALESCE(from_u.verified, false)::boolean AS from_user_verified,
COALESCE(from_u.support, false)::boolean AS from_user_support,
COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at
FROM message_boxes m
LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
LEFT JOIN users from_u ON from_u.id = m.from_user_id
WHERE m.owner_user_id = $1
AND NOT m.deleted
AND (
NOT sqlc.arg(has_peer)::boolean
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
)
AND (
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int)
AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int)
),
total AS (
SELECT count(*)::int AS total_count
FROM base
WHERE sqlc.arg(need_total_count)::boolean
),
backward AS (
SELECT b.*
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'backward'
AND (
(p.offset_date > 0 AND b.message_date < p.offset_date)
OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id))
)
ORDER BY b.box_id DESC
OFFSET GREATEST((SELECT add_offset FROM load_params), 0)
LIMIT (SELECT limit_count FROM load_params)
),
around_forward AS (
SELECT f.*
FROM (
SELECT b.*
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'around'
AND (
(p.offset_date > 0 AND b.message_date >= p.offset_date)
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id)
)
ORDER BY b.box_id ASC
LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params))
) f
),
around_backward AS (
SELECT b.*
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'around'
AND (
(p.offset_date > 0 AND b.message_date < p.offset_date)
OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id))
)
ORDER BY b.box_id DESC
LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0)
),
forward AS (
SELECT f.*
FROM (
SELECT b.*
FROM base b
CROSS JOIN load_params p
WHERE p.load_type = 'forward'
AND (
(p.offset_date > 0 AND b.message_date >= p.offset_date)
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id)
)
ORDER BY b.box_id ASC
LIMIT (SELECT limit_count FROM load_params)
) f
),
paged AS (
SELECT * FROM backward
UNION ALL
SELECT * FROM around_forward
UNION ALL
SELECT * FROM around_backward
UNION ALL
SELECT * FROM forward
)
SELECT
box_id,
private_message_id,
owner_user_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media_json,
peer_user_id,
peer_access_hash,
peer_phone,
peer_first_name,
peer_last_name,
peer_username,
peer_country_code,
peer_verified,
peer_support,
peer_last_seen_at,
from_user_user_id,
from_user_access_hash,
from_user_phone,
from_user_first_name,
from_user_last_name,
from_user_username,
from_user_country_code,
from_user_verified,
from_user_support,
from_user_last_seen_at,
COALESCE(total.total_count, 0)::int AS total_count
FROM paged
CROSS JOIN total
ORDER BY box_id DESC;
-- name: GetMessageBoxesByIDs :many
SELECT
wanted.box_id AS requested_box_id,
m.box_id,
m.private_message_id,
m.owner_user_id,
m.peer_type,
m.peer_id,
m.from_user_id,
m.message_date,
m.edit_date,
m.outgoing,
m.body,
m.entities::text AS entities_json,
m.silent,
m.noforwards,
m.reply_to_msg_id,
m.reply_to_peer_type,
m.reply_to_peer_id,
m.reply_to_top_id,
m.quote_text,
m.quote_entities::text AS quote_entities_json,
m.quote_offset,
m.fwd_from_peer_type,
m.fwd_from_peer_id,
m.fwd_from_name,
m.fwd_date,
m.pts,
m.media::text AS media_json,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
COALESCE(peer_u.first_name, '')::text AS peer_first_name,
COALESCE(peer_u.last_name, '')::text AS peer_last_name,
COALESCE(peer_u.username, '')::text AS peer_username,
COALESCE(peer_u.country_code, '')::text AS peer_country_code,
COALESCE(peer_u.verified, false)::boolean AS peer_verified,
COALESCE(peer_u.support, false)::boolean AS peer_support,
COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at,
COALESCE(from_u.id, 0)::bigint AS from_user_user_id,
COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash,
COALESCE(from_u.phone, '')::text AS from_user_phone,
COALESCE(from_u.first_name, '')::text AS from_user_first_name,
COALESCE(from_u.last_name, '')::text AS from_user_last_name,
COALESCE(from_u.username, '')::text AS from_user_username,
COALESCE(from_u.country_code, '')::text AS from_user_country_code,
COALESCE(from_u.verified, false)::boolean AS from_user_verified,
COALESCE(from_u.support, false)::boolean AS from_user_support,
COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at
FROM unnest(@box_ids::int[]) WITH ORDINALITY AS wanted(box_id, ord)
JOIN message_boxes m
ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.box_id = wanted.box_id
AND NOT m.deleted
LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
LEFT JOIN users from_u ON from_u.id = m.from_user_id
ORDER BY wanted.ord ASC;
-- name: GetMessageBoxForEdit :one
SELECT
box_id,
private_message_id,
owner_user_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
FROM message_boxes
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND box_id = sqlc.arg(box_id)::int
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND NOT deleted
LIMIT 1
FOR UPDATE;
-- name: ListVisibleMessageBoxesByPrivateMessage :many
SELECT
box_id,
private_message_id,
owner_user_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json
FROM message_boxes
WHERE message_sender_id = sqlc.arg(message_sender_id)::bigint
AND private_message_id = sqlc.arg(private_message_id)::bigint
AND NOT deleted
ORDER BY owner_user_id ASC, box_id ASC
FOR UPDATE;
-- name: UpdatePrivateMessageEdit :exec
UPDATE private_messages
SET body = sqlc.arg(body)::text,
entities = sqlc.arg(entities_json)::jsonb,
edit_date = sqlc.arg(edit_date)::int
WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint
AND id = sqlc.arg(private_message_id)::bigint;
-- name: UpdateMessageBoxEdit :one
UPDATE message_boxes
SET body = sqlc.arg(body)::text,
entities = sqlc.arg(entities_json)::jsonb,
edit_date = sqlc.arg(edit_date)::int,
pts = sqlc.arg(pts)::int
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND box_id = sqlc.arg(box_id)::int
AND NOT deleted
RETURNING
box_id,
private_message_id,
owner_user_id,
message_sender_id,
peer_type,
peer_id,
from_user_id,
message_date,
edit_date,
outgoing,
body,
entities::text AS entities_json,
silent,
noforwards,
reply_to_msg_id,
reply_to_peer_type,
reply_to_peer_id,
reply_to_top_id,
quote_text,
quote_entities::text AS quote_entities_json,
quote_offset,
fwd_from_peer_type,
fwd_from_peer_id,
fwd_from_name,
fwd_date,
pts,
media::text AS media_json;
-- name: GetDialogReadStateForUpdate :one
SELECT
user_id,
peer_type,
peer_id,
top_message_id,
read_inbox_max_id,
unread_count
FROM dialogs
WHERE user_id = sqlc.arg(user_id)::bigint
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
FOR UPDATE;
-- name: LatestIncomingReadReceiptCandidate :one
SELECT
m.message_sender_id,
m.private_message_id,
sender_box.owner_user_id AS sender_owner_user_id,
sender_box.box_id AS sender_box_id
FROM message_boxes m
JOIN message_boxes sender_box
ON sender_box.message_sender_id = m.message_sender_id
AND sender_box.private_message_id = m.private_message_id
AND sender_box.owner_user_id = m.message_sender_id
AND sender_box.outgoing
AND NOT sender_box.deleted
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND NOT m.outgoing
AND NOT m.deleted
AND m.box_id > sqlc.arg(old_read_inbox_max_id)::int
AND m.box_id <= sqlc.arg(new_read_inbox_max_id)::int
ORDER BY m.box_id DESC
LIMIT 1;
-- name: UpdateDialogReadInbox :one
UPDATE dialogs d
SET
read_inbox_max_id = GREATEST(d.read_inbox_max_id, sqlc.arg(read_inbox_max_id)::int),
unread_count = (
SELECT count(*)::int
FROM message_boxes m
WHERE m.owner_user_id = d.user_id
AND m.peer_type = d.peer_type
AND m.peer_id = d.peer_id
AND NOT m.deleted
AND NOT m.outgoing
AND m.box_id > GREATEST(d.read_inbox_max_id, sqlc.arg(read_inbox_max_id)::int)
),
unread_mark = false,
unread_mentions_count = 0,
unread_reactions_count = 0,
updated_at = now()
WHERE d.user_id = sqlc.arg(user_id)::bigint
AND d.peer_type = sqlc.arg(peer_type)::text
AND d.peer_id = sqlc.arg(peer_id)::bigint
RETURNING
d.read_inbox_max_id,
d.unread_count;
-- name: UpdateDialogReadOutbox :one
UPDATE dialogs
SET
read_outbox_max_id = GREATEST(read_outbox_max_id, sqlc.arg(read_outbox_max_id)::int),
updated_at = now()
WHERE user_id = sqlc.arg(user_id)::bigint
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND read_outbox_max_id < sqlc.arg(read_outbox_max_id)::int
RETURNING read_outbox_max_id;
-- name: GetOutboxMessageForReadDate :one
SELECT box_id
FROM message_boxes
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND box_id = sqlc.arg(box_id)::int
AND outgoing
AND NOT deleted
LIMIT 1;
-- name: GetOutboxReadDate :one
SELECT COALESCE(MIN(date), 0)::int AS read_date
FROM user_update_events
WHERE user_id = sqlc.arg(user_id)::bigint
AND event_type = 'read_history_outbox'
AND peer_type = sqlc.arg(peer_type)::text
AND peer_id = sqlc.arg(peer_id)::bigint
AND max_id >= sqlc.arg(message_id)::int;
-- name: DeleteMessageBoxesByIDs :many
WITH updated AS (
UPDATE message_boxes m
SET deleted = true
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.box_id = ANY(sqlc.arg(box_ids)::int[])
AND NOT m.deleted
RETURNING
m.owner_user_id,
m.box_id,
m.private_message_id,
m.message_sender_id,
m.peer_type,
m.peer_id
)
SELECT
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id
FROM updated
ORDER BY box_id ASC;
-- name: DeleteMessageBoxesByPeer :many
WITH updated AS (
UPDATE message_boxes m
SET deleted = true
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int)
AND NOT m.deleted
RETURNING
m.owner_user_id,
m.box_id,
m.private_message_id,
m.message_sender_id,
m.peer_type,
m.peer_id
)
SELECT
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id
FROM updated
ORDER BY box_id ASC;
-- name: DeleteMessageBoxesByPeerBatch :many
WITH target AS (
SELECT
m.owner_user_id,
m.box_id
FROM message_boxes m
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int)
AND NOT m.deleted
ORDER BY m.box_id DESC
LIMIT sqlc.arg(limit_count)::int
FOR UPDATE SKIP LOCKED
),
updated AS (
UPDATE message_boxes m
SET deleted = true
FROM target t
WHERE m.owner_user_id = t.owner_user_id
AND m.box_id = t.box_id
RETURNING
m.owner_user_id,
m.box_id,
m.private_message_id,
m.message_sender_id,
m.peer_type,
m.peer_id
)
SELECT
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id
FROM updated
ORDER BY box_id ASC;
-- name: HasDeletableMessageBoxByPeer :one
SELECT EXISTS (
SELECT 1
FROM message_boxes m
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND m.peer_type = sqlc.arg(peer_type)::text
AND m.peer_id = sqlc.arg(peer_id)::bigint
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int)
AND NOT m.deleted
LIMIT 1
)::boolean AS more;
-- name: DeleteMessageBoxesByPrivateMessages :many
WITH requested AS (
SELECT
(sqlc.arg(message_sender_ids)::bigint[])[i] AS message_sender_id,
(sqlc.arg(private_message_ids)::bigint[])[i] AS private_message_id
FROM generate_subscripts(sqlc.arg(private_message_ids)::bigint[], 1) AS g(i)
WHERE i <= cardinality(sqlc.arg(message_sender_ids)::bigint[])
),
deduped AS (
SELECT DISTINCT message_sender_id, private_message_id
FROM requested
),
updated AS (
UPDATE message_boxes m
SET deleted = true
FROM deduped d
WHERE m.message_sender_id = d.message_sender_id
AND m.private_message_id = d.private_message_id
AND NOT m.deleted
RETURNING
m.owner_user_id,
m.box_id,
m.private_message_id,
m.message_sender_id,
m.peer_type,
m.peer_id
)
SELECT
owner_user_id,
box_id,
private_message_id,
message_sender_id,
peer_type,
peer_id
FROM updated
ORDER BY owner_user_id ASC, box_id ASC;
-- name: TopVisibleMessageBoxByPeer :one
SELECT
box_id,
message_date
FROM message_boxes
WHERE owner_user_id = $1
AND peer_type = $2
AND peer_id = $3
AND NOT deleted
ORDER BY box_id DESC
LIMIT 1;

View file

@ -0,0 +1,23 @@
-- name: UpsertTempAuthKeyBinding :exec
INSERT INTO temp_auth_key_bindings (
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
perm_auth_key_id = EXCLUDED.perm_auth_key_id,
nonce = EXCLUDED.nonce,
temp_session_id = EXCLUDED.temp_session_id,
expires_at = EXCLUDED.expires_at,
encrypted_message = EXCLUDED.encrypted_message,
created_at = now();
-- name: GetTempAuthKeyBinding :one
SELECT
temp_auth_key_id,
perm_auth_key_id,
nonce,
temp_session_id,
expires_at,
encrypted_message
FROM temp_auth_key_bindings
WHERE temp_auth_key_id = $1;

View file

@ -0,0 +1,24 @@
-- name: GetUpdateState :one
SELECT auth_key_id, user_id, pts, qts, date, seq
FROM update_states
WHERE auth_key_id = $1
AND user_id = $2;
-- name: UpsertUpdateState :exec
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
pts = EXCLUDED.pts,
qts = EXCLUDED.qts,
date = EXCLUDED.date,
seq = EXCLUDED.seq,
updated_at = now();
-- name: DeleteUpdateState :exec
DELETE FROM update_states
WHERE auth_key_id = $1
AND user_id = $2;
-- name: DeleteUpdateStatesByAuthKey :exec
DELETE FROM update_states
WHERE auth_key_id = $1;

View file

@ -0,0 +1,104 @@
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
-- name: GetUsersByIDs :many
SELECT *
FROM users
WHERE id = ANY(sqlc.arg(ids)::bigint[])
ORDER BY id;
-- name: GetUserByPhone :one
SELECT * FROM users WHERE phone = $1;
-- name: GetUsersByPhones :many
SELECT *
FROM users
WHERE phone = ANY(sqlc.arg(phones)::text[])
ORDER BY id;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '';
-- name: SearchUsers :many
WITH matched AS (
SELECT
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.about,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS contact,
COALESCE(c.mutual, false)::boolean AS mutual,
CASE
WHEN sqlc.arg(phone_query)::text <> '' AND u.phone = sqlc.arg(phone_query)::text THEN 0
WHEN lower(u.username) = sqlc.arg(query_lower)::text THEN 1
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = sqlc.arg(query_lower)::text THEN 2
WHEN lower(u.first_name) = sqlc.arg(query_lower)::text THEN 3
WHEN c.contact_user_id IS NOT NULL THEN 4
ELSE 5
END AS rank
FROM users u
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
WHERE u.id <> sqlc.arg(current_user_id)::bigint
AND sqlc.arg(query_lower)::text <> ''
AND (
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
OR lower(u.username) LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(u.first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(u.last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(c.contact_first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(c.contact_last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
)
)
SELECT
id,
access_hash,
phone,
first_name,
last_name,
about,
username,
country_code,
verified,
support,
last_seen_at,
contact,
mutual
FROM matched
ORDER BY contact DESC, rank, id
LIMIT sqlc.arg(limit_count);
-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: UpdateUserUsername :one
UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: UpdateUserLastSeen :exec
UPDATE users
SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint),
updated_at = now()
WHERE id = sqlc.arg(id)::bigint;
-- name: UpdateUserProfile :one
UPDATE users
SET first_name = $2,
last_name = $3,
about = $4,
updated_at = now()
WHERE id = $1
RETURNING *;

View file

@ -0,0 +1,464 @@
-- name: AppendUserUpdateEvent :exec
INSERT INTO user_update_events (
user_id,
pts,
pts_count,
date,
event_type,
event_bool,
event_peers,
peer_settings,
message_ids,
dialog_filter,
filter_order,
folder_peers,
message_box_id,
peer_type,
peer_id,
filter_id,
max_id,
still_unread_count,
tags_enabled
) VALUES (
$1,
$2,
$3,
$4,
$5,
sqlc.arg(event_bool)::boolean,
sqlc.arg(event_peers)::jsonb,
sqlc.arg(peer_settings)::jsonb,
sqlc.arg(message_ids)::jsonb,
sqlc.arg(dialog_filter)::jsonb,
sqlc.arg(filter_order)::jsonb,
sqlc.arg(folder_peers)::jsonb,
sqlc.narg(message_box_id),
sqlc.narg(peer_type)::text,
sqlc.narg(peer_id)::bigint,
sqlc.arg(filter_id)::int,
sqlc.arg(max_id)::int,
sqlc.arg(still_unread_count)::int,
sqlc.arg(tags_enabled)::boolean
)
ON CONFLICT (user_id, pts) DO NOTHING;
-- name: ListUserUpdateEventsAfter :many
SELECT
e.user_id,
e.pts,
e.pts_count,
e.date,
e.event_type,
e.event_bool,
COALESCE(e.event_peers::text, '[]')::text AS event_peers_json,
COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json,
COALESCE(e.message_ids::text, '[]')::text AS message_ids_json,
COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json,
COALESCE(e.filter_order::text, '[]')::text AS filter_order_json,
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.peer_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id,
e.max_id,
e.still_unread_count,
e.tags_enabled,
COALESCE(m.box_id, 0)::int AS message_id,
COALESCE(m.private_message_id, 0)::bigint AS private_message_id,
COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id,
COALESCE(m.peer_type, '')::text AS peer_type,
COALESCE(m.peer_id, 0)::bigint AS peer_id,
COALESCE(m.from_user_id, 0)::bigint AS from_user_id,
COALESCE(m.message_date, 0)::int AS message_date,
COALESCE(m.edit_date, 0)::int AS edit_date,
COALESCE(m.outgoing, false)::boolean AS outgoing,
COALESCE(m.body, '')::text AS body,
COALESCE(m.entities::text, '[]')::text AS message_entities_json,
COALESCE(m.silent, false)::boolean AS silent,
COALESCE(m.noforwards, false)::boolean AS noforwards,
COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_id,
COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type,
COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id,
COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id,
COALESCE(m.quote_text, '')::text AS quote_text,
COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json,
COALESCE(m.quote_offset, 0)::int AS quote_offset,
COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type,
COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id,
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
COALESCE(m.fwd_date, 0)::int AS fwd_date,
COALESCE(m.media::text, '{}')::text AS media_json,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
COALESCE(peer_u.first_name, '')::text AS peer_first_name,
COALESCE(peer_u.last_name, '')::text AS peer_last_name,
COALESCE(peer_u.username, '')::text AS peer_username,
COALESCE(peer_u.country_code, '')::text AS peer_country_code,
COALESCE(peer_u.verified, false)::boolean AS peer_verified,
COALESCE(peer_u.support, false)::boolean AS peer_support,
COALESCE(from_u.id, 0)::bigint AS from_user_user_id,
COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash,
COALESCE(from_u.phone, '')::text AS from_user_phone,
COALESCE(from_u.first_name, '')::text AS from_user_first_name,
COALESCE(from_u.last_name, '')::text AS from_user_last_name,
COALESCE(from_u.username, '')::text AS from_user_username,
COALESCE(from_u.country_code, '')::text AS from_user_country_code,
COALESCE(from_u.verified, false)::boolean AS from_user_verified,
COALESCE(from_u.support, false)::boolean AS from_user_support,
COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id,
COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash,
COALESCE(fwd_u.phone, '')::text AS fwd_user_phone,
COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name,
COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name,
COALESCE(fwd_u.username, '')::text AS fwd_user_username,
COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code,
COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified,
COALESCE(fwd_u.support, false)::boolean AS fwd_user_support,
COALESCE(reply_u.id, 0)::bigint AS reply_user_id,
COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash,
COALESCE(reply_u.phone, '')::text AS reply_user_phone,
COALESCE(reply_u.first_name, '')::text AS reply_user_first_name,
COALESCE(reply_u.last_name, '')::text AS reply_user_last_name,
COALESCE(reply_u.username, '')::text AS reply_user_username,
COALESCE(reply_u.country_code, '')::text AS reply_user_country_code,
COALESCE(reply_u.verified, false)::boolean AS reply_user_verified,
COALESCE(reply_u.support, false)::boolean AS reply_user_support,
COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id,
COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash,
COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id,
COALESCE(fwd_ch.title, '')::text AS fwd_channel_title,
COALESCE(fwd_ch.about, '')::text AS fwd_channel_about,
COALESCE(fwd_ch.username, '')::text AS fwd_channel_username,
COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast,
COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup,
COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum,
COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards,
COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures,
COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden,
COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds,
COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights,
COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count,
COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count,
COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count,
COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count,
COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id,
COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id,
COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts,
COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period,
COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date,
COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted,
COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id,
COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash,
COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id,
COALESCE(reply_ch.title, '')::text AS reply_channel_title,
COALESCE(reply_ch.about, '')::text AS reply_channel_about,
COALESCE(reply_ch.username, '')::text AS reply_channel_username,
COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast,
COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup,
COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum,
COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards,
COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures,
COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden,
COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds,
COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights,
COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count,
COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count,
COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count,
COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count,
COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id,
COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id,
COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts,
COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period,
COALESCE(reply_ch.date, 0)::int AS reply_channel_date,
COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted
FROM user_update_events e
LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id
LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
LEFT JOIN users from_u ON from_u.id = m.from_user_id
LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id
LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id
LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id
LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id
WHERE e.user_id = $1
AND e.pts > $2
ORDER BY e.pts ASC
LIMIT sqlc.arg(limit_count);
-- name: MaxUserPts :one
SELECT COALESCE(MAX(pts), 0)::int AS max_pts
FROM user_update_events
WHERE user_id = $1;
-- name: RecentUserPts :many
-- 取某 user 最近的一段 pts降序供计算「最大连续已提交 pts」用。
-- 只看顶部窗口:瞬时空洞只可能出现在最近在途事务区,窗口足够大即可覆盖其下方连续。
SELECT pts, pts_count
FROM user_update_events
WHERE user_id = $1
ORDER BY pts DESC
LIMIT sqlc.arg(window_size);
-- name: EnsureUserUpdateWatermark :exec
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
VALUES ($1, 0)
ON CONFLICT (user_id) DO NOTHING;
-- name: GetUserUpdateWatermark :one
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1;
-- name: LockUserUpdateWatermark :one
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1
FOR UPDATE;
-- name: NextUserPtsAfter :many
SELECT pts, pts_count
FROM user_update_events
WHERE user_id = $1
AND pts > $2
ORDER BY pts ASC
LIMIT sqlc.arg(limit_count);
-- name: SaveUserUpdateWatermark :exec
INSERT INTO user_update_watermarks (user_id, contiguous_pts, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (user_id) DO UPDATE SET
contiguous_pts = GREATEST(user_update_watermarks.contiguous_pts, EXCLUDED.contiguous_pts),
updated_at = now();
-- name: EnqueueDispatch :exec
INSERT INTO dispatch_outbox (
target_user_id,
pts,
event_type,
exclude_auth_key_id,
exclude_session_id
) VALUES (
$1, $2, $3, $4, $5
)
ON CONFLICT DO NOTHING;
-- name: ClaimDispatchOutbox :many
WITH picked AS (
SELECT target_user_id, id
FROM dispatch_outbox
WHERE (
status = 'pending'
AND next_attempt_at <= now()
)
OR (
status = 'dispatching'
AND updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int)
)
ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC
LIMIT sqlc.arg(limit_count)
FOR UPDATE SKIP LOCKED
)
UPDATE dispatch_outbox d
SET
status = 'dispatching',
attempts = d.attempts + 1,
updated_at = now()
FROM picked p
WHERE d.target_user_id = p.target_user_id
AND d.id = p.id
RETURNING
d.id,
d.target_user_id,
d.pts,
d.event_type,
d.exclude_auth_key_id,
d.exclude_session_id,
d.attempts;
-- name: MarkDispatchDelivered :exec
-- 方案 A投递成功即删除。outbox 是任务队列delivered 行无保留价值
-- (消息在 message_boxes、离线补偿在 user_update_events删除让表维持「未完成任务」小稳态。
DELETE FROM dispatch_outbox
WHERE target_user_id = $1
AND id = $2;
-- name: MarkDispatchFailed :exec
UPDATE dispatch_outbox
SET
status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END,
next_attempt_at = CASE
WHEN attempts >= 5 THEN next_attempt_at
ELSE now() + make_interval(secs => LEAST(60, attempts * attempts))
END,
last_error = $3,
updated_at = now()
WHERE target_user_id = $1
AND id = $2;
-- name: BatchListDispatchEvents :many
-- 按 (user_id, pts) 精确批量取账号事件,供 outbox worker 一次性加载一批 claim 的事件详情,
-- 取代逐条 ListUserUpdateEventsAfter。列与 ListUserUpdateEventsAfter 完全一致以复用转换逻辑。
SELECT
e.user_id,
e.pts,
e.pts_count,
e.date,
e.event_type,
e.event_bool,
COALESCE(e.event_peers::text, '[]')::text AS event_peers_json,
COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json,
COALESCE(e.message_ids::text, '[]')::text AS message_ids_json,
COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json,
COALESCE(e.filter_order::text, '[]')::text AS filter_order_json,
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
COALESCE(e.peer_type, '')::text AS event_peer_type,
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
e.filter_id,
e.max_id,
e.still_unread_count,
e.tags_enabled,
COALESCE(m.box_id, 0)::int AS message_id,
COALESCE(m.private_message_id, 0)::bigint AS private_message_id,
COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id,
COALESCE(m.peer_type, '')::text AS peer_type,
COALESCE(m.peer_id, 0)::bigint AS peer_id,
COALESCE(m.from_user_id, 0)::bigint AS from_user_id,
COALESCE(m.message_date, 0)::int AS message_date,
COALESCE(m.edit_date, 0)::int AS edit_date,
COALESCE(m.outgoing, false)::boolean AS outgoing,
COALESCE(m.body, '')::text AS body,
COALESCE(m.entities::text, '[]')::text AS message_entities_json,
COALESCE(m.silent, false)::boolean AS silent,
COALESCE(m.noforwards, false)::boolean AS noforwards,
COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_id,
COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type,
COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id,
COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id,
COALESCE(m.quote_text, '')::text AS quote_text,
COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json,
COALESCE(m.quote_offset, 0)::int AS quote_offset,
COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type,
COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id,
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
COALESCE(m.fwd_date, 0)::int AS fwd_date,
COALESCE(m.media::text, '{}')::text AS media_json,
COALESCE(peer_u.id, 0)::bigint AS peer_user_id,
COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash,
COALESCE(peer_u.phone, '')::text AS peer_phone,
COALESCE(peer_u.first_name, '')::text AS peer_first_name,
COALESCE(peer_u.last_name, '')::text AS peer_last_name,
COALESCE(peer_u.username, '')::text AS peer_username,
COALESCE(peer_u.country_code, '')::text AS peer_country_code,
COALESCE(peer_u.verified, false)::boolean AS peer_verified,
COALESCE(peer_u.support, false)::boolean AS peer_support,
COALESCE(from_u.id, 0)::bigint AS from_user_user_id,
COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash,
COALESCE(from_u.phone, '')::text AS from_user_phone,
COALESCE(from_u.first_name, '')::text AS from_user_first_name,
COALESCE(from_u.last_name, '')::text AS from_user_last_name,
COALESCE(from_u.username, '')::text AS from_user_username,
COALESCE(from_u.country_code, '')::text AS from_user_country_code,
COALESCE(from_u.verified, false)::boolean AS from_user_verified,
COALESCE(from_u.support, false)::boolean AS from_user_support,
COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id,
COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash,
COALESCE(fwd_u.phone, '')::text AS fwd_user_phone,
COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name,
COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name,
COALESCE(fwd_u.username, '')::text AS fwd_user_username,
COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code,
COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified,
COALESCE(fwd_u.support, false)::boolean AS fwd_user_support,
COALESCE(reply_u.id, 0)::bigint AS reply_user_id,
COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash,
COALESCE(reply_u.phone, '')::text AS reply_user_phone,
COALESCE(reply_u.first_name, '')::text AS reply_user_first_name,
COALESCE(reply_u.last_name, '')::text AS reply_user_last_name,
COALESCE(reply_u.username, '')::text AS reply_user_username,
COALESCE(reply_u.country_code, '')::text AS reply_user_country_code,
COALESCE(reply_u.verified, false)::boolean AS reply_user_verified,
COALESCE(reply_u.support, false)::boolean AS reply_user_support,
COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id,
COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash,
COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id,
COALESCE(fwd_ch.title, '')::text AS fwd_channel_title,
COALESCE(fwd_ch.about, '')::text AS fwd_channel_about,
COALESCE(fwd_ch.username, '')::text AS fwd_channel_username,
COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast,
COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup,
COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum,
COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards,
COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures,
COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden,
COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds,
COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights,
COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count,
COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count,
COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count,
COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count,
COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id,
COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id,
COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts,
COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period,
COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date,
COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted,
COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id,
COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash,
COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id,
COALESCE(reply_ch.title, '')::text AS reply_channel_title,
COALESCE(reply_ch.about, '')::text AS reply_channel_about,
COALESCE(reply_ch.username, '')::text AS reply_channel_username,
COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast,
COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup,
COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum,
COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards,
COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures,
COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden,
COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds,
COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights,
COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count,
COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count,
COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count,
COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count,
COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id,
COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id,
COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts,
COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period,
COALESCE(reply_ch.date, 0)::int AS reply_channel_date,
COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted
FROM unnest(@user_ids::bigint[]) WITH ORDINALITY AS u(user_id, ord)
JOIN unnest(@pts_list::int[]) WITH ORDINALITY AS p(pts, ord) USING (ord)
JOIN user_update_events e ON e.user_id = u.user_id AND e.pts = p.pts
LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id
LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
LEFT JOIN users from_u ON from_u.id = m.from_user_id
LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id
LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id
LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id
LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id;
-- name: MarkDispatchDeliveredBatch :exec
-- 批量删除一批已投递的 (target_user_id, id)target_user_id 入 WHERE 保证分区裁剪。
DELETE FROM dispatch_outbox d
USING unnest(@target_user_ids::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord)
JOIN unnest(@ids::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord)
WHERE d.target_user_id = tu.target_user_id
AND d.id = di.id;
-- name: DeleteFailedDispatchOutbox :one
WITH doomed AS (
SELECT target_user_id, id
FROM dispatch_outbox
WHERE status = 'failed'
AND updated_at < now() - make_interval(secs => sqlc.arg(older_than_seconds)::int)
ORDER BY updated_at ASC, target_user_id ASC, id ASC
LIMIT sqlc.arg(limit_count)
),
deleted AS (
DELETE FROM dispatch_outbox d
USING doomed x
WHERE d.target_user_id = x.target_user_id
AND d.id = x.id
RETURNING d.id
)
SELECT count(*)::int AS deleted_count
FROM deleted;

View file

@ -0,0 +1,87 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: account.sql
package sqlcgen
import (
"context"
)
const getPasswordByUser = `-- name: GetPasswordByUser :one
SELECT
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random
FROM account_passwords
WHERE user_id = $1
`
type GetPasswordByUserRow struct {
UserID int64
HasRecovery bool
HasSecureValues bool
HasPassword bool
Hint string
EmailUnconfirmedPattern string
LoginEmailPattern string
SecureRandom []byte
}
func (q *Queries) GetPasswordByUser(ctx context.Context, userID int64) (GetPasswordByUserRow, error) {
row := q.db.QueryRow(ctx, getPasswordByUser, userID)
var i GetPasswordByUserRow
err := row.Scan(
&i.UserID,
&i.HasRecovery,
&i.HasSecureValues,
&i.HasPassword,
&i.Hint,
&i.EmailUnconfirmedPattern,
&i.LoginEmailPattern,
&i.SecureRandom,
)
return i, err
}
const upsertPassword = `-- name: UpsertPassword :exec
INSERT INTO account_passwords (
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id) DO UPDATE SET
has_recovery = EXCLUDED.has_recovery,
has_secure_values = EXCLUDED.has_secure_values,
has_password = EXCLUDED.has_password,
hint = EXCLUDED.hint,
email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern,
login_email_pattern = EXCLUDED.login_email_pattern,
secure_random = EXCLUDED.secure_random,
updated_at = now()
`
type UpsertPasswordParams struct {
UserID int64
HasRecovery bool
HasSecureValues bool
HasPassword bool
Hint string
EmailUnconfirmedPattern string
LoginEmailPattern string
SecureRandom []byte
}
func (q *Queries) UpsertPassword(ctx context.Context, arg UpsertPasswordParams) error {
_, err := q.db.Exec(ctx, upsertPassword,
arg.UserID,
arg.HasRecovery,
arg.HasSecureValues,
arg.HasPassword,
arg.Hint,
arg.EmailUnconfirmedPattern,
arg.LoginEmailPattern,
arg.SecureRandom,
)
return err
}

View file

@ -0,0 +1,46 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: authkey.sql
package sqlcgen
import (
"context"
)
const getAuthKey = `-- name: GetAuthKey :one
SELECT auth_key_id, body, server_salt, created_at
FROM auth_keys
WHERE auth_key_id = $1
`
func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (AuthKey, error) {
row := q.db.QueryRow(ctx, getAuthKey, authKeyID)
var i AuthKey
err := row.Scan(
&i.AuthKeyID,
&i.Body,
&i.ServerSalt,
&i.CreatedAt,
)
return i, err
}
const upsertAuthKey = `-- name: UpsertAuthKey :exec
INSERT INTO auth_keys (auth_key_id, body, server_salt)
VALUES ($1, $2, $3)
ON CONFLICT (auth_key_id) DO UPDATE
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt
`
type UpsertAuthKeyParams struct {
AuthKeyID int64
Body []byte
ServerSalt int64
}
func (q *Queries) UpsertAuthKey(ctx context.Context, arg UpsertAuthKeyParams) error {
_, err := q.db.Exec(ctx, upsertAuthKey, arg.AuthKeyID, arg.Body, arg.ServerSalt)
return err
}

View file

@ -0,0 +1,124 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: authorization.sql
package sqlcgen
import (
"context"
)
const deleteAuthorization = `-- name: DeleteAuthorization :exec
DELETE FROM authorizations WHERE auth_key_id = $1
`
func (q *Queries) DeleteAuthorization(ctx context.Context, authKeyID int64) error {
_, err := q.db.Exec(ctx, deleteAuthorization, authKeyID)
return err
}
const getAuthorizationByAuthKey = `-- name: GetAuthorizationByAuthKey :one
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at FROM authorizations WHERE auth_key_id = $1
`
func (q *Queries) GetAuthorizationByAuthKey(ctx context.Context, authKeyID int64) (Authorization, error) {
row := q.db.QueryRow(ctx, getAuthorizationByAuthKey, authKeyID)
var i Authorization
err := row.Scan(
&i.AuthKeyID,
&i.UserID,
&i.Hash,
&i.Layer,
&i.DeviceModel,
&i.Platform,
&i.SystemVersion,
&i.ApiID,
&i.AppVersion,
&i.Ip,
&i.CreatedAt,
&i.ActiveAt,
)
return i, err
}
const listAuthorizationsByUser = `-- name: ListAuthorizationsByUser :many
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at FROM authorizations
WHERE user_id = $1
ORDER BY active_at DESC, auth_key_id DESC
`
func (q *Queries) ListAuthorizationsByUser(ctx context.Context, userID int64) ([]Authorization, error) {
rows, err := q.db.Query(ctx, listAuthorizationsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Authorization
for rows.Next() {
var i Authorization
if err := rows.Scan(
&i.AuthKeyID,
&i.UserID,
&i.Hash,
&i.Layer,
&i.DeviceModel,
&i.Platform,
&i.SystemVersion,
&i.ApiID,
&i.AppVersion,
&i.Ip,
&i.CreatedAt,
&i.ActiveAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAuthorization = `-- name: UpsertAuthorization :exec
INSERT INTO authorizations (auth_key_id, user_id, layer, device_model, platform, system_version, api_id, app_version, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (auth_key_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
layer = EXCLUDED.layer,
device_model = EXCLUDED.device_model,
platform = EXCLUDED.platform,
system_version = EXCLUDED.system_version,
api_id = EXCLUDED.api_id,
app_version = EXCLUDED.app_version,
ip = EXCLUDED.ip,
active_at = now()
`
type UpsertAuthorizationParams struct {
AuthKeyID int64
UserID int64
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
Ip string
}
func (q *Queries) UpsertAuthorization(ctx context.Context, arg UpsertAuthorizationParams) error {
_, err := q.db.Exec(ctx, upsertAuthorization,
arg.AuthKeyID,
arg.UserID,
arg.Layer,
arg.DeviceModel,
arg.Platform,
arg.SystemVersion,
arg.ApiID,
arg.AppVersion,
arg.Ip,
)
return err
}

View file

@ -0,0 +1,426 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: contact.sql
package sqlcgen
import (
"context"
)
const deleteContacts = `-- name: DeleteContacts :one
WITH deleted AS (
DELETE FROM contacts
WHERE user_id = $1::bigint
AND contact_user_id = ANY($2::bigint[])
RETURNING contact_user_id
),
reverse_updated AS (
UPDATE contacts c
SET mutual = false,
updated_at = now()
FROM deleted d
WHERE c.user_id = d.contact_user_id
AND c.contact_user_id = $1::bigint
RETURNING c.user_id
)
SELECT COUNT(*)::int AS deleted_count
FROM deleted
`
type DeleteContactsParams struct {
UserID int64
ContactUserIds []int64
}
func (q *Queries) DeleteContacts(ctx context.Context, arg DeleteContactsParams) (int32, error) {
row := q.db.QueryRow(ctx, deleteContacts, arg.UserID, arg.ContactUserIds)
var deleted_count int32
err := row.Scan(&deleted_count)
return deleted_count, err
}
const getContact = `-- name: GetContact :one
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
AND c.contact_user_id = $2
`
type GetContactParams struct {
UserID int64
ContactUserID int64
}
type GetContactRow struct {
ContactUserID int64
Mutual bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
}
func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) {
row := q.db.QueryRow(ctx, getContact, arg.UserID, arg.ContactUserID)
var i GetContactRow
err := row.Scan(
&i.ContactUserID,
&i.Mutual,
&i.ContactPhone,
&i.ContactFirstName,
&i.ContactLastName,
&i.Note,
&i.NoteEntitiesJson,
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
)
return i, err
}
const listContactsByUser = `-- name: ListContactsByUser :many
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u.id
`
type ListContactsByUserRow struct {
ContactUserID int64
Mutual bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
}
func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) {
rows, err := q.db.Query(ctx, listContactsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListContactsByUserRow
for rows.Next() {
var i ListContactsByUserRow
if err := rows.Scan(
&i.ContactUserID,
&i.Mutual,
&i.ContactPhone,
&i.ContactFirstName,
&i.ContactLastName,
&i.Note,
&i.NoteEntitiesJson,
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateContactNote = `-- name: UpdateContactNote :one
WITH updated AS (
UPDATE contacts c
SET note = $1::text,
note_entities = $2::jsonb,
updated_at = now()
WHERE c.user_id = $3::bigint
AND c.contact_user_id = $4::bigint
RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM updated c
JOIN users u ON u.id = c.contact_user_id
`
type UpdateContactNoteParams struct {
Note string
NoteEntities []byte
UserID int64
ContactUserID int64
}
type UpdateContactNoteRow struct {
ContactUserID int64
Mutual bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
}
func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) {
row := q.db.QueryRow(ctx, updateContactNote,
arg.Note,
arg.NoteEntities,
arg.UserID,
arg.ContactUserID,
)
var i UpdateContactNoteRow
err := row.Scan(
&i.ContactUserID,
&i.Mutual,
&i.ContactPhone,
&i.ContactFirstName,
&i.ContactLastName,
&i.Note,
&i.NoteEntitiesJson,
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
)
return i, err
}
const upsertContact = `-- name: UpsertContact :one
WITH reverse AS (
SELECT EXISTS (
SELECT 1
FROM contacts
WHERE user_id = $1::bigint
AND contact_user_id = $2::bigint
)::boolean AS mutual
),
upserted AS (
INSERT INTO contacts (
user_id,
contact_user_id,
contact_phone,
contact_first_name,
contact_last_name,
note,
note_entities,
mutual
)
SELECT
$2::bigint,
$1::bigint,
$3::text,
$4::text,
$5::text,
$6::text,
$7::jsonb,
reverse.mutual
FROM reverse
ON CONFLICT (user_id, contact_user_id) DO UPDATE SET
contact_phone = EXCLUDED.contact_phone,
contact_first_name = EXCLUDED.contact_first_name,
contact_last_name = EXCLUDED.contact_last_name,
note = EXCLUDED.note,
note_entities = EXCLUDED.note_entities,
mutual = contacts.mutual OR EXCLUDED.mutual,
updated_at = now()
RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden
),
reverse_updated AS (
UPDATE contacts c
SET mutual = true,
updated_at = now()
WHERE c.user_id = $1::bigint
AND c.contact_user_id = $2::bigint
AND NOT c.mutual
RETURNING c.user_id
)
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
FROM upserted c
JOIN users u ON u.id = c.contact_user_id
`
type UpsertContactParams struct {
ContactUserID int64
UserID int64
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntities []byte
}
type UpsertContactRow struct {
ContactUserID int64
Mutual bool
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntitiesJson string
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
ReverseMutualChanged bool
}
func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) {
row := q.db.QueryRow(ctx, upsertContact,
arg.ContactUserID,
arg.UserID,
arg.ContactPhone,
arg.ContactFirstName,
arg.ContactLastName,
arg.Note,
arg.NoteEntities,
)
var i UpsertContactRow
err := row.Scan(
&i.ContactUserID,
&i.Mutual,
&i.ContactPhone,
&i.ContactFirstName,
&i.ContactLastName,
&i.Note,
&i.NoteEntitiesJson,
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
&i.ReverseMutualChanged,
)
return i, err
}

View file

@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlcgen
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,159 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: help.sql
package sqlcgen
import (
"context"
)
const getAppConfig = `-- name: GetAppConfig :one
SELECT client, hash, config_json::text AS config_json
FROM app_configs
WHERE client = $1
`
type GetAppConfigRow struct {
Client string
Hash int32
ConfigJson string
}
func (q *Queries) GetAppConfig(ctx context.Context, client string) (GetAppConfigRow, error) {
row := q.db.QueryRow(ctx, getAppConfig, client)
var i GetAppConfigRow
err := row.Scan(&i.Client, &i.Hash, &i.ConfigJson)
return i, err
}
const listCountries = `-- name: ListCountries :many
SELECT
c.iso2,
c.default_name,
c.name,
c.hidden,
cc.country_code,
cc.prefixes,
cc.patterns
FROM countries c
JOIN country_codes cc ON cc.iso2 = c.iso2
ORDER BY c.order_index, c.iso2, cc.order_index, cc.country_code
`
type ListCountriesRow struct {
Iso2 string
DefaultName string
Name string
Hidden bool
CountryCode string
Prefixes []string
Patterns []string
}
func (q *Queries) ListCountries(ctx context.Context) ([]ListCountriesRow, error) {
rows, err := q.db.Query(ctx, listCountries)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListCountriesRow
for rows.Next() {
var i ListCountriesRow
if err := rows.Scan(
&i.Iso2,
&i.DefaultName,
&i.Name,
&i.Hidden,
&i.CountryCode,
&i.Prefixes,
&i.Patterns,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAppConfig = `-- name: UpsertAppConfig :exec
INSERT INTO app_configs (client, hash, config_json)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (client) DO UPDATE SET
hash = EXCLUDED.hash,
config_json = EXCLUDED.config_json,
updated_at = now()
`
type UpsertAppConfigParams struct {
Client string
Hash int32
ConfigJson []byte
}
func (q *Queries) UpsertAppConfig(ctx context.Context, arg UpsertAppConfigParams) error {
_, err := q.db.Exec(ctx, upsertAppConfig, arg.Client, arg.Hash, arg.ConfigJson)
return err
}
const upsertCountry = `-- name: UpsertCountry :exec
INSERT INTO countries (iso2, default_name, name, hidden, order_index)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (iso2) DO UPDATE SET
default_name = EXCLUDED.default_name,
name = EXCLUDED.name,
hidden = EXCLUDED.hidden,
order_index = EXCLUDED.order_index,
updated_at = now()
`
type UpsertCountryParams struct {
Iso2 string
DefaultName string
Name string
Hidden bool
OrderIndex int32
}
func (q *Queries) UpsertCountry(ctx context.Context, arg UpsertCountryParams) error {
_, err := q.db.Exec(ctx, upsertCountry,
arg.Iso2,
arg.DefaultName,
arg.Name,
arg.Hidden,
arg.OrderIndex,
)
return err
}
const upsertCountryCode = `-- name: UpsertCountryCode :exec
INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (iso2, country_code) DO UPDATE SET
prefixes = EXCLUDED.prefixes,
patterns = EXCLUDED.patterns,
order_index = EXCLUDED.order_index
`
type UpsertCountryCodeParams struct {
Iso2 string
CountryCode string
Prefixes []string
Patterns []string
OrderIndex int32
}
func (q *Queries) UpsertCountryCode(ctx context.Context, arg UpsertCountryCodeParams) error {
_, err := q.db.Exec(ctx, upsertCountryCode,
arg.Iso2,
arg.CountryCode,
arg.Prefixes,
arg.Patterns,
arg.OrderIndex,
)
return err
}

View file

@ -0,0 +1,250 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: langpack.sql
package sqlcgen
import (
"context"
)
const getLangPackMeta = `-- name: GetLangPackMeta :one
SELECT lang_pack, lang_code, version, strings_count
FROM lang_packs
WHERE lang_pack = $1 AND lang_code = $2
`
type GetLangPackMetaParams struct {
LangPack string
LangCode string
}
type GetLangPackMetaRow struct {
LangPack string
LangCode string
Version int32
StringsCount int32
}
func (q *Queries) GetLangPackMeta(ctx context.Context, arg GetLangPackMetaParams) (GetLangPackMetaRow, error) {
row := q.db.QueryRow(ctx, getLangPackMeta, arg.LangPack, arg.LangCode)
var i GetLangPackMetaRow
err := row.Scan(
&i.LangPack,
&i.LangCode,
&i.Version,
&i.StringsCount,
)
return i, err
}
const getLangPackStringsByKeys = `-- name: GetLangPackStringsByKeys :many
SELECT
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
FROM lang_pack_strings
WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY($3::text[]) AND NOT deleted
ORDER BY key
`
type GetLangPackStringsByKeysParams struct {
LangPack string
LangCode string
Keys []string
}
type GetLangPackStringsByKeysRow struct {
LangPack string
LangCode string
Key string
Version int32
Pluralized bool
Value string
ZeroValue string
OneValue string
TwoValue string
FewValue string
ManyValue string
OtherValue string
Deleted bool
}
func (q *Queries) GetLangPackStringsByKeys(ctx context.Context, arg GetLangPackStringsByKeysParams) ([]GetLangPackStringsByKeysRow, error) {
rows, err := q.db.Query(ctx, getLangPackStringsByKeys, arg.LangPack, arg.LangCode, arg.Keys)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetLangPackStringsByKeysRow
for rows.Next() {
var i GetLangPackStringsByKeysRow
if err := rows.Scan(
&i.LangPack,
&i.LangCode,
&i.Key,
&i.Version,
&i.Pluralized,
&i.Value,
&i.ZeroValue,
&i.OneValue,
&i.TwoValue,
&i.FewValue,
&i.ManyValue,
&i.OtherValue,
&i.Deleted,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLangPackStrings = `-- name: ListLangPackStrings :many
SELECT
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
FROM lang_pack_strings
WHERE lang_pack = $1 AND lang_code = $2 AND NOT deleted
ORDER BY key
`
type ListLangPackStringsParams struct {
LangPack string
LangCode string
}
type ListLangPackStringsRow struct {
LangPack string
LangCode string
Key string
Version int32
Pluralized bool
Value string
ZeroValue string
OneValue string
TwoValue string
FewValue string
ManyValue string
OtherValue string
Deleted bool
}
func (q *Queries) ListLangPackStrings(ctx context.Context, arg ListLangPackStringsParams) ([]ListLangPackStringsRow, error) {
rows, err := q.db.Query(ctx, listLangPackStrings, arg.LangPack, arg.LangCode)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLangPackStringsRow
for rows.Next() {
var i ListLangPackStringsRow
if err := rows.Scan(
&i.LangPack,
&i.LangCode,
&i.Key,
&i.Version,
&i.Pluralized,
&i.Value,
&i.ZeroValue,
&i.OneValue,
&i.TwoValue,
&i.FewValue,
&i.ManyValue,
&i.OtherValue,
&i.Deleted,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertLangPackMeta = `-- name: UpsertLangPackMeta :exec
INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (lang_pack, lang_code) DO UPDATE SET
version = EXCLUDED.version,
strings_count = EXCLUDED.strings_count,
updated_at = now()
`
type UpsertLangPackMetaParams struct {
LangPack string
LangCode string
Version int32
StringsCount int32
}
func (q *Queries) UpsertLangPackMeta(ctx context.Context, arg UpsertLangPackMetaParams) error {
_, err := q.db.Exec(ctx, upsertLangPackMeta,
arg.LangPack,
arg.LangCode,
arg.Version,
arg.StringsCount,
)
return err
}
const upsertLangPackString = `-- name: UpsertLangPackString :exec
INSERT INTO lang_pack_strings (
lang_pack, lang_code, key, version, pluralized, value,
zero_value, one_value, two_value, few_value, many_value, other_value, deleted
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET
version = EXCLUDED.version,
pluralized = EXCLUDED.pluralized,
value = EXCLUDED.value,
zero_value = EXCLUDED.zero_value,
one_value = EXCLUDED.one_value,
two_value = EXCLUDED.two_value,
few_value = EXCLUDED.few_value,
many_value = EXCLUDED.many_value,
other_value = EXCLUDED.other_value,
deleted = EXCLUDED.deleted,
updated_at = now()
`
type UpsertLangPackStringParams struct {
LangPack string
LangCode string
Key string
Version int32
Pluralized bool
Value string
ZeroValue string
OneValue string
TwoValue string
FewValue string
ManyValue string
OtherValue string
Deleted bool
}
func (q *Queries) UpsertLangPackString(ctx context.Context, arg UpsertLangPackStringParams) error {
_, err := q.db.Exec(ctx, upsertLangPackString,
arg.LangPack,
arg.LangCode,
arg.Key,
arg.Version,
arg.Pluralized,
arg.Value,
arg.ZeroValue,
arg.OneValue,
arg.TwoValue,
arg.FewValue,
arg.ManyValue,
arg.OtherValue,
arg.Deleted,
)
return err
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,681 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlcgen
import (
"github.com/jackc/pgx/v5/pgtype"
)
type AccountPassword struct {
UserID int64
HasRecovery bool
HasSecureValues bool
HasPassword bool
Hint string
EmailUnconfirmedPattern string
LoginEmailPattern string
SecureRandom []byte
UpdatedAt pgtype.Timestamptz
}
type AppConfig struct {
Client string
Hash int32
ConfigJson []byte
UpdatedAt pgtype.Timestamptz
}
type AuthKey struct {
AuthKeyID int64
Body []byte
ServerSalt int64
CreatedAt pgtype.Timestamptz
}
type Authorization struct {
AuthKeyID int64
UserID int64
Hash int64
Layer int32
DeviceModel string
Platform string
SystemVersion string
ApiID int32
AppVersion string
Ip string
CreatedAt pgtype.Timestamptz
ActiveAt pgtype.Timestamptz
}
type AvailableReaction struct {
Reaction string
Title string
Inactive bool
Premium bool
StaticIconID int64
AppearAnimationID int64
SelectAnimationID int64
ActivateAnimationID int64
EffectAnimationID int64
AroundAnimationID int64
CenterIconID int64
SortOrder int32
}
type Channel struct {
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
Username *string
Broadcast bool
Megagroup bool
Forum bool
ForumTabs bool
Noforwards bool
JoinToSend bool
JoinRequest bool
Signatures bool
PreHistoryHidden bool
ParticipantsHidden bool
Antispam bool
LinkedChatID int64
SlowmodeSeconds int32
DefaultBannedRights []byte
AvailableReactions []byte
ColorSet bool
Color int32
ColorBackgroundEmojiID int64
ProfileColorSet bool
ProfileColor int32
ProfileColorBackgroundEmojiID int64
EmojiStatusDocumentID int64
EmojiStatusUntil int32
ParticipantsCount int32
AdminsCount int32
KickedCount int32
BannedCount int32
TopMessageID int32
Pts int32
AdminLogSeq int64
TtlPeriod int32
Date int32
Deleted bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
PinnedMessageID int32
Autotranslation bool
RestrictedSponsored bool
BroadcastMessagesAllowed bool
SendPaidMessagesStars int64
PhotoID int64
PhotoDcID int32
PhotoStripped []byte
}
type ChannelAdminLogEvent struct {
ChannelID int64
ID int64
ActorUserID int64
EventDate int32
EventType string
PrevString string
NewString string
PrevBool bool
NewBool bool
PrevInt int32
NewInt int32
PrevParticipant []byte
NewParticipant []byte
Participant []byte
Message []byte
PrevMessage []byte
NewMessage []byte
Query string
CreatedAt pgtype.Timestamptz
}
type ChannelDialog struct {
UserID int64
ChannelID int64
FolderID int32
TopMessageID int32
TopMessageDate int32
ReadInboxMaxID int32
ReadOutboxMaxID int32
UnreadCount int32
UnreadMentionsCount int32
UnreadReactionsCount int32
Pinned bool
PinnedOrder int32
UnreadMark bool
ViewForumAsMessages bool
NotifySettings []byte
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
DefaultSendAsPeerType *string
DefaultSendAsPeerID *int64
}
type ChannelForumTopic struct {
ChannelID int64
TopicID int32
CreatorUserID int64
Title string
IconColor int32
IconEmojiID int64
TitleMissing bool
Closed bool
Hidden bool
Pinned bool
PinnedOrder int32
Date int32
TopMessageID int32
ReadInboxMaxID int32
ReadOutboxMaxID int32
UnreadCount int32
UnreadMentionsCount int32
UnreadReactionsCount int32
UnreadPollVotesCount int32
Deleted bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChannelInvite struct {
ChannelID int64
InviteID int64
Hash string
AdminUserID int64
Title string
Permanent bool
Revoked bool
RequestNeeded bool
ExpireDate *int32
UsageLimit *int32
UsageCount int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
RequestedCount int32
}
type ChannelInviteHash struct {
Hash string
ChannelID int64
InviteID int64
UpdatedAt pgtype.Timestamptz
}
type ChannelInviteImporter struct {
ChannelID int64
InviteID int64
UserID int64
Date int32
Requested bool
ApprovedBy int64
ViaChatlist bool
About string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChannelMember struct {
ChannelID int64
UserID int64
InviterUserID int64
Role string
Status string
JoinedAt int32
LeftAt int32
AdminRights []byte
BannedRights []byte
Rank string
AvailableMinID int32
AvailableMinPts int32
ReadInboxMaxID int32
ReadInboxDate int32
ReadOutboxMaxID int32
UnreadMark bool
SlowmodeLastSendDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChannelMessage struct {
ChannelID int64
ID int32
RandomID int64
SenderUserID int64
FromPeerType string
FromPeerID int64
SendAsPeerType *string
SendAsPeerID *int64
MessageDate int32
EditDate int32
Post bool
Silent bool
Noforwards bool
Body string
Entities []byte
ReplyTo []byte
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
FwdFrom []byte
DiscussionChannelID int64
DiscussionMessageID int32
Action []byte
Pts int32
Deleted bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ViewsCount int32
Media []byte
}
type ChannelMessageReaction struct {
ChannelID int64
MessageID int32
ReactedUserID int64
SenderUserID int64
ReactionType string
ReactionValue string
Big bool
Unread bool
ChosenOrder int32
ReactionDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ChannelMessageViewer struct {
ChannelID int64
MessageID int32
ViewerUserID int64
ViewedAt int32
CreatedAt pgtype.Timestamptz
}
type ChannelUnreadMention struct {
UserID int64
ChannelID int64
MessageID int32
TopMessageID int32
CreatedAt pgtype.Timestamptz
}
type ChannelUpdateEvent struct {
ChannelID int64
Pts int32
PtsCount int32
Date int32
EventType string
MessageID int32
MessageIds []byte
SenderUserID int64
UserIds []byte
Payload []byte
CreatedAt pgtype.Timestamptz
}
type ChannelUsername struct {
UsernameLower string
ChannelID int64
UpdatedAt pgtype.Timestamptz
}
type Contact struct {
UserID int64
ContactUserID int64
Mutual bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ContactPhone string
ContactFirstName string
ContactLastName string
Note string
NoteEntities []byte
CloseFriend bool
StoriesHidden bool
}
type Country struct {
Iso2 string
DefaultName string
Name string
Hidden bool
OrderIndex int32
UpdatedAt pgtype.Timestamptz
}
type CountryCode struct {
ID int64
Iso2 string
CountryCode string
Prefixes []string
Patterns []string
OrderIndex int32
}
type Dialog struct {
UserID int64
PeerType string
PeerID int64
TopMessageID int32
TopMessageDate int32
ReadInboxMaxID int32
ReadOutboxMaxID int32
UnreadCount int32
UnreadMentionsCount int32
UnreadReactionsCount int32
Pinned bool
UpdatedAt pgtype.Timestamptz
PinnedOrder int32
UnreadMark bool
HiddenPeerSettingsBar bool
FolderID int32
}
type DialogDraft struct {
UserID int64
PeerType string
PeerID int64
TopMessageID int32
Date int32
Draft []byte
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type DialogFilter struct {
UserID int64
FilterID int32
IsChatlist bool
Filter []byte
OrderValue int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type DialogFilterSetting struct {
UserID int64
TagsEnabled bool
UpdatedAt pgtype.Timestamptz
}
type DispatchOutbox struct {
ID int64
TargetUserID int64
Pts int32
EventType string
ExcludeSessionID int64
Status string
Attempts int32
NextAttemptAt pgtype.Timestamptz
LastError string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ExcludeAuthKeyID int64
}
type Document struct {
ID int64
AccessHash int64
FileReference []byte
Date int32
MimeType string
Size int64
DcID int32
Attributes []byte
Thumbs []byte
CreatedAt pgtype.Timestamptz
}
type FileBlob struct {
LocationKey string
Backend string
ObjectKey string
Size int64
Sha256 []byte
MimeType string
CreatedAt pgtype.Timestamptz
}
type LangPack struct {
LangPack string
LangCode string
Version int32
StringsCount int32
UpdatedAt pgtype.Timestamptz
}
type LangPackString struct {
LangPack string
LangCode string
Key string
Version int32
Pluralized bool
Value string
ZeroValue string
OneValue string
TwoValue string
FewValue string
ManyValue string
OtherValue string
Deleted bool
UpdatedAt pgtype.Timestamptz
}
type MessageBox struct {
OwnerUserID int64
BoxID int32
PrivateMessageID int64
MessageSenderID int64
PeerType string
PeerID int64
FromUserID int64
MessageDate int32
Outgoing bool
Body string
Entities []byte
Pts int32
Deleted bool
CreatedAt pgtype.Timestamptz
EditDate int32
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
QuoteText string
QuoteEntities []byte
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
Media []byte
}
type Photo struct {
ID int64
AccessHash int64
FileReference []byte
Date int32
DcID int32
HasStickers bool
Sizes []byte
CreatedAt pgtype.Timestamptz
}
type PrivateMessage struct {
ID int64
SenderUserID int64
RecipientUserID int64
RandomID int64
MessageDate int32
Body string
Entities []byte
CreatedAt pgtype.Timestamptz
EditDate int32
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
QuoteText string
QuoteEntities []byte
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
Media []byte
}
type ProfilePhoto struct {
OwnerPeerType string
OwnerPeerID int64
PhotoID int64
Date int32
Active bool
SortOrder int64
CreatedAt pgtype.Timestamptz
}
type StickerSet struct {
ID int64
AccessHash int64
ShortName string
Title string
Count int32
Hash int32
SetKind string
Official bool
Animated bool
Videos bool
Emojis bool
Masks bool
Installed bool
Archived bool
InstalledDate int32
ThumbDocumentID int64
Thumbs []byte
ThumbDcID int32
ThumbVersion int32
DocumentIds []byte
Packs []byte
SortOrder int32
SystemKey string
CreatedAt pgtype.Timestamptz
}
type TempAuthKeyBinding struct {
TempAuthKeyID int64
PermAuthKeyID int64
Nonce int64
ExpiresAt int32
EncryptedMessage []byte
CreatedAt pgtype.Timestamptz
TempSessionID int64
}
type UpdateState struct {
AuthKeyID int64
Pts int32
Qts int32
Date int32
Seq int32
UpdatedAt pgtype.Timestamptz
UserID int64
}
type UploadPart struct {
OwnerUserID int64
FileID int64
Part int32
TotalParts int32
IsBig bool
Bytes []byte
CreatedAt pgtype.Timestamptz
}
type User struct {
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
Verified bool
Support bool
About string
LastSeenAt int64
}
type UserRecentReaction struct {
UserID int64
ReactionType string
ReactionValue string
ReactionDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type UserSavedReactionTag struct {
UserID int64
ReactionType string
ReactionValue string
Title string
ReactionCount int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type UserTopReaction struct {
UserID int64
ReactionType string
ReactionValue string
ReactionCount int32
ReactionDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type UserUpdateEvent struct {
UserID int64
Pts int32
PtsCount int32
Date int32
EventType string
MessageBoxID *int32
PeerType *string
PeerID *int64
MaxID int32
StillUnreadCount int32
CreatedAt pgtype.Timestamptz
EventBool bool
EventPeers []byte
PeerSettings []byte
MessageIds []byte
DialogFilter []byte
FilterOrder []byte
FolderPeers []byte
FilterID int32
TagsEnabled bool
}
type UserUpdateWatermark struct {
UserID int64
ContiguousPts int32
UpdatedAt pgtype.Timestamptz
}

View file

@ -0,0 +1,80 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: temp_auth_key.sql
package sqlcgen
import (
"context"
)
const getTempAuthKeyBinding = `-- name: GetTempAuthKeyBinding :one
SELECT
temp_auth_key_id,
perm_auth_key_id,
nonce,
temp_session_id,
expires_at,
encrypted_message
FROM temp_auth_key_bindings
WHERE temp_auth_key_id = $1
`
type GetTempAuthKeyBindingRow struct {
TempAuthKeyID int64
PermAuthKeyID int64
Nonce int64
TempSessionID int64
ExpiresAt int32
EncryptedMessage []byte
}
func (q *Queries) GetTempAuthKeyBinding(ctx context.Context, tempAuthKeyID int64) (GetTempAuthKeyBindingRow, error) {
row := q.db.QueryRow(ctx, getTempAuthKeyBinding, tempAuthKeyID)
var i GetTempAuthKeyBindingRow
err := row.Scan(
&i.TempAuthKeyID,
&i.PermAuthKeyID,
&i.Nonce,
&i.TempSessionID,
&i.ExpiresAt,
&i.EncryptedMessage,
)
return i, err
}
const upsertTempAuthKeyBinding = `-- name: UpsertTempAuthKeyBinding :exec
INSERT INTO temp_auth_key_bindings (
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
perm_auth_key_id = EXCLUDED.perm_auth_key_id,
nonce = EXCLUDED.nonce,
temp_session_id = EXCLUDED.temp_session_id,
expires_at = EXCLUDED.expires_at,
encrypted_message = EXCLUDED.encrypted_message,
created_at = now()
`
type UpsertTempAuthKeyBindingParams struct {
TempAuthKeyID int64
PermAuthKeyID int64
Nonce int64
TempSessionID int64
ExpiresAt int32
EncryptedMessage []byte
}
func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAuthKeyBindingParams) error {
_, err := q.db.Exec(ctx, upsertTempAuthKeyBinding,
arg.TempAuthKeyID,
arg.PermAuthKeyID,
arg.Nonce,
arg.TempSessionID,
arg.ExpiresAt,
arg.EncryptedMessage,
)
return err
}

View file

@ -0,0 +1,103 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: update_state.sql
package sqlcgen
import (
"context"
)
const deleteUpdateState = `-- name: DeleteUpdateState :exec
DELETE FROM update_states
WHERE auth_key_id = $1
AND user_id = $2
`
type DeleteUpdateStateParams struct {
AuthKeyID int64
UserID int64
}
func (q *Queries) DeleteUpdateState(ctx context.Context, arg DeleteUpdateStateParams) error {
_, err := q.db.Exec(ctx, deleteUpdateState, arg.AuthKeyID, arg.UserID)
return err
}
const deleteUpdateStatesByAuthKey = `-- name: DeleteUpdateStatesByAuthKey :exec
DELETE FROM update_states
WHERE auth_key_id = $1
`
func (q *Queries) DeleteUpdateStatesByAuthKey(ctx context.Context, authKeyID int64) error {
_, err := q.db.Exec(ctx, deleteUpdateStatesByAuthKey, authKeyID)
return err
}
const getUpdateState = `-- name: GetUpdateState :one
SELECT auth_key_id, user_id, pts, qts, date, seq
FROM update_states
WHERE auth_key_id = $1
AND user_id = $2
`
type GetUpdateStateParams struct {
AuthKeyID int64
UserID int64
}
type GetUpdateStateRow struct {
AuthKeyID int64
UserID int64
Pts int32
Qts int32
Date int32
Seq int32
}
func (q *Queries) GetUpdateState(ctx context.Context, arg GetUpdateStateParams) (GetUpdateStateRow, error) {
row := q.db.QueryRow(ctx, getUpdateState, arg.AuthKeyID, arg.UserID)
var i GetUpdateStateRow
err := row.Scan(
&i.AuthKeyID,
&i.UserID,
&i.Pts,
&i.Qts,
&i.Date,
&i.Seq,
)
return i, err
}
const upsertUpdateState = `-- name: UpsertUpdateState :exec
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
pts = EXCLUDED.pts,
qts = EXCLUDED.qts,
date = EXCLUDED.date,
seq = EXCLUDED.seq,
updated_at = now()
`
type UpsertUpdateStateParams struct {
AuthKeyID int64
UserID int64
Pts int32
Qts int32
Date int32
Seq int32
}
func (q *Queries) UpsertUpdateState(ctx context.Context, arg UpsertUpdateStateParams) error {
_, err := q.db.Exec(ctx, upsertUpdateState,
arg.AuthKeyID,
arg.UserID,
arg.Pts,
arg.Qts,
arg.Date,
arg.Seq,
)
return err
}

View file

@ -0,0 +1,426 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: user.sql
package sqlcgen
import (
"context"
)
const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at
`
type CreateUserParams struct {
AccessHash int64
Phone string
FirstName string
LastName string
Username string
CountryCode string
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser,
arg.AccessHash,
arg.Phone,
arg.FirstName,
arg.LastName,
arg.Username,
arg.CountryCode,
)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
return i, err
}
const getUserByPhone = `-- name: GetUserByPhone :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE phone = $1
`
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
row := q.db.QueryRow(ctx, getUserByPhone, phone)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE lower(username) = lower($1) AND username <> ''
`
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
row := q.db.QueryRow(ctx, getUserByUsername, lower)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
return i, err
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at
FROM users
WHERE id = ANY($1::bigint[])
ORDER BY id
`
func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error) {
rows, err := q.db.Query(ctx, getUsersByIDs, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUsersByPhones = `-- name: GetUsersByPhones :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at
FROM users
WHERE phone = ANY($1::text[])
ORDER BY id
`
func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User, error) {
rows, err := q.db.Query(ctx, getUsersByPhones, phones)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const searchUsers = `-- name: SearchUsers :many
WITH matched AS (
SELECT
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.about,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at,
(c.contact_user_id IS NOT NULL)::boolean AS contact,
COALESCE(c.mutual, false)::boolean AS mutual,
CASE
WHEN $2::text <> '' AND u.phone = $2::text THEN 0
WHEN lower(u.username) = $3::text THEN 1
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $3::text THEN 2
WHEN lower(u.first_name) = $3::text THEN 3
WHEN c.contact_user_id IS NOT NULL THEN 4
ELSE 5
END AS rank
FROM users u
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
WHERE u.id <> $4::bigint
AND $3::text <> ''
AND (
($2::text <> '' AND u.phone LIKE $2::text || '%')
OR lower(u.username) LIKE $5::text || '%' ESCAPE '\'
OR lower(u.first_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(u.last_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(c.contact_first_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(c.contact_last_name) LIKE '%' || $5::text || '%' ESCAPE '\'
OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $5::text || '%' ESCAPE '\'
)
)
SELECT
id,
access_hash,
phone,
first_name,
last_name,
about,
username,
country_code,
verified,
support,
last_seen_at,
contact,
mutual
FROM matched
ORDER BY contact DESC, rank, id
LIMIT $1
`
type SearchUsersParams struct {
LimitCount int32
PhoneQuery string
QueryLower string
CurrentUserID int64
QueryLike string
}
type SearchUsersRow struct {
ID int64
AccessHash int64
Phone string
FirstName string
LastName string
About string
Username string
CountryCode string
Verified bool
Support bool
LastSeenAt int64
Contact bool
Mutual bool
}
func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) {
rows, err := q.db.Query(ctx, searchUsers,
arg.LimitCount,
arg.PhoneQuery,
arg.QueryLower,
arg.CurrentUserID,
arg.QueryLike,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SearchUsersRow
for rows.Next() {
var i SearchUsersRow
if err := rows.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.About,
&i.Username,
&i.CountryCode,
&i.Verified,
&i.Support,
&i.LastSeenAt,
&i.Contact,
&i.Mutual,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec
UPDATE users
SET last_seen_at = GREATEST(last_seen_at, $1::bigint),
updated_at = now()
WHERE id = $2::bigint
`
type UpdateUserLastSeenParams struct {
LastSeenAt int64
ID int64
}
func (q *Queries) UpdateUserLastSeen(ctx context.Context, arg UpdateUserLastSeenParams) error {
_, err := q.db.Exec(ctx, updateUserLastSeen, arg.LastSeenAt, arg.ID)
return err
}
const updateUserProfile = `-- name: UpdateUserProfile :one
UPDATE users
SET first_name = $2,
last_name = $3,
about = $4,
updated_at = now()
WHERE id = $1
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at
`
type UpdateUserProfileParams struct {
ID int64
FirstName string
LastName string
About string
}
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) {
row := q.db.QueryRow(ctx, updateUserProfile,
arg.ID,
arg.FirstName,
arg.LastName,
arg.About,
)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
return i, err
}
const updateUserUsername = `-- name: UpdateUserUsername :one
UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at
`
type UpdateUserUsernameParams struct {
ID int64
Username string
}
func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsernameParams) (User, error) {
row := q.db.QueryRow(ctx, updateUserUsername, arg.ID, arg.Username)
var i User
err := row.Scan(
&i.ID,
&i.AccessHash,
&i.Phone,
&i.FirstName,
&i.LastName,
&i.Username,
&i.CountryCode,
&i.CreatedAt,
&i.UpdatedAt,
&i.Verified,
&i.Support,
&i.About,
&i.LastSeenAt,
)
return i, err
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// TempAuthKeyBindingStore 用 PostgreSQL 实现 store.TempAuthKeyBindingStore。
type TempAuthKeyBindingStore struct {
q *sqlcgen.Queries
}
// NewTempAuthKeyBindingStore 基于 pgx 连接池(或事务)创建 TempAuthKeyBindingStore。
func NewTempAuthKeyBindingStore(db sqlcgen.DBTX) *TempAuthKeyBindingStore {
return &TempAuthKeyBindingStore{q: sqlcgen.New(db)}
}
func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error {
if err := s.q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{
TempAuthKeyID: authKeyIDToInt64(b.TempAuthKeyID),
PermAuthKeyID: b.PermAuthKeyID,
Nonce: b.Nonce,
TempSessionID: b.TempSessionID,
ExpiresAt: int32(b.ExpiresAt),
EncryptedMessage: b.EncryptedMessage,
}); err != nil {
return fmt.Errorf("upsert temp auth key binding: %w", err)
}
return nil
}
func (s *TempAuthKeyBindingStore) GetByTemp(ctx context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) {
row, err := s.q.GetTempAuthKeyBinding(ctx, authKeyIDToInt64(tempAuthKeyID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.TempAuthKeyBinding{}, false, nil
}
return domain.TempAuthKeyBinding{}, false, fmt.Errorf("get temp auth key binding: %w", err)
}
return domain.TempAuthKeyBinding{
TempAuthKeyID: authKeyIDFromInt64(row.TempAuthKeyID),
PermAuthKeyID: row.PermAuthKeyID,
Nonce: row.Nonce,
TempSessionID: row.TempSessionID,
ExpiresAt: int(row.ExpiresAt),
EncryptedMessage: append([]byte(nil), row.EncryptedMessage...),
}, true, nil
}

View file

@ -0,0 +1,925 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// UpdateEventStore 用 PostgreSQL 实现 store.UpdateEventStore。
type UpdateEventStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewUpdateEventStore 基于 pgx 连接池(或事务)创建 UpdateEventStore。
func NewUpdateEventStore(db sqlcgen.DBTX) *UpdateEventStore {
return &UpdateEventStore{db: db, q: sqlcgen.New(db)}
}
func (s *UpdateEventStore) Append(ctx context.Context, userID int64, event domain.UpdateEvent) error {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
if err := appendUserUpdateEvent(ctx, s.q, userID, event); err != nil {
return fmt.Errorf("append update event: %w", err)
}
return nil
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin append update event: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := appendUserUpdateEvent(ctx, sqlcgen.New(tx), userID, event); err != nil {
return fmt.Errorf("append update event: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit append update event: %w", err)
}
committed = true
return nil
}
// AppendWithDispatch 将账号级 update 事件与在线投递 outbox 放入同一个 PG 事务。
// 设置类 RPC 不像消息发送那样已有业务大事务;这里至少保证“事件已持久化”与
// “可靠在线投递任务已入队”同生共死,避免进程在手动 push 前退出造成在线通知漏投。
func (s *UpdateEventStore) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
if err := s.Append(ctx, userID, event); err != nil {
return err
}
if err := s.q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(event.Pts),
EventType: string(event.Type),
ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID),
ExcludeSessionID: excludeSessionID,
}); err != nil {
return fmt.Errorf("enqueue dispatch: %w", err)
}
return nil
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin append update dispatch: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
qtx := sqlcgen.New(tx)
if err := appendUserUpdateEvent(ctx, qtx, userID, event); err != nil {
return fmt.Errorf("append update event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(event.Pts),
EventType: string(event.Type),
ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID),
ExcludeSessionID: excludeSessionID,
}); err != nil {
return fmt.Errorf("enqueue dispatch: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit append update dispatch: %w", err)
}
committed = true
return nil
}
func appendUserUpdateEvent(ctx context.Context, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent) error {
var messageID *int32
if event.Message.ID != 0 {
id := int32(event.Message.ID)
messageID = &id
}
var peerType *string
var peerID *int64
peer := event.Peer
if peer.ID == 0 {
peer = event.Message.Peer
}
if peer.ID != 0 {
t := string(peer.Type)
id := peer.ID
peerType = &t
peerID = &id
}
peers, err := encodeEventPeers(event.Peers)
if err != nil {
return err
}
settings, err := encodePeerSettings(event.Settings)
if err != nil {
return err
}
messageIDs, err := encodeEventMessageIDs(event.MessageIDs)
if err != nil {
return err
}
dialogFilter, err := encodeEventDialogFilter(event.DialogFilter)
if err != nil {
return err
}
filterOrder, err := encodeEventFilterOrder(event.FilterOrder)
if err != nil {
return err
}
folderPeers, err := encodeEventFolderPeers(event.FolderPeers)
if err != nil {
return err
}
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
UserID: userID,
Pts: int32(event.Pts),
PtsCount: int32(event.PtsCount),
Date: int32(event.Date),
EventType: string(event.Type),
EventBool: event.Bool,
EventPeers: peers,
PeerSettings: settings,
MessageIds: messageIDs,
DialogFilter: dialogFilter,
FilterOrder: filterOrder,
FolderPeers: folderPeers,
MaxID: pgInt32NonNegative(event.MaxID),
StillUnreadCount: int32(event.StillUnreadCount),
FilterID: pgInt32NonNegative(event.FilterID),
TagsEnabled: event.TagsEnabled,
MessageBoxID: messageID,
PeerType: peerType,
PeerID: peerID,
}); err != nil {
return err
}
if _, err := advanceContiguousPts(ctx, q, userID); err != nil {
return fmt.Errorf("advance update watermark: %w", err)
}
return nil
}
func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.q.ListUserUpdateEventsAfter(ctx, sqlcgen.ListUserUpdateEventsAfterParams{
UserID: userID,
Pts: int32(pts),
LimitCount: int32(limit),
})
if err != nil {
return nil, fmt.Errorf("list update events: %w", err)
}
out := make([]domain.UpdateEvent, 0, len(rows))
for _, row := range rows {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return nil, fmt.Errorf("decode message entities: %w", err)
}
silent, noforwards, reply, forward, err := messageMetadataFromFields(
row.Silent,
row.Noforwards,
row.ReplyToMsgID,
row.ReplyToPeerType,
row.ReplyToPeerID,
row.ReplyToTopID,
row.QuoteText,
row.QuoteEntitiesJson,
row.QuoteOffset,
row.FwdFromPeerType,
row.FwdFromPeerID,
row.FwdFromName,
row.FwdDate,
)
if err != nil {
return nil, fmt.Errorf("decode message metadata: %w", err)
}
peers, err := decodeEventPeers(row.EventPeersJson)
if err != nil {
return nil, fmt.Errorf("decode event peers: %w", err)
}
settings, err := decodePeerSettings(row.PeerSettingsJson)
if err != nil {
return nil, fmt.Errorf("decode peer settings: %w", err)
}
messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson)
if err != nil {
return nil, fmt.Errorf("decode message ids: %w", err)
}
dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson)
if err != nil {
return nil, fmt.Errorf("decode dialog filter: %w", err)
}
filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson)
if err != nil {
return nil, fmt.Errorf("decode filter order: %w", err)
}
folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson)
if err != nil {
return nil, fmt.Errorf("decode folder peers: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode message media: %w", err)
}
out = append(out, domain.UpdateEvent{
UserID: row.UserID,
Type: domain.UpdateEventType(row.EventType),
Pts: int(row.Pts),
PtsCount: int(row.PtsCount),
Date: int(row.Date),
Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID},
Peers: peers,
Bool: row.EventBool,
Settings: settings,
MessageIDs: messageIDs,
MaxID: int(row.MaxID),
StillUnreadCount: int(row.StillUnreadCount),
FilterID: int(row.FilterID),
DialogFilter: dialogFilter,
FilterOrder: filterOrder,
FolderPeers: folderPeers,
TagsEnabled: row.TagsEnabled,
Message: domain.Message{
ID: int(row.MessageID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
},
Users: usersFromUpdateEventRow(row),
Channels: channelsFromUpdateEventRow(row),
})
}
return out, nil
}
func (s *UpdateEventStore) Current(ctx context.Context, userID int64) (int, error) {
pts, err := s.q.MaxUserPts(ctx, userID)
if err != nil {
return 0, fmt.Errorf("max user pts: %w", err)
}
return int(pts), nil
}
func (s *UpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
pts, err := advanceContiguousPts(ctx, s.q, userID)
if err != nil {
return 0, fmt.Errorf("advance update watermark: %w", err)
}
return pts, nil
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin advance update watermark: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
pts, err := advanceContiguousPts(ctx, sqlcgen.New(tx), userID)
if err != nil {
return 0, fmt.Errorf("advance update watermark: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit advance update watermark: %w", err)
}
committed = true
return pts, nil
}
// contiguousWindow 是计算最大连续 pts 时回看的顶部 pts 数量。
// 瞬时空洞只来自最近在途的发送事务(提交即填实、回退即补 noop单用户在途量远小于此
// 故窗口内若无空洞即可认定窗口下方连续。生产极端高 fan-in 可调大。
const contiguousWindow = 4096
// MaxContiguousPts 见 store.UpdateEventStore 接口说明。正常路径 O(1) 读账号水位;
// 缺行通常来自迁移前数据,允许一次性从 durable 事件计算并补写。
func (s *UpdateEventStore) MaxContiguousPts(ctx context.Context, userID int64) (int, error) {
pts, err := s.q.GetUserUpdateWatermark(ctx, userID)
if err == nil {
return int(pts), nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("get update watermark: %w", err)
}
return s.AdvanceContiguousPts(ctx, userID)
}
func advanceContiguousPts(ctx context.Context, q *sqlcgen.Queries, userID int64) (int, error) {
if err := q.EnsureUserUpdateWatermark(ctx, userID); err != nil {
return 0, err
}
locked, err := q.LockUserUpdateWatermark(ctx, userID)
if err != nil {
return 0, err
}
contiguous := int(locked)
for {
rows, err := q.NextUserPtsAfter(ctx, sqlcgen.NextUserPtsAfterParams{
UserID: userID,
Pts: int32(contiguous),
LimitCount: contiguousWindow,
})
if err != nil {
return 0, err
}
if len(rows) == 0 {
break
}
advanced := false
for _, row := range rows {
count := maxInt(int(row.PtsCount), 1)
expected := contiguous + count
if int(row.Pts) != expected {
if contiguous > int(locked) {
if err := saveUserUpdateWatermark(ctx, q, userID, contiguous); err != nil {
return 0, err
}
}
return contiguous, nil
}
contiguous = int(row.Pts)
advanced = true
}
if len(rows) < contiguousWindow || !advanced {
break
}
}
if contiguous > int(locked) {
if err := saveUserUpdateWatermark(ctx, q, userID, contiguous); err != nil {
return 0, err
}
}
return contiguous, nil
}
func saveUserUpdateWatermark(ctx context.Context, q *sqlcgen.Queries, userID int64, contiguous int) error {
return q.SaveUserUpdateWatermark(ctx, sqlcgen.SaveUserUpdateWatermarkParams{
UserID: userID,
ContiguousPts: int32(contiguous),
})
}
func computeContiguousPtsFromRecent(ctx context.Context, q *sqlcgen.Queries, userID int64) (int, error) {
rows, err := q.RecentUserPts(ctx, sqlcgen.RecentUserPtsParams{
UserID: userID,
WindowSize: contiguousWindow,
})
if err != nil {
return 0, fmt.Errorf("recent user pts: %w", err)
}
if len(rows) == 0 {
return 0, nil
}
nextByStart := make(map[int]int, len(rows))
floor := int(rows[0].Pts) - maxInt(int(rows[0].PtsCount), 1)
for _, p := range rows {
count := maxInt(int(p.PtsCount), 1)
v := int(p.Pts)
start := v - count
nextByStart[start] = v
if start < floor {
floor = start
}
}
contiguous := floor
for {
next, ok := nextByStart[contiguous]
if !ok {
break
}
contiguous = next
}
return contiguous, nil
}
// BatchByCursor 按 (user_id, pts) 一次性批量取多条账号事件,供 outbox worker 取代逐条 ListAfter。
// 返回顺序不保证与 cursors 一致,调用方按 (UserID,Pts) 自行索引。
func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) {
if len(cursors) == 0 {
return nil, nil
}
userIDs := make([]int64, len(cursors))
ptsList := make([]int32, len(cursors))
for i, c := range cursors {
userIDs[i] = c.UserID
ptsList[i] = int32(c.Pts)
}
rows, err := s.q.BatchListDispatchEvents(ctx, sqlcgen.BatchListDispatchEventsParams{
UserIds: userIDs,
PtsList: ptsList,
})
if err != nil {
return nil, fmt.Errorf("batch list dispatch events: %w", err)
}
out := make([]domain.UpdateEvent, 0, len(rows))
for _, row := range rows {
entities, err := decodeMessageEntities(row.MessageEntitiesJson)
if err != nil {
return nil, fmt.Errorf("decode message entities: %w", err)
}
silent, noforwards, reply, forward, err := messageMetadataFromFields(
row.Silent,
row.Noforwards,
row.ReplyToMsgID,
row.ReplyToPeerType,
row.ReplyToPeerID,
row.ReplyToTopID,
row.QuoteText,
row.QuoteEntitiesJson,
row.QuoteOffset,
row.FwdFromPeerType,
row.FwdFromPeerID,
row.FwdFromName,
row.FwdDate,
)
if err != nil {
return nil, fmt.Errorf("decode message metadata: %w", err)
}
peers, err := decodeEventPeers(row.EventPeersJson)
if err != nil {
return nil, fmt.Errorf("decode event peers: %w", err)
}
settings, err := decodePeerSettings(row.PeerSettingsJson)
if err != nil {
return nil, fmt.Errorf("decode peer settings: %w", err)
}
messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson)
if err != nil {
return nil, fmt.Errorf("decode message ids: %w", err)
}
dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson)
if err != nil {
return nil, fmt.Errorf("decode dialog filter: %w", err)
}
filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson)
if err != nil {
return nil, fmt.Errorf("decode filter order: %w", err)
}
folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson)
if err != nil {
return nil, fmt.Errorf("decode folder peers: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return nil, fmt.Errorf("decode message media: %w", err)
}
out = append(out, domain.UpdateEvent{
UserID: row.UserID,
Type: domain.UpdateEventType(row.EventType),
Pts: int(row.Pts),
PtsCount: int(row.PtsCount),
Date: int(row.Date),
Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID},
Peers: peers,
Bool: row.EventBool,
Settings: settings,
MessageIDs: messageIDs,
MaxID: int(row.MaxID),
StillUnreadCount: int(row.StillUnreadCount),
FilterID: int(row.FilterID),
DialogFilter: dialogFilter,
FilterOrder: filterOrder,
FolderPeers: folderPeers,
TagsEnabled: row.TagsEnabled,
Message: domain.Message{
ID: int(row.MessageID),
UID: row.PrivateMessageID,
OwnerUserID: row.OwnerUserID,
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID},
Date: int(row.MessageDate),
EditDate: int(row.EditDate),
Out: row.Outgoing,
Silent: silent,
NoForwards: noforwards,
Body: row.Body,
Entities: entities,
ReplyTo: reply,
Forward: forward,
Media: media,
},
Users: usersFromBatchDispatchRow(row),
Channels: channelsFromBatchDispatchRow(row),
})
}
return out, nil
}
func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.User {
return mergeEventUsers(
domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
},
domain.User{
ID: row.FromUserUserID,
AccessHash: row.FromUserAccessHash,
Phone: row.FromUserPhone,
FirstName: row.FromUserFirstName,
LastName: row.FromUserLastName,
Username: row.FromUserUsername,
CountryCode: row.FromUserCountryCode,
Verified: row.FromUserVerified,
Support: row.FromUserSupport,
},
domain.User{
ID: row.FwdUserID,
AccessHash: row.FwdUserAccessHash,
Phone: row.FwdUserPhone,
FirstName: row.FwdUserFirstName,
LastName: row.FwdUserLastName,
Username: row.FwdUserUsername,
CountryCode: row.FwdUserCountryCode,
Verified: row.FwdUserVerified,
Support: row.FwdUserSupport,
},
domain.User{
ID: row.ReplyUserID,
AccessHash: row.ReplyUserAccessHash,
Phone: row.ReplyUserPhone,
FirstName: row.ReplyUserFirstName,
LastName: row.ReplyUserLastName,
Username: row.ReplyUserUsername,
CountryCode: row.ReplyUserCountryCode,
Verified: row.ReplyUserVerified,
Support: row.ReplyUserSupport,
},
)
}
// usersFromBatchDispatchRow 与 usersFromUpdateEventRow 等价,只是行类型为 BatchListDispatchEventsRow
// (两条查询列完全一致;改一处列时务必同步另一处)。
func usersFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.User {
return mergeEventUsers(
domain.User{
ID: row.PeerUserID,
AccessHash: row.PeerAccessHash,
Phone: row.PeerPhone,
FirstName: row.PeerFirstName,
LastName: row.PeerLastName,
Username: row.PeerUsername,
CountryCode: row.PeerCountryCode,
Verified: row.PeerVerified,
Support: row.PeerSupport,
},
domain.User{
ID: row.FromUserUserID,
AccessHash: row.FromUserAccessHash,
Phone: row.FromUserPhone,
FirstName: row.FromUserFirstName,
LastName: row.FromUserLastName,
Username: row.FromUserUsername,
CountryCode: row.FromUserCountryCode,
Verified: row.FromUserVerified,
Support: row.FromUserSupport,
},
domain.User{
ID: row.FwdUserID,
AccessHash: row.FwdUserAccessHash,
Phone: row.FwdUserPhone,
FirstName: row.FwdUserFirstName,
LastName: row.FwdUserLastName,
Username: row.FwdUserUsername,
CountryCode: row.FwdUserCountryCode,
Verified: row.FwdUserVerified,
Support: row.FwdUserSupport,
},
domain.User{
ID: row.ReplyUserID,
AccessHash: row.ReplyUserAccessHash,
Phone: row.ReplyUserPhone,
FirstName: row.ReplyUserFirstName,
LastName: row.ReplyUserLastName,
Username: row.ReplyUserUsername,
CountryCode: row.ReplyUserCountryCode,
Verified: row.ReplyUserVerified,
Support: row.ReplyUserSupport,
},
)
}
func channelsFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.Channel {
return mergeEventChannels(
eventChannelFromFields(
row.FwdChannelID, row.FwdChannelAccessHash, row.FwdChannelCreatorUserID, row.FwdChannelTitle, row.FwdChannelAbout, row.FwdChannelUsername,
row.FwdChannelBroadcast, row.FwdChannelMegagroup, row.FwdChannelForum, row.FwdChannelNoforwards, row.FwdChannelSignatures, row.FwdChannelPreHistoryHidden,
int(row.FwdChannelSlowmodeSeconds), row.FwdChannelDefaultBannedRights, int(row.FwdChannelParticipantsCount), int(row.FwdChannelAdminsCount),
int(row.FwdChannelKickedCount), int(row.FwdChannelBannedCount), int(row.FwdChannelTopMessageID), int(row.FwdChannelPinnedMessageID),
int(row.FwdChannelPts), int(row.FwdChannelTtlPeriod), int(row.FwdChannelDate), row.FwdChannelDeleted,
),
eventChannelFromFields(
row.ReplyChannelID, row.ReplyChannelAccessHash, row.ReplyChannelCreatorUserID, row.ReplyChannelTitle, row.ReplyChannelAbout, row.ReplyChannelUsername,
row.ReplyChannelBroadcast, row.ReplyChannelMegagroup, row.ReplyChannelForum, row.ReplyChannelNoforwards, row.ReplyChannelSignatures, row.ReplyChannelPreHistoryHidden,
int(row.ReplyChannelSlowmodeSeconds), row.ReplyChannelDefaultBannedRights, int(row.ReplyChannelParticipantsCount), int(row.ReplyChannelAdminsCount),
int(row.ReplyChannelKickedCount), int(row.ReplyChannelBannedCount), int(row.ReplyChannelTopMessageID), int(row.ReplyChannelPinnedMessageID),
int(row.ReplyChannelPts), int(row.ReplyChannelTtlPeriod), int(row.ReplyChannelDate), row.ReplyChannelDeleted,
),
)
}
func channelsFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.Channel {
return mergeEventChannels(
eventChannelFromFields(
row.FwdChannelID, row.FwdChannelAccessHash, row.FwdChannelCreatorUserID, row.FwdChannelTitle, row.FwdChannelAbout, row.FwdChannelUsername,
row.FwdChannelBroadcast, row.FwdChannelMegagroup, row.FwdChannelForum, row.FwdChannelNoforwards, row.FwdChannelSignatures, row.FwdChannelPreHistoryHidden,
int(row.FwdChannelSlowmodeSeconds), row.FwdChannelDefaultBannedRights, int(row.FwdChannelParticipantsCount), int(row.FwdChannelAdminsCount),
int(row.FwdChannelKickedCount), int(row.FwdChannelBannedCount), int(row.FwdChannelTopMessageID), int(row.FwdChannelPinnedMessageID),
int(row.FwdChannelPts), int(row.FwdChannelTtlPeriod), int(row.FwdChannelDate), row.FwdChannelDeleted,
),
eventChannelFromFields(
row.ReplyChannelID, row.ReplyChannelAccessHash, row.ReplyChannelCreatorUserID, row.ReplyChannelTitle, row.ReplyChannelAbout, row.ReplyChannelUsername,
row.ReplyChannelBroadcast, row.ReplyChannelMegagroup, row.ReplyChannelForum, row.ReplyChannelNoforwards, row.ReplyChannelSignatures, row.ReplyChannelPreHistoryHidden,
int(row.ReplyChannelSlowmodeSeconds), row.ReplyChannelDefaultBannedRights, int(row.ReplyChannelParticipantsCount), int(row.ReplyChannelAdminsCount),
int(row.ReplyChannelKickedCount), int(row.ReplyChannelBannedCount), int(row.ReplyChannelTopMessageID), int(row.ReplyChannelPinnedMessageID),
int(row.ReplyChannelPts), int(row.ReplyChannelTtlPeriod), int(row.ReplyChannelDate), row.ReplyChannelDeleted,
),
)
}
// mergeEventUsers 合并事件依赖用户,跳过 ID=0 并按 ID 去重。
func mergeEventUsers(items ...domain.User) []domain.User {
users := make([]domain.User, 0, len(items))
add := func(u domain.User) {
if u.ID == 0 {
return
}
for _, existing := range users {
if existing.ID == u.ID {
return
}
}
users = append(users, u)
}
for _, item := range items {
add(item)
}
return users
}
func eventChannelFromFields(id, accessHash, creatorUserID int64, title, about, username string, broadcast, megagroup, forum, noforwards, signatures, preHistoryHidden bool, slowmodeSeconds int, defaultRights string, participantsCount, adminsCount, kickedCount, bannedCount, topMessageID, pinnedMessageID, pts, ttlPeriod, date int, deleted bool) domain.Channel {
if id == 0 {
return domain.Channel{}
}
ch := domain.Channel{
ID: id,
AccessHash: accessHash,
CreatorUserID: creatorUserID,
Title: title,
About: about,
Username: username,
Broadcast: broadcast,
Megagroup: megagroup,
Forum: forum,
NoForwards: noforwards,
Signatures: signatures,
PreHistoryHidden: preHistoryHidden,
SlowmodeSeconds: slowmodeSeconds,
ParticipantsCount: participantsCount,
AdminsCount: adminsCount,
KickedCount: kickedCount,
BannedCount: bannedCount,
TopMessageID: topMessageID,
PinnedMessageID: pinnedMessageID,
Pts: pts,
TTLPeriod: ttlPeriod,
Date: date,
Deleted: deleted,
}
_ = json.Unmarshal([]byte(defaultRights), &ch.DefaultBannedRights)
return ch
}
func mergeEventChannels(items ...domain.Channel) []domain.Channel {
channels := make([]domain.Channel, 0, len(items))
seen := make(map[int64]struct{}, len(items))
for _, ch := range items {
if ch.ID == 0 {
continue
}
if _, ok := seen[ch.ID]; ok {
continue
}
seen[ch.ID] = struct{}{}
channels = append(channels, ch)
}
return channels
}
type eventPeerJSON struct {
Type string `json:"type"`
ID int64 `json:"id"`
}
func encodeEventPeers(peers []domain.Peer) ([]byte, error) {
if len(peers) == 0 {
return []byte("[]"), nil
}
wire := make([]eventPeerJSON, 0, len(peers))
for _, peer := range peers {
if peer.ID == 0 {
continue
}
wire = append(wire, eventPeerJSON{Type: string(peer.Type), ID: peer.ID})
}
raw, err := json.Marshal(wire)
if err != nil {
return nil, fmt.Errorf("marshal event peers: %w", err)
}
return raw, nil
}
func decodeEventPeers(raw string) ([]domain.Peer, error) {
if raw == "" {
return nil, nil
}
var wire []eventPeerJSON
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return nil, err
}
out := make([]domain.Peer, 0, len(wire))
for _, peer := range wire {
if peer.ID == 0 {
continue
}
out = append(out, domain.Peer{Type: domain.PeerType(peer.Type), ID: peer.ID})
}
return out, nil
}
func encodeEventMessageIDs(ids []int) ([]byte, error) {
if len(ids) == 0 {
return []byte("[]"), nil
}
raw, err := json.Marshal(ids)
if err != nil {
return nil, fmt.Errorf("marshal event message ids: %w", err)
}
return raw, nil
}
func decodeEventMessageIDs(raw string) ([]int, error) {
if raw == "" {
return nil, nil
}
var ids []int
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return nil, err
}
return ids, nil
}
func encodeEventDialogFilter(folder *domain.DialogFolder) ([]byte, error) {
if folder == nil {
return []byte("{}"), nil
}
raw, err := json.Marshal(folder)
if err != nil {
return nil, fmt.Errorf("marshal event dialog filter: %w", err)
}
return raw, nil
}
func decodeEventDialogFilter(raw string) (*domain.DialogFolder, error) {
if raw == "" || raw == "{}" {
return nil, nil
}
var folder domain.DialogFolder
if err := json.Unmarshal([]byte(raw), &folder); err != nil {
return nil, err
}
return &folder, nil
}
func encodeEventFilterOrder(order []int) ([]byte, error) {
if len(order) == 0 {
return []byte("[]"), nil
}
raw, err := json.Marshal(order)
if err != nil {
return nil, fmt.Errorf("marshal event filter order: %w", err)
}
return raw, nil
}
func decodeEventFilterOrder(raw string) ([]int, error) {
if raw == "" {
return nil, nil
}
var order []int
if err := json.Unmarshal([]byte(raw), &order); err != nil {
return nil, err
}
return order, nil
}
func encodeEventFolderPeers(peers []domain.FolderPeerUpdate) ([]byte, error) {
if len(peers) == 0 {
return []byte("[]"), nil
}
raw, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("marshal event folder peers: %w", err)
}
return raw, nil
}
func decodeEventFolderPeers(raw string) ([]domain.FolderPeerUpdate, error) {
if raw == "" {
return nil, nil
}
var peers []domain.FolderPeerUpdate
if err := json.Unmarshal([]byte(raw), &peers); err != nil {
return nil, err
}
return peers, nil
}
type peerSettingsJSON struct {
AddContact bool `json:"add_contact,omitempty"`
BlockContact bool `json:"block_contact,omitempty"`
ShareContact bool `json:"share_contact,omitempty"`
NeedContactsException bool `json:"need_contacts_exception,omitempty"`
HiddenPeerSettingsBar bool `json:"hidden_peer_settings_bar,omitempty"`
}
func encodePeerSettings(settings domain.PeerSettings) ([]byte, error) {
raw, err := json.Marshal(peerSettingsJSON{
AddContact: settings.AddContact,
BlockContact: settings.BlockContact,
ShareContact: settings.ShareContact,
NeedContactsException: settings.NeedContactsException,
HiddenPeerSettingsBar: settings.HiddenPeerSettingsBar,
})
if err != nil {
return nil, fmt.Errorf("marshal peer settings: %w", err)
}
return raw, nil
}
func decodePeerSettings(raw string) (domain.PeerSettings, error) {
if raw == "" {
return domain.PeerSettings{}, nil
}
var wire peerSettingsJSON
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return domain.PeerSettings{}, err
}
return domain.PeerSettings{
AddContact: wire.AddContact,
BlockContact: wire.BlockContact,
ShareContact: wire.ShareContact,
NeedContactsException: wire.NeedContactsException,
HiddenPeerSettingsBar: wire.HiddenPeerSettingsBar,
}, nil
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}

View file

@ -0,0 +1,72 @@
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// UpdateStateStore 用 PostgreSQL 实现 store.UpdateStateStore。
type UpdateStateStore struct {
q *sqlcgen.Queries
}
// NewUpdateStateStore 基于 pgx 连接池(或事务)创建 UpdateStateStore。
func NewUpdateStateStore(db sqlcgen.DBTX) *UpdateStateStore {
return &UpdateStateStore{q: sqlcgen.New(db)}
}
func (s *UpdateStateStore) Get(ctx context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) {
row, err := s.q.GetUpdateState(ctx, sqlcgen.GetUpdateStateParams{
AuthKeyID: authKeyIDToInt64(id),
UserID: userID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.UpdateState{}, false, nil
}
return domain.UpdateState{}, false, fmt.Errorf("get update state: %w", err)
}
return domain.UpdateState{
Pts: int(row.Pts),
Qts: int(row.Qts),
Date: int(row.Date),
Seq: int(row.Seq),
}, true, nil
}
func (s *UpdateStateStore) Save(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
if err := s.q.UpsertUpdateState(ctx, sqlcgen.UpsertUpdateStateParams{
AuthKeyID: authKeyIDToInt64(id),
UserID: userID,
Pts: int32(st.Pts),
Qts: int32(st.Qts),
Date: int32(st.Date),
Seq: int32(st.Seq),
}); err != nil {
return fmt.Errorf("upsert update state: %w", err)
}
return nil
}
func (s *UpdateStateStore) Delete(ctx context.Context, id [8]byte, userID int64) error {
if err := s.q.DeleteUpdateState(ctx, sqlcgen.DeleteUpdateStateParams{
AuthKeyID: authKeyIDToInt64(id),
UserID: userID,
}); err != nil {
return fmt.Errorf("delete update state: %w", err)
}
return nil
}
func (s *UpdateStateStore) DeleteAuthKey(ctx context.Context, id [8]byte) error {
if err := s.q.DeleteUpdateStatesByAuthKey(ctx, authKeyIDToInt64(id)); err != nil {
return fmt.Errorf("delete update states by auth key: %w", err)
}
return nil
}

View file

@ -0,0 +1,234 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// UserStore 用 PostgreSQL 实现 store.UserStore。
type UserStore struct {
q *sqlcgen.Queries
}
// NewUserStore 基于 pgx 连接池(或事务)创建 UserStore。
func NewUserStore(db sqlcgen.DBTX) *UserStore {
return &UserStore{q: sqlcgen.New(db)}
}
func (s *UserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) {
row, err := s.q.GetUserByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, false, nil
}
return domain.User{}, false, fmt.Errorf("get user by id: %w", err)
}
return userFromModel(row), true, nil
}
func (s *UserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := s.q.GetUsersByIDs(ctx, ids)
if err != nil {
return nil, fmt.Errorf("get users by ids: %w", err)
}
out := make([]domain.User, 0, len(rows))
for _, row := range rows {
out = append(out, userFromModel(row))
}
return out, nil
}
func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) {
row, err := s.q.GetUserByPhone(ctx, phone)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, false, nil
}
return domain.User{}, false, fmt.Errorf("get user by phone: %w", err)
}
return userFromModel(row), true, nil
}
func (s *UserStore) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) {
if len(phones) == 0 {
return nil, nil
}
rows, err := s.q.GetUsersByPhones(ctx, phones)
if err != nil {
return nil, fmt.Errorf("get users by phones: %w", err)
}
out := make([]domain.User, 0, len(rows))
for _, row := range rows {
out = append(out, userFromModel(row))
}
return out, nil
}
func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
if username == "" {
return domain.User{}, false, nil
}
row, err := s.q.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, false, nil
}
return domain.User{}, false, fmt.Errorf("get user by username: %w", err)
}
return userFromModel(row), true, nil
}
func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) {
query = strings.ToLower(strings.TrimSpace(query))
if currentUserID == 0 || query == "" {
return domain.UserSearchResult{}, nil
}
if limit <= 0 || limit > 50 {
limit = 50
}
rows, err := s.q.SearchUsers(ctx, sqlcgen.SearchUsersParams{
CurrentUserID: currentUserID,
QueryLower: query,
QueryLike: escapeLike(query),
PhoneQuery: phoneQuery,
LimitCount: int32(limit),
})
if err != nil {
return domain.UserSearchResult{}, fmt.Errorf("search users: %w", err)
}
out := domain.UserSearchResult{
MyResults: make([]domain.User, 0, len(rows)),
Results: make([]domain.User, 0, len(rows)),
}
for _, row := range rows {
u := domain.User{
ID: row.ID,
AccessHash: row.AccessHash,
Phone: row.Phone,
FirstName: row.FirstName,
LastName: row.LastName,
About: row.About,
Username: row.Username,
CountryCode: row.CountryCode,
Verified: row.Verified,
Support: row.Support,
LastSeenAt: int(row.LastSeenAt),
Contact: row.Contact,
Mutual: row.Mutual,
}
if row.Contact {
out.MyResults = append(out.MyResults, u)
} else {
out.Results = append(out.Results, u)
}
}
return out, nil
}
func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error) {
row, err := s.q.UpdateUserProfile(ctx, sqlcgen.UpdateUserProfileParams{
ID: userID,
FirstName: firstName,
LastName: lastName,
About: about,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrFirstNameInvalid
}
return domain.User{}, fmt.Errorf("update user profile: %w", err)
}
return userFromModel(row), nil
}
func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
row, err := s.q.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{
ID: userID,
Username: username,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" {
return domain.User{}, domain.ErrUsernameOccupied
}
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUsernameNotOccupied
}
return domain.User{}, fmt.Errorf("update user username: %w", err)
}
return userFromModel(row), nil
}
func (s *UserStore) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error {
if lastSeenAt <= 0 {
return nil
}
if err := s.q.UpdateUserLastSeen(ctx, sqlcgen.UpdateUserLastSeenParams{
ID: userID,
LastSeenAt: int64(lastSeenAt),
}); err != nil {
return fmt.Errorf("update user last seen: %w", err)
}
return nil
}
func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, error) {
row, err := s.q.CreateUser(ctx, sqlcgen.CreateUserParams{
AccessHash: u.AccessHash,
Phone: u.Phone,
FirstName: u.FirstName,
LastName: u.LastName,
Username: u.Username,
CountryCode: u.CountryCode,
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" {
return domain.User{}, domain.ErrUsernameOccupied
}
return domain.User{}, fmt.Errorf("create user: %w", err)
}
return userFromModel(row), nil
}
func escapeLike(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '%' || r == '_' || r == '\\' {
b.WriteRune('\\')
}
b.WriteRune(r)
}
return b.String()
}
func userFromModel(r sqlcgen.User) domain.User {
return domain.User{
ID: r.ID,
AccessHash: r.AccessHash,
Phone: r.Phone,
FirstName: r.FirstName,
LastName: r.LastName,
About: r.About,
Username: r.Username,
CountryCode: r.CountryCode,
Verified: r.Verified,
Support: r.Support,
LastSeenAt: int(r.LastSeenAt),
}
}

View file

@ -0,0 +1,11 @@
package store
import (
"context"
"time"
)
// RateLimiter 提供轻量窗口限流。返回 retryAfterSeconds 供 RPC 映射 FLOOD_WAIT_X。
type RateLimiter interface {
Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error)
}

View file

@ -0,0 +1,303 @@
package redisstore
import (
"context"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
// PtsAllocator 用 Redis INCR 分配账号级 ptsRedis 丢失时从 PG durable log 恢复当前最大 pts。
type PtsAllocator struct {
counter counterAllocator
}
// BoxIDAllocator 用 Redis INCR 分配 owner 视角的 message box id。
type BoxIDAllocator struct {
counter counterAllocator
}
// ChannelPtsAllocator 用 Redis INCR 分配 channel 维度 pts。
type ChannelPtsAllocator struct {
counter counterAllocator
}
// ChannelIDAllocator 用 Redis INCR 分配全局 channel/supergroup id。
type ChannelIDAllocator struct {
counter counterAllocator
}
// ChannelMessageIDAllocator 用 Redis INCR 分配 channel 维度 message id。
type ChannelMessageIDAllocator struct {
counter counterAllocator
}
type counterAllocator struct {
c *redis.Client
source store.CounterSource
key func(int64) string
name string
}
const missingCounterSentinel int64 = -1
var (
counterNextScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if current then
return redis.call("INCR", KEYS[1])
end
return -1
`)
counterNextByScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if current then
return redis.call("INCRBY", KEYS[1], ARGV[1])
end
return -1
`)
counterRecoverCurrentScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if current then
return tonumber(current)
end
redis.call("SET", KEYS[1], ARGV[1])
return tonumber(ARGV[1])
`)
counterRecoverNextScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if not current then
redis.call("SET", KEYS[1], ARGV[1])
end
return redis.call("INCR", KEYS[1])
`)
counterRecoverNextByScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if not current then
redis.call("SET", KEYS[1], ARGV[1])
end
return redis.call("INCRBY", KEYS[1], ARGV[2])
`)
)
// NewPtsAllocator 创建 Redis-backed pts allocator。
func NewPtsAllocator(c *redis.Client, source store.CounterSource) *PtsAllocator {
return &PtsAllocator{counter: counterAllocator{
c: c,
source: source,
key: ptsKey,
name: "pts",
}}
}
// NewBoxIDAllocator 创建 Redis-backed message box id allocator。
func NewBoxIDAllocator(c *redis.Client, source store.CounterSource) *BoxIDAllocator {
return &BoxIDAllocator{counter: counterAllocator{
c: c,
source: source,
key: boxIDKey,
name: "box_id",
}}
}
// NewChannelPtsAllocator 创建 Redis-backed channel pts allocator。
func NewChannelPtsAllocator(c *redis.Client, source store.CounterSource) *ChannelPtsAllocator {
return &ChannelPtsAllocator{counter: counterAllocator{
c: c,
source: source,
key: channelPtsKey,
name: "channel_pts",
}}
}
// NewChannelIDAllocator 创建 Redis-backed channel id allocator。
func NewChannelIDAllocator(c *redis.Client, source store.CounterSource) *ChannelIDAllocator {
return &ChannelIDAllocator{counter: counterAllocator{
c: c,
source: source,
key: channelIDKey,
name: "channel_id",
}}
}
// NewChannelMessageIDAllocator 创建 Redis-backed channel message id allocator。
func NewChannelMessageIDAllocator(c *redis.Client, source store.CounterSource) *ChannelMessageIDAllocator {
return &ChannelMessageIDAllocator{counter: counterAllocator{
c: c,
source: source,
key: channelMessageIDKey,
name: "channel_msg_id",
}}
}
func ptsKey(userID int64) string {
return fmt.Sprintf("counter:pts:{%d}", userID)
}
func boxIDKey(userID int64) string {
return fmt.Sprintf("counter:box_id:{%d}", userID)
}
func channelPtsKey(channelID int64) string {
return fmt.Sprintf("counter:channel_pts:{%d}", channelID)
}
func channelIDKey(_ int64) string {
return "counter:channel_id"
}
func channelMessageIDKey(channelID int64) string {
return fmt.Sprintf("counter:channel_msg_id:{%d}", channelID)
}
func (a *PtsAllocator) NextPts(ctx context.Context, userID int64) (int, error) {
v, err := a.counter.next(ctx, userID)
return int(v), err
}
func (a *PtsAllocator) NextPtsN(ctx context.Context, userID int64, count int) (int, error) {
v, err := a.counter.nextBy(ctx, userID, count)
return int(v), err
}
func (a *PtsAllocator) CurrentPts(ctx context.Context, userID int64) (int, error) {
v, err := a.counter.current(ctx, userID)
return int(v), err
}
func (a *BoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) {
v, err := a.counter.next(ctx, userID)
return int(v), err
}
func (a *BoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) {
v, err := a.counter.current(ctx, userID)
return int(v), err
}
func (a *ChannelPtsAllocator) NextChannelPts(ctx context.Context, channelID int64) (int, error) {
v, err := a.counter.next(ctx, channelID)
return int(v), err
}
func (a *ChannelPtsAllocator) NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) {
v, err := a.counter.nextBy(ctx, channelID, count)
return int(v), err
}
func (a *ChannelPtsAllocator) CurrentChannelPts(ctx context.Context, channelID int64) (int, error) {
v, err := a.counter.current(ctx, channelID)
return int(v), err
}
func (a *ChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) {
return a.counter.next(ctx, 1)
}
func (a *ChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) {
return a.counter.current(ctx, 1)
}
func (a *ChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) {
v, err := a.counter.next(ctx, channelID)
return int(v), err
}
func (a *ChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) {
v, err := a.counter.current(ctx, channelID)
return int(v), err
}
func (a counterAllocator) next(ctx context.Context, userID int64) (int64, error) {
return a.nextBy(ctx, userID, 1)
}
func (a counterAllocator) nextBy(ctx context.Context, userID int64, count int) (int64, error) {
if count <= 0 {
return 0, fmt.Errorf("redis next %s counter: invalid count %d", a.name, count)
}
key, err := a.validatedKey(userID)
if err != nil {
return 0, err
}
script := counterNextScript
args := []any{}
if count > 1 {
script = counterNextByScript
args = append(args, count)
}
v, err := script.Run(ctx, a.c, []string{key}, args...).Int64()
if err != nil {
return 0, fmt.Errorf("redis next %s counter: %w", a.name, err)
}
if v != missingCounterSentinel {
return v, nil
}
recovered, err := a.recovered(ctx, userID)
if err != nil {
return 0, err
}
recoverScript := counterRecoverNextScript
recoverArgs := []any{recovered}
if count > 1 {
recoverScript = counterRecoverNextByScript
recoverArgs = append(recoverArgs, count)
}
v, err = recoverScript.Run(ctx, a.c, []string{key}, recoverArgs...).Int64()
if err != nil {
return 0, fmt.Errorf("redis recover-next %s counter: %w", a.name, err)
}
return v, nil
}
func (a counterAllocator) current(ctx context.Context, userID int64) (int64, error) {
key, err := a.validatedKey(userID)
if err != nil {
return 0, err
}
v, err := a.c.Get(ctx, key).Int64()
if err == nil {
return v, nil
}
if !errors.Is(err, redis.Nil) {
return 0, fmt.Errorf("redis get %s counter: %w", a.name, err)
}
recovered, err := a.recovered(ctx, userID)
if err != nil {
return 0, err
}
v, err = counterRecoverCurrentScript.Run(ctx, a.c, []string{key}, recovered).Int64()
if err != nil {
return 0, fmt.Errorf("redis recover-current %s counter: %w", a.name, err)
}
return v, nil
}
func (a counterAllocator) validatedKey(userID int64) (string, error) {
if userID == 0 {
return "", fmt.Errorf("redis %s counter: missing user id", a.name)
}
if a.c == nil {
return "", fmt.Errorf("redis %s counter: nil client", a.name)
}
return a.key(userID), nil
}
func (a counterAllocator) recovered(ctx context.Context, userID int64) (int, error) {
recovered := 0
var err error
if a.source != nil {
recovered, err = a.source.Current(ctx, userID)
if err != nil {
return 0, fmt.Errorf("recover %s counter: %w", a.name, err)
}
}
return recovered, nil
}

View file

@ -0,0 +1,160 @@
package redisstore
import (
"context"
"os"
"sync"
"testing"
"time"
)
type staticCounterSource struct {
value int
}
func (s staticCounterSource) Current(context.Context, int64) (int, error) {
return s.value, nil
}
func TestRedisAllocatorsRecoverFromCounterSource(t *testing.T) {
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
}
ctx := context.Background()
c, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
userID := time.Now().UnixNano()
t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID), boxIDKey(userID)).Err() })
pts := NewPtsAllocator(c, staticCounterSource{value: 41})
currentPts, err := pts.CurrentPts(ctx, userID)
if err != nil {
t.Fatalf("CurrentPts: %v", err)
}
if currentPts != 41 {
t.Fatalf("current pts = %d, want recovered 41", currentPts)
}
nextPts, err := pts.NextPts(ctx, userID)
if err != nil {
t.Fatalf("NextPts: %v", err)
}
if nextPts != 42 {
t.Fatalf("next pts = %d, want 42", nextPts)
}
boxes := NewBoxIDAllocator(c, staticCounterSource{value: 100})
currentBox, err := boxes.CurrentBoxID(ctx, userID)
if err != nil {
t.Fatalf("CurrentBoxID: %v", err)
}
if currentBox != 100 {
t.Fatalf("current box = %d, want recovered 100", currentBox)
}
nextBox, err := boxes.NextBoxID(ctx, userID)
if err != nil {
t.Fatalf("NextBoxID: %v", err)
}
if nextBox != 101 {
t.Fatalf("next box = %d, want 101", nextBox)
}
}
func TestRedisAllocatorConcurrentFirstUse(t *testing.T) {
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
}
ctx := context.Background()
c, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
userID := time.Now().UnixNano()
t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID)).Err() })
const workers = 32
pts := NewPtsAllocator(c, staticCounterSource{value: 1000})
values := make(chan int, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
v, err := pts.NextPts(ctx, userID)
if err != nil {
errs <- err
return
}
values <- v
}()
}
wg.Wait()
close(values)
close(errs)
for err := range errs {
t.Fatalf("NextPts: %v", err)
}
seen := make(map[int]bool, workers)
for v := range values {
if v < 1001 || v > 1000+workers {
t.Fatalf("pts = %d, want recovered contiguous range", v)
}
if seen[v] {
t.Fatalf("duplicate pts %d", v)
}
seen[v] = true
}
for want := 1001; want <= 1000+workers; want++ {
if !seen[want] {
t.Fatalf("missing pts %d", want)
}
}
current, err := pts.CurrentPts(ctx, userID)
if err != nil {
t.Fatalf("CurrentPts: %v", err)
}
if current != 1000+workers {
t.Fatalf("current pts = %d, want %d", current, 1000+workers)
}
}
func TestRedisRateLimiterWindow(t *testing.T) {
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
}
ctx := context.Background()
c, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
key := "test:" + time.Now().Format("150405.000000000")
t.Cleanup(func() { _ = c.Del(ctx, rateLimitKey(key)).Err() })
limiter := NewRateLimiter(c)
allowed, retry, err := limiter.Allow(ctx, key, 1, time.Minute)
if err != nil {
t.Fatalf("Allow first: %v", err)
}
if !allowed || retry != 0 {
t.Fatalf("first allowed=%v retry=%d, want allowed", allowed, retry)
}
allowed, retry, err = limiter.Allow(ctx, key, 1, time.Minute)
if err != nil {
t.Fatalf("Allow second: %v", err)
}
if allowed || retry <= 0 {
t.Fatalf("second allowed=%v retry=%d, want limited with retry", allowed, retry)
}
}

View file

@ -0,0 +1,55 @@
package redisstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
// CodeStore 用 Redis 实现 store.CodeStore验证码带 TTL 自动过期)。
type CodeStore struct {
c *redis.Client
}
// NewCodeStore 创建 Redis CodeStore。
func NewCodeStore(c *redis.Client) *CodeStore {
return &CodeStore{c: c}
}
func codeKey(hash string) string { return "phonecode:" + hash }
func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
v, err := json.Marshal(code)
if err != nil {
return fmt.Errorf("marshal phone code: %w", err)
}
if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil {
return fmt.Errorf("redis set phone code: %w", err)
}
return nil
}
func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool, error) {
raw, err := s.c.Get(ctx, codeKey(hash)).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return store.PhoneCode{}, false, nil
}
return store.PhoneCode{}, false, fmt.Errorf("redis get phone code: %w", err)
}
var code store.PhoneCode
if err := json.Unmarshal(raw, &code); err != nil {
return store.PhoneCode{}, false, fmt.Errorf("unmarshal phone code: %w", err)
}
return code, true, nil
}
func (s *CodeStore) Del(ctx context.Context, hash string) error {
return s.c.Del(ctx, codeKey(hash)).Err()
}

View file

@ -0,0 +1,57 @@
package redisstore
import (
"context"
"fmt"
"math"
"time"
"github.com/redis/go-redis/v9"
)
// RateLimiter 用 Redis INCR + TTL 实现固定窗口限流。
type RateLimiter struct {
c *redis.Client
}
// NewRateLimiter 创建 Redis-backed RateLimiter。
func NewRateLimiter(c *redis.Client) *RateLimiter {
return &RateLimiter{c: c}
}
func rateLimitKey(key string) string {
return "ratelimit:" + key
}
func (l *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) {
if limit <= 0 {
return true, 0, nil
}
if window <= 0 {
window = time.Second
}
if l == nil || l.c == nil {
return false, 0, fmt.Errorf("redis rate limiter: nil client")
}
redisKey := rateLimitKey(key)
count, err := l.c.Incr(ctx, redisKey).Result()
if err != nil {
return false, 0, fmt.Errorf("redis incr rate limit: %w", err)
}
if count == 1 {
if err := l.c.Expire(ctx, redisKey, window).Err(); err != nil {
return false, 0, fmt.Errorf("redis expire rate limit: %w", err)
}
}
if count <= int64(limit) {
return true, 0, nil
}
ttl, err := l.c.TTL(ctx, redisKey).Result()
if err != nil {
return false, 0, fmt.Errorf("redis ttl rate limit: %w", err)
}
if ttl <= 0 {
ttl = window
}
return false, int(math.Ceil(ttl.Seconds())), nil
}

View file

@ -0,0 +1,25 @@
// Package redisstore 用 Redis 实现高频易失态的存储接口第一阶段SessionStore
//
// 职责边界见 docs/persistence-layer.md §1Redis 存「态与计数」,丢失可由 PG/协议恢复。
package redisstore
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
// Open 按地址建立 Redis 连接并 ping 验证。
func Open(ctx context.Context, addr, password string, db int) (*redis.Client, error) {
c := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
})
if err := c.Ping(ctx).Err(); err != nil {
_ = c.Close()
return nil, fmt.Errorf("redis ping %s: %w", addr, err)
}
return c, nil
}

View file

@ -0,0 +1,75 @@
package redisstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
// DefaultSessionTTL 是 session 记录的默认过期时间。
// session 是连接态:过期或丢失后,客户端重连会触发 new_session_created / bad_server_salt 重建,
// 因此 TTL 不必很长。
const DefaultSessionTTL = 30 * 24 * time.Hour
// SessionStore 用 Redis 实现 store.SessionStore。
type SessionStore struct {
c *redis.Client
ttl time.Duration
}
// NewSessionStore 创建 Redis SessionStore。ttl<=0 表示永不过期。
func NewSessionStore(c *redis.Client, ttl time.Duration) *SessionStore {
return &SessionStore{c: c, ttl: ttl}
}
func sessionKey(id int64) string {
return fmt.Sprintf("session:%d", id)
}
// sessionValue 是 SessionData 在 Redis 中的序列化形态(不含 IDID 即 key
type sessionValue struct {
AuthKeyID [8]byte `json:"auth_key_id"`
Salt int64 `json:"salt"`
LastSeen int64 `json:"last_seen"`
}
// Save 实现 store.SessionStore。
func (s *SessionStore) Save(ctx context.Context, d store.SessionData) error {
v, err := json.Marshal(sessionValue{AuthKeyID: d.AuthKeyID, Salt: d.Salt, LastSeen: d.LastSeen})
if err != nil {
return fmt.Errorf("marshal session: %w", err)
}
if err := s.c.Set(ctx, sessionKey(d.ID), v, s.ttl).Err(); err != nil {
return fmt.Errorf("redis set session: %w", err)
}
return nil
}
// Get 实现 store.SessionStore。不存在时 found=false。
func (s *SessionStore) Get(ctx context.Context, id int64) (store.SessionData, bool, error) {
raw, err := s.c.Get(ctx, sessionKey(id)).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return store.SessionData{}, false, nil
}
return store.SessionData{}, false, fmt.Errorf("redis get session: %w", err)
}
var v sessionValue
if err := json.Unmarshal(raw, &v); err != nil {
return store.SessionData{}, false, fmt.Errorf("unmarshal session: %w", err)
}
return store.SessionData{ID: id, AuthKeyID: v.AuthKeyID, Salt: v.Salt, LastSeen: v.LastSeen}, true, nil
}
func (s *SessionStore) Delete(ctx context.Context, id int64) error {
if err := s.c.Del(ctx, sessionKey(id)).Err(); err != nil {
return fmt.Errorf("redis delete session: %w", err)
}
return nil
}

View file

@ -0,0 +1,52 @@
package redisstore
import (
"context"
"os"
"testing"
"time"
"telesrv/internal/store"
)
// TestSessionStoreRoundTrip 验证 session 落 Redis 后能用全新 store 实例原样读回。
// 未设 TELESRV_TEST_REDIS_ADDR 则跳过。
func TestSessionStoreRoundTrip(t *testing.T) {
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
}
ctx := context.Background()
c, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
want := store.SessionData{
ID: 0x1234beef,
AuthKeyID: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
Salt: 42,
LastSeen: 1000,
}
t.Cleanup(func() { _ = c.Del(ctx, sessionKey(want.ID)).Err() })
if err := NewSessionStore(c, time.Minute).Save(ctx, want); err != nil {
t.Fatalf("save: %v", err)
}
got, found, err := NewSessionStore(c, time.Minute).Get(ctx, want.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if !found {
t.Fatal("session not found after save")
}
if got != want {
t.Fatalf("round trip mismatch: got %+v want %+v", got, want)
}
if _, found, _ := NewSessionStore(c, time.Minute).Get(ctx, 999999); found {
t.Fatal("unexpected found for missing session")
}
}

View file

@ -0,0 +1,112 @@
package redisstore
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
// UserCounterAllocator 在一次 Lua 往返内同时分配某 user 的下一个 pts 与 box_id。
//
// counter:pts:{userID} 与 counter:box_id:{userID} 共享 hash-tag {userID},处于同一
// Redis Cluster slot可被单个 Lua 脚本原子操作——把发送热路径上「pts + box 两次往返」
// 合并成一次。语义与 PtsAllocator/BoxIDAllocator 各自调用等价pts 账号级无洞、
// Redis 冷 miss 时从 PG durable log 恢复基线。
type UserCounterAllocator struct {
c *redis.Client
ptsSource store.CounterSource
boxSource store.CounterSource
}
// NewUserCounterAllocator 创建合并 allocator。ptsSource 恢复 ptsMAX(user_update_events.pts)
// boxSource 恢复 box_idMAX(message_boxes.box_id))。
func NewUserCounterAllocator(c *redis.Client, ptsSource, boxSource store.CounterSource) *UserCounterAllocator {
return &UserCounterAllocator{c: c, ptsSource: ptsSource, boxSource: boxSource}
}
// userCountersNextScript 热路径:两 key 都存在时各自 INCR 并返回;任一缺失返回 {-1,-1} 触发恢复。
var userCountersNextScript = redis.NewScript(`
local pts = redis.call("GET", KEYS[1])
local box = redis.call("GET", KEYS[2])
if pts and box then
return {redis.call("INCR", KEYS[1]), redis.call("INCR", KEYS[2])}
end
return {-1, -1}
`)
// userCountersRecoverScript 冷路径:每个 key「缺失才用 PG 基线 SET再 INCR」。
// 对已存在的 key 跳过 SET 只 INCR故对「一存一缺」也安全Redis 单线程串行化保证无重复无洞。
var userCountersRecoverScript = redis.NewScript(`
local function recnext(key, base)
if not redis.call("GET", key) then
redis.call("SET", key, base)
end
return redis.call("INCR", key)
end
return {recnext(KEYS[1], ARGV[1]), recnext(KEYS[2], ARGV[2])}
`)
// NextUserCounters 返回该 user 的下一个 (pts, boxID)。
func (a *UserCounterAllocator) NextUserCounters(ctx context.Context, userID int64) (int, int, error) {
if userID == 0 {
return 0, 0, fmt.Errorf("redis user counters: missing user id")
}
if a.c == nil {
return 0, 0, fmt.Errorf("redis user counters: nil client")
}
keys := []string{ptsKey(userID), boxIDKey(userID)}
pts, box, err := runTwoCounters(ctx, a.c, userCountersNextScript, keys)
if err != nil {
return 0, 0, fmt.Errorf("redis next user counters: %w", err)
}
if pts != missingCounterSentinel && box != missingCounterSentinel {
return int(pts), int(box), nil
}
// 冷路径:至少一个 key 缺失,从 PG durable log 恢复两个基线,再 recover-next。
ptsBase, err := recoverBase(ctx, a.ptsSource, userID, "pts")
if err != nil {
return 0, 0, err
}
boxBase, err := recoverBase(ctx, a.boxSource, userID, "box_id")
if err != nil {
return 0, 0, err
}
pts, box, err = runTwoCounters(ctx, a.c, userCountersRecoverScript, keys, ptsBase, boxBase)
if err != nil {
return 0, 0, fmt.Errorf("redis recover user counters: %w", err)
}
return int(pts), int(box), nil
}
// runTwoCounters 执行返回二元整数表的 Lua 脚本,用 .Slice() 手动解析(不依赖 Int64Slice
func runTwoCounters(ctx context.Context, c *redis.Client, script *redis.Script, keys []string, args ...any) (int64, int64, error) {
raw, err := script.Run(ctx, c, keys, args...).Slice()
if err != nil {
return 0, 0, err
}
if len(raw) != 2 {
return 0, 0, fmt.Errorf("unexpected reply len %d, want 2", len(raw))
}
a, okA := raw[0].(int64)
b, okB := raw[1].(int64)
if !okA || !okB {
return 0, 0, fmt.Errorf("unexpected reply element types %T,%T, want int64", raw[0], raw[1])
}
return a, b, nil
}
func recoverBase(ctx context.Context, source store.CounterSource, userID int64, name string) (int, error) {
if source == nil {
return 0, nil
}
v, err := source.Current(ctx, userID)
if err != nil {
return 0, fmt.Errorf("recover %s counter: %w", name, err)
}
return v, nil
}

23
internal/store/session.go Normal file
View file

@ -0,0 +1,23 @@
package store
import "context"
// SessionData 是一条 MTProto session 记录client 生成的 session_id
//
// 后续里程碑会扩展 device / layer 等字段。
type SessionData struct {
ID int64 // session_id客户端生成
AuthKeyID [8]byte // 绑定的 auth key
Salt int64 // 当前 server salt
LastSeen int64 // unix 秒
}
// SessionStore 记录在线 MTProto session。实现见 store/memory测试替身、store/redisstore。
type SessionStore interface {
// Save 保存或更新一条 session 记录。
Save(ctx context.Context, s SessionData) error
// Get 按 session_id 查询;不存在时 found=false。
Get(ctx context.Context, id int64) (data SessionData, found bool, err error)
// Delete 删除一条 session 记录;不存在时不报错。
Delete(ctx context.Context, id int64) error
}

View file

@ -0,0 +1,13 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// TempAuthKeyBindingStore 持久化 auth.bindTempAuthKey 的 temp→perm 绑定。
type TempAuthKeyBindingStore interface {
Save(ctx context.Context, binding domain.TempAuthKeyBinding) error
GetByTemp(ctx context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error)
}

View file

@ -0,0 +1,26 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// UpdateEventStore 持久化 user 维度的增量事件。
type UpdateEventStore interface {
Append(ctx context.Context, userID int64, event domain.UpdateEvent) error
ListAfter(ctx context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error)
Current(ctx context.Context, userID int64) (int, error)
// MaxContiguousPts 返回从 1 起无空洞的最大已提交 pts。
// 并发发送在途时pts 已分配未提交)会暂时小于 Current最大已提交空洞提交/补洞后回升。
// getState/getDifference 据此只暴露连续 pts避免客户端跳过在途空洞而丢消息。
MaxContiguousPts(ctx context.Context, userID int64) (int, error)
// AdvanceContiguousPts 推进并返回账号级连续 pts 水位。实现应在写事件的同一事务内调用。
AdvanceContiguousPts(ctx context.Context, userID int64) (int, error)
}
// EventCursor 精确定位单条账号事件,用于 outbox worker 批量加载已 claim 事件。
type EventCursor struct {
UserID int64
Pts int
}

View file

@ -0,0 +1,15 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// UpdateStateStore 持久化 auth_key + user 维度的 pts/qts/seq 状态,避免同一设备换号串状态。
type UpdateStateStore interface {
Get(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error)
Save(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState) error
Delete(ctx context.Context, authKeyID [8]byte, userID int64) error
DeleteAuthKey(ctx context.Context, authKeyID [8]byte) error
}

22
internal/store/user.go Normal file
View file

@ -0,0 +1,22 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// UserStore 持久化用户。实现见 store/memory测试替身、store/postgres。
type UserStore interface {
ByID(ctx context.Context, id int64) (domain.User, bool, error)
ByIDs(ctx context.Context, ids []int64) ([]domain.User, error)
ByPhone(ctx context.Context, phone string) (domain.User, bool, error)
ByPhones(ctx context.Context, phones []string) ([]domain.User, error)
ByUsername(ctx context.Context, username string) (domain.User, bool, error)
Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error)
UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error)
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error
// Create 创建用户并返回分配了 ID 的副本。
Create(ctx context.Context, u domain.User) (domain.User, error)
}