Files
tamabox/internal/db/restriction.go
T
2026-10-18 21:14:59 +08:00

266 lines
9.9 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"
"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
}