264 lines
9.9 KiB
Go
264 lines
9.9 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
|
||
"github.com/gotd/td/tg"
|
||
"github.com/gotd/td/tgerr"
|
||
"go.uber.org/zap"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
// 本文件是 poll 链路的响应/推送/事件辅助,与 reaction(messages_reactions_send.go)同构:
|
||
// - 私聊:双方各记一条 message_poll durable event(无 pts 的 updateMessagePoll + aux pts 簿记),
|
||
// 离线端经 getDifference 拿到消息快照与最新 poll 状态;
|
||
// - 频道:实时 fan-out 给在线 viewer(作者进 explicit 收件人),无 durable event——
|
||
// 与 channel reaction 现状一致(离线缺口见 compatibility-matrix.md,客户端靠 getPollResults 刷新)。
|
||
|
||
// pollMutationErr 把 poll/消息域错误映射为 RPC error。
|
||
func pollMutationErr(err error) error {
|
||
switch {
|
||
case err == nil:
|
||
return nil
|
||
case errors.Is(err, domain.ErrPollClosed):
|
||
return pollClosedErr()
|
||
case errors.Is(err, domain.ErrPollRevoteNotAllowed):
|
||
return revoteNotAllowedErr()
|
||
case errors.Is(err, domain.ErrPollOptionInvalid):
|
||
return optionInvalidErr()
|
||
case errors.Is(err, domain.ErrPollNotCreator):
|
||
return tgerr.New(403, "MESSAGE_AUTHOR_REQUIRED")
|
||
case errors.Is(err, domain.ErrPollNotFound), errors.Is(err, domain.ErrPollInvalid), errors.Is(err, domain.ErrMessageIDInvalid):
|
||
return messageIDInvalidErr()
|
||
default:
|
||
return channelInvalidErr(err)
|
||
}
|
||
}
|
||
|
||
// loadMessagePoll 按 viewer 视角加载消息上的 poll(已 enrich)。
|
||
func (r *Router) loadMessagePoll(ctx context.Context, userID int64, peer domain.Peer, msgID int) (*domain.MessagePoll, domain.Peer, bool) {
|
||
switch peer.Type {
|
||
case domain.PeerTypeChannel:
|
||
if r.deps.Channels == nil {
|
||
return nil, peer, false
|
||
}
|
||
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID})
|
||
if err != nil {
|
||
return nil, peer, false
|
||
}
|
||
for _, msg := range history.Messages {
|
||
if msg.ID == msgID && msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindPoll && msg.Media.Poll != nil {
|
||
return msg.Media.Poll, peer, true
|
||
}
|
||
}
|
||
case domain.PeerTypeUser:
|
||
if r.deps.Messages == nil {
|
||
return nil, peer, false
|
||
}
|
||
list, err := r.deps.Messages.GetMessages(ctx, userID, []int{msgID})
|
||
if err != nil {
|
||
return nil, peer, false
|
||
}
|
||
for _, msg := range list.Messages {
|
||
if msg.ID == msgID && msg.Peer == peer && msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindPoll && msg.Media.Poll != nil {
|
||
return msg.Media.Poll, peer, true
|
||
}
|
||
}
|
||
}
|
||
return nil, peer, false
|
||
}
|
||
|
||
// pollUpdateRefs 收集 updateMessagePoll 响应需要的 users/chats(recent voters 头像 + channel 实体)。
|
||
// channel 由调用方传入(频道 poll 用结果里的 res.Channel;私聊传零值):与 reaction fan-out 同款用
|
||
// 单个 channel 一次性投影 tgChannels,避免频道 poll fan-out 每 viewer 一次 GetChannel 的 DB N+1
|
||
// (poll 聚合 N+1 已由 ChannelPollFanoutViews 消除,这是 poll fan-out 残留的 per-viewer DB 调用)。
|
||
func (r *Router) pollUpdateRefs(ctx context.Context, viewerUserID int64, peer domain.Peer, poll *domain.MessagePoll, channel domain.Channel) ([]tg.UserClass, []tg.ChatClass) {
|
||
userIDs := make([]int64, 0, domain.MaxPollRecentVoters)
|
||
if poll != nil && poll.Results != nil {
|
||
userIDs = append(userIDs, poll.Results.RecentVoters...)
|
||
}
|
||
users := r.tgUsersForIDs(ctx, viewerUserID, userIDs)
|
||
chats := []tg.ChatClass{}
|
||
if peer.Type == domain.PeerTypeChannel {
|
||
if channel.ID != 0 {
|
||
// fan-out 路径:调用方已带 channel,一次性投影(同 reaction),免 per-viewer GetChannel。
|
||
chats = tgChannels(viewerUserID, []domain.Channel{channel})
|
||
} else if r.deps.Channels != nil {
|
||
// 非 fan-out 单 viewer 路径(getPollResults):channel 未带,保持原 GetChannel 行为不变。
|
||
if view, err := r.deps.Channels.GetChannel(ctx, viewerUserID, peer.ID); err == nil && view.Channel.ID != 0 {
|
||
chats = []tg.ChatClass{tgChannelChatForView(viewerUserID, view)}
|
||
}
|
||
}
|
||
}
|
||
return users, chats
|
||
}
|
||
|
||
// privatePollUpdates 记录双方 durable event、推送双方在线 session,并返回投票者视角响应。
|
||
func (r *Router) privatePollUpdates(ctx context.Context, requestUserID int64, res domain.PrivateMessagePollResult) tg.UpdatesClass {
|
||
recordedEvents := r.recordPrivateMessagePollEvents(ctx, requestUserID, res)
|
||
var requesterUpdates *tg.Updates
|
||
for _, msg := range res.Messages {
|
||
if msg.ID <= 0 || msg.Media == nil || msg.Media.Poll == nil {
|
||
continue
|
||
}
|
||
update := tgUpdateMessagePoll(msg.Peer, msg.ID, msg.Media.Poll)
|
||
if update == nil {
|
||
continue
|
||
}
|
||
users, chats := r.pollUpdateRefs(ctx, msg.OwnerUserID, msg.Peer, msg.Media.Poll, domain.Channel{})
|
||
updates := &tg.Updates{
|
||
Updates: []tg.UpdateClass{update},
|
||
Users: users,
|
||
Chats: chats,
|
||
Date: int(r.clock.Now().Unix()),
|
||
}
|
||
// message_poll 事件占账号 pts 但 updateMessagePoll 无 pts 字段,附 aux 簿记推水位。
|
||
updates.Updates = appendAuxPtsBookkeeping(updates.Updates, recordedEvents[msg.OwnerUserID])
|
||
if msg.OwnerUserID == requestUserID {
|
||
requesterUpdates = updates
|
||
}
|
||
r.pushUserUpdates(ctx, msg.OwnerUserID, updates)
|
||
}
|
||
if requesterUpdates == nil {
|
||
return tgEmptyUpdates(int(r.clock.Now().Unix()))
|
||
}
|
||
return requesterUpdates
|
||
}
|
||
|
||
func (r *Router) recordPrivateMessagePollEvents(ctx context.Context, requestUserID int64, res domain.PrivateMessagePollResult) map[int64]domain.UpdateEvent {
|
||
if r.deps.Updates == nil {
|
||
return nil
|
||
}
|
||
recorder, ok := r.deps.Updates.(messagePollUpdateRecorder)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||
events := make(map[int64]domain.UpdateEvent, len(res.Messages))
|
||
for _, msg := range res.Messages {
|
||
if msg.OwnerUserID == 0 || msg.ID == 0 {
|
||
continue
|
||
}
|
||
eventAuthKeyID := [8]byte{}
|
||
if msg.OwnerUserID == requestUserID {
|
||
eventAuthKeyID = authKeyID
|
||
}
|
||
event, _, err := recorder.RecordMessagePoll(ctx, eventAuthKeyID, msg.OwnerUserID, msg)
|
||
if err != nil {
|
||
r.log.Warn("record message poll event failed")
|
||
continue
|
||
}
|
||
events[msg.OwnerUserID] = event
|
||
}
|
||
return events
|
||
}
|
||
|
||
// onEditMessageClosePoll 处理 editMessage + InputMediaPoll:当前唯一支持的 poll 编辑是
|
||
// 关闭(closed=true,仅 poll 创建者);改题/改选项无官方客户端路径,显式拒绝。
|
||
func (r *Router) onEditMessageClosePoll(ctx context.Context, req *tg.MessagesEditMessageRequest, media *tg.InputMediaPoll) (tg.UpdatesClass, error) {
|
||
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
if !media.Poll.Closed {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
userID, peer, err := r.reactionPeer(ctx, req.Peer, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
switch peer.Type {
|
||
case domain.PeerTypeChannel:
|
||
if r.deps.Channels == nil {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
res, err := r.deps.Channels.CloseMessagePoll(ctx, userID, domain.CloseChannelMessagePollRequest{
|
||
UserID: userID,
|
||
ChannelID: peer.ID,
|
||
MessageID: req.ID,
|
||
Date: now,
|
||
})
|
||
if err != nil {
|
||
return nil, pollMutationErr(err)
|
||
}
|
||
return r.channelPollUpdates(ctx, userID, peer, req.ID, res, true), nil
|
||
case domain.PeerTypeUser:
|
||
if r.deps.Messages == nil {
|
||
return nil, messageIDInvalidErr()
|
||
}
|
||
res, err := r.deps.Messages.CloseMessagePoll(ctx, userID, domain.ClosePrivateMessagePollRequest{
|
||
UserID: userID,
|
||
Peer: peer,
|
||
MessageID: req.ID,
|
||
Date: now,
|
||
})
|
||
if err != nil {
|
||
return nil, pollMutationErr(err)
|
||
}
|
||
return r.privatePollUpdates(ctx, userID, res), nil
|
||
default:
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
}
|
||
|
||
// channelPollUpdates 组装投票者视角响应;push 为 true 时按 viewer 重建并 fan-out 给在线成员。
|
||
//
|
||
// Phase 4 模板化:fan-out 前一次性批量加载所有收件人的 per-viewer poll 投影(ChannelPollFanoutViews
|
||
// 把 viewer-invariant 聚合只算一次 + 批量 viewerOptions/可见性),消除原先每 viewer 一次 GetMessages
|
||
// 的 N+1。dispatch 仍同步(poll 是 viewer-only 无 durable event,改异步丢队列即永久漏)。预取未覆盖
|
||
// 的 viewer 回退逐 viewer loadMessagePoll 保正确(与旧路径字节同源)。actor echo 仍用 res poll(旧行为)。
|
||
func (r *Router) channelPollUpdates(ctx context.Context, userID int64, peer domain.Peer, msgID int, res domain.ChannelMessagePollResult, push bool) tg.UpdatesClass {
|
||
var batched map[int64]*domain.MessagePoll
|
||
if push && peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil {
|
||
recipients := r.channelFanoutRecipients(ctx, channelFanoutViewers, res.Channel.ID, res.Recipients)
|
||
if len(recipients) > 0 {
|
||
views, err := r.deps.Channels.ChannelPollFanoutViews(ctx, res.Channel.ID, msgID, recipients, int(r.clock.Now().Unix()))
|
||
if err != nil {
|
||
r.log.Warn("channel poll fanout prefetch failed; falling back to per-viewer reload",
|
||
zap.Int64("channel_id", res.Channel.ID), zap.Int("msg_id", msgID), zap.Error(err))
|
||
} else {
|
||
batched = views
|
||
}
|
||
}
|
||
}
|
||
build := func(viewerUserID int64) *tg.Updates {
|
||
poll := res.Message.Media.Poll
|
||
if viewerUserID != userID {
|
||
// 其它 viewer 的 chosen/correct/solution 门控不同,按其视角取投影。
|
||
if p, evaluated := batched[viewerUserID]; evaluated {
|
||
if p == nil {
|
||
return nil // 预取已判定该 viewer 不可见
|
||
}
|
||
poll = p
|
||
} else {
|
||
// 预取未覆盖(极少:并发/收件人集漂移)→ 回退逐 viewer 重载。
|
||
reloaded, _, found := r.loadMessagePoll(ctx, viewerUserID, peer, msgID)
|
||
if !found {
|
||
return nil
|
||
}
|
||
poll = reloaded
|
||
}
|
||
}
|
||
update := tgUpdateMessagePoll(peer, msgID, poll)
|
||
if update == nil {
|
||
return nil
|
||
}
|
||
users, chats := r.pollUpdateRefs(ctx, viewerUserID, peer, poll, res.Channel)
|
||
return &tg.Updates{
|
||
Updates: []tg.UpdateClass{update},
|
||
Users: users,
|
||
Chats: chats,
|
||
Date: int(r.clock.Now().Unix()),
|
||
}
|
||
}
|
||
updates := build(userID)
|
||
if push {
|
||
r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, res.Recipients, build)
|
||
}
|
||
if updates == nil {
|
||
return tgEmptyUpdates(int(r.clock.Now().Unix()))
|
||
}
|
||
return updates
|
||
}
|