Files
ShowenV2/clients/flutter/lib/services/http_api_service.dart
showen d30c111c71 feat: M1.1 完成 + M1.2 启动 — 全量更新
M1.1 收尾:
- 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests)
- Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB
- 示例插件完善: manifest.json + 请求/响应示范 + 7个测试
- API 文档重写 (以 routes.rs 为唯一权威)
- MILESTONES.md 更新至 100%

M1.2 启动:
- P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states)
- ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs)
- M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景)
- 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护

总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:12:42 +08:00

564 lines
16 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../models/api_response.dart';
import '../models/ble_status.dart';
import '../models/player_status.dart';
import '../models/video_item.dart';
import '../models/wifi_network.dart';
import '../models/wifi_status.dart';
typedef UploadProgressCallback = void Function(double progress);
class HttpApiService {
HttpApiService({required String baseUrl, http.Client? client})
: _baseUrl = normalizeBaseUrl(baseUrl),
_client = client ?? http.Client();
final http.Client _client;
String _baseUrl;
String get baseUrl => _baseUrl;
set baseUrl(String value) {
_baseUrl = normalizeBaseUrl(value);
}
Uri _uri(String path, [Map<String, String>? queryParameters]) {
final normalizedPath = path.startsWith('/') ? path : '/$path';
return Uri.parse('$_baseUrl$normalizedPath').replace(
queryParameters: queryParameters == null || queryParameters.isEmpty
? null
: queryParameters,
);
}
Future<String> fetchConsolePage() async {
final response = await _client.get(_uri('/'));
_ensureSuccess(response);
return response.body;
}
Future<String> fetchConsoleIndexHtml() async {
final response = await _client.get(_uri('/index.html'));
_ensureSuccess(response);
return response.body;
}
Future<PlayerStatus> getStatus() => getPlaybackStatus();
Future<PlayerStatus> getPlaybackStatus() async {
final response = await _client.get(_uri('/api/status'));
_ensureSuccess(response);
return PlayerStatus.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> play() => _postCommand('/api/play');
Future<ApiResponse> pause() => _postCommand('/api/pause');
Future<ApiResponse> next() => _postCommand('/api/next');
Future<ApiResponse> previous() => _postCommand('/api/previous');
Future<ApiResponse> goto(int index) => _postCommand('/api/goto/$index');
Future<ApiResponse> gotoIndex(int index) => goto(index);
Future<List<String>> getPlaylist() async {
final response = await _client.get(_uri('/api/playlist'));
_ensureSuccess(response);
final decoded = _decodeJson(response.body);
if (decoded is! List) {
return const <String>[];
}
return decoded.map<String>((dynamic item) {
if (item is String) {
return item;
}
if (item is Map<String, dynamic>) {
return item['name']?.toString() ?? jsonEncode(item);
}
if (item is Map) {
return item['name']?.toString() ?? jsonEncode(item);
}
return item.toString();
}).toList(growable: false);
}
Future<ApiResponse> changeScene(String name) {
return _postCommand('/api/scene/${Uri.encodeComponent(name)}');
}
Future<ApiResponse> switchScene(String name) => changeScene(name);
Future<ApiResponse> trigger(String name, [String? value]) {
final suffix = value == null || value.isEmpty
? ''
: '/${Uri.encodeComponent(value)}';
return _postCommand('/api/trigger/${Uri.encodeComponent(name)}$suffix');
}
Future<ApiResponse> triggerEvent(String name, {String? value}) {
return trigger(name, value);
}
Future<WifiStatus> getWifiStatus() async {
final response = await _client.get(_uri('/api/wifi/status'));
_ensureSuccess(response);
return WifiStatus.fromJson(_decodeMap(response.body));
}
Future<List<WifiNetwork>> scanWifi() async {
try {
final response = await _client.get(_uri('/api/wifi/scan'));
_ensureSuccess(response);
return _decodeWifiNetworks(response.body);
} on ApiException {
final response = await _client.post(_uri('/api/wifi/scan'));
_ensureSuccess(response);
return _decodeWifiNetworks(response.body);
}
}
Future<List<WifiNetwork>> scanWifiViaGet() => scanWifi();
Future<List<WifiNetwork>> scanWifiViaPost() => scanWifi();
Future<ApiResponse> connectWifi(String ssid, String password) async {
final response = await _client.post(
_uri('/api/wifi/connect'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{
'ssid': ssid,
'password': password,
}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> startAP([String? ssid, String? password]) async {
final body = <String, dynamic>{
if (ssid != null && ssid.isNotEmpty) 'ssid': ssid,
if (password != null && password.isNotEmpty) 'password': password,
};
try {
final response = await _client.post(
_uri('/api/wifi/ap/start'),
headers: _jsonHeaders,
body: jsonEncode(body),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
} on ApiException {
final response = await _client.post(
_uri('/api/wifi/hotspot/start'),
headers: _jsonHeaders,
body: jsonEncode(body),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
}
Future<ApiResponse> startAccessPoint({String? ssid, String? password}) {
return startAP(ssid, password);
}
Future<ApiResponse> startHotspot({String? ssid, String? password}) {
return startAP(ssid, password);
}
Future<ApiResponse> stopAP() async {
try {
return await _postCommand('/api/wifi/ap/stop');
} on ApiException {
return _postCommand('/api/wifi/hotspot/stop');
}
}
Future<ApiResponse> stopAccessPoint() => stopAP();
Future<ApiResponse> stopHotspot() => stopAP();
Future<BleServiceStatus> getBleStatus() async {
final response = await _client.get(_uri('/api/ble/status'));
_ensureSuccess(response);
return BleServiceStatus.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> startBle([String? deviceName]) async {
final response = await _client.post(
_uri('/api/ble/start'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{
if (deviceName != null && deviceName.isNotEmpty)
'device_name': deviceName,
}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> stopBle() => _postCommand('/api/ble/stop');
Future<List<VideoItem>> getVideos() async {
final response = await _client.get(_uri('/api/videos'));
_ensureSuccess(response);
final decoded = _decodeJson(response.body);
if (decoded is! List) {
return const <VideoItem>[];
}
return decoded.map<VideoItem>((dynamic item) {
if (item is Map<String, dynamic>) {
return VideoItem.fromJson(item);
}
if (item is Map) {
return VideoItem.fromJson(Map<String, dynamic>.from(item));
}
return const VideoItem(name: '', size: 0);
}).where((item) => item.name.isNotEmpty).toList(growable: false);
}
Future<ApiResponse> uploadVideo(File file) {
return _uploadSingleFile('/api/videos/upload', file);
}
Future<ApiResponse> uploadVideoWithProgress(
File file, {
UploadProgressCallback? onProgress,
}) {
return _uploadSingleFile(
'/api/videos/upload',
file,
onProgress: onProgress,
);
}
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
if (filePaths.isEmpty) {
throw const ApiException('未选择上传文件');
}
final request = http.MultipartRequest('POST', _uri('/api/videos/upload'));
for (final filePath in filePaths) {
request.files.add(await http.MultipartFile.fromPath('file', filePath));
}
final response = await http.Response.fromStream(await request.send());
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> deleteVideo(String name) {
return _deleteCommand('/api/videos/${Uri.encodeComponent(name)}');
}
Future<Map<String, dynamic>> getConfig() async {
final response = await _client.get(_uri('/api/config'));
_ensureSuccess(response);
return _decodeMap(response.body);
}
Future<ApiResponse> updateConfig(Map<String, dynamic> config) async {
final response = await _client.post(
_uri('/api/config'),
headers: _jsonHeaders,
body: jsonEncode(config),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<Map<String, dynamic>> getDisplayConfig() async {
final response = await _client.get(_uri('/api/config/display'));
_ensureSuccess(response);
return _decodeMap(response.body);
}
Future<Map<String, dynamic>> getAvailableConfigs() async {
final response = await _client.get(_uri('/api/config/available'));
_ensureSuccess(response);
return _decodeMap(response.body);
}
Future<ApiResponse> switchConfig(String filename) async {
final response = await _client.post(
_uri('/api/config/switch'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{'filename': filename}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<List<Map<String, dynamic>>> listFiles(String dirKey, [String? path]) async {
final response = await _client.get(
_uri('/api/files/$dirKey', _pathQuery(path)),
);
_ensureSuccess(response);
final decoded = _decodeJson(response.body);
if (decoded is! List) {
return const <Map<String, dynamic>>[];
}
return decoded
.whereType<Map>()
.map((entry) => Map<String, dynamic>.from(entry))
.toList(growable: false);
}
Future<ApiResponse> uploadFile(String dirKey, File file, [String? path]) {
return _uploadSingleFile(
'/api/files/$dirKey/upload',
file,
directoryPath: path,
);
}
Future<Uint8List> downloadFile(String dirKey, String path) async {
final response = await _client.get(
_uri('/api/files/$dirKey/download', <String, String>{'path': path}),
);
_ensureSuccess(response, expectJson: false);
return response.bodyBytes;
}
Future<ApiResponse> deleteFile(String dirKey, String path) async {
final response = await _client.post(
_uri('/api/files/$dirKey/delete'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{'path': path}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> mkdir(String dirKey, String path) async {
final response = await _client.post(
_uri('/api/files/$dirKey/mkdir'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{'path': path}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<List<Map<String, dynamic>>> getPlugins() async {
final response = await _client.get(_uri('/api/plugins'));
_ensureSuccess(response);
final decoded = _decodeJson(response.body);
if (decoded is! List) {
return const <Map<String, dynamic>>[];
}
return decoded
.whereType<Map>()
.map((plugin) => Map<String, dynamic>.from(plugin))
.toList(growable: false);
}
Future<ApiResponse> enablePlugin(String id) {
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/enable');
}
Future<ApiResponse> disablePlugin(String id) {
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/disable');
}
Future<ApiResponse> installPlugin(String id, [String? version]) async {
final response = await _client.post(
_uri('/api/plugins/install'),
headers: _jsonHeaders,
body: jsonEncode(<String, dynamic>{
'id': id,
if (version != null && version.isNotEmpty) 'version': version,
}),
);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> _uploadSingleFile(
String endpoint,
File file, {
String? directoryPath,
UploadProgressCallback? onProgress,
}) async {
final request = http.MultipartRequest(
'POST',
_uri(endpoint, _pathQuery(directoryPath)),
);
request.files.add(
await _createMultipartFile(
file,
fieldName: 'file',
onProgress: onProgress,
),
);
final response = await http.Response.fromStream(await request.send());
onProgress?.call(1);
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<http.MultipartFile> _createMultipartFile(
File file, {
required String fieldName,
UploadProgressCallback? onProgress,
}) async {
final length = await file.length();
final filename = file.uri.pathSegments.isNotEmpty
? file.uri.pathSegments.last
: file.path;
if (onProgress == null) {
return http.MultipartFile.fromPath(fieldName, file.path, filename: filename);
}
var uploaded = 0;
final stream = http.ByteStream(
file.openRead().transform(
StreamTransformer<List<int>, List<int>>.fromHandlers(
handleData: (chunk, sink) {
uploaded += chunk.length;
if (length > 0) {
final progress = (uploaded / length).clamp(0.0, 1.0);
onProgress(progress);
}
sink.add(chunk);
},
),
),
);
return http.MultipartFile(
fieldName,
stream,
length,
filename: filename,
);
}
Future<ApiResponse> _postCommand(String path) async {
final response = await _client.post(_uri(path));
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
Future<ApiResponse> _deleteCommand(String path) async {
final response = await _client.delete(_uri(path));
_ensureSuccess(response);
return ApiResponse.fromJson(_decodeMap(response.body));
}
List<WifiNetwork> _decodeWifiNetworks(String body) {
final decoded = _decodeJson(body);
if (decoded is! List) {
return const <WifiNetwork>[];
}
return decoded.map<WifiNetwork>((dynamic item) {
if (item is Map<String, dynamic>) {
return WifiNetwork.fromJson(item);
}
if (item is Map) {
return WifiNetwork.fromJson(Map<String, dynamic>.from(item));
}
return const WifiNetwork(ssid: '', signal: 0, security: 'Unknown');
}).where((network) => network.ssid.isNotEmpty).toList(growable: false);
}
dynamic _decodeJson(String body) {
try {
return jsonDecode(body);
} on FormatException catch (error) {
throw ApiException('JSON 解析失败: ${error.message}');
}
}
Map<String, dynamic> _decodeMap(String body) {
final decoded = _decodeJson(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
throw const ApiException('期望返回 JSON 对象');
}
Map<String, String>? _pathQuery(String? path) {
if (path == null || path.isEmpty) {
return null;
}
return <String, String>{'path': path};
}
void _ensureSuccess(http.Response response, {bool expectJson = true}) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}
if (!expectJson) {
throw ApiException(
'请求失败 (${response.statusCode}): ${response.body}',
statusCode: response.statusCode,
);
}
String message = response.body;
try {
final decoded = _decodeJson(response.body);
if (decoded is Map) {
message = decoded['message']?.toString() ?? response.body;
}
} on ApiException {
message = response.body;
}
throw ApiException(message, statusCode: response.statusCode);
}
Map<String, String> get _jsonHeaders => const <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
};
void dispose() {
_client.close();
}
static String normalizeBaseUrl(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
throw const ApiException('baseUrl 不能为空');
}
final withScheme = trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: 'http://$trimmed';
return withScheme.endsWith('/')
? withScheme.substring(0, withScheme.length - 1)
: withScheme;
}
}
class ApiException implements Exception {
const ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@override
String toString() => message;
}