feat: sync monoforum and collectible emoji status
Sync telesrv bd15657 (feat(account): implement collectible emoji status). Skipped telesrv docs changes per public sync rules.
This commit is contained in:
parent
edb7057757
commit
0c99ae0a9d
91 changed files with 4061 additions and 693 deletions
|
|
@ -180,6 +180,7 @@ UPDATE users SET
|
|||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, last_seen_at = 0,
|
||||
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
|
||||
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
|
||||
color_set = false, color = 0, color_background_emoji_id = 0,
|
||||
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
|
||||
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
|
||||
|
|
|
|||
|
|
@ -377,6 +377,20 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentIDs := make([]int64, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
|
||||
parentIDs = append(parentIDs, channel.LinkedMonoforumID)
|
||||
}
|
||||
}
|
||||
parents, err := listChannelsByIDs(ctx, s.db, parentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentsByID := make(map[int64]domain.Channel, len(parents))
|
||||
for _, parent := range parents {
|
||||
parentsByID[parent.ID] = parent
|
||||
}
|
||||
linkedGuests, err := s.listLinkedDiscussionGuests(ctx, s.db, viewerUserID, remaining)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -402,6 +416,17 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
|
|||
}
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
|
||||
if parent, ok := parentsByID[channel.LinkedMonoforumID]; ok && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID {
|
||||
member := syntheticMonoforumUserMember(channel, viewerUserID)
|
||||
views[channel.ID] = domain.ChannelView{
|
||||
Channel: channel,
|
||||
Self: member,
|
||||
Dialog: previewChannelDialog(viewerUserID, channel, member),
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(channel) {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,12 +298,26 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID,
|
|||
limit = domain.MaxSynchronousChannelDialogFanout
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT channel_id
|
||||
FROM user_channel_member_index
|
||||
WHERE user_id = $1
|
||||
AND status = 'active'
|
||||
AND NOT deleted
|
||||
AND channel_id > $2
|
||||
WITH visible_channels AS (
|
||||
SELECT channel_id
|
||||
FROM user_channel_member_index
|
||||
WHERE user_id = $1 AND status = 'active' AND NOT deleted
|
||||
UNION
|
||||
SELECT mono.id
|
||||
FROM channels mono
|
||||
JOIN channels parent ON parent.id = mono.linked_monoforum_id
|
||||
AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id
|
||||
WHERE mono.monoforum AND NOT mono.deleted
|
||||
AND (EXISTS (
|
||||
SELECT 1 FROM channel_members admin
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM channel_messages message
|
||||
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
|
||||
))
|
||||
)
|
||||
SELECT channel_id FROM visible_channels
|
||||
WHERE channel_id > $2
|
||||
ORDER BY channel_id
|
||||
LIMIT $3`, userID, afterChannelID, limit)
|
||||
if err != nil {
|
||||
|
|
@ -329,16 +343,31 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userI
|
|||
limit = domain.MaxChannelDifferenceLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT i.channel_id, c.pts
|
||||
FROM user_channel_member_index i
|
||||
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
|
||||
JOIN channel_update_checkpoints cp ON cp.channel_id = i.channel_id
|
||||
WHERE i.user_id = $1
|
||||
AND i.status = 'active'
|
||||
AND NOT i.deleted
|
||||
AND i.channel_id > $3
|
||||
WITH visible_channels AS (
|
||||
SELECT channel_id
|
||||
FROM user_channel_member_index
|
||||
WHERE user_id = $1 AND status = 'active' AND NOT deleted
|
||||
UNION
|
||||
SELECT mono.id
|
||||
FROM channels mono
|
||||
JOIN channels parent ON parent.id = mono.linked_monoforum_id
|
||||
AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id
|
||||
WHERE mono.monoforum AND NOT mono.deleted
|
||||
AND (EXISTS (
|
||||
SELECT 1 FROM channel_members admin
|
||||
WHERE admin.channel_id = parent.id AND admin.user_id = $1 AND admin.status = 'active' AND admin.role IN ('creator', 'admin')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM channel_messages message
|
||||
WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = $1 AND NOT message.deleted
|
||||
))
|
||||
)
|
||||
SELECT visible.channel_id, c.pts
|
||||
FROM visible_channels visible
|
||||
JOIN channels c ON c.id = visible.channel_id AND NOT c.deleted
|
||||
JOIN channel_update_checkpoints cp ON cp.channel_id = visible.channel_id
|
||||
WHERE visible.channel_id > $3
|
||||
AND cp.latest_event_date > $2
|
||||
ORDER BY i.channel_id ASC
|
||||
ORDER BY visible.channel_id ASC
|
||||
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list dirty active channels for user: %w", err)
|
||||
|
|
@ -381,6 +410,15 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
|
|||
} else if ok {
|
||||
return ch, member, true, nil
|
||||
}
|
||||
if ch.Monoforum && ch.LinkedMonoforumID != 0 {
|
||||
parent, parentErr := s.channelByID(ctx, db, ch.LinkedMonoforumID)
|
||||
if parentErr != nil {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, parentErr
|
||||
}
|
||||
if parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == ch.ID {
|
||||
return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(ch) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
|
|||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if channel.Monoforum {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
||||
}
|
||||
if !canInviteToChannel(channel, inviter) {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
|
|
|
|||
|
|
@ -478,6 +478,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
|
|||
return member
|
||||
}
|
||||
|
||||
func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember {
|
||||
return domain.ChannelMember{
|
||||
ChannelID: mono.ID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
}
|
||||
}
|
||||
|
||||
func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool {
|
||||
return rights == domain.ChannelAdminRights{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64,
|
|||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if channel.Monoforum {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
||||
}
|
||||
existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID)
|
||||
if existingErr == nil {
|
||||
switch {
|
||||
|
|
|
|||
|
|
@ -33,17 +33,19 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) {
|
|||
var richMessageJSON string
|
||||
var savedPeerType string
|
||||
var savedPeerID int64
|
||||
var suggestedPostJSON string
|
||||
if err := row.Scan(
|
||||
&msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID,
|
||||
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
|
||||
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
|
||||
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
|
||||
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID,
|
||||
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON,
|
||||
); err != nil {
|
||||
return domain.ChannelMessage{}, err
|
||||
}
|
||||
msg.From.Type = domain.PeerType(fromType)
|
||||
msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID}
|
||||
msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON)
|
||||
if sendAsType.Valid && sendAsID.Valid {
|
||||
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64}
|
||||
}
|
||||
|
|
@ -90,17 +92,19 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er
|
|||
var richMessageJSON string
|
||||
var savedPeerType string
|
||||
var savedPeerID int64
|
||||
var suggestedPostJSON string
|
||||
if err := row.Scan(
|
||||
&msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID,
|
||||
&sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards,
|
||||
&msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID,
|
||||
&forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON,
|
||||
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count,
|
||||
&replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &msg.PaidMessageStars, &suggestedPostJSON, &count,
|
||||
); err != nil {
|
||||
return domain.ChannelMessage{}, 0, err
|
||||
}
|
||||
msg.From.Type = domain.PeerType(fromType)
|
||||
msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID}
|
||||
msg.SuggestedPost = decodeJSONPtr[domain.SuggestedPost](suggestedPostJSON)
|
||||
if sendAsType.Valid && sendAsID.Valid {
|
||||
msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int6
|
|||
base := "channel_id = $1 AND NOT deleted"
|
||||
extraChannels := []domain.Channel(nil)
|
||||
if channel.Monoforum {
|
||||
base += " AND saved_peer_id = 0"
|
||||
if isChannelAdmin(member) {
|
||||
base += " AND saved_peer_id = 0"
|
||||
} else {
|
||||
baseArgs = append(baseArgs, viewerUserID)
|
||||
base += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(baseArgs))
|
||||
}
|
||||
if channel.LinkedMonoforumID != 0 {
|
||||
if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil {
|
||||
extraChannels = append(extraChannels, parent)
|
||||
|
|
|
|||
|
|
@ -470,7 +470,16 @@ WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan(
|
|||
Message: replay,
|
||||
SenderUserID: first.SenderUserID,
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}, nil
|
||||
result := domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}
|
||||
if first.PaidMessageStars > 0 {
|
||||
balance := domain.StarsBalance{UserID: first.SenderUserID}
|
||||
if err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, first.SenderUserID).
|
||||
Scan(&balance.Balance, &balance.Granted); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("load paid-message replay balance: %w", err)
|
||||
}
|
||||
result.SenderStarsBalance = &balance
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
|
||||
|
|
@ -578,6 +587,10 @@ func insertChannelMessageWithFingerprintTx(ctx context.Context, tx pgx.Tx, msg d
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suggestedPost, err := marshalJSON(msg.SuggestedPost, "{}")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sendSnapshot := []byte("{}")
|
||||
if msg.RandomID != 0 {
|
||||
sendSnapshot, err = store.EncodeChannelSendSnapshot(msg)
|
||||
|
|
@ -613,12 +626,12 @@ INSERT INTO channel_messages (
|
|||
channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id,
|
||||
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards,
|
||||
body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
|
||||
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, send_snapshot, request_fingerprint
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38::jsonb,$39::bytea)`,
|
||||
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post, send_snapshot, request_fingerprint
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39::jsonb,$40::jsonb,$41::bytea)`,
|
||||
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID,
|
||||
sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards,
|
||||
msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID,
|
||||
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, sendSnapshot, requestFingerprint); err != nil {
|
||||
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, msg.PaidMessageStars, suggestedPost, sendSnapshot, requestFingerprint); err != nil {
|
||||
return fmt.Errorf("insert channel message: %w", err)
|
||||
}
|
||||
// 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。
|
||||
|
|
|
|||
|
|
@ -12,14 +12,19 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const paidMessageChannelCommissionPermille int64 = 850
|
||||
|
||||
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
|
||||
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);发件权限(订阅者身份/管理员)
|
||||
// 由 RPC 层校验,store 只校验 monoforum 频道存在,不要求发件人是成员(订阅者不是 monoforum 成员)。
|
||||
// 私信消息存进 channel_messages(复用 channel pts/事件/difference);store 在写边界再次强制:订阅者
|
||||
// 无需成员记录但只能写自己的 saved_peer,母频道管理员可以回复任意订阅者。
|
||||
func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
|
||||
if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 ||
|
||||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
|
||||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.AllowPaidStars < 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
requestFingerprint, err := store.MonoforumSendFingerprint(req)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
|
|
@ -65,6 +70,101 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
|
|||
if channel.Deleted || !channel.Monoforum {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
parent, err := getChannelByID(ctx, tx, channel.LinkedMonoforumID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
var monoDeleted, parentDeleted, directEnabled bool
|
||||
var linkedMonoforumID, monoPrice, parentPrice int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT m.deleted, p.deleted, p.broadcast_messages_allowed, p.linked_monoforum_id,
|
||||
m.send_paid_messages_stars, p.send_paid_messages_stars
|
||||
FROM channels m
|
||||
JOIN channels p ON p.id = m.linked_monoforum_id
|
||||
WHERE m.id = $1
|
||||
FOR SHARE OF m, p`, channel.ID).Scan(
|
||||
&monoDeleted, &parentDeleted, &directEnabled, &linkedMonoforumID, &monoPrice, &parentPrice,
|
||||
); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if monoDeleted || parentDeleted || !directEnabled || linkedMonoforumID != channel.ID {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate
|
||||
}
|
||||
if monoPrice != parentPrice || monoPrice < 0 {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("monoforum %d paid-message price disagrees with parent %d", channel.ID, parent.ID)
|
||||
}
|
||||
channel.SendPaidMessagesStars = monoPrice
|
||||
parent.SendPaidMessagesStars = parentPrice
|
||||
parentMember, parentMemberErr := s.getChannelMember(ctx, tx, parent.ID, req.SenderUserID)
|
||||
if parentMemberErr != nil && !errors.Is(parentMemberErr, domain.ErrChannelPrivate) {
|
||||
return domain.SendChannelMessageResult{}, parentMemberErr
|
||||
}
|
||||
isAdmin := parentMemberErr == nil && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
|
||||
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
var senderBalance *domain.StarsBalance
|
||||
paidMessageStars := int64(0)
|
||||
if !isAdmin && channel.SendPaidMessagesStars > 0 {
|
||||
if req.AllowPaidStars < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars}
|
||||
}
|
||||
balance := domain.StarsBalance{UserID: req.SenderUserID}
|
||||
if err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, req.SenderUserID).
|
||||
Scan(&balance.Balance, &balance.Granted); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("lock paid-message sender balance: %w", err)
|
||||
}
|
||||
if balance.Balance < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
paidMessageStars = channel.SendPaidMessagesStars
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE stars_balances
|
||||
SET balance = balance - $2, updated_at = now()
|
||||
WHERE user_id = $1
|
||||
RETURNING balance`, req.SenderUserID, paidMessageStars).Scan(&balance.Balance); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("debit paid-message sender balance: %w", err)
|
||||
}
|
||||
if err := insertStarsTxn(ctx, tx, req.SenderUserID, -paidMessageStars, domain.StarsReasonPaidMessage,
|
||||
domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}, req.Date, "Paid message", ""); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
channelCredit := paidMessageStars * paidMessageChannelCommissionPermille / 1000
|
||||
if channelCredit > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO channel_stars_balances(channel_id, balance)
|
||||
VALUES($1, $2)
|
||||
ON CONFLICT(channel_id) DO UPDATE
|
||||
SET balance = channel_stars_balances.balance + EXCLUDED.balance, updated_at = now()`, parent.ID, channelCredit); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("credit paid-message channel balance: %w", err)
|
||||
}
|
||||
}
|
||||
senderBalance = &balance
|
||||
}
|
||||
if req.ReplyTo != nil {
|
||||
if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM channel_messages
|
||||
WHERE channel_id = $1 AND id = $2 AND NOT deleted
|
||||
AND saved_peer_type = $3 AND saved_peer_id = $4
|
||||
)`, channel.ID, req.ReplyTo.MessageID, string(req.SavedPeer.Type), req.SavedPeer.ID).Scan(&exists); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
}
|
||||
from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
if isAdmin {
|
||||
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
|
||||
}
|
||||
msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.MonoforumID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err)
|
||||
|
|
@ -74,16 +174,22 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
|
|||
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err)
|
||||
}
|
||||
msg := domain.ChannelMessage{
|
||||
ChannelID: req.MonoforumID,
|
||||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
|
||||
SavedPeer: req.SavedPeer,
|
||||
Date: req.Date,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Pts: pts,
|
||||
ChannelID: req.MonoforumID,
|
||||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: from,
|
||||
SavedPeer: req.SavedPeer,
|
||||
SuggestedPost: req.SuggestedPost,
|
||||
PaidMessageStars: paidMessageStars,
|
||||
Date: req.Date,
|
||||
Silent: req.Silent,
|
||||
NoForwards: req.NoForwards,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Pts: pts,
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{
|
||||
ChannelID: req.MonoforumID,
|
||||
|
|
@ -130,13 +236,31 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
|
|||
if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.MonoforumID, msgID, pts); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err)
|
||||
}
|
||||
recipients := []int64{req.SavedPeer.ID}
|
||||
rows, err := tx.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator', 'admin') ORDER BY user_id`, parent.ID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("list monoforum recipients: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var recipient int64
|
||||
if err := rows.Scan(&recipient); err != nil {
|
||||
rows.Close()
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
recipients = append(recipients, recipient)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
rows.Close()
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err)
|
||||
}
|
||||
committed = true
|
||||
channel.TopMessageID = msgID
|
||||
channel.Pts = pts
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event}, nil
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: uniqueChannelUserIDs(recipients, 0), SenderStarsBalance: senderBalance}, nil
|
||||
}
|
||||
|
||||
// ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。
|
||||
|
|
@ -205,9 +329,11 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
|
|||
return domain.Channel{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
isAdmin := false
|
||||
if _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); err == nil {
|
||||
if _, member, memberErr := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); memberErr == nil {
|
||||
isAdmin = member.Status == domain.ChannelMemberActive &&
|
||||
(member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin)
|
||||
} else if !errors.Is(memberErr, domain.ErrChannelPrivate) {
|
||||
return domain.Channel{}, false, memberErr
|
||||
}
|
||||
return mono, isAdmin, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -55,18 +56,46 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
channelIDs = append(channelIDs, monoID)
|
||||
|
||||
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
|
||||
if _, err := channels.GetChannel(ctx, sub.ID, monoID); err != nil {
|
||||
t.Fatalf("subscriber get enabled monoforum without membership: %v", err)
|
||||
}
|
||||
if _, err := channels.JoinChannel(ctx, monoID, sub.ID, 1700001001); !errors.Is(err, domain.ErrChannelMonoforumUnsupported) {
|
||||
t.Fatalf("subscriber join monoforum err = %v, want ErrChannelMonoforumUnsupported", err)
|
||||
}
|
||||
suggestedDraft := domain.DialogDraft{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}, Message: "pending suggested post", Date: 1700001001,
|
||||
SuggestedPost: &domain.SuggestedPost{
|
||||
Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10},
|
||||
ScheduleDate: 1700100000,
|
||||
},
|
||||
}
|
||||
dialogStore := NewDialogStore(pool)
|
||||
if err := dialogStore.SaveDraft(ctx, sub.ID, suggestedDraft); err != nil {
|
||||
t.Fatalf("save subscriber monoforum draft: %v", err)
|
||||
}
|
||||
loadedDraft, found, err := dialogStore.GetDraft(ctx, sub.ID, suggestedDraft.Peer, 0)
|
||||
if err != nil || !found || loadedDraft.SuggestedPost == nil || loadedDraft.SuggestedPost.Price == nil || loadedDraft.SuggestedPost.Price.Amount != 10 || loadedDraft.SuggestedPost.ScheduleDate != 1700100000 {
|
||||
t.Fatalf("loaded subscriber monoforum draft = %+v, %v, %v; want suggested post", loadedDraft, found, err)
|
||||
}
|
||||
|
||||
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001})
|
||||
suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000}
|
||||
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001,
|
||||
SuggestedPost: suggestedPost,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber send 1: %v", err)
|
||||
}
|
||||
if m1.Message.SavedPeer != subPeer || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 {
|
||||
t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message)
|
||||
}
|
||||
if len(m1.Recipients) != 2 || !slices.Contains(m1.Recipients, owner.ID) || !slices.Contains(m1.Recipients, sub.ID) {
|
||||
t.Fatalf("m1 recipients = %v, want subscriber %d + parent admin %d", m1.Recipients, sub.ID, owner.ID)
|
||||
}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 112, Message: "again", Date: 1700001002}); err != nil {
|
||||
t.Fatalf("subscriber send 2: %v", err)
|
||||
}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", Date: 1700001003}); err != nil {
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001003}); err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -85,12 +114,21 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID {
|
||||
t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID)
|
||||
}
|
||||
if _, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil {
|
||||
t.Fatalf("subscriber main monoforum history = nil err, want denied")
|
||||
subscriberHist, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber monoforum history: %v", err)
|
||||
}
|
||||
if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 {
|
||||
t.Fatalf("subscriber monoforum history count=%d len=%d, want own 3", subscriberHist.Count, len(subscriberHist.Messages))
|
||||
}
|
||||
for _, message := range subscriberHist.Messages {
|
||||
if message.SavedPeer != subPeer {
|
||||
t.Fatalf("subscriber history leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等。
|
||||
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001004})
|
||||
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004})
|
||||
if err != nil {
|
||||
t.Fatalf("dup send: %v", err)
|
||||
}
|
||||
|
|
@ -124,6 +162,16 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer)
|
||||
}
|
||||
}
|
||||
oldest := hist.Messages[len(hist.Messages)-1]
|
||||
if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 {
|
||||
t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost)
|
||||
}
|
||||
if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID {
|
||||
t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID)
|
||||
}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 114, Message: "bad reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1700001004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
|
||||
t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err)
|
||||
}
|
||||
|
||||
// 另一个订阅者不串会话。
|
||||
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
|
||||
|
|
@ -134,6 +182,31 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
if subHist.Count != 3 {
|
||||
t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count)
|
||||
}
|
||||
subscriberChannelHistory, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil || subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 {
|
||||
t.Fatalf("subscriber channel history after other = %d/%d, %v; want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages), err)
|
||||
}
|
||||
for _, message := range subscriberChannelHistory.Messages {
|
||||
if message.SavedPeer != subPeer {
|
||||
t.Fatalf("subscriber channel history leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber channel difference: %v", err)
|
||||
}
|
||||
if len(diff.NewMessages) != 3 {
|
||||
t.Fatalf("subscriber channel difference messages = %d, want own 3", len(diff.NewMessages))
|
||||
}
|
||||
for _, message := range diff.NewMessages {
|
||||
if message.SavedPeer != subPeer {
|
||||
t.Fatalf("subscriber difference leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
activeChannelIDs, err := channels.ListActiveChannelIDsForUser(ctx, sub.ID, 0, 10)
|
||||
if err != nil || !slices.Contains(activeChannelIDs, monoID) {
|
||||
t.Fatalf("subscriber active channels = %v, %v; want monoforum %d", activeChannelIDs, err, monoID)
|
||||
}
|
||||
|
||||
// 去重按订阅者子会话维度(迁移 0022 唯一索引含 saved_peer_id):管理员用相同 random_id 向两个不同
|
||||
// 订阅者发,不得互相去重(与 memory 行为一致)。
|
||||
|
|
@ -202,6 +275,13 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Fatalf("commit monoforum delete: %v", err)
|
||||
}
|
||||
deleteDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber difference after own delete: %v", err)
|
||||
}
|
||||
if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID {
|
||||
t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts)
|
||||
}
|
||||
var ptsBeforeReplay, eventsBeforeReplay int
|
||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsBeforeReplay); err != nil {
|
||||
t.Fatalf("load monoforum pts: %v", err)
|
||||
|
|
@ -227,3 +307,117 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", ptsAfterReplay, eventsAfterReplay, ptsBeforeReplay, eventsBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPaidMonoforumMessageLedgerPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 191, Phone: "+1789" + suffix + "41", FirstName: "PaidMonoOwner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
sub, err := users.Create(ctx, domain.User{AccessHash: 192, Phone: "+1789" + suffix + "42", FirstName: "PaidMonoSub"})
|
||||
if err != nil {
|
||||
t.Fatalf("create sub: %v", err)
|
||||
}
|
||||
other, err := users.Create(ctx, domain.User{AccessHash: 193, Phone: "+1789" + suffix + "43", FirstName: "PaidMonoOther"})
|
||||
if err != nil {
|
||||
t.Fatalf("create other: %v", err)
|
||||
}
|
||||
channels := NewChannelStore(pool)
|
||||
broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Paid Mono " + suffix, Broadcast: true, Date: 1700002000})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, broadcast.Channel.ID, 10, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable paid DM: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{broadcast.Channel.ID, monoID})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, sub.ID, other.ID})
|
||||
})
|
||||
stars := NewStarsStore(pool)
|
||||
if _, _, err := stars.EnsureGrant(ctx, sub.ID, 25, 1700002000); err != nil {
|
||||
t.Fatalf("grant subscriber stars: %v", err)
|
||||
}
|
||||
if _, _, err := stars.EnsureGrant(ctx, other.ID, 5, 1700002000); err != nil {
|
||||
t.Fatalf("grant other stars: %v", err)
|
||||
}
|
||||
subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID}
|
||||
var beforeMessages int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&beforeMessages); err != nil {
|
||||
t.Fatalf("count messages before paid send: %v", err)
|
||||
}
|
||||
lowReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4001, Message: "too low", AllowPaidStars: 9, Date: 1700002001}
|
||||
var required *domain.StarsPaymentRequiredError
|
||||
if _, err := channels.SendMonoforumMessage(ctx, lowReq); !errors.As(err, &required) || required.Stars != 10 {
|
||||
t.Fatalf("low authorization err = %v, want 10-Star payment required", err)
|
||||
}
|
||||
var afterLowMessages int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1`, monoID).Scan(&afterLowMessages); err != nil || afterLowMessages != beforeMessages {
|
||||
t.Fatalf("low authorization message count = %d/%v, want %d", afterLowMessages, err, beforeMessages)
|
||||
}
|
||||
|
||||
paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 4002, Message: "paid", AllowPaidStars: 99, Date: 1700002002}
|
||||
paid, err := channels.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid send: %v", err)
|
||||
}
|
||||
if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance)
|
||||
}
|
||||
var senderBalance, channelBalance, persistedPaid int64
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil {
|
||||
t.Fatalf("load sender balance: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil {
|
||||
t.Fatalf("load channel balance: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT paid_message_stars FROM channel_messages WHERE channel_id=$1 AND id=$2`, monoID, paid.Message.ID).Scan(&persistedPaid); err != nil {
|
||||
t.Fatalf("load persisted paid stars: %v", err)
|
||||
}
|
||||
if senderBalance != 15 || channelBalance != 8 || persistedPaid != 10 {
|
||||
t.Fatalf("persisted sender/channel/message = %d/%d/%d, want 15/8/10", senderBalance, channelBalance, persistedPaid)
|
||||
}
|
||||
|
||||
replay, err := channels.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid replay: %v", err)
|
||||
}
|
||||
if !replay.Duplicate || replay.Message.ID != paid.Message.ID || replay.SenderStarsBalance == nil || replay.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid replay = %+v, want exact original and balance 15", replay)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, sub.ID).Scan(&senderBalance); err != nil || senderBalance != 15 {
|
||||
t.Fatalf("paid replay sender balance = %d/%v, want 15", senderBalance, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 {
|
||||
t.Fatalf("paid replay channel balance = %d/%v, want 8", channelBalance, err)
|
||||
}
|
||||
|
||||
admin, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 4003, Message: "free admin reply", AllowPaidStars: 100, Date: 1700002003,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil {
|
||||
t.Fatalf("admin reply charged: message=%+v balance=%+v", admin.Message, admin.SenderStarsBalance)
|
||||
}
|
||||
|
||||
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 4004, Message: "insufficient", AllowPaidStars: 10, Date: 1700002004,
|
||||
}); !errors.Is(err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err)
|
||||
}
|
||||
var otherBalance int64
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id=$1`, other.ID).Scan(&otherBalance); err != nil || otherBalance != 5 {
|
||||
t.Fatalf("insufficient sender balance = %d/%v, want 5", otherBalance, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM channel_stars_balances WHERE channel_id=$1`, broadcast.Channel.ID).Scan(&channelBalance); err != nil || channelBalance != 8 {
|
||||
t.Fatalf("insufficient channel balance = %d/%v, want 8", channelBalance, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p
|
|||
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body,
|
||||
entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
|
||||
fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text,
|
||||
reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id`
|
||||
reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id, paid_message_stars, suggested_post::text`
|
||||
|
||||
const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id,
|
||||
title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
|
|||
args = append(args, member.AvailableMinID)
|
||||
where += fmt.Sprintf(" AND id > $%d", len(args))
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
args = append(args, req.UserID)
|
||||
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
|
||||
}
|
||||
args = append(args, domain.MaxChannelDifferenceTooLongMessages)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+channelMessageColumns+`
|
||||
|
|
@ -100,11 +104,15 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
if err != nil {
|
||||
return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30}
|
||||
userRefs := make(map[int64]struct{})
|
||||
channelRefs := make(map[int64]struct{})
|
||||
lastPts := req.Pts
|
||||
type differenceEventRow struct {
|
||||
event domain.ChannelUpdateEvent
|
||||
messageID int
|
||||
}
|
||||
eventRows := make([]differenceEventRow, 0, limit)
|
||||
for rows.Next() {
|
||||
event, messageID, err := scanChannelEvent(rows)
|
||||
if err != nil {
|
||||
|
|
@ -131,6 +139,27 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
break
|
||||
}
|
||||
lastPts = event.Pts
|
||||
eventRows = append(eventRows, differenceEventRow{event: event, messageID: messageID})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
rows.Close()
|
||||
var visibleMonoforumMessageIDs map[int]struct{}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
messageIDs := make([]int, 0)
|
||||
for _, row := range eventRows {
|
||||
messageIDs = append(messageIDs, row.event.MessageIDs...)
|
||||
}
|
||||
visibleMonoforumMessageIDs, err = s.monoforumVisibleMessageIDs(ctx, req.ChannelID, req.UserID, messageIDs)
|
||||
if err != nil {
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
}
|
||||
for _, row := range eventRows {
|
||||
event := row.event
|
||||
messageID := row.messageID
|
||||
if messageID != 0 && event.Message.ID == 0 {
|
||||
msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID)
|
||||
if err != nil {
|
||||
|
|
@ -143,6 +172,12 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
continue
|
||||
}
|
||||
event = visibleEvent
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if preview && event.Type == domain.ChannelUpdateParticipant {
|
||||
continue
|
||||
}
|
||||
|
|
@ -156,9 +191,6 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
diff.OtherUpdates = append(diff.OtherUpdates, event)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.ChannelDifference{}, err
|
||||
}
|
||||
if len(diff.Events) == 0 {
|
||||
diff.Pts = lastPts
|
||||
} else if lastPts > diff.Pts {
|
||||
|
|
@ -208,6 +240,55 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
|
|||
return diff, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) monoforumVisibleMessageIDs(ctx context.Context, channelID, userID int64, ids []int) (map[int]struct{}, error) {
|
||||
visible := make(map[int]struct{})
|
||||
if len(ids) == 0 {
|
||||
return visible, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id
|
||||
FROM channel_messages
|
||||
WHERE channel_id = $1
|
||||
AND id = ANY($2::int[])
|
||||
AND saved_peer_type = 'user'
|
||||
AND saved_peer_id = $3`, channelID, int32s(ids), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list visible monoforum message ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible[id] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) {
|
||||
if event.Message.ID != 0 {
|
||||
return event, event.Message.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID})
|
||||
}
|
||||
if len(event.MessageIDs) == 0 {
|
||||
return event, false
|
||||
}
|
||||
ids := make([]int, 0, len(event.MessageIDs))
|
||||
for _, id := range event.MessageIDs {
|
||||
if _, ok := visibleMessageIDs[id]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return event, false
|
||||
}
|
||||
event.MessageIDs = ids
|
||||
return event, true
|
||||
}
|
||||
|
||||
func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) {
|
||||
var pts int
|
||||
err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ SELECT
|
|||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -137,6 +139,8 @@ SELECT
|
|||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -278,6 +282,8 @@ SELECT
|
|||
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
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
|
||||
|
|
@ -342,6 +348,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
|
|||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
emojiCollectibleID *int64
|
||||
emojiCollectibleJSON []byte
|
||||
lastSeenAt int64
|
||||
reverseMutualChanged bool
|
||||
)
|
||||
|
|
@ -366,6 +374,8 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
|
|||
&premiumUntil,
|
||||
&emojiStatusDocID,
|
||||
&emojiStatusUntil,
|
||||
&emojiCollectibleID,
|
||||
&emojiCollectibleJSON,
|
||||
&lastSeenAt,
|
||||
&reverseMutualChanged,
|
||||
); err != nil {
|
||||
|
|
@ -376,7 +386,7 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do
|
|||
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, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend))
|
||||
out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate upsert contacts many: %w", err)
|
||||
|
|
@ -556,7 +566,7 @@ func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, erro
|
|||
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, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
}
|
||||
|
||||
func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
|
||||
|
|
@ -564,7 +574,7 @@ func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) {
|
|||
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, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
}
|
||||
|
||||
func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) {
|
||||
|
|
@ -572,7 +582,7 @@ func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error)
|
|||
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, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
}
|
||||
|
||||
func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) {
|
||||
|
|
@ -580,7 +590,7 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact,
|
|||
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, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), row.EmojiStatusCollectibleID, row.EmojiStatusCollectible, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil
|
||||
}
|
||||
|
||||
// contactFromFields 组装 domain.Contact。getContacts 主路径(List/Get/Upsert/UpdateNote
|
||||
|
|
@ -588,27 +598,28 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact,
|
|||
// raw-scan 调用传 false/0——bot 无 phone 不经手机号导入,bot 加联系人走 username
|
||||
// 的单条 UpsertContact 路径(已带真实 bot 列)。premium/emoji status 列所有路径必须
|
||||
// 传真实值:TDesktop 对任何缺 emoji_status 字段的 user TL 一律清空本地状态。
|
||||
func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact {
|
||||
func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil int, emojiCollectibleID *int64, emojiCollectibleJSON []byte, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend 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,
|
||||
Bot: isBot,
|
||||
BotInfoVersion: botInfoVersion,
|
||||
PremiumUntil: premiumUntil,
|
||||
EmojiStatusDocumentID: emojiStatusDocumentID,
|
||||
EmojiStatusUntil: emojiStatusUntil,
|
||||
LastSeenAt: lastSeenAt,
|
||||
Contact: true,
|
||||
Mutual: mutual,
|
||||
CloseFriend: closeFriend,
|
||||
ID: id,
|
||||
AccessHash: accessHash,
|
||||
Phone: phone,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Username: username,
|
||||
CountryCode: countryCode,
|
||||
Verified: verified,
|
||||
Support: support,
|
||||
Bot: isBot,
|
||||
BotInfoVersion: botInfoVersion,
|
||||
PremiumUntil: premiumUntil,
|
||||
EmojiStatusDocumentID: emojiStatusDocumentID,
|
||||
EmojiStatusUntil: emojiStatusUntil,
|
||||
EmojiStatusCollectible: mustDecodeEmojiStatusCollectible(emojiCollectibleID, emojiCollectibleJSON),
|
||||
LastSeenAt: lastSeenAt,
|
||||
Contact: true,
|
||||
Mutual: mutual,
|
||||
CloseFriend: closeFriend,
|
||||
},
|
||||
FirstName: contactFirstName,
|
||||
LastName: contactLastName,
|
||||
|
|
@ -626,27 +637,29 @@ type contactScanner interface {
|
|||
|
||||
func scanContactRows(row contactScanner) (domain.Contact, error) {
|
||||
var (
|
||||
contactUserID int64
|
||||
mutual bool
|
||||
closeFriend 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
|
||||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
lastSeenAt int32
|
||||
contactUserID int64
|
||||
mutual bool
|
||||
closeFriend 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
|
||||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
emojiCollectibleID *int64
|
||||
emojiCollectibleJSON []byte
|
||||
lastSeenAt int32
|
||||
)
|
||||
if err := row.Scan(
|
||||
&contactUserID,
|
||||
|
|
@ -669,6 +682,8 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
|
|||
&premiumUntil,
|
||||
&emojiStatusDocID,
|
||||
&emojiStatusUntil,
|
||||
&emojiCollectibleID,
|
||||
&emojiCollectibleJSON,
|
||||
&lastSeenAt,
|
||||
); err != nil {
|
||||
return domain.Contact{}, err
|
||||
|
|
@ -677,32 +692,34 @@ func scanContactRows(row contactScanner) (domain.Contact, error) {
|
|||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil
|
||||
return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil
|
||||
}
|
||||
|
||||
func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
|
||||
var (
|
||||
ownerUserID int64
|
||||
mutual bool
|
||||
closeFriend 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
|
||||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
lastSeenAt int32
|
||||
ownerUserID int64
|
||||
mutual bool
|
||||
closeFriend 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
|
||||
premiumUntil int64
|
||||
emojiStatusDocID int64
|
||||
emojiStatusUntil int64
|
||||
emojiCollectibleID *int64
|
||||
emojiCollectibleJSON []byte
|
||||
lastSeenAt int32
|
||||
)
|
||||
if err := row.Scan(
|
||||
&ownerUserID,
|
||||
|
|
@ -725,6 +742,8 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
|
|||
&premiumUntil,
|
||||
&emojiStatusDocID,
|
||||
&emojiStatusUntil,
|
||||
&emojiCollectibleID,
|
||||
&emojiCollectibleJSON,
|
||||
&lastSeenAt,
|
||||
); err != nil {
|
||||
return 0, domain.Contact{}, err
|
||||
|
|
@ -733,7 +752,7 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
|
|||
if err != nil {
|
||||
return 0, domain.Contact{}, err
|
||||
}
|
||||
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)
|
||||
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)
|
||||
return ownerUserID, contact, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
54
internal/store/postgres/emoji_status_codec_test.go
Normal file
54
internal/store/postgres/emoji_status_codec_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func testCollectibleEmojiStatusValue() domain.UserEmojiStatus {
|
||||
return domain.UserEmojiStatus{
|
||||
DocumentID: 101,
|
||||
Until: 2_000_000_000,
|
||||
Collectible: domain.EmojiStatusCollectible{
|
||||
CollectibleID: 1001, DocumentID: 101, Title: "Gift", Slug: "Gift-1",
|
||||
PatternDocumentID: 102, CenterColor: 1, EdgeColor: 2, PatternColor: 3, TextColor: 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusUserAndEventCodecsRoundTrip(t *testing.T) {
|
||||
value := testCollectibleEmojiStatusValue()
|
||||
raw, id, err := encodeEmojiStatusCollectible(value)
|
||||
if err != nil {
|
||||
t.Fatalf("encode user collectible: %v", err)
|
||||
}
|
||||
if id == nil || *id != value.Collectible.CollectibleID {
|
||||
t.Fatalf("collectible id = %v", id)
|
||||
}
|
||||
if got := mustDecodeEmojiStatusCollectible(id, raw); got != value.Collectible {
|
||||
t.Fatalf("decoded user collectible = %+v, want %+v", got, value.Collectible)
|
||||
}
|
||||
|
||||
eventRaw, err := encodeEventEmojiStatus(value)
|
||||
if err != nil {
|
||||
t.Fatalf("encode event collectible: %v", err)
|
||||
}
|
||||
got, err := decodeEventEmojiStatus(string(eventRaw))
|
||||
if err != nil || got != value {
|
||||
t.Fatalf("decoded event collectible = %+v err=%v, want %+v", got, err, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusCodecRejectsPartialSnapshot(t *testing.T) {
|
||||
value := domain.UserEmojiStatus{
|
||||
DocumentID: 101,
|
||||
Collectible: domain.EmojiStatusCollectible{CollectibleID: 1001, DocumentID: 101},
|
||||
}
|
||||
if _, _, err := encodeEmojiStatusCollectible(value); err == nil {
|
||||
t.Fatal("partial user snapshot encoded successfully")
|
||||
}
|
||||
if _, err := encodeEventEmojiStatus(value); err == nil {
|
||||
t.Fatal("partial event snapshot encoded successfully")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestUpdateEmojiStatusWithEventIsAtomic(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
users := NewUserStore(pool)
|
||||
u, err := users.Create(ctx, domain.User{
|
||||
AccessHash: time.Now().UnixNano(),
|
||||
Phone: fmt.Sprintf("1666%d", time.Now().UnixNano()),
|
||||
FirstName: "Emoji status event",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, u.ID) })
|
||||
|
||||
status := domain.UserEmojiStatus{DocumentID: 42}
|
||||
event := domain.UpdateEvent{
|
||||
Type: domain.UpdateEventUserEmojiStatus,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: u.ID},
|
||||
EmojiStatus: status,
|
||||
Date: int(time.Now().Unix()),
|
||||
PtsCount: 1,
|
||||
}
|
||||
// A nonzero session without its auth-key half violates the outbox
|
||||
// exclusion-pair invariant. The event failure must roll back the users row.
|
||||
if _, _, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, [8]byte{}, 77); err == nil {
|
||||
t.Fatal("UpdateEmojiStatusWithEvent unexpectedly accepted a partial exclusion pair")
|
||||
}
|
||||
got, found, err := users.ByID(ctx, u.ID)
|
||||
if err != nil || !found || !got.EmojiStatus().Empty() {
|
||||
t.Fatalf("failed aggregate write leaked user state: user=%+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
|
||||
authKeyID := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
got, storedEvent, err := users.UpdateEmojiStatusWithEvent(ctx, u.ID, status, event, authKeyID, 77)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateEmojiStatusWithEvent: %v", err)
|
||||
}
|
||||
if got.EmojiStatus() != status || storedEvent.Pts <= 0 || storedEvent.EmojiStatus != status {
|
||||
t.Fatalf("aggregate result: user=%+v event=%+v", got.EmojiStatus(), storedEvent)
|
||||
}
|
||||
loaded, err := NewUpdateEventStore(pool).ListAfter(ctx, u.ID, storedEvent.Pts-1, 1)
|
||||
if err != nil || len(loaded) != 1 || loaded[0].EmojiStatus != status {
|
||||
t.Fatalf("durable event: events=%+v err=%v", loaded, err)
|
||||
}
|
||||
var outboxCount int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, u.ID, storedEvent.Pts).Scan(&outboxCount); err != nil || outboxCount != 1 {
|
||||
t.Fatalf("dispatch outbox count=%d err=%v", outboxCount, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -331,19 +331,20 @@ func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event do
|
|||
event.PtsCount = 1
|
||||
}
|
||||
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
|
||||
UserID: event.UserID,
|
||||
Pts: int32(event.Pts),
|
||||
PtsCount: int32(event.PtsCount),
|
||||
Date: int32(event.Date),
|
||||
EventType: string(domain.UpdateEventDeleteMessages),
|
||||
EventPeers: []byte("[]"),
|
||||
PeerSettings: []byte("{}"),
|
||||
MessageIds: messageIDs,
|
||||
DialogFilter: []byte("{}"),
|
||||
FilterOrder: []byte("[]"),
|
||||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
UserID: event.UserID,
|
||||
Pts: int32(event.Pts),
|
||||
PtsCount: int32(event.PtsCount),
|
||||
Date: int32(event.Date),
|
||||
EventType: string(domain.UpdateEventDeleteMessages),
|
||||
EventPeers: []byte("[]"),
|
||||
PeerSettings: []byte("{}"),
|
||||
MessageIds: messageIDs,
|
||||
DialogFilter: []byte("{}"),
|
||||
FilterOrder: []byte("[]"),
|
||||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
EmojiStatusPayload: []byte("{}"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("append delete messages event: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -656,22 +656,23 @@ func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.M
|
|||
peerType := string(msg.Peer.Type)
|
||||
peerID := msg.Peer.ID
|
||||
if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{
|
||||
UserID: msg.OwnerUserID,
|
||||
Pts: int32(msg.Pts),
|
||||
PtsCount: 1,
|
||||
Date: int32(msg.Date),
|
||||
EventType: string(domain.UpdateEventNewMessage),
|
||||
EventPeers: []byte("[]"),
|
||||
PeerSettings: []byte("{}"),
|
||||
MessageIds: []byte("[]"),
|
||||
DialogFilter: []byte("{}"),
|
||||
FilterOrder: []byte("[]"),
|
||||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
MessageBoxID: &boxID,
|
||||
PeerType: &peerType,
|
||||
PeerID: &peerID,
|
||||
UserID: msg.OwnerUserID,
|
||||
Pts: int32(msg.Pts),
|
||||
PtsCount: 1,
|
||||
Date: int32(msg.Date),
|
||||
EventType: string(domain.UpdateEventNewMessage),
|
||||
EventPeers: []byte("[]"),
|
||||
PeerSettings: []byte("{}"),
|
||||
MessageIds: []byte("[]"),
|
||||
DialogFilter: []byte("{}"),
|
||||
FilterOrder: []byte("[]"),
|
||||
FolderPeers: []byte("[]"),
|
||||
StoryPayload: []byte("{}"),
|
||||
ReactionPayload: []byte("{}"),
|
||||
EmojiStatusPayload: []byte("{}"),
|
||||
MessageBoxID: &boxID,
|
||||
PeerType: &peerType,
|
||||
PeerID: &peerID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("append new message event: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -52,6 +54,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -130,6 +134,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at,
|
||||
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
|
||||
FROM upserted c
|
||||
|
|
@ -168,6 +174,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM updated c
|
||||
JOIN users u ON u.id = c.contact_user_id;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ WITH matched AS (
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.color_set,
|
||||
u.color,
|
||||
u.color_background_emoji_id,
|
||||
|
|
@ -86,6 +88,8 @@ SELECT
|
|||
premium_expires_at,
|
||||
emoji_status_document_id,
|
||||
emoji_status_until,
|
||||
emoji_status_collectible_id,
|
||||
emoji_status_collectible,
|
||||
color_set,
|
||||
color,
|
||||
color_background_emoji_id,
|
||||
|
|
@ -165,6 +169,8 @@ RETURNING *;
|
|||
UPDATE users
|
||||
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
|
||||
emoji_status_until = sqlc.arg(emoji_status_until)::bigint,
|
||||
emoji_status_collectible_id = sqlc.narg(emoji_status_collectible_id)::bigint,
|
||||
emoji_status_collectible = sqlc.arg(emoji_status_collectible)::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
|
||||
RETURNING *;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ INSERT INTO user_update_events (
|
|||
folder_peers,
|
||||
story_payload,
|
||||
reaction_payload,
|
||||
emoji_status_payload,
|
||||
message_box_id,
|
||||
peer_type,
|
||||
peer_id,
|
||||
|
|
@ -40,6 +41,7 @@ INSERT INTO user_update_events (
|
|||
sqlc.arg(folder_peers)::jsonb,
|
||||
sqlc.arg(story_payload)::jsonb,
|
||||
sqlc.arg(reaction_payload)::jsonb,
|
||||
sqlc.arg(emoji_status_payload)::jsonb,
|
||||
sqlc.narg(message_box_id),
|
||||
sqlc.narg(peer_type)::text,
|
||||
sqlc.narg(peer_id)::bigint,
|
||||
|
|
@ -68,6 +70,7 @@ SELECT
|
|||
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
|
||||
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
|
||||
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
|
||||
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
|
||||
COALESCE(e.peer_type, '')::text AS event_peer_type,
|
||||
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
|
||||
e.filter_id,
|
||||
|
|
@ -341,6 +344,7 @@ SELECT
|
|||
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
|
||||
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
|
||||
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
|
||||
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
|
||||
COALESCE(e.peer_type, '')::text AS event_peer_type,
|
||||
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
|
||||
e.filter_id,
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
|
|||
const insertBotUser = `-- name: InsertBotUser :one
|
||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
|
||||
VALUES ($1, '', $2, '', $3, '', TRUE, 1)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type InsertBotUserParams struct {
|
||||
|
|
@ -213,6 +213,8 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -80,29 +82,31 @@ type GetContactParams struct {
|
|||
}
|
||||
|
||||
type GetContactRow struct {
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend 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
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
LastSeenAt int64
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend 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
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) {
|
||||
|
|
@ -131,6 +135,8 @@ func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetCont
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LastSeenAt,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -160,6 +166,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -168,29 +176,31 @@ ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u
|
|||
`
|
||||
|
||||
type ListContactsByUserRow struct {
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend 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
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
LastSeenAt int64
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend 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
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) {
|
||||
|
|
@ -225,6 +235,8 @@ func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListC
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -270,6 +282,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at
|
||||
FROM updated c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
|
|
@ -283,29 +297,31 @@ type UpdateContactNoteParams struct {
|
|||
}
|
||||
|
||||
type UpdateContactNoteRow struct {
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend 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
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
LastSeenAt int64
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend 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
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) {
|
||||
|
|
@ -339,6 +355,8 @@ func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNotePa
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LastSeenAt,
|
||||
)
|
||||
return i, err
|
||||
|
|
@ -416,6 +434,8 @@ SELECT
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.last_seen_at,
|
||||
EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed
|
||||
FROM upserted c
|
||||
|
|
@ -433,30 +453,32 @@ type UpsertContactParams struct {
|
|||
}
|
||||
|
||||
type UpsertContactRow struct {
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend 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
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
LastSeenAt int64
|
||||
ReverseMutualChanged bool
|
||||
ContactUserID int64
|
||||
Mutual bool
|
||||
CloseFriend 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
|
||||
IsBot bool
|
||||
BotInfoVersion int32
|
||||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
LastSeenAt int64
|
||||
ReverseMutualChanged bool
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) {
|
||||
|
|
@ -493,6 +515,8 @@ func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (U
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.LastSeenAt,
|
||||
&i.ReverseMutualChanged,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2144,6 +2144,8 @@ type User struct {
|
|||
DeletionSource string
|
||||
DeletionReason string
|
||||
AccountDeleteAt pgtype.Timestamptz
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
}
|
||||
|
||||
type UserBusinessProfile struct {
|
||||
|
|
@ -2219,33 +2221,34 @@ type UserTopReaction struct {
|
|||
}
|
||||
|
||||
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
|
||||
ChannelPts int32
|
||||
FolderID int32
|
||||
QuickReplies []byte
|
||||
QuickReplyMessage []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
EventPhone string
|
||||
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
|
||||
ChannelPts int32
|
||||
FolderID int32
|
||||
QuickReplies []byte
|
||||
QuickReplyMessage []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
EventPhone string
|
||||
EmojiStatusPayload []byte
|
||||
}
|
||||
|
||||
type UserUpdateRetention struct {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
|
|
@ -72,12 +72,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE id = $1
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
|
|
@ -117,12 +119,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE phone = $1 AND deleted_at IS NULL
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE phone = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
|
||||
|
|
@ -162,12 +166,14 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
|
||||
|
|
@ -207,12 +213,14 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
FROM users
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id
|
||||
|
|
@ -261,6 +269,8 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -273,7 +283,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
|
|||
}
|
||||
|
||||
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, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
FROM users
|
||||
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
|
||||
ORDER BY id
|
||||
|
|
@ -322,6 +332,8 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -351,6 +363,8 @@ WITH matched AS (
|
|||
u.premium_expires_at,
|
||||
u.emoji_status_document_id,
|
||||
u.emoji_status_until,
|
||||
u.emoji_status_collectible_id,
|
||||
u.emoji_status_collectible,
|
||||
u.color_set,
|
||||
u.color,
|
||||
u.color_background_emoji_id,
|
||||
|
|
@ -400,6 +414,8 @@ SELECT
|
|||
premium_expires_at,
|
||||
emoji_status_document_id,
|
||||
emoji_status_until,
|
||||
emoji_status_collectible_id,
|
||||
emoji_status_collectible,
|
||||
color_set,
|
||||
color,
|
||||
color_background_emoji_id,
|
||||
|
|
@ -438,6 +454,8 @@ type SearchUsersRow struct {
|
|||
PremiumExpiresAt pgtype.Timestamptz
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
ColorSet bool
|
||||
Color int32
|
||||
ColorBackgroundEmojiID int64
|
||||
|
|
@ -480,6 +498,8 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea
|
|||
&i.PremiumExpiresAt,
|
||||
&i.EmojiStatusDocumentID,
|
||||
&i.EmojiStatusUntil,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
&i.ColorSet,
|
||||
&i.Color,
|
||||
&i.ColorBackgroundEmojiID,
|
||||
|
|
@ -505,7 +525,7 @@ UPDATE users
|
|||
SET premium_expires_at = $1::timestamptz,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type SetUserPremiumUntilParams struct {
|
||||
|
|
@ -550,6 +570,8 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -559,7 +581,7 @@ UPDATE users
|
|||
SET verified = $1::boolean,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type SetUserVerifiedParams struct {
|
||||
|
|
@ -604,6 +626,8 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -620,7 +644,7 @@ WHERE id IN (
|
|||
ORDER BY premium_expires_at
|
||||
LIMIT $2::int
|
||||
)
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type SweepExpiredPremiumParams struct {
|
||||
|
|
@ -671,6 +695,8 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -689,7 +715,7 @@ SET birthday_day = $1::int,
|
|||
birthday_year = $3::int,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type UpdateUserBirthdayParams struct {
|
||||
|
|
@ -741,6 +767,8 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -752,7 +780,7 @@ SET color_set = $1::boolean,
|
|||
color_background_emoji_id = $3::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type UpdateUserColorParams struct {
|
||||
|
|
@ -804,6 +832,8 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -812,19 +842,29 @@ const updateUserEmojiStatus = `-- name: UpdateUserEmojiStatus :one
|
|||
UPDATE users
|
||||
SET emoji_status_document_id = $1::bigint,
|
||||
emoji_status_until = $2::bigint,
|
||||
emoji_status_collectible_id = $3::bigint,
|
||||
emoji_status_collectible = $4::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $3::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
WHERE id = $5::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type UpdateUserEmojiStatusParams struct {
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
ID int64
|
||||
EmojiStatusDocumentID int64
|
||||
EmojiStatusUntil int64
|
||||
EmojiStatusCollectibleID *int64
|
||||
EmojiStatusCollectible []byte
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmojiStatusParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, updateUserEmojiStatus, arg.EmojiStatusDocumentID, arg.EmojiStatusUntil, arg.ID)
|
||||
row := q.db.QueryRow(ctx, updateUserEmojiStatus,
|
||||
arg.EmojiStatusDocumentID,
|
||||
arg.EmojiStatusUntil,
|
||||
arg.EmojiStatusCollectibleID,
|
||||
arg.EmojiStatusCollectible,
|
||||
arg.ID,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
|
|
@ -860,6 +900,8 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -886,7 +928,7 @@ UPDATE users
|
|||
SET personal_channel_id = $1::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type UpdateUserPersonalChannelParams struct {
|
||||
|
|
@ -931,6 +973,8 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -940,7 +984,7 @@ UPDATE users
|
|||
SET phone = $1::text,
|
||||
updated_at = now()
|
||||
WHERE id = $2::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type UpdateUserPhoneParams struct {
|
||||
|
|
@ -985,6 +1029,8 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -996,7 +1042,7 @@ SET first_name = $2,
|
|||
about = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
|
|
@ -1048,6 +1094,8 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1059,7 +1107,7 @@ SET profile_color_set = $1::boolean,
|
|||
profile_color_background_emoji_id = $3::bigint,
|
||||
updated_at = now()
|
||||
WHERE id = $4::bigint AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type UpdateUserProfileColorParams struct {
|
||||
|
|
@ -1111,6 +1159,8 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -1120,7 +1170,7 @@ UPDATE users
|
|||
SET username = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
|
||||
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at, emoji_status_collectible_id, emoji_status_collectible
|
||||
`
|
||||
|
||||
type UpdateUserUsernameParams struct {
|
||||
|
|
@ -1165,6 +1215,8 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
|
|||
&i.DeletionSource,
|
||||
&i.DeletionReason,
|
||||
&i.AccountDeleteAt,
|
||||
&i.EmojiStatusCollectibleID,
|
||||
&i.EmojiStatusCollectible,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ INSERT INTO user_update_events (
|
|||
folder_peers,
|
||||
story_payload,
|
||||
reaction_payload,
|
||||
emoji_status_payload,
|
||||
message_box_id,
|
||||
peer_type,
|
||||
peer_id,
|
||||
|
|
@ -51,43 +52,45 @@ INSERT INTO user_update_events (
|
|||
$13::jsonb,
|
||||
$14::jsonb,
|
||||
$15::jsonb,
|
||||
$16,
|
||||
$17::text,
|
||||
$18::bigint,
|
||||
$19::int,
|
||||
$16::jsonb,
|
||||
$17,
|
||||
$18::text,
|
||||
$19::bigint,
|
||||
$20::int,
|
||||
$21::int,
|
||||
$22::int,
|
||||
$23::boolean,
|
||||
$24::int
|
||||
$23::int,
|
||||
$24::boolean,
|
||||
$25::int
|
||||
)
|
||||
`
|
||||
|
||||
type AppendUserUpdateEventParams struct {
|
||||
UserID int64
|
||||
Pts int32
|
||||
PtsCount int32
|
||||
Date int32
|
||||
EventType string
|
||||
EventBool bool
|
||||
EventPhone string
|
||||
EventPeers []byte
|
||||
PeerSettings []byte
|
||||
MessageIds []byte
|
||||
DialogFilter []byte
|
||||
FilterOrder []byte
|
||||
FolderPeers []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
MessageBoxID *int32
|
||||
PeerType *string
|
||||
PeerID *int64
|
||||
FilterID int32
|
||||
MaxID int32
|
||||
StillUnreadCount int32
|
||||
ChannelPts int32
|
||||
TagsEnabled bool
|
||||
FolderID int32
|
||||
UserID int64
|
||||
Pts int32
|
||||
PtsCount int32
|
||||
Date int32
|
||||
EventType string
|
||||
EventBool bool
|
||||
EventPhone string
|
||||
EventPeers []byte
|
||||
PeerSettings []byte
|
||||
MessageIds []byte
|
||||
DialogFilter []byte
|
||||
FilterOrder []byte
|
||||
FolderPeers []byte
|
||||
StoryPayload []byte
|
||||
ReactionPayload []byte
|
||||
EmojiStatusPayload []byte
|
||||
MessageBoxID *int32
|
||||
PeerType *string
|
||||
PeerID *int64
|
||||
FilterID int32
|
||||
MaxID int32
|
||||
StillUnreadCount int32
|
||||
ChannelPts int32
|
||||
TagsEnabled bool
|
||||
FolderID int32
|
||||
}
|
||||
|
||||
func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error {
|
||||
|
|
@ -107,6 +110,7 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat
|
|||
arg.FolderPeers,
|
||||
arg.StoryPayload,
|
||||
arg.ReactionPayload,
|
||||
arg.EmojiStatusPayload,
|
||||
arg.MessageBoxID,
|
||||
arg.PeerType,
|
||||
arg.PeerID,
|
||||
|
|
@ -137,6 +141,7 @@ SELECT
|
|||
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
|
||||
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
|
||||
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
|
||||
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
|
||||
COALESCE(e.peer_type, '')::text AS event_peer_type,
|
||||
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
|
||||
e.filter_id,
|
||||
|
|
@ -274,6 +279,7 @@ type BatchListDispatchEventsRow struct {
|
|||
FolderPeersJson string
|
||||
StoryPayloadJson string
|
||||
ReactionPayloadJson string
|
||||
EmojiStatusPayloadJson string
|
||||
EventPeerType string
|
||||
EventPeerID int64
|
||||
FilterID int32
|
||||
|
|
@ -409,6 +415,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
|
|||
&i.FolderPeersJson,
|
||||
&i.StoryPayloadJson,
|
||||
&i.ReactionPayloadJson,
|
||||
&i.EmojiStatusPayloadJson,
|
||||
&i.EventPeerType,
|
||||
&i.EventPeerID,
|
||||
&i.FilterID,
|
||||
|
|
@ -788,6 +795,7 @@ SELECT
|
|||
COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json,
|
||||
COALESCE(e.story_payload::text, '{}')::text AS story_payload_json,
|
||||
COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json,
|
||||
COALESCE(e.emoji_status_payload::text, '{}')::text AS emoji_status_payload_json,
|
||||
COALESCE(e.peer_type, '')::text AS event_peer_type,
|
||||
COALESCE(e.peer_id, 0)::bigint AS event_peer_id,
|
||||
e.filter_id,
|
||||
|
|
@ -928,6 +936,7 @@ type ListUserUpdateEventsAfterRow struct {
|
|||
FolderPeersJson string
|
||||
StoryPayloadJson string
|
||||
ReactionPayloadJson string
|
||||
EmojiStatusPayloadJson string
|
||||
EventPeerType string
|
||||
EventPeerID int64
|
||||
FilterID int32
|
||||
|
|
@ -1061,6 +1070,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
|
|||
&i.FolderPeersJson,
|
||||
&i.StoryPayloadJson,
|
||||
&i.ReactionPayloadJson,
|
||||
&i.EmojiStatusPayloadJson,
|
||||
&i.EventPeerType,
|
||||
&i.EventPeerID,
|
||||
&i.FilterID,
|
||||
|
|
|
|||
|
|
@ -349,6 +349,37 @@ func (s *StarGiftStore) UniqueByIDs(ctx context.Context, uniqueGiftIDs []int64)
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListUniqueByOwner(ctx context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
if owner.ID <= 0 || limit <= 0 {
|
||||
return []domain.UniqueStarGift{}, nil
|
||||
}
|
||||
if limit > domain.MaxSavedStarGiftsLimit {
|
||||
limit = domain.MaxSavedStarGiftsLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, uniqueStarGiftQuery(`
|
||||
u.owner_peer_type=$1 AND u.owner_peer_id=$2
|
||||
AND NOT u.burned AND u.owner_address=''
|
||||
AND sg.lifecycle_status='active'`)+`
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $3`, string(owner.Type), owner.ID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list unique star gifts by owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.UniqueStarGift, 0, limit)
|
||||
for rows.Next() {
|
||||
gift, err := scanUniqueStarGift(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, gift)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate unique star gifts by owner: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) uniqueByPredicate(ctx context.Context, predicate string, value any) (domain.UniqueStarGift, bool, error) {
|
||||
row := s.db.QueryRow(ctx, uniqueStarGiftQuery(predicate), value)
|
||||
unique, err := scanUniqueStarGift(row)
|
||||
|
|
|
|||
|
|
@ -213,6 +213,21 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
|||
if err != nil || resold.Unique.Owner.ID != resaleBuyer.ID || resold.Balance.Balance != 999000 || resold.Saved.TransferStars != 25 {
|
||||
t.Fatalf("TON resale = %+v err %v", resold, err)
|
||||
}
|
||||
selected, valid := domain.CollectibleEmojiStatus(resold.Unique)
|
||||
if !valid {
|
||||
t.Fatalf("resold collectible cannot project emoji status: %+v", resold.Unique)
|
||||
}
|
||||
if _, err := users.UpdateEmojiStatus(ctx, resaleBuyer.ID, domain.UserEmojiStatus{
|
||||
DocumentID: selected.DocumentID,
|
||||
Collectible: selected,
|
||||
}); err != nil {
|
||||
t.Fatalf("wear resold collectible: %v", err)
|
||||
}
|
||||
updateEvents := NewUpdateEventStore(pool)
|
||||
statusPtsBeforeTransfer, err := updateEvents.MaxContiguousPts(ctx, resaleBuyer.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("emoji status pts before transfer: %v", err)
|
||||
}
|
||||
if sellerTON, err := lifecycle.TonBalance(ctx, offerBuyer.ID); err != nil || sellerTON != 1_000_900 {
|
||||
t.Fatalf("TON seller local balance = %d err %v", sellerTON, err)
|
||||
}
|
||||
|
|
@ -233,6 +248,29 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
|||
if err != nil || transferred.Unique.Owner != ownerPeer || transferred.Saved.TransferStars != 25 || transferred.Balance.Balance != 9975 {
|
||||
t.Fatalf("paid transfer = %+v err %v", transferred, err)
|
||||
}
|
||||
clearedUser, found, err := users.ByID(ctx, resaleBuyer.ID)
|
||||
if err != nil || !found || !clearedUser.EmojiStatus().Empty() {
|
||||
t.Fatalf("transferred collectible status was not cleared: user=%+v found=%v err=%v", clearedUser, found, err)
|
||||
}
|
||||
statusEvents, err := updateEvents.ListAfter(ctx, resaleBuyer.ID, statusPtsBeforeTransfer, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("load collectible invalidation event: %v", err)
|
||||
}
|
||||
var clearEvent domain.UpdateEvent
|
||||
for _, event := range statusEvents {
|
||||
if event.Type == domain.UpdateEventUserEmojiStatus {
|
||||
clearEvent = event
|
||||
break
|
||||
}
|
||||
}
|
||||
if clearEvent.Pts == 0 || !clearEvent.EmojiStatus.Empty() || clearEvent.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: resaleBuyer.ID}) {
|
||||
t.Fatalf("collectible invalidation event = %+v", clearEvent)
|
||||
}
|
||||
var clearOutboxCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='user_emoji_status'`, resaleBuyer.ID, clearEvent.Pts).Scan(&clearOutboxCount); err != nil || clearOutboxCount != 1 {
|
||||
t.Fatalf("collectible invalidation outbox count=%d err=%v, want 1", clearOutboxCount, err)
|
||||
}
|
||||
|
||||
// A second prepaid collectible makes craft chance exactly 1000‰. Success
|
||||
// preserves the first aggregate as crafted and burns the other input. The
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != 107 {
|
||||
t.Fatalf("migration status = %+v, want clean version 107", status)
|
||||
if status.Dirty || status.Empty || status.Version != 118 {
|
||||
t.Fatalf("migration status = %+v, want clean version 118", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,31 +211,36 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
emojiStatusPayload, err := encodeEventEmojiStatus(event.EmojiStatus)
|
||||
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,
|
||||
EventPhone: event.Phone,
|
||||
EventPeers: peers,
|
||||
PeerSettings: settings,
|
||||
MessageIds: messageIDs,
|
||||
DialogFilter: dialogFilter,
|
||||
FilterOrder: filterOrder,
|
||||
FolderPeers: folderPeers,
|
||||
StoryPayload: storyPayload,
|
||||
ReactionPayload: reactionPayload,
|
||||
MaxID: pgInt32NonNegative(event.MaxID),
|
||||
StillUnreadCount: int32(event.StillUnreadCount),
|
||||
ChannelPts: int32(event.ChannelPts),
|
||||
FilterID: pgInt32NonNegative(event.FilterID),
|
||||
TagsEnabled: event.TagsEnabled,
|
||||
FolderID: pgInt32NonNegative(event.FolderID),
|
||||
MessageBoxID: messageID,
|
||||
PeerType: peerType,
|
||||
PeerID: peerID,
|
||||
UserID: userID,
|
||||
Pts: int32(event.Pts),
|
||||
PtsCount: int32(event.PtsCount),
|
||||
Date: int32(event.Date),
|
||||
EventType: string(event.Type),
|
||||
EventBool: event.Bool,
|
||||
EventPhone: event.Phone,
|
||||
EventPeers: peers,
|
||||
PeerSettings: settings,
|
||||
MessageIds: messageIDs,
|
||||
DialogFilter: dialogFilter,
|
||||
FilterOrder: filterOrder,
|
||||
FolderPeers: folderPeers,
|
||||
StoryPayload: storyPayload,
|
||||
ReactionPayload: reactionPayload,
|
||||
EmojiStatusPayload: emojiStatusPayload,
|
||||
MaxID: pgInt32NonNegative(event.MaxID),
|
||||
StillUnreadCount: int32(event.StillUnreadCount),
|
||||
ChannelPts: int32(event.ChannelPts),
|
||||
FilterID: pgInt32NonNegative(event.FilterID),
|
||||
TagsEnabled: event.TagsEnabled,
|
||||
FolderID: pgInt32NonNegative(event.FolderID),
|
||||
MessageBoxID: messageID,
|
||||
PeerType: peerType,
|
||||
PeerID: peerID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -385,6 +390,10 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("decode reaction payload: %w", err)
|
||||
}
|
||||
emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode emoji status payload: %w", err)
|
||||
}
|
||||
media, err := decodeMessageMedia(row.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode message media: %w", err)
|
||||
|
|
@ -420,6 +429,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
|
|||
TagsEnabled: row.TagsEnabled,
|
||||
FolderID: int(row.FolderID),
|
||||
Reaction: reaction,
|
||||
EmojiStatus: emojiStatus,
|
||||
Message: domain.Message{
|
||||
ID: int(row.MessageID),
|
||||
UID: row.PrivateMessageID,
|
||||
|
|
@ -579,6 +589,10 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("decode reaction payload: %w", err)
|
||||
}
|
||||
emojiStatus, err := decodeEventEmojiStatus(row.EmojiStatusPayloadJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode emoji status payload: %w", err)
|
||||
}
|
||||
media, err := decodeMessageMedia(row.MediaJson)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode message media: %w", err)
|
||||
|
|
@ -614,6 +628,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
|
|||
TagsEnabled: row.TagsEnabled,
|
||||
FolderID: int(row.FolderID),
|
||||
Reaction: reaction,
|
||||
EmojiStatus: emojiStatus,
|
||||
Message: domain.Message{
|
||||
ID: int(row.MessageID),
|
||||
UID: row.PrivateMessageID,
|
||||
|
|
@ -979,6 +994,31 @@ func decodeEventReaction(raw string) (*domain.MessageReaction, error) {
|
|||
return decodeStoryReaction(raw)
|
||||
}
|
||||
|
||||
func encodeEventEmojiStatus(status domain.UserEmojiStatus) ([]byte, error) {
|
||||
if !status.Valid() {
|
||||
return nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
raw, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal event emoji status: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodeEventEmojiStatus(raw string) (domain.UserEmojiStatus, error) {
|
||||
if raw == "" || raw == "{}" || raw == "null" {
|
||||
return domain.UserEmojiStatus{}, nil
|
||||
}
|
||||
var status domain.UserEmojiStatus
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return domain.UserEmojiStatus{}, err
|
||||
}
|
||||
if !status.Valid() {
|
||||
return domain.UserEmojiStatus{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
type peerSettingsJSON struct {
|
||||
AddContact bool `json:"add_contact,omitempty"`
|
||||
BlockContact bool `json:"block_contact,omitempty"`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
|
@ -134,27 +135,29 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon
|
|||
Results: make([]domain.User, 0, len(rows)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
collectible := mustDecodeEmojiStatusCollectible(row.EmojiStatusCollectibleID, row.EmojiStatusCollectible)
|
||||
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,
|
||||
Bot: row.IsBot,
|
||||
BotInfoVersion: int(row.BotInfoVersion),
|
||||
PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt),
|
||||
EmojiStatusDocumentID: row.EmojiStatusDocumentID,
|
||||
EmojiStatusUntil: int(row.EmojiStatusUntil),
|
||||
Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID),
|
||||
ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID),
|
||||
LastSeenAt: int(row.LastSeenAt),
|
||||
Contact: row.Contact,
|
||||
Mutual: row.Mutual,
|
||||
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,
|
||||
Bot: row.IsBot,
|
||||
BotInfoVersion: int(row.BotInfoVersion),
|
||||
PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt),
|
||||
EmojiStatusDocumentID: row.EmojiStatusDocumentID,
|
||||
EmojiStatusUntil: int(row.EmojiStatusUntil),
|
||||
EmojiStatusCollectible: collectible,
|
||||
Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID),
|
||||
ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID),
|
||||
LastSeenAt: int(row.LastSeenAt),
|
||||
Contact: row.Contact,
|
||||
Mutual: row.Mutual,
|
||||
}
|
||||
if row.Contact {
|
||||
out.MyResults = append(out.MyResults, u)
|
||||
|
|
@ -336,22 +339,110 @@ func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit in
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。
|
||||
func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) {
|
||||
row, err := s.q.UpdateUserEmojiStatus(ctx, sqlcgen.UpdateUserEmojiStatusParams{
|
||||
ID: userID,
|
||||
EmojiStatusDocumentID: documentID,
|
||||
EmojiStatusUntil: int64(until),
|
||||
})
|
||||
// UpdateEmojiStatus atomically replaces the complete emoji-status snapshot.
|
||||
func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
|
||||
collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
params := sqlcgen.UpdateUserEmojiStatusParams{
|
||||
ID: userID,
|
||||
EmojiStatusDocumentID: status.DocumentID,
|
||||
EmojiStatusUntil: int64(status.Until),
|
||||
EmojiStatusCollectibleID: collectibleID,
|
||||
EmojiStatusCollectible: collectibleJSON,
|
||||
}
|
||||
var row sqlcgen.User
|
||||
if status.Collectible.Empty() {
|
||||
row, err = updateEmojiStatusRow(ctx, s.db, s.q, userID, status, params)
|
||||
} else {
|
||||
// Serialize selection against transfer/export/burn. RPC-level ownership
|
||||
// checks are advisory; this lock is the write-boundary invariant that
|
||||
// prevents a concurrent lifecycle commit from leaving a non-owned gift
|
||||
// installed after its invalidation trigger already ran.
|
||||
err = withTx(ctx, s.db, "update collectible emoji status", func(tx pgx.Tx) error {
|
||||
row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return domain.User{}, fmt.Errorf("update user emoji status: %w", err)
|
||||
}
|
||||
return userFromModel(row), nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatusWithEvent commits the user snapshot, allocated pts event
|
||||
// and dispatch outbox row as one aggregate transaction. This is the production
|
||||
// boundary used by account.updateEmojiStatus; no success can expose a users
|
||||
// row whose change is absent from updates.getDifference.
|
||||
func (s *UserStore) UpdateEmojiStatusWithEvent(ctx context.Context, userID int64, status domain.UserEmojiStatus, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.User, domain.UpdateEvent, error) {
|
||||
collectibleJSON, collectibleID, err := encodeEmojiStatusCollectible(status)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.UpdateEvent{}, err
|
||||
}
|
||||
if event.Type != domain.UpdateEventUserEmojiStatus || event.EmojiStatus != status ||
|
||||
event.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
|
||||
return domain.User{}, domain.UpdateEvent{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
params := sqlcgen.UpdateUserEmojiStatusParams{
|
||||
ID: userID,
|
||||
EmojiStatusDocumentID: status.DocumentID,
|
||||
EmojiStatusUntil: int64(status.Until),
|
||||
EmojiStatusCollectibleID: collectibleID,
|
||||
EmojiStatusCollectible: collectibleJSON,
|
||||
}
|
||||
var row sqlcgen.User
|
||||
err = withTx(ctx, s.db, "update emoji status with event", func(tx pgx.Tx) error {
|
||||
row, err = updateEmojiStatusRow(ctx, tx, sqlcgen.New(tx), userID, status, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, err = NewUpdateEventStore(tx).AppendAllocatedWithDispatch(
|
||||
ctx, userID, event, excludeAuthKeyID, excludeSessionID,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.UpdateEvent{}, domain.ErrUserNotFound
|
||||
}
|
||||
if errors.Is(err, domain.ErrStarGiftCollectibleInvalid) {
|
||||
return domain.User{}, domain.UpdateEvent{}, err
|
||||
}
|
||||
return domain.User{}, domain.UpdateEvent{}, fmt.Errorf("update user emoji status with event: %w", err)
|
||||
}
|
||||
return userFromModel(row), event, nil
|
||||
}
|
||||
|
||||
func updateEmojiStatusRow(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, status domain.UserEmojiStatus, params sqlcgen.UpdateUserEmojiStatusParams) (sqlcgen.User, error) {
|
||||
if !status.Collectible.Empty() {
|
||||
var lockedID int64
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT id FROM unique_star_gifts WHERE id=$1 FOR UPDATE`, status.Collectible.CollectibleID).Scan(&lockedID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
return sqlcgen.User{}, err
|
||||
}
|
||||
gift, found, err := NewStarGiftStore(db).UniqueByID(ctx, lockedID)
|
||||
if err != nil {
|
||||
return sqlcgen.User{}, err
|
||||
}
|
||||
expected, valid := domain.CollectibleEmojiStatus(gift)
|
||||
if !found || !valid || gift.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) ||
|
||||
gift.Burned || gift.OwnerAddress != "" || expected != status.Collectible {
|
||||
return sqlcgen.User{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
}
|
||||
return q.UpdateUserEmojiStatus(ctx, params)
|
||||
}
|
||||
|
||||
// UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。
|
||||
func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
|
||||
row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{
|
||||
|
|
@ -444,32 +535,34 @@ func escapeLike(s string) string {
|
|||
}
|
||||
|
||||
func userFromModel(r sqlcgen.User) domain.User {
|
||||
collectible := mustDecodeEmojiStatusCollectible(r.EmojiStatusCollectibleID, r.EmojiStatusCollectible)
|
||||
u := 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,
|
||||
Bot: r.IsBot,
|
||||
BotInfoVersion: int(r.BotInfoVersion),
|
||||
PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt),
|
||||
EmojiStatusDocumentID: r.EmojiStatusDocumentID,
|
||||
EmojiStatusUntil: int(r.EmojiStatusUntil),
|
||||
Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)},
|
||||
PersonalChannelID: r.PersonalChannelID,
|
||||
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
|
||||
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
|
||||
LastSeenAt: int(r.LastSeenAt),
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
|
||||
DeletionReason: r.DeletionReason,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
AccountDeleteAt: r.AccountDeleteAt.Time,
|
||||
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,
|
||||
Bot: r.IsBot,
|
||||
BotInfoVersion: int(r.BotInfoVersion),
|
||||
PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt),
|
||||
EmojiStatusDocumentID: r.EmojiStatusDocumentID,
|
||||
EmojiStatusUntil: int(r.EmojiStatusUntil),
|
||||
EmojiStatusCollectible: collectible,
|
||||
Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)},
|
||||
PersonalChannelID: r.PersonalChannelID,
|
||||
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
|
||||
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
|
||||
LastSeenAt: int(r.LastSeenAt),
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
|
||||
DeletionReason: r.DeletionReason,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
AccountDeleteAt: r.AccountDeleteAt.Time,
|
||||
}
|
||||
if r.DeletedAt.Valid {
|
||||
u.DeletedAt = r.DeletedAt.Time.Unix()
|
||||
|
|
@ -478,6 +571,38 @@ func userFromModel(r sqlcgen.User) domain.User {
|
|||
return u
|
||||
}
|
||||
|
||||
func encodeEmojiStatusCollectible(status domain.UserEmojiStatus) ([]byte, *int64, error) {
|
||||
if !status.Valid() {
|
||||
return nil, nil, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
if status.Collectible.Empty() {
|
||||
return []byte(`{}`), nil, nil
|
||||
}
|
||||
raw, err := json.Marshal(status.Collectible)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("encode collectible emoji status: %w", err)
|
||||
}
|
||||
id := status.Collectible.CollectibleID
|
||||
return raw, &id, nil
|
||||
}
|
||||
|
||||
func mustDecodeEmojiStatusCollectible(id *int64, raw []byte) domain.EmojiStatusCollectible {
|
||||
var collectible domain.EmojiStatusCollectible
|
||||
if err := json.Unmarshal(raw, &collectible); err != nil {
|
||||
panic(fmt.Sprintf("invalid users.emoji_status_collectible JSON: %v", err))
|
||||
}
|
||||
if id == nil {
|
||||
if !collectible.Empty() {
|
||||
panic("users emoji-status invariant: snapshot exists without collectible id")
|
||||
}
|
||||
return domain.EmojiStatusCollectible{}
|
||||
}
|
||||
if !collectible.Valid() || collectible.CollectibleID != *id {
|
||||
panic("users emoji-status invariant: incomplete or mismatched collectible snapshot")
|
||||
}
|
||||
return collectible
|
||||
}
|
||||
|
||||
func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {
|
||||
return domain.PeerColor{
|
||||
HasColor: hasColor,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue