Files
system-design/software-copyright/10-writech-app-pad/service/api_service.dart
T
2026-03-22 15:24:40 +08:00

674 lines
19 KiB
Dart

// 自然写互动课堂平板端应用软件 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<T> {
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<String, dynamic> 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<String, dynamic>? 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<String, dynamic> toJson() => {
'id': id,
'method': method,
'path': path,
'data': data,
'created_at': createdAt.toIso8601String(),
'retry_count': retryCount,
};
/// 从JSON反序列化
factory OfflineRequest.fromJson(Map<String, dynamic> 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<bool>? _refreshCompleter;
/// 离线请求队列
final List<OfflineRequest> _offlineQueue = [];
/// 网络状态标志
bool _isOnline = true;
/// API事件流控制器(登录状态变化等)
final StreamController<String> _eventController =
StreamController<String>.broadcast();
/// API事件流
Stream<String> 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<void> _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<void> _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<bool> _refreshAccessToken() async {
// 如果已经在刷新中,等待结果
if (_refreshCompleter != null) {
return _refreshCompleter!.future;
}
_refreshCompleter = Completer<bool>();
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<void> flushOfflineQueue() async {
if (_offlineQueue.isEmpty) return;
_isOnline = true;
final pendingRequests = List<OfflineRequest>.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<ApiResponse<Map<String, dynamic>>> 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<Map<String, dynamic>>.fromJson(
response.data,
(data) => data as Map<String, dynamic>,
);
// 保存登录令牌
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<ApiResponse<Map<String, dynamic>>> 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<Map<String, dynamic>>.fromJson(
response.data,
(data) => data as Map<String, dynamic>,
);
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<ApiResponse<List<dynamic>>> 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<List<dynamic>>.fromJson(
response.data,
(data) => data as List<dynamic>,
);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '获取作业列表失败');
}
}
/// 下载作业详情(含题目内容,支持离线作答)
Future<ApiResponse<Map<String, dynamic>>> downloadHomework(
String homeworkId,
) async {
try {
final response = await _dio.get('/homework/detail/$homeworkId');
return ApiResponse<Map<String, dynamic>>.fromJson(
response.data,
(data) => data as Map<String, dynamic>,
);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '下载作业失败');
}
}
/// 提交作业(含笔迹数据)
Future<ApiResponse<Map<String, dynamic>>> submitHomework({
required String homeworkId,
required List<Map<String, dynamic>> 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<Map<String, dynamic>>.fromJson(
response.data,
(data) => data as Map<String, dynamic>,
);
} on DioException catch (e) {
// 离线时暂存提交请求
if (!_isOnline) {
_enqueueOfflineRequest(e.requestOptions);
}
return ApiResponse(code: -1, message: e.message ?? '提交作业失败');
}
}
/// 获取作业批改结果
Future<ApiResponse<Map<String, dynamic>>> getHomeworkResult(
String homeworkId,
) async {
try {
final response = await _dio.get('/homework/result/$homeworkId');
return ApiResponse<Map<String, dynamic>>.fromJson(
response.data,
(data) => data as Map<String, dynamic>,
);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '获取批改结果失败');
}
}
// ============================================================
// 字帖练习接口
// ============================================================
/// 获取字帖模板列表(按年级/学科分类)
Future<ApiResponse<List<dynamic>>> 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<List<dynamic>>.fromJson(
response.data,
(data) => data as List<dynamic>,
);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '获取字帖失败');
}
}
/// 上传练字笔迹评分
Future<ApiResponse<Map<String, dynamic>>> submitPracticeStroke({
required String templateId,
required String character,
required List<Map<String, dynamic>> strokes,
}) async {
try {
final response = await _dio.post('/copybook/evaluate', data: {
'template_id': templateId,
'character': character,
'strokes': strokes,
});
return ApiResponse<Map<String, dynamic>>.fromJson(
response.data,
(data) => data as Map<String, dynamic>,
);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '提交练字评分失败');
}
}
// ============================================================
// 错题本接口
// ============================================================
/// 获取错题列表(按知识点/科目分类)
Future<ApiResponse<List<dynamic>>> 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<List<dynamic>>.fromJson(
response.data,
(data) => data as List<dynamic>,
);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '获取错题本失败');
}
}
// ============================================================
// 学情与学习计划接口
// ============================================================
/// 获取学生个人学情概览
Future<ApiResponse<Map<String, dynamic>>> getStudentProfile() async {
try {
final response = await _dio.get('/profile/student/overview');
return ApiResponse<Map<String, dynamic>>.fromJson(
response.data,
(data) => data as Map<String, dynamic>,
);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '获取学情失败');
}
}
/// 获取学习计划列表
Future<ApiResponse<List<dynamic>>> getStudyPlans() async {
try {
final response = await _dio.get('/study-plan/list');
return ApiResponse<List<dynamic>>.fromJson(
response.data,
(data) => data as List<dynamic>,
);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '获取学习计划失败');
}
}
/// 更新学习计划进度
Future<ApiResponse<void>> 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<void>.fromJson(response.data, null);
} on DioException catch (e) {
return ApiResponse(code: -1, message: e.message ?? '更新进度失败');
}
}
/// 登出,清除本地令牌
Future<void> 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();
}
}