Files
tamabox/internal/db/questions.go
T
tamakyi 0124e392c7 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 与实际消息数一致)。
2026-10-29 16:28:34 +08:00

632 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
var Questions QuestionsStore
type QuestionsStore interface {
Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error)
GetByID(ctx context.Context, id 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
// AnswerByIDPrivate 写入回答的同时把题目置为私密(站方 L2「收窄公开」)。
//
// 做成单条 UPDATE 而不是「先 SetPrivate 再 AnswerByID」:
// - 两条语句之间存在「公开 + 已作答」的窗口,而 L2 的全部意义就是消灭这个窗口;
// - 若第二条失败,题目会变成「私密 + 无回答」,箱主等于凭空丢了一条内容。
AnswerByIDPrivate(ctx context.Context, id uint, answer string) error
DeleteByID(ctx context.Context, id uint) error
UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error
Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, error)
SetPrivate(ctx context.Context, id uint) error
SetPublic(ctx context.Context, id uint) error
SetPinned(ctx context.Context, id uint, pinned bool) error
// SetPinnedIfCurrent 仅在「当前置顶状态 == current」时才写成 pinned,返回是否真的写入。
//
// 用于「切换」这类 read-modify-write:handler 拿到的 is_pinned 是本次请求早期读的,
// 双击或并发提交时两个请求都会算出「取反」并写同一个值,状态机不是原子的
// (用户点两次期望「置顶后又取消」,实际仍是「置顶」)。
// 加上 current 条件后,第二次会因为状态已变而 0 行 —— 语义上「只切换一次」。
SetPinnedIfCurrent(ctx context.Context, id uint, current, pinned bool) (bool, error)
SetRead(ctx context.Context, id uint) error
SetUnread(ctx context.Context, id uint) error
// NextUnread 取该用户最早的一条未读提问(未读 = is_read=false 且未回答)。
// excludeID 用于跳过正在查看的那条,避免在两条未读之间来回跳。
NextUnread(ctx context.Context, userID, excludeID uint) (*Question, error)
SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error)
// 全站统计
QuestionStats(ctx context.Context) (*QuestionStats, error)
}
// QuestionStats 全站提问聚合统计(不按单个用户过滤,供首页"提问箱信息统计"使用)。
type QuestionStats struct {
TotalQuestions int64
AnsweredQuestions int64
LastQuestionAt *time.Time // 最后一个提问发生时间(questions.created_at 最大值)
LastAnswerAt *time.Time // 最后一个回答发生时间(answered_at 最大值,历史行退回 updated_at)
}
func NewQuestionsStore(db *gorm.DB) QuestionsStore {
return &questions{db}
}
type questions struct {
*gorm.DB
}
type Question struct {
dbutil.Model
FromIP string `json:"-"`
UserID uint `gorm:"index:idx_question_user_id" json:"-"`
Content string `json:"content"`
ContentCensorMetadata datatypes.JSON `json:"-"`
Token string `json:"-"`
Answer string `json:"answer"`
AnswerCensorMetadata datatypes.JSON `json:"-"`
ReceiveReplyEmail string `json:"-"`
AskerUserID uint `json:"-"`
IsPrivate bool `gorm:"default: FALSE; NOT NULL" json:"-"`
IsPinned bool `gorm:"default: FALSE; NOT NULL" json:"is_pinned"`
// AnsweredAt 首次回答的时间,用于算「回答耗时」。为空表示还没回答过。
//
// 不能拿 updated_at 当回答时间:GORM 的 Update 会自动刷新 updated_at,
// 而已读、置顶、公开/私密切换都走 Update,回答之后再点一次「已读」
// 就会把耗时撑大(实测:一条 10 天前的提问被 SetRead 后 updated_at 直接推到当下)。
// 只在 answer 由空变非空时写一次,改写回答不重置。
AnsweredAt *time.Time `json:"-"`
// IsRead 标记箱主是否已读/处理过该提问。有效"已读" = IsRead=true 或 已有回答(answer != "")。
// 已回答的问题恒为已读且锁定,不可改回未读(见 SetUnread 的 answer='' 条件与上层校验)。
IsRead bool `gorm:"default: FALSE; NOT NULL" json:"is_read"`
}
type CreateQuestionOptions struct {
FromIP string
UserID uint
Content string
ReceiveReplyEmail string
AskerUserID uint
IsPrivate bool
}
func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Question, error) {
question := Question{
FromIP: opts.FromIP,
UserID: opts.UserID,
Token: randstr.String(12),
Content: opts.Content,
ReceiveReplyEmail: opts.ReceiveReplyEmail,
AskerUserID: opts.AskerUserID,
IsPrivate: opts.IsPrivate,
}
return &question, db.WithContext(ctx).Create(&question).Error
}
type UpdateQuestionCensorOptions struct {
ContentCensorMetadata json.RawMessage
AnswerCensorMetadata json.RawMessage
}
func (db *questions) UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error {
question, err := db.GetByID(ctx, id)
if err != nil {
return errors.Wrap(err, "get by ID")
}
contentCensorMetadata := question.ContentCensorMetadata
if checkTextCensorResponseValid(opts.ContentCensorMetadata) {
contentCensorMetadata = datatypes.JSON(opts.ContentCensorMetadata)
}
answerCensorMetadata := question.AnswerCensorMetadata
if checkTextCensorResponseValid(opts.AnswerCensorMetadata) {
answerCensorMetadata = datatypes.JSON(opts.AnswerCensorMetadata)
}
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Updates(&Question{
ContentCensorMetadata: contentCensorMetadata,
AnswerCensorMetadata: answerCensorMetadata,
}).Error
}
func checkTextCensorResponseValid(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
if bytes.EqualFold(raw, []byte("null")) {
return false
}
var response struct {
SourceName string `json:"source_name"`
}
if err := json.Unmarshal(raw, &response); err != nil {
return false
}
return response.SourceName != ""
}
var ErrQuestionNotExist = errors.New("提问不存在")
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 {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrQuestionNotExist
}
return nil, errors.Wrap(err, "get question by ID")
}
return &question, nil
}
type GetQuestionsByUserIDOptions struct {
*dbutil.Cursor
FilterAnswered bool // 已废弃,推荐使用 AnsweredStatus
ShowPrivate bool // 已废弃,推荐使用 PrivateStatus
AnsweredStatus int // 0: 全部, 1: 已回答, 2: 未回答
PrivateStatus int // 0: 全部, 1: 仅公开(is_private=false), 2: 仅私密(is_private=true)
ReadStatus int // 0: 全部, 1: 已读(is_read=true 或已回答), 2: 未读
SortOrder string // "ASC" 或 "DESC",默认 "DESC"
PinFirst bool // 置顶优先:is_pinned 的提问排在最前(公开主页使用)
}
// GetByUserID 根据用户 ID 获取提问列表,支持游标分页
func (db *questions) GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error) {
q := db.WithContext(ctx).Model(&Question{}).Where("user_id = ?", userID)
// 回答状态筛选
if opts.AnsweredStatus == 1 {
q = q.Where("answer != ''")
} else if opts.AnsweredStatus == 2 {
q = q.Where("answer = ''")
} else if opts.FilterAnswered {
// 兼容旧调用
q = q.Where("answer != ''")
}
// 私密状态筛选
if opts.PrivateStatus == 1 {
q = q.Where("is_private = false")
} else if opts.PrivateStatus == 2 {
q = q.Where("is_private = true")
} else if !opts.ShowPrivate && opts.PrivateStatus == 0 {
// 兼容旧调用:如果 ShowPrivate=false 且未指定 PrivateStatus,则过滤私密
q = q.Where("is_private = false")
}
// 阅读状态筛选:已读 = is_read=true 或已回答;未读 = is_read=false 且未回答。
// 使用括号包裹 OR 条件,避免与前面的 user_id 等条件产生 AND/OR 优先级问题。
if opts.ReadStatus == 1 {
q = q.Where("(is_read = ? OR answer != '')", true)
} else if opts.ReadStatus == 2 {
q = q.Where("(is_read = ? AND answer = '')", false)
}
// 排序方向先规范化,游标方向依赖它(ASC=下一页 id 更大,DESC=下一页 id 更小)。
sortOrder := opts.SortOrder
if sortOrder == "" {
sortOrder = "DESC"
}
if sortOrder != "ASC" && sortOrder != "DESC" {
sortOrder = "DESC"
}
// 游标分页
// 关键语义:携带游标却无法解析时,不再“忽略游标、从首页重新开始”。
// 旧行为会导致「加载更多」重复返回第一页内容、按钮永不消失(内容重复的
// 直接根因之一,见 history-template 的复合游标改造历史)。改为直接返回
// 空列表:上层(ListAPI/Pager 等)据此判定 has_more=false,前端按钮收敛。
if opts.Cursor != nil && opts.Cursor.Value != nil {
cursorStr := fmt.Sprintf("%v", opts.Cursor.Value)
if cursorStr != "" {
if opts.PinFirst {
// 置顶优先场景下,排序为 (is_pinned DESC, created_at, id),
// 必须用"复合键集"(keyset)游标,否则置顶项与非置顶项 id 交错时
// 单纯 id < 游标会漏掉介于置顶项 id 之间、但排在更后面的非置顶项。
// 游标格式:"<isPinned>.<createdAtUnixNano>.<id>"。
isPinned, createdAt, idVal, ok := parsePinCursor(cursorStr)
if !ok {
return []*Question{}, nil
}
// 排序为 (is_pinned DESC, created_at DESC, id DESC),下一页 = 排序序位严格在游标之后的行。
// 注意:is_pinned 在 PG 下是 boolean,与整型比较(boolean < integer)无操作符会直接报错,
// 因此必须传 Go bool 而非 0/1 整型(MySQL 下 bool 亦兼容 tinyint)。
// 游标位于置顶段时,后续行 = 置顶段内 (created_at,id) 更小 + 全部非置顶段;
// 游标位于非置顶段时,后续行 = 非置顶段内 (created_at,id) 更小。
if isPinned == 1 {
q = q.Where(
"(is_pinned = ? AND created_at < ?) OR (is_pinned = ? AND created_at = ? AND id < ?) OR (is_pinned = ?)",
true, createdAt, true, createdAt, idVal, false,
)
} else {
q = q.Where(
"(is_pinned = ? AND created_at < ?) OR (is_pinned = ? AND created_at = ? AND id < ?)",
false, createdAt, false, createdAt, idVal,
)
}
} else {
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
// 且 ASC(旧在前)时下一页 id 更大,DESC(新在前)时下一页 id 更小。
idVal, err := strconv.ParseUint(cursorStr, 10, 64)
if err != nil {
// 同上:游标非法不再回退首页,直接收敛为空列表。
return []*Question{}, nil
}
if sortOrder == "ASC" {
q = q.Where("id > ?", idVal)
} else {
q = q.Where("id < ?", idVal)
}
}
}
}
if opts.Cursor != nil {
limit := opts.Cursor.Limit()
q = q.Limit(limit)
}
if opts.PinFirst {
// 公开主页:置顶优先;用 id 作为稳定 tiebreaker 保证排序完全确定。
q = q.Order("is_pinned DESC, created_at " + sortOrder + ", id " + sortOrder)
} else {
q = q.Order("created_at " + sortOrder + ", id " + sortOrder)
}
var questions []*Question
if err := q.Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get questions by user id")
}
return questions, nil
}
// parsePinCursor 解析 PinFirst 分页的复合键集游标,格式 "<isPinned>.<createdAtUnixNano>.<id>"。
// 返回 (isPinned, createdAt, id, ok);格式非法时 ok=false,调用方应忽略该游标(从首页重新开始)。
//
// 时区约定(务必保持,否则线上翻页会退化为首页死循环):
// EncodePinCursor 的 nano 取自 DB 读回的 CreatedAt.UnixNano(),生产列是
// timestamp WITHOUT time zone,读写按「墙钟即 UTC」对称,因此 Parse 侧必须把 nano
// 还原成 UTC 墙钟的 time.Time(time.Unix(0,nano).UTC()),再交给 GORM 与无时区列比较。
// 若直接返回 time.Unix(0, nano)(Location=服务器本地时区),当进程 TZ 非 UTC 时
// (如 Asia/Shanghai)墙钟会偏移 +8h,导致 keyset 条件 created_at < 偏移时间恒为真,
// 游标形同虚设,每次请求都退回第一页 → 「加载更多」无限重复且按钮永不消失。
func parsePinCursor(s string) (int, time.Time, uint, bool) {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return 0, time.Time{}, 0, false
}
isPinned, err := strconv.Atoi(parts[0])
if err != nil {
return 0, time.Time{}, 0, false
}
nano, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, time.Time{}, 0, false
}
idVal, err := strconv.ParseUint(parts[2], 10, 64)
if err != nil {
return 0, time.Time{}, 0, false
}
return isPinned, time.Unix(0, nano).UTC(), uint(idVal), true
}
// EncodePinCursor 生成 PinFirst 分页游标(供路由层把末条记录编码进下一页游标)。
func EncodePinCursor(isPinned bool, createdAt time.Time, id uint) string {
v := 0
if isPinned {
v = 1
}
return fmt.Sprintf("%d.%d.%d", v, createdAt.UnixNano(), id)
}
type GetQuestionsByAskUserIDOptions struct {
*dbutil.Cursor
FilterAnswered bool
ShowPrivate bool
}
func (db *questions) GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error) {
where := `asker_user_id = ?`
args := userID
if opts.FilterAnswered {
where = `asker_user_id = ? AND answer != ''`
}
if !opts.ShowPrivate {
where += ` AND is_private = false`
}
questions, err := db.getBy(ctx, opts.Cursor, where, args)
if err != nil {
return nil, errors.Wrap(err, "get by")
}
return questions, nil
}
// getBy 保留用于 GetByAskUserID 等旧逻辑
func (db *questions) getBy(ctx context.Context, cursor *dbutil.Cursor, whereQuery string, args ...interface{}) ([]*Question, error) {
var questions []*Question
q := db.WithContext(ctx).Where(whereQuery, args...)
if cursor != nil {
cursorID := cursor.Value
if cursorID != nil && fmt.Sprintf("%v", cursorID) != "" {
// 游标必须为纯数字 id:直接传 string 在 PG 下会因 integer < text 无操作符而报错。
// 解析失败不再忽略游标回退首页(会导致「加载更多」重复首页内容),
// 直接收敛为空列表,令调用方按“无更多”处理。
idVal, err := strconv.ParseUint(fmt.Sprintf("%v", cursorID), 10, 64)
if err != nil {
return []*Question{}, nil
}
if idVal > 0 {
q = q.Where(`id < ?`, idVal)
}
}
limit := cursor.Limit()
q = q.Limit(limit)
}
q = q.Order("created_at DESC")
if err := q.Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get questions by page ID")
}
return questions, nil
}
// AnswerByIDPrivate 回答并强制私密。见接口注释:必须是一条 UPDATE。
func (db *questions) AnswerByIDPrivate(ctx context.Context, id uint, answer string) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
Updates(answerUpdates(question.Answer, answer, map[string]interface{}{"is_private": true})).Error; err != nil {
return errors.Wrap(err, "update question answer as private")
}
return nil
}
func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
Updates(answerUpdates(question.Answer, answer, nil)).Error; err != nil {
return errors.Wrap(err, "update question answer")
}
return nil
}
// answerUpdates 组装回答写入的字段集合。
//
// 只有「原本没回答」时才写 answered_at:改写已有回答不该重置首次回答时间,
// 否则「回答耗时」会跟着编辑动作变大。extra 用于合并调用方自己的附加字段
// (如强制私密),保持仍然只有一条 UPDATE。
func answerUpdates(prevAnswer, answer string, extra map[string]interface{}) map[string]interface{} {
updates := map[string]interface{}{"answer": answer, "is_read": true}
for k, v := range extra {
updates[k] = v
}
if prevAnswer == "" {
updates["answered_at"] = time.Now()
}
return updates
}
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 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)
}
}
return nil
}
type GetQuestionsCountOptions struct {
FilterAnswered bool
ShowPrivate bool
IsPrivate *bool // 非 nil 时按该值过滤 is_private
IsPinned *bool // 非 nil 时按该值过滤 is_pinned
ReadStatus int // 0: 全部, 1: 已读(is_read=true 或已回答), 2: 未读
}
func (db *questions) Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, error) {
q := db.WithContext(ctx).Model(&Question{})
if opts.FilterAnswered {
q = q.Where(`user_id = ? AND answer != ''`, userID)
} else {
q = q.Where(`user_id = ?`, userID)
}
if !opts.ShowPrivate {
q = q.Where(`is_private = ?`, false)
}
if opts.IsPrivate != nil {
q = q.Where(`is_private = ?`, *opts.IsPrivate)
}
if opts.IsPinned != nil {
q = q.Where(`is_pinned = ?`, *opts.IsPinned)
}
// 阅读状态筛选:已读 = is_read=true 或已回答;未读 = is_read=false 且未回答。
if opts.ReadStatus == 1 {
q = q.Where("(is_read = ? OR answer != '')", true)
} else if opts.ReadStatus == 2 {
q = q.Where("(is_read = ? AND answer = '')", false)
}
var count int64
return count, q.Count(&count).Error
}
func (db *questions) SetPrivate(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", true).Error
}
// SetPinned 设置/取消问题的置顶状态。
func (db *questions) SetPinned(ctx context.Context, id uint, pinned bool) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_pinned", pinned).Error
}
// SetPinnedIfCurrent 条件更新置顶状态(详见接口注释)。
func (db *questions) SetPinnedIfCurrent(ctx context.Context, id uint, current, pinned bool) (bool, error) {
res := db.WithContext(ctx).Model(&Question{}).
Where("id = ? AND is_pinned = ?", id, current).
Update("is_pinned", pinned)
if res.Error != nil {
return false, errors.Wrap(res.Error, "set question pinned if current")
}
return res.RowsAffected > 0, nil
}
func (db *questions) SetPublic(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_private", false).Error
}
// SetRead 标记提问为已读(箱主侧手动标记,或作为其他语义化操作的前置状态)。
func (db *questions) SetRead(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ?", id).Update("is_read", true).Error
}
// SetUnread 标记提问为未读。已回答的提问恒为已读且锁定,故追加 answer=” 条件:
// 即使上层遗漏校验,也不会把已回答的提问错误地改回未读(纵深防御)。
func (db *questions) SetUnread(ctx context.Context, id uint) error {
return db.WithContext(ctx).Model(&Question{}).Where("id = ? AND answer = ''", id).Update("is_read", false).Error
}
// NextUnread 按创建时间正序取第一条未读提问(未读 = is_read=false 且未回答),无则返回 nil。
// 正序即「先来先处理」:反复取下一条就能把积压按先后顺序清空,不会跳过更早的提问。
func (db *questions) NextUnread(ctx context.Context, userID, excludeID uint) (*Question, error) {
var question Question
q := db.WithContext(ctx).
Model(&Question{}).
Where("user_id = ? AND is_read = ? AND answer = ''", userID, false)
if excludeID > 0 {
q = q.Where("id != ?", excludeID)
}
if err := q.Order("created_at ASC, id ASC").First(&question).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, errors.Wrap(err, "get next unread question")
}
return &question, nil
}
// SearchByUserID 全文搜索提问箱主人收到的提问(含私密,仅限本人管理后台使用)。
// 使用 LIKE 模糊匹配问题内容与回答;keyword 中的通配符 % _ \ 会被转义,避免模式滥用。
func (db *questions) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error) {
escaped := escapeLike(keyword)
var questions []*Question
if err := db.WithContext(ctx).
Where("user_id = ? AND (content LIKE ? OR answer LIKE ?)", userID, "%"+escaped+"%", "%"+escaped+"%").
Order("created_at DESC").
Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "search questions by user id")
}
return questions, nil
}
// escapeLike 转义 LIKE 模式中的特殊字符。
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// QuestionStats 返回全站提问相关聚合统计。
// 使用 gorm 查询构造器生成 COUNT / MAX,兼容 MySQL 与 PostgreSQL。
//
// 最后一个回答时间取 answered_at,不取 updated_at:GORM 的 Update 一律刷新
// updated_at,而置顶、公开/私密切换、标记已读都会走 Update,于是「最后一个回答」
// 会被这些与回答无关的操作推到当下(实测一条 9 天前回答的提问,置顶后 LastAnswerAt
// 变成"刚刚")。answered_at 为空的是加列之前的历史行,退回 updated_at,
// 与改动前的口径一致,不会让这些行从统计里消失。
func (db *questions) QuestionStats(ctx context.Context) (*QuestionStats, error) {
var total, answered int64
if err := db.WithContext(ctx).Model(&Question{}).Count(&total).Error; err != nil {
return nil, errors.Wrap(err, "count questions")
}
if err := db.WithContext(ctx).Model(&Question{}).Where("answer != ''").Count(&answered).Error; err != nil {
return nil, errors.Wrap(err, "count answered questions")
}
var t struct {
LastQ *time.Time `gorm:"column:last_q"`
LastA *time.Time `gorm:"column:last_a"`
}
if err := db.WithContext(ctx).Model(&Question{}).
Select("MAX(created_at) AS last_q, MAX(CASE WHEN answer <> '' THEN COALESCE(answered_at, updated_at) END) AS last_a").
Scan(&t).Error; err != nil {
return nil, errors.Wrap(err, "get last question/answer time")
}
return &QuestionStats{
TotalQuestions: total,
AnsweredQuestions: answered,
LastQuestionAt: t.LastQ,
LastAnswerAt: t.LastA,
}, nil
}
// backfillAnsweredAt 为「加列之前就已经回答」的提问冻结回答时间。
//
// 只能在刚加上 answered_at 这一列时跑一次(调用方按 HasColumn 判断)。
// 取值用 updated_at 当近似 —— 那是当时唯一能拿到的可信时间。
// 之后这些行的 updated_at 还会被已读/置顶继续往前推,冻结住才不会让
// 历史「回答耗时」一天天变大,看着像统计出错。
func backfillAnsweredAt(db *gorm.DB) error {
return db.Exec(
`UPDATE questions SET answered_at = updated_at WHERE answer <> '' AND answered_at IS NULL`).Error
}