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

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
+49
View File
@@ -15,6 +15,7 @@ import (
"github.com/pkg/errors"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
@@ -25,6 +26,8 @@ type VoteSubmissionsStore interface {
Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error)
GetByID(ctx context.Context, id uint) (*VoteSubmission, error)
ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error)
// ListByVoteIDs 批量查询多个投票的全部提交记录(IN 查询 + 内存分组),避免逐投票 N+1。
ListByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint][]*VoteSubmission, error)
CountByVoteID(ctx context.Context, voteID uint) (int64, error)
CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error)
CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error)
@@ -77,6 +80,7 @@ var (
// ErrVoteAlreadyVoted 同一投票被同一身份重复提交(数据库唯一索引兜底返回)。
ErrVoteAlreadyVoted = errors.New("你已参与过该投票")
ErrVoteSubmissionNotExist = errors.New("投票记录不存在")
ErrVoteNotAccepting = errors.New("投票未开放或已达到收集上限")
)
// Create 写入一条投票/提交记录。若命中复合唯一索引(同一 user 或同一 token 重复提交),
@@ -92,6 +96,26 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
CustomText: opts.CustomText,
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if opts.QuotaUserID != 0 {
if err := reserveFeatureQuotaTx(tx, opts.QuotaUserID, FeatureVote); err != nil {
return err
}
}
var vote Vote
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&vote, opts.VoteID).Error; err != nil {
return errors.Wrap(err, "lock vote")
}
now := time.Now()
if vote.Status != VoteStatusPublished || (vote.StartAt != nil && now.Before(*vote.StartAt)) || (vote.EndAt != nil && !now.Before(*vote.EndAt)) {
return ErrVoteNotAccepting
}
var count int64
if err := tx.Model(&VoteSubmission{}).Where("vote_id = ?", opts.VoteID).Count(&count).Error; err != nil {
return errors.Wrap(err, "count vote submissions")
}
if vote.QuotaLimit > 0 && count >= int64(vote.QuotaLimit) {
return ErrVoteNotAccepting
}
if err := tx.Create(submission).Error; err != nil {
if isDuplicateKeyError(err) {
return ErrVoteAlreadyVoted
@@ -107,6 +131,11 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
return errors.Wrap(err, "increment vote quota")
}
}
if vote.QuotaLimit > 0 && count+1 >= int64(vote.QuotaLimit) {
if err := tx.Model(&Vote{}).Where("id = ?", vote.ID).Update("status", VoteStatusEnded).Error; err != nil {
return errors.Wrap(err, "end vote on quota")
}
}
return nil
})
if err != nil {
@@ -148,6 +177,26 @@ func (db *voteSubmissions) ListByVoteID(ctx context.Context, voteID uint, cursor
return submissions, nil
}
// ListByVoteIDs 批量查询多个投票的全部提交记录(IN 查询 + 内存分组),避免逐投票 N+1。
// 返回 map[voteID][]*VoteSubmission;未出现在结果中的 voteID 视为无提交。
func (db *voteSubmissions) ListByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint][]*VoteSubmission, error) {
out := make(map[uint][]*VoteSubmission, len(voteIDs))
if len(voteIDs) == 0 {
return out, nil
}
var submissions []*VoteSubmission
if err := db.WithContext(ctx).
Where("vote_id IN ?", voteIDs).
Order("created_at ASC").
Find(&submissions).Error; err != nil {
return nil, errors.Wrap(err, "list vote submissions by vote ids")
}
for _, s := range submissions {
out[s.VoteID] = append(out[s.VoteID], s)
}
return out, nil
}
// CountByVoteID 统计某投票的总参与人数。
func (db *voteSubmissions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) {
var count int64