raw_content ?? $comment->content ?? ''); $hash = hash('sha256', $this->normalize($content)); if (! (bool) config('comment_spam.enabled', true) || $mode === 'off') { return $this->saveMetadata($comment, 0, 0, 'disabled', 'Comment spam protection disabled.'); } $signature = $this->signature($hash); if ($signature !== null) { $this->recordSignatureHit($signature); $isSpam = ($signature->source === 'spam' || $signature->source === 'admin_spam'); return $this->finish($comment, $mode, $isSpam ? 100 : 0, $isSpam ? 100 : 0, 'signature', (string) $signature->reason, $isSpam); } $scan = $this->processing->process($content, [ 'content_type' => 'artwork_comment', 'content_id' => (int) $comment->id, 'artwork_id' => (int) $comment->artwork_id, 'user_id' => (int) $user->id, 'content_snapshot' => $content, 'comment_spam_mode' => $mode, ], true); $local = (int) $scan['result']->score; $probability = $local; $source = 'local'; $reason = implode(' ', array_slice($scan['result']->reasons, 0, 2)); $isSpam = $local >= (int) config('comment_spam.local_spam_min', 70); $needsAi = $local > (int) config('comment_spam.local_safe_max', 29) && ! $isSpam; $aiPending = false; // Repeated external links and explicit copy/paste promotions are // deterministic spam signals. Do not let an AI false negative make // an obvious advertisement public. if ($this->isObviousPromotionalSpam($content, (array) $scan['result']->matchedLinks)) { $local = max($local, (int) config('comment_spam.local_spam_min', 70)); $probability = 100; $isSpam = true; $needsAi = false; $source = 'local_hard_rule'; $reason = trim($reason.' Repeated promotional external link pattern.'); } if ($needsAi && (bool) config('comment_spam.ai_enabled', true)) { try { $assessment = $this->ai->classify($content); $probability = $assessment->spam ? max($local, (int) round($assessment->confidence * 100)) : $local; $source = 'local+ai'; $reason = trim(implode(' ', array_filter([$reason, $assessment->reason]))); $isSpam = $assessment->spam && $assessment->confidence >= (float) config('comment_spam.ai.spam_confidence', 0.90); $aiPending = $assessment->spam && ! $isSpam; Log::info('comment_spam_ai_classification', ['comment_id' => $comment->id, 'provider' => $assessment->provider, 'model' => $assessment->model, 'spam' => $assessment->spam, 'confidence' => $assessment->confidence, 'latency_ms' => $assessment->latencyMs, 'decision' => $isSpam ? 'spam' : ($assessment->spam ? 'pending' : 'approved')]); } catch (Throwable) { $source = 'local+ai_error'; $aiPending = true; } } $status = $mode === 'observe' ? 'observed' : ($isSpam ? 'spam' : ($needsAi && $aiPending ? 'pending' : 'approved')); $comment->forceFill([ 'is_approved' => $mode === 'enforce' ? ! in_array($status, ['spam', 'pending'], true) : true, 'spam_score' => min(100, $local), 'spam_probability' => min(100, $probability), 'spam_reason' => $reason !== '' ? mb_substr($reason, 0, 500) : null, 'moderation_source' => $source, 'moderated_at' => now(), ])->save(); if ($mode === 'enforce' && $status === 'spam') { $this->rememberSignature($hash, 'spam', $reason, $probability); } return compact('status', 'local', 'probability', 'source', 'reason') + ['score' => $local]; } private function finish(ArtworkComment $comment, string $mode, int $score, int $probability, string $source, string $reason, ?bool $isSpam = null): array { $isSpam ??= $score >= (int) config('comment_spam.local_spam_min', 70); $status = $mode === 'observe' ? 'observed' : ($isSpam ? 'spam' : 'approved'); $comment->forceFill([ 'is_approved' => $mode !== 'enforce' || ! $isSpam, 'spam_score' => $score, 'spam_probability' => $probability, 'spam_reason' => mb_substr($reason, 0, 500), 'moderation_source' => $source, 'moderated_at' => now(), ])->save(); return compact('status', 'score', 'probability', 'source', 'reason'); } private function saveMetadata(ArtworkComment $comment, int $score, int $probability, string $source, string $reason): array { $comment->forceFill(['spam_score' => $score, 'spam_probability' => $probability, 'spam_reason' => $reason, 'moderation_source' => $source, 'moderated_at' => now()])->save(); return ['status' => 'approved', 'score' => $score, 'probability' => $probability, 'source' => $source, 'reason' => $reason]; } private function signature(string $hash): ?CommentSpamSignature { return Cache::remember('comment-spam:'.$hash, now()->addMinutes((int) config('comment_spam.signature_cache_minutes', 1440)), fn () => CommentSpamSignature::query()->where('content_hash', $hash)->first()); } private function recordSignatureHit(CommentSpamSignature $signature): void { try { if (! Schema::hasColumn('comment_spam_signatures', 'hit_count')) { return; } CommentSpamSignature::query()->whereKey($signature->getKey())->increment('hit_count'); } catch (Throwable $e) { Log::warning('comment_spam_signature_hit_failed', [ 'signature_id' => $signature->getKey(), 'message' => $e->getMessage(), ]); } } private function rememberSignature(string $hash, string $source, string $reason, int $confidence): void { CommentSpamSignature::query()->updateOrCreate(['content_hash' => $hash], ['source' => $source, 'reason' => mb_substr($reason, 0, 500), 'confidence' => min(100, $confidence), 'created_at' => now()]); Cache::forget('comment-spam:'.$hash); } private function normalize(string $content): string { return mb_strtolower((string) preg_replace('/\s+/u', ' ', trim($content))); } private function isObviousPromotionalSpam(string $content, array $matchedLinks): bool { $links = array_values(array_unique(array_map( fn (string $link): string => mb_strtolower(trim($link)), array_filter($matchedLinks, 'is_string'), ))); preg_match_all('#https?://[^\s<>\[\]"\'`\)]+#iu', $content, $matches); $allLinks = array_map('mb_strtolower', $matches[0] ?? []); $hasRepeatedLink = count($allLinks) >= 2 && count(array_unique($allLinks)) < count($allLinks); $hasPromotion = preg_match('/\b(copy\s*(?:&|and)\s*paste|buy\s+now|cheap\s+seo|guaranteed\s+traffic|visit\s+my\s+profile)\b/iu', $content) === 1; return $hasRepeatedLink || ($hasPromotion && $links !== []); } }