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

469 lines
16 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"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"sort"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
"time"
)
func (s *MessageStore) ReadMessageContents(ctx context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) {
res := domain.ReadMessageContentsResult{OwnerUserID: req.OwnerUserID}
if req.OwnerUserID == 0 {
return res, fmt.Errorf("read message contents: missing owner user id")
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if len(req.IDs) > domain.MaxGetMessageIDs {
return res, domain.ErrMessageIDInvalid
}
seen := make(map[int]struct{}, len(req.IDs))
ids := make([]int32, 0, len(req.IDs))
for _, id := range req.IDs {
if id <= 0 || id > domain.MaxMessageBoxID {
return res, domain.ErrMessageIDInvalid
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, int32(id))
}
if len(ids) == 0 {
return res, nil
}
beginner, ok := s.db.(txBeginner)
if !ok {
return res, fmt.Errorf("read message contents: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return res, fmt.Errorf("begin read message contents tx: %w", err)
}
qtx := sqlcgen.New(tx)
committed := false
defer func() {
if committed {
return
}
_ = tx.Rollback(ctx)
}()
// 先做只读预查推导本次涉及的对端 sender保证 advisory lock 仍按
// user_id 升序统一获取,避免与 send/read 路径形成交叉锁序。
senderRows, err := tx.Query(ctx, `
SELECT DISTINCT message_sender_id
FROM message_boxes
WHERE owner_user_id = $1
AND box_id = ANY($2::int[])
AND NOT deleted
AND media_unread
AND NOT outgoing
AND peer_type = 'user'
AND message_sender_id <> $1`, req.OwnerUserID, ids)
if err != nil {
return res, fmt.Errorf("preview read message contents senders: %w", err)
}
lockIDs := []int64{req.OwnerUserID}
for senderRows.Next() {
var senderID int64
if err := senderRows.Scan(&senderID); err != nil {
senderRows.Close()
return res, fmt.Errorf("scan read message contents sender: %w", err)
}
lockIDs = append(lockIDs, senderID)
}
if err := senderRows.Err(); err != nil {
senderRows.Close()
return res, fmt.Errorf("preview read message contents senders rows: %w", err)
}
senderRows.Close()
if err := lockUsersForUpdate(ctx, tx, lockIDs...); err != nil {
return res, fmt.Errorf("lock read message contents users: %w", err)
}
rows, err := tx.Query(ctx, `
WITH target AS (
SELECT owner_user_id, box_id, peer_type, peer_id, media_unread, reaction_unread,
private_message_id, message_sender_id, outgoing
FROM message_boxes
WHERE owner_user_id = $1
AND box_id = ANY($2::int[])
AND NOT deleted
AND (media_unread OR reaction_unread)
FOR UPDATE
),
updated AS (
UPDATE message_boxes
SET media_unread = false,
reaction_unread = false
FROM target t
WHERE message_boxes.owner_user_id = t.owner_user_id
AND message_boxes.box_id = t.box_id
RETURNING message_boxes.box_id, t.peer_type, t.peer_id, t.media_unread, t.reaction_unread,
t.private_message_id, t.message_sender_id, t.outgoing
)
SELECT box_id, peer_type, peer_id, media_unread, reaction_unread, private_message_id, message_sender_id, outgoing
FROM updated
ORDER BY box_id`, req.OwnerUserID, ids)
if err != nil {
return res, fmt.Errorf("read message contents: %w", err)
}
defer rows.Close()
affectedPeers := make(map[domain.Peer]struct{})
senderPrivateMessageIDs := make(map[int64][]int64)
for rows.Next() {
var id int32
var peerType string
var peerID, privateMessageID, messageSenderID int64
var mediaUnread, reactionUnread, outgoing bool
if err := rows.Scan(&id, &peerType, &peerID, &mediaUnread, &reactionUnread, &privateMessageID, &messageSenderID, &outgoing); err != nil {
return res, fmt.Errorf("scan read message contents: %w", err)
}
res.MessageIDs = append(res.MessageIDs, int(id))
// reaction 与 media(语音/圆形视频)未读清除都要 UPDATE dialogs 行:
// dialog_light 触发器仅挂在 dialogs 表message_boxes.media_unread 翻转不 bump 版本。
// 不补这一下,「语音先历史已读、之后单独点听」会让 getPeerDialogs 的 per-peer 缓存
// 顶层消息一直带 media_unread=true(蓝点不消),直到该会话因别的写被失效。
if (reactionUnread || mediaUnread) && peerID != 0 {
affectedPeers[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = struct{}{}
}
if mediaUnread && !outgoing && peerType == string(domain.PeerTypeUser) && messageSenderID != 0 && messageSenderID != req.OwnerUserID {
senderPrivateMessageIDs[messageSenderID] = append(senderPrivateMessageIDs[messageSenderID], privateMessageID)
}
}
if err := rows.Err(); err != nil {
return res, fmt.Errorf("read message contents rows: %w", err)
}
if len(res.MessageIDs) == 0 {
if err := tx.Commit(ctx); err != nil {
return res, fmt.Errorf("commit read message contents noop: %w", err)
}
committed = true
return res, nil
}
for peer := range affectedPeers {
if peer.Type != domain.PeerTypeUser || peer.ID == 0 {
continue
}
if _, err := tx.Exec(ctx, `
UPDATE dialogs d
SET unread_reactions_count = (
SELECT COUNT(*)::int
FROM message_boxes m
WHERE m.owner_user_id = d.user_id
AND m.peer_type = d.peer_type
AND m.peer_id = d.peer_id
AND NOT m.deleted
AND m.reaction_unread
),
updated_at = now()
WHERE d.user_id = $1
AND d.peer_type = $2
AND d.peer_id = $3`, req.OwnerUserID, string(peer.Type), peer.ID); err != nil {
return res, fmt.Errorf("refresh dialog unread reactions after content read: %w", err)
}
}
pts, err := s.reservePtsN(ctx, tx, req.OwnerUserID, len(res.MessageIDs))
if err != nil {
return res, fmt.Errorf("allocate read message contents pts: %w", err)
}
res.Event = domain.UpdateEvent{
UserID: req.OwnerUserID,
Type: domain.UpdateEventReadMessageContents,
Pts: pts,
PtsCount: len(res.MessageIDs),
Date: req.Date,
MessageIDs: append([]int(nil), res.MessageIDs...),
}
if err := appendUserUpdateEvent(ctx, tx, qtx, req.OwnerUserID, res.Event); err != nil {
return res, fmt.Errorf("append read message contents event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.OwnerUserID,
Pts: int32(pts),
EventType: string(domain.UpdateEventReadMessageContents),
ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID),
ExcludeSessionID: req.OriginSessionID,
}); err != nil {
return res, fmt.Errorf("enqueue read message contents dispatch: %w", err)
}
senderIDs := make([]int64, 0, len(senderPrivateMessageIDs))
for senderID := range senderPrivateMessageIDs {
senderIDs = append(senderIDs, senderID)
}
sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] })
for _, senderID := range senderIDs {
senderBoxRows, err := tx.Query(ctx, `
UPDATE message_boxes
SET media_unread = false
WHERE owner_user_id = $1
AND private_message_id = ANY($2::bigint[])
AND outgoing
AND NOT deleted
AND media_unread
RETURNING box_id`, senderID, senderPrivateMessageIDs[senderID])
if err != nil {
return res, fmt.Errorf("clear sender media unread: %w", err)
}
senderBoxIDs := make([]int, 0, len(senderPrivateMessageIDs[senderID]))
for senderBoxRows.Next() {
var boxID int32
if err := senderBoxRows.Scan(&boxID); err != nil {
senderBoxRows.Close()
return res, fmt.Errorf("scan sender media unread box: %w", err)
}
senderBoxIDs = append(senderBoxIDs, int(boxID))
}
if err := senderBoxRows.Err(); err != nil {
senderBoxRows.Close()
return res, fmt.Errorf("sender media unread rows: %w", err)
}
senderBoxRows.Close()
if len(senderBoxIDs) == 0 {
continue
}
sort.Ints(senderBoxIDs)
senderPts, err := s.reservePtsN(ctx, tx, senderID, len(senderBoxIDs))
if err != nil {
return res, fmt.Errorf("allocate sender content read pts: %w", err)
}
event := domain.UpdateEvent{
UserID: senderID,
Type: domain.UpdateEventReadMessageContents,
Pts: senderPts,
PtsCount: len(senderBoxIDs),
Date: req.Date,
MessageIDs: senderBoxIDs,
}
if err := appendUserUpdateEvent(ctx, tx, qtx, senderID, event); err != nil {
return res, fmt.Errorf("append sender content read event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: senderID,
Pts: int32(senderPts),
EventType: string(domain.UpdateEventReadMessageContents),
}); err != nil {
return res, fmt.Errorf("enqueue sender content read dispatch: %w", err)
}
res.SenderEvents = append(res.SenderEvents, event)
}
if err := tx.Commit(ctx); err != nil {
return res, fmt.Errorf("commit read message contents tx: %w", err)
}
committed = true
return res, nil
}
func (s *MessageStore) GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error) {
if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
return 0, domain.ErrMessageIDInvalid
}
if _, err := s.q.GetOutboxMessageForReadDate(ctx, sqlcgen.GetOutboxMessageForReadDateParams{
OwnerUserID: req.OwnerUserID,
PeerType: string(req.Peer.Type),
PeerID: req.Peer.ID,
BoxID: int32(req.ID),
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, domain.ErrMessageIDInvalid
}
return 0, fmt.Errorf("get outbox message for read date: %w", err)
}
date, err := s.q.GetOutboxReadDate(ctx, sqlcgen.GetOutboxReadDateParams{
UserID: req.OwnerUserID,
PeerType: string(req.Peer.Type),
PeerID: req.Peer.ID,
MessageID: int32(req.ID),
})
if err != nil {
return 0, fmt.Errorf("get outbox read date: %w", err)
}
if date == 0 {
return 0, domain.ErrMessageNotReadYet
}
return int(date), nil
}
type deletedUnreadMessages map[deletedOwnerPeerKey]map[int]struct{}
func loadDeleteUnreadCorrections(ctx context.Context, q *sqlcgen.Queries, deleted deletedUnreadMessages, date int) (map[int64][]domain.UpdateEvent, error) {
if len(deleted) == 0 {
return nil, nil
}
keys := make([]deletedOwnerPeerKey, 0, len(deleted))
for key := range deleted {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].userID != keys[j].userID {
return keys[i].userID < keys[j].userID
}
if keys[i].peer.Type != keys[j].peer.Type {
return keys[i].peer.Type < keys[j].peer.Type
}
return keys[i].peer.ID < keys[j].peer.ID
})
out := make(map[int64][]domain.UpdateEvent)
for _, key := range keys {
deletedIDs := deleted[key]
if len(deletedIDs) == 0 {
continue
}
correctionMaxID := maxDeletedMessageID(deletedIDs)
stillUnread := 0
state, err := q.GetDialogReadStateForUpdate(ctx, sqlcgen.GetDialogReadStateForUpdateParams{
UserID: key.userID,
PeerType: string(key.peer.Type),
PeerID: key.peer.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// dialog 行已随本次删除被移除peer 下已无存活消息),没有读边界
// 可推进,不应再对一个正在丢弃的会话发悬空的 read 校正。
continue
}
return nil, fmt.Errorf("load dialog read state after delete: %w", err)
}
readMax := int(state.ReadInboxMaxID)
stillUnread = int(state.UnreadCount)
correctionMaxID = readMax
for nextID := readMax + 1; ; nextID++ {
if _, ok := deletedIDs[nextID]; !ok {
break
}
correctionMaxID = nextID
}
// TDesktop stores unread as a boundary plus count. A read-history update
// must never advance the boundary across a still-live unread message, or the
// client will stop sending a real readHistory for that message while the
// server still considers it unread. Only deleted unread prefix items are safe
// to skip over here.
if correctionMaxID == readMax {
continue
}
out[key.userID] = append(out[key.userID], domain.UpdateEvent{
UserID: key.userID,
Type: domain.UpdateEventReadHistoryInbox,
PtsCount: 1,
Date: date,
Peer: key.peer,
MaxID: correctionMaxID,
StillUnreadCount: stillUnread,
})
}
return out, nil
}
func clampDialogReadInboxToMaxBox(ctx context.Context, tx pgx.Tx, ownerUserID int64, peer domain.Peer, maxID int) error {
_, err := tx.Exec(ctx, `
UPDATE dialogs
SET
read_inbox_max_id = $4,
unread_count = 0,
unread_mentions_count = 0,
unread_reactions_count = 0,
unread_mark = false,
updated_at = now()
WHERE user_id = $1
AND peer_type = $2
AND peer_id = $3
AND read_inbox_max_id > $4`, ownerUserID, string(peer.Type), peer.ID, maxID)
return err
}
// ListUnreadReactionMessages 返回当前 owner 在该 peer 下 reaction_unread 的
// 消息(含最新 reactions 聚合),供 messages.getUnreadReactions 跳转。
func (s *MessageStore) ListUnreadReactionMessages(ctx context.Context, ownerUserID int64, peer domain.Peer, limit int) ([]domain.Message, error) {
if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return nil, nil
}
if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit {
limit = domain.MaxChannelUnreadReactionsLimit
}
rows, err := s.q.ListUnreadReactionMessageBoxes(ctx, sqlcgen.ListUnreadReactionMessageBoxesParams{
OwnerUserID: ownerUserID,
PeerType: string(peer.Type),
PeerID: peer.ID,
PageLimit: int32(limit),
})
if err != nil {
return nil, fmt.Errorf("list unread reaction messages: %w", err)
}
out := make([]domain.Message, 0, len(rows))
for _, row := range rows {
out = append(out, messageFromBoxRow(sqlcgen.CreateMessageBoxRow(row)))
}
if err := s.enrichPrivateMessageReactions(ctx, s.db, ownerUserID, out); err != nil {
return nil, err
}
return out, nil
}
// ReadPeerReactions 清理当前 owner 在该 peer 下的全部未读 reaction 状态,
// 对应 messages.readReactions 的私聊分支。
//
// 必须在单事务内、且持与 SetMessageReactions 同键空间(owner, peer)的 advisory 锁:
// 否则「清 message_boxes.reaction_unread」与「重置 dialogs 计数」是两条独立 autocommit
// 语句,既有原子性缺口(stmt1 成功 stmt2 失败 → 底层已清但计数与缓存仍陈旧),又会与并发
// 入站 SetMessageReactions 交错产生 lost-update(本侧硬归零 clobber 掉对端刚自增的正确计数,
// 角标永久偏低)。计数按存活 reaction_unread 重算而非硬置 0:既保留历史坏数据的 stale-counter
// 自愈(无存活行时 COUNT 自然为 0),又在并发交错时只得出与当前 box 状态一致的值。
func (s *MessageStore) ReadPeerReactions(ctx context.Context, ownerUserID int64, peer domain.Peer) (int, error) {
if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return 0, nil
}
beginner, ok := s.db.(txBeginner)
if !ok {
return 0, fmt.Errorf("read peer reactions: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin read peer reactions tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := lockUsersForUpdate(ctx, tx, ownerUserID, peer.ID); err != nil {
return 0, fmt.Errorf("lock read peer reactions users: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE message_boxes
SET reaction_unread = false
WHERE owner_user_id = $1
AND peer_type = $2
AND peer_id = $3
AND NOT deleted
AND reaction_unread`, ownerUserID, string(peer.Type), peer.ID)
if err != nil {
return 0, fmt.Errorf("read peer reactions: %w", err)
}
// 计数始终重算(且无条件 UPDATE dialogs 行)以 bump dialog_light 失效缓存;
// COUNT 在同事务内读到清除后的 box 状态,无存活行即为 0保留 stale-counter 自愈。
if _, err := tx.Exec(ctx, `
UPDATE dialogs d
SET unread_reactions_count = (
SELECT COUNT(*)::int
FROM message_boxes m
WHERE m.owner_user_id = d.user_id
AND m.peer_type = d.peer_type
AND m.peer_id = d.peer_id
AND NOT m.deleted
AND m.reaction_unread
),
updated_at = now()
WHERE d.user_id = $1 AND d.peer_type = $2 AND d.peer_id = $3`, ownerUserID, string(peer.Type), peer.ID); err != nil {
return 0, fmt.Errorf("reset dialog unread reactions: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit read peer reactions tx: %w", err)
}
committed = true
return int(tag.RowsAffected()), nil
}