owpengram-server/internal/store/postgres/channel_reaction_helpers.go
2026-09-01 12:06:31 +03:00

293 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package postgres
import (
"context"
"fmt"
"strconv"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type channelReactionMessageKey struct {
channelID int64
messageID int
}
// channelMessagePairs 把 {channelID: [messageID...]} 摊平成两条等长并行数组,供
// `WHERE (channel_id, message_id) IN (SELECT * FROM unnest($a::bigint[], $b::int[]))`
// 跨频道一次批量取,消除「每频道一条 SQL」的 N+1。
func channelMessagePairs(idsByChannel map[int64][]int32) ([]int64, []int32) {
var channels []int64
var messages []int32
for channelID, ids := range idsByChannel {
for _, id := range ids {
channels = append(channels, channelID)
messages = append(messages, id)
}
}
return channels, messages
}
type channelReactionCursor struct {
date int
userID int64
reactionType domain.MessageReactionType
value string
legacyValue bool
}
func emptyChannelMessageReactions(channel domain.Channel) domain.ChannelMessageReactions {
return domain.ChannelMessageReactions{
CanSeeList: !channel.Broadcast || channel.Megagroup,
Results: []domain.ChannelMessageReactionCount{},
Recent: []domain.ChannelMessagePeerReaction{},
}
}
func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) error {
return s.populateChannelMessagesReactionsWhere(ctx, db, viewerUserID, channels, messages, nil, false)
}
func (s *ChannelStore) populateChannelMessagesReactionsWhere(
ctx context.Context,
db sqlcgen.DBTX,
viewerUserID int64,
channels []domain.Channel,
messages []domain.ChannelMessage,
reactionEligible func(domain.ChannelMessage) bool,
unreadAlreadyProjected bool,
) error {
if len(messages) == 0 {
return nil
}
// poll enrichment 与 reactions 同点位挂载:所有频道消息读路径都经过本函数(见 channel_polls.go
if err := s.populateChannelMessagesPolls(ctx, db, viewerUserID, messages); err != nil {
return err
}
if !unreadAlreadyProjected {
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil {
return err
}
}
channelsByID := make(map[int64]domain.Channel, len(channels))
for _, ch := range channels {
if ch.ID != 0 {
channelsByID[ch.ID] = ch
}
}
indexes := make(map[channelReactionMessageKey][]int)
idsByChannel := make(map[int64][]int32)
for i := range messages {
if messages[i].ChannelID == 0 || messages[i].ID <= 0 {
continue
}
if reactionEligible != nil && !reactionEligible(messages[i]) {
continue
}
key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID}
if _, ok := indexes[key]; !ok {
idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID))
}
indexes[key] = append(indexes[key], i)
}
if len(idsByChannel) == 0 {
return nil
}
// 缺失的频道元数据一次批量补齐emptyChannelMessageReactions 需要 Broadcast/Megagroup
// 替代原来「每个未知频道一条 getChannelByID」的 N+1。
var missing []int64
for channelID := range idsByChannel {
if _, ok := channelsByID[channelID]; !ok {
missing = append(missing, channelID)
}
}
if len(missing) > 0 {
fetched, err := listChannelsByIDs(ctx, db, missing)
if err != nil {
return err
}
for _, ch := range fetched {
channelsByID[ch.ID] = ch
}
}
// 跨频道 (channel, message) 对recent 反应仅对「非广播或超级群」频道下发(广播 reaction
// 匿名,与官方一致只给计数,不暴露反应者身份)。
pairChannels, pairMessages := channelMessagePairs(idsByChannel)
var recentChannels []int64
var recentMessages []int32
for channelID, ids := range idsByChannel {
ch := channelsByID[channelID]
if ch.Broadcast && !ch.Megagroup {
continue
}
for _, id := range ids {
recentChannels = append(recentChannels, channelID)
recentMessages = append(recentMessages, id)
}
}
// 1) 反应计数:跨频道一次 GROUP BY。
countRows, err := db.Query(ctx, `
SELECT channel_id, message_id, reaction_type, reaction_value, COUNT(*)::int,
COALESCE(MAX(CASE WHEN reacted_user_id = $1 THEN chosen_order ELSE 0 END), 0)::int,
COALESCE(MAX(reaction_date), 0)::int
FROM channel_message_reactions
WHERE (channel_id, message_id) IN (SELECT * FROM unnest($2::bigint[], $3::int[]))
GROUP BY channel_id, message_id, reaction_type, reaction_value
ORDER BY channel_id ASC, message_id ASC, COUNT(*) DESC, COALESCE(MAX(reaction_date), 0) DESC, reaction_type ASC, reaction_value ASC`, viewerUserID, pairChannels, pairMessages)
if err != nil {
return fmt.Errorf("load channel message reaction counts: %w", err)
}
for countRows.Next() {
var channelID int64
var msgID int
var reactionType, reactionValue string
var count, chosenOrder, latestDate int
if err := countRows.Scan(&channelID, &msgID, &reactionType, &reactionValue, &count, &chosenOrder, &latestDate); err != nil {
countRows.Close()
return err
}
_ = latestDate
ch := channelsByID[channelID]
key := channelReactionMessageKey{channelID: channelID, messageID: msgID}
for _, idx := range indexes[key] {
if messages[idx].Reactions == nil {
reactions := emptyChannelMessageReactions(ch)
messages[idx].Reactions = &reactions
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
continue
}
messages[idx].Reactions.Results = append(messages[idx].Reactions.Results, domain.ChannelMessageReactionCount{
Reaction: reaction,
Count: count,
ChosenOrder: chosenOrder,
})
}
}
if err := countRows.Err(); err != nil {
countRows.Close()
return err
}
countRows.Close()
// 2) recent 反应(仅超级群/非广播):跨频道一次 window 查询PARTITION BY (channel,message)。
if len(recentChannels) > 0 {
recentRows, err := db.Query(ctx, `
SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value,
big, unread, chosen_order, reaction_date
FROM (
SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value,
big, unread, chosen_order, reaction_date,
row_number() OVER (
PARTITION BY channel_id, message_id
ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_type ASC, reaction_value ASC
) AS rn
FROM channel_message_reactions
WHERE (channel_id, message_id) IN (SELECT * FROM unnest($1::bigint[], $2::int[]))
) ranked
WHERE rn <= $3
ORDER BY channel_id ASC, message_id ASC, reaction_date DESC, reacted_user_id DESC, reaction_type ASC, reaction_value ASC`, recentChannels, recentMessages, domain.MaxChannelMessageReactionRecent)
if err != nil {
return fmt.Errorf("load channel message recent reactions: %w", err)
}
for recentRows.Next() {
row, err := scanChannelMessagePeerReaction(recentRows, viewerUserID)
if err != nil {
recentRows.Close()
return err
}
ch := channelsByID[row.ChannelID]
key := channelReactionMessageKey{channelID: row.ChannelID, messageID: row.MessageID}
for _, idx := range indexes[key] {
if messages[idx].Reactions == nil {
reactions := emptyChannelMessageReactions(ch)
messages[idx].Reactions = &reactions
}
messages[idx].Reactions.Recent = append(messages[idx].Reactions.Recent, row)
}
}
if err := recentRows.Err(); err != nil {
recentRows.Close()
return err
}
recentRows.Close()
}
return nil
}
// populateChannelDialogTopMessageReactions keeps poll and unread-mention
// enrichment exact for every message, but uses the shared top-message
// existence cache to avoid querying three reaction tables when no reaction row
// can possibly contribute to the viewer projection.
func (s *ChannelStore) populateChannelDialogTopMessageReactions(
ctx context.Context,
db sqlcgen.DBTX,
viewerUserID int64,
channels []domain.Channel,
messages []domain.ChannelMessage,
unreadAlreadyProjected bool,
) error {
if !s.topMessageCacheActive(db) || len(messages) == 0 {
return s.populateChannelMessagesReactions(ctx, db, viewerUserID, channels, messages)
}
presence, err := s.topMsgCache.reactionPresenceFor(ctx, db, messages)
if err != nil {
return fmt.Errorf("load channel top reaction presence: %w", err)
}
return s.populateChannelMessagesReactionsWhere(ctx, db, viewerUserID, channels, messages, func(msg domain.ChannelMessage) bool {
return presence[channelMessageLookupKey{channelID: msg.ChannelID, id: msg.ID}].any()
}, unreadAlreadyProjected)
}
func channelReactionOffset(row domain.ChannelMessagePeerReaction) string {
return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + string(row.Reaction.Type) + ":" + row.Reaction.Value()
}
func parseChannelReactionOffset(offset string) (channelReactionCursor, bool) {
parts := strings.SplitN(offset, ":", 4)
if len(parts) != 3 && len(parts) != 4 {
return channelReactionCursor{}, false
}
date, err := strconv.Atoi(parts[0])
if err != nil || date < 0 {
return channelReactionCursor{}, false
}
userID, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || userID < 0 {
return channelReactionCursor{}, false
}
if len(parts) == 3 {
return channelReactionCursor{date: date, userID: userID, value: parts[2], legacyValue: true}, true
}
return channelReactionCursor{date: date, userID: userID, reactionType: domain.MessageReactionType(parts[2]), value: parts[3]}, true
}
func scanChannelMessagePeerReaction(row rowScanner, viewerUserID int64) (domain.ChannelMessagePeerReaction, error) {
var out domain.ChannelMessagePeerReaction
var reactionType, reactionValue string
if err := row.Scan(
&out.ChannelID,
&out.MessageID,
&out.UserID,
&out.SenderUserID,
&reactionType,
&reactionValue,
&out.Big,
&out.Unread,
&out.ChosenOrder,
&out.Date,
); err != nil {
return domain.ChannelMessagePeerReaction{}, err
}
out.My = out.UserID == viewerUserID
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
return domain.ChannelMessagePeerReaction{}, fmt.Errorf("invalid channel message reaction value")
}
out.Reaction = reaction
return out, nil
}