虫取り/表示周り中心の作業を反映

This commit is contained in:
joe 2026-03-01 17:07:08 +09:00
parent e8382db72f
commit 3ddd56760a
7 changed files with 355 additions and 0 deletions

View file

@ -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<void> main(List<String> 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);
});
}

View file

@ -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/<client_id>/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 UIHTML/JS or Flutter Web`/dashboard` で提供。
5. バックアップ ZIP と世代管理ロジックを追加。
6. 将来の拡張Google Drive 連携、Cloudflare/S SH/Tunnelingをこの土台に差し込む。
## 今後の課題
- 認証方式LAN 内とはいえ API キーやシグネチャを検討)。
- データ移行/マイグレーション(年度パック+最終ハッシュの保持方法)。
- フロントエンドからモジュール追加を制御するインターフェイス。
- チャット/通知の同期待ち行列設計。

View file

@ -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;
}

View file

@ -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<String, dynamic> toJson() => {
'clientId': clientId,
'lastSync': lastSync?.toIso8601String(),
'lastHash': lastHash,
'remainingLifespanSeconds': remainingLifespan?.inSeconds,
};
}
class MothershipDataStore {
MothershipDataStore(this.rootDir);
final Directory rootDir;
final Map<String, ClientStatus> _statuses = {};
Future<void> init() async {
if (!await rootDir.exists()) {
await rootDir.create(recursive: true);
}
await _loadStatuses();
}
Future<void> _loadStatuses() async {
final statusFile = File('${rootDir.path}/status.json');
if (await statusFile.exists()) {
final decoded = jsonDecode(await statusFile.readAsString()) as List<dynamic>;
for (final entry in decoded) {
final map = Map<String, dynamic>.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<void> _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<void> 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<void> 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<ClientStatus> listStatuses() => _statuses.values.toList()
..sort((a, b) => (a.clientId).compareTo(b.clientId));
}

123
lib/mothership/server.dart Normal file
View file

@ -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<HttpServer> 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<Response> _handleHeartbeat(Request request) async {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
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<Response> _handleHash(Request request) async {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
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<Response> _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<Response> _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 '<tr><td>${status.clientId}</td><td>$lastSync</td><td>${status.lastHash ?? '-'}</td><td>$remaining</td></tr>';
}).join();
final html = '''
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>Mothership Dashboard</title>
<style>
body { font-family: sans-serif; margin: 24px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
</style>
</head>
<body>
<h1> - </h1>
<table>
<thead>
<tr>
<th>Client ID</th>
<th>Last Sync</th>
<th>Last Hash</th>
<th>Remaining</th>
</tr>
</thead>
<tbody>
$rows
</tbody>
</table>
</body>
</html>
''';
return Response.ok(html, headers: {'content-type': 'text/html; charset=utf-8'});
}
}

View file

@ -320,6 +320,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" 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: http_parser:
dependency: transitive dependency: transitive
description: description:
@ -792,6 +800,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View file

@ -54,6 +54,8 @@ dependencies:
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
mailer: ^6.0.1 mailer: ^6.0.1
flutter_email_sender: ^6.0.3 flutter_email_sender: ^6.0.3
shelf: ^1.4.1
shelf_router: ^1.1.4
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: