Files
tamabox/route/user/vote.go
T
2026-09-08 10:10:44 +08:00

1319 lines
48 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"
votepkg "github.com/tamakyi/TamaBox/route/vote"
)
// voteTextRepliesCache 箱主表单投票"回复明细"(逐条文本)的短 TTL 缓存。
// 该视图仅在箱主结果页展示,且需在 Go 层逐条 json.Unmarshal 全部提交记录(O(N)),
// 缓存可显著降低重算开销;结果页非强一致,30s 陈旧可接受。
var voteTextRepliesCache = cache.NewTTLCache(64)
// voteTextRepliesCacheTTL 回复明细缓存有效期。
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 对单段文本做内容审核。
// fail-closed:审核服务异常时拒绝发布(与提问/回答流程一致);
// 未开启 EnableTextCensor 时 censor.Text 直接放行,不影响开发环境。
func censorCheck(ctx context.Context, text string) error {
resp, err := censor.Text(ctx.Request().Context(), text)
if err != nil {
return errors.New(i18n.T(ctx.Lang(), "censor.unavailable"))
}
if !resp.Pass {
return errors.New(resp.ErrorMessage())
}
return nil
}
// parseTimeInput 解析 "YYYY-MM-DDTHH:MM"(HTML datetime-local 格式),空字符串返回 nil。
// 使用 time.Local 解析:datetime-local 提交的是无时区信息的时间串,按服务端本地时区解释,
// 与 Evaluate 中 time.Now() 的比较基准保持一致(旧的 time.Parse 会解析成 UTC,产生时区偏移)。
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 —— Bind 的反射绑定会把 string 直接赋给 int 字段导致 panic,
// 且选项/表单字段数组需要 JSON 解析,手动解析更安全。
// draft=true 时放宽“选项/字段数量”与内容审核要求(详见 parseVoteMeta/parseOptions/parseFormFields),
// 使草稿可随时暂存、不丢失已填内容。
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 时放宽校验并跳过内容审核:草稿为私有内容,保存时不应因审核服务抖动而丢失;
// 审核统一在发布(VotePublish)时执行,确保对外公开内容仍受管控。
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 := censor.Text(ctx.Request().Context(), title)
if err != nil {
return nil, errors.New("内容审核服务暂时不可用,请稍后再试")
}
if !titleCensor.Pass {
return nil, errors.New(titleCensor.ErrorMessage())
}
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))
}
// 单个选项长度与单选/多选选项同规则(vote-form.html 前端 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
}
// ---------------- 处理器 ----------------
// VoteList 投票管理列表(含每票参与数统计)。
// effectiveVoteStatus 计算投票的有效展示状态(status + 时间窗联合判断):
// published 且未到开始时间 → 未开始;published 且已过结束时间 → 已结束;
// 其余与 status 字段一致。管理列表据此展示,避免"已过结束时间仍显示进行中"。
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 将投票列表转换为管理面板所需的富信息条目。
// QuestionList 的「我的提问箱 · 投票」面板与此前独立的投票管理列表共用,避免重复统计逻辑。
//
// 性能:参与数与选项数通过 CountByVoteIDs 批量聚合(2 条 WHERE vote_id IN (...)),
// 替代原先对每票各发 2 次 COUNT 的 N+1 查询,N 张投票从 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))
// 批量预取参与数与选项数(避免 N+1)
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 在保存失败时重新渲染投票表单并回填用户已提交的内容,
// 避免校验/审核/数据库错误导致整页输入被清空(原先 Redirect("/user/votes/new") 会丢失全部数据)。
// isEdit=true 时回填 vote.ID,保留编辑动作的提交地址。
//
// flashMsg/internal 用于在同请求内直接写入 c.Data 的错误提示(而非 session flash):
// 本项目的 flash 在请求开始时读取进 c.Data,随后写入 session 的 flash 不会被本次渲染读到,
// 且会残留到下一次请求造成"陈旧错误"串页;故错误回填采用直接写 c.Data 的方式。
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 创建投票(默认草稿状态)。
// 草稿保存放宽校验并跳过内容审核(见 parseVoteForm/parseVoteMeta);任何错误均回填表单而非清空输入。
// 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"
// action=publish:已保存为草稿,再做发布校验;失败则保留草稿并停留在编辑页,避免重复创建
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 上下文中被 html/template JS 转义导致初始化失效。
// 发布后的投票编辑器被锁定,回填数据不展示但保持 JS 初始化安全(非空数组)。
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 更新投票。
// 状态分级(方案 A):草稿可完整编辑(含事务重建选项/字段);进行中仅可编辑元信息
// (类型与选项/字段锁定,避免破坏历史投票数据归因);已结束/归档完全禁止。
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,
// 发布后类型/选项不可改(见 parseVoteMetaForm 注释)。db.Votes.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
}
// censorVoteForPublish 发布前对投票全部可见文本做内容审核(fail-closed)。
// 草稿保存阶段跳过了审核,发布(对外公开)时必须补齐,确保公开内容受管控。
func censorVoteForPublish(ctx context.Context, vote *db.Vote) error {
if vote.Title != "" {
resp, err := censor.Text(ctx.Request().Context(), vote.Title)
if err != nil {
return errors.New(i18n.T(ctx.Lang(), "censor.unavailable"))
}
if !resp.Pass {
return errors.New(resp.ErrorMessage())
}
}
if vote.Description != "" {
if err := censorCheck(ctx, vote.Description); err != nil {
return err
}
}
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
for _, o := range options {
if err := censorCheck(ctx, o.Content); err != nil {
return err
}
}
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
for _, f := range fields {
if err := censorCheck(ctx, f.Label); err != nil {
return err
}
// 预置选项(select/radio/checkbox/rank)也纳入审核
for _, fo := range parseJSONStringArray(f.Options) {
if fo == "" {
continue
}
if err := censorCheck(ctx, fo); err != nil {
return err
}
}
}
return nil
}
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{}
// 所有类型统一走 LoadResults:选项型拿统计;表单型拿 FormFieldStats(字段级聚合)
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 将表单字段值格式化为可读文本(CSV 导出用):
// 多选数组 → "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()))
}