Files
tamabox/internal/db/feature_control.go
T

754 lines
28 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 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
}