chore: baseline snapshot before moderation-strictness feature

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.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
+480
View File
@@ -0,0 +1,480 @@
// 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
}