0904
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// 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 (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// featureTimeLayout 与 <input type="datetime-local"> 的取值格式一致。
|
||||
const featureTimeLayout = "2006-01-02T15:04"
|
||||
|
||||
// 账号设置页的面板标识:第一个是用户设置,其余三个与受控功能一一对应,
|
||||
// 因此保存后可以直接用功能标识回跳到对应的功能面板。
|
||||
const (
|
||||
ProfileTabAccount = "account"
|
||||
ProfileTabQuestion = db.FeatureQuestion
|
||||
ProfileTabChat = db.FeatureChat
|
||||
ProfileTabVote = db.FeatureVote
|
||||
)
|
||||
|
||||
// profileURLWithTab 账号设置页 + 指定面板的回跳地址,用于保存后停留在原面板。
|
||||
func profileURLWithTab(tab string) string {
|
||||
return "/user/profile?tab=" + ProfileTab(tab)
|
||||
}
|
||||
|
||||
// ProfileTab 归一化账号设置页的 tab 参数,非法值回落到用户设置面板。
|
||||
func ProfileTab(tab string) string {
|
||||
switch tab {
|
||||
case ProfileTabQuestion, ProfileTabChat, ProfileTabVote:
|
||||
return tab
|
||||
}
|
||||
return ProfileTabAccount
|
||||
}
|
||||
|
||||
// featureView 功能管理面板里单个功能的展示模型(配置 + 实时状态 + 已格式化的时间串)。
|
||||
//
|
||||
// 面板以「点 = 本功能」的方式渲染到部分模板 user/feature-control-form,
|
||||
// 因此把渲染所需的语言与 CSRF 令牌一并带上,部分模板内无需访问根数据。
|
||||
type featureView struct {
|
||||
Key string
|
||||
LangCode string
|
||||
NameText string
|
||||
StatusText string
|
||||
CSRFTokenHTML interface{}
|
||||
Control *db.FeatureControl
|
||||
State db.FeatureState
|
||||
OpenAtStr string
|
||||
CloseAtStr string
|
||||
QuotaResetAtStr string
|
||||
ScheduleDays string // 逗号分隔周几(0=周日…6=周六),模板据此勾选开放日
|
||||
ScheduleStart string // "HH:MM"
|
||||
ScheduleEnd string // "HH:MM"
|
||||
Mode string // 收集模式:always(长期开放)/ window(定时收集)/ recurring(循环定时)
|
||||
}
|
||||
|
||||
// 收集模式取值(表单 mode 字段)。三种模式在保存时互斥:
|
||||
// 只保留所选模式的字段,其余清空,避免「一次性窗口 AND 循环定时」叠加
|
||||
// 导致一次性关闭时间成为永久硬边界、难以恢复长期开放。
|
||||
const (
|
||||
FeatureModeAlways = "always" // 长期开放:不限时间
|
||||
FeatureModeWindow = "window" // 定时收集:一次性开放 / 关闭窗口
|
||||
FeatureModeRecurring = "recurring" // 循环定时:每周循环
|
||||
)
|
||||
|
||||
// inferFeatureMode 根据字段组合推断收集模式:
|
||||
// 有循环定时配置 → recurring;有一性次时间 → window;否则 → always。
|
||||
// 用于渲染面板时从已保存配置还原模式,以及旧表单提交未携带 mode 时的兜底。
|
||||
func inferFeatureMode(openAt, closeAt *time.Time, scheduleDays, scheduleStart, scheduleEnd string) string {
|
||||
if scheduleDays != "" || (scheduleStart != "" && scheduleEnd != "") {
|
||||
return FeatureModeRecurring
|
||||
}
|
||||
if openAt != nil || closeAt != nil {
|
||||
return FeatureModeWindow
|
||||
}
|
||||
return FeatureModeAlways
|
||||
}
|
||||
|
||||
// resolveLang 取当前请求已解析好的语言代码(由 Contexter 写入 Data)。
|
||||
func resolveLang(ctx context.Context) string {
|
||||
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
|
||||
return l
|
||||
}
|
||||
return i18n.Default
|
||||
}
|
||||
|
||||
// buildFeatureViews 读取全部受控功能的配置,按功能标识建索引;
|
||||
// 尚未落库的功能用默认值补齐,保证三个功能面板始终有数据可渲染。
|
||||
func buildFeatureViews(ctx context.Context, userID uint) map[string]*featureView {
|
||||
lang := resolveLang(ctx)
|
||||
now := time.Now()
|
||||
csrf := ctx.Data["CSRFTokenHTML"]
|
||||
|
||||
existing := map[string]*db.FeatureControl{}
|
||||
if list, err := db.FeatureControls.ListByUserID(ctx.Request().Context(), userID); err == nil {
|
||||
for _, fc := range list {
|
||||
existing[fc.Feature] = fc
|
||||
}
|
||||
} else {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to list feature controls")
|
||||
}
|
||||
|
||||
views := make(map[string]*featureView, len(db.FeatureKeys))
|
||||
for _, key := range db.FeatureKeys {
|
||||
fc, ok := existing[key]
|
||||
if !ok {
|
||||
fc = db.DefaultFeatureControl(userID, key)
|
||||
}
|
||||
state := fc.Evaluate(now)
|
||||
mode := inferFeatureMode(fc.OpenAt, fc.CloseAt, fc.ScheduleDays, fc.ScheduleStart, fc.ScheduleEnd)
|
||||
v := &featureView{
|
||||
Key: key,
|
||||
LangCode: lang,
|
||||
NameText: i18n.FeatureName(lang, key),
|
||||
StatusText: i18n.FeatureStatusText(lang, state.Reason),
|
||||
CSRFTokenHTML: csrf,
|
||||
Control: fc,
|
||||
State: state,
|
||||
Mode: mode,
|
||||
}
|
||||
if fc.OpenAt != nil {
|
||||
v.OpenAtStr = fc.OpenAt.Format(featureTimeLayout)
|
||||
}
|
||||
if fc.CloseAt != nil {
|
||||
v.CloseAtStr = fc.CloseAt.Format(featureTimeLayout)
|
||||
}
|
||||
if fc.QuotaResetAt != nil {
|
||||
v.QuotaResetAtStr = fc.QuotaResetAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
v.ScheduleDays = fc.ScheduleDays
|
||||
v.ScheduleStart = fc.ScheduleStart
|
||||
v.ScheduleEnd = fc.ScheduleEnd
|
||||
views[key] = v
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// InjectFeatureViews 把三个功能的配置注入账号设置页(用户设置面板不需要,但一并注入开销可忽略)。
|
||||
// 供 Profile 与保存失败后重渲染的场景复用。
|
||||
func InjectFeatureViews(ctx context.Context) {
|
||||
views := buildFeatureViews(ctx, ctx.User.ID)
|
||||
ctx.Data["FeatureQuestion"] = views[db.FeatureQuestion]
|
||||
ctx.Data["FeatureChat"] = views[db.FeatureChat]
|
||||
ctx.Data["FeatureVote"] = views[db.FeatureVote]
|
||||
ctx.Data["QuotaLimitMax"] = db.QuotaLimitMax
|
||||
}
|
||||
|
||||
// FeaturesUpdate 保存单个功能的开关 / 定时 / 定量设置。
|
||||
// 每个功能面板各自一张表单,只提交自己的字段,保存后回跳到该面板。
|
||||
func FeaturesUpdate(ctx context.Context) {
|
||||
lang := resolveLang(ctx)
|
||||
key := strings.TrimSpace(ctx.Request().FormValue("feature"))
|
||||
if !db.IsValidFeature(key) {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errInvalid"))
|
||||
ctx.Redirect(profileURLWithTab(ProfileTabAccount))
|
||||
return
|
||||
}
|
||||
|
||||
name := i18n.FeatureName(lang, key)
|
||||
tab := ProfileTab(key)
|
||||
|
||||
openAt, err := parseTimeInput(ctx, strings.TrimSpace(ctx.Request().FormValue("open_at")))
|
||||
if err != nil {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errTime", name))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
closeAt, err := parseTimeInput(ctx, strings.TrimSpace(ctx.Request().FormValue("close_at")))
|
||||
if err != nil {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errTime", name))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
|
||||
quota, err := parseQuotaLimit(strings.TrimSpace(ctx.Request().FormValue("quota_limit")))
|
||||
if err != nil {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errQuota", name))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
|
||||
// 循环定时:开放日(逗号分隔周几)+ 每日时间段(HH:MM,需成对填写)
|
||||
scheduleDays := normalizeScheduleDays(ctx.Request().FormValue("schedule_days"))
|
||||
scheduleStart := strings.TrimSpace(ctx.Request().FormValue("schedule_start"))
|
||||
scheduleEnd := strings.TrimSpace(ctx.Request().FormValue("schedule_end"))
|
||||
|
||||
// 收集模式三选一;未携带 mode(旧表单重放等)时按提交字段推断,保持兼容。
|
||||
mode := strings.TrimSpace(ctx.Request().FormValue("mode"))
|
||||
switch mode {
|
||||
case FeatureModeAlways, FeatureModeWindow, FeatureModeRecurring:
|
||||
default:
|
||||
mode = inferFeatureMode(openAt, closeAt, scheduleDays, scheduleStart, scheduleEnd)
|
||||
}
|
||||
|
||||
// 模式互斥:清理未选模式的字段,一次性窗口与循环定时不再叠加
|
||||
// (旧版叠加语义下,一次性关闭时间过后功能永久结束,难以恢复长期开放)。
|
||||
switch mode {
|
||||
case FeatureModeAlways:
|
||||
openAt, closeAt = nil, nil
|
||||
scheduleDays, scheduleStart, scheduleEnd = "", "", ""
|
||||
case FeatureModeWindow:
|
||||
scheduleDays, scheduleStart, scheduleEnd = "", "", ""
|
||||
case FeatureModeRecurring:
|
||||
openAt, closeAt = nil, nil
|
||||
}
|
||||
|
||||
if mode == FeatureModeWindow && openAt != nil && closeAt != nil && !closeAt.After(*openAt) {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errTimeRange", name))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
if mode == FeatureModeRecurring {
|
||||
if (scheduleStart == "") != (scheduleEnd == "") {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errScheduleHalf", name))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
if scheduleStart != "" {
|
||||
startM, err := parseHHMM(scheduleStart)
|
||||
if err != nil {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errScheduleTime", name))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
endM, err := parseHHMM(scheduleEnd)
|
||||
if err != nil {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errScheduleTime", name))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
if endM <= startM {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errScheduleRange", name))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 私聊不提供收集上限:会话是双向对话(已有单会话消息数上限),
|
||||
// 限制「新建会话数」对箱主意义不大。UI 已隐藏该字段,
|
||||
// 这里强制清零,防止历史残留的上限让私聊「莫名关闭」。
|
||||
if key == db.FeatureChat {
|
||||
quota = 0
|
||||
}
|
||||
|
||||
if err := db.FeatureControls.Save(ctx.Request().Context(), ctx.User.ID, key, db.UpdateFeatureControlOptions{
|
||||
Enabled: ctx.Request().FormValue("enabled") == "on",
|
||||
OpenAt: openAt,
|
||||
CloseAt: closeAt,
|
||||
ScheduleDays: scheduleDays,
|
||||
ScheduleStart: scheduleStart,
|
||||
ScheduleEnd: scheduleEnd,
|
||||
QuotaLimit: quota,
|
||||
}); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).
|
||||
Errorf("Failed to save feature control %q", key)
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
return
|
||||
}
|
||||
|
||||
// 私聊开关与 users.allow_chat 双向同步:后者仍被邮件通知等既有逻辑读取,
|
||||
// 保持两处一致,避免功能面板与旧字段表现不一致。
|
||||
if key == db.FeatureChat {
|
||||
if err := db.Users.UpdateAllowChat(ctx.Request().Context(), ctx.User.ID, ctx.Request().FormValue("enabled") == "on"); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to sync users.allow_chat")
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(lang, "feature.saved"))
|
||||
ctx.Redirect(profileURLWithTab(tab))
|
||||
}
|
||||
|
||||
// FeatureResetQuota 清零某功能的收集计数,开启下一轮定量收集。
|
||||
func FeatureResetQuota(ctx context.Context) {
|
||||
lang := resolveLang(ctx)
|
||||
key := strings.TrimSpace(ctx.Request().FormValue("feature"))
|
||||
if !db.IsValidFeature(key) {
|
||||
ctx.SetErrorFlash(i18n.T(lang, "feature.errInvalid"))
|
||||
ctx.Redirect(profileURLWithTab(ProfileTabAccount))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.FeatureControls.ResetQuota(ctx.Request().Context(), ctx.User.ID, key); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).
|
||||
Errorf("Failed to reset feature quota %q", key)
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect(profileURLWithTab(key))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(lang, "feature.quotaReset", i18n.FeatureName(lang, key)))
|
||||
ctx.Redirect(profileURLWithTab(key))
|
||||
}
|
||||
|
||||
// parseQuotaLimit 解析收集上限:空串与 0 均表示不限制。
|
||||
func parseQuotaLimit(s string) (int64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, errors.New("invalid quota limit")
|
||||
}
|
||||
if n > db.QuotaLimitMax {
|
||||
return 0, errors.New("quota limit too large")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// normalizeScheduleDays 归一化循环定时的开放日:只保留 0-6 的合法周几并去重、排序。
|
||||
// 复选框多选值形如 ["0","2","4"](time.Weekday:0=周日…6=周六)。
|
||||
func normalizeScheduleDays(raw string) string {
|
||||
seen := make(map[int]bool)
|
||||
var days []string
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil || n < 0 || n > 6 || seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
days = append(days, strconv.Itoa(n))
|
||||
}
|
||||
// 按周几排序,保证存储稳定、展示一致
|
||||
sort.Strings(days)
|
||||
return strings.Join(days, ",")
|
||||
}
|
||||
|
||||
// parseHHMM 解析 "HH:MM" 为当天第几分钟(与 db 包内同名函数逻辑一致,路由层用于校验表单)。
|
||||
func parseHHMM(s string) (int, error) {
|
||||
t, err := time.Parse("15:04", strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0, errors.New("invalid HH:MM")
|
||||
}
|
||||
return t.Hour()*60 + t.Minute(), nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2022 E99p1ant. 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 (
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// returnTab 表单提交后回跳的账号设置面板:屏蔽词设置已分散展示在
|
||||
// 提问/私聊/投票三个面板,action 携带 ?tab= 来源参数,保存后停留原面板。
|
||||
func returnTab(ctx context.Context) string {
|
||||
return profileURLWithTab(ProfileTab(ctx.Query("tab")))
|
||||
}
|
||||
|
||||
// abortOnFormError 处理表单校验/反序列化失败:返回 true 表示已中止本次写入。
|
||||
//
|
||||
// 背景:form.Bind 在反序列化失败(如请求体超限、body 畸形)时只置位错误并继续执行
|
||||
// handler 链(flamego 语义:响应未写即自动续链),零值结构体会照常进入业务动作——
|
||||
// 对不检查 ctx.HasError() 的 handler,会把用户既有配置静默清空/关闭,例如:
|
||||
// - UpdateHarassment:RegisterOnly="" → 防骚扰被改回 none(关闭"仅允许注册用户");
|
||||
// - UpdateBlockWords:BlockWords="" → 全部屏蔽词被清空;
|
||||
// - UpdateTags:Tags="" → 标签被清空、ShowInHome 被关;
|
||||
// - UpdatePagePassword / UpdateAskPassword:Password="" → 访问/提问密码被解除。
|
||||
//
|
||||
// 错误信息已由 Bind 经 context.SetError 写入 Data["Error"](govalid 校验错误已按界面
|
||||
// 语言翻译,解析错误为原文),此处转入 flash,保证 PRG 重定向后仍能向用户展示。
|
||||
func abortOnFormError(ctx context.Context, redirectTo string) bool {
|
||||
if !ctx.HasError() {
|
||||
return false
|
||||
}
|
||||
if msg, ok := ctx.Data["Error"].(string); ok && msg != "" {
|
||||
ctx.SetErrorFlash(msg)
|
||||
}
|
||||
ctx.Redirect(redirectTo)
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateHarassment 仅更新「防骚扰类型」(none / register_only,即"仅允许注册用户向我提问")。
|
||||
// 只写 harassment_setting 列;全局屏蔽词已拆分为独立入口 UpdateBlockWords,
|
||||
// 二者互不覆盖(此前同一个表单一次写两列,面板拆分后单侧保存会误清空另一侧)。
|
||||
func UpdateHarassment(ctx context.Context, f form.UpdateHarassment) {
|
||||
// 反序列化失败(如请求体超限)时零值 RegisterOnly="" 会把防骚扰静默改回 none
|
||||
// (若此前为 register_only)——先中止,不触碰现有设置。
|
||||
if abortOnFormError(ctx, returnTab(ctx)) {
|
||||
return
|
||||
}
|
||||
|
||||
harassmentSettingType := db.HarassmentSettingNone
|
||||
if f.RegisterOnly != "" {
|
||||
harassmentSettingType = db.HarassmentSettingTypeRegisterOnly
|
||||
}
|
||||
|
||||
if err := db.Users.UpdateHarassmentType(ctx.Request().Context(), ctx.User.ID, harassmentSettingType); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update harassment setting")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.harassmentUpdated"))
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
}
|
||||
|
||||
// UpdateBlockWords 更新全局屏蔽词表:提问、私聊(对方发给我的消息)与投票
|
||||
// 提交的开放文本共用同一份词表(users.block_words)。
|
||||
func UpdateBlockWords(ctx context.Context, f form.UpdateBlockWords) {
|
||||
// 反序列化失败(如请求体超限)时零值 BlockWords="" 会经 sanitizeBlockWords 得到
|
||||
// 空词表,静默清空用户全部屏蔽词——先中止,不触碰现有词表。
|
||||
if abortOnFormError(ctx, returnTab(ctx)) {
|
||||
return
|
||||
}
|
||||
|
||||
words, errKey, errWord := sanitizeBlockWords(f.BlockWords)
|
||||
if errKey != "" {
|
||||
if errKey == "admin.flash.blockWordTooLong" {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), errKey, errWord))
|
||||
} else {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), errKey))
|
||||
}
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Users.UpdateBlockWords(ctx.Request().Context(), ctx.User.ID, strings.Join(words, ",")); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update block words")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.blockWordsUpdated"))
|
||||
ctx.Redirect(returnTab(ctx))
|
||||
}
|
||||
|
||||
// sanitizeBlockWords 把输入的屏蔽词串规整为去重后的词表:
|
||||
// - 全角逗号转半角;逐词 TrimSpace;跳过空词与重复词。
|
||||
// - 单词长度按字符(rune)计数,超限(conf.Limits.MaxBlockWordLen)返回超长提示;
|
||||
// - 词总数超限(conf.Limits.MaxBlockWords)返回数量提示。
|
||||
//
|
||||
// 成功时 errKey 为空,errWord 为触发超长的具体词(供 flash 展示)。
|
||||
func sanitizeBlockWords(raw string) (words []string, errKey string, errWord string) {
|
||||
blockWords := strings.ReplaceAll(raw, ",", ",")
|
||||
blockWords = strings.TrimSpace(blockWords)
|
||||
|
||||
words = make([]string, 0)
|
||||
wordSet := make(map[string]struct{})
|
||||
for _, word := range strings.Split(blockWords, ",") {
|
||||
word := strings.TrimSpace(word)
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := wordSet[word]; ok {
|
||||
continue
|
||||
}
|
||||
wordSet[word] = struct{}{}
|
||||
|
||||
// 按字符(rune)计数:早期用 len(word) 按字节计数,中文一词 3 字节,
|
||||
// 导致中文屏蔽词实际只能输 3 个字,与界面文案「最大长度为 10」不符。
|
||||
// 上限取自 conf.Limits.MaxBlockWordLen([limits] 配置段)。
|
||||
if len([]rune(word)) > conf.Limits.MaxBlockWordLen {
|
||||
return nil, "admin.flash.blockWordTooLong", word
|
||||
}
|
||||
words = append(words, word)
|
||||
}
|
||||
|
||||
if len(words) > conf.Limits.MaxBlockWords {
|
||||
return nil, "admin.flash.blockWordsTooMany", ""
|
||||
}
|
||||
return words, "", ""
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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 (
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
// UpdatePagePassword 更新页面访问密码
|
||||
func UpdatePagePassword(ctx context.Context, f form.UpdatePagePassword) {
|
||||
// 反序列化失败(如请求体超限)时零值 Password="" 会把密码静默解除——先中止,
|
||||
// 不触碰现有密码。govalid 校验失败(如密码超长)同样在此拦截,避免绕过前端限制。
|
||||
if abortOnFormError(ctx, "/user/profile") {
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Users.UpdatePagePassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update page password")
|
||||
ctx.SetInternalErrorFlash()
|
||||
} else {
|
||||
if f.Password == "" {
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.pagePwdOff"))
|
||||
} else {
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.pagePwdUpdated"))
|
||||
}
|
||||
}
|
||||
ctx.Redirect("/user/profile")
|
||||
}
|
||||
|
||||
// UpdateAskPassword 更新提问密码
|
||||
func UpdateAskPassword(ctx context.Context, f form.UpdateAskPassword) {
|
||||
// 同上:反序列化失败或校验失败时零值 Password="" 会把提问密码静默解除。
|
||||
if abortOnFormError(ctx, profileURLWithTab(ProfileTabQuestion)) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Users.UpdateAskPassword(ctx.Request().Context(), ctx.User.ID, f.Password); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update ask password")
|
||||
ctx.SetInternalErrorFlash()
|
||||
} else {
|
||||
if f.Password == "" {
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.askPwdOff"))
|
||||
} else {
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.askPwdUpdated"))
|
||||
}
|
||||
}
|
||||
// 提问密码属于提问功能的专属设置,保存后回到「提问」面板
|
||||
ctx.Redirect(profileURLWithTab(ProfileTabQuestion))
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
// Copyright 2022 E99p1ant. 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
"github.com/tamakyi/TamaBox/internal/storage"
|
||||
)
|
||||
|
||||
// Profile 账号设置页。按面板分区:
|
||||
// - account:用户设置(个人信息 / 个性化 / 社交 / 密码 / 导出 / 停用账号);
|
||||
// - question / chat / vote:三个功能的管理(开关 + 定时 + 定量 + 各自的专属设置)。
|
||||
//
|
||||
// 面板由 ?tab= 指定,非法值回落到用户设置,保证分享链接不会渲染出空页面。
|
||||
func Profile(ctx context.Context) {
|
||||
ctx.Data["HasPagePassword"] = ctx.User.PagePassword != ""
|
||||
ctx.Data["HasAskPassword"] = ctx.User.AskPassword != ""
|
||||
ctx.Data["ActiveProfileTab"] = ProfileTab(ctx.Query("tab"))
|
||||
ctx.Data["TagLimits"] = tagLimits()
|
||||
ctx.Data["ChatLimits"] = chatLimits()
|
||||
InjectFeatureViews(ctx)
|
||||
ctx.Success("user/profile")
|
||||
}
|
||||
|
||||
// tagLimits 标签与屏蔽词的数量 / 长度上限,供前端标签块输入组件做实时提示。
|
||||
// 数值来自 conf.Limits([limits] 配置段),后端校验引用同一份配置。
|
||||
func tagLimits() map[string]int {
|
||||
return map[string]int{
|
||||
"MaxTags": conf.Limits.MaxTags,
|
||||
"MaxTagLen": conf.Limits.MaxTagLen,
|
||||
"MaxBlockWords": conf.Limits.MaxBlockWords,
|
||||
"MaxBlockWordLen": conf.Limits.MaxBlockWordLen,
|
||||
}
|
||||
}
|
||||
|
||||
// chatLimits 私聊面板的数值上下界,供设置表单的 number 输入框做 min/max 提示。
|
||||
// 同样来自 conf.Limits:会话条数硬顶与每日条数可设上限。
|
||||
func chatLimits() map[string]int {
|
||||
return map[string]int{
|
||||
"MaxChatMessages": conf.Limits.ChatMaxMessages,
|
||||
"DailyMessageLimitMax": conf.Limits.DailyMessageLimitMax,
|
||||
}
|
||||
}
|
||||
|
||||
func ProfileAPI(ctx context.Context) error {
|
||||
return ctx.JSON(ctx.User)
|
||||
}
|
||||
|
||||
func UpdateProfile(ctx context.Context, f form.UpdateProfile) {
|
||||
if ctx.HasError() {
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户信息,用于保留未修改的字段
|
||||
currentUser, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get current user")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect("/user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// 处理头像上传(强制 1:1,见 storage.UploadAvatarToS3)
|
||||
avatarURL := currentUser.Avatar // 默认使用原值
|
||||
avatarFile, avatarFileHeader, err := ctx.Request().FormFile("avatar")
|
||||
if err == nil {
|
||||
defer avatarFile.Close()
|
||||
if avatarFileHeader.Size > storage.MaxAvatarSize {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.avatarTooLarge")))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
uploadedURL, err := storage.UploadAvatarToS3(avatarFile, avatarFileHeader)
|
||||
if err != nil {
|
||||
if errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, imagecheck.ErrBadAspectRatio) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "profile.err.avatarRatio"))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload avatar")
|
||||
} else {
|
||||
avatarURL = uploadedURL
|
||||
}
|
||||
}
|
||||
|
||||
// 从表单读取 UseCustomQRColor 并转换为布尔值
|
||||
useCustomQRColor := f.UseCustomQRColor == "on"
|
||||
|
||||
// 处理背景上传(强制 16:9,见 storage.UploadBackgroundToS3)
|
||||
backgroundURL := currentUser.Background // 默认使用原值
|
||||
backgroundFile, backgroundFileHeader, err := ctx.Request().FormFile("background")
|
||||
if err == nil {
|
||||
defer backgroundFile.Close()
|
||||
if backgroundFileHeader.Size > storage.MaxBackgroundSize {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.backgroundTooLarge")))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
uploadedURL, err := storage.UploadBackgroundToS3(backgroundFile, backgroundFileHeader)
|
||||
if err != nil {
|
||||
if errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, imagecheck.ErrBadAspectRatio) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "profile.err.backgroundRatio"))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload background")
|
||||
} else {
|
||||
backgroundURL = uploadedURL
|
||||
}
|
||||
}
|
||||
|
||||
// 处理二维码背景图上传
|
||||
backgroundImageURL := currentUser.BackgroundImage // 默认使用原值
|
||||
backgroundimageFile, backgroundimageFileHeader, err := ctx.Request().FormFile("backgroundimage")
|
||||
if err == nil {
|
||||
defer backgroundimageFile.Close()
|
||||
if backgroundimageFileHeader.Size > storage.MaxBackgroundSize {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.backgroundTooLarge")))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
uploadedURL, err := storage.UploadPictureToS3(backgroundimageFile, backgroundimageFileHeader)
|
||||
if err != nil {
|
||||
if errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload QRbackgroundImage")
|
||||
} else {
|
||||
backgroundImageURL = uploadedURL
|
||||
}
|
||||
}
|
||||
|
||||
// 密码修改逻辑(新密码非空时强制至少 8 位)。
|
||||
// 是否真的改了密码会决定保存成功后的去向:改密则清会话登出(见文件尾部保存分支)。
|
||||
passwordChanged := false
|
||||
if f.NewPassword != "" {
|
||||
if len(f.NewPassword) < 8 {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.newPasswordTooShort")))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
if err := db.Users.ChangePassword(ctx.Request().Context(), ctx.User.ID, f.OldPassword, f.NewPassword); err != nil {
|
||||
if errors.Is(err, db.ErrBadCredential) {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "profile.err.badOldPassword")))
|
||||
} else {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update password")
|
||||
ctx.SetInternalError()
|
||||
}
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
passwordChanged = true
|
||||
}
|
||||
|
||||
// 通知类型
|
||||
var notify db.NotifyType
|
||||
if f.NotifyEmail != "" {
|
||||
notify = db.NotifyTypeEmail
|
||||
} else {
|
||||
notify = db.NotifyTypeNone
|
||||
}
|
||||
|
||||
// 构建更新选项
|
||||
if err := db.Users.Update(ctx.Request().Context(), ctx.User.ID, db.UpdateUserOptions{
|
||||
Name: f.Name,
|
||||
Avatar: avatarURL,
|
||||
Background: backgroundURL,
|
||||
Intro: f.Intro,
|
||||
Qrcodebackcolor: f.Qrcodebackcolor,
|
||||
Qrcodecolor: f.Qrcodecolor,
|
||||
Dotscale: f.Dotscale,
|
||||
BackgroundImage: backgroundImageURL,
|
||||
Backgroundimagealpha: f.Backgroundimagealpha,
|
||||
Usernamecolor: f.Usernamecolor,
|
||||
Introcolor: f.Introcolor,
|
||||
Qrcodepdpcolor: f.Qrcodepdpcolor,
|
||||
Acfunlink: f.Acfunlink,
|
||||
Bililink: f.Bililink,
|
||||
Weibolink: f.Weibolink,
|
||||
Xiaohongshulink: f.Xiaohongshulink,
|
||||
Bloglink: f.Bloglink,
|
||||
UseCustomQRColor: useCustomQRColor,
|
||||
Notify: notify,
|
||||
Language: i18n.Resolve(f.Language),
|
||||
}); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update user profile")
|
||||
if passwordChanged {
|
||||
// 密码已落库且版本号已 +1:当前会话在下次请求即失效,
|
||||
// 主动清会话并给出明确提示后回登录页,避免用户被「请先登录」含糊文案打断。
|
||||
ctx.Session.Flush()
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "profile.flash.passwordChanged"))
|
||||
ctx.Redirect("/login")
|
||||
return
|
||||
}
|
||||
ctx.SetInternalErrorFlash()
|
||||
} else if passwordChanged {
|
||||
// 密码修改成功且其余资料也保存成功:强制登出重登。
|
||||
// 顺序与 DeactivateProfileAction 保持一致:先 Flush 会话再写入 flash,随后跳登录页。
|
||||
ctx.Session.Flush()
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "profile.flash.passwordChanged"))
|
||||
ctx.Redirect("/login")
|
||||
return
|
||||
} else {
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.profileUpdated"))
|
||||
}
|
||||
ctx.Redirect("/user/profile")
|
||||
}
|
||||
|
||||
// 更新私聊设置(手动读取表单,不使用 form.Bind)
|
||||
//
|
||||
// 注意:私聊的「开启 / 关闭」由账号设置页「私聊」面板统一控制(feature_controls,含定时与定量),
|
||||
// 此处不再从表单读取开关值——否则移除复选框后每次保存都会把开关重置为关闭。
|
||||
// users.allow_chat 仍按功能管理的当前值回写,保持两处一致。
|
||||
func UpdateChatSettings(ctx context.Context) {
|
||||
maxMessagesStr := ctx.Request().FormValue("chat_max_messages")
|
||||
dailyLimitStr := ctx.Request().FormValue("chat_daily_limit")
|
||||
|
||||
maxMessages, err := strconv.Atoi(maxMessagesStr)
|
||||
if err != nil || maxMessages < 1 {
|
||||
maxMessages = 1
|
||||
}
|
||||
// 会话条数上限:用户只能在自己的设置里取 1..系统硬顶(conf.Limits.ChatMaxMessages)
|
||||
if maxMessages > conf.Limits.ChatMaxMessages {
|
||||
maxMessages = conf.Limits.ChatMaxMessages
|
||||
}
|
||||
|
||||
dailyLimit, _ := strconv.Atoi(dailyLimitStr)
|
||||
if dailyLimit < 0 {
|
||||
dailyLimit = 0
|
||||
}
|
||||
// 每日条数:0 = 不限制;否则取 1..conf.Limits.DailyMessageLimitMax
|
||||
if dailyLimit > conf.Limits.DailyMessageLimitMax {
|
||||
dailyLimit = conf.Limits.DailyMessageLimitMax
|
||||
}
|
||||
|
||||
allowChat := db.FeatureOpen(ctx.Request().Context(), ctx.User.ID, db.FeatureChat)
|
||||
if err := db.Users.UpdateChatSettings(ctx.Request().Context(), ctx.User.ID, allowChat, maxMessages); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update chat settings")
|
||||
ctx.SetInternalErrorFlash()
|
||||
} else {
|
||||
// 更新每日限制
|
||||
if err := db.Users.UpdateDailyMessageLimit(ctx.Request().Context(), ctx.User.ID, dailyLimit); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update daily limit")
|
||||
}
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.chatUpdated"))
|
||||
}
|
||||
ctx.Redirect(profileURLWithTab(ProfileTabChat))
|
||||
}
|
||||
|
||||
func ExportProfile(ctx context.Context) {
|
||||
user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user")
|
||||
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errUser")))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := db.Questions.GetByUserID(ctx.Request().Context(), user.ID, db.GetQuestionsByUserIDOptions{
|
||||
FilterAnswered: false,
|
||||
ShowPrivate: true,
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions")
|
||||
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errQuestion")))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := createExportExcelFile(user, questions)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create excel file")
|
||||
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errCreateExcel")))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("狼的提问箱账号信息导出-%s-%s.xlsx", user.Domain, time.Now().Format("20060102150405"))
|
||||
ctx.ResponseWriter().Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(fileName))
|
||||
|
||||
if err := f.Write(ctx.ResponseWriter()); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to write excel file")
|
||||
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "admin.export.errWriteExcel")))
|
||||
ctx.Success("user/profile")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func createXLSXStreamWriter(xlsx *excelize.File, sheet string, headers []string) (*excelize.StreamWriter, error) {
|
||||
xlsx.NewSheet(sheet)
|
||||
sw, err := xlsx.NewStreamWriter(sheet)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "new stream writer")
|
||||
}
|
||||
|
||||
cols := make([]interface{}, 0, len(headers))
|
||||
for _, c := range headers {
|
||||
cols = append(cols, c)
|
||||
}
|
||||
err = sw.SetRow("A1", cols)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "set header row")
|
||||
}
|
||||
return sw, nil
|
||||
}
|
||||
|
||||
// sanitizeCell 防止表格软件公式注入:以 = + - @ (或控制字符)开头的单元格内容会在
|
||||
// Excel/CSV 中被当作公式执行,可能导致数据外泄或代码执行。前置单引号可让表格软件
|
||||
// 将其按纯文本处理(显示时自动隐藏该引号)。
|
||||
func sanitizeCell(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
switch r[0] {
|
||||
case '=', '+', '-', '@', '\t', '\r', '\n':
|
||||
return "'" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func createExportExcelFile(user *db.User, questions []*db.Question) (*excelize.File, error) {
|
||||
f := excelize.NewFile()
|
||||
|
||||
sw, err := createXLSXStreamWriter(f, "账号信息", nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create xlsx stream writer: 提问")
|
||||
}
|
||||
// Set personal information sheet.
|
||||
personalData := [][]interface{}{
|
||||
{"狼的提问箱 账号信息导出", fmt.Sprintf("导出时间 %s", time.Now().Format("2006-01-02 15:04:05"))},
|
||||
{"电子邮箱", user.Email},
|
||||
{"昵称", user.Name},
|
||||
{"个性域名", user.Domain},
|
||||
{"AcFun主页", user.Acfunlink},
|
||||
{"Bili主页", user.Bililink},
|
||||
{"微博主页", user.Weibolink},
|
||||
{"小红书主页", user.Xiaohongshulink},
|
||||
{"个人博客主页", user.Bloglink},
|
||||
{"介绍", user.Intro},
|
||||
{"二维码背景色", user.Qrcodebackcolor},
|
||||
{"二维码前景色", user.Qrcodecolor},
|
||||
{"二维码点大小", user.Dotscale},
|
||||
{"昵称颜色", user.Usernamecolor},
|
||||
{"介绍颜色", user.Introcolor},
|
||||
{"二维码探测图形颜色", user.Qrcodepdpcolor},
|
||||
{"头像 URL", user.Avatar},
|
||||
{"背景图 URL", user.Background},
|
||||
{"注册时间", user.CreatedAt},
|
||||
}
|
||||
currentRow := 1
|
||||
for _, row := range personalData {
|
||||
// personalData 是 [][]interface{}:字符串需净化防公式注入,
|
||||
// 非字符串(如 CreatedAt 的 time.Time)原样写入,交给 excelize 处理。
|
||||
vals := make([]interface{}, len(row))
|
||||
for i, v := range row {
|
||||
if s, ok := v.(string); ok {
|
||||
vals[i] = sanitizeCell(s)
|
||||
} else {
|
||||
vals[i] = v
|
||||
}
|
||||
}
|
||||
cell, _ := excelize.CoordinatesToCellName(1, currentRow)
|
||||
_ = sw.SetRow(cell, vals)
|
||||
currentRow++
|
||||
}
|
||||
if err := sw.Flush(); err != nil {
|
||||
return nil, errors.Wrap(err, "flush personal data")
|
||||
}
|
||||
|
||||
// Set questions sheet.
|
||||
sw, err = createXLSXStreamWriter(f, "提问", []string{"提问时间", "问题", "回答"})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create xlsx stream writer: 提问")
|
||||
}
|
||||
|
||||
currentRow = 2 // Include header row.
|
||||
for _, question := range questions {
|
||||
question := question
|
||||
vals := []interface{}{question.CreatedAt, sanitizeCell(question.Content), sanitizeCell(question.Answer)}
|
||||
cell, _ := excelize.CoordinatesToCellName(1, currentRow)
|
||||
_ = sw.SetRow(cell, vals)
|
||||
currentRow++
|
||||
}
|
||||
if err := sw.Flush(); err != nil {
|
||||
return nil, errors.Wrap(err, "flush personal data")
|
||||
}
|
||||
|
||||
f.SetActiveSheet(f.GetSheetIndex("提问"))
|
||||
f.DeleteSheet("Sheet1") // Delete default sheet.
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func DeactivateProfile(ctx context.Context) {
|
||||
ctx.Success("user/deactivate")
|
||||
}
|
||||
|
||||
// ExportProfileJSON 以 JSON 格式全量导出账号数据(用户信息 + 提问/回答/追问 + 私聊消息),
|
||||
// 符合可携带权要求,供用户备份/迁移。
|
||||
func ExportProfileJSON(ctx context.Context) {
|
||||
user, err := db.Users.GetByID(ctx.Request().Context(), ctx.User.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user")
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errUser"))
|
||||
ctx.Redirect("/user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// 用户提问(含私密)
|
||||
questions, err := db.Questions.GetByUserID(ctx.Request().Context(), user.ID, db.GetQuestionsByUserIDOptions{
|
||||
FilterAnswered: false,
|
||||
ShowPrivate: true,
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions for export")
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.export.errQuestion"))
|
||||
ctx.Redirect("/user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
type exportedQuestion struct {
|
||||
ID uint `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Answer string `json:"answer"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
FollowUps []*db.FollowUp `json:"follow_ups"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
// 批量拉取所有提问的追问与图片(IN 查询 + 内存分组),避免逐题查询造成 N+1。
|
||||
questionIDs := make([]uint, 0, len(questions))
|
||||
for _, q := range questions {
|
||||
questionIDs = append(questionIDs, q.ID)
|
||||
}
|
||||
followUpMap, err := db.FollowUps.GetByQuestionIDs(ctx.Request().Context(), questionIDs)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get follow ups for export")
|
||||
followUpMap = make(map[uint][]*db.FollowUp)
|
||||
}
|
||||
imageMap, err := db.UploadImages.GetByQuestionIDs(ctx.Request().Context(), questionIDs)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get images for export")
|
||||
imageMap = make(map[uint][]*db.UploadImage)
|
||||
}
|
||||
|
||||
exportQuestions := make([]exportedQuestion, 0, len(questions))
|
||||
for _, q := range questions {
|
||||
followUps := followUpMap[q.ID]
|
||||
if followUps == nil {
|
||||
followUps = []*db.FollowUp{}
|
||||
}
|
||||
imgs := imageMap[q.ID]
|
||||
imageURLs := make([]string, 0, len(imgs))
|
||||
for _, img := range imgs {
|
||||
imageURLs = append(imageURLs, img.Key)
|
||||
}
|
||||
exportQuestions = append(exportQuestions, exportedQuestion{
|
||||
ID: q.ID,
|
||||
Content: q.Content,
|
||||
Answer: q.Answer,
|
||||
IsPrivate: q.IsPrivate,
|
||||
IsPinned: q.IsPinned,
|
||||
CreatedAt: q.CreatedAt,
|
||||
UpdatedAt: q.UpdatedAt,
|
||||
FollowUps: followUps,
|
||||
Images: imageURLs,
|
||||
})
|
||||
}
|
||||
|
||||
// 私聊会话与消息
|
||||
type exportedMessage struct {
|
||||
Content string `json:"content"`
|
||||
SenderID uint `json:"sender_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
type exportedConversation struct {
|
||||
OwnerID uint `json:"owner_id"`
|
||||
AskerID uint `json:"asker_id"`
|
||||
IsClosed bool `json:"is_closed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Messages []exportedMessage `json:"messages"`
|
||||
}
|
||||
|
||||
exportConvs := make([]exportedConversation, 0)
|
||||
allConvs := make([]*db.Conversation, 0)
|
||||
if owned, err := db.Conversations.ListByOwner(ctx.Request().Context(), user.ID, 0, 0); err == nil {
|
||||
allConvs = append(allConvs, owned...)
|
||||
}
|
||||
if asked, err := db.Conversations.ListByAsker(ctx.Request().Context(), user.ID, 0, 0); err == nil {
|
||||
allConvs = append(allConvs, asked...)
|
||||
}
|
||||
// 去重并收集会话 ID,批量拉取全部消息(IN 查询 + 内存分组),避免逐会话查询造成 N+1。
|
||||
seen := make(map[uint]bool)
|
||||
dedupedConvs := make([]*db.Conversation, 0, len(allConvs))
|
||||
convIDs := make([]uint, 0, len(allConvs))
|
||||
for _, conv := range allConvs {
|
||||
if seen[conv.ID] {
|
||||
continue
|
||||
}
|
||||
seen[conv.ID] = true
|
||||
dedupedConvs = append(dedupedConvs, conv)
|
||||
convIDs = append(convIDs, conv.ID)
|
||||
}
|
||||
msgMap, err := db.Messages.GetAllByConversationIDs(ctx.Request().Context(), convIDs)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get messages for export")
|
||||
msgMap = make(map[uint][]*db.Message)
|
||||
}
|
||||
for _, conv := range dedupedConvs {
|
||||
msgs := msgMap[conv.ID]
|
||||
if msgs == nil {
|
||||
msgs = []*db.Message{}
|
||||
}
|
||||
exportMsgs := make([]exportedMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
exportMsgs = append(exportMsgs, exportedMessage{
|
||||
Content: m.Content,
|
||||
SenderID: m.SenderID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
})
|
||||
}
|
||||
exportConvs = append(exportConvs, exportedConversation{
|
||||
OwnerID: conv.OwnerID,
|
||||
AskerID: conv.AskerID,
|
||||
IsClosed: conv.IsClosed,
|
||||
CreatedAt: conv.CreatedAt,
|
||||
Messages: exportMsgs,
|
||||
})
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"exported_at": time.Now().Format(time.RFC3339),
|
||||
"version": 1,
|
||||
"user": user,
|
||||
"questions": exportQuestions,
|
||||
"conversations": exportConvs,
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("提问箱账号数据-%s-%s.json", user.Domain, time.Now().Format("20060102150405"))
|
||||
ctx.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(fileName))
|
||||
|
||||
enc := json.NewEncoder(ctx.ResponseWriter())
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(payload); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode JSON export")
|
||||
}
|
||||
}
|
||||
|
||||
func DeactivateProfileAction(ctx context.Context) {
|
||||
if err := db.Users.Deactivate(ctx.Request().Context(), ctx.User.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to deactivate user")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("user/deactivate")
|
||||
return
|
||||
}
|
||||
ctx.Session.Flush()
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.deactivated"))
|
||||
ctx.Redirect("/login")
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// Copyright 2022 E99p1ant. 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 (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||
)
|
||||
|
||||
type conversationDetail struct {
|
||||
Conversation *db.Conversation
|
||||
OwnerDomain string
|
||||
OwnerName string
|
||||
FirstMessage string
|
||||
IsBlocked bool
|
||||
}
|
||||
|
||||
type receivedItem struct {
|
||||
Type string // "question_public", "question_private", "chat"
|
||||
CreatedAt time.Time
|
||||
Content string
|
||||
Link string
|
||||
IsAnswered bool
|
||||
IsRead bool // 仅对普通提问有效:有效已读 = is_read=true 或已回答
|
||||
IsPrivate bool // 仅对普通提问有效
|
||||
IsBlocked bool // 仅对私聊有效
|
||||
QuestionID uint // 仅对普通提问有效,用于置顶操作
|
||||
IsPinned bool // 仅对普通提问有效
|
||||
PinLink string // 仅对普通提问有效:置顶/取消置顶操作链接
|
||||
}
|
||||
|
||||
// QuestionList 用户后台提问列表(支持筛选与全文搜索)
|
||||
func QuestionList(ctx context.Context) {
|
||||
// 获取筛选参数
|
||||
sortParam := ctx.Query("sort")
|
||||
if sortParam != "latest" && sortParam != "oldest" {
|
||||
sortParam = "latest"
|
||||
}
|
||||
statusParam := ctx.Query("status")
|
||||
if statusParam != "all" && statusParam != "answered" && statusParam != "unanswered" {
|
||||
statusParam = "all"
|
||||
}
|
||||
typeParam := ctx.Query("type")
|
||||
// 私聊已独立成"私聊"标签页(见 ReceivedChats),不再参与提问列表的类型筛选;
|
||||
// 旧的 type=chat 链接统一归一为"全部",避免空列表。
|
||||
if typeParam != "all" && typeParam != "public" && typeParam != "private_question" {
|
||||
typeParam = "all"
|
||||
}
|
||||
readParam := ctx.Query("read")
|
||||
if readParam != "all" && readParam != "read" && readParam != "unread" {
|
||||
readParam = "all"
|
||||
}
|
||||
keyword := strings.TrimSpace(ctx.Query("q"))
|
||||
|
||||
// 映射排序方向
|
||||
sortOrder := "DESC"
|
||||
if sortParam == "oldest" {
|
||||
sortOrder = "ASC"
|
||||
}
|
||||
|
||||
// 映射回答状态
|
||||
answeredStatus := 0 // 全部
|
||||
if statusParam == "answered" {
|
||||
answeredStatus = 1
|
||||
} else if statusParam == "unanswered" {
|
||||
answeredStatus = 2
|
||||
}
|
||||
|
||||
// 映射阅读状态
|
||||
readStatus := 0 // 全部
|
||||
if readParam == "read" {
|
||||
readStatus = 1
|
||||
} else if readParam == "unread" {
|
||||
readStatus = 2
|
||||
}
|
||||
|
||||
// 管理后台进入问题详情的链接:携带 from=admin 及当前筛选参数,
|
||||
// 使详情页底部「返回」按钮跳回带筛选条件的后台列表(保留 sort/status/type/read/q)。
|
||||
adminLinkQuery := url.Values{}
|
||||
adminLinkQuery.Set("from", "admin")
|
||||
if sortParam != "latest" {
|
||||
adminLinkQuery.Set("sort", sortParam)
|
||||
}
|
||||
if statusParam != "all" {
|
||||
adminLinkQuery.Set("status", statusParam)
|
||||
}
|
||||
if typeParam != "all" {
|
||||
adminLinkQuery.Set("type", typeParam)
|
||||
}
|
||||
if readParam != "all" {
|
||||
adminLinkQuery.Set("read", readParam)
|
||||
}
|
||||
if keyword != "" {
|
||||
adminLinkQuery.Set("q", keyword)
|
||||
}
|
||||
adminLinkQueryStr := adminLinkQuery.Encode()
|
||||
|
||||
var receivedItems []receivedItem
|
||||
|
||||
// 1. 获取普通提问
|
||||
var questions []*db.Question
|
||||
if keyword != "" {
|
||||
results, err := db.Questions.SearchByUserID(ctx.Request().Context(), ctx.User.ID, keyword)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to search questions")
|
||||
} else {
|
||||
questions = results
|
||||
}
|
||||
} else if typeParam == "all" || typeParam == "public" || typeParam == "private_question" {
|
||||
privateStatus := 0 // 全部
|
||||
if typeParam == "public" {
|
||||
privateStatus = 1 // 仅公开
|
||||
} else if typeParam == "private_question" {
|
||||
privateStatus = 2 // 仅私密
|
||||
}
|
||||
|
||||
// 游标分页:每页固定大小,游标为上一页最后一条提问 ID(默认按创建时间倒序,
|
||||
// 与 db.GetQuestionsByUserID 的 id < cursor 游标一致)。避免全量返回。
|
||||
questionsPageSize := 50
|
||||
qCursor := parseCursorUint(ctx.Query("q_cursor"))
|
||||
qOpts := db.GetQuestionsByUserIDOptions{
|
||||
AnsweredStatus: answeredStatus,
|
||||
PrivateStatus: privateStatus,
|
||||
// PrivateStatus=0("全部")时必须显式声明含私密:store 层的旧调用兼容分支
|
||||
// 会把 "ShowPrivate=false 且未指定 PrivateStatus" 当作仅公开过滤,
|
||||
// 导致私密提问在"全部"类型下不可见(与统计看板/内存过滤逻辑不一致)。
|
||||
ShowPrivate: true,
|
||||
ReadStatus: readStatus,
|
||||
SortOrder: sortOrder,
|
||||
Cursor: &dbutil.Cursor{PageSize: questionsPageSize},
|
||||
}
|
||||
if qCursor > 0 {
|
||||
qOpts.Cursor.Value = qCursor
|
||||
}
|
||||
|
||||
var qs []*db.Question
|
||||
var qErr error
|
||||
qs, qErr = db.Questions.GetByUserID(ctx.Request().Context(), ctx.User.ID, qOpts)
|
||||
if qErr != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(qErr).Error("Failed to get questions by user ID")
|
||||
} else {
|
||||
questions = qs
|
||||
}
|
||||
// 提问"加载更多"(仅非搜索路径支持游标分页):满页且仍有更多时暴露下一页游标
|
||||
if qErr == nil && len(qs) >= questionsPageSize {
|
||||
lastQID := qs[len(qs)-1].ID
|
||||
ctx.Data["HasMoreQuestions"] = true
|
||||
ctx.Data["QuestionLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "q_cursor", strconv.FormatUint(uint64(lastQID), 10), "")
|
||||
} else {
|
||||
ctx.Data["HasMoreQuestions"] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索场景下关键词已在 SQL 中匹配,但类型/状态筛选需在此统一应用,
|
||||
// 使"搜索 + 筛选"可组合(完善筛选逻辑)。非搜索场景下此处与 SQL 过滤结果一致。
|
||||
for _, q := range questions {
|
||||
if answeredStatus == 1 && q.Answer == "" {
|
||||
continue
|
||||
}
|
||||
if answeredStatus == 2 && q.Answer != "" {
|
||||
continue
|
||||
}
|
||||
if typeParam == "public" && q.IsPrivate {
|
||||
continue
|
||||
}
|
||||
if typeParam == "private_question" && !q.IsPrivate {
|
||||
continue
|
||||
}
|
||||
// 有效已读 = 手动已读(is_read) 或 已回答(answer != "")
|
||||
isRead := q.IsRead || q.Answer != ""
|
||||
if readStatus == 1 && !isRead {
|
||||
continue
|
||||
}
|
||||
if readStatus == 2 && isRead {
|
||||
continue
|
||||
}
|
||||
itemType := "question_public"
|
||||
if q.IsPrivate {
|
||||
itemType = "question_private"
|
||||
}
|
||||
receivedItems = append(receivedItems, receivedItem{
|
||||
Type: itemType,
|
||||
CreatedAt: q.CreatedAt,
|
||||
Content: q.Content,
|
||||
Link: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "?" + adminLinkQueryStr,
|
||||
IsAnswered: q.Answer != "",
|
||||
IsRead: isRead,
|
||||
IsPrivate: q.IsPrivate,
|
||||
QuestionID: q.ID,
|
||||
IsPinned: q.IsPinned,
|
||||
PinLink: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "/pin",
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 私聊会话:独立"私聊"标签页展示(ReceivedChats),不再混入提问列表。
|
||||
// 会话没有回答状态/阅读状态概念,不受提问的 sort/status/type/read/q 筛选影响。
|
||||
// 私聊列表分页(游标=offset),避免一次拉取全部会话造成内存/DB 压力。
|
||||
chatPageSize := 50
|
||||
chatOffset := parseCursorUint(ctx.Query("chat_cursor"))
|
||||
|
||||
var receivedChats []receivedItem
|
||||
convs, err := db.Conversations.ListByOwner(ctx.Request().Context(), ctx.User.ID, chatPageSize, int(chatOffset))
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get owned conversations")
|
||||
} else if len(convs) > 0 {
|
||||
// 批量获取首条消息
|
||||
convIDs := make([]uint, len(convs))
|
||||
for i, c := range convs {
|
||||
convIDs[i] = c.ID
|
||||
}
|
||||
firstMsgMap, err := db.Messages.GetFirstMessagesByConversationIDs(ctx.Request().Context(), convIDs)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get first messages")
|
||||
firstMsgMap = make(map[uint]string)
|
||||
}
|
||||
|
||||
// 批量判断"我拉黑了哪些 asker",避免循环 IsUserBlocked 造成 N+1。
|
||||
askerIDs := make([]uint, 0, len(convs))
|
||||
for _, c := range convs {
|
||||
askerIDs = append(askerIDs, c.AskerID)
|
||||
}
|
||||
blockedMap, err := db.Users.BlockedByUser(ctx.Request().Context(), ctx.User.ID, askerIDs)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch check blocked askers")
|
||||
blockedMap = make(map[uint]bool)
|
||||
}
|
||||
|
||||
for _, conv := range convs {
|
||||
receivedChats = append(receivedChats, receivedItem{
|
||||
Type: "chat",
|
||||
CreatedAt: conv.CreatedAt,
|
||||
Content: firstMsgMap[conv.ID],
|
||||
Link: "/_/" + ctx.User.Domain + "/chat/" + conv.Token,
|
||||
IsBlocked: blockedMap[conv.AskerID],
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.Data["ReceivedChats"] = receivedChats
|
||||
|
||||
// 私聊总数与"加载更多"判定(基于真实总数,而非当前页长度)
|
||||
chatTotalReceived, cntErr := db.Conversations.CountByOwner(ctx.Request().Context(), ctx.User.ID)
|
||||
if cntErr != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(cntErr).Error("Failed to count owned conversations")
|
||||
chatTotalReceived = int64(len(convs))
|
||||
}
|
||||
hasMoreChats := chatTotalReceived > 0 && (chatOffset+uint(len(convs)) < uint(chatTotalReceived))
|
||||
if hasMoreChats {
|
||||
nextOffset := chatOffset + uint(len(convs))
|
||||
ctx.Data["ChatLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "chat_cursor", strconv.FormatUint(uint64(nextOffset), 10), "chats")
|
||||
}
|
||||
ctx.Data["HasMoreChats"] = hasMoreChats
|
||||
|
||||
// 排序(搜索时保留搜索结果的倒序,不混合排序)
|
||||
if keyword == "" {
|
||||
sort.Slice(receivedItems, func(i, j int) bool {
|
||||
if sortParam == "latest" {
|
||||
return receivedItems[i].CreatedAt.After(receivedItems[j].CreatedAt)
|
||||
}
|
||||
return receivedItems[i].CreatedAt.Before(receivedItems[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 提问箱统计看板 ----------
|
||||
// 独立统计(不受筛选影响),供管理页顶部展示。全部用 COUNT 查询,避免全量拉取问题记录。
|
||||
totalCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true})
|
||||
answeredCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{FilterAnswered: true, ShowPrivate: true})
|
||||
unansweredCount := totalCount - answeredCount
|
||||
|
||||
isPrivateTrue := true
|
||||
privateCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, IsPrivate: &isPrivateTrue})
|
||||
isPinnedTrue := true
|
||||
pinnedCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, IsPinned: &isPinnedTrue})
|
||||
|
||||
// 阅读状态统计(不受其他筛选影响):已读 = is_read=true 或已回答;未读 = 其余。
|
||||
readCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, ReadStatus: 1})
|
||||
unreadCount, _ := db.Questions.Count(ctx.Request().Context(), ctx.User.ID, db.GetQuestionsCountOptions{ShowPrivate: true, ReadStatus: 2})
|
||||
|
||||
ctx.Data["Stats"] = map[string]int64{
|
||||
"total": totalCount,
|
||||
"answered": answeredCount,
|
||||
"unanswered": unansweredCount,
|
||||
"read": readCount,
|
||||
"unread": unreadCount,
|
||||
"private": privateCount,
|
||||
"pinned": pinnedCount,
|
||||
}
|
||||
// 我创建的投票(提问箱「投票」面板用)—— 与投票管理列表共用富信息条目,
|
||||
// 含参与数、有效状态(draft/published/not_started/ended/archived)、剩余时间等。
|
||||
// 分页:每页固定大小,游标为上一页最后一条投票 ID(按创建时间倒序),避免全量返回。
|
||||
votesPageSize := 50
|
||||
voteCursor := parseCursorUint(ctx.Query("vote_cursor"))
|
||||
voteListOpts := db.ListVotesOptions{Cursor: &dbutil.Cursor{PageSize: votesPageSize}}
|
||||
if voteCursor > 0 {
|
||||
voteListOpts.Cursor.Value = voteCursor
|
||||
}
|
||||
myVotesRaw, err := db.Votes.ListByUserID(ctx.Request().Context(), ctx.User.ID, voteListOpts)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get my votes")
|
||||
myVotesRaw = []*db.Vote{}
|
||||
}
|
||||
myVotes := buildVoteItems(ctx, myVotesRaw)
|
||||
ctx.Data["MyVotes"] = myVotes
|
||||
|
||||
// 投票统计看板(不受筛选影响):总数(真实全量计数)+ 本页有效进行中数
|
||||
voteTotal, _ := db.Votes.CountByUserID(ctx.Request().Context(), ctx.User.ID)
|
||||
votePublished := int64(0)
|
||||
for _, it := range myVotes {
|
||||
if it.EffectiveStatus == db.VoteStatusPublished {
|
||||
votePublished++
|
||||
}
|
||||
}
|
||||
ctx.Data["VoteStats"] = map[string]int64{
|
||||
"total": voteTotal,
|
||||
"published": votePublished,
|
||||
}
|
||||
// 投票"加载更多":本页满页且仍有更多时暴露下一页游标(保留筛选参数并跳回投票面板)
|
||||
if len(myVotesRaw) >= votesPageSize {
|
||||
lastID := myVotesRaw[len(myVotesRaw)-1].ID
|
||||
ctx.Data["HasMoreVotes"] = true
|
||||
ctx.Data["VoteLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "vote_cursor", strconv.FormatUint(uint64(lastID), 10), "votes")
|
||||
} else {
|
||||
ctx.Data["HasMoreVotes"] = false
|
||||
}
|
||||
|
||||
ctx.Data["Keyword"] = keyword
|
||||
|
||||
ctx.Data["ReceivedItems"] = receivedItems
|
||||
ctx.Data["SelectedSort"] = sortParam
|
||||
ctx.Data["SelectedStatus"] = statusParam
|
||||
ctx.Data["SelectedRead"] = readParam
|
||||
ctx.Data["SelectedType"] = typeParam
|
||||
|
||||
// 我发起的私聊 — 同样使用批量查询与分页(复用 chat_cursor 游标)
|
||||
askerConvs, err := db.Conversations.ListByAsker(ctx.Request().Context(), ctx.User.ID, chatPageSize, int(chatOffset))
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get asked conversations")
|
||||
askerConvs = []*db.Conversation{}
|
||||
}
|
||||
var askerDetails []conversationDetail
|
||||
if len(askerConvs) > 0 {
|
||||
askerConvIDs := make([]uint, len(askerConvs))
|
||||
for i, c := range askerConvs {
|
||||
askerConvIDs[i] = c.ID
|
||||
}
|
||||
askerFirstMsgMap, err := db.Messages.GetFirstMessagesByConversationIDs(ctx.Request().Context(), askerConvIDs)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get first messages for asker")
|
||||
askerFirstMsgMap = make(map[uint]string)
|
||||
}
|
||||
|
||||
// 批量查询所有 owner(去重),避免逐个 GetByID 造成 N+1;拉黑状态用批量关联表查询。
|
||||
ownerIDSet := make(map[uint]struct{})
|
||||
ownerIDs := make([]uint, 0, len(askerConvs))
|
||||
for _, conv := range askerConvs {
|
||||
if _, ok := ownerIDSet[conv.OwnerID]; !ok {
|
||||
ownerIDSet[conv.OwnerID] = struct{}{}
|
||||
ownerIDs = append(ownerIDs, conv.OwnerID)
|
||||
}
|
||||
}
|
||||
ownerMap := make(map[uint]*db.User)
|
||||
if owners, err := db.Users.GetByIDs(ctx.Request().Context(), ownerIDs); err == nil {
|
||||
for _, u := range owners {
|
||||
ownerMap[u.ID] = u
|
||||
}
|
||||
} else {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch get owners")
|
||||
}
|
||||
|
||||
// 批量判断"哪些 owner 拉黑了我",避免循环查询。
|
||||
blockedByMap, err := db.Users.BlockedByAnyOf(ctx.Request().Context(), ownerIDs, ctx.User.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to batch check blocked by owners")
|
||||
blockedByMap = make(map[uint]bool)
|
||||
}
|
||||
|
||||
for _, conv := range askerConvs {
|
||||
firstMsg := askerFirstMsgMap[conv.ID]
|
||||
ownerDomain := "unknown"
|
||||
ownerName := "未知用户"
|
||||
if owner, ok := ownerMap[conv.OwnerID]; ok {
|
||||
ownerDomain = owner.Domain
|
||||
ownerName = owner.Name
|
||||
}
|
||||
askerDetails = append(askerDetails, conversationDetail{
|
||||
Conversation: conv,
|
||||
OwnerDomain: ownerDomain,
|
||||
OwnerName: ownerName,
|
||||
FirstMessage: firstMsg,
|
||||
IsBlocked: blockedByMap[conv.OwnerID],
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.Data["ConversationDetails"] = askerDetails
|
||||
|
||||
// 我发起的私聊总数与"加载更多"判定
|
||||
askerTotal, cntErr := db.Conversations.CountByAsker(ctx.Request().Context(), ctx.User.ID)
|
||||
if cntErr != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(cntErr).Error("Failed to count asked conversations")
|
||||
askerTotal = int64(len(askerConvs))
|
||||
}
|
||||
hasMoreAsked := askerTotal > 0 && (chatOffset+uint(len(askerConvs)) < uint(askerTotal))
|
||||
if hasMoreAsked {
|
||||
nextOffset := chatOffset + uint(len(askerConvs))
|
||||
ctx.Data["AskedChatsLoadMoreURL"] = buildQListPageURL(ctx.Request().URL.Query(), "chat_cursor", strconv.FormatUint(uint64(nextOffset), 10), "chats")
|
||||
}
|
||||
ctx.Data["HasMoreAskedChats"] = hasMoreAsked
|
||||
|
||||
// ---------- 私聊统计看板 ----------
|
||||
// 与提问/投票看板一致,展示在「私聊」面板的顶部。全部基于已加载的会话切片计算,
|
||||
// 不触发额外数据库查询:总数 = 我收到的(作为箱主) + 我发起的(作为提问者);
|
||||
// 已关闭 = 两端会话中 is_closed=true 的计数。
|
||||
chatClosed := int64(0)
|
||||
for _, c := range convs {
|
||||
if c.IsClosed {
|
||||
chatClosed++
|
||||
}
|
||||
}
|
||||
for _, c := range askerConvs {
|
||||
if c.IsClosed {
|
||||
chatClosed++
|
||||
}
|
||||
}
|
||||
ctx.Data["ChatStats"] = map[string]int64{
|
||||
"total": chatTotalReceived + askerTotal,
|
||||
"received": chatTotalReceived,
|
||||
"initiated": askerTotal,
|
||||
"closed": chatClosed,
|
||||
}
|
||||
|
||||
ctx.Success("user/question-list")
|
||||
}
|
||||
|
||||
// parseCursorUint 将游标字符串安全解析为 uint;空或非法返回 0(表示从头分页)。
|
||||
func parseCursorUint(s string) uint {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0
|
||||
}
|
||||
return uint(id)
|
||||
}
|
||||
|
||||
// buildQListPageURL 在保留当前筛选/搜索参数的基础上构造"加载更多"链接,
|
||||
// 并追加锚点(anchor 指定跳转到的面板;空串表示默认提问面板)。
|
||||
// 用于投票/提问列表的游标分页,避免丢失已选的 sort/status/type/q 等筛选条件。
|
||||
func buildQListPageURL(current url.Values, key, val, anchor string) string {
|
||||
q := url.Values{}
|
||||
for k, vs := range current {
|
||||
q[k] = vs
|
||||
}
|
||||
if val != "" {
|
||||
q.Set(key, val)
|
||||
} else {
|
||||
q.Del(key)
|
||||
}
|
||||
qs := q.Encode()
|
||||
path := "/user/questions"
|
||||
if qs != "" {
|
||||
path += "?" + qs
|
||||
}
|
||||
if anchor != "" {
|
||||
path += "#" + anchor
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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 (
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
)
|
||||
|
||||
func UpdateTags(ctx context.Context, f form.UpdateTags) {
|
||||
// 反序列化失败(如请求体超限)时零值 Tags="" / ShowInHome="" 会静默清空标签、
|
||||
// 关闭首页展示——先中止,不触碰现有标签。
|
||||
if abortOnFormError(ctx, "/user/profile") {
|
||||
return
|
||||
}
|
||||
|
||||
tagsStr := f.Tags
|
||||
tagsStr = strings.ReplaceAll(tagsStr, ",", ",")
|
||||
tagsStr = strings.TrimSpace(tagsStr)
|
||||
|
||||
words := make([]string, 0)
|
||||
wordSet := make(map[string]struct{})
|
||||
for _, tag := range strings.Split(tagsStr, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := wordSet[tag]; ok {
|
||||
continue
|
||||
}
|
||||
wordSet[tag] = struct{}{}
|
||||
|
||||
if len([]rune(tag)) > conf.Limits.MaxTagLen {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.tagTooLong", tag))
|
||||
ctx.Redirect("/user/profile")
|
||||
return
|
||||
}
|
||||
words = append(words, tag)
|
||||
}
|
||||
|
||||
if len(words) > conf.Limits.MaxTags {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "admin.flash.tagsTooMany"))
|
||||
ctx.Redirect("/user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
showInHome := false
|
||||
if f.ShowInHome == "on" || f.ShowInHome == "1" || f.ShowInHome == "true" {
|
||||
showInHome = true
|
||||
}
|
||||
|
||||
if err := db.Users.UpdateTags(ctx.Request().Context(), ctx.User.ID, db.UpdateTagsOptions{
|
||||
Tags: strings.Join(words, ","),
|
||||
ShowInHome: showInHome,
|
||||
}); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update tags")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect("/user/profile")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "admin.flash.tagsUpdated"))
|
||||
ctx.Redirect("/user/profile")
|
||||
}
|
||||
+1318
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user