540 lines
16 KiB
Dart
540 lines
16 KiB
Dart
import 'dart:convert';
|
||
import 'dart:typed_data';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:pointycastle/export.dart';
|
||
import '../models/login_status.dart';
|
||
import '../models/course.dart';
|
||
import 'http_client.dart';
|
||
import '../utils/retry_handler.dart';
|
||
|
||
/// AUFE教务系统连接类
|
||
class AUFEConnection {
|
||
final String userId;
|
||
final String ecPassword;
|
||
final String password;
|
||
|
||
late HTTPClient _client;
|
||
String? _twfId;
|
||
String? _token;
|
||
bool _ecLogged = false;
|
||
bool _uaapLogged = false;
|
||
DateTime _lastCheck = DateTime.now();
|
||
|
||
// 配置常量
|
||
static const String serverUrl = 'https://vpn.aufe.edu.cn';
|
||
static const String uaapLoginUrl =
|
||
'http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3A%2F%2Fjwcxk2.aufe.edu.cn%2Fj_spring_cas_security_check';
|
||
static const String uaapCheckUrl =
|
||
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/';
|
||
static const String ecCheckUrl =
|
||
'http://txzx-aufe-edu-cn-s.vpn2.aufe.edu.cn:8118/dzzy/list.htm';
|
||
|
||
AUFEConnection({
|
||
required this.userId,
|
||
required this.ecPassword,
|
||
required this.password,
|
||
});
|
||
|
||
/// 初始化HTTP客户端
|
||
void startClient() {
|
||
_client = HTTPClient(baseUrl: serverUrl, timeout: 30000);
|
||
}
|
||
|
||
/// EC系统登录(RSA加密)
|
||
Future<ECLoginStatus> ecLogin() async {
|
||
try {
|
||
return await RetryHandler.retry(
|
||
operation: () async => await _performEcLogin(),
|
||
retryIf: RetryHandler.shouldRetryOnError,
|
||
maxAttempts: 3,
|
||
);
|
||
} catch (e) {
|
||
return ECLoginStatus(failUnknownError: true);
|
||
}
|
||
}
|
||
|
||
Future<ECLoginStatus> _performEcLogin() async {
|
||
try {
|
||
// 1. 获取认证参数
|
||
final response = await _client.get('/por/login_auth.csp?apiversion=1');
|
||
final responseText = response.data.toString();
|
||
|
||
// 2. 提取TwfID
|
||
final twfIdMatch = RegExp(
|
||
r'<TwfID>(.*?)</TwfID>',
|
||
).firstMatch(responseText);
|
||
if (twfIdMatch == null) {
|
||
return ECLoginStatus(failNotFoundTwfid: true);
|
||
}
|
||
_twfId = twfIdMatch.group(1);
|
||
|
||
// 3. 提取RSA密钥
|
||
final rsaKeyMatch = RegExp(
|
||
r'<RSA_ENCRYPT_KEY>(.*?)</RSA_ENCRYPT_KEY>',
|
||
).firstMatch(responseText);
|
||
if (rsaKeyMatch == null) {
|
||
return ECLoginStatus(failNotFoundRsaKey: true);
|
||
}
|
||
final rsaKey = rsaKeyMatch.group(1)!;
|
||
|
||
// 4. 提取RSA指数
|
||
final rsaExpMatch = RegExp(
|
||
r'<RSA_ENCRYPT_EXP>(.*?)</RSA_ENCRYPT_EXP>',
|
||
).firstMatch(responseText);
|
||
if (rsaExpMatch == null) {
|
||
return ECLoginStatus(failNotFoundRsaExp: true);
|
||
}
|
||
final rsaExp = rsaExpMatch.group(1)!;
|
||
|
||
// 5. 提取CSRF代码
|
||
final csrfMatch = RegExp(
|
||
r'<CSRF_RAND_CODE>(.*?)</CSRF_RAND_CODE>',
|
||
).firstMatch(responseText);
|
||
if (csrfMatch == null) {
|
||
return ECLoginStatus(failNotFoundCsrfCode: true);
|
||
}
|
||
final csrfCode = csrfMatch.group(1)!;
|
||
|
||
// 6. RSA加密密码
|
||
final passwordToEncrypt = '${ecPassword}_$csrfCode';
|
||
final encryptedPassword = _rsaEncrypt(passwordToEncrypt, rsaKey, rsaExp);
|
||
|
||
// 7. 执行登录
|
||
final loginResponse = await _client.post(
|
||
'/por/login_psw.csp?anti_replay=1&encrypt=1&type=cs',
|
||
data: {
|
||
'svpn_rand_code': '',
|
||
'mitm': '',
|
||
'svpn_req_randcode': csrfCode,
|
||
'svpn_name': userId,
|
||
'svpn_password': encryptedPassword,
|
||
},
|
||
options: Options(
|
||
contentType: Headers.formUrlEncodedContentType,
|
||
headers: {'Cookie': 'TWFID=$_twfId'},
|
||
),
|
||
);
|
||
|
||
final loginResponseText = loginResponse.data.toString();
|
||
|
||
// 8. 检查登录结果
|
||
if (loginResponseText.contains('<Result>1</Result>')) {
|
||
_client.setCookie('TWFID', _twfId!);
|
||
_ecLogged = true;
|
||
return ECLoginStatus(success: true);
|
||
} else if (loginResponseText.contains('Invalid username or password!')) {
|
||
return ECLoginStatus(failInvalidCredentials: true);
|
||
} else if (loginResponseText.contains('[CDATA[maybe attacked]]') ||
|
||
loginResponseText.contains('CAPTCHA required')) {
|
||
return ECLoginStatus(failMaybeAttacked: true);
|
||
} else {
|
||
return ECLoginStatus(failUnknownError: true);
|
||
}
|
||
} on DioException catch (e) {
|
||
return ECLoginStatus(failNetworkError: true);
|
||
} catch (e) {
|
||
return ECLoginStatus(failUnknownError: true);
|
||
}
|
||
}
|
||
|
||
/// RSA加密
|
||
String _rsaEncrypt(String plaintext, String modulusHex, String exponentStr) {
|
||
// 解析模数和指数
|
||
final modulus = BigInt.parse(modulusHex, radix: 16);
|
||
final exponent = BigInt.parse(exponentStr);
|
||
|
||
// 创建RSA公钥
|
||
final publicKey = RSAPublicKey(modulus, exponent);
|
||
|
||
// 创建加密器
|
||
final encryptor = PKCS1Encoding(RSAEngine());
|
||
encryptor.init(true, PublicKeyParameter<RSAPublicKey>(publicKey));
|
||
|
||
// 加密
|
||
final plainBytes = utf8.encode(plaintext);
|
||
final encrypted = encryptor.process(Uint8List.fromList(plainBytes));
|
||
|
||
// 转换为十六进制字符串
|
||
return encrypted.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||
}
|
||
|
||
/// UAAP系统登录(DES加密)
|
||
Future<UAAPLoginStatus> uaapLogin() async {
|
||
try {
|
||
return await RetryHandler.retry(
|
||
operation: () async => await _performUaapLogin(),
|
||
retryIf: RetryHandler.shouldRetryOnError,
|
||
maxAttempts: 3,
|
||
);
|
||
} catch (e) {
|
||
return UAAPLoginStatus(failUnknownError: true);
|
||
}
|
||
}
|
||
|
||
Future<UAAPLoginStatus> _performUaapLogin() async {
|
||
try {
|
||
// 1. 获取登录页面
|
||
final response = await _client.get(uaapLoginUrl);
|
||
final responseText = response.data.toString();
|
||
|
||
// 2. 提取lt参数
|
||
final ltMatch = RegExp(
|
||
r'name="lt" value="(.*?)"',
|
||
).firstMatch(responseText);
|
||
if (ltMatch == null) {
|
||
return UAAPLoginStatus(failNotFoundLt: true);
|
||
}
|
||
final ltValue = ltMatch.group(1)!;
|
||
|
||
// 3. 提取execution参数
|
||
final executionMatch = RegExp(
|
||
r'name="execution" value="(.*?)"',
|
||
).firstMatch(responseText);
|
||
if (executionMatch == null) {
|
||
return UAAPLoginStatus(failNotFoundExecution: true);
|
||
}
|
||
final executionValue = executionMatch.group(1)!;
|
||
|
||
// 4. DES加密密码
|
||
final encryptedPassword = _desEncrypt(password, ltValue);
|
||
|
||
// 5. 提交登录表单
|
||
final loginResponse = await _client.post(
|
||
uaapLoginUrl,
|
||
data: {
|
||
'username': userId,
|
||
'password': encryptedPassword,
|
||
'lt': ltValue,
|
||
'execution': executionValue,
|
||
'_eventId': 'submit',
|
||
'submit': 'LOGIN',
|
||
},
|
||
options: Options(
|
||
contentType: Headers.formUrlEncodedContentType,
|
||
followRedirects: false,
|
||
validateStatus: (status) => status! < 500,
|
||
),
|
||
);
|
||
|
||
// 6. 检查登录结果并访问重定向URL以建立session
|
||
if (loginResponse.statusCode == 302) {
|
||
final location = loginResponse.headers['location']?.first ?? '';
|
||
print('🔐 UAAP redirect location: $location');
|
||
|
||
if (location.contains('ticket=')) {
|
||
// 访问重定向URL以完成CAS认证并建立session
|
||
print('🔐 Following redirect to establish session...');
|
||
final ticketResponse = await _client.get(
|
||
location,
|
||
options: Options(
|
||
followRedirects: true,
|
||
validateStatus: (status) => status! < 500,
|
||
),
|
||
);
|
||
print('🔐 Ticket response status: ${ticketResponse.statusCode}');
|
||
|
||
_uaapLogged = true;
|
||
return UAAPLoginStatus(success: true);
|
||
}
|
||
}
|
||
|
||
final loginResponseText = loginResponse.data.toString();
|
||
if (loginResponseText.contains('Invalid username or password')) {
|
||
return UAAPLoginStatus(failInvalidCredentials: true);
|
||
}
|
||
|
||
return UAAPLoginStatus(failUnknownError: true);
|
||
} on DioException catch (e) {
|
||
return UAAPLoginStatus(failNetworkError: true);
|
||
} catch (e) {
|
||
return UAAPLoginStatus(failUnknownError: true);
|
||
}
|
||
}
|
||
|
||
/// DES加密(使用TripleDES ECB模式)
|
||
String _desEncrypt(String plaintext, String key) {
|
||
// 处理密钥 - 取前8字节
|
||
var keyBytes = utf8.encode(key);
|
||
if (keyBytes.length > 8) {
|
||
keyBytes = keyBytes.sublist(0, 8);
|
||
} else if (keyBytes.length < 8) {
|
||
// 不足8字节用0填充
|
||
keyBytes = Uint8List(8)..setRange(0, keyBytes.length, keyBytes);
|
||
}
|
||
|
||
// 创建DES密钥(TripleDES使用相同的8字节密钥重复3次)
|
||
final desKey = KeyParameter(
|
||
Uint8List(24)
|
||
..setRange(0, 8, keyBytes)
|
||
..setRange(8, 16, keyBytes)
|
||
..setRange(16, 24, keyBytes),
|
||
);
|
||
|
||
// 创建加密器
|
||
final cipher = PaddedBlockCipherImpl(PKCS7Padding(), DESedeEngine());
|
||
cipher.init(true, PaddedBlockCipherParameters(desKey, null));
|
||
|
||
// 加密
|
||
final plainBytes = utf8.encode(plaintext);
|
||
final encrypted = cipher.process(Uint8List.fromList(plainBytes));
|
||
|
||
// Base64编码
|
||
return base64.encode(encrypted);
|
||
}
|
||
|
||
/// 检查EC登录状态
|
||
Future<ECCheckStatus> checkEcLoginStatus() async {
|
||
if (!_ecLogged) {
|
||
return ECCheckStatus(loggedIn: false);
|
||
}
|
||
|
||
try {
|
||
final response = await _client.get(ecCheckUrl);
|
||
if (response.statusCode == 200) {
|
||
return ECCheckStatus(loggedIn: true);
|
||
} else {
|
||
return ECCheckStatus(loggedIn: false);
|
||
}
|
||
} on DioException catch (e) {
|
||
return ECCheckStatus(failNetworkError: true);
|
||
} catch (e) {
|
||
return ECCheckStatus(failUnknownError: true);
|
||
}
|
||
}
|
||
|
||
/// 检查UAAP登录状态
|
||
Future<ECCheckStatus> checkUaapLoginStatus() async {
|
||
return ECCheckStatus(loggedIn: _uaapLogged);
|
||
}
|
||
|
||
/// 健康检查
|
||
Future<bool> healthCheck() async {
|
||
final delta = DateTime.now().difference(_lastCheck);
|
||
|
||
// 5分钟未检查则视为不健康
|
||
if (delta.inSeconds > 300) {
|
||
return false;
|
||
}
|
||
|
||
// 检查UAAP登录状态
|
||
final uaapStatus = await checkUaapLoginStatus();
|
||
if (!uaapStatus.isLoggedIn) {
|
||
return false;
|
||
}
|
||
|
||
// 检查EC登录状态
|
||
final ecStatus = await checkEcLoginStatus();
|
||
if (!ecStatus.isLoggedIn) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// 更新健康检查时间戳
|
||
void healthCheckpoint() {
|
||
_lastCheck = DateTime.now();
|
||
}
|
||
|
||
/// 关闭连接
|
||
Future<void> close() async {
|
||
_client.close();
|
||
}
|
||
|
||
/// 获取HTTP客户端
|
||
HTTPClient get client {
|
||
healthCheckpoint();
|
||
return _client;
|
||
}
|
||
|
||
/// 获取CSRF Token
|
||
Future<String?> getToken() async {
|
||
try {
|
||
final response = await _client.get(
|
||
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/evaluation/index',
|
||
);
|
||
|
||
if (response.statusCode != 200) {
|
||
return null;
|
||
}
|
||
|
||
final html = response.data.toString();
|
||
final tokenMatch = RegExp(
|
||
r'id="tokenValue"[^>]*value="([^"]*)"',
|
||
).firstMatch(html);
|
||
|
||
if (tokenMatch != null) {
|
||
_token = tokenMatch.group(1);
|
||
return _token;
|
||
}
|
||
|
||
return null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 获取待评课程列表
|
||
Future<List<Course>> fetchCourseList() async {
|
||
try {
|
||
final response = await _client.post(
|
||
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/search?sf_request_type=ajax',
|
||
data: {'optType': '1', 'pagesize': '50'},
|
||
options: Options(contentType: Headers.formUrlEncodedContentType),
|
||
);
|
||
|
||
if (response.statusCode != 200) {
|
||
return [];
|
||
}
|
||
|
||
final data = response.data;
|
||
if (data is Map<String, dynamic> && data['data'] is List) {
|
||
final courseList = (data['data'] as List)
|
||
.map((item) => _parseCourse(item))
|
||
.where((course) => course != null)
|
||
.cast<Course>()
|
||
.toList();
|
||
return courseList;
|
||
}
|
||
|
||
return [];
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// 解析课程数据
|
||
Course? _parseCourse(dynamic item) {
|
||
try {
|
||
if (item is! Map<String, dynamic>) return null;
|
||
|
||
final id = item['id'] as Map<String, dynamic>?;
|
||
final questionnaire = item['questionnaire'] as Map<String, dynamic>?;
|
||
|
||
return Course(
|
||
id: id?['evaluatedPeople']?.toString() ?? '',
|
||
name: item['evaluationContent']?.toString() ?? '',
|
||
teacher: item['evaluatedPeople']?.toString() ?? '',
|
||
evaluatedPeople: item['evaluatedPeople']?.toString() ?? '',
|
||
evaluatedPeopleNumber: id?['evaluatedPeople']?.toString() ?? '',
|
||
coureSequenceNumber: id?['coureSequenceNumber']?.toString() ?? '',
|
||
evaluationContentNumber:
|
||
id?['evaluationContentNumber']?.toString() ?? '',
|
||
questionnaireCode:
|
||
questionnaire?['questionnaireNumber']?.toString() ?? '',
|
||
questionnaireName:
|
||
questionnaire?['questionnaireName']?.toString() ?? '',
|
||
isEvaluated: item['isEvaluated']?.toString() == '是',
|
||
);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 访问评价页面并返回HTML内容
|
||
Future<String?> accessEvaluationPage(
|
||
Course course, {
|
||
int? totalCourses,
|
||
}) async {
|
||
try {
|
||
print('📝 Accessing evaluation page for: ${course.name}');
|
||
|
||
if (_token == null) {
|
||
print('📝 Getting token first...');
|
||
await getToken();
|
||
print('📝 Token: $_token');
|
||
}
|
||
|
||
// count is the total number of courses in the course list
|
||
final count = totalCourses?.toString() ?? '28';
|
||
print('📝 Using count: $count (total courses: $totalCourses)');
|
||
|
||
print('📝 Posting to evaluation page...');
|
||
final response = await _client.post(
|
||
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/evaluationPage',
|
||
data: {
|
||
'count': count,
|
||
'evaluatedPeople': course.evaluatedPeople,
|
||
'evaluatedPeopleNumber': course.evaluatedPeopleNumber,
|
||
'questionnaireCode': course.questionnaireCode,
|
||
'questionnaireName': course.questionnaireName,
|
||
'coureSequenceNumber': course.coureSequenceNumber,
|
||
'evaluationContentNumber': course.evaluationContentNumber,
|
||
'evaluationContentContent': '',
|
||
'tokenValue': _token ?? '',
|
||
},
|
||
options: Options(contentType: Headers.formUrlEncodedContentType),
|
||
);
|
||
|
||
print('📝 Access evaluation page status: ${response.statusCode}');
|
||
|
||
if (response.statusCode == 200) {
|
||
final html = response.data.toString();
|
||
print('📝 Got HTML content, length: ${html.length}');
|
||
return html;
|
||
}
|
||
|
||
return null;
|
||
} catch (e, stackTrace) {
|
||
print('❌ accessEvaluationPage error: $e');
|
||
print('❌ Stack trace: $stackTrace');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 提交评价表单
|
||
Future<EvaluationResponse> submitEvaluation(
|
||
Map<String, String> formData,
|
||
) async {
|
||
try {
|
||
print('📤 Submitting evaluation with form data:');
|
||
print('📤 Form data entries: ${formData.length}');
|
||
formData.forEach((key, value) {
|
||
print(
|
||
' $key = ${value.length > 50 ? value.substring(0, 50) + "..." : value}',
|
||
);
|
||
});
|
||
|
||
final response = await _client.post(
|
||
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/assessment?sf_request_type=ajax',
|
||
data: formData,
|
||
options: Options(contentType: Headers.formUrlEncodedContentType),
|
||
);
|
||
|
||
print('📤 Submit response status: ${response.statusCode}');
|
||
print('📤 Submit response data: ${response.data}');
|
||
|
||
if (response.statusCode != 200) {
|
||
return EvaluationResponse(
|
||
result: 'error',
|
||
msg: '网络请求失败 (${response.statusCode})',
|
||
);
|
||
}
|
||
|
||
final data = response.data;
|
||
if (data is Map<String, dynamic>) {
|
||
return EvaluationResponse(
|
||
result: data['result']?.toString() ?? 'error',
|
||
msg: data['msg']?.toString() ?? '未知错误',
|
||
);
|
||
}
|
||
|
||
return EvaluationResponse(result: 'error', msg: '响应格式错误');
|
||
} catch (e) {
|
||
print('❌ Submit evaluation error: $e');
|
||
return EvaluationResponse(result: 'error', msg: '请求异常: $e');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 评价响应
|
||
class EvaluationResponse {
|
||
final String result;
|
||
final String msg;
|
||
|
||
EvaluationResponse({required this.result, required this.msg});
|
||
|
||
bool get isSuccess => result == 'success';
|
||
}
|