Files
tamabox/route/user/features.go
T
2026-09-08 10:10:44 +08:00

352 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}