Files
tamabox/route/vote/vote.go
T
2026-10-19 16:46:42 +08:00

1147 lines
40 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.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package vote
import (
stdctx "context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/cache"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/theme"
)
// ctxLang 取当前请求已解析好的语言代码(由 Contexter 写入 Data)。
func ctxLang(ctx context.Context) string {
if l, ok := ctx.Data["LangCode"].(string); ok && l != "" {
return l
}
return i18n.Default
}
// applyOwnerLocale 让界面语言与主题跟随投票发布者,与提问箱页面同一套规则:
// 投票链接是发布者对外分享的内容,访客看到的应当是他的语言与配色。
// 发布者未配置语言时保留 Contexter 解析出的值(访客 cookie / 站点默认)。
func applyOwnerLocale(data map[string]interface{}, owner *db.User) {
data["Owner"] = owner
if owner.Language != "" {
lang := i18n.Resolve(owner.Language)
data["Lang"] = lang
data["LangCode"] = lang
}
data["ThemeSlug"] = theme.Resolve(owner.Theme)
}
// resultsCache 投票结果聚合统计的短 TTL 进程内缓存。
// 结果统计需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)),缓存可显著降低数据库实时重算与 CPU 开销;
// 结果页非强一致,30s 陈旧可接受(新提交最多延迟 30s 反映)。多实例部署时各实例独立缓存,不影响正确性。
var resultsCache = cache.NewTTLCache(256)
// resultsCacheTTL 结果缓存有效期。
const resultsCacheTTL = 30 * time.Second
// InvalidateResultsCache 主动失效某投票的结果聚合缓存(写穿式)。
// 投票提交、选项/字段变更、投票删除等会改变结果的操作都应调用,
// 避免结果页在 TTL 内显示陈旧数据(多实例部署下各实例独立失效自身缓存)。
func InvalidateResultsCache(voteID uint) {
resultsCache.Delete(strconv.FormatUint(uint64(voteID), 10))
}
// censorCheck 对开放文本做内容审核。
//
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
// 投票发起人的「审核严格度」档位决定,而不是无条件采信审核服务的判定。
// 审核服务异常仍为 fail-closed 拒绝提交;EnableTextCensor=false 时底层自动放行。
// owner 为 nil 时回退到最严档位,避免发起人为空就把或从宽放行。
func censorCheck(ctx context.Context, owner *db.User, lang, text string) error {
strictness := censor.StrictnessStrict
if owner != nil {
strictness = moderation.EffectiveStrictness(ctx.Request().Context(), owner.ID, owner.ModerationStrictness)
}
_, err := moderation.Check(ctx.Request().Context(), strictness, lang, text)
return err
}
// blockWordCheck 校验开放文本是否命中投票发起人的全局屏蔽词表
// (users.block_words,与提问/私聊共用同一份词表,owner 为投票发起人)。
// 命中即返回提示,供调用方以表单错误形式中断提交。
func blockWordCheck(owner *db.User, lang, text string) error {
if owner == nil {
return nil
}
if _, hit := shared.BlockWordHit(owner.BlockWords, text); hit {
return errors.New(i18n.T(lang, "vote.err.blockWords"))
}
return nil
}
// voterCookieName 匿名投票指纹 Cookie(服务端签发,防伪造;HttpOnly 防脚本读取)。
const voterCookieName = "vote_fp"
// voterTokenSecret 使用启动时强制配置的 XSRF key 作为 Cookie 签名密钥。
// Cookie 本身仍不是强身份凭证,匿名提交同时受 IP 限流保护。
func voterTokenSecret() []byte {
return []byte(conf.Server.XSRFKey)
}
func signVoterToken(nonce string) string {
mac := hmac.New(sha256.New, voterTokenSecret())
_, _ = mac.Write([]byte(nonce))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// newVoterToken 生成带 HMAC 的匿名指纹 token。
func newVoterToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand 失败时使用时间戳摘要兜底,保持固定 token 格式;提交端的 IP 限流仍生效。
fallback := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
copy(b, fallback[:])
}
nonce := hex.EncodeToString(b)
return nonce + "." + signVoterToken(nonce)
}
func validVoterToken(token string) bool {
parts := strings.Split(token, ".")
if len(parts) != 2 || len(parts[0]) != 64 || len(parts[1]) == 0 {
return false
}
if _, err := hex.DecodeString(parts[0]); err != nil {
return false
}
expected := signVoterToken(parts[0])
return hmac.Equal([]byte(parts[1]), []byte(expected))
}
// ensureVoterToken 读取或签发匿名指纹 Cookie;无效/伪造值会被覆盖。
func ensureVoterToken(ctx context.Context) string {
if c, err := ctx.Request().Cookie(voterCookieName); err == nil && validVoterToken(c.Value) {
return c.Value
}
token := newVoterToken()
http.SetCookie(ctx.ResponseWriter(), &http.Cookie{
Name: voterCookieName,
Value: token,
Path: "/",
MaxAge: 30 * 24 * 3600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return token
}
// currentVoterToken 仅返回服务端签发且签名有效的 Cookie。
func currentVoterToken(ctx context.Context) string {
if c, err := ctx.Request().Cookie(voterCookieName); err == nil && validVoterToken(c.Value) {
return c.Value
}
return ""
}
// VoterTokenRateLimitKey 返回有效匿名 token 的限流标识。
// 无效或缺失的 Cookie 返回空串,由调用方回退到 IP 兜底限流。
func VoterTokenRateLimitKey(r *http.Request) string {
if r == nil {
return ""
}
c, err := r.Cookie(voterCookieName)
if err != nil || !validVoterToken(c.Value) {
return ""
}
return c.Value
}
// ---------------- 状态与结果策略 ----------------
func isVoteActive(v *db.Vote, now time.Time) bool {
if v.Status != db.VoteStatusPublished {
return false
}
if v.StartAt != nil && now.Before(*v.StartAt) {
return false
}
if v.EndAt != nil && !now.Before(*v.EndAt) {
return false
}
return true
}
func isVoteEnded(v *db.Vote, now time.Time) bool {
if v.Status == db.VoteStatusEnded {
return true
}
return v.EndAt != nil && !now.Before(*v.EndAt)
}
// ---------------- 结果统计 ----------------
type OptionStat struct {
Option *db.VoteOption
Count int64
Percent int
AvgRank float64 // rank 题型:该选项的平均排名
}
type RatingStat struct {
Score int
Count int64
}
type RankStat struct {
Option *db.VoteOption
AvgRank float64
}
// CustomStat 自定义补充选项统计(AllowCustom 时参与者填写的自由文本)。
type CustomStat struct {
Text string
Count int64
}
// FormFieldStat 表单投票(混合问卷)中单个字段的聚合统计。
type FormFieldStat struct {
Field *db.VoteFormField
Total int64 // 有效回答数(参与该字段的人数)
TotalSelections int64 // 多选(checkbox)总勾选数,作为百分比分母;单选/评分/排序恒为 0
OptionStats []*OptionStat // radio/select/checkbox 选项票数;rank 为"被排次数"
CustomStats []CustomStat // 允许补充自定义选项的字段:自定义文本(按文本去重计数)
AvgRating float64 // rating 平均分
RatingCounts []*RatingStat // rating 分布(1-10)
AvgRank float64 // rank 全部选项的平均排名
AvgNumber float64 // number 平均值
TextCount int64 // text/textarea 填写数(内容不展示,保护隐私)
}
type Results struct {
Type string
Total int64
Options []*OptionStat // single / multiple
Ratings []*RatingStat // rating
AvgRating float64
Rankings []*RankStat // rank
AvgRank float64
CustomStats []CustomStat // 自定义补充选项(按文本去重计数)
FormCount int64 // form:参与总数
FormFields []*db.VoteFormField
FormFieldStats []*FormFieldStat // form:各字段聚合统计(混合问卷)
}
// LoadResults 按投票类型聚合统计(参与端结果页与管理后台结果页共用)。
func LoadResults(ctx context.Context, v *db.Vote) (*Results, error) {
// 短 TTL 缓存:避免每次请求都逐条 json.Unmarshal 全部提交记录(O(N) 重算)。
cacheKey := strconv.FormatUint(uint64(v.ID), 10)
if cached, ok := resultsCache.Get(cacheKey); ok {
return cached.(*Results), nil
}
res := &Results{Type: v.Type}
total, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), v.ID)
if err != nil {
return nil, errors.Wrap(err, "count submissions")
}
res.Total = total
switch v.Type {
case db.VoteTypeSingle:
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
// 一次分组查询替代逐选项 COUNT(避免 N+1)
counts, err := db.VoteSubmissions.CountGroupByOptionID(ctx.Request().Context(), v.ID)
if err != nil {
return nil, errors.Wrap(err, "count options")
}
for _, o := range options {
count := counts[o.ID]
percent := 0
if total > 0 {
percent = int(count * 100 / total)
}
res.Options = append(res.Options, &OptionStat{Option: o, Count: count, Percent: percent})
}
case db.VoteTypeMultiple:
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
counts := make(map[uint]int64, len(options))
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
var totalSelections int64
for _, s := range submissions {
var data struct {
Options []uint `json:"options"`
}
_ = json.Unmarshal(s.FormData, &data)
seen := make(map[uint]struct{}, len(data.Options))
for _, id := range data.Options {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
counts[id]++
totalSelections++
}
}
for _, o := range options {
count := counts[o.ID]
percent := 0
// 多选:百分比分母取"总勾选数"(各项相加≈100%,符合"票数分布"直觉);
// res.Total 仍是参与人数,用于"总参与"展示。
if totalSelections > 0 {
percent = int(count * 100 / totalSelections)
}
res.Options = append(res.Options, &OptionStat{Option: o, Count: count, Percent: percent})
}
case db.VoteTypeRating:
scoreCounts := make(map[int]int64)
var sum int64
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
for _, s := range submissions {
var data struct {
Rating int `json:"rating"`
}
_ = json.Unmarshal(s.FormData, &data)
if data.Rating >= 1 && data.Rating <= 10 {
scoreCounts[data.Rating]++
sum += int64(data.Rating)
}
}
for score := 1; score <= 10; score++ {
res.Ratings = append(res.Ratings, &RatingStat{Score: score, Count: scoreCounts[score]})
}
if total > 0 {
res.AvgRating = float64(sum) / float64(total)
}
case db.VoteTypeRank:
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
rankSums := make(map[uint]float64, len(options))
rankCounts := make(map[uint]int64, len(options))
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
for _, s := range submissions {
var data struct {
Ranking map[string]float64 `json:"ranking"`
}
_ = json.Unmarshal(s.FormData, &data)
for idStr, r := range data.Ranking {
id, _ := strconv.ParseUint(idStr, 10, 64)
rankSums[uint(id)] += r
rankCounts[uint(id)]++
}
}
for _, o := range options {
var avg float64
if rankCounts[o.ID] > 0 {
avg = rankSums[o.ID] / float64(rankCounts[o.ID])
}
res.Rankings = append(res.Rankings, &RankStat{Option: o, AvgRank: avg})
}
var rankSum float64
for _, r := range res.Rankings {
rankSum += r.AvgRank
}
if len(res.Rankings) > 0 {
res.AvgRank = rankSum / float64(len(res.Rankings))
}
case db.VoteTypeForm:
res.FormFields, _ = db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
res.FormCount = total
res.FormFieldStats = aggregateFormFieldStats(ctx, res.FormFields)
}
// 自定义补充选项聚合(single/multiple/rank 且开启 AllowCustom;评分/表单不适用)
if v.AllowCustom && (v.Type == db.VoteTypeSingle || v.Type == db.VoteTypeMultiple || v.Type == db.VoteTypeRank) {
res.CustomStats = aggregateCustomStats(ctx, v)
}
resultsCache.Set(cacheKey, res, resultsCacheTTL)
return res, nil
}
// aggregateFormFieldStats 统计表单投票(混合问卷)各字段的聚合结果:
// radio/select/checkbox → 选项票数;rating → 分布 + 平均分;rank → 各选项被排次数 + 平均排名;
// number → 平均值;text/textarea → 填写数(内容不聚合展示,保护隐私)。
func aggregateFormFieldStats(ctx context.Context, fields []*db.VoteFormField) []*FormFieldStat {
if len(fields) == 0 {
return nil
}
// 必须加载全部提交记录:radio/select/checkbox/rating/rank/number 等字段的统计
// 同样依赖 submissions,不能只在存在 text/textarea 字段时才加载。
// 否则无文本字段的表单投票结果页会全部显示为 0(缺陷)。
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), fields[0].VoteID, nil)
return aggregateFormFieldStatsWithSubs(fields, submissions)
}
// aggregateFormFieldStatsWithSubs 统计核心(无 context 依赖,便于单元测试)。
func aggregateFormFieldStatsWithSubs(fields []*db.VoteFormField, submissions []*db.VoteSubmission) []*FormFieldStat {
stats := make([]*FormFieldStat, 0, len(fields))
for _, f := range fields {
stat := &FormFieldStat{Field: f}
var fieldOpts []string
if f.Options != "" {
_ = json.Unmarshal([]byte(f.Options), &fieldOpts)
}
hasSubmissions := submissions != nil
switch f.FieldType {
case db.VoteFormFieldTypeRadio, db.VoteFormFieldTypeSelect:
counts := make(map[string]int64, len(fieldOpts))
presetSet := make(map[string]bool, len(fieldOpts))
for _, o := range fieldOpts {
presetSet[o] = true
}
if hasSubmissions {
for _, s := range submissions {
var data map[string]interface{}
_ = json.Unmarshal(s.FormData, &data)
if val, ok := data[f.Label].(string); ok && val != "" {
stat.Total++
// 开启"允许补充自定义选项"时,非预置值视为自定义补充文本(按文本去重计数)
if f.AllowCustom && !presetSet[val] {
stat.CustomStats = addCustomStat(stat.CustomStats, val)
continue
}
counts[val]++
}
}
}
for _, o := range fieldOpts {
stat.OptionStats = append(stat.OptionStats, &OptionStat{Option: &db.VoteOption{Content: o}, Count: counts[o]})
}
case db.VoteFormFieldTypeCheckbox:
counts := make(map[string]int64, len(fieldOpts))
presetSet := make(map[string]bool, len(fieldOpts))
for _, o := range fieldOpts {
presetSet[o] = true
}
var totalSelections int64
if hasSubmissions {
for _, s := range submissions {
var data map[string]interface{}
_ = json.Unmarshal(s.FormData, &data)
if arr, ok := data[f.Label].([]interface{}); ok && len(arr) > 0 {
stat.Total++
for _, v := range arr {
totalSelections++
val := fmt.Sprint(v)
// 开启"允许补充自定义选项"时,非预置值视为自定义补充文本(按文本去重计数)
if f.AllowCustom && !presetSet[val] {
stat.CustomStats = addCustomStat(stat.CustomStats, val)
continue
}
counts[val]++
}
}
}
}
// 多选(checkbox):百分比分母用"总勾选数"而非参与人数,使各项相加≈100%
stat.TotalSelections = totalSelections
for _, o := range fieldOpts {
stat.OptionStats = append(stat.OptionStats, &OptionStat{Option: &db.VoteOption{Content: o}, Count: counts[o]})
}
case db.VoteFormFieldTypeRating:
scoreCounts := make(map[int]int64, 10)
var sum int64
if hasSubmissions {
for _, s := range submissions {
var data map[string]interface{}
_ = json.Unmarshal(s.FormData, &data)
if n, ok := data[f.Label].(float64); ok && n >= 1 && n <= 10 {
scoreCounts[int(n)]++
sum += int64(n)
stat.Total++
}
}
}
for score := 1; score <= 10; score++ {
stat.RatingCounts = append(stat.RatingCounts, &RatingStat{Score: score, Count: scoreCounts[score]})
}
if stat.Total > 0 {
stat.AvgRating = float64(sum) / float64(stat.Total)
}
case db.VoteFormFieldTypeRank:
rankSums := make(map[string]float64, len(fieldOpts))
rankCounts := make(map[string]int64, len(fieldOpts))
if hasSubmissions {
for _, s := range submissions {
var data map[string]interface{}
_ = json.Unmarshal(s.FormData, &data)
if m, ok := data[f.Label].(map[string]interface{}); ok && len(m) > 0 {
stat.Total++
for opt, r := range m {
if rv, ok := r.(float64); ok {
rankSums[opt] += rv
rankCounts[opt]++
}
}
}
}
}
var allSum float64
var allCnt int64
for _, o := range fieldOpts {
var avg float64
if rankCounts[o] > 0 {
avg = rankSums[o] / float64(rankCounts[o])
}
stat.OptionStats = append(stat.OptionStats, &OptionStat{Option: &db.VoteOption{Content: o}, Count: rankCounts[o], AvgRank: avg})
allSum += rankSums[o]
allCnt += rankCounts[o]
}
if allCnt > 0 {
stat.AvgRank = allSum / float64(allCnt)
}
case db.VoteFormFieldTypeNumber:
var sum float64
if hasSubmissions {
for _, s := range submissions {
var data map[string]interface{}
_ = json.Unmarshal(s.FormData, &data)
if n, ok := data[f.Label].(float64); ok {
sum += n
stat.Total++
}
}
}
if stat.Total > 0 {
stat.AvgNumber = sum / float64(stat.Total)
}
case db.VoteFormFieldTypeText, db.VoteFormFieldTypeTextarea:
if hasSubmissions {
for _, s := range submissions {
var data map[string]interface{}
_ = json.Unmarshal(s.FormData, &data)
if val, ok := data[f.Label].(string); ok && val != "" {
// 文本字段的"回答数"等于"已填写数",故 Total 与 TextCount 同步递增,
// 避免结果页出现"回答数: 0 已填写: N"的矛盾展示。
stat.Total++
stat.TextCount++
}
}
}
}
stats = append(stats, stat)
}
return stats
}
// addCustomStat 向自定义补充选项统计追加一条(按文本去重计数,保持首现顺序)。
func addCustomStat(stats []CustomStat, text string) []CustomStat {
for i := range stats {
if stats[i].Text == text {
stats[i].Count++
return stats
}
}
return append(stats, CustomStat{Text: text, Count: 1})
}
// aggregateCustomStats 统计自定义补充选项(按文本去重计数,保持首现顺序)。
func aggregateCustomStats(ctx context.Context, v *db.Vote) []CustomStat {
submissions, err := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
if err != nil {
return nil
}
counts := make(map[string]int64)
var order []string
for _, s := range submissions {
text := strings.TrimSpace(s.CustomText)
if text == "" {
continue
}
if _, ok := counts[text]; !ok {
order = append(order, text)
}
counts[text]++
}
stats := make([]CustomStat, 0, len(order))
for _, text := range order {
stats = append(stats, CustomStat{Text: text, Count: counts[text]})
}
return stats
}
// ---------------- 页面 ----------------
// Page 投票页。
func Page(ctx context.Context) {
vote, err := db.Votes.GetByToken(ctx.Request().Context(), ctx.Param("token"))
if err != nil {
ctx.SetErrorFlash(i18n.T(ctxLang(ctx), "vote.flash.notFound"))
ctx.Redirect("/")
return
}
// 草稿 / 归档不可访问(仅链接分享场景同样不可见)
if vote.Status == db.VoteStatusDraft || vote.Status == db.VoteStatusArchived {
ctx.SetErrorFlash(i18n.T(ctxLang(ctx), "vote.flash.notFound"))
ctx.Redirect("/")
return
}
now := time.Now()
isOwner := ctx.IsLogged && ctx.User.ID == vote.UserID
// 发布者的语言与主题:必须在任何 i18n 取词之前应用,否则「投票功能已关闭」
// 这类提示会先按访客语言生成。箱主已删除(GetByID 失败)则保持站点默认外观。
if owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID); err == nil {
applyOwnerLocale(ctx.Data, owner)
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get vote owner")
}
// 箱主侧的投票功能开关(开关 / 定时开放 / 定量收集):关闭时访客不可参与,
// 页面本身仍可访问(结果展示策略不受影响)。
voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote)
if !voteState.Open {
ctx.Data["FeatureClosedMessage"] = i18n.FeatureClosedMessage(ctxLang(ctx), db.FeatureVote, voteState.Reason)
}
ctx.Data["Vote"] = vote
ctx.Data["IsOwner"] = isOwner
ctx.Data["NotStarted"] = vote.Status == db.VoteStatusPublished && vote.StartAt != nil && now.Before(*vote.StartAt)
ctx.Data["Ended"] = isVoteEnded(vote, now)
ctx.Data["VoteScopeRegisteredOnly"] = vote.VoterScope == db.VoteVoterScopeRegisteredOnly
// 选项 / 表单字段
if vote.Type == db.VoteTypeForm {
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
ctx.Data["FormFields"] = fields
} else if vote.Type != db.VoteTypeRating {
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
ctx.Data["Options"] = options
}
// "其他/自定义选项"保留标记(模板参与端表单与 JS 联动使用,避免硬编码漂移)
ctx.Data["CustomFieldMarker"] = db.CustomFieldMarker
// 自定义补充选项的字数上限:参与端模板据此渲染实时计数与超限提示,
// 与后端校验共用 conf.Limits.CustomAnswerLen,避免前后端上限漂移。
ctx.Data["MaxCustomTextLen"] = conf.Limits.CustomAnswerLen
// 已投判断(登录按账号;匿名按指纹 Cookie)
hasVoted := false
if ctx.IsLogged {
hasVoted, _ = db.VoteSubmissions.HasUserVoted(ctx.Request().Context(), vote.ID, ctx.User.ID)
} else if token := currentVoterToken(ctx); token != "" {
hasVoted, _ = db.VoteSubmissions.HasTokenVoted(ctx.Request().Context(), vote.ID, token)
}
ctx.Data["HasVoted"] = hasVoted
ctx.Data["CanVote"] = isVoteActive(vote, now) && voteState.Open && !hasVoted && !ctx.Data["NotStarted"].(bool)
// 匿名首次访问签发指纹
if !ctx.IsLogged {
ensureVoterToken(ctx)
}
// 结果可见性
showResults := false
switch vote.ShowResult {
case db.VoteShowResultAfterVote:
showResults = hasVoted
case db.VoteShowResultAfterEnd:
showResults = ctx.Data["Ended"].(bool)
case db.VoteShowResultOwnerOnly:
showResults = isOwner
}
ctx.Data["ShowResults"] = showResults
if showResults {
res, err := LoadResults(ctx, vote)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to load vote results")
} else {
ctx.Data["Results"] = res
}
}
ctx.Data["Title"] = vote.Title
ctx.Success("vote/page")
}
// ---------------- 提交 ----------------
type formFieldInput struct {
FieldType string
Label string
Required bool
MaxLength int
Options []string
}
// Submit 提交投票。
func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
vote, err := db.Votes.GetByToken(ctx.Request().Context(), ctx.Param("token"))
if err != nil {
ctx.SetErrorFlash(i18n.T(ctxLang(ctx), "vote.flash.notFound"))
ctx.Redirect("/")
return
}
// 发布者的语言与主题:提交成功/失败都回跳投票页,提示文案必须与页面语言一致。
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote owner")
ctx.SetInternalErrorFlash()
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
applyOwnerLocale(ctx.Data, owner)
lang := ctxLang(ctx)
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。
if voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote); !voteState.Open {
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
now := time.Now()
if !isVoteActive(vote, now) {
switch vote.Status {
case db.VoteStatusDraft:
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.notPublished"))
case db.VoteStatusArchived:
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.archived"))
default:
if isVoteEnded(vote, now) {
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.ended"))
} else {
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.notStarted"))
}
}
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
// 仅注册用户可投:未登录跳登录页
if vote.VoterScope == db.VoteVoterScopeRegisteredOnly && !ctx.IsLogged {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.loginRequired"))
ctx.Redirect("/login?to=" + ctx.Request().Request.RequestURI)
return
}
// 已投检查(应用层预检,返回友好提示;最终防重由数据库唯一索引兜底)
if ctx.IsLogged {
hasVoted, _ := db.VoteSubmissions.HasUserVoted(ctx.Request().Context(), vote.ID, ctx.User.ID)
if hasVoted {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
} else {
if token := currentVoterToken(ctx); token != "" {
hasVoted, _ := db.VoteSubmissions.HasTokenVoted(ctx.Request().Context(), vote.ID, token)
if hasVoted {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
}
}
ip := shared.ResolveClientIP(ctx.Request().Request)
// 匿名:验证码(可配置)+ token 防重;IP 不再作为"一票封顶"依据,避免共享出口误伤。
var voterToken *string
if !ctx.IsLogged {
// 匿名:始终要求验证码(决策 #3 + P2-2 修复:清除 Cookie 换 token 的刷票依赖验证码兜底);
// 仅当全局已配置验证码(CaptchaType 非空)时强制,未配置则不阻断,避免无密钥部署整崩。
// token 防重 + IP 高阈值兜底限流保持不变。
if conf.Recaptcha.CaptchaType != "" {
ok, err := captcha.Verify(ctx.Request().FormValue("g-recaptcha-response"), ip, ctx.Session, recaptcha, lang)
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(lang, "captcha.error"))
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
}
// IP 仅由投票提交中间件承担高阈值兜底限流,不再按"一票封顶"拦截。
// 这样同一局域网的不同匿名浏览器可凭各自签名 token 正常参与。
token := ensureVoterToken(ctx)
voterToken = &token
}
// 解析提交内容。投票发起人的全局屏蔽词表(users.block_words)用于校验开放文本:
// 自定义补充选项、表单“其他”文本、文本/长文本字段命中即拒(与提问/私聊一致)。
submission, err := parseSubmission(ctx, vote, owner)
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
submission.VoteID = vote.ID
submission.VoterIP = ip
submission.UserID = nil
if ctx.IsLogged {
uid := ctx.User.ID
submission.UserID = &uid
}
submission.VoterToken = voterToken
if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil {
if errors.Is(err, db.ErrVoteAlreadyVoted) {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote submission")
ctx.SetInternalErrorFlash()
}
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
// 写穿式失效:新提交成功后立即清除该投票的结果缓存,
// 避免结果页在 TTL 内显示陈旧计数,并把 O(N) 重算成本收敛到"每次新投票一次"。
InvalidateResultsCache(vote.ID)
// 定量收集:箱主级累计一份;单份投票设置上限后达到即自动结束该投票。
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), vote.UserID, db.FeatureVote, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment vote feature quota")
}
if vote.QuotaLimit > 0 {
count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to count vote submissions for quota")
} else if count >= int64(vote.QuotaLimit) {
if err := db.Votes.UpdateStatus(ctx.Request().Context(), vote.ID, db.VoteStatusEnded); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to auto end vote on quota reached")
}
}
}
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)。owner 在上方已取过,直接复用。
if vote.Type == db.VoteTypeForm && owner.Notify == db.NotifyTypeEmail {
email := owner.Email
title := vote.Title
voteID := vote.ID
shared.SafeGo(func() {
if err := mail.SendNewVoteSubmissionMail(email, title, voteID); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new vote submission mail")
}
})
}
ctx.SetSuccessFlash(i18n.T(lang, "vote.flash.success"))
ctx.Redirect("/vote/" + vote.LinkToken)
}
// parseMultipleOptionIDs 解析多选参数并拒绝重复选项,避免同一提交被重复计票。
func parseMultipleOptionIDs(ids []string, maxChoices int, validOptions map[uint]bool, lang string) ([]uint, error) {
if len(ids) == 0 {
return nil, errors.New(i18n.T(lang, "vote.err.noChoice"))
}
if maxChoices < 1 {
maxChoices = 1
}
if len(ids) > maxChoices {
return nil, fmt.Errorf(i18n.T(lang, "vote.err.tooManyChoices"), maxChoices)
}
chosen := make([]uint, 0, len(ids))
seen := make(map[uint]struct{}, len(ids))
for _, s := range ids {
parsed, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
optionID := uint(parsed)
if err != nil || uint64(optionID) != parsed || !validOptions[optionID] {
return nil, errors.New(i18n.T(lang, "vote.err.invalidOption"))
}
if _, exists := seen[optionID]; exists {
return nil, errors.New(i18n.T(lang, "vote.err.optionDuplicate"))
}
seen[optionID] = struct{}{}
chosen = append(chosen, optionID)
}
return chosen, nil
}
// parseSubmission 按投票类型解析并校验提交内容,构造 VoteSubmission。
// owner 为投票发起人(用于其全局屏蔽词表对开放文本的本地过滤)。
func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.CreateVoteSubmissionOptions, error) {
lang := ctxLang(ctx)
get := func(name string) string { return strings.TrimSpace(ctx.Request().FormValue(name)) }
sub := &db.CreateVoteSubmissionOptions{}
formData := map[string]interface{}{}
// 选项合法性集合
validOptions := map[uint]bool{}
if v.Type == db.VoteTypeSingle || v.Type == db.VoteTypeMultiple || v.Type == db.VoteTypeRank {
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
for _, o := range options {
validOptions[o.ID] = true
}
}
switch v.Type {
case db.VoteTypeSingle:
optID := get("option_id")
id, err := strconv.ParseUint(optID, 10, 64)
if err != nil || !validOptions[uint(id)] {
return nil, errors.New(i18n.T(lang, "vote.err.pickOne"))
}
sub.OptionID = uint(id)
case db.VoteTypeMultiple:
ids := ctx.Request().Form["option_ids"]
chosen, err := parseMultipleOptionIDs(ids, v.MaxChoices, validOptions, lang)
if err != nil {
return nil, err
}
sub.OptionID = chosen[0] // 展示用
formData["options"] = chosen
case db.VoteTypeRating:
score, err := strconv.Atoi(get("rating_value"))
if err != nil || score < 1 || score > 10 {
return nil, errors.New(i18n.T(lang, "vote.err.ratingRequired"))
}
formData["rating"] = score
case db.VoteTypeRank:
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
ranking := map[string]float64{}
used := map[float64]bool{}
for _, o := range options {
r, err := strconv.ParseFloat(get(fmt.Sprintf("rank_%d", o.ID)), 64)
if err != nil || r < 1 || r > float64(len(options)) {
return nil, fmt.Errorf(i18n.T(lang, "vote.err.rankRequired"), len(options))
}
if used[r] {
return nil, errors.New(i18n.T(lang, "vote.err.rankDuplicate"))
}
used[r] = true
ranking[fmt.Sprint(o.ID)] = r
}
formData["ranking"] = ranking
case db.VoteTypeForm:
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
for _, f := range fields {
// 字段选项集合
validFieldOptions := map[string]bool{}
var fieldOpts []string
if f.Options != "" {
_ = json.Unmarshal([]byte(f.Options), &fieldOpts)
for _, o := range fieldOpts {
validFieldOptions[o] = true
}
}
values := ctx.Request().Form["field_"+fmt.Sprint(f.ID)]
// 单选类字段(radio/select)取单值;多选 checkbox 允许多值
if f.FieldType == db.VoteFormFieldTypeRadio || f.FieldType == db.VoteFormFieldTypeSelect {
val := ""
if len(values) > 0 {
val = strings.TrimSpace(values[0])
}
// 空值视为未填写:非必填跳过,必填报"请填写"。
// 注意:下拉框未选择时表单会提交空字符串(values 非空但 val=""),
// 直接走选项校验会误报"选项无效",这里必须先归一化。
if val == "" {
if f.Required {
return nil, errors.New(i18n.T(lang, "vote.err.fieldRequired", f.Label))
}
continue
}
// "其他/自定义选项":选中保留标记时读取参与者填写的文本
if f.AllowCustom && val == db.CustomFieldMarker {
custom := strings.TrimSpace(ctx.Request().FormValue(fmt.Sprintf("field_%d_custom", f.ID)))
if custom == "" {
return nil, errors.New(i18n.T(lang, "vote.err.fieldCustomRequired", f.Label))
}
if len([]rune(custom)) > conf.Limits.CustomAnswerLen {
return nil, errors.New(i18n.T(lang, "vote.err.fieldCustomTooLong", f.Label, conf.Limits.CustomAnswerLen))
}
// 发起人全局屏蔽词(提问/私聊/投票共用一份词表)
if err := blockWordCheck(owner, lang, custom); err != nil {
return nil, err
}
// 开放文本内容审核(fail-closed)
if err := censorCheck(ctx, owner, lang, custom); err != nil {
return nil, err
}
formData[f.Label] = custom
continue
}
if !validFieldOptions[val] {
return nil, errors.New(i18n.T(lang, "vote.err.fieldInvalidOption", f.Label))
}
formData[f.Label] = val
continue
}
// 多选 checkbox
if f.FieldType == db.VoteFormFieldTypeCheckbox {
var chosen []string
for _, val := range values {
val = strings.TrimSpace(val)
if val == "" {
continue // 忽略空值(非必填未勾选时不应误报"选项无效")
}
// "其他/自定义选项":勾选保留标记时读取参与者填写的文本
if f.AllowCustom && val == db.CustomFieldMarker {
custom := strings.TrimSpace(ctx.Request().FormValue(fmt.Sprintf("field_%d_custom", f.ID)))
if custom == "" {
return nil, errors.New(i18n.T(lang, "vote.err.fieldCustomRequired", f.Label))
}
if len([]rune(custom)) > conf.Limits.CustomAnswerLen {
return nil, errors.New(i18n.T(lang, "vote.err.fieldCustomTooLong", f.Label, conf.Limits.CustomAnswerLen))
}
// 发起人全局屏蔽词(提问/私聊/投票共用一份词表):
// checkbox 的「其他/自定义」同样属自由文本,须与 radio/select 分支一致拦截。
if err := blockWordCheck(owner, lang, custom); err != nil {
return nil, err
}
// 开放文本内容审核(fail-closed)
if err := censorCheck(ctx, owner, lang, custom); err != nil {
return nil, err
}
chosen = append(chosen, custom)
continue
}
if !validFieldOptions[val] {
return nil, errors.New(i18n.T(lang, "vote.err.fieldInvalidOption", f.Label))
}
chosen = append(chosen, val)
}
if len(chosen) == 0 && f.Required {
return nil, errors.New(i18n.T(lang, "vote.err.fieldRequired", f.Label))
}
if len(chosen) > 0 {
formData[f.Label] = chosen
}
continue
}
// 排序题 rank:每个选项一个排名输入(name=field_{id}_{i}),校验 1..N 且不重复
if f.FieldType == db.VoteFormFieldTypeRank {
if len(fieldOpts) < 2 {
return nil, errors.New(i18n.T(lang, "vote.err.fieldConfigError", f.Label))
}
ranking := map[string]float64{}
used := map[float64]bool{}
for i, opt := range fieldOpts {
raw := strings.TrimSpace(ctx.Request().FormValue(fmt.Sprintf("field_%d_%d", f.ID, i)))
if raw == "" {
if f.Required {
return nil, errors.New(i18n.T(lang, "vote.err.fieldRankIncomplete", f.Label))
}
continue
}
r, err := strconv.ParseFloat(raw, 64)
if err != nil || r < 1 || r > float64(len(fieldOpts)) {
return nil, errors.New(i18n.T(lang, "vote.err.fieldRankRange", f.Label, len(fieldOpts)))
}
if used[r] {
return nil, errors.New(i18n.T(lang, "vote.err.fieldRankDuplicate", f.Label))
}
used[r] = true
ranking[opt] = r
}
if len(ranking) > 0 {
formData[f.Label] = ranking
}
continue
}
// 文本 / 数字 / 评分
val := ""
if len(values) > 0 {
val = strings.TrimSpace(values[0])
}
if f.Required && val == "" {
return nil, errors.New(i18n.T(lang, "vote.err.fieldRequired", f.Label))
}
if val == "" {
continue
}
if f.MaxLength > 0 && len([]rune(val)) > f.MaxLength {
return nil, errors.New(i18n.T(lang, "vote.err.fieldTooLong", f.Label, f.MaxLength))
}
switch f.FieldType {
case db.VoteFormFieldTypeNumber:
n, err := strconv.ParseFloat(val, 64)
if err != nil {
return nil, errors.New(i18n.T(lang, "vote.err.fieldNotNumber", f.Label))
}
formData[f.Label] = n
case db.VoteFormFieldTypeRating:
score, err := strconv.Atoi(val)
if err != nil || score < 1 || score > 10 {
return nil, errors.New(i18n.T(lang, "vote.err.fieldRating", f.Label))
}
formData[f.Label] = score
default:
// 发起人全局屏蔽词(提问/私聊/投票共用一份词表)
if err := blockWordCheck(owner, lang, val); err != nil {
return nil, err
}
// 开放文本内容审核(fail-closed)
if err := censorCheck(ctx, owner, lang, val); err != nil {
return nil, err
}
formData[f.Label] = val
}
}
default:
return nil, errors.New(i18n.T(lang, "vote.err.unknownType"))
}
// 自定义补充选项(single/multiple/rank 且 AllowCustom)
if v.AllowCustom && (v.Type == db.VoteTypeSingle || v.Type == db.VoteTypeMultiple || v.Type == db.VoteTypeRank) {
if custom := strings.TrimSpace(ctx.Request().FormValue("custom")); custom != "" {
if len([]rune(custom)) > conf.Limits.CustomAnswerLen {
return nil, errors.New(i18n.T(lang, "vote.err.customTooLong"))
}
// 发起人全局屏蔽词(提问/私聊/投票共用一份词表)
if err := blockWordCheck(owner, lang, custom); err != nil {
return nil, err
}
// 开放文本内容审核(fail-closed)
if err := censorCheck(ctx, owner, lang, custom); err != nil {
return nil, err
}
sub.CustomText = custom
}
}
if len(formData) > 0 {
data, err := json.Marshal(formData)
if err != nil {
return nil, errors.New(i18n.T(lang, "vote.err.serialize"))
}
sub.FormData = datatypes.JSON(data)
}
return sub, nil
}