owpengram-server/internal/domain/poll.go

325 lines
12 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 domain
import (
"bytes"
"errors"
)
// 本文件定义投票poll的业务对象与共享语义
//
// - MessagePoll 是随消息 media JSONB 落库的「渲染安全定义快照」——不含 quiz 正确答案与
// solution 等服务端机密Closed 仅是发送时刻快照,读路径必须用 polls 权威态覆盖。
// - PollDefinition 是 polls 权威表行(含机密与可变 closed投票校验与结果门控只信它。
// - 投票校验ValidatePollVote与结果门控ResolvePollResults是纯函数由 memory 与
// postgres 两个 store 共用,杜绝双实现行为漂移。
//
// 转发不复制 poll转发快照携带同一 poll id投票/关闭全局聚合(与官方一致)。
const (
// MaxPollQuestionLength 与官方 poll question 上限一致。
MaxPollQuestionLength = 255
// MinPollAnswers / MaxPollAnswers 是答案个数边界(官方 2..10)。
MinPollAnswers = 2
MaxPollAnswers = 10
// MaxPollAnswerTextLength 是单个答案文本上限(官方 100
MaxPollAnswerTextLength = 100
// MaxPollSolutionLength 是 quiz 解释文本上限(官方 200
MaxPollSolutionLength = 200
// MinPollClosePeriod / MaxPollClosePeriod 是自动关闭倒计时边界(官方 5..600 秒)。
MinPollClosePeriod = 5
MaxPollClosePeriod = 600
// MaxPollRecentVoters 是 recent_voters 截断(与 reaction recent 同款,官方 3
MaxPollRecentVoters = 3
// MaxPollVotesPageLimit 是 messages.getPollVotes 单页上限。
MaxPollVotesPageLimit = 50
)
// 投票链路业务错误rpc 层映射为对应 RPC error 文本。
var (
ErrPollInvalid = errors.New("poll invalid")
ErrPollNotFound = errors.New("poll not found")
ErrPollClosed = errors.New("poll closed")
ErrPollOptionInvalid = errors.New("poll option invalid")
ErrPollRevoteNotAllowed = errors.New("poll revote not allowed")
ErrPollNotCreator = errors.New("poll can only be closed by creator")
ErrPollNotPublic = errors.New("poll voters are not public")
)
// MessagePollAnswer 是一个候选答案(文本 + option 字节键 + 可选配图)。
// Media 仅允许 photo/document 快照rpc 层限制输入类型)。
type MessagePollAnswer struct {
Text string `json:"text"`
Entities []MessageEntity `json:"entities,omitempty"`
Option []byte `json:"option"`
Media *MessageMedia `json:"media,omitempty"`
}
// MessagePoll 是消息 media 上的 poll 定义快照(渲染安全,不含机密)。
type MessagePoll struct {
ID int64 `json:"id"`
Question string `json:"question"`
QuestionEntities []MessageEntity `json:"question_entities,omitempty"`
Answers []MessagePollAnswer `json:"answers"`
// Closed 是发送时刻快照;读路径以 polls 权威态覆盖(转发副本/关闭后历史一致性靠它)。
Closed bool `json:"closed,omitempty"`
PublicVoters bool `json:"public_voters,omitempty"`
MultipleChoice bool `json:"multiple_choice,omitempty"`
Quiz bool `json:"quiz,omitempty"`
RevotingDisabled bool `json:"revoting_disabled,omitempty"`
ShuffleAnswers bool `json:"shuffle_answers,omitempty"`
HideResultsUntilClose bool `json:"hide_results_until_close,omitempty"`
ClosePeriod int `json:"close_period,omitempty"`
CloseDate int `json:"close_date,omitempty"`
// AttachedMedia 是 poll 题干配图messageMediaPoll.attached_mediaphoto/document 快照。
AttachedMedia *MessageMedia `json:"attached_media,omitempty"`
// Results 在读路径按 viewer 填充(含 chosen/correct/solution 门控),不落库。
Results *MessagePollResults `json:"-"`
}
// MessagePollAnswerVoters 是一个答案的聚合结果(已按 viewer 门控)。
type MessagePollAnswerVoters struct {
Option []byte
Voters int
Chosen bool
Correct bool
}
// MessagePollResults 是按 viewer 解析后的聚合结果。
type MessagePollResults struct {
TotalVoters int
Voters []MessagePollAnswerVoters // 与 MessagePoll.Answers 同序
RecentVoters []int64 // 仅 public_voters 填充,截断 MaxPollRecentVoters
ViewerVoted bool
Solution string // 仅 quiz 且 (viewer 已投 || 已关闭) 下发
SolutionEntities []MessageEntity
}
// PollDefinition 是 polls 权威表行可变状态closed+ 服务端机密 + 校验所需选项集。
type PollDefinition struct {
ID int64
CreatorUserID int64
Options [][]byte // 合法选项集合(与答案顺序一致)
PublicVoters bool
MultipleChoice bool
Quiz bool
RevotingDisabled bool
HideResultsUntilClose bool
Closed bool
ClosePeriod int
CloseDate int
CorrectOptions [][]byte
Solution string
SolutionEntities []MessageEntity
}
// ClosedAt 返回 now 时刻 poll 是否应视为已关闭(显式关闭或 close_date 已过)。
func (d PollDefinition) ClosedAt(now int) bool {
return d.Closed || (d.CloseDate > 0 && now >= d.CloseDate)
}
// HasOption 判断 option 是否在合法选项集内。
func (d PollDefinition) HasOption(option []byte) bool {
for _, candidate := range d.Options {
if bytes.Equal(candidate, option) {
return true
}
}
return false
}
// PollVote 是一个用户的投票行。Options 保留客户端提交顺序。
type PollVote struct {
PollID int64
UserID int64
Options [][]byte
Date int
}
// PollAggregates 是 store 层产出的原始聚合,交由 ResolvePollResults 做 viewer 门控。
// Counts 的 key 是 string(option)。
type PollAggregates struct {
Counts map[string]int
TotalVoters int
RecentVoters []int64 // 按 vote_date DESC 截断 MaxPollRecentVoters仅 public_voters 需要
ViewerOptions [][]byte // viewer 自己的投票nil = 未投)
}
// ValidatePollVote 校验一次投票提交existing 是 viewer 当前投票nil=未投options 为空表示撤票。
// memory 与 postgres store 必须共用本函数,保持双实现一致。
func ValidatePollVote(def PollDefinition, existing [][]byte, options [][]byte, now int) error {
if def.ID == 0 {
return ErrPollNotFound
}
if def.ClosedAt(now) {
return ErrPollClosed
}
if def.Quiz || def.RevotingDisabled {
// quiz / revoting_disabled不可撤票、不可改票。
if len(existing) > 0 || len(options) == 0 {
return ErrPollRevoteNotAllowed
}
}
if def.Quiz {
if len(options) != 1 {
return ErrPollOptionInvalid
}
} else if len(options) > 1 && !def.MultipleChoice {
return ErrPollOptionInvalid
}
if len(options) > len(def.Options) {
return ErrPollOptionInvalid
}
seen := make(map[string]struct{}, len(options))
for _, option := range options {
if !def.HasOption(option) {
return ErrPollOptionInvalid
}
key := string(option)
if _, dup := seen[key]; dup {
return ErrPollOptionInvalid
}
seen[key] = struct{}{}
}
return nil
}
// ResolvePollResults 把原始聚合解析成 viewer 视角结果chosen 标 viewer 自己的选项;
// correct/solution 仅 quiz 且 (viewer 已投 || 已关闭) 揭示;
// hide_results_until_close 在关闭前对非创建者隐藏计数(防恶意客户端绕过 UI
func ResolvePollResults(def PollDefinition, agg PollAggregates, viewerUserID int64, now int) MessagePollResults {
closed := def.ClosedAt(now)
viewerVoted := len(agg.ViewerOptions) > 0
reveal := def.Quiz && (viewerVoted || closed)
hideCounts := def.HideResultsUntilClose && !closed && viewerUserID != def.CreatorUserID
chosen := make(map[string]struct{}, len(agg.ViewerOptions))
for _, option := range agg.ViewerOptions {
chosen[string(option)] = struct{}{}
}
correct := make(map[string]struct{}, len(def.CorrectOptions))
if reveal {
for _, option := range def.CorrectOptions {
correct[string(option)] = struct{}{}
}
}
out := MessagePollResults{
TotalVoters: agg.TotalVoters,
Voters: make([]MessagePollAnswerVoters, 0, len(def.Options)),
ViewerVoted: viewerVoted,
}
for _, option := range def.Options {
key := string(option)
_, isChosen := chosen[key]
_, isCorrect := correct[key]
voters := agg.Counts[key]
if hideCounts {
voters = 0
}
out.Voters = append(out.Voters, MessagePollAnswerVoters{
Option: option,
Voters: voters,
Chosen: isChosen,
Correct: isCorrect,
})
}
if def.PublicVoters && !hideCounts && len(agg.RecentVoters) > 0 {
recent := agg.RecentVoters
if len(recent) > MaxPollRecentVoters {
recent = recent[:MaxPollRecentVoters]
}
out.RecentVoters = append([]int64(nil), recent...)
}
if reveal {
out.Solution = def.Solution
out.SolutionEntities = append([]MessageEntity(nil), def.SolutionEntities...)
}
return out
}
// ApplyPollState 把权威态与解析结果写回 media 上的定义快照(读路径 enrichment 终点)。
func ApplyPollState(poll *MessagePoll, def PollDefinition, results MessagePollResults, now int) {
if poll == nil {
return
}
poll.Closed = def.ClosedAt(now)
poll.Results = &results
}
// VotePrivateMessagePollRequest 是私聊消息投票请求msg id 为 viewer box id
type VotePrivateMessagePollRequest struct {
UserID int64
Peer Peer
MessageID int
Options [][]byte // 空 = 撤票
Date int
}
// PrivateMessagePollResult 返回两个 owner 视角的消息media 已按各自 owner enrich
type PrivateMessagePollResult struct {
PollID int64
Messages []Message
}
// ClosePrivateMessagePollRequest 关闭私聊消息上的 poll仅 poll 创建者)。
type ClosePrivateMessagePollRequest struct {
UserID int64
Peer Peer
MessageID int
Date int
}
// VoteChannelMessagePollRequest 是频道/超级群消息投票请求。
type VoteChannelMessagePollRequest struct {
UserID int64
ChannelID int64
MessageID int
Options [][]byte
Date int
}
// ChannelMessagePollResult 返回投票者视角消息与建议推送收件人。
type ChannelMessagePollResult struct {
PollID int64
Channel Channel
Message ChannelMessage
Recipients []int64
}
// ChannelPollFanoutViews 是频道 poll fan-out 的批量 per-viewer enrich 结果(消除逐 viewer GetMessages
// 的 N+1viewer-invariant 聚合counts/total/recent只算一次 + 批量 viewerOptions + 批量成员可见性,
// 每 viewer 用同一 ResolvePollResults 合成,与逐 viewer 路径字节同源。
// - Foundpoll 消息存在且确为 poll否则视为无可投影
// - Message基础消息快照未 per-viewer enrich供 app 层叠加 bot 历史可见性过滤。
// - Pollskey 存在=已评估该 viewer值为 nil=该 viewer 不可见(非成员/pre-history 隐藏);
// 非 nil=该 viewer 视角已 enrich 的 poll。bot 历史过滤由 app 层在此基础上叠加(置 nil
type ChannelPollFanoutViews struct {
Found bool
Message ChannelMessage
Polls map[int64]*MessagePoll
}
// CloseChannelMessagePollRequest 关闭频道消息上的 poll仅 poll 创建者)。
type CloseChannelMessagePollRequest struct {
UserID int64
ChannelID int64
MessageID int
Date int
}
// PollVotesListRequest 是 messages.getPollVotes 的分页请求。
type PollVotesListRequest struct {
PollID int64
Option []byte // 可选:仅列出投了该选项的人
OffsetDate int // 0 = 第一页;翻页用上页末行 (date,user_id)
OffsetUserID int64
Limit int
}
// PollVotesList 是投票人分页结果。
type PollVotesList struct {
Count int // 满足过滤条件的总数
Votes []PollVote
// HasMore 为 true 时 rpc 层用末行 (Date,UserID) 编码 next_offset。
HasMore bool
}