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

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
+142
View File
@@ -61,6 +61,9 @@ const (
type VotesStore interface {
Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error)
// CreateWithOptions 在单个事务里创建投票 + 选项 + 表单字段。
// 任一步失败整体回滚,避免留下「有投票无选项」或「有选项无字段」的半成品。
CreateWithOptions(ctx context.Context, opts CreateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) (*Vote, error)
GetByID(ctx context.Context, id uint) (*Vote, error)
GetByToken(ctx context.Context, token string) (*Vote, error)
ListByUserID(ctx context.Context, userID uint, opts ListVotesOptions) ([]*Vote, error)
@@ -68,6 +71,9 @@ type VotesStore interface {
ListPublishedPublic(ctx context.Context, limit int) ([]*Vote, error)
GetHot(ctx context.Context, limit int) ([]*Vote, error)
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
// UpdateWithOptions 在单个事务里更新投票元信息 + 替换选项 + 替换表单字段。
// 仅在草稿状态调用(发布后选项/字段锁定,避免破坏历史数据归因)。
UpdateWithOptions(ctx context.Context, id uint, opts UpdateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) error
UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(ctx context.Context, id uint) error
// DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。
@@ -160,6 +166,69 @@ func (db *votes) Create(ctx context.Context, opts CreateVoteOptions) (*Vote, err
return vote, nil
}
// CreateWithOptions 在单个事务里创建投票 + 选项 + 表单字段。
// 任一步失败整体回滚,避免留下「有投票无选项」或「有选项无字段」的半成品。
func (db *votes) CreateWithOptions(ctx context.Context, opts CreateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) (*Vote, error) {
var vote *Vote
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
v := &Vote{
UserID: opts.UserID,
Title: opts.Title,
Description: opts.Description,
Type: opts.Type,
Visibility: opts.Visibility,
VoterScope: opts.VoterScope,
ShowResult: opts.ShowResult,
AllowCustom: opts.AllowCustom,
MaxChoices: opts.MaxChoices,
StartAt: opts.StartAt,
EndAt: opts.EndAt,
MaxVotesPerFingerprint: opts.MaxVotesPerFingerprint,
RequireCaptcha: opts.RequireCaptcha,
CensorMetadata: opts.CensorMetadata,
QuotaLimit: opts.QuotaLimit,
}
if err := tx.Create(v).Error; err != nil {
return errors.Wrap(err, "create vote")
}
vote = v
if len(optionContents) > 0 {
options := make([]*VoteOption, 0, len(optionContents))
for i, content := range optionContents {
options = append(options, &VoteOption{VoteID: v.ID, Content: content, SortOrder: i})
}
if err := tx.Create(&options).Error; err != nil {
return errors.Wrap(err, "create vote options")
}
}
if len(formFields) > 0 {
fields := make([]*VoteFormField, 0, len(formFields))
for _, f := range formFields {
fields = append(fields, &VoteFormField{
VoteID: v.ID,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
if err := tx.Create(&fields).Error; err != nil {
return errors.Wrap(err, "create vote form fields")
}
}
return nil
})
if err != nil {
return nil, err
}
return vote, nil
}
var ErrVoteNotExist = errors.New("投票不存在")
func (db *votes) GetByID(ctx context.Context, id uint) (*Vote, error) {
@@ -335,6 +404,79 @@ func (db *votes) Update(ctx context.Context, id uint, opts UpdateVoteOptions) er
return nil
}
// UpdateWithOptions 在单个事务里更新投票元信息 + 替换选项 + 替换表单字段。
// 仅在草稿状态调用(发布后选项/字段锁定,避免破坏历史数据归因)。
// 任一步失败整体回滚,避免「元信息已更新、字段只更新一部分」的不一致草稿。
func (db *votes) UpdateWithOptions(ctx context.Context, id uint, opts UpdateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updates := map[string]interface{}{
"title": opts.Title,
"description": opts.Description,
"visibility": opts.Visibility,
"voter_scope": opts.VoterScope,
"show_result": opts.ShowResult,
"allow_custom": opts.AllowCustom,
"start_at": opts.StartAt,
"end_at": opts.EndAt,
"max_votes_per_fingerprint": opts.MaxVotesPerFingerprint,
"require_captcha": opts.RequireCaptcha,
"censor_metadata": opts.CensorMetadata,
"quota_limit": opts.QuotaLimit,
}
if opts.Type != nil {
updates["type"] = *opts.Type
}
if opts.MaxChoices != nil {
updates["max_choices"] = *opts.MaxChoices
}
res := tx.Model(&Vote{}).Where("id = ?", id).Updates(updates)
if res.Error != nil {
return errors.Wrap(res.Error, "update vote")
}
if res.RowsAffected == 0 {
return ErrVoteNotExist
}
// 替换选项(先删后建)
if err := tx.Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete old vote options")
}
if len(optionContents) > 0 {
options := make([]*VoteOption, 0, len(optionContents))
for i, content := range optionContents {
options = append(options, &VoteOption{VoteID: id, Content: content, SortOrder: i})
}
if err := tx.Create(&options).Error; err != nil {
return errors.Wrap(err, "create vote options")
}
}
// 替换表单字段(先删后建)
if err := tx.Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete old vote form fields")
}
if len(formFields) > 0 {
fields := make([]*VoteFormField, 0, len(formFields))
for _, f := range formFields {
fields = append(fields, &VoteFormField{
VoteID: id,
FieldType: f.FieldType,
Label: f.Label,
Required: f.Required,
MaxLength: f.MaxLength,
Options: f.Options,
SortOrder: f.SortOrder,
AllowCustom: f.AllowCustom,
})
}
if err := tx.Create(&fields).Error; err != nil {
return errors.Wrap(err, "create vote form fields")
}
}
return nil
})
}
// UpdateStatus 更新投票状态(draft → published → ended → archived)。
func (db *votes) UpdateStatus(ctx context.Context, id uint, status string) error {
res := db.WithContext(ctx).Model(&Vote{}).Where("id = ?", id).Update("status", status)