// 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) }