1129 lines
38 KiB
Go
1129 lines
38 KiB
Go
// 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/shared"
|
||
)
|
||
|
||
// ctxLang 取当前请求已解析好的语言代码(由 Contexter 写入 Data)。
|
||
func ctxLang(ctx context.Context) string {
|
||
if l, ok := ctx.Data["LangCode"].(string); ok && l != "" {
|
||
return l
|
||
}
|
||
return i18n.Default
|
||
}
|
||
|
||
// 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 对开放文本做内容审核(fail-closed;EnableTextCensor=false 时自动放行)。
|
||
func censorCheck(ctx context.Context, lang, text string) error {
|
||
resp, err := censor.Text(ctx.Request().Context(), text)
|
||
if err != nil {
|
||
return errors.New(i18n.T(lang, "censor.unavailable"))
|
||
}
|
||
if !resp.Pass {
|
||
return errors.New(resp.ErrorMessage())
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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
|
||
|
||
// 箱主侧的投票功能开关(开关 / 定时开放 / 定量收集):关闭时访客不可参与,
|
||
// 页面本身仍可访问(结果展示策略不受影响)。
|
||
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
|
||
|
||
// 箱主信息
|
||
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
|
||
if err == nil {
|
||
ctx.Data["Owner"] = owner
|
||
}
|
||
|
||
// 选项 / 表单字段
|
||
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) {
|
||
lang := ctxLang(ctx)
|
||
vote, err := db.Votes.GetByToken(ctx.Request().Context(), ctx.Param("token"))
|
||
if err != nil {
|
||
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.notFound"))
|
||
ctx.Redirect("/")
|
||
return
|
||
}
|
||
|
||
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 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):投票提交中的开放文本
|
||
// (自定义补充选项、表单“其他”文本、文本/长文本字段)需要与提问/私聊一样
|
||
// 命中即拒,因此把 owner 一并传入 parseSubmission 做本地词表检查。
|
||
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
|
||
}
|
||
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")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)
|
||
if vote.Type == db.VoteTypeForm {
|
||
owner, err := db.Users.GetByID(ctx.Request().Context(), vote.UserID)
|
||
if err == nil && 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, 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, 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, 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, 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
|
||
}
|