package postgres import ( "context" "fmt" "strings" "telesrv/internal/domain" ) func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } for _, reaction := range req.Reactions { if !reaction.Valid() { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } } req.Reactions = domain.TrimMessageReactionsToUserMax(req.Reactions, req.ReactionsPerUserMax) if req.Date <= 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("set channel message reactions: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin set channel message reactions: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, _, err := s.getChannelForViewer(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if len(req.Reactions) > 0 { selfBoostsApplied := 0 if channel.Megagroup { selfBoostsApplied, err = countActiveUserBoostsForPeer(ctx, tx, req.UserID, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, req.Date) if err != nil { return domain.ChannelMessageReactionsResult{}, err } } if domain.ChannelBannedRightsBlockReactions(channel, member, selfBoostsApplied) { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelWriteForbidden } } msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID || !channelMessageVisibleToViewer(channel, member, req.UserID, msg) { return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid } // 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。 for _, reaction := range req.Reactions { if !channel.ReactionPolicy.AllowsReaction(reaction) { return domain.ChannelMessageReactionsResult{}, domain.ErrReactionInvalid } } if len(req.Reactions) > 0 { // READ COMMITTED 下并发新增不同新种类互不可见、会同时通过去重闸门, // 事务级 advisory lock 按 (channel, message) 串行化带闸门的写入(独立于 // lockUsersForUpdate 的单参数键空间);撤销不经闸门,无需上锁。 if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashint8($1::bigint), $2::int)`, req.ChannelID, req.MessageID); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("advisory lock channel message reactions: %w", err) } // 官方 REACTIONS_TOO_MANY 只挡「引入消息上尚不存在的新种类」:存量已超限 //(管理员调低 reactions_limit / 部署前数据)时,重发自己的 reaction 或给 // 已有种类投票必须放行,否则客户端点击合法 chip 也会收到 400。 existing := make(map[string]struct{}) final := make(map[string]struct{}) rows, err := tx.Query(ctx, ` SELECT reaction_type, reaction_value, BOOL_OR(reacted_user_id <> $3) FROM channel_message_reactions WHERE channel_id = $1 AND message_id = $2 GROUP BY reaction_type, reaction_value`, req.ChannelID, req.MessageID, req.UserID) if err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("list channel message reaction values: %w", err) } for rows.Next() { var reactionType, value string var byOthers bool if err := rows.Scan(&reactionType, &value, &byOthers); err != nil { rows.Close() return domain.ChannelMessageReactionsResult{}, err } key := string(reactionType) + "\x00" + value existing[key] = struct{}{} if byOthers { final[key] = struct{}{} } } if err := rows.Err(); err != nil { rows.Close() return domain.ChannelMessageReactionsResult{}, err } rows.Close() newKind := false for _, reaction := range req.Reactions { key := reaction.Key() if _, ok := existing[key]; !ok { newKind = true } final[key] = struct{}{} } if newKind && len(final) > channel.ReactionPolicy.UniqueReactionsLimit() { return domain.ChannelMessageReactionsResult{}, domain.ErrReactionsTooMany } } if _, err := tx.Exec(ctx, ` DELETE FROM channel_message_reactions WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, req.ChannelID, req.MessageID, req.UserID); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel message reactions: %w", err) } // 广播频道 reaction 匿名(官方语义),作者不收 unread 角标,不写 unread 簿记。 unreadEligible := !channel.Broadcast || channel.Megagroup for i, reaction := range req.Reactions { if _, err := tx.Exec(ctx, ` INSERT INTO channel_message_reactions ( channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, big, unread, chosen_order, reaction_date ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ChannelID, req.MessageID, req.UserID, msg.SenderUserID, string(reaction.Type), reaction.Value(), req.Big, unreadEligible && msg.SenderUserID != 0 && msg.SenderUserID != req.UserID, i+1, req.Date); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("insert channel message reaction: %w", err) } if req.AddToRecent { if _, err := tx.Exec(ctx, ` INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, req.UserID, string(reaction.Type), reaction.Value(), req.Date); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert recent message reaction: %w", err) } } if _, err := tx.Exec(ctx, ` INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) VALUES ($1,$2,$3,1,$4) ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, req.UserID, string(reaction.Type), reaction.Value(), req.Date); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert top message reaction: %w", err) } } if unreadEligible { if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { return domain.ChannelMessageReactionsResult{}, err } } if err := tx.Commit(ctx); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit set channel message reactions: %w", err) } committed = true messages := []domain.ChannelMessage{msg} if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { return domain.ChannelMessageReactionsResult{}, err } msg = messages[0] reactions := emptyChannelMessageReactions(channel) if msg.Reactions != nil { reactions = *msg.Reactions } else { msg.Reactions = &reactions } // sendReaction 实时推送走在线 viewer scope(rpc 层封顶),不预热全量成员列表。 return domain.ChannelMessageReactionsResult{ Channel: channel, Message: msg, Messages: []domain.ChannelMessage{msg}, Reactions: reactions, Recipients: []int64{req.UserID}, }, nil } func (s *ChannelStore) DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel participant reaction: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin delete channel participant reaction: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if !canDeleteAnyChannelMessage(member) { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelAdminRequired } msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if msg.Deleted || msg.ID <= member.AvailableMinID { return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid } if _, err := tx.Exec(ctx, ` DELETE FROM channel_message_reactions WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, req.ChannelID, req.MessageID, req.ParticipantUserID); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete participant reaction: %w", err) } if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { return domain.ChannelMessageReactionsResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit delete participant reaction: %w", err) } committed = true messages := []domain.ChannelMessage{msg} if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { return domain.ChannelMessageReactionsResult{}, err } msg = messages[0] reactions := emptyChannelMessageReactions(channel) if msg.Reactions != nil { reactions = *msg.Reactions } else { msg.Reactions = &reactions } recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) if err != nil { recipients = []int64{req.UserID} } return domain.ChannelMessageReactionsResult{ Channel: channel, Message: msg, Messages: []domain.ChannelMessage{msg}, Reactions: reactions, Recipients: recipients, }, nil } func (s *ChannelStore) DeleteChannelParticipantReactions(ctx context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid } if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { req.Limit = domain.MaxDeleteParticipantReactionsBatch } beginner, ok := s.db.(txBeginner) if !ok { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete channel participant reactions: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("begin delete channel participant reactions: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.DeleteChannelParticipantReactionsResult{}, err } if !canDeleteAnyChannelMessage(member) { return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelAdminRequired } rows, err := tx.Query(ctx, ` SELECT message_id, MAX(sender_user_id) FROM channel_message_reactions WHERE channel_id = $1 AND reacted_user_id = $2 GROUP BY message_id ORDER BY MAX(reaction_date) DESC, message_id DESC LIMIT $3`, req.ChannelID, req.ParticipantUserID, req.Limit) if err != nil { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("list participant reaction messages: %w", err) } ids := make([]int, 0, req.Limit) owners := make(map[int64]struct{}) for rows.Next() { var msgID int var senderUserID int64 if err := rows.Scan(&msgID, &senderUserID); err != nil { rows.Close() return domain.DeleteChannelParticipantReactionsResult{}, err } ids = append(ids, msgID) if senderUserID != 0 { owners[senderUserID] = struct{}{} } } if err := rows.Err(); err != nil { rows.Close() return domain.DeleteChannelParticipantReactionsResult{}, err } rows.Close() if len(ids) > 0 { if _, err := tx.Exec(ctx, ` DELETE FROM channel_message_reactions WHERE channel_id = $1 AND reacted_user_id = $2 AND message_id = ANY($3::int[])`, req.ChannelID, req.ParticipantUserID, int32s(ids)); err != nil { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete participant reactions: %w", err) } for ownerID := range owners { if err := refreshChannelUnreadReactionsCountTx(ctx, tx, ownerID, req.ChannelID); err != nil { return domain.DeleteChannelParticipantReactionsResult{}, err } } } if err := tx.Commit(ctx); err != nil { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("commit delete participant reactions: %w", err) } committed = true messages := []domain.ChannelMessage{} if len(ids) > 0 { res, err := s.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ UserID: req.UserID, ChannelID: req.ChannelID, IDs: ids, }) if err != nil { return domain.DeleteChannelParticipantReactionsResult{}, err } messages = res.Messages } recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) if err != nil { recipients = []int64{req.UserID} } return domain.DeleteChannelParticipantReactionsResult{ Channel: channel, Messages: messages, Recipients: recipients, Deleted: len(ids), }, nil } func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } if len(req.IDs) > domain.MaxGetMessageIDs { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if len(req.IDs) == 0 { return domain.ChannelMessageReactionsResult{Channel: channel}, nil } id32, _, err := validUniqueChannelMessageIDs(req.IDs) if err != nil { return domain.ChannelMessageReactionsResult{}, err } args := []any{req.ChannelID, id32} where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" if member.AvailableMinID > 0 { args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } if channel.Monoforum && !member.CanManageDirectMessages() { args = append(args, string(domain.PeerTypeUser), req.UserID) where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args)) } rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+` ORDER BY id DESC`, args...) if err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("get channel message reactions messages: %w", err) } defer rows.Close() messages := make([]domain.ChannelMessage, 0, len(req.IDs)) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return domain.ChannelMessageReactionsResult{}, err } messages = append(messages, msg) } if err := rows.Err(); err != nil { return domain.ChannelMessageReactionsResult{}, err } if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { return domain.ChannelMessageReactionsResult{}, err } res := domain.ChannelMessageReactionsResult{Channel: channel, Messages: messages} if len(messages) == 1 { res.Message = messages[0] res.Reactions = emptyChannelMessageReactions(channel) if messages[0].Reactions != nil { res.Reactions = *messages[0].Reactions } } return res, nil } func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid } if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { req.Limit = domain.MaxChannelMessageReactionListLimit } channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsList{}, err } if channel.Broadcast && !channel.Megagroup { return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden } msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelMessageReactionsList{}, err } if msg.Deleted || msg.ID <= member.AvailableMinID || !channelMessageVisibleToViewer(channel, member, req.UserID, msg) { return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid } baseWhere := []string{"channel_id = $1", "message_id = $2"} baseArgs := []any{req.ChannelID, req.MessageID} if req.Reaction != nil { if !req.Reaction.Valid() { return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid } baseArgs = append(baseArgs, string(req.Reaction.Type), req.Reaction.Value()) baseWhere = append(baseWhere, fmt.Sprintf("reaction_type = $%d AND reaction_value = $%d", len(baseArgs)-1, len(baseArgs))) } var count int if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_message_reactions WHERE `+strings.Join(baseWhere, " AND "), baseArgs...).Scan(&count); err != nil { return domain.ChannelMessageReactionsList{}, fmt.Errorf("count channel message reactions: %w", err) } where := append([]string(nil), baseWhere...) args := append([]any(nil), baseArgs...) if req.Offset != "" { cursor, ok := parseChannelReactionOffset(req.Offset) if !ok { return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid } if cursor.legacyValue { args = append(args, cursor.date, cursor.userID, cursor.value) n := len(args) where = append(where, fmt.Sprintf("(reaction_date < $%d OR (reaction_date = $%d AND (reacted_user_id < $%d OR (reacted_user_id = $%d AND reaction_value > $%d))))", n-2, n-2, n-1, n-1, n)) } else { args = append(args, cursor.date, cursor.userID, string(cursor.reactionType), cursor.value) n := len(args) where = append(where, fmt.Sprintf("(reaction_date < $%d OR (reaction_date = $%d AND (reacted_user_id < $%d OR (reacted_user_id = $%d AND (reaction_type > $%d OR (reaction_type = $%d AND reaction_value > $%d))))))", n-3, n-3, n-2, n-2, n-1, n-1, n)) } } args = append(args, req.Limit+1) rows, err := s.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 channel_message_reactions WHERE `+strings.Join(where, " AND ")+` ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_type ASC, reaction_value ASC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return domain.ChannelMessageReactionsList{}, fmt.Errorf("list channel message reactions: %w", err) } defer rows.Close() reactions := make([]domain.ChannelMessagePeerReaction, 0, req.Limit+1) for rows.Next() { row, err := scanChannelMessagePeerReaction(rows, req.UserID) if err != nil { return domain.ChannelMessageReactionsList{}, err } reactions = append(reactions, row) } if err := rows.Err(); err != nil { return domain.ChannelMessageReactionsList{}, err } next := "" if len(reactions) > req.Limit { reactions = reactions[:req.Limit] next = channelReactionOffset(reactions[len(reactions)-1]) } return domain.ChannelMessageReactionsList{ Channel: channel, Message: msg, Count: count, Reactions: reactions, NextOffset: next, }, nil } func (s *ChannelStore) FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) { if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 { return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid } channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.ViewerUserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionLookup{}, false, err } message, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelMessageReactionLookup{}, false, err } if message.Deleted || message.ID <= member.AvailableMinID || !channelMessageVisibleToViewer(channel, member, req.ViewerUserID, message) { return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid } rows, err := s.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 channel_message_reactions WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3 ORDER BY chosen_order, reaction_type, reaction_value LIMIT $4`, req.ChannelID, req.MessageID, req.ReactorUserID, domain.MaxChannelMessageReactionsPerUser) if err != nil { return domain.ChannelMessageReactionLookup{}, false, fmt.Errorf("find channel message reaction: %w", err) } defer rows.Close() reactions := make([]domain.ChannelMessagePeerReaction, 0, domain.MaxChannelMessageReactionsPerUser) for rows.Next() { reaction, err := scanChannelMessagePeerReaction(rows, req.ViewerUserID) if err != nil { return domain.ChannelMessageReactionLookup{}, false, err } reactions = append(reactions, reaction) } if err := rows.Err(); err != nil { return domain.ChannelMessageReactionLookup{}, false, err } return domain.ChannelMessageReactionLookup{ Channel: channel, Message: message, Reactions: reactions, }, len(reactions) > 0, nil }