chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,469 @@
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
}