Files
tamabox/internal/db/feature_control.go
T
2026-09-08 10:10:44 +08:00

724 lines
27 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 _ 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
}