284 lines
7.7 KiB
Go
284 lines
7.7 KiB
Go
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
|
||
}
|