四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。
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。
302 lines
12 KiB
Go
302 lines
12 KiB
Go
// 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
|
||
}
|