chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 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)
}
+351
View File
@@ -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
}
+139
View File
@@ -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, "", ""
}
+56
View File
@@ -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))
}
+599
View File
@@ -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")
}
+480
View File
@@ -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
}
+73
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff