Files
tamabox/route/user/explore.go
T
2026-09-08 10:10:44 +08:00

300 lines
8.7 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 2026 tamakyi. 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 (
stdctx "context"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/flamego/cache"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// hotVotesCacheKey 探索页热门投票缓存 key(缓存 60s,避免每次 COUNT 聚合)。
// v2:改用独立 DTO 缓存,修复 db.Vote.LinkToken 因 json:"-" 在 Redis 序列化时丢失、
// 导致热门投票链接退化为 /vote/ 的问题。
// v3:额外保留 UserID,用于按箱主的投票功能开关过滤。
const hotVotesCacheKey = "explore:hot_votes:v3"
// hotVoteCacheItem 热门投票缓存条目:独立 DTO,显式保留 LinkToken(db.Vote 的
// LinkToken 带 json:"-" 标签,直接 Marshal Vote 会丢失,缓存命中后链接失效)。
type hotVoteCacheItem struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
LinkToken string `json:"link_token"`
CreatedAt time.Time `json:"created_at"`
}
var tagColors = []string{
"uk-label-success",
"uk-label-warning",
"uk-label-danger",
"uk-label-primary",
"uk-label-secondary",
}
type exploreUser struct {
*db.User
TagList []string
TagColors []string
}
// getHotVotesCached 读取探索页热门投票,带 Redis 缓存(60s)。
// 存储格式为 JSON []byte,避免 gob 序列化对模型类型注册的依赖。
func getHotVotesCached(ctx context.Context, c cache.Cache) ([]*db.Vote, error) {
if v, err := c.Get(stdctx.Background(), hotVotesCacheKey); err == nil {
if data, ok := v.([]byte); ok {
var items []*hotVoteCacheItem
if json.Unmarshal(data, &items) == nil && len(items) > 0 {
votes := make([]*db.Vote, 0, len(items))
for _, it := range items {
votes = append(votes, &db.Vote{
Model: dbutil.Model{ID: it.ID, CreatedAt: it.CreatedAt},
UserID: it.UserID,
Title: it.Title,
Type: it.Type,
LinkToken: it.LinkToken,
})
}
return votes, nil
}
}
}
votes, err := db.Votes.GetHot(ctx.Request().Context(), 6)
if err != nil {
return nil, err
}
items := make([]*hotVoteCacheItem, 0, len(votes))
for _, v := range votes {
items = append(items, &hotVoteCacheItem{
ID: v.ID,
UserID: v.UserID,
Title: v.Title,
Type: v.Type,
LinkToken: v.LinkToken,
CreatedAt: v.CreatedAt,
})
}
if data, err := json.Marshal(items); err == nil {
_ = c.Set(stdctx.Background(), hotVotesCacheKey, data, 60*time.Second)
}
return votes, nil
}
// filterVotesByOwnerFeature 过滤掉箱主已关闭投票功能的投票(开关 / 定时 / 定量收集),
// 避免探索页出现点了打不开的死链。功能配置批量加载,避免逐条查询造成 N+1;
// 加载失败时按原样返回,依赖表抖动不应导致探索页空白。
func filterVotesByOwnerFeature(ctx context.Context, votes []*db.Vote) []*db.Vote {
if len(votes) == 0 {
return votes
}
ownerIDs := make([]uint, 0, len(votes))
seen := make(map[uint]bool, len(votes))
for _, v := range votes {
if !seen[v.UserID] {
seen[v.UserID] = true
ownerIDs = append(ownerIDs, v.UserID)
}
}
controls, err := db.FeatureControls.ListByUserIDs(ctx.Request().Context(), ownerIDs, db.FeatureVote)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load vote feature controls")
return votes
}
now := time.Now()
out := make([]*db.Vote, 0, len(votes))
for _, v := range votes {
fc, ok := controls[v.UserID]
if !ok {
fc = db.DefaultFeatureControl(v.UserID, db.FeatureVote)
}
if fc.Evaluate(now).Open {
out = append(out, v)
}
}
return out
}
// Explore 显示用户列表,支持分页和排序
func Explore(ctx context.Context, c cache.Cache) {
keyword := ctx.Query("q")
sort := ctx.Query("sort")
// 白名单校验排序方式
if sort != "latest" && sort != "oldest" {
sort = "latest"
}
pageStr := ctx.Query("page")
page := 1
if pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
pageSize := 20
var users []*db.User
var total int64
var err error
if keyword != "" {
users, total, err = db.Users.GetByTag(ctx.Request().Context(), keyword, page, pageSize, sort)
} else {
users, total, err = db.Users.GetShowInHome(ctx.Request().Context(), page, pageSize, sort)
}
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get users for explore")
ctx.SetInternalError() // 设置内部错误并渲染页面
ctx.Success("user/explore")
return
}
// 热门投票板块(Redis 缓存 60s)
hotVotes, err := getHotVotesCached(ctx, c)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get hot votes")
hotVotes = []*db.Vote{}
}
ctx.Data["HotVotes"] = filterVotesByOwnerFeature(ctx, hotVotes)
allVotes, err := db.Votes.ListPublishedPublic(ctx.Request().Context(), 30)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get public votes for explore")
allVotes = []*db.Vote{}
}
ctx.Data["AllVotes"] = filterVotesByOwnerFeature(ctx, allVotes)
// 计算总页数(向上取整)
totalPages := (int(total) + pageSize - 1) / pageSize
// 页面越界保护
if page > totalPages && totalPages > 0 {
page = totalPages
}
// 构建带有标签颜色的用户列表
exploreUsers := make([]*exploreUser, 0, len(users))
for _, u := range users {
var tags []string
if u.Tags != "" {
tags = strings.Split(u.Tags, ",")
}
tagColorsList := make([]string, len(tags))
for i := range tags {
tagColorsList[i] = tagColors[i%len(tagColors)]
}
exploreUsers = append(exploreUsers, &exploreUser{
User: u,
TagList: tags,
TagColors: tagColorsList,
})
}
ctx.Data["Users"] = exploreUsers
ctx.Data["Keyword"] = keyword
ctx.Data["Sort"] = sort
ctx.Data["Page"] = page
ctx.Data["TotalPages"] = totalPages
ctx.Data["PrevPage"] = page - 1
ctx.Data["NextPage"] = page + 1
// 热门标签榜:统计展示在首页用户的高频标签
hotTags, err := db.Users.GetHotTags(ctx.Request().Context(), 12)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get hot tags")
hotTags = []string{}
}
ctx.Data["HotTags"] = hotTags
// 服务端直接获取随机推荐,避免前端 AJAX 往返导致"加载中"等待
randomUsers, err := db.Users.GetRandom(ctx.Request().Context(), 6)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to get random users for sidebar")
}
recommendUsers := make([]*exploreUser, 0, len(randomUsers))
for _, u := range randomUsers {
var tags []string
if u.Tags != "" {
tags = strings.Split(u.Tags, ",")
}
tagColorsList := make([]string, len(tags))
for i := range tags {
tagColorsList[i] = tagColors[i%len(tagColors)]
}
recommendUsers = append(recommendUsers, &exploreUser{
User: u,
TagList: tags,
TagColors: tagColorsList,
})
}
ctx.Data["RecommendUsers"] = recommendUsers
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetTitle(i18n.T(lang, "nav.explore") + " - " + i18n.T(lang, "site.title"))
ctx.Success("user/explore")
}
// RandomRecommend 返回6个随机推荐用户(JSON格式)
// 前端期望的响应格式:{ "code": 0, "data": [...] }
func RandomRecommend(ctx context.Context) {
users, err := db.Users.GetRandomFresh(ctx.Request().Context(), 6)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get random users")
ctx.JSONError(50000, i18n.T(ctx.Lang(), "explore.errRandomUser"))
return
}
type recommendUser struct {
ID uint `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
Avatar string `json:"avatar"`
Intro string `json:"intro"`
TagList []string `json:"tag_list"`
TagColor []string `json:"tag_color"`
}
result := make([]recommendUser, 0, len(users))
for _, u := range users {
var tags []string
if u.Tags != "" {
tags = strings.Split(u.Tags, ",")
}
tagColorsList := make([]string, len(tags))
for i := range tags {
tagColorsList[i] = tagColors[i%len(tagColors)]
}
result = append(result, recommendUser{
ID: u.ID,
Name: u.Name,
Domain: u.Domain,
Avatar: u.Avatar,
Intro: u.Intro,
TagList: tags,
TagColor: tagColorsList,
})
}
// Context.JSON 已统一包装 code/data/message,这里只传业务数据,避免嵌套响应。
ctx.JSON(result)
}