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.
724 lines
27 KiB
Go
724 lines
27 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 db
|
||
|
||
import (
|
||
"context"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||
)
|
||
|
||
var FeatureControls FeatureControlsStore
|
||
|
||
var _ FeatureControlsStore = (*featureControls)(nil)
|
||
|
||
// 受控功能标识(feature_controls.feature 取值)。
|
||
const (
|
||
FeatureQuestion = "question" // 提问
|
||
FeatureChat = "chat" // 私聊
|
||
FeatureVote = "vote" // 投票
|
||
)
|
||
|
||
// FeatureKeys 全部受控功能,顺序即功能管理页的展示顺序。
|
||
var FeatureKeys = []string{FeatureQuestion, FeatureChat, FeatureVote}
|
||
|
||
// 功能不可用原因(空串表示可用)。
|
||
const (
|
||
FeatureReasonDisabled = "disabled" // 手动关闭
|
||
FeatureReasonNotStarted = "not_started" // 未到开放时间
|
||
FeatureReasonEnded = "ended" // 已过关闭时间
|
||
FeatureReasonQuotaReached = "quota_reached" // 已达定量收集上限
|
||
)
|
||
|
||
// QuotaLimitMax 单个功能的收集上限最大值,防止误填极大值。
|
||
const QuotaLimitMax = 1_000_000
|
||
|
||
type FeatureControlsStore interface {
|
||
// Get 读取某功能的配置;无记录时返回该功能的默认配置(不落库)。
|
||
Get(ctx context.Context, userID uint, feature string) (*FeatureControl, error)
|
||
// ListByUserID 读取某用户全部功能的配置(无记录的功能不出现在结果中)。
|
||
ListByUserID(ctx context.Context, userID uint) ([]*FeatureControl, error)
|
||
// ListByUserIDs 批量读取多个用户同一功能的配置(探索页等批量场景用,避免 N+1)。
|
||
ListByUserIDs(ctx context.Context, userIDs []uint, feature string) (map[uint]*FeatureControl, error)
|
||
// Save 写入配置(不存在则创建,存在则更新开关/时间窗/上限,不影响已收集计数)。
|
||
Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error
|
||
// ResetQuota 清零收集计数,重新开始一轮定量收集。
|
||
ResetQuota(ctx context.Context, userID uint, feature string) error
|
||
// IncrementQuota 累加收集计数;无记录时按 delta 创建。
|
||
IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error
|
||
}
|
||
|
||
func NewFeatureControlsStore(db *gorm.DB) FeatureControlsStore {
|
||
return &featureControls{db}
|
||
}
|
||
|
||
type featureControls struct {
|
||
*gorm.DB
|
||
}
|
||
|
||
// FeatureControl 提问箱单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
|
||
//
|
||
// 三类控制互不覆盖,最终可用状态由 Evaluate 综合计算:
|
||
// - Enabled:手动总开关;
|
||
// - OpenAt / CloseAt:定时开放与定时关闭(nil 表示不限制该侧);
|
||
// - QuotaLimit / QuotaCount:定量收集,累计收集数达到上限后自动关闭(0 表示不限制)。
|
||
//
|
||
// 计数不做定时重置:一轮收集结束后由箱主在功能管理页手动「重置计数」开启下一轮,
|
||
// 避免与定时开关叠加产生难以预期的自动恢复行为。
|
||
type FeatureControl struct {
|
||
dbutil.Model
|
||
UserID uint `gorm:"uniqueIndex:idx_feature_control_user_feature;not null" json:"-"`
|
||
Feature string `gorm:"size:20;uniqueIndex:idx_feature_control_user_feature;not null" json:"feature"`
|
||
// ⚠️ 不要给 Enabled 加 gorm:"default:true" 标签:
|
||
// GORM 在 Create 时会把零值 false 替换为字段默认值(true),导致「取消勾选保存」后
|
||
// 数据库里仍是 true、复选框自动恢复为勾选。去掉标签后 GORM 会原样写入显式设置的值。
|
||
Enabled bool `json:"enabled"`
|
||
// OpenAt / CloseAt:一次性定时开放 / 关闭(nil 表示不限制该侧)。
|
||
OpenAt *time.Time `json:"open_at"`
|
||
CloseAt *time.Time `json:"close_at"`
|
||
// 循环定时(每周循环):ScheduleDays 为逗号分隔的周几(0=周日…6=周六,与 time.Weekday 一致),
|
||
// 空表示每天都开放;ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示该日全天)。
|
||
// 两者都为空时表示未启用循环定时。
|
||
ScheduleDays string `gorm:"size:31" json:"schedule_days"`
|
||
ScheduleStart string `gorm:"size:5" json:"schedule_start"`
|
||
ScheduleEnd string `gorm:"size:5" json:"schedule_end"`
|
||
QuotaLimit int64 `gorm:"default:0" json:"quota_limit"`
|
||
QuotaCount int64 `gorm:"default:0" json:"quota_count"`
|
||
QuotaResetAt *time.Time `json:"quota_reset_at"`
|
||
}
|
||
|
||
// FeatureState 某功能在某一时刻的综合状态。
|
||
type FeatureState struct {
|
||
Enabled bool // 手动开关
|
||
Open bool // 综合(开关 + 定时 + 定量)后的最终可用状态
|
||
Reason string // 不可用原因,空串表示可用(取值见 FeatureReason*)
|
||
QuotaLimit int64 // 收集上限(0 = 不限制)
|
||
QuotaCount int64 // 已收集数量
|
||
OpenAt *time.Time // 一次性定时开放时间
|
||
CloseAt *time.Time // 一次性定时关闭时间
|
||
NextOpenAt *time.Time // 下一次开放时间(可能来自一次性窗口或循环定时;永久关闭时为 nil)
|
||
LastCloseAt *time.Time // 最近一次关闭时间(从未开放过时为 nil)
|
||
}
|
||
|
||
// Evaluate 计算该功能在 now 时刻的综合状态。
|
||
// 判定优先级:手动关闭 > 未到一次性开放时间 > 已过一次性关闭时间 > 不在循环定时窗口内 > 达到收集上限。
|
||
func (fc *FeatureControl) Evaluate(now time.Time) FeatureState {
|
||
st := FeatureState{
|
||
Enabled: fc.Enabled,
|
||
QuotaLimit: fc.QuotaLimit,
|
||
QuotaCount: fc.QuotaCount,
|
||
OpenAt: fc.OpenAt,
|
||
CloseAt: fc.CloseAt,
|
||
}
|
||
// 先算好下次开放 / 最近关闭,供 reason 判定与展示复用。
|
||
st.NextOpenAt = fc.nextOpenAt(now)
|
||
st.LastCloseAt = fc.lastCloseAt(now)
|
||
|
||
switch {
|
||
case !fc.Enabled:
|
||
st.Reason = FeatureReasonDisabled
|
||
// 手动关闭与时间无关,不展示开放/关闭时间
|
||
st.NextOpenAt, st.LastCloseAt = nil, nil
|
||
case fc.OpenAt != nil && now.Before(*fc.OpenAt):
|
||
st.Reason = FeatureReasonNotStarted
|
||
case fc.CloseAt != nil && !now.Before(*fc.CloseAt):
|
||
// 已过一次性关闭时间:一次性窗口是硬性外边界,循环定时仅在窗口内生效,
|
||
// 因此之后不会再开放(nextOpenAt 已按 CloseAt 约束计算,返回 nil)。
|
||
st.Reason = FeatureReasonEnded
|
||
st.NextOpenAt = nil
|
||
default:
|
||
switch fc.scheduleSlotStatus(now) {
|
||
case scheduleBeforeStart:
|
||
st.Reason = FeatureReasonNotStarted
|
||
case scheduleAfterEnd:
|
||
if st.NextOpenAt != nil {
|
||
st.Reason = FeatureReasonNotStarted
|
||
} else {
|
||
st.Reason = FeatureReasonEnded
|
||
}
|
||
}
|
||
if st.Reason == "" && fc.QuotaLimit > 0 && fc.QuotaCount >= fc.QuotaLimit {
|
||
st.Reason = FeatureReasonQuotaReached
|
||
// 定量上限与时间无关,不展示开放/关闭时间
|
||
st.NextOpenAt, st.LastCloseAt = nil, nil
|
||
}
|
||
}
|
||
st.Open = st.Reason == ""
|
||
return st
|
||
}
|
||
|
||
// scheduleSlotStatus 表示 now 相对循环定时窗口的位置。
|
||
type scheduleSlotStatus int
|
||
|
||
const (
|
||
scheduleInWindow scheduleSlotStatus = iota // 在开放窗口内
|
||
scheduleBeforeStart // 今天开放,但未到开始时间
|
||
scheduleAfterEnd // 已过结束时间,或今天不在开放日
|
||
)
|
||
|
||
// scheduleActive 判断是否启用了循环定时。
|
||
// 规则:设置了开放日,或同时设置了开始/结束时间,即视为启用;
|
||
// 只设置单侧时间视为配置异常,由路由层校验拦截。
|
||
func (fc *FeatureControl) scheduleActive() bool {
|
||
return fc.ScheduleDays != "" || (fc.ScheduleStart != "" && fc.ScheduleEnd != "")
|
||
}
|
||
|
||
// scheduleDayOpen 判断某天是否为开放日(ScheduleDays 为空 = 每天都开放)。
|
||
func (fc *FeatureControl) scheduleDayOpen(day time.Time) bool {
|
||
return fc.ScheduleDays == "" || containsWeekday(fc.ScheduleDays, int(day.Weekday()))
|
||
}
|
||
|
||
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。
|
||
// 未设置时间段时视为全天 [0, 1440)。配置非法时返回错误(调用方按"不限制"处理)。
|
||
func (fc *FeatureControl) scheduleWindow() (startM, endM int, err error) {
|
||
if fc.ScheduleStart == "" && fc.ScheduleEnd == "" {
|
||
return 0, 24 * 60, nil
|
||
}
|
||
if startM, err = parseHHMM(fc.ScheduleStart); err != nil {
|
||
return 0, 0, errors.Wrap(err, "parse schedule start")
|
||
}
|
||
if endM, err = parseHHMM(fc.ScheduleEnd); err != nil {
|
||
return 0, 0, errors.Wrap(err, "parse schedule end")
|
||
}
|
||
if endM <= startM {
|
||
return 0, 0, errors.New("schedule end must be after start")
|
||
}
|
||
return startM, endM, nil
|
||
}
|
||
|
||
// scheduleSlotStatus 返回 now 相对循环定时窗口的位置;未启用循环定时时恒为"在窗口内"。
|
||
func (fc *FeatureControl) scheduleSlotStatus(now time.Time) scheduleSlotStatus {
|
||
if !fc.scheduleActive() {
|
||
return scheduleInWindow
|
||
}
|
||
if !fc.scheduleDayOpen(now) {
|
||
return scheduleAfterEnd
|
||
}
|
||
startM, endM, err := fc.scheduleWindow()
|
||
if err != nil {
|
||
return scheduleInWindow // 配置异常按不限制处理(路由层已校验,此处兜底)
|
||
}
|
||
mins := now.Hour()*60 + now.Minute()
|
||
switch {
|
||
case mins < startM:
|
||
return scheduleBeforeStart
|
||
case mins >= endM:
|
||
return scheduleAfterEnd
|
||
}
|
||
return scheduleInWindow
|
||
}
|
||
|
||
// nextOpenAt 计算下一次可开放的时间(严格晚于 now)。
|
||
// 同时考虑一次性窗口与循环定时,返回二者中最早的下一次开放时刻;永久关闭时返回 nil。
|
||
func (fc *FeatureControl) nextOpenAt(now time.Time) *time.Time {
|
||
var earliest *time.Time
|
||
|
||
// 未启用循环定时时,一次性开放时间即为下一次开放。
|
||
// 启用后由下方按日扫描统一处理(一次性开放时间也必须落在循环窗口内)。
|
||
if fc.OpenAt != nil && fc.OpenAt.After(now) && !fc.scheduleActive() {
|
||
t := *fc.OpenAt
|
||
earliest = &t
|
||
}
|
||
|
||
if fc.scheduleActive() {
|
||
startM, endM, err := fc.scheduleWindow()
|
||
if err == nil {
|
||
day := startOfDay(now)
|
||
// 扫描今天起的 8 天:7 天覆盖全部周几组合,多 1 天兜底边界。
|
||
for i := 0; i < 8; i++ {
|
||
d := day.AddDate(0, 0, i)
|
||
if !fc.scheduleDayOpen(d) {
|
||
continue
|
||
}
|
||
slotStart := d.Add(time.Duration(startM) * time.Minute)
|
||
slotEnd := d.Add(time.Duration(endM) * time.Minute)
|
||
// 与一次性窗口求交集:有效开放起点 = max(时段起点, 一次性开放时间),且须在时段与一次性关闭时间之前
|
||
effStart := slotStart
|
||
if fc.OpenAt != nil && fc.OpenAt.After(effStart) {
|
||
effStart = *fc.OpenAt
|
||
}
|
||
if !effStart.Before(slotEnd) {
|
||
continue // 有效开放起点晚于该日时段结束,该日无有效窗口
|
||
}
|
||
if fc.CloseAt != nil && !effStart.Before(*fc.CloseAt) {
|
||
continue // 有效开放起点已过一次性关闭时间
|
||
}
|
||
if effStart.After(now) {
|
||
if earliest == nil || effStart.Before(*earliest) {
|
||
t := effStart
|
||
earliest = &t
|
||
}
|
||
break // 该日找到最早有效开放点即可(同一日只有一个窗口)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return earliest
|
||
}
|
||
|
||
// lastCloseAt 计算最近一次关闭的时间(严格早于 now);从未开放过时返回 nil。
|
||
func (fc *FeatureControl) lastCloseAt(now time.Time) *time.Time {
|
||
var latest *time.Time
|
||
|
||
// 未启用循环定时时,一次性关闭时间即为最近一次关闭。
|
||
// 启用后由下方按日扫描统一处理(与时段结束取较早者)。
|
||
if fc.CloseAt != nil && fc.CloseAt.Before(now) && !fc.scheduleActive() {
|
||
t := *fc.CloseAt
|
||
latest = &t
|
||
}
|
||
|
||
if fc.scheduleActive() {
|
||
startM, endM, err := fc.scheduleWindow()
|
||
if err == nil {
|
||
day := startOfDay(now)
|
||
// 从今天向前扫描 8 天,找最近一次已结束的窗口。
|
||
for i := 0; i < 8; i++ {
|
||
d := day.AddDate(0, 0, -i)
|
||
if !fc.scheduleDayOpen(d) {
|
||
continue
|
||
}
|
||
slotStart := d.Add(time.Duration(startM) * time.Minute)
|
||
slotEnd := d.Add(time.Duration(endM) * time.Minute)
|
||
// 与一次性窗口求交集:有效关闭点 = min(时段结束, 一次性关闭时间)
|
||
effEnd := slotEnd
|
||
if fc.CloseAt != nil && fc.CloseAt.Before(effEnd) {
|
||
effEnd = *fc.CloseAt
|
||
}
|
||
if effEnd.Before(slotStart) {
|
||
continue // 一次性关闭时间早于该日时段起点,该日无有效窗口
|
||
}
|
||
if effEnd.Before(now) {
|
||
if latest == nil || effEnd.After(*latest) {
|
||
t := effEnd
|
||
latest = &t
|
||
}
|
||
break // 已找到最近一次关闭
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return latest
|
||
}
|
||
|
||
// parseHHMM 解析 "HH:MM" 为当天第几分钟。
|
||
func parseHHMM(s string) (int, error) {
|
||
t, err := time.Parse("15:04", s)
|
||
if err != nil {
|
||
return 0, errors.Wrap(err, "parse HH:MM")
|
||
}
|
||
return t.Hour()*60 + t.Minute(), nil
|
||
}
|
||
|
||
// containsWeekday 判断逗号分隔的周几列表(0=周日…6=周六)是否包含 wd。
|
||
func containsWeekday(days string, wd int) bool {
|
||
for _, part := range strings.Split(days, ",") {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
n, err := strconv.Atoi(part)
|
||
if err == nil && n == wd {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// startOfDay 返回 t 所在日历日的零点(保持时区一致)。
|
||
func startOfDay(t time.Time) time.Time {
|
||
y, m, d := t.Date()
|
||
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
||
}
|
||
|
||
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。
|
||
// 提问与投票默认开启(与历史行为一致);私聊默认关闭,沿用 users.allow_chat 的默认值,
|
||
// 避免历史账号在功能上线后被动开启私聊。
|
||
func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
|
||
return &FeatureControl{
|
||
UserID: userID,
|
||
Feature: feature,
|
||
Enabled: feature != FeatureChat,
|
||
}
|
||
}
|
||
|
||
// FeatureStateOf 便捷入口:读取某用户某功能在当前的综合状态。
|
||
// 查询失败或无存储时回退到默认配置,保证单个依赖表异常不会让整站功能不可用。
|
||
func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureState {
|
||
if FeatureControls == nil {
|
||
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
|
||
}
|
||
fc, err := FeatureControls.Get(ctx, userID, feature)
|
||
if err != nil {
|
||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to get feature control %q of user %d", feature, userID)
|
||
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
|
||
}
|
||
return fc.Evaluate(time.Now())
|
||
}
|
||
|
||
// FeatureOpen 便捷入口:某用户某功能当前是否可用。
|
||
func FeatureOpen(ctx context.Context, userID uint, feature string) bool {
|
||
return FeatureStateOf(ctx, userID, feature).Open
|
||
}
|
||
|
||
// IsValidFeature 判断功能标识是否合法(防越权写入未知功能)。
|
||
func IsValidFeature(feature string) bool {
|
||
for _, k := range FeatureKeys {
|
||
if k == feature {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (db *featureControls) Get(ctx context.Context, userID uint, feature string) (*FeatureControl, error) {
|
||
var fc FeatureControl
|
||
err := db.WithContext(ctx).Where("user_id = ? AND feature = ?", userID, feature).First(&fc).Error
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return DefaultFeatureControl(userID, feature), nil
|
||
}
|
||
return nil, errors.Wrap(err, "get feature control")
|
||
}
|
||
return &fc, nil
|
||
}
|
||
|
||
func (db *featureControls) ListByUserID(ctx context.Context, userID uint) ([]*FeatureControl, error) {
|
||
var list []*FeatureControl
|
||
err := db.WithContext(ctx).
|
||
Where("user_id = ?", userID).
|
||
Order("feature ASC").
|
||
Find(&list).Error
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "list feature controls by user id")
|
||
}
|
||
return list, nil
|
||
}
|
||
|
||
func (db *featureControls) ListByUserIDs(ctx context.Context, userIDs []uint, feature string) (map[uint]*FeatureControl, error) {
|
||
result := make(map[uint]*FeatureControl, len(userIDs))
|
||
if len(userIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
var list []*FeatureControl
|
||
if err := db.WithContext(ctx).
|
||
Where("user_id IN ? AND feature = ?", userIDs, feature).
|
||
Find(&list).Error; err != nil {
|
||
return nil, errors.Wrap(err, "list feature controls by user ids")
|
||
}
|
||
for _, fc := range list {
|
||
result[fc.UserID] = fc
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
type UpdateFeatureControlOptions struct {
|
||
Enabled bool
|
||
OpenAt *time.Time
|
||
CloseAt *time.Time
|
||
ScheduleDays string
|
||
ScheduleStart string
|
||
ScheduleEnd string
|
||
QuotaLimit int64
|
||
}
|
||
|
||
// Save 写入功能配置。使用 ON CONFLICT 幂等写入,并显式列出更新列,
|
||
// 避免把 quota_count(已收集数量)误重置为 0。
|
||
func (db *featureControls) Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error {
|
||
now := time.Now()
|
||
fc := &FeatureControl{
|
||
// UpdatedAt 显式写入(gorm 的 on-conflict 更新列会用到);CreatedAt 留零值由 gorm 自动填充
|
||
Model: dbutil.Model{UpdatedAt: now},
|
||
UserID: userID,
|
||
Feature: feature,
|
||
Enabled: opts.Enabled,
|
||
OpenAt: opts.OpenAt,
|
||
CloseAt: opts.CloseAt,
|
||
ScheduleDays: opts.ScheduleDays,
|
||
ScheduleStart: opts.ScheduleStart,
|
||
ScheduleEnd: opts.ScheduleEnd,
|
||
QuotaLimit: opts.QuotaLimit,
|
||
}
|
||
err := db.WithContext(ctx).Clauses(clause.OnConflict{
|
||
Columns: []clause.Column{{Name: "user_id"}, {Name: "feature"}},
|
||
DoUpdates: clause.AssignmentColumns([]string{
|
||
"enabled", "open_at", "close_at",
|
||
"schedule_days", "schedule_start", "schedule_end",
|
||
"quota_limit", "updated_at",
|
||
}),
|
||
}).Create(fc).Error
|
||
if err != nil {
|
||
return errors.Wrap(err, "save feature control")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ResetQuota 清零收集计数并记录重置时间。无记录时创建一条(沿用该功能的默认开关值)。
|
||
func (db *featureControls) ResetQuota(ctx context.Context, userID uint, feature string) error {
|
||
now := time.Now()
|
||
res := db.WithContext(ctx).Model(&FeatureControl{}).
|
||
Where("user_id = ? AND feature = ?", userID, feature).
|
||
Updates(map[string]interface{}{
|
||
"quota_count": 0,
|
||
"quota_reset_at": now,
|
||
"updated_at": now,
|
||
})
|
||
if res.Error != nil {
|
||
return errors.Wrap(res.Error, "reset feature quota")
|
||
}
|
||
if res.RowsAffected > 0 {
|
||
return nil
|
||
}
|
||
// 尚无记录:按默认配置创建(提问/投票默认开启,私聊默认关闭)
|
||
fc := DefaultFeatureControl(userID, feature)
|
||
fc.QuotaResetAt = &now
|
||
if err := db.WithContext(ctx).
|
||
Clauses(clause.OnConflict{DoNothing: true}).
|
||
Create(fc).Error; err != nil {
|
||
return errors.Wrap(err, "create feature control on reset quota")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// IncrementQuota 累加收集计数(delta 可为负,用于删除内容时回退计数)。
|
||
// 负 delta 不会把计数减到 0 以下(WHERE quota_count > 0),避免误删历史内容导致负计数。
|
||
func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feature string, delta int64) error {
|
||
if delta == 0 {
|
||
return nil
|
||
}
|
||
now := time.Now()
|
||
q := db.WithContext(ctx).Model(&FeatureControl{}).
|
||
Where("user_id = ? AND feature = ?", userID, feature)
|
||
if delta < 0 {
|
||
q = q.Where("quota_count > 0")
|
||
}
|
||
res := q.Updates(map[string]interface{}{
|
||
"quota_count": gorm.Expr("quota_count + ?", delta),
|
||
"updated_at": now,
|
||
})
|
||
if res.Error != nil {
|
||
return errors.Wrap(res.Error, "increment feature quota")
|
||
}
|
||
if res.RowsAffected > 0 {
|
||
return nil
|
||
}
|
||
|
||
// 尚无配置行:建一条并把计数初始化为本条(负数按 0 起步,不产生负计数)。
|
||
fc := DefaultFeatureControl(userID, feature)
|
||
if delta > 0 {
|
||
fc.QuotaCount = delta
|
||
}
|
||
fc.QuotaResetAt = &now
|
||
created := db.WithContext(ctx).
|
||
Clauses(clause.OnConflict{DoNothing: true}).
|
||
Create(fc)
|
||
if created.Error != nil {
|
||
return errors.Wrap(created.Error, "create feature control on increment quota")
|
||
}
|
||
if created.RowsAffected > 0 || delta <= 0 {
|
||
return nil
|
||
}
|
||
// 并发下配置行可能刚被其它请求创建:补一次累加,保证计数不丢。
|
||
retry := db.WithContext(ctx).Model(&FeatureControl{}).
|
||
Where("user_id = ? AND feature = ?", userID, feature).
|
||
Updates(map[string]interface{}{
|
||
"quota_count": gorm.Expr("quota_count + ?", delta),
|
||
"updated_at": time.Now(),
|
||
})
|
||
if retry.Error != nil {
|
||
return errors.Wrap(retry.Error, "increment feature quota (retry)")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// backfillFeatureControls 为尚未初始化的历史账号补齐功能配置行。
|
||
//
|
||
// 背景:私聊开关此前落在 users.allow_chat,提问/投票则一直默认开启。功能上线后若没有
|
||
// 对应的配置行,Get 会回退到默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭。
|
||
// 因此在 AutoMigrate 之后按 users.allow_chat 回填私聊开关,其余功能按"开启"回填。
|
||
//
|
||
// 幂等:每轮只挑出仍缺配置行的账号,写完后进入下一轮,直到没有遗漏。
|
||
// 分批 + ON CONFLICT DO NOTHING,避免"边插入边查同一张表"在不同数据库上的差异行为,
|
||
// 并保证并发启动(多实例同时启动)时不会重复插入。
|
||
func backfillFeatureControls(db *gorm.DB) error {
|
||
// backfillBatchSize 每轮处理的账号数,控制单次事务体积与内存占用。
|
||
const backfillBatchSize = 500
|
||
|
||
type backfillRow struct {
|
||
UserID uint
|
||
AllowChat bool
|
||
}
|
||
|
||
for _, feature := range FeatureKeys {
|
||
defaultEnabled := feature != FeatureChat
|
||
created := int64(0)
|
||
|
||
for {
|
||
var rows []backfillRow
|
||
err := db.Table("users").
|
||
Select("users.id AS user_id, users.allow_chat AS allow_chat").
|
||
Joins("LEFT JOIN feature_controls fc ON fc.user_id = users.id AND fc.feature = ?", feature).
|
||
Where("users.deleted_at IS NULL AND fc.id IS NULL").
|
||
Limit(backfillBatchSize).
|
||
Scan(&rows).Error
|
||
if err != nil {
|
||
return errors.Wrapf(err, "query users without feature control %s", feature)
|
||
}
|
||
if len(rows) == 0 {
|
||
break
|
||
}
|
||
|
||
batch := make([]FeatureControl, 0, len(rows))
|
||
for _, r := range rows {
|
||
enabled := defaultEnabled
|
||
if feature == FeatureChat {
|
||
enabled = r.AllowChat
|
||
}
|
||
batch = append(batch, FeatureControl{
|
||
UserID: r.UserID,
|
||
Feature: feature,
|
||
Enabled: enabled,
|
||
})
|
||
}
|
||
|
||
res := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&batch)
|
||
if res.Error != nil {
|
||
return errors.Wrapf(res.Error, "backfill feature controls: %s", feature)
|
||
}
|
||
// 本轮没有任何新行落库:说明并发实例已写完或数据异常,停止循环避免死循环。
|
||
if res.RowsAffected == 0 {
|
||
break
|
||
}
|
||
created += res.RowsAffected
|
||
if len(rows) < backfillBatchSize {
|
||
break
|
||
}
|
||
}
|
||
|
||
if created > 0 {
|
||
logrus.WithField("feature", feature).
|
||
Infof("功能开关初始化:为 %d 个历史账号补齐配置", created)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// reconcileLegacyChatEnabled 修复旧版 backfill 写入的私聊错误开关。
|
||
//
|
||
// 背景:早期版本 FeatureControl.Enabled 带 gorm:"default:true" 标签,backfill 为
|
||
// allow_chat=false 的用户补私聊行时,false 被 GORM 替换为默认值 true,导致私聊被被动开启;
|
||
// 且 UpdateChatSettings 会把 users.allow_chat 反向同步为 true,形成持久化。
|
||
//
|
||
// 纠偏条件(必须同时满足,只命中"纯 backfill 默认行",绝不碰箱主配置过的行):
|
||
// - feature = 'chat' 且 enabled = true;
|
||
// - 对应 users.allow_chat = false(箱主从未在面板开启私聊——面板开启会同步 allow_chat=true);
|
||
// - 该行无任何自定义配置(open_at / close_at 为空、quota_limit = 0、循环定时为空);
|
||
// - updated_at <= created_at(从未被面板保存过,updated_at 未变化)。
|
||
//
|
||
// 满足条件即复位 feature_controls.enabled = false,并同步 users.allow_chat = false。
|
||
// 幂等:每轮只处理仍满足条件的行,直到没有遗漏;已复位/已配置的行不会重复命中。
|
||
func reconcileLegacyChatEnabled(db *gorm.DB) error {
|
||
// reconcileBatchSize 每轮处理的账号数,控制单次事务体积。
|
||
const reconcileBatchSize = 500
|
||
|
||
// pureDefaultFilterFC / pureDefaultFilterPlain 只匹配纯默认配置的私聊行
|
||
// (无一次性窗口/循环定时/定量上限)。两者仅列前缀不同:
|
||
// - FC 版带 "fc." 前缀,用于 SELECT(feature_controls fc 别名场景);
|
||
// - 纯版不带前缀,用于 UPDATE(GORM 不会为 UPDATE 自动加别名,带前缀会报
|
||
// missing FROM-clause entry for table "fc")。
|
||
// 同时兼容旧行 schedule 列为 NULL 与新行空串两种情况。
|
||
pureDefaultFilterFC := "fc.open_at IS NULL AND fc.close_at IS NULL AND fc.quota_limit = 0" +
|
||
" AND (fc.schedule_days IS NULL OR fc.schedule_days = '')" +
|
||
" AND (fc.schedule_start IS NULL OR fc.schedule_start = '')" +
|
||
" AND (fc.schedule_end IS NULL OR fc.schedule_end = '')"
|
||
pureDefaultFilterPlain := "open_at IS NULL AND close_at IS NULL AND quota_limit = 0" +
|
||
" AND (schedule_days IS NULL OR schedule_days = '')" +
|
||
" AND (schedule_start IS NULL OR schedule_start = '')" +
|
||
" AND (schedule_end IS NULL OR schedule_end = '')"
|
||
|
||
fixed := int64(0)
|
||
for {
|
||
var rows []struct{ UserID uint }
|
||
err := db.Table("feature_controls fc").
|
||
Select("fc.user_id").
|
||
Joins("JOIN users u ON u.id = fc.user_id AND u.allow_chat = false").
|
||
Where("fc.feature = ? AND fc.enabled = true", FeatureChat).
|
||
Where(pureDefaultFilterFC).
|
||
Where("fc.updated_at <= fc.created_at").
|
||
Where("fc.deleted_at IS NULL AND u.deleted_at IS NULL").
|
||
Limit(reconcileBatchSize).
|
||
Scan(&rows).Error
|
||
if err != nil {
|
||
return errors.Wrap(err, "query legacy chat rows to reconcile")
|
||
}
|
||
if len(rows) == 0 {
|
||
break
|
||
}
|
||
|
||
userIDs := make([]uint, 0, len(rows))
|
||
for _, r := range rows {
|
||
userIDs = append(userIDs, r.UserID)
|
||
}
|
||
|
||
// 复位 feature_controls.enabled(同一批用户 + 同样的纯默认条件,避免误伤并发新增配置)。
|
||
res := db.Table("feature_controls").
|
||
Where("user_id IN ? AND feature = ? AND enabled = true", userIDs, FeatureChat).
|
||
Where(pureDefaultFilterPlain).
|
||
Where("deleted_at IS NULL").
|
||
Update("enabled", false)
|
||
if res.Error != nil {
|
||
return errors.Wrap(res.Error, "reset legacy chat enabled")
|
||
}
|
||
fixed += res.RowsAffected
|
||
|
||
// 同步 users.allow_chat,保证两处一致。
|
||
if err := db.Table("users").
|
||
Where("id IN ? AND allow_chat = true", userIDs).
|
||
Where("deleted_at IS NULL").
|
||
Update("allow_chat", false).Error; err != nil {
|
||
return errors.Wrap(err, "sync legacy users.allow_chat")
|
||
}
|
||
|
||
if int64(len(rows)) < reconcileBatchSize {
|
||
break
|
||
}
|
||
}
|
||
|
||
if fixed > 0 {
|
||
logrus.WithField("count", fixed).
|
||
Warn("私聊开关纠偏:旧版数据把未开启私聊的账号置为开启,已复位为关闭")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// normalizeChatQuota 清零私聊行上历史残留的收集上限与计数。
|
||
//
|
||
// 背景:私聊不提供收集上限能力(会话是双向对话,限制「新建会话数」意义不大),
|
||
// UI 已隐藏该字段、保存时强制清零;此处在启动期做一次性兜底,把旧版本
|
||
// 设置过的私聊上限/计数统一清零,避免达到残留上限后私聊「莫名关闭」。
|
||
// 幂等:重复执行不产生任何变更,失败由调用方告警(不阻断启动)。
|
||
func normalizeChatQuota(db *gorm.DB) error {
|
||
res := db.Model(&FeatureControl{}).
|
||
Where("feature = ? AND (quota_limit > 0 OR quota_count > 0)", FeatureChat).
|
||
Updates(map[string]interface{}{"quota_limit": 0, "quota_count": 0})
|
||
if res.Error != nil {
|
||
return errors.Wrap(res.Error, "normalize chat feature quota")
|
||
}
|
||
if res.RowsAffected > 0 {
|
||
logrus.WithField("count", res.RowsAffected).
|
||
Info("私聊收集上限已下线:已清零历史残留的私聊收集上限与计数")
|
||
}
|
||
return nil
|
||
}
|