Files
tamabox/route/user/explore.go
T
tamakyi c971d99bdb fix(vote): 站方处置覆盖投票链路
提问链路上 ActiveLevel 有 7 处调用,投票链路 0 处。于是 L4(整页停用)处置某人后:
/_/domain 已经 302 到 /restricted,但 /vote/{token} 照常渲染、照常收票;被处置者
还能发新投票;探索页的「热门/全部投票」也不做处置过滤 —— 同一张页面里
「箱主消失了、他的投票还在曝光」。这是运营口径不一致,不是越权。

补齐三处,与提问链路对齐:
- vote.Page      L4 → 302 /restricted(同箱主页;查询失败 fail-open,与 Pager 一致)
- vote.Submit    L4 → 拒绝(页面进不去不代表直接构造 POST 也拦得住)
- VotePublish    L2 起拒绝(L2 已是「收窄公开」,不该再新增公开内容)
- 探索页投票列表改用 Restrictions.ActiveLevels 批量过滤(与 GetShowInHome 同口径,
  一次查询避免 N+1),功能开关过滤逻辑保持不变

新增字典 key vote.err.restrictedPublish(四语言)。

验证(本地实例):
- L4 后 /vote/{token} 302 → /restricted;撤销后恢复 200
- 探索页该投票出现次数 0(处置中)→ 2(撤销后)
- 草稿投票在 L2 下发布后状态停留 draft,撤销后同一请求变为 published
2026-10-29 15:53:49 +08:00

328 lines
9.8 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
}
// filterVotesForExplore 过滤掉不该出现在探索页的投票:
//
// 1. 箱主已关闭投票功能(开关 / 定时 / 定量收集)—— 避免点开是死链;
// 2. 箱主处于站方处置中 —— 与首页箱主列表(GetShowInHome 用
// activeRestrictionsSubQuery)保持同一口径。此前只过滤了功能开关,
// 于是同一张探索页里会出现「箱主本人在列表里消失了,他的投票还在曝光」。
//
// 两次配置都是批量加载,避免逐条查询造成 N+1;任一侧加载失败时按「该项不过滤」
// 处理,依赖表抖动不应让探索页整页空白。
func filterVotesForExplore(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)
}
}
// 生效处置(任意级别):这批箱主的内容不再进入公开曝光
restricted := make(map[uint]bool, len(ownerIDs))
if levels, err := db.Restrictions.ActiveLevels(ctx.Request().Context(), ownerIDs); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to batch load user restrictions")
} else {
for uid, level := range levels {
if level != "" {
restricted[uid] = true
}
}
}
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 {
if restricted[v.UserID] {
continue
}
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 != "" {
// 翻页深度上限:原来只校验 > 0,于是 page=2147483647 会让 Postgres 先算出
// 一个巨大的 OFFSET 再丢掉全部结果 —— 一次请求换一次廉价慢查询。
// 500 × 20 = 10000 个用户,远超探索页的真实翻页需求。
const maxPage = 500
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
if p > maxPage {
p = maxPage
}
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"] = filterVotesForExplore(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"] = filterVotesForExplore(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)
}