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:
+78
-29
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/thanhpk/randstr"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
@@ -27,6 +26,8 @@ var Questions QuestionsStore
|
||||
type QuestionsStore interface {
|
||||
Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error)
|
||||
GetByID(ctx context.Context, id uint) (*Question, error)
|
||||
// ListByIDsAndUser 按 ID 批量取某箱主名下的提问(一次查询,避免 N+1)。
|
||||
ListByIDsAndUser(ctx context.Context, ids []uint, userID uint) ([]*Question, error)
|
||||
GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
|
||||
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
|
||||
AnswerByID(ctx context.Context, id uint, answer string) error
|
||||
@@ -110,6 +111,12 @@ type CreateQuestionOptions struct {
|
||||
IsPrivate bool
|
||||
}
|
||||
|
||||
// Create 新建提问,并在**同一个事务**里累加箱主的提问收集计数。
|
||||
//
|
||||
// 为什么配额要和提问同事务:原来是 handler 里「先 Create、再 IncrementQuota」
|
||||
// 两次独立写入,后者失败只打一条 Warn。于是 quota_count 比真实提问数少,
|
||||
// 箱主看到的「已收集 7/10」永远偏低;而达到上限后因为计数偏小,实际还能继续收,
|
||||
// 等于定量收集被静默绕过。重置计数也校准不回来(重置是把计数清零,更脱节)。
|
||||
func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) {
|
||||
question := Question{
|
||||
FromIP: opts.FromIP,
|
||||
@@ -120,7 +127,20 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
|
||||
AskerUserID: opts.AskerUserID,
|
||||
IsPrivate: opts.IsPrivate,
|
||||
}
|
||||
return &question, db.WithContext(ctx).Create(&question).Error
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&question).Error; err != nil {
|
||||
return errors.Wrap(err, "create question")
|
||||
}
|
||||
// 追问不计入配额,只算「一条提问」—— 与界面上「已收集」的口径一致。
|
||||
if err := incrementQuotaTx(tx, opts.UserID, FeatureQuestion, 1); err != nil {
|
||||
return errors.Wrap(err, "increment question quota")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &question, nil
|
||||
}
|
||||
|
||||
type UpdateQuestionCensorOptions struct {
|
||||
@@ -169,6 +189,25 @@ func checkTextCensorResponseValid(raw json.RawMessage) bool {
|
||||
|
||||
var ErrQuestionNotExist = errors.New("提问不存在")
|
||||
|
||||
// ListByIDsAndUser 按 ID 批量取提问,且必须属于指定箱主。
|
||||
//
|
||||
// 一次 IN 查询而不是循环 GetByID,供「提问者找回自己的提问」那个接口用:
|
||||
// 浏览器里可能存着十几条记录,逐条查就是典型的 N+1。
|
||||
// 只选择判定与摘要需要的列 —— 不取审核元数据、图片、追问等无关字段。
|
||||
func (db *questions) ListByIDsAndUser(ctx context.Context, ids []uint, userID uint) ([]*Question, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var questions []*Question
|
||||
if err := db.WithContext(ctx).
|
||||
Select("id", "user_id", "token", "answer", "content", "created_at").
|
||||
Where("id IN ? AND user_id = ?", ids, userID).
|
||||
Find(&questions).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "list questions by ids and user")
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
func (db *questions) GetByID(ctx context.Context, id uint) (*Question, error) {
|
||||
var question Question
|
||||
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
|
||||
@@ -442,37 +481,47 @@ func answerUpdates(prevAnswer, answer string, extra map[string]interface{}) map[
|
||||
return updates
|
||||
}
|
||||
|
||||
// DeleteByID 删除提问及其追问、图片关联,并回退箱主的收集计数。
|
||||
//
|
||||
// 三步在同一个事务里完成:原来删除本体后,级联清理与配额回退还各自独立执行、
|
||||
// 失败只打日志 —— 中途失败会留下「提问没了但追问还在」的孤儿记录,
|
||||
// 或者计数没退回(达上限后功能一直关闭,箱主只能手动重置)。
|
||||
//
|
||||
// 用 Unscoped 真删:用户点删除的预期就是删掉。软删只是让界面上看不见,
|
||||
// 提问里的 FromIP 与正文仍留在库里,等于「删了但没删干净」。
|
||||
func (db *questions) DeleteByID(ctx context.Context, id uint) error {
|
||||
var question Question
|
||||
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrQuestionNotExist
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var question Question
|
||||
if err := tx.First(&question, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrQuestionNotExist
|
||||
}
|
||||
return errors.Wrap(err, "get question by ID")
|
||||
}
|
||||
return errors.Wrap(err, "get question by ID")
|
||||
}
|
||||
|
||||
if err := db.WithContext(ctx).Delete(&Question{}, id).Error; err != nil {
|
||||
return errors.Wrap(err, "delete question")
|
||||
}
|
||||
|
||||
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
|
||||
// 直接用本 store 的 *gorm.DB 构造子 store,避免依赖包级全局单例
|
||||
// (全局单例仅在 db.Init 装配,测试/部分初始化场景下为 nil 会 panic)。
|
||||
if err := NewFollowUpsStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
|
||||
}
|
||||
if err := NewUploadImagesStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id)
|
||||
}
|
||||
|
||||
// 回退定量收集计数:删除提问等价于少一条已收集内容,
|
||||
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
|
||||
if question.UserID != 0 {
|
||||
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID)
|
||||
if err := tx.Unscoped().Delete(&Question{}, id).Error; err != nil {
|
||||
return errors.Wrap(err, "delete question")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
|
||||
// 直接用事务句柄构造子 store(NewXStore(tx)),子操作才会落在同一个事务里;
|
||||
// 用包级全局单例在测试/部分初始化场景下还会是 nil。
|
||||
if err := NewFollowUpsStore(tx).DeleteByQuestionID(ctx, id); err != nil {
|
||||
return errors.Wrap(err, "delete follow ups of question")
|
||||
}
|
||||
if err := NewUploadImagesStore(tx).DeleteByQuestionID(ctx, id); err != nil {
|
||||
return errors.Wrap(err, "delete upload images of question")
|
||||
}
|
||||
|
||||
// 回退定量收集计数:删除提问等价于少一条已收集内容,
|
||||
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
|
||||
if question.UserID != 0 {
|
||||
if err := incrementQuotaTx(tx, question.UserID, FeatureQuestion, -1); err != nil {
|
||||
return errors.Wrap(err, "decrement question quota")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type GetQuestionsCountOptions struct {
|
||||
|
||||
Reference in New Issue
Block a user