Files
tamabox/internal/db/votes.go
T
2026-09-08 10:10:44 +08:00

378 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
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
UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(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
}
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
}
// 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
}
// 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)
}