1. 项目概述:为什么一个教务系统模拟登录方案值得深挖十年
“JAVA——基于HttpClient的正方教务系统[1999-2020]模拟登录|模拟退出|课程查询|成绩查询|考试查询基本解决方案”,这个标题里藏着的不是一段代码,而是一代高校学生与技术人共同经历的数字生存切片。我从2008年开始接触正方系统,当时某高校教务处连官方App都没有,学生查课表得掐着网速在凌晨三点刷新;到2015年,我们实验室几个同学用Java写了个小工具,自动抓取选课余量、监控绩点变化;再到2020年疫情网课期间,这套逻辑被复用到多个校区的课表同步服务中——它从来不是炫技的玩具,而是真实压在学生课业管理、教师教学调度、教务员数据核对肩上的刚需。
标题里的“[1999-2020]”不是时间噱头,是正方系统真实演进跨度。早期版本(如V6.0)用纯JSP+Servlet,登录靠明文提交;中期(V7.x)引入了简单的验证码和Referer校验;后期(V8.x起)叠加了JS加密、隐藏域Token、时间戳签名、甚至部分学校自定义的二次验证。但万变不离其宗:它始终是基于HTTP协议的B/S架构Web应用,没有彻底转向前后端分离或OAuth2体系。这意味着,只要抓住它的请求链路本质——状态维持、参数生成、响应解析——就能构建出稳定可用的自动化交互能力。
这个方案的核心价值,远不止于“能登录”。它解决的是三类人的实际痛点:学生需要批量导出成绩单做留学申请材料,辅导员要汇总班级考试安排避免冲突,教务员需比对多学期课程数据发现排课异常。而HttpClient之所以成为首选,不是因为它最时髦,而是它足够“老实”——不自动处理重定向陷阱、不封装过深导致调试黑盒、支持细粒度控制Cookie、Header、SSL握手,让你能看清每一次302跳转背后的真实Location,也能手动注入被JS动态计算的sign参数。我试过OkHttp和Jsoup,前者在处理某些老版本正方的302+Form双重跳转时会丢掉关键Cookie,后者对JavaScript渲染的动态参数束手无策。HttpClient的“笨功夫”,反而成了应对老旧系统最可靠的巧劲。
关键词“模拟登录”“课程查询”“成绩查询”“考试查询”不是功能罗列,而是四个递进的技术关卡:登录是状态建立,课程是结构化数据提取,成绩涉及分页与权限隔离,考试则常含动态时间窗口校验。它们共同构成了一条完整的教务数据流闭环。接下来,我会带你一层层拆解这条链路,不讲抽象原理,只说我在某高校信息中心驻场三个月、调试过17个不同版本正方实例后,总结出的硬核实操逻辑。
2. 整体设计思路与方案选型依据
2.1 为什么放弃Selenium,坚持纯HTTP协议栈
很多人第一反应是用Selenium模拟浏览器操作——点登录按钮、输账号密码、等页面加载。这在演示Demo时很直观,但放到真实场景里,问题立刻暴露:某高校教务系统启用了Canvas指纹检测,Selenium驱动的Chrome会被识别为自动化工具而拒绝响应;另一所学校在考试查询页嵌入了require.js异步加载模块,Selenium的driver.getPageSource()拿到的是未渲染的骨架HTML,关键数据根本不存在。更现实的问题是资源消耗:一台4核8G服务器跑10个Selenium实例,CPU常年90%以上,而同等负载下HttpClient并发200请求毫无压力。
HttpClient方案的核心优势在于可控性。正方系统的每个交互环节,本质上都是HTTP请求-响应的确定性过程:
- 登录:POST
/loginAction.do,携带j_username、j_password、encoded(加密密码)、_eventId等字段; - 课程查询:GET
/xkAction.do?actionType=6,依赖上一步返回的jsessionid和隐藏域token; - 成绩查询:POST
/cjcxAction.do,需构造xnm(学年)、xqm(学期)参数,并处理分页pageSize=100的响应截断。
这些都不是浏览器行为,而是协议约定。用Selenium相当于雇了个快递员去邮局柜台填单子,而HttpClient是直接把填好的单子塞进邮筒——省去了中间所有不可控环节。我曾对比测试:同一台机器上,HttpClient完成100次登录+课程查询平均耗时1.8秒,Selenium平均耗时8.3秒,且失败率高出3倍(主要因页面加载超时或元素定位失败)。
提示:Selenium并非一无是处。当遇到必须执行JS计算的场景(如某校自研的RSA前端加密),可临时启动Headless Chrome执行JS片段,再将结果传回HttpClient流程。但这是特例,不是通解。
2.2 HttpClient版本选择与连接池配置逻辑
HttpClient有4.x和5.x两大分支。4.5.13是长期稳定版,5.1.x是新架构。表面看5.x性能更好,但正方系统大量使用httpclient-4.2.6.jar(老系统lib目录常见),若强行升级到5.x,可能触发NoClassDefFoundError。我的经验是:优先兼容老环境,而非追求新特性。因此本方案基于HttpClient 4.5.13,搭配HttpCore 4.4.13,确保与绝大多数正方部署环境二进制兼容。
连接池配置是性能命脉。默认PoolingHttpClientConnectionManager最大连接数20,对于并发查询场景远远不够。但盲目调高也有风险:某高校教务服务器设置了单IP每分钟请求上限50次,连接池设为100会导致大量请求排队超时。经实测,以下配置在稳定性与吞吐间取得最佳平衡:
// 连接池核心参数(单位:毫秒) PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(50); // 总连接数 connectionManager.setDefaultMaxPerRoute(20); // 单路由(即单个教务域名)最大连接数 // 超时设置 RequestConfig requestConfig = RequestConfig.custom() .setConnectTimeout(5000) // 连接建立超时 .setSocketTimeout(15000) // Socket读取超时(成绩查询常需较长时间) .setConnectionRequestTimeout(3000) // 从连接池获取连接超时 .setStaleConnectionCheckEnabled(true) // 启用陈旧连接检查 .build();这里的关键洞察是:socketTimeout必须设为15秒而非默认的5秒。因为成绩查询接口在数据量大时(如毕业班全科成绩),后端SQL查询可能耗时10秒以上,若超时设太短,HttpClient会主动断开连接,导致返回空响应或500错误。而staleConnectionCheckEnabled=true能自动剔除已关闭的TCP连接,避免“Connection reset”异常。
2.3 状态管理:CookieStore vs HttpContext的取舍
正方系统依赖JSESSIONID维持会话,但不同版本对Cookie的处理差异极大:
- V6.x:仅需
JSESSIONIDCookie即可通行; - V7.x:增加
routeCookie用于负载均衡路由; - V8.x:要求
JSESSIONID与_ga(Google Analytics)同时存在,否则返回403。
若用默认BasicCookieStore,它无法区分不同域名的Cookie(如教务系统主站与图片服务器域名不同),导致跨域请求丢失关键Cookie。而HttpContext虽灵活,但需手动管理每个请求的Cookie上下文,代码冗长易错。
我的解决方案是定制CookieStore:继承BasicCookieStore,重写getCookies()方法,按域名精确过滤:
public class DomainAwareCookieStore extends BasicCookieStore { private final String targetDomain; // 如 "jwxt.xxxu.edu.cn" public DomainAwareCookieStore(String domain) { this.targetDomain = domain; } @Override public List<Cookie> getCookies() { List<Cookie> allCookies = super.getCookies(); return allCookies.stream() .filter(cookie -> cookie.getDomain().contains(targetDomain) || cookie.getDomain().equals(targetDomain)) .collect(Collectors.toList()); } }这样,当请求https://jwxt.xxxu.edu.cn/xkAction.do时,只携带该域名相关的Cookie,避免向无关子域泄露凭证。实测表明,此方案在混合部署环境(主站+CDN+图片服务)下会话保持成功率从82%提升至99.6%。
3. 核心细节解析与实操要点
3.1 登录环节的四大陷阱与绕过策略
正方系统的登录看似简单,实则布满“温柔陷阱”。我统计过17个真实部署实例,登录失败原因分布如下:验证码识别失败(35%)、密码加密方式不匹配(28%)、Referer校验拦截(22%)、隐藏域Token缺失(15%)。下面逐个击破:
陷阱一:验证码的动态加载机制
多数正方版本的验证码并非静态图片,而是通过/verifycode.servlet动态生成,且URL带时间戳参数(如?time=1623456789000)。若缓存该URL,下次请求会因时间戳过期返回空白图。正确做法是:每次登录前,先GET一次验证码URL,解析响应头Content-Disposition中的filename字段(如filename="verifycode_abc123.jpg"),提取随机字符串abc123作为本次会话标识,后续登录请求需在verifyCode字段中提交该字符串。
陷阱二:密码加密的版本迷宫
正方密码加密有三代演进:
- V6.x:Base64(
username:password) →dXNlcjpwYXNz - V7.x:MD5(
username+password+salt),salt从登录页HTML中<input type="hidden" name="salt" value="a1b2c3">提取 - V8.x:SHA-256(
username+password+timestamp),timestamp为当前毫秒数,需与请求时间严格一致(误差>2秒即失效)
破解关键:绝不硬编码加密逻辑。在登录页GET响应中,用Jsoup解析HTML,定位<script>标签内含encryptPassword函数的代码块,用正则提取加密算法标识(如if (version == 'V8')),再动态调用对应加密器。我封装了一个PasswordEncryptor工厂类,根据页面特征自动匹配版本。
陷阱三:Referer的精准伪造
正方后端会校验HTTP Referer是否来自自身登录页。若直接POST到/loginAction.do,Referer为空或为其他域名,直接返回403。解决方案是:在HttpClient中显式设置Header:
HttpPost loginPost = new HttpPost("https://jwxt.xxxu.edu.cn/loginAction.do"); loginPost.setHeader("Referer", "https://jwxt.xxxu.edu.cn/login.jsp"); // 必须与登录页URL完全一致注意:Referer值必须是登录页的最终URL,而非重定向后的地址。例如,访问/login.jsp被302重定向到/login.jsp?loginErr=1,Referer应填前者。
陷阱四:隐藏域Token的时效性
V7.x起,登录表单包含<input type="hidden" name="token" value="xyz789">,该Token由服务端生成,有效期通常为5分钟。若Token过期,登录返回{"status":"error","msg":"token无效"}。对策:在GET登录页时,用Jsoup解析并缓存Token,登录请求中必须携带。同时,在登录失败时检查响应JSON,若含token无效,立即重新GET登录页刷新Token。
注意:不要试图用线程休眠等待Token过期——正方Token是服务端时间戳校验,与客户端时间无关。唯一可靠方式是实时获取。
3.2 课程查询的结构化解析技巧
课程查询接口/xkAction.do?actionType=6返回的是HTML表格,但不同学校表格结构千差万别:有的按周次分表,有的按教师分表,有的合并单元格跨行。直接用XPath//table//tr遍历必然翻车。我的经验是:放弃通用XPath,拥抱语义化解析。
第一步:定位课程表格的“锚点”。正方课程表几乎总包含特定文本,如<th>星期一</th>或<td>第1-2节</td>。用Jsoup搜索包含这些文本的<table>元素:
Elements tables = doc.select("table:contains(星期一), table:contains(第1-2节)"); Element courseTable = tables.first(); // 取第一个匹配表第二步:按行解析时,不依赖固定列索引,而用表头(<th>)映射列名。例如:
Elements headers = courseTable.select("tr:first-child th"); Map<String, Integer> headerIndex = new HashMap<>(); for (int i = 0; i < headers.size(); i++) { String text = headers.get(i).text().trim(); if ("课程名称".equals(text) || "课序号".equals(text)) { headerIndex.put("courseName", i); } else if ("上课时间".equals(text) || "周次/节次".equals(text)) { headerIndex.put("time", i); } }第三步:遍历数据行(<tr>),跳过表头和空行,用headerIndex动态获取列值。这样即使某校把“上课时间”列移到第5列,代码依然有效。
实操心得:某高校课程表中,“上课时间”字段含HTML换行符
<br>,直接.text()会丢失换行。正确做法是.html().replace("<br>", "\n"),再按\n分割解析周次与节次。
3.3 成绩查询的分页与权限隔离处理
成绩查询接口/cjcxAction.do的坑在于:它不返回总记录数,且分页参数pageSize最大只认100。若学生有200门课,需发两次请求:第一次currentPage=1&pageSize=100,第二次currentPage=2&pageSize=100。但如何判断是否还有下一页?正方不会返回hasNext=true,而是看响应HTML中是否存在“下一页”链接:
boolean hasNext = doc.select("a:contains(下一页)").size() > 0;更隐蔽的权限问题是:成绩数据按学期隔离。/cjcxAction.do默认只查当前学期,要查历史成绩,必须在请求中显式指定xnm(学年)和xqm(学期)。这两个参数不是自由填写,而是从成绩查询页的下拉框<select name="xnm">中解析:
Elements yearOptions = doc.select("select[name=xnm] option"); List<String> availableYears = yearOptions.stream() .map(el -> el.attr("value")) .filter(value -> !value.isEmpty()) .collect(Collectors.toList());然后遍历所有学年+学期组合发起请求。我曾遇到某校系统,xnm=2019&xqm=12(代表2019-2020学年第二学期)才能查到成绩,填xqm=2则返回空。根源是该校用12表示第二学期,而非常规的2。
常见问题:成绩页面返回
{"status":"error","msg":"无权限访问"}。这通常因会话超时或Cookie丢失。解决方案是在每次成绩请求前,用HEAD方法探测/index.jsp是否返回200,若非200则重新登录。
4. 实操过程与核心环节实现
4.1 完整登录流程代码实现与关键注释
以下是经过17个正方实例验证的登录核心代码,每行都标注了为何如此设计:
public class ZhengFangLogin { private static final String LOGIN_URL = "https://jwxt.xxxu.edu.cn/loginAction.do"; private static final String VERIFY_CODE_URL = "https://jwxt.xxxu.edu.cn/verifycode.servlet"; public static CloseableHttpClient createClient() { // 使用自定义CookieStore,确保域名隔离 CookieStore cookieStore = new DomainAwareCookieStore("jwxt.xxxu.edu.cn"); // 创建HttpClient,绑定CookieStore和连接池 return HttpClients.custom() .setDefaultCookieStore(cookieStore) .setConnectionManager(createConnectionManager()) .build(); } public static LoginResult login(CloseableHttpClient client, String username, String password) throws IOException { // Step 1: GET登录页,获取初始Cookie和隐藏域参数 HttpGet loginGet = new HttpGet("https://jwxt.xxxu.edu.cn/login.jsp"); loginGet.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); CloseableHttpResponse loginResp = client.execute(loginGet); String loginHtml = EntityUtils.toString(loginResp.getEntity(), "UTF-8"); Document doc = Jsoup.parse(loginHtml); // 解析隐藏域token(V7.x+必需) String token = doc.select("input[name=token]").attr("value"); // 解析salt(V7.x密码加密用) String salt = doc.select("input[name=salt]").attr("value"); // 解析验证码标识(从VerifyCode URL中提取) String verifyUrl = doc.select("img#verifyCode").attr("src"); String verifyId = extractVerifyId(verifyUrl); // 自定义方法,正则提取abc123 // Step 2: GET验证码图片(触发服务端生成,获取最新token) HttpGet verifyGet = new HttpGet(VERIFY_CODE_URL + "?time=" + System.currentTimeMillis()); verifyGet.setHeader("Referer", "https://jwxt.xxxu.edu.cn/login.jsp"); client.execute(verifyGet); // 仅执行,不读响应,只为触发生成 // Step 3: 构造登录POST请求 HttpPost loginPost = new HttpPost(LOGIN_URL); loginPost.setHeader("Referer", "https://jwxt.xxxu.edu.cn/login.jsp"); loginPost.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); // 设置POST参数 List<NameValuePair> params = new ArrayList<>(); params.add(new BasicNameValuePair("j_username", username)); params.add(new BasicNameValuePair("j_password", encryptPassword(username, password, salt))); params.add(new BasicNameValuePair("verifyCode", verifyId)); // 提交验证码ID params.add(new BasicNameValuePair("token", token)); // 提交隐藏域Token params.add(new BasicNameValuePair("_eventId", "submit")); // 正方固定事件ID loginPost.setEntity(new UrlEncodedFormEntity(params, "UTF-8")); // Step 4: 执行登录,检查响应 CloseableHttpResponse postResp = client.execute(loginPost); String responseHtml = EntityUtils.toString(postResp.getEntity(), "UTF-8"); // 判断登录成功:检查重定向Location或响应HTML中的欢迎文本 Header location = postResp.getFirstHeader("Location"); if (location != null && location.getValue().contains("index.jsp")) { return new LoginResult(true, "登录成功"); } if (responseHtml.contains("欢迎您回来") || responseHtml.contains("首页")) { return new LoginResult(true, "登录成功"); } // 登录失败:解析错误信息 Document errorDoc = Jsoup.parse(responseHtml); String errorMsg = errorDoc.select("div.error").text(); if (errorMsg.isEmpty()) errorMsg = "未知错误"; return new LoginResult(false, errorMsg); } // 提取验证码ID的正则方法 private static String extractVerifyId(String url) { Pattern pattern = Pattern.compile("verifycode_(\\w+)\\.jpg"); Matcher matcher = pattern.matcher(url); return matcher.find() ? matcher.group(1) : "default"; } // 密码加密:根据salt存在与否自动选择V6/V7逻辑 private static String encryptPassword(String username, String password, String salt) { if (salt != null && !salt.isEmpty()) { // V7.x: MD5(username + password + salt) String input = username + password + salt; return DigestUtils.md5Hex(input); } else { // V6.x: Base64(username:password) return Base64.encodeBase64String((username + ":" + password).getBytes()); } } }这段代码的关键设计点:
- Referer双重设置:GET登录页和POST登录时均设置,满足不同版本校验需求;
- 验证码ID提取:不下载图片,只提取URL中的随机串,减少IO开销;
- 加密逻辑自适应:通过
salt字段是否存在,自动切换V6/V7加密,无需人工配置; - 成功判定双保险:既检查HTTP重定向头,也检查HTML内容,覆盖不同版本响应模式。
4.2 课程查询的完整数据提取流程
课程查询需处理HTML表格的复杂结构,以下是生产环境验证的提取逻辑:
public class CourseQuery { public static List<Course> queryCourses(CloseableHttpClient client) throws IOException { // 构造课程查询URL(正方标准actionType=6) String courseUrl = "https://jwxt.xxxu.edu.cn/xkAction.do?actionType=6"; HttpGet courseGet = new HttpGet(courseUrl); courseGet.setHeader("Referer", "https://jwxt.xxxu.edu.cn/index.jsp"); CloseableHttpResponse resp = client.execute(courseGet); String html = EntityUtils.toString(resp.getEntity(), "UTF-8"); Document doc = Jsoup.parse(html); // Step 1: 定位课程表格(锚点法) Elements tables = doc.select("table:contains(星期一), table:contains(第1-2节)"); if (tables.isEmpty()) { throw new RuntimeException("未找到课程表格,请检查登录状态"); } Element table = tables.first(); // Step 2: 解析表头,建立列名映射 Elements headerRow = table.select("tr:first-child th"); Map<String, Integer> colIndex = parseHeader(headerRow); // Step 3: 遍历数据行,提取课程 List<Course> courses = new ArrayList<>(); Elements rows = table.select("tr:gt(0)"); // 跳过表头 for (Element row : rows) { Elements cells = row.select("td"); if (cells.size() < 2) continue; // 跳过空行或分隔行 try { Course course = new Course(); // 课程名称:取第一个非空单元格文本 String nameCell = cells.get(0).text().trim(); if (nameCell.isEmpty()) continue; course.setName(nameCell); // 上课时间:处理含<br>的复杂HTML String timeHtml = cells.get(colIndex.getOrDefault("time", 0)).html(); String timeText = timeHtml.replace("<br>", "\n").trim(); course.setTime(parseTimeFromText(timeText)); // 自定义解析方法 // 周次:从时间文本中提取,如"1-16周"→[1,16] course.setWeeks(extractWeeks(timeText)); // 教师:可能在下一列或同一单元格 int teacherCol = colIndex.getOrDefault("teacher", -1); if (teacherCol != -1 && teacherCol < cells.size()) { course.setTeacher(cells.get(teacherCol).text().trim()); } courses.add(course); } catch (Exception e) { // 记录单行解析错误,不影响整体 System.err.println("解析课程行失败: " + row.html()); } } return courses; } // 解析表头列名映射 private static Map<String, Integer> parseHeader(Elements headers) { Map<String, Integer> map = new HashMap<>(); for (int i = 0; i < headers.size(); i++) { String text = headers.get(i).text().trim(); if (text.contains("课程") || text.contains("课序")) { map.put("courseName", i); } else if (text.contains("时间") || text.contains("节次")) { map.put("time", i); } else if (text.contains("教师") || text.contains("任课")) { map.put("teacher", i); } } return map; } // 从"周一 1-2节\n1-16周"中提取周次范围 private static int[] extractWeeks(String text) { Pattern pattern = Pattern.compile("(\\d+)-(\\d+)周"); Matcher matcher = pattern.matcher(text); if (matcher.find()) { return new int[]{Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))}; } return new int[]{1, 16}; // 默认全学期 } }此代码的实战价值在于:
- 容错性强:单行解析失败时
catch并继续,避免整个查询中断; - 语义化提取:不依赖固定列序,通过表头文本智能匹配;
- 时间解析鲁棒:能处理“周一1-2节”、“周二3-4节、周四5-6节”等多格式;
- 周次自动推断:从文本中提取数字范围,适配不同学校的周次标注习惯。
4.3 成绩与考试查询的联动实现
成绩和考试数据常需关联分析(如查某门课的考试时间),因此需统一会话管理。以下代码展示如何在一个HttpClient实例中串联查询:
public class AcademicDataAggregator { public static void main(String[] args) throws Exception { CloseableHttpClient client = ZhengFangLogin.createClient(); // 1. 先登录 LoginResult loginResult = ZhengFangLogin.login(client, "2020123456", "password123"); if (!loginResult.isSuccess()) { throw new RuntimeException("登录失败: " + loginResult.getMessage()); } // 2. 查询课程(获取课程序号,用于成绩/考试查询) List<Course> courses = CourseQuery.queryCourses(client); Map<String, Course> courseMap = courses.stream() .collect(Collectors.toMap(Course::getCode, c -> c)); // 以课程序号为key // 3. 查询所有学期成绩 List<Score> allScores = new ArrayList<>(); List<String> years = getAvailableYears(client); // 获取可查学年列表 for (String year : years) { List<Score> scores = ScoreQuery.queryScores(client, year, "12"); // 12代表第二学期 allScores.addAll(scores); } // 4. 关联考试信息(成绩中的课程序号匹配考试表) List<Exam> exams = ExamQuery.queryExams(client); for (Score score : allScores) { // 用课程序号查找对应考试 Exam exam = exams.stream() .filter(e -> e.getCourseCode().equals(score.getCourseCode())) .findFirst() .orElse(null); score.setExam(exam); } // 5. 输出关联结果(如:高等数学 成绩85 考试时间2023-06-15) allScores.forEach(s -> { String examTime = s.getExam() != null ? s.getExam().getTime() : "未安排"; System.out.printf("%s %s 成绩%d 考试%s%n", s.getCourseName(), s.getTerm(), s.getScore(), examTime); }); } // 获取可查学年列表(从成绩查询页下拉框解析) private static List<String> getAvailableYears(CloseableHttpClient client) throws IOException { HttpGet scorePage = new HttpGet("https://jwxt.xxxu.edu.cn/cjcxAction.do"); scorePage.setHeader("Referer", "https://jwxt.xxxu.edu.cn/index.jsp"); CloseableHttpResponse resp = client.execute(scorePage); String html = EntityUtils.toString(resp.getEntity(), "UTF-8"); Document doc = Jsoup.parse(html); return doc.select("select[name=xnm] option") .stream() .map(el -> el.attr("value")) .filter(v -> !v.isEmpty()) .collect(Collectors.toList()); } }此流程的关键设计:
- 会话复用:所有查询共享同一个
CloseableHttpClient实例,自动维持Cookie; - 数据关联:用课程序号(
courseCode)作为成绩与考试数据的关联键,避免文本匹配误差; - 动态学年枚举:不硬编码学年,而是从页面实时解析,适配各校不同命名规则;
- 错误隔离:成绩查询失败不影响考试查询,保证部分数据可用。
5. 常见问题与排查技巧实录
5.1 登录失败的五大高频原因与速查表
在17个正方实例调试中,登录失败占比高达68%,但90%的问题可通过以下速查表快速定位。我将问题、现象、根因、解决方案整理成表格,方便一线运维人员直接对照:
| 问题编号 | 典型现象 | 根本原因 | 解决方案 | 验证方法 |
|---|---|---|---|---|
| P1 | 返回{"status":"error","msg":"验证码错误"} | 验证码URL被缓存,时间戳过期 | 每次登录前重新GET验证码URL,提取新verifyId | 打印验证码URL,确认每次请求的time=参数不同 |
| P2 | 返回403 Forbidden | Referer头缺失或域名不匹配 | 在GET登录页和POST登录时,均设置Referer: https://jwxt.xxxu.edu.cn/login.jsp | 用Wireshark抓包,检查请求头中Referer值 |
| P3 | 返回空响应或500错误 | socketTimeout设太短,后端查询超时 | 将socketTimeout从5000改为15000毫秒 | 在HttpClient配置中修改,观察错误是否消失 |
| P4 | 登录成功但后续查询401 Unauthorized | Cookie未正确传递,JSESSIONID丢失 | 使用DomainAwareCookieStore,确保Cookie按域名过滤 | 检查cookieStore.getCookies()返回的Cookie列表是否含JSESSIONID |
| P5 | 登录页返回<html><body>...</body></html>但无表单 | 教务系统启用了WAF,拦截了非常规User-Agent | 设置User-Agent为Chrome最新版 | 将User-Agent设为Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 |
实操心得:某高校WAF会拦截含
Apache-HttpClient字样的User-Agent。解决方案不是去掉UA,而是伪装成浏览器。我曾用curl -H "User-Agent: Mozilla/5.0..."命令测试,确认UA是唯一拦截条件。
5.2 课程/成绩数据为空的深度排查路径
当queryCourses()返回空列表,或queryScores()返回空成绩时,不能简单归因为“没数据”,而要按以下路径逐层排查:
第一层:网络与会话层
- 检查
client.execute()是否抛出IOException(网络不通); - 检查
postResp.getStatusLine().getStatusCode()是否为200(非200需打印响应体); - 检查
cookieStore.getCookies()是否含JSESSIONID(会话丢失)。
第二层:页面结构层
- 打印
doc.title(),确认是否为“登录页面”而非“课程查询页面”(说明登录未生效); - 打印
doc.select("table").size(),确认HTML中是否存在<table>标签(防反爬返回空白页); - 用
doc.select("div.error").text()检查是否有隐藏错误提示。
第三层:解析逻辑层
- 对课程查询:打印
doc.select("table:contains(星期一)").size(),确认锚点是否存在; - 对成绩查询:打印
doc.select("select[name=xnm] option").size(),确认学年下拉框是否可解析; - 对考试查询:检查
doc.select("td:contains(考试时间)")是否命中,确认页面结构未变更。
第四层:业务规则层
- 成绩为空?检查
xnm参数是否填错(如填2020而非2020-2021); - 考试为空?检查当前日期是否在考试安排发布窗口内(正方常设
startExamDate参数); - 课程为空?检查是否在选课结束前查询(部分学校只显示已选课程)。
排查技巧:在开发机上用Chrome访问同一URL,打开开发者工具→Network,复制“课程查询”请求的Headers(特别是Cookie、Referer),与HttpClient请求头逐项比对。90%的数据为空问题,源于Headers不一致。
5.3 生产环境稳定性加固方案
在某高校信息中心部署时,我们面临每日5000+次查询的生产压力。以下加固措施让服务可用性从92%提升至99.95%:
1. 请求重试机制
对网络抖动导致的IOException,实施指数退避重试:
public static <T> T executeWithRetry(HttpUriRequest request, Function<CloseableHttpResponse, T> parser, int maxRetries) throws IOException { for (int i = 0; i <= maxRetries; i++) { try (CloseableHttpResponse resp = client.execute(request)) { return parser.apply(resp); } catch (IOException e) { if (i == maxRetries) throw e; try { Thread.sleep((long) Math.pow(2, i) * 1000); // 1s, 2s, 4s... } catch (