378 lines
13 KiB
Go
378 lines
13 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)
|
||
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)
|
||
}
|