// 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 ErrFeatureQuotaReached = errors.New("功能收集配额已满") 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 单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。 // Enabled 为手动总开关;OpenAt / CloseAt 为一次性定时(nil 表示不限该侧); // QuotaLimit / QuotaCount 为定量收集(0 表示不限制)。最终状态由 Evaluate 综合计算。 // // 计数不做定时重置:一轮结束后由箱主手动「重置计数」,避免与定时开关叠加产生 // 难以预期的自动恢复行为。 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、复选框自动恢复勾选。 Enabled bool `json:"enabled"` OpenAt *time.Time `json:"open_at"` CloseAt *time.Time `json:"close_at"` // 循环定时(每周):ScheduleDays 为逗号分隔的周几(0=周日…6=周六),空表示每天都开放; // 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)。未设置时间段时视为全天; // 配置非法时返回错误(调用方按「不限制」处理)。 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()) } // FeatureStateOfStrict 与 FeatureStateOf 相同,但查询失败时返回错误而不回退默认配置。 // // 给**写入路径**用。FeatureStateOf 的 fail-open 对读展示是对的(配置表抖动不该让 // 页面白屏),但对写入是危险的:它回退到 DefaultFeatureControl —— 提问与投票的 // 默认值是「开启」,于是箱主明明设了「已关闭」或「已达上限」,只要这次读配置失败 // 就被放行,等于定量收集与功能开关都能被一次数据库抖动绕过去。 // 与审核链路「不可用即拒绝」同一口径。 func FeatureStateOfStrict(ctx context.Context, userID uint, feature string) (FeatureState, error) { if FeatureControls == nil { // 未装配(测试 / 部分初始化):沿用默认配置,否则所有写路径都会失败。 return DefaultFeatureControl(userID, feature).Evaluate(time.Now()), nil } 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 FeatureState{}, errors.Wrap(err, "get feature control") } return fc.Evaluate(time.Now()), nil } // 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 { return incrementQuotaTx(db.WithContext(ctx), userID, feature, delta) } // incrementQuotaTx 是配额递增的唯一实现,接受任意 *gorm.DB(可以是事务)。 // // 为什么要暴露一个「能用事务」的版本:配额与内容必须是同生共死的关系。 // 原来「创建提问」与「quota_count+1」是两次独立写入,后者失败只打一条 Warn —— // 于是计数比真实行数少,箱主的「已收集 7/10」永远偏低,达到上限后仍能继续收, // 而重置计数也校准不回来(重置是把计数清零,与实际行数更脱节)。 // 现在内容 store 把这一步放进自己的事务里,任一步失败整体回滚,计数天然对齐。 func reserveFeatureQuotaTx(tx *gorm.DB, userID uint, feature string) error { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(DefaultFeatureControl(userID, feature)).Error; err != nil { return errors.Wrap(err, "initialize feature quota") } var fc FeatureControl if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ? AND feature = ?", userID, feature).First(&fc).Error; err != nil { return errors.Wrap(err, "lock feature quota") } if fc.QuotaLimit > 0 && fc.QuotaCount >= fc.QuotaLimit { return ErrFeatureQuotaReached } return nil } func incrementQuotaTx(tx *gorm.DB, userID uint, feature string, delta int64) error { if delta == 0 { return nil } now := time.Now() q := tx.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 := tx. 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 := tx.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 写入的私聊错误开关。 // // 背景:早期 Enabled 带 gorm:"default:true",backfill 为 allow_chat=false 的用户补行时 // false 被替换为 true,私聊被被动开启;UpdateChatSettings 还会把 allow_chat 同步为 true。 // // 纠偏条件(必须同时满足,只命中「纯 backfill 默认行」,绝不碰箱主配置过的行): // feature='chat' 且 enabled=true;对应 users.allow_chat=false;该行无任何自定义配置 // (open_at/close_at 为空、quota_limit=0、循环定时为空);updated_at <= created_at。 // 满足即复位 enabled=false 并同步 users.allow_chat=false。幂等,已配置行不会重复命中。 func reconcileLegacyChatEnabled(db *gorm.DB) error { // reconcileBatchSize 每轮处理的账号数,控制单次事务体积。 const reconcileBatchSize = 500 // 两者只匹配纯默认配置的私聊行(无一次性窗口/循环定时/定量上限),仅列前缀不同: // FC 版带 "fc." 用于 SELECT;纯版不带前缀用于 UPDATE(GORM 不会为 UPDATE 自动加别名, // 带前缀会报 missing FROM-clause entry for table "fc")。同时兼容旧行 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 }