安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性

This commit is contained in:
tamakyi
2026-11-05 12:17:05 +08:00
parent 7636456d4e
commit 25aee35030
18 changed files with 1002 additions and 194 deletions
+99
View File
@@ -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 {
+17
View File
@@ -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
+3
View File
@@ -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")
}
+163
View File
@@ -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
View File
@@ -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
}
// 新增私聊设置
+1
View File
@@ -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",
})
+49
View File
@@ -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
+114 -65
View File
@@ -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)
}
+142
View File
@@ -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)
+17 -41
View File
@@ -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 {