虫取り/表示周り中心の作業を反映
This commit is contained in:
parent
e8382db72f
commit
3ddd56760a
7 changed files with 355 additions and 0 deletions
19
bin/mothership_server.dart
Normal file
19
bin/mothership_server.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
66
docs/mothership_lan_plan.md
Normal file
66
docs/mothership_lan_plan.md
Normal 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 UI(HTML/JS or Flutter Web)を `/dashboard` で提供。
|
||||||
|
5. バックアップ ZIP と世代管理ロジックを追加。
|
||||||
|
6. 将来の拡張(Google Drive 連携、Cloudflare/S SH/Tunneling)をこの土台に差し込む。
|
||||||
|
|
||||||
|
## 今後の課題
|
||||||
|
- 認証方式(LAN 内とはいえ API キーやシグネチャを検討)。
|
||||||
|
- データ移行/マイグレーション(年度パック+最終ハッシュの保持方法)。
|
||||||
|
- フロントエンドからモジュール追加を制御するインターフェイス。
|
||||||
|
- チャット/通知の同期待ち行列設計。
|
||||||
29
lib/mothership/config.dart
Normal file
29
lib/mothership/config.dart
Normal 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;
|
||||||
|
}
|
||||||
92
lib/mothership/data_store.dart
Normal file
92
lib/mothership/data_store.dart
Normal 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
123
lib/mothership/server.dart
Normal 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'});
|
||||||
|
}
|
||||||
|
}
|
||||||
24
pubspec.lock
24
pubspec.lock
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue