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
+7
View File
@@ -124,6 +124,13 @@ func (db *conversations) CreateWithFirstMessage(
UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error; err != nil { UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error; err != nil {
return errors.Wrap(err, "increment message count") return errors.Wrap(err, "increment message count")
} }
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)。
// 放在事务里而不是 handler 里:原来 handler 在事务之后调 IncrementQuota 且
// 失败只打 Warn,会话建成了但计数没加,箱主的「已收集」偏少、上限被绕过。
if err := incrementQuotaTx(tx, opts.OwnerID, FeatureChat, 1); err != nil {
return errors.Wrap(err, "increment chat quota")
}
return nil return nil
}) })
if err != nil { if err != nil {
+34 -3
View File
@@ -360,6 +360,26 @@ func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureSta
return fc.Evaluate(time.Now()) 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 便捷入口:某用户某功能当前是否可用。 // FeatureOpen 便捷入口:某用户某功能当前是否可用。
func FeatureOpen(ctx context.Context, userID uint, feature string) bool { func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
return FeatureStateOf(ctx, userID, feature).Open return FeatureStateOf(ctx, userID, feature).Open
@@ -487,11 +507,22 @@ func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。 // IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。 // 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error { 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 { if delta == 0 {
return nil return nil
} }
now := time.Now() now := time.Now()
q := db.WithContext(ctx).Model(&FeatureControl{}). q := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature) Where("user_id = ? AND feature = ?", userID, feature)
if delta < 0 { if delta < 0 {
q = q.Where("quota_count > 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.QuotaCount = delta
} }
fc.QuotaResetAt = &now fc.QuotaResetAt = &now
created := db.WithContext(ctx). created := tx.
Clauses(clause.OnConflict{DoNothing: true}). Clauses(clause.OnConflict{DoNothing: true}).
Create(fc) Create(fc)
if created.Error != nil { if created.Error != nil {
@@ -523,7 +554,7 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
return nil return nil
} }
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。 // 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
retry := db.WithContext(ctx).Model(&FeatureControl{}). retry := tx.Model(&FeatureControl{}).
Where("user_id = ? AND feature = ?", userID, feature). Where("user_id = ? AND feature = ?", userID, feature).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"quota_count": gorm.Expr("quota_count + ?", delta), "quota_count": gorm.Expr("quota_count + ?", delta),
+27 -13
View File
@@ -38,9 +38,10 @@ type FollowUpsStore interface {
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。 // AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。 // 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。 // UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
// 不存在/不属于该问题返回 ErrFollowUpNotFound。 // 不存在/不属于该问题返回 ErrFollowUpNotFound。
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (wasFirst bool, err error)
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。 // DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
DeleteByQuestionID(ctx context.Context, questionID uint) error DeleteByQuestionID(ctx context.Context, questionID uint) error
} }
@@ -208,18 +209,31 @@ func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, a
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权), // UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。 // 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error { func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (bool, error) {
res := db.WithContext(ctx). var wasFirst bool
Model(&FollowUp{}). err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
Where("id = ? AND question_id = ?", id, questionID). // 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
Update("answer", answer) // 它手上的 question 是请求开始时读的,并发下可能已经过期。
if res.Error != nil { var fu FollowUp
return errors.Wrap(res.Error, "update follow up answer") if err := tx.Where("id = ? AND question_id = ?", id, questionID).First(&fu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrFollowUpNotFound
}
return errors.Wrap(err, "get follow up")
}
wasFirst = fu.Answer == ""
if err := tx.Model(&FollowUp{}).
Where("id = ? AND question_id = ?", id, questionID).
Update("answer", answer).Error; err != nil {
return errors.Wrap(err, "update follow up answer")
}
return nil
})
if err != nil {
return false, err
} }
if res.RowsAffected == 0 { return wasFirst, nil
return ErrFollowUpNotFound
}
return nil
} }
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。 // DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
+78 -29
View File
@@ -14,7 +14,6 @@ import (
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr" "github.com/thanhpk/randstr"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
@@ -27,6 +26,8 @@ var Questions QuestionsStore
type QuestionsStore interface { type QuestionsStore interface {
Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error)
GetByID(ctx context.Context, id uint) (*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) GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error) GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) error AnswerByID(ctx context.Context, id uint, answer string) error
@@ -110,6 +111,12 @@ type CreateQuestionOptions struct {
IsPrivate bool IsPrivate bool
} }
// Create 新建提问,并在**同一个事务**里累加箱主的提问收集计数。
//
// 为什么配额要和提问同事务:原来是 handler 里「先 Create、再 IncrementQuota」
// 两次独立写入,后者失败只打一条 Warn。于是 quota_count 比真实提问数少,
// 箱主看到的「已收集 7/10」永远偏低;而达到上限后因为计数偏小,实际还能继续收,
// 等于定量收集被静默绕过。重置计数也校准不回来(重置是把计数清零,更脱节)。
func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) { func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) {
question := Question{ question := Question{
FromIP: opts.FromIP, FromIP: opts.FromIP,
@@ -120,7 +127,20 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
AskerUserID: opts.AskerUserID, AskerUserID: opts.AskerUserID,
IsPrivate: opts.IsPrivate, 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 { type UpdateQuestionCensorOptions struct {
@@ -169,6 +189,25 @@ func checkTextCensorResponseValid(raw json.RawMessage) bool {
var ErrQuestionNotExist = errors.New("提问不存在") 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) { func (db *questions) GetByID(ctx context.Context, id uint) (*Question, error) {
var question Question var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil { 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 return updates
} }
// DeleteByID 删除提问及其追问、图片关联,并回退箱主的收集计数。
//
// 三步在同一个事务里完成:原来删除本体后,级联清理与配额回退还各自独立执行、
// 失败只打日志 —— 中途失败会留下「提问没了但追问还在」的孤儿记录,
// 或者计数没退回(达上限后功能一直关闭,箱主只能手动重置)。
//
// 用 Unscoped 真删:用户点删除的预期就是删掉。软删只是让界面上看不见,
// 提问里的 FromIP 与正文仍留在库里,等于「删了但没删干净」。
func (db *questions) DeleteByID(ctx context.Context, id uint) error { func (db *questions) DeleteByID(ctx context.Context, id uint) error {
var question Question return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := db.WithContext(ctx).First(&question, id).Error; err != nil { var question Question
if errors.Is(err, gorm.ErrRecordNotFound) { if err := tx.First(&question, id).Error; err != nil {
return ErrQuestionNotExist 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 { if err := tx.Unscoped().Delete(&Question{}, id).Error; err != nil {
return errors.Wrap(err, "delete question") 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)
} }
}
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 { type GetQuestionsCountOptions struct {
+22 -4
View File
@@ -68,6 +68,9 @@ type CreateVoteSubmissionOptions struct {
VoterIP string VoterIP string
FormData datatypes.JSON FormData datatypes.JSON
CustomText string CustomText string
// QuotaUserID 是投票的发起人(箱主)。非 0 时在同一事务里把票数计入
// 该用户「投票」功能的定量收集计数;为 0 表示不计数(测试/内部调用)。
QuotaUserID uint
} }
var ( var (
@@ -88,11 +91,26 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
FormData: opts.FormData, FormData: opts.FormData,
CustomText: opts.CustomText, CustomText: opts.CustomText,
} }
if err := db.WithContext(ctx).Create(submission).Error; err != nil { err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if isDuplicateKeyError(err) { if err := tx.Create(submission).Error; err != nil {
return nil, ErrVoteAlreadyVoted if isDuplicateKeyError(err) {
return ErrVoteAlreadyVoted
}
return errors.Wrap(err, "create vote submission")
} }
return nil, errors.Wrap(err, "create vote submission") // 箱主级的「投票」定量收集按累计收到的票数计 —— 与创建投票无关,
// 所以这一步只能放在提交成功这里。放在事务内的理由同提问:
// 原来 handler 里是独立写入且失败只 Warn,跳过一票就永久少一票,
// 达上限时自动结束投票会晚一步,箱主的「已收集」也一直偏低。
if opts.QuotaUserID != 0 {
if err := incrementQuotaTx(tx, opts.QuotaUserID, FeatureVote, 1); err != nil {
return errors.Wrap(err, "increment vote quota")
}
}
return nil
})
if err != nil {
return nil, err
} }
return submission, nil return submission, nil
} }
+46
View File
@@ -70,6 +70,8 @@ type VotesStore interface {
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
UpdateStatus(ctx context.Context, id uint, status string) error UpdateStatus(ctx context.Context, id uint, status string) error
DeleteByID(ctx context.Context, id uint) error DeleteByID(ctx context.Context, id uint) error
// DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。
DeleteWithCascade(ctx context.Context, id uint) error
CountByUserID(ctx context.Context, userID uint) (int64, error) CountByUserID(ctx context.Context, userID uint) (int64, error)
} }
@@ -359,6 +361,50 @@ func (db *votes) DeleteByID(ctx context.Context, id uint) error {
return nil return nil
} }
// DeleteWithCascade 删除投票及其全部关联数据(选项、表单字段、提交记录)。
//
// 为什么必须是一个事务:原来 handler 里是 4 次独立写入,前 3 次失败只打 Warn,
// 于是可能留下「选项没了但票数还在」(结果页统计全 0 而提交记录占着库)、
// 「投票没了但提交记录成孤儿」这类无法通过界面修复的状态 —— 箱主只能重试删除,
// 而重试时 vote 已不存在,直接报 not exist,脏数据永远留着。
//
// 为什么是 Unscoped(真删)而不是软删:
// 1. 用户点「删除」的预期就是删掉,不是「我还能看到但数据库里还在」;
// 2. 提交记录里有参与者填写的文本、voter_ip、voter_token —— 软删等于把这些
// 个人数据永久留在库里,而界面上已经看不到了,等于删了但没删干净;
// 3. 软删还会占住 link_token 唯一索引,删过的链接永远无法复用。
func (db *votes) DeleteWithCascade(ctx context.Context, id uint) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var vote Vote
if err := tx.First(&vote, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrVoteNotExist
}
return errors.Wrap(err, "get vote by ID")
}
// 顺序:先清子表再删主表。没有外键约束(项目不用 FK),
// 所以顺序只影响可读性;真正的保证来自事务本身。
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteSubmission{}).Error; err != nil {
return errors.Wrap(err, "delete vote submissions")
}
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteOption{}).Error; err != nil {
return errors.Wrap(err, "delete vote options")
}
if err := tx.Unscoped().Where("vote_id = ?", id).Delete(&VoteFormField{}).Error; err != nil {
return errors.Wrap(err, "delete vote form fields")
}
if err := tx.Unscoped().Delete(&Vote{}, id).Error; err != nil {
return errors.Wrap(err, "delete vote")
}
// 注意:这里**不**回退 quota_count。投票的定量收集口径是「累计收到的票数」
// (见 route/vote/vote.go 中提交成功后 +1),不是「创建了几个投票」——
// 删掉一个投票并不意味着那些票没发生过。箱主要腾额度靠界面上的「重置计数」。
return nil
})
}
// CountByUserID 统计箱主的投票总数(全部状态)。 // CountByUserID 统计箱主的投票总数(全部状态)。
func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) { func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) {
var count int64 var count int64
+14 -4
View File
@@ -2,6 +2,7 @@ package question
import ( import (
stdctx "context" stdctx "context"
"crypto/subtle"
"fmt" "fmt"
"unicode/utf8" "unicode/utf8"
@@ -21,7 +22,11 @@ import (
// isQuestioner 判断当前请求者是否为该提问的提问者。 // isQuestioner 判断当前请求者是否为该提问的提问者。
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。 // 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
func isQuestioner(ctx context.Context, question *db.Question, token string) bool { func isQuestioner(ctx context.Context, question *db.Question, token string) bool {
if question.Token != "" && question.Token == token { // 常数时间比较:与 Pager 中间件里的校验(route/question/question.go)同一口径。
// 用 == 会按字节短路,攻击者能靠响应时间逐字节试探出 token ——
// 而 token 就是「私密提问 / 尚未回答的提问」唯一的钥匙。
if question.Token != "" && token != "" &&
subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1 {
return true return true
} }
if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID { if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID {
@@ -192,7 +197,8 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
return return
} }
if err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer); err != nil { wasFirst, err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer)
if err != nil {
if errors.Is(err, db.ErrFollowUpNotFound) { if errors.Is(err, db.ErrFollowUpNotFound) {
// 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。 // 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "followup.errNotFound")), f) ctx.SetError(errors.New(i18n.T(ctx.Lang(), "followup.errNotFound")), f)
@@ -205,9 +211,13 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
return return
} }
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接 // 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接。
//
// 只在**首次**回答时发:箱主改一次措辞、补一句、修错别字都会重新保存,
// 每次都发的话提问者会连着收到好几封一模一样的「您的提问有回复」。
// 主回答那边早就有同样的判定(question.Answer == ""),这里补齐。
shared.SafeGo(func() { shared.SafeGo(func() {
if question.ReceiveReplyEmail != "" { if wasFirst && question.ReceiveReplyEmail != "" {
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil { if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner") logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
} }
+16 -13
View File
@@ -388,7 +388,15 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。 // 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。 // 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
isChatRequest := f.StartChat == "true" && ctx.IsLogged isChatRequest := f.StartChat == "true" && ctx.IsLogged
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion) // 用严格版:读配置失败时拒绝而不是回退默认「开启」,
// 否则箱主设的关闭 / 已达上限只要赶上一次数据库抖动就被绕过。
askState, err := db.FeatureStateOfStrict(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
if err != nil {
// 同请求渲染:这里用户刚写完正文,不能因为一次读配置失败就把内容丢掉。
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if !askState.Open && !isChatRequest { if !askState.Open && !isChatRequest {
lang := i18n.Default lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" { if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
@@ -419,8 +427,13 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return return
} }
// 私聊功能开关 / 定时 / 定量 // 私聊功能开关 / 定时 / 定量(严格版,理由同上)
chatState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat) chatState, err := db.FeatureStateOfStrict(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
if err != nil {
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if !chatState.Open { if !chatState.Open {
lang := i18n.Default lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" { if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
@@ -540,11 +553,6 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return return
} }
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)
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")
}
// 请求态数据提前提取,避免 goroutine 在请求结束后访问 ctx.User // 请求态数据提前提取,避免 goroutine 在请求结束后访问 ctx.User
askerName := ctx.User.Name askerName := ctx.User.Name
askerEmail := ctx.User.Email askerEmail := ctx.User.Email
@@ -670,11 +678,6 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
return return
} }
// 提问定量收集:一条提问计一次(含私密提问);追问不重复计数
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment question quota")
}
if len(f.Images) > 0 { if len(f.Images) > 0 {
image := f.Images[0] image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{ if err := uploadImage(ctx, uploadImageOptions{
+31 -20
View File
@@ -1060,6 +1060,8 @@ func VoteClose(ctx context.Context) {
} }
// VoteDelete 删除投票(级联删除选项、表单字段与提交记录)。 // VoteDelete 删除投票(级联删除选项、表单字段与提交记录)。
//
// 实际删除动作全部在 db.Votes.DeleteWithCascade 的一个事务里完成,且是真删(Unscoped)。
func VoteDelete(ctx context.Context) { func VoteDelete(ctx context.Context) {
vote, err := getOwnedVote(ctx, ctx.Param("voteID")) vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
if err != nil { if err != nil {
@@ -1068,24 +1070,24 @@ func VoteDelete(ctx context.Context) {
return return
} }
if err := db.VoteOptions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil { // 级联删除必须是一个事务:原来四步独立写入、前三步失败只打 Warn,
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote options") // 中途失败会留下「选项没了但投票记录还在」这种界面无法修复的状态 ——
} // 而重试删除时投票已不存在,只能报 not exist,脏数据永远留着。
if err := db.VoteFormFields.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil { //
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote form fields") // 顺带修掉一个隐私问题:这些表都带软删除,原来的 Delete 全是软删,
} // 提交记录里参与者填写的文本、voter_ip、voter_token 只是「界面上看不到」,
if err := db.VoteSubmissions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil { // 库里永久留着。DeleteWithCascade 对四张表统一走 Unscoped 真删。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote submissions") if err := db.Votes.DeleteWithCascade(ctx.Request().Context(), vote.ID); err != nil {
} logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote with cascade")
// 投票已删除:失效其结果缓存(即便竞态下仍能命中也已无意义)。
votepkg.InvalidateResultsCache(vote.ID)
if err := db.Votes.DeleteByID(ctx.Request().Context(), vote.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote")
ctx.SetInternalErrorFlash() ctx.SetInternalErrorFlash()
ctx.Redirect("/user/questions#votes") ctx.Redirect("/user/questions#votes")
return return
} }
// 删除成功后再失效缓存。放在事务之后是有意的:
// 若先删缓存而事务回滚,读请求会立刻重新算一遍并再次填进旧值。
votepkg.InvalidateResultsCache(vote.ID)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.deleted")) ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.deleted"))
ctx.Redirect("/user/questions#votes") ctx.Redirect("/user/questions#votes")
} }
@@ -1206,13 +1208,21 @@ func VoteResults(ctx context.Context) {
ctx.Success("user/vote-results") ctx.Success("user/vote-results")
} }
// sanitizeCSVCell 防止 CSV 公式注入:以 = + - @ 开头的单元格会被 Excel/Numbers // sanitizeCSVCell 防 CSV 公式注入。
// 当作公式执行,前置单引号强制按纯文本处理。 //
// Excel / LibreOffice / WPS 会把以 = + - @ 开头的单元格当公式执行,
// 于是「选项名」或「参与者填的文本」里写 =HYPERLINK("http://x?"&A1) 就能在
// 打开导出文件的人机器上把邻列内容外带出去。前置单引号会让它变成纯文本
// (CSV 里单引号不会显示出来,Excel 读到也不当公式)。
//
// 判断用 trim 后的首字符:带前导空格/制表符的 =SUM(1) 在部分表格软件里
// 仍会被解析成公式,只看第一个字节会漏。
func sanitizeCSVCell(s string) string { func sanitizeCSVCell(s string) string {
if s == "" { trimmed := strings.TrimLeft(s, " \t\r\n")
if trimmed == "" {
return s return s
} }
r := []rune(s)[0] r := []rune(trimmed)[0]
if r == '=' || r == '+' || r == '-' || r == '@' { if r == '=' || r == '+' || r == '-' || r == '@' {
return "'" + s return "'" + s
} }
@@ -1261,9 +1271,10 @@ func VoteExportCSV(ctx context.Context) {
if vote.Type == db.VoteTypeForm { if vote.Type == db.VoteTypeForm {
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID) fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
// 字段名是箱主自己填的,同样可能是 =HYPERLINK(...) 形式。
header := []string{"提交时间"} header := []string{"提交时间"}
for _, f := range fields { for _, f := range fields {
header = append(header, f.Label) header = append(header, sanitizeCSVCell(f.Label))
} }
_ = w.Write(header) _ = w.Write(header)
@@ -1288,7 +1299,7 @@ func VoteExportCSV(ctx context.Context) {
switch res.Type { switch res.Type {
case db.VoteTypeSingle, db.VoteTypeMultiple: case db.VoteTypeSingle, db.VoteTypeMultiple:
for _, o := range res.Options { for _, o := range res.Options {
_ = w.Write([]string{o.Option.Content, strconv.FormatInt(o.Count, 10)}) _ = w.Write([]string{sanitizeCSVCell(o.Option.Content), strconv.FormatInt(o.Count, 10)})
} }
case db.VoteTypeRating: case db.VoteTypeRating:
for _, r := range res.Ratings { for _, r := range res.Ratings {
@@ -1297,7 +1308,7 @@ func VoteExportCSV(ctx context.Context) {
_ = w.Write([]string{"平均分", fmt.Sprintf("%.2f", res.AvgRating)}) _ = w.Write([]string{"平均分", fmt.Sprintf("%.2f", res.AvgRating)})
case db.VoteTypeRank: case db.VoteTypeRank:
for _, r := range res.Rankings { for _, r := range res.Rankings {
_ = w.Write([]string{r.Option.Content, fmt.Sprintf("%.2f", r.AvgRank)}) _ = w.Write([]string{sanitizeCSVCell(r.Option.Content), fmt.Sprintf("%.2f", r.AvgRank)})
} }
_ = w.Write([]string{"平均排名", fmt.Sprintf("%.2f", res.AvgRank)}) _ = w.Write([]string{"平均排名", fmt.Sprintf("%.2f", res.AvgRank)})
} }
+12 -5
View File
@@ -617,7 +617,14 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
lang := ctxLang(ctx) lang := ctxLang(ctx)
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。 // 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 POST。
if voteState := db.FeatureStateOf(ctx.Request().Context(), vote.UserID, db.FeatureVote); !voteState.Open { // 严格版:读配置失败时拒绝,避免「箱主已关闭投票 / 已达上限」被一次配置读失败绕过去。
voteState, err := db.FeatureStateOfStrict(ctx.Request().Context(), vote.UserID, db.FeatureVote)
if err != nil {
ctx.SetErrorFlash(i18n.T(lang, "feature.stateUnavailable"))
ctx.Redirect("/vote/" + vote.LinkToken)
return
}
if !voteState.Open {
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason)) ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureVote, voteState.Reason))
ctx.Redirect("/vote/" + vote.LinkToken) ctx.Redirect("/vote/" + vote.LinkToken)
return return
@@ -711,6 +718,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
} }
submission.VoterToken = voterToken submission.VoterToken = voterToken
// QuotaUserID 让「票数计入箱主的定量收集」与提交本身落在同一个事务里。
submission.QuotaUserID = vote.UserID
if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil { if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil {
if errors.Is(err, db.ErrVoteAlreadyVoted) { if errors.Is(err, db.ErrVoteAlreadyVoted) {
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted")) ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
@@ -728,10 +737,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
// 用 InvalidateResultsCache 会让每一票都变成下一个读请求的同步全量重算(O(N²))。 // 用 InvalidateResultsCache 会让每一票都变成下一个读请求的同步全量重算(O(N²))。
ExpireResultsCache(vote.ID) ExpireResultsCache(vote.ID)
// 定量收集:箱主级累计一份;单份投票设置上限后达到即自动结束该投票。 // 单份投票的份数上限:达到即自动结束该投票。
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), vote.UserID, db.FeatureVote, 1); err != nil { // (箱主级的累计计数已由 VoteSubmissions.Create 在同一事务里 +1,这里不再重复)
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment vote feature quota")
}
if vote.QuotaLimit > 0 { if vote.QuotaLimit > 0 {
count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID) count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
if err != nil { if err != nil {