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 的 poll(fan-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 }