1319 lines
48 KiB
Go
1319 lines
48 KiB
Go
// Copyright 2026 tamakyi. All rights reserved.
|
||
// Use of this source code is governed by a MIT-style
|
||
// license that can be found in the LICENSE file.
|
||
|
||
package 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()))
|
||
}
|