/// API 服务 — 封装所有 HTTP 请求 import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; class ApiService { late final Dio _dio; String? _baseUrl; String? _token; String? get token => _token; String? get baseUrl => _baseUrl; ApiService() { _dio = Dio(BaseOptions( connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), headers: {'Content-Type': 'application/json'}, )); // 签名拦截器:给每个 API 请求加上客户端签名 _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { if (_token != null && !options.path.contains('/auth/verify') && !options.path.contains('/health')) { final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000; final payload = utf8.encode('${options.method}${options.path}$ts'); final key = utf8.encode(_token!); final sig = Hmac(sha256, key).convert(payload); final sigHex = sig.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); options.headers['X-Timestamp'] = '$ts'; options.headers['X-Signature'] = sigHex; } handler.next(options); }, )); _dio.interceptors.add(LogInterceptor(requestBody: false, responseBody: false)); } // ── 连接配置 ────────────────────────────────────── bool get isConfigured => _baseUrl != null && _token != null; Future loadConfig() async { final prefs = await SharedPreferences.getInstance(); _baseUrl = prefs.getString('server_url'); _token = prefs.getString('api_token'); if (_baseUrl != null) { _dio.options.baseUrl = _baseUrl!; } } Future saveConfig(String url, String token) async { _baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; _token = token; _dio.options.baseUrl = _baseUrl!; final prefs = await SharedPreferences.getInstance(); await prefs.setString('server_url', _baseUrl!); await prefs.setString('api_token', token); } Future verifyToken(String url, String token) async { try { final testDio = Dio(BaseOptions( baseUrl: url.endsWith('/') ? url.substring(0, url.length - 1) : url, connectTimeout: const Duration(seconds: 5), )); final response = await testDio.post('/api/auth/verify', data: {'token': token}); return response.data['valid'] == true; } catch (_) { return false; } } Future clearConfig() async { _baseUrl = null; _token = null; final prefs = await SharedPreferences.getInstance(); await prefs.remove('server_url'); await prefs.remove('api_token'); } Options get _authOptions => Options(headers: { 'Authorization': 'Bearer $_token', }); // ── 健康检查 ────────────────────────────────────── Future> health() async { final response = await _dio.get('/api/health'); return response.data; } // ── 统计 ────────────────────────────────────────── Future> stats() async { final response = await _dio.get('/api/stats', options: _authOptions); return response.data; } // ── 题目列表 ────────────────────────────────────── Future> listProblems({ String? chapter, String? knowledgeCode, String? questionType, String? importType, String? annotated, int page = 1, int pageSize = 20, }) async { final params = { 'page': page, 'page_size': pageSize, }; if (chapter != null && chapter.isNotEmpty) params['chapter'] = chapter; if (knowledgeCode != null && knowledgeCode.isNotEmpty) params['knowledge_code'] = knowledgeCode; if (questionType != null && questionType.isNotEmpty) params['question_type'] = questionType; if (importType != null && importType.isNotEmpty) params['import_type'] = importType; if (annotated != null && annotated.isNotEmpty) params['annotated'] = annotated; final response = await _dio.get('/api/problems', queryParameters: params, options: _authOptions); return response.data; } // WebDAV Future> getWebDAVConfig() async { final response = await _dio.get('/api/admin/backup-config', options: _authOptions); return response.data; } Future setWebDAVConfig(String url, String username, String password) async { await _dio.put('/api/admin/backup-config', data: {'url': url, 'username': username, 'password': password}, options: _authOptions); } Future triggerFullBackup() async { await _dio.post('/api/admin/full-backup', options: _authOptions); } // ── 题目详情 ────────────────────────────────────── Future> problemDetail(String id) async { final response = await _dio.get('/api/problems/$id', options: _authOptions); return response.data; } // ── 题目渲染 ────────────────────────────────────── Future> renderProblem(String id, {bool includeAnswer = true}) async { final response = await _dio.get( '/api/problems/$id/render', queryParameters: {'include_answer': includeAnswer}, options: _authOptions, ); return response.data; } // ── 搜索 ────────────────────────────────────────── Future> search(String query, {int page = 1, int pageSize = 20}) async { final response = await _dio.get( '/api/search', queryParameters: {'q': query, 'page': page, 'page_size': pageSize}, options: _authOptions, ); return response.data; } // ── 元数据更新 ──────────────────────────────────── Future updateMetadata(String id, Map fields) async { await _dio.put('/api/problems/$id/metadata', data: fields, options: _authOptions); } Future updateKnowledge(String id, List codes) async { await _dio.put('/api/problems/$id/knowledge', data: {'knowledge_codes': codes}, options: _authOptions); } // ── 连续标注导航 ────────────────────────────────── Future> nextUnannotated({String? currentId, String direction = 'next'}) async { final params = {'direction': direction}; if (currentId != null) params['current_id'] = currentId; final response = await _dio.get('/api/problems/next-unannotated', queryParameters: params, options: _authOptions); return response.data; } // ── 导入 ────────────────────────────────────────── Future> importDocx(String filePath, String importType) async { final formData = FormData.fromMap({ 'file': await MultipartFile.fromFile(filePath, filename: filePath.split('/').last), 'import_type': importType, }); final response = await _dio.post('/api/import', data: formData, options: _authOptions); return response.data; } Future> importStatus(String taskId) async { final response = await _dio.get('/api/import/$taskId/status', options: _authOptions); return response.data; } // ── 导出 ────────────────────────────────────────── Future> exportChapter(String? chapter, String? importType, String title) async { final response = await _dio.post('/api/export/chapter', data: {'chapter': chapter, 'import_type': importType, 'title': title}, options: _authOptions); return response.data; } Future> exportCart(int cartId, String title) async { final response = await _dio.post('/api/export/cart/$cartId', data: {'title': title}, options: _authOptions); return response.data; } String get downloadUrl => '$_baseUrl/api/exports/'; Future downloadExport(String filename, String savePath) async { await _dio.download('/api/exports/$filename', savePath, options: _authOptions); return savePath; } // ── 知识点 ──────────────────────────────────────── Future> knowledgeTree() async { final response = await _dio.get('/api/knowledge/tree', options: _authOptions); return response.data; } Future> searchKnowledge(String query) async { final response = await _dio.get('/api/knowledge/search', queryParameters: {'q': query}, options: _authOptions); return response.data; } // ── 试题篮 ──────────────────────────────────────── Future> listCarts() async { final response = await _dio.get('/api/carts', options: _authOptions); return response.data; } Future> createCart(String name) async { final response = await _dio.post('/api/carts', data: {'name': name}, options: _authOptions); return response.data; } Future deleteCart(int id) async { await _dio.delete('/api/carts/$id', options: _authOptions); } Future> cartDetail(int id) async { final response = await _dio.get('/api/carts/$id', options: _authOptions); return response.data; } Future addToCart(int cartId, String problemId) async { await _dio.post('/api/carts/$cartId/items', data: {'problem_id': problemId}, options: _authOptions); } Future removeFromCart(int cartId, String problemId) async { await _dio.delete('/api/carts/$cartId/items/$problemId', options: _authOptions); } Future clearCart(int cartId) async { await _dio.post('/api/carts/$cartId/clear', options: _authOptions); } }