Files
tamabox/internal/db/questions.go
T
2026-10-19 01:58:00 +08:00

572 lines
22 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
SetRead(ctx context.Context, id uint) error
SetUnread(ctx context.Context, id uint) error
SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error)
GetHotQuestions(ctx context.Context, limit int) ([]*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 // 最后一个回答发生时间(已回答问题的 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"`
// 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(map[string]interface{}{"answer": answer, "is_read": true, "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(map[string]interface{}{"answer": answer, "is_read": true}).Error; err != nil {
return errors.Wrap(err, "update question answer")
}
return nil
}
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
}
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
}
// 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)
}
// GetHotQuestions 返回"热门问题":最新活跃的公开已回答提问(按回答更新时间倒序)。
// 提问箱没有点赞数据,用"最近被回答"作为热度近似,适合主页/探索页展示。
func (db *questions) GetHotQuestions(ctx context.Context, limit int) ([]*Question, error) {
if limit <= 0 {
limit = 10
}
var questions []*Question
if err := db.WithContext(ctx).
Where("is_private = ? AND answer != ''", false).
Order("updated_at DESC").
Limit(limit).
Find(&questions).Error; err != nil {
return nil, errors.Wrap(err, "get hot questions")
}
return questions, nil
}
// QuestionStats 返回全站提问相关聚合统计。
// 使用 gorm 查询构造器生成 COUNT / MAX,兼容 MySQL 与 PostgreSQL。
// 最后一个回答时间取"已回答问题"的 updated_at 最大值——回答动作会更新 updated_at,
// 与 crontab 旧逻辑(answer 非空记录中 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 updated_at ELSE NULL 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
}