chore: baseline snapshot before moderation-strictness feature

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.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
// 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
}