// 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)) } // ExpireResultsCache 把某投票的结果缓存标记为过期,但保留旧值。 // // 投票提交用这个,不用 InvalidateResultsCache:后者会把缓存删光, // 于是每一票都让下一个读请求同步重算一次全量聚合(O(N)), // 持续有人投票时就是 O(N²)。标记过期后,下一个读请求先拿到旧值, // 再由后台按投票去重地重算。 func ExpireResultsCache(voteID uint) { resultsCache.Expire(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:各字段聚合统计(混合问卷) } // aggregateFormFieldStats 统计表单投票(混合问卷)各字段的聚合结果: // radio/select/checkbox → 选项票数;rating → 分布 + 平均分;rank → 各选项被排次数 + 平均排名; // number → 平均值;text/textarea → 填写数(内容不聚合展示,保护隐私)。 func aggregateFormFieldStats(reqCtx stdctx.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(reqCtx, 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(reqCtx stdctx.Context, v *db.Vote) []CustomStat { submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, 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 } // 站方处置 L4:整页停止对外访问(数据保留)。 // 与提问箱主页同一口径:此前投票链路完全不查处置, // 于是 L4 后 /_/domain 已跳 /restricted,/vote/{token} 却照常可访。 // 查询失败不拦(fail-open):DB 异常时整站都不正常, // 这里再 fail-closed 只会让所有投票一起白屏。 if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), vote.UserID); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query vote owner restriction") } else if level == db.RestrictionL4 { ctx.Redirect("/restricted") 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 } // 站方处置 L4:与 Page 同一口径。 // 页面进不去不代表 POST 也拦得住——直接构造请求可以绕过页面。 if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), vote.UserID); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query vote owner restriction") } else if level == db.RestrictionL4 { 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 会让每一票都变成下一个读请求的同步全量重算(O(N²))。 ExpireResultsCache(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 }