291 lines
No EOL
9.3 KiB
Dart
291 lines
No EOL
9.3 KiB
Dart
// 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; |