// 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 // 仅对普通提问有效:置顶/取消置顶操作链接 } // 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" } 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 } } // 搜索场景下关键词已在 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 } 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", }) } // 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 // 我发起的私聊 — 同样使用批量查询与分页(复用 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 }