import 'dart:convert'; import 'dart:io'; 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'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; import 'package:sqlite3/sqlite3.dart'; class LocalDatabase { 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; String? get namespace => _namespace; Future namespaceExists(String namespace) async { final safeNamespace = namespace.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_'); final directory = await getApplicationSupportDirectory(); return File(p.join(directory.path, 'jizhi_$safeNamespace.db')).exists(); } Database get _db { final database = _database; if (database == null) throw StateError('本地数据库尚未初始化'); return database; } Future openNamespace(String namespace) async { if (_namespace == namespace && _database != null) return; close(); final safeNamespace = namespace.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_'); final directory = await getApplicationSupportDirectory(); final databasePath = p.join(directory.path, 'jizhi_$safeNamespace.db'); final keyName = 'local_db_key_$safeNamespace'; var key = await _secureStorage.read(key: keyName); if (key == null) { final random = Random.secure(); key = List.generate( 32, (_) => random.nextInt(256), ).map((value) => value.toRadixString(16).padLeft(2, '0')).join(); await _secureStorage.write(key: keyName, value: key); } final database = sqlite3.open(databasePath); try { database.execute("PRAGMA key = \"x'$key'\""); database.execute('PRAGMA foreign_keys = ON'); database.execute('PRAGMA journal_mode = WAL'); database.execute('PRAGMA cipher_memory_security = ON'); database.select('SELECT count(*) FROM sqlite_master'); _database = database; _namespace = namespace; _migrate(); if (namespace == 'guest') _seedDefaults(); } catch (_) { database.close(); rethrow; } } void close() { _database?.close(); _database = null; _namespace = null; } void _migrate() { _db.execute(''' CREATE TABLE IF NOT EXISTS local_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) '''); _db.execute(''' CREATE TABLE IF NOT EXISTS ledgers ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, icon_key TEXT NOT NULL DEFAULT 'wallet', is_default INTEGER NOT NULL DEFAULT 0, transaction_count INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL ) '''); _db.execute(''' CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, icon_key TEXT NOT NULL, color_key TEXT NOT NULL DEFAULT 'mint', type TEXT NOT NULL, sort_order INTEGER NOT NULL, is_custom INTEGER NOT NULL DEFAULT 0, is_deleted INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL ) '''); _db.execute(''' CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY, ledger_id INTEGER NOT NULL, category_id INTEGER NOT NULL, type TEXT NOT NULL, 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, sync_state TEXT NOT NULL DEFAULT 'local', updated_at TEXT NOT NULL ) '''); _db.execute(''' CREATE INDEX IF NOT EXISTS ix_local_transactions_period ON transactions (ledger_id, occurred_at) '''); _db.execute(''' CREATE TABLE IF NOT EXISTS budgets ( ledger_id INTEGER NOT NULL, period INTEGER NOT NULL, category_key INTEGER NOT NULL, amount REAL NOT NULL, is_recurring INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY (ledger_id, period, category_key) ) '''); _db.execute(''' CREATE TABLE IF NOT EXISTS sync_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT NOT NULL, entity_id INTEGER NOT NULL, operation TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL, retry_count INTEGER NOT NULL DEFAULT 0 ) '''); _db.execute(''' CREATE TABLE IF NOT EXISTS sync_conflicts ( id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT NOT NULL, entity_id INTEGER NOT NULL, operation TEXT NOT NULL DEFAULT 'update', local_payload TEXT NOT NULL, remote_payload TEXT NOT NULL, created_at TEXT NOT NULL, resolved_at TEXT ) '''); _db.execute(''' CREATE TABLE IF NOT EXISTS local_id_map ( entity_type TEXT NOT NULL, local_id INTEGER NOT NULL, remote_id INTEGER NOT NULL, PRIMARY KEY (entity_type, local_id) ) '''); final transactionColumns = _db .select('PRAGMA table_info(transactions)') .map((row) => row['name']) .toSet(); if (!transactionColumns.contains('client_request_id')) { _db.execute('ALTER TABLE transactions ADD COLUMN client_request_id TEXT'); } const addedTransactionColumns = { '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']) .toSet(); if (!conflictColumns.contains('operation')) { _db.execute( "ALTER TABLE sync_conflicts ADD COLUMN operation TEXT NOT NULL DEFAULT 'update'", ); } final recognitionTimestampRepaired = _db.select( "SELECT 1 FROM local_meta WHERE key = 'recognition_timestamp_repair_v1'", ); if (recognitionTimestampRepaired.isEmpty) { _db.execute("""UPDATE transactions SET occurred_at = updated_at WHERE source IN ('accessibility', 'notification', 'recognition_ai') AND occurred_at < '2000-01-01T00:00:00Z'"""); _db.execute("""INSERT OR REPLACE INTO local_meta (key, value) VALUES ('recognition_timestamp_repair_v1', 'done')"""); } } void _seedDefaults() { final now = DateTime.now().toUtc().toIso8601String(); _db.execute( '''INSERT OR IGNORE INTO ledgers (id, name, icon_key, is_default, transaction_count, updated_at) VALUES (1, '日常账本', 'wallet', 1, 0, ?)''', [now], ); const defaults = <(int, String, String, String, String, int)>[ (1, '餐饮', 'food', 'coral', 'expense', 10), (2, '饮品', 'drink', 'cyan', 'expense', 20), (3, '购物', 'cart', 'blue', 'expense', 30), (4, '交通', 'metro', 'teal', 'expense', 40), (5, '住房', 'house', 'sand', 'expense', 50), (6, '娱乐', 'game', 'violet', 'expense', 60), (7, '医疗', 'medical', 'red', 'expense', 70), (8, '学习', 'book', 'indigo', 'expense', 80), (9, '服饰', 'clothes', 'plum', 'expense', 90), (10, '人情', 'gift', 'rose', 'expense', 100), (11, '旅行', 'travel', 'sky', 'expense', 110), (12, '其他', 'tag', 'graphite', 'expense', 120), (101, '工资', 'money', 'mint', 'income', 10), (102, '兼职', 'parttime', 'forest', 'income', 20), (103, '理财', 'invest', 'navy', 'income', 30), (104, '红包', 'redpacket', 'orange', 'income', 40), (105, '报销', 'reimburse', 'amber', 'income', 50), (106, '奖金', 'bonus', 'aqua', 'income', 60), (107, '其他', 'tag', 'lime', 'income', 70), ]; final statement = _db.prepare(''' INSERT OR IGNORE INTO categories (id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?) '''); try { for (final item in defaults) { statement.execute([ item.$1, item.$2, item.$3, item.$4, item.$5, item.$6, now, ]); } } finally { statement.close(); } } Map guestSnapshot() { final customCategories = _db .select(''' SELECT * FROM categories WHERE is_custom = 1 AND is_deleted = 0 ORDER BY type, sort_order ''') .map(_categoryMap) .toList(); final transactions = _db .select(''' SELECT t.*, c.name AS category_name, c.icon_key AS category_icon, c.color_key AS category_color FROM transactions t JOIN categories c ON c.id = t.category_id WHERE t.is_deleted = 0 ORDER BY t.occurred_at, t.id ''') .map(_transactionMap) .toList(); final budgets = _db .select('SELECT * FROM budgets ORDER BY period, category_key') .map( (row) => { 'period': (row['period'] as num).toInt(), 'categoryId': (row['category_key'] as num).toInt() == 0 ? null : (row['category_key'] as num).toInt(), 'amount': (row['amount'] as num).toDouble(), 'recurring': (row['is_recurring'] as num).toInt() == 1, }, ) .toList(); return { 'hasData': transactions.isNotEmpty || customCategories.isNotEmpty || budgets.isNotEmpty || ledgers().length > 1, 'categories': customCategories, 'transactions': transactions, 'budgets': budgets, }; } Map exportSnapshot() { final ledgers = _db .select('SELECT * FROM ledgers ORDER BY is_default DESC, id') .map( (row) => { 'id': (row['id'] as num).toInt(), 'name': row['name'] as String, 'iconKey': row['icon_key'] as String, 'isDefault': (row['is_default'] as num).toInt() == 1, 'updatedAt': row['updated_at'] as String, }, ) .toList(); final categories = _db .select('SELECT * FROM categories ORDER BY type, sort_order, id') .map( (row) => { ..._categoryMap(row), 'isDeleted': (row['is_deleted'] as num).toInt() == 1, 'updatedAt': row['updated_at'] as String, }, ) .toList(); final transactions = _db .select(''' SELECT t.*, c.name AS category_name, c.icon_key AS category_icon, c.color_key AS category_color FROM transactions t JOIN categories c ON c.id = t.category_id ORDER BY t.occurred_at, t.id ''') .map( (row) => { ..._transactionMap(row), 'deletedAt': row['deleted_at'] as String?, }, ) .toList(); final budgets = _db .select( 'SELECT * FROM budgets ORDER BY period, ledger_id, category_key', ) .map( (row) => { 'ledgerId': (row['ledger_id'] as num).toInt(), 'period': (row['period'] as num).toInt(), 'categoryId': (row['category_key'] as num).toInt() == 0 ? null : (row['category_key'] as num).toInt(), 'amount': (row['amount'] as num).toDouble(), 'recurring': (row['is_recurring'] as num).toInt() == 1, 'updatedAt': row['updated_at'] as String, }, ) .toList(); return { 'schemaVersion': 1, 'exportedAt': DateTime.now().toUtc().toIso8601String(), 'ledgers': ledgers, 'categories': categories, 'transactions': transactions, 'budgets': budgets, }; } List> ledgers() { final counts = {}; for (final row in _db.select(''' SELECT ledger_id, COUNT(*) AS tx_count FROM transactions WHERE is_deleted = 0 GROUP BY ledger_id ''')) { counts[(row['ledger_id'] as num).toInt()] = (row['tx_count'] as num) .toInt(); } return _db .select('SELECT * FROM ledgers ORDER BY is_default DESC, id') .map( (row) => { 'id': (row['id'] as num).toInt(), 'name': row['name'] as String, 'iconKey': row['icon_key'] as String, 'isDefault': (row['is_default'] as num).toInt() == 1, 'txCount': counts[(row['id'] as num).toInt()] ?? 0, }, ) .toList(); } void cacheLedgers(List values) { final now = DateTime.now().toUtc().toIso8601String(); _db.execute('BEGIN'); try { final incomingIds = values .map( (value) => ((value as Map)['id'] as num).toInt(), ) .toSet(); for (final row in _db.select('SELECT id FROM ledgers WHERE id > 0')) { final id = (row['id'] as num).toInt(); if (incomingIds.contains(id)) continue; final references = _db .select( ''' SELECT (SELECT COUNT(*) FROM transactions WHERE ledger_id = ?) + (SELECT COUNT(*) FROM budgets WHERE ledger_id = ?) + (SELECT COUNT(*) FROM sync_queue WHERE entity_type = 'ledger' AND entity_id = ?) AS value ''', [id, id, id], ) .first['value'] as int; if (references == 0) { _db.execute('DELETE FROM ledgers WHERE id = ?', [id]); } } for (final value in values) { final item = value as Map; _db.execute( ''' INSERT INTO ledgers (id, name, icon_key, is_default, transaction_count, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, icon_key = excluded.icon_key, is_default = excluded.is_default, transaction_count = excluded.transaction_count, updated_at = excluded.updated_at ''', [ item['id'], item['name'], item['iconKey'] ?? 'wallet', item['isDefault'] == true ? 1 : 0, item['txCount'] ?? 0, now, ], ); } _db.execute('COMMIT'); } catch (_) { _db.execute('ROLLBACK'); rethrow; } } int createLedger(String name) { final id = _nextNegativeId('ledgers'); _db.execute( ''' INSERT INTO ledgers (id, name, icon_key, is_default, transaction_count, updated_at) VALUES (?, ?, 'wallet', 0, 0, ?) ''', [id, name, DateTime.now().toUtc().toIso8601String()], ); return id; } void selectLedger(int id) { _db.execute('UPDATE ledgers SET is_default = 0'); _db.execute('UPDATE ledgers SET is_default = 1 WHERE id = ?', [id]); } void renameLedger(int id, String name, String iconKey) { _db.execute( 'UPDATE ledgers SET name = ?, icon_key = ?, updated_at = ? WHERE id = ?', [name, iconKey, DateTime.now().toUtc().toIso8601String(), id], ); } void deleteLedger(int id) { final count = _db.select( 'SELECT COUNT(*) AS value FROM transactions WHERE ledger_id = ?', [id], ).first['value'] as int; final budgetCount = _db.select( 'SELECT COUNT(*) AS value FROM budgets WHERE ledger_id = ?', [id], ).first['value'] as int; if (count > 0 || budgetCount > 0) { throw StateError('仅可删除没有账单和预算的账本'); } _db.execute('DELETE FROM ledgers WHERE id = ? AND is_default = 0', [id]); } List> categories(String type) => _db .select( '''SELECT * FROM categories WHERE type = ? AND is_deleted = 0 ORDER BY is_custom, sort_order, id''', [type], ) .map(_categoryMap) .toList(); void cacheCategories(List values, {String? replaceType}) { final now = DateTime.now().toUtc().toIso8601String(); if (replaceType != null) { final incomingIds = values .map( (value) => ((value as Map)['id'] as num).toInt(), ) .toSet(); for (final row in _db.select( 'SELECT id FROM categories WHERE id > 0 AND type = ?', [replaceType], )) { final id = (row['id'] as num).toInt(); if (!incomingIds.contains(id)) { _db.execute( 'UPDATE categories SET is_deleted = 1, updated_at = ? WHERE id = ?', [now, id], ); } } } for (final value in values) { final item = value as Map; _db.execute( ''' INSERT INTO categories (id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, icon_key = excluded.icon_key, color_key = excluded.color_key, type = excluded.type, sort_order = excluded.sort_order, is_custom = excluded.is_custom, is_deleted = 0, updated_at = excluded.updated_at ''', [ item['id'], item['name'], item['iconKey'] ?? 'tag', item['colorKey'] ?? 'mint', item['type'], item['sortOrder'] ?? 0, item['isCustom'] == true ? 1 : 0, now, ], ); } } Map createCategory( String name, String iconKey, String colorKey, String type, ) { final id = _nextNegativeId('categories'); final sortOrder = (_db.select( 'SELECT MAX(sort_order) AS value FROM categories WHERE type = ?', [type], ).first['value'] as num?) ?.toInt() ?? 0; _db.execute( ''' INSERT INTO categories (id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?) ''', [ id, name, iconKey, colorKey, type, sortOrder + 10, DateTime.now().toUtc().toIso8601String(), ], ); return _categoryMap( _db.select('SELECT * FROM categories WHERE id = ?', [id]).first, ); } Map updateCategory( int id, String name, String iconKey, String colorKey, int sortOrder, ) { _db.execute( ''' UPDATE categories SET name = ?, icon_key = ?, color_key = ?, sort_order = ?, updated_at = ? WHERE id = ? AND is_custom = 1 ''', [ name, iconKey, colorKey, sortOrder, DateTime.now().toUtc().toIso8601String(), id, ], ); return _categoryMap( _db.select('SELECT * FROM categories WHERE id = ?', [id]).first, ); } void reorderCategories(String type, List ids) { _db.execute('BEGIN'); try { for (var index = 0; index < ids.length; index++) { _db.execute( 'UPDATE categories SET sort_order = ? WHERE id = ? AND type = ? AND is_custom = 1', [(index + 1) * 10, ids[index], type], ); } _db.execute('COMMIT'); } catch (_) { _db.execute('ROLLBACK'); rethrow; } } void deleteCategory(int id) { _db.execute( 'UPDATE categories SET is_deleted = 1, updated_at = ? WHERE id = ? AND is_custom = 1', [DateTime.now().toUtc().toIso8601String(), id], ); } void cacheMonthSummary(Map summary) { for (final day in summary['days'] as List? ?? const []) { final group = day as Map; for (final value in group['items'] as List? ?? const []) { cacheTransaction(value as Map); } } } void cacheTransactions(Iterable values) { for (final value in values) { cacheTransaction(value as Map); } } void cacheTransaction(Map value) { final categoryId = (value['categoryId'] as num).toInt(); final categoryExists = _db.select('SELECT 1 FROM categories WHERE id = ?', [ categoryId, ]).isNotEmpty; if (!categoryExists) { _db.execute( ''' INSERT INTO categories (id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at) VALUES (?, ?, ?, ?, ?, 999, 0, 0, ?) ''', [ categoryId, value['categoryName'] ?? '其他', value['categoryIcon'] ?? 'tag', value['categoryColor'] ?? 'mint', _categoryType(value), DateTime.now().toUtc().toIso8601String(), ], ); } _db.execute( ''' INSERT INTO transactions (id, ledger_id, category_id, type, amount, note, payment_method, 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, type = excluded.type, 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', updated_at = excluded.updated_at ''', [ value['id'], value['ledgerId'], categoryId, value['type'], 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(), ], ); } void replaceLocalTransaction(int localId, Map remote) { final remoteId = (remote['id'] as num).toInt(); _db.execute('BEGIN'); try { cacheTransaction(remote); _saveIdMap('transaction', localId, remoteId); _db.execute('DELETE FROM transactions WHERE id = ?', [localId]); _db.execute('COMMIT'); } catch (_) { _db.execute('ROLLBACK'); rethrow; } } void enqueueSync( String entityType, int entityId, String operation, Map payload, ) { _db.execute( ''' INSERT INTO sync_queue (entity_type, entity_id, operation, payload, created_at, retry_count) VALUES (?, ?, ?, ?, ?, 0) ''', [ entityType, entityId, operation, jsonEncode(payload), DateTime.now().toUtc().toIso8601String(), ], ); } int? remoteId(String entityType, int localId) { if (localId >= 0) return localId; final rows = _db.select( 'SELECT remote_id FROM local_id_map WHERE entity_type = ? AND local_id = ?', [entityType, localId], ); return rows.isEmpty ? null : (rows.first['remote_id'] as num).toInt(); } void remapLedger(int localId, Map remote) { final newId = (remote['id'] as num).toInt(); _db.execute('BEGIN'); try { _db.execute( ''' INSERT OR REPLACE INTO ledgers (id, name, icon_key, is_default, transaction_count, updated_at) SELECT ?, ?, ?, is_default, transaction_count, ? FROM ledgers WHERE id = ? ''', [ newId, remote['name'], remote['iconKey'] ?? 'wallet', DateTime.now().toUtc().toIso8601String(), localId, ], ); _db.execute('UPDATE transactions SET ledger_id = ? WHERE ledger_id = ?', [ newId, localId, ]); _db.execute('UPDATE budgets SET ledger_id = ? WHERE ledger_id = ?', [ newId, localId, ]); _db.execute('DELETE FROM ledgers WHERE id = ?', [localId]); _saveIdMap('ledger', localId, newId); _db.execute('COMMIT'); } catch (_) { _db.execute('ROLLBACK'); rethrow; } } void remapCategory(int localId, Map remote) { final newId = (remote['id'] as num).toInt(); _db.execute('BEGIN'); try { _db.execute( ''' INSERT OR REPLACE INTO categories (id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at) SELECT ?, ?, ?, ?, type, sort_order, 1, 0, ? FROM categories WHERE id = ? ''', [ newId, remote['name'], remote['iconKey'] ?? 'tag', remote['colorKey'] ?? 'mint', DateTime.now().toUtc().toIso8601String(), localId, ], ); _db.execute( 'UPDATE transactions SET category_id = ? WHERE category_id = ?', [newId, localId], ); _db.execute( 'UPDATE budgets SET category_key = ? WHERE category_key = ?', [newId, localId], ); _db.execute('DELETE FROM categories WHERE id = ?', [localId]); _saveIdMap('category', localId, newId); _db.execute('COMMIT'); } catch (_) { _db.execute('ROLLBACK'); rethrow; } } void _saveIdMap(String entityType, int localId, int remoteId) { _db.execute( ''' INSERT INTO local_id_map (entity_type, local_id, remote_id) VALUES (?, ?, ?) ON CONFLICT(entity_type, local_id) DO UPDATE SET remote_id = excluded.remote_id ''', [entityType, localId, remoteId], ); } List> pendingSync() => _db .select('SELECT * FROM sync_queue ORDER BY id') .map( (row) => { 'id': (row['id'] as num).toInt(), 'entityType': row['entity_type'] as String, 'entityId': (row['entity_id'] as num).toInt(), 'operation': row['operation'] as String, 'payload': jsonDecode(row['payload'] as String) as Map, 'retryCount': (row['retry_count'] as num).toInt(), }, ) .toList(); void completeSync(int queueId) { _db.execute('DELETE FROM sync_queue WHERE id = ?', [queueId]); } void failSync(int queueId) { _db.execute( 'UPDATE sync_queue SET retry_count = retry_count + 1 WHERE id = ?', [queueId], ); } void addConflict( String entityType, int entityId, String operation, Map local, Map remote, ) { _db.execute( ''' INSERT INTO sync_conflicts (entity_type, entity_id, operation, local_payload, remote_payload, created_at) VALUES (?, ?, ?, ?, ?, ?) ''', [ entityType, entityId, operation, jsonEncode(local), jsonEncode(remote), DateTime.now().toUtc().toIso8601String(), ], ); } List> conflicts() => _db .select( 'SELECT * FROM sync_conflicts WHERE resolved_at IS NULL ORDER BY id DESC', ) .map( (row) => { 'id': (row['id'] as num).toInt(), 'entityType': row['entity_type'] as String, 'entityId': (row['entity_id'] as num).toInt(), 'operation': row['operation'] as String, 'local': jsonDecode(row['local_payload'] as String) as Map, 'remote': jsonDecode(row['remote_payload'] as String) as Map, 'createdAt': row['created_at'] as String, }, ) .toList(); void resolveConflict(int id) { _db.execute('UPDATE sync_conflicts SET resolved_at = ? WHERE id = ?', [ DateTime.now().toUtc().toIso8601String(), id, ]); } Map createTransaction(Map value) { 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'] != _categoryType(value)) { throw StateError('分类与收支类型不一致'); } final id = _nextNegativeId('transactions'); final now = DateTime.now().toUtc().toIso8601String(); final occurredAt = (value['occurredAt'] as String?) ?? now; _db.execute( ''' INSERT INTO transactions (id, ledger_id, category_id, type, amount, note, payment_method, 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, value['ledgerId'] ?? 1, categoryId, value['type'], 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, ], ); return transaction(id, includeDeleted: true)!; } List> createTransactionsBatch( List> values, { bool enqueueSyncChanges = false, }) { _db.execute('BEGIN'); try { final created = >[]; for (final value in values) { final existed = _existingTransactionId(value) != null; final transaction = createTransaction(value); created.add(transaction); final id = (transaction['id'] as num).toInt(); if (enqueueSyncChanges && id < 0 && !existed) { enqueueSync('transaction', id, 'create', value); } } _db.execute('COMMIT'); return created; } catch (_) { _db.execute('ROLLBACK'); rethrow; } } Map? transaction(int id, {bool includeDeleted = false}) { final rows = _db.select( ''' SELECT t.*, c.name AS category_name, c.icon_key AS category_icon, c.color_key AS category_color FROM transactions t JOIN categories c ON c.id = t.category_id WHERE t.id = ? ${includeDeleted ? '' : 'AND t.is_deleted = 0'} ''', [id], ); return rows.isEmpty ? null : _transactionMap(rows.first); } Map updateTransaction(int id, Map value) { final category = _db.select( 'SELECT type FROM categories WHERE id = ? AND is_deleted = 0', [value['categoryId']], ); if (category.isEmpty || category.first['type'] != _categoryType(value)) { throw StateError('分类与收支类型不一致'); } _db.execute( ''' UPDATE transactions SET ledger_id = ?, category_id = ?, type = ?, amount = ?, note = ?, payment_method = ?, transfer_direction = ?, counterparty = ?, occurred_at = ?, sync_state = 'local', updated_at = ? WHERE id = ? ''', [ value['ledgerId'], value['categoryId'], value['type'], value['amount'], value['note'], value['paymentMethod'], value['transferDirection'], value['counterparty'], value['occurredAt'], DateTime.now().toUtc().toIso8601String(), id, ], ); return transaction(id, includeDeleted: true)!; } void softDeleteTransaction(int id) { final now = DateTime.now().toUtc().toIso8601String(); _db.execute( ''' UPDATE transactions SET is_deleted = 1, deleted_at = ?, sync_state = 'local', updated_at = ? WHERE id = ? ''', [now, now, id], ); } Map restoreTransaction(int id) { _db.execute( ''' UPDATE transactions SET is_deleted = 0, deleted_at = NULL, sync_state = 'local', updated_at = ? WHERE id = ? ''', [DateTime.now().toUtc().toIso8601String(), id], ); return transaction(id, includeDeleted: true)!; } void permanentlyDeleteTransaction(int id) { _db.execute('DELETE FROM transactions WHERE id = ? AND is_deleted = 1', [ id, ]); } void clearRecycleBin(int ledgerId) { _db.execute( 'DELETE FROM transactions WHERE ledger_id = ? AND is_deleted = 1', [ledgerId], ); } List> recycleBin(int ledgerId) => _db .select( ''' SELECT t.*, c.name AS category_name, c.icon_key AS category_icon, c.color_key AS category_color FROM transactions t JOIN categories c ON c.id = t.category_id WHERE t.ledger_id = ? AND t.is_deleted = 1 ORDER BY t.deleted_at DESC, t.id DESC ''', [ledgerId], ) .map(_transactionMap) .toList(); Map monthSummary(int year, int month, int ledgerId) { final range = ShanghaiTime.monthRangeUtc(year, month); final start = range.start; final end = range.end; final items = _transactionsBetween(ledgerId, start, end); final groups = >>{}; for (final item in items) { final occurredAt = ShanghaiTime.parseCivil(item['occurredAt'] as String); final date = DateTime(occurredAt.year, occurredAt.month, occurredAt.day); groups.putIfAbsent(date, () => []).add(item); } final days = groups.entries.toList() ..sort((a, b) => b.key.compareTo(a.key)); final income = _sumType(items, 'income'); final expense = _sumType(items, 'expense'); return { 'year': year, 'month': month, 'income': income, 'expense': expense, 'balance': income - expense, 'count': items.length, 'days': days .map( (entry) => { 'date': entry.key.toIso8601String(), 'expense': _sumType(entry.value, 'expense'), 'income': _sumType(entry.value, 'income'), 'items': entry.value, }, ) .toList(), }; } Map periodStats( String period, DateTime anchor, int ledgerId, ) { late DateTime start; late DateTime end; late String label; if (period == 'week') { final offset = (anchor.weekday - DateTime.monday) % 7; start = DateTime( anchor.year, anchor.month, anchor.day, ).subtract(Duration(days: offset)); end = start.add(const Duration(days: 7)); label = '${_monthDay(start)} - ${_monthDay(end.subtract(const Duration(days: 1)))}'; } else if (period == 'year') { start = DateTime(anchor.year, 1, 1); end = DateTime(anchor.year + 1, 1, 1); label = '${anchor.year}年'; } else { start = DateTime(anchor.year, anchor.month, 1); end = DateTime(anchor.year, anchor.month + 1, 1); label = '${anchor.year}年${anchor.month}月'; } final items = _transactionsBetween( ledgerId, ShanghaiTime.civilToUtc(start), ShanghaiTime.civilToUtc(end), ); final expenses = items.where(_isExpense).toList(); final expense = _sumType(items, 'expense'); final income = _sumType(items, 'income'); final categories = >{}; for (final item in expenses) { final categoryId = item['categoryId'] as int; final aggregate = categories.putIfAbsent( categoryId, () => { 'categoryId': categoryId, 'name': item['categoryName'], 'iconKey': item['categoryIcon'], 'colorKey': item['categoryColor'], 'amount': 0.0, }, ); aggregate['amount'] = (aggregate['amount'] as double) + (item['amount'] as num).toDouble(); } final byCategory = categories.values.map((item) { final amount = item['amount'] as double; return { ...item, 'percent': expense == 0 ? 0.0 : amount / expense * 100, }; }).toList()..sort( (a, b) => (b['amount'] as double).compareTo(a['amount'] as double), ); final trend = >[]; if (period == 'year') { for (var month = 1; month <= 12; month++) { final pointItems = items .where( (item) => ShanghaiTime.parseCivil(item['occurredAt'] as String).month == month, ) .toList(); trend.add({ 'label': '$month月', 'date': DateTime(anchor.year, month, 1).toIso8601String(), 'expense': _sumType(pointItems, 'expense'), 'income': _sumType(pointItems, 'income'), }); } } else { final dayCount = end.difference(start).inDays; const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; for (var index = 0; index < dayCount; index++) { final date = start.add(Duration(days: index)); final pointItems = items.where((item) { final local = ShanghaiTime.parseCivil(item['occurredAt'] as String); return local.year == date.year && local.month == date.month && local.day == date.day; }).toList(); trend.add({ 'label': period == 'week' ? weekLabels[index] : '${date.day}日', 'date': date.toIso8601String(), 'expense': _sumType(pointItems, 'expense'), 'income': _sumType(pointItems, 'income'), }); } } final top = byCategory.isEmpty ? null : byCategory.first; return { 'periodType': period, 'periodLabel': label, 'startDate': start.toIso8601String(), 'endDate': end.toIso8601String(), 'totalExpense': expense, 'totalIncome': income, 'balance': income - expense, 'count': items.length, 'byCategory': byCategory, 'trend': trend, 'analysis': top == null ? '这个周期还没有支出记录' : '${top['name']}支出最多,共 ¥${(top['amount'] as double).toStringAsFixed(0)}', }; } Map periodReport( String period, DateTime anchor, int ledgerId, ) { final stats = periodStats(period, anchor, ledgerId); final start = DateTime.parse(stats['startDate'] as String); final end = DateTime.parse(stats['endDate'] as String); final items = _transactionsBetween( ledgerId, ShanghaiTime.civilToUtc(start), ShanghaiTime.civilToUtc(end), ); final expenses = items.where(_isExpense).toList(); final grouped = >>{}; for (final item in expenses) { final local = ShanghaiTime.parseCivil(item['occurredAt'] as String); final key = period == 'year' ? DateTime(local.year, local.month) : DateTime(local.year, local.month, local.day); grouped.putIfAbsent(key, () => []).add(item); } final peaks = grouped.entries.toList() ..sort( (a, b) => _sumType( b.value, 'expense', ).compareTo(_sumType(a.value, 'expense')), ); final peak = peaks.isEmpty ? null : peaks.first; final ranking = (stats['byCategory'] as List).map((item) { final category = item as Map; return { 'name': category['name'], 'iconKey': category['iconKey'], 'colorKey': category['colorKey'], 'amount': category['amount'], 'percent': category['percent'], }; }).toList(); final top = ranking.isEmpty ? null : ranking.first; const aiSources = { 'ai_chat', 'voice', 'ocr', 'screenshot', 'recognition_ai', }; final aiCount = items .where((item) => aiSources.contains(item['source'])) .length; return { 'periodType': period == 'month' ? 'monthly' : '${period}ly', 'periodLabel': stats['periodLabel'], 'startDate': stats['startDate'], 'endDate': stats['endDate'], 'income': stats['totalIncome'], 'expense': stats['totalExpense'], 'balance': stats['balance'], 'count': stats['count'], 'aiRatio': items.isEmpty ? 0.0 : aiCount / items.length * 100, 'peakLabel': peak == null ? null : period == 'year' ? '${peak.key.month}月' : '${peak.key.month}月${peak.key.day}日', 'peakAmount': peak == null ? 0.0 : _sumType(peak.value, 'expense'), 'peakNote': peak?.value.first['note'], 'topCategory': top?['name'], 'topCategoryAmount': top?['amount'] ?? 0.0, 'topCategoryPercent': top?['percent'] ?? 0.0, 'categoryRanking': ranking, 'commentary': stats['analysis'] ?? '本地统计已生成', }; } List> searchTransactions({ required int ledgerId, String? query, bool aiOnly = false, int? categoryId, String? type, double? minAmount, double? maxAmount, DateTime? from, DateTime? to, DateTime? beforeOccurredAt, int? beforeId, int limit = 50, }) { final values = _db .select( ''' SELECT t.*, c.name AS category_name, c.icon_key AS category_icon, c.color_key AS category_color FROM transactions t JOIN categories c ON c.id = t.category_id WHERE t.ledger_id = ? AND t.is_deleted = 0 ORDER BY t.occurred_at DESC, t.id DESC ''', [ledgerId], ) .map(_transactionMap) .where((item) { final occurredAt = DateTime.parse(item['occurredAt'] as String); final amount = (item['amount'] as num).toDouble(); final keyword = query?.trim().toLowerCase(); if (keyword != null && keyword.isNotEmpty) { final haystack = '${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) { return false; } if (type != null && item['type'] != type) return false; if (minAmount != null && amount < minAmount) return false; if (maxAmount != null && amount > maxAmount) return false; if (from != null && occurredAt.isBefore(ShanghaiTime.civilToUtc(from))) { return false; } if (to != null && !occurredAt.isBefore(ShanghaiTime.civilToUtc(to))) { return false; } if (beforeOccurredAt != null) { final cursor = ShanghaiTime.civilToUtc(beforeOccurredAt); if (occurredAt.isAfter(cursor)) return false; if (occurredAt.isAtSameMomentAs(cursor) && beforeId != null && (item['id'] as int) >= beforeId) { return false; } } return true; }) .take(limit) .toList(); return values; } Map budgets(int year, int month, int ledgerId) { final period = year * 100 + month; final rows = _db.select( ''' SELECT b.*, c.name AS category_name, c.icon_key AS category_icon, c.color_key AS category_color FROM budgets b LEFT JOIN categories c ON c.id = b.category_key WHERE b.ledger_id = ? AND b.period IN (0, ?) ORDER BY b.period DESC ''', [ledgerId, period], ); final chosen = {}; for (final row in rows) { chosen.putIfAbsent((row['category_key'] as num).toInt(), () => row); } final monthItems = _transactionsBetween( ledgerId, ShanghaiTime.monthRangeUtc(year, month).start, ShanghaiTime.monthRangeUtc(year, month).end, ); Map mapBudget(Row row) { final categoryKey = (row['category_key'] as num).toInt(); final spent = monthItems .where( (item) => _isExpense(item) && (categoryKey == 0 || item['categoryId'] == categoryKey), ) .fold( 0, (sum, item) => sum + (item['amount'] as num).toDouble(), ); return { 'categoryId': categoryKey == 0 ? null : categoryKey, 'categoryName': row['category_name'], 'categoryIcon': row['category_icon'], 'categoryColor': row['category_color'] ?? 'mint', 'amount': (row['amount'] as num).toDouble(), 'spent': spent, 'isRecurring': (row['is_recurring'] as num).toInt() == 1, }; } final total = chosen.remove(0); final categoryRows = chosen.values.map(mapBudget).toList(); return { 'year': year, 'month': month, 'total': total == null ? null : mapBudget(total), 'categories': categoryRows, }; } void upsertBudget( int year, int month, int ledgerId, int? categoryId, double amount, bool recurring, ) { final period = recurring ? 0 : year * 100 + month; final categoryKey = categoryId ?? 0; if (amount <= 0) { _db.execute( 'DELETE FROM budgets WHERE ledger_id = ? AND period = ? AND category_key = ?', [ledgerId, period, categoryKey], ); return; } _db.execute( ''' INSERT INTO budgets (ledger_id, period, category_key, amount, is_recurring, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(ledger_id, period, category_key) DO UPDATE SET amount = excluded.amount, is_recurring = excluded.is_recurring, updated_at = excluded.updated_at ''', [ ledgerId, period, categoryKey, amount, recurring ? 1 : 0, DateTime.now().toUtc().toIso8601String(), ], ); } void applyBudgets( int year, int month, int ledgerId, bool recurring, Map values, ) { _db.execute('BEGIN'); try { for (final entry in values.entries) { upsertBudget(year, month, ledgerId, entry.key, entry.value, recurring); } _db.execute('COMMIT'); } catch (_) { _db.execute('ROLLBACK'); rethrow; } } List> _transactionsBetween( int ledgerId, DateTime start, DateTime end, ) => _db .select( ''' SELECT t.*, c.name AS category_name, c.icon_key AS category_icon, c.color_key AS category_color FROM transactions t JOIN categories c ON c.id = t.category_id WHERE t.ledger_id = ? AND t.is_deleted = 0 AND t.occurred_at >= ? AND t.occurred_at < ? ORDER BY t.occurred_at DESC, t.id DESC ''', [ start.toIso8601String(), end.toIso8601String(), ].insertAtStart(ledgerId), ) .map(_transactionMap) .toList(); int _nextNegativeId(String table) { final value = _db.select('SELECT MIN(id) AS value FROM $table').first['value'] as num?; final minimum = value?.toInt() ?? 0; return minimum <= 0 ? minimum - 1 : -1; } Map _categoryMap(Row row) => { 'id': (row['id'] as num).toInt(), 'name': row['name'] as String, 'iconKey': row['icon_key'] as String, 'colorKey': row['color_key'] as String? ?? 'mint', 'type': row['type'] as String, 'sortOrder': (row['sort_order'] as num).toInt(), 'isCustom': (row['is_custom'] as num).toInt() == 1, }; Map _transactionMap(Row row) => { 'id': (row['id'] as num).toInt(), 'ledgerId': (row['ledger_id'] as num).toInt(), 'categoryId': (row['category_id'] as num).toInt(), 'categoryName': row['category_name'] as String, 'categoryIcon': row['category_icon'] as String, 'categoryColor': row['category_color'] as String? ?? 'mint', 'type': row['type'] as String, '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> items, String type) => items .where((item) => type == 'income' ? _isIncome(item) : _isExpense(item)) .fold(0, (sum, item) => sum + (item['amount'] as num).toDouble()); bool _isIncome(Map item) => item['type'] == 'income' || item['type'] == 'transfer' && item['transferDirection'] == 'in'; bool _isExpense(Map item) => item['type'] == 'expense' || item['type'] == 'transfer' && item['transferDirection'] == 'out'; String _categoryType(Map 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 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')}日'; } extension _ParameterList on List { List insertAtStart(Object? value) => [value, ...this]; }