Files
tamabox/route/user/question.go
T
2026-10-18 23:04:50 +08:00

502 lines
18 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 user
import (
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
type conversationDetail struct {
Conversation *db.Conversation
OwnerDomain string
OwnerName string
FirstMessage string
IsBlocked bool
}
type receivedItem struct {
Type string // "question_public", "question_private", "chat"
CreatedAt time.Time
Content string
Link string
IsAnswered bool
IsRead bool // 仅对普通提问有效:有效已读 = is_read=true 或已回答
IsPrivate bool // 仅对普通提问有效
IsBlocked bool // 仅对私聊有效
QuestionID uint // 仅对普通提问有效,用于置顶操作
IsPinned bool // 仅对普通提问有效
PinLink string // 仅对普通提问有效:置顶/取消置顶操作链接
// ReportCount 仅对普通提问有效:待处理举报数。
// 只给数量、不给举报人 IP —— 箱主若能看到 IP,就有能力反查举报者,举报意愿会直接归零。
ReportCount int64
}
// QuestionList 用户后台提问列表(支持筛选与全文搜索)
func QuestionList(ctx context.Context) {
// 获取筛选参数
sortParam := ctx.Query("sort")
if sortParam != "latest" && sortParam != "oldest" {
sortParam = "latest"
}
statusParam := ctx.Query("status")
if statusParam != "all" && statusParam != "answered" && statusParam != "unanswered" {
statusParam = "all"
}
// reported 筛选:只看当前有「待处理举报」的提问。
// 依赖下方 reportCounts(同一份查询复用),不额外打库。
reportedParam := ctx.Query("reported")
if reportedParam != "reported" {
reportedParam = "all"
}
typeParam := ctx.Query("type")
// 私聊已独立成"私聊"标签页(见 ReceivedChats),不再参与提问列表的类型筛选;
// 旧的 type=chat 链接统一归一为"全部",避免空列表。
if typeParam != "all" && typeParam != "public" && typeParam != "private_question" {
typeParam = "all"
}
readParam := ctx.Query("read")
if readParam != "all" && readParam != "read" && readParam != "unread" {
readParam = "all"
}
keyword := strings.TrimSpace(ctx.Query("q"))
// 映射排序方向
sortOrder := "DESC"
if sortParam == "oldest" {
sortOrder = "ASC"
}
// 映射回答状态
answeredStatus := 0 // 全部
if statusParam == "answered" {
answeredStatus = 1
} else if statusParam == "unanswered" {
answeredStatus = 2
}
// 映射阅读状态
readStatus := 0 // 全部
if readParam == "read" {
readStatus = 1
} else if readParam == "unread" {
readStatus = 2
}
// 管理后台进入问题详情的链接:携带 from=admin 及当前筛选参数,
// 使详情页底部「返回」按钮跳回带筛选条件的后台列表(保留 sort/status/type/read/q)。
adminLinkQuery := url.Values{}
adminLinkQuery.Set("from", "admin")
if sortParam != "latest" {
adminLinkQuery.Set("sort", sortParam)
}
if statusParam != "all" {
adminLinkQuery.Set("status", statusParam)
}
if typeParam != "all" {
adminLinkQuery.Set("type", typeParam)
}
if readParam != "all" {
adminLinkQuery.Set("read", readParam)
}
if keyword != "" {
adminLinkQuery.Set("q", keyword)
}
adminLinkQueryStr := adminLinkQuery.Encode()
var receivedItems []receivedItem
// 1. 获取普通提问
var questions []*db.Question
if keyword != "" {
results, err := db.Questions.SearchByUserID(ctx.Request().Context(), ctx.User.ID, keyword)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to search questions")
} else {
questions = results
}
} else if typeParam == "all" || typeParam == "public" || typeParam == "private_question" {
privateStatus := 0 // 全部
if typeParam == "public" {
privateStatus = 1 // 仅公开
} else if typeParam == "private_question" {
privateStatus = 2 // 仅私密
}
// 游标分页:每页固定大小,游标为上一页最后一条提问 ID(默认按创建时间倒序,
// 与 db.GetQuestionsByUserID 的 id < cursor 游标一致)。避免全量返回。
questionsPageSize := 50
qCursor := parseCursorUint(ctx.Query("q_cursor"))
qOpts := db.GetQuestionsByUserIDOptions{
AnsweredStatus: answeredStatus,
PrivateStatus: privateStatus,
// PrivateStatus=0("全部")时必须显式声明含私密:store 层的旧调用兼容分支
// 会把 "ShowPrivate=false 且未指定 PrivateStatus" 当作仅公开过滤,
// 导致私密提问在"全部"类型下不可见(与统计看板/内存过滤逻辑不一致)。
ShowPrivate: true,
ReadStatus: readStatus,
SortOrder: sortOrder,
Cursor: &dbutil.Cursor{PageSize: questionsPageSize},
}
if qCursor > 0 {
qOpts.Cursor.Value = qCursor
}
var qs []*db.Question
var qErr error
qs, qErr = db.Questions.GetByUserID(ctx.Request().Context(), ctx.User.ID, qOpts)
if qErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(qErr).Error("Failed to get questions by user ID")
} else {
questions = qs
}
// 提问"加载更多"(仅非搜索路径支持游标分页):满页且仍有更多时暴露下一页游标
if qErr == nil && len(qs) >= questionsPageSize {
lastQID := qs[len(qs)-1].ID
ctx.Data["HasMoreQuestions"] = true
ctx.Data["QuestionLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "q_cursor", strconv.FormatUint(uint64(lastQID), 10), "")
} else {
ctx.Data["HasMoreQuestions"] = false
}
}
// 待处理举报数:箱主侧只展示数量。查询失败不阻断列表,仅少一个标记。
reportCounts, rErr := db.Reports.QuestionIDsByUser(ctx.Request().Context(), ctx.User.ID)
if rErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(rErr).Error("Failed to count content reports")
reportCounts = map[uint]int64{}
}
// 搜索场景下关键词已在 SQL 中匹配,但类型/状态筛选需在此统一应用,
// 使"搜索 + 筛选"可组合(完善筛选逻辑)。非搜索场景下此处与 SQL 过滤结果一致。
for _, q := range questions {
if answeredStatus == 1 && q.Answer == "" {
continue
}
if answeredStatus == 2 && q.Answer != "" {
continue
}
if typeParam == "public" && q.IsPrivate {
continue
}
if typeParam == "private_question" && !q.IsPrivate {
continue
}
// 有效已读 = 手动已读(is_read) 或 已回答(answer != "")
isRead := q.IsRead || q.Answer != ""
if readStatus == 1 && !isRead {
continue
}
if readStatus == 2 && isRead {
continue
}
if reportedParam == "reported" && reportCounts[q.ID] == 0 {
continue
}
itemType := "question_public"
if q.IsPrivate {
itemType = "question_private"
}
receivedItems = append(receivedItems, receivedItem{
Type: itemType,
CreatedAt: q.CreatedAt,
Content: q.Content,
Link: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "?" + adminLinkQueryStr,
IsAnswered: q.Answer != "",
IsRead: isRead,
IsPrivate: q.IsPrivate,
QuestionID: q.ID,
IsPinned: q.IsPinned,
PinLink: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "/pin",
ReportCount: reportCounts[q.ID],
})
}
// 2. 私聊会话:独立"私聊"标签页展示(ReceivedChats),不再混入提问列表。
// 会话没有回答状态/阅读状态概念,不受提问的 sort/status/type/read/q 筛选影响。
// 私聊列表分页(游标=offset),避免一次拉取全部会话造成内存/DB 压力。
chatPageSize := 50
chatOffset := parseCursorUint(ctx.Query("chat_cursor"))
var receivedChats []receivedItem
convs, err := db.Conversations.ListByOwner(ctx.Request().Context(), ctx.User.ID, chatPageSize, int(chatOffset))
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get owned conversations")
} else if len(convs) > 0 {
// 批量获取首条消息
convIDs := make([]uint, len(convs))
for i, c := range convs {
convIDs[i] = c.ID
}
firstMsgMap, err := db.Messages.GetFirstMessagesByConversationIDs(ctx.Request().Context(), convIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get first messages")
firstMsgMap = make(map[uint]string)
}
// 批量判断"我拉黑了哪些 asker",避免循环 IsUserBlocked 造成 N+1。
askerIDs := make([]uint, 0, len(convs))
for _, c := range convs {
askerIDs = append(askerIDs, c.AskerID)
}
blockedMap, err := db.Users.BlockedByUser(ctx.Request().Context(), ctx.User.ID, askerIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch check blocked askers")
blockedMap = make(map[uint]bool)
}
for _, conv := range convs {
receivedChats = append(receivedChats, receivedItem{
Type: "chat",
CreatedAt: conv.CreatedAt,
Content: firstMsgMap[conv.ID],
Link: "/_/" + ctx.User.Domain + "/chat/" + conv.Token,
IsBlocked: blockedMap[conv.AskerID],
})
}
}
ctx.Data["ReceivedChats"] = receivedChats
// 私聊总数与"加载更多"判定(基于真实总数,而非当前页长度)
chatTotalReceived, cntErr := db.Conversations.CountByOwner(ctx.Request().Context(), ctx.User.ID)
if cntErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(cntErr).Error("Failed to count owned conversations")
chatTotalReceived = int64(len(convs))
}
hasMoreChats := chatTotalReceived > 0 && (chatOffset+uint(len(convs)) < uint(chatTotalReceived))
if hasMoreChats {
nextOffset := chatOffset + uint(len(convs))
ctx.Data["ChatLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "chat_cursor", strconv.FormatUint(uint64(nextOffset), 10), "chats")
}
ctx.Data["HasMoreChats"] = hasMoreChats
// 排序(搜索时保留搜索结果的倒序,不混合排序)
if keyword == "" {
sort.Slice(receivedItems, func(i, j int) bool {
if sortParam == "latest" {
return receivedItems[i].CreatedAt.After(receivedItems[j].CreatedAt)
}
return receivedItems[i].CreatedAt.Before(receivedItems[j].CreatedAt)
})
}
// ---------- 提问箱统计看板 ----------
// 独立统计(不受筛选影响),供管理页顶部展示。全部用 COUNT 查询,避免全量拉取问题记录。
totalCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true})
answeredCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{FilterAnswered: true, ShowPrivate: true})
unansweredCount := totalCount - answeredCount
isPrivateTrue := true
privateCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, IsPrivate: &isPrivateTrue})
isPinnedTrue := true
pinnedCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, IsPinned: &isPinnedTrue})
// 阅读状态统计(不受其他筛选影响):已读 = is_read=true 或已回答;未读 = 其余。
readCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, ReadStatus: 1})
unreadCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, ReadStatus: 2})
ctx.Data["Stats"] = map[string]int64{
"total": totalCount,
"answered": answeredCount,
"unanswered": unansweredCount,
"read": readCount,
"unread": unreadCount,
"private": privateCount,
"pinned": pinnedCount,
}
// 我创建的投票(提问箱「投票」面板用)—— 与投票管理列表共用富信息条目,
// 含参与数、有效状态(draft/published/not_started/ended/archived)、剩余时间等。
// 分页:每页固定大小,游标为上一页最后一条投票 ID(按创建时间倒序),避免全量返回。
votesPageSize := 50
voteCursor := parseCursorUint(ctx.Query("vote_cursor"))
voteListOpts := db.ListVotesOptions{Cursor: &dbutil.Cursor{PageSize: votesPageSize}}
if voteCursor > 0 {
voteListOpts.Cursor.Value = voteCursor
}
myVotesRaw, err := db.Votes.ListByUserID(ctx.Request().Context(), ctx.User.ID, voteListOpts)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get my votes")
myVotesRaw = []*db.Vote{}
}
myVotes := buildVoteItems(ctx, myVotesRaw)
ctx.Data["MyVotes"] = myVotes
// 投票统计看板(不受筛选影响):总数(真实全量计数)+ 本页有效进行中数
voteTotal, _ := db.Votes.CountByUserID(ctx.Request().Context(), ctx.User.ID)
votePublished := int64(0)
for _, it := range myVotes {
if it.EffectiveStatus == db.VoteStatusPublished {
votePublished++
}
}
ctx.Data["VoteStats"] = map[string]int64{
"total": voteTotal,
"published": votePublished,
}
// 投票"加载更多":本页满页且仍有更多时暴露下一页游标(保留筛选参数并跳回投票面板)
if len(myVotesRaw) >= votesPageSize {
lastID := myVotesRaw[len(myVotesRaw)-1].ID
ctx.Data["HasMoreVotes"] = true
ctx.Data["VoteLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "vote_cursor", strconv.FormatUint(uint64(lastID), 10), "votes")
} else {
ctx.Data["HasMoreVotes"] = false
}
ctx.Data["Keyword"] = keyword
ctx.Data["ReceivedItems"] = receivedItems
ctx.Data["SelectedSort"] = sortParam
ctx.Data["SelectedStatus"] = statusParam
ctx.Data["SelectedRead"] = readParam
ctx.Data["SelectedType"] = typeParam
ctx.Data["SelectedReported"] = reportedParam
// 我发起的私聊 — 同样使用批量查询与分页(复用 chat_cursor 游标)
askerConvs, err := db.Conversations.ListByAsker(ctx.Request().Context(), ctx.User.ID, chatPageSize, int(chatOffset))
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get asked conversations")
askerConvs = []*db.Conversation{}
}
var askerDetails []conversationDetail
if len(askerConvs) > 0 {
askerConvIDs := make([]uint, len(askerConvs))
for i, c := range askerConvs {
askerConvIDs[i] = c.ID
}
askerFirstMsgMap, err := db.Messages.GetFirstMessagesByConversationIDs(ctx.Request().Context(), askerConvIDs)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get first messages for asker")
askerFirstMsgMap = make(map[uint]string)
}
// 批量查询所有 owner(去重),避免逐个 GetByID 造成 N+1;拉黑状态用批量关联表查询。
ownerIDSet := make(map[uint]struct{})
ownerIDs := make([]uint, 0, len(askerConvs))
for _, conv := range askerConvs {
if _, ok := ownerIDSet[conv.OwnerID]; !ok {
ownerIDSet[conv.OwnerID] = struct{}{}
ownerIDs = append(ownerIDs, conv.OwnerID)
}
}
ownerMap := make(map[uint]*db.User)
if owners, err := db.Users.GetByIDs(ctx.Request().Context(), ownerIDs); err == nil {
for _, u := range owners {
ownerMap[u.ID] = u
}
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get owners")
}
// 批量判断"哪些 owner 拉黑了我",避免循环查询。
blockedByMap, err := db.Users.BlockedByAnyOf(ctx.Request().Context(), ownerIDs, ctx.User.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch check blocked by owners")
blockedByMap = make(map[uint]bool)
}
for _, conv := range askerConvs {
firstMsg := askerFirstMsgMap[conv.ID]
ownerDomain := "unknown"
ownerName := "未知用户"
if owner, ok := ownerMap[conv.OwnerID]; ok {
ownerDomain = owner.Domain
ownerName = owner.Name
}
askerDetails = append(askerDetails, conversationDetail{
Conversation: conv,
OwnerDomain: ownerDomain,
OwnerName: ownerName,
FirstMessage: firstMsg,
IsBlocked: blockedByMap[conv.OwnerID],
})
}
}
ctx.Data["ConversationDetails"] = askerDetails
// 我发起的私聊总数与"加载更多"判定
askerTotal, cntErr := db.Conversations.CountByAsker(ctx.Request().Context(), ctx.User.ID)
if cntErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(cntErr).Error("Failed to count asked conversations")
askerTotal = int64(len(askerConvs))
}
hasMoreAsked := askerTotal > 0 && (chatOffset+uint(len(askerConvs)) < uint(askerTotal))
if hasMoreAsked {
nextOffset := chatOffset + uint(len(askerConvs))
ctx.Data["AskedChatsLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "chat_cursor", strconv.FormatUint(uint64(nextOffset), 10), "chats")
}
ctx.Data["HasMoreAskedChats"] = hasMoreAsked
// ---------- 私聊统计看板 ----------
// 与提问/投票看板一致,展示在「私聊」面板的顶部。全部基于已加载的会话切片计算,
// 不触发额外数据库查询:总数 = 我收到的(作为箱主) + 我发起的(作为提问者);
// 已关闭 = 两端会话中 is_closed=true 的计数。
chatClosed := int64(0)
for _, c := range convs {
if c.IsClosed {
chatClosed++
}
}
for _, c := range askerConvs {
if c.IsClosed {
chatClosed++
}
}
ctx.Data["ChatStats"] = map[string]int64{
"total": chatTotalReceived + askerTotal,
"received": chatTotalReceived,
"initiated": askerTotal,
"closed": chatClosed,
}
ctx.Success("user/question-list")
}
// parseCursorUint 将游标字符串安全解析为 uint;空或非法返回 0(表示从头分页)。
func parseCursorUint(s string) uint {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
id, err := strconv.ParseUint(s, 10, 64)
if err != nil || id == 0 {
return 0
}
return uint(id)
}
// buildQListPageURL 在保留当前筛选/搜索参数的基础上构造"加载更多"链接,
// 并追加锚点(anchor 指定跳转到的面板;空串表示默认提问面板)。
// 用于投票/提问列表的游标分页,避免丢失已选的 sort/status/type/q 等筛选条件。
func buildQListPageURL(current url.Values, key, val, anchor string) string {
q := url.Values{}
for k, vs := range current {
q[k] = vs
}
if val != "" {
q.Set(key, val)
} else {
q.Del(key)
}
qs := q.Encode()
path := "/user/questions"
if qs != "" {
path += "?" + qs
}
if anchor != "" {
path += "#" + anchor
}
return path
}