266 lines
10 KiB
Go
266 lines
10 KiB
Go
// 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) {
|
||
// 只查一次:这个方法在每次内容提交时都会被调用(发布拦截 + 审核严格度),
|
||
// 早先「先 ActiveLevels 再 First」是两次往返,1C1G 上没必要。
|
||
var rows []*UserRestriction
|
||
if err := s.db.WithContext(ctx).
|
||
Where("user_id = ?", userID).
|
||
Where("revoked_at IS NULL").
|
||
Where("expires_at > ?", time.Now()).
|
||
Find(&rows).Error; err != nil {
|
||
return "", nil, errors.Wrap(err, "list active restrictions")
|
||
}
|
||
|
||
// 正常情况最多一条(Create 是替换语义),这里取最高级别以防历史脏数据叠加。
|
||
var best *UserRestriction
|
||
bestRank := 0
|
||
for _, row := range rows {
|
||
if rank, ok := restrictionLevelRank[RestrictionLevel(row.Level)]; ok && rank > bestRank {
|
||
best, bestRank = row, rank
|
||
}
|
||
}
|
||
if best == nil {
|
||
return "", nil, nil
|
||
}
|
||
return RestrictionLevel(best.Level), best, 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
|
||
}
|