Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
257 lines
9.8 KiB
Go
257 lines
9.8 KiB
Go
// 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
|
|
}
|