Files
tamabox/internal/db/conversation.go
T
tamakyi 0124e392c7 fix(correctness): 三处幂等缺陷、私聊首条事务化、验证码失败不再丢正文
四处都属于「并发/失败路径下状态不一致」,用户侧表现为「点了没反应」「发了没收到」。

1) 删除会话重复回退配额
   MarkDeletedByOwner 是 check-then-act:双击时两个请求都读到 is_deleted_by_owner=false,
   都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉其他会话的额度,
   表现为定量收集提前关闭,箱主重置计数也校准不回来。
   → 改条件更新 + RowsAffected 决定是否回退。

2) 双击产生两条重复追问
   「上一条追问必须先被回复」原来只在 handler 里判断(事务外):两个请求都能通过那次检查,
   随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条相同追问都建成,
   吃掉 2/3 名额,箱主还收两封邮件。
   → 判定挪进事务,新增 ErrFollowUpAwaitingReply;handler 的预检查保留作为快速失败。

3) 双击置顶结果与预期相反
   SetPinned(id, !question.IsPinned) 是 read-modify-write:两个请求都读到 false 都写 true,
   用户点两次期望「置顶后取消」,实际仍是「置顶」。
   → 新增 SetPinnedIfCurrent(current, next),第二次因状态已变而 0 行,语义是「只切换一次」。

4) 私聊首条三步写入无事务
   建会话 → 写首条消息 → 计数是三次独立写入,第二步失败会留下 message_count=0 的空会话,
   箱主列表里能点进去却什么都没有;而 BeforeCreate 已占掉一个 6 字符 token(唯一索引),
   孤儿会话永久占用它。
   → 新增 CreateWithFirstMessage,三步收进一个事务。

5) 验证码失败丢正文
   该分支原来走 SetErrorFlash + Redirect,而其它失败分支都是 SetError + Success 回填表单。
   验证码失败恰恰是最常见的失败(token 过期、网络抖动),结果用户写好的正文整段丢失。
   → 改为同请求渲染并回填;错误文案不再透传 err.Error()(可能含上游原始信息)。

6) 5 处裸类型断言改带 ok(share.go ×3 / context.go ×1 / user/vote.go ×1),
   断言失败时丢弃脏缓存并走重建,而不是 panic 掉整个请求。share.go 的 ttlCache 补 Delete。

验证:内部 DB 测试 3 项(并发删除只回退一次配额 5→4、双击只产生一条追问、
双击只切换一次置顶);verify_round3.py 6/6(验证码失败 200+正文回填+不建提问、
私聊首条 message_count 与实际消息数一致)。
2026-10-29 16:28:34 +08:00

295 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")
}
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
}