fix anwser token and database issus
This commit is contained in:
@@ -223,7 +223,8 @@ func (db *conversations) MarkDeletedByOwner(ctx context.Context, id uint) error
|
||||
// 回退定量收集计数:删除私聊会话等价于少一条已收集内容,
|
||||
// 否则达上限后即便删掉旧会话,功能仍会显示 quota_reached 关闭,须手动重置计数。
|
||||
if conv.OwnerID != 0 {
|
||||
if err := FeatureControls.IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
|
||||
// 用本 store 的 *gorm.DB 构造,避免依赖包级全局单例(详见 DeleteByID 注释)。
|
||||
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, conv.OwnerID, FeatureChat, -1); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement chat quota for user %d", conv.OwnerID)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -42,21 +42,15 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
return nil, errors.Wrap(err, "connect to database")
|
||||
}
|
||||
|
||||
// 配置连接池
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get sql.DB from gorm")
|
||||
}
|
||||
|
||||
// 1C1G 低配环境:调低连接池,避免每个 DB 连接占用过多内存。
|
||||
// 提问箱是低并发场景,10 个打开连接绰绰有余。
|
||||
// 最大打开连接数
|
||||
// 1C1G 低配环境:调低连接池避免单连接占用过多内存。提问箱低并发,10 个打开连接绰绰有余。
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
// 最大空闲连接数
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
// 连接最大存活时间(避免长连接被数据库服务端断开)
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
// 空闲连接最大存活时间
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute) // 避免长连接被数据库服务端断开
|
||||
sqlDB.SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
// 仅在明确开启迁移清理开关时处理 users 表历史重复值,避免服务启动时静默改写账号数据。
|
||||
@@ -89,6 +83,12 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
logrus.WithError(err).Warn("私聊收集上限历史数据清理时异常(可在功能管理页保存一次私聊设置)")
|
||||
}
|
||||
|
||||
// 防骚扰设置归一:历史账号 harassment_setting 为空串(建表时无默认值),与 none 语义等价
|
||||
// 但取值不同,会导致按 == none 判断时漏判。幂等,失败仅告警。
|
||||
if err := normalizeHarassmentSetting(db); err != nil {
|
||||
logrus.WithError(err).Warn("防骚扰设置历史数据归一时异常(可在防骚扰设置页保存一次)")
|
||||
}
|
||||
|
||||
// 启动期补充高频查询复合索引(幂等,适合既有库)。
|
||||
// 仅 PostgreSQL 需要:这些索引针对服务端真实查询模式(按归属用户+私密+时间、
|
||||
// 按 asker、按展示/投票状态+可见性+时间、按会话/发送者+时间)。
|
||||
|
||||
@@ -66,29 +66,23 @@ type featureControls struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
// FeatureControl 提问箱单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
|
||||
// FeatureControl 单个功能(提问 / 私聊 / 投票)的开关、定时与定量收集配置。
|
||||
// Enabled 为手动总开关;OpenAt / CloseAt 为一次性定时(nil 表示不限该侧);
|
||||
// QuotaLimit / QuotaCount 为定量收集(0 表示不限制)。最终状态由 Evaluate 综合计算。
|
||||
//
|
||||
// 三类控制互不覆盖,最终可用状态由 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 表示不限制该侧)。
|
||||
// ⚠️ 不要给 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=周六,与 time.Weekday 一致),
|
||||
// 空表示每天都开放;ScheduleStart/ScheduleEnd 为每日时间段 "HH:MM"(留空表示该日全天)。
|
||||
// 两者都为空时表示未启用循环定时。
|
||||
// 循环定时(每周):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"`
|
||||
@@ -178,8 +172,8 @@ func (fc *FeatureControl) scheduleDayOpen(day time.Time) bool {
|
||||
return fc.ScheduleDays == "" || containsWeekday(fc.ScheduleDays, int(day.Weekday()))
|
||||
}
|
||||
|
||||
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。
|
||||
// 未设置时间段时视为全天 [0, 1440)。配置非法时返回错误(调用方按"不限制"处理)。
|
||||
// scheduleWindow 返回单日开放窗口的分钟数 [startM, endM)。未设置时间段时视为全天;
|
||||
// 配置非法时返回错误(调用方按「不限制」处理)。
|
||||
func (fc *FeatureControl) scheduleWindow() (startM, endM int, err error) {
|
||||
if fc.ScheduleStart == "" && fc.ScheduleEnd == "" {
|
||||
return 0, 24 * 60, nil
|
||||
@@ -342,9 +336,8 @@ func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。
|
||||
// 提问与投票默认开启(与历史行为一致);私聊默认关闭,沿用 users.allow_chat 的默认值,
|
||||
// 避免历史账号在功能上线后被动开启私聊。
|
||||
// DefaultFeatureControl 返回某功能的默认配置(尚未落库时的行为)。提问与投票默认开启
|
||||
// (与历史行为一致);私聊默认关闭,沿用 users.allow_chat 默认值,避免历史账号被动开启。
|
||||
func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
|
||||
return &FeatureControl{
|
||||
UserID: userID,
|
||||
@@ -353,8 +346,8 @@ func DefaultFeatureControl(userID uint, feature string) *FeatureControl {
|
||||
}
|
||||
}
|
||||
|
||||
// FeatureStateOf 便捷入口:读取某用户某功能在当前的综合状态。
|
||||
// 查询失败或无存储时回退到默认配置,保证单个依赖表异常不会让整站功能不可用。
|
||||
// FeatureStateOf 读取某用户某功能当前的综合状态。查询失败或无存储时回退默认配置,
|
||||
// 保证单个依赖表异常不会让整站功能不可用。
|
||||
func FeatureStateOf(ctx context.Context, userID uint, feature string) FeatureState {
|
||||
if FeatureControls == nil {
|
||||
return DefaultFeatureControl(userID, feature).Evaluate(time.Now())
|
||||
@@ -433,7 +426,7 @@ type UpdateFeatureControlOptions struct {
|
||||
QuotaLimit int64
|
||||
}
|
||||
|
||||
// Save 写入功能配置。使用 ON CONFLICT 幂等写入,并显式列出更新列,
|
||||
// Save 写入功能配置。用 ON CONFLICT 幂等写入并显式列出更新列,
|
||||
// 避免把 quota_count(已收集数量)误重置为 0。
|
||||
func (db *featureControls) Save(ctx context.Context, userID uint, feature string, opts UpdateFeatureControlOptions) error {
|
||||
now := time.Now()
|
||||
@@ -544,13 +537,12 @@ func (db *featureControls) IncrementQuota(ctx context.Context, userID uint, feat
|
||||
|
||||
// backfillFeatureControls 为尚未初始化的历史账号补齐功能配置行。
|
||||
//
|
||||
// 背景:私聊开关此前落在 users.allow_chat,提问/投票则一直默认开启。功能上线后若没有
|
||||
// 对应的配置行,Get 会回退到默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭。
|
||||
// 因此在 AutoMigrate 之后按 users.allow_chat 回填私聊开关,其余功能按"开启"回填。
|
||||
// 私聊开关此前落在 users.allow_chat,提问/投票一直默认开启;功能上线后没有配置行时
|
||||
// Get 会回退默认值(私聊默认关闭),导致已开启私聊的历史账号被静默关闭,
|
||||
// 故在 AutoMigrate 之后按 users.allow_chat 回填私聊,其余按「开启」回填。
|
||||
//
|
||||
// 幂等:每轮只挑出仍缺配置行的账号,写完后进入下一轮,直到没有遗漏。
|
||||
// 分批 + ON CONFLICT DO NOTHING,避免"边插入边查同一张表"在不同数据库上的差异行为,
|
||||
// 并保证并发启动(多实例同时启动)时不会重复插入。
|
||||
// 幂等:每轮只挑仍缺行的账号,分批 + ON CONFLICT DO NOTHING,避免不同数据库上
|
||||
// 「边插入边查同一张表」的差异行为,也保证多实例并发启动时不重复插入。
|
||||
func backfillFeatureControls(db *gorm.DB) error {
|
||||
// backfillBatchSize 每轮处理的账号数,控制单次事务体积与内存占用。
|
||||
const backfillBatchSize = 500
|
||||
@@ -616,28 +608,20 @@ func backfillFeatureControls(db *gorm.DB) error {
|
||||
|
||||
// reconcileLegacyChatEnabled 修复旧版 backfill 写入的私聊错误开关。
|
||||
//
|
||||
// 背景:早期版本 FeatureControl.Enabled 带 gorm:"default:true" 标签,backfill 为
|
||||
// allow_chat=false 的用户补私聊行时,false 被 GORM 替换为默认值 true,导致私聊被被动开启;
|
||||
// 且 UpdateChatSettings 会把 users.allow_chat 反向同步为 true,形成持久化。
|
||||
// 背景:早期 Enabled 带 gorm:"default:true",backfill 为 allow_chat=false 的用户补行时
|
||||
// false 被替换为 true,私聊被被动开启;UpdateChatSettings 还会把 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。
|
||||
// 幂等:每轮只处理仍满足条件的行,直到没有遗漏;已复位/已配置的行不会重复命中。
|
||||
// 纠偏条件(必须同时满足,只命中「纯 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
|
||||
|
||||
// pureDefaultFilterFC / pureDefaultFilterPlain 只匹配纯默认配置的私聊行
|
||||
// (无一次性窗口/循环定时/定量上限)。两者仅列前缀不同:
|
||||
// - FC 版带 "fc." 前缀,用于 SELECT(feature_controls fc 别名场景);
|
||||
// - 纯版不带前缀,用于 UPDATE(GORM 不会为 UPDATE 自动加别名,带前缀会报
|
||||
// missing FROM-clause entry for table "fc")。
|
||||
// 同时兼容旧行 schedule 列为 NULL 与新行空串两种情况。
|
||||
// 两者只匹配纯默认配置的私聊行(无一次性窗口/循环定时/定量上限),仅列前缀不同:
|
||||
// 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 = '')" +
|
||||
@@ -703,11 +687,9 @@ func reconcileLegacyChatEnabled(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// normalizeChatQuota 清零私聊行上历史残留的收集上限与计数。
|
||||
//
|
||||
// 背景:私聊不提供收集上限能力(会话是双向对话,限制「新建会话数」意义不大),
|
||||
// UI 已隐藏该字段、保存时强制清零;此处在启动期做一次性兜底,把旧版本
|
||||
// 设置过的私聊上限/计数统一清零,避免达到残留上限后私聊「莫名关闭」。
|
||||
// 幂等:重复执行不产生任何变更,失败由调用方告警(不阻断启动)。
|
||||
// 私聊不提供收集上限(会话是双向对话,限制「新建会话数」意义不大),UI 已隐藏该字段、
|
||||
// 保存时强制清零;此处在启动期兜底清零,避免达到残留上限后私聊「莫名关闭」。
|
||||
// 幂等,失败由调用方告警(不阻断启动)。
|
||||
func normalizeChatQuota(db *gorm.DB) error {
|
||||
res := db.Model(&FeatureControl{}).
|
||||
Where("feature = ? AND (quota_limit > 0 OR quota_count > 0)", FeatureChat).
|
||||
|
||||
+12
-22
@@ -11,39 +11,31 @@ import (
|
||||
)
|
||||
|
||||
// MaxFollowUpCount 每条提问允许的最大追问次数(防滥用)。
|
||||
// 提问者被回答后,用 token(或登录账号)继续追问,达到上限后不可再追问。
|
||||
const MaxFollowUpCount = 3
|
||||
|
||||
// 追问相关错误(供 handler 区分处理,避免把业务错误当 500)
|
||||
// 追问相关错误,供 handler 区分处理,避免把业务错误当 500。
|
||||
var (
|
||||
// ErrFollowUpLimitReached 追问次数已达上限
|
||||
ErrFollowUpLimitReached = errors.New("follow up limit reached")
|
||||
// ErrFollowUpNotFound 追问不存在或不属于该问题
|
||||
ErrFollowUpNotFound = errors.New("follow up not found")
|
||||
// ErrFollowUpAlreadyAnswered 追问已被回答(禁止覆盖)
|
||||
ErrFollowUpLimitReached = errors.New("follow up limit reached")
|
||||
ErrFollowUpNotFound = errors.New("follow up not found")
|
||||
ErrFollowUpAlreadyAnswered = errors.New("follow up already answered")
|
||||
)
|
||||
|
||||
var FollowUps FollowUpsStore
|
||||
|
||||
type FollowUpsStore interface {
|
||||
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建。
|
||||
// 返回 created=false 且 err==ErrFollowUpLimitReached 表示已达上限。
|
||||
// 通过事务内锁定问题行,串行化同一问题的并发追问,消除 count-then-create 竞态。
|
||||
// CreateIfBelowLimit 原子创建追问:仅当该问题追问次数未达上限时创建;
|
||||
// 事务内锁定问题行串行化并发追问,消除 count-then-create 竞态。已达上限返回 (false, ErrFollowUpLimitReached)。
|
||||
CreateIfBelowLimit(ctx context.Context, opts CreateFollowUpOptions) (created bool, err error)
|
||||
GetByQuestionID(ctx context.Context, questionID uint) ([]*FollowUp, error)
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问;无追问时返回 (nil, nil)。
|
||||
GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error)
|
||||
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回。
|
||||
// 用于账号导出等场景,将 N 次单题查询合并为 1 次 IN 查询,避免 N+1。
|
||||
// GetByQuestionIDs 批量获取多个提问的追问,按 question_id 分组返回(导出场景合并 N+1 为 1 次 IN 查询)。
|
||||
GetByQuestionIDs(ctx context.Context, questionIDs []uint) (map[uint][]*FollowUp, error)
|
||||
CountByQuestionID(ctx context.Context, questionID uint) (int64, error)
|
||||
// AnswerByID 回答某条追问;questionID 用于防止越权(必须属于该问题)。
|
||||
// 仅允许回答尚未回答的追问(answer 为空),已回答的返回 ErrFollowUpAlreadyAnswered,
|
||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
// AnswerByID 回答某条追问;questionID 防越权(必须属于该问题),仅允许回答尚未回答的(answer 为空)。
|
||||
// 已回答返回 ErrFollowUpAlreadyAnswered,不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防止越权),
|
||||
// 与 AnswerByID 不同,此处允许覆盖已回答的追问(供主人修改回复使用)。
|
||||
// UpdateAnswerByID 修改某条追问的回答内容(带 question_id 条件防越权),允许覆盖已回答的(供主人修改回复)。
|
||||
// 不存在/不属于该问题返回 ErrFollowUpNotFound。
|
||||
UpdateAnswerByID(ctx context.Context, questionID uint, id uint, answer string) error
|
||||
// DeleteByQuestionID 删除某条提问时级联清理其全部追问(软删除,与提问保持一致)。
|
||||
@@ -120,8 +112,7 @@ func (db *followUps) GetByQuestionID(ctx context.Context, questionID uint) ([]*F
|
||||
return followUps, nil
|
||||
}
|
||||
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序取首条);
|
||||
// 无追问时返回 (nil, nil)。供发布追问前校验"上一条是否已回复"。
|
||||
// GetLatestByQuestionID 返回某条提问最新的一条追问(created_at 倒序);无追问时返回 (nil, nil),供发布前校验上一条是否已回复。
|
||||
func (db *followUps) GetLatestByQuestionID(ctx context.Context, questionID uint) (*FollowUp, error) {
|
||||
var fu FollowUp
|
||||
err := db.WithContext(ctx).
|
||||
@@ -169,9 +160,8 @@ func (db *followUps) CountByQuestionID(ctx context.Context, questionID uint) (in
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// AnswerByID 更新某条追问的回答内容(带 question_id 条件防止越权)。
|
||||
// 原子更新条件含 answer=”:已回答的追问不会被覆盖(避免重复提交时邮件/回答被二次写入)。
|
||||
// RowsAffected==0 时再查一次以区分"不存在"与"已回答",返回对应错误。
|
||||
// AnswerByID 更新回答(带 question_id 防越权)。
|
||||
// 原子条件含 answer=”,已回答的不被覆盖;RowsAffected==0 再查一次以区分"不存在"与"已回答"。
|
||||
func (db *followUps) AnswerByID(ctx context.Context, questionID uint, id uint, answer string) error {
|
||||
res := db.WithContext(ctx).
|
||||
Model(&FollowUp{}).
|
||||
|
||||
@@ -92,7 +92,7 @@ func (db *questions) Create(ctx context.Context, opts CreateQuestionOptions) (*Q
|
||||
question := Question{
|
||||
FromIP: opts.FromIP,
|
||||
UserID: opts.UserID,
|
||||
Token: randstr.String(6),
|
||||
Token: randstr.String(12),
|
||||
Content: opts.Content,
|
||||
ReceiveReplyEmail: opts.ReceiveReplyEmail,
|
||||
AskerUserID: opts.AskerUserID,
|
||||
@@ -401,17 +401,19 @@ func (db *questions) DeleteByID(ctx context.Context, id uint) error {
|
||||
}
|
||||
|
||||
// 级联清理子表:追问与上传图片,避免删除提问后残留孤儿记录。
|
||||
if err := FollowUps.DeleteByQuestionID(ctx, id); err != nil {
|
||||
// 直接用本 store 的 *gorm.DB 构造子 store,避免依赖包级全局单例
|
||||
// (全局单例仅在 db.Init 装配,测试/部分初始化场景下为 nil 会 panic)。
|
||||
if err := NewFollowUpsStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete follow ups for question %d", id)
|
||||
}
|
||||
if err := UploadImages.DeleteByQuestionID(ctx, id); err != nil {
|
||||
if err := NewUploadImagesStore(db.DB).DeleteByQuestionID(ctx, id); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to cascade delete upload images for question %d", id)
|
||||
}
|
||||
|
||||
// 回退定量收集计数:删除提问等价于少一条已收集内容,
|
||||
// 否则达上限后即便删掉旧提问,功能仍会显示 quota_reached 关闭,须手动重置计数。
|
||||
if question.UserID != 0 {
|
||||
if err := FeatureControls.IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
|
||||
if err := NewFeatureControlsStore(db.DB).IncrementQuota(ctx, question.UserID, FeatureQuestion, -1); err != nil {
|
||||
logrus.WithContext(ctx).WithError(err).Errorf("Failed to decrement question quota for user %d", question.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
)
|
||||
|
||||
func TestUploadImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 注意:各子测试都创建 question_id=1 的上传图,cleanup 会 truncate 并 RESTART IDENTITY,
|
||||
// 并行子测试会拿到相同自增 id,撞 upload_image_questions 的复合主键
|
||||
// (upload_image_id, question_id);故顺序执行,使每个子测试独占全表。
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -29,7 +30,10 @@ func TestUploadImages(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("upload_images"); err != nil {
|
||||
// 必须一并清链接表:upload_image_questions 无 FK 级联依赖 upload_images,
|
||||
// 仅 truncate upload_images 会残留 (upload_image_id, question_id) 链接行,
|
||||
// 下一个子测试因身份重启拿到相同 id 而撞复合主键。
|
||||
if err := cleanup("upload_images", "upload_image_questions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
+24
-2
@@ -86,7 +86,7 @@ type User struct {
|
||||
UID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"-"`
|
||||
Email string `json:"email" gorm:"uniqueIndex:idx_users_email_unique"`
|
||||
Email string `json:"-" gorm:"uniqueIndex:idx_users_email_unique"`
|
||||
Avatar string `json:"avatar"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex:idx_users_domain_unique"`
|
||||
Background string `json:"background"`
|
||||
@@ -105,8 +105,12 @@ type User struct {
|
||||
Qrcodepdpcolor string `json:"qrcodepdpcolor"`
|
||||
Backgroundimagealpha string `json:"Backgroundimagealpha"`
|
||||
Notify NotifyType `json:"notify"`
|
||||
HarassmentSetting HarassmentSettingType `json:"harassment_setting"`
|
||||
HarassmentSetting HarassmentSettingType `json:"harassment_setting" gorm:"default:'none'"`
|
||||
BlockWords string `json:"-"`
|
||||
// 防骚扰设置取值 none / register_only(见下方常量)。历史账号此列为空串(建表时无默认值),
|
||||
// 与 none 语义等价但取值不同,会导致「按 == none 判断」的隐性漏判 → 已补 schema 默认值、
|
||||
// Create 显式写入、启动期归一历史空值(见 db.Init 的 normalizeHarassmentSetting)。
|
||||
//
|
||||
// ModerationStrictness 内容审核严格度:lax / standard / strict。
|
||||
// 空值表示「跟随站点默认」([security] default_moderation_strictness),
|
||||
// 存量账号迁移后即为空值形态 —— 缺省回退 strict(最严档),
|
||||
@@ -244,6 +248,7 @@ func (db *users) Create(ctx context.Context, opts CreateUserOptions) error {
|
||||
Backgroundimagealpha: opts.Backgroundimagealpha,
|
||||
Qrcodepdpcolor: opts.Qrcodepdpcolor,
|
||||
Notify: NotifyTypeEmail,
|
||||
HarassmentSetting: HarassmentSettingNone,
|
||||
Usernamecolor: opts.Usernamecolor,
|
||||
Introcolor: opts.Introcolor,
|
||||
Acfunlink: opts.Acfunlink,
|
||||
@@ -388,6 +393,23 @@ func (db *users) UpdateHarassmentType(ctx context.Context, id uint, typ Harassme
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHarassmentSetting 把 users.harassment_setting 的历史空值(空字符串)归一为 none。
|
||||
// 空串与 none 语义等价(均表示不做限制),归一后取值唯一,避免将来按 == none 判断时漏判。
|
||||
// 幂等:无空值时不做写入;失败由调用方告警(不阻断启动)。
|
||||
func normalizeHarassmentSetting(db *gorm.DB) error {
|
||||
res := db.Model(&User{}).
|
||||
Where("harassment_setting IS NULL OR harassment_setting = ''").
|
||||
Update("harassment_setting", HarassmentSettingNone)
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(res.Error, "normalize harassment setting")
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
logrus.WithField("count", res.RowsAffected).
|
||||
Info("防骚扰设置已归一:历史空值已置为 none")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBlockWords 仅更新全局屏蔽词表(逗号分隔,保存前已去重/去空白/限额)。
|
||||
// 只写 block_words 列,不动 harassment_setting。
|
||||
func (db *users) UpdateBlockWords(ctx context.Context, id uint, words string) error {
|
||||
|
||||
+59
-15
@@ -11,11 +11,26 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
)
|
||||
|
||||
func TestUsers(t *testing.T) {
|
||||
t.Parallel()
|
||||
// requireUserEqual 比较期望与实际的 User。
|
||||
// bcrypt 哈希每次加盐不同,无法逐字节比较:先按明文校验密码,再把哈希字段对齐后比较其余字段。
|
||||
// Create 会按 conf.Limits.ChatMaxMessages 显式写入该字段,故期望值同步取该配置默认。
|
||||
func requireUserEqual(t *testing.T, want, got *User) {
|
||||
t.Helper()
|
||||
require.True(t, got.Authenticate(want.Password))
|
||||
want.Password = got.Password
|
||||
want.ChatMaxMessages = conf.Limits.ChatMaxMessages
|
||||
want.HarassmentSetting = HarassmentSettingNone // Create 显式写入 none
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestUsers(t *testing.T) {
|
||||
// 注意:本测试各子测试创建用户均使用固定邮箱/域名([email protected] / e99),
|
||||
// 且每个子测试结束会 truncate 整张 users 表。若开启 t.Parallel(),并发子测试会在
|
||||
// 同一张表上同时插入相同唯一键导致"邮箱已注册"误报;故顺序执行,使每个子测试独占全表。
|
||||
db, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -122,8 +137,7 @@ func testUsersGetByID(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm.",
|
||||
Notify: NotifyTypeEmail,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
@@ -165,8 +179,7 @@ func testUsersGetByEmail(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm.",
|
||||
Notify: NotifyTypeEmail,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
@@ -208,8 +221,7 @@ func testUsersGetByDomain(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm.",
|
||||
Notify: NotifyTypeEmail,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
@@ -260,8 +272,7 @@ func testUsersUpdate(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm!!",
|
||||
Notify: NotifyTypeNone,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -307,6 +318,10 @@ func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) {
|
||||
require.Nil(t, err)
|
||||
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
// 先显式设定防骚扰类型,验证 UpdateBlockWords 不会误改该列(类型列默认为空串,
|
||||
// 与 HarassmentSettingNone 等价但值不同,故显式设定后再断言更可靠)。
|
||||
require.Nil(t, db.UpdateHarassmentType(ctx, 1, HarassmentSettingTypeRegisterOnly))
|
||||
|
||||
err := db.UpdateBlockWords(ctx, 1, "广告,抽奖")
|
||||
require.Nil(t, err)
|
||||
|
||||
@@ -314,7 +329,7 @@ func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "广告,抽奖", got.BlockWords)
|
||||
// 只应更新屏蔽词,类型列不受影响
|
||||
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting)
|
||||
require.Equal(t, HarassmentSettingTypeRegisterOnly, got.HarassmentSetting)
|
||||
})
|
||||
|
||||
t.Run("empty clears words", func(t *testing.T) {
|
||||
@@ -359,8 +374,7 @@ func testUsersAuthenticate(t *testing.T, ctx context.Context, db *users) {
|
||||
Intro: "Be cool, but also be warm.",
|
||||
Notify: NotifyTypeEmail,
|
||||
}
|
||||
want.EncodePassword()
|
||||
require.Equal(t, want, got)
|
||||
requireUserEqual(t, want, got)
|
||||
}
|
||||
|
||||
func testUsersChangePassword(t *testing.T, ctx context.Context, db *users) {
|
||||
@@ -408,9 +422,9 @@ func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) {
|
||||
err := db.Create(ctx, CreateUserOptions{
|
||||
Name: "E99p1ant",
|
||||
Password: "super_secret",
|
||||
Email: "i@github.red",
|
||||
Email: "deactivate@github.red",
|
||||
Avatar: "avater.png",
|
||||
Domain: "e99",
|
||||
Domain: "deactivate",
|
||||
Background: "background.png",
|
||||
Intro: "Be cool, but also be warm.",
|
||||
})
|
||||
@@ -426,3 +440,33 @@ func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) {
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestHarassmentSettingNormalize 覆盖防骚扰设置取值归一:
|
||||
// 新建用户即写入规范值 none;历史空值经启动期归一(normalizeHarassmentSetting)后也为 none。
|
||||
func TestHarassmentSettingNormalize(t *testing.T) {
|
||||
db, _ := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
usersStore := NewUsersStore(db)
|
||||
|
||||
require.Nil(t, usersStore.Create(ctx, CreateUserOptions{
|
||||
Name: "E99p1ant",
|
||||
Password: "super_secret",
|
||||
Email: "[email protected]",
|
||||
Avatar: "avater.png",
|
||||
Domain: "normalize",
|
||||
Background: "background.png",
|
||||
Intro: "Be cool, but also be warm.",
|
||||
}))
|
||||
|
||||
got, err := usersStore.GetByID(ctx, 1)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "新建用户应为规范值 none")
|
||||
|
||||
// 模拟历史行:直接把列置为空串,再跑一次启动期归一。
|
||||
require.Nil(t, db.Exec("UPDATE users SET harassment_setting = '' WHERE id = 1").Error)
|
||||
require.Nil(t, normalizeHarassmentSetting(db))
|
||||
|
||||
got, err = usersStore.GetByID(ctx, 1)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "历史空值应被归一为 none")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user