fix(data): 删除改成一个事务且真删、配额与内容同事务

四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。

1) 删除投票的级联没有事务(route/user/vote.go)
   选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
   中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
   投票已不存在,只能报 not exist,脏数据永远留着。
   → db.Votes.DeleteWithCascade:四张表在同一事务里删完。

2) 删除其实是软删除,个人数据没真删掉
   模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
   提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
   只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
   软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
   → 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
     原来也是三个独立写入(失败只 Warn),一并收进事务。

3) 配额计数会永久偏低
   「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
   箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
   等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
   → 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
     提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
     VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
   注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
   不是「创建了几个投票」—— 所以删除投票不回退这个计数。

4) 改写追问回答每次都发通知邮件
   箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
   好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
   的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。

顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
  与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
  而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
  拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
  数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。

验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
This commit is contained in:
tamakyi
2026-10-29 17:18:28 +08:00
parent 89e263cbd4
commit d5d25c804b
10 changed files with 287 additions and 91 deletions
+34 -3
View File
@@ -360,6 +360,26 @@ func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureSta
return fc.Evaluate(time.Now())
}
// FeatureStateOfStrict 与 FeatureStateOf 相同,但查询失败时返回错误而不回退默认配置。
//
// 给**写入路径**用。FeatureStateOf 的 fail-open 对读展示是对的(配置表抖动不该让
// 页面白屏),但对写入是危险的:它回退到 DefaultFeatureControl —— 提问与投票的
// 默认值是「开启」,于是箱主明明设了「已关闭」或「已达上限」,只要这次读配置失败
// 就被放行,等于定量收集与功能开关都能被一次数据库抖动绕过去。
// 与审核链路「不可用即拒绝」同一口径。
func FeatureStateOfStrict(ctx context.Context, userID uint, feature string) (FeatureState, error) {
if FeatureControls == nil {
// 未装配(测试 / 部分初始化):沿用默认配置,否则所有写路径都会失败。
return DefaultFeatureControl(userID, feature).Evaluate(time.Now()), nil
}
fc, err := FeatureControls.Get(ctx, userID, feature)
if err != nil {
logrus.WithContext(ctx).WithError(err).Errorf("Failed to get feature control %q of user %d", feature, userID)
return FeatureState{}, errors.Wrap(err, "get feature control")
}
return fc.Evaluate(time.Now()), nil
}
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
return FeatureStateOf(ctx, userID, feature).Open
@@ -487,11 +507,22 @@ func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
return incrementQuotaTx(db.WithContext(ctx), userID, feature, delta)
}
// incrementQuotaTx 是配额递增的唯一实现,接受任意 *gorm.DB(可以是事务)。
//
// 为什么要暴露一个「能用事务」的版本:配额与内容必须是同生共死的关系。
// 原来「创建提问」与「quota_count+1」是两次独立写入,后者失败只打一条 Warn ——
// 于是计数比真实行数少,箱主的「已收集 7/10」永远偏低,达到上限后仍能继续收,
// 而重置计数也校准不回来(重置是把计数清零,与实际行数更脱节)。
// 现在内容 store 把这一步放进自己的事务里,任一步失败整体回滚,计数天然对齐。
func incrementQuotaTx(tx *gorm.DB, userID uint, feature string, delta int64) error {
if delta == 0 {
return nil
}
now := time.Now()
q := db.WithContext(ctx).Model(&FeatureControl{}).
q := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature)
if delta < 0 {
q = q.Where("quota_count > 0")
@@ -513,7 +544,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
fc.QuotaCount = delta
}
fc.QuotaResetAt = &now
created := db.WithContext(ctx).
created := tx.
Clauses(clause.OnConflict{DoNothing: true}).
Create(fc)
if created.Error != nil {
@@ -523,7 +554,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
return nil
}
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
retry := db.WithContext(ctx).Model(&FeatureControl{}).
retry := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta),