owpengram-server/internal/store/memory/poll.go

284 lines
8.2 KiB
Go
Raw Permalink 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 memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
// PollStore 是 store.PollStore 的内存实现,同时充当 MessageStore/ChannelStore 投票与
// 读路径 enrichment 的共享权威postgres 侧对应 polls / poll_votes 两张表)。
// 校验与门控复用 domain.ValidatePollVote / ResolvePollResults保证与 postgres 行为一致。
type PollStore struct {
mu sync.RWMutex
polls map[int64]domain.PollDefinition
votes map[int64]map[int64]domain.PollVote
}
// NewPollStore 创建内存 poll 权威存储。
func NewPollStore() *PollStore {
return &PollStore{
polls: make(map[int64]domain.PollDefinition),
votes: make(map[int64]map[int64]domain.PollVote),
}
}
func (s *PollStore) CreatePoll(_ context.Context, def domain.PollDefinition) error {
if def.ID == 0 || len(def.Options) == 0 {
return domain.ErrPollInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.polls[def.ID]; exists {
return domain.ErrPollInvalid
}
s.polls[def.ID] = clonePollDefinition(def)
return nil
}
func (s *PollStore) GetPollDefinition(_ context.Context, pollID int64) (domain.PollDefinition, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
def, ok := s.polls[pollID]
if !ok {
return domain.PollDefinition{}, false, nil
}
return clonePollDefinition(def), true, nil
}
func (s *PollStore) ListPollVotes(_ context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error) {
if req.PollID == 0 || req.Limit <= 0 {
return domain.PollVotesList{}, domain.ErrPollInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
if _, ok := s.polls[req.PollID]; !ok {
return domain.PollVotesList{}, domain.ErrPollNotFound
}
rows := make([]domain.PollVote, 0, len(s.votes[req.PollID]))
for _, vote := range s.votes[req.PollID] {
if len(req.Option) > 0 && !voteHasOption(vote, req.Option) {
continue
}
rows = append(rows, clonePollVote(vote))
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Date != rows[j].Date {
return rows[i].Date > rows[j].Date
}
return rows[i].UserID > rows[j].UserID
})
out := domain.PollVotesList{Count: len(rows)}
start := 0
if req.OffsetDate > 0 || req.OffsetUserID > 0 {
for i, row := range rows {
if row.Date < req.OffsetDate || (row.Date == req.OffsetDate && row.UserID < req.OffsetUserID) {
start = i
break
}
start = i + 1
}
}
end := start + req.Limit
if end > len(rows) {
end = len(rows)
}
if start < end {
out.Votes = rows[start:end]
}
out.HasMore = end < len(rows)
return out, nil
}
// Vote 校验并落一票options 为空 = 撤票)。校验逻辑全部来自 domain.ValidatePollVote。
func (s *PollStore) Vote(pollID, userID int64, options [][]byte, date int) error {
if pollID == 0 || userID == 0 {
return domain.ErrPollNotFound
}
if date == 0 {
date = int(time.Now().Unix())
}
s.mu.Lock()
defer s.mu.Unlock()
def, ok := s.polls[pollID]
if !ok {
return domain.ErrPollNotFound
}
var existing [][]byte
if vote, voted := s.votes[pollID][userID]; voted {
existing = vote.Options
}
if err := domain.ValidatePollVote(def, existing, options, date); err != nil {
return err
}
if len(options) == 0 {
delete(s.votes[pollID], userID)
return nil
}
if s.votes[pollID] == nil {
s.votes[pollID] = make(map[int64]domain.PollVote)
}
s.votes[pollID][userID] = clonePollVote(domain.PollVote{PollID: pollID, UserID: userID, Options: options, Date: date})
return nil
}
// Close 关闭 poll仅创建者可关重复关闭幂等。
func (s *PollStore) Close(pollID, byUserID int64) error {
if pollID == 0 || byUserID == 0 {
return domain.ErrPollNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
def, ok := s.polls[pollID]
if !ok {
return domain.ErrPollNotFound
}
if def.CreatorUserID != byUserID {
return domain.ErrPollNotCreator
}
def.Closed = true
s.polls[pollID] = def
return nil
}
// EnrichPoll 把权威态 + viewer 视角聚合写回 media 定义快照poll 不存在时保持快照原样。
func (s *PollStore) EnrichPoll(poll *domain.MessagePoll, viewerUserID int64, now int) {
if s == nil || poll == nil || poll.ID == 0 {
return
}
s.mu.RLock()
defer s.mu.RUnlock()
def, ok := s.polls[poll.ID]
if !ok {
return
}
agg := domain.PollAggregates{Counts: make(map[string]int)}
recent := make([]domain.PollVote, 0, len(s.votes[poll.ID]))
for userID, vote := range s.votes[poll.ID] {
agg.TotalVoters++
for _, option := range vote.Options {
agg.Counts[string(option)]++
}
if userID == viewerUserID {
agg.ViewerOptions = append([][]byte(nil), vote.Options...)
}
recent = append(recent, vote)
}
sort.Slice(recent, func(i, j int) bool {
if recent[i].Date != recent[j].Date {
return recent[i].Date > recent[j].Date
}
return recent[i].UserID > recent[j].UserID
})
for i, vote := range recent {
if i >= domain.MaxPollRecentVoters {
break
}
agg.RecentVoters = append(agg.RecentVoters, vote.UserID)
}
results := domain.ResolvePollResults(def, agg, viewerUserID, now)
domain.ApplyPollState(poll, def, results, now)
}
// EnrichPollForViewers 批量为一组 viewer 返回 per-viewer enrich 的 pollfan-out 模板化):
// viewer-invariant 聚合counts/total/recent只遍历一次 + per-viewer ViewerOptions每 viewer 用与
// 单 viewer EnrichPoll 完全相同的 ResolvePollResults/ApplyPollState 合成(字节同源)。返回 map[viewer]
// 各自的 poll 克隆def 不存在时返回空 map与 EnrichPoll 的 no-op 一致)。
func (s *PollStore) EnrichPollForViewers(basePoll *domain.MessagePoll, viewers []int64, now int) map[int64]*domain.MessagePoll {
out := make(map[int64]*domain.MessagePoll, len(viewers))
if s == nil || basePoll == nil || basePoll.ID == 0 || len(viewers) == 0 {
return out
}
s.mu.RLock()
defer s.mu.RUnlock()
def, ok := s.polls[basePoll.ID]
if !ok {
return out
}
counts := make(map[string]int)
total := 0
optionsByUser := make(map[int64][][]byte, len(s.votes[basePoll.ID]))
recent := make([]domain.PollVote, 0, len(s.votes[basePoll.ID]))
for userID, vote := range s.votes[basePoll.ID] {
total++
for _, option := range vote.Options {
counts[string(option)]++
}
optionsByUser[userID] = append([][]byte(nil), vote.Options...)
recent = append(recent, vote)
}
sort.Slice(recent, func(i, j int) bool {
if recent[i].Date != recent[j].Date {
return recent[i].Date > recent[j].Date
}
return recent[i].UserID > recent[j].UserID
})
recentVoters := make([]int64, 0, domain.MaxPollRecentVoters)
for i, vote := range recent {
if i >= domain.MaxPollRecentVoters {
break
}
recentVoters = append(recentVoters, vote.UserID)
}
for _, viewer := range viewers {
agg := domain.PollAggregates{
Counts: counts,
TotalVoters: total,
RecentVoters: recentVoters,
ViewerOptions: optionsByUser[viewer],
}
results := domain.ResolvePollResults(def, agg, viewer, now)
pollCopy := *basePoll
domain.ApplyPollState(&pollCopy, def, results, now)
out[viewer] = &pollCopy
}
return out
}
// enrichPollMediaForViewer 克隆 media避免共享指针把 viewer 态写进 store 本体)并 enrich。
func enrichPollMediaForViewer(polls *PollStore, media *domain.MessageMedia, viewerUserID int64, now int) *domain.MessageMedia {
if polls == nil || media == nil || media.Kind != domain.MessageMediaKindPoll || media.Poll == nil {
return media
}
cloned := *media
poll := *media.Poll
poll.Answers = append([]domain.MessagePollAnswer(nil), media.Poll.Answers...)
cloned.Poll = &poll
polls.EnrichPoll(cloned.Poll, viewerUserID, now)
return &cloned
}
func clonePollDefinition(def domain.PollDefinition) domain.PollDefinition {
def.Options = cloneOptionList(def.Options)
def.CorrectOptions = cloneOptionList(def.CorrectOptions)
def.SolutionEntities = append([]domain.MessageEntity(nil), def.SolutionEntities...)
return def
}
func clonePollVote(vote domain.PollVote) domain.PollVote {
vote.Options = cloneOptionList(vote.Options)
return vote
}
func cloneOptionList(options [][]byte) [][]byte {
if options == nil {
return nil
}
out := make([][]byte, 0, len(options))
for _, option := range options {
out = append(out, append([]byte(nil), option...))
}
return out
}
func voteHasOption(vote domain.PollVote, option []byte) bool {
for _, candidate := range vote.Options {
if string(candidate) == string(option) {
return true
}
}
return false
}