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:
+25
-5
@@ -369,6 +369,15 @@ func (c *ttlCache) Get(key string) (interface{}, bool) {
|
||||
return item.value, true
|
||||
}
|
||||
|
||||
// Delete 移除单条缓存。
|
||||
// 用于读到「类型不符的脏数据」时丢弃这一条 —— 没有它就只能等下次 Set 覆盖,
|
||||
// 而重建路径万一再次失败,这条脏数据会一直留在缓存里被反复读到。
|
||||
func (c *ttlCache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.items, key)
|
||||
}
|
||||
|
||||
func (c *ttlCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -447,8 +456,13 @@ func ShareCard(ctx context.Context) {
|
||||
// 命中缓存直接返回,避免重复下载图片与重新绘制
|
||||
cacheKey := shareCardCacheKey(user)
|
||||
if cached, ok := shareCardCache.Get(cacheKey); ok {
|
||||
writeShareCardResponse(ctx, cached.([]byte))
|
||||
return
|
||||
if data, isBytes := cached.([]byte); isBytes {
|
||||
writeShareCardResponse(ctx, data)
|
||||
return
|
||||
}
|
||||
// 类型不符:理论上不会发生(key 由本文件独占写入),但裸断言遇到不符就是 panic。
|
||||
// 丢弃这条脏缓存,走下面重新绘制。
|
||||
shareCardCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
|
||||
@@ -712,7 +726,10 @@ func loadImage(url string, w, h int) (image.Image, error) {
|
||||
|
||||
key := fmt.Sprintf("%s|%dx%d", url, w, h)
|
||||
if cached, ok := imageCache.Get(key); ok {
|
||||
return cached.(image.Image), nil
|
||||
if img, isImage := cached.(image.Image); isImage {
|
||||
return img, nil
|
||||
}
|
||||
imageCache.Delete(key)
|
||||
}
|
||||
|
||||
resp, err := shareHTTPClient.Get(url)
|
||||
@@ -1087,8 +1104,11 @@ func QuestionShareCard(ctx context.Context) {
|
||||
|
||||
cacheKey := questionShareCardCacheKey(user, question)
|
||||
if cached, ok := questionShareCache.Get(cacheKey); ok {
|
||||
writeShareCardResponse(ctx, cached.([]byte))
|
||||
return
|
||||
if data, isBytes := cached.([]byte); isBytes {
|
||||
writeShareCardResponse(ctx, data)
|
||||
return
|
||||
}
|
||||
questionShareCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
|
||||
|
||||
Reference in New Issue
Block a user