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
+3
View File
@@ -21,6 +21,8 @@ var AllTables = []interface{}{
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
&FeatureControl{},
// 站方处置与举报(无管理后台,处置经 CLI 写入、运行时读表生效)
&UserRestriction{}, &ContentReport{},
}
func Init(typ, dsn string) (*gorm.DB, error) {
@@ -113,6 +115,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
VoteFormFields = NewVoteFormFieldsStore(db)
VoteSubmissions = NewVoteSubmissionsStore(db)
FeatureControls = NewFeatureControlsStore(db)
Restrictions = NewRestrictionsStore(db)
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
+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
}
+154
View File
@@ -0,0 +1,154 @@
// 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"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestRestrictions 覆盖站方处置的读写语义与「L1 停止分发」的曝光摘除。
// 这些行为没有管理后台兜底(处置只能经 CLI 写入),一旦语义写错无法从界面发现,
// 因此用数据库测试把「替换 / 到期 / 撤销 / 曝光排除」四条钉死。
func TestRestrictions(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
usersStore := NewUsersStore(database)
store := NewRestrictionsStore(database)
t.Cleanup(func() {
if err := cleanup("users", "user_restrictions"); err != nil {
t.Fatal(err)
}
})
require.NoError(t, usersStore.Create(ctx, CreateUserOptions{
Name: "Box Owner", Password: "super_secret", Email: "[email protected]", Domain: "rbox",
}))
owner, err := usersStore.GetByDomain(ctx, "rbox")
require.NoError(t, err)
require.NoError(t, usersStore.Create(ctx, CreateUserOptions{
Name: "Other Owner", Password: "super_secret", Email: "[email protected]", Domain: "obox",
}))
other, err := usersStore.GetByDomain(ctx, "obox")
require.NoError(t, err)
// 两个箱主都上首页,用于验证处置后是否被摘除。
require.NoError(t, usersStore.UpdateTags(ctx, owner.ID, UpdateTagsOptions{Tags: "test", ShowInHome: true}))
require.NoError(t, usersStore.UpdateTags(ctx, other.ID, UpdateTagsOptions{Tags: "test", ShowInHome: true}))
// 无处置时:不排除任何人。
level, active, err := store.ActiveLevel(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, RestrictionLevel(""), level)
require.Nil(t, active)
list, _, err := usersStore.GetShowInHome(ctx, 1, 10, "latest")
require.NoError(t, err)
require.Len(t, list, 2)
// 施加 L1。
created, err := store.Create(ctx, CreateRestrictionOptions{
UserID: owner.ID, Level: string(RestrictionL1), Reason: "组织挂人", CreatedBy: "test", Days: 7,
})
require.NoError(t, err)
require.WithinDuration(t, time.Now().AddDate(0, 0, 7), created.ExpiresAt, time.Minute)
level, active, err = store.ActiveLevel(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, RestrictionL1, level)
require.NotNil(t, active)
require.Equal(t, "组织挂人", active.Reason)
// L1 的核心语义:被处置箱主从首页 / 探索的展示池里摘除。
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
require.NoError(t, err)
require.Len(t, list, 1)
require.Equal(t, other.ID, list[0].ID)
// 批量版本只返回被处置者。
levels, err := store.ActiveLevels(ctx, []uint{owner.ID, other.ID})
require.NoError(t, err)
require.Equal(t, map[uint]RestrictionLevel{owner.ID: RestrictionL1}, levels)
// 替换语义:升级到 L3 后,旧记录保留但被撤销,同一时刻只有一条生效。
_, err = store.Create(ctx, CreateRestrictionOptions{
UserID: owner.ID, Level: string(RestrictionL3), Reason: "升级", CreatedBy: "test", Days: 3,
})
require.NoError(t, err)
level, _, err = store.ActiveLevel(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, RestrictionL3, level)
history, err := store.ListByUserID(ctx, owner.ID)
require.NoError(t, err)
require.Len(t, history, 2)
// ListByUserID 按创建时间倒序,第一条是新的 L3(未撤销),第二条是已撤销的 L1。
require.Equal(t, string(RestrictionL3), history[0].Level)
require.Nil(t, history[0].RevokedAt)
require.Equal(t, string(RestrictionL1), history[1].Level)
require.NotNil(t, history[1].RevokedAt)
// 到期即失效:直接把到期时间改到过去,ActiveLevel 必须返回空(处置自动解除,不需要定时任务)。
require.NoError(t, database.Model(&UserRestriction{}).
Where("user_id = ? AND revoked_at IS NULL", owner.ID).
Update("expires_at", time.Now().Add(-time.Minute)).Error)
level, active, err = store.ActiveLevel(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, RestrictionLevel(""), level)
require.Nil(t, active)
// 过期后曝光自动恢复。
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
require.NoError(t, err)
require.Len(t, list, 2)
// 撤销语义:有效期内撤销后立即恢复可见。
_, err = store.Create(ctx, CreateRestrictionOptions{
UserID: owner.ID, Level: string(RestrictionL1), Reason: "再处置", CreatedBy: "test", Days: 7,
})
require.NoError(t, err)
affected, err := store.RevokeActive(ctx, owner.ID)
require.NoError(t, err)
require.EqualValues(t, 1, affected)
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
require.NoError(t, err)
require.Len(t, list, 2)
// 幂等:再次撤销不再有任何生效记录。
affected, err = store.RevokeActive(ctx, owner.ID)
require.NoError(t, err)
require.EqualValues(t, 0, affected)
}
// TestRestrictionLevelValidation 防止脏级别进入 ORDER BY level DESC 的排序("L9" 会排在 "L4" 之后)。
func TestRestrictionLevelValidation(t *testing.T) {
require.True(t, IsValidRestrictionLevel(string(RestrictionL1)))
require.True(t, IsValidRestrictionLevel(string(RestrictionL4)))
require.False(t, IsValidRestrictionLevel("L5"))
require.False(t, IsValidRestrictionLevel("l1")) // 大小写敏感,避免 CLI 误传
require.False(t, IsValidRestrictionLevel(""))
}
// TestRestrictionAtLeast 守住发布侧拦截的判定:L2 收窄公开、L3 冻结发布都依赖这个比较。
// 空级别(无处置)与未知级别必须恒为 false,否则脏数据会把正常箱主锁死。
func TestRestrictionAtLeast(t *testing.T) {
require.True(t, RestrictionAtLeast(RestrictionL2, RestrictionL1))
require.True(t, RestrictionAtLeast(RestrictionL4, RestrictionL3))
require.True(t, RestrictionAtLeast(RestrictionL2, RestrictionL2))
require.False(t, RestrictionAtLeast(RestrictionL1, RestrictionL2))
require.False(t, RestrictionAtLeast(RestrictionL3, RestrictionL4))
require.False(t, RestrictionAtLeast("", RestrictionL1))
require.False(t, RestrictionAtLeast("L9", RestrictionL1))
}
+15
View File
@@ -626,6 +626,7 @@ func (db *users) GetShowInHome(ctx context.Context, page, pageSize int, sort str
}
query := db.WithContext(ctx).Where("show_in_home = ?", true)
query = query.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
switch sort {
case "oldest":
query = query.Order("created_at asc")
@@ -653,6 +654,7 @@ func (db *users) GetByTag(ctx context.Context, tag string, page, pageSize int, s
tagCond = "FIND_IN_SET(?, tags)"
}
baseQuery := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ? AND tags IS NOT NULL AND "+tagCond, true, tag)
baseQuery = baseQuery.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
if err := baseQuery.Count(&count).Error; err != nil {
return nil, 0, errors.Wrap(err, "count users by tag")
}
@@ -746,6 +748,7 @@ func (db *users) getRandomUsers(ctx context.Context, limit int) ([]*User, error)
}
if err := db.WithContext(ctx).
Where("show_in_home = ?", true).
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
Order(randomFunc).
Limit(limit).
Find(&users).Error; err != nil {
@@ -1047,3 +1050,15 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
}
return nil
}
// activeRestrictionsSubQuery 返回「当前处于生效中的处置」的用户 ID 子查询。
//
// 用于把被处置的箱主从首页 / 探索 / 标签检索 / 随机位摘除,即 L1「停止分发」的曝光部分。
// 用子查询而不是先查一遍 ID 列表:这三处都在列表热路径上,多一次往返没有收益;
// 没有处置记录时子查询返回空集,正常路径不产生额外代价。
// 注意 GetRandom 有 30 秒缓存,处置后随机位最多滞后 30 秒生效(其余入口即时生效)。
func activeRestrictionsSubQuery(tx *gorm.DB) *gorm.DB {
return tx.Table("user_restrictions").Select("user_id").
Where("revoked_at IS NULL").
Where("expires_at > ?", time.Now())
}