feat: 母艦サーバー HTTP 実装を簡素化 (UUID ライブラリ不要・環境変数から読み込む)
This commit is contained in:
parent
485c4c232f
commit
0ac6edac9f
1 changed files with 291 additions and 0 deletions
291
bin/mothership_server.dart
Normal file
291
bin/mothership_server.dart
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// Version: 1.0.0
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// 母艦「お局様」用サーバーアプリケーション
|
||||
///
|
||||
/// クライアント(販売アシスト 1 号)のハッシュチェーン監視、
|
||||
/// システムバックアップ、チャットリレーを管理するサービスです。
|
||||
///
|
||||
/// 環境変数(推奨:`.env` を使用):
|
||||
/// - MOTHERSHIP_HOST: サーバーホスト(例:localhost / 0.0.0.0)
|
||||
/// - MOTHERSHIP_PORT: ポート番号(例:8787)
|
||||
/// - MOTHERSHIP_API_KEY: API キー(同期認証用)
|
||||
/// - MOTHERSHIP_DATA_DIR: データ保存先ディレクトリ
|
||||
|
||||
class MothershipServer {
|
||||
// サーバー状態管理
|
||||
final statusFile = File('data/mothership/status.json');
|
||||
|
||||
// クライアントハッシュチェーンのステート
|
||||
Map<String, ClientData> _clientStatuses = {};
|
||||
|
||||
// チャットメッセージキュー(永続化せず、メモリ保存)
|
||||
List<ChatMessage> _chatQueue = [];
|
||||
|
||||
/// サーバー起動処理
|
||||
Future<void> start() async {
|
||||
// データディレクトリの準備
|
||||
final dataDir = Directory(args['MOTHERSHIP_DATA_DIR'] ?? 'data/mothership');
|
||||
await dataDir.create(recursive: true);
|
||||
|
||||
// チャットキューの読み込み(初期化)
|
||||
_chatQueue = [];
|
||||
|
||||
print('=== 母艦「お局様」サーバー起動 ===');
|
||||
print('ホスト:${args['MOTHERSHIP_HOST'] ?? '0.0.0.0'}:${args['MOTHERSHIP_PORT'] ?? '8787'}');
|
||||
print('API キー:${args['MOTHERSHIP_API_KEY']}');
|
||||
print('データディレクトリ:${dataDir.path}');
|
||||
|
||||
// 簡易な HTTP サーバーを起動(dart:io の httpServer 使用)
|
||||
final server = await HttpServer.bind(
|
||||
args['MOTHERSHIP_HOST'] ?? '0.0.0.0',
|
||||
int.parse(args['MOTHERSHIP_PORT'] ?? '8787'),
|
||||
);
|
||||
|
||||
print('サーバー起動完了。HTTP リッスン開始');
|
||||
|
||||
// 各接続に対してループ処理
|
||||
await for (final request in server.request) {
|
||||
final response = switch (request.url.path) {
|
||||
'/health' => _handleHealth(request),
|
||||
'/status' => _handleStatus(request),
|
||||
'/sync/heartbeat' => _handleHeartbeat(request),
|
||||
'/chat/send' => _handleChatSend(request),
|
||||
'/chat/pending' => _handleChatPending(request),
|
||||
'/chat/ack' => _handleChatAck(request),
|
||||
'/backup/drive' => _handleBackupDrive(request),
|
||||
_ => _handleNotFound(request),
|
||||
};
|
||||
|
||||
await request.response.write(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// ハンドラ:ヘルスチェック(GET /health)
|
||||
String _handleHealth(HttpRequest request) {
|
||||
return jsonEncode({
|
||||
'status': 'ok',
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
'clients_connected': _clientStatuses.length,
|
||||
});
|
||||
}
|
||||
|
||||
/// ハンドラ:ステータス一覧取得(GET /status)
|
||||
String _handleStatus(HttpRequest request) {
|
||||
final clients = <String, Map<String, dynamic>>{};
|
||||
|
||||
_clientStatuses.forEach((clientId, clientData) {
|
||||
clients[clientId] = {
|
||||
'id': clientData.id,
|
||||
'last_heartbeat': clientData.lastHeartbeat?.toIso8601String(),
|
||||
'hash_chain_root': clientData.hashChainRoot,
|
||||
'expiry_remaining_days': clientData.expiryRemainingDays,
|
||||
};
|
||||
});
|
||||
|
||||
return jsonEncode({
|
||||
'server_time': DateTime.now().toUtc().toIso8601String(),
|
||||
'clients': clients,
|
||||
});
|
||||
}
|
||||
|
||||
/// ハンドラ:ハートビート同期(POST /sync/heartbeat)
|
||||
Future<String> _handleHeartbeat(HttpRequest request) async {
|
||||
final body = jsonDecode(utf8.decode(request.requestedBytes)) as Map<String, dynamic>;
|
||||
final apiKey = body['api_key'] as String?;
|
||||
|
||||
if (apiKey != args['MOTHERSHIP_API_KEY']) {
|
||||
return jsonEncode({
|
||||
'error': '未認証',
|
||||
'status_code': 401,
|
||||
});
|
||||
}
|
||||
|
||||
final clientId = body['client_id'] as String?;
|
||||
if (clientId == null) {
|
||||
return jsonEncode({'error': 'client_id が不足'});
|
||||
}
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
_clientStatuses[clientId] = ClientData(
|
||||
id: clientId,
|
||||
lastHeartbeat: now,
|
||||
hashChainRoot: body['hash_chain_root'] as String?,
|
||||
expiryRemainingDays: body['expiry_remaining_days'] ?? 90,
|
||||
);
|
||||
|
||||
// ステータスを永続化(JSON ファイルに保存)
|
||||
await _persistStatus();
|
||||
|
||||
return jsonEncode({
|
||||
'status': 'success',
|
||||
'client_id': clientId,
|
||||
'last_heartbeat': now.toIso8601String(),
|
||||
'expiry_warning': (_clientStatuses[clientId]?.expiryRemainingDays ?? 90) <= 30,
|
||||
});
|
||||
}
|
||||
|
||||
/// ハンドラ:チャット送信(POST /chat/send)
|
||||
String _handleChatSend(HttpRequest request) {
|
||||
final body = jsonDecode(utf8.decode(request.requestedBytes)) as Map<String, dynamic>;
|
||||
final apiKey = body['api_key'] as String?;
|
||||
|
||||
if (apiKey != args['MOTHERSHIP_API_KEY']) {
|
||||
return jsonEncode({
|
||||
'error': '未認証',
|
||||
'status_code': 401,
|
||||
});
|
||||
}
|
||||
|
||||
final clientMessage = ChatMessage(
|
||||
fromClient: body['client_id'] as String?,
|
||||
message: body['message'] as String,
|
||||
priority: body['priority'] as int? ?? 0,
|
||||
);
|
||||
|
||||
_chatQueue.add(clientMessage);
|
||||
|
||||
return jsonEncode({
|
||||
'status': 'queued',
|
||||
'queue_length': _chatQueue.length,
|
||||
});
|
||||
}
|
||||
|
||||
/// ハンドラ:チャット未送信メッセージ取得(GET /chat/pending)
|
||||
String _handleChatPending(HttpRequest request) {
|
||||
// クライアントごとにキューをフィルタ
|
||||
final clientQueues = <String, List<ChatMessage>>{};
|
||||
|
||||
for (final message in _chatQueue) {
|
||||
if (message.fromClient != null) {
|
||||
clientQueues.putIfAbsent(message.fromClient!, () => []).add(message);
|
||||
} else {
|
||||
// 非クライアント用メッセージはすべてのクライアントに配信
|
||||
final pending = clientQueues.values.fold([], (acc, curr) => [...acc, ...curr]);
|
||||
if (pending.isNotEmpty) {
|
||||
clientQueues['all_clients'] = pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jsonEncode({'queues': clientQueues});
|
||||
}
|
||||
|
||||
/// ハンドラ:チャット送信確認(POST /chat/ack)
|
||||
String _handleChatAck(HttpRequest request) {
|
||||
final body = jsonDecode(utf8.decode(request.requestedBytes)) as Map<String, dynamic>;
|
||||
final apiKey = body['api_key'] as String?;
|
||||
|
||||
if (apiKey != args['MOTHERSHIP_API_KEY']) {
|
||||
return jsonEncode({'error': '未認証'});
|
||||
}
|
||||
|
||||
final acknowledgedIds = <String>[];
|
||||
_chatQueue.removeWhere((msg) => msg.id == body['message_id']);
|
||||
|
||||
for (final msg in _chatQueue) {
|
||||
acknowledgedIds.add(msg.id);
|
||||
}
|
||||
|
||||
return jsonEncode({
|
||||
'status': 'acknowledged',
|
||||
'acknowledged_count': acknowledgedIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
/// ハンドラ:バックアップドライブ確認(POST /backup/drive)
|
||||
String _handleBackupDrive(HttpRequest request) {
|
||||
final apiKey = body['api_key'] as String?;
|
||||
|
||||
if (apiKey != args['MOTHERSHIP_API_KEY']) {
|
||||
return jsonEncode({'error': '未認証'});
|
||||
}
|
||||
|
||||
// 簡易的な Drive API 接続は後実装(ここに OAuth2 フローを想定)
|
||||
return jsonEncode({
|
||||
'status': 'backup_ready',
|
||||
'drive_quota_gb': 15,
|
||||
'used_space_gb': 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
/// ハンドラ:未定義エンドポイント
|
||||
String _handleNotFound(HttpRequest request) {
|
||||
return jsonEncode({
|
||||
'error': 'Not Found',
|
||||
'path': request.url.path,
|
||||
'method': request.method,
|
||||
}, statusCode: HttpStatus.notFound);
|
||||
}
|
||||
|
||||
/// ステータスを永続化(JSON ファイル)
|
||||
Future<void> _persistStatus() async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final data = {
|
||||
'server_time': now.toIso8601String(),
|
||||
'clients': {},
|
||||
};
|
||||
|
||||
_clientStatuses.forEach((id, client) {
|
||||
data['clients'][id] = {
|
||||
'last_heartbeat': client.lastHeartbeat?.toIso8601String(),
|
||||
'hash_chain_root': client.hashChainRoot,
|
||||
'expiry_remaining_days': client.expiryRemainingDays,
|
||||
};
|
||||
});
|
||||
|
||||
final file = File(statusFile.path);
|
||||
await file.writeAsString(jsonEncode(data));
|
||||
}
|
||||
}
|
||||
|
||||
/// クライアントデータ構造(簡易モデル)
|
||||
class ClientData {
|
||||
final String id;
|
||||
final DateTime? lastHeartbeat;
|
||||
final String? hashChainRoot;
|
||||
final int expiryRemainingDays;
|
||||
|
||||
ClientData({
|
||||
required this.id,
|
||||
this.lastHeartbeat,
|
||||
this.hashChainRoot,
|
||||
required this.expiryRemainingDays,
|
||||
});
|
||||
}
|
||||
|
||||
/// チャットメッセージ(キュー単位)
|
||||
class ChatMessage {
|
||||
final String? fromClient;
|
||||
final String message;
|
||||
final int priority;
|
||||
final String id; // ランダム文字列で生成
|
||||
|
||||
ChatMessage({
|
||||
this.fromClient,
|
||||
required this.message,
|
||||
this.priority = 0,
|
||||
String? id,
|
||||
}) : id = id ?? const String.fromCharCodes([0x4a, 0x61, 0x70, 0x2d, DateTime.now().millisecondsSinceEpoch % 1000]);
|
||||
}
|
||||
|
||||
void main(List<String> args) async {
|
||||
// 環境変数の取得(簡易的な実装)
|
||||
final host = Platform.environment['MOTHERSHIP_HOST'] ?? '0.0.0.0';
|
||||
final port = Platform.environment['MOTHERSHIP_PORT'] ?? '8787';
|
||||
final apiKey = Platform.environment['MOTHERSHIP_API_KEY'] ?? 'TEST_MOTHERSHIP_KEY';
|
||||
final dataDir = Platform.environment['MOTHERSHIP_DATA_DIR'] ?? 'data/mothership';
|
||||
|
||||
print('環境変数取得完了:');
|
||||
print(' HOST: $host');
|
||||
print(' PORT: $port');
|
||||
print(' API_KEY: ${apiKey?.substring(0, min(apiKey!.length, 10))}...');
|
||||
|
||||
final server = MothershipServer();
|
||||
await server.start();
|
||||
}
|
||||
|
||||
/// min 関数の簡易実装(標準ライブラリにないため)
|
||||
int min(int a, int b) => a < b ? a : b;
|
||||
Loading…
Reference in a new issue