// 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" "time" "github.com/pkg/errors" "gorm.io/gorm" ) // RestrictionLevel 站方对箱主的处置级别。 // // 本项目刻意不做管理后台:处置只能由运维经 CLI(TamaBox restrict)写入,运行时读表生效。 // 级别用稳定字符串并依赖字典序("L1" < "L2" < "L3" < "L4",均为 L + 单个数字), // 取「当前最高生效级别」时可直接 ORDER BY level DESC —— 不要引入多字符级别("L10" < "L4")。 type RestrictionLevel string const ( // RestrictionL1 停止分发:禁用单题分享卡、撤销首页与探索曝光。不涉及价值判断,可批量执行。 RestrictionL1 RestrictionLevel = "L1" // RestrictionL2 收窄公开:新回答与「设为公开」动作强制转为私密。 RestrictionL2 RestrictionLevel = "L2" // RestrictionL3 冻结发布:禁止回答 / 设为公开 / 回答追问,仍可收信(不剥夺收件能力)。 RestrictionL3 RestrictionLevel = "L3" // RestrictionL4 页面停用:整页不可访问,数据保留。 RestrictionL4 RestrictionLevel = "L4" ) // restrictionLevels 合法级别集合。CLI 与写入前校验共用,非法值一律拒绝, // 避免脏数据让「ORDER BY level DESC」选出非预期级别。 var restrictionLevels = map[RestrictionLevel]struct{}{ RestrictionL1: {}, RestrictionL2: {}, RestrictionL3: {}, RestrictionL4: {}, } // IsValidRestrictionLevel 校验级别取值合法性。 func IsValidRestrictionLevel(level string) bool { _, ok := restrictionLevels[RestrictionLevel(level)] return ok } // restrictionLevelRank 级别权重,用于「是否达到某一档」的比较。 // 显式权重而非字符串比较:字符串比较依赖「L + 单个数字」这个隐含约束, // 将来加 L10 会静默排错("L10" < "L4")。 var restrictionLevelRank = map[RestrictionLevel]int{ RestrictionL1: 1, RestrictionL2: 2, RestrictionL3: 3, RestrictionL4: 4, } // RestrictionAtLeast 判断当前级别是否达到 target(含相等)。 // 空级别(无处置)恒为 false;未知级别按「无处置」处理,避免脏数据把箱主锁死。 func RestrictionAtLeast(level, target RestrictionLevel) bool { current, ok := restrictionLevelRank[RestrictionLevel(level)] if !ok { return false } threshold, ok := restrictionLevelRank[target] if !ok { return false } return current >= threshold } // UserRestriction 一条站方处置记录。 // // 刻意不嵌 dbutil.Model: // - 处置记录不允许软删除(撤销走 RevokedAt),必须留痕,否则无法自证处置过程; // - 到期自动失效靠 expires_at 与当前时间比较实现,不需要定时任务或后台解封动作。 type UserRestriction struct { ID uint `gorm:"primarykey" json:"id"` UserID uint `gorm:"index:idx_user_restrictions_user;not null" json:"user_id"` Level string `gorm:"type:varchar(8);not null" json:"level"` Reason string `gorm:"type:text" json:"reason"` CreatedBy string `gorm:"type:varchar(64)" json:"created_by"` ExpiresAt time.Time `gorm:"index:idx_user_restrictions_expires;not null" json:"expires_at"` RevokedAt *time.Time `json:"revoked_at"` CreatedAt time.Time `json:"created_at"` } // ContentReport 访客对单条提问/回答的举报。 // // 同 IP 对同一问题只能报一次(复合唯一索引)。这里同样不嵌 dbutil.Model: // 举报是审计证据,不做软删除;带 DeletedAt 的唯一索引会留下「删了仍占槽位」的陷阱。 // 注意:唯一索引不含 deleted_at,一旦将来引入软删除,重复举报会在 INSERT 处被拦成 500。 type ContentReport struct { ID uint `gorm:"primarykey" json:"id"` QuestionID uint `gorm:"uniqueIndex:idx_content_reports_question_ip,priority:1;not null" json:"question_id"` ReporterIP string `gorm:"type:varchar(64);uniqueIndex:idx_content_reports_question_ip,priority:2;not null" json:"reporter_ip"` Reason string `gorm:"type:varchar(32);not null" json:"reason"` Status string `gorm:"type:varchar(16);not null;default:'pending';index:idx_content_reports_status" json:"status"` CreatedAt time.Time `json:"created_at"` } // 举报处理状态。 const ( ReportStatusPending = "pending" // 待处理 ReportStatusAccepted = "accepted" // 举报成立 ReportStatusRejected = "rejected" // 举报驳回 ) // RestrictionsStore 处置记录读写。 // // 只提供「写入 / 撤销 / 查询当前生效级别」三类动作:本项目没有管理后台, // 历史查询由 CLI(TamaBox reports)直接读库完成,不需要在这里堆展示型方法。 type RestrictionsStore interface { // Create 追加一条处置记录。写入前会撤销该用户现有生效中的记录, // 保证同一用户同一时刻只有一条生效记录(升级/降级都是替换语义,避免叠加混乱)。 Create(ctx context.Context, opts CreateRestrictionOptions) (*UserRestriction, error) // RevokeActive 撤销某用户当前所有生效中的记录,返回受影响行数(幂等)。 RevokeActive(ctx context.Context, userID uint) (int64, error) // ActiveLevel 返回某用户当前生效的最高级别;无生效记录时返回空串与 nil。 ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error) // ActiveLevels 批量版本:探索页等列表场景避免 N+1。 ActiveLevels(ctx context.Context, userIDs []uint) (map[uint]RestrictionLevel, error) // ListByUserID 按创建时间倒序列出某用户全部处置历史(含已撤销/已过期)。 ListByUserID(ctx context.Context, userID uint) ([]*UserRestriction, error) } // Restrictions 处置记录全局 store,由 db.Init 装配。 // ⚠️ 与其它 store 一致:方法内部只用自身 db 句柄,不反向引用该包级变量(测试下未装配会 nil panic)。 var Restrictions RestrictionsStore var _ RestrictionsStore = (*restrictions)(nil) // CreateRestrictionOptions 新建处置记录的入参。 type CreateRestrictionOptions struct { UserID uint Level string Reason string CreatedBy string // Days 生效天数,从写入时刻起算;必须为正整数,避免写入即过期的空记录。 Days int } func NewRestrictionsStore(db *gorm.DB) RestrictionsStore { return &restrictions{db: db} } type restrictions struct { db *gorm.DB } func (s *restrictions) Create(ctx context.Context, opts CreateRestrictionOptions) (*UserRestriction, error) { if !IsValidRestrictionLevel(opts.Level) { return nil, errors.Errorf("invalid restriction level: %q", opts.Level) } if opts.Days <= 0 { return nil, errors.New("restriction days must be positive") } restriction := &UserRestriction{ UserID: opts.UserID, Level: opts.Level, Reason: opts.Reason, CreatedBy: opts.CreatedBy, ExpiresAt: time.Now().AddDate(0, 0, opts.Days), } // 替换语义:先撤销旧的生效记录,再写新的。两条语句共用一个事务, // ⚠️ 事务里每条语句都要重新取句柄(tx.WithContext),复用同一 tx 会串条件。 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { now := time.Now() if err := tx.WithContext(ctx). Model(&UserRestriction{}). Where("user_id = ?", opts.UserID). Where("revoked_at IS NULL"). Where("expires_at > ?", now). Updates(map[string]interface{}{"revoked_at": now}).Error; err != nil { return errors.Wrap(err, "revoke active restrictions") } if err := tx.WithContext(ctx).Create(restriction).Error; err != nil { return errors.Wrap(err, "create restriction") } return nil }) if err != nil { return nil, err } return restriction, nil } func (s *restrictions) RevokeActive(ctx context.Context, userID uint) (int64, error) { now := time.Now() result := s.db.WithContext(ctx). Model(&UserRestriction{}). Where("user_id = ?", userID). Where("revoked_at IS NULL"). Where("expires_at > ?", now). Updates(map[string]interface{}{"revoked_at": now}) if result.Error != nil { return 0, errors.Wrap(result.Error, "revoke active restrictions") } return result.RowsAffected, nil } func (s *restrictions) ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error) { levels, err := s.ActiveLevels(ctx, []uint{userID}) if err != nil { return "", nil, err } level, ok := levels[userID] if !ok { return "", nil, nil } var restriction UserRestriction if err := s.db.WithContext(ctx). Where("user_id = ?", userID). Where("level = ?", string(level)). Where("revoked_at IS NULL"). Where("expires_at > ?", time.Now()). Order("created_at DESC"). First(&restriction).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return level, nil, nil } return "", nil, errors.Wrap(err, "find active restriction") } return level, &restriction, nil } func (s *restrictions) ActiveLevels(ctx context.Context, userIDs []uint) (map[uint]RestrictionLevel, error) { levels := make(map[uint]RestrictionLevel, len(userIDs)) if len(userIDs) == 0 { return levels, nil } var rows []*UserRestriction if err := s.db.WithContext(ctx). Where("user_id IN ?", userIDs). Where("revoked_at IS NULL"). Where("expires_at > ?", time.Now()). Order("level DESC"). Find(&rows).Error; err != nil { return nil, errors.Wrap(err, "list active restrictions") } // Order 已按级别降序,每个 user 首次出现即为最高级别。 for _, row := range rows { if _, ok := levels[row.UserID]; ok { continue } levels[row.UserID] = RestrictionLevel(row.Level) } return levels, nil } func (s *restrictions) ListByUserID(ctx context.Context, userID uint) ([]*UserRestriction, error) { var rows []*UserRestriction if err := s.db.WithContext(ctx). Where("user_id = ?", userID). Order("created_at DESC"). Find(&rows).Error; err != nil { return nil, errors.Wrap(err, "list restrictions") } return rows, nil }