提问链路上 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
328 lines
9.8 KiB
Go
328 lines
9.8 KiB
Go
// 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)
|
||
}
|