352 lines
12 KiB
Go
352 lines
12 KiB
Go
// 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
|
||
}
|