diff --git a/bin/mothership_server.dart b/bin/mothership_server.dart new file mode 100644 index 0000000..5ab9c32 --- /dev/null +++ b/bin/mothership_server.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:h_1/mothership/config.dart'; +import 'package:h_1/mothership/data_store.dart'; +import 'package:h_1/mothership/server.dart'; + +Future main(List args) async { + final config = MothershipConfig.fromEnv(); + final dataStore = MothershipDataStore(config.dataDirectory); + await dataStore.init(); + final server = MothershipServer(config: config, dataStore: dataStore); + final httpServer = await server.start(); + stdout.writeln('Mothership listening on http://${config.host}:${config.port}'); + ProcessSignal.sigint.watch().listen((_) async { + stdout.writeln('Stopping mothership...'); + await httpServer.close(); + exit(0); + }); +} diff --git a/docs/mothership_lan_plan.md b/docs/mothership_lan_plan.md new file mode 100644 index 0000000..3721d97 --- /dev/null +++ b/docs/mothership_lan_plan.md @@ -0,0 +1,66 @@ +# 母艦「お局様」LAN 常駐サーバ叩き台 +本ドキュメントは、Flutter/Dart プロジェクト内にヘッドレス構成の母艦サーバを実装し、LAN 上で販売アシスト1号クライアントと同期・監視・バックアップを行うための叩き台です。 + +## 目的と前提 +- 端末 1 台だけの利用者から、複数クライアントを抱える小規模事業者までカバーする。 +- まずは LAN 内で完結する通信を実現し、その後 SSH/Cloudflare/DDNS など外部経路へ拡張できる土台を用意する。 +- 同一 Flutter プロジェクト内で UI アプリとサーバ常駐処理を併存させる(headless Isolate / `dart` エントリーポイント)。 + +## 最小構成 +1. **同期 API サーバ** + - Dart `HttpServer`(shelf など)で起動。 + - エンドポイント例: + - `POST /sync/logs` : ハッシュチェーン・操作ログを受信。 + - `POST /sync/diff` : 伝票差分や顧客レコードを受信し、母艦側 DB に再構築。 + - `GET /status` : 各クライアントの最終同期状況を返す。 +2. **データ保全レイヤ** + - 受信データを `data/mothership//YYYY` 配下に JSON/SQLite/ZIP で保存。 + - 世代管理(例: 年度単位 10 パック)と最終ハッシュの保持。 +3. **モニタリング Web UI** + - 同じサーバで `GET /dashboard` を提供し、ブラウザで一覧表示。 + - 表示項目: クライアント名、最終同期、寿命残り、チャット未読、バックアップサイズ。 +4. **バックアップトリガー** + - 手動/定期ジョブで `data/` を ZIP 圧縮。 + - 将来的に Google Drive/API へ転送するフックを用意。 + +## 実装候補 +- `bin/mothership_server.dart` : サーバ起動用 Dart エントリーポイント。 +- `lib/mothership/` : サーバ用ロジック(API ハンドラ、リポジトリ、チャットキューなど)。 +- `lib/mothership/ui/dashboard.dart` : Web UI(`shelf_static` で提供 or Flutter Web をビルドして同梱)。 +- `lib/services/mothership_client.dart` : 販売アシスト1号側から呼び出すクライアント。 + +## データフロー概要 +1. クライアントがオフラインで業務継続。 +2. LAN で母艦に接続できたタイミングで `POST /sync/...` を実行。 +3. 母艦は受信データを保存し、最終ハッシュと同期時刻を更新。 +4. バックアップジョブが ZIP を生成、必要に応じて外部ストレージへ転送。 +5. Web UI で状態を確認し、チャット/通知も同じ API でやり取り。 + +## API 叩き台 +| メソッド | パス | 概要 | +| --- | --- | --- | +| `POST` | `/sync/heartbeat` | 端末 UUID・アプリバージョン・寿命残りを報告 | +| `POST` | `/sync/hash` | `{ clientId, chain }` を送信、整合性チェック | +| `POST` | `/sync/diff` | 伝票・顧客など差分データを送付 | +| `GET` | `/status` | 母艦が把握している全クライアントの状態を返却 | +| `GET` | `/chat/pending` | クライアント向け未読チャット取得 | +| `POST` | `/chat/send` | クライアント→母艦の問い合わせ送信 | + +### 認証 (テスト期間) +- すべての API リクエストに固定キーを送付(例: `X-Api-Key: TEST_MOTHERSHIP_KEY`)。 +- サーバ側は一致チェックのみを行い、不一致は `401 Unauthorized` を返す。 +- 本番想定ではトークンローテーションや署名方式に置き換える前提で、キー値は設定ファイルまたは環境変数から読み込む実装とする。 + +## 実装ステップ案 +1. `bin/mothership_server.dart` を追加し、LAN で HTTP をリッスン。 +2. `lib/mothership/` に API ハンドラとファイル保存ロジックを実装。 +3. `lib/services/mothership_client.dart` を作成し、販売アシスト1号側から API を叩けるようにする。 +4. 簡易 Web UI(HTML/JS or Flutter Web)を `/dashboard` で提供。 +5. バックアップ ZIP と世代管理ロジックを追加。 +6. 将来の拡張(Google Drive 連携、Cloudflare/S SH/Tunneling)をこの土台に差し込む。 + +## 今後の課題 +- 認証方式(LAN 内とはいえ API キーやシグネチャを検討)。 +- データ移行/マイグレーション(年度パック+最終ハッシュの保持方法)。 +- フロントエンドからモジュール追加を制御するインターフェイス。 +- チャット/通知の同期待ち行列設計。 diff --git a/lib/mothership/config.dart b/lib/mothership/config.dart new file mode 100644 index 0000000..f8add40 --- /dev/null +++ b/lib/mothership/config.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +class MothershipConfig { + MothershipConfig({ + required this.host, + required this.port, + required this.apiKey, + required this.dataDirectory, + }); + + factory MothershipConfig.fromEnv() { + final env = Platform.environment; + final host = env['MOTHERSHIP_HOST'] ?? '0.0.0.0'; + final port = int.tryParse(env['MOTHERSHIP_PORT'] ?? '') ?? 8787; + final apiKey = env['MOTHERSHIP_API_KEY'] ?? 'TEST_MOTHERSHIP_KEY'; + final dataDirPath = env['MOTHERSHIP_DATA_DIR'] ?? 'data/mothership'; + return MothershipConfig( + host: host, + port: port, + apiKey: apiKey, + dataDirectory: Directory(dataDirPath), + ); + } + + final String host; + final int port; + final String apiKey; + final Directory dataDirectory; +} diff --git a/lib/mothership/data_store.dart b/lib/mothership/data_store.dart new file mode 100644 index 0000000..d70cf76 --- /dev/null +++ b/lib/mothership/data_store.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:io'; + +class ClientStatus { + ClientStatus({ + required this.clientId, + required this.lastSync, + required this.lastHash, + required this.remainingLifespan, + }); + + final String clientId; + final DateTime? lastSync; + final String? lastHash; + final Duration? remainingLifespan; + + Map toJson() => { + 'clientId': clientId, + 'lastSync': lastSync?.toIso8601String(), + 'lastHash': lastHash, + 'remainingLifespanSeconds': remainingLifespan?.inSeconds, + }; +} + +class MothershipDataStore { + MothershipDataStore(this.rootDir); + + final Directory rootDir; + final Map _statuses = {}; + + Future init() async { + if (!await rootDir.exists()) { + await rootDir.create(recursive: true); + } + await _loadStatuses(); + } + + Future _loadStatuses() async { + final statusFile = File('${rootDir.path}/status.json'); + if (await statusFile.exists()) { + final decoded = jsonDecode(await statusFile.readAsString()) as List; + for (final entry in decoded) { + final map = Map.from(entry as Map); + _statuses[map['clientId'] as String] = ClientStatus( + clientId: map['clientId'] as String, + lastSync: map['lastSync'] != null ? DateTime.tryParse(map['lastSync'] as String) : null, + lastHash: map['lastHash'] as String?, + remainingLifespan: map['remainingLifespanSeconds'] != null + ? Duration(seconds: map['remainingLifespanSeconds'] as int) + : null, + ); + } + } + } + + Future _persistStatuses() async { + final statusFile = File('${rootDir.path}/status.json'); + final list = _statuses.values.map((e) => e.toJson()).toList(); + await statusFile.writeAsString(const JsonEncoder.withIndent(' ').convert(list)); + } + + Future recordHeartbeat({ + required String clientId, + required Duration? remaining, + }) async { + final existing = _statuses[clientId]; + _statuses[clientId] = ClientStatus( + clientId: clientId, + lastSync: DateTime.now().toUtc(), + lastHash: existing?.lastHash, + remainingLifespan: remaining, + ); + await _persistStatuses(); + } + + Future recordHash({ + required String clientId, + required String hash, + }) async { + final existing = _statuses[clientId]; + _statuses[clientId] = ClientStatus( + clientId: clientId, + lastSync: DateTime.now().toUtc(), + lastHash: hash, + remainingLifespan: existing?.remainingLifespan, + ); + await _persistStatuses(); + } + + List listStatuses() => _statuses.values.toList() + ..sort((a, b) => (a.clientId).compareTo(b.clientId)); +} diff --git a/lib/mothership/server.dart b/lib/mothership/server.dart new file mode 100644 index 0000000..5e81cb4 --- /dev/null +++ b/lib/mothership/server.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_router/shelf_router.dart'; + +import 'config.dart'; +import 'data_store.dart'; + +class MothershipServer { + MothershipServer({required this.config, required this.dataStore}); + + final MothershipConfig config; + final MothershipDataStore dataStore; + + Future start() async { + final router = Router() + ..post('/sync/heartbeat', _handleHeartbeat) + ..post('/sync/hash', _handleHash) + ..get('/status', _handleStatus) + ..get('/', _handleDashboard); + + final handler = const Pipeline() + .addMiddleware(logRequests()) + .addMiddleware(_apiKeyMiddleware(config.apiKey)) + .addHandler(router); + + final server = await serve(handler, config.host, config.port); + return server; + } + + Middleware _apiKeyMiddleware(String expectedKey) { + return (innerHandler) { + return (request) async { + // Dashboard ('/')は無認証、その他は API キーを要求 + if (request.url.path.isNotEmpty && request.url.path != 'status') { + final key = request.headers['x-api-key']; + if (key != expectedKey) { + return Response(401, body: 'Invalid API key'); + } + } + return innerHandler(request); + }; + }; + } + + Future _handleHeartbeat(Request request) async { + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + final clientId = json['clientId'] as String?; + if (clientId == null || clientId.isEmpty) { + return Response(400, body: 'clientId is required'); + } + final remainingSeconds = json['remainingLifespanSeconds'] as int?; + await dataStore.recordHeartbeat( + clientId: clientId, + remaining: remainingSeconds != null ? Duration(seconds: remainingSeconds) : null, + ); + return Response.ok('ok'); + } + + Future _handleHash(Request request) async { + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + final clientId = json['clientId'] as String?; + final hash = json['hash'] as String?; + if (clientId == null || hash == null) { + return Response(400, body: 'clientId and hash are required'); + } + await dataStore.recordHash(clientId: clientId, hash: hash); + return Response.ok('ok'); + } + + Future _handleStatus(Request request) async { + final status = dataStore.listStatuses().map((e) => e.toJson()).toList(); + return Response.ok(jsonEncode({'clients': status}), headers: {'content-type': 'application/json'}); + } + + Future _handleDashboard(Request request) async { + final rows = dataStore.listStatuses().map((status) { + final lastSync = status.lastSync?.toIso8601String() ?? '-'; + final remaining = status.remainingLifespan != null + ? '${status.remainingLifespan!.inHours ~/ 24}d ${status.remainingLifespan!.inHours % 24}h' + : '-'; + return '${status.clientId}$lastSync${status.lastHash ?? '-'}$remaining'; + }).join(); + + final html = ''' + + + + + Mothership Dashboard + + + +

母艦お局様 - ステータス

+ + + + + + + + + + + $rows + +
Client IDLast SyncLast HashRemaining
+ + +'''; + + return Response.ok(html, headers: {'content-type': 'text/html; charset=utf-8'}); + } +} diff --git a/pubspec.lock b/pubspec.lock index 20bc2ce..ff39e83 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -320,6 +320,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" http_parser: dependency: transitive description: @@ -792,6 +800,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 918e55c..bc631a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,8 @@ dependencies: shared_preferences: ^2.2.2 mailer: ^6.0.1 flutter_email_sender: ^6.0.3 + shelf: ^1.4.1 + shelf_router: ^1.1.4 dev_dependencies: flutter_test: