Files
ai-contract-platform/mobile/lib/shared/network/api_client.dart
T

1026 lines
35 KiB
Dart

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<AuthSession> 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<AuthSession> 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<List<ReviewHistoryItem>> 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<String, dynamic>>()
.map(ReviewHistoryItem.fromJson)
.toList(growable: false);
}
Future<ReviewDetail> 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<ReviewResultSummary> 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<ReviewReportSummary> 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<Uint8List> downloadReportFile({
required String accessToken,
required String downloadUrl,
}) async {
final uri = Uri.parse(resolveServerUrl(downloadUrl));
final headers = <String, String>{};
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<void> streamChat({
required String accessToken,
required String message,
String? conversationId,
Map<String, dynamic>? 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(<String, dynamic>{
'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<List<ChatConversationListItem>> listConversations(
String accessToken, {
int page = 1,
int pageSize = 20,
}) async {
final uri = Uri.parse(conversationsUrl).replace(
queryParameters: <String, String>{
'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<String, dynamic>>()
.map(ChatConversationListItem.fromJson)
.toList(growable: false);
}
Future<ChatConversationDetail> 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<SharedAiStatus> 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<AdminSnapshot> 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<List<ContractTemplateItem>> listContractTemplates(
String accessToken, {
String? contractType,
String? documentTypeCode,
String? templateCategory,
String? industryCategory,
}) async {
final queryParameters = <String, String>{};
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<String, dynamic>>()
.map(ContractTemplateItem.fromJson)
.toList(growable: false);
}
Future<LegalDocumentTypeListResponse> listLegalDocumentTypes(
String accessToken, {
String? category,
String? industry,
String? scenario,
String? query,
bool activeOnly = true,
}) async {
final queryParameters = <String, String>{
'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<LegalDocumentTypeItem> 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<LegalDocumentElementSchema> 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<LegalDocumentValidationResult> validateLegalDocumentElements(
String accessToken,
String documentTypeCode,
Map<String, dynamic> values,
) async {
final response = await http.post(
Uri.parse(
'$legalDocumentTypesUrl/$documentTypeCode/element-schema/validate'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{'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<LegalDocumentDraftListResponse> listLegalDocumentDrafts(
String accessToken, {
String? documentTypeCode,
String? status = 'draft',
int limit = 20,
}) async {
final queryParameters = <String, String>{
'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<LegalDocumentDraft> createLegalDocumentDraft(
String accessToken, {
required String documentTypeCode,
required Map<String, dynamic> elementValues,
String? title,
String? templateId,
}) async {
final response = await http.post(
Uri.parse(legalDocumentDraftsUrl),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'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<LegalDocumentDraft> updateLegalDocumentDraft(
String accessToken,
String draftId, {
Map<String, dynamic>? 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(<String, dynamic>{
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<LegalDocumentDraft> 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<LegalDocumentDraftGenerateResponse> generateLegalDocumentDraft(
String accessToken,
String draftId, {
String exportFormat = 'docx',
String? templateId,
Map<String, dynamic>? options,
}) async {
final response = await http.post(
Uri.parse('$legalDocumentDraftsUrl/$draftId/generate'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'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<ContractPreviewItem> previewContract(
String accessToken, {
required String templateId,
required Map<String, String> 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<GeneratedContractItem> generateContract(
String accessToken, {
required String templateId,
required Map<String, String> 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<GeneratedContractListResponse> listGeneratedContracts(
String accessToken, {
int page = 1,
int pageSize = 10,
String? contractType,
String? keyword,
}) async {
final queryParameters = <String, String>{
'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<String, dynamic>>()
.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<GeneratedContractItem> 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<Uint8List> 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<GeneratedContractVersionListResponse> 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<GeneratedContractVersionItem> 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 = <String, dynamic>{
'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<GeneratedContractVersionDiffResult> diffGeneratedContractVersions(
String accessToken,
String contractId, {
String? fromVersionId,
String? toVersionId,
}) async {
final queryParameters = <String, String>{};
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<Uint8List> 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<Map<String, dynamic>> 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<String, dynamic> _decodeBody(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const ApiException('接口响应格式错误');
}
Map<String, dynamic> _extractMap(Object? value, {required String fallback}) {
if (value is Map<String, dynamic>) {
return value;
}
throw ApiException(fallback);
}
String _extractMessage(Map<String, dynamic> 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<String, dynamic>) {
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;
}