// 自然写互动课堂平板端应用软件 V1.0 // service/api_service.dart - 云平台API服务(Dio HTTP客户端) import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:crypto/crypto.dart'; /// 云平台API基础路径配置 class ApiConfig { /// 生产环境API地址 static const String productionBaseUrl = 'https://api.writech.com/v1'; /// 测试环境API地址 static const String stagingBaseUrl = 'https://staging-api.writech.com/v1'; /// 连接超时时间(毫秒) static const int connectTimeout = 15000; /// 接收超时时间(毫秒) static const int receiveTimeout = 30000; /// Token刷新路径 static const String refreshTokenPath = '/auth/refresh'; /// 最大重试次数 static const int maxRetryCount = 3; /// HMAC签名密钥标识 static const String hmacKeyId = 'writech-pad-v1'; } /// API响应数据统一封装 class ApiResponse { final int code; final String message; final T? data; final String? requestId; ApiResponse({ required this.code, required this.message, this.data, this.requestId, }); /// 判断请求是否成功 bool get isSuccess => code == 0 || code == 200; /// 从JSON解析响应 factory ApiResponse.fromJson( Map json, T Function(dynamic)? fromData, ) { return ApiResponse( code: json['code'] ?? -1, message: json['message'] ?? '未知错误', data: json['data'] != null && fromData != null ? fromData(json['data']) : json['data'] as T?, requestId: json['request_id'], ); } } /// 离线请求队列项 class OfflineRequest { final String id; final String method; final String path; final Map? data; final DateTime createdAt; int retryCount; OfflineRequest({ required this.id, required this.method, required this.path, this.data, required this.createdAt, this.retryCount = 0, }); /// 序列化为JSON用于本地持久化 Map toJson() => { 'id': id, 'method': method, 'path': path, 'data': data, 'created_at': createdAt.toIso8601String(), 'retry_count': retryCount, }; /// 从JSON反序列化 factory OfflineRequest.fromJson(Map json) { return OfflineRequest( id: json['id'], method: json['method'], path: json['path'], data: json['data'], createdAt: DateTime.parse(json['created_at']), retryCount: json['retry_count'] ?? 0, ); } } /// 平板端云平台API服务 /// 负责与云平台的所有HTTP通信,包括: /// - JWT双令牌认证与自动刷新 /// - HMAC-SHA256请求签名 /// - 离线请求队列暂存 /// - 学生简化登录(班级+姓名/学号) class PadApiService { late final Dio _dio; final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); /// 当前访问令牌 String? _accessToken; /// 刷新令牌 String? _refreshToken; /// Token刷新锁,防止并发刷新 Completer? _refreshCompleter; /// 离线请求队列 final List _offlineQueue = []; /// 网络状态标志 bool _isOnline = true; /// API事件流控制器(登录状态变化等) final StreamController _eventController = StreamController.broadcast(); /// API事件流 Stream get eventStream => _eventController.stream; /// 单例实例 static PadApiService? _instance; /// 获取单例 static PadApiService get instance { _instance ??= PadApiService._internal(); return _instance!; } /// 私有构造函数,初始化Dio客户端 PadApiService._internal() { _dio = Dio(BaseOptions( baseUrl: ApiConfig.productionBaseUrl, connectTimeout: Duration(milliseconds: ApiConfig.connectTimeout), receiveTimeout: Duration(milliseconds: ApiConfig.receiveTimeout), headers: { 'Content-Type': 'application/json', 'X-Client-Platform': 'pad', 'X-Client-Version': '1.0.0', }, )); // 添加请求拦截器:自动附加Token和HMAC签名 _dio.interceptors.add(InterceptorsWrapper( onRequest: _onRequest, onResponse: _onResponse, onError: _onError, )); // 从安全存储恢复令牌 _restoreTokens(); } /// 从安全存储恢复上次保存的令牌 Future _restoreTokens() async { _accessToken = await _secureStorage.read(key: 'access_token'); _refreshToken = await _secureStorage.read(key: 'refresh_token'); } /// 请求拦截器:附加Authorization头和HMAC签名 void _onRequest( RequestOptions options, RequestInterceptorHandler handler, ) { // 附加JWT访问令牌 if (_accessToken != null) { options.headers['Authorization'] = 'Bearer $_accessToken'; } // 生成HMAC-SHA256请求签名 final timestamp = DateTime.now().millisecondsSinceEpoch.toString(); options.headers['X-Timestamp'] = timestamp; final signature = _generateSignature( options.method, options.path, timestamp, options.data, ); options.headers['X-Signature'] = signature; handler.next(options); } /// 响应拦截器:统一处理响应 void _onResponse( Response response, ResponseInterceptorHandler handler, ) { handler.next(response); } /// 错误拦截器:处理401自动刷新Token、离线暂存等 Future _onError( DioException error, ErrorInterceptorHandler handler, ) async { // 网络不可用时,将请求加入离线队列 if (error.type == DioExceptionType.connectionError || error.type == DioExceptionType.connectionTimeout) { _isOnline = false; _enqueueOfflineRequest(error.requestOptions); handler.reject(error); return; } // 401未授权:尝试刷新Token后重试 if (error.response?.statusCode == 401) { final refreshSuccess = await _refreshAccessToken(); if (refreshSuccess) { // Token刷新成功,使用新Token重试原请求 final retryOptions = error.requestOptions; retryOptions.headers['Authorization'] = 'Bearer $_accessToken'; try { final response = await _dio.fetch(retryOptions); handler.resolve(response); return; } catch (retryError) { // 重试也失败了 } } else { // Token刷新失败,通知登出 _eventController.add('token_expired'); } } handler.reject(error); } /// 生成HMAC-SHA256请求签名 /// 签名内容: METHOD\nPATH\nTIMESTAMP\nBODY_SHA256 String _generateSignature( String method, String path, String timestamp, dynamic body, ) { // 计算请求体SHA256哈希 String bodyHash = ''; if (body != null) { final bodyStr = body is String ? body : jsonEncode(body); bodyHash = sha256.convert(utf8.encode(bodyStr)).toString(); } // 拼接签名原文 final signContent = '$method\n$path\n$timestamp\n$bodyHash'; final hmacKey = utf8.encode(ApiConfig.hmacKeyId); final hmac = Hmac(sha256, hmacKey); final digest = hmac.convert(utf8.encode(signContent)); return digest.toString(); } /// 刷新访问令牌 /// 使用Completer防止并发多次刷新 Future _refreshAccessToken() async { // 如果已经在刷新中,等待结果 if (_refreshCompleter != null) { return _refreshCompleter!.future; } _refreshCompleter = Completer(); try { if (_refreshToken == null) { _refreshCompleter!.complete(false); return false; } // 发送刷新请求(不经过拦截器避免死循环) final response = await Dio().post( '${ApiConfig.productionBaseUrl}${ApiConfig.refreshTokenPath}', data: {'refresh_token': _refreshToken}, ); if (response.statusCode == 200 && response.data['code'] == 0) { _accessToken = response.data['data']['access_token']; _refreshToken = response.data['data']['refresh_token']; // 持久化新令牌到安全存储 await _secureStorage.write( key: 'access_token', value: _accessToken, ); await _secureStorage.write( key: 'refresh_token', value: _refreshToken, ); _refreshCompleter!.complete(true); return true; } _refreshCompleter!.complete(false); return false; } catch (e) { _refreshCompleter!.complete(false); return false; } finally { _refreshCompleter = null; } } /// 将失败的请求加入离线队列 void _enqueueOfflineRequest(RequestOptions options) { final offlineReq = OfflineRequest( id: DateTime.now().microsecondsSinceEpoch.toString(), method: options.method, path: options.path, data: options.data is Map ? options.data : null, createdAt: DateTime.now(), ); _offlineQueue.add(offlineReq); } /// 网络恢复后,批量重发离线队列中的请求 Future flushOfflineQueue() async { if (_offlineQueue.isEmpty) return; _isOnline = true; final pendingRequests = List.from(_offlineQueue); _offlineQueue.clear(); for (final req in pendingRequests) { try { if (req.retryCount >= ApiConfig.maxRetryCount) continue; req.retryCount++; switch (req.method.toUpperCase()) { case 'POST': await _dio.post(req.path, data: req.data); break; case 'PUT': await _dio.put(req.path, data: req.data); break; case 'DELETE': await _dio.delete(req.path); break; default: await _dio.get(req.path); } } catch (e) { // 重发失败的请求重新加入队列 if (req.retryCount < ApiConfig.maxRetryCount) { _offlineQueue.add(req); } } } } // ============================================================ // 学生登录接口(简化登录,班级+姓名/学号) // ============================================================ /// 学生简化登录(无需手机号,仅班级+姓名或学号) Future>> studentLogin({ required String schoolCode, required String classId, required String studentName, String? studentNo, }) async { try { final response = await _dio.post('/auth/student/login', data: { 'school_code': schoolCode, 'class_id': classId, 'student_name': studentName, 'student_no': studentNo, 'device_type': 'pad', }); final result = ApiResponse>.fromJson( response.data, (data) => data as Map, ); // 保存登录令牌 if (result.isSuccess && result.data != null) { _accessToken = result.data!['access_token']; _refreshToken = result.data!['refresh_token']; await _secureStorage.write( key: 'access_token', value: _accessToken, ); await _secureStorage.write( key: 'refresh_token', value: _refreshToken, ); } return result; } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '网络请求失败'); } } /// 教师登录(手机号+验证码) Future>> teacherLogin({ required String phone, required String verifyCode, }) async { try { final response = await _dio.post('/auth/teacher/login', data: { 'phone': phone, 'verify_code': verifyCode, 'device_type': 'pad', }); final result = ApiResponse>.fromJson( response.data, (data) => data as Map, ); if (result.isSuccess && result.data != null) { _accessToken = result.data!['access_token']; _refreshToken = result.data!['refresh_token']; await _secureStorage.write( key: 'access_token', value: _accessToken, ); await _secureStorage.write( key: 'refresh_token', value: _refreshToken, ); } return result; } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '网络请求失败'); } } // ============================================================ // 作业相关接口 // ============================================================ /// 获取学生待完成作业列表 Future>> getHomeworkList({ int page = 1, int pageSize = 20, String? status, }) async { try { final response = await _dio.get('/homework/list', queryParameters: { 'page': page, 'page_size': pageSize, if (status != null) 'status': status, }); return ApiResponse>.fromJson( response.data, (data) => data as List, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取作业列表失败'); } } /// 下载作业详情(含题目内容,支持离线作答) Future>> downloadHomework( String homeworkId, ) async { try { final response = await _dio.get('/homework/detail/$homeworkId'); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '下载作业失败'); } } /// 提交作业(含笔迹数据) Future>> submitHomework({ required String homeworkId, required List> strokePages, int? timeCostSeconds, }) async { try { final response = await _dio.post('/homework/submit', data: { 'homework_id': homeworkId, 'stroke_pages': strokePages, 'time_cost': timeCostSeconds, 'submit_time': DateTime.now().toIso8601String(), }); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { // 离线时暂存提交请求 if (!_isOnline) { _enqueueOfflineRequest(e.requestOptions); } return ApiResponse(code: -1, message: e.message ?? '提交作业失败'); } } /// 获取作业批改结果 Future>> getHomeworkResult( String homeworkId, ) async { try { final response = await _dio.get('/homework/result/$homeworkId'); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取批改结果失败'); } } // ============================================================ // 字帖练习接口 // ============================================================ /// 获取字帖模板列表(按年级/学科分类) Future>> getCopybookTemplates({ required String grade, String? subject, int page = 1, }) async { try { final response = await _dio.get('/copybook/templates', queryParameters: { 'grade': grade, 'subject': subject, 'page': page, }); return ApiResponse>.fromJson( response.data, (data) => data as List, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取字帖失败'); } } /// 上传练字笔迹评分 Future>> submitPracticeStroke({ required String templateId, required String character, required List> strokes, }) async { try { final response = await _dio.post('/copybook/evaluate', data: { 'template_id': templateId, 'character': character, 'strokes': strokes, }); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '提交练字评分失败'); } } // ============================================================ // 错题本接口 // ============================================================ /// 获取错题列表(按知识点/科目分类) Future>> getErrorBookList({ String? subject, String? knowledgePoint, int page = 1, int pageSize = 20, }) async { try { final response = await _dio.get('/error-book/list', queryParameters: { if (subject != null) 'subject': subject, if (knowledgePoint != null) 'knowledge_point': knowledgePoint, 'page': page, 'page_size': pageSize, }); return ApiResponse>.fromJson( response.data, (data) => data as List, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取错题本失败'); } } // ============================================================ // 学情与学习计划接口 // ============================================================ /// 获取学生个人学情概览 Future>> getStudentProfile() async { try { final response = await _dio.get('/profile/student/overview'); return ApiResponse>.fromJson( response.data, (data) => data as Map, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取学情失败'); } } /// 获取学习计划列表 Future>> getStudyPlans() async { try { final response = await _dio.get('/study-plan/list'); return ApiResponse>.fromJson( response.data, (data) => data as List, ); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '获取学习计划失败'); } } /// 更新学习计划进度 Future> updateStudyPlanProgress({ required String planId, required String taskId, required double progress, }) async { try { final response = await _dio.put('/study-plan/progress', data: { 'plan_id': planId, 'task_id': taskId, 'progress': progress, 'update_time': DateTime.now().toIso8601String(), }); return ApiResponse.fromJson(response.data, null); } on DioException catch (e) { return ApiResponse(code: -1, message: e.message ?? '更新进度失败'); } } /// 登出,清除本地令牌 Future logout() async { try { await _dio.post('/auth/logout'); } catch (_) { // 忽略登出请求失败 } _accessToken = null; _refreshToken = null; await _secureStorage.delete(key: 'access_token'); await _secureStorage.delete(key: 'refresh_token'); _eventController.add('logged_out'); } /// 释放资源 void dispose() { _eventController.close(); _dio.close(); } }