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

628 lines
21 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/auth/domain/auth_session.dart';
import '../../features/profile/domain/admin_snapshot.dart';
import '../../features/profile/domain/shared_ai_status.dart';
import '../../features/review/domain/review_draft.dart';
import '../../features/review_history/domain/review_detail.dart';
import '../../features/review_history/domain/review_history_item.dart';
import '../../features/review_history/domain/review_result_summary.dart';
import '../constants/api_endpoints.dart';
class ApiClient {
const ApiClient();
String buildUrl(String path) => '${AppConfig.apiBaseUrl}$path';
String get reviewTasksUrl => buildUrl(ApiEndpoints.reviewTasks);
String get loginUrl => buildUrl(ApiEndpoints.login);
String get currentUserUrl => buildUrl(ApiEndpoints.currentUser);
String get adminSnapshotUrl => buildUrl(ApiEndpoints.systemAdminSnapshot);
String get chatStreamUrl => buildUrl(ApiEndpoints.chatStream);
String get conversationsUrl => buildUrl(ApiEndpoints.conversations);
String get sharedAiStatusUrl => buildUrl(ApiEndpoints.systemSharedAi);
String get contractsUrl => buildUrl('/contracts');
String get contractTemplatesUrl => buildUrl('/contracts/templates');
Future<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,
}) async {
final uri = Uri.parse(contractTemplatesUrl).replace(
queryParameters: contractType == null || contractType.trim().isEmpty
? null
: <String, String>{'contract_type': contractType},
);
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $accessToken'},
);
final body = _decodeBody(response.body);
if (response.statusCode != 200) {
throw ApiException(_extractMessage(body, fallback: '获取合同模板失败'), statusCode: response.statusCode);
}
final data = body['data'];
if (data is! List) {
throw const ApiException('合同模板响应缺少 data');
}
return data
.whereType<Map<String, dynamic>>()
.map(ContractTemplateItem.fromJson)
.toList(growable: false);
}
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<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;
}