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
+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
}