feat(messages): sync saved message tags

This commit is contained in:
iamxvbaba 2026-07-25 19:23:14 +08:00
parent a785ae7491
commit 6b3eba6c5d
42 changed files with 1581 additions and 551 deletions

View file

@ -121,70 +121,3 @@ func (s *ChannelStore) ClearRecentMessageReactions(ctx context.Context, userID i
}
return nil
}
func (s *ChannelStore) ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) {
if userID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 {
return []domain.SavedReactionTag{}, nil
}
if limit > domain.MaxSavedReactionTags {
limit = domain.MaxSavedReactionTags
}
rows, err := s.db.Query(ctx, `
SELECT reaction_type, reaction_value, title, reaction_count
FROM user_saved_reaction_tags
WHERE user_id = $1
ORDER BY reaction_count DESC, updated_at DESC, reaction_type ASC, reaction_value ASC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list saved reaction tags: %w", err)
}
defer rows.Close()
out := make([]domain.SavedReactionTag, 0, limit)
for rows.Next() {
var reactionType, reactionValue, title string
var count int
if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil {
return nil, err
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
continue
}
out = append(out, domain.SavedReactionTag{
UserID: userID,
Reaction: reaction,
Title: title,
Count: count,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *ChannelStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji {
return domain.ErrChannelInvalid
}
reactionValue := strings.TrimSpace(tag.Reaction.Emoticon)
if reactionValue == "" {
return domain.ErrChannelInvalid
}
if tag.Count < 0 {
tag.Count = 0
}
if _, err := s.db.Exec(ctx, `
INSERT INTO user_saved_reaction_tags (user_id, reaction_type, reaction_value, title, reaction_count)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET
title = EXCLUDED.title,
reaction_count = GREATEST(user_saved_reaction_tags.reaction_count, EXCLUDED.reaction_count),
updated_at = now()`, tag.UserID, string(tag.Reaction.Type), reactionValue, tag.Title, tag.Count); err != nil {
return fmt.Errorf("upsert saved reaction tag: %w", err)
}
return nil
}

View file

@ -4,10 +4,12 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
"time"
)
func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) {
@ -92,6 +94,7 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
savedPeerType = string(filter.SavedPeer.Type)
savedPeerID = filter.SavedPeer.ID
}
savedReactionKeys := postgresSavedReactionKeys(filter.SavedReactions)
// add_offset>=0 是 backward 热路径(初始加载/上滑翻页,占 getHistory 绝大多数)。
// 走扁平静态查询 ListMessagesBackward:规划仅单 index scan + 2 LEFT JOIN,避免
// ListMessagesByUser 大 CTE 把 4 个分支+total 全树规划(6.7ms→~1ms)。与 CTE
@ -100,23 +103,26 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
var rows []sqlcgen.ListMessagesByUserRow
if addOffset >= 0 {
bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
OffsetID: pgInt32NonNegative(filter.OffsetID),
RowOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MinDate: pgInt32NonNegative(filter.MinDate),
MaxDate: pgInt32NonNegative(filter.MaxDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
SavedReactionKeys: savedReactionKeys,
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
OffsetID: pgInt32NonNegative(filter.OffsetID),
RowOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("list messages (backward): %w", err)
@ -127,19 +133,22 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
}
if filter.NeedTotalCount {
total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MinDate: pgInt32NonNegative(filter.MinDate),
MaxDate: pgInt32NonNegative(filter.MaxDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
SavedReactionKeys: savedReactionKeys,
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("count messages: %w", err)
@ -153,24 +162,27 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
} else {
var err error
rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
AddOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
NeedTotalCount: filter.NeedTotalCount,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
OwnerUserID: userID,
HasPeer: filter.HasPeer,
PeerType: string(filter.Peer.Type),
PeerID: filter.Peer.ID,
RestrictPeerIds: filter.RestrictPeerIDs,
PeerIds: filter.PeerIDs,
Query: filter.Query,
MinDate: pgInt32NonNegative(filter.MinDate),
MaxDate: pgInt32NonNegative(filter.MaxDate),
OffsetID: pgInt32NonNegative(filter.OffsetID),
OffsetDate: pgInt32NonNegative(filter.OffsetDate),
MaxID: pgInt32NonNegative(filter.MaxID),
MinID: pgInt32NonNegative(filter.MinID),
AddOffset: pgInt32Bounded(addOffset),
LimitCount: int32(queryLimit),
PinnedOnly: filter.PinnedOnly,
MusicOnly: filter.MusicOnly,
NeedTotalCount: filter.NeedTotalCount,
SavedPeerType: savedPeerType,
SavedPeerID: savedPeerID,
SavedReactionKeys: savedReactionKeys,
})
if err != nil {
return domain.MessageList{}, fmt.Errorf("list messages: %w", err)
@ -273,6 +285,16 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
return out, nil
}
func postgresSavedReactionKeys(reactions []domain.MessageReaction) []string {
out := make([]string, 0, len(reactions))
for _, reaction := range reactions {
if reaction.Valid() {
out = append(out, string(reaction.Type)+":"+reaction.Value())
}
}
return out
}
func (s *MessageStore) ReadHistory(ctx context.Context, req domain.ReadHistoryRequest) (res domain.ReadHistoryResult, err error) {
res = domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID}
if req.OwnerUserID == 0 {

View file

@ -29,6 +29,9 @@ func (s *MessageStore) SetMessageReactions(ctx context.Context, req domain.SetPr
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
}
if req.Peer.ID == req.UserID {
return s.setSavedMessageTags(ctx, req)
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("set message reactions: db does not support transactions")
@ -240,10 +243,17 @@ func (s *MessageStore) enrichPrivateMessageReactions(ctx context.Context, db sql
if err := s.enrichPrivateMessagePolls(ctx, db, viewerUserID, messages); err != nil {
return err
}
if err := s.enrichSavedMessageTags(ctx, db, messages); err != nil {
return err
}
keySet := make(map[privateMessageReactionKey]struct{}, len(messages))
senderIDs := make([]int64, 0, len(messages))
privateIDs := make([]int64, 0, len(messages))
for _, msg := range messages {
if msg.OwnerUserID != 0 &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: msg.OwnerUserID}) {
continue
}
if msg.UID == 0 || msg.From.ID == 0 {
continue
}
@ -420,6 +430,11 @@ func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageRe
return
}
var buf [16]byte
if reactions.AsTags {
_, _ = h.Write([]byte{1})
} else {
_, _ = h.Write([]byte{0})
}
for _, item := range reactions.Results {
_, _ = h.Write([]byte(item.Reaction.Type))
_, _ = h.Write([]byte{0})

View file

@ -0,0 +1,308 @@
package postgres
import (
"context"
"errors"
"fmt"
"sort"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func (s *MessageStore) setSavedMessageTags(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("set saved message tags: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("begin set saved message tags tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := lockUsersForUpdate(ctx, tx, req.UserID); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("lock saved message tag owner: %w", err)
}
var boxID int32
if err := tx.QueryRow(ctx, `
SELECT box_id
FROM message_boxes
WHERE owner_user_id = $1
AND box_id = $2
AND peer_type = 'user'
AND peer_id = $1
AND NOT deleted
LIMIT 1
FOR UPDATE`, req.UserID, int32(req.MessageID)).Scan(&boxID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("get saved message for tags: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM saved_message_reaction_tags
WHERE user_id = $1 AND message_box_id = $2`, req.UserID, boxID); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("delete old saved message tags: %w", err)
}
for i, reaction := range req.Reactions {
if !reaction.Valid() {
return domain.PrivateMessageReactionsResult{}, domain.ErrReactionInvalid
}
if _, err := tx.Exec(ctx, `
INSERT INTO saved_message_reaction_tags (
user_id, message_box_id, reaction_type, reaction_value, chosen_order
) VALUES ($1, $2, $3, $4, $5)`,
req.UserID, boxID, string(reaction.Type), reaction.Value(), int32(i+1)); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("insert saved message tag: %w", err)
}
}
rows, err := sqlcgen.New(tx).GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{
OwnerUserID: req.UserID,
BoxIds: []int32{boxID},
})
if err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("reload saved message tags box: %w", err)
}
if len(rows) != 1 {
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
msg, err := messageFromIDRow(rows[0])
if err != nil {
return domain.PrivateMessageReactionsResult{}, err
}
messages := []domain.Message{msg}
if err := s.enrichPrivateMessageReactions(ctx, tx, req.UserID, messages); err != nil {
return domain.PrivateMessageReactionsResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.PrivateMessageReactionsResult{}, fmt.Errorf("commit saved message tags tx: %w", err)
}
committed = true
reactions := domain.ChannelMessageReactions{AsTags: true}
if messages[0].Reactions != nil {
reactions = *messages[0].Reactions
}
return domain.PrivateMessageReactionsResult{
Messages: messages,
Reactions: reactions,
}, nil
}
func (s *MessageStore) enrichSavedMessageTags(ctx context.Context, db sqlcgen.DBTX, messages []domain.Message) error {
ownerIDs := make([]int64, 0, len(messages))
boxIDs := make([]int32, 0, len(messages))
indexes := make(map[[2]int64]int, len(messages))
for i := range messages {
msg := messages[i]
if msg.OwnerUserID == 0 ||
msg.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: msg.OwnerUserID}) {
continue
}
ownerIDs = append(ownerIDs, msg.OwnerUserID)
boxIDs = append(boxIDs, int32(msg.ID))
indexes[[2]int64{msg.OwnerUserID, int64(msg.ID)}] = i
}
if len(ownerIDs) == 0 {
return nil
}
rows, err := db.Query(ctx, `
WITH wanted AS (
SELECT user_id, message_box_id
FROM unnest($1::bigint[], $2::int[]) AS w(user_id, message_box_id)
)
SELECT t.user_id, t.message_box_id, t.reaction_type, t.reaction_value, t.chosen_order
FROM saved_message_reaction_tags t
JOIN wanted w
ON w.user_id = t.user_id
AND w.message_box_id = t.message_box_id
ORDER BY t.user_id, t.message_box_id, t.chosen_order, t.reaction_type, t.reaction_value`,
ownerIDs, boxIDs)
if err != nil {
return fmt.Errorf("load saved message tags: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
userID int64
messageBoxID int32
reactionType string
reactionValue string
chosenOrder int32
)
if err := rows.Scan(&userID, &messageBoxID, &reactionType, &reactionValue, &chosenOrder); err != nil {
return fmt.Errorf("scan saved message tag: %w", err)
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
continue
}
index, ok := indexes[[2]int64{userID, int64(messageBoxID)}]
if !ok {
continue
}
if messages[index].Reactions == nil {
messages[index].Reactions = &domain.ChannelMessageReactions{
AsTags: true,
Results: []domain.ChannelMessageReactionCount{},
Recent: []domain.ChannelMessagePeerReaction{},
}
}
messages[index].Reactions.Results = append(messages[index].Reactions.Results, domain.ChannelMessageReactionCount{
Reaction: reaction,
Count: 1,
ChosenOrder: int(chosenOrder),
})
}
if err := rows.Err(); err != nil {
return fmt.Errorf("saved message tag rows: %w", err)
}
return nil
}
func (s *MessageStore) ListSavedReactionTags(ctx context.Context, req domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error) {
if req.UserID == 0 {
return nil, domain.ErrReactionInvalid
}
if req.Limit <= 0 || req.Limit > domain.MaxSavedReactionTags {
req.Limit = domain.MaxSavedReactionTags
}
savedPeerType := ""
var savedPeerID int64
if req.SavedPeer.ID != 0 {
savedPeerType = string(req.SavedPeer.Type)
savedPeerID = req.SavedPeer.ID
}
rows, err := s.db.Query(ctx, `
SELECT
a.reaction_type,
a.reaction_value,
CASE WHEN $2 = '' THEN COALESCE(t.title, '') ELSE '' END AS title,
COUNT(*)::int AS reaction_count
FROM saved_message_reaction_tags a
JOIN message_boxes m
ON m.owner_user_id = a.user_id
AND m.box_id = a.message_box_id
AND NOT m.deleted
AND m.peer_type = 'user'
AND m.peer_id = a.user_id
LEFT JOIN user_saved_reaction_tags t
ON t.user_id = a.user_id
AND t.reaction_type = a.reaction_type
AND t.reaction_value = a.reaction_value
WHERE a.user_id = $1
AND ($2 = '' OR (m.saved_peer_type = $2 AND m.saved_peer_id = $3))
GROUP BY a.reaction_type, a.reaction_value, title
ORDER BY
reaction_count DESC,
CASE
WHEN a.reaction_type = 'custom_emoji'
THEN lpad(to_hex(a.reaction_value::bigint), 16, '0')
ELSE substr(md5(replace(a.reaction_value, U&'\FE0F', '')), 1, 16)
END DESC
LIMIT $4`, req.UserID, savedPeerType, savedPeerID, int32(req.Limit))
if err != nil {
return nil, fmt.Errorf("list saved reaction tags: %w", err)
}
defer rows.Close()
out := make([]domain.SavedReactionTag, 0, req.Limit)
for rows.Next() {
var reactionType, reactionValue, title string
var count int32
if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil {
return nil, fmt.Errorf("scan saved reaction tag: %w", err)
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok || count <= 0 {
continue
}
out = append(out, domain.SavedReactionTag{
UserID: req.UserID,
Reaction: reaction,
Title: title,
Count: int(count),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("saved reaction tag rows: %w", err)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Reaction.Key() > out[j].Reaction.Key()
})
return out, nil
}
func (s *MessageStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || !tag.Reaction.Valid() || utf8.RuneCountInString(tag.Title) > 12 {
return domain.ErrReactionInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return fmt.Errorf("update saved reaction tag title: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin update saved reaction tag title tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := lockUsersForUpdate(ctx, tx, tag.UserID); err != nil {
return fmt.Errorf("lock saved reaction tag owner: %w", err)
}
var exists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM saved_message_reaction_tags a
JOIN message_boxes m
ON m.owner_user_id = a.user_id
AND m.box_id = a.message_box_id
AND NOT m.deleted
AND m.peer_type = 'user'
AND m.peer_id = a.user_id
WHERE a.user_id = $1
AND a.reaction_type = $2
AND a.reaction_value = $3
)`, tag.UserID, string(tag.Reaction.Type), tag.Reaction.Value()).Scan(&exists); err != nil {
return fmt.Errorf("check saved reaction tag assignment: %w", err)
}
if !exists {
return domain.ErrReactionInvalid
}
if tag.Title == "" {
if _, err := tx.Exec(ctx, `
DELETE FROM user_saved_reaction_tags
WHERE user_id = $1 AND reaction_type = $2 AND reaction_value = $3`,
tag.UserID, string(tag.Reaction.Type), tag.Reaction.Value()); err != nil {
return fmt.Errorf("delete saved reaction tag title: %w", err)
}
} else if _, err := tx.Exec(ctx, `
INSERT INTO user_saved_reaction_tags (
user_id, reaction_type, reaction_value, title, reaction_count
) VALUES ($1, $2, $3, $4, 0)
ON CONFLICT (user_id, reaction_type, reaction_value)
DO UPDATE SET title = EXCLUDED.title, reaction_count = 0, updated_at = now()`,
tag.UserID, string(tag.Reaction.Type), tag.Reaction.Value(), tag.Title); err != nil {
return fmt.Errorf("upsert saved reaction tag title: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit saved reaction tag title tx: %w", err)
}
committed = true
return nil
}

View file

@ -0,0 +1,177 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestSavedMessageTagsPostgresAssignmentCountsSearchAndDelete(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
user, err := users.Create(ctx, domain.User{
AccessHash: 1,
Phone: "+1777" + suffix + "01",
FirstName: "SavedTags",
})
if err != nil {
t.Fatalf("create saved-tag user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM saved_message_reaction_tags WHERE user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM user_saved_reaction_tags WHERE user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = $1 OR recipient_user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = $1", user.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID)
})
messages := NewMessageStore(pool)
self := domain.Peer{Type: domain.PeerTypeUser, ID: user.ID}
peerA := domain.Peer{Type: domain.PeerTypeUser, ID: user.ID}
peerB := domain.Peer{Type: domain.PeerTypeChannel, ID: 90001}
create := func(body string, savedPeer domain.Peer) domain.Message {
msg, err := messages.Create(ctx, domain.Message{
OwnerUserID: user.ID,
Peer: self,
From: self,
Date: int(time.Now().Unix()),
Body: body,
})
if err != nil {
t.Fatalf("create saved message: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE message_boxes
SET saved_peer_type = $3, saved_peer_id = $4
WHERE owner_user_id = $1 AND box_id = $2`,
user.ID, msg.ID, string(savedPeer.Type), savedPeer.ID); err != nil {
t.Fatalf("set saved peer: %v", err)
}
msg.SavedPeer = savedPeer
return msg
}
first := create("first", peerA)
second := create("second", peerA)
third := create("third", peerB)
thumb := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"}
custom := domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 70001}
set := func(msg domain.Message, reactions ...domain.MessageReaction) {
t.Helper()
result, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
UserID: user.ID,
Peer: self,
MessageID: msg.ID,
Reactions: reactions,
ReactionsPerUserMax: 3,
})
if err != nil {
t.Fatalf("set saved tags on %d: %v", msg.ID, err)
}
if len(result.Messages) != 1 || result.Messages[0].Reactions == nil ||
!result.Messages[0].Reactions.AsTags {
t.Fatalf("set saved tags result = %+v", result)
}
}
set(first, thumb)
set(second, thumb, custom)
set(third, custom)
if err := messages.UpsertSavedReactionTag(ctx, domain.SavedReactionTag{
UserID: user.ID, Reaction: custom, Title: "Custom",
}); err != nil {
t.Fatalf("rename custom saved tag: %v", err)
}
global, err := messages.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: user.ID, Limit: 100,
})
if err != nil {
t.Fatalf("list global saved tags: %v", err)
}
assertPostgresSavedTag(t, global, thumb, 2, "")
assertPostgresSavedTag(t, global, custom, 2, "Custom")
perPeer, err := messages.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: user.ID, SavedPeer: peerA, Limit: 100,
})
if err != nil {
t.Fatalf("list per-peer saved tags: %v", err)
}
assertPostgresSavedTag(t, perPeer, thumb, 2, "")
assertPostgresSavedTag(t, perPeer, custom, 1, "")
search, err := messages.ListByUser(ctx, user.ID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedPeer: peerA,
SavedReactions: []domain.MessageReaction{custom},
NeedTotalCount: true,
Limit: 10,
})
if err != nil {
t.Fatalf("search saved tag: %v", err)
}
if len(search.Messages) != 1 || search.Messages[0].ID != second.ID ||
search.Messages[0].Reactions == nil || !search.Messages[0].Reactions.AsTags {
t.Fatalf("saved tag search = %+v, want second message", search.Messages)
}
searchAny, err := messages.ListByUser(ctx, user.ID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedReactions: []domain.MessageReaction{thumb, custom},
NeedTotalCount: true,
Limit: 10,
})
if err != nil {
t.Fatalf("search any saved tag: %v", err)
}
if len(searchAny.Messages) != 3 || searchAny.Count != 3 {
t.Fatalf("saved tag OR search = count %d messages %+v, want all three", searchAny.Count, searchAny.Messages)
}
var reactionEvents int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM user_update_events
WHERE user_id = $1 AND event_type = 'message_reactions'`, user.ID).Scan(&reactionEvents); err != nil {
t.Fatalf("count reaction events: %v", err)
}
if reactionEvents != 0 {
t.Fatalf("reaction durable events = %d, want 0", reactionEvents)
}
if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: user.ID,
IDs: []int{second.ID},
Date: int(time.Now().Unix()),
}); err != nil {
t.Fatalf("delete tagged saved message: %v", err)
}
global, err = messages.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: user.ID, Limit: 100,
})
if err != nil {
t.Fatalf("list tags after delete: %v", err)
}
assertPostgresSavedTag(t, global, thumb, 1, "")
assertPostgresSavedTag(t, global, custom, 1, "Custom")
}
func assertPostgresSavedTag(t *testing.T, tags []domain.SavedReactionTag, reaction domain.MessageReaction, count int, title string) {
t.Helper()
for _, tag := range tags {
if tag.Reaction.Key() == reaction.Key() {
if tag.Count != count || tag.Title != title {
t.Fatalf("tag %s = %+v, want count=%d title=%q", reaction.Key(), tag, count, title)
}
return
}
}
t.Fatalf("tag %s not found in %+v", reaction.Key(), tags)
}

View file

@ -545,6 +545,8 @@ base AS NOT MATERIALIZED (
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
)
AND (sqlc.arg(min_date)::int <= 0 OR m.message_date > sqlc.arg(min_date)::int)
AND (sqlc.arg(max_date)::int <= 0 OR m.message_date < sqlc.arg(max_date)::int)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int)
AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int)
AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned)
@ -564,6 +566,17 @@ base AS NOT MATERIALIZED (
sqlc.arg(saved_peer_type)::text = ''
OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint)
)
AND (
cardinality(sqlc.arg(saved_reaction_keys)::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY(sqlc.arg(saved_reaction_keys)::text[])
)
)
),
total AS (
SELECT count(*)::int AS total_count
@ -810,6 +823,8 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
)
AND (sqlc.arg(min_date)::int <= 0 OR m.message_date > sqlc.arg(min_date)::int)
AND (sqlc.arg(max_date)::int <= 0 OR m.message_date < sqlc.arg(max_date)::int)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int)
AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int)
AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned)
@ -829,6 +844,17 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
sqlc.arg(saved_peer_type)::text = ''
OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint)
)
AND (
cardinality(sqlc.arg(saved_reaction_keys)::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY(sqlc.arg(saved_reaction_keys)::text[])
)
)
AND (
(sqlc.arg(offset_date)::int > 0 AND m.message_date < sqlc.arg(offset_date)::int)
OR (sqlc.arg(offset_date)::int <= 0 AND (sqlc.arg(offset_id)::int <= 0 OR m.box_id < sqlc.arg(offset_id)::int))
@ -856,6 +882,8 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
sqlc.arg(query)::text = ''
OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%')
)
AND (sqlc.arg(min_date)::int <= 0 OR m.message_date > sqlc.arg(min_date)::int)
AND (sqlc.arg(max_date)::int <= 0 OR m.message_date < sqlc.arg(max_date)::int)
AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int)
AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int)
AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned)
@ -874,6 +902,17 @@ WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
AND (
sqlc.arg(saved_peer_type)::text = ''
OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint)
)
AND (
cardinality(sqlc.arg(saved_reaction_keys)::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY(sqlc.arg(saved_reaction_keys)::text[])
)
);
-- name: GetMessageBoxesByIDs :many

View file

@ -26,11 +26,13 @@ WHERE m.owner_user_id = $1::bigint
$7::text = ''
OR m.body ILIKE ('%' || $7::text || '%')
)
AND ($8::int <= 0 OR m.box_id < $8::int)
AND ($9::int <= 0 OR m.box_id > $9::int)
AND (NOT $10::boolean OR m.pinned)
AND ($8::int <= 0 OR m.message_date > $8::int)
AND ($9::int <= 0 OR m.message_date < $9::int)
AND ($10::int <= 0 OR m.box_id < $10::int)
AND ($11::int <= 0 OR m.box_id > $11::int)
AND (NOT $12::boolean OR m.pinned)
AND (
NOT $11::boolean
NOT $13::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -42,25 +44,39 @@ WHERE m.owner_user_id = $1::bigint
)
)
AND (
$12::text = ''
OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint)
$14::text = ''
OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint)
)
AND (
cardinality($16::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY($16::text[])
)
)
`
type CountMessagesByUserParams struct {
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MinDate int32
MaxDate int32
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
SavedReactionKeys []string
}
// ListMessagesByUser total CTE 的独立化:相同 base 过滤(不含分页 anchor),
@ -74,12 +90,15 @@ func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUs
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MinDate,
arg.MaxDate,
arg.MaxID,
arg.MinID,
arg.PinnedOnly,
arg.MusicOnly,
arg.SavedPeerType,
arg.SavedPeerID,
arg.SavedReactionKeys,
)
var total_count int32
err := row.Scan(&total_count)
@ -2295,11 +2314,13 @@ WHERE m.owner_user_id = $1::bigint
$7::text = ''
OR m.body ILIKE ('%' || $7::text || '%')
)
AND ($8::int <= 0 OR m.box_id < $8::int)
AND ($9::int <= 0 OR m.box_id > $9::int)
AND (NOT $10::boolean OR m.pinned)
AND ($8::int <= 0 OR m.message_date > $8::int)
AND ($9::int <= 0 OR m.message_date < $9::int)
AND ($10::int <= 0 OR m.box_id < $10::int)
AND ($11::int <= 0 OR m.box_id > $11::int)
AND (NOT $12::boolean OR m.pinned)
AND (
NOT $11::boolean
NOT $13::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -2311,36 +2332,50 @@ WHERE m.owner_user_id = $1::bigint
)
)
AND (
$12::text = ''
OR (m.saved_peer_type = $12::text AND m.saved_peer_id = $13::bigint)
$14::text = ''
OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint)
)
AND (
($14::int > 0 AND m.message_date < $14::int)
OR ($14::int <= 0 AND ($15::int <= 0 OR m.box_id < $15::int))
cardinality($16::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY($16::text[])
)
)
AND (
($17::int > 0 AND m.message_date < $17::int)
OR ($17::int <= 0 AND ($18::int <= 0 OR m.box_id < $18::int))
)
ORDER BY m.box_id DESC
OFFSET GREATEST($16::int, 0)
LIMIT $17::int
OFFSET GREATEST($19::int, 0)
LIMIT $20::int
`
type ListMessagesBackwardParams struct {
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
OffsetDate int32
OffsetID int32
RowOffset int32
LimitCount int32
OwnerUserID int64
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MinDate int32
MaxDate int32
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
SavedReactionKeys []string
OffsetDate int32
OffsetID int32
RowOffset int32
LimitCount int32
}
type ListMessagesBackwardRow struct {
@ -2433,12 +2468,15 @@ func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBack
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MinDate,
arg.MaxDate,
arg.MaxID,
arg.MinID,
arg.PinnedOnly,
arg.MusicOnly,
arg.SavedPeerType,
arg.SavedPeerID,
arg.SavedReactionKeys,
arg.OffsetDate,
arg.OffsetID,
arg.RowOffset,
@ -2641,11 +2679,13 @@ base AS NOT MATERIALIZED (
$11::text = ''
OR m.body ILIKE ('%' || $11::text || '%')
)
AND ($12::int <= 0 OR m.box_id < $12::int)
AND ($13::int <= 0 OR m.box_id > $13::int)
AND (NOT $14::boolean OR m.pinned)
AND ($12::int <= 0 OR m.message_date > $12::int)
AND ($13::int <= 0 OR m.message_date < $13::int)
AND ($14::int <= 0 OR m.box_id < $14::int)
AND ($15::int <= 0 OR m.box_id > $15::int)
AND (NOT $16::boolean OR m.pinned)
AND (
NOT $15::boolean
NOT $17::boolean
OR (
m.media->>'kind' = 'document'
AND EXISTS (
@ -2657,14 +2697,25 @@ base AS NOT MATERIALIZED (
)
)
AND (
$16::text = ''
OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint)
$18::text = ''
OR (m.saved_peer_type = $18::text AND m.saved_peer_id = $19::bigint)
)
AND (
cardinality($20::text[]) = 0
OR EXISTS (
SELECT 1
FROM saved_message_reaction_tags tag
WHERE tag.user_id = m.owner_user_id
AND tag.message_box_id = m.box_id
AND (tag.reaction_type || ':' || tag.reaction_value)
= ANY($20::text[])
)
)
),
total AS (
SELECT count(*)::int AS total_count
FROM base
WHERE $18::boolean
WHERE $21::boolean
),
backward AS (
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
@ -2811,24 +2862,27 @@ ORDER BY box_id DESC
`
type ListMessagesByUserParams struct {
OwnerUserID int64
OffsetID int32
OffsetDate int32
AddOffset int32
LimitCount int32
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
NeedTotalCount bool
OwnerUserID int64
OffsetID int32
OffsetDate int32
AddOffset int32
LimitCount int32
HasPeer bool
PeerType string
PeerID int64
RestrictPeerIds bool
PeerIds []int64
Query string
MinDate int32
MaxDate int32
MaxID int32
MinID int32
PinnedOnly bool
MusicOnly bool
SavedPeerType string
SavedPeerID int64
SavedReactionKeys []string
NeedTotalCount bool
}
type ListMessagesByUserRow struct {
@ -2921,12 +2975,15 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
arg.RestrictPeerIds,
arg.PeerIds,
arg.Query,
arg.MinDate,
arg.MaxDate,
arg.MaxID,
arg.MinID,
arg.PinnedOnly,
arg.MusicOnly,
arg.SavedPeerType,
arg.SavedPeerID,
arg.SavedReactionKeys,
arg.NeedTotalCount,
)
if err != nil {

View file

@ -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 != 147 {
t.Fatalf("migration status = %+v, want clean version 147", status)
if status.Dirty || status.Empty || status.Version != 148 {
t.Fatalf("migration status = %+v, want clean version 148", status)
}
}