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

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

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

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

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

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

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

302 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Conversations ConversationStore
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)
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)
GetByIDAndAsker(ctx context.Context, id, askerID uint) (*Conversation, error)
IncrementMessageCount(ctx context.Context, id uint) error
IncrementMessageCountIfBelow(ctx context.Context, id uint, max int) (bool, error)
DecrementMessageCount(ctx context.Context, id uint) error
MaybeClose(ctx context.Context, id uint, max int) error
Close(ctx context.Context, id uint) error
// ListByOwner 返回某箱主拥有的会话。limit>0 时启用分页(取 limit 条、跳过 offset 条),
// 用于私聊列表分页;导出场景传 limit=0 取全量,行为与改造前一致。
ListByOwner(ctx context.Context, ownerID uint, limit, offset int) ([]*Conversation, error)
// CountByOwner 统计某箱主拥有的会话总数(分页看板与"加载更多"判定用)。
CountByOwner(ctx context.Context, ownerID uint) (int64, error)
// ListByAsker 返回某提问者发起的会话;分页语义同 ListByOwner。
ListByAsker(ctx context.Context, askerID uint, limit, offset int) ([]*Conversation, error)
// CountByAsker 统计某提问者发起的会话总数。
CountByAsker(ctx context.Context, askerID uint) (int64, error)
MarkDeletedByOwner(ctx context.Context, id uint) error
}
func NewConversationStore(db *gorm.DB) ConversationStore {
return &conversations{db}
}
type conversations struct {
*gorm.DB
}
type Conversation struct {
dbutil.Model
OwnerID uint `gorm:"index:idx_conversation_owner" json:"owner_id"`
AskerID uint `gorm:"index:idx_conversation_asker" json:"asker_id"`
Token string `gorm:"size:6;uniqueIndex" json:"token"`
MaxMessages int `gorm:"default:20" json:"max_messages"`
MessageCount int `gorm:"default:0" json:"message_count"`
IsClosed bool `gorm:"default:false" json:"is_closed"`
IsDeletedByOwner bool `gorm:"default:false" json:"is_deleted_by_owner"` // 新增
}
func (c *Conversation) BeforeCreate(tx *gorm.DB) error {
c.MessageCount = 0
c.Token = randstr.String(6)
return nil
}
type CreateConversationOptions struct {
OwnerID uint
AskerID uint
MaxMessages int
}
func (db *conversations) Create(ctx context.Context, opts CreateConversationOptions) (*Conversation, error) {
conversation := &Conversation{
OwnerID: opts.OwnerID,
AskerID: opts.AskerID,
MaxMessages: opts.MaxMessages,
}
if err := db.WithContext(ctx).Create(conversation).Error; err != nil {
return nil, errors.Wrap(err, "create conversation")
}
return conversation, nil
}
// CreateWithFirstMessage 在一个事务里完成「建会话 + 写首条消息 + 消息计数」。
//
// 原来这三步是三次独立写入:第二步失败时会话已经提交,于是留下一条
// message_count=0、没有任何消息的「空会话」——箱主的私聊列表里能看到它,
// 点进去什么都没有;更麻烦的是 Conversations.Create 的 BeforeCreate 已经
// 生成并占用了一个 6 字符 token(唯一索引),孤儿会话会永久占着这个 token。
func (db *conversations) CreateWithFirstMessage(
ctx context.Context, opts CreateConversationOptions, senderID uint, content string,
) (*Conversation, *Message, error) {
var conv *Conversation
var msg *Message
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
conv = &Conversation{
OwnerID: opts.OwnerID,
AskerID: opts.AskerID,
MaxMessages: opts.MaxMessages,
}
// 走 tx 而不是 db:BeforeCreate 里生成 token 的 hook 同样会被触发。
if err := tx.Create(conv).Error; err != nil {
return errors.Wrap(err, "create conversation")
}
msg = &Message{
ConversationID: conv.ID,
SenderID: senderID,
Content: content,
}
if err := tx.Create(msg).Error; err != nil {
return errors.Wrap(err, "create first message")
}
// 原子自增,与 IncrementMessageCount 保持同一写法
if err := tx.Model(&Conversation{}).
Where("id = ?", conv.ID).
UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error; err != nil {
return errors.Wrap(err, "increment message count")
}
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)。
// 放在事务里而不是 handler 里:原来 handler 在事务之后调 IncrementQuota 且
// 失败只打 Warn,会话建成了但计数没加,箱主的「已收集」偏少、上限被绕过。
if err := incrementQuotaTx(tx, opts.OwnerID, FeatureChat, 1); err != nil {
return errors.Wrap(err, "increment chat quota")
}
return nil
})
if err != nil {
return nil, nil, err
}
return conv, 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 {
return nil, errors.Wrap(err, "get conversation")
}
return &conv, nil
}
func (db *conversations) GetByToken(ctx context.Context, token string) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("token = ?", token).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get conversation by token")
}
return &conv, nil
}
func (db *conversations) GetByIDAndOwner(ctx context.Context, id, ownerID uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("id = ? AND owner_id = ?", id, ownerID).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get by owner")
}
return &conv, nil
}
func (db *conversations) GetByIDAndAsker(ctx context.Context, id, askerID uint) (*Conversation, error) {
var conv Conversation
if err := db.WithContext(ctx).Where("id = ? AND asker_id = ?", id, askerID).First(&conv).Error; err != nil {
return nil, errors.Wrap(err, "get by asker")
}
return &conv, nil
}
func (db *conversations) IncrementMessageCount(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error
}
// IncrementMessageCountIfBelow 原子地检查并递增消息计数:
// 仅当 message_count < max 且未关闭时才 +1,返回 true。
// 并发场景下(同一对话两端同时发送)可防止消息数超出上限。
func (db *conversations) IncrementMessageCountIfBelow(ctx context.Context, id uint, max int) (bool, error) {
res := db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count < ? AND is_closed = ?", id, max, false).
UpdateColumn("message_count", gorm.Expr("message_count + 1"))
if res.Error != nil {
return false, errors.Wrap(res.Error, "increment message count if below")
}
return res.RowsAffected > 0, nil
}
// DecrementMessageCount 回滚一次消息计数(计数已递增但消息落库失败时调用)。
func (db *conversations) DecrementMessageCount(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count > 0", id).
UpdateColumn("message_count", gorm.Expr("message_count - 1")).Error
}
// MaybeClose 当消息数达到上限时关闭对话(幂等,供发送消息后调用)。
func (db *conversations) MaybeClose(ctx context.Context, id uint, max int) error {
return db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND message_count >= ? AND is_closed = ?", id, max, false).
Update("is_closed", true).Error
}
func (db *conversations) Close(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_closed", true).Error
}
func (db *conversations) ListByOwner(ctx context.Context, ownerID uint, limit, offset int) ([]*Conversation, error) {
var convs []*Conversation
q := db.WithContext(ctx).Where("owner_id = ? AND is_deleted_by_owner = ? AND token != ''", ownerID, false).
Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit)
if offset > 0 {
q = q.Offset(offset)
}
}
if err := q.Find(&convs).Error; err != nil {
return nil, errors.Wrap(err, "list by owner")
}
return convs, nil
}
func (db *conversations) CountByOwner(ctx context.Context, ownerID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&Conversation{}).
Where("owner_id = ? AND is_deleted_by_owner = ? AND token != ''", ownerID, false).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count by owner")
}
return count, nil
}
func (db *conversations) ListByAsker(ctx context.Context, askerID uint, limit, offset int) ([]*Conversation, error) {
var convs []*Conversation
q := db.WithContext(ctx).Where("asker_id = ? AND is_deleted_by_owner = ? AND token != ''", askerID, false).
Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit)
if offset > 0 {
q = q.Offset(offset)
}
}
if err := q.Find(&convs).Error; err != nil {
return nil, errors.Wrap(err, "list by asker")
}
return convs, nil
}
func (db *conversations) CountByAsker(ctx context.Context, askerID uint) (int64, error) {
var count int64
if err := db.WithContext(ctx).Model(&Conversation{}).
Where("asker_id = ? AND is_deleted_by_owner = ? AND token != ''", askerID, false).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count by asker")
}
return count, nil
}
func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error {
var conv Conversation
if err := db.WithContext(ctx).First(&conv, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 已删除或不存在:幂等,不影响调用方。
return nil
}
return errors.Wrap(err, "get conversation for delete")
}
if conv.IsDeletedByOwner {
// 已经是删除状态:幂等返回,避免重复请求多次回退配额计数。
return nil
}
// 用「条件更新 + RowsAffected」代替纯 Update:
// 原来的写法是 check-then-act,双击「删除会话」或前端重复提交时,两个请求
// 都能读到 is_deleted_by_owner=false,于是都执行 UPDATE、都执行一次
// IncrementQuota(-1)。IncrementQuota 有 quota_count>0 兜底不会变负,
// 但会白吃掉本用户其他会话的计数 —— 表现为定量收集提前关闭,
// 而箱主重置计数也校准不回来。
res := db.WithContext(ctx).Model(&Conversation{}).
Where("id = ? AND is_deleted_by_owner = ?", id, false).
Update("is_deleted_by_owner", true)
if res.Error != nil {
return errors.Wrap(res.Error, "mark conversation deleted by owner")
}
if res.RowsAffected == 0 {
// 并发里被另一个请求抢先删掉了:它已经做过配额回退,这里不能再来一次。
return nil
}
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
// 否则达上限后即便删掉旧会话,功能仍会显示 quota_reached 关闭,须手动重置计数。
if conv.OwnerID != 0 {
// 用本 store 的 *gorm.DB 构造,避免依赖包级全局单例(详见 DeleteByID 注释)。
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement chat quota for user %d", conv.OwnerID)
}
}
return nil
}