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:
tamakyi
2026-10-29 16:28:34 +08:00
parent 0041cc51f8
commit 0124e392c7
9 changed files with 332 additions and 35 deletions
+18
View File
@@ -42,6 +42,13 @@ type QuestionsStore interface {
SetPrivate(ctx context.Context, id uint) error
SetPublic(ctx context.Context, id uint) error
SetPinned(ctx context.Context, id uint, pinned bool) error
// SetPinnedIfCurrent 仅在「当前置顶状态 == current」时才写成 pinned,返回是否真的写入。
//
// 用于「切换」这类 read-modify-write:handler 拿到的 is_pinned 是本次请求早期读的,
// 双击或并发提交时两个请求都会算出「取反」并写同一个值,状态机不是原子的
// (用户点两次期望「置顶后又取消」,实际仍是「置顶」)。
// 加上 current 条件后,第二次会因为状态已变而 0 行 —— 语义上「只切换一次」。
SetPinnedIfCurrent(ctx context.Context, id uint, current, pinned bool) (bool, error)
SetRead(ctx context.Context, id uint) error
SetUnread(ctx context.Context, id uint) error
// NextUnread 取该用户最早的一条未读提问(未读 = is_read=false 且未回答)。
@@ -512,6 +519,17 @@ func (db *questions) SetPinned(ctx context.Context, id uint, pinned bool) error
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_pinned", pinned).Error
}
// SetPinnedIfCurrent 条件更新置顶状态(详见接口注释)。
func (db *questions) SetPinnedIfCurrent(ctx context.Context, id uint, current, pinned bool) (bool, error) {
res := db.WithContext(ctx).Model(&Question{}).
Where("id = ? AND is_pinned = ?", id, current).
Update("is_pinned", pinned)
if res.Error != nil {
return false, errors.Wrap(res.Error, "set question pinned if current")
}
return res.RowsAffected > 0, nil
}
func (db *questions) SetPublic(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", false).Error
}