// 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 db import ( "context" "strconv" "strings" "time" "github.com/go-sql-driver/mysql" "github.com/jackc/pgconn" "github.com/pkg/errors" "gorm.io/datatypes" "gorm.io/gorm" "github.com/tamakyi/TamaBox/internal/dbutil" ) var VoteSubmissions VoteSubmissionsStore 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) 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) CountGroupByOptionID(ctx context.Context, voteID uint) (map[uint]int64, error) CountByIP(ctx context.Context, voteID uint, ip string, since time.Time) (int64, error) HasUserVoted(ctx context.Context, voteID, userID uint) (bool, error) HasTokenVoted(ctx context.Context, voteID uint, token string) (bool, error) DeleteByVoteID(ctx context.Context, voteID uint) error } func NewVoteSubmissionsStore(db *gorm.DB) VoteSubmissionsStore { return &voteSubmissions{db} } type voteSubmissions struct { *gorm.DB } // VoteSubmission 投票/提交记录(不记名:IP / token / user_id 仅用于防刷与审计,任何界面不展示)。 // // 防一人一票的数据库硬保证(复合唯一索引 + NULL 不冲突,规避 MySQL 5.7 部分索引兼容问题): // - UNIQUE(vote_id, user_id):登录用户一个账号对同一投票只能投一次(匿名行 user_id 为 NULL 不冲突) // - UNIQUE(vote_id, voter_token):同一匿名指纹(服务端签发 Cookie)只能投一次(登录行 voter_token 为 NULL 不冲突) type VoteSubmission struct { dbutil.Model VoteID uint `gorm:"index:idx_submission_vote;uniqueIndex:idx_submission_user;uniqueIndex:idx_submission_token" json:"vote_id"` OptionID uint `gorm:"index:idx_submission_option" json:"option_id"` UserID *uint `gorm:"uniqueIndex:idx_submission_user" json:"-"` // HMAC token = 64-char hex nonce + dot + 43-char base64url signature (108 chars). VoterToken *string `gorm:"size:128;uniqueIndex:idx_submission_token" json:"-"` VoterIP string `gorm:"size:64;index:idx_submission_ip" json:"-"` FormData datatypes.JSON `json:"form_data"` CustomText string `gorm:"type:text" json:"custom_text"` } type CreateVoteSubmissionOptions struct { VoteID uint OptionID uint UserID *uint VoterToken *string VoterIP string FormData datatypes.JSON CustomText string } var ( // ErrVoteAlreadyVoted 同一投票被同一身份重复提交(数据库唯一索引兜底返回)。 ErrVoteAlreadyVoted = errors.New("你已参与过该投票") ErrVoteSubmissionNotExist = errors.New("投票记录不存在") ) // Create 写入一条投票/提交记录。若命中复合唯一索引(同一 user 或同一 token 重复提交), // 返回 ErrVoteAlreadyVoted。 func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error) { submission := &VoteSubmission{ VoteID: opts.VoteID, OptionID: opts.OptionID, UserID: opts.UserID, VoterToken: opts.VoterToken, VoterIP: opts.VoterIP, FormData: opts.FormData, CustomText: opts.CustomText, } if err := db.WithContext(ctx).Create(submission).Error; err != nil { if isDuplicateKeyError(err) { return nil, ErrVoteAlreadyVoted } return nil, errors.Wrap(err, "create vote submission") } return submission, nil } func (db *voteSubmissions) GetByID(ctx context.Context, id uint) (*VoteSubmission, error) { var submission VoteSubmission if err := db.WithContext(ctx).First(&submission, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrVoteSubmissionNotExist } return nil, errors.Wrap(err, "get vote submission by ID") } return &submission, nil } // ListByVoteID 分页获取某投票的提交记录(箱主查看表单回复用,新提交在前)。 func (db *voteSubmissions) ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error) { q := db.WithContext(ctx).Model(&VoteSubmission{}).Where("vote_id = ?", voteID) if cursor != nil && cursor.Value != nil { // 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。 cursorStr := strings.TrimSpace(cursorValueToString(cursor.Value)) if idVal, err := strconv.ParseUint(cursorStr, 10, 64); err == nil && idVal > 0 { q = q.Where("id < ?", idVal) } } if cursor != nil { q = q.Limit(cursor.Limit()) } q = q.Order("created_at DESC") var submissions []*VoteSubmission if err := q.Find(&submissions).Error; err != nil { return nil, errors.Wrap(err, "list vote submissions by vote id") } return submissions, nil } // CountByVoteID 统计某投票的总参与人数。 func (db *voteSubmissions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) { var count int64 if err := db.WithContext(ctx).Model(&VoteSubmission{}).Where("vote_id = ?", voteID).Count(&count).Error; err != nil { return 0, errors.Wrap(err, "count vote submissions by vote id") } return count, nil } // CountByVoteIDs 一次查询批量统计多个投票的参与人数(替代逐投票 N 次 COUNT,消除 N+1)。 // 返回 map[voteID]count;未出现在结果中的 voteID 视为 0 参与。 func (db *voteSubmissions) CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error) { out := make(map[uint]int64, len(voteIDs)) if len(voteIDs) == 0 { return out, nil } type row struct { VoteID uint Cnt int64 } var rows []row if err := db.WithContext(ctx).Model(&VoteSubmission{}). Select("vote_id, COUNT(id) AS cnt"). Where("vote_id IN ?", voteIDs). Group("vote_id"). Find(&rows).Error; err != nil { return nil, errors.Wrap(err, "count vote submissions by vote ids") } for _, r := range rows { out[r.VoteID] = r.Cnt } return out, nil } // CountByOptionID 统计某选项的票数(结果页条形图数据源)。 func (db *voteSubmissions) CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error) { var count int64 if err := db.WithContext(ctx).Model(&VoteSubmission{}). Where("vote_id = ? AND option_id = ?", voteID, optionID). Count(&count).Error; err != nil { return 0, errors.Wrap(err, "count vote submissions by option id") } return count, nil } // CountGroupByOptionID 一次查询统计某投票下各选项的票数(替代逐选项 N 次 COUNT)。 func (db *voteSubmissions) CountGroupByOptionID(ctx context.Context, voteID uint) (map[uint]int64, error) { type row struct { OptionID uint Cnt int64 } var rows []row if err := db.WithContext(ctx).Model(&VoteSubmission{}). Select("option_id, COUNT(id) AS cnt"). Where("vote_id = ?", voteID). Group("option_id"). Find(&rows).Error; err != nil { return nil, errors.Wrap(err, "count vote submissions group by option id") } out := make(map[uint]int64, len(rows)) for _, r := range rows { out[r.OptionID] = r.Cnt } return out, nil } // CountByIP 统计同一 IP 在 since 时间窗内的提交次数(匿名防刷兜底:清 Cookie 重投时按 IP 计数)。 func (db *voteSubmissions) CountByIP(ctx context.Context, voteID uint, ip string, since time.Time) (int64, error) { var count int64 if err := db.WithContext(ctx).Model(&VoteSubmission{}). Where("vote_id = ? AND voter_ip = ? AND created_at >= ?", voteID, ip, since). Count(&count).Error; err != nil { return 0, errors.Wrap(err, "count vote submissions by ip") } return count, nil } // HasUserVoted 判断登录用户是否已投过该投票(应用层预检查,返回友好提示;最终防重由唯一索引兜底)。 func (db *voteSubmissions) HasUserVoted(ctx context.Context, voteID, userID uint) (bool, error) { var count int64 if err := db.WithContext(ctx).Model(&VoteSubmission{}). Where("vote_id = ? AND user_id = ?", voteID, userID). Count(&count).Error; err != nil { return false, errors.Wrap(err, "check user voted") } return count > 0, nil } // HasTokenVoted 判断匿名指纹是否已投过该投票。 func (db *voteSubmissions) HasTokenVoted(ctx context.Context, voteID uint, token string) (bool, error) { var count int64 if err := db.WithContext(ctx).Model(&VoteSubmission{}). Where("vote_id = ? AND voter_token = ?", voteID, token). Count(&count).Error; err != nil { return false, errors.Wrap(err, "check token voted") } return count > 0, nil } // DeleteByVoteID 删除投票时级联清理全部提交记录。 // // 使用 Unscoped 物理删除而非软删除:VoteSubmission 上的复合唯一索引 // (vote_id, user_id)/(vote_id, voter_token) 不含 deleted_at,软删除会让被删行继续占据 // 唯一槽位,导致"统计归零却无法重投"。级联删除发生在整个投票被删时,物理删除安全且正确。 func (db *voteSubmissions) DeleteByVoteID(ctx context.Context, voteID uint) error { if err := db.WithContext(ctx).Unscoped().Where("vote_id = ?", voteID).Delete(&VoteSubmission{}).Error; err != nil { return errors.Wrap(err, "delete vote submissions by vote id") } return nil } // isDuplicateKeyError 判断数据库唯一索引冲突错误(MySQL 1062 / PostgreSQL 23505)。 // 不依赖 GORM 的 TranslateError 配置(全局未开启),手动映射以保证语义化错误。 func isDuplicateKeyError(err error) bool { var mysqlErr *mysql.MySQLError if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { return true } var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return true } return false }