import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; import '../../app/config/app_config.dart'; import '../../features/chat/domain/chat_models.dart'; import '../../features/contracts/domain/contract_models.dart'; import '../../features/auth/domain/auth_session.dart'; import '../../features/profile/domain/admin_snapshot.dart'; import '../../features/profile/domain/shared_ai_status.dart'; import '../../features/review/domain/review_draft.dart'; import '../../features/review_history/domain/review_detail.dart'; import '../../features/review_history/domain/review_history_item.dart'; import '../../features/review_history/domain/review_result_summary.dart'; import '../constants/api_endpoints.dart'; class ApiClient { const ApiClient(); String buildUrl(String path) => '${AppConfig.apiBaseUrl}$path'; String get reviewTasksUrl => buildUrl(ApiEndpoints.reviewTasks); String get loginUrl => buildUrl(ApiEndpoints.login); String get currentUserUrl => buildUrl(ApiEndpoints.currentUser); String get adminSnapshotUrl => buildUrl(ApiEndpoints.systemAdminSnapshot); String get chatStreamUrl => buildUrl(ApiEndpoints.chatStream); String get conversationsUrl => buildUrl(ApiEndpoints.conversations); String get sharedAiStatusUrl => buildUrl(ApiEndpoints.systemSharedAi); String get contractsUrl => buildUrl('/contracts'); String get contractTemplatesUrl => buildUrl('/contracts/templates'); Future login( {required String account, required String password}) async { final response = await http.post( Uri.parse(loginUrl), headers: const {'Content-Type': 'application/json'}, body: jsonEncode({'account': account, 'password': password}), ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '登录失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '登录响应缺少 data'); final user = _extractMap(data['user'], fallback: '登录响应缺少用户信息'); final accessToken = data['access_token']?.toString() ?? ''; if (accessToken.isEmpty) { throw const ApiException('登录响应缺少 access_token'); } return AuthSession( accessToken: accessToken, userId: user['id']?.toString() ?? '', tenantId: user['tenant_id']?.toString() ?? '', username: user['username']?.toString() ?? account, userDisplayName: user['display_name']?.toString() ?? account, role: user['role']?.toString() ?? 'unknown', status: user['status']?.toString() ?? 'unknown', ); } Future fetchCurrentUser(String accessToken) async { final response = await http.get( Uri.parse(currentUserUrl), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取当前用户失败'), statusCode: response.statusCode); } final user = _extractMap(body['data'], fallback: '当前用户响应缺少 data'); return AuthSession( accessToken: accessToken, userId: user['id']?.toString() ?? '', tenantId: user['tenant_id']?.toString() ?? '', username: user['username']?.toString() ?? '', userDisplayName: user['display_name']?.toString() ?? user['username']?.toString() ?? '', role: user['role']?.toString() ?? 'unknown', status: user['status']?.toString() ?? 'unknown', ); } Future> fetchReviewHistory(String accessToken) async { final response = await http.get( Uri.parse(reviewTasksUrl), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取审查记录失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '审查记录响应缺少 data'); final items = data['items']; if (items is! List) { throw const ApiException('审查记录响应缺少 items'); } return items .whereType>() .map(ReviewHistoryItem.fromJson) .toList(growable: false); } Future fetchReviewDetail({ required String accessToken, required String reviewId, }) async { final response = await http.get( Uri.parse('$reviewTasksUrl/$reviewId'), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取审查详情失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '审查详情响应缺少 data'); return ReviewDetail.fromJson(data); } Future fetchReviewResult({ required String accessToken, required String reviewId, }) async { final response = await http.get( Uri.parse('$reviewTasksUrl/$reviewId/result'), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取审查结果失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '审查结果响应缺少 data'); return ReviewResultSummary.fromJson(data); } Future fetchReviewReport({ required String accessToken, required String reviewId, }) async { final response = await http.get( Uri.parse('$reviewTasksUrl/$reviewId/report'), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取报告信息失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '报告响应缺少 data'); return ReviewReportSummary( fileName: data['file_name']?.toString() ?? '', downloadUrl: resolveServerUrl(data['download_url']?.toString() ?? ''), ); } Future downloadReportFile({ required String accessToken, required String downloadUrl, }) async { final uri = Uri.parse(resolveServerUrl(downloadUrl)); final headers = {}; if (_shouldAttachAuthorization(uri)) { headers['Authorization'] = 'Bearer $accessToken'; } final response = await http.get(uri, headers: headers); if (response.statusCode != 200) { throw ApiException( _extractDownloadErrorMessage(response), statusCode: response.statusCode, ); } if (response.bodyBytes.isEmpty) { throw const ApiException('报告文件为空,请稍后重试'); } return response.bodyBytes; } Future streamChat({ required String accessToken, required String message, String? conversationId, Map? reviewContext, required void Function(ChatStreamEvent event) onEvent, }) async { final request = http.Request('POST', Uri.parse(chatStreamUrl)) ..headers['Content-Type'] = 'application/json' ..headers['Authorization'] = 'Bearer $accessToken' ..body = jsonEncode({ 'message': message, if (conversationId != null && conversationId.trim().isNotEmpty) 'conversation_id': conversationId, if (reviewContext != null) 'review_context': reviewContext, }); final streamed = await request.send(); if (streamed.statusCode != 200) { final response = await http.Response.fromStream(streamed); throw ApiException( _extractDownloadErrorMessage(response), statusCode: response.statusCode, ); } var buffer = ''; await for (final chunk in streamed.stream.transform(utf8.decoder)) { buffer += chunk; final segments = buffer.split('\n\n'); buffer = segments.removeLast(); for (final segment in segments) { final event = _tryParseStreamEvent(segment); if (event != null) { onEvent(event); } } } final trailingEvent = _tryParseStreamEvent(buffer); if (trailingEvent != null) { onEvent(trailingEvent); } } Future> listConversations( String accessToken, { int page = 1, int pageSize = 20, }) async { final uri = Uri.parse(conversationsUrl).replace( queryParameters: { 'page': '$page', 'page_size': '$pageSize', }, ); final response = await http.get( uri, headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取会话列表失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '会话列表响应缺少 data'); final items = data['items']; if (items is! List) { throw const ApiException('会话列表响应缺少 items'); } return items .whereType>() .map(ChatConversationListItem.fromJson) .toList(growable: false); } Future getConversationDetail( String accessToken, String conversationId, ) async { final response = await http.get( Uri.parse('$conversationsUrl/$conversationId'), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取会话详情失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '会话详情响应缺少 data'); return ChatConversationDetail.fromJson(data); } Future fetchSharedAiStatus(String accessToken) async { final response = await http.get( Uri.parse(sharedAiStatusUrl), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException( _extractMessage(body, fallback: '鑾峰彇 Shared AI 鐘舵€佸け璐?'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: 'Shared AI 鐘舵€佸搷搴旂己灏? data'); return SharedAiStatus.fromJson(data); } Future fetchAdminSnapshot(String accessToken) async { final response = await http.get( Uri.parse(adminSnapshotUrl), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '鑾峰彇绠$悊鎽樿澶辫触'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '绠$悊鎽樿鍝嶅簲缂哄皯 data'); return AdminSnapshot.fromJson(data); } Future> listContractTemplates( String accessToken, { String? contractType, }) async { final uri = Uri.parse(contractTemplatesUrl).replace( queryParameters: contractType == null || contractType.trim().isEmpty ? null : {'contract_type': contractType}, ); final response = await http.get( uri, headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取合同模板失败'), statusCode: response.statusCode); } final data = body['data']; if (data is! List) { throw const ApiException('合同模板响应缺少 data'); } return data .whereType>() .map(ContractTemplateItem.fromJson) .toList(growable: false); } Future previewContract( String accessToken, { required String templateId, required Map variableValues, }) async { final request = http.MultipartRequest('POST', Uri.parse('$contractsUrl/preview')) ..headers['Authorization'] = 'Bearer $accessToken' ..fields['template_id'] = templateId ..fields['variable_values_json'] = jsonEncode(variableValues); final streamed = await request.send(); final response = await http.Response.fromStream(streamed); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '合同预览失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '合同预览响应缺少 data'); return ContractPreviewItem.fromJson(data); } Future generateContract( String accessToken, { required String templateId, required Map variableValues, String exportFormat = 'docx', }) async { final request = http.MultipartRequest('POST', Uri.parse('$contractsUrl/generate')) ..headers['Authorization'] = 'Bearer $accessToken' ..fields['template_id'] = templateId ..fields['variable_values_json'] = jsonEncode(variableValues) ..fields['export_format'] = exportFormat; final streamed = await request.send(); final response = await http.Response.fromStream(streamed); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '合同生成失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '合同生成响应缺少 data'); return GeneratedContractItem.fromJson(data); } Future listGeneratedContracts( String accessToken, { int page = 1, int pageSize = 10, String? contractType, String? keyword, }) async { final queryParameters = { 'page': '$page', 'page_size': '$pageSize', }; if (contractType != null && contractType.trim().isNotEmpty) { queryParameters['contract_type'] = contractType.trim(); } if (keyword != null && keyword.trim().isNotEmpty) { queryParameters['keyword'] = keyword.trim(); } final response = await http.get( Uri.parse(contractsUrl).replace(queryParameters: queryParameters), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取合同历史失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '合同历史响应缺少 data'); final items = data['items']; if (items is! List) { throw const ApiException('合同历史响应缺少 items'); } return GeneratedContractListResponse( items: items .whereType>() .map(GeneratedContractListItem.fromJson) .toList(growable: false), total: (data['total'] as num?)?.toInt() ?? 0, page: (data['page'] as num?)?.toInt() ?? page, pageSize: (data['page_size'] as num?)?.toInt() ?? pageSize, ); } Future getGeneratedContract( String accessToken, String contractId, ) async { final response = await http.get( Uri.parse('$contractsUrl/$contractId'), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取合同详情失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '合同详情响应缺少 data'); return GeneratedContractItem.fromJson(data); } Future downloadContractFile( String accessToken, String contractId, ) async { final response = await http.get( Uri.parse('$contractsUrl/$contractId/download'), headers: {'Authorization': 'Bearer $accessToken'}, ); if (response.statusCode != 200) { throw ApiException( _extractDownloadErrorMessage(response), statusCode: response.statusCode, ); } if (response.bodyBytes.isEmpty) { throw const ApiException('合同文件为空,请稍后重试'); } return response.bodyBytes; } Future> submitReview({ required String accessToken, required ReviewDraft draft, }) async { final request = http.MultipartRequest('POST', Uri.parse(reviewTasksUrl)) ..headers['Authorization'] = 'Bearer $accessToken' ..fields.addAll(draft.toFormFields()); final attachment = draft.attachment; if (attachment != null) { request.files.add( http.MultipartFile.fromBytes( 'file', attachment.bytes, filename: attachment.name, contentType: attachment.mimeType == null || attachment.mimeType!.trim().isEmpty ? null : MediaType.parse(attachment.mimeType!), ), ); } final streamed = await request.send(); final response = await http.Response.fromStream(streamed); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '提交审查失败'), statusCode: response.statusCode); } final code = body['code']; if (code is num && code != 0) { throw ApiException(_extractMessage(body, fallback: '提交审查失败')); } return _extractMap(body['data'], fallback: '审查响应缺少 data'); } Map _decodeBody(String body) { final decoded = jsonDecode(body); if (decoded is Map) { return decoded; } throw const ApiException('接口响应格式错误'); } Map _extractMap(Object? value, {required String fallback}) { if (value is Map) { return value; } throw ApiException(fallback); } String _extractMessage(Map body, {required String fallback}) { final detail = body['detail']?.toString(); if (detail != null && detail.isNotEmpty) { return detail; } final message = body['message']?.toString(); if (message != null && message.isNotEmpty) { return message; } return fallback; } static String resolveServerUrl(String path, {String? baseUrl}) { final trimmed = path.trim(); if (trimmed.isEmpty) { return trimmed; } if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { return trimmed; } final configuredBaseUrl = (baseUrl ?? AppConfig.apiBaseUrl).trim(); if (configuredBaseUrl.isEmpty) { return trimmed; } final baseUri = Uri.parse(configuredBaseUrl); final originUri = Uri( scheme: baseUri.scheme, host: baseUri.host, port: baseUri.hasPort ? baseUri.port : null, ); if (trimmed.startsWith('/')) { return originUri.resolve(trimmed).toString(); } if (trimmed.startsWith('api/')) { return originUri.resolve('/$trimmed').toString(); } final normalizedBaseUri = baseUri.replace( path: baseUri.path.endsWith('/') ? baseUri.path : '${baseUri.path}/', ); return normalizedBaseUri.resolve(trimmed).toString(); } ChatStreamEvent? _tryParseStreamEvent(String chunk) { final normalized = chunk.trim(); if (normalized.isEmpty) { return null; } String? dataLine; for (final line in normalized.split('\n')) { final trimmed = line.trim(); if (trimmed.startsWith('data: ')) { dataLine = trimmed.substring(6); break; } } if (dataLine == null || dataLine.isEmpty) { return null; } final decoded = jsonDecode(dataLine); if (decoded is Map) { return ChatStreamEvent.fromJson(decoded); } return null; } bool _shouldAttachAuthorization(Uri uri) { final baseUri = Uri.parse(AppConfig.apiBaseUrl); return uri.scheme == baseUri.scheme && uri.host == baseUri.host && uri.port == baseUri.port; } String _extractDownloadErrorMessage(http.Response response) { final contentType = response.headers['content-type'] ?? ''; if (contentType.contains('application/json') || contentType.contains('text/json') || contentType.contains('text/plain')) { try { final body = _decodeBody(response.body); return _extractMessage(body, fallback: '下载报告失败'); } catch (_) { if (response.body.trim().isNotEmpty) { return response.body.trim(); } } } return '下载报告失败'; } } class ApiException implements Exception { const ApiException(this.message, {this.statusCode}); final String message; final int? statusCode; bool get isUnauthorized => statusCode == 401 || statusCode == 403; @override String toString() => message; }