// 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 与 的取值格式一致。 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 }