Files
tamabox/route/user/vote.go
T
2026-10-19 01:11:28 +08:00

1298 lines
46 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 user
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"go.opentelemetry.io/otel/trace"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/cache"
"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/security/censor"
"github.com/tamakyi/TamaBox/internal/security/moderation"
votepkg "github.com/tamakyi/TamaBox/route/vote"
)
// voteTextRepliesCache 表单投票「回复明细」的短 TTL 缓存:该视图需逐条 json.Unmarshal
// 全部提交记录(O(N)),结果页非强一致,30s 陈旧可接受。
var voteTextRepliesCache = cache.NewTTLCache(64)
const voteTextRepliesCacheTTL = 30 * time.Second
// ---------------- 表单解析 ----------------
// voteFormFieldInput 表单字段的前端输入结构(vote-form.html 中的 JS 数组)。
type voteFormFieldInput struct {
FieldType string `json:"field_type"`
Label string `json:"label"`
Required bool `json:"required"`
MaxLength int `json:"max_length"`
Options []string `json:"options"` // 选项数组(select/radio/checkbox/rank),前端逐条添加
AllowCustom bool `json:"allow_custom"` // 允许补充自定义选项(radio/select/checkbox)
}
// voteForm 解析后的完整表单(含选项与表单字段)。
type voteForm struct {
opts db.CreateVoteOptions
options []string
formFields []db.CreateVoteFormFieldOptions
censorMetadata datatypes.JSON // 标题审核结果
}
// censorCheck 对单段文本做内容审核。与提问 / 私聊共用 moderation.Check 守卫,
// 是否拦截由发起人的「审核严格度」档位决定;fail-closed,服务异常时拒绝发布。
func censorCheck(ctx context.Context, text string) error {
_, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), ctx.User.ID, ctx.User.ModerationStrictness), ctx.Lang(), text)
return err
}
// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空串返回 nil。
// 用 time.Local 而非 time.Parse:后者会解析成 UTC,与 time.Now() 比较时产生时区偏移。
func parseTimeInput(ctx context.Context, s string) (*time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}
t, err := time.ParseInLocation("2006-01-02T15:04", s, time.Local)
if err != nil {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.timeFormat"))
}
return &t, nil
}
// parseVoteForm 手动解析投票表单(元信息 + 类型 + 选项/字段)。
// 不用 form.Bind:其反射绑定会把 string 直接赋给 int 字段导致 panic,
// 且选项/字段数组需 JSON 解析。draft=true 时放宽数量与审核要求,便于随时暂存。
func parseVoteForm(ctx context.Context, draft bool) (*voteForm, error) {
form, err := parseVoteMeta(ctx, draft)
if err != nil {
return nil, err
}
voteType := strings.TrimSpace(ctx.Request().FormValue("type"))
switch voteType {
case db.VoteTypeSingle, db.VoteTypeMultiple, db.VoteTypeRating, db.VoteTypeRank, db.VoteTypeForm:
default:
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.type"))
}
form.opts.Type = voteType
maxChoices := 1
if voteType == db.VoteTypeMultiple {
maxChoices, _ = strconv.Atoi(strings.TrimSpace(ctx.Request().FormValue("max_choices")))
if maxChoices < 1 {
maxChoices = 1
}
if maxChoices > 20 {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.maxChoicesLimit"))
}
}
form.opts.MaxChoices = maxChoices
if voteType == db.VoteTypeForm {
if err := parseFormFields(ctx, form, draft); err != nil {
return nil, err
}
} else {
if err := parseOptions(ctx, form, voteType, maxChoices, draft); err != nil {
return nil, err
}
}
return form, nil
}
// parseVoteMetaForm 仅解析元信息(发布后编辑用):标题/描述/可见性/范围/结果策略/
// 自定义开关/时间窗/防刷/验证码。类型与选项/字段发布后不可修改(避免破坏历史数据归因)。
func parseVoteMetaForm(ctx context.Context) (*voteForm, error) {
return parseVoteMeta(ctx, false)
}
// parseVoteMeta 解析投票元信息(不含类型与选项/字段)。draft=true 时放宽校验并跳过
// 内容审核:草稿为私有内容,不应因审核服务抖动而丢失;审核统一在发布时补齐。
func parseVoteMeta(ctx context.Context, draft bool) (*voteForm, error) {
get := func(name string) string { return strings.TrimSpace(ctx.Request().FormValue(name)) }
title := get("title")
// 草稿允许暂存空标题(发布前再补全);非草稿必须填写
if title == "" && !draft {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.titleRequired"))
}
if len([]rune(title)) > 100 {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.titleTooLong"))
}
description := get("description")
if len([]rune(description)) > 5000 {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.descriptionTooLong"))
}
// 内容审核(fail-closed):非草稿执行;草稿跳过(见上说明)
var censorMeta datatypes.JSON
if !draft {
titleCensor, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), ctx.User.ID, ctx.User.ModerationStrictness), ctx.Lang(), title)
if err != nil {
return nil, err
}
censorMeta = datatypes.JSON(titleCensor.ToJSON())
if description != "" {
if err := censorCheck(ctx, description); err != nil {
return nil, err
}
}
}
visibility := get("visibility")
switch visibility {
case db.VoteVisibilityPublic, db.VoteVisibilityLinkOnly:
default:
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.visibility"))
}
voterScope := get("voter_scope")
switch voterScope {
case db.VoteVoterScopeAnyone, db.VoteVoterScopeRegisteredOnly:
default:
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.voterScope"))
}
showResult := get("show_result")
switch showResult {
case db.VoteShowResultAfterVote, db.VoteShowResultAfterEnd, db.VoteShowResultOwnerOnly:
default:
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.showResult"))
}
allowCustom := get("allow_custom") == "on"
// 匿名投票始终要求验证码(决策 #3 / P2-2):忽略客户端开关,强制开启,关闭复选框无法解除。
requireCaptcha := true
// 一人一票由业务规则固定;不从客户端读取已废弃字段,避免与数据库约束冲突。
maxVotes := 1
startAt, err := parseTimeInput(ctx, get("start_at"))
if err != nil {
return nil, err
}
endAt, err := parseTimeInput(ctx, get("end_at"))
if err != nil {
return nil, err
}
if startAt != nil && endAt != nil && !endAt.After(*startAt) {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.timeRange"))
}
// 定量收集上限:收集到指定份数后自动结束该投票(0 = 不限制)
quotaLimit, err := parseQuotaLimit(get("quota_limit"))
if err != nil {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.quota"))
}
return &voteForm{
opts: db.CreateVoteOptions{
UserID: ctx.User.ID,
Title: title,
Description: description,
Visibility: visibility,
VoterScope: voterScope,
ShowResult: showResult,
AllowCustom: allowCustom,
StartAt: startAt,
EndAt: endAt,
MaxVotesPerFingerprint: maxVotes,
RequireCaptcha: requireCaptcha,
QuotaLimit: int(quotaLimit),
},
censorMetadata: censorMeta,
}, nil
}
func parseOptions(ctx context.Context, form *voteForm, voteType string, maxChoices int, draft bool) error {
raw := strings.TrimSpace(ctx.Request().FormValue("options"))
if raw == "" {
// 草稿允许暂未添加选项;非草稿必须至少 2 个
if !draft {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionsTooFew"))
}
form.options = nil
return nil
}
var options []string
if err := json.Unmarshal([]byte(raw), &options); err != nil {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionsFormat"))
}
cleaned := make([]string, 0, len(options))
for _, o := range options {
o = strings.TrimSpace(o)
if o == "" {
continue
}
if len([]rune(o)) > conf.Limits.VoteOptionLen {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen))
}
// 选项内容审核(fail-closed):草稿跳过,发布时统一审核
if !draft {
if err := censorCheck(ctx, o); err != nil {
return err
}
}
cleaned = append(cleaned, o)
}
// 评分题不需要选项(固定 1-10 分)
if voteType == db.VoteTypeRating {
form.options = nil
return nil
}
// 草稿允许暂存少于 2 个选项;非草稿必须至少 2 个
if !draft && len(cleaned) < 2 {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionsTooFew"))
}
if voteType == db.VoteTypeMultiple && maxChoices > len(cleaned) {
return errors.New(i18n.T(ctx.Lang(), "vote.err.maxChoicesExceed"))
}
form.options = cleaned
return nil
}
func parseFormFields(ctx context.Context, form *voteForm, draft bool) error {
raw := strings.TrimSpace(ctx.Request().FormValue("form_fields"))
if raw == "" {
// 草稿允许暂未添加字段;非草稿必须至少 1 个
if !draft {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldsTooFew"))
}
return nil
}
var inputs []voteFormFieldInput
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldsFormat"))
}
if len(inputs) == 0 {
if !draft {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldsTooFew"))
}
return nil
}
if len(inputs) > 50 {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldsTooMany"))
}
// label 必须唯一:FormData 以 label 为 key,重复会导致提交数据互相覆盖
seenLabels := make(map[string]bool, len(inputs))
for i, in := range inputs {
switch in.FieldType {
case db.VoteFormFieldTypeText, db.VoteFormFieldTypeTextarea, db.VoteFormFieldTypeSelect,
db.VoteFormFieldTypeRadio, db.VoteFormFieldTypeCheckbox, db.VoteFormFieldTypeNumber,
db.VoteFormFieldTypeRating, db.VoteFormFieldTypeRank:
default:
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldType"))
}
label := strings.TrimSpace(in.Label)
if label == "" {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldLabelMissing", i+1))
}
if len([]rune(label)) > 100 {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldLabelTooLong", i+1))
}
if seenLabels[label] {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldLabelDuplicate", label))
}
seenLabels[label] = true
if in.MaxLength < 0 || in.MaxLength > 2000 {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldMaxLength", i+1))
}
// 字段标签审核(fail-closed):草稿跳过,发布时统一审核
if !draft {
if err := censorCheck(ctx, label); err != nil {
return err
}
}
field := db.CreateVoteFormFieldOptions{
FieldType: in.FieldType,
Label: label,
Required: in.Required,
MaxLength: in.MaxLength,
SortOrder: i,
AllowCustom: in.AllowCustom,
}
switch in.FieldType {
case db.VoteFormFieldTypeSelect, db.VoteFormFieldTypeRadio, db.VoteFormFieldTypeCheckbox, db.VoteFormFieldTypeRank:
var opts []string
for _, raw := range in.Options {
opts = append(opts, splitOptionTokens(raw)...)
}
// "其他"保留标记不允许作为预设选项内容(参与端用它触发自定义输入)
for _, o := range opts {
if o == db.CustomFieldMarker {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldReservedOption", i+1, label))
}
// 长度与单选/多选选项同规则(前端 maxlength 同源,这里补后端兜底校验)
if len([]rune(o)) > conf.Limits.VoteOptionLen {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", conf.Limits.VoteOptionLen))
}
}
if len(opts) < 1 {
// 草稿允许暂未填写字段选项;非草稿必须至少 1 个
if !draft {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldOptionsTooFew", i+1, label))
}
}
if in.FieldType == db.VoteFormFieldTypeRank && len(opts) < 2 {
if !draft {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldRankTooFew", i+1, label))
}
}
// 预置选项审核:草稿跳过,发布时统一审核
if !draft {
for _, o := range opts {
if err := censorCheck(ctx, o); err != nil {
return err
}
}
}
optsJSON, err := json.Marshal(opts)
if err != nil {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldSerialize"))
}
field.Options = string(optsJSON)
}
form.formFields = append(form.formFields, field)
}
return nil
}
// ---------------- 归属校验 ----------------
// getOwnedVote 获取当前登录用户拥有的投票,校验归属。
func getOwnedVote(ctx context.Context, voteIDStr string) (*db.Vote, error) {
voteID, err := strconv.ParseUint(voteIDStr, 10, 64)
if err != nil {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.invalidID"))
}
vote, err := db.Votes.GetByID(ctx.Request().Context(), uint(voteID))
if err != nil {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.notExist"))
}
if vote.UserID != ctx.User.ID {
return nil, errors.New(i18n.T(ctx.Lang(), "vote.err.noPermission"))
}
return vote, nil
}
// ---------------- 处理器 ----------------
// effectiveVoteStatus 计算投票的有效展示状态(status + 时间窗联合判断):
// published 且未到开始时间 → 未开始;已过结束时间 → 已结束。
// 避免「已过结束时间仍显示进行中」。
func effectiveVoteStatus(v *db.Vote, now time.Time) string {
if v.Status != db.VoteStatusPublished {
return v.Status
}
if v.StartAt != nil && now.Before(*v.StartAt) {
return db.VoteStatusNotStarted
}
if v.EndAt != nil && !now.Before(*v.EndAt) {
return db.VoteStatusEnded
}
return db.VoteStatusPublished
}
// formatRemaining 格式化投票剩余时间(如"剩余 2 天 3 小时"),多语言文案。
func formatRemaining(lang string, endAt time.Time, now time.Time) string {
d := endAt.Sub(now)
if d <= 0 {
return ""
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
parts := []string{}
if days > 0 {
parts = append(parts, fmt.Sprintf("%d%s", days, i18n.T(lang, "vote.durationDay")))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%d%s", hours, i18n.T(lang, "vote.durationHour")))
}
if mins > 0 && days == 0 {
parts = append(parts, fmt.Sprintf("%d%s", mins, i18n.T(lang, "vote.durationMinute")))
}
if len(parts) == 0 {
return ""
}
return i18n.T(lang, "vote.remainLabel") + strings.Join(parts, " ")
}
// voteItem 管理面板/投票列表所需的富信息条目(参与数、有效状态、剩余时间)。
type voteItem struct {
*db.Vote
SubmissionCount int64
OptionCount int64
EffectiveStatus string
RemainingText string // 有效进行中:剩余时间;无结束时间:无限期;其余为空
}
// buildVoteItems 将投票列表转换为管理面板所需的富信息条目。
// 性能:参与数与选项数经 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN),
// 替代原先每票各发 2 次 COUNT 的 N+1 查询(2N 条 SQL 降为 2 条)。
func buildVoteItems(ctx context.Context, votes []*db.Vote) []*voteItem {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
now := time.Now()
items := make([]*voteItem, 0, len(votes))
ids := make([]uint, 0, len(votes))
for _, v := range votes {
ids = append(ids, v.ID)
}
subCounts := map[uint]int64{}
optCounts := map[uint]int64{}
if len(ids) > 0 {
if m, err := db.VoteSubmissions.CountByVoteIDs(ctx.Request().Context(), ids); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch count submissions")
} else {
subCounts = m
}
if m, err := db.VoteOptions.CountByVoteIDs(ctx.Request().Context(), ids); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch count options")
} else {
optCounts = m
}
}
for _, v := range votes {
count := subCounts[v.ID]
optionCount := optCounts[v.ID]
eff := effectiveVoteStatus(v, now)
remaining := ""
if eff == db.VoteStatusPublished {
if v.EndAt != nil {
remaining = formatRemaining(lang, *v.EndAt, now)
} else {
remaining = i18n.T(lang, "vote.perpetual")
}
}
items = append(items, &voteItem{
Vote: v,
SubmissionCount: count,
OptionCount: optionCount,
EffectiveStatus: eff,
RemainingText: remaining,
})
}
return items
}
// VoteList 投票管理列表(保留以兼容旧链接):投票管理已合并到「我的提问箱」的投票面板,
// 直接跳转 /user/questions#votes,避免两套功能重复的页面。
func VoteList(ctx context.Context) {
ctx.Redirect("/user/questions#votes")
}
// VoteNew 新建投票页面。
func VoteNew(ctx context.Context) {
ctx.Data["Title"] = "新建投票"
ctx.Data["IsEdit"] = false
ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS)
// 默认投票类型为表单收集(用户要求);同时给定模板零值兜底,避免切到多选时 max_choices 显示 0。
ctx.Data["Vote"] = &db.Vote{
Type: db.VoteTypeForm,
MaxChoices: 3,
MaxVotesPerFingerprint: 1,
}
ctx.Data["InitialOptions"] = []string{}
ctx.Data["InitialFields"] = []voteFormFieldInput{}
ctx.Success("user/vote-form")
}
// renderVoteFormWithInput 在保存失败时重新渲染表单并回填已提交内容,避免整页输入被清空。
// isEdit=true 时回填 vote.ID,保留编辑动作的提交地址。
//
// 错误提示直接写 c.Data 而非 session flash:本项目的 flash 在请求开始时就已读进 c.Data,
// 之后写入 session 的 flash 本次渲染读不到,还会残留到下次请求造成「陈旧错误」串页。
func renderVoteFormWithInput(ctx context.Context, isEdit bool, voteID uint, flashMsg string, internal bool) {
ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS)
get := func(n string) string { return strings.TrimSpace(ctx.Request().FormValue(n)) }
title := get("title")
description := get("description")
voteType := get("type")
visibility := get("visibility")
voterScope := get("voter_scope")
showResult := get("show_result")
maxChoices, _ := strconv.Atoi(get("max_choices"))
if maxChoices < 1 {
maxChoices = 1
}
// 与数据库唯一索引保持一致;该策略不再由表单配置。
maxVotes := 1
allowCustom := get("allow_custom") == "on"
// 匿名投票始终要求验证码(决策 #3 / P2-2):忽略客户端开关,强制开启。
requireCaptcha := true
startAt, _ := parseTimeInput(ctx, get("start_at"))
endAt, _ := parseTimeInput(ctx, get("end_at"))
quotaLimit, _ := parseQuotaLimit(get("quota_limit"))
var initialOptions []string
if raw := strings.TrimSpace(ctx.Request().FormValue("options")); raw != "" {
_ = json.Unmarshal([]byte(raw), &initialOptions)
}
var initialFields []voteFormFieldInput
if raw := strings.TrimSpace(ctx.Request().FormValue("form_fields")); raw != "" {
_ = json.Unmarshal([]byte(raw), &initialFields)
}
vote := &db.Vote{
Title: title,
Description: description,
Type: voteType,
Visibility: visibility,
VoterScope: voterScope,
ShowResult: showResult,
MaxChoices: maxChoices,
MaxVotesPerFingerprint: maxVotes,
AllowCustom: allowCustom,
RequireCaptcha: requireCaptcha,
StartAt: startAt,
EndAt: endAt,
QuotaLimit: int(quotaLimit),
}
if isEdit {
vote.ID = voteID
}
ctx.Data["Title"] = "新建投票"
if isEdit {
ctx.Data["Title"] = "编辑投票"
}
ctx.Data["IsEdit"] = isEdit
ctx.Data["Vote"] = vote
ctx.Data["LockedContent"] = false
ctx.Data["InitialOptions"] = initialOptions
ctx.Data["InitialFields"] = initialFields
if startAt != nil {
ctx.Data["StartAtStr"] = startAt.Format("2006-01-02T15:04")
}
if endAt != nil {
ctx.Data["EndAtStr"] = endAt.Format("2006-01-02T15:04")
}
// 同请求错误提示:直接写入 c.Data(见函数注释,不能用 session flash)
if internal {
ctx.Data["HasError"] = true
ctx.Data["Error"] = "服务内部错误,请稍后重试。"
span := trace.SpanFromContext(ctx.Request().Context())
ctx.Data["FlashTip"] = fmt.Sprintf("若问题一直出现,请带上该段字符 %s 提交反馈。", span.SpanContext().TraceID().String())
} else if flashMsg != "" {
ctx.Data["HasError"] = true
ctx.Data["Error"] = flashMsg
}
ctx.Success("user/vote-form")
}
// VoteCreate 创建投票(默认草稿状态)。草稿保存放宽校验并跳过内容审核;
// 任何错误均回填表单而非清空输入。action=publish 时先存草稿再做发布校验,
// 失败则保留草稿并停留在编辑页,避免重复创建。
func VoteCreate(ctx context.Context) {
action := strings.TrimSpace(ctx.Request().FormValue("action"))
form, err := parseVoteForm(ctx, true)
if err != nil {
renderVoteFormWithInput(ctx, false, 0, err.Error(), false)
return
}
form.opts.CensorMetadata = form.censorMetadata
vote, err := db.Votes.Create(ctx.Request().Context(), form.opts)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote")
renderVoteFormWithInput(ctx, false, 0, "", true)
return
}
if len(form.options) > 0 {
if _, err := db.VoteOptions.BulkCreate(ctx.Request().Context(), vote.ID, form.options); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote options")
_ = db.Votes.DeleteByID(ctx.Request().Context(), vote.ID) // 回滚本次已建的空草稿,避免孤儿记录
renderVoteFormWithInput(ctx, false, 0, "", true)
return
}
}
if len(form.formFields) > 0 {
for i := range form.formFields {
form.formFields[i].VoteID = vote.ID
}
if _, err := db.VoteFormFields.BulkCreate(ctx.Request().Context(), vote.ID, form.formFields); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote form fields")
_ = db.Votes.DeleteByID(ctx.Request().Context(), vote.ID) // 回滚本次已建的空草稿,避免孤儿记录
renderVoteFormWithInput(ctx, false, 0, "", true)
return
}
}
editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit"
if action == "publish" {
if err := validateForPublish(ctx, vote); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect(editURL)
return
}
if err := db.Votes.UpdateStatus(ctx.Request().Context(), vote.ID, db.VoteStatusPublished); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to publish vote")
ctx.SetInternalErrorFlash()
ctx.Redirect(editURL)
return
}
shareURL := fmt.Sprintf("%s/vote/%s", strings.TrimRight(conf.App.ExternalURL, "/"), vote.LinkToken)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.published", shareURL))
ctx.Redirect("/user/questions#votes")
return
}
// 草稿保存成功:停留在编辑页,使用页面 toast 提示,避免 success flash 与 toast 重复展示。
ctx.Redirect(editURL + "?saved=1")
}
// VoteEdit 编辑投票页面(预填选项与表单字段)。
func VoteEdit(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
ctx.Data["Vote"] = vote
ctx.Data["IsEdit"] = true
ctx.Data["MaxVoteOptionLen"] = conf.Limits.VoteOptionLen // 选项输入框 maxlength 同源(vote-form.html JS)
ctx.Data["Title"] = "编辑投票"
// 草稿保存成功仅通过本次 URL 参数驱动统一 alert,不使用 session flash。
if ctx.Query("saved") == "1" {
ctx.Data["Success"] = i18n.T(ctx.Lang(), "vote.flash.draftSaved")
ctx.Data["DraftSaved"] = true
}
// 发布后的投票锁定类型与选项/字段(避免破坏历史投票数据归因),仅可编辑元信息
ctx.Data["LockedContent"] = vote.Status == db.VoteStatusPublished
// 选项 / 字段回填传结构化数据,模板经 toJSON(template.JS)注入 <script>,
// 避免 template.HTML 在 script 上下文被转义导致初始化失效。
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
ctx.Data["InitialOptions"] = optionsContent(options)
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
ctx.Data["InitialFields"] = fieldsContent(fields)
// 时间回填:Go 模板直接输出 time.Time 不符合 datetime-local 格式,预先格式化
if vote.StartAt != nil {
ctx.Data["StartAtStr"] = vote.StartAt.Format("2006-01-02T15:04")
}
if vote.EndAt != nil {
ctx.Data["EndAtStr"] = vote.EndAt.Format("2006-01-02T15:04")
}
ctx.Success("user/vote-form")
}
// optionsContent 提取选项内容数组(模板回填用)。
func optionsContent(options []*db.VoteOption) []string {
out := make([]string, 0, len(options))
for _, o := range options {
out = append(out, o.Content)
}
return out
}
// fieldsContent 提取表单字段数组(模板回填用)。
func fieldsContent(fields []*db.VoteFormField) []voteFormFieldInput {
out := make([]voteFormFieldInput, 0, len(fields))
for _, f := range fields {
out = append(out, voteFormFieldInput{
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: parseJSONStringArray(f.Options),
AllowCustom: f.AllowCustom,
})
}
return out
}
// parseJSONStringArray 解析 JSON 字符串数组,失败返回空数组。
func parseJSONStringArray(raw string) []string {
var out []string
if raw == "" {
return out
}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return []string{}
}
return out
}
// splitOptionTokens 按英文/中文逗号、顿号或换行拆分选项文本,兼容用户误用中文标点。
func splitOptionTokens(s string) []string {
parts := strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == ',' || r == '、' || r == '\n' || r == '\r'
})
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// VoteUpdate 更新投票。状态分级:草稿可完整编辑(含事务重建选项/字段);
// 进行中仅可编辑元信息(类型与选项/字段锁定,避免破坏历史数据归因);已结束/归档禁止。
func VoteUpdate(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
// 已结束/归档的投票不允许编辑
if vote.Status == db.VoteStatusEnded || vote.Status == db.VoteStatusArchived {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.editClosed"))
ctx.Redirect("/user/questions#votes")
return
}
// 进行中:仅解析元信息(标题/描述/可见性/范围/结果策略/时间窗/防刷/验证码)
if vote.Status == db.VoteStatusPublished {
form, err := parseVoteMetaForm(ctx)
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/votes/" + fmt.Sprint(vote.ID) + "/edit")
return
}
if err := db.Votes.Update(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
Title: form.opts.Title,
Description: form.opts.Description,
// 发布后类型/选项不可改。Update 对 nil 指针字段跳过写入,故不传 Type/MaxChoices
// 即保留原值;即便将来漏传也不会把 type 写成 ""。选项由 ReplaceByVoteID 托管。
Visibility: form.opts.Visibility,
VoterScope: form.opts.VoterScope,
ShowResult: form.opts.ShowResult,
AllowCustom: form.opts.AllowCustom,
StartAt: form.opts.StartAt,
EndAt: form.opts.EndAt,
MaxVotesPerFingerprint: form.opts.MaxVotesPerFingerprint,
RequireCaptcha: form.opts.RequireCaptcha,
CensorMetadata: form.censorMetadata,
QuotaLimit: form.opts.QuotaLimit,
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update vote meta")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/votes/" + fmt.Sprint(vote.ID) + "/edit")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.updated"))
ctx.Redirect("/user/questions#votes")
return
}
// 草稿:完整编辑(含选项/字段事务重建);草稿放宽校验,错误时回填表单不丢输入
form, err := parseVoteForm(ctx, true)
if err != nil {
renderVoteFormWithInput(ctx, true, vote.ID, err.Error(), false)
return
}
if err := db.Votes.Update(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
Title: form.opts.Title,
Description: form.opts.Description,
Type: &form.opts.Type,
Visibility: form.opts.Visibility,
VoterScope: form.opts.VoterScope,
ShowResult: form.opts.ShowResult,
AllowCustom: form.opts.AllowCustom,
MaxChoices: &form.opts.MaxChoices,
StartAt: form.opts.StartAt,
EndAt: form.opts.EndAt,
MaxVotesPerFingerprint: form.opts.MaxVotesPerFingerprint,
RequireCaptcha: form.opts.RequireCaptcha,
CensorMetadata: form.censorMetadata,
QuotaLimit: form.opts.QuotaLimit,
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update vote")
renderVoteFormWithInput(ctx, true, vote.ID, "", true)
return
}
// 事务重建选项与表单字段(草稿阶段无历史数据,删除重建安全)
if err := db.VoteOptions.ReplaceByVoteID(ctx.Request().Context(), vote.ID, form.options); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to replace vote options")
renderVoteFormWithInput(ctx, true, vote.ID, i18n.T(ctx.Lang(), "vote.err.optionUpdateFailed"), false)
return
}
for i := range form.formFields {
form.formFields[i].VoteID = vote.ID
}
if err := db.VoteFormFields.ReplaceByVoteID(ctx.Request().Context(), vote.ID, form.formFields); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to replace vote form fields")
renderVoteFormWithInput(ctx, true, vote.ID, i18n.T(ctx.Lang(), "vote.err.fieldUpdateFailed"), false)
return
}
// 选项/字段已重建,结果聚合可能变化:主动失效该投票的结果缓存。
votepkg.InvalidateResultsCache(vote.ID)
action := strings.TrimSpace(ctx.Request().FormValue("action"))
editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit"
// action=preview:草稿已保存,跳转预览页(发布态由 VotePreview 内部跳真实参与页)。
if action == "preview" {
ctx.Redirect("/user/votes/" + fmt.Sprint(vote.ID) + "/preview")
return
}
// action=publish:已保存为草稿,再执行发布校验;失败则保留草稿并停留在编辑页
if action == "publish" {
// 用刚保存的内容刷新 vote 结构,供审核与结构校验读取最新标题/类型
vote.Title = form.opts.Title
vote.Description = form.opts.Description
vote.Type = form.opts.Type
if err := validateForPublish(ctx, vote); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect(editURL)
return
}
if err := db.Votes.UpdateStatus(ctx.Request().Context(), vote.ID, db.VoteStatusPublished); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to publish vote")
ctx.SetInternalErrorFlash()
ctx.Redirect(editURL)
return
}
shareURL := fmt.Sprintf("%s/vote/%s", strings.TrimRight(conf.App.ExternalURL, "/"), vote.LinkToken)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.published", shareURL))
ctx.Redirect("/user/questions#votes")
return
}
// 草稿保存成功:停留在编辑页,使用页面 toast 提示,避免 success flash 与 toast 重复展示。
ctx.Redirect(editURL + "?saved=1")
}
// VotePublish 发布投票(draft → published)。草稿阶段跳过的结构校验与 fail-closed
// 内容审核在此统一补齐,确保对外公开内容仍受管控。
func VotePublish(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
if vote.Status != db.VoteStatusDraft {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.publishDraftOnly"))
ctx.Redirect("/user/questions#votes")
return
}
if err := validateForPublish(ctx, vote); err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
if err := db.Votes.UpdateStatus(ctx.Request().Context(), vote.ID, db.VoteStatusPublished); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to publish vote")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
shareURL := fmt.Sprintf("%s/vote/%s", strings.TrimRight(conf.App.ExternalURL, "/"), vote.LinkToken)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.published", shareURL))
ctx.Redirect("/user/questions#votes")
}
// validateForPublish 发布前补齐完整校验:结构合法性(标题/选项数/字段数/排序题选项数)
// + fail-closed 内容审核,确保对外公开的投票结构合法且内容合规。
func validateForPublish(ctx context.Context, vote *db.Vote) error {
if strings.TrimSpace(vote.Title) == "" {
return errors.New(i18n.T(ctx.Lang(), "vote.err.publishTitleRequired"))
}
if vote.Type == db.VoteTypeForm {
count, err := db.VoteFormFields.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil || count < 1 {
return errors.New(i18n.T(ctx.Lang(), "vote.err.publishFormFieldsRequired"))
}
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
for i, f := range fields {
if f.FieldType == db.VoteFormFieldTypeRank {
if len(parseJSONStringArray(f.Options)) < 2 {
return errors.New(i18n.T(ctx.Lang(), "vote.err.fieldRankTooFew", i+1, f.Label))
}
}
}
} else if vote.Type != db.VoteTypeRating {
count, err := db.VoteOptions.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil || count < 2 {
return errors.New(i18n.T(ctx.Lang(), "vote.err.publishOptionsRequired"))
}
}
if err := censorVoteForPublish(ctx, vote); err != nil {
return err
}
return nil
}
// voteOwnerStrictness 读取投票发起人本人的「审核严格度」档位。
// 不用 ctx.User:发布动作未必由发起人触发,ctx.User 可能是操作者,会导致
// 「按别人的档位审核别人箱子的内容」。取不到发起人时回退最严档——宁严勿松。
func voteOwnerStrictness(ctx context.Context, ownerID uint) censor.Strictness {
owner, err := db.Users.GetByID(ctx.Request().Context(), ownerID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).WithField("owner_id", ownerID).
Error("Failed to get vote owner for moderation strictness")
return censor.StrictnessStrict
}
return moderation.EffectiveStrictness(ctx.Request().Context(), ownerID, owner.ModerationStrictness)
}
// censorVoteForPublish 发布前对投票全部可见文本做内容审核(fail-closed)。
func censorVoteForPublish(ctx context.Context, vote *db.Vote) error {
// 同一次发布的全部文本必须用同一个档位裁决:否则标题走发起人档位、描述/选项走
// ctx.User 档位,一旦发布动作不是发起人触发,同一条投票会落到两个账号的松紧档上。
strictness := voteOwnerStrictness(ctx, vote.UserID)
check := func(text string) error {
_, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), text)
return err
}
if vote.Title != "" {
if err := check(vote.Title); err != nil {
return err
}
}
if vote.Description != "" {
if err := check(vote.Description); err != nil {
return err
}
}
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
for _, o := range options {
if err := check(o.Content); err != nil {
return err
}
}
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
for _, f := range fields {
if err := check(f.Label); err != nil {
return err
}
// 预置选项(select/radio/checkbox/rank)也纳入审核
for _, fo := range parseJSONStringArray(f.Options) {
if fo == "" {
continue
}
if err := check(fo); err != nil {
return err
}
}
}
return nil
}
// VoteClose 提前结束进行中的投票。
func VoteClose(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
if vote.Status != db.VoteStatusPublished {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.closePublishedOnly"))
ctx.Redirect("/user/questions#votes")
return
}
if err := db.Votes.UpdateStatus(ctx.Request().Context(), vote.ID, db.VoteStatusEnded); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to close vote")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.closed"))
ctx.Redirect("/user/questions#votes")
}
// VoteDelete 删除投票(级联删除选项、表单字段与提交记录)。
func VoteDelete(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
if err := db.VoteOptions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote options")
}
if err := db.VoteFormFields.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote form fields")
}
if err := db.VoteSubmissions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote submissions")
}
// 投票已删除:失效其结果缓存(即便竞态下仍能命中也已无意义)。
votepkg.InvalidateResultsCache(vote.ID)
if err := db.Votes.DeleteByID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.deleted"))
ctx.Redirect("/user/questions#votes")
}
// textReplyView 文本类字段单条提交内容的展示视图(按字段归类,内联在题目下方折叠展示)。
type textReplyView struct {
CreatedAt time.Time
Text string
}
// VotePreview 草稿预览:箱主在发布前查看参与页面样例(提交禁用)。
// 仅草稿状态可预览;发布后直接跳真实参与页。
func VotePreview(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
if vote.Status != db.VoteStatusDraft {
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
ctx.Data["Vote"] = vote
ctx.Data["IsOwner"] = true
ctx.Data["PreviewMode"] = true
ctx.Data["NotStarted"] = false
ctx.Data["Ended"] = false
ctx.Data["VoteScopeRegisteredOnly"] = vote.VoterScope == db.VoteVoterScopeRegisteredOnly
ctx.Data["IsLogged"] = true // 预览按登录态渲染,避免登录提示干扰样例
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
}
// "其他/自定义选项"保留标记(与参与端 Page 一致)
ctx.Data["CustomFieldMarker"] = db.CustomFieldMarker
ctx.Data["HasVoted"] = false
ctx.Data["CanVote"] = true
ctx.Data["ShowResults"] = false
ctx.Data["Title"] = "预览:" + vote.Title
ctx.Success("vote/page")
}
// VoteResults 投票结果页:选项型(单选/多选/评分/排序)走统一聚合统计,
// 表单类型展示回复明细(仅箱主可见)。
func VoteResults(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
total, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count submissions")
total = 0
}
var formFields []*db.VoteFormField
textReplies := map[string][]textReplyView{} // 文本类字段按标签归类,选择/评分/排序已聚合
results, err := votepkg.LoadResults(ctx, vote)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load vote results")
}
if vote.Type == db.VoteTypeForm {
formFields, _ = db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
// 短 TTL 缓存回复明细解析结果(逐条 json.Unmarshal 全部提交记录的 O(N) 开销)。
replyKey := strconv.FormatUint(uint64(vote.ID), 10)
if cached, ok := voteTextRepliesCache.Get(replyKey); ok {
textReplies = cached.(map[string][]textReplyView)
} else {
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), vote.ID, nil)
for _, s := range submissions {
var data map[string]interface{}
if len(s.FormData) > 0 {
_ = json.Unmarshal(s.FormData, &data)
}
for _, f := range formFields {
if f.FieldType == db.VoteFormFieldTypeText || f.FieldType == db.VoteFormFieldTypeTextarea {
if val, ok := data[f.Label].(string); ok && strings.TrimSpace(val) != "" {
textReplies[f.Label] = append(textReplies[f.Label], textReplyView{CreatedAt: s.CreatedAt, Text: val})
}
}
}
}
voteTextRepliesCache.Set(replyKey, textReplies, voteTextRepliesCacheTTL)
}
}
ctx.Data["Vote"] = vote
ctx.Data["Total"] = total
ctx.Data["Results"] = results
ctx.Data["FormFields"] = formFields
ctx.Data["TextReplies"] = textReplies
ctx.Data["Title"] = "投票结果"
ctx.Success("user/vote-results")
}
// sanitizeCSVCell 防止 CSV 公式注入:以 = + - @ 开头的单元格会被 Excel/Numbers
// 当作公式执行,前置单引号强制按纯文本处理。
func sanitizeCSVCell(s string) string {
if s == "" {
return s
}
r := []rune(s)[0]
if r == '=' || r == '+' || r == '-' || r == '@' {
return "'" + s
}
return s
}
// formatFieldValue 将表单字段值格式化为可读文本:多选数组 → "A、B";
// 排序 map → "首选:1;次选:2"(按键排序保证稳定);其余原样。
func formatFieldValue(v interface{}) string {
switch val := v.(type) {
case []interface{}:
parts := make([]string, 0, len(val))
for _, x := range val {
parts = append(parts, fmt.Sprint(x))
}
return strings.Join(parts, "、")
case map[string]interface{}:
keys := make([]string, 0, len(val))
for k := range val {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s:%v", k, val[k]))
}
return strings.Join(parts, ";")
default:
return fmt.Sprint(v)
}
}
// VoteExportCSV 导出投票结果 CSV:选项型每选项一行(内容 + 票数/均值)末行总参与;
// 表单型每行一条回复(提交时间 + 各字段列)。前置 UTF-8 BOM 保证 Excel 中文不乱码。
func VoteExportCSV(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil {
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/user/questions#votes")
return
}
var buf strings.Builder
buf.WriteString("\xef\xbb\xbf") // UTF-8 BOM
w := csv.NewWriter(&buf)
if vote.Type == db.VoteTypeForm {
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
header := []string{"提交时间"}
for _, f := range fields {
header = append(header, f.Label)
}
_ = w.Write(header)
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), vote.ID, nil)
for _, s := range submissions {
var data map[string]interface{}
_ = json.Unmarshal(s.FormData, &data)
row := []string{s.CreatedAt.Format("2006-01-02 15:04:05")}
for _, f := range fields {
row = append(row, sanitizeCSVCell(formatFieldValue(data[f.Label])))
}
_ = w.Write(row)
}
} else {
res, err := votepkg.LoadResults(ctx, vote)
if err != nil {
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
_ = w.Write([]string{"选项", "票数"})
switch res.Type {
case db.VoteTypeSingle, db.VoteTypeMultiple:
for _, o := range res.Options {
_ = w.Write([]string{o.Option.Content, strconv.FormatInt(o.Count, 10)})
}
case db.VoteTypeRating:
for _, r := range res.Ratings {
_ = w.Write([]string{fmt.Sprintf("%d 分", r.Score), strconv.FormatInt(r.Count, 10)})
}
_ = w.Write([]string{"平均分", fmt.Sprintf("%.2f", res.AvgRating)})
case db.VoteTypeRank:
for _, r := range res.Rankings {
_ = w.Write([]string{r.Option.Content, fmt.Sprintf("%.2f", r.AvgRank)})
}
_ = w.Write([]string{"平均排名", fmt.Sprintf("%.2f", res.AvgRank)})
}
// 自定义补充选项(开启 AllowCustom 时)
if len(res.CustomStats) > 0 {
_ = w.Write([]string{"自定义补充", "次数"})
for _, c := range res.CustomStats {
_ = w.Write([]string{sanitizeCSVCell(c.Text), strconv.FormatInt(c.Count, 10)})
}
}
_ = w.Write([]string{"总参与", strconv.FormatInt(res.Total, 10)})
}
w.Flush()
ctx.ResponseWriter().Header().Set("Content-Type", "text/csv; charset=utf-8")
ctx.ResponseWriter().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="vote_%d.csv"`, vote.ID))
ctx.ResponseWriter().Header().Set("X-Content-Type-Options", "nosniff")
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write([]byte(buf.String()))
}