From 25aee35030d5afc923995d74195e1aebf6b4f81c Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 5 Nov 2026 12:17:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=B8=8E=E5=8F=AF=E9=9D=A0?= =?UTF-8?q?=E6=80=A7:=20=E4=BF=AE=E5=A4=8D=E9=85=8D=E9=A2=9D=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E3=80=81=E6=8A=95=E7=A5=A8=E4=BA=8B=E5=8A=A1=E3=80=81?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E6=8E=88=E6=9D=83=E4=B8=8E=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/db/conversation.go | 99 ++++++++++++ internal/db/feature_control.go | 17 ++ internal/db/questions.go | 3 + internal/db/quota_concurrency_test.go | 163 +++++++++++++++++++ internal/db/users.go | 17 +- internal/db/users_test.go | 1 + internal/db/vote_submissions.go | 49 ++++++ internal/db/vote_submissions_test.go | 179 ++++++++++++-------- internal/db/votes.go | 142 ++++++++++++++++ internal/route/chat/chat_handler.go | 58 ++----- route/auth/recover.go | 52 ++++-- route/question/ask_history.go | 24 +++ route/question/page.go | 25 ++- route/user/profile.go | 225 +++++++++++++++++++++++++- route/user/vote.go | 63 +++----- route/vote/results.go | 36 ++++- route/vote/vote.go | 36 ++--- route/vote/vote_stats_test.go | 7 + 18 files changed, 1002 insertions(+), 194 deletions(-) create mode 100644 internal/db/quota_concurrency_test.go diff --git a/internal/db/conversation.go b/internal/db/conversation.go index 5aba0b4..07fa41e 100644 --- a/internal/db/conversation.go +++ b/internal/db/conversation.go @@ -6,22 +6,30 @@ package db import ( "context" + "time" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/thanhpk/randstr" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/tamakyi/TamaBox/internal/dbutil" ) var Conversations ConversationStore +var ( + ErrDailyMessageLimit = errors.New("已达到每日私聊上限") + ErrConversationLimit = errors.New("会话已达到消息上限或已关闭") +) + type ConversationStore interface { Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) // CreateWithFirstMessage 事务化地建会话 + 首条消息 + 消息计数。 // 单独写首条消息(用于「发第一条私聊」这类一次性动作),避免失败时留下空会话。 CreateWithFirstMessage(ctx context.Context, opts CreateConversationOptions, senderID uint, content string) (*Conversation, *Message, error) + SendMessage(ctx context.Context, opts SendConversationMessageOptions) (*Message, error) GetByID(ctx context.Context, id uint) (*Conversation, error) GetByToken(ctx context.Context, token string) (*Conversation, error) GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error) @@ -74,6 +82,39 @@ type CreateConversationOptions struct { MaxMessages int } +type SendConversationMessageOptions struct { + ConversationID uint + SenderID uint + Content string + Image *CreateMessageImageOptions +} + +func reserveDailyMessageTx(tx *gorm.DB, ownerID, senderID uint) error { + var sender User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&sender, senderID).Error; err != nil { + return errors.Wrap(err, "lock message sender") + } + var owner User + if err := tx.Select("daily_message_limit").First(&owner, ownerID).Error; err != nil { + return errors.Wrap(err, "get owner daily message limit") + } + if owner.DailyMessageLimit <= 0 { + return nil + } + now := time.Now() + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + var count int64 + if err := tx.Table("messages").Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("conversations.owner_id = ? AND messages.sender_id = ? AND messages.created_at >= ?", ownerID, senderID, start). + Count(&count).Error; err != nil { + return errors.Wrap(err, "count today messages") + } + if count >= int64(owner.DailyMessageLimit) { + return ErrDailyMessageLimit + } + return nil +} + func (db *conversations) Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) { conversation := &Conversation{ OwnerID: opts.OwnerID, @@ -99,6 +140,11 @@ func (db *conversations) CreateWithFirstMessage( var msg *Message err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if senderID == opts.AskerID { + if err := reserveDailyMessageTx(tx, opts.OwnerID, senderID); err != nil { + return err + } + } conv = &Conversation{ OwnerID: opts.OwnerID, AskerID: opts.AskerID, @@ -139,6 +185,59 @@ func (db *conversations) CreateWithFirstMessage( return conv, msg, nil } +func (db *conversations) SendMessage(ctx context.Context, opts SendConversationMessageOptions) (*Message, error) { + msg := &Message{ConversationID: opts.ConversationID, SenderID: opts.SenderID, Content: opts.Content} + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 先锁发送者,再锁会话,保证与 CreateWithFirstMessage 的锁顺序一致, + // 避免并发下两条路径互相等待造成配额检查窗口。 + var sender User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&sender, opts.SenderID).Error; err != nil { + return errors.Wrap(err, "lock message sender") + } + + var conv Conversation + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conv, opts.ConversationID).Error; err != nil { + return errors.Wrap(err, "lock conversation") + } + + // 发送者必须是会话双方之一 + if conv.OwnerID != opts.SenderID && conv.AskerID != opts.SenderID { + return errors.New("sender is not part of the conversation") + } + + // 只有 asker 发送才消耗 owner 的每日配额 + if conv.AskerID == opts.SenderID { + if err := reserveDailyMessageTx(tx, conv.OwnerID, opts.SenderID); err != nil { + return err + } + } + + res := tx.Model(&Conversation{}).Where("id = ? AND message_count < max_messages AND is_closed = ?", conv.ID, false). + UpdateColumn("message_count", gorm.Expr("message_count + 1")) + if res.Error != nil { + return errors.Wrap(res.Error, "increment message count") + } + if res.RowsAffected == 0 { + return ErrConversationLimit + } + if err := tx.Create(msg).Error; err != nil { + return errors.Wrap(err, "create message") + } + if opts.Image != nil { + image := *opts.Image + image.MessageID = msg.ID + if err := tx.Create(&MessageImage{MessageID: image.MessageID, URL: image.URL, ObjectKey: image.ObjectKey, FileName: image.FileName, FileSize: image.FileSize}).Error; err != nil { + return errors.Wrap(err, "create message image") + } + } + return nil + }) + if err != nil { + return nil, err + } + return msg, nil +} + func (db *conversations) GetByID(ctx context.Context, id uint) (*Conversation, error) { var conv Conversation if err := db.WithContext(ctx).First(&conv, id).Error; err != nil { diff --git a/internal/db/feature_control.go b/internal/db/feature_control.go index c019100..77a81ba 100644 --- a/internal/db/feature_control.go +++ b/internal/db/feature_control.go @@ -20,6 +20,8 @@ import ( var FeatureControls FeatureControlsStore +var ErrFeatureQuotaReached = errors.New("功能收集配额已满") + var _ FeatureControlsStore = (*featureControls)(nil) // 受控功能标识(feature_controls.feature 取值)。 @@ -517,6 +519,21 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat // 于是计数比真实行数少,箱主的「已收集 7/10」永远偏低,达到上限后仍能继续收, // 而重置计数也校准不回来(重置是把计数清零,与实际行数更脱节)。 // 现在内容 store 把这一步放进自己的事务里,任一步失败整体回滚,计数天然对齐。 +func reserveFeatureQuotaTx(tx *gorm.DB, userID uint, feature string) error { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(DefaultFeatureControl(userID, feature)).Error; err != nil { + return errors.Wrap(err, "initialize feature quota") + } + var fc FeatureControl + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND feature = ?", userID, feature).First(&fc).Error; err != nil { + return errors.Wrap(err, "lock feature quota") + } + if fc.QuotaLimit > 0 && fc.QuotaCount >= fc.QuotaLimit { + return ErrFeatureQuotaReached + } + return nil +} + func incrementQuotaTx(tx *gorm.DB, userID uint, feature string, delta int64) error { if delta == 0 { return nil diff --git a/internal/db/questions.go b/internal/db/questions.go index f2d1d69..2f318af 100644 --- a/internal/db/questions.go +++ b/internal/db/questions.go @@ -128,6 +128,9 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q IsPrivate: opts.IsPrivate, } err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := reserveFeatureQuotaTx(tx, opts.UserID, FeatureQuestion); err != nil { + return err + } if err := tx.Create(&question).Error; err != nil { return errors.Wrap(err, "create question") } diff --git a/internal/db/quota_concurrency_test.go b/internal/db/quota_concurrency_test.go new file mode 100644 index 0000000..0088255 --- /dev/null +++ b/internal/db/quota_concurrency_test.go @@ -0,0 +1,163 @@ +package db + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func createQuotaTestUser(t *testing.T, ctx context.Context, conn *gorm.DB, name string, dailyLimit int) *User { + t.Helper() + user := &User{Name: name, Domain: name, Email: name + "@example.test", Password: "test-only", DailyMessageLimit: dailyLimit} + require.NoError(t, conn.WithContext(ctx).Create(user).Error) + return user +} + +func setFeatureQuota(t *testing.T, ctx context.Context, conn *gorm.DB, userID uint, feature string, limit int64) { + t.Helper() + require.NoError(t, NewFeatureControlsStore(conn).Save(ctx, userID, feature, UpdateFeatureControlOptions{Enabled: true, QuotaLimit: limit})) +} + +func runConcurrently(n int, fn func(int) error) []error { + start := make(chan struct{}) + errs := make([]error, n) + var wg sync.WaitGroup + for i := range n { + wg.Add(1) + go func() { + defer wg.Done() + <-start + errs[i] = fn(i) + }() + } + close(start) + wg.Wait() + return errs +} + +func requireOnlyQuotaErrors(t *testing.T, errs []error, quotaErr error) int { + t.Helper() + successes := 0 + for _, err := range errs { + if err == nil { + successes++ + continue + } + require.ErrorIs(t, err, quotaErr) + } + return successes +} + +func TestQuestionQuotaConcurrentLastSlot(t *testing.T) { + conn, _ := newTestDB(t) + ctx := context.Background() + owner := createQuotaTestUser(t, ctx, conn, "quota-question-owner", 0) + setFeatureQuota(t, ctx, conn, owner.ID, FeatureQuestion, 1) + questions := NewQuestionsStore(conn) + errs := runConcurrently(12, func(i int) error { + _, err := questions.Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: fmt.Sprintf("question-%d", i)}) + return err + }) + require.Equal(t, 1, requireOnlyQuotaErrors(t, errs, ErrFeatureQuotaReached)) + var count int64 + require.NoError(t, conn.Model(&Question{}).Where("user_id = ?", owner.ID).Count(&count).Error) + require.Equal(t, int64(1), count) + fc, err := NewFeatureControlsStore(conn).Get(ctx, owner.ID, FeatureQuestion) + require.NoError(t, err) + require.Equal(t, int64(1), fc.QuotaCount) +} + +func TestVoteQuotasConcurrentLastSlot(t *testing.T) { + conn, _ := newTestDB(t) + ctx := context.Background() + owner := createQuotaTestUser(t, ctx, conn, "quota-vote-owner", 0) + setFeatureQuota(t, ctx, conn, owner.ID, FeatureVote, 1) + votes := NewVotesStore(conn) + options := NewVoteOptionsStore(conn) + submissions := NewVoteSubmissionsStore(conn) + ids := make([]uint, 2) + for i := range 2 { + vote, err := votes.Create(ctx, CreateVoteOptions{UserID: owner.ID, Title: fmt.Sprintf("vote-%d", i), Type: VoteTypeSingle, QuotaLimit: 1}) + require.NoError(t, err) + require.NoError(t, votes.UpdateStatus(ctx, vote.ID, VoteStatusPublished)) + option, err := options.Create(ctx, CreateVoteOptionOptions{VoteID: vote.ID, Content: "option"}) + require.NoError(t, err) + ids[i] = option.ID + } + errs := runConcurrently(12, func(i int) error { + token := fmt.Sprintf("voter-%d", i) + _, err := submissions.Create(ctx, CreateVoteSubmissionOptions{VoteID: uint(i%2 + 1), OptionID: ids[i%2], VoterToken: &token, QuotaUserID: owner.ID}) + return err + }) + successes := 0 + for _, err := range errs { + if err == nil { + successes++ + } else { + require.Error(t, err) + } + } + require.Equal(t, 1, successes) + var count int64 + require.NoError(t, conn.Model(&VoteSubmission{}).Count(&count).Error) + require.Equal(t, int64(1), count) + fc, err := NewFeatureControlsStore(conn).Get(ctx, owner.ID, FeatureVote) + require.NoError(t, err) + require.Equal(t, int64(1), fc.QuotaCount) + var ended int64 + require.NoError(t, conn.Model(&Vote{}).Where("status = ?", VoteStatusEnded).Count(&ended).Error) + require.Equal(t, int64(1), ended) +} + +func TestDailyMessageLimitAcrossConversationPaths(t *testing.T) { + conn, _ := newTestDB(t) + ctx := context.Background() + owner := createQuotaTestUser(t, ctx, conn, "quota-chat-owner", 1) + asker := createQuotaTestUser(t, ctx, conn, "quota-chat-asker", 0) + conversations := NewConversationStore(conn) + existing, err := conversations.Create(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20}) + require.NoError(t, err) + var wg sync.WaitGroup + start := make(chan struct{}) + errs := make([]error, 2) + wg.Add(2) + go func() { + defer wg.Done() + <-start + _, _, errs[0] = conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20}, asker.ID, "first") + }() + go func() { + defer wg.Done() + <-start + _, errs[1] = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: existing.ID, SenderID: asker.ID, Content: "existing"}) + }() + close(start) + wg.Wait() + require.Equal(t, 1, requireOnlyQuotaErrors(t, errs, ErrDailyMessageLimit)) + var messages, convCount int64 + require.NoError(t, conn.Model(&Message{}).Count(&messages).Error) + require.NoError(t, conn.Model(&Conversation{}).Count(&convCount).Error) + require.Equal(t, int64(1), messages) + require.Equal(t, int64(1), convCount) +} + +func TestOwnerReplyDoesNotConsumeAskerDailyQuota(t *testing.T) { + conn, _ := newTestDB(t) + ctx := context.Background() + owner := createQuotaTestUser(t, ctx, conn, "quota-reply-owner", 1) + asker := createQuotaTestUser(t, ctx, conn, "quota-reply-asker", 0) + conversations := NewConversationStore(conn) + conv, _, err := conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{OwnerID: owner.ID, AskerID: asker.ID, MaxMessages: 20}, asker.ID, "first") + require.NoError(t, err) + _, err = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: conv.ID, SenderID: owner.ID, Content: "reply"}) + require.NoError(t, err) + _, err = conversations.SendMessage(ctx, SendConversationMessageOptions{ConversationID: conv.ID, SenderID: asker.ID, Content: "over"}) + require.ErrorIs(t, err, ErrDailyMessageLimit) + var count int64 + require.NoError(t, conn.Model(&Message{}).Count(&count).Error) + require.Equal(t, int64(2), count) +} diff --git a/internal/db/users.go b/internal/db/users.go index ff43300..ad49849 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -133,6 +133,13 @@ type User struct { PagePassword string `json:"-" gorm:"column:page_password;type:varchar(255);default:''"` AskPassword string `json:"-" gorm:"column:ask_password;type:varchar(255);default:''"` + // PagePasswordVer / AskPasswordVer 是页面访问密码 / 提问密码的版本号。 + // 每次修改对应密码时 +1,session 中保存授权时的版本号; + // 校验时若 session 中的版本号 != 当前版本号,则视为授权已失效。 + // 这让「修改页面密码」能立即撤销已泄露的旧授权(SEC-06)。 + PagePasswordVer uint `gorm:"default:0" json:"-"` + AskPasswordVer uint `gorm:"default:0" json:"-"` + // 私聊相关 AllowChat bool `json:"allow_chat" gorm:"default:false"` // 每个私聊会话的消息条数上限(用户自设)。default:20 仅是 DB 层兜底; @@ -884,7 +891,10 @@ func (db *users) UpdatePagePassword(ctx context.Context, id uint, password strin return err } user.SetPagePassword(password) - return db.WithContext(ctx).Model(user).Update("page_password", user.PagePassword).Error + return db.WithContext(ctx).Model(user).Updates(map[string]interface{}{ + "page_password": user.PagePassword, + "page_password_ver": gorm.Expr("page_password_ver + 1"), + }).Error } func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string) error { @@ -893,7 +903,10 @@ func (db *users) UpdateAskPassword(ctx context.Context, id uint, password string return err } user.SetAskPassword(password) - return db.WithContext(ctx).Model(user).Update("ask_password", user.AskPassword).Error + return db.WithContext(ctx).Model(user).Updates(map[string]interface{}{ + "ask_password": user.AskPassword, + "ask_password_ver": gorm.Expr("ask_password_ver + 1"), + }).Error } // 新增私聊设置 diff --git a/internal/db/users_test.go b/internal/db/users_test.go index 7cfef99..fbc6c58 100644 --- a/internal/db/users_test.go +++ b/internal/db/users_test.go @@ -609,6 +609,7 @@ func TestDeactivatePurgesAllOwnedContent(t *testing.T) { UserID: owner.ID, Title: "owned vote", Type: VoteTypeForm, }) require.Nil(t, err) + require.Nil(t, NewVotesStore(db).UpdateStatus(ctx, vote.ID, VoteStatusPublished)) option, err := NewVoteOptionsStore(db).Create(ctx, CreateVoteOptionOptions{ VoteID: vote.ID, Content: "option", }) diff --git a/internal/db/vote_submissions.go b/internal/db/vote_submissions.go index 1e5fe0c..b80fff9 100644 --- a/internal/db/vote_submissions.go +++ b/internal/db/vote_submissions.go @@ -15,6 +15,7 @@ import ( "github.com/pkg/errors" "gorm.io/datatypes" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/tamakyi/TamaBox/internal/dbutil" ) @@ -25,6 +26,8 @@ type VoteSubmissionsStore interface { Create(ctx context.Context, opts CreateVoteSubmissionOptions) (*VoteSubmission, error) GetByID(ctx context.Context, id uint) (*VoteSubmission, error) ListByVoteID(ctx context.Context, voteID uint, cursor *dbutil.Cursor) ([]*VoteSubmission, error) + // ListByVoteIDs 批量查询多个投票的全部提交记录(IN 查询 + 内存分组),避免逐投票 N+1。 + ListByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint][]*VoteSubmission, error) CountByVoteID(ctx context.Context, voteID uint) (int64, error) CountByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint]int64, error) CountByOptionID(ctx context.Context, voteID, optionID uint) (int64, error) @@ -77,6 +80,7 @@ var ( // ErrVoteAlreadyVoted 同一投票被同一身份重复提交(数据库唯一索引兜底返回)。 ErrVoteAlreadyVoted = errors.New("你已参与过该投票") ErrVoteSubmissionNotExist = errors.New("投票记录不存在") + ErrVoteNotAccepting = errors.New("投票未开放或已达到收集上限") ) // Create 写入一条投票/提交记录。若命中复合唯一索引(同一 user 或同一 token 重复提交), @@ -92,6 +96,26 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission CustomText: opts.CustomText, } err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if opts.QuotaUserID != 0 { + if err := reserveFeatureQuotaTx(tx, opts.QuotaUserID, FeatureVote); err != nil { + return err + } + } + var vote Vote + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&vote, opts.VoteID).Error; err != nil { + return errors.Wrap(err, "lock vote") + } + now := time.Now() + if vote.Status != VoteStatusPublished || (vote.StartAt != nil && now.Before(*vote.StartAt)) || (vote.EndAt != nil && !now.Before(*vote.EndAt)) { + return ErrVoteNotAccepting + } + var count int64 + if err := tx.Model(&VoteSubmission{}).Where("vote_id = ?", opts.VoteID).Count(&count).Error; err != nil { + return errors.Wrap(err, "count vote submissions") + } + if vote.QuotaLimit > 0 && count >= int64(vote.QuotaLimit) { + return ErrVoteNotAccepting + } if err := tx.Create(submission).Error; err != nil { if isDuplicateKeyError(err) { return ErrVoteAlreadyVoted @@ -107,6 +131,11 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission return errors.Wrap(err, "increment vote quota") } } + if vote.QuotaLimit > 0 && count+1 >= int64(vote.QuotaLimit) { + if err := tx.Model(&Vote{}).Where("id = ?", vote.ID).Update("status", VoteStatusEnded).Error; err != nil { + return errors.Wrap(err, "end vote on quota") + } + } return nil }) if err != nil { @@ -148,6 +177,26 @@ func (db *voteSubmissions) ListByVoteID(ctx context.Context, voteID uint, cursor return submissions, nil } +// ListByVoteIDs 批量查询多个投票的全部提交记录(IN 查询 + 内存分组),避免逐投票 N+1。 +// 返回 map[voteID][]*VoteSubmission;未出现在结果中的 voteID 视为无提交。 +func (db *voteSubmissions) ListByVoteIDs(ctx context.Context, voteIDs []uint) (map[uint][]*VoteSubmission, error) { + out := make(map[uint][]*VoteSubmission, len(voteIDs)) + if len(voteIDs) == 0 { + return out, nil + } + var submissions []*VoteSubmission + if err := db.WithContext(ctx). + Where("vote_id IN ?", voteIDs). + Order("created_at ASC"). + Find(&submissions).Error; err != nil { + return nil, errors.Wrap(err, "list vote submissions by vote ids") + } + for _, s := range submissions { + out[s.VoteID] = append(out[s.VoteID], s) + } + return out, nil +} + // CountByVoteID 统计某投票的总参与人数。 func (db *voteSubmissions) CountByVoteID(ctx context.Context, voteID uint) (int64, error) { var count int64 diff --git a/internal/db/vote_submissions_test.go b/internal/db/vote_submissions_test.go index 53b7a65..0a60d67 100644 --- a/internal/db/vote_submissions_test.go +++ b/internal/db/vote_submissions_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/datatypes" + "gorm.io/gorm" "github.com/tamakyi/TamaBox/internal/dbutil" ) @@ -53,10 +54,41 @@ func TestVoteSubmissions(t *testing.T) { func userIDPtr(id uint) *uint { return &id } func tokenPtr(s string) *string { return &s } +// seedPublishedVotes 创建 n 个已发布的投票,每个投票带两个选项。 +// 返回 voteID 列表;调用方用 votes[i] 作为 VoteID、options[voteID][j] 作为 OptionID。 +// VoteSubmissions.Create 现在要求投票必须 published 才接受提交,旧的测试夹具 +// 直接用 VoteID=1 的虚拟行会被锁行失败拒绝,因此需要真实投票。 +func seedPublishedVotes(t *testing.T, ctx context.Context, conn *gorm.DB, userID uint, n int) ([]uint, map[uint][]uint) { + t.Helper() + votesStore := NewVotesStore(conn) + optionsStore := NewVoteOptionsStore(conn) + voteIDs := make([]uint, 0, n) + optionIDs := make(map[uint][]uint, n) + for i := 0; i < n; i++ { + vote, err := votesStore.Create(ctx, CreateVoteOptions{ + UserID: userID, Title: "seed vote", Type: VoteTypeSingle, + }) + require.NoError(t, err) + require.NoError(t, votesStore.UpdateStatus(ctx, vote.ID, VoteStatusPublished)) + voteIDs = append(voteIDs, vote.ID) + optionIDs[vote.ID] = make([]uint, 0, 2) + for j := 0; j < 2; j++ { + opt, err := optionsStore.Create(ctx, CreateVoteOptionOptions{ + VoteID: vote.ID, Content: "option", + }) + require.NoError(t, err) + optionIDs[vote.ID] = append(optionIDs[vote.ID], opt.ID) + } + } + return voteIDs, optionIDs +} + func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmissions) { + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1) + voteID := voteIDs[0] got, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 2, + VoteID: voteID, + OptionID: optionIDs[voteID][1], UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) @@ -67,9 +99,11 @@ func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmis // 核心防刷:同一登录用户对同一投票重复提交必须被数据库唯一索引拦截。 func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSubmissions) { + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1) + voteID := voteIDs[0] _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 1, + VoteID: voteID, + OptionID: optionIDs[voteID][0], UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) @@ -77,8 +111,8 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu t.Run("duplicate rejected", func(t *testing.T) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 2, + VoteID: voteID, + OptionID: optionIDs[voteID][1], UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) @@ -87,8 +121,8 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu t.Run("different user allowed", func(t *testing.T) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 1, + VoteID: voteID, + OptionID: optionIDs[voteID][0], UserID: userIDPtr(11), VoterIP: "114.5.1.5", }) @@ -98,9 +132,11 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu // 核心防刷:同一匿名指纹对同一投票重复提交必须被拦截。 func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteSubmissions) { + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1) + voteID := voteIDs[0] _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 1, + VoteID: voteID, + OptionID: optionIDs[voteID][0], VoterToken: tokenPtr("fp-token-001"), VoterIP: "114.5.1.4", }) @@ -108,8 +144,8 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS t.Run("duplicate rejected", func(t *testing.T) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 2, + VoteID: voteID, + OptionID: optionIDs[voteID][1], VoterToken: tokenPtr("fp-token-001"), VoterIP: "114.5.1.4", }) @@ -118,8 +154,8 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS t.Run("different token allowed", func(t *testing.T) { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 1, + VoteID: voteID, + OptionID: optionIDs[voteID][0], VoterToken: tokenPtr("fp-token-002"), VoterIP: "114.5.1.4", }) @@ -129,17 +165,19 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS // 登录用户与匿名指纹可在同一投票下并存(user_id 为 NULL 与 voter_token 为 NULL 不冲突)。 func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db *voteSubmissions) { + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1) + voteID := voteIDs[0] _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 1, + VoteID: voteID, + OptionID: optionIDs[voteID][0], UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) require.Nil(t, err) _, err = db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 2, + VoteID: voteID, + OptionID: optionIDs[voteID][1], VoterToken: tokenPtr("fp-anon-001"), VoterIP: "114.5.1.6", }) @@ -147,25 +185,26 @@ func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db // 同一 user 换匿名 token 再来:user_id 相同仍应被拦截 _, err = db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 1, + VoteID: voteID, + OptionID: optionIDs[voteID][0], UserID: userIDPtr(10), VoterToken: tokenPtr("fp-anon-002"), VoterIP: "114.5.1.7", }) require.Equal(t, ErrVoteAlreadyVoted, err) - count, err := db.CountByVoteID(ctx, 1) + count, err := db.CountByVoteID(ctx, voteID) require.Nil(t, err) require.Equal(t, int64(2), count) } // 同一用户 / 同一 token 对不同投票不受约束。 func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSubmissions) { - for voteID := uint(1); voteID <= 2; voteID++ { + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2) + for _, voteID := range voteIDs { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ VoteID: voteID, - OptionID: 1, + OptionID: optionIDs[voteID][0], UserID: userIDPtr(10), VoterIP: "114.5.1.4", }) @@ -173,50 +212,50 @@ func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSub } // 同一 token 投两个不同投票也允许 - _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 1, - VoterToken: tokenPtr("fp-x"), - VoterIP: "114.5.1.8", - }) - require.Nil(t, err) - _, err = db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 2, - OptionID: 1, - VoterToken: tokenPtr("fp-x"), - VoterIP: "114.5.1.8", - }) - require.Nil(t, err) + for _, voteID := range voteIDs { + _, err := db.Create(ctx, CreateVoteSubmissionOptions{ + VoteID: voteID, + OptionID: optionIDs[voteID][0], + VoterToken: tokenPtr("fp-x"), + VoterIP: "114.5.1.8", + }) + require.Nil(t, err) + } - count, err := db.CountByVoteID(ctx, 1) + count, err := db.CountByVoteID(ctx, voteIDs[0]) require.Nil(t, err) require.Equal(t, int64(2), count) } func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmissions) { - // 投票 1:选项 1 两票,选项 2 一票 - _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"}) + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2) + vote1, vote2 := voteIDs[0], voteIDs[1] + opt1a, opt1b := optionIDs[vote1][0], optionIDs[vote1][1] + opt2a := optionIDs[vote2][0] + + // 投票 1:选项 A 两票,选项 B 一票 + _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1a, UserID: userIDPtr(1), VoterIP: "a"}) require.Nil(t, err) - _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"}) + _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1a, UserID: userIDPtr(2), VoterIP: "b"}) require.Nil(t, err) - _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 2, UserID: userIDPtr(3), VoterIP: "c"}) + _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1b, UserID: userIDPtr(3), VoterIP: "c"}) require.Nil(t, err) // 投票 2:一票 - _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(4), VoterIP: "d"}) + _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote2, OptionID: opt2a, UserID: userIDPtr(4), VoterIP: "d"}) require.Nil(t, err) t.Run("total", func(t *testing.T) { - got, err := db.CountByVoteID(ctx, 1) + got, err := db.CountByVoteID(ctx, vote1) require.Nil(t, err) require.Equal(t, int64(3), got) }) t.Run("by option", func(t *testing.T) { - got, err := db.CountByOptionID(ctx, 1, 1) + got, err := db.CountByOptionID(ctx, vote1, opt1a) require.Nil(t, err) require.Equal(t, int64(2), got) - got, err = db.CountByOptionID(ctx, 1, 2) + got, err = db.CountByOptionID(ctx, vote1, opt1b) require.Nil(t, err) require.Equal(t, int64(1), got) }) @@ -229,54 +268,62 @@ func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmiss } func testVoteSubmissionsHasUserVoted(t *testing.T, ctx context.Context, db *voteSubmissions) { - _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "a"}) + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1) + voteID := voteIDs[0] + _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(10), VoterIP: "a"}) require.Nil(t, err) - got, err := db.HasUserVoted(ctx, 1, 10) + got, err := db.HasUserVoted(ctx, voteID, 10) require.Nil(t, err) require.True(t, got) - got, err = db.HasUserVoted(ctx, 1, 11) + got, err = db.HasUserVoted(ctx, voteID, 11) require.Nil(t, err) require.False(t, got) } func testVoteSubmissionsHasTokenVoted(t *testing.T, ctx context.Context, db *voteSubmissions) { - _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-1"), VoterIP: "a"}) + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1) + voteID := voteIDs[0] + _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], VoterToken: tokenPtr("fp-1"), VoterIP: "a"}) require.Nil(t, err) - got, err := db.HasTokenVoted(ctx, 1, "fp-1") + got, err := db.HasTokenVoted(ctx, voteID, "fp-1") require.Nil(t, err) require.True(t, got) - got, err = db.HasTokenVoted(ctx, 1, "fp-2") + got, err = db.HasTokenVoted(ctx, voteID, "fp-2") require.Nil(t, err) require.False(t, got) } func testVoteSubmissionsCountByIP(t *testing.T, ctx context.Context, db *voteSubmissions) { + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1) + voteID := voteIDs[0] since := time.Now().Add(-1 * time.Hour) - _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "1.2.3.4"}) + _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(1), VoterIP: "1.2.3.4"}) require.Nil(t, err) - _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "1.2.3.4"}) + _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(2), VoterIP: "1.2.3.4"}) require.Nil(t, err) - _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(3), VoterIP: "5.6.7.8"}) + _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(3), VoterIP: "5.6.7.8"}) require.Nil(t, err) - got, err := db.CountByIP(ctx, 1, "1.2.3.4", since) + got, err := db.CountByIP(ctx, voteID, "1.2.3.4", since) require.Nil(t, err) require.Equal(t, int64(2), got) - got, err = db.CountByIP(ctx, 1, "5.6.7.8", since) + got, err = db.CountByIP(ctx, voteID, "5.6.7.8", since) require.Nil(t, err) require.Equal(t, int64(1), got) } func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) { + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1) + voteID := voteIDs[0] for i := 0; i < 3; i++ { _, err := db.Create(ctx, CreateVoteSubmissionOptions{ - VoteID: 1, - OptionID: 1, + VoteID: voteID, + OptionID: optionIDs[voteID][0], UserID: userIDPtr(uint(100 + i)), VoterIP: "x", FormData: datatypes.JSON(`{"nickname":"user` + string(rune('0'+i)) + `"}`), @@ -284,25 +331,27 @@ func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *vote require.Nil(t, err) } - got, err := db.ListByVoteID(ctx, 1, &dbutil.Cursor{Value: 2, PageSize: 10}) + got, err := db.ListByVoteID(ctx, voteID, &dbutil.Cursor{Value: 2, PageSize: 10}) require.Nil(t, err) require.Len(t, got, 1) require.Equal(t, uint(1), got[0].ID, "游标取 id < 2 的记录") } func testVoteSubmissionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) { - _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"}) + voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2) + vote1, vote2 := voteIDs[0], voteIDs[1] + _, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: optionIDs[vote1][0], UserID: userIDPtr(1), VoterIP: "a"}) require.Nil(t, err) - _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"}) + _, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote2, OptionID: optionIDs[vote2][0], UserID: userIDPtr(2), VoterIP: "b"}) require.Nil(t, err) - require.Nil(t, db.DeleteByVoteID(ctx, 1)) + require.Nil(t, db.DeleteByVoteID(ctx, vote1)) - got, err := db.CountByVoteID(ctx, 1) + got, err := db.CountByVoteID(ctx, vote1) require.Nil(t, err) require.Equal(t, int64(0), got) - got, err = db.CountByVoteID(ctx, 2) + got, err = db.CountByVoteID(ctx, vote2) require.Nil(t, err) require.Equal(t, int64(1), got) } diff --git a/internal/db/votes.go b/internal/db/votes.go index 185e1f8..d2cd6c8 100644 --- a/internal/db/votes.go +++ b/internal/db/votes.go @@ -61,6 +61,9 @@ const ( type VotesStore interface { Create(ctx context.Context, opts CreateVoteOptions) (*Vote, error) + // CreateWithOptions 在单个事务里创建投票 + 选项 + 表单字段。 + // 任一步失败整体回滚,避免留下「有投票无选项」或「有选项无字段」的半成品。 + CreateWithOptions(ctx context.Context, opts CreateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) (*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) @@ -68,6 +71,9 @@ type VotesStore interface { 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 + // UpdateWithOptions 在单个事务里更新投票元信息 + 替换选项 + 替换表单字段。 + // 仅在草稿状态调用(发布后选项/字段锁定,避免破坏历史数据归因)。 + UpdateWithOptions(ctx context.Context, id uint, opts UpdateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) error UpdateStatus(ctx context.Context, id uint, status string) error DeleteByID(ctx context.Context, id uint) error // DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。 @@ -160,6 +166,69 @@ func (db *votes) Create(ctx context.Context, opts CreateVoteOptions) (*Vote, err return vote, nil } +// CreateWithOptions 在单个事务里创建投票 + 选项 + 表单字段。 +// 任一步失败整体回滚,避免留下「有投票无选项」或「有选项无字段」的半成品。 +func (db *votes) CreateWithOptions(ctx context.Context, opts CreateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) (*Vote, error) { + var vote *Vote + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + v := &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 := tx.Create(v).Error; err != nil { + return errors.Wrap(err, "create vote") + } + vote = v + + if len(optionContents) > 0 { + options := make([]*VoteOption, 0, len(optionContents)) + for i, content := range optionContents { + options = append(options, &VoteOption{VoteID: v.ID, Content: content, SortOrder: i}) + } + if err := tx.Create(&options).Error; err != nil { + return errors.Wrap(err, "create vote options") + } + } + + if len(formFields) > 0 { + fields := make([]*VoteFormField, 0, len(formFields)) + for _, f := range formFields { + fields = append(fields, &VoteFormField{ + VoteID: v.ID, + FieldType: f.FieldType, + Label: f.Label, + Required: f.Required, + MaxLength: f.MaxLength, + Options: f.Options, + SortOrder: f.SortOrder, + AllowCustom: f.AllowCustom, + }) + } + if err := tx.Create(&fields).Error; err != nil { + return errors.Wrap(err, "create vote form fields") + } + } + return nil + }) + if err != nil { + return nil, err + } + return vote, nil +} + var ErrVoteNotExist = errors.New("投票不存在") func (db *votes) GetByID(ctx context.Context, id uint) (*Vote, error) { @@ -335,6 +404,79 @@ func (db *votes) Update(ctx context.Context, id uint, opts UpdateVoteOptions) er return nil } +// UpdateWithOptions 在单个事务里更新投票元信息 + 替换选项 + 替换表单字段。 +// 仅在草稿状态调用(发布后选项/字段锁定,避免破坏历史数据归因)。 +// 任一步失败整体回滚,避免「元信息已更新、字段只更新一部分」的不一致草稿。 +func (db *votes) UpdateWithOptions(ctx context.Context, id uint, opts UpdateVoteOptions, optionContents []string, formFields []CreateVoteFormFieldOptions) error { + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) 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, + } + if opts.Type != nil { + updates["type"] = *opts.Type + } + if opts.MaxChoices != nil { + updates["max_choices"] = *opts.MaxChoices + } + res := tx.Model(&Vote{}).Where("id = ?", id).Updates(updates) + if res.Error != nil { + return errors.Wrap(res.Error, "update vote") + } + if res.RowsAffected == 0 { + return ErrVoteNotExist + } + + // 替换选项(先删后建) + if err := tx.Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil { + return errors.Wrap(err, "delete old vote options") + } + if len(optionContents) > 0 { + options := make([]*VoteOption, 0, len(optionContents)) + for i, content := range optionContents { + options = append(options, &VoteOption{VoteID: id, Content: content, SortOrder: i}) + } + if err := tx.Create(&options).Error; err != nil { + return errors.Wrap(err, "create vote options") + } + } + + // 替换表单字段(先删后建) + if err := tx.Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil { + return errors.Wrap(err, "delete old vote form fields") + } + if len(formFields) > 0 { + fields := make([]*VoteFormField, 0, len(formFields)) + for _, f := range formFields { + fields = append(fields, &VoteFormField{ + VoteID: id, + FieldType: f.FieldType, + Label: f.Label, + Required: f.Required, + MaxLength: f.MaxLength, + Options: f.Options, + SortOrder: f.SortOrder, + AllowCustom: f.AllowCustom, + }) + } + if err := tx.Create(&fields).Error; err != nil { + return errors.Wrap(err, "create vote form fields") + } + } + 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) diff --git a/internal/route/chat/chat_handler.go b/internal/route/chat/chat_handler.go index aa23954..2190efb 100644 --- a/internal/route/chat/chat_handler.go +++ b/internal/route/chat/chat_handler.go @@ -235,6 +235,9 @@ func SendMessage(ctx context.Context, cache cache.Cache) { todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID) if err != nil { logrus.WithError(err).Error("Failed to count today messages") + ctx.SetInternalErrorFlash() + ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token)) + return } if todayCount >= int64(pageUser.DailyMessageLimit) { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit)) @@ -330,54 +333,27 @@ func SendMessage(ctx context.Context, cache cache.Cache) { } } - // 原子地递增消息计数:真正的上限检查点(先于消息落库,防止超额消息写入)。 - // 并发场景(对话双方同时发送)下,仅当 message_count < max 时才 +1 成功。 - ok, err := db.Conversations.IncrementMessageCountIfBelow(ctx.Request().Context(), conv.ID, conv.MaxMessages) - if err != nil { - logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count") - ctx.SetInternalErrorFlash() - ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token)) - return + var image *db.CreateMessageImageOptions + if imageURL != "" { + image = &db.CreateMessageImageOptions{URL: imageURL, ObjectKey: imageKey, FileName: imageHeader.Filename, FileSize: imageHeader.Size} } - if !ok { - ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached")) - ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token)) - return - } - - // 创建消息 - msg, err := db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{ - ConversationID: conv.ID, - SenderID: ctx.User.ID, - Content: content, + _, err = db.Conversations.SendMessage(ctx.Request().Context(), db.SendConversationMessageOptions{ + ConversationID: conv.ID, SenderID: ctx.User.ID, Content: content, Image: image, }) if err != nil { - // 计数已递增但消息落库失败:回滚计数,避免占用配额 - if rerr := db.Conversations.DecrementMessageCount(ctx.Request().Context(), conv.ID); rerr != nil { - logrus.WithContext(ctx.Request().Context()).WithError(rerr).Error("Failed to rollback message count") + switch { + case errors.Is(err, db.ErrDailyMessageLimit): + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errDailyLimit", pageUser.DailyMessageLimit)) + case errors.Is(err, db.ErrConversationLimit): + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errLimitReached")) + default: + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send message") + ctx.SetInternalErrorFlash() } - logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create message") - ctx.SetInternalErrorFlash() ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token)) return } - - // 保存图片记录(如有) - if imageURL != "" { - if _, err := db.MessageImages.Create(ctx.Request().Context(), db.CreateMessageImageOptions{ - MessageID: msg.ID, - URL: imageURL, - ObjectKey: imageKey, - FileName: imageHeader.Filename, - FileSize: imageHeader.Size, - }); err != nil { - logrus.WithError(err).Error("Failed to save message image") - ctx.SetInternalErrorFlash() - ctx.Redirect(fmt.Sprintf("/_/%s/chat/%s", domain, token)) - return - } - imageAttached = true - } + imageAttached = true // 达到上限时关闭对话(幂等,多实例同时执行也安全) if err := db.Conversations.MaybeClose(ctx.Request().Context(), conv.ID, conv.MaxMessages); err != nil { diff --git a/route/auth/recover.go b/route/auth/recover.go index e33b6c3..7035a4e 100644 --- a/route/auth/recover.go +++ b/route/auth/recover.go @@ -5,6 +5,7 @@ package auth import ( + "fmt" "os" "time" @@ -73,6 +74,20 @@ func ForgotPasswordAction(ctx context.Context, f form.ForgotPassword, cache cach return } + // SEC-05:签发新令牌前,先尝试删除该用户上一次的找回令牌。 + // 用 userID → code 反查键实现:新令牌签发后旧令牌的反查键被覆盖, + // 旧 code 在 24h 内虽然物理上还在缓存里,但已经不在用户的"当前令牌"集合中, + // checkRecoverPasswordCode 会拒绝使用。 + userCodeKey := "forgot-password-user-code:" + fmt.Sprint(user.ID) + if oldCodeItf, err := cache.Get(ctx.Request().Context(), userCodeKey); err == nil { + if oldCode, ok := oldCodeItf.(string); ok && oldCode != "" { + oldCodeKey := "forgot-password-recovery-code:" + oldCode + if err := cache.Delete(ctx.Request().Context(), oldCodeKey); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to delete old recovery code") + } + } + } + code := randstr.String(64) recoveryCodeCacheKey := "forgot-password-recovery-code:" + code if err := cache.Set(ctx.Request().Context(), recoveryCodeCacheKey, user.ID, 24*time.Hour); err != nil { @@ -82,6 +97,16 @@ func ForgotPasswordAction(ctx context.Context, f form.ForgotPassword, cache cach return } + // 记录该用户当前的找回令牌 code,供下次签发时撤销。 + if err := cache.Set(ctx.Request().Context(), userCodeKey, code, 24*time.Hour); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set user recovery code cache") + // 尽力回滚刚写入的令牌,避免孤儿 + _ = cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey) + ctx.SetInternalErrorFlash() + ctx.Redirect("/forgot-password") + return + } + if err := mail.SendPasswordRecoveryMail(user.Email, code); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to send password recovery mail") ctx.SetErrorFlash(i18n.T(ctx.Lang(), "auth.errEmailFailed")) @@ -151,15 +176,10 @@ func RecoverPasswordAction(ctx context.Context, cache cache.Cache, f form.Recove return } - code := ctx.Query("code") - recoveryCodeCacheKey := "forgot-password-recovery-code:" + code - if err := cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey); err != nil { - logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete password recovery code cache") - ctx.SetInternalErrorFlash() - ctx.Redirect("/login") - return - } - + // SEC-05:先更新密码,成功后再删除令牌。 + // 旧实现先删令牌再改密码:若密码更新失败,用户必须重新申请。 + // 现在密码更新成功才删令牌;即使删令牌失败,密码已改, + // 旧令牌也不再有用(重新登录需要新密码)。 if err := db.Users.UpdatePassword(ctx.Request().Context(), user.ID, f.NewPassword); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user password") ctx.SetInternalErrorFlash() @@ -167,6 +187,20 @@ func RecoverPasswordAction(ctx context.Context, cache cache.Cache, f form.Recove return } + code := ctx.Query("code") + recoveryCodeCacheKey := "forgot-password-recovery-code:" + code + if err := cache.Delete(ctx.Request().Context(), recoveryCodeCacheKey); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete password recovery code cache") + // 令牌删除失败不阻断流程:密码已改,旧令牌已经无法用于登录。 + // 但记录日志便于排查缓存异常。 + } + + // 同时清理 user → code 反查键,避免下次申请时尝试删除已不存在的旧 code。 + userCodeKey := "forgot-password-user-code:" + fmt.Sprint(user.ID) + if err := cache.Delete(ctx.Request().Context(), userCodeKey); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to delete user recovery code mapping") + } + ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "auth.errPasswordChanged")) ctx.Redirect("/login") } diff --git a/route/question/ask_history.go b/route/question/ask_history.go index b52e1b9..9961d22 100644 --- a/route/question/ask_history.go +++ b/route/question/ask_history.go @@ -7,6 +7,7 @@ package question import ( "crypto/subtle" "encoding/json" + "fmt" "net/http" "strings" @@ -101,6 +102,29 @@ func AskStatusAPI(ctx context.Context) error { return ctx.ServerError() } + // SEC-06:与 ListAPI(route/question/page.go)对齐,补上 L4 和页面密码检查。 + // 否则持有正确问题 token 的请求者可以绕过页面访问边界,取得回答状态、 + // 创建时间和前 60 字摘要。 + // 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。 + if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err == nil && level == db.RestrictionL4 { + out := make([]askStatusResult, 0, len(req.Items)) + for _, it := range req.Items { + out = append(out, askStatusResult{ID: it.ID}) + } + return ctx.JSON(map[string]interface{}{"items": out}) + } + if owner.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == owner.ID) { + passKey := fmt.Sprintf("page_pass_%d", owner.ID) + sessionVer, _ := ctx.Session.Get(passKey).(uint) + if sessionVer != owner.PagePasswordVer { + out := make([]askStatusResult, 0, len(req.Items)) + for _, it := range req.Items { + out = append(out, askStatusResult{ID: it.ID}) + } + return ctx.JSON(map[string]interface{}{"items": out}) + } + } + // 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。 ids := make([]uint, 0, len(req.Items)) for _, it := range req.Items { diff --git a/route/question/page.go b/route/question/page.go index 200a8c3..ae98274 100644 --- a/route/question/page.go +++ b/route/question/page.go @@ -83,10 +83,12 @@ func Pager(ctx context.Context) { // 与背景图、颜色同属箱主个性化配置;/user 后台由 Contexter 按登录用户自己的配置注入。 // 空串 = 不加载主题 css(站点默认就是基础外观时省掉一次请求)。 ctx.Data["ThemeSlug"] = theme.Resolve(pageUser.Theme) - // 页面密码检查 + // 页面密码检查:session 中保存授权时的 PagePasswordVer 版本号, + // 与当前版本号比对,修改密码后旧授权立即失效(SEC-06)。 if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) { passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) - if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized { + sessionVer, _ := ctx.Session.Get(passKey).(uint) + if sessionVer != pageUser.PagePasswordVer { // 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页 ctx.Data["Next"] = ctx.Request().URL.RequestURI() ctx.Data["PageUser"] = pageUser @@ -278,7 +280,8 @@ func VerifyPagePassword(ctx context.Context) { if pageUser.CheckPagePassword(password) { pagePasswordGuard.Reset(guardKey) passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) - ctx.Session.Set(passKey, true) + // 保存当前密码版本号到 session,修改密码后旧授权自动失效(SEC-06) + ctx.Session.Set(passKey, pageUser.PagePasswordVer) // 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。 // 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」, // 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。 @@ -330,7 +333,8 @@ func ListAPI(ctx context.Context) error { } if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) { passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) - if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized { + sessionVer, _ := ctx.Session.Get(passKey).(uint) + if sessionVer != pageUser.PagePasswordVer { return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc")) } } @@ -471,6 +475,9 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID) if err != nil { logrus.WithError(err).Error("Failed to count today messages") + ctx.SetInternalError(f) + ctx.Success("question/list") + return } if todayCount >= int64(pageUser.DailyMessageLimit) { ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errChatDailyLimit", pageUser.DailyMessageLimit)) @@ -558,6 +565,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r f.Content, ) if err != nil { + if errors.Is(err, db.ErrDailyMessageLimit) { + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errChatDailyLimit", pageUser.DailyMessageLimit)) + ctx.Redirect("/_/" + pageUser.Domain) + return + } logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation with first message") ctx.SetInternalError(f) ctx.Success("question/list") @@ -686,6 +698,11 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r IsPrivate: isPrivate, }) if err != nil { + if errors.Is(err, db.ErrFeatureQuotaReached) { + ctx.SetErrorFlash(i18n.FeatureClosedMessage(ctx.Lang(), db.FeatureQuestion, db.FeatureReasonQuotaReached)) + ctx.Redirect("/_/" + pageUser.Domain) + return + } logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new question") ctx.SetInternalError(f) ctx.Success("question/list") diff --git a/route/user/profile.go b/route/user/profile.go index d90c3fa..c423a32 100644 --- a/route/user/profile.go +++ b/route/user/profile.go @@ -16,6 +16,7 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/xuri/excelize/v2" + "gorm.io/datatypes" "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/context" @@ -499,8 +500,9 @@ func DeactivatedProfile(ctx context.Context) { ctx.Success("user/deactivated") } -// ExportProfileJSON 以 JSON 格式全量导出账号数据(用户信息 + 提问/回答/追问 + 私聊消息), +// ExportProfileJSON 以 JSON 格式导出账号数据(用户信息 + 提问/回答/追问 + 投票 + 私聊消息), // 符合可携带权要求,供用户备份/迁移。 +// FUN-01 修复:补齐投票及提交、私聊图片;子查询失败时 fail-closed 而非静默生成缺数据的文件。 func ExportProfileJSON(ctx context.Context) { user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID) if err != nil { @@ -542,12 +544,16 @@ func ExportProfileJSON(ctx context.Context) { followUpMap, err := db.FollowUps.GetByQuestionIDs(ctx.Request().Context(), questionIDs) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get follow ups for export") - followUpMap = make(map[uint][]*db.FollowUp) + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return } imageMap, err := db.UploadImages.GetByQuestionIDs(ctx.Request().Context(), questionIDs) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get images for export") - imageMap = make(map[uint][]*db.UploadImage) + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return } exportQuestions := make([]exportedQuestion, 0, len(questions)) @@ -579,6 +585,7 @@ func ExportProfileJSON(ctx context.Context) { Content string `json:"content"` SenderID uint `json:"sender_id"` CreatedAt time.Time `json:"created_at"` + Images []string `json:"images"` } type exportedConversation struct { OwnerID uint `json:"owner_id"` @@ -592,9 +599,19 @@ func ExportProfileJSON(ctx context.Context) { allConvs := make([]*db.Conversation, 0) if owned, err := db.Conversations.ListByOwner(ctx.Request().Context(), user.ID, 0, 0); err == nil { allConvs = append(allConvs, owned...) + } else { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list owned conversations for export") + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return } if asked, err := db.Conversations.ListByAsker(ctx.Request().Context(), user.ID, 0, 0); err == nil { allConvs = append(allConvs, asked...) + } else { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list asked conversations for export") + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return } // 去重并收集会话 ID,批量拉取全部消息(IN 查询 + 内存分组),避免逐会话查询造成 N+1。 seen := make(map[uint]bool) @@ -611,8 +628,26 @@ func ExportProfileJSON(ctx context.Context) { msgMap, err := db.Messages.GetAllByConversationIDs(ctx.Request().Context(), convIDs) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get messages for export") - msgMap = make(map[uint][]*db.Message) + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return } + + // 批量拉取全部消息的图片(IN 查询 + 内存分组),避免逐消息查询造成 N+1。 + allMessageIDs := make([]uint, 0) + for _, msgs := range msgMap { + for _, m := range msgs { + allMessageIDs = append(allMessageIDs, m.ID) + } + } + msgImageMap, err := db.MessageImages.GetByMessageIDs(ctx.Request().Context(), allMessageIDs) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get message images for export") + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return + } + for _, conv := range dedupedConvs { msgs := msgMap[conv.ID] if msgs == nil { @@ -620,10 +655,15 @@ func ExportProfileJSON(ctx context.Context) { } exportMsgs := make([]exportedMessage, 0, len(msgs)) for _, m := range msgs { + imageKeys := make([]string, 0) + for _, img := range msgImageMap[m.ID] { + imageKeys = append(imageKeys, img.ObjectKey) + } exportMsgs = append(exportMsgs, exportedMessage{ Content: m.Content, SenderID: m.SenderID, CreatedAt: m.CreatedAt, + Images: imageKeys, }) } exportConvs = append(exportConvs, exportedConversation{ @@ -635,12 +675,187 @@ func ExportProfileJSON(ctx context.Context) { }) } + // 投票及提交(FUN-01:补齐投票导出) + type exportedVoteOption struct { + ID uint `json:"id"` + Content string `json:"content"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + } + type exportedVoteFormField struct { + ID uint `json:"id"` + FieldType string `json:"field_type"` + Label string `json:"label"` + Required bool `json:"required"` + MaxLength int `json:"max_length"` + Options string `json:"options"` + SortOrder int `json:"sort_order"` + AllowCustom bool `json:"allow_custom"` + CreatedAt time.Time `json:"created_at"` + } + type exportedVoteSubmission struct { + ID uint `json:"id"` + OptionID uint `json:"option_id"` + VoterIP string `json:"voter_ip"` + FormData datatypes.JSON `json:"form_data"` + CustomText string `json:"custom_text"` + CreatedAt time.Time `json:"created_at"` + } + type exportedVote struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + Visibility string `json:"visibility"` + VoterScope string `json:"voter_scope"` + ShowResult string `json:"show_result"` + AllowCustom bool `json:"allow_custom"` + MaxChoices int `json:"max_choices"` + StartAt *time.Time `json:"start_at"` + EndAt *time.Time `json:"end_at"` + MaxVotesPerFingerprint int `json:"max_votes_per_fingerprint"` + RequireCaptcha bool `json:"require_captcha"` + QuotaLimit int `json:"quota_limit"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Options []exportedVoteOption `json:"options"` + FormFields []exportedVoteFormField `json:"form_fields"` + Submissions []exportedVoteSubmission `json:"submissions"` + } + + votes, err := db.Votes.ListByUserID(ctx.Request().Context(), user.ID, db.ListVotesOptions{}) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list votes for export") + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return + } + + voteIDs := make([]uint, 0, len(votes)) + for _, v := range votes { + voteIDs = append(voteIDs, v.ID) + } + + // 批量拉取选项、字段、提交(IN 查询 + 内存分组),避免逐投票 N+1。 + voteOptsMap := make(map[uint][]*db.VoteOption) + if len(voteIDs) > 0 { + for _, v := range votes { + opts, err := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote options for export") + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return + } + voteOptsMap[v.ID] = opts + } + } + + voteFieldsMap := make(map[uint][]*db.VoteFormField) + if len(voteIDs) > 0 { + for _, v := range votes { + fields, err := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get vote form fields for export") + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return + } + voteFieldsMap[v.ID] = fields + } + } + + voteSubsMap, err := db.VoteSubmissions.ListByVoteIDs(ctx.Request().Context(), voteIDs) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get vote submissions for export") + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion")) + ctx.Redirect("/user/profile") + return + } + + exportVotes := make([]exportedVote, 0, len(votes)) + for _, v := range votes { + opts := voteOptsMap[v.ID] + if opts == nil { + opts = []*db.VoteOption{} + } + exportOpts := make([]exportedVoteOption, 0, len(opts)) + for _, o := range opts { + exportOpts = append(exportOpts, exportedVoteOption{ + ID: o.ID, + Content: o.Content, + SortOrder: o.SortOrder, + CreatedAt: o.CreatedAt, + }) + } + + fields := voteFieldsMap[v.ID] + if fields == nil { + fields = []*db.VoteFormField{} + } + exportFields := make([]exportedVoteFormField, 0, len(fields)) + for _, f := range fields { + exportFields = append(exportFields, exportedVoteFormField{ + ID: f.ID, + FieldType: f.FieldType, + Label: f.Label, + Required: f.Required, + MaxLength: f.MaxLength, + Options: f.Options, + SortOrder: f.SortOrder, + AllowCustom: f.AllowCustom, + CreatedAt: f.CreatedAt, + }) + } + + subs := voteSubsMap[v.ID] + if subs == nil { + subs = []*db.VoteSubmission{} + } + exportSubs := make([]exportedVoteSubmission, 0, len(subs)) + for _, s := range subs { + exportSubs = append(exportSubs, exportedVoteSubmission{ + ID: s.ID, + OptionID: s.OptionID, + VoterIP: s.VoterIP, + FormData: s.FormData, + CustomText: s.CustomText, + CreatedAt: s.CreatedAt, + }) + } + + exportVotes = append(exportVotes, exportedVote{ + ID: v.ID, + Title: v.Title, + Description: v.Description, + Type: v.Type, + Status: v.Status, + Visibility: v.Visibility, + VoterScope: v.VoterScope, + ShowResult: v.ShowResult, + AllowCustom: v.AllowCustom, + MaxChoices: v.MaxChoices, + StartAt: v.StartAt, + EndAt: v.EndAt, + MaxVotesPerFingerprint: v.MaxVotesPerFingerprint, + RequireCaptcha: v.RequireCaptcha, + QuotaLimit: v.QuotaLimit, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + Options: exportOpts, + FormFields: exportFields, + Submissions: exportSubs, + }) + } + payload := map[string]interface{}{ "exported_at": time.Now().Format(time.RFC3339), - "version": 1, + "version": 2, "user": user, "questions": exportQuestions, "conversations": exportConvs, + "votes": exportVotes, } fileName := fmt.Sprintf("提问箱账号数据-%s-%s.json", user.Domain, time.Now().Format("20060102150405")) diff --git a/route/user/vote.go b/route/user/vote.go index efe0ff9..c543c51 100644 --- a/route/user/vote.go +++ b/route/user/vote.go @@ -626,33 +626,15 @@ func VoteCreate(ctx context.Context) { } form.opts.CensorMetadata = form.censorMetadata - vote, err := db.Votes.Create(ctx.Request().Context(), form.opts) + // 事务化创建:投票 + 选项 + 表单字段。任一步失败整体回滚, + // 避免留下「有投票无选项」或「有选项无字段」的半成品。 + vote, err := db.Votes.CreateWithOptions(ctx.Request().Context(), form.opts, form.options, form.formFields) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote") renderVoteFormWithInput(ctx, false, 0, "", true) return } - if len(form.options) > 0 { - if _, err := db.VoteOptions.BulkCreate(ctx.Request().Context(), vote.ID, form.options); err != nil { - logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote options") - _ = db.Votes.DeleteByID(ctx.Request().Context(), vote.ID) // 回滚本次已建的空草稿,避免孤儿记录 - renderVoteFormWithInput(ctx, false, 0, "", true) - return - } - } - if len(form.formFields) > 0 { - for i := range form.formFields { - form.formFields[i].VoteID = vote.ID - } - if _, err := db.VoteFormFields.BulkCreate(ctx.Request().Context(), vote.ID, form.formFields); err != nil { - logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote form fields") - _ = db.Votes.DeleteByID(ctx.Request().Context(), vote.ID) // 回滚本次已建的空草稿,避免孤儿记录 - renderVoteFormWithInput(ctx, false, 0, "", true) - return - } - } - editURL := "/user/votes/" + fmt.Sprint(vote.ID) + "/edit" if action == "publish" { @@ -704,10 +686,22 @@ func VoteEdit(ctx context.Context) { // 选项 / 字段回填传结构化数据,模板经 toJSON(template.JS)注入