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 与实际消息数一致)。
This commit is contained in:
@@ -19,6 +19,9 @@ 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)
|
||||
@@ -83,6 +86,52 @@ func (db *conversations) Create(ctx context.Context, opts CreateConversationOpti
|
||||
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 {
|
||||
@@ -216,8 +265,21 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Model(&Conversation{}).Where("id = ?", id).Update("is_deleted_by_owner", true).Error; err != nil {
|
||||
return errors.Wrap(err, "mark conversation deleted by owner")
|
||||
// 用「条件更新 + 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
|
||||
}
|
||||
|
||||
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
|
||||
|
||||
Reference in New Issue
Block a user