713 lines
26 KiB
Go
713 lines
26 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"github.com/jackc/pgx/v5"
|
||
"strconv"
|
||
"strings"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/postgres/sqlcgen"
|
||
)
|
||
|
||
func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) {
|
||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
limit := filter.Limit
|
||
if limit <= 0 || limit > 100 {
|
||
limit = 100
|
||
}
|
||
// 公共过滤条件(不含 offset 锚点的方向条件,供 add_offset 各模式复用)
|
||
baseArgs := []any{filter.ChannelID}
|
||
base := "channel_id = $1 AND NOT deleted"
|
||
extraChannels := []domain.Channel(nil)
|
||
if channel.Monoforum {
|
||
base += " AND saved_peer_id = 0"
|
||
if channel.LinkedMonoforumID != 0 {
|
||
if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil {
|
||
extraChannels = append(extraChannels, parent)
|
||
} else {
|
||
return domain.ChannelHistory{}, parentErr
|
||
}
|
||
}
|
||
}
|
||
if member.AvailableMinID > 0 {
|
||
baseArgs = append(baseArgs, member.AvailableMinID)
|
||
base += fmt.Sprintf(" AND id > $%d", len(baseArgs))
|
||
}
|
||
if filter.PinnedOnly {
|
||
base += " AND pinned"
|
||
}
|
||
if filter.MusicOnly {
|
||
base += ` AND media->>'kind' = 'document'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM jsonb_array_elements(COALESCE(media #> '{document,attributes}', '[]'::jsonb)) AS attr
|
||
WHERE attr->>'kind' = 'audio'
|
||
AND COALESCE((attr->>'voice')::boolean, false) = false
|
||
)`
|
||
}
|
||
if filter.Query != "" {
|
||
baseArgs = append(baseArgs, filter.Query)
|
||
base += fmt.Sprintf(" AND body ILIKE '%%' || $%d || '%%'", len(baseArgs))
|
||
}
|
||
if filter.SenderUserID != 0 {
|
||
baseArgs = append(baseArgs, filter.SenderUserID)
|
||
base += fmt.Sprintf(" AND sender_user_id = $%d", len(baseArgs))
|
||
}
|
||
if filter.MinDate > 0 {
|
||
baseArgs = append(baseArgs, filter.MinDate)
|
||
base += fmt.Sprintf(" AND message_date > $%d", len(baseArgs))
|
||
}
|
||
if filter.MaxDate > 0 {
|
||
baseArgs = append(baseArgs, filter.MaxDate)
|
||
base += fmt.Sprintf(" AND message_date < $%d", len(baseArgs))
|
||
}
|
||
if filter.MaxID > 0 {
|
||
baseArgs = append(baseArgs, filter.MaxID)
|
||
base += fmt.Sprintf(" AND id <= $%d", len(baseArgs))
|
||
}
|
||
if filter.MinID > 0 {
|
||
baseArgs = append(baseArgs, filter.MinID)
|
||
base += fmt.Sprintf(" AND id > $%d", len(baseArgs))
|
||
}
|
||
scanList := func(sql string, queryArgs []any) ([]domain.ChannelMessage, error) {
|
||
rows, err := s.db.Query(ctx, sql, queryArgs...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list channel history: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var list []domain.ChannelMessage
|
||
for rows.Next() {
|
||
msg, scanErr := scanChannelMessage(rows)
|
||
if scanErr != nil {
|
||
return nil, scanErr
|
||
}
|
||
list = append(list, msg)
|
||
}
|
||
return list, rows.Err()
|
||
}
|
||
// add_offset 决定加载方向(对齐私聊 ListMessagesByUser):
|
||
// >= 0 backward:锚点更旧方向,先跳过 add_offset 条
|
||
// < 0 且 +limit>0 around:以锚点为中心,向更新取 -add_offset 条 + 向更旧取 limit+add_offset 条
|
||
// 否则 forward:仅锚点更新方向(拉未读消息)
|
||
// store 层二次钳制 add_offset 到 [-100,100](与私聊 ListMessagesByUser 对齐):
|
||
// 即便某个 caller 漏在 RPC 层钳制,也不会把客户端巨大值变成大 SQL OFFSET 跳扫。
|
||
addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset)
|
||
out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels}
|
||
hasMoreOlder := false
|
||
// 锚点条件:offset_date 优先按日期、否则按消息 id(对齐私聊);
|
||
// 二者皆空时向更新方向退化为空、向更旧方向退化为全部(取最新)。
|
||
forwardCond := func(args *[]any) string {
|
||
if filter.OffsetDate > 0 {
|
||
*args = append(*args, filter.OffsetDate)
|
||
return fmt.Sprintf("message_date >= $%d", len(*args))
|
||
}
|
||
if filter.OffsetID > 0 {
|
||
*args = append(*args, filter.OffsetID)
|
||
return fmt.Sprintf("id > $%d", len(*args))
|
||
}
|
||
return "false"
|
||
}
|
||
aroundOlderCond := func(args *[]any) string {
|
||
if filter.OffsetDate > 0 {
|
||
*args = append(*args, filter.OffsetDate)
|
||
return fmt.Sprintf("message_date < $%d", len(*args))
|
||
}
|
||
if filter.OffsetID > 0 {
|
||
*args = append(*args, filter.OffsetID)
|
||
return fmt.Sprintf("id <= $%d", len(*args))
|
||
}
|
||
return "true"
|
||
}
|
||
switch {
|
||
case addOffset < 0 && addOffset+limit > 0:
|
||
// around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
|
||
fwdLimit := minInt(-addOffset, limit)
|
||
bwdLimit := maxInt(limit+addOffset, 0)
|
||
fwdArgs := append([]any{}, baseArgs...)
|
||
fwdWhere := forwardCond(&fwdArgs)
|
||
fwdArgs = append(fwdArgs, fwdLimit)
|
||
newer, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id ASC LIMIT $%d", base, fwdWhere, len(fwdArgs)), fwdArgs)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
bwdArgs := append([]any{}, baseArgs...)
|
||
bwdWhere := aroundOlderCond(&bwdArgs)
|
||
bwdArgs = append(bwdArgs, bwdLimit+1)
|
||
older, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id DESC LIMIT $%d", base, bwdWhere, len(bwdArgs)), bwdArgs)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
if len(older) > bwdLimit {
|
||
older = older[:bwdLimit]
|
||
hasMoreOlder = true
|
||
}
|
||
for i := len(newer) - 1; i >= 0; i-- {
|
||
out.Messages = append(out.Messages, newer[i])
|
||
}
|
||
out.Messages = append(out.Messages, older...)
|
||
case addOffset < 0:
|
||
// forward:仅锚点更新方向(拉未读/更新消息)
|
||
fwdArgs := append([]any{}, baseArgs...)
|
||
fwdWhere := forwardCond(&fwdArgs)
|
||
fwdArgs = append(fwdArgs, limit+1)
|
||
newer, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id ASC LIMIT $%d", base, fwdWhere, len(fwdArgs)), fwdArgs)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
if len(newer) > limit {
|
||
newer = newer[:limit]
|
||
}
|
||
for i := len(newer) - 1; i >= 0; i-- {
|
||
out.Messages = append(out.Messages, newer[i])
|
||
}
|
||
default:
|
||
// backward:锚点更旧方向(不含锚点),先跳过 add_offset 条
|
||
where := base
|
||
args := append([]any{}, baseArgs...)
|
||
if filter.OffsetDate > 0 {
|
||
args = append(args, filter.OffsetDate)
|
||
where += fmt.Sprintf(" AND message_date < $%d", len(args))
|
||
} else if filter.OffsetID > 0 {
|
||
args = append(args, filter.OffsetID)
|
||
where += fmt.Sprintf(" AND id < $%d", len(args))
|
||
}
|
||
args = append(args, limit+1)
|
||
limIdx := len(args)
|
||
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC"
|
||
if addOffset > 0 {
|
||
args = append(args, addOffset)
|
||
sql += fmt.Sprintf(" OFFSET $%d", len(args))
|
||
}
|
||
sql += fmt.Sprintf(" LIMIT $%d", limIdx)
|
||
older, err := scanList(sql, args)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
if len(older) > limit {
|
||
older = older[:limit]
|
||
hasMoreOlder = true
|
||
}
|
||
out.Messages = older
|
||
}
|
||
out.Count = len(out.Messages)
|
||
if hasMoreOlder {
|
||
out.Count = len(out.Messages) + 1
|
||
}
|
||
if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) {
|
||
query := strings.TrimSpace(req.Query)
|
||
if viewerUserID == 0 || (query == "" && !req.MusicOnly) {
|
||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||
}
|
||
limit := req.Limit
|
||
if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit {
|
||
limit = domain.MaxChannelGlobalSearchLimit
|
||
}
|
||
args := []any{viewerUserID}
|
||
where := `NOT deleted`
|
||
if query != "" {
|
||
args = append(args, "%"+escapeLike(query)+"%")
|
||
where += fmt.Sprintf(`
|
||
AND body <> ''
|
||
AND body ILIKE $%d ESCAPE '\'`, len(args))
|
||
}
|
||
if req.MusicOnly {
|
||
where += `
|
||
AND channel_messages.media->>'kind' = 'document'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM jsonb_array_elements(COALESCE(channel_messages.media #> '{document,attributes}', '[]'::jsonb)) AS attr
|
||
WHERE attr->>'kind' = 'audio'
|
||
AND COALESCE((attr->>'voice')::boolean, false) = false
|
||
)`
|
||
}
|
||
where += `
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM channels c
|
||
JOIN channel_members cm ON cm.channel_id = c.id
|
||
AND cm.user_id = $1
|
||
AND cm.status = 'active'
|
||
AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false)
|
||
LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1
|
||
WHERE c.id = channel_messages.channel_id
|
||
AND NOT c.deleted
|
||
AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)`
|
||
if req.BroadcastsOnly {
|
||
where += `
|
||
AND c.broadcast AND NOT c.megagroup`
|
||
}
|
||
if req.GroupsOnly {
|
||
where += `
|
||
AND c.megagroup`
|
||
}
|
||
if req.HasFolderID {
|
||
args = append(args, req.FolderID)
|
||
where += fmt.Sprintf(`
|
||
AND d.folder_id = $%d`, len(args))
|
||
}
|
||
where += `
|
||
)`
|
||
if req.MinDate > 0 {
|
||
args = append(args, req.MinDate)
|
||
where += fmt.Sprintf(" AND message_date > $%d", len(args))
|
||
}
|
||
if req.MaxDate > 0 {
|
||
args = append(args, req.MaxDate)
|
||
where += fmt.Sprintf(" AND message_date < $%d", len(args))
|
||
}
|
||
switch {
|
||
case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0:
|
||
args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID)
|
||
n := len(args)
|
||
where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n)
|
||
case req.OffsetRate > 0:
|
||
args = append(args, req.OffsetRate)
|
||
where += fmt.Sprintf(" AND message_date < $%d", len(args))
|
||
case req.OffsetChannelID > 0 && req.OffsetID > 0:
|
||
args = append(args, req.OffsetChannelID, req.OffsetID)
|
||
n := len(args)
|
||
where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n)
|
||
case req.OffsetID > 0:
|
||
args = append(args, req.OffsetID)
|
||
where += fmt.Sprintf(" AND id < $%d", len(args))
|
||
}
|
||
queryLimit := limit + 1
|
||
args = append(args, queryLimit)
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+channelMessageColumns+`
|
||
FROM channel_messages
|
||
WHERE `+where+`
|
||
ORDER BY message_date DESC, channel_id DESC, id DESC
|
||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, fmt.Errorf("search joined channel messages: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := domain.ChannelHistory{}
|
||
channelRefs := make(map[int64]struct{})
|
||
for rows.Next() {
|
||
msg, err := scanChannelMessage(rows)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
out.Messages = append(out.Messages, msg)
|
||
channelRefs[msg.ChannelID] = struct{}{}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
if len(out.Messages) > limit {
|
||
out.Messages = out.Messages[:limit]
|
||
out.Count = limit + 1
|
||
channelRefs = make(map[int64]struct{}, len(out.Messages))
|
||
for _, msg := range out.Messages {
|
||
channelRefs[msg.ChannelID] = struct{}{}
|
||
}
|
||
} else {
|
||
out.Count = len(out.Messages)
|
||
}
|
||
channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs))
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
out.Channels = channels
|
||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *ChannelStore) GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) {
|
||
// viewer 口径(非严格 member):公开频道的非成员可预览读取消息(与 ListChannelHistory 一致)。
|
||
// 否则查看他人资料里设置的公开「个人频道」时,DrKLO 经 channels.getMessages 拉最新一帖会被拒,
|
||
// 资料页个人频道整块不显示。私有频道非成员仍返回 ErrChannelPrivate。
|
||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
return s.getChannelMessagesForMember(ctx, viewerUserID, channel, member, ids)
|
||
}
|
||
|
||
func (s *ChannelStore) getChannelMessagesForMember(ctx context.Context, viewerUserID int64, channel domain.Channel, member domain.ChannelMember, ids []int) (domain.ChannelHistory, error) {
|
||
if len(ids) == 0 {
|
||
return domain.ChannelHistory{Channel: channel, Self: member}, nil
|
||
}
|
||
if len(ids) > domain.MaxGetMessageIDs {
|
||
return domain.ChannelHistory{}, domain.ErrChannelInvalid
|
||
}
|
||
id32, _, err := validUniqueChannelMessageIDs(ids)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
// AvailableMinID 用固定哨兵($3<=0 表示无下限)而非条件追加,使 by-id 补拉恒为
|
||
// 单一 query-shape(2→1),计划可复用。这里安全无副作用:索引由 id=ANY($2) 驱动
|
||
// (Index Cond),哨兵仅作残余 Filter——EXPLAIN 实测仍走 channel_messages_history_idx、
|
||
// 执行不变。注意:这种"OR 哨兵"只对【非排序锚点】的残余过滤安全;ListChannelHistory
|
||
// 的方向/anchor 条件若同样哨兵化会让规划器无法用索引顺序做 LIMIT、退化为全表扫+排序
|
||
// (实测 0.06ms→23ms),故那里【刻意保留】动态 SQL。
|
||
args := []any{channel.ID, id32, member.AvailableMinID}
|
||
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted AND ($3 <= 0 OR id > $3)"
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+channelMessageColumns+`
|
||
FROM channel_messages
|
||
WHERE `+where+`
|
||
ORDER BY id DESC`, args...)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, fmt.Errorf("get channel messages by ids: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
out := domain.ChannelHistory{Channel: channel, Self: member}
|
||
for rows.Next() {
|
||
msg, err := scanChannelMessage(rows)
|
||
if err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
out.Messages = append(out.Messages, msg)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
out.Count = len(out.Messages)
|
||
if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
|
||
return domain.ChannelHistory{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *ChannelStore) ListStoryMessageForwards(ctx context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) {
|
||
if req.ViewerUserID == 0 || req.Owner.ID == 0 || req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
|
||
return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid
|
||
}
|
||
if req.Owner.Type != domain.PeerTypeUser && req.Owner.Type != domain.PeerTypeChannel {
|
||
return domain.StoryMessageForwardList{}, domain.ErrStoryPeerInvalid
|
||
}
|
||
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
||
return domain.StoryMessageForwardList{}, err
|
||
}
|
||
ownerType := string(req.Owner.Type)
|
||
ownerID := strconv.FormatInt(req.Owner.ID, 10)
|
||
storyID := strconv.Itoa(req.StoryID)
|
||
where := `
|
||
NOT deleted
|
||
AND media->>'kind' = 'story'
|
||
-- domain.Peer 无 json tag → 序列化为大写 Type/ID(与 repost 查询 ->>'Type'/'ID' 同口径);
|
||
-- 用小写 type/id 会永远匹配不到 → story message forward 计数恒 0(postgres-only,memory 读 struct 不受影响)。
|
||
AND media #>> '{story,peer,Type}' = $1
|
||
AND media #>> '{story,peer,ID}' = $2
|
||
AND media #>> '{story,id}' = $3
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM channels c
|
||
WHERE c.id = channel_messages.channel_id
|
||
AND NOT c.deleted
|
||
AND (c.broadcast OR c.megagroup)
|
||
AND btrim(COALESCE(c.username, '')) <> ''
|
||
)`
|
||
var count int
|
||
if err := s.db.QueryRow(ctx, `SELECT count(*)::int FROM channel_messages WHERE `+where, ownerType, ownerID, storyID).Scan(&count); err != nil {
|
||
return domain.StoryMessageForwardList{}, fmt.Errorf("count story message forwards: %w", err)
|
||
}
|
||
limit := clampPGStoryInteractionLimit(req.Limit)
|
||
cursor := parsePGStoryInteractionCursor(req.Offset)
|
||
args := []any{ownerType, ownerID, storyID}
|
||
cursorClause := ""
|
||
group := 0
|
||
if cursor.set {
|
||
cursorChannelID := int64(0)
|
||
if cursor.viewerID < 0 {
|
||
cursorChannelID = -cursor.viewerID
|
||
if cursorChannelID < 0 {
|
||
return domain.StoryMessageForwardList{}, domain.ErrStoryOffsetInvalid
|
||
}
|
||
}
|
||
args = append(args, int32(group), int32(cursor.group), int32(cursor.date), cursorChannelID, int32(cursor.messageID))
|
||
cursorClause = fmt.Sprintf(`
|
||
AND (
|
||
$%d::int > $%d::int
|
||
OR (
|
||
$%d::int = $%d::int
|
||
AND (
|
||
message_date < $%d::int
|
||
OR (
|
||
message_date = $%d::int
|
||
AND (
|
||
channel_id > $%d::bigint
|
||
OR (channel_id = $%d::bigint AND id < $%d::int)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)`, len(args)-4, len(args)-3, len(args)-4, len(args)-3, len(args)-2, len(args)-2, len(args)-1, len(args)-1, len(args))
|
||
}
|
||
args = append(args, int32(limit+1))
|
||
rows, err := s.db.Query(ctx, `
|
||
SELECT `+channelMessageColumns+`
|
||
FROM channel_messages
|
||
WHERE `+where+cursorClause+`
|
||
ORDER BY message_date DESC, channel_id ASC, id DESC
|
||
LIMIT $`+strconv.Itoa(len(args)), args...)
|
||
if err != nil {
|
||
return domain.StoryMessageForwardList{}, fmt.Errorf("list story message forwards: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
views := make([]domain.StoryView, 0)
|
||
for rows.Next() {
|
||
msg, err := scanChannelMessage(rows)
|
||
if err != nil {
|
||
return domain.StoryMessageForwardList{}, err
|
||
}
|
||
views = append(views, domain.StoryView{
|
||
Owner: req.Owner,
|
||
StoryID: req.StoryID,
|
||
Date: msg.Date,
|
||
PublicForward: &domain.StoryPublicForward{
|
||
Message: msg,
|
||
},
|
||
})
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return domain.StoryMessageForwardList{}, err
|
||
}
|
||
sortPGStoryViewsForList(views, req.ReactionsFirst, req.ForwardsFirst)
|
||
nextOffset := ""
|
||
if len(views) > limit {
|
||
views = views[:limit]
|
||
nextOffset = formatPGStoryInteractionCursor(views[len(views)-1], req.ReactionsFirst, req.ForwardsFirst)
|
||
}
|
||
return domain.StoryMessageForwardList{Count: count, Forwards: views, NextOffset: nextOffset}, nil
|
||
}
|
||
|
||
func (s *ChannelStore) GetChannelMessageForInlineBot(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) {
|
||
if botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID {
|
||
return domain.Channel{}, domain.ChannelMessage{}, false, nil
|
||
}
|
||
channel, err := getChannelByID(ctx, s.db, channelID)
|
||
if err != nil {
|
||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||
return domain.Channel{}, domain.ChannelMessage{}, false, nil
|
||
}
|
||
return domain.Channel{}, domain.ChannelMessage{}, false, err
|
||
}
|
||
msg, err := s.getChannelMessage(ctx, s.db, channelID, id)
|
||
if err != nil {
|
||
if errors.Is(err, domain.ErrMessageIDInvalid) {
|
||
return domain.Channel{}, domain.ChannelMessage{}, false, nil
|
||
}
|
||
return domain.Channel{}, domain.ChannelMessage{}, false, err
|
||
}
|
||
if msg.Deleted || msg.Action != nil || msg.ViaBotID != botID {
|
||
return domain.Channel{}, domain.ChannelMessage{}, false, nil
|
||
}
|
||
return channel, msg, true, nil
|
||
}
|
||
|
||
func (s *ChannelStore) GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) {
|
||
source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
|
||
if err != nil {
|
||
return domain.ChannelDiscussionMessage{}, err
|
||
}
|
||
msg, err := s.getChannelMessage(ctx, s.db, channelID, msgID)
|
||
if err != nil || msg.Deleted || msg.ID <= member.AvailableMinID {
|
||
return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid
|
||
}
|
||
result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}}
|
||
target := source
|
||
targetMsg := msg
|
||
if source.Broadcast {
|
||
if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 {
|
||
return result, nil
|
||
}
|
||
linked, err := getChannelByID(ctx, s.db, msg.Discussion.ChannelID)
|
||
if err != nil {
|
||
return result, nil
|
||
}
|
||
linkedMsg, err := s.getChannelMessage(ctx, s.db, linked.ID, msg.Discussion.MessageID)
|
||
if err != nil || linkedMsg.Deleted {
|
||
return result, nil
|
||
}
|
||
target = linked
|
||
targetMsg = linkedMsg
|
||
result.DiscussionChannel = linked
|
||
result.Channels = []domain.Channel{source, linked}
|
||
}
|
||
messages := []domain.ChannelMessage{targetMsg}
|
||
if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil {
|
||
return domain.ChannelDiscussionMessage{}, err
|
||
}
|
||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil {
|
||
return domain.ChannelDiscussionMessage{}, err
|
||
}
|
||
readInbox, readOutbox := s.channelReadWatermarks(ctx, target.ID, viewerUserID)
|
||
result.Messages = messages
|
||
result.ReadInboxMaxID = readInbox
|
||
result.ReadOutboxMaxID = readOutbox
|
||
if messages[0].Replies != nil {
|
||
result.MaxID = messages[0].Replies.MaxID
|
||
}
|
||
result.UnreadCount = s.channelThreadUnreadCount(ctx, target.ID, targetMsg.ID, viewerUserID, readInbox)
|
||
return result, nil
|
||
}
|
||
|
||
func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
|
||
channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
|
||
if err != nil {
|
||
return domain.ReadChannelHistoryResult{}, err
|
||
}
|
||
maxID := req.MaxID
|
||
if maxID <= 0 || maxID > channel.TopMessageID {
|
||
maxID = channel.TopMessageID
|
||
}
|
||
previous, unreadMark, err := s.channelReadHistoryState(ctx, req.ChannelID, req.UserID)
|
||
if err != nil {
|
||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err)
|
||
}
|
||
if maxID <= previous && !unreadMark {
|
||
return domain.ReadChannelHistoryResult{
|
||
ChannelID: req.ChannelID,
|
||
MaxID: maxID,
|
||
Changed: false,
|
||
Pts: channel.Pts,
|
||
Forum: channel.Forum,
|
||
}, nil
|
||
}
|
||
beginner, ok := s.db.(txBeginner)
|
||
if !ok {
|
||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel history: db does not support transactions")
|
||
}
|
||
tx, err := beginner.Begin(ctx)
|
||
if err != nil {
|
||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("begin read channel history: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback(ctx)
|
||
}
|
||
}()
|
||
if err := tx.QueryRow(ctx, `SELECT read_inbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID).Scan(&previous); err != nil {
|
||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err)
|
||
}
|
||
changed := maxID > previous
|
||
var outboxUpdates []domain.ChannelReadOutboxUpdate
|
||
if changed {
|
||
// 先碰 channels 行再碰 channel_members:send 路径的顺序是
|
||
// channels→members,read 路径必须同序,否则并发 send+read 形成
|
||
// AB-BA 死锁(PG 1s 检测击杀后整个事务回滚)。
|
||
// channel 级公共已读水位:top1/top2 是任一成员推进过的最高两个
|
||
// read_inbox。sender 的 read_outbox 由它派生(top1 持有者本人取
|
||
// top2),即使下面的实时 fanout 被截断,回执真值也不会停滞。
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE channels
|
||
SET read_inbox_top2 = CASE
|
||
WHEN read_inbox_top1_user_id = $2 THEN read_inbox_top2
|
||
WHEN $3 >= read_inbox_top1 THEN read_inbox_top1
|
||
ELSE GREATEST(read_inbox_top2, $3)
|
||
END,
|
||
read_inbox_top1_user_id = CASE
|
||
WHEN read_inbox_top1_user_id = $2 THEN read_inbox_top1_user_id
|
||
WHEN $3 >= read_inbox_top1 THEN $2
|
||
ELSE read_inbox_top1_user_id
|
||
END,
|
||
read_inbox_top1 = CASE
|
||
WHEN read_inbox_top1_user_id = $2 THEN GREATEST(read_inbox_top1, $3)
|
||
WHEN $3 >= read_inbox_top1 THEN $3
|
||
ELSE read_inbox_top1
|
||
END,
|
||
updated_at = now()
|
||
WHERE id = $1`, req.ChannelID, req.UserID, maxID); err != nil {
|
||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("advance channel read watermarks: %w", err)
|
||
}
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE channel_members
|
||
SET read_inbox_date = CASE WHEN read_inbox_max_id < $3 THEN $4 ELSE read_inbox_date END,
|
||
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, maxID, req.Date); err != nil {
|
||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("update channel member read: %w", err)
|
||
}
|
||
msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID)
|
||
if changed {
|
||
outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel, msg, req.UserID, previous, maxID)
|
||
if err != nil {
|
||
return domain.ReadChannelHistoryResult{}, err
|
||
}
|
||
}
|
||
if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, maxID, 0); err != nil {
|
||
return domain.ReadChannelHistoryResult{}, err
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("commit read channel history: %w", err)
|
||
}
|
||
committed = true
|
||
dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel)
|
||
if err != nil {
|
||
return domain.ReadChannelHistoryResult{}, err
|
||
}
|
||
return domain.ReadChannelHistoryResult{
|
||
ChannelID: req.ChannelID,
|
||
MaxID: maxID,
|
||
StillUnreadCount: dialog.UnreadCount,
|
||
Changed: changed,
|
||
Pts: channel.Pts,
|
||
Forum: channel.Forum,
|
||
Dialog: dialog,
|
||
OutboxUpdates: outboxUpdates,
|
||
}, nil
|
||
}
|
||
|
||
func (s *ChannelStore) channelReadHistoryState(ctx context.Context, channelID, userID int64) (readInboxMaxID int, unreadMark bool, err error) {
|
||
err = s.db.QueryRow(ctx, `
|
||
SELECT read_inbox_max_id, unread_mark
|
||
FROM channel_members
|
||
WHERE channel_id = $1 AND user_id = $2`, channelID, userID).Scan(&readInboxMaxID, &unreadMark)
|
||
return readInboxMaxID, unreadMark, err
|
||
}
|
||
|
||
func (s *ChannelStore) getChannelMessage(ctx context.Context, db sqlcgen.DBTX, channelID int64, id int) (domain.ChannelMessage, error) {
|
||
if channelID == 0 || id == 0 {
|
||
return domain.ChannelMessage{}, pgx.ErrNoRows
|
||
}
|
||
msg, err := scanChannelMessage(db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, id))
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return domain.ChannelMessage{}, domain.ErrMessageIDInvalid
|
||
}
|
||
return msg, err
|
||
}
|
||
|
||
type messageHistoryLoad int
|
||
|
||
func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad {
|
||
if addOffset >= 0 {
|
||
return messageHistoryLoadBackward
|
||
}
|
||
if addOffset+limit > 0 {
|
||
return messageHistoryLoadAround
|
||
}
|
||
return messageHistoryLoadForward
|
||
}
|
||
|
||
func channelMessageLess(a, b domain.ChannelMessage) bool {
|
||
if a.Date != b.Date {
|
||
return a.Date > b.Date
|
||
}
|
||
return a.ID > b.ID
|
||
}
|