feat: sync monoforum and collectible emoji status
Sync telesrv bd15657 (feat(account): implement collectible emoji status). Skipped telesrv docs changes per public sync rules.
This commit is contained in:
parent
edb7057757
commit
0c99ae0a9d
91 changed files with 4061 additions and 693 deletions
|
|
@ -290,6 +290,12 @@ func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, af
|
|||
}
|
||||
out = append(out, channelID)
|
||||
}
|
||||
for channelID, channel := range s.channels {
|
||||
if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) || containsInt64(out, channelID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, channelID)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
|
|
@ -324,6 +330,24 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
|
|||
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
|
||||
}
|
||||
}
|
||||
for channelID, channel := range s.channels {
|
||||
if channelID <= afterChannelID || !s.monoforumVisibleToUserLocked(channel, userID) {
|
||||
continue
|
||||
}
|
||||
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
|
||||
if checkpoint.LatestEventDate > sinceDate {
|
||||
found := false
|
||||
for _, item := range out {
|
||||
if item.ChannelID == channelID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
|
|
@ -331,6 +355,34 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) monoforumVisibleToUserLocked(mono domain.Channel, userID int64) bool {
|
||||
if userID == 0 || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 {
|
||||
return false
|
||||
}
|
||||
parent, ok := s.channels[mono.LinkedMonoforumID]
|
||||
if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != mono.ID {
|
||||
return false
|
||||
}
|
||||
if member, ok := s.members[parent.ID][userID]; ok && member.Status == domain.ChannelMemberActive && isChannelAdmin(member) {
|
||||
return true
|
||||
}
|
||||
for _, msg := range s.messages[mono.ID] {
|
||||
if !msg.Deleted && msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsInt64(items []int64, target int64) bool {
|
||||
for _, item := range items {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *ChannelStore) nextChannelIDLocked() int64 {
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
|
|
@ -369,6 +421,10 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C
|
|||
if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) {
|
||||
return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil
|
||||
}
|
||||
parent, ok := s.channels[channel.LinkedMonoforumID]
|
||||
if ok && !parent.Deleted && parent.BroadcastMessagesAllowed && parent.LinkedMonoforumID == channel.ID {
|
||||
return channel, syntheticMonoforumUserMember(channel, userID), true, nil
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(channel) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUser
|
|||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if channel.Monoforum {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
||||
}
|
||||
inviter := s.members[channelID][inviterUserID]
|
||||
if !canInviteToChannel(channel, inviter) {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, d
|
|||
if !ok || channel.Deleted {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if channel.Monoforum {
|
||||
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
||||
}
|
||||
preJoinTopID := channel.TopMessageID
|
||||
if existing, ok := s.members[channelID][userID]; ok {
|
||||
if existing.Status == domain.ChannelMemberActive {
|
||||
|
|
@ -1050,6 +1053,15 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
|
|||
return member
|
||||
}
|
||||
|
||||
func syntheticMonoforumUserMember(mono domain.Channel, userID int64) domain.ChannelMember {
|
||||
return domain.ChannelMember{
|
||||
ChannelID: mono.ID,
|
||||
UserID: userID,
|
||||
Role: domain.ChannelRoleMember,
|
||||
Status: domain.ChannelMemberActive,
|
||||
}
|
||||
}
|
||||
|
||||
func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) {
|
||||
if !publicSearchableChannel(channel) {
|
||||
return 0, false
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage {
|
|||
in.Discussion = cloneChannelDiscussionRef(in.Discussion)
|
||||
in.Replies = cloneChannelMessageReplies(in.Replies)
|
||||
in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions)
|
||||
if in.SuggestedPost != nil {
|
||||
suggested := *in.SuggestedPost
|
||||
if suggested.Price != nil {
|
||||
price := *suggested.Price
|
||||
suggested.Price = &price
|
||||
}
|
||||
in.SuggestedPost = &suggested
|
||||
}
|
||||
if in.SendAs != nil {
|
||||
p := *in.SendAs
|
||||
in.SendAs = &p
|
||||
|
|
|
|||
|
|
@ -24,12 +24,18 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
|
|||
// 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。
|
||||
query := strings.ToLower(strings.TrimSpace(filter.Query))
|
||||
matched := make([]domain.ChannelMessage, 0, len(items))
|
||||
monoforumUserView := channel.Monoforum && !isChannelAdmin(member)
|
||||
for _, msg := range items {
|
||||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
if channel.Monoforum {
|
||||
if monoforumUserView && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) {
|
||||
continue
|
||||
}
|
||||
if !monoforumUserView && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -316,13 +316,21 @@ func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendRepla
|
|||
Message: cloneChannelMessage(replay),
|
||||
SenderUserID: first.SenderUserID,
|
||||
}
|
||||
return domain.SendChannelMessageResult{
|
||||
result := domain.SendChannelMessageResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Message: cloneChannelMessage(replay),
|
||||
Event: event,
|
||||
Duplicate: true,
|
||||
ReplayDeleteEvent: replayDelete,
|
||||
}, true, nil
|
||||
}
|
||||
if first.PaidMessageStars > 0 {
|
||||
balance, ok := s.starsBalances[first.SenderUserID]
|
||||
if !ok {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory paid-message replay has no sender balance")
|
||||
}
|
||||
result.SenderStarsBalance = &domain.StarsBalance{UserID: first.SenderUserID, Balance: balance, Granted: true}
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,19 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const paidMessageChannelCommissionPermille int64 = 850
|
||||
|
||||
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
|
||||
// 与 postgres 行为一致:复用 channel pts/事件;只校验 monoforum 存在,不要求发件人是成员。
|
||||
// 与 postgres 行为一致:复用 channel pts/事件;订阅者无需成员记录且只能写自己的 saved_peer,
|
||||
// 母频道管理员可以回复任意订阅者。
|
||||
func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
|
||||
if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 ||
|
||||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
|
||||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" && req.Media == nil {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.AllowPaidStars < 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInvalidAmount
|
||||
}
|
||||
var fingerprint []byte
|
||||
var err error
|
||||
if req.RandomID != 0 {
|
||||
|
|
@ -43,23 +49,81 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
if !ok || channel.Deleted || !channel.Monoforum {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
parent, ok := s.channels[channel.LinkedMonoforumID]
|
||||
if !ok || parent.Deleted || !parent.BroadcastMessagesAllowed || parent.LinkedMonoforumID != channel.ID {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelPrivate
|
||||
}
|
||||
parentMember, parentMemberOK := s.members[parent.ID][req.SenderUserID]
|
||||
isAdmin := parentMemberOK && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember)
|
||||
if req.SenderUserID != req.SavedPeer.ID && !isAdmin {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
if req.ReplyTo != nil {
|
||||
if req.ReplyTo.MessageID <= 0 || req.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range s.messages[channel.ID] {
|
||||
if candidate.ID == req.ReplyTo.MessageID && !candidate.Deleted && candidate.SavedPeer == req.SavedPeer {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
}
|
||||
var senderBalance *domain.StarsBalance
|
||||
paidMessageStars := int64(0)
|
||||
balanceAfter := int64(0)
|
||||
if !isAdmin && channel.SendPaidMessagesStars > 0 {
|
||||
if channel.SendPaidMessagesStars != parent.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.AllowPaidStars < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, &domain.StarsPaymentRequiredError{Stars: channel.SendPaidMessagesStars}
|
||||
}
|
||||
current, ok := s.starsBalances[req.SenderUserID]
|
||||
if !ok {
|
||||
current = domain.DefaultStarsStartingGrant
|
||||
}
|
||||
if current < channel.SendPaidMessagesStars {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrStarsInsufficient
|
||||
}
|
||||
paidMessageStars = channel.SendPaidMessagesStars
|
||||
balanceAfter = current - paidMessageStars
|
||||
senderBalance = &domain.StarsBalance{UserID: req.SenderUserID, Balance: balanceAfter, Granted: true}
|
||||
}
|
||||
from := domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
if isAdmin {
|
||||
from = domain.Peer{Type: domain.PeerTypeChannel, ID: parent.ID}
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
pts := s.nextChannelPtsLocked(req.MonoforumID)
|
||||
msgID := s.nextChannelMessageIDLocked(req.MonoforumID)
|
||||
msg := domain.ChannelMessage{
|
||||
ChannelID: req.MonoforumID,
|
||||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID},
|
||||
SavedPeer: req.SavedPeer,
|
||||
Date: req.Date,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Pts: pts,
|
||||
ChannelID: req.MonoforumID,
|
||||
ID: msgID,
|
||||
RandomID: req.RandomID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
From: from,
|
||||
SavedPeer: req.SavedPeer,
|
||||
SuggestedPost: req.SuggestedPost,
|
||||
PaidMessageStars: paidMessageStars,
|
||||
Date: req.Date,
|
||||
Silent: req.Silent,
|
||||
NoForwards: req.NoForwards,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Pts: pts,
|
||||
}
|
||||
// Store owns the persisted snapshot; callers must not be able to mutate it through
|
||||
// SuggestedPost/Media pointers after SendMonoforumMessage returns.
|
||||
msg = cloneChannelMessage(msg)
|
||||
var sendSnapshot []byte
|
||||
if req.RandomID != 0 {
|
||||
var snapshotErr error
|
||||
|
|
@ -78,6 +142,10 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
SenderUserID: req.SenderUserID,
|
||||
}
|
||||
s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg)
|
||||
if paidMessageStars > 0 {
|
||||
s.starsBalances[req.SenderUserID] = balanceAfter
|
||||
s.channelStarsBalances[parent.ID] += paidMessageStars * paidMessageChannelCommissionPermille / 1000
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID}
|
||||
s.sendSnapshots[replayKey] = sendSnapshot
|
||||
|
|
@ -87,7 +155,13 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
channel.TopMessageID = msgID
|
||||
channel.Pts = pts
|
||||
s.channels[req.MonoforumID] = channel
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event)}, nil
|
||||
recipients := []int64{req.SavedPeer.ID}
|
||||
for userID, member := range s.members[parent.ID] {
|
||||
if member.Status == domain.ChannelMemberActive && isChannelAdmin(member) {
|
||||
recipients = append(recipients, userID)
|
||||
}
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event), Recipients: uniqueNonZero(recipients, 0), SenderStarsBalance: senderBalance}, nil
|
||||
}
|
||||
|
||||
// findMonoforumDuplicateLocked 按 (sender, saved_peer, random_id) 查 monoforum 子会话内的重发消息。
|
||||
|
|
|
|||
|
|
@ -35,11 +35,14 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if m1.Message.SavedPeer != sub || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 {
|
||||
t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message)
|
||||
}
|
||||
if !containsInt64(m1.Recipients, 1) || !containsInt64(m1.Recipients, 42) || len(m1.Recipients) != 2 {
|
||||
t.Fatalf("m1 recipients = %v, want subscriber 42 + parent admin 1", m1.Recipients)
|
||||
}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 112, Message: "again", Date: 1_700_001_002}); err != nil {
|
||||
t.Fatalf("subscriber send 2: %v", err)
|
||||
}
|
||||
// 管理员回复:发件人是 creator,saved_peer 仍是该订阅者(同一子会话)。
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", Date: 1_700_001_003}); err != nil {
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", ReplyTo: &domain.MessageReply{MessageID: m1.Message.ID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_003}); err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -58,8 +61,17 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID {
|
||||
t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID)
|
||||
}
|
||||
if _, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil {
|
||||
t.Fatalf("subscriber main monoforum history = nil err, want denied")
|
||||
subscriberHist, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber monoforum history: %v", err)
|
||||
}
|
||||
if subscriberHist.Count != 3 || len(subscriberHist.Messages) != 3 {
|
||||
t.Fatalf("subscriber monoforum history count=%d len=%d, want 3 own messages", subscriberHist.Count, len(subscriberHist.Messages))
|
||||
}
|
||||
for _, message := range subscriberHist.Messages {
|
||||
if message.SavedPeer != sub {
|
||||
t.Fatalf("subscriber history leaked saved_peer=%+v, want self %+v", message.SavedPeer, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:相同 randomID 返回原消息、不重复。
|
||||
|
|
@ -84,6 +96,12 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if hist.Messages[0].Body != "reply" {
|
||||
t.Fatalf("history[0] = %q, want newest 'reply'", hist.Messages[0].Body)
|
||||
}
|
||||
if hist.Messages[0].ReplyTo == nil || hist.Messages[0].ReplyTo.MessageID != m1.Message.ID {
|
||||
t.Fatalf("history[0] reply = %+v, want message %d", hist.Messages[0].ReplyTo, m1.Message.ID)
|
||||
}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 114, Message: "cross reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
|
||||
t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err)
|
||||
}
|
||||
for _, m := range hist.Messages {
|
||||
if m.SavedPeer != sub {
|
||||
t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer)
|
||||
|
|
@ -99,6 +117,40 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if subHist.Count != 3 {
|
||||
t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count)
|
||||
}
|
||||
subscriberChannelHistory, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber channel history after other subscriber: %v", err)
|
||||
}
|
||||
if subscriberChannelHistory.Count != 3 || len(subscriberChannelHistory.Messages) != 3 {
|
||||
t.Fatalf("subscriber channel history after other = %d/%d, want own 3", subscriberChannelHistory.Count, len(subscriberChannelHistory.Messages))
|
||||
}
|
||||
for _, message := range subscriberChannelHistory.Messages {
|
||||
if message.SavedPeer != sub {
|
||||
t.Fatalf("subscriber channel history leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: 0, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber channel difference: %v", err)
|
||||
}
|
||||
if diff.Pts != store.channels[monoID].Pts {
|
||||
t.Fatalf("subscriber difference pts = %d, want channel pts %d despite filtered events", diff.Pts, store.channels[monoID].Pts)
|
||||
}
|
||||
if len(diff.NewMessages) != 3 {
|
||||
t.Fatalf("subscriber difference messages = %d, want own 3", len(diff.NewMessages))
|
||||
}
|
||||
for _, message := range diff.NewMessages {
|
||||
if message.SavedPeer != sub {
|
||||
t.Fatalf("subscriber difference leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
activeChannelIDs, err := store.ListActiveChannelIDsForUser(ctx, 42, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber active channels: %v", err)
|
||||
}
|
||||
if !containsInt64(activeChannelIDs, monoID) {
|
||||
t.Fatalf("subscriber active channels = %v, want monoforum %d for offline recovery", activeChannelIDs, monoID)
|
||||
}
|
||||
|
||||
// 去重按订阅者子会话维度:同一发件人(此处管理员)用相同 random_id 向两个不同订阅者发,不得互相去重。
|
||||
a, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_010})
|
||||
|
|
@ -141,6 +193,13 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("delete monoforum message: %v", err)
|
||||
}
|
||||
deleteDiff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: deleteEvent.Pts - deleteEvent.PtsCount, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber difference after own delete: %v", err)
|
||||
}
|
||||
if deleteDiff.Pts != deleteEvent.Pts || len(deleteDiff.OtherUpdates) != 1 || len(deleteDiff.OtherUpdates[0].MessageIDs) != 1 || deleteDiff.OtherUpdates[0].MessageIDs[0] != a.Message.ID {
|
||||
t.Fatalf("subscriber delete difference = %+v, want own deleted id %d at pts %d", deleteDiff, a.Message.ID, deleteEvent.Pts)
|
||||
}
|
||||
ptsBeforeReplay, eventsBeforeReplay := store.ptsSeq[monoID], len(store.events[monoID])
|
||||
deletedReplay, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_014})
|
||||
if err != nil {
|
||||
|
|
@ -153,3 +212,73 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", store.ptsSeq[monoID], len(store.events[monoID]), ptsBeforeReplay, eventsBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPaidMonoforumMessageLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "Paid DM", Broadcast: true, Date: 1_700_002_000})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
enabled, err := store.SetPaidMessagesPrice(ctx, 1, broadcast.Channel.ID, 10, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable paid DM: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
|
||||
baseMessages := len(store.messages[monoID])
|
||||
|
||||
low := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3001, Message: "too low", AllowPaidStars: 9, Date: 1_700_002_001}
|
||||
var required *domain.StarsPaymentRequiredError
|
||||
if _, err := store.SendMonoforumMessage(ctx, low); !errors.As(err, &required) || required.Stars != 10 {
|
||||
t.Fatalf("low authorization err = %v, want 10-Star payment required", err)
|
||||
}
|
||||
if len(store.messages[monoID]) != baseMessages {
|
||||
t.Fatalf("low authorization wrote a message")
|
||||
}
|
||||
|
||||
store.starsBalances[42] = 25
|
||||
paidReq := domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 3002, Message: "paid", AllowPaidStars: 99, Date: 1_700_002_002}
|
||||
paid, err := store.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid send: %v", err)
|
||||
}
|
||||
if paid.Message.PaidMessageStars != 10 || paid.SenderStarsBalance == nil || paid.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid result = %+v balance=%+v, want actual 10 and balance 15", paid.Message, paid.SenderStarsBalance)
|
||||
}
|
||||
if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("ledger sender/channel = %d/%d, want 15/8", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
duplicate, err := store.SendMonoforumMessage(ctx, paidReq)
|
||||
if err != nil {
|
||||
t.Fatalf("paid replay: %v", err)
|
||||
}
|
||||
if !duplicate.Duplicate || duplicate.Message.ID != paid.Message.ID || duplicate.SenderStarsBalance == nil || duplicate.SenderStarsBalance.Balance != 15 {
|
||||
t.Fatalf("paid replay = %+v, want original message and balance 15", duplicate)
|
||||
}
|
||||
if store.starsBalances[42] != 15 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("paid replay double charged: sender/channel=%d/%d", store.starsBalances[42], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
admin, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 3003, Message: "free admin reply", AllowPaidStars: 100, Date: 1_700_002_003,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin reply: %v", err)
|
||||
}
|
||||
if admin.Message.PaidMessageStars != 0 || admin.SenderStarsBalance != nil || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("admin reply charged: message=%+v balance=%+v channel=%d", admin.Message, admin.SenderStarsBalance, store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
|
||||
store.starsBalances[99] = 5
|
||||
other := domain.Peer{Type: domain.PeerTypeUser, ID: 99}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 3004, Message: "insufficient", AllowPaidStars: 10, Date: 1_700_002_004,
|
||||
}); !errors.Is(err, domain.ErrStarsInsufficient) {
|
||||
t.Fatalf("insufficient err = %v, want ErrStarsInsufficient", err)
|
||||
}
|
||||
if store.starsBalances[99] != 5 || store.channelStarsBalances[broadcast.Channel.ID] != 8 {
|
||||
t.Fatalf("insufficient send mutated ledger: sender/channel=%d/%d", store.starsBalances[99], store.channelStarsBalances[broadcast.Channel.ID])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,27 +73,29 @@ type ChannelStore struct {
|
|||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
|
||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
savedTags map[int64]map[string]domain.SavedReactionTag
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
adminLogs map[int64][]domain.ChannelAdminLogEvent
|
||||
invites map[string]domain.ChannelInvite
|
||||
importers map[int64]map[int64]domain.ChannelInviteImporter
|
||||
msgSeq map[int64]int
|
||||
ptsSeq map[int64]int
|
||||
logSeq map[int64]int64
|
||||
randomToID map[channelRandomKey]int
|
||||
sendSnapshots map[channelMessageReplayKey][]byte
|
||||
sendFingerprints map[channelMessageReplayKey][]byte
|
||||
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
|
||||
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
|
||||
readMarks map[int64]channelReadWatermark
|
||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
savedTags map[int64]map[string]domain.SavedReactionTag
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
adminLogs map[int64][]domain.ChannelAdminLogEvent
|
||||
invites map[string]domain.ChannelInvite
|
||||
importers map[int64]map[int64]domain.ChannelInviteImporter
|
||||
msgSeq map[int64]int
|
||||
ptsSeq map[int64]int
|
||||
logSeq map[int64]int64
|
||||
randomToID map[channelRandomKey]int
|
||||
sendSnapshots map[channelMessageReplayKey][]byte
|
||||
sendFingerprints map[channelMessageReplayKey][]byte
|
||||
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
|
||||
starsBalances map[int64]int64
|
||||
channelStarsBalances map[int64]int64
|
||||
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
|
||||
readMarks map[int64]channelReadWatermark
|
||||
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
|
||||
topicReads map[int64]map[int64]map[int]memoryTopicRead
|
||||
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
|
||||
|
|
@ -108,35 +110,37 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
|
|||
// NewChannelStore creates an in-memory ChannelStore.
|
||||
func NewChannelStore() *ChannelStore {
|
||||
return &ChannelStore{
|
||||
nextID: firstMemoryChannelID,
|
||||
nextHash: 900000000000,
|
||||
channels: make(map[int64]domain.Channel),
|
||||
members: make(map[int64]map[int64]domain.ChannelMember),
|
||||
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
|
||||
topics: make(map[int64]map[int]domain.ChannelForumTopic),
|
||||
messages: make(map[int64][]domain.ChannelMessage),
|
||||
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
|
||||
top: make(map[int64]map[string]domain.TopMessageReaction),
|
||||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
|
||||
invites: make(map[string]domain.ChannelInvite),
|
||||
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
|
||||
msgSeq: make(map[int64]int),
|
||||
ptsSeq: make(map[int64]int),
|
||||
logSeq: make(map[int64]int64),
|
||||
randomToID: make(map[channelRandomKey]int),
|
||||
sendSnapshots: make(map[channelMessageReplayKey][]byte),
|
||||
sendFingerprints: make(map[channelMessageReplayKey][]byte),
|
||||
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
nextID: firstMemoryChannelID,
|
||||
nextHash: 900000000000,
|
||||
channels: make(map[int64]domain.Channel),
|
||||
members: make(map[int64]map[int64]domain.ChannelMember),
|
||||
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
|
||||
topics: make(map[int64]map[int]domain.ChannelForumTopic),
|
||||
messages: make(map[int64][]domain.ChannelMessage),
|
||||
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
|
||||
top: make(map[int64]map[string]domain.TopMessageReaction),
|
||||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
|
||||
invites: make(map[string]domain.ChannelInvite),
|
||||
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
|
||||
msgSeq: make(map[int64]int),
|
||||
ptsSeq: make(map[int64]int),
|
||||
logSeq: make(map[int64]int64),
|
||||
randomToID: make(map[channelRandomKey]int),
|
||||
sendSnapshots: make(map[channelMessageReplayKey][]byte),
|
||||
sendFingerprints: make(map[channelMessageReplayKey][]byte),
|
||||
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
|
||||
starsBalances: make(map[int64]int64),
|
||||
channelStarsBalances: make(map[int64]int64),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
if msg.Deleted {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
|
||||
continue
|
||||
}
|
||||
if msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
|
|
@ -68,6 +71,16 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
}
|
||||
events := make([]domain.ChannelUpdateEvent, 0, limit)
|
||||
lastPts := req.Pts
|
||||
var visibleMonoforumMessageIDs map[int]struct{}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
visibleMonoforumMessageIDs = make(map[int]struct{})
|
||||
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}
|
||||
for _, message := range s.messages[req.ChannelID] {
|
||||
if message.SavedPeer == savedPeer {
|
||||
visibleMonoforumMessageIDs[message.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, event := range s.events[req.ChannelID] {
|
||||
if event.Pts <= req.Pts {
|
||||
continue
|
||||
|
|
@ -77,6 +90,12 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum && !isChannelAdmin(member) {
|
||||
visible, ok = filterMonoforumEventForUser(visible, req.UserID, visibleMonoforumMessageIDs)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if preview && visible.Type == domain.ChannelUpdateParticipant {
|
||||
continue
|
||||
}
|
||||
|
|
@ -121,6 +140,27 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
return diff, nil
|
||||
}
|
||||
|
||||
func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) {
|
||||
savedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
|
||||
if event.Message.ID != 0 {
|
||||
return event, event.Message.SavedPeer == savedPeer
|
||||
}
|
||||
if len(event.MessageIDs) == 0 {
|
||||
return event, false
|
||||
}
|
||||
visibleIDs := make([]int, 0, len(event.MessageIDs))
|
||||
for _, id := range event.MessageIDs {
|
||||
if _, ok := visibleMessageIDs[id]; ok {
|
||||
visibleIDs = append(visibleIDs, id)
|
||||
}
|
||||
}
|
||||
if len(visibleIDs) == 0 {
|
||||
return event, false
|
||||
}
|
||||
event.MessageIDs = visibleIDs
|
||||
return event, true
|
||||
}
|
||||
|
||||
func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
|
|||
|
|
@ -868,6 +868,14 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft {
|
|||
draft.WebPage = &webpage
|
||||
}
|
||||
draft.RichMessage = cloneRichMessage(draft.RichMessage)
|
||||
if draft.SuggestedPost != nil {
|
||||
suggested := *draft.SuggestedPost
|
||||
if suggested.Price != nil {
|
||||
price := *suggested.Price
|
||||
suggested.Price = &price
|
||||
}
|
||||
draft.SuggestedPost = &suggested
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -325,6 +325,25 @@ func (s *StarGiftStore) UniqueByIDs(_ context.Context, uniqueGiftIDs []int64) (m
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) ListUniqueByOwner(_ context.Context, owner domain.Peer, limit int) ([]domain.UniqueStarGift, error) {
|
||||
if owner.ID <= 0 || limit <= 0 {
|
||||
return []domain.UniqueStarGift{}, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domain.UniqueStarGift, 0, min(limit, len(s.uniqueByID)))
|
||||
for _, gift := range s.uniqueByID {
|
||||
if gift.Owner == owner && !gift.Burned && gift.OwnerAddress == "" {
|
||||
out = append(out, gift)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) {
|
||||
if !validSavedStarGift(gift) {
|
||||
return 0, domain.ErrStarGiftInvalid
|
||||
|
|
|
|||
|
|
@ -306,19 +306,20 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int)
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。
|
||||
func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentID int64, until int) (domain.User, error) {
|
||||
// UpdateEmojiStatus 更新用户自定义 emoji status(零值表示清除)。
|
||||
func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if documentID == 0 {
|
||||
until = 0
|
||||
if !status.Valid() {
|
||||
return domain.User{}, domain.ErrStarGiftCollectibleInvalid
|
||||
}
|
||||
u.EmojiStatusDocumentID = documentID
|
||||
u.EmojiStatusUntil = until
|
||||
u.EmojiStatusDocumentID = status.DocumentID
|
||||
u.EmojiStatusUntil = status.Until
|
||||
u.EmojiStatusCollectible = status.Collectible
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue