package postgres import ( "context" "errors" "fmt" "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) { if userID == 0 || len(ids) == 0 { return domain.MessageList{}, nil } boxIDs := make([]int32, 0, len(ids)) for _, id := range ids { if id <= 0 || id > domain.MaxMessageBoxID { continue } boxIDs = append(boxIDs, int32(id)) } if len(boxIDs) == 0 { return domain.MessageList{}, nil } rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{ OwnerUserID: userID, BoxIds: boxIDs, }) if err != nil { return domain.MessageList{}, fmt.Errorf("get messages by ids: %w", err) } out := domain.MessageList{ Messages: make([]domain.Message, 0, len(rows)), Users: make([]domain.User, 0, len(rows)*2), } seenUsers := map[int64]struct{}{} for _, row := range rows { msg, err := messageFromIDRow(row) if err != nil { return domain.MessageList{}, err } out.Messages = append(out.Messages, msg) appendUsersFromMessageIDRow(&out, seenUsers, row) } if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { return domain.MessageList{}, err } out.Hash = messageListHash(out.Messages) return out, nil } func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { limit := filter.Limit if limit <= 0 { limit = 100 } if limit > 500 { limit = 500 } addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset) queryLimit := limit probeHasMore := !filter.NeedTotalCount && addOffset >= 0 if probeHasMore { queryLimit++ } savedPeerType := "" var savedPeerID int64 if filter.SavedPeer.ID != 0 { savedPeerType = string(filter.SavedPeer.Type) savedPeerID = filter.SavedPeer.ID } // add_offset>=0 是 backward 热路径(初始加载/上滑翻页,占 getHistory 绝大多数)。 // 走扁平静态查询 ListMessagesBackward:规划仅单 index scan + 2 LEFT JOIN,避免 // ListMessagesByUser 大 CTE 把 4 个分支+total 全树规划(6.7ms→~1ms)。与 CTE // 的 backward 分支逐位等价。around/forward(add_offset<0,锚点跳转,较罕见)仍走 // 原 CTE,逻辑零改动。total 在 backward 路径由 CountMessagesByUser 独立提供。 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, 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), }) if err != nil { return domain.MessageList{}, fmt.Errorf("list messages (backward): %w", err) } rows = make([]sqlcgen.ListMessagesByUserRow, len(bw)) for i := range bw { rows[i] = backwardRowToByUserRow(bw[i]) } 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, Query: filter.Query, MaxID: pgInt32NonNegative(filter.MaxID), MinID: pgInt32NonNegative(filter.MinID), PinnedOnly: filter.PinnedOnly, MusicOnly: filter.MusicOnly, SavedPeerType: savedPeerType, SavedPeerID: savedPeerID, }) if err != nil { return domain.MessageList{}, fmt.Errorf("count messages: %w", err) } // 镜像原 CTE 的 CROSS JOIN total 语义:total_count 只随结果行下发, // paged 为空时不产出(out.Count 保持 0),故仅在有行时附着。 for i := range rows { rows[i].TotalCount = total } } } 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, 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, }) if err != nil { return domain.MessageList{}, fmt.Errorf("list messages: %w", err) } } hasMore := false if probeHasMore && len(rows) > limit { hasMore = true rows = rows[:limit] } out := domain.MessageList{ Messages: make([]domain.Message, 0, len(rows)), Users: make([]domain.User, 0, len(rows)*2), } seenUsers := map[int64]struct{}{} for _, row := range rows { entities, err := decodeMessageEntities(row.EntitiesJson) if err != nil { return domain.MessageList{}, fmt.Errorf("decode message entities: %w", err) } silent, noforwards, reply, forward, err := messageMetadataFromFields( row.Silent, row.Noforwards, row.ReplyToMsgID, row.ReplyToPeerType, row.ReplyToPeerID, row.ReplyToTopID, row.ReplyToStoryID, row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, row.FwdDate, row.FwdSavedFromPeerType, row.FwdSavedFromPeerID, row.FwdSavedFromMsgID, ) if err != nil { return domain.MessageList{}, fmt.Errorf("decode message metadata: %w", err) } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return domain.MessageList{}, fmt.Errorf("decode message media: %w", err) } markup, err := decodeReplyMarkup(row.ReplyMarkupJson) if err != nil { return domain.MessageList{}, fmt.Errorf("decode message reply markup: %w", err) } rich, err := decodeRichMessage(row.RichMessageJson) if err != nil { return domain.MessageList{}, fmt.Errorf("decode message rich message: %w", err) } out.Messages = append(out.Messages, domain.Message{ ID: int(row.BoxID), UID: row.PrivateMessageID, OwnerUserID: row.OwnerUserID, Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, Date: int(row.MessageDate), EditDate: int(row.EditDate), HideEdited: row.HideEdited, Out: row.Outgoing, Silent: silent, NoForwards: noforwards, Body: row.Body, Entities: entities, ReplyTo: reply, Forward: forward, Pts: int(row.Pts), TTLPeriod: int(row.TtlPeriod), ExpiresAt: int(row.ExpiresAt), Media: media, ReplyMarkup: markup, RichMessage: rich, MediaUnread: row.MediaUnread, ReactionUnread: row.ReactionUnread, ViaBotID: row.ViaBotID, GroupedID: row.GroupedID, Effect: row.Effect, Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), }) if filter.NeedTotalCount && out.Count == 0 { out.Count = int(row.TotalCount) } appendUserFromMessageRow(&out, seenUsers, row) } if !filter.NeedTotalCount { out.Count = len(out.Messages) if hasMore { out.Count++ } } if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { return domain.MessageList{}, err } out.Hash = messageListHash(out.Messages) return out, nil } 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 { return res, fmt.Errorf("read history: missing owner user id") } if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { return res, fmt.Errorf("read history: invalid peer") } if req.Date == 0 { req.Date = int(time.Now().Unix()) } beginner, ok := s.db.(txBeginner) if !ok { return res, fmt.Errorf("read history: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return res, fmt.Errorf("begin read history tx: %w", err) } qtx := sqlcgen.New(tx) committed := false defer func() { if committed { return } _ = tx.Rollback(ctx) }() // advisory lock 串行化与会话对端的并发写(peer 即私聊另一方 / 回执 sender),须在行锁前获取。 if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { return res, fmt.Errorf("lock read history users: %w", err) } state, err := qtx.GetDialogReadStateForUpdate(ctx, sqlcgen.GetDialogReadStateForUpdateParams{ UserID: req.OwnerUserID, PeerType: string(req.Peer.Type), PeerID: req.Peer.ID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return res, nil } return res, fmt.Errorf("get dialog read state: %w", err) } oldRead := int(state.ReadInboxMaxID) maxAllocated, err := maxMessageBoxIDForDialog(ctx, tx, req.OwnerUserID, req.Peer) if err != nil { return res, fmt.Errorf("load dialog max box id: %w", err) } clampedFutureRead := false if oldRead > maxAllocated { if err := clampDialogReadInboxToMaxBox(ctx, tx, req.OwnerUserID, req.Peer, maxAllocated); err != nil { return res, fmt.Errorf("clamp dialog read inbox: %w", err) } oldRead = maxAllocated clampedFutureRead = true res.StillUnreadCount = 0 } readMax := req.MaxID if readMax <= 0 { readMax = int(state.TopMessageID) } if readMax > int(state.TopMessageID) { readMax = int(state.TopMessageID) } res.MaxID = readMax advancesRead := readMax > oldRead if !advancesRead { if int(state.UnreadCount) > 0 { updated, err := qtx.UpdateDialogReadInbox(ctx, sqlcgen.UpdateDialogReadInboxParams{ UserID: req.OwnerUserID, PeerType: string(req.Peer.Type), PeerID: req.Peer.ID, ReadInboxMaxID: int32(readMax), }) if err != nil { return res, fmt.Errorf("repair stale dialog unread count: %w", err) } res.MaxID = int(updated.ReadInboxMaxID) res.StillUnreadCount = int(updated.UnreadCount) if err := tx.Commit(ctx); err != nil { return res, fmt.Errorf("commit read history repair tx: %w", err) } committed = true } else if clampedFutureRead { if err := tx.Commit(ctx); err != nil { return res, fmt.Errorf("commit read history clamp tx: %w", err) } committed = true } return res, nil } candidate, candidateErr := qtx.LatestIncomingReadReceiptCandidate(ctx, sqlcgen.LatestIncomingReadReceiptCandidateParams{ OwnerUserID: req.OwnerUserID, PeerType: string(req.Peer.Type), PeerID: req.Peer.ID, OldReadInboxMaxID: int32(oldRead), NewReadInboxMaxID: int32(readMax), }) if candidateErr != nil && !errors.Is(candidateErr, pgx.ErrNoRows) { return res, fmt.Errorf("load read receipt candidate: %w", candidateErr) } updated, err := qtx.UpdateDialogReadInbox(ctx, sqlcgen.UpdateDialogReadInboxParams{ UserID: req.OwnerUserID, PeerType: string(req.Peer.Type), PeerID: req.Peer.ID, ReadInboxMaxID: int32(readMax), }) if err != nil { return res, fmt.Errorf("update dialog read inbox: %w", err) } readerPts, err := s.reservePts(ctx, tx, req.OwnerUserID) if err != nil { return res, fmt.Errorf("allocate read history pts: %w", err) } res.Changed = true res.MaxID = int(updated.ReadInboxMaxID) res.StillUnreadCount = int(updated.UnreadCount) res.InboxEvent = domain.UpdateEvent{ UserID: req.OwnerUserID, Type: domain.UpdateEventReadHistoryInbox, Pts: readerPts, PtsCount: 1, Date: req.Date, Peer: req.Peer, MaxID: res.MaxID, StillUnreadCount: res.StillUnreadCount, } if err := appendUserUpdateEvent(ctx, tx, qtx, req.OwnerUserID, res.InboxEvent); err != nil { return res, fmt.Errorf("append read inbox event: %w", err) } if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ TargetUserID: req.OwnerUserID, Pts: int32(readerPts), EventType: string(domain.UpdateEventReadHistoryInbox), ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), ExcludeSessionID: req.OriginSessionID, }); err != nil { return res, fmt.Errorf("enqueue read inbox dispatch: %w", err) } if candidateErr == nil && candidate.SenderOwnerUserID != 0 && int(candidate.SenderBoxID) > 0 { if _, err := qtx.UpdateDialogReadOutbox(ctx, sqlcgen.UpdateDialogReadOutboxParams{ UserID: candidate.SenderOwnerUserID, PeerType: string(domain.PeerTypeUser), PeerID: req.OwnerUserID, ReadOutboxMaxID: candidate.SenderBoxID, }); err == nil { senderPts, err := s.reservePts(ctx, tx, candidate.SenderOwnerUserID) if err != nil { return res, fmt.Errorf("allocate read outbox pts: %w", err) } res.OutboxChanged = true res.OutboxUserID = candidate.SenderOwnerUserID res.OutboxEvent = domain.UpdateEvent{ UserID: candidate.SenderOwnerUserID, Type: domain.UpdateEventReadHistoryOutbox, Pts: senderPts, PtsCount: 1, Date: req.Date, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, MaxID: int(candidate.SenderBoxID), } if err := appendUserUpdateEvent(ctx, tx, qtx, candidate.SenderOwnerUserID, res.OutboxEvent); err != nil { return res, fmt.Errorf("append read outbox event: %w", err) } if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ TargetUserID: candidate.SenderOwnerUserID, Pts: int32(senderPts), EventType: string(domain.UpdateEventReadHistoryOutbox), ExcludeAuthKeyID: 0, ExcludeSessionID: 0, }); err != nil { return res, fmt.Errorf("enqueue read outbox dispatch: %w", err) } } else if !errors.Is(err, pgx.ErrNoRows) { return res, fmt.Errorf("update dialog read outbox: %w", err) } } if err := tx.Commit(ctx); err != nil { return res, fmt.Errorf("commit read history tx: %w", err) } committed = true return res, nil } func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} if req.OwnerUserID == 0 { return res, fmt.Errorf("delete history: missing owner user id") } if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { return res, fmt.Errorf("delete history: invalid peer") } if req.Date == 0 { req.Date = int(time.Now().Unix()) } beginner, ok := s.db.(txBeginner) if !ok { return res, fmt.Errorf("delete history: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return res, fmt.Errorf("begin delete history tx: %w", err) } qtx := sqlcgen.New(tx) committed := false defer func() { if committed { return } _ = tx.Rollback(ctx) }() // advisory lock 串行化与会话对端的并发写,须在行锁前获取,消除 AB-BA 死锁。 if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { return res, fmt.Errorf("lock delete history users: %w", err) } maxID := pgInt32NonNegative(req.MaxID) minDate := pgInt32NonNegative(req.MinDate) maxDate := pgInt32NonNegative(req.MaxDate) rows, err := qtx.DeleteMessageBoxesByPeerBatch(ctx, sqlcgen.DeleteMessageBoxesByPeerBatchParams{ OwnerUserID: req.OwnerUserID, PeerType: string(req.Peer.Type), PeerID: req.Peer.ID, MaxID: maxID, MinDate: minDate, MaxDate: maxDate, LimitCount: int32(domain.MaxDeleteHistoryBatch), }) if err != nil { return res, fmt.Errorf("delete message boxes by peer: %w", err) } deleted := deletedRowsFromPeerBatchRows(rows) if req.Revoke { if len(deleted) > 0 { peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted)) if err != nil { return res, fmt.Errorf("delete revoked private history boxes: %w", err) } deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...) } // 反查只覆盖"本批从我方实删的行":我方此前已单向删除/清空过的 // 消息不在本批,对端会永久残留。全量清史(max_id 不限)与按日期 // 清史的 revoke 直接对对端 box 再扫一批;date 是共享属性、对端 // 适用同一区间,box_id 上限则是 owner 私有序无法映射,部分 // max_id 清史保持反查模型(官方 UI 无此入口)。 if req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID { peerSideRows, err := qtx.DeleteMessageBoxesByPeerBatch(ctx, sqlcgen.DeleteMessageBoxesByPeerBatchParams{ OwnerUserID: req.Peer.ID, PeerType: string(domain.PeerTypeUser), PeerID: req.OwnerUserID, MaxID: 0, MinDate: minDate, MaxDate: maxDate, LimitCount: int32(domain.MaxDeleteHistoryBatch), }) if err != nil { return res, fmt.Errorf("delete revoked peer-side history boxes: %w", err) } deleted = append(deleted, deletedRowsFromPeerBatchRows(peerSideRows)...) } } res, err = s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, req.JustClear) if err != nil { return res, err } more, err := qtx.HasDeletableMessageBoxByPeer(ctx, sqlcgen.HasDeletableMessageBoxByPeerParams{ OwnerUserID: req.OwnerUserID, PeerType: string(req.Peer.Type), PeerID: req.Peer.ID, MaxID: maxID, MinDate: minDate, MaxDate: maxDate, }) if err != nil { return res, fmt.Errorf("check remaining history after delete: %w", err) } if !more && req.Revoke && req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID { more, err = qtx.HasDeletableMessageBoxByPeer(ctx, sqlcgen.HasDeletableMessageBoxByPeerParams{ OwnerUserID: req.Peer.ID, PeerType: string(domain.PeerTypeUser), PeerID: req.OwnerUserID, MaxID: 0, MinDate: minDate, MaxDate: maxDate, }) if err != nil { return res, fmt.Errorf("check remaining peer history after revoke: %w", err) } } if more { res.Offset = 1 } if err := tx.Commit(ctx); err != nil { return res, fmt.Errorf("commit delete history tx: %w", err) } committed = true return res, nil } func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message { entities, _ := decodeMessageEntities(row.EntitiesJson) media, _ := decodeMessageMedia(row.MediaJson) markup, _ := decodeReplyMarkup(row.ReplyMarkupJson) rich, _ := decodeRichMessage(row.RichMessageJson) silent, noforwards, reply, forward, _ := messageMetadataFromFields( row.Silent, row.Noforwards, row.ReplyToMsgID, row.ReplyToPeerType, row.ReplyToPeerID, row.ReplyToTopID, row.ReplyToStoryID, row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, row.FwdDate, row.FwdSavedFromPeerType, row.FwdSavedFromPeerID, row.FwdSavedFromMsgID, ) return domain.Message{ Media: media, ReplyMarkup: markup, RichMessage: rich, ID: int(row.BoxID), UID: row.PrivateMessageID, OwnerUserID: row.OwnerUserID, Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, Date: int(row.MessageDate), EditDate: int(row.EditDate), HideEdited: row.HideEdited, Out: row.Outgoing, Silent: silent, NoForwards: noforwards, Body: row.Body, Entities: entities, ReplyTo: reply, Forward: forward, Pts: int(row.Pts), TTLPeriod: int(row.TtlPeriod), ExpiresAt: int(row.ExpiresAt), MediaUnread: row.MediaUnread, ReactionUnread: row.ReactionUnread, ViaBotID: row.ViaBotID, GroupedID: row.GroupedID, Effect: row.Effect, Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), } } func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.Message { entities, _ := decodeMessageEntities(row.EntitiesJson) media, _ := decodeMessageMedia(row.MediaJson) markup, _ := decodeReplyMarkup(row.ReplyMarkupJson) rich, _ := decodeRichMessage(row.RichMessageJson) silent, noforwards, reply, forward, _ := messageMetadataFromFields( row.Silent, row.Noforwards, row.ReplyToMsgID, row.ReplyToPeerType, row.ReplyToPeerID, row.ReplyToTopID, row.ReplyToStoryID, row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, row.FwdDate, row.FwdSavedFromPeerType, row.FwdSavedFromPeerID, row.FwdSavedFromMsgID, ) return domain.Message{ Media: media, ReplyMarkup: markup, RichMessage: rich, ID: int(row.BoxID), UID: row.PrivateMessageID, OwnerUserID: row.OwnerUserID, Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, Date: int(row.MessageDate), EditDate: int(row.EditDate), HideEdited: row.HideEdited, Out: row.Outgoing, Silent: silent, NoForwards: noforwards, Body: row.Body, Entities: entities, ReplyTo: reply, Forward: forward, Pts: int(row.Pts), TTLPeriod: int(row.TtlPeriod), ExpiresAt: int(row.ExpiresAt), MediaUnread: row.MediaUnread, ReactionUnread: row.ReactionUnread, ViaBotID: row.ViaBotID, GroupedID: row.GroupedID, Effect: row.Effect, Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), } } func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessageRow) (domain.Message, error) { entities, err := decodeMessageEntities(row.EntitiesJson) if err != nil { return domain.Message{}, fmt.Errorf("decode visible message entities: %w", err) } silent, noforwards, reply, forward, err := messageMetadataFromFields( row.Silent, row.Noforwards, row.ReplyToMsgID, row.ReplyToPeerType, row.ReplyToPeerID, row.ReplyToTopID, row.ReplyToStoryID, row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, row.FwdDate, row.FwdSavedFromPeerType, row.FwdSavedFromPeerID, row.FwdSavedFromMsgID, ) if err != nil { return domain.Message{}, fmt.Errorf("decode visible message metadata: %w", err) } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return domain.Message{}, fmt.Errorf("decode visible message media: %w", err) } markup, err := decodeReplyMarkup(row.ReplyMarkupJson) if err != nil { return domain.Message{}, fmt.Errorf("decode visible message reply markup: %w", err) } rich, err := decodeRichMessage(row.RichMessageJson) if err != nil { return domain.Message{}, fmt.Errorf("decode visible message rich message: %w", err) } return domain.Message{ Media: media, ReplyMarkup: markup, RichMessage: rich, ID: int(row.BoxID), UID: row.PrivateMessageID, OwnerUserID: row.OwnerUserID, Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, Date: int(row.MessageDate), EditDate: int(row.EditDate), HideEdited: row.HideEdited, Out: row.Outgoing, Silent: silent, NoForwards: noforwards, Body: row.Body, Entities: entities, ReplyTo: reply, Forward: forward, Pts: int(row.Pts), TTLPeriod: int(row.TtlPeriod), ExpiresAt: int(row.ExpiresAt), MediaUnread: row.MediaUnread, ReactionUnread: row.ReactionUnread, ViaBotID: row.ViaBotID, GroupedID: row.GroupedID, Effect: row.Effect, Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), }, nil } func messageFromUpdateEditRow(row sqlcgen.UpdateMessageBoxEditRow) (domain.Message, error) { entities, err := decodeMessageEntities(row.EntitiesJson) if err != nil { return domain.Message{}, fmt.Errorf("decode edited message entities: %w", err) } silent, noforwards, reply, forward, err := messageMetadataFromFields( row.Silent, row.Noforwards, row.ReplyToMsgID, row.ReplyToPeerType, row.ReplyToPeerID, row.ReplyToTopID, row.ReplyToStoryID, row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, row.FwdDate, row.FwdSavedFromPeerType, row.FwdSavedFromPeerID, row.FwdSavedFromMsgID, ) if err != nil { return domain.Message{}, fmt.Errorf("decode edited message metadata: %w", err) } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return domain.Message{}, fmt.Errorf("decode edited message media: %w", err) } markup, err := decodeReplyMarkup(row.ReplyMarkupJson) if err != nil { return domain.Message{}, fmt.Errorf("decode edited message reply markup: %w", err) } rich, err := decodeRichMessage(row.RichMessageJson) if err != nil { return domain.Message{}, fmt.Errorf("decode edited message rich message: %w", err) } return domain.Message{ Media: media, ReplyMarkup: markup, RichMessage: rich, ID: int(row.BoxID), UID: row.PrivateMessageID, OwnerUserID: row.OwnerUserID, Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, Date: int(row.MessageDate), EditDate: int(row.EditDate), HideEdited: row.HideEdited, Out: row.Outgoing, Silent: silent, NoForwards: noforwards, Body: row.Body, Entities: entities, ReplyTo: reply, Forward: forward, Pts: int(row.Pts), TTLPeriod: int(row.TtlPeriod), ExpiresAt: int(row.ExpiresAt), MediaUnread: row.MediaUnread, ReactionUnread: row.ReactionUnread, ViaBotID: row.ViaBotID, GroupedID: row.GroupedID, Effect: row.Effect, Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), }, nil } func messageFromIDRow(row sqlcgen.GetMessageBoxesByIDsRow) (domain.Message, error) { entities, err := decodeMessageEntities(row.EntitiesJson) if err != nil { return domain.Message{}, fmt.Errorf("decode message entities: %w", err) } silent, noforwards, reply, forward, err := messageMetadataFromFields( row.Silent, row.Noforwards, row.ReplyToMsgID, row.ReplyToPeerType, row.ReplyToPeerID, row.ReplyToTopID, row.ReplyToStoryID, row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, row.FwdFromPeerType, row.FwdFromPeerID, row.FwdFromName, row.FwdDate, row.FwdSavedFromPeerType, row.FwdSavedFromPeerID, row.FwdSavedFromMsgID, ) if err != nil { return domain.Message{}, fmt.Errorf("decode message metadata: %w", err) } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return domain.Message{}, fmt.Errorf("decode message media: %w", err) } markup, err := decodeReplyMarkup(row.ReplyMarkupJson) if err != nil { return domain.Message{}, fmt.Errorf("decode message reply markup: %w", err) } rich, err := decodeRichMessage(row.RichMessageJson) if err != nil { return domain.Message{}, fmt.Errorf("decode message rich message: %w", err) } return domain.Message{ Media: media, ReplyMarkup: markup, RichMessage: rich, ID: int(row.BoxID), UID: row.PrivateMessageID, OwnerUserID: row.OwnerUserID, Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, Date: int(row.MessageDate), EditDate: int(row.EditDate), HideEdited: row.HideEdited, Out: row.Outgoing, Silent: silent, NoForwards: noforwards, Body: row.Body, Entities: entities, ReplyTo: reply, Forward: forward, Pts: int(row.Pts), TTLPeriod: int(row.TtlPeriod), ExpiresAt: int(row.ExpiresAt), MediaUnread: row.MediaUnread, ReactionUnread: row.ReactionUnread, ViaBotID: row.ViaBotID, GroupedID: row.GroupedID, Effect: row.Effect, Pinned: row.Pinned, SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), }, nil } func eventFromMessage(msg domain.Message) domain.UpdateEvent { return domain.UpdateEvent{ UserID: msg.OwnerUserID, Type: domain.UpdateEventNewMessage, Pts: msg.Pts, PtsCount: 1, Date: msg.Date, Message: msg, } } // backwardRowToByUserRow 把 ListMessagesBackward(扁平 backward 热路径)的行适配为 // ListMessagesByUserRow,从而复用 ListByUser 既有的解码/用户收集下游逻辑。两者列与 // base 完全一致,仅缺 TotalCount(由调用方按 NeedTotalCount 单独填,默认 0)。 func backwardRowToByUserRow(r sqlcgen.ListMessagesBackwardRow) sqlcgen.ListMessagesByUserRow { return sqlcgen.ListMessagesByUserRow{ BoxID: r.BoxID, PrivateMessageID: r.PrivateMessageID, OwnerUserID: r.OwnerUserID, PeerType: r.PeerType, PeerID: r.PeerID, FromUserID: r.FromUserID, MessageDate: r.MessageDate, TtlPeriod: r.TtlPeriod, ExpiresAt: r.ExpiresAt, EditDate: r.EditDate, HideEdited: r.HideEdited, Outgoing: r.Outgoing, Body: r.Body, EntitiesJson: r.EntitiesJson, Silent: r.Silent, Noforwards: r.Noforwards, ReplyToMsgID: r.ReplyToMsgID, ReplyToPeerType: r.ReplyToPeerType, ReplyToPeerID: r.ReplyToPeerID, ReplyToTopID: r.ReplyToTopID, ReplyToStoryID: r.ReplyToStoryID, QuoteText: r.QuoteText, QuoteEntitiesJson: r.QuoteEntitiesJson, QuoteOffset: r.QuoteOffset, FwdFromPeerType: r.FwdFromPeerType, FwdFromPeerID: r.FwdFromPeerID, FwdFromName: r.FwdFromName, FwdDate: r.FwdDate, FwdSavedFromPeerType: r.FwdSavedFromPeerType, FwdSavedFromPeerID: r.FwdSavedFromPeerID, FwdSavedFromMsgID: r.FwdSavedFromMsgID, SavedPeerType: r.SavedPeerType, SavedPeerID: r.SavedPeerID, Pts: r.Pts, MediaJson: r.MediaJson, MediaUnread: r.MediaUnread, ReactionUnread: r.ReactionUnread, Pinned: r.Pinned, ViaBotID: r.ViaBotID, GroupedID: r.GroupedID, Effect: r.Effect, ReplyMarkupJson: r.ReplyMarkupJson, RichMessageJson: r.RichMessageJson, PeerUserID: r.PeerUserID, PeerAccessHash: r.PeerAccessHash, PeerPhone: r.PeerPhone, PeerFirstName: r.PeerFirstName, PeerLastName: r.PeerLastName, PeerUsername: r.PeerUsername, PeerCountryCode: r.PeerCountryCode, PeerVerified: r.PeerVerified, PeerSupport: r.PeerSupport, PeerIsBot: r.PeerIsBot, PeerBotInfoVersion: r.PeerBotInfoVersion, PeerPremiumUntil: r.PeerPremiumUntil, PeerEmojiStatusDocumentID: r.PeerEmojiStatusDocumentID, PeerEmojiStatusUntil: r.PeerEmojiStatusUntil, PeerLastSeenAt: r.PeerLastSeenAt, FromUserUserID: r.FromUserUserID, FromUserAccessHash: r.FromUserAccessHash, FromUserPhone: r.FromUserPhone, FromUserFirstName: r.FromUserFirstName, FromUserLastName: r.FromUserLastName, FromUserUsername: r.FromUserUsername, FromUserCountryCode: r.FromUserCountryCode, FromUserVerified: r.FromUserVerified, FromUserSupport: r.FromUserSupport, FromUserIsBot: r.FromUserIsBot, FromUserBotInfoVersion: r.FromUserBotInfoVersion, FromUserPremiumUntil: r.FromUserPremiumUntil, FromUserEmojiStatusDocumentID: r.FromUserEmojiStatusDocumentID, FromUserEmojiStatusUntil: r.FromUserEmojiStatusUntil, FromUserLastSeenAt: r.FromUserLastSeenAt, } } func appendUserFromMessageRow(out *domain.MessageList, seen map[int64]struct{}, row sqlcgen.ListMessagesByUserRow) { appendMessageUsers(out, seen, domain.User{ ID: row.PeerUserID, AccessHash: row.PeerAccessHash, Phone: row.PeerPhone, FirstName: row.PeerFirstName, LastName: row.PeerLastName, Username: row.PeerUsername, CountryCode: row.PeerCountryCode, Verified: row.PeerVerified, Support: row.PeerSupport, Bot: row.PeerIsBot, BotInfoVersion: int(row.PeerBotInfoVersion), PremiumUntil: int(row.PeerPremiumUntil), EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID, EmojiStatusUntil: int(row.PeerEmojiStatusUntil), LastSeenAt: int(row.PeerLastSeenAt), }, domain.User{ ID: row.FromUserUserID, AccessHash: row.FromUserAccessHash, Phone: row.FromUserPhone, FirstName: row.FromUserFirstName, LastName: row.FromUserLastName, Username: row.FromUserUsername, CountryCode: row.FromUserCountryCode, Verified: row.FromUserVerified, Support: row.FromUserSupport, Bot: row.FromUserIsBot, BotInfoVersion: int(row.FromUserBotInfoVersion), PremiumUntil: int(row.FromUserPremiumUntil), EmojiStatusDocumentID: row.FromUserEmojiStatusDocumentID, EmojiStatusUntil: int(row.FromUserEmojiStatusUntil), LastSeenAt: int(row.FromUserLastSeenAt), }, ) } func appendUsersFromMessageIDRow(out *domain.MessageList, seen map[int64]struct{}, row sqlcgen.GetMessageBoxesByIDsRow) { appendMessageUsers(out, seen, domain.User{ ID: row.PeerUserID, AccessHash: row.PeerAccessHash, Phone: row.PeerPhone, FirstName: row.PeerFirstName, LastName: row.PeerLastName, Username: row.PeerUsername, CountryCode: row.PeerCountryCode, Verified: row.PeerVerified, Support: row.PeerSupport, Bot: row.PeerIsBot, BotInfoVersion: int(row.PeerBotInfoVersion), PremiumUntil: int(row.PeerPremiumUntil), EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID, EmojiStatusUntil: int(row.PeerEmojiStatusUntil), LastSeenAt: int(row.PeerLastSeenAt), }, domain.User{ ID: row.FromUserUserID, AccessHash: row.FromUserAccessHash, Phone: row.FromUserPhone, FirstName: row.FromUserFirstName, LastName: row.FromUserLastName, Username: row.FromUserUsername, CountryCode: row.FromUserCountryCode, Verified: row.FromUserVerified, Support: row.FromUserSupport, Bot: row.FromUserIsBot, BotInfoVersion: int(row.FromUserBotInfoVersion), PremiumUntil: int(row.FromUserPremiumUntil), EmojiStatusDocumentID: row.FromUserEmojiStatusDocumentID, EmojiStatusUntil: int(row.FromUserEmojiStatusUntil), LastSeenAt: int(row.FromUserLastSeenAt), }, ) } func appendMessageUsers(out *domain.MessageList, seen map[int64]struct{}, users ...domain.User) { add := func(u domain.User) { if u.ID == 0 { return } if _, ok := seen[u.ID]; ok { return } seen[u.ID] = struct{}{} out.Users = append(out.Users, u) } for _, user := range users { add(user) } }