安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性

This commit is contained in:
tamakyi
2026-11-05 12:17:05 +08:00
parent 7636456d4e
commit 25aee35030
18 changed files with 1002 additions and 194 deletions
+43 -9
View File
@@ -5,6 +5,7 @@
package auth
import (
"fmt"
"os"
"time"
@@ -73,6 +74,20 @@ func ForgotPasswordAction(ctx context.Context, f form.ForgotPassword, cache cach
return
}
// SEC-05:签发新令牌前,先尝试删除该用户上一次的找回令牌。
// 用 userID → code 反查键实现:新令牌签发后旧令牌的反查键被覆盖,
// 旧 code 在 24h 内虽然物理上还在缓存里,但已经不在用户的"当前令牌"集合中,
// checkRecoverPasswordCode 会拒绝使用。
userCodeKey := "forgot-password-user-code:" + fmt.Sprint(user.ID)
if oldCodeItf, err := cache.Get(ctx.Request().Context(), userCodeKey); err == nil {
if oldCode, ok := oldCodeItf.(string); ok && oldCode != "" {
oldCodeKey := "forgot-password-recovery-code:" + oldCode
if err := cache.Delete(ctx.Request().Context(), oldCodeKey); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to delete old recovery code")
}
}
}
code := randstr.String(64)
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
if err := cache.Set(ctx.Request().Context(), recoveryCodeCacheKey, user.ID, 24*time.Hour); err != nil {
@@ -82,6 +97,16 @@ func ForgotPasswordAction(ctx context.Context, f form.ForgotPassword, cache cach
return
}
// 记录该用户当前的找回令牌 code,供下次签发时撤销。
if err := cache.Set(ctx.Request().Context(), userCodeKey, code, 24*time.Hour); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set user recovery code cache")
// 尽力回滚刚写入的令牌,避免孤儿
_ = cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey)
ctx.SetInternalErrorFlash()
ctx.Redirect("/forgot-password")
return
}
if err := mail.SendPasswordRecoveryMail(user.Email, code); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send password recovery mail")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errEmailFailed"))
@@ -151,15 +176,10 @@ func RecoverPasswordAction(ctx context.Context, cache cache.Cache, f form.Recove
return
}
code := ctx.Query("code")
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
if err := cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete password recovery code cache")
ctx.SetInternalErrorFlash()
ctx.Redirect("/login")
return
}
// SEC-05:先更新密码,成功后再删除令牌。
// 旧实现先删令牌再改密码:若密码更新失败,用户必须重新申请。
// 现在密码更新成功才删令牌;即使删令牌失败,密码已改,
// 旧令牌也不再有用(重新登录需要新密码)。
if err := db.Users.UpdatePassword(ctx.Request().Context(), user.ID, f.NewPassword); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user password")
ctx.SetInternalErrorFlash()
@@ -167,6 +187,20 @@ func RecoverPasswordAction(ctx context.Context, cache cache.Cache, f form.Recove
return
}
code := ctx.Query("code")
recoveryCodeCacheKey := "forgot-password-recovery-code:" + code
if err := cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete password recovery code cache")
// 令牌删除失败不阻断流程:密码已改,旧令牌已经无法用于登录。
// 但记录日志便于排查缓存异常。
}
// 同时清理 user → code 反查键,避免下次申请时尝试删除已不存在的旧 code。
userCodeKey := "forgot-password-user-code:" + fmt.Sprint(user.ID)
if err := cache.Delete(ctx.Request().Context(), userCodeKey); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to delete user recovery code mapping")
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "auth.errPasswordChanged"))
ctx.Redirect("/login")
}
+24
View File
@@ -7,6 +7,7 @@ package question
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -101,6 +102,29 @@ func AskStatusAPI(ctx context.Context) error {
return ctx.ServerError()
}
// SEC-06:与 ListAPI(route/question/page.go)对齐,补上 L4 和页面密码检查。
// 否则持有正确问题 token 的请求者可以绕过页面访问边界,取得回答状态、
// 创建时间和前 60 字摘要。
// 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err == nil && level == db.RestrictionL4 {
out := make([]askStatusResult, 0, len(req.Items))
for _, it := range req.Items {
out = append(out, askStatusResult{ID: it.ID})
}
return ctx.JSON(map[string]interface{}{"items": out})
}
if owner.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == owner.ID) {
passKey := fmt.Sprintf("page_pass_%d", owner.ID)
sessionVer, _ := ctx.Session.Get(passKey).(uint)
if sessionVer != owner.PagePasswordVer {
out := make([]askStatusResult, 0, len(req.Items))
for _, it := range req.Items {
out = append(out, askStatusResult{ID: it.ID})
}
return ctx.JSON(map[string]interface{}{"items": out})
}
}
// 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。
ids := make([]uint, 0, len(req.Items))
for _, it := range req.Items {
+21 -4
View File
@@ -83,10 +83,12 @@ func Pager(ctx context.Context) {
// 与背景图、颜色同属箱主个性化配置;/user 后台由 Contexter 按登录用户自己的配置注入。
// 空串 = 不加载主题 css(站点默认就是基础外观时省掉一次请求)。
ctx.Data["ThemeSlug"] = theme.Resolve(pageUser.Theme)
// 页面密码检查
// 页面密码检查:session 中保存授权时的 PagePasswordVer 版本号,
// 与当前版本号比对,修改密码后旧授权立即失效(SEC-06)。
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized {
sessionVer, _ := ctx.Session.Get(passKey).(uint)
if sessionVer != pageUser.PagePasswordVer {
// 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页
ctx.Data["Next"] = ctx.Request().URL.RequestURI()
ctx.Data["PageUser"] = pageUser
@@ -278,7 +280,8 @@ func VerifyPagePassword(ctx context.Context) {
if pageUser.CheckPagePassword(password) {
pagePasswordGuard.Reset(guardKey)
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
ctx.Session.Set(passKey, true)
// 保存当前密码版本号到 session,修改密码后旧授权自动失效(SEC-06)
ctx.Session.Set(passKey, pageUser.PagePasswordVer)
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
// 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
// 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
@@ -330,7 +333,8 @@ func ListAPI(ctx context.Context) error {
}
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized {
sessionVer, _ := ctx.Session.Get(passKey).(uint)
if sessionVer != pageUser.PagePasswordVer {
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc"))
}
}
@@ -471,6 +475,9 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to count today messages")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if todayCount >= int64(pageUser.DailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errChatDailyLimit", pageUser.DailyMessageLimit))
@@ -558,6 +565,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
f.Content,
)
if err != nil {
if errors.Is(err, db.ErrDailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errChatDailyLimit", pageUser.DailyMessageLimit))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation with first message")
ctx.SetInternalError(f)
ctx.Success("question/list")
@@ -686,6 +698,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
IsPrivate: isPrivate,
})
if err != nil {
if errors.Is(err, db.ErrFeatureQuotaReached) {
ctx.SetErrorFlash(i18n.FeatureClosedMessage(ctx.Lang(), db.FeatureQuestion, db.FeatureReasonQuotaReached))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new question")
ctx.SetInternalError(f)
ctx.Success("question/list")
+220 -5
View File
@@ -16,6 +16,7 @@ import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/xuri/excelize/v2"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
@@ -499,8 +500,9 @@ func DeactivatedProfile(ctx context.Context) {
ctx.Success("user/deactivated")
}
// ExportProfileJSON 以 JSON 格式全量导出账号数据(用户信息 + 提问/回答/追问 + 私聊消息),
// ExportProfileJSON 以 JSON 格式导出账号数据(用户信息 + 提问/回答/追问 + 投票 + 私聊消息),
// 符合可携带权要求,供用户备份/迁移。
// FUN-01 修复:补齐投票及提交、私聊图片;子查询失败时 fail-closed 而非静默生成缺数据的文件。
func ExportProfileJSON(ctx context.Context) {
user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
if err != nil {
@@ -542,12 +544,16 @@ func ExportProfileJSON(ctx context.Context) {
followUpMap, err := db.FollowUps.GetByQuestionIDs(ctx.Request().Context(), questionIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get follow ups for export")
followUpMap = make(map[uint][]*db.FollowUp)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
imageMap, err := db.UploadImages.GetByQuestionIDs(ctx.Request().Context(), questionIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get images for export")
imageMap = make(map[uint][]*db.UploadImage)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
exportQuestions := make([]exportedQuestion, 0, len(questions))
@@ -579,6 +585,7 @@ func ExportProfileJSON(ctx context.Context) {
Content string `json:"content"`
SenderID uint `json:"sender_id"`
CreatedAt time.Time `json:"created_at"`
Images []string `json:"images"`
}
type exportedConversation struct {
OwnerID uint `json:"owner_id"`
@@ -592,9 +599,19 @@ func ExportProfileJSON(ctx context.Context) {
allConvs := make([]*db.Conversation, 0)
if owned, err := db.Conversations.ListByOwner(ctx.Request().Context(), user.ID, 0, 0); err == nil {
allConvs = append(allConvs, owned...)
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list owned conversations for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
if asked, err := db.Conversations.ListByAsker(ctx.Request().Context(), user.ID, 0, 0); err == nil {
allConvs = append(allConvs, asked...)
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list asked conversations for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
// 去重并收集会话 ID,批量拉取全部消息(IN 查询 + 内存分组),避免逐会话查询造成 N+1。
seen := make(map[uint]bool)
@@ -611,8 +628,26 @@ func ExportProfileJSON(ctx context.Context) {
msgMap, err := db.Messages.GetAllByConversationIDs(ctx.Request().Context(), convIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get messages for export")
msgMap = make(map[uint][]*db.Message)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
// 批量拉取全部消息的图片(IN 查询 + 内存分组),避免逐消息查询造成 N+1。
allMessageIDs := make([]uint, 0)
for _, msgs := range msgMap {
for _, m := range msgs {
allMessageIDs = append(allMessageIDs, m.ID)
}
}
msgImageMap, err := db.MessageImages.GetByMessageIDs(ctx.Request().Context(), allMessageIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get message images for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
for _, conv := range dedupedConvs {
msgs := msgMap[conv.ID]
if msgs == nil {
@@ -620,10 +655,15 @@ func ExportProfileJSON(ctx context.Context) {
}
exportMsgs := make([]exportedMessage, 0, len(msgs))
for _, m := range msgs {
imageKeys := make([]string, 0)
for _, img := range msgImageMap[m.ID] {
imageKeys = append(imageKeys, img.ObjectKey)
}
exportMsgs = append(exportMsgs, exportedMessage{
Content: m.Content,
SenderID: m.SenderID,
CreatedAt: m.CreatedAt,
Images: imageKeys,
})
}
exportConvs = append(exportConvs, exportedConversation{
@@ -635,12 +675,187 @@ func ExportProfileJSON(ctx context.Context) {
})
}
// 投票及提交(FUN-01:补齐投票导出)
type exportedVoteOption struct {
ID uint `json:"id"`
Content string `json:"content"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
}
type exportedVoteFormField struct {
ID uint `json:"id"`
FieldType string `json:"field_type"`
Label string `json:"label"`
Required bool `json:"required"`
MaxLength int `json:"max_length"`
Options string `json:"options"`
SortOrder int `json:"sort_order"`
AllowCustom bool `json:"allow_custom"`
CreatedAt time.Time `json:"created_at"`
}
type exportedVoteSubmission struct {
ID uint `json:"id"`
OptionID uint `json:"option_id"`
VoterIP string `json:"voter_ip"`
FormData datatypes.JSON `json:"form_data"`
CustomText string `json:"custom_text"`
CreatedAt time.Time `json:"created_at"`
}
type exportedVote struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
Visibility string `json:"visibility"`
VoterScope string `json:"voter_scope"`
ShowResult string `json:"show_result"`
AllowCustom bool `json:"allow_custom"`
MaxChoices int `json:"max_choices"`
StartAt *time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
MaxVotesPerFingerprint int `json:"max_votes_per_fingerprint"`
RequireCaptcha bool `json:"require_captcha"`
QuotaLimit int `json:"quota_limit"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Options []exportedVoteOption `json:"options"`
FormFields []exportedVoteFormField `json:"form_fields"`
Submissions []exportedVoteSubmission `json:"submissions"`
}
votes, err := db.Votes.ListByUserID(ctx.Request().Context(), user.ID, db.ListVotesOptions{})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list votes for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
voteIDs := make([]uint, 0, len(votes))
for _, v := range votes {
voteIDs = append(voteIDs, v.ID)
}
// 批量拉取选项、字段、提交(IN 查询 + 内存分组),避免逐投票 N+1。
voteOptsMap := make(map[uint][]*db.VoteOption)
if len(voteIDs) > 0 {
for _, v := range votes {
opts, err := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote options for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
voteOptsMap[v.ID] = opts
}
}
voteFieldsMap := make(map[uint][]*db.VoteFormField)
if len(voteIDs) > 0 {
for _, v := range votes {
fields, err := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote form fields for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
voteFieldsMap[v.ID] = fields
}
}
voteSubsMap, err := db.VoteSubmissions.ListByVoteIDs(ctx.Request().Context(), voteIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get vote submissions for export")
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
ctx.Redirect("/user/profile")
return
}
exportVotes := make([]exportedVote, 0, len(votes))
for _, v := range votes {
opts := voteOptsMap[v.ID]
if opts == nil {
opts = []*db.VoteOption{}
}
exportOpts := make([]exportedVoteOption, 0, len(opts))
for _, o := range opts {
exportOpts = append(exportOpts, exportedVoteOption{
ID: o.ID,
Content: o.Content,
SortOrder: o.SortOrder,
CreatedAt: o.CreatedAt,
})
}
fields := voteFieldsMap[v.ID]
if fields == nil {
fields = []*db.VoteFormField{}
}
exportFields := make([]exportedVoteFormField, 0, len(fields))
for _, f := range fields {
exportFields = append(exportFields, exportedVoteFormField{
ID: f.ID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
CreatedAt: f.CreatedAt,
})
}
subs := voteSubsMap[v.ID]
if subs == nil {
subs = []*db.VoteSubmission{}
}
exportSubs := make([]exportedVoteSubmission, 0, len(subs))
for _, s := range subs {
exportSubs = append(exportSubs, exportedVoteSubmission{
ID: s.ID,
OptionID: s.OptionID,
VoterIP: s.VoterIP,
FormData: s.FormData,
CustomText: s.CustomText,
CreatedAt: s.CreatedAt,
})
}
exportVotes = append(exportVotes, exportedVote{
ID: v.ID,
Title: v.Title,
Description: v.Description,
Type: v.Type,
Status: v.Status,
Visibility: v.Visibility,
VoterScope: v.VoterScope,
ShowResult: v.ShowResult,
AllowCustom: v.AllowCustom,
MaxChoices: v.MaxChoices,
StartAt: v.StartAt,
EndAt: v.EndAt,
MaxVotesPerFingerprint: v.MaxVotesPerFingerprint,
RequireCaptcha: v.RequireCaptcha,
QuotaLimit: v.QuotaLimit,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
Options: exportOpts,
FormFields: exportFields,
Submissions: exportSubs,
})
}
payload := map[string]interface{}{
"exported_at": time.Now().Format(time.RFC3339),
"version": 1,
"version": 2,
"user": user,
"questions": exportQuestions,
"conversations": exportConvs,
"votes": exportVotes,
}
fileName := fmt.Sprintf("提问箱账号数据-%s-%s.json", user.Domain, time.Now().Format("20060102150405"))
+22 -41
View File
@@ -626,33 +626,15 @@ func VoteCreate(ctx context.Context) {
}
form.opts.CensorMetadata = form.censorMetadata
vote, err := db.Votes.Create(ctx.Request().Context(), form.opts)
// 事务化创建:投票 + 选项 + 表单字段。任一步失败整体回滚,
// 避免留下「有投票无选项」或「有选项无字段」的半成品。
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
}
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" {
@@ -704,10 +686,22 @@ func VoteEdit(ctx context.Context) {
// 选项 / 字段回填传结构化数据,模板经 toJSON(template.JS)注入 <script>,
// 避免 template.HTML 在 script 上下文被转义导致初始化失效。
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
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, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
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 格式,预先格式化
@@ -829,7 +823,9 @@ func VoteUpdate(ctx context.Context) {
return
}
if err := db.Votes.Update(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
// 事务化更新:投票元信息 + 替换选项 + 替换表单字段。任一步失败整体回滚,
// 避免「元信息已更新、字段只更新一部分」的不一致草稿。
if err := db.Votes.UpdateWithOptions(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
Title: form.opts.Title,
Description: form.opts.Description,
Type: &form.opts.Type,
@@ -844,27 +840,12 @@ func VoteUpdate(ctx context.Context) {
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")
}, 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
}
// 事务重建选项与表单字段(草稿阶段无历史数据,删除重建安全)
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)
+29 -7
View File
@@ -116,7 +116,10 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
switch v.Type {
case db.VoteTypeSingle:
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
options, err := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
if err != nil {
return nil, errors.Wrap(err, "list vote options")
}
// 一次分组查询替代逐选项 COUNT(避免 N+1)
counts, err := db.VoteSubmissions.CountGroupByOptionID(reqCtx, v.ID)
if err != nil {
@@ -132,9 +135,15 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
}
case db.VoteTypeMultiple:
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
options, err := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
if err != nil {
return nil, errors.Wrap(err, "list vote options")
}
submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
if err != nil {
return nil, errors.Wrap(err, "list vote submissions")
}
counts := make(map[uint]int64, len(options))
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
var totalSelections int64
for _, s := range submissions {
var data struct {
@@ -163,9 +172,12 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
}
case db.VoteTypeRating:
submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
if err != nil {
return nil, errors.Wrap(err, "list vote submissions")
}
scoreCounts := make(map[int]int64)
var sum int64
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
for _, s := range submissions {
var data struct {
Rating int `json:"rating"`
@@ -184,10 +196,16 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
}
case db.VoteTypeRank:
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
options, err := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
if err != nil {
return nil, errors.Wrap(err, "list vote options")
}
submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
if err != nil {
return nil, errors.Wrap(err, "list vote submissions")
}
rankSums := make(map[uint]float64, len(options))
rankCounts := make(map[uint]int64, len(options))
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
for _, s := range submissions {
var data struct {
Ranking map[string]float64 `json:"ranking"`
@@ -215,7 +233,11 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
}
case db.VoteTypeForm:
res.FormFields, _ = db.VoteFormFields.ListByVoteID(reqCtx, v.ID)
fields, err := db.VoteFormFields.ListByVoteID(reqCtx, v.ID)
if err != nil {
return nil, errors.Wrap(err, "list vote form fields")
}
res.FormFields = fields
res.FormCount = total
res.FormFieldStats = aggregateFormFieldStats(reqCtx, res.FormFields)
}
+16 -20
View File
@@ -724,6 +724,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil {
if errors.Is(err, db.ErrVoteAlreadyVoted) {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
} else if errors.Is(err, db.ErrVoteNotAccepting) || errors.Is(err, db.ErrFeatureQuotaReached) {
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.ended"))
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote submission")
ctx.SetInternalErrorFlash()
@@ -738,19 +740,6 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
// 用 InvalidateResultsCache 会让每一票都变成下一个读请求的同步全量重算(O(N²))。
ExpireResultsCache(vote.ID)
// 单份投票的份数上限:达到即自动结束该投票。
// (箱主级的累计计数已由 VoteSubmissions.Create 在同一事务里 +1,这里不再重复)
if vote.QuotaLimit > 0 {
count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to count vote submissions for quota")
} else if count >= int64(vote.QuotaLimit) {
if err := db.Votes.UpdateStatus(ctx.Request().Context(), vote.ID, db.VoteStatusEnded); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to auto end vote on quota reached")
}
}
}
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)。owner 在上方已取过,直接复用。
if vote.Type == db.VoteTypeForm && owner.Notify == db.NotifyTypeEmail {
email := owner.Email
@@ -820,9 +809,14 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
// 选项合法性集合
validOptions := map[uint]bool{}
var voteOptions []*db.VoteOption
if v.Type == db.VoteTypeSingle || v.Type == db.VoteTypeMultiple || v.Type == db.VoteTypeRank {
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
for _, o := range options {
var err error
voteOptions, err = db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
if err != nil {
return nil, errors.Wrap(err, "load vote options")
}
for _, o := range voteOptions {
validOptions[o.ID] = true
}
}
@@ -853,13 +847,12 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
formData["rating"] = score
case db.VoteTypeRank:
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
ranking := map[string]float64{}
used := map[float64]bool{}
for _, o := range options {
for _, o := range voteOptions {
r, err := strconv.ParseFloat(get(fmt.Sprintf("rank_%d", o.ID)), 64)
if err != nil || r < 1 || r > float64(len(options)) {
return nil, fmt.Errorf(i18n.T(lang, "vote.err.rankRequired"), len(options))
if err != nil || r < 1 || r > float64(len(voteOptions)) {
return nil, fmt.Errorf(i18n.T(lang, "vote.err.rankRequired"), len(voteOptions))
}
if used[r] {
return nil, errors.New(i18n.T(lang, "vote.err.rankDuplicate"))
@@ -870,7 +863,10 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
formData["ranking"] = ranking
case db.VoteTypeForm:
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
fields, err := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
if err != nil {
return nil, errors.Wrap(err, "load vote form fields")
}
for _, f := range fields {
// 字段选项集合
validFieldOptions := map[string]bool{}
+7
View File
@@ -21,6 +21,13 @@ type mockVoteSubmissions struct {
func (m *mockVoteSubmissions) ListByVoteID(ctx stdctx.Context, voteID uint, cursor *dbutil.Cursor) ([]*db.VoteSubmission, error) {
return m.submissions, nil
}
func (m *mockVoteSubmissions) ListByVoteIDs(ctx stdctx.Context, voteIDs []uint) (map[uint][]*db.VoteSubmission, error) {
out := make(map[uint][]*db.VoteSubmission)
for _, s := range m.submissions {
out[s.VoteID] = append(out[s.VoteID], s)
}
return out, nil
}
func (m *mockVoteSubmissions) Create(ctx stdctx.Context, opts db.CreateVoteSubmissionOptions) (*db.VoteSubmission, error) {
return nil, nil
}