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

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
+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
}