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

457 lines
17 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"
"github.com/jackc/pgx/v5"
"sort"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
if len(req.IDs) > domain.MaxDeleteMessageIDs {
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
}
if req.Date == 0 {
req.Date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("delete channel messages: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin delete channel messages: %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.DeleteChannelMessagesResult{}, err
}
refs, err := s.discussionRefsForMessages(ctx, tx, channel.ID, req.IDs)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, req.IDs, req.UserID, req.Date)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
cascades, err := s.cascadeDeleteDiscussionRootsTx(ctx, tx, refs, deleted, req.UserID, req.Date)
if err != nil {
return domain.DeleteChannelMessagesResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit delete channel messages: %w", err)
}
committed = true
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0)
for i := range cascades {
cascades[i].Recipients, _ = s.ListActiveChannelMemberIDs(ctx, 0, cascades[i].Channel.ID, 0)
}
return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, DiscussionDeletes: cascades}, nil
}
// discussionRefsForMessages 取待删消息携带的讨论组转发根引用。
func (s *ChannelStore) discussionRefsForMessages(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) (map[int]domain.ChannelDiscussionRef, error) {
id32, _, err := validUniqueChannelMessageIDs(ids)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `
SELECT id, discussion_channel_id, discussion_message_id
FROM channel_messages
WHERE channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted
AND discussion_channel_id <> 0 AND discussion_message_id <> 0`, channelID, id32)
if err != nil {
return nil, fmt.Errorf("list discussion refs for delete: %w", err)
}
defer rows.Close()
out := make(map[int]domain.ChannelDiscussionRef)
for rows.Next() {
var id, msgID int
var discussionChannelID int64
if err := rows.Scan(&id, &discussionChannelID, &msgID); err != nil {
return nil, err
}
out[id] = domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: msgID}
}
return out, rows.Err()
}
// cascadeDeleteDiscussionRootsTx 随 broadcast post 删除其在 linked 讨论组的
// 转发根(官方为服务端级联);锁序固定 post channel → discussion channel
// 讨论组侧删根不反向级联,无交叉死锁路径。
func (s *ChannelStore) cascadeDeleteDiscussionRootsTx(ctx context.Context, tx pgx.Tx, refs map[int]domain.ChannelDiscussionRef, deleted []int, actorUserID int64, date int) ([]domain.ChannelCascadeDelete, error) {
if len(refs) == 0 || len(deleted) == 0 {
return nil, nil
}
byChannel := make(map[int64][]int)
for _, id := range deleted {
ref, ok := refs[id]
if !ok || ref.ChannelID == 0 || ref.MessageID == 0 {
continue
}
byChannel[ref.ChannelID] = append(byChannel[ref.ChannelID], ref.MessageID)
}
if len(byChannel) == 0 {
return nil, nil
}
channelIDs := make([]int64, 0, len(byChannel))
for id := range byChannel {
channelIDs = append(channelIDs, id)
}
sort.Slice(channelIDs, func(i, j int) bool { return channelIDs[i] < channelIDs[j] })
out := make([]domain.ChannelCascadeDelete, 0, len(channelIDs))
for _, discussionChannelID := range channelIDs {
group, err := getChannelByID(ctx, tx, discussionChannelID)
if err != nil || group.Deleted {
continue
}
// 级联是服务端动作,按 creator 权限执行(频道 admin 未必是讨论组成员)。
systemMember := domain.ChannelMember{ChannelID: discussionChannelID, UserID: actorUserID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}
groupDeleted, groupEvent, group, err := s.deleteChannelMessagesTx(ctx, tx, group, systemMember, byChannel[discussionChannelID], actorUserID, date)
if err != nil {
return nil, fmt.Errorf("cascade delete discussion roots: %w", err)
}
if len(groupDeleted) == 0 {
continue
}
out = append(out, domain.ChannelCascadeDelete{Channel: group, Event: groupEvent})
}
return out, nil
}
func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
if req.UserID == 0 || req.ChannelID == 0 {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
}
if req.Date == 0 {
req.Date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete channel history: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete channel history: %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.DeleteChannelHistoryResult{}, err
}
maxID := req.MaxID
if maxID <= 0 || maxID > channel.TopMessageID {
maxID = channel.TopMessageID
}
if !req.ForEveryone {
appliedMinID := maxInt(member.AvailableMinID, maxID)
topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date)
if err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if _, err := tx.Exec(ctx, `
UPDATE channel_members
SET available_min_id = GREATEST(available_min_id, $3),
read_inbox_max_id = GREATEST(read_inbox_max_id, $3),
unread_mark = false,
updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err)
}
if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if err := refreshChannelUnreadReactionsCountTx(ctx, tx, req.UserID, req.ChannelID); err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if _, err := tx.Exec(ctx, `
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_mark
) VALUES ($1,$2,$3,$4,$5,0,0,false)
ON CONFLICT (user_id, channel_id) DO UPDATE SET
top_message_id = EXCLUDED.top_message_id,
top_message_date = EXCLUDED.top_message_date,
read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id),
unread_count = 0,
unread_mark = false,
updated_at = now()`, req.UserID, req.ChannelID, topID, topDate, appliedMinID); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("upsert channel local clear dialog: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err)
}
committed = true
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil
}
if !canDeleteAnyChannelMessage(member) {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
}
// id=1 是建群服务消息,全员清空必须保留:它是清空后会话仅剩的
// top message没有它客户端会把 lastMessage 视为空并从聊天列表
// 隐藏该会话(成员资格仍在,但会话条目对全员消失)。
rows, err := tx.Query(ctx, `
SELECT id
FROM channel_messages
WHERE channel_id = $1 AND id <= $2 AND id > 1 AND NOT deleted
ORDER BY id DESC
LIMIT $3`, req.ChannelID, maxID, domain.MaxDeleteHistoryBatch)
if err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list channel history delete ids: %w", err)
}
ids := make([]int, 0, domain.MaxDeleteHistoryBatch)
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
rows.Close()
return domain.DeleteChannelHistoryResult{}, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.DeleteChannelHistoryResult{}, err
}
rows.Close()
deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date)
if err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete channel history: %w", err)
}
committed = true
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0)
offset := 0
if len(deleted) == domain.MaxDeleteHistoryBatch {
offset = 1
}
return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil
}
func (s *ChannelStore) DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
}
if req.Date == 0 {
req.Date = nowUnix()
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete participant channel history: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete participant channel history: %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.DeleteChannelHistoryResult{}, err
}
if !canDeleteAnyChannelMessage(member) {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
}
// 同全员清空id=1 建群服务消息不随发送者(创建者)历史一起删除,
// 否则会话会因 lastMessage 为空从全员聊天列表隐藏。
rows, err := tx.Query(ctx, `
SELECT id
FROM channel_messages
WHERE channel_id = $1 AND sender_user_id = $2 AND id > 1 AND NOT deleted
ORDER BY id DESC
LIMIT $3`, req.ChannelID, req.ParticipantUserID, domain.MaxDeleteHistoryBatch)
if err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list participant channel history delete ids: %w", err)
}
ids := make([]int, 0, domain.MaxDeleteHistoryBatch)
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
rows.Close()
return domain.DeleteChannelHistoryResult{}, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.DeleteChannelHistoryResult{}, err
}
rows.Close()
deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date)
if err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete participant channel history: %w", err)
}
committed = true
recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0)
offset := 0
if len(deleted) == domain.MaxDeleteHistoryBatch {
offset = 1
}
return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil
}
func (s *ChannelStore) deleteChannelMessagesTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) {
if len(ids) == 0 {
return nil, domain.ChannelUpdateEvent{}, channel, nil
}
id32, ordered, err := validUniqueChannelMessageIDs(ids)
if err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, err
}
rows, err := tx.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted
ORDER BY id`, channel.ID, id32)
if err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("list channel messages for delete: %w", err)
}
byID := make(map[int]domain.ChannelMessage, len(ordered))
for rows.Next() {
msg, err := scanChannelMessage(rows)
if err != nil {
rows.Close()
return nil, domain.ChannelUpdateEvent{}, channel, err
}
byID[msg.ID] = msg
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, domain.ChannelUpdateEvent{}, channel, err
}
rows.Close()
deleted := make([]int, 0, len(ordered))
for _, id := range ordered {
msg, ok := byID[id]
if !ok {
continue
}
if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) {
return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired
}
if id <= 1 {
// id=1 建群服务消息是清空后会话仅剩的兜底 top message所有
// 删除入口统一静默跳过(官方客户端对它禁用删除)。
continue
}
deleted = append(deleted, id)
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channel.ID,
UserID: actorUserID,
Date: date,
Type: domain.ChannelAdminLogDeleteMessage,
Message: &msg,
Query: msg.Body,
}); err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, err
}
}
if len(deleted) == 0 {
return nil, domain.ChannelUpdateEvent{}, channel, nil
}
pts, err := s.reserveChannelPtsN(ctx, tx, channel.ID, len(deleted))
if err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("allocate channel delete pts: %w", err)
}
deleted32 := int32s(deleted)
if _, err := tx.Exec(ctx, `
UPDATE channel_messages
SET deleted = true, pts = $3, updated_at = now()
WHERE channel_id = $1 AND id = ANY($2::int[])`, channel.ID, deleted32, pts); err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("soft delete channel messages: %w", err)
}
if err := deleteChannelUnreadMentionsTx(ctx, tx, channel.ID, deleted); err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, err
}
if err := refreshChannelUnreadReactionsCountsForMessagesTx(ctx, tx, channel.ID, deleted); err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, err
}
topID, err := topNonDeletedChannelMessageID(ctx, tx, channel.ID)
if err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, err
}
var latestPinned int
if err := tx.QueryRow(ctx, `
UPDATE channels
SET top_message_id = $2, pts = $3,
-- 删除即从置顶集合移除pinned 查询过滤 NOT deleted 自动免疫),
-- 这里同步重算「最新置顶 id」缓存避免悬挂。
pinned_message_id = COALESCE((
SELECT MAX(id) FROM channel_messages
WHERE channel_id = $1 AND pinned AND NOT deleted
), 0),
updated_at = now()
WHERE id = $1
RETURNING pinned_message_id`, channel.ID, topID, pts).Scan(&latestPinned); err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("update channel top after delete: %w", err)
}
channel.TopMessageID = topID
channel.Pts = pts
channel.PinnedMessageID = latestPinned
// 删除后不再对全员刷新 channel_dialogs 缓存行(性能审计 H4aunread 读时由
// 可见 incoming 消息动态派生deleted 自动出列top 由 channels.top_message_id 提供。
event := domain.ChannelUpdateEvent{
ChannelID: channel.ID,
Type: domain.ChannelUpdateDeleteMessages,
Pts: pts,
PtsCount: len(deleted),
Date: date,
MessageIDs: append([]int(nil), deleted...),
SenderUserID: actorUserID,
}
if err := insertChannelEventTx(ctx, tx, event); err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, err
}
return deleted, event, channel, nil
}
func validUniqueChannelMessageIDs(ids []int) ([]int32, []int, error) {
seen := make(map[int]struct{}, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 || id > domain.MaxMessageBoxID {
return nil, nil, domain.ErrMessageIDInvalid
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return int32s(out), out, nil
}
func topNonDeletedChannelMessageID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (int, error) {
var id int
if err := db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&id); err != nil {
return 0, fmt.Errorf("select channel top after delete: %w", err)
}
return id, nil
}
func canDeleteAnyChannelMessage(member domain.ChannelMember) bool {
return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages)
}