feat(app): seed restaurants, geocode addresses, refresh sharing
This commit is contained in:
192
lib/builders/store_seed_builder.dart
Normal file
192
lib/builders/store_seed_builder.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:build/build.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class StoreSeedBuilder implements Builder {
|
||||
StoreSeedBuilder();
|
||||
|
||||
@override
|
||||
final Map<String, List<String>> buildExtensions = const {
|
||||
'doc/restaurant_data/store.db': [
|
||||
'assets/data/store_seed.json',
|
||||
'assets/data/store_seed.meta.json',
|
||||
],
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> build(BuildStep buildStep) async {
|
||||
final inputId = buildStep.inputId;
|
||||
final bytes = await buildStep.readAsBytes(inputId);
|
||||
if (bytes.isEmpty) {
|
||||
log.warning('store.db가 비어 있습니다. 시드를 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final tempDir = await Directory.systemTemp.createTemp('store_seed_');
|
||||
final tempDbPath = p.join(tempDir.path, 'store.db');
|
||||
await File(tempDbPath).writeAsBytes(bytes, flush: true);
|
||||
|
||||
final sqlitePath = await _findSqliteBinary();
|
||||
if (sqlitePath == null) {
|
||||
log.severe('sqlite3 바이너리를 찾을 수 없습니다. 설치 후 다시 시도하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
final rows = await _fetchRows(sqlitePath, tempDbPath);
|
||||
if (rows.isEmpty) {
|
||||
log.warning('restaurants 테이블에서 가져온 행이 없습니다.');
|
||||
await tempDir.delete(recursive: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final newSeeds = rows.map(_seedFromMap).toList();
|
||||
final merged = await _mergeWithExisting(buildStep, newSeeds);
|
||||
|
||||
final signature = _buildSignature(bytes);
|
||||
final generatedAt = DateTime.now().toUtc().toIso8601String();
|
||||
final meta = {
|
||||
'version': signature,
|
||||
'generatedAt': generatedAt,
|
||||
'sourceDb': inputId.path,
|
||||
'itemCount': merged.length,
|
||||
'sourceSignature': {'hash': signature, 'size': bytes.length},
|
||||
};
|
||||
|
||||
final encoder = const JsonEncoder.withIndent(' ');
|
||||
await buildStep.writeAsString(
|
||||
AssetId(inputId.package, 'assets/data/store_seed.json'),
|
||||
'${encoder.convert(merged)}\n',
|
||||
);
|
||||
await buildStep.writeAsString(
|
||||
AssetId(inputId.package, 'assets/data/store_seed.meta.json'),
|
||||
'${encoder.convert(meta)}\n',
|
||||
);
|
||||
|
||||
await tempDir.delete(recursive: true);
|
||||
log.info(
|
||||
'store_seed 생성 완료: ${merged.length}개 (sig: $signature, src: ${inputId.path})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _fetchRows(
|
||||
String sqlitePath,
|
||||
String dbPath,
|
||||
) async {
|
||||
const query =
|
||||
'SELECT id, province, district, name, title, address, road_address, '
|
||||
'latitude, longitude FROM restaurants';
|
||||
|
||||
final result = await Process.run(
|
||||
sqlitePath,
|
||||
['-json', dbPath, query],
|
||||
stdoutEncoding: utf8,
|
||||
stderrEncoding: utf8,
|
||||
);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
throw StateError('sqlite3 실행 실패: ${result.stderr}');
|
||||
}
|
||||
|
||||
final output = result.stdout as String;
|
||||
final decoded = jsonDecode(output);
|
||||
if (decoded is! List) {
|
||||
throw const FormatException('예상치 못한 JSON 포맷입니다.');
|
||||
}
|
||||
|
||||
return decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _seedFromMap(Map<String, dynamic> map) {
|
||||
return {
|
||||
'storeId': map['id'] as int,
|
||||
'province': (map['province'] as String).trim(),
|
||||
'district': (map['district'] as String).trim(),
|
||||
'name': (map['name'] as String).trim(),
|
||||
'title': (map['title'] as String).trim(),
|
||||
'address': (map['address'] as String).trim(),
|
||||
'roadAddress': (map['road_address'] as String).trim(),
|
||||
'latitude': (map['latitude'] as num).toDouble(),
|
||||
'longitude': (map['longitude'] as num).toDouble(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _mergeWithExisting(
|
||||
BuildStep buildStep,
|
||||
List<Map<String, dynamic>> newSeeds,
|
||||
) async {
|
||||
final existingId = AssetId(
|
||||
buildStep.inputId.package,
|
||||
'assets/data/store_seed.json',
|
||||
);
|
||||
|
||||
List<Map<String, dynamic>> existing = [];
|
||||
if (await buildStep.canRead(existingId)) {
|
||||
final raw = await buildStep.readAsString(existingId);
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
existing = decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (_) {
|
||||
log.warning('기존 store_seed.json 파싱 실패, 신규 데이터로 대체합니다.');
|
||||
}
|
||||
}
|
||||
|
||||
final byId = <String, Map<String, dynamic>>{};
|
||||
for (final seed in existing) {
|
||||
final id = _seedId(seed);
|
||||
byId[id] = seed;
|
||||
}
|
||||
|
||||
for (final seed in newSeeds) {
|
||||
final id = _seedId(seed);
|
||||
if (!byId.containsKey(id)) {
|
||||
byId[id] = seed;
|
||||
continue;
|
||||
}
|
||||
|
||||
final isDuplicateByNameAndAddress = byId.values.any((existingSeed) {
|
||||
return existingSeed['name'] == seed['name'] &&
|
||||
existingSeed['roadAddress'] == seed['roadAddress'];
|
||||
});
|
||||
|
||||
if (!isDuplicateByNameAndAddress) {
|
||||
byId[id] = seed; // 같은 ID는 최신 값으로 교체
|
||||
}
|
||||
}
|
||||
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => (_seedId(a)).compareTo(_seedId(b)));
|
||||
return merged;
|
||||
}
|
||||
|
||||
String _seedId(Map<String, dynamic> seed) => 'store-${seed['storeId']}';
|
||||
|
||||
Future<String?> _findSqliteBinary() async {
|
||||
try {
|
||||
final result = await Process.run('which', ['sqlite3']);
|
||||
if (result.exitCode == 0) {
|
||||
final path = (result.stdout as String).trim();
|
||||
if (path.isNotEmpty) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _buildSignature(List<int> bytes) {
|
||||
int hash = 0;
|
||||
for (final byte in bytes) {
|
||||
hash = (hash * 31 + byte) & 0x7fffffff;
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
}
|
||||
|
||||
Builder storeSeedBuilder(BuilderOptions options) => StoreSeedBuilder();
|
||||
// ignore_for_file: depend_on_referenced_packages
|
||||
Reference in New Issue
Block a user