feat: improve sms scan review and detail layouts
This commit is contained in:
@@ -41,6 +41,7 @@ class SubscriptionConverter {
|
||||
currency: model.currency,
|
||||
paymentCardId: model.paymentCardId,
|
||||
paymentCardSuggestion: result.cardSuggestion,
|
||||
rawMessage: result.rawMessage,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,22 @@ class SmsScanner {
|
||||
return [];
|
||||
}
|
||||
|
||||
final filteredSms = smsList
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.where(_isEligibleSubscriptionSms)
|
||||
.toList();
|
||||
|
||||
Log.d(
|
||||
'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}건');
|
||||
|
||||
if (filteredSms.isEmpty) {
|
||||
Log.w('SmsScanner: 결제 패턴 SMS 미검출');
|
||||
return [];
|
||||
}
|
||||
|
||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||
final List<SmsScanResult> subscriptions = [];
|
||||
final serviceGroups = _groupMessagesByIdentifier(smsList);
|
||||
final serviceGroups = _groupMessagesByIdentifier(filteredSms);
|
||||
|
||||
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
|
||||
|
||||
@@ -355,16 +368,80 @@ class SmsScanner {
|
||||
}
|
||||
}
|
||||
|
||||
const List<String> _paymentLikeKeywords = [
|
||||
'승인',
|
||||
'결제',
|
||||
'청구',
|
||||
'charged',
|
||||
'charge',
|
||||
'payment',
|
||||
'billed',
|
||||
'purchase',
|
||||
];
|
||||
|
||||
const List<String> _blockedKeywords = [
|
||||
'otp',
|
||||
'인증',
|
||||
'보안',
|
||||
'verification',
|
||||
'code',
|
||||
'코드',
|
||||
'password',
|
||||
'pw',
|
||||
'일회성',
|
||||
'1회용',
|
||||
'보안문자',
|
||||
];
|
||||
|
||||
bool _containsPaymentKeyword(String message) {
|
||||
if (message.isEmpty) return false;
|
||||
final normalized = message.toLowerCase();
|
||||
return _paymentLikeKeywords.any(
|
||||
(keyword) => normalized.contains(keyword.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
bool _containsBlockedKeyword(String message) {
|
||||
if (message.isEmpty) return false;
|
||||
final normalized = message.toLowerCase();
|
||||
return _blockedKeywords.any(
|
||||
(keyword) => normalized.contains(keyword.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isEligibleSubscriptionSms(Map<String, dynamic> sms) {
|
||||
final amount = (sms['monthlyCost'] as num?)?.toDouble();
|
||||
if (amount == null || amount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final message = sms['message'] as String? ?? '';
|
||||
final isPaymentLike =
|
||||
(sms['isPaymentLike'] as bool?) ?? _containsPaymentKeyword(message);
|
||||
final isBlocked =
|
||||
(sms['isBlocked'] as bool?) ?? _containsBlockedKeyword(message);
|
||||
|
||||
if (!isPaymentLike || isBlocked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== Isolate 오프로딩용 Top-level 파서 =====
|
||||
|
||||
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
||||
List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||
List<Map<String, dynamic>> messages) {
|
||||
final amountPatterns = <RegExp>[
|
||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
||||
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
||||
RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'),
|
||||
RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
|
||||
RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false),
|
||||
RegExp(r'(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|KRW)',
|
||||
caseSensitive: false),
|
||||
RegExp(r'(?:USD|KRW)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)',
|
||||
caseSensitive: false),
|
||||
RegExp(r'(?:결제|승인)[^0-9]{0,12}(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
|
||||
];
|
||||
|
||||
final results = <Map<String, dynamic>>[];
|
||||
@@ -377,6 +454,8 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
|
||||
final serviceName = _isoExtractServiceName(body, sender);
|
||||
final amount = _isoExtractAmount(body, amountPatterns);
|
||||
final isPaymentLike = _containsPaymentKeyword(body);
|
||||
final isBlocked = _containsBlockedKeyword(body);
|
||||
final billingCycle = _isoExtractBillingCycle(body);
|
||||
final nextBillingDate =
|
||||
_isoCalculateNextBillingFromDate(date, billingCycle);
|
||||
@@ -391,6 +470,8 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||
'normalizedBody': normalizedBody,
|
||||
'nextBillingDate': nextBillingDate.toIso8601String(),
|
||||
'previousPaymentDate': date.toIso8601String(),
|
||||
'isPaymentLike': isPaymentLike,
|
||||
'isBlocked': isBlocked,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -557,6 +638,10 @@ _RepeatDetectionResult? _detectRepeatingSubscriptions(
|
||||
|
||||
if (matchedIndices.length < 2) return null;
|
||||
|
||||
final hasValidInterval = matchedPairs.any((pair) =>
|
||||
pair.type == _MatchType.monthly || pair.type == _MatchType.yearly);
|
||||
if (!hasValidInterval) return null;
|
||||
|
||||
final baseIndex = matchedIndices
|
||||
.reduce((value, element) => value < element ? value : element);
|
||||
final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]);
|
||||
|
||||
Reference in New Issue
Block a user