Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
481 lines
17 KiB
Go
481 lines
17 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 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
|
||
}
|