topfans/backend/services/moderationService/service/report_service.go
2026-06-22 17:19:48 +08:00

284 lines
7.7 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.

package service
import (
"context"
"encoding/json"
"errors"
"time"
"gorm.io/gorm"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/moderation"
"github.com/topfans/backend/services/moderationService/client"
"github.com/topfans/backend/services/moderationService/config"
"github.com/topfans/backend/services/moderationService/repository"
)
// 错误码spec §7
var (
ErrCategoryInvalid = errors.New("category invalid")
ErrFeedbackCatInvalid = errors.New("feedback category invalid")
ErrTargetNotFound = errors.New("target not found")
ErrDuplicateReport = errors.New("already reported")
ErrDescriptionTooLong = errors.New("description too long")
ErrTooManyEvidence = errors.New("too many evidence")
ErrReportNotFound = errors.New("report not found")
ErrReportClaimed = errors.New("report claimed by other")
ErrReportClosed = errors.New("report already closed")
ErrSelfReport = errors.New("cannot self-report")
ErrRateLimited = errors.New("rate limited")
ErrInvalidStateMigration = errors.New("invalid state migration")
ErrTargetTypeUnsupported = errors.New("target_type not supported")
)
type ReportService struct {
repo *repository.ModerationRepository
tx *TxHelper
assetClient *client.AssetClient
userClient *client.UserClient
notifClient *client.NotificationClient
autoHide *AutoHideService
category *CategoryService
cfg config.ModerationConfig
}
func NewReportService(
repo *repository.ModerationRepository,
tx *TxHelper,
assetClient *client.AssetClient,
userClient *client.UserClient,
notifClient *client.NotificationClient,
autoHide *AutoHideService,
category *CategoryService,
cfg config.ModerationConfig,
) *ReportService {
return &ReportService{
repo: repo, tx: tx,
assetClient: assetClient, userClient: userClient,
notifClient: notifClient, autoHide: autoHide,
category: category, cfg: cfg,
}
}
// snapshotToJSONB 序列化 snapshot map 为 JSONB
func snapshotToJSONB(snap map[string]interface{}) models.JSONB {
b, _ := json.Marshal(snap)
return models.JSONB(b)
}
// SubmitReport - spec §6.1 完整流程
func (s *ReportService) SubmitReport(ctx context.Context, req *pb.SubmitReportRequest) (*pb.SubmitReportResponse, error) {
now := time.Now().UnixMilli()
// 1. 验证 target_type
if req.TargetType != "asset" && req.TargetType != "user_profile" {
return nil, ErrTargetTypeUnsupported
}
// 2. 验证分类启用
enabled, err := s.category.IsReportCategoryEnabled(ctx, req.CategoryCode)
if err != nil {
return nil, err
}
if !enabled {
return nil, ErrCategoryInvalid
}
// 3. 验证 description 长度
if len(req.Description) > s.cfg.MaxDescriptionLen {
return nil, ErrDescriptionTooLong
}
// 4. 验证 evidence 数量
if len(req.EvidenceKeys) > s.cfg.MaxEvidenceCount {
return nil, ErrTooManyEvidence
}
// 5. 验证目标存在 + 抓取 snapshot + 自举报拦截
var snapshot models.JSONB
switch req.TargetType {
case "asset":
ownerUID, _, snap, err := s.assetClient.GetAssetForReport(ctx, req.TargetId)
if err != nil {
return nil, ErrTargetNotFound
}
if req.ReporterId == ownerUID {
return nil, ErrSelfReport
}
snapshot = snapshotToJSONB(snap)
case "user_profile":
_, snap, err := s.userClient.GetUserForReport(ctx, req.TargetId)
if err != nil {
return nil, ErrTargetNotFound
}
if req.ReporterId == req.TargetId {
return nil, ErrSelfReport
}
snapshot = snapshotToJSONB(snap)
}
// 6. 防重复检查
existing, _ := s.repo.GetReportByReporterAndTarget(ctx, req.ReporterId, req.TargetType, req.TargetId)
if existing != nil {
return nil, ErrDuplicateReport
}
// 7. 写 reports + evidence (事务内)
report := &models.Report{
ReporterID: req.ReporterId,
TargetType: req.TargetType,
TargetID: req.TargetId,
TargetSnapshot: snapshot,
CategoryCode: req.CategoryCode,
IsAnonymous: req.IsAnonymous,
Status: "pending",
ClaimedBy: nil,
ClaimedAt: nil,
CreatedAt: now,
UpdatedAt: now,
}
if req.Description != "" {
desc := req.Description
report.Description = &desc
}
if err := s.tx.WithTx(ctx, func(tx *gorm.DB) error {
if err := tx.Create(report).Error; err != nil {
return err
}
for i, key := range req.EvidenceKeys {
ev := &models.ReportEvidence{
ReportID: report.ID,
OSSKey: key,
SortOrder: int32(i),
CreatedAt: now,
}
if err := tx.Create(ev).Error; err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
// 8. Redis Lua 计数 → 自动隐藏
triggered, _, _ := s.autoHide.TryTrigger(ctx, req.TargetType, req.TargetId, req.ReporterId)
autoHidden := false
targetHidden := false
if triggered {
if err := s.autoHide.ExecuteAutoHide(ctx, req.TargetType, req.TargetId, report.ID); err != nil {
logger.Sugar.Warnw("execute auto hide failed", "err", err, "report_id", report.ID)
} else {
autoHidden = true
targetHidden = true
// 通知被举报方
notifyOwner := int64(0)
if req.TargetType == "asset" {
notifyOwner, _ = s.repo.GetAssetOwnerUID(ctx, req.TargetId)
} else {
notifyOwner = req.TargetId
}
if notifyOwner > 0 {
go func() {
_ = s.notifClient.SendReportAutoHidden(context.Background(), notifyOwner, req.TargetType, req.TargetId)
}()
}
}
}
status := "pending"
if autoHidden {
status = "auto_hidden"
}
return &pb.SubmitReportResponse{
ReportId: report.ID,
Status: status,
AutoHidden: autoHidden,
TargetHidden: targetHidden,
ClaimedBy: 0,
ClaimedAt: 0,
CreatedAt: now,
}, nil
}
// ListMyReports
func (s *ReportService) ListMyReports(ctx context.Context, req *pb.ListMyReportsRequest) (*pb.ListMyReportsResponse, error) {
page := int(req.Page)
if page < 1 {
page = 1
}
pageSize := int(req.PageSize)
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
reports, total, err := s.repo.ListMyReports(ctx, req.ReporterId, req.Status, page, pageSize)
if err != nil {
return nil, err
}
resp := &pb.ListMyReportsResponse{Total: int32(total)}
for _, r := range reports {
item := &pb.ReportSummary{
Id: r.ID,
TargetType: r.TargetType,
TargetId: r.TargetID,
CategoryCode: r.CategoryCode,
Status: r.Status,
CreatedAt: r.CreatedAt,
}
if r.ResolvedAt != nil {
item.ResolvedAt = *r.ResolvedAt
}
if r.ResolvedAction != nil {
item.ResolvedAction = *r.ResolvedAction
}
resp.Reports = append(resp.Reports, item)
}
return resp, nil
}
// GetReport
func (s *ReportService) GetReport(ctx context.Context, req *pb.GetReportRequest) (*pb.GetReportResponse, error) {
report, err := s.repo.GetReportByID(ctx, req.Id)
if err != nil {
return nil, ErrReportNotFound
}
if report.ReporterID != req.ReporterId {
return nil, ErrReportNotFound
}
resp := &pb.GetReportResponse{
Report: &pb.ReportSummary{
Id: report.ID,
TargetType: report.TargetType,
TargetId: report.TargetID,
CategoryCode: report.CategoryCode,
Status: report.Status,
CreatedAt: report.CreatedAt,
},
}
if report.Description != nil {
resp.Description = *report.Description
}
if report.ResolvedAt != nil {
resp.Report.ResolvedAt = *report.ResolvedAt
}
if report.ResolvedAction != nil {
resp.Report.ResolvedAction = *report.ResolvedAction
}
if len(report.TargetSnapshot) > 0 {
resp.TargetSnapshotJson = string(report.TargetSnapshot)
}
evs, _ := s.repo.GetReportEvidence(ctx, report.ID)
for _, ev := range evs {
item := &pb.EvidenceSummary{OssKey: ev.OSSKey}
if ev.OSSURL != nil {
item.OssUrl = *ev.OSSURL
}
resp.Evidence = append(resp.Evidence, item)
}
return resp, nil
}