四处都属于「并发/失败路径下状态不一致」,用户侧表现为「点了没反应」「发了没收到」。 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 与实际消息数一致)。
632 lines
25 KiB
Go
632 lines
25 KiB
Go
// 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
|
||
}
|