restrict v6

This commit is contained in:
tamakyi
2026-10-18 22:18:21 +08:00
parent d14d26df17
commit bd81398a06
18 changed files with 621 additions and 10 deletions
+1
View File
@@ -116,6 +116,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
VoteSubmissions = NewVoteSubmissionsStore(db)
FeatureControls = NewFeatureControlsStore(db)
Restrictions = NewRestrictionsStore(db)
Reports = NewReportsStore(db)
if err := db.Use(otelgorm.NewPlugin(
otelgorm.WithDBName(conf.Database.Name),
+123
View File
@@ -0,0 +1,123 @@
// 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"
)
// ReportsStore 举报记录的读写。
//
// 举报只是「信号」,处置仍然由箱主(在 /user 面板删/设私密)或运维(CLI)完成 ——
// 本项目没有管理后台,所以这里不提供「审核队列」之类的方法,只做写入与计数。
type ReportsStore interface {
// Create 写入一条举报。返回 created=false 表示该 IP 已举报过同一内容(唯一索引冲突),
// 属于幂等成功:调用方不应据此提示用户「已举报过」,否则会成为内容存在性的探测接口。
Create(ctx context.Context, opts CreateReportOptions) (bool, error)
// QuestionIDsByUser 返回某箱主名下「被举报的提问 ID -> 待处理举报数」。
// 箱主列表用它打标;已删除的提问不计入。
QuestionIDsByUser(ctx context.Context, userID uint) (map[uint]int64, error)
// CountByUserSince 统计某箱主自 since 起收到的待处理举报总数(邮件告警的触发依据)。
CountByUserSince(ctx context.Context, userID uint, since time.Time) (int64, error)
}
// Reports 举报记录全局 store,由 db.Init 装配。
var Reports ReportsStore
var _ ReportsStore = (*reports)(nil)
// 举报理由白名单。做成固定枚举而不是自由文本:自由填写等于又开了一个可以写辱骂内容的输入框,
// 而举报本身并不需要精确描述。
const (
ReportReasonHarassment = "harassment" // 人身攻击 / 辱骂
ReportReasonDoxxing = "doxxing" // 曝光他人账号或作品
ReportReasonDivisive = "divisive" // 挑起群体对立
ReportReasonOther = "other" // 其他
)
// IsValidReportReason 校验举报理由。
func IsValidReportReason(reason string) bool {
switch reason {
case ReportReasonHarassment, ReportReasonDoxxing, ReportReasonDivisive, ReportReasonOther:
return true
default:
return false
}
}
// CreateReportOptions 写入举报的入参。
type CreateReportOptions struct {
QuestionID uint
ReporterIP string
Reason string
}
func NewReportsStore(db *gorm.DB) ReportsStore {
return &reports{db: db}
}
type reports struct {
db *gorm.DB
}
func (s *reports) Create(ctx context.Context, opts CreateReportOptions) (bool, error) {
report := &ContentReport{
QuestionID: opts.QuestionID,
ReporterIP: opts.ReporterIP,
Reason: opts.Reason,
Status: ReportStatusPending,
}
if err := s.db.WithContext(ctx).Create(report).Error; err != nil {
// (question_id, reporter_ip) 唯一索引:同一个人对同一条内容只能报一次。
// 静默视为成功,避免通过「已举报过」的提示反查内容是否存在。
if isDuplicateKeyError(err) {
return false, nil
}
return false, errors.Wrap(err, "create content report")
}
return true, nil
}
func (s *reports) QuestionIDsByUser(ctx context.Context, userID uint) (map[uint]int64, error) {
type row struct {
QuestionID uint
Count int64
}
var rows []*row
if err := s.db.WithContext(ctx).
Table("content_reports AS r").
Select("r.question_id AS question_id, COUNT(r.id) AS count").
Joins("JOIN questions q ON q.id = r.question_id").
Where("q.user_id = ? AND q.deleted_at IS NULL", userID).
Where("r.status = ?", ReportStatusPending).
Group("r.question_id").
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(err, "count reports by user")
}
counts := make(map[uint]int64, len(rows))
for _, r := range rows {
counts[r.QuestionID] = r.Count
}
return counts, nil
}
func (s *reports) CountByUserSince(ctx context.Context, userID uint, since time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("content_reports AS r").
Joins("JOIN questions q ON q.id = r.question_id").
Where("q.user_id = ? AND q.deleted_at IS NULL", userID).
Where("r.status = ?", ReportStatusPending).
Where("r.created_at >= ?", since).
Count(&count).Error; err != nil {
return 0, errors.Wrap(err, "count reports since")
}
return count, nil
}
+87
View File
@@ -0,0 +1,87 @@
// 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"
)
// TestReports 覆盖举报写入的幂等性与两种计数口径。
//
// 幂等必须由唯一索引 + isDuplicateKeyError 兜住:如果改成「先查后插」,
// 并发下仍会撞索引报 500;而如果直接把冲突当错误返回,举报按钮会变成
// 「这条内容是否存在」的探测接口(第二次举报报错 = 内容存在)。
func TestReports(t *testing.T) {
database, cleanup := newTestDB(t)
ctx := context.Background()
t.Cleanup(func() {
if err := cleanup("users", "questions", "content_reports"); err != nil {
t.Fatal(err)
}
})
usersStore := NewUsersStore(database)
questionsStore := NewQuestionsStore(database)
reportsStore := NewReportsStore(database)
require.NoError(t, usersStore.Create(ctx, CreateUserOptions{
Name: "Box", Password: "super_secret", Email: "[email protected]", Domain: "rpbox",
}))
owner, err := usersStore.GetByDomain(ctx, "rpbox")
require.NoError(t, err)
question, err := questionsStore.Create(ctx, CreateQuestionOptions{
FromIP: "1.1.1.1", UserID: owner.ID, Content: "被举报的内容",
})
require.NoError(t, err)
require.NoError(t, questionsStore.AnswerByID(ctx, question.ID, "回答"))
// 首次举报写入成功。
created, err := reportsStore.Create(ctx, CreateReportOptions{
QuestionID: question.ID, ReporterIP: "2.2.2.2", Reason: ReportReasonHarassment,
})
require.NoError(t, err)
require.True(t, created)
// 同 IP 再报一次:幂等成功(不报错、不重复计数)。
created, err = reportsStore.Create(ctx, CreateReportOptions{
QuestionID: question.ID, ReporterIP: "2.2.2.2", Reason: ReportReasonDoxxing,
})
require.NoError(t, err)
require.False(t, created, "duplicate (question_id, reporter_ip) must be idempotent")
// 另一个 IP 正常计入。
created, err = reportsStore.Create(ctx, CreateReportOptions{
QuestionID: question.ID, ReporterIP: "3.3.3.3", Reason: ReportReasonDivisive,
})
require.NoError(t, err)
require.True(t, created)
counts, err := reportsStore.QuestionIDsByUser(ctx, owner.ID)
require.NoError(t, err)
require.Equal(t, map[uint]int64{question.ID: 2}, counts)
count24, err := reportsStore.CountByUserSince(ctx, owner.ID, time.Now().Add(-24*time.Hour))
require.NoError(t, err)
require.EqualValues(t, 2, count24)
// 窗口之外(未来时间点起算)应为 0,保证告警不会用错时间范围。
countFuture, err := reportsStore.CountByUserSince(ctx, owner.ID, time.Now().Add(time.Hour))
require.NoError(t, err)
require.EqualValues(t, 0, countFuture)
// 已驳回的不计入箱主侧标记与告警。
require.NoError(t, database.Model(&ContentReport{}).
Where("question_id = ?", question.ID).
Update("status", ReportStatusRejected).Error)
counts, err = reportsStore.QuestionIDsByUser(ctx, owner.ID)
require.NoError(t, err)
require.Empty(t, counts)
}