0904
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user