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/contracts/domain/contract_version_models.dart'; import '../../features/auth/domain/auth_session.dart'; import '../../features/documents/domain/legal_document_models.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 legalDocumentTypesUrl => buildUrl(ApiEndpoints.legalDocumentTypes); String get legalDocumentDraftsUrl => buildUrl(ApiEndpoints.legalDocumentDrafts); 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, String? documentTypeCode, String? templateCategory, String? industryCategory, }) async { final queryParameters = {}; if (contractType != null && contractType.trim().isNotEmpty) { queryParameters['contract_type'] = contractType.trim(); } if (documentTypeCode != null && documentTypeCode.trim().isNotEmpty) { queryParameters['document_type_code'] = documentTypeCode.trim(); } if (templateCategory != null && templateCategory.trim().isNotEmpty) { queryParameters['template_category'] = templateCategory.trim(); } if (industryCategory != null && industryCategory.trim().isNotEmpty) { queryParameters['industry_category'] = industryCategory.trim(); } final uri = Uri.parse(contractTemplatesUrl).replace( queryParameters: queryParameters.isEmpty ? null : queryParameters, ); 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 listLegalDocumentTypes( String accessToken, { String? category, String? industry, String? scenario, String? query, bool activeOnly = true, }) async { final queryParameters = { 'active_only': '$activeOnly', }; if (category != null && category.trim().isNotEmpty) { queryParameters['category'] = category.trim(); } if (industry != null && industry.trim().isNotEmpty) { queryParameters['industry'] = industry.trim(); } if (scenario != null && scenario.trim().isNotEmpty) { queryParameters['scenario'] = scenario.trim(); } if (query != null && query.trim().isNotEmpty) { queryParameters['query'] = query.trim(); } final response = await http.get( Uri.parse(legalDocumentTypesUrl) .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'); return LegalDocumentTypeListResponse.fromJson(data); } Future getLegalDocumentType( String accessToken, String documentTypeCode, ) async { final response = await http.get( Uri.parse('$legalDocumentTypesUrl/$documentTypeCode'), 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 LegalDocumentTypeItem.fromJson(data); } Future getLegalDocumentElementSchema( String accessToken, String documentTypeCode, ) async { final response = await http.get( Uri.parse('$legalDocumentTypesUrl/$documentTypeCode/element-schema'), headers: {'Authorization': 'Bearer $accessToken'}, ); final body = _decodeBody(response.body); if (response.statusCode != 200) { throw ApiException(_extractMessage(body, fallback: '获取文书 schema 失败'), statusCode: response.statusCode); } final data = _extractMap(body['data'], fallback: '文书 schema 响应缺少 data'); return LegalDocumentElementSchema.fromJson(data); } Future validateLegalDocumentElements( String accessToken, String documentTypeCode, Map values, ) async { final response = await http.post( Uri.parse( '$legalDocumentTypesUrl/$documentTypeCode/element-schema/validate'), headers: { 'Authorization': 'Bearer $accessToken', 'Content-Type': 'application/json', }, body: jsonEncode({'values': values}), ); 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 LegalDocumentValidationResult.fromJson(data); } Future listLegalDocumentDrafts( String accessToken, { String? documentTypeCode, String? status = 'draft', int limit = 20, }) async { final queryParameters = { 'status': status ?? 'draft', 'limit': '$limit', }; if (documentTypeCode != null && documentTypeCode.trim().isNotEmpty) { queryParameters['document_type_code'] = documentTypeCode.trim(); } final response = await http.get( Uri.parse(legalDocumentDraftsUrl) .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'); return LegalDocumentDraftListResponse.fromJson(data); } Future createLegalDocumentDraft( String accessToken, { required String documentTypeCode, required Map elementValues, String? title, String? templateId, }) async { final response = await http.post( Uri.parse(legalDocumentDraftsUrl), headers: { 'Authorization': 'Bearer $accessToken', 'Content-Type': 'application/json', }, body: jsonEncode({ 'document_type_code': documentTypeCode, 'element_values': elementValues, if (title != null && title.trim().isNotEmpty) 'title': title.trim(), if (templateId != null && templateId.trim().isNotEmpty) 'template_id': templateId.trim(), }), ); 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 LegalDocumentDraft.fromJson(data); } Future updateLegalDocumentDraft( String accessToken, String draftId, { Map? elementValues, String? title, String? templateId, bool clearTemplateId = false, }) async { final response = await http.put( Uri.parse('$legalDocumentDraftsUrl/$draftId'), headers: { 'Authorization': 'Bearer $accessToken', 'Content-Type': 'application/json', }, body: jsonEncode({ if (elementValues != null) 'element_values': elementValues, if (title != null) 'title': title.trim(), if (templateId != null) 'template_id': templateId.trim() else if (clearTemplateId) 'template_id': null, }), ); 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 LegalDocumentDraft.fromJson(data); } Future deleteLegalDocumentDraft( String accessToken, String draftId, ) async { final response = await http.delete( Uri.parse('$legalDocumentDraftsUrl/$draftId'), 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 LegalDocumentDraft.fromJson(data); } Future generateLegalDocumentDraft( String accessToken, String draftId, { String exportFormat = 'docx', String? templateId, Map? options, }) async { final response = await http.post( Uri.parse('$legalDocumentDraftsUrl/$draftId/generate'), headers: { 'Authorization': 'Bearer $accessToken', 'Content-Type': 'application/json', }, body: jsonEncode({ 'export_format': exportFormat, if (templateId != null && templateId.trim().isNotEmpty) 'template_id': templateId.trim(), if (options != null && options.isNotEmpty) 'options': options, }), ); 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 LegalDocumentDraftGenerateResponse.fromJson(data); } 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 listGeneratedContractVersions( String accessToken, String contractId, ) async { final response = await http.get( Uri.parse('$contractsUrl/$contractId/versions'), 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 GeneratedContractVersionListResponse.fromJson(data); } Future createGeneratedContractVersion( String accessToken, String contractId, { required String content, String changeType = 'editor_save', String? changeSummary, String? sourceVersionId, String? originalText, String? replacementText, String locateStatus = 'applied', }) async { final payload = { 'content': content, 'change_type': changeType, 'locate_status': locateStatus, if (changeSummary != null && changeSummary.trim().isNotEmpty) 'change_summary': changeSummary.trim(), if (sourceVersionId != null && sourceVersionId.trim().isNotEmpty) 'source_version_id': sourceVersionId.trim(), if (originalText != null && originalText.trim().isNotEmpty) 'original_text': originalText.trim(), if (replacementText != null && replacementText.trim().isNotEmpty) 'replacement_text': replacementText.trim(), }; final response = await http.post( Uri.parse('$contractsUrl/$contractId/versions'), headers: { 'Authorization': 'Bearer $accessToken', 'Content-Type': 'application/json', }, body: jsonEncode(payload), ); 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 GeneratedContractVersionItem.fromJson(data, contractId: contractId); } Future diffGeneratedContractVersions( String accessToken, String contractId, { String? fromVersionId, String? toVersionId, }) async { final queryParameters = {}; if (fromVersionId != null && fromVersionId.trim().isNotEmpty) { queryParameters['from_version_id'] = fromVersionId.trim(); } if (toVersionId != null && toVersionId.trim().isNotEmpty) { queryParameters['to_version_id'] = toVersionId.trim(); } final uri = Uri.parse('$contractsUrl/$contractId/versions/diff').replace( queryParameters: queryParameters.isEmpty ? null : queryParameters); 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'); return GeneratedContractVersionDiffResult.fromJson(data); } Future downloadGeneratedContractVersionFile( String accessToken, String contractId, String versionId, { String exportFormat = 'docx', }) async { final response = await http.get( Uri.parse('$contractsUrl/$contractId/versions/$versionId/download') .replace(queryParameters: {'export_format': exportFormat}), 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; }