四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。
1) 删除投票的级联没有事务(route/user/vote.go)
选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
投票已不存在,只能报 not exist,脏数据永远留着。
→ db.Votes.DeleteWithCascade:四张表在同一事务里删完。
2) 删除其实是软删除,个人数据没真删掉
模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
→ 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
原来也是三个独立写入(失败只 Warn),一并收进事务。
3) 配额计数会永久偏低
「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
→ 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
不是「创建了几个投票」—— 所以删除投票不回退这个计数。
4) 改写追问回答每次都发通知邮件
箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。
顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。
验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
681 lines
28 KiB
Go
681 lines
28 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/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>.<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
|
||
}
|
||
|
||
// 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
|
||
}
|