// 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) }