566 lines
21 KiB
Go
566 lines
21 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"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/pkg/errors"
|
||
"github.com/thanhpk/randstr"
|
||
"gorm.io/datatypes"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||
)
|
||
|
||
var Votes VotesStore
|
||
|
||
// 投票状态
|
||
const (
|
||
VoteStatusDraft = "draft" // 草稿(未发布,不可访问)
|
||
VoteStatusPublished = "published" // 进行中
|
||
VoteStatusEnded = "ended" // 已结束
|
||
VoteStatusArchived = "archived" // 归档
|
||
// VoteStatusNotStarted 仅作为管理列表的"有效展示状态"(published + 未到开始时间),不入库。
|
||
VoteStatusNotStarted = "not_started"
|
||
)
|
||
|
||
// 投票类型(收集方式)
|
||
const (
|
||
VoteTypeSingle = "single" // 单选
|
||
VoteTypeMultiple = "multiple" // 多选
|
||
VoteTypeRating = "rating" // 评分
|
||
VoteTypeRank = "rank" // 排序
|
||
VoteTypeForm = "form" // 结构化表单
|
||
)
|
||
|
||
// 展示可见性
|
||
const (
|
||
VoteVisibilityPublic = "public" // 探索页 + 提问箱页公开展示
|
||
VoteVisibilityLinkOnly = "link_only" // 仅通过链接分享
|
||
)
|
||
|
||
// 投票范围
|
||
const (
|
||
VoteVoterScopeAnyone = "anyone" // 完全匿名可投
|
||
VoteVoterScopeRegisteredOnly = "registered_only" // 仅注册用户可投
|
||
)
|
||
|
||
// 结果可见策略
|
||
const (
|
||
VoteShowResultAfterVote = "after_vote" // 投票后立即可见
|
||
VoteShowResultAfterEnd = "after_end" // 结束后可见
|
||
VoteShowResultOwnerOnly = "owner_only" // 仅主人可见
|
||
)
|
||
|
||
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)
|
||
ListPublishedPublicByUserID(ctx context.Context, userID uint, limit int) ([]*Vote, error)
|
||
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 在一个事务里真删投票及其选项、表单字段、提交记录。
|
||
DeleteWithCascade(ctx context.Context, id uint) error
|
||
CountByUserID(ctx context.Context, userID uint) (int64, error)
|
||
}
|
||
|
||
func NewVotesStore(db *gorm.DB) VotesStore {
|
||
return &votes{db}
|
||
}
|
||
|
||
type votes struct {
|
||
*gorm.DB
|
||
}
|
||
|
||
type Vote struct {
|
||
dbutil.Model
|
||
UserID uint `gorm:"index:idx_vote_user" json:"-"`
|
||
Title string `gorm:"size:100" json:"title"`
|
||
Description string `gorm:"type:text" json:"description"`
|
||
Type string `gorm:"size:20;default:single" json:"type"`
|
||
Status string `gorm:"size:20;default:draft;index" json:"status"`
|
||
Visibility string `gorm:"size:20;default:public" json:"visibility"`
|
||
VoterScope string `gorm:"size:20;default:anyone" json:"voter_scope"`
|
||
ShowResult string `gorm:"size:20;default:after_vote" json:"show_result"`
|
||
AllowCustom bool `gorm:"default:false" json:"allow_custom"`
|
||
MaxChoices int `gorm:"default:1" json:"max_choices"`
|
||
StartAt *time.Time `json:"start_at"`
|
||
EndAt *time.Time `json:"end_at"`
|
||
LinkToken string `gorm:"size:16;uniqueIndex" json:"-"`
|
||
MaxVotesPerFingerprint int `gorm:"default:1" json:"-"`
|
||
// ⚠️ 不要给 RequireCaptcha 加 gorm:"default:true" 标签:GORM 在 Create 时会把零值 false
|
||
// 替换为字段默认值(true),导致「创建时不要求验证码」的配置被静默写成 true。
|
||
// 当前业务硬编码 requireCaptcha=true(决策 #3),去掉标签不影响现状,但消除同类陷阱。
|
||
RequireCaptcha bool `json:"-"`
|
||
CensorMetadata datatypes.JSON `json:"-"`
|
||
// QuotaLimit 定量收集上限:收集到该份数后自动结束投票(0 表示不限制)。
|
||
// 与 EndAt 互补——EndAt 按时间关闭,QuotaLimit 按数量关闭。
|
||
QuotaLimit int `gorm:"default:0" json:"quota_limit"`
|
||
}
|
||
|
||
// BeforeCreate 自动生成链接分享 token(与私信 Conversation.Token 同款模式,
|
||
// 不可枚举,防止通过数字 ID 扫描他人投票)。
|
||
func (v *Vote) BeforeCreate(tx *gorm.DB) error {
|
||
if v.LinkToken == "" {
|
||
v.LinkToken = randstr.String(12)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type CreateVoteOptions struct {
|
||
UserID uint
|
||
Title string
|
||
Description string
|
||
Type string
|
||
Visibility string
|
||
VoterScope string
|
||
ShowResult string
|
||
AllowCustom bool
|
||
MaxChoices int
|
||
StartAt *time.Time
|
||
EndAt *time.Time
|
||
MaxVotesPerFingerprint int
|
||
RequireCaptcha bool
|
||
CensorMetadata datatypes.JSON
|
||
QuotaLimit int
|
||
}
|
||
|
||
func (db *votes) Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error) {
|
||
vote := &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 := db.WithContext(ctx).Create(vote).Error; err != nil {
|
||
return nil, errors.Wrap(err, "create vote")
|
||
}
|
||
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) {
|
||
var vote Vote
|
||
if err := db.WithContext(ctx).First(&vote, id).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, ErrVoteNotExist
|
||
}
|
||
return nil, errors.Wrap(err, "get vote by ID")
|
||
}
|
||
return &vote, nil
|
||
}
|
||
|
||
func (db *votes) GetByToken(ctx context.Context, token string) (*Vote, error) {
|
||
var vote Vote
|
||
if err := db.WithContext(ctx).Where("link_token = ?", token).First(&vote).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, ErrVoteNotExist
|
||
}
|
||
return nil, errors.Wrap(err, "get vote by token")
|
||
}
|
||
return &vote, nil
|
||
}
|
||
|
||
type ListVotesOptions struct {
|
||
*dbutil.Cursor
|
||
Status string // 空 = 全部;否则按状态筛选(draft/published/ended/archived)
|
||
}
|
||
|
||
// ListByUserID 根据箱主 ID 获取投票列表,支持游标分页与状态筛选。
|
||
func (db *votes) ListByUserID(ctx context.Context, userID uint, opts ListVotesOptions) ([]*Vote, error) {
|
||
q := db.WithContext(ctx).Model(&Vote{}).Where("user_id = ?", userID)
|
||
if opts.Status != "" {
|
||
q = q.Where("status = ?", opts.Status)
|
||
}
|
||
if opts.Cursor != nil && opts.Cursor.Value != nil {
|
||
cursorStr := strings.TrimSpace(cursorValueToString(opts.Cursor.Value))
|
||
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
|
||
if idVal, err := strconv.ParseUint(cursorStr, 10, 64); err == nil && idVal > 0 {
|
||
q = q.Where("id < ?", idVal)
|
||
}
|
||
// 解析失败则忽略该游标(从首页重新开始),与提问列表游标行为一致。
|
||
}
|
||
if opts.Cursor != nil {
|
||
q = q.Limit(opts.Cursor.Limit())
|
||
}
|
||
q = q.Order("created_at DESC")
|
||
|
||
var votes []*Vote
|
||
if err := q.Find(&votes).Error; err != nil {
|
||
return nil, errors.Wrap(err, "list votes by user id")
|
||
}
|
||
return votes, nil
|
||
}
|
||
|
||
// ListPublishedPublicByUserID 获取箱主"进行中"的公开投票(提问箱页内嵌板块用):
|
||
// status=published 且 visibility=public,且在开始/结束时间窗内,按创建时间倒序。
|
||
func (db *votes) ListPublishedPublicByUserID(ctx context.Context, userID uint, limit int) ([]*Vote, error) {
|
||
if limit <= 0 {
|
||
limit = 3
|
||
}
|
||
now := time.Now()
|
||
q := db.WithContext(ctx).Model(&Vote{}).
|
||
Where("user_id = ? AND status = ? AND visibility = ?", userID, VoteStatusPublished, VoteVisibilityPublic).
|
||
Where("(start_at IS NULL OR start_at <= ?)", now).
|
||
Where("(end_at IS NULL OR end_at > ?)", now).
|
||
Order("created_at DESC").
|
||
Limit(limit)
|
||
|
||
var votes []*Vote
|
||
if err := q.Find(&votes).Error; err != nil {
|
||
return nil, errors.Wrap(err, "list published public votes by user id")
|
||
}
|
||
return votes, nil
|
||
}
|
||
|
||
// ListPublishedPublic 返回探索页可展示的公开投票,按创建时间倒序。
|
||
func (db *votes) ListPublishedPublic(ctx context.Context, limit int) ([]*Vote, error) {
|
||
if limit <= 0 {
|
||
limit = 20
|
||
}
|
||
now := time.Now()
|
||
var votes []*Vote
|
||
err := db.WithContext(ctx).Model(&Vote{}).
|
||
Where("status = ? AND visibility = ?", VoteStatusPublished, VoteVisibilityPublic).
|
||
Where("(start_at IS NULL OR start_at <= ?)", now).
|
||
Where("(end_at IS NULL OR end_at > ?)", now).
|
||
Order("created_at DESC").
|
||
Limit(limit).Find(&votes).Error
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "list published public votes")
|
||
}
|
||
return votes, nil
|
||
}
|
||
|
||
// GetHot 返回探索页"热门投票":进行中的公开投票,按参与人数倒序。
|
||
// 参与数通过子查询聚合(跨 MySQL/PostgreSQL 兼容,规避 GROUP BY 全列要求)。
|
||
func (db *votes) GetHot(ctx context.Context, limit int) ([]*Vote, error) {
|
||
if limit <= 0 {
|
||
limit = 6
|
||
}
|
||
now := time.Now()
|
||
sub := db.WithContext(ctx).Model(&VoteSubmission{}).
|
||
Select("vote_id, COUNT(id) AS cnt").
|
||
Where("deleted_at IS NULL").
|
||
Group("vote_id")
|
||
|
||
q := db.WithContext(ctx).Model(&Vote{}).
|
||
Select("votes.*").
|
||
Joins("LEFT JOIN (?) AS vote_stat ON vote_stat.vote_id = votes.id", sub).
|
||
Where("votes.status = ? AND votes.visibility = ?", VoteStatusPublished, VoteVisibilityPublic).
|
||
Where("(votes.start_at IS NULL OR votes.start_at <= ?)", now).
|
||
Where("(votes.end_at IS NULL OR votes.end_at > ?)", now).
|
||
Order("COALESCE(vote_stat.cnt, 0) DESC, votes.created_at DESC").
|
||
Limit(limit)
|
||
|
||
var votes []*Vote
|
||
if err := q.Find(&votes).Error; err != nil {
|
||
return nil, errors.Wrap(err, "get hot votes")
|
||
}
|
||
return votes, nil
|
||
}
|
||
|
||
type UpdateVoteOptions struct {
|
||
Title string
|
||
Description string
|
||
Type *string
|
||
Visibility string
|
||
VoterScope string
|
||
ShowResult string
|
||
AllowCustom bool
|
||
MaxChoices *int
|
||
StartAt *time.Time
|
||
EndAt *time.Time
|
||
MaxVotesPerFingerprint int
|
||
RequireCaptcha bool
|
||
CensorMetadata datatypes.JSON
|
||
QuotaLimit int
|
||
}
|
||
|
||
// Update 更新投票的可编辑字段(不含 Status,状态流转走 UpdateStatus)。
|
||
func (db *votes) Update(ctx context.Context, id uint, opts UpdateVoteOptions) 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,
|
||
}
|
||
// Type / MaxChoices 用指针承载:nil 表示「不修改」,db 层跳过写入,避免调用方漏传时
|
||
// 被 map 更新写成零值(曾导致发布后编辑把 type 写成 "" 的同类 bug)。草稿阶段显式传 &值
|
||
// 以设置;已发布编辑传 nil 以保留原值。
|
||
if opts.Type != nil {
|
||
updates["type"] = *opts.Type
|
||
}
|
||
if opts.MaxChoices != nil {
|
||
updates["max_choices"] = *opts.MaxChoices
|
||
}
|
||
res := db.WithContext(ctx).Model(&Vote{}).Where("id = ?", id).Updates(updates)
|
||
if res.Error != nil {
|
||
return errors.Wrap(res.Error, "update vote")
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return ErrVoteNotExist
|
||
}
|
||
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)
|
||
if res.Error != nil {
|
||
return errors.Wrap(res.Error, "update vote status")
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return ErrVoteNotExist
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (db *votes) DeleteByID(ctx context.Context, id uint) error {
|
||
var vote Vote
|
||
if err := db.WithContext(ctx).First(&vote, id).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return ErrVoteNotExist
|
||
}
|
||
return errors.Wrap(err, "get vote by ID")
|
||
}
|
||
if err := db.WithContext(ctx).Delete(&Vote{}, id).Error; err != nil {
|
||
return errors.Wrap(err, "delete vote")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteWithCascade 删除投票及其全部关联数据(选项、表单字段、提交记录)。
|
||
//
|
||
// 为什么必须是一个事务:原来 handler 里是 4 次独立写入,前 3 次失败只打 Warn,
|
||
// 于是可能留下「选项没了但票数还在」(结果页统计全 0 而提交记录占着库)、
|
||
// 「投票没了但提交记录成孤儿」这类无法通过界面修复的状态 —— 箱主只能重试删除,
|
||
// 而重试时 vote 已不存在,直接报 not exist,脏数据永远留着。
|
||
//
|
||
// 为什么是 Unscoped(真删)而不是软删:
|
||
// 1. 用户点「删除」的预期就是删掉,不是「我还能看到但数据库里还在」;
|
||
// 2. 提交记录里有参与者填写的文本、voter_ip、voter_token —— 软删等于把这些
|
||
// 个人数据永久留在库里,而界面上已经看不到了,等于删了但没删干净;
|
||
// 3. 软删还会占住 link_token 唯一索引,删过的链接永远无法复用。
|
||
func (db *votes) DeleteWithCascade(ctx context.Context, id uint) error {
|
||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var vote Vote
|
||
if err := tx.First(&vote, id).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return ErrVoteNotExist
|
||
}
|
||
return errors.Wrap(err, "get vote by ID")
|
||
}
|
||
|
||
// 顺序:先清子表再删主表。没有外键约束(项目不用 FK),
|
||
// 所以顺序只影响可读性;真正的保证来自事务本身。
|
||
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteSubmission{}).Error; err != nil {
|
||
return errors.Wrap(err, "delete vote submissions")
|
||
}
|
||
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil {
|
||
return errors.Wrap(err, "delete vote options")
|
||
}
|
||
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil {
|
||
return errors.Wrap(err, "delete vote form fields")
|
||
}
|
||
if err := tx.Unscoped().Delete(&Vote{}, id).Error; err != nil {
|
||
return errors.Wrap(err, "delete vote")
|
||
}
|
||
|
||
// 注意:这里**不**回退 quota_count。投票的定量收集口径是「累计收到的票数」
|
||
// (见 route/vote/vote.go 中提交成功后 +1),不是「创建了几个投票」——
|
||
// 删掉一个投票并不意味着那些票没发生过。箱主要腾额度靠界面上的「重置计数」。
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// CountByUserID 统计箱主的投票总数(全部状态)。
|
||
func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) {
|
||
var count int64
|
||
if err := db.WithContext(ctx).Model(&Vote{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
|
||
return 0, errors.Wrap(err, "count votes by user id")
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
// cursorValueToString 将游标值转为字符串,兼容传入 uint / string。
|
||
func cursorValueToString(v interface{}) string {
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
return fmt.Sprintf("%v", v)
|
||
}
|