1026 lines
35 KiB
Dart
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;
|
|
}
|