کشف تقلب در تراکنشهای مالی: ۶ الگوی کاربردی با SQL
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۰۲ ۰۹:۵۰
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
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;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) ایجاد کنید تا از ایجاد هشدارهای نادرست جلوگیری شود.
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;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;
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) را پوشش میدهد.
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 بسیار حیاتی است. این فیلتر باعث میشود که اگر کاربر یک بار در گذشته به صورت اتفاقی در نیمهشب خریدی انجام داده باشد، آن ساعت جزو رفتار "نرمال" او تلقی نشود. الگوی نرمال باید بر اساس تکرار شکل بگیرد.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';NULL از مقادیر پیشفرض ساختگی (Sentinel Values) مانند 1900-01-01 یا 9999-12-31 استفاده میشود. هنگام نوشتن دستورات شرطی، ابتدا این قراردادها را در دیتابیس خود بررسی کنید تا تراکنشها از قلم نیفتند.LAG, SUM() OVER) روی حجم دادههای بالا بسیار سنگین هستند. همیشه ابتدا بازه زمانی دادهها را در بخش WHERE محدود کنید و سپس توابع پنجرهای را اعمال کنید. اعمال توابع پنجرهای روی کل تاریخچه دیتابیس بدون فیلتر اولیه، میتواند منابع سرور را به طور کامل تخلیه کند.Cardholder، Merchant و Transaction را تعریف میکنیم.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>();
}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>();
}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!;
}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);
});
}
}Point را به ساختار مطلع از جغرافیای بانک اطلاعاتی (مانند GEOMETRY یا GEOGRAPHY در SQL Server) تبدیل کند، زمان پیکربندی دیتابیس در فایل تنظیمات پروژه (Program.cs یا کانفیک تزریق وابستگی)، قابلیت NetTopologySuite را فعال کنید:builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"),
x => x.UseNetTopologySuite() // فعالسازی قابلیتهای جغرافیایی
));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 کنید.AsNoTracking استفاده کنیم تا حافظه سرور درگیر ردیابی تغییرات نشود.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();COUNT(*) OVER (...)) با بازههای زمانی لغزان (RANGE BETWEEN) به صورت LINQ خالص در EF Core پیچیده است و گاهی اوقات توسط ارائهدهنده دیتابیس (Provider) به SQL بهینه ترجمه نمیشود.FromSqlRaw یا FromSql در EF Core 8/9/10/11 است. این قابلیت به شما اجازه میدهد قدرت و ظرافت کوئری دیتابیس را حفظ کرده و خروجی را مستقیماً به یک شیء (موجودیت یا دیتیاو) نگاشت کنید.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();NetTopologySuite استفاده میکنید، EF Core متدهای محاسبه فاصله را مستقیماً به کدهای بهینه SQL ترجمه میکند.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 به شکل زیر خواهد بود.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; }
}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();EF.Functions.DateDiffSecond: در روش اول، استفاده از عملیات جبری ساده روی خط زمانی در LINQ همیشه به دستورات بهینه در SQL (مثل DATEDIFF) تبدیل نمیشود. متد کمککننده EF.Functions این اطمینان را ایجاد میکند که فرآیند کاملاً در سمت دیتابیس هندل شود.GEOGRAPHY محاسبات را بر حسب متر انجام میدهد در حالی که GEOMETRY بر حسب واحدِ سیستم مختصاتِ نقشه (مختصات مسطح) محاسبه میکند. مدل ارائه شده در بخش قبل بر اساس استاندارد کروی (GEOGRAPHY) در نظر گرفته شده است.Amount داریم. از آنجا که این الگو مستقیماً روی فیلترهای استاندارد WHERE تمرکز دارد، ترجمه آن به 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) اعمال شده و حجم دادههای ورودی به شروطِ مبلغ را به شدت کاهش میدهد.SqlQuery برای انبار دادههای بزرگ) ارائه شده است.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 مستقیماً در سمت دیتابیس، پرفورمنس به مراتب بالاتری دارد.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; }
}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 شود.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 یکپارچگی و پرفورمنس بالاتری را در حجم داده بالا تضمین میکند.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; }
}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();WHERE کوئری اول، کاربرانی که تاریخ افتتاح حساب یا اولین تراکنش آنها کمتر از مثلاً ۳۰ روز پیش بوده است را مستثنی کنید و آنها را با الگوهای عمومی شبکه (مانند تراکنش کلی در ساعات ۱ بامداد تا ۵ صبح) بسنجید.LAG و SUM OVER RANGE در یک کوئری) به LINQ خالص، کدهای بسیار ناخوانایی تولید میکند که معمولاً توسط فرستنده دیتابیس (SQL Server/PostgreSQL) به اسکیوالِ بهینه ترجمه نمیشود و کل دادهها را برای محاسبات به حافظه برنامهنویسی (Memory) منتقل میکند.SqlQuery است تا بتوان از سرعت و بهینهسازی موتور دیتابیس استفاده کرد.SqlQuerypublic 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; }
}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();WITH TxWithWindows را به عنوان یک Database View در دیتابیس ذخیره کنید و آن را به یک کلاس در EF Core نگاشت کنید، یا متد آن را طوری بنویسید که فیلترهای خروجی پویا باشند.// نمونهای از تغییر فرضیه تقلب بدون دستکاری توابع پنجرهای بالا:
var newHypothesisAlerts = fraudAlerts
.Where(x => x.Running24hTotal > 10000000m && x.TxOfDay > 4)
.ToList();WHERE Timestamp >= {analysisThreshold} دقیقاً درون بخش CTE قرار گرفته است. این کار باعث میشود دیتابیس ابتدا حجم دادهها را کاهش دهد و سپس توابع سنگین SUM OVER و LAG را اعمال کند که سرعت اجرای کوئری را تا ۱۰ برابر افزایش میدهد.IX_Transactions_Cardholder_Timestamp بهره میبرد.