Files
tamabox/internal/db/idempotency_test.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

163 lines
5.5 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 2026 tamakyi. 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"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// TestSetPinnedIfCurrentOnlyAppliesOnce 覆盖「双击置顶」这个场景。
//
// 原来的 handler 是 read-modify-write:SetPinned(id, !question.IsPinned)。
// 双击时两个请求都读到 is_pinned=false,都写 true,于是用户点了两次、期望
// 「置顶后取消」,结果仍是「置顶」。现在加 current 条件,第二次会因为状态
// 已变而影响 0 行 —— 语义变成「只切换一次」,符合用户本意(手抖点两下)。
func TestSetPinnedIfCurrentOnlyAppliesOnce(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("questions"); err != nil {
t.Fatal(err)
}
})
store := NewQuestionsStore(database)
q, err := store.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "试试置顶",
})
require.NoError(t, err)
require.False(t, q.IsPinned)
// 第一次切换:false -> true,生效
applied, err := store.SetPinnedIfCurrent(ctx, q.ID, false, true)
require.NoError(t, err)
require.True(t, applied, "第一次切换应生效")
// 第二次切换:请求方仍然以为当前是 false(它读到的旧值),写入 true
// 此时数据库已经是 true,条件不成立 -> 0 行
applied, err = store.SetPinnedIfCurrent(ctx, q.ID, false, true)
require.NoError(t, err)
require.False(t, applied, "状态已变,第二次切换不应再生效")
got, err := store.GetByID(ctx, q.ID)
require.NoError(t, err)
require.True(t, got.IsPinned, "最终状态应仍是置顶,而不是被双击反转")
// 正常取消置顶(条件匹配)
applied, err = store.SetPinnedIfCurrent(ctx, q.ID, true, false)
require.NoError(t, err)
require.True(t, applied)
got, err = store.GetByID(ctx, q.ID)
require.NoError(t, err)
require.False(t, got.IsPinned)
}
// TestFollowUpRejectsSecondWhilePreviousUnanswered 覆盖「追问必须等主人回复」。
//
// 这条规则原来只在 handler 里判断(事务外)。双击提交时两个请求都能通过那次检查,
// 随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条内容相同的
// 追问都创建成功,吃掉 2/3 名额,箱主还会收到两封邮件。
// 现在判定挪进事务,第二个请求会拿到 ErrFollowUpAwaitingReply。
func TestFollowUpRejectsSecondWhilePreviousUnanswered(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("follow_ups", "questions"); err != nil {
t.Fatal(err)
}
})
qStore := NewQuestionsStore(database)
q, err := qStore.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "允许我追问吗",
})
require.NoError(t, err)
fStore := NewFollowUpsStore(database)
created, err := fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第一次追问",
})
require.NoError(t, err)
require.True(t, created)
// 第一条还没被回复:第二次追问必须被拒
_, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第一次追问", // 内容相同,模拟双击
})
require.ErrorIs(t, err, ErrFollowUpAwaitingReply)
// 确认库里只有一条(没有因为双击写入两条)
list, err := fStore.GetByQuestionID(ctx, q.ID)
require.NoError(t, err)
require.Len(t, list, 1, "双击不应产生两条追问")
// 主人回复后,允许继续追问
require.NoError(t, fStore.AnswerByID(ctx, q.ID, list[0].ID, "答:可以"))
created, err = fStore.CreateIfBelowLimit(ctx, CreateFollowUpOptions{
QuestionID: q.ID,
Content: "第二次追问",
})
require.NoError(t, err)
require.True(t, created, "上一条已回复后应允许继续追问")
}
// TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce 覆盖并发删除会话时的配额回退。
//
// 原来是 check-then-act:两次并发请求都读到 is_deleted_by_owner=false,
// 都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉
// 其他会话的额度,表现为定量收集提前关闭。
func TestMarkDeletedByOwnerConcurrentDecrementsQuotaOnce(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("conversations", "feature_controls"); err != nil {
t.Fatal(err)
}
})
const ownerID = 7
// 先把私聊配额计数做到 5
fcStore := NewFeatureControlsStore(database)
require.NoError(t, fcStore.IncrementQuota(ctx, ownerID, FeatureChat, 5))
before, err := fcStore.Get(ctx, ownerID, FeatureChat)
require.NoError(t, err)
require.EqualValues(t, 5, before.QuotaCount)
convStore := NewConversationStore(database)
conv, err := convStore.Create(ctx, CreateConversationOptions{OwnerID: ownerID})
require.NoError(t, err)
// 并发两次删除(模拟双击 / 前端重复提交)
var wg sync.WaitGroup
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
errs[idx] = convStore.MarkDeletedByOwner(ctx, conv.ID)
}(i)
}
wg.Wait()
require.NoError(t, errs[0])
require.NoError(t, errs[1])
after, err := fcStore.Get(ctx, ownerID, FeatureChat)
require.NoError(t, err)
require.EqualValues(t, 4, after.QuotaCount,
"两个并发删除请求只应回退一次配额(5 -> 4),而不是两次(5 -> 3)")
}