Files
tamabox/internal/db/votes.go
T
tamakyi d5d25c804b fix(data): 删除改成一个事务且真删、配额与内容同事务
四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。

1) 删除投票的级联没有事务(route/user/vote.go)
   选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
   中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
   投票已不存在,只能报 not exist,脏数据永远留着。
   → db.Votes.DeleteWithCascade:四张表在同一事务里删完。

2) 删除其实是软删除,个人数据没真删掉
   模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
   提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
   只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
   软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
   → 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
     原来也是三个独立写入(失败只 Warn),一并收进事务。

3) 配额计数会永久偏低
   「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
   箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
   等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
   → 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
     提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
     VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
   注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
   不是「创建了几个投票」—— 所以删除投票不回退这个计数。

4) 改写追问回答每次都发通知邮件
   箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
   好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
   的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。

顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
  与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
  而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
  拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
  数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。

验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
2026-10-29 17:18:28 +08:00

424 lines
16 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
// DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。
DeleteWithCascade(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
}
// DeleteWithCascade 删除投票及其全部关联数据(选项、表单字段、提交记录)。
//
// 为什么必须是一个事务:原来 handler 里是 4 次独立写入,前 3 次失败只打 Warn,
// 于是可能留下「选项没了但票数还在」(结果页统计全 0 而提交记录占着库)、
// 「投票没了但提交记录成孤儿」这类无法通过界面修复的状态 —— 箱主只能重试删除,
// 而重试时 vote 已不存在,直接报 not exist,脏数据永远留着。
//
// 为什么是 Unscoped(真删)而不是软删:
// 1. 用户点「删除」的预期就是删掉,不是「我还能看到但数据库里还在」;
// 2. 提交记录里有参与者填写的文本、voter_ip、voter_token —— 软删等于把这些
// 个人数据永久留在库里,而界面上已经看不到了,等于删了但没删干净;
// 3. 软删还会占住 link_token 唯一索引,删过的链接永远无法复用。
func (db *votes) DeleteWithCascade(ctx context.Context, id uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var vote Vote
if err := tx.First(&vote, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrVoteNotExist
}
return errors.Wrap(err, "get vote by ID")
}
// 顺序:先清子表再删主表。没有外键约束(项目不用 FK),
// 所以顺序只影响可读性;真正的保证来自事务本身。
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions")
}
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options")
}
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields")
}
if err := tx.Unscoped().Delete(&Vote{}, id).Error; err != nil {
return errors.Wrap(err, "delete vote")
}
// 注意:这里**不**回退 quota_count。投票的定量收集口径是「累计收到的票数」
// (见 route/vote/vote.go 中提交成功后 +1),不是「创建了几个投票」——
// 删掉一个投票并不意味着那些票没发生过。箱主要腾额度靠界面上的「重置计数」。
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)
}