This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+377
View File
@@ -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)
}