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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user