0904
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
// 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
|
||||
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(6),
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
|
||||
if err := FollowUps.DeleteByQuestionID(ctx, id); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
|
||||
}
|
||||
if err := UploadImages.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 := FeatureControls.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
|
||||
}
|
||||
Reference in New Issue
Block a user