// 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/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) // 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 // 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 } // 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, UserID: opts.UserID, Token: randstr.String(12), Content: opts.Content, ReceiveReplyEmail: opts.ReceiveReplyEmail, AskerUserID: opts.AskerUserID, IsPrivate: opts.IsPrivate, } 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 { 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("提问不存在") // 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 { 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, 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, 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 } // DeleteByID 删除提问及其追问、图片关联,并回退箱主的收集计数。 // // 三步在同一个事务里完成:原来删除本体后,级联清理与配额回退还各自独立执行、 // 失败只打日志 —— 中途失败会留下「提问没了但追问还在」的孤儿记录, // 或者计数没退回(达上限后功能一直关闭,箱主只能手动重置)。 // // 用 Unscoped 真删:用户点删除的预期就是删掉。软删只是让界面上看不见, // 提问里的 FromIP 与正文仍留在库里,等于「删了但没删干净」。 func (db *questions) DeleteByID(ctx context.Context, id uint) error { 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") } if err := tx.Unscoped().Delete(&Question{}, id).Error; err != nil { return errors.Wrap(err, "delete question") } // 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。 // 直接用事务句柄构造子 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 { 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 }