四处都属于「并发/失败路径下状态不一致」,用户侧表现为「点了没反应」「发了没收到」。 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 与实际消息数一致)。
163 lines
5.5 KiB
Go
163 lines
5.5 KiB
Go
// 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)")
|
||
}
|