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 }