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
+9
View File
@@ -82,6 +82,9 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
// 服务端校验"等主人回复":模板仅在前一条被回复后才展示追问表单,
// 但 POST 可被直接构造绕过,因此必须检查最新一条追问是否已回复。
//
// 这一段只是「快速失败」省掉一次事务开销;并发/双击下的正确性由
// db.FollowUps.CreateIfBelowLimit 在事务内重新判定(见 ErrFollowUpAwaitingReply)。
latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get latest follow up")
@@ -118,6 +121,12 @@ func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Questi
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
if errors.Is(err, db.ErrFollowUpAwaitingReply) {
// 事务层的兜底(并发双击时会走到这里)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create follow up")
ctx.SetInternalErrorFlash()
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
+24 -26
View File
@@ -518,34 +518,28 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
maxMessages = conf.Limits.ChatMaxMessages
}
conv, err := db.Conversations.Create(ctx.Request().Context(), db.CreateConversationOptions{
OwnerID: pageUser.ID,
AskerID: ctx.User.ID,
MaxMessages: maxMessages,
})
// 建会话 + 首条消息,一个事务写完。
// 原来三步分开写:第二步失败时会话已经落库,
// 留下一条 message_count=0 、没有任何消息的「空会话」
// (箱主的私聊列表里能看到、点进去什么都没有),
// 而且 BeforeCreate 已经占掉一个 6 字符 token(唯一索引),孤儿会话会永久占用它。
conv, _, err := db.Conversations.CreateWithFirstMessage(
ctx.Request().Context(),
db.CreateConversationOptions{
OwnerID: pageUser.ID,
AskerID: ctx.User.ID,
MaxMessages: maxMessages,
},
ctx.User.ID,
f.Content,
)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation")
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation with first message")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
_, err = db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: f.Content,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create first message")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if err := db.Conversations.IncrementMessageCount(ctx.Request().Context(), conv.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
}
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureChat, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment chat quota")
@@ -607,13 +601,17 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/_/" + pageUser.Domain)
// 不把 err.Error() 直接给用户:里面可能带上游返回的原始信息。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "captcha.error")), f)
ctx.Success("question/list")
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/_/" + pageUser.Domain)
// 验证码失败是这里最常见的失败(token 过期、网络抖动)。
// 原来走 Flash + Redirect 会把用户已经写好的正文整段丢掉;
// 这里与其他失败分支一致,用 SetError + Success 回填表单。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "captcha.error")), f)
ctx.Success("question/list")
return
}
+6 -1
View File
@@ -370,7 +370,12 @@ func TogglePin(ctx context.Context, pageUser *db.User, question *db.Question, ca
return
}
if err := db.Questions.SetPinned(ctx.Request().Context(), question.ID, !question.IsPinned); err != nil {
// 条件更新:只有当前状态仍是这次读到的值时才写入。
// 原来直接 SetPinned(!question.IsPinned) 是 read-modify-write,双击时两个请求
// 都读到 false、都写 true,结果停在「置顶」与用户预期的「取消」
// 相反。现在第二次会因状态已变而影响 0 行,即「只切换一次」。
if _, err := db.Questions.SetPinnedIfCurrent(
ctx.Request().Context(), question.ID, question.IsPinned, !question.IsPinned); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle question pinned")
ctx.SetInternalError()
ctx.Success("question/item")
+25 -5
View File
@@ -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))