> 《从零开发校园墙微信小程序》进阶篇之六(完结篇)。前两篇我们成功拿回了教务系统的课表页面。这一篇把那一大坨 HTML 变成结构化的数据,并解决课表里最绕的几个概念:学期、教学周、单双周、调课与放假。 ## 拿到的是什么 教务返回的课表是一个 HTML 表格: * 每一**行** 是一个节次(第一节课、第二节课……),行首的 `` 是节次名; * 每一**列** 是星期一到星期日; * 每个**单元格** 里有一个或多个 ``,每个 div 是一门课,格式大致是: ``` 课程名 1-17(周) … … ``` 目标是把它变成一行行 `Schedules` 记录: ```java @TableName("schedules") public class Schedules { private Integer id; private String studentid; // 学号 private String time; // 节次,如「第一节课 08:20-09:05」 private String name; // 课程名 private Integer weeks; // 周次位掩码(见下文) private String dayofweek; // 星期一 ~ 星期日 private String classroom; private String teacher; private String semester; // 学期,如 2025-2026-1 } ``` ## 用 Jsoup 解析 HTML 解析用 **Jsoup** 。整体是三层循环: ```java Document doc = Jsoup.parse(courseHtml); for (Element row : doc.select("table tr")) { Element timeElement = row.selectFirst("th"); if (timeElement == null) continue; // 非数据行(如表头) String time = enrichTime(timeElement.text()); Elements cells = row.select("td"); for (int i = 0; i < cells.size() && i < DAYS_OF_WEEK.length; i++) { // i 上限保护 for (Element courseDiv : cells.get(i).select("div[class^=kbcontent]")) { if (courseDiv.id().endsWith("-1")) continue; // 教务系统的无效数据标记 ... } } } ``` 这段代码里的每一个防御都对应一个**真实踩过的坑** ,类上的注释把它们列得很清楚: 1. **周次字符串不匹配时返回`null` 导致 NPE** → 现在返回空位掩码 `0`; 2. **`1,3-5` 这种混合周次格式**解析抛 `NumberFormatException`; 3. **不支持单双周** `1-17(单)` / `1-16(双)`; 4. **`td` 下标越界**导致星期错位 / 数组越界 —— 所以循环里加了 `i < DAYS_OF_WEEK.length` 的上限保护; 5. 正则在循环内重复编译、`contains` 去重是 O(n²)、清理 `span` 时没开 `DOTALL`。 教训是:**解析外部 HTML 要把「它不按你想的来」当成常态。** 每一个假设(下标、格式、非空)都要有兜底,而不是默认页面永远整齐。 ### 选择器的小技巧 `div[class^=kbcontent]` 用的是**属性前缀选择器** :只要 class 以 `kbcontent` 开头就匹配。因为教务页面里同一类元素的 class 可能是 `kbcontent` 也可能是 `kbcontent1` 之类,用前缀匹配比精确匹配更稳。 ### 多门课挤在同一个格子 同一个节次、同一个星期,可能有多门课(比如不同周次上不同的课)。教务会用一条长分隔线把它们隔开: ```java private static final Pattern MULTI_COURSE_SEPARATOR = Pattern.compile("-{21,23}"); // 判断是否有多门课 private static final Pattern SPLIT_SEPARATOR = Pattern.compile("\\s*-{5,}\\s*"); // 拆分用 ``` 先用「21~23 个短横线」判断这个格子里是不是多门课,是的话再按「5 个以上短横线」拆开,逐门解析。拆完要处理残留:除第一段外,段首可能留着一个多余的 ``,要去掉。 ### 去重 同一门课可能在表格里出现多次(比如连上两节,每个节次各出现一次)。解析结果用 `HashSet` 去重,前提是实体类实现了 `equals` / `hashCode`: ```java // Schedules 已实现 equals/hashCode,用 Set 做 O(1) 去重 Set seen = new HashSet<>(); ... if (course != null && seen.add(course)) courses.add(course); ``` 旧版用 `list.contains()` 去重,是 O(n²),课多的时候会明显变慢。 ## 节次时间:补齐缺失的时刻 教务页面的 `` 以前会带具体时刻(`第一节课 08:20-09:05`),后来改版只给节次标签(`第一节课`)。但库里已有大量老数据是带时刻的格式。为了**新旧数据格式一致** ,解析器按学校统一的作息表补齐: ```java private static final Map PERIOD_TIME_MAP = Map.ofEntries( Map.entry("第一节课", "08:20-09:05"), Map.entry("第二节课", "09:10-09:55"), ... Map.entry("中午休息时间", "12:00-14:10"), ... ); private static String enrichTime(String time) { time = time.trim(); if (time.isEmpty() || CLOCK_TIME_PATTERN.matcher(time).find()) return time; // 已含时刻,原样返回 String clock = PERIOD_TIME_MAP.get(time); return clock != null ? time + " " + clock : time; } ``` 这张表不是拍脑袋写的:注释里说,**已经对照数据库里 622 条老数据验证过——同一节次在所有学号、所有星期下,时刻完全一致** ,才敢直接按节次补。 还有个有意思的细节:**「中午休息时间」也在表里** ,因为库里确实有课程被安排在这个时段(比如周三 12:00–14:10 的某门课)。别想当然地把它当成纯分隔行丢掉。 `normalizeTime` 和 `periodTimeMap` 还对外暴露出来,供**管理后台手工编辑课表** 时使用(下拉选项、规范化),保证人工录入和自动采集的数据格式一致。 ## 周次:用位掩码存储 这是课表数据设计里最漂亮的一个点。一门课的上课周次可能是:`5`、`1-17`、`1,3,5`、`1,3-5,8`、`1-17(单)`、`1-16(双)`…… 怎么存?如果存字符串,每次前端判断「第 7 周有没有这门课」都要重新解析,还容易写错。项目里把周次存成一个**整数位掩码** (bitmask):第 N 周上课,就把第 N 位设为 1。 ```java public static int weeksToBitmask(List weeks) { int bitmask = 0; for (int week : weeks) bitmask |= 1 << (week - 1); return bitmask; } public static List bitmaskToWeeks(int bitmask) { List weeks = new ArrayList<>(); for (int i = 0; i < 32; i++) { if ((bitmask & (1 << i)) != 0) weeks.add(i + 1); } return weeks; } ``` 例如第 1、3、5 周上课: ``` 第5周 第4周 第3周 第2周 第1周 1 0 1 0 1 = 二进制 10101 = 十进制 21 ``` 判断「第 7 周有没有课」只需一次位运算:`(weeks & (1 << 6)) != 0`,不需要任何字符串解析。 **好处** :一个 `int` 存下整学期(最多 32 周,课表场景足够);判断「某周有没有课」是 O(1);单双周、不连续周次都用统一的方式表达,数据库里存的就是个数字,体积小、索引友好。 > 注意 `week` 的范围是 1 ~ 32。超过 32 周会溢出,不过没有学期会有这么多周。 ### 周次字符串解析 字符串到周次列表,用一个正则抓出数字串和可选的单双标记: ```java // 如 "1-17(周)"、"1-16(双)"、"1,3-5,8" Pattern WEEK_PATTERN = Pattern.compile("(\\d+(?:-\\d+)?(?:,\\d+(?:-\\d+)?)*)\\s*(?:\\((单|双)\\))?"); ``` 然后按逗号拆开,每段要么是单个数字,要么是 `a-b` 区间: ```java for (int w = Math.max(1, start); w <= end; w++) { // 单周=奇数周,双周=偶数周 if ("单".equals(parity) && w % 2 == 0) continue; if ("双".equals(parity) && w % 2 == 1) continue; weeks.add(w); } ``` 两个防御:每个片段的数字解析都包在 `try / catch (NumberFormatException)` 里,**一个片段坏了不影响其他片段** ;整体解析失败返回**空列表** (位掩码 0),**不抛异常** 。一门课周次解析失败,最多是这门课显示不出来,绝不能让整份课表都解析失败。 ## 学期与教学周 课表按学期保存。「学期」用 `2025-2026-1` 这样的字符串表示:学年 + 第几学期(1 = 秋季,2 = 春季)。当前是哪个学期,由**当前月份** 推导: ```java public static String getCurrentSemester() { LocalDate now = LocalDate.now(); int year = now.getYear(); if (month >= 9 && month <= 12) return year + "-" + (year + 1) + "-1"; // 9~12 月:本学年第 1 学期 else if (month == 1) return (year - 1) + "-" + year + "-1"; // 1 月:上一学年第 1 学期 else if (month >= 2 && <= 6) return (year - 1) + "-" + year + "-2"; // 2~6 月:上一学年第 2 学期 else /* 7~8 月 */ return year + "-" + (year + 1) + "-1"; // 暑假:提前显示下一学年第 1 学期 } ``` 然后是「现在是**第几周** 」。这需要知道学期的**开课日期** 。项目的做法是**分层兜底** : 1. **数据库里有这个学期的开课日期,以它为准** (并规整到最近的周一); 2. 没有,就**按学期名推导一个基准日** :秋季默认 9 月 1 日,春季默认 3 月 1 日(春节日期浮动,没有固定规律,只能兜底);推导出来后**自动插入一条记录** ,之后在数据库里直接改就行,不用发版。 基准日不一定是周一,所以要找**「最近的周一」** : ```java /** * 取某日期最近的那个周一:周四及以前往前取,周五及以后往后取。 * 例如 9月1日是周日 -> 9月2日(周一);9月1日是周二 -> 8月31日(周一)。 */ public static LocalDate nearestMonday(LocalDate date) { LocalDate prev = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); LocalDate next = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY)); long diffPrev = Math.abs(ChronoUnit.DAYS.between(date, prev)); long diffNext = Math.abs(ChronoUnit.DAYS.between(date, next)); return diffPrev <= diffNext ? prev : next; } ``` 用 `java.time` 的 `TemporalAdjusters` 比自己算日期可靠得多。开课基准日的两个默认值还做成了**配置项** (`semester.fall-start` / `semester.spring-start`)。 **这里体现的是一种「默认值 + 可覆盖」的思路** :规律能推导的先推导出来保证有值;规律不固定的(春季开课日),留一个数据库里可以直接改的口子。 ## 服务端返回整学期,前端按周过滤 获取课表接口的注释里有一个设计决定值得讲: ```java // 返回全学期课表:前端按 viewWeek(当前周+周偏移)本地过滤 weeks 位掩码并支持整学期翻周, // 服务端若按当前周过滤会把只上稀疏周的课(如形策第3/8/13周)在非当周时整条滤掉,翻周永远看不到 return schedulesRepository.findScheduleByOpenid(openid, semesterStr).collectList() .map(result -> new Result(200, "获取课表成功", result)); ``` 最初的想法是「服务端按当前周过滤」,数据量小。但这样用户**翻到别的周就看不到那些只在稀疏周上课的课** (比如只在第 3、8、13 周上的课程)。所以改成:**服务端返回全学期,前端用位掩码本地过滤、自由翻周** 。位掩码正好让前端的这个过滤又快又简单。 ## 校历调整:调课与放假 国内学校总有「调休补课」和「放假」:比如某周六要补周一的课,国庆中秋假期不上课。这些**不在课表 HTML 里** ,要另外维护。 项目里有一张 `calendar_adjust` 表,两种类型: 类型 | 含义 | 字段 ---|---|--- `makeup` | 补课:某天按「被补的星期 + 被补的教学周」显示课表 | 日期、目标星期、目标周、说明 `break` | 放假:区间内不显示课程 | 开始日、结束日、假期名称 放假区间用**半开区间`[start, end)`**,结束日当天复课,避免「结束日算不算放假」的歧义;`end` 缺省视为次日复课。 接口把它们整理后返回给前端,前端据此渲染:调课日显示被补的那一天的课程,放假期间不显示课程,还能根据假期名称显示节日问候。 ### 内容指纹:让客户端知道「校历变了没有」 ```java // 内容指纹:任一条目被增删改都会变化,前端据此判断"校历有没有变"(不必轮询全量比对) String version = String.join("|", list.stream() .map(i -> String.join(":", String.valueOf(i.getId()), String.valueOf(i.getAdjusttype()), String.valueOf(i.getDatestart()), String.valueOf(i.getDateend()), String.valueOf(i.getTargetdayofweek()), String.valueOf(i.getTargetweek()), String.valueOf(i.getLabel()))) .toList()); data.put("version", version.isEmpty() ? "empty" : version); ``` 把每一条校历的关键字段拼成一个**版本指纹** 。前端缓存了校历,下次只需要比较一下 `version` 字段是否变化,就知道要不要刷新,而不用把全量数据逐条比较。注意连 `label`(假期名称)也参与了指纹——因为**只改名称也要让客户端刷新** 。 最关键的是:**校历存在数据库里,改库即对所有客户端生效,无需发版。** 小程序发版要走审核,节假日调整却是每个学期都不一样的,所以必须是数据驱动的。 ## 小结 1. 解析课表:Jsoup 三层循环,**每个假设都要有兜底** (下标、格式、非空),多门课拆分、`HashSet` 去重; 2. 页面改版后缺失的字段,用**已验证的映射表** 补齐,保证新旧数据格式一致; 3. **周次用位掩码** :一个 `int` 存整学期,判断某周是否有课是 O(1),单双周统一表达; 4. 解析失败要**局部失败不扩散** :一个片段失败只影响自己,整体失败返回空而不是抛异常; 5. 学期由月份推导,开课日**数据库优先、推导兜底、自动落库可手改** ;找最近周一用 `TemporalAdjusters`; 6. 服务端返回全学期,**前端按位掩码本地过滤** ,才能自由翻周; 7. 校历调整(调课 / 放假)**数据驱动** ,用半开区间,用**内容指纹** 让客户端感知变化。 ## 系列完结 到这里,「培正森林」从数据库到登录、投稿、敏感词、图片、微博开放平台、课表、再到部署上线,主线都讲完了。回头看,这些文章里反复出现的是同几条经验: * **把失败分类** :确定性 vs 瞬态,写入、重试、降级都建立在这个分类之上; * **降级要分层** ,并且协议要能区分「失败」和「空」; * **默认值 + 可覆盖** :数据驱动的配置,让运营不用发版; * **外部系统一定会出乎意料** :超时、幂等、去重、限速、白名单,一个都不能少; * **把血泪教训写进注释和文档** ,因为一年后的你,已经不记得为什么要这样写了。 谢谢你读到最后。如果你也在做类似的校园小程序,希望这个系列能帮你少踩几个坑。 《从零开发校园墙微信小程序》进阶篇之六(完结篇)。前两篇我们成功拿回了教务系统的课表页面。这一篇把那一大坨 HTML 变成结构化的数据,并解决课表里最绕的几个概念:学期、教学周、单双周、调课与放假。拿到的是什么教务返回的课表是一个 HTML 表格:每一行 是一个节次(第一节课、第二节课……),行首的 <th> 是节次名;每一列 是星期一到星期日;每个单元格 里有一个或多个 <div class="kbcontent...">,每个 div 是一门课,格式大致是:课程名 <br> <font title="周次">1-17(周)</font> <br> <font title="教室">…</font> <br> <font title="老师">…</font>目标是把它变成一行行 Schedules 记录:@TableName("schedules") public class Schedules { private Integer id; private String studentid; // 学号 private String time; // 节次,如「第一节课 08:20-09:05」 private String name; // 课程名 private Integer weeks; // 周次位掩码(见下文) private String dayofweek; // 星期一 ~ 星期日 private String classroom; private String teacher; private String semester; // 学期,如 2025-2026-1 }用 Jsoup 解析HTML 解析用 Jsoup 。整体是三层循环:Document doc = Jsoup.parse(courseHtml); for (Element row : doc.select("table tr")) { Element timeElement = row.selectFirst("th"); if (timeElement == null) continue; // 非数据行(如表头) String time = enrichTime(timeElement.text()); Elements cells = row.select("td"); for (int i = 0; i < cells.size() && i < DAYS_OF_WEEK.length; i++) { // i 上限保护 for (Element courseDiv : cells.get(i).select("div[class^=kbcontent]")) { if (courseDiv.id().endsWith("-1")) continue; // 教务系统的无效数据标记 ... } } }这段代码里的每一个防御都对应一个真实踩过的坑 ,类上的注释把它们列得很清楚:周次字符串不匹配时返回null 导致 NPE → 现在返回空位掩码 0;1,3-5 这种混合周次格式解析抛 NumberFormatException;不支持单双周 1-17(单) / 1-16(双);td 下标越界导致星期错位 / 数组越界 —— 所以循环里加了 i < DAYS_OF_WEEK.length 的上限保护;正则在循环内重复编译、contains 去重是 O(n²)、清理 span 时没开 DOTALL。教训是:解析外部 HTML 要把「它不按你想的来」当成常态。 每一个假设(下标、格式、非空)都要有兜底,而不是默认页面永远整齐。选择器的小技巧div[class^=kbcontent] 用的是属性前缀选择器 :只要 class 以 kbcontent 开头就匹配。因为教务页面里同一类元素的 class 可能是 kbcontent 也可能是 kbcontent1 之类,用前缀匹配比精确匹配更稳。多门课挤在同一个格子同一个节次、同一个星期,可能有多门课(比如不同周次上不同的课)。教务会用一条长分隔线把它们隔开:private static final Pattern MULTI_COURSE_SEPARATOR = Pattern.compile("-{21,23}"); // 判断是否有多门课 private static final Pattern SPLIT_SEPARATOR = Pattern.compile("\\s*-{5,}\\s*"); // 拆分用先用「21~23 个短横线」判断这个格子里是不是多门课,是的话再按「5 个以上短横线」拆开,逐门解析。拆完要处理残留:除第一段外,段首可能留着一个多余的 <br>,要去掉。去重同一门课可能在表格里出现多次(比如连上两节,每个节次各出现一次)。解析结果用 HashSet 去重,前提是实体类实现了 equals / hashCode:// Schedules 已实现 equals/hashCode,用 Set 做 O(1) 去重 Set<Schedules> seen = new HashSet<>(); ... if (course != null && seen.add(course)) courses.add(course);旧版用 list.contains() 去重,是 O(n²),课多的时候会明显变慢。节次时间:补齐缺失的时刻教务页面的 <th> 以前会带具体时刻(第一节课 08:20-09:05),后来改版只给节次标签(第一节课)。但库里已有大量老数据是带时刻的格式。为了新旧数据格式一致 ,解析器按学校统一的作息表补齐:private static final Map<String, String> PERIOD_TIME_MAP = Map.ofEntries( Map.entry("第一节课", "08:20-09:05"), Map.entry("第二节课", "09:10-09:55"), ... Map.entry("中午休息时间", "12:00-14:10"), ... ); private static String enrichTime(String time) { time = time.trim(); if (time.isEmpty() || CLOCK_TIME_PATTERN.matcher(time).find()) return time; // 已含时刻,原样返回 String clock = PERIOD_TIME_MAP.get(time); return clock != null ? time + " " + clock : time; }这张表不是拍脑袋写的:注释里说,已经对照数据库里 622 条老数据验证过——同一节次在所有学号、所有星期下,时刻完全一致 ,才敢直接按节次补。还有个有意思的细节:「中午休息时间」也在表里 ,因为库里确实有课程被安排在这个时段(比如周三 12:00–14:10 的某门课)。别想当然地把它当成纯分隔行丢掉。normalizeTime 和 periodTimeMap 还对外暴露出来,供管理后台手工编辑课表 时使用(下拉选项、规范化),保证人工录入和自动采集的数据格式一致。周次:用位掩码存储这是课表数据设计里最漂亮的一个点。一门课的上课周次可能是:5、1-17、1,3,5、1,3-5,8、1-17(单)、1-16(双)……怎么存?如果存字符串,每次前端判断「第 7 周有没有这门课」都要重新解析,还容易写错。项目里把周次存成一个整数位掩码 (bitmask):第 N 周上课,就把第 N 位设为 1。public static int weeksToBitmask(List<Integer> weeks) { int bitmask = 0; for (int week : weeks) bitmask |= 1 << (week - 1); return bitmask; } public static List<Integer> bitmaskToWeeks(int bitmask) { List<Integer> weeks = new ArrayList<>(); for (int i = 0; i < 32; i++) { if ((bitmask & (1 << i)) != 0) weeks.add(i + 1); } return weeks; }例如第 1、3、5 周上课:第5周 第4周 第3周 第2周 第1周 1 0 1 0 1 = 二进制 10101 = 十进制 21判断「第 7 周有没有课」只需一次位运算:(weeks & (1 << 6)) != 0,不需要任何字符串解析。好处 :一个 int 存下整学期(最多 32 周,课表场景足够);判断「某周有没有课」是 O(1);单双周、不连续周次都用统一的方式表达,数据库里存的就是个数字,体积小、索引友好。注意 week 的范围是 1 ~ 32。超过 32 周会溢出,不过没有学期会有这么多周。周次字符串解析字符串到周次列表,用一个正则抓出数字串和可选的单双标记:// 如 "1-17(周)"、"1-16(双)"、"1,3-5,8" Pattern WEEK_PATTERN = Pattern.compile("(\\d+(?:-\\d+)?(?:,\\d+(?:-\\d+)?)*)\\s*(?:\\((单|双)\\))?");然后按逗号拆开,每段要么是单个数字,要么是 a-b 区间:for (int w = Math.max(1, start); w <= end; w++) { // 单周=奇数周,双周=偶数周 if ("单".equals(parity) && w % 2 == 0) continue; if ("双".equals(parity) && w % 2 == 1) continue; weeks.add(w); }两个防御:每个片段的数字解析都包在 try / catch (NumberFormatException) 里,一个片段坏了不影响其他片段 ;整体解析失败返回空列表 (位掩码 0),不抛异常 。一门课周次解析失败,最多是这门课显示不出来,绝不能让整份课表都解析失败。学期与教学周课表按学期保存。「学期」用 2025-2026-1 这样的字符串表示:学年 + 第几学期(1 = 秋季,2 = 春季)。当前是哪个学期,由当前月份 推导:public static String getCurrentSemester() { LocalDate now = LocalDate.now(); int year = now.getYear(); if (month >= 9 && month <= 12) return year + "-" + (year + 1) + "-1"; // 9~12 月:本学年第 1 学期 else if (month == 1) return (year - 1) + "-" + year + "-1"; // 1 月:上一学年第 1 学期 else if (month >= 2 && <= 6) return (year - 1) + "-" + year + "-2"; // 2~6 月:上一学年第 2 学期 else /* 7~8 月 */ return year + "-" + (year + 1) + "-1"; // 暑假:提前显示下一学年第 1 学期 }然后是「现在是第几周 」。这需要知道学期的开课日期 。项目的做法是分层兜底 :数据库里有这个学期的开课日期,以它为准 (并规整到最近的周一);没有,就按学期名推导一个基准日 :秋季默认 9 月 1 日,春季默认 3 月 1 日(春节日期浮动,没有固定规律,只能兜底);推导出来后自动插入一条记录 ,之后在数据库里直接改就行,不用发版。基准日不一定是周一,所以要找「最近的周一」 :/** * 取某日期最近的那个周一:周四及以前往前取,周五及以后往后取。 * 例如 9月1日是周日 -> 9月2日(周一);9月1日是周二 -> 8月31日(周一)。 */ public static LocalDate nearestMonday(LocalDate date) { LocalDate prev = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); LocalDate next = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY)); long diffPrev = Math.abs(ChronoUnit.DAYS.between(date, prev)); long diffNext = Math.abs(ChronoUnit.DAYS.between(date, next)); return diffPrev <= diffNext ? prev : next; }用 java.time 的 TemporalAdjusters 比自己算日期可靠得多。开课基准日的两个默认值还做成了配置项 (semester.fall-start / semester.spring-start)。这里体现的是一种「默认值 + 可覆盖」的思路 :规律能推导的先推导出来保证有值;规律不固定的(春季开课日),留一个数据库里可以直接改的口子。服务端返回整学期,前端按周过滤获取课表接口的注释里有一个设计决定值得讲:// 返回全学期课表:前端按 viewWeek(当前周+周偏移)本地过滤 weeks 位掩码并支持整学期翻周, // 服务端若按当前周过滤会把只上稀疏周的课(如形策第3/8/13周)在非当周时整条滤掉,翻周永远看不到 return schedulesRepository.findScheduleByOpenid(openid, semesterStr).collectList() .map(result -> new Result(200, "获取课表成功", result));最初的想法是「服务端按当前周过滤」,数据量小。但这样用户翻到别的周就看不到那些只在稀疏周上课的课 (比如只在第 3、8、13 周上的课程)。所以改成:服务端返回全学期,前端用位掩码本地过滤、自由翻周 。位掩码正好让前端的这个过滤又快又简单。校历调整:调课与放假国内学校总有「调休补课」和「放假」:比如某周六要补周一的课,国庆中秋假期不上课。这些不在课表 HTML 里 ,要另外维护。项目里有一张 calendar_adjust 表,两种类型:类型含义字段makeup补课:某天按「被补的星期 + 被补的教学周」显示课表日期、目标星期、目标周、说明break放假:区间内不显示课程开始日、结束日、假期名称放假区间用半开区间[start, end),结束日当天复课,避免「结束日算不算放假」的歧义;end 缺省视为次日复课。接口把它们整理后返回给前端,前端据此渲染:调课日显示被补的那一天的课程,放假期间不显示课程,还能根据假期名称显示节日问候。内容指纹:让客户端知道「校历变了没有」// 内容指纹:任一条目被增删改都会变化,前端据此判断"校历有没有变"(不必轮询全量比对) String version = String.join("|", list.stream() .map(i -> String.join(":", String.valueOf(i.getId()), String.valueOf(i.getAdjusttype()), String.valueOf(i.getDatestart()), String.valueOf(i.getDateend()), String.valueOf(i.getTargetdayofweek()), String.valueOf(i.getTargetweek()), String.valueOf(i.getLabel()))) .toList()); data.put("version", version.isEmpty() ? "empty" : version);把每一条校历的关键字段拼成一个版本指纹 。前端缓存了校历,下次只需要比较一下 version 字段是否变化,就知道要不要刷新,而不用把全量数据逐条比较。注意连 label(假期名称)也参与了指纹——因为只改名称也要让客户端刷新 。最关键的是:校历存在数据库里,改库即对所有客户端生效,无需发版。 小程序发版要走审核,节假日调整却是每个学期都不一样的,所以必须是数据驱动的。小结解析课表:Jsoup 三层循环,每个假设都要有兜底 (下标、格式、非空),多门课拆分、HashSet 去重;页面改版后缺失的字段,用已验证的映射表 补齐,保证新旧数据格式一致;周次用位掩码 :一个 int 存整学期,判断某周是否有课是 O(1),单双周统一表达;解析失败要局部失败不扩散 :一个片段失败只影响自己,整体失败返回空而不是抛异常;学期由月份推导,开课日数据库优先、推导兜底、自动落库可手改 ;找最近周一用 TemporalAdjusters;服务端返回全学期,前端按位掩码本地过滤 ,才能自由翻周;校历调整(调课 / 放假)数据驱动 ,用半开区间,用内容指纹 让客户端感知变化。系列完结到这里,「培正森林」从数据库到登录、投稿、敏感词、图片、微博开放平台、课表、再到部署上线,主线都讲完了。回头看,这些文章里反复出现的是同几条经验:把失败分类 :确定性 vs 瞬态,写入、重试、降级都建立在这个分类之上;降级要分层 ,并且协议要能区分「失败」和「空」;默认值 + 可覆盖 :数据驱动的配置,让运营不用发版;外部系统一定会出乎意料 :超时、幂等、去重、限速、白名单,一个都不能少;把血泪教训写进注释和文档 ,因为一年后的你,已经不记得为什么要这样写了。谢谢你读到最后。如果你也在做类似的校园小程序,希望这个系列能帮你少踩几个坑。 最后修改:2026 年 11 月 12 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 如果觉得我的文章对你有用,请随意赞赏 文章引用 反向引用 Loading... 暂未引用其他文章 暂未被其它文章引用 下一篇 上一篇 发表评论 取消回复 使用cookie技术保留您的个人信息以便您下次快速评论,继续评论表示您已同意该条款 评论 * 私密评论 名称 * 🎲 邮箱 * 地址 发表评论 提交中... 进阶:强智课表(下),HTML 解析、周次位掩码与学期、教学周、校历调整 swt • 2025 年 04 月 19 日 文章目录 /*滚动条效果*/ /*定义滚动条高宽及背景 高宽分别对应横竖滚动条的尺寸*/ ::-webkit-scrollbar { width:7px; height:6px; } /*定义滚动条轨道*/ ::-webkit-scrollbar-track { background-color:white; -webkit-border-radius: 0em; -moz-border-radius: 0em; border-radius: 0em; } /*定义滑块 内阴影+圆角*/ ::-webkit-scrollbar-thumb { background-color: #ff676c; background-image: -webkit-linear-gradient(45deg,rgba(255,255,255,.4) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.4) 50%,rgba(255,255,255,.4) 75%,transparent 75%,transparent); -webkit-border-radius: 2em; -moz-border-radius: 2em; border-radius: 2em; cursor: pointer; } header.bg-light.lter.wrapper-md{ text-align: center; } .post_title_wrapper{ justify-content: center; } .text-muted.post-item-foot-icon.text-ellipsis.list-inline { text-align: center; } .btn-pay { animation: star 0.5s ease-in-out infinite alternate; } @keyframes star{ from { transform: scale(1); } to { transform: scale(1.1); } } img.img-full.img-circle { transition: 0.5s; } span.thumb-lg.w-auto-folded.avatar.m-t-sm { animation: light 4s ease-in-out infinite; } .thumb-lg { width: 68px } .img-full:hover { transform: scale(1.1) rotate(360deg); } @keyframes light { 0% { box-shadow: 0 0 4px red } 25% { box-shadow: 0 0 16px #0f0 } 50% { box-shadow: 0 0 4px #00f } 75% { box-shadow: 0 0 16px #0f0 } 100% { box-shadow: 0 0 4px green } } .item-thumb { cursor: pointer; transition: all .6s; } .item-thumb:hover { transform: scale(1.05); } .blog-post .panel:not(article){transition:all .3s} .blog-post .panel:not(article):hover{transform:translateY(-10px)} .index-post-title a:hover {color:#ff676c;}