1054 lines
37 KiB
Go
1054 lines
37 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"github.com/jackc/pgx/v5"
|
||
"sort"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/postgres/sqlcgen"
|
||
)
|
||
|
||
func channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr string) string {
|
||
// LIMIT 子查询把动态未读 COUNT 钳到 MaxDialogUnreadCount(P1-v):既限定扫描工作量
|
||
// (最多 cap 行,避免广播/大群积压时 O(积压) 扫描),又把下发角标钳到上界。
|
||
return fmt.Sprintf(`(
|
||
SELECT COUNT(*)::int
|
||
FROM (
|
||
SELECT 1
|
||
FROM channel_messages cm_unread
|
||
WHERE cm_unread.channel_id = c.id
|
||
AND cm_unread.id > GREATEST(%s, m.available_min_id)
|
||
AND cm_unread.id <= %s
|
||
AND NOT cm_unread.deleted
|
||
AND cm_unread.sender_user_id <> m.user_id
|
||
LIMIT %d
|
||
) cm_unread_capped
|
||
)`, readInboxExpr, topIDExpr, domain.MaxDialogUnreadCount)
|
||
}
|
||
|
||
func channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr string) string {
|
||
return fmt.Sprintf(`EXISTS (
|
||
SELECT 1
|
||
FROM channel_messages cm_unread
|
||
WHERE cm_unread.channel_id = c.id
|
||
AND cm_unread.id > GREATEST(%s, m.available_min_id)
|
||
AND cm_unread.id <= %s
|
||
AND NOT cm_unread.deleted
|
||
AND cm_unread.sender_user_id <> m.user_id
|
||
)`, readInboxExpr, topIDExpr)
|
||
}
|
||
|
||
func channelDialogVisibleUnreadCountSQL(readInboxExpr, topIDExpr string) string {
|
||
dynamicCount := channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr)
|
||
// LEAST 钳制缓存分支 d.unread_count(小群走缓存列,可能因逐条自增长过上界)到 P1-v 上界;
|
||
// 动态分支已被 LIMIT 子查询钳过,外层 LEAST 对两分支统一封顶,保证下发角标 ≤ 上界。
|
||
return fmt.Sprintf(`LEAST(CASE
|
||
WHEN c.broadcast OR c.participants_count > %d THEN %s
|
||
ELSE COALESCE(d.unread_count, %s)
|
||
END, %d)`, domain.MaxSynchronousChannelDialogFanout, dynamicCount, dynamicCount, domain.MaxDialogUnreadCount)
|
||
}
|
||
|
||
func channelDialogHasUnreadSQL(readInboxExpr, topIDExpr string) string {
|
||
dynamicUnread := channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr)
|
||
return fmt.Sprintf(`CASE
|
||
WHEN c.broadcast OR c.participants_count > %d THEN %s
|
||
ELSE COALESCE(d.unread_count > 0, %s)
|
||
END`, domain.MaxSynchronousChannelDialogFanout, dynamicUnread, dynamicUnread)
|
||
}
|
||
|
||
func (s *ChannelStore) SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error) {
|
||
if userID == 0 || channelID == 0 {
|
||
return false, nil
|
||
}
|
||
var changed bool
|
||
if err := s.db.QueryRow(ctx, `
|
||
WITH target AS (
|
||
SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date,
|
||
m.read_inbox_max_id, m.read_outbox_max_id, m.available_min_id
|
||
FROM channels c
|
||
JOIN channel_members m ON m.channel_id = c.id
|
||
WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted
|
||
),
|
||
ensured AS (
|
||
-- 惰性建行必须带 member 真实水位与未读数:0 值缓存行一旦存在就会
|
||
-- 遮蔽真值(小群读取信缓存列)。
|
||
INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date,
|
||
read_inbox_max_id, read_outbox_max_id, unread_count)
|
||
SELECT $1, channel_id, top_message_id, top_message_date,
|
||
read_inbox_max_id, read_outbox_max_id, (
|
||
SELECT COUNT(*)::int
|
||
FROM channel_messages cm
|
||
WHERE cm.channel_id = target.channel_id
|
||
AND cm.id > GREATEST(target.read_inbox_max_id, target.available_min_id)
|
||
AND NOT cm.deleted
|
||
AND cm.sender_user_id <> $1
|
||
)
|
||
FROM target
|
||
ON CONFLICT (user_id, channel_id) DO NOTHING
|
||
),
|
||
updated_dialog AS (
|
||
UPDATE channel_dialogs d
|
||
SET unread_mark = $3, updated_at = now()
|
||
WHERE d.user_id = $1 AND d.channel_id = $2
|
||
AND EXISTS (SELECT 1 FROM target)
|
||
AND d.unread_mark IS DISTINCT FROM $3::boolean
|
||
RETURNING d.user_id
|
||
),
|
||
updated_member AS (
|
||
UPDATE channel_members m
|
||
SET unread_mark = $3
|
||
WHERE m.user_id = $1 AND m.channel_id = $2 AND m.status = 'active'
|
||
RETURNING m.user_id
|
||
)
|
||
SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, unread).Scan(&changed); err != nil {
|
||
return false, fmt.Errorf("set channel dialog unread mark: %w", err)
|
||
}
|
||
// 连接池写后同步失效本进程缓存(dialog 的 unread_mark 与 member 的 unread_mark 都被改),
|
||
// 保证同实例 read-your-write;跨实例由 dialog_light / channel_member NOTIFY 异步兜底。
|
||
if changed && s.dialogCacheActive(s.db) {
|
||
s.dialogCache.delete(userID, channelID)
|
||
s.memberCache.delete(channelID, userID)
|
||
}
|
||
return changed, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) {
|
||
if userID == 0 {
|
||
return nil, nil
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT d.channel_id
|
||
FROM channel_dialogs d
|
||
JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active'
|
||
JOIN channels c ON c.id = d.channel_id AND NOT c.deleted
|
||
WHERE d.user_id = $1 AND d.unread_mark
|
||
ORDER BY d.top_message_date DESC, d.top_message_id DESC, d.channel_id DESC
|
||
LIMIT 500`, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list channel unread marks: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]domain.Peer, 0)
|
||
for rows.Next() {
|
||
var channelID int64
|
||
if err := rows.Scan(&channelID); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) {
|
||
if req.UserID == 0 || req.ChannelID == 0 {
|
||
return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel message contents: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("begin read channel message contents: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||
if err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, err
|
||
}
|
||
if len(req.IDs) == 0 {
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err)
|
||
}
|
||
committed = true
|
||
return domain.ReadChannelMessageContentsResult{Channel: channel}, nil
|
||
}
|
||
if len(req.IDs) > domain.MaxGetMessageIDs {
|
||
return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid
|
||
}
|
||
id32, _, err := validUniqueChannelMessageIDs(req.IDs)
|
||
if err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, err
|
||
}
|
||
args := []any{req.ChannelID, id32}
|
||
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted"
|
||
if member.AvailableMinID > 0 {
|
||
args = append(args, member.AvailableMinID)
|
||
where += fmt.Sprintf(" AND id > $%d", len(args))
|
||
}
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT `+channelMessageColumns+`
|
||
FROM channel_messages
|
||
WHERE `+where+`
|
||
ORDER BY id DESC`, args...)
|
||
if err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel messages by ids: %w", err)
|
||
}
|
||
messages := make([]domain.ChannelMessage, 0, len(id32))
|
||
for rows.Next() {
|
||
msg, err := scanChannelMessage(rows)
|
||
if err != nil {
|
||
rows.Close()
|
||
return domain.ReadChannelMessageContentsResult{}, err
|
||
}
|
||
messages = append(messages, msg)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
rows.Close()
|
||
return domain.ReadChannelMessageContentsResult{}, err
|
||
}
|
||
rows.Close()
|
||
visibleIDs := make([]int32, 0, len(messages))
|
||
for _, msg := range messages {
|
||
visibleIDs = append(visibleIDs, int32(msg.ID))
|
||
}
|
||
cleared, err := clearChannelUnreadReactionsForMessageIDsTx(ctx, tx, req.UserID, req.ChannelID, visibleIDs)
|
||
if err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, err
|
||
}
|
||
clearedMentions, err := readChannelMentionsForMessageIDsTx(ctx, tx, req.UserID, req.ChannelID, visibleIDs)
|
||
if err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, err
|
||
}
|
||
if err := s.populateChannelMessageReplies(ctx, tx, req.UserID, channel, messages); err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, err
|
||
}
|
||
if err := s.populateChannelMessagesReactions(ctx, tx, req.UserID, []domain.Channel{channel}, messages); err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err)
|
||
}
|
||
committed = true
|
||
return domain.ReadChannelMessageContentsResult{
|
||
Channel: channel,
|
||
Messages: messages,
|
||
ClearedUnreadReactionMessageIDs: cleared,
|
||
ClearedUnreadMentionMessageIDs: clearedMentions,
|
||
}, nil
|
||
}
|
||
|
||
// readChannelMentionsForMessageIDsTx 把指定可见消息上的未读 mention 翻转为
|
||
// 已读并重算 dialog 计数;视口内容已读(readMessageContents)的主路径。
|
||
func readChannelMentionsForMessageIDsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, ids []int32) ([]int, error) {
|
||
if len(ids) == 0 {
|
||
return nil, nil
|
||
}
|
||
rows, err := tx.Query(ctx, `
|
||
WITH flipped AS (
|
||
UPDATE channel_unread_mentions
|
||
SET unread = false
|
||
WHERE user_id = $1 AND channel_id = $2 AND message_id = ANY($3::int[]) AND unread
|
||
RETURNING message_id
|
||
),
|
||
updated_dialog AS (
|
||
UPDATE channel_dialogs d
|
||
SET unread_mentions_count = (
|
||
SELECT COUNT(*)::int
|
||
FROM channel_unread_mentions um
|
||
WHERE um.user_id = $1 AND um.channel_id = $2 AND um.unread
|
||
AND NOT EXISTS (SELECT 1 FROM flipped f WHERE f.message_id = um.message_id)
|
||
),
|
||
updated_at = now()
|
||
WHERE d.user_id = $1 AND d.channel_id = $2
|
||
AND EXISTS (SELECT 1 FROM flipped)
|
||
)
|
||
SELECT message_id FROM flipped ORDER BY message_id`, userID, channelID, ids)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read channel mentions by contents: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]int, 0, len(ids))
|
||
for rows.Next() {
|
||
var id int
|
||
if err := rows.Scan(&id); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, id)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *ChannelStore) ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) {
|
||
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
limit := filter.Limit
|
||
if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit {
|
||
limit = domain.MaxChannelUnreadMentionsLimit
|
||
}
|
||
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
|
||
count, err := s.countChannelUnreadMentions(ctx, viewerUserID, filter, member.AvailableMinID)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
messages, err := s.queryChannelUnreadMentionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ReadChannelMentions(ctx context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) {
|
||
if req.UserID == 0 || req.ChannelID == 0 {
|
||
return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.ReadChannelMentionsResult{}, fmt.Errorf("read channel mentions: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.ReadChannelMentionsResult{}, fmt.Errorf("begin read channel mentions: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||
if err != nil {
|
||
return domain.ReadChannelMentionsResult{}, err
|
||
}
|
||
limit := req.Limit
|
||
if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch {
|
||
limit = domain.MaxChannelReadMentionsBatch
|
||
}
|
||
cleared, remaining, err := readChannelMentionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit)
|
||
if err != nil {
|
||
return domain.ReadChannelMentionsResult{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.ReadChannelMentionsResult{}, fmt.Errorf("commit read channel mentions: %w", err)
|
||
}
|
||
committed = true
|
||
offset := 0
|
||
if remaining > 0 {
|
||
offset = 1
|
||
}
|
||
return domain.ReadChannelMentionsResult{
|
||
Channel: channel,
|
||
Cleared: cleared,
|
||
Remaining: remaining,
|
||
Offset: offset,
|
||
ChannelPts: channel.Pts,
|
||
}, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
|
||
var lastErr error
|
||
for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ {
|
||
res, err := s.readChannelHistoryOnce(ctx, req)
|
||
if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil {
|
||
return res, err
|
||
}
|
||
lastErr = err
|
||
}
|
||
return domain.ReadChannelHistoryResult{}, lastErr
|
||
}
|
||
|
||
func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) {
|
||
if maxID <= previous {
|
||
return nil, nil
|
||
}
|
||
lowerID := previous
|
||
if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages {
|
||
lowerID = maxID - domain.MaxChannelReadOutboxScanMessages
|
||
}
|
||
rows, err := tx.Query(ctx, `
|
||
WITH latest_sender_messages AS (
|
||
SELECT sender_user_id, MAX(id) AS max_id
|
||
FROM channel_messages
|
||
WHERE channel_id = $1
|
||
AND id > $2
|
||
AND id <= $3
|
||
AND NOT deleted
|
||
AND sender_user_id <> $4
|
||
GROUP BY sender_user_id
|
||
ORDER BY max_id DESC
|
||
LIMIT $5
|
||
)
|
||
SELECT sender_user_id, max_id
|
||
FROM latest_sender_messages
|
||
ORDER BY sender_user_id ASC`, channel.ID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list channel read outbox senders: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
type candidate struct {
|
||
userID int64
|
||
maxID int
|
||
}
|
||
candidates := make([]candidate, 0, domain.MaxChannelReadOutboxFanout)
|
||
for rows.Next() {
|
||
var item candidate
|
||
if err := rows.Scan(&item.userID, &item.maxID); err != nil {
|
||
return nil, err
|
||
}
|
||
candidates = append(candidates, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]domain.ChannelReadOutboxUpdate, 0, len(candidates))
|
||
for _, item := range candidates {
|
||
var readOutboxMaxID, readInboxMaxID int
|
||
err := tx.QueryRow(ctx, `
|
||
UPDATE channel_members
|
||
SET read_outbox_max_id = GREATEST(read_outbox_max_id, $3),
|
||
updated_at = now()
|
||
WHERE channel_id = $1
|
||
AND user_id = $2
|
||
AND status = 'active'
|
||
AND read_outbox_max_id < $3
|
||
RETURNING read_outbox_max_id, read_inbox_max_id`, channel.ID, item.userID, item.maxID).Scan(&readOutboxMaxID, &readInboxMaxID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("update channel sender read outbox: %w", err)
|
||
}
|
||
if err := upsertChannelDialogTx(ctx, tx, item.userID, channel, top, readInboxMaxID, readOutboxMaxID); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, domain.ChannelReadOutboxUpdate{UserID: item.userID, MaxID: readOutboxMaxID})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) {
|
||
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
|
||
if err != nil {
|
||
return domain.ChannelReadParticipantsResult{}, err
|
||
}
|
||
if req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
|
||
return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid
|
||
}
|
||
msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID)
|
||
if err != nil {
|
||
return domain.ChannelReadParticipantsResult{}, err
|
||
}
|
||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||
return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid
|
||
}
|
||
result := domain.ChannelReadParticipantsResult{Channel: channel, Message: msg}
|
||
if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants {
|
||
return result, nil
|
||
}
|
||
if req.Date > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= req.Date {
|
||
return result, nil
|
||
}
|
||
limit := req.Limit
|
||
if limit <= 0 || limit > domain.MaxChannelReadParticipants {
|
||
limit = domain.MaxChannelReadParticipants
|
||
}
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT user_id, read_inbox_date
|
||
FROM channel_members
|
||
WHERE channel_id = $1
|
||
AND status = 'active'
|
||
AND user_id <> $2
|
||
AND available_min_id < $3
|
||
AND read_inbox_max_id >= $3
|
||
AND read_inbox_date > 0
|
||
AND NOT COALESCE((banned_rights->>'ViewMessages')::boolean, false)
|
||
ORDER BY read_inbox_date ASC, user_id ASC
|
||
LIMIT $4`, req.ChannelID, req.UserID, req.MessageID, limit)
|
||
if err != nil {
|
||
return domain.ChannelReadParticipantsResult{}, fmt.Errorf("list channel read participants: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var item domain.ChannelReadParticipant
|
||
if err := rows.Scan(&item.UserID, &item.Date); err != nil {
|
||
return domain.ChannelReadParticipantsResult{}, err
|
||
}
|
||
result.Participants = append(result.Participants, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return domain.ChannelReadParticipantsResult{}, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func populateChannelMessageUnreadFlags(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.ChannelMessage) error {
|
||
if viewerUserID == 0 || len(messages) == 0 {
|
||
return nil
|
||
}
|
||
indexes := make(map[channelReactionMessageKey][]int)
|
||
idsByChannel := make(map[int64][]int32)
|
||
for i := range messages {
|
||
if messages[i].ChannelID == 0 || messages[i].ID <= 0 {
|
||
continue
|
||
}
|
||
key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID}
|
||
if _, ok := indexes[key]; !ok {
|
||
idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID))
|
||
}
|
||
indexes[key] = append(indexes[key], i)
|
||
}
|
||
if len(idsByChannel) == 0 {
|
||
return nil
|
||
}
|
||
// 跨频道一次批量取,消除「每个频道一条 SQL」的 N+1(getDialogs/getChannelDifference 多频道页放大)。
|
||
pairChannels, pairMessages := channelMessagePairs(idsByChannel)
|
||
{
|
||
rows, err := db.Query(ctx, `
|
||
SELECT channel_id, message_id, unread
|
||
FROM channel_unread_mentions
|
||
WHERE user_id = $1
|
||
AND (channel_id, message_id) IN (SELECT * FROM unnest($2::bigint[], $3::int[]))`, viewerUserID, pairChannels, pairMessages)
|
||
if err != nil {
|
||
return fmt.Errorf("load channel message unread flags: %w", err)
|
||
}
|
||
for rows.Next() {
|
||
var channelID int64
|
||
var messageID int
|
||
var unread bool
|
||
if err := rows.Scan(&channelID, &messageID, &unread); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
key := channelReactionMessageKey{channelID: channelID, messageID: messageID}
|
||
for _, idx := range indexes[key] {
|
||
// mentioned 永久保留(官方语义);客户端的未读提及判定要求
|
||
// mentioned 与 media_unread 同时置位,media_unread 跟随 mention
|
||
// 的未读状态而非消息是否含媒体。
|
||
messages[idx].Mentioned = true
|
||
messages[idx].MediaUnread = unread
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
rows.Close()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *ChannelStore) channelReadWatermarks(ctx context.Context, channelID, userID int64) (int, int) {
|
||
var inbox, outbox int
|
||
_ = s.db.QueryRow(ctx, `SELECT read_inbox_max_id, read_outbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, userID).Scan(&inbox, &outbox)
|
||
return inbox, outbox
|
||
}
|
||
|
||
func (s *ChannelStore) channelThreadUnreadCount(ctx context.Context, channelID int64, rootID int, viewerUserID int64, readMaxID int) int {
|
||
var count int
|
||
// LIMIT 子查询把 thread 未读 COUNT 钳到上界(P1-v):限定扫描工作量与下发角标。
|
||
_ = s.db.QueryRow(ctx, `
|
||
SELECT COUNT(*)::int
|
||
FROM (
|
||
SELECT 1
|
||
FROM channel_messages
|
||
WHERE channel_id = $1 AND reply_to_top_id = $2 AND id > $3 AND sender_user_id <> $4 AND NOT deleted
|
||
LIMIT $5
|
||
) thread_unread_capped`, channelID, rootID, readMaxID, viewerUserID, domain.MaxDialogUnreadCount).Scan(&count)
|
||
return count
|
||
}
|
||
|
||
func countChannelUnreadMessages(ctx context.Context, db sqlcgen.DBTX, userID, channelID int64, readMaxID, topID int) (int, error) {
|
||
if userID == 0 || channelID == 0 || topID <= readMaxID {
|
||
return 0, nil
|
||
}
|
||
var count int
|
||
// LIMIT 子查询把未读 COUNT 钳到上界(P1-v):该值会写回 channel_dialogs.unread_count 缓存,
|
||
// 钳制既限定扫描工作量也限定缓存列上界,与动态/可见投影口径一致。
|
||
if err := db.QueryRow(ctx, `
|
||
SELECT COUNT(*)::int
|
||
FROM (
|
||
SELECT 1
|
||
FROM channel_messages
|
||
WHERE channel_id = $1
|
||
AND id > $2
|
||
AND id <= $3
|
||
AND sender_user_id <> $4
|
||
AND NOT deleted
|
||
LIMIT $5
|
||
) unread_capped`, channelID, readMaxID, topID, userID, domain.MaxDialogUnreadCount).Scan(&count); err != nil {
|
||
return 0, fmt.Errorf("count channel unread messages: %w", err)
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
func (s *ChannelStore) countChannelUnreadMentionsForTop(ctx context.Context, userID, channelID int64, topMsgID int) int {
|
||
var count int
|
||
_ = s.db.QueryRow(ctx, `
|
||
SELECT COUNT(*)::int
|
||
FROM channel_unread_mentions
|
||
WHERE user_id = $1 AND channel_id = $2 AND unread
|
||
AND (top_message_id = $3 OR ($3 = 1 AND top_message_id = 0))`, userID, channelID, topMsgID).Scan(&count)
|
||
return count
|
||
}
|
||
|
||
func (s *ChannelStore) countChannelUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (int, error) {
|
||
where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID)
|
||
var count int
|
||
if err := s.db.QueryRow(ctx, `
|
||
SELECT COUNT(*)::int
|
||
FROM channel_unread_mentions um
|
||
JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id
|
||
WHERE `+where, args...).Scan(&count); err != nil {
|
||
return 0, fmt.Errorf("count channel unread mentions: %w", err)
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
func (s *ChannelStore) queryChannelUnreadMentionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) {
|
||
if limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
switch messageHistoryLoadType(filter.AddOffset, limit) {
|
||
case messageHistoryLoadForward:
|
||
return s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, limit)
|
||
case messageHistoryLoadAround:
|
||
forwardLimit := -filter.AddOffset
|
||
if forwardLimit > limit {
|
||
forwardLimit = limit
|
||
}
|
||
backwardLimit := limit + filter.AddOffset
|
||
if backwardLimit < 0 {
|
||
backwardLimit = 0
|
||
}
|
||
forward, err := s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, forwardLimit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
backward, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := append(forward, backward...)
|
||
sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) })
|
||
return out, nil
|
||
default:
|
||
start := filter.AddOffset
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
items, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, limit+start, false)
|
||
if err != nil || start >= len(items) {
|
||
return nil, err
|
||
}
|
||
return items[start:], nil
|
||
}
|
||
}
|
||
|
||
func (s *ChannelStore) queryChannelUnreadMentionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) {
|
||
if limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID)
|
||
where, args = appendChannelUnreadMentionBackwardOffset(where, args, filter, includeOffset)
|
||
args = append(args, limit)
|
||
return s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "DESC")
|
||
}
|
||
|
||
func (s *ChannelStore) queryChannelUnreadMentionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) {
|
||
if limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID)
|
||
where, args = appendChannelUnreadMentionForwardOffset(where, args, filter)
|
||
args = append(args, limit)
|
||
out, err := s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "ASC")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) })
|
||
return out, nil
|
||
}
|
||
|
||
func (s *ChannelStore) queryChannelUnreadMentions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) {
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+channelMessageColumns+`
|
||
FROM channel_messages
|
||
WHERE channel_id = $2
|
||
AND id = ANY(ARRAY(
|
||
SELECT cm.id
|
||
FROM channel_unread_mentions um
|
||
JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id
|
||
WHERE `+where+`
|
||
ORDER BY cm.id `+order+`
|
||
LIMIT $`+fmt.Sprint(len(args))+`
|
||
)::int[])
|
||
ORDER BY id `+order, args...)
|
||
_ = channelID
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list channel unread mentions: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := make([]domain.ChannelMessage, 0)
|
||
for rows.Next() {
|
||
msg, err := scanChannelMessage(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, msg)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func channelUnreadMentionBaseWhere(userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (string, []any) {
|
||
args := []any{userID, filter.ChannelID}
|
||
where := "um.user_id = $1 AND um.channel_id = $2 AND um.unread AND NOT cm.deleted"
|
||
if availableMinID > 0 {
|
||
args = append(args, availableMinID)
|
||
where += fmt.Sprintf(" AND cm.id > $%d", len(args))
|
||
}
|
||
if filter.TopMsgID > 0 {
|
||
args = append(args, filter.TopMsgID)
|
||
// forum General(root=1)的无主题消息存储 top=0。
|
||
where += fmt.Sprintf(" AND (um.top_message_id = $%d OR ($%d = 1 AND um.top_message_id = 0))", len(args), len(args))
|
||
}
|
||
if filter.MaxID > 0 {
|
||
args = append(args, filter.MaxID)
|
||
where += fmt.Sprintf(" AND cm.id < $%d", len(args))
|
||
}
|
||
if filter.MinID > 0 {
|
||
args = append(args, filter.MinID)
|
||
where += fmt.Sprintf(" AND cm.id > $%d", len(args))
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func appendChannelUnreadMentionBackwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter, include bool) (string, []any) {
|
||
if filter.OffsetDate > 0 {
|
||
args = append(args, filter.OffsetDate)
|
||
if include {
|
||
return where + fmt.Sprintf(" AND cm.message_date <= $%d", len(args)), args
|
||
}
|
||
return where + fmt.Sprintf(" AND cm.message_date < $%d", len(args)), args
|
||
}
|
||
if filter.OffsetID > 0 {
|
||
args = append(args, filter.OffsetID)
|
||
if include {
|
||
return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args
|
||
}
|
||
return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func appendChannelUnreadMentionForwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter) (string, []any) {
|
||
if filter.OffsetDate > 0 {
|
||
args = append(args, filter.OffsetDate)
|
||
return where + fmt.Sprintf(" AND cm.message_date >= $%d", len(args)), args
|
||
}
|
||
if filter.OffsetID > 0 {
|
||
args = append(args, filter.OffsetID)
|
||
return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func insertChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) error {
|
||
candidates := uniqueChannelUserIDs(userIDs, senderUserID)
|
||
if len(candidates) == 0 || msg.ID == 0 {
|
||
return nil
|
||
}
|
||
if len(candidates) > domain.MaxChannelMentionRecipients {
|
||
candidates = candidates[:domain.MaxChannelMentionRecipients]
|
||
}
|
||
topID := channelMentionTopID(msg)
|
||
mediaUnread := !msg.Media.IsZero()
|
||
if _, err := tx.Exec(ctx, `
|
||
WITH input(user_id) AS (
|
||
SELECT DISTINCT unnest($4::bigint[])
|
||
),
|
||
active AS (
|
||
SELECT i.user_id
|
||
FROM input i
|
||
JOIN channel_members m ON m.channel_id = $1 AND m.user_id = i.user_id
|
||
WHERE m.status = 'active'
|
||
AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false)
|
||
AND $2 > m.available_min_id
|
||
AND $2 > m.read_inbox_max_id
|
||
LIMIT $6
|
||
),
|
||
inserted AS (
|
||
INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id, media_unread)
|
||
SELECT user_id, $1, $2, $3, $7
|
||
FROM active
|
||
ON CONFLICT DO NOTHING
|
||
RETURNING user_id, channel_id, message_id, created_at
|
||
),
|
||
indexed AS (
|
||
INSERT INTO channel_unread_mention_index (channel_id, message_id, user_id, created_at)
|
||
SELECT channel_id, message_id, user_id, created_at
|
||
FROM inserted
|
||
ON CONFLICT DO NOTHING
|
||
)
|
||
INSERT INTO channel_dialogs (
|
||
user_id, channel_id, top_message_id, top_message_date,
|
||
read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count
|
||
)
|
||
SELECT i.user_id, $1, $2, $5,
|
||
m.read_inbox_max_id, m.read_outbox_max_id,
|
||
(
|
||
SELECT COUNT(*)::int
|
||
FROM channel_messages cm
|
||
WHERE cm.channel_id = $1
|
||
AND cm.id > GREATEST(m.read_inbox_max_id, m.available_min_id)
|
||
AND NOT cm.deleted
|
||
AND cm.sender_user_id <> i.user_id
|
||
),
|
||
1
|
||
FROM inserted i
|
||
JOIN channel_members m ON m.channel_id = $1 AND m.user_id = i.user_id
|
||
ON CONFLICT (user_id, channel_id) DO UPDATE SET
|
||
top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id),
|
||
top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date),
|
||
unread_mentions_count = channel_dialogs.unread_mentions_count + 1,
|
||
updated_at = now()`, channelID, msg.ID, topID, candidates, msg.Date, domain.MaxChannelMentionRecipients, mediaUnread); err != nil {
|
||
return fmt.Errorf("insert channel unread mentions: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func readChannelMentionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) {
|
||
var cleared, remaining int
|
||
if err := tx.QueryRow(ctx, `
|
||
WITH target AS (
|
||
SELECT user_id, channel_id, message_id
|
||
FROM channel_unread_mentions
|
||
WHERE user_id = $1
|
||
AND channel_id = $2
|
||
AND unread
|
||
AND ($3 = 0 OR top_message_id = $3 OR ($3 = 1 AND top_message_id = 0))
|
||
ORDER BY message_id DESC
|
||
LIMIT $4
|
||
),
|
||
deleted AS (
|
||
-- 已读=翻转标记而非删行:mentioned 高亮在历史回放中永久保留。
|
||
UPDATE channel_unread_mentions um
|
||
SET unread = false
|
||
FROM target t
|
||
WHERE um.user_id = t.user_id
|
||
AND um.channel_id = t.channel_id
|
||
AND um.message_id = t.message_id
|
||
RETURNING um.user_id, um.channel_id, um.message_id
|
||
),
|
||
scoped_before AS (
|
||
SELECT COUNT(*)::int AS count
|
||
FROM channel_unread_mentions
|
||
WHERE user_id = $1
|
||
AND channel_id = $2
|
||
AND unread
|
||
AND ($3 = 0 OR top_message_id = $3 OR ($3 = 1 AND top_message_id = 0))
|
||
),
|
||
all_before AS (
|
||
SELECT COUNT(*)::int AS count
|
||
FROM channel_unread_mentions
|
||
WHERE user_id = $1 AND channel_id = $2 AND unread
|
||
),
|
||
deleted_count AS (
|
||
SELECT COUNT(*)::int AS count FROM deleted
|
||
),
|
||
updated_dialog AS (
|
||
UPDATE channel_dialogs
|
||
SET unread_mentions_count = GREATEST((SELECT count FROM all_before) - (SELECT count FROM deleted_count), 0),
|
||
updated_at = now()
|
||
WHERE user_id = $1 AND channel_id = $2
|
||
)
|
||
SELECT
|
||
(SELECT count FROM deleted_count),
|
||
GREATEST((SELECT count FROM scoped_before) - (SELECT count FROM deleted_count), 0)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil {
|
||
return 0, 0, fmt.Errorf("read channel mentions: %w", err)
|
||
}
|
||
return cleared, remaining, nil
|
||
}
|
||
|
||
func deleteChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
affected, err := channelUnreadMentionAffectedUsersTx(ctx, tx, channelID, ids)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for start := 0; start < len(affected); start += channelUnreadMentionDeleteUserBatch {
|
||
end := start + channelUnreadMentionDeleteUserBatch
|
||
if end > len(affected) {
|
||
end = len(affected)
|
||
}
|
||
if err := deleteChannelUnreadMentionsForUsersTx(ctx, tx, channelID, ids, affected[start:end]); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
const channelUnreadMentionDeleteUserBatch = 1000
|
||
|
||
func channelUnreadMentionAffectedUsersTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) ([]int64, error) {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT DISTINCT user_id
|
||
FROM channel_unread_mention_index
|
||
WHERE channel_id = $1
|
||
AND message_id = ANY($2::int[])
|
||
ORDER BY user_id ASC`, channelID, int32s(ids))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list channel unread mention affected users: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []int64
|
||
for rows.Next() {
|
||
var userID int64
|
||
if err := rows.Scan(&userID); err != nil {
|
||
return nil, fmt.Errorf("scan channel unread mention affected user: %w", err)
|
||
}
|
||
out = append(out, userID)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("read channel unread mention affected users: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func deleteChannelUnreadMentionsForUsersTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int, userIDs []int64) error {
|
||
if len(ids) == 0 || len(userIDs) == 0 {
|
||
return nil
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
WITH affected AS (
|
||
SELECT DISTINCT unnest($3::bigint[]) AS user_id
|
||
),
|
||
deleted_mentions AS (
|
||
DELETE FROM channel_unread_mentions um
|
||
USING affected a
|
||
WHERE um.user_id = ANY($3::bigint[])
|
||
AND um.user_id = a.user_id
|
||
AND um.channel_id = $1
|
||
AND um.message_id = ANY($2::int[])
|
||
RETURNING um.user_id, um.channel_id, um.message_id, um.unread
|
||
),
|
||
deleted_index AS (
|
||
DELETE FROM channel_unread_mention_index i
|
||
USING affected a
|
||
WHERE i.channel_id = $1
|
||
AND i.user_id = a.user_id
|
||
AND i.message_id = ANY($2::int[])
|
||
),
|
||
counts_before AS (
|
||
SELECT user_id, COUNT(*)::int AS count
|
||
FROM channel_unread_mentions
|
||
WHERE channel_id = $1
|
||
AND user_id = ANY($3::bigint[])
|
||
AND unread
|
||
GROUP BY user_id
|
||
),
|
||
deleted_counts AS (
|
||
SELECT user_id, COUNT(*)::int AS count
|
||
FROM deleted_mentions
|
||
WHERE unread
|
||
GROUP BY user_id
|
||
)
|
||
UPDATE channel_dialogs d
|
||
SET unread_mentions_count = GREATEST(COALESCE(c.count, 0) - COALESCE(dc.count, 0), 0),
|
||
updated_at = now()
|
||
FROM affected a
|
||
LEFT JOIN counts_before c ON c.user_id = a.user_id
|
||
LEFT JOIN deleted_counts dc ON dc.user_id = a.user_id
|
||
WHERE d.user_id = ANY($3::bigint[])
|
||
AND d.channel_id = $1
|
||
AND d.user_id = a.user_id`, channelID, int32s(ids), int64s(userIDs)); err != nil {
|
||
return fmt.Errorf("delete channel unread mentions: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func deleteChannelUnreadMentionsUpToTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, maxID int) error {
|
||
if maxID <= 0 {
|
||
return nil
|
||
}
|
||
var deleted int
|
||
if err := tx.QueryRow(ctx, `
|
||
WITH deleted AS (
|
||
DELETE FROM channel_unread_mentions
|
||
WHERE user_id = $1 AND channel_id = $2 AND message_id <= $3
|
||
RETURNING user_id, channel_id, message_id, unread
|
||
),
|
||
deleted_index AS (
|
||
DELETE FROM channel_unread_mention_index i
|
||
USING deleted d
|
||
WHERE i.channel_id = d.channel_id
|
||
AND i.user_id = d.user_id
|
||
AND i.message_id = d.message_id
|
||
),
|
||
all_before AS (
|
||
SELECT COUNT(*)::int AS count
|
||
FROM channel_unread_mentions
|
||
WHERE user_id = $1 AND channel_id = $2 AND unread
|
||
),
|
||
deleted_count AS (
|
||
SELECT COUNT(*)::int AS count FROM deleted WHERE unread
|
||
),
|
||
updated_dialog AS (
|
||
UPDATE channel_dialogs
|
||
SET unread_mentions_count = GREATEST((SELECT count FROM all_before) - (SELECT count FROM deleted_count), 0),
|
||
updated_at = now()
|
||
WHERE user_id = $1 AND channel_id = $2
|
||
)
|
||
SELECT count FROM deleted_count`, userID, channelID, maxID).Scan(&deleted); err != nil {
|
||
return fmt.Errorf("delete channel unread mentions up to: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func channelMentionTopID(msg domain.ChannelMessage) int {
|
||
if msg.ReplyTo == nil {
|
||
return 0
|
||
}
|
||
if msg.ReplyTo.TopMessageID > 0 {
|
||
return msg.ReplyTo.TopMessageID
|
||
}
|
||
return msg.ReplyTo.MessageID
|
||
}
|
||
|
||
// clearChannelMentionsForUserTx 在成员离开/被踢时清空其该频道的全部提及
|
||
// 状态,避免重新加入后出现指向入群前消息的 @ 角标。
|
||
func clearChannelMentionsForUserTx(ctx context.Context, tx pgx.Tx, channelID, userID int64) error {
|
||
if channelID == 0 || userID == 0 {
|
||
return nil
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
WITH deleted AS (
|
||
DELETE FROM channel_unread_mentions
|
||
WHERE user_id = $2 AND channel_id = $1
|
||
RETURNING message_id
|
||
),
|
||
deleted_index AS (
|
||
DELETE FROM channel_unread_mention_index i
|
||
USING deleted d
|
||
WHERE i.channel_id = $1 AND i.user_id = $2 AND i.message_id = d.message_id
|
||
)
|
||
UPDATE channel_dialogs
|
||
SET unread_mentions_count = 0, updated_at = now()
|
||
WHERE user_id = $2 AND channel_id = $1`, channelID, userID); err != nil {
|
||
return fmt.Errorf("clear channel mentions on leave: %w", err)
|
||
}
|
||
return nil
|
||
}
|