安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+15
-2
@@ -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
|
||||
}
|
||||
|
||||
// 新增私聊设置
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+43
-9
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+21
-4
@@ -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")
|
||||
|
||||
+220
-5
@@ -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"))
|
||||
|
||||
+22
-41
@@ -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)注入 <script>,
|
||||
// 避免 template.HTML 在 script 上下文被转义导致初始化失效。
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
options, err := db.VoteOptions.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list vote options")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect("/user/questions#votes")
|
||||
return
|
||||
}
|
||||
ctx.Data["InitialOptions"] = optionsContent(options)
|
||||
|
||||
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
fields, err := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list vote form fields")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect("/user/questions#votes")
|
||||
return
|
||||
}
|
||||
ctx.Data["InitialFields"] = fieldsContent(fields)
|
||||
|
||||
// 时间回填:Go 模板直接输出 time.Time 不符合 datetime-local 格式,预先格式化
|
||||
@@ -829,7 +823,9 @@ func VoteUpdate(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Votes.Update(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
|
||||
// 事务化更新:投票元信息 + 替换选项 + 替换表单字段。任一步失败整体回滚,
|
||||
// 避免「元信息已更新、字段只更新一部分」的不一致草稿。
|
||||
if err := db.Votes.UpdateWithOptions(ctx.Request().Context(), vote.ID, db.UpdateVoteOptions{
|
||||
Title: form.opts.Title,
|
||||
Description: form.opts.Description,
|
||||
Type: &form.opts.Type,
|
||||
@@ -844,27 +840,12 @@ func VoteUpdate(ctx context.Context) {
|
||||
RequireCaptcha: form.opts.RequireCaptcha,
|
||||
CensorMetadata: form.censorMetadata,
|
||||
QuotaLimit: form.opts.QuotaLimit,
|
||||
}); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update vote")
|
||||
}, form.options, form.formFields); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update vote with options")
|
||||
renderVoteFormWithInput(ctx, true, vote.ID, "", true)
|
||||
return
|
||||
}
|
||||
|
||||
// 事务重建选项与表单字段(草稿阶段无历史数据,删除重建安全)
|
||||
if err := db.VoteOptions.ReplaceByVoteID(ctx.Request().Context(), vote.ID, form.options); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to replace vote options")
|
||||
renderVoteFormWithInput(ctx, true, vote.ID, i18n.T(ctx.Lang(), "vote.err.optionUpdateFailed"), false)
|
||||
return
|
||||
}
|
||||
for i := range form.formFields {
|
||||
form.formFields[i].VoteID = vote.ID
|
||||
}
|
||||
if err := db.VoteFormFields.ReplaceByVoteID(ctx.Request().Context(), vote.ID, form.formFields); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to replace vote form fields")
|
||||
renderVoteFormWithInput(ctx, true, vote.ID, i18n.T(ctx.Lang(), "vote.err.fieldUpdateFailed"), false)
|
||||
return
|
||||
}
|
||||
|
||||
// 选项/字段已重建,结果聚合可能变化:主动失效该投票的结果缓存。
|
||||
votepkg.InvalidateResultsCache(vote.ID)
|
||||
|
||||
|
||||
+29
-7
@@ -116,7 +116,10 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
|
||||
|
||||
switch v.Type {
|
||||
case db.VoteTypeSingle:
|
||||
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
|
||||
options, err := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "list vote options")
|
||||
}
|
||||
// 一次分组查询替代逐选项 COUNT(避免 N+1)
|
||||
counts, err := db.VoteSubmissions.CountGroupByOptionID(reqCtx, v.ID)
|
||||
if err != nil {
|
||||
@@ -132,9 +135,15 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
|
||||
}
|
||||
|
||||
case db.VoteTypeMultiple:
|
||||
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
|
||||
options, err := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "list vote options")
|
||||
}
|
||||
submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "list vote submissions")
|
||||
}
|
||||
counts := make(map[uint]int64, len(options))
|
||||
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
|
||||
var totalSelections int64
|
||||
for _, s := range submissions {
|
||||
var data struct {
|
||||
@@ -163,9 +172,12 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
|
||||
}
|
||||
|
||||
case db.VoteTypeRating:
|
||||
submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "list vote submissions")
|
||||
}
|
||||
scoreCounts := make(map[int]int64)
|
||||
var sum int64
|
||||
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
|
||||
for _, s := range submissions {
|
||||
var data struct {
|
||||
Rating int `json:"rating"`
|
||||
@@ -184,10 +196,16 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
|
||||
}
|
||||
|
||||
case db.VoteTypeRank:
|
||||
options, _ := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
|
||||
options, err := db.VoteOptions.ListByVoteID(reqCtx, v.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "list vote options")
|
||||
}
|
||||
submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "list vote submissions")
|
||||
}
|
||||
rankSums := make(map[uint]float64, len(options))
|
||||
rankCounts := make(map[uint]int64, len(options))
|
||||
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
|
||||
for _, s := range submissions {
|
||||
var data struct {
|
||||
Ranking map[string]float64 `json:"ranking"`
|
||||
@@ -215,7 +233,11 @@ func computeResults(reqCtx stdctx.Context, v *db.Vote) (*Results, error) {
|
||||
}
|
||||
|
||||
case db.VoteTypeForm:
|
||||
res.FormFields, _ = db.VoteFormFields.ListByVoteID(reqCtx, v.ID)
|
||||
fields, err := db.VoteFormFields.ListByVoteID(reqCtx, v.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "list vote form fields")
|
||||
}
|
||||
res.FormFields = fields
|
||||
res.FormCount = total
|
||||
res.FormFieldStats = aggregateFormFieldStats(reqCtx, res.FormFields)
|
||||
}
|
||||
|
||||
+16
-20
@@ -724,6 +724,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
||||
if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil {
|
||||
if errors.Is(err, db.ErrVoteAlreadyVoted) {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
|
||||
} else if errors.Is(err, db.ErrVoteNotAccepting) || errors.Is(err, db.ErrFeatureQuotaReached) {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "vote.flash.ended"))
|
||||
} else {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create vote submission")
|
||||
ctx.SetInternalErrorFlash()
|
||||
@@ -738,19 +740,6 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
||||
// 用 InvalidateResultsCache 会让每一票都变成下一个读请求的同步全量重算(O(N²))。
|
||||
ExpireResultsCache(vote.ID)
|
||||
|
||||
// 单份投票的份数上限:达到即自动结束该投票。
|
||||
// (箱主级的累计计数已由 VoteSubmissions.Create 在同一事务里 +1,这里不再重复)
|
||||
if vote.QuotaLimit > 0 {
|
||||
count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to count vote submissions for quota")
|
||||
} else if count >= int64(vote.QuotaLimit) {
|
||||
if err := db.Votes.UpdateStatus(ctx.Request().Context(), vote.ID, db.VoteStatusEnded); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to auto end vote on quota reached")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表单类型投票:异步通知箱主查看新回复(开启邮件通知时)。owner 在上方已取过,直接复用。
|
||||
if vote.Type == db.VoteTypeForm && owner.Notify == db.NotifyTypeEmail {
|
||||
email := owner.Email
|
||||
@@ -820,9 +809,14 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
|
||||
|
||||
// 选项合法性集合
|
||||
validOptions := map[uint]bool{}
|
||||
var voteOptions []*db.VoteOption
|
||||
if v.Type == db.VoteTypeSingle || v.Type == db.VoteTypeMultiple || v.Type == db.VoteTypeRank {
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
for _, o := range options {
|
||||
var err error
|
||||
voteOptions, err = db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "load vote options")
|
||||
}
|
||||
for _, o := range voteOptions {
|
||||
validOptions[o.ID] = true
|
||||
}
|
||||
}
|
||||
@@ -853,13 +847,12 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
|
||||
formData["rating"] = score
|
||||
|
||||
case db.VoteTypeRank:
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
ranking := map[string]float64{}
|
||||
used := map[float64]bool{}
|
||||
for _, o := range options {
|
||||
for _, o := range voteOptions {
|
||||
r, err := strconv.ParseFloat(get(fmt.Sprintf("rank_%d", o.ID)), 64)
|
||||
if err != nil || r < 1 || r > float64(len(options)) {
|
||||
return nil, fmt.Errorf(i18n.T(lang, "vote.err.rankRequired"), len(options))
|
||||
if err != nil || r < 1 || r > float64(len(voteOptions)) {
|
||||
return nil, fmt.Errorf(i18n.T(lang, "vote.err.rankRequired"), len(voteOptions))
|
||||
}
|
||||
if used[r] {
|
||||
return nil, errors.New(i18n.T(lang, "vote.err.rankDuplicate"))
|
||||
@@ -870,7 +863,10 @@ func parseSubmission(ctx context.Context, v *db.Vote, owner *db.User) (*db.Creat
|
||||
formData["ranking"] = ranking
|
||||
|
||||
case db.VoteTypeForm:
|
||||
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
fields, err := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "load vote form fields")
|
||||
}
|
||||
for _, f := range fields {
|
||||
// 字段选项集合
|
||||
validFieldOptions := map[string]bool{}
|
||||
|
||||
@@ -21,6 +21,13 @@ type mockVoteSubmissions struct {
|
||||
func (m *mockVoteSubmissions) ListByVoteID(ctx stdctx.Context, voteID uint, cursor *dbutil.Cursor) ([]*db.VoteSubmission, error) {
|
||||
return m.submissions, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) ListByVoteIDs(ctx stdctx.Context, voteIDs []uint) (map[uint][]*db.VoteSubmission, error) {
|
||||
out := make(map[uint][]*db.VoteSubmission)
|
||||
for _, s := range m.submissions {
|
||||
out[s.VoteID] = append(out[s.VoteID], s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *mockVoteSubmissions) Create(ctx stdctx.Context, opts db.CreateVoteSubmissionOptions) (*db.VoteSubmission, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user