owpengram-server/internal/store/postgres/saved_dialog.go

482 lines
18 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"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// ListSavedDialogs 返回收藏夹子会话分页首页offset 无效)且不排除置顶时
// 置顶块按 pinned_order 在前,普通块按 top box id 严格降序;翻页恒排除置顶
// 置顶只随首页返回避免跨页重复。Count 按过滤口径(含/不含置顶)。
func (s *MessageStore) ListSavedDialogs(ctx context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) {
out := domain.SavedDialogList{}
if userID == 0 {
return out, fmt.Errorf("list saved dialogs: missing user id")
}
limit := filter.Limit
if limit <= 0 || limit > domain.MaxSavedDialogsLimit {
limit = domain.MaxSavedDialogsLimit
}
// TDesktop 首页 offset_id=0DrKLO Android 首页 offset_id=int32 max
// 两者都视为"从最新开始"。
offsetID := filter.OffsetID
// DrKLO Android 首页恰好发 offset_id = MaxMessageBoxIDint32 max必须用 >=
// 才能命中首页分支;否则该值被误判为续页,置顶子会话块被整体跳过、首页丢置顶。
firstPage := offsetID <= 0 || offsetID >= domain.MaxMessageBoxID
if firstPage {
offsetID = 0
}
if firstPage && !filter.ExcludePinned {
rows, err := s.q.ListPinnedSavedDialogTops(ctx, userID)
if err != nil {
return out, fmt.Errorf("list pinned saved dialog tops: %w", err)
}
for _, row := range rows {
if len(out.Dialogs) >= limit {
break
}
msg, err := messageFromSavedDialogRow(savedDialogRowFields(row))
if err != nil {
return out, err
}
out.Dialogs = append(out.Dialogs, domain.SavedDialog{
Peer: domain.Peer{Type: domain.PeerType(row.DialogPeerType), ID: row.DialogPeerID},
TopMessage: msg.ID,
Pinned: true,
})
out.Messages = append(out.Messages, msg)
}
}
remaining := limit - len(out.Dialogs)
hasMore := false
if remaining > 0 {
rows, err := s.q.ListSavedDialogTops(ctx, sqlcgen.ListSavedDialogTopsParams{
OwnerUserID: userID,
OffsetID: pgInt32NonNegative(offsetID),
// 普通块恒排除置顶:置顶要么已随首页置顶块返回,要么被
// exclude_pinned 显式排除,任何一页都不允许再出现。
ExcludePinned: true,
LimitCount: int32(remaining + 1),
})
if err != nil {
return out, fmt.Errorf("list saved dialog tops: %w", err)
}
if len(rows) > remaining {
hasMore = true
rows = rows[:remaining]
}
for _, row := range rows {
msg, err := messageFromSavedDialogRow(savedDialogRowFields(row))
if err != nil {
return out, err
}
out.Dialogs = append(out.Dialogs, domain.SavedDialog{
Peer: domain.Peer{Type: domain.PeerType(row.DialogPeerType), ID: row.DialogPeerID},
TopMessage: msg.ID,
Pinned: row.DialogPinned,
})
out.Messages = append(out.Messages, msg)
}
}
total, err := s.q.CountSavedDialogs(ctx, sqlcgen.CountSavedDialogsParams{
OwnerUserID: userID,
ExcludePinned: filter.ExcludePinned,
})
if err != nil {
return out, fmt.Errorf("count saved dialogs: %w", err)
}
out.Count = int(total)
out.Full = !hasMore
if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil {
return out, err
}
return out, nil
}
// ListPinnedSavedDialogs 返回全部置顶子会话messages.getPinnedSavedDialogs
func (s *MessageStore) ListPinnedSavedDialogs(ctx context.Context, userID int64) (domain.SavedDialogList, error) {
out := domain.SavedDialogList{Full: true}
if userID == 0 {
return out, fmt.Errorf("list pinned saved dialogs: missing user id")
}
rows, err := s.q.ListPinnedSavedDialogTops(ctx, userID)
if err != nil {
return out, fmt.Errorf("list pinned saved dialog tops: %w", err)
}
for _, row := range rows {
msg, err := messageFromSavedDialogRow(savedDialogRowFields(row))
if err != nil {
return out, err
}
out.Dialogs = append(out.Dialogs, domain.SavedDialog{
Peer: domain.Peer{Type: domain.PeerType(row.DialogPeerType), ID: row.DialogPeerID},
TopMessage: msg.ID,
Pinned: true,
})
out.Messages = append(out.Messages, msg)
}
out.Count = len(out.Dialogs)
if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil {
return out, err
}
return out, nil
}
// ListSavedDialogsByPeers 返回指定子会话messages.getSavedDialogsByID
// 不存在的 peer 静默缺席。
func (s *MessageStore) ListSavedDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) {
out := domain.SavedDialogList{Full: true}
if userID == 0 {
return out, fmt.Errorf("list saved dialogs by peers: missing user id")
}
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID == 0 {
continue
}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
if len(peerTypes) == 0 {
return out, nil
}
rows, err := s.q.ListSavedDialogTopsByPeers(ctx, sqlcgen.ListSavedDialogTopsByPeersParams{
OwnerUserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
})
if err != nil {
return out, fmt.Errorf("list saved dialog tops by peers: %w", err)
}
for _, row := range rows {
msg, err := messageFromSavedDialogRow(savedDialogRowFields(row))
if err != nil {
return out, err
}
out.Dialogs = append(out.Dialogs, domain.SavedDialog{
Peer: domain.Peer{Type: domain.PeerType(row.DialogPeerType), ID: row.DialogPeerID},
TopMessage: msg.ID,
Pinned: row.DialogPinned,
})
out.Messages = append(out.Messages, msg)
}
out.Count = len(out.Dialogs)
if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil {
return out, err
}
return out, nil
}
// ToggleSavedDialogPin 翻转一个子会话的置顶状态;新置顶插到最前。
// 返回状态是否实际变化。
func (s *MessageStore) ToggleSavedDialogPin(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) {
if userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, fmt.Errorf("toggle saved dialog pin: invalid input")
}
if !pinned {
rows, err := s.q.DeleteSavedDialogPin(ctx, sqlcgen.DeleteSavedDialogPinParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
return false, fmt.Errorf("delete saved dialog pin: %w", err)
}
return rows > 0, nil
}
count, err := s.q.CountSavedDialogPins(ctx, userID)
if err != nil {
return false, fmt.Errorf("count saved dialog pins: %w", err)
}
if int(count) >= domain.MaxPinnedSavedDialogs {
return false, domain.ErrPinnedSavedDialogsTooMuch
}
rows, err := s.q.UpsertSavedDialogPinFront(ctx, sqlcgen.UpsertSavedDialogPinFrontParams{
UserID: userID,
PeerType: string(peer.Type),
PeerID: peer.ID,
})
if err != nil {
return false, fmt.Errorf("upsert saved dialog pin: %w", err)
}
return rows > 0, nil
}
// ReorderPinnedSavedDialogs 全量重排置顶顺序force 时清掉不在 order 中的
// 既有置顶(与 messages.reorderPinnedDialogs 的 force 语义一致)。
func (s *MessageStore) ReorderPinnedSavedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error {
if userID == 0 {
return fmt.Errorf("reorder pinned saved dialogs: missing user id")
}
if len(order) > domain.MaxPinnedSavedDialogs {
return domain.ErrPinnedSavedDialogsTooMuch
}
peerTypes := make([]string, 0, len(order))
peerIDs := make([]int64, 0, len(order))
for _, peer := range order {
if peer.Type == "" || peer.ID == 0 {
continue
}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
if force {
if err := s.q.ClearSavedDialogPinsNotInOrder(ctx, sqlcgen.ClearSavedDialogPinsNotInOrderParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
}); err != nil {
return fmt.Errorf("clear saved dialog pins not in order: %w", err)
}
}
if len(peerTypes) == 0 {
return nil
}
if err := s.q.ReorderSavedDialogPins(ctx, sqlcgen.ReorderSavedDialogPinsParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
}); err != nil {
return fmt.Errorf("reorder saved dialog pins: %w", err)
}
return nil
}
// DeleteSavedHistory 删除 self-chat 中一个 saved 子会话的消息(单批,
// 上限 MaxDeleteHistoryBatch生成带 pts 的 delete_messages durable 事件,
// 修复 self dialog top子会话删空时顺带清掉它的置顶行。
func (s *MessageStore) DeleteSavedHistory(ctx context.Context, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) {
res := domain.DeleteSavedHistoryResult{}
if req.OwnerUserID == 0 || req.SavedPeer.Type == "" || req.SavedPeer.ID == 0 {
return res, fmt.Errorf("delete saved history: invalid input")
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
beginner, ok := s.db.(txBeginner)
if !ok {
return res, fmt.Errorf("delete saved history: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return res, fmt.Errorf("begin delete saved history tx: %w", err)
}
qtx := sqlcgen.New(tx)
committed := false
defer func() {
if committed {
return
}
_ = tx.Rollback(ctx)
}()
if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil {
return res, fmt.Errorf("lock delete saved history user: %w", err)
}
rows, err := qtx.DeleteMessageBoxesBySavedPeerBatch(ctx, sqlcgen.DeleteMessageBoxesBySavedPeerBatchParams{
OwnerUserID: req.OwnerUserID,
SavedPeerType: string(req.SavedPeer.Type),
SavedPeerID: req.SavedPeer.ID,
MaxID: pgInt32NonNegative(req.MaxID),
MinDate: pgInt32NonNegative(req.MinDate),
MaxDate: pgInt32NonNegative(req.MaxDate),
LimitCount: int32(domain.MaxDeleteHistoryBatch),
})
if err != nil {
return res, fmt.Errorf("delete saved history batch: %w", err)
}
deleted := make([]deletedBox, 0, len(rows))
for _, row := range rows {
deleted = append(deleted, deletedBox{
ownerUserID: row.OwnerUserID,
boxID: int(row.BoxID),
privateMessageID: row.PrivateMessageID,
messageSenderID: row.MessageSenderID,
peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
})
}
delRes, err := s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, false)
if err != nil {
return res, err
}
if len(deleted) > 0 {
more, err := qtx.HasDeletableMessageBoxBySavedPeer(ctx, sqlcgen.HasDeletableMessageBoxBySavedPeerParams{
OwnerUserID: req.OwnerUserID,
SavedPeerType: string(req.SavedPeer.Type),
SavedPeerID: req.SavedPeer.ID,
MaxID: pgInt32NonNegative(req.MaxID),
MinDate: pgInt32NonNegative(req.MinDate),
MaxDate: pgInt32NonNegative(req.MaxDate),
})
if err != nil {
return res, fmt.Errorf("probe deletable saved history: %w", err)
}
res.More = more
if !more {
// 边界口径外可能还有残留(如 max_id 截断),子会话仍存活时
// 不能清置顶;只有全量删空才清。
alive, err := qtx.HasDeletableMessageBoxBySavedPeer(ctx, sqlcgen.HasDeletableMessageBoxBySavedPeerParams{
OwnerUserID: req.OwnerUserID,
SavedPeerType: string(req.SavedPeer.Type),
SavedPeerID: req.SavedPeer.ID,
})
if err != nil {
return res, fmt.Errorf("probe saved dialog alive: %w", err)
}
if !alive {
if _, err := qtx.DeleteSavedDialogPin(ctx, sqlcgen.DeleteSavedDialogPinParams{
UserID: req.OwnerUserID,
PeerType: string(req.SavedPeer.Type),
PeerID: req.SavedPeer.ID,
}); err != nil {
return res, fmt.Errorf("clear pin after saved history delete: %w", err)
}
}
}
}
if err := tx.Commit(ctx); err != nil {
return res, fmt.Errorf("commit delete saved history tx: %w", err)
}
committed = true
for _, d := range delRes.Deleted {
if d.UserID == req.OwnerUserID {
res.MessageIDs = d.MessageIDs
res.Event = d.Event
}
}
return res, nil
}
// savedDialogTopRowFields 是三个 saved dialog top 查询共有的行字段。
type savedDialogTopRowFields struct {
BoxID int32
PrivateMessageID int64
OwnerUserID int64
PeerType string
PeerID int64
FromUserID int64
MessageDate int32
TtlPeriod int32
ExpiresAt int32
EditDate int32
HideEdited bool
Outgoing bool
Body string
EntitiesJson string
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
ReplyToStoryID int32
QuoteText string
QuoteEntitiesJson string
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
FwdSavedFromPeerType string
FwdSavedFromPeerID int64
FwdSavedFromMsgID int32
SavedPeerType string
SavedPeerID int64
Pts int32
MediaJson string
MediaUnread bool
ReactionUnread bool
ViaBotID int64
GroupedID int64
Effect int64
ReplyMarkupJson string
RichMessageJson string
Pinned bool
}
func savedDialogRowFields[T sqlcgen.ListSavedDialogTopsRow | sqlcgen.ListPinnedSavedDialogTopsRow | sqlcgen.ListSavedDialogTopsByPeersRow](row T) savedDialogTopRowFields {
switch r := any(row).(type) {
case sqlcgen.ListSavedDialogTopsRow:
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
case sqlcgen.ListPinnedSavedDialogTopsRow:
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
case sqlcgen.ListSavedDialogTopsByPeersRow:
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
}
return savedDialogTopRowFields{}
}
func messageFromSavedDialogRow(row savedDialogTopRowFields) (domain.Message, error) {
entities, err := decodeMessageEntities(row.EntitiesJson)
if err != nil {
return domain.Message{}, fmt.Errorf("decode saved dialog 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 saved dialog message metadata: %w", err)
}
media, err := decodeMessageMedia(row.MediaJson)
if err != nil {
return domain.Message{}, fmt.Errorf("decode saved dialog message media: %w", err)
}
markup, err := decodeReplyMarkup(row.ReplyMarkupJson)
if err != nil {
return domain.Message{}, fmt.Errorf("decode saved dialog message reply markup: %w", err)
}
rich, err := decodeRichMessage(row.RichMessageJson)
if err != nil {
return domain.Message{}, fmt.Errorf("decode saved dialog message rich message: %w", err)
}
return 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,
MediaUnread: row.MediaUnread,
ReactionUnread: row.ReactionUnread,
ViaBotID: row.ViaBotID,
GroupedID: row.GroupedID,
Effect: row.Effect,
ReplyMarkup: markup,
RichMessage: rich,
Pinned: row.Pinned,
SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID),
}, nil
}