This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
// 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)
}