عنوان:

‫کشف تقلب در تراکنش‌های مالی: ۶ الگوی کاربردی با SQL


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۰۲ ۰۹:۵۰
آدرس: www.dntips.ir
در دنیای مهندسی داده و امنیت سیستم‌های مالی، کماکان بخش عمده‌ای از فرآیند شناسایی تقلب (Fraud Detection) نه با الگوریتم‌های پیچیده یادگیری ماشین و پایگاه‌های داده گراف، بلکه با اجرای کوئری‌های بهینه و هوشمند SQL روی جداول تراکنش‌ها انجام می‌شود. شناسایی الگوهای مشکوک، برقراری پیوندهای (Joins) درست و تحلیل ساختار داده‌ها، کلید اصلی کشف رفتارهای ناهنجار است. این مقاله راهنمایی جامع و کاربردی برای توسعه‌دهندگان و تحلیل‌گران داده است تا با شش الگوی کلیدی SQL برای ردیابی تقلب در سیستم‌های بانکی، تجارت الکترونیک، بیمه و هر سامانه‌ای که با جابه‌جایی پول سروکار دارد، آشنا شوند.

۱. تحلیل نرخ تکرار (Velocity)
ساده‌ترین و رایج‌ترین شکل تقلب این است که فردی پس از سرقت یک کارت، تلاش می‌کند پیش از مسدود شدن آن، موجودی را به سرعت تخلیه کند. در این حالت، تعداد تراکنش‌ها در یک بازه زمانی کوتاه به شدت بالا می‌رود.

روش اول: گروه‌بندی زمانی ثابت (Time-Bucketing)
در این روش، زمان را به بخش‌های ثابتی (مثلاً یک ساعته) تقسیم کرده و تراکنش‌های هر کاربر را می‌شماریم:
SELECT
  cardholder_id,
  DATE_TRUNC('hour', timestamp) AS hour_bucket,
  COUNT(*) AS tx_count,
  MIN(timestamp) AS first_tx,
  MAX(timestamp) AS last_tx
FROM transactions
WHERE timestamp >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY 1, 2
HAVING COUNT(*) > 10;
نکته کاربردی: برای دریافت نتیجه دقیق‌تر، بهتر است پنجره‌های زمانی مختلف (۱ دقیقه، ۵ دقیقه و ۱ ساعت) را به طور موازی بررسی کنید. حملات خودکار (کارت تستینگ) در چند ثانیه رخ می‌دهند، در حالی که تقلب‌های سنتی ممکن است چند ساعت طول بکشند.

روش دوم: پنجره لغزان (Sliding Window)
برای افزایش دقت و جلوگیری از گم شدن تراکنش‌هایی که در مرز دو ساعتِ متفاوت رخ می‌دهند، از تابع پنجره‌ای (Window Function) با پنجره لغزان استفاده می‌کنیم:
SELECT
  cardholder_id,
  timestamp,
  COUNT(*) OVER (
    PARTITION BY cardholder_id
    ORDER BY timestamp
    RANGE BETWEEN INTERVAL '5 minutes' PRECEDING AND CURRENT ROW
  ) AS tx_in_last_5min
FROM transactions
-- در دیتابیس‌های مدرن مانند Snowflake یا BigQuery از QUALIFY استفاده می‌شود.
-- در Postgres باید این کوئری را درون یک CTE قرار داده و بیرون آن فیلتر کنید.
QUALIFY tx_in_last_5min >= 5
ORDER BY cardholder_id, timestamp;
مدیریت خطای مثبت کاذب (False Positives): برخی رفتارهای قانونی نیز ممکن است این فیلتر را رد کنند (مانند اپراتورهایی که دستگاه‌های فروش خودکار را شارژ می‌کنند). برای این موارد باید یک فهرست مجاز (Whitelist) ایجاد کنید تا از ایجاد هشدارهای نادرست جلوگیری شود.

۲. سفر غیرممکن (Impossible Travel)
اگر کارتی در ساعت ۱۲:۰۰ در تهران تراکنش داشته باشد و ۷ دقیقه بعد با همان کارت در مشهد تراکنشی ثبت شود، قطعاً یکی از آن‌ها جعلی یا کارت شبیه‌سازی شده (Cloned) است. این الگو یکی از قطعی‌ترین نشانه‌های تقلب است.
برای محاسبه فاصله جغرافیایی بین دو تراکنش متوالی، از فرمول هاورساین ($Haversine$) استفاده می‌کنیم که فاصله روی کره زمین را محاسبه می‌کند:
WITH ordered_tx AS (
  SELECT
    cardholder_id,
    timestamp,
    location,
    LAG(timestamp) OVER (PARTITION BY cardholder_id ORDER BY timestamp) AS prev_ts,
    LAG(location)  OVER (PARTITION BY cardholder_id ORDER BY timestamp) AS prev_loc
  FROM transactions
)
SELECT
  cardholder_id,
  prev_ts AS first_tx,
  timestamp AS second_tx,
  prev_loc AS first_location,
  location AS second_location,
  EXTRACT(EPOCH FROM (timestamp - prev_ts)) / 60 AS minutes_apart,
  haversine(prev_loc, location) AS miles_apart
FROM ordered_tx
WHERE prev_ts IS NOT NULL
  AND prev_loc <> location
  -- بررسی سرعت حرکت (مایل بر ساعت): اگر بیشتر از ۶۰۰ مایل بر ساعت (حدود ۹۶۰ کیلومتر) باشد
  AND haversine(prev_loc, location) 
      / NULLIF(EXTRACT(EPOCH FROM (timestamp - prev_ts)), 0) 
      * 3600 > 600;
توسعه الگو: آستانه ۶۰۰ مایل بر ساعت برای هواپیما است. شما می‌توانید این عدد را برای سفرهای بین‌شهری زمینی به ۱۰۰ مایل بر ساعت کاهش دهید. همچنین الگوهای زیرمجموعه این خانواده عبارتند از:
  • دو شهر متفاوت در یک استان در کمتر از ۵ دقیقه (باندهای شبیه‌سازی محلی).
  • تغییر چندین کد پستی در کمتر از یک ساعت.

۳. ناهنجاری در مبالغ تراکنش (Amount Anomalies)
مبالغ خاصی در شبکه وجود دارند که رفتارهای عادی کاربران به ندرت آن‌ها را تولید می‌کند، اما در فرآیندهای تقلب بسیار رایج هستند.
SELECT cardholder_id, timestamp, amount, merchant_id
FROM transactions
WHERE
  (amount >= 99.55 AND amount < 100.00)
  OR (amount >= 499.50 AND amount < 500.00)
  OR amount IN (1.00, 5.00, 10.00)
ORDER BY cardholder_id, timestamp;
تحلیل رفتار پشت این مبالغ:
  • مبالغ رند و کوچک ($1.00, $5.00): سارقان پس از خرید اطلاعات کارت‌ها، ابتدا یک تراکنش بسیار کوچک و رند انجام می‌دهند تا مطمئن شوند کارت فعال است (Card Testing). خریدهای واقعی معمولاً به دلیل مالیات یا قیمت‌گذاری‌ها اعداد رندی نیستند.
  • مبالغ نزدیک به آستانه ($99.99 یا $499.99): این مبالغ برای دور زدن قوانین کنترلی انتخاب می‌شوند. برای مثال، در بسیاری از فروشگاه‌ها تراکنش‌های بالای ۱۰۰ دلار نیاز به تایید هویت دارند، یا سقف برداشت روزانه از خودپرداز ۵۰۰ دلار است. نفوذگران با ماندن در زیر این سقف، سیستم را دور می‌زنند.

۴. پدپذیرندگان مشکوک (Suspicious Merchants)
زمانی که یک ابزار اسکیمر (Skimmer) روی دستگاه پوز یک فروشگاه یا پمپ بنزین نصب می‌شود، اطلاعات ده‌ها کارت سرقت می‌شود. نشانه‌ی این اتفاق در سمت پذیرنده (Merchant) خود را نشان می‌دهد: افزایش ناگهانی تعداد کارت‌های غیرمرتبط که در یک بازه زمانی کوتاه، مبالغ بالایی را تراکنش می‌زنند.
برای اینکه فروشگاه‌های بزرگ (مانند هایپرمارکت‌ها) به اشتباه به عنوان متخلف شناسایی نشوند، نباید از اعداد ثابت استفاده کرد؛ بلکه باید رفتار هر پذیرنده را نسبت به میانگین گذشته خودش (Baseline) سنجید:
WITH merchant_hourly AS (
  SELECT
    merchant_id,
    DATE_TRUNC('hour', timestamp) AS hour_bucket,
    COUNT(DISTINCT cardholder_id) AS unique_cards
  FROM transactions
  WHERE timestamp >= CURRENT_DATE - INTERVAL '60 days'
  GROUP BY 1, 2
),
with_baseline AS (
  SELECT
    *,
    AVG(unique_cards) OVER (
      PARTITION BY merchant_id
      ORDER BY hour_bucket
      ROWS BETWEEN 168 PRECEDING AND 1 PRECEDING
    ) AS rolling_avg_cards
  FROM merchant_hourly
)
SELECT *,
  unique_cards / NULLIF(rolling_avg_cards, 0) AS spike_ratio
FROM with_baseline
-- فیلتر کردن پذیرنده‌هایی که فعالیت آن‌ها بیش از ۳ برابر میانگین هفته گذشته‌شان شده است
WHERE unique_cards > rolling_avg_cards * 3
ORDER BY spike_ratio DESC;
چرا عدد ۱۶۸؟ ۱۶۸ ساعت معادل دقیقاً یک هفته است. الگوهای رفتاری در روزهای هفته پویایی متفاوتی دارند (مثلا رفتار خرید در روزهای آخر هفته با اواسط هفته متفاوت است). بررسی یک هفته کامل، این نوسانات دوره‌ای (Seasonality) را پوشش می‌دهد.

۵. تراکنش در ساعات غیرمتعارف (Off-Hours)
بیشتر انسان‌ها رفتارهای مالی نظام‌مندی دارند. کارمندی که سبک زندگی روزانه دارد، به ندرت در ساعت ۳ بامداد خرید سنگین یا تراکنش‌های متوالی انجام می‌دهد.
WITH cardholder_hour_pattern AS (
  SELECT
    cardholder_id,
    EXTRACT(HOUR FROM timestamp) AS hour_of_day,
    COUNT(*) AS tx_count
  FROM transactions
  WHERE timestamp >= CURRENT_DATE - INTERVAL '90 days'
  GROUP BY 1, 2
),
cardholder_normal AS (
  SELECT
    cardholder_id,
    MIN(hour_of_day) FILTER (WHERE tx_count >= 2) AS earliest_hour,
    MAX(hour_of_day) FILTER (WHERE tx_count >= 2) AS latest_hour
  FROM cardholder_hour_pattern
  GROUP BY 1
)
SELECT t.cardholder_id, t.timestamp, t.amount, t.merchant_id
FROM transactions t
JOIN cardholder_normal cn USING (cardholder_id)
WHERE EXTRACT(HOUR FROM t.timestamp) NOT BETWEEN cn.earliest_hour AND cn.latest_hour
ORDER BY t.timestamp DESC;
نکته کلیدی: استفاده از فیلتر WHERE tx_count >= 2 بسیار حیاتی است. این فیلتر باعث می‌شود که اگر کاربر یک بار در گذشته به صورت اتفاقی در نیمه‌شب خریدی انجام داده باشد، آن ساعت جزو رفتار "نرمال" او تلقی نشود. الگوی نرمال باید بر اساس تکرار شکل بگیرد.

۶. ترکیب سیگنال‌ها با توابع پنجره‌ای (Chained Signals)
این الگو یک روش کشف مستقل نیست، بلکه یک زیرساخت (Primitive) قدرتمند است که به شما اجازه می‌دهد تمام ۵ الگوی قبلی را با یکدیگر ترکیب (Compose) کنید. با این کار، فرآیند پیاده‌سازی فرضیات جدید تقلب، از پروژه‌های سنگین مهندسی به چند خط فیلتر ساده تبدیل می‌شود.
WITH tx_with_windows AS (
  SELECT
    cardholder_id,
    timestamp,
    amount,
    merchant_id,
    -- محاسبه فاصله زمانی با تراکنش قبلی
    timestamp - LAG(timestamp) OVER w AS time_since_last,
    -- بررسی تغییر پذیرنده
    CASE WHEN merchant_id <> LAG(merchant_id) OVER w THEN 'changed' ELSE 'same' END AS merchant_change,
    -- مجموع تراکنش‌های ۲۴ ساعت گذشته
    SUM(amount) OVER (
      PARTITION BY cardholder_id
      ORDER BY timestamp
      RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND CURRENT ROW
    ) AS running_24h_total,
    -- شماره تراکنش در روز جاری
    ROW_NUMBER() OVER (
      PARTITION BY cardholder_id, DATE(timestamp)
      ORDER BY timestamp
    ) AS tx_of_day
  FROM transactions
  WINDOW w AS (PARTITION BY cardholder_id ORDER BY timestamp)
)
-- اکنون اعمال فرضیه تقلب بسیار ساده می‌شود:
SELECT *
FROM tx_with_windows
WHERE tx_of_day >= 5
  AND time_since_last < INTERVAL '60 seconds'
  AND merchant_change = 'changed';

نکات حیاتی در محیط‌های عملیاتی (Production)
در مسیر توسعه این سیستم‌ها، توجه به الزامات فنی و عملیاتی زیر مانع از بروز فاجعه در پایگاه داده و تجربه کاربری می‌شود:
  • مدیریت مقادیر تهی (NULL Handling): در سیستم‌های مالی قدیمی، به جای NULL از مقادیر پیش‌فرض ساختگی (Sentinel Values) مانند 1900-01-01 یا 9999-12-31 استفاده می‌شود. هنگام نوشتن دستورات شرطی، ابتدا این قراردادها را در دیتابیس خود بررسی کنید تا تراکنش‌ها از قلم نیفتند.
  • بهینه‌سازی هزینه و کارایی (Cost & Performance): توابع پنجره‌ای (LAG, SUM() OVER) روی حجم داده‌های بالا بسیار سنگین هستند. همیشه ابتدا بازه زمانی داده‌ها را در بخش WHERE محدود کنید و سپس توابع پنجره‌ای را اعمال کنید. اعمال توابع پنجره‌ای روی کل تاریخچه دیتابیس بدون فیلتر اولیه، می‌تواند منابع سرور را به طور کامل تخلیه کند.
  • حفظ حریم خصوصی (Privacy): جداول تراکنش حاوی اطلاعات حساس (PII) هستند. در صورت امکان، فرآیند توسعه و تنظیم قوانین را روی داده‌های بی‌نام‌سازی‌شده (De-identified) یا نمونه‌برداری‌شده (Sampled) انجام دهید.

نتیجه‌گیری
هیچ‌کدام از این الگوها به تنهایی برای متوقف کردن تقلب کافی نیستند. نرخ تکرار بالا ممکن است ناشی از یک خرید قانونی عمده باشد و ناهنجاری ساعت ممکن است به دلیل سفر رخ دهد. موفقیت نهایی در امتیازدهی ترکیبی (Risk Scoring) است؛ تراکنشی که همزمان در ۳ یا ۴ الگو مردود شود، با احتمال بالا تقلب است.
اگر در ابتدای این مسیر هستید، ابتدا با تحلیل نرخ تکرار (الگوی ۱) شروع کنید چرا که پیاده‌سازی آن ساده و بازدهی آن سریع است. قدم بزرگ بعدی، سرمایه‌گذاری روی توابع ترکیبی (الگوی ۶) است تا سرعت تیم تحلیل شما را در خنثی‌سازی روش‌های جدید تقلب، از چند هفته به چند ساعت کاهش دهد.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۴/۰۲ ۱۰:۱۰
    برای پیاده‌سازی الگوهای بررسی‌شده در سطح دیتابیس با استفاده از Entity Framework Core (EF Core)، نیاز به طراحی اصولی مدل‌ها (Entities) و پیکربندی آن‌ها (Fluent API) داریم.
    در ادامه‌، مدل‌های متناظر، روابط بین آن‌ها و تنظیمات بهینه‌سازی عملکرد (Performance) برای کار با داده‌های حجم بالا ارائه شده است.

    ۱. تعریف مدل‌های پویای دیتابیس (Entities)
    برای پوشش دادن الگوهایی مثل مکان جغرافیایی (فرمول هاورساین)، شناسه پذیرندگان، و تراکنش‌ها، سه موجودیت اصلی Cardholder، Merchant و Transaction را تعریف می‌کنیم.

    موجودیت دارنده کارت (Cardholder)
    public class Cardholder
    {
        public long Id { get; set; }
        public string FirstName { get; set; } = string.Empty;
        public string LastName { get; set; } = string.Empty;
        public bool IsWhitelisted { get; set; } // برای الگوی ۱ (مدیریت مثبت کاذب)
    
        // رابطه یک به چند با تراکنش‌ها
        public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
    }

    موجودیت پذیرنده / فروشگاه (Merchant)
    public class Merchant
    {
        public long Id { get; set; }
        public string Name { get; set; } = string.Empty;
        public string Category { get; set; } = string.Empty; // مثلاً پوشاک، پمپ بنزین
    
        // رابطه یک به چند با تراکنش‌ها
        public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
    }

    موجودیت کلیدی تراکنش (Transaction)
    برای ذخیره موقعیت جغرافیایی در SQL Server، استفاده از نوع داده استاندارد NetTopologySuite.Geometries.Point بهترین گزینه است تا محاسبات فاصله (مانند الگوی سفر غیرممکن) به جای کد سی‌شارپ، با پرفورمنس بالا در سمت دیتابیس انجام شود.
    using NetTopologySuite.Geometries;
    
    public class Transaction
    {
        public long Id { get; set; }
        public long CardholderId { get; set; }
        public long MerchantId { get; set; }
        
        public decimal Amount { get; set; }
        public DateTime Timestamp { get; set; }
        
        // استفاده از Point برای ذخیره طول و عرض جغرافیایی (الگوی ۲)
        public Point Location { get; set; } = default!; 
    
        // Navigation Properties
        public Cardholder Cardholder { get; set; } = default!;
        public Merchant Merchant { get; set; } = default!;
    }

    ۲. پیکربندی صریح روابط و ایندکس‌ها (Fluent API)
    در سیستم‌های مالی و کشف تقلب، سرعت کوئری‌ها حیاتی است. بنابراین باید روی ستون‌های پرکاربرد در بدنه WHERE و JOINها (مانند Timestamp و CardholderId) ایندکس‌های ترکیبی (Composite Indexes) بسازیم.
    using Microsoft.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore.Metadata.Builders;
    
    public class AppDbContext : DbContext
    {
        public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
    
        public DbSet<Transaction> Transactions => Set<Transaction>();
        public DbSet<Cardholder> Cardholders => Set<Cardholder>();
        public DbSet<Merchant> Merchants => Set<Merchant>();
    
        protected override void OnModelCreating(ModelCreatingCollection modelBuilder)
        {
            // پیکربندی تراکنش‌ها
            modelBuilder.Entity<Transaction>(entity =>
            {
                entity.HasKey(e => e.Id);
                
                entity.Property(e => e.Amount)
                      .HasPrecision(18, 2); // دقت استاندارد مبالغ مالی
    
                // تعریف ایندکس ترکیبی حیاتی برای الگوهای ۱، ۲، ۵ و ۶ (Velocity & Off-Hours)
                entity.HasIndex(e => new { e.CardholderId, e.Timestamp })
                      .HasDatabaseName("IX_Transactions_Cardholder_Timestamp");
    
                // تعریف ایندکس ترکیبی برای الگوی ۴ (Suspicious Merchants)
                entity.HasIndex(e => new { e.MerchantId, e.Timestamp })
                      .HasDatabaseName("IX_Transactions_Merchant_Timestamp");
    
                // روابط
                entity.HasOne(d => d.Cardholder)
                      .WithMany(p => p.Transactions)
                      .HasForeignKey(d => d.CardholderId)
                      .OnDelete(DeleteBehavior.Restrict);
    
                entity.HasOne(d => d.Merchant)
                      .WithMany(p => p.Transactions)
                      .HasForeignKey(d => d.MerchantId)
                      .OnDelete(DeleteBehavior.Restrict);
            });
        }
    }

    ۳. افزونه جهت پشتیبانی از مکان جغرافیایی در دیتابیس
    برای اینکه EF Core بتواند ویژگی Point را به ساختار مطلع از جغرافیای بانک اطلاعاتی (مانند GEOMETRY یا GEOGRAPHY در SQL Server) تبدیل کند، زمان پیکربندی دیتابیس در فایل تنظیمات پروژه (Program.cs یا کانفیک تزریق وابستگی)، قابلیت NetTopologySuite را فعال کنید:
    builder.Services.AddDbContext<AppDbContext>(options =>
        options.UseSqlServer(
            builder.Configuration.GetConnectionString("DefaultConnection"),
            x => x.UseNetTopologySuite() // فعال‌سازی قابلیت‌های جغرافیایی
        ));

    یک نمونه از ترجمه الگوها به LINQ (الگوی سفر غیرممکن)
    با این ساختار مدل‌سازی، حالا می‌توانید محاسبات سنگین را به راحتی به LINQ بسپارید تا به SQL بهینه ترجمه شود:
    var suspiciousTravels = await context.Transactions
        .AsNoTracking()
        .Select(t => new
        {
            t.CardholderId,
            t.Timestamp,
            t.Location,
            // واکشی تراکنش قبلی با کمک لید/لگ تعبیه‌شده در EF Core
            PreviousTimestamp = context.Transactions
                .Where(prev => prev.CardholderId == t.CardholderId && prev.Timestamp < t.Timestamp)
                .OrderByDescending(prev => prev.Timestamp)
                .Select(prev => (DateTime?)prev.Timestamp)
                .FirstOrDefault(),
            PreviousLocation = context.Transactions
                .Where(prev => prev.CardholderId == t.CardholderId && prev.Timestamp < t.Timestamp)
                .OrderByDescending(prev => prev.Timestamp)
                .Select(prev => prev.Location)
                .FirstOrDefault()
        })
        .Where(x => x.PreviousTimestamp != null && 
                    // محاسبه فاصله مستقیم بر اساس متر در سمت دیتابیس
                    x.Location.Distance(x.PreviousLocation) > 500000) // به عنوان مثال بیش از ۵۰۰ کیلومتر
        .ToListAsync();
    نکته کارایی: برای کوئری‌های پیچیده توابع پنجره‌ای و زنجیره‌ای (الگوی ۶)، اگر نگاشت فرمول‌های سنگین به LINQ خوانایی کد را کاهش داد، پیشنهاد می‌شود از قابلیت FromSqlRaw در EF Core استفاده کرده و کوئری SQL بهینه ارائه شده در مقاله را مستقیماً اجرا و خروجی را به این مدل‌ها Map کنید.
  • وحید نصیری در ۱۴۰۵/۰۴/۰۲ ۱۰:۱۲
    برای پیاده‌سازی الگوی ۱ (تحلیل نرخ تکرار یا Velocity) با استفاده از EF Core، هر دو روش «گروه‌بندی زمانی ثابت» و «پنجره لغزان» را می‌توانیم به شکل LINQ پیاده‌سازی کنیم.
    در دیتابیس‌های بزرگ، برای حفظ کارایی حتماً باید فیلتر بازه زمانی (مثلاً ۳۰ روز گذشته) در ابتدای کوئری اعمال شود و از قابلیت AsNoTracking استفاده کنیم تا حافظه سرور درگیر ردیابی تغییرات نشود.

    روش اول: گروه‌بندی زمانی ثابت (Time-Bucketing) در EF Core
    در این روش، با استفاده از توابع تاریخ در LINQ (که توسط EF Core به تابع DATE_TRUNC یا معادل آن در SQL Server یعنی DATEADD/DATEDIFF ترجمه می‌شود) تراکنش‌ها را در دسته‌های یک‌ساعته گروه‌بندی می‌کنیم:
    using Microsoft.EntityFrameworkCore;
    
    DateTime thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
    
    var highVelocityBuckets = await context.Transactions
        .AsNoTracking()
        .Where(t => t.Timestamp >= thirtyDaysAgo)
        // بررسی وضعیت فهرست مجاز برای حذف مثبت‌های کاذب
        .Where(t => !t.Cardholder.IsWhitelisted) 
        .GroupBy(t => new
        {
            t.CardholderId,
            // گروه‌بندی بر اساس تاریخ و ساعت (حذف دقیقه‌ها و ثانیه‌ها)
            HourBucket = new DateTime(t.Timestamp.Year, t.Timestamp.Month, t.Timestamp.Day, t.Timestamp.Hour, 0, 0)
        })
        .Where(g => g.Count() > 10) // شرط HAVING در SQL
        .Select(g => new
        {
            g.Key.CardholderId,
            g.Key.HourBucket,
            TransactionCount = g.Count(),
            FirstTransaction = g.Min(t => t.Timestamp),
            LastTransaction = g.Max(t => t.Timestamp)
        })
        .OrderByDescending(p => p.TransactionCount)
        .ToListAsync();

    روش دوم: پنجره لغزان (Sliding Window) در EF Core
    نگاشت توابع پنجره‌ای پیشرفته (مانند COUNT(*) OVER (...)) با بازه‌های زمانی لغزان (RANGE BETWEEN) به صورت LINQ خالص در EF Core پیچیده است و گاهی اوقات توسط ارائه‌دهنده دیتابیس (Provider) به SQL بهینه ترجمه نمی‌شود.
    بهترین، تمیزترین و پرفورمنسی‌ترین روش برای این سناریو، استفاده از FromSqlRaw یا FromSql در EF Core 8/9/10/11 است. این قابلیت به شما اجازه می‌دهد قدرت و ظرافت کوئری دیتابیس را حفظ کرده و خروجی را مستقیماً به یک شیء (موجودیت یا دی‌تی‌او) نگاشت کنید.
    ابتدا یک کلاس ساده (DTO) برای دریافت خروجی تعریف می‌کنیم:
    public class VelocityAlertDto
    {
        public long CardholderId { get; set; }
        public DateTime Timestamp { get; set; }
        public int TxInLast5Min { get; set; }
    }
    سپس کوئری را به شکل زیر در متد خود اجرا می‌کنید:
    using Microsoft.EntityFrameworkCore;
    
    // تعریف پارامتر برای جلوگیری از SQL Injection
    var thresholdDate = DateTime.UtcNow.AddDays(-30); 
    
    var slidingWindowAlerts = await context.Database
        .SqlQuery<VelocityAlertDto>($"""
            SELECT 
                cardholder_id AS CardholderId, 
                timestamp AS Timestamp, 
                COUNT(*) OVER (
                    PARTITION BY cardholder_id 
                    ORDER BY timestamp 
                    RANGE BETWEEN INTERVAL '5 minutes' PRECEDING AND CURRENT ROW
                ) AS TxInLast5Min
            FROM transactions
            WHERE timestamp >= {thresholdDate}
            QUALIFY TxInLast5Min >= 5
            ORDER BY cardholder_id, timestamp
        """)
        .ToListAsync();
    نکته مهم برای دیتابیس SQL Server: از آنجا که SQL Server از دستور QUALIFY پشتیبانی نمی‌کند، اگر دیتابیس شما SQL Server است، EF Core دستور بالا را معادل لایه بیرونی (CTE) ترجمه خواهد کرد. فرمت بهینه آن در SqlQuery برای SQL Server به این صورت خواهد بود:
    var sqlServerAlerts = await context.Database
        .SqlQuery<VelocityAlertDto>($"""
            WITH RankedTransactions AS (
                SELECT 
                    CardholderId, 
                    Timestamp, 
                    COUNT(*) OVER (
                        PARTITION BY CardholderId 
                        ORDER BY Timestamp 
                        -- تبدیل بازه به فرمت معادل در SQL Server
                        ROWS BETWEEN 4 PRECEDING AND CURRENT ROW 
                    ) AS TxInLast5Min
                FROM Transactions
                WHERE Timestamp >= {thresholdDate}
            )
            SELECT CardholderId, Timestamp, TxInLast5Min 
            FROM RankedTransactions 
            WHERE TxInLast5Min >= 5
        """)
        .ToListAsync();

  • وحید نصیری در ۱۴۰۵/۰۴/۰۲ ۱۰:۱۳
    برای پیاده‌سازی الگوی ۲ (سفر غیرممکن یا Impossible Travel) با استفاده از EF Core، به دلیل استفاده از ویژگی‌های جغرافیایی، دو روش مجزا پیش رو داریم.
    اگر از دیتابیس SQL Server یا PostgreSQL (با افزونه PostGIS) به همراه کتابخانه NetTopologySuite استفاده می‌کنید، EF Core متدهای محاسبه فاصله را مستقیماً به کدهای بهینه SQL ترجمه می‌کند.

    روش اول: پیاده‌سازی با LINQ خالص (نسخه بهینه و مدرن)
    در این روش، برای شبیه‌سازی مکانیزم LAG بدون افت کارایی، از یک Subquery بهینه بهره می‌گیریم. متد .Distance() در کتابخانه NetTopologySuite مسافت بین دو نقطه را بر اساس واحد دیتابیس (معمولاً متر در سیستم‌های مختصاتی استاندارد مانند SRID 4326) محاسبه می‌کند.
    using Microsoft.EntityFrameworkCore;
    
    // تعریف بازه زمانی برای بهینه‌سازی (مثلاً بررسی تراکنش‌های ۴۸ ساعت گذشته)
    DateTime filterStartDate = DateTime.UtcNow.AddDays(-2);
    double maxSpeedMetersPerSecond = 268.0; // معادل ~۶۰۰ مایل بر ساعت یا ۹۶۵ کیلومتر بر ساعت
    
    var impossibleTravelAlerts = await context.Transactions
        .AsNoTracking()
        .Where(t => t.Timestamp >= filterStartDate)
        .Select(current => new
        {
            CurrentTx = current,
            // یافتن دقیقاً اولین تراکنش قبلیِ همین کاربری که تراکنش فعلی را زده است
            PrevTx = context.Transactions
                .Where(p => p.CardholderId == current.CardholderId && p.Timestamp < current.Timestamp)
                .OrderByDescending(p => p.Timestamp)
                .FirstOrDefault()
        })
        // فیلتر کردن مواردی که تراکنش قبلی وجود دارد و موقعیت مکانی تغییر کرده است
        .Where(x => x.PrevTx != null && x.CurrentTx.Location != x.PrevTx.Location)
        .Select(x => new
        {
            x.CurrentTx.CardholderId,
            FirstTxTime = x.PrevTx!.Timestamp,
            SecondTxTime = x.CurrentTx.Timestamp,
            // محاسبه فاصله به متر در سمت دیتابیس
            DistanceInMeters = x.CurrentTx.Location.Distance(x.PrevTx.Location),
            // محاسبه اختلاف زمان به ثانیه
            TimeApartInSeconds = EF.Functions.DateDiffSecond(x.PrevTx.Timestamp, x.CurrentTx.Timestamp)
        })
        // فیلتر ثانویه: بررسی سرعت جابه‌جایی (سرعت = مسافت / زمان)
        .Where(x => x.TimeApartInSeconds > 0 && 
                    (x.DistanceInMeters / x.TimeApartInSeconds) > maxSpeedMetersPerSecond)
        .Select(x => new
        {
            x.CardholderId,
            x.FirstTxTime,
            x.SecondTxTime,
            DistanceInKilometers = x.DistanceInMeters / 1000.0,
            MinutesApart = x.TimeApartInSeconds / 60.0,
            CalculatedSpeedKmh = (x.DistanceInMeters / 1000.0) / (x.TimeApartInSeconds / 3600.0)
        })
        .ToListAsync();

    روش دوم: استفاده ازSqlQuery(برای ساختارهای پیشرفته و انبار داده‌ها)
    اگر ترجیح می‌دهید از توابع بومی دیتابیس یا توابع خاص مانند فرمول سفارشی هاورساین استفاده کنید، ساختار متناظر آن با SqlQuery به شکل زیر خواهد بود.
    ابتدا خروجی مورد نظر را به شکل یک کلاس (DTO) مدل‌سازی می‌کنیم:
    public class ImpossibleTravelAlertDto
    {
        public long CardholderId { get; set; }
        public DateTime FirstTx { get; set; }
        public DateTime SecondTx { get; set; }
        public double MinutesApart { get; set; }
        public double MilesApart { get; set; }
    }
    سپس کوئری فرستاده شده به سمت دیتابیس را به این صورت اجرا می‌کنیم (نمونه استاندارد با استفاده از تابع جغرافیایی SQL Server):
    using Microsoft.EntityFrameworkCore;
    
    DateTime queryThreshold = DateTime.UtcNow.AddDays(-7);
    
    var rawSqlAlerts = await context.Database
        .SqlQuery<ImpossibleTravelAlertDto>($"""
            WITH OrderedTx AS (
                SELECT
                    CardholderId,
                    Timestamp,
                    Location,
                    LAG(Timestamp) OVER (PARTITION BY CardholderId ORDER BY Timestamp) AS PrevTs,
                    LAG(Location)  OVER (PARTITION BY CardholderId ORDER BY Timestamp) AS PrevLoc
                FROM Transactions
                WHERE Timestamp >= {queryThreshold}
            )
            SELECT
                CardholderId,
                PrevTs AS FirstTx,
                Timestamp AS SecondTx,
                DATEDIFF(SECOND, PrevTs, Timestamp) / 60.0 AS MinutesApart,
                -- در SQL Server متد STDistance فاصله را محاسبه می‌کند
                Location.STDistance(PrevLoc) / 1609.344 AS MilesApart -- تبدیل متر به مایل
            FROM OrderedTx
            WHERE PrevTs IS NOT NULL
              AND PrevLoc.STEquals(Location) = 0
              -- شرط سرعت بالای ۶۰۰ مایل بر ساعت
              AND (Location.STDistance(PrevLoc) / 1609.344) / 
                  NULLIF(DATEDIFF(SECOND, PrevTs, Timestamp) / 3600.0, 0) > 600
        """)
        .ToListAsync();
    چند نکته طلایی برای افزایش کارایی (Performance):
    • استفاده از EF.Functions.DateDiffSecond: در روش اول، استفاده از عملیات جبری ساده روی خط زمانی در LINQ همیشه به دستورات بهینه در SQL (مثل DATEDIFF) تبدیل نمی‌شود. متد کمک‌کننده EF.Functions این اطمینان را ایجاد می‌کند که فرآیند کاملاً در سمت دیتابیس هندل شود.
    • بررسی نوع فیلد جغرافیایی: توجه داشته باشید که در SQL Server، نوع داده GEOGRAPHY محاسبات را بر حسب متر انجام می‌دهد در حالی که GEOMETRY بر حسب واحدِ سیستم مختصاتِ نقشه (مختصات مسطح) محاسبه می‌کند. مدل ارائه شده در بخش قبل بر اساس استاندارد کروی (GEOGRAPHY) در نظر گرفته شده است.

  • وحید نصیری در ۱۴۰۵/۰۴/۰۲ ۱۰:۱۴
    برای پیاده‌سازی الگوی ۳ (ناهنجاری در مبالغ تراکنش یا Amount Anomalies) در EF Core، نیاز به اعمال شروط منطقی ترکیبی روی ستون Amount داریم. از آنجا که این الگو مستقیماً روی فیلترهای استاندارد WHERE تمرکز دارد، ترجمه آن به LINQ بسیار تمیز، سرراست و فوق‌العاده بهینه خواهد بود.
    در ادامه، کوئری متناظر LINQ را به همراه بهینه‌سازی‌های لازم برای این الگو مشاهده می‌کنید:
    using Microsoft.EntityFrameworkCore;
    
    // تعریف بازه زمانی برای بالا بردن سرعت کوئری (به عنوان مثال، بررسی تراکنش‌های یک هفته گذشته)
    DateTime scanThresholdDate = DateTime.UtcNow.AddDays(-7);
    
    // لیست مبالغ رند و کوچک جهت شناسایی فرآیند تست کارت (Card Testing)
    var roundCardTestAmounts = new List<decimal> { 1.00m, 5.00m, 10.00m };
    
    var suspiciousAmountAlerts = await context.Transactions
        .AsNoTracking()
        .Where(t => t.Timestamp >= scanThresholdDate)
        .Where(t => 
            // ۱. مبالغ در آستانه سقف‌های روانی یا قانونی (مثلاً زیر ۱۰۰ دلار/یورو)
            (t.Amount >= 99.50m && t.Amount < 100.00m) ||
            
            // ۲. مبالغ در آستانه سقف‌های برداشت روزانه دیتابیس یا خودپرداز (مثلاً زیر ۵۰۰ دلار/یورو)
            (t.Amount >= 499.50m && t.Amount < 500.00m) ||
            
            // ۳. بررسی مبالغ رندِ تست کارت با استفاده از دستور معادل IN در اس‌کیوال
            roundCardTestAmounts.Contains(t.Amount)
        )
        .Select(t => new
        {
            t.CardholderId,
            t.MerchantId,
            t.Amount,
            t.Timestamp,
            // غنی‌سازی خروجی: مشخص کردن نوع شبهه برای تیم تحلیل تقلب
            AnomalyType = roundCardTestAmounts.Contains(t.Amount) 
                ? "Card Testing (Round Amount)" 
                : "Threshold Evading (Just Below Limit)"
        })
        .OrderByDescending(t => t.Timestamp)
        .ToListAsync();
    چند نکته مهم در مورد این کوئری و کارایی آن:
    • پسوند m برای مقادیر مبالغ: از آنجا که ستون Amount در مدل مطلع از ساختار مالی با نوع داده decimal تعریف شده است، حتماً باید در کدهای سی‌شارپ از پسوند m (مانند 99.50m) استفاده کنید تا جلوی خطاهای عدم تطابق نوع داده (Data Type Mismatch) و تبدیل‌های ضمنی (Implicit Conversions) در سمت دیتابیس گرفته شود.
    • استفاده از .Contains(): متد .Contains() روی لیست‌های محلی در LINQ، توسط EF Core به دستور بسیار بهینه IN (...) در SQL ترجمه می‌شود که از نظر پرفورمنس کاملاً استاندارد است.
    • پشتیبانی از ایندکس: این کوئری به طور کامل از ایندکس ترکیبی که در مرحله اول روی جداول ساختیم (IX_Transactions_Cardholder_Timestamp یا ایندکس‌های مشابه بر پایه زمان) استفاده می‌کند؛ چرا که فیلتر اصلی آن روی ستون زمان (Timestamp) اعمال شده و حجم داده‌های ورودی به شروطِ مبلغ را به شدت کاهش می‌دهد.
  • وحید نصیری در ۱۴۰۵/۰۴/۰۲ ۱۰:۱۶
    برای پیاده‌سازی الگوی ۴ (پذیرندگان مشکوک یا Suspicious Merchants) در EF Core، چالش اصلی محاسبه میانگین متحرک یا همان رفتار گذشته پذیرنده (Baseline) و مقایسه آن با بازه زمانی فعلی است.
    همان‌طور که در مقاله اشاره شد، استفاده از عدد ۱۶۸ ساعت (یک هفته کامل) برای سنجش میانگین، نوسانات دوره‌ای بازار را به بهترین شکل خنثی می‌کند. برای این الگو نیز دو روش پیاده‌سازی (LINQ خالص برای حجم داده کنترل‌شده و SqlQuery برای انبار داده‌های بزرگ) ارائه شده است.

    روش اول: پیاده‌سازی با LINQ خالص (رویکرد Subquery)
    در این روش، ابتدا تراکنش‌های ۷ روز گذشته را فیلتر می‌کنیم، سپس برای هر ساعتِ هر پذیرنده، میانگین تعداد کارت‌های منحصربه‌فرد را در کل بازه ۶۰ روزه با یک زیرکوئری (Subquery) محاسبه کرده و در نهایت شرط «۳ برابر بودن» را اعمال می‌کنیم.
    using Microsoft.EntityFrameworkCore;
    
    DateTime now = DateTime.UtcNow;
    DateTime sevenDaysAgo = now.AddDays(-7);
    DateTime sixtyDaysAgo = now.AddDays(-60);
    
    var suspiciousMerchantAlerts = await context.Transactions
        .AsNoTracking()
        // ۱. ابتدا فیلتر بازه زمانی نزدیک برای بررسی جهش (Spike)
        .Where(t => t.Timestamp >= sevenDaysAgo)
        .GroupBy(t => new
        {
            t.MerchantId,
            // دسته‌بندی بر اساس پنجره‌های یک ساعته
            HourBucket = new DateTime(t.Timestamp.Year, t.Timestamp.Month, t.Timestamp.Day, t.Timestamp.Hour, 0, 0)
        })
        .Select(g => new
        {
            g.Key.MerchantId,
            g.Key.HourBucket,
            // تعداد کارت‌های منحصربه‌فرد در این ساعت خاص
            UniqueCardsCount = g.Select(t => t.CardholderId).Distinct().Count(),
            
            // محاسبه بیس‌لاین: میانگین تعداد کاربران منحصربه‌فرد در هر ساعت برای این پذیرنده در ۶۰ روز گذشته
            RollingAvgCards = context.Transactions
                .Where(t => t.MerchantId == g.Key.MerchantId && t.Timestamp >= sixtyDaysAgo && t.Timestamp < sevenDaysAgo)
                .GroupBy(t => new DateTime(t.Timestamp.Year, t.Timestamp.Month, t.Timestamp.Day, t.Timestamp.Hour, 0, 0))
                .Select(subGroup => subGroup.Select(t => t.CardholderId).Distinct().Count())
                // استفاده از Cast برای جلوگیری از خطای تهی بودن خروجی میانگین
                .Average()
        })
        // ۲. فیلتر پذیرنده‌هایی که تعداد کارت‌هایشان بیش از ۳ برابر میانگین خودشان شده است
        .Where(x => x.RollingAvgCards > 0 && x.UniqueCardsCount > (x.RollingAvgCards * 3))
        .Select(x => new
        {
            x.MerchantId,
            x.HourBucket,
            x.UniqueCardsCount,
            x.RollingAvgCards,
            SpikeRatio = (double)x.UniqueCardsCount / x.RollingAvgCards
        })
        .OrderByDescending(x => x.SpikeRatio)
        .ToListAsync();

    روش دوم: استفاده ازSqlQueryو تابع پنجره‌ای (بسیار بهینه برای SQL Server)
    از آنجا که محاسبات گروه‌بندی زمانی و Distinct().Count() متوالی روی داده‌های حجیم مالی سنگین است، استفاده از تابع پنجره‌ای AVG(...) OVER مستقیماً در سمت دیتابیس، پرفورمنس به مراتب بالاتری دارد.
    ابتدا DTO خروجی را تعریف می‌کنیم:
    public class SuspiciousMerchantDto
    {
        public long MerchantId { get; set; }
        public DateTime HourBucket { get; set; }
        public int UniqueCards { get; set; }
        public double RollingAvgCards { get; set; }
        public double SpikeRatio { get; set; }
    }
    سپس کوئری بهینه زیر را از طریق بستر EF Core اجرا می‌کنیم:
    using Microsoft.EntityFrameworkCore;
    
    DateTime historyThreshold = DateTime.UtcNow.AddDays(-60);
    
    var alerts = await context.Database
        .SqlQuery<SuspiciousMerchantDto>($"""
            WITH MerchantHourly AS (
                SELECT
                    MerchantId,
                    DATEADD(hour, DATEDIFF(hour, 0, Timestamp), 0) AS HourBucket,
                    COUNT(DISTINCT CardholderId) AS UniqueCards
                FROM Transactions
                WHERE Timestamp >= {historyThreshold}
                GROUP BY MerchantId, DATEADD(hour, DATEDIFF(hour, 0, Timestamp), 0)
            ),
            WithBaseline AS (
                SELECT
                    *,
                    AVG(CAST(UniqueCards AS FLOAT)) OVER (
                        PARTITION BY MerchantId
                        ORDER BY HourBucket
                        ROWS BETWEEN 168 PRECEDING AND 1 PRECEDING
                    ) AS RollingAvgCards
                FROM MerchantHourly
            )
            SELECT 
                MerchantId,
                HourBucket,
                UniqueCards,
                RollingAvgCards,
                (UniqueCards / NULLIF(RollingAvgCards, 0)) AS SpikeRatio
            FROM WithBaseline
            -- فیلتر جهش‌های بالای ۳ برابر و اعمال محدودیت نمایش برای ۷ روز اخیر در لایه نهایی
            WHERE UniqueCards > (RollingAvgCards * 3)
              AND HourBucket >= DATEADD(day, -7, GETUTCDATE())
            ORDER BY SpikeRatio DESC
        """)
        .ToListAsync();
    نکات کلیدی پرفورمنس برای این الگو:
    • نقش حیاتی ایندکس دوم: این کوئری به شدت وابسته به ایندکس ترکیبی است که در بخش پیکربندی ساختیم: IX_Transactions_Merchant_Timestamp. بدون این ایندکس، اسکن کل جدول تراکنش‌ها دیتابیس را قفل خواهد کرد.
    • برتری روش دوم در تراکنش‌های میلیونی: در پروژه‌های واقعی با بیش از ۵۰۰ هزار تراکنش، روش دوم (SqlQuery) به شدت توصیه می‌شود؛ زیرا مکانیزم ROWS BETWEEN 168 PRECEDING دیتابیس را مأمور می‌کند تا محاسبات را در سطح ردیف‌های پیش‌فرض حافظه موقت (Buffer) انجام دهد، در حالی که روش اول ممکن است باعث تولید کدهای بسیار طولانی Nested Loops در لایه اجرای SQL شود.

  • وحید نصیری در ۱۴۰۵/۰۴/۰۲ ۱۰:۱۷
    برای پیاده‌سازی الگوی ۵ (تراکنش در ساعات غیرمتعارف یا Off-Hours) در EF Core، هدف این است که ابتدا ساعات فعالیت عادی هر کاربر را بر اساس تاریخچه تراکنش‌های او (مثلاً ۹۰ روز گذشته) استخراج کنیم و سپس تراکنش‌های اخیر را با این مرزهای زمانی بسنجیم.
    همان‌طور که در مقاله اصلی اشاره شد، اعمال فیلتر تکرار (حداقل ۲ تراکنش در یک ساعت مشخص) بسیار حیاتی است تا خریدهای اتفاقی نیمه‌شب به عنوان رفتار عادی کاربر ثبت نشوند.

    روش اول: پیاده‌سازی با LINQ خالص (رویکرد ترکیبی بهینه)
    در این روش، با استفاده از قابلیت‌های پیشرفته LINQ، محدوده ساعتِ مجاز هر کاربر را به صورت تفکیک‌شده محاسبه کرده و تراکنش‌های جاری را نسبت به آن فیلتر می‌کنیم.
    using Microsoft.EntityFrameworkCore;
    
    DateTime now = DateTime.UtcNow;
    DateTime ninetyDaysAgo = now.AddDays(-90);
    DateTime checkPeriodStart = now.AddDays(-1); // برای مثال، بررسی تراکنش‌های مشکوک ۲۴ ساعت گذشته
    
    // ۱. استخراج الگوی ساعات نرمال هر کاربر (ساعاتی که حداقل ۲ بار در آن‌ها تراکنش داشته است)
    var cardholderNormalHours = context.Transactions
        .AsNoTracking()
        .Where(t => t.Timestamp >= ninetyDaysAgo)
        .GroupBy(t => new { t.CardholderId, HourOfDay = t.Timestamp.Hour })
        .Where(g => g.Count() >= 2) // فیلتر غنی‌سازی: فقط ساعاتی که به "عادت" تبدیل شده‌اند
        .GroupBy(g => g.Key.CardholderId)
        .Select(g => new
        {
            CardholderId = g.Key,
            EarliestHour = g.Min(x => x.Key.HourOfDay),
            LatestHour = g.Max(x => x.Key.HourOfDay)
        });
    
    // ۲. یافتن تراکنش‌های اخیری که خارج از محدوده ساعت نرمال کاربر رخ داده‌اند
    var offHoursAlerts = await context.Transactions
        .AsNoTracking()
        .Where(t => t.Timestamp >= checkPeriodStart)
        // پیوند تراکنش‌های اخیر با جدول الگوهای ساعتی کاربران
        .Join(cardholderNormalHours,
              t => t.CardholderId,
              n => n.CardholderId,
              (t, n) => new { Transaction = t, NormalPattern = n })
        // شرط اصلی: ساعت تراکنش خارج از بازه [EarliestHour, LatestHour] باشد
        .Where(x => x.Transaction.Timestamp.Hour < x.NormalPattern.EarliestHour || 
                    x.Transaction.Timestamp.Hour > x.NormalPattern.LatestHour)
        .Select(x => new
        {
            x.Transaction.CardholderId,
            x.Transaction.Id,
            x.Transaction.Amount,
            x.Transaction.Timestamp,
            TransactionHour = x.Transaction.Timestamp.Hour,
            AllowedRange = $"{x.NormalPattern.EarliestHour}:00 to {x.NormalPattern.LatestHour}:00"
        })
        .OrderByDescending(x => x.Timestamp)
        .ToListAsync();

    روش دوم: استفاده ازSqlQuery(بسیار بهینه برای تحلیل‌های بچ یا Batch Processing)
    از آنجا که استخراج توابع زمانی مانند EXTRACT(HOUR FROM ...) در LINQ بسته به پرووایدر دیتابیس (SQL Server یا PostgreSQL) امضا و ترجمه‌های متفاوتی در SQL ایجاد می‌کند، استفاده از SqlQuery یکپارچگی و پرفورمنس بالاتری را در حجم داده بالا تضمین می‌کند.
    DTO خروجی را به شکل زیر تعریف می‌کنیم:
    public class OffHoursAlertDto
    {
        public long CardholderId { get; set; }
        public long TransactionId { get; set; }
        public decimal Amount { get; set; }
        public DateTime Timestamp { get; set; }
        public int TransactionHour { get; set; }
    }
    سپس کوئری را در بستر EF Core به این شکل فراخوانی می‌کنیم (نمونه استاندارد برای SQL Server):
    using Microsoft.EntityFrameworkCore;
    
    DateTime historyLimit = DateTime.UtcNow.AddDays(-90);
    DateTime scanLimit = DateTime.UtcNow.AddDays(-1);
    
    var rawSqlAlerts = await context.Database
        .SqlQuery<OffHoursAlertDto>($"""
            WITH CardholderHourPattern AS (
                SELECT
                    CardholderId,
                    DATEPART(HOUR, Timestamp) AS HourOfDay,
                    COUNT(*) AS TxCount
                FROM Transactions
                WHERE Timestamp >= {historyLimit}
                GROUP BY CardholderId, DATEPART(HOUR, Timestamp)
            ),
            CardholderNormal AS (
                SELECT
                    CardholderId,
                    MIN(CASE WHEN TxCount >= 2 THEN HourOfDay END) AS EarliestHour,
                    MAX(CASE WHEN TxCount >= 2 THEN HourOfDay END) AS LatestHour
                FROM CardholderHourPattern
                GROUP BY CardholderId
            )
            SELECT 
                t.Id AS TransactionId,
                t.CardholderId AS CardholderId,
                t.Amount AS Amount,
                t.Timestamp AS Timestamp,
                DATEPART(HOUR, t.Timestamp) AS TransactionHour
            FROM Transactions t
            INNER JOIN CardholderNormal cn ON t.CardholderId = cn.CardholderId
            WHERE t.Timestamp >= {scanLimit}
              AND (DATEPART(HOUR, t.Timestamp) < cn.EarliestHour OR DATEPART(HOUR, t.Timestamp) > cn.LatestHour)
            ORDER BY t.Timestamp DESC
        """)
        .ToListAsync();
    محدودیت فنی و راهکار جایگزین:
    همان‌طور که در بخش قبل بحث شد، این الگوریتم برای حساب‌های جدید (New Accounts) کارایی ندارد؛ زیرا تاریخچه ۹۰ روزه‌ای از آن‌ها در دیتابیس موجود نیست تا بیس‌لاین رفتاری تشکیل شود.
    در سی‌شارپ برای مدیریت این استثنا، می‌توانید در بخش WHERE کوئری اول، کاربرانی که تاریخ افتتاح حساب یا اولین تراکنش آن‌ها کمتر از مثلاً ۳۰ روز پیش بوده است را مستثنی کنید و آن‌ها را با الگوهای عمومی شبکه (مانند تراکنش کلی در ساعات ۱ بامداد تا ۵ صبح) بسنجید.
  • وحید نصیری در ۱۴۰۵/۰۴/۰۲ ۱۰:۱۹
    برای پیاده‌سازی الگوی ۶ (ترکیب سیگنال‌ها یا Chained Signals) با استفاده از EF Core، هدف اصلی این است که ابتدا ویژگی‌های موقتیِ تراکنش (مثل فاصله زمانی با تراکنش قبلی، تغییر پذیرنده و مجموع تراکنش‌های ۲۴ ساعت گذشته) را با استفاده از توابع پنجره‌ای (Window Functions) محاسبه کنیم و سپس فیلترهای نهایی تقلب را روی این ستون‌های محاسباتی اعمال کنیم.
    در دیتابیس‌های بزرگ، ترجمه توابع پنجره‌ای پیشرفته و چندگانه (ترکیب LAG و SUM OVER RANGE در یک کوئری) به LINQ خالص، کدهای بسیار ناخوانایی تولید می‌کند که معمولاً توسط فرستنده دیتابیس (SQL Server/PostgreSQL) به اس‌کیوالِ بهینه ترجمه نمی‌شود و کل داده‌ها را برای محاسبات به حافظه برنامه‌نویسی (Memory) منتقل می‌کند.
    بنابراین، بهترین و حرفه‌ای‌ترین رویکرد در EF Core برای این سناریو، استفاده از SqlQuery است تا بتوان از سرعت و بهینه‌سازی موتور دیتابیس استفاده کرد.

    پیاده‌سازی الگوی ۶ با استفاده از EF Core وSqlQuery
    ابتدا ساختار داده‌ای (DTO) که قرار است خروجیِ محاسبات توابع پنجره‌ای را دریافت کند، تعریف می‌کنیم:
    public class ChainedSignalDto
    {
        public long CardholderId { get; set; }
        public long TransactionId { get; set; }
        public decimal Amount { get; set; }
        public long MerchantId { get; set; }
        public int? TimeSinceLastSeconds { get; set; }
        public string MerchantChange { get; set; } = string.Empty;
        public decimal Running24hTotal { get; set; }
        public long TxOfDay { get; set; }
    }
    سپس با استفاده از بستر EF Core، کوئری ترکیبی را اجرا می‌کنیم. در این کوئری، قوانین تقلب به فیلترهای بسیار ساده در لایه بیرونی (WHERE) تبدیل می‌شوند:
    using Microsoft.EntityFrameworkCore;
    
    // محدود کردن پنجره بررسی اولیه برای حفظ پرفورمنس سرور (مثلاً تراکنش‌های ۳ روز گذشته)
    DateTime analysisThreshold = DateTime.UtcNow.AddDays(-3);
    
    var fraudAlerts = await context.Database
        .SqlQuery<ChainedSignalDto>($"""
            WITH TxWithWindows AS (
                SELECT
                    CardholderId,
                    Id AS TransactionId,
                    Amount,
                    MerchantId,
                    
                    -- ۱. محاسبه فاصله زمانی با تراکنش قبلی بر حسب ثانیه
                    DATEDIFF(SECOND, LAG(Timestamp) OVER (PARTITION BY CardholderId ORDER BY Timestamp), Timestamp) AS TimeSinceLastSeconds,
                    
                    -- ۲. بررسی تغییر پذیرنده نسبت به تراکنش قبلی
                    CASE 
                        WHEN MerchantId <> LAG(MerchantId) OVER (PARTITION BY CardholderId ORDER BY Timestamp) THEN 'changed' 
                        ELSE 'same' 
                    END AS MerchantChange,
                    
                    -- ۳. مجموع تراکنش‌های ۲۴ ساعت گذشته کاربر (شبیه‌سازی لغزان در SQL Server)
                    SUM(Amount) OVER (
                        PARTITION BY CardholderId
                        ORDER BY Timestamp
                        RANGE BETWEEN INTERVAL '24' HOUR PRECEDING AND CURRENT ROW
                    ) AS Running24hTotal,
                    
                    -- ۴. شمارنده تعداد تراکنش‌های امروزِ این کاربر
                    ROW_NUMBER() OVER (
                        PARTITION BY CardholderId, CAST(Timestamp AS DATE)
                        ORDER BY Timestamp
                    ) AS TxOfDay
                FROM Transactions
                WHERE Timestamp >= {analysisThreshold}
            )
            -- اعمال فرضیه تقلب: کارت‌تستینگ با سرعت بالا و مکرر در فروشگاه‌های مختلف
            SELECT *
            FROM TxWithWindows
            WHERE TxOfDay >= 5                       -- حداقل ۵ تراکنش در امروز ثبت شده باشد
              AND TimeSinceLastSeconds < 60          -- فاصله تراکنش فعلی با قبلی کمتر از ۱ دقیقه باشد
              AND MerchantChange = 'changed'        -- پذیرنده فوراً تغییر کرده باشد
            ORDER BY CardholderId, TimeSinceLastSeconds
        """)
        .ToListAsync();

    مزیت معماری این روش در لایه سرویس (Service Layer)
    بزرگ‌ترین مزیت این الگو این است که شما می‌توانید بخش WITH TxWithWindows را به عنوان یک Database View در دیتابیس ذخیره کنید و آن را به یک کلاس در EF Core نگاشت کنید، یا متد آن را طوری بنویسید که فیلترهای خروجی پویا باشند.
    به عنوان مثال، اگر تیم تحلیل تقلب فرضیه جدیدی ارائه داد (مثلاً: «کاربرانی که مجموع تراکنش ۲۴ ساعت گذشته آن‌ها ناگهان از ۱۰ میلیون تومان فراتر رفته و بیش از ۴ تراکنش داشته‌اند»)، دیگر نیازی به تغییر کدهای زیرساختی و توابع پنجره‌ای نیست؛ فقط بخش شرط پایانی تغییر می‌کند:
    // نمونه‌ای از تغییر فرضیه تقلب بدون دستکاری توابع پنجره‌ای بالا:
    var newHypothesisAlerts = fraudAlerts
        .Where(x => x.Running24hTotal > 10000000m && x.TxOfDay > 4)
        .ToList();

    نکات کلیدی کارایی (Performance):
    • فیلتر کردن پیش از پنجره: شرط WHERE Timestamp >= {analysisThreshold} دقیقاً درون بخش CTE قرار گرفته است. این کار باعث می‌شود دیتابیس ابتدا حجم داده‌ها را کاهش دهد و سپس توابع سنگین SUM OVER و LAG را اعمال کند که سرعت اجرای کوئری را تا ۱۰ برابر افزایش می‌دهد.
    • ایندکس ترکیبی: این الگو نیز مانند الگوهای قبلی به طور کامل از ایندکس ترکیبی IX_Transactions_Cardholder_Timestamp بهره می‌برد.