Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
+165 -34
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -13,6 +14,16 @@ class LocalDatabase {
static final instance = LocalDatabase._();
static const _secureStorage = FlutterSecureStorage();
@visibleForTesting
static LocalDatabase inMemoryForTesting() {
final database = LocalDatabase._();
database._database = sqlite3.openInMemory();
database._namespace = 'test';
database._migrate();
database._seedDefaults();
return database;
}
Database? _database;
String? _namespace;
@@ -102,9 +113,16 @@ class LocalDatabase {
amount REAL NOT NULL,
note TEXT,
payment_method TEXT,
transfer_direction TEXT,
counterparty TEXT,
source TEXT NOT NULL DEFAULT 'manual',
source_text TEXT,
client_request_id TEXT,
provider TEXT,
provider_transaction_id TEXT,
recognition_occurrence_id TEXT,
evidence_fingerprint TEXT,
recognition_confidence TEXT,
occurred_at TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT,
@@ -165,11 +183,37 @@ class LocalDatabase {
if (!transactionColumns.contains('client_request_id')) {
_db.execute('ALTER TABLE transactions ADD COLUMN client_request_id TEXT');
}
const addedTransactionColumns = <String, String>{
'transfer_direction': 'TEXT',
'counterparty': 'TEXT',
'provider': 'TEXT',
'provider_transaction_id': 'TEXT',
'recognition_occurrence_id': 'TEXT',
'evidence_fingerprint': 'TEXT',
'recognition_confidence': 'TEXT',
};
for (final entry in addedTransactionColumns.entries) {
if (!transactionColumns.contains(entry.key)) {
_db.execute(
'ALTER TABLE transactions ADD COLUMN ${entry.key} ${entry.value}',
);
}
}
_db.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_client_request
ON transactions (client_request_id)
WHERE client_request_id IS NOT NULL
''');
_db.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_occurrence
ON transactions (recognition_occurrence_id)
WHERE recognition_occurrence_id IS NOT NULL
''');
_db.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_provider_tx
ON transactions (provider, provider_transaction_id)
WHERE provider IS NOT NULL AND provider_transaction_id IS NOT NULL
''');
final conflictColumns = _db
.select('PRAGMA table_info(sync_conflicts)')
.map((row) => row['name'])
@@ -659,7 +703,7 @@ class LocalDatabase {
value['categoryName'] ?? '其他',
value['categoryIcon'] ?? 'tag',
value['categoryColor'] ?? 'mint',
value['type'],
_categoryType(value),
DateTime.now().toUtc().toIso8601String(),
],
);
@@ -668,8 +712,11 @@ class LocalDatabase {
'''
INSERT INTO transactions
(id, ledger_id, category_id, type, amount, note, payment_method,
source, source_text, occurred_at, is_deleted, deleted_at, sync_state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'synced', ?)
transfer_direction, counterparty, source, source_text, client_request_id,
provider, provider_transaction_id, recognition_occurrence_id,
evidence_fingerprint, recognition_confidence,
occurred_at, is_deleted, deleted_at, sync_state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'synced', ?)
ON CONFLICT(id) DO UPDATE SET
ledger_id = excluded.ledger_id,
category_id = excluded.category_id,
@@ -677,8 +724,16 @@ class LocalDatabase {
amount = excluded.amount,
note = excluded.note,
payment_method = excluded.payment_method,
transfer_direction = excluded.transfer_direction,
counterparty = excluded.counterparty,
source = excluded.source,
source_text = excluded.source_text,
client_request_id = excluded.client_request_id,
provider = excluded.provider,
provider_transaction_id = excluded.provider_transaction_id,
recognition_occurrence_id = excluded.recognition_occurrence_id,
evidence_fingerprint = excluded.evidence_fingerprint,
recognition_confidence = excluded.recognition_confidence,
occurred_at = excluded.occurred_at,
is_deleted = excluded.is_deleted,
sync_state = 'synced',
@@ -692,8 +747,16 @@ class LocalDatabase {
value['amount'],
value['note'],
value['paymentMethod'],
value['transferDirection'],
value['counterparty'],
value['source'] ?? 'manual',
value['sourceText'],
value['clientRequestId'],
value['provider'],
value['providerTransactionId'],
value['recognitionOccurrenceId'],
value['evidenceFingerprint'],
value['recognitionConfidence'],
DateTime.parse(value['occurredAt'] as String).toUtc().toIso8601String(),
value['isDeleted'] == true ? 1 : 0,
value['updatedAt'] ?? DateTime.now().toUtc().toIso8601String(),
@@ -902,25 +965,20 @@ class LocalDatabase {
}
Map<String, dynamic> createTransaction(Map<String, dynamic> value) {
final clientRequestId = value['clientRequestId'] as String?;
if (clientRequestId != null && clientRequestId.isNotEmpty) {
final existing = _db.select(
'SELECT id FROM transactions WHERE client_request_id = ? LIMIT 1',
[clientRequestId],
);
if (existing.isNotEmpty) {
return transaction(
(existing.first['id'] as num).toInt(),
includeDeleted: true,
)!;
}
final existingId = _existingTransactionId(value);
if (existingId != null) {
return transaction(existingId, includeDeleted: true)!;
}
final clientRequestId = _identity(value['clientRequestId']);
final occurrenceId = _identity(value['recognitionOccurrenceId']);
final provider = _identity(value['provider']);
final providerTransactionId = _identity(value['providerTransactionId']);
final categoryId = (value['categoryId'] as num).toInt();
final category = _db.select(
'SELECT * FROM categories WHERE id = ? AND is_deleted = 0',
[categoryId],
);
if (category.isEmpty || category.first['type'] != value['type']) {
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
throw StateError('分类与收支类型不一致');
}
final id = _nextNegativeId('transactions');
@@ -930,8 +988,11 @@ class LocalDatabase {
'''
INSERT INTO transactions
(id, ledger_id, category_id, type, amount, note, payment_method,
source, source_text, client_request_id, occurred_at, is_deleted, sync_state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'local', ?)
transfer_direction, counterparty, source, source_text, client_request_id,
provider, provider_transaction_id, recognition_occurrence_id,
evidence_fingerprint, recognition_confidence,
occurred_at, is_deleted, sync_state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'local', ?)
''',
[
id,
@@ -941,9 +1002,16 @@ class LocalDatabase {
value['amount'],
value['note'],
value['paymentMethod'],
value['transferDirection'],
value['counterparty'],
value['source'] ?? 'manual',
value['sourceText'],
clientRequestId,
provider,
providerTransactionId,
occurrenceId,
value['evidenceFingerprint'],
value['recognitionConfidence'],
occurredAt,
now,
],
@@ -959,14 +1027,7 @@ class LocalDatabase {
try {
final created = <Map<String, dynamic>>[];
for (final value in values) {
final clientRequestId = value['clientRequestId'] as String?;
final existed =
clientRequestId != null &&
clientRequestId.isNotEmpty &&
_db.select(
'SELECT 1 FROM transactions WHERE client_request_id = ? LIMIT 1',
[clientRequestId],
).isNotEmpty;
final existed = _existingTransactionId(value) != null;
final transaction = createTransaction(value);
created.add(transaction);
final id = (transaction['id'] as num).toInt();
@@ -1000,13 +1061,14 @@ class LocalDatabase {
'SELECT type FROM categories WHERE id = ? AND is_deleted = 0',
[value['categoryId']],
);
if (category.isEmpty || category.first['type'] != value['type']) {
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
throw StateError('分类与收支类型不一致');
}
_db.execute(
'''
UPDATE transactions SET ledger_id = ?, category_id = ?, type = ?,
amount = ?, note = ?, payment_method = ?, occurred_at = ?,
amount = ?, note = ?, payment_method = ?, transfer_direction = ?,
counterparty = ?, occurred_at = ?,
sync_state = 'local', updated_at = ? WHERE id = ?
''',
[
@@ -1016,6 +1078,8 @@ class LocalDatabase {
value['amount'],
value['note'],
value['paymentMethod'],
value['transferDirection'],
value['counterparty'],
value['occurredAt'],
DateTime.now().toUtc().toIso8601String(),
id,
@@ -1140,7 +1204,7 @@ class LocalDatabase {
ShanghaiTime.civilToUtc(start),
ShanghaiTime.civilToUtc(end),
);
final expenses = items.where((item) => item['type'] == 'expense').toList();
final expenses = items.where(_isExpense).toList();
final expense = _sumType(items, 'expense');
final income = _sumType(items, 'income');
final categories = <int, Map<String, dynamic>>{};
@@ -1236,7 +1300,7 @@ class LocalDatabase {
ShanghaiTime.civilToUtc(start),
ShanghaiTime.civilToUtc(end),
);
final expenses = items.where((item) => item['type'] == 'expense').toList();
final expenses = items.where(_isExpense).toList();
final grouped = <DateTime, List<Map<String, dynamic>>>{};
for (final item in expenses) {
final local = ShanghaiTime.parseCivil(item['occurredAt'] as String);
@@ -1331,13 +1395,15 @@ class LocalDatabase {
final keyword = query?.trim().toLowerCase();
if (keyword != null && keyword.isNotEmpty) {
final haystack =
'${item['note'] ?? ''} ${item['categoryName']} ${item['sourceText'] ?? ''}'
'${item['note'] ?? ''} ${item['counterparty'] ?? ''} '
'${item['categoryName']} ${item['sourceText'] ?? ''}'
.toLowerCase();
if (!haystack.contains(keyword)) return false;
}
if (aiOnly && item['source'] == 'manual') return false;
if (categoryId != null && item['categoryId'] != categoryId)
if (categoryId != null && item['categoryId'] != categoryId) {
return false;
}
if (type != null && item['type'] != type) return false;
if (minAmount != null && amount < minAmount) return false;
if (maxAmount != null && amount > maxAmount) return false;
@@ -1390,7 +1456,7 @@ class LocalDatabase {
final spent = monthItems
.where(
(item) =>
item['type'] == 'expense' &&
_isExpense(item) &&
(categoryKey == 0 || item['categoryId'] == categoryKey),
)
.fold<double>(
@@ -1526,18 +1592,83 @@ class LocalDatabase {
'amount': (row['amount'] as num).toDouble(),
'note': row['note'] as String?,
'paymentMethod': row['payment_method'] as String?,
'transferDirection': row['transfer_direction'] as String?,
'counterparty': row['counterparty'] as String?,
'source': row['source'] as String,
'sourceText': row['source_text'] as String?,
'clientRequestId': row['client_request_id'] as String?,
'provider': row['provider'] as String?,
'providerTransactionId': row['provider_transaction_id'] as String?,
'recognitionOccurrenceId': row['recognition_occurrence_id'] as String?,
'evidenceFingerprint': row['evidence_fingerprint'] as String?,
'recognitionConfidence': row['recognition_confidence'] as String?,
'occurredAt': row['occurred_at'] as String,
'isDeleted': (row['is_deleted'] as num).toInt() == 1,
'updatedAt': row['updated_at'] as String,
};
double _sumType(List<Map<String, dynamic>> items, String type) => items
.where((item) => item['type'] == type)
.where((item) => type == 'income' ? _isIncome(item) : _isExpense(item))
.fold<double>(0, (sum, item) => sum + (item['amount'] as num).toDouble());
bool _isIncome(Map<String, dynamic> item) =>
item['type'] == 'income' ||
item['type'] == 'transfer' && item['transferDirection'] == 'in';
bool _isExpense(Map<String, dynamic> item) =>
item['type'] == 'expense' ||
item['type'] == 'transfer' && item['transferDirection'] == 'out';
String _categoryType(Map<String, dynamic> value) {
final type = value['type'] as String;
final direction = value['transferDirection'] as String?;
if (type == 'transfer') {
if (direction == 'in') return 'income';
if (direction == 'out') return 'expense';
throw StateError('转账必须选择转入或转出');
}
if (direction != null && direction.isNotEmpty) {
throw StateError('非转账账单不能设置转账方向');
}
return type;
}
int? _existingTransactionId(Map<String, dynamic> value) {
final provider = _identity(value['provider']);
final providerTransactionId = _identity(value['providerTransactionId']);
if (provider != null && providerTransactionId != null) {
final rows = _db.select(
'SELECT id FROM transactions '
'WHERE provider = ? AND provider_transaction_id = ? LIMIT 1',
[provider, providerTransactionId],
);
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
}
final occurrenceId = _identity(value['recognitionOccurrenceId']);
if (occurrenceId != null) {
final rows = _db.select(
'SELECT id FROM transactions '
'WHERE recognition_occurrence_id = ? LIMIT 1',
[occurrenceId],
);
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
}
final clientRequestId = _identity(value['clientRequestId']);
if (clientRequestId != null) {
final rows = _db.select(
'SELECT id FROM transactions WHERE client_request_id = ? LIMIT 1',
[clientRequestId],
);
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
}
return null;
}
String? _identity(Object? value) {
final normalized = value?.toString().trim();
return normalized == null || normalized.isEmpty ? null : normalized;
}
String _monthDay(DateTime date) =>
'${date.month.toString().padLeft(2, '0')}${date.day.toString().padLeft(2, '0')}';
}