restrict v1

This commit is contained in:
tamakyi
2026-10-18 21:14:59 +08:00
parent da7e76a7db
commit 6bd90779f8
23 changed files with 1754 additions and 1 deletions
+265
View File
@@ -0,0 +1,265 @@
// 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
}