孩子上学以后,家长群里的消息一天能刷几百条,老师发的通知、作业、照片经常被顶没影儿。学校想统一管理考勤和请假流程,家长又希望能有个地方集中看通知。这种需求在很多中小学都很常见,但真正做起来就会发现:既要让家长用得顺手(不用额外装 App,微信里直接打开),又要让老师管理省心,还要扛得住上下学时段的高并发访问。我最近完整做了一个“uniapp+springboot家校通小程序”,把这一整套流程跑通了。这篇就把整个项目的设计思路、关键代码、踩坑记录全部摊开讲,从技术选型到上线发布,完整还原一个可落地的家校通小程序是怎么从0到1做出来的。无论你是刚开始接触 uniapp 和 Spring Boot 的学生、准备接外包的独立开发者,还是想在校内信息化的方向尝试的公司团队,这篇都有可以“抄作业”的干货。
先说结论:前端用 uniapp,后端用 Spring Boot,数据存储选 MySQL + Redis,文件走对象存储,微信端通过小程序容器承载。这套组合最大的优势是快。uniapp 一套代码能同时编译到微信小程序、H5 和安卓 App,后端 Spring Boot 生态成熟、招人容易、出活稳定。对于家校通这种典型的“家长用微信小程序、老师用后台网页”的场景,这套方案能在很短时间内覆盖全部终端。
很多人喜欢一上来就贴代码,但我觉得做这类项目,先想清楚用户是谁、他们怎么用,比写代码重要一百倍。家校通的核心用户有三类:家长、老师、学校管理员。他们的使用场景完全不同。家长要的是“少操心”——打开小程序就能看到今天老师发了什么、孩子在校状态如何、请假申请有没有被批准。老师在意的是“别烦我”——发通知最好能一键群发,统计谁没读谁没回,考勤打卡要快,最好是扫码或 NFC。管理员则更多在看数据:班级出勤率、请假趋势、异常考勤。
基于这些使用场景,我设计了五个核心功能模块。通知公告模块是家校沟通的主阵地,支持富文本和图片附加;考勤打卡模块,支持老师批量标记和课中即时调整;请假审批模块,学生提交后经过班主任和德育处两级审批;班级相册模块,老师上传的照片自动归入班级相册,解决微信群照片过期的问题;还有个人中心,承载账号绑定和消息通知设置。
模块定完之后最重要的就是数据结构。比如通知公告,我设计成 notice、notice_read_record 两张表。notice 存通知本体,notice_read_record 存阅读记录。为什么分开?因为一个班三十个家长,一条通知发出去后需要记录“谁看了”“谁没看”,如果在一张表里用逗号拼 id,统计时写 SQL 会非常痛苦。分开以后,每个状态变化只插入一条记录,后台查“未读名单”就是一个简单的 left join。
考勤表同样不复杂。attendance_record 记录每个学生每天的考勤状态,一个学生一天一条记录,用日期字段加唯一索引约束。早读、午休、放学这种需要多次考勤的场景,加一个 period 字段区分。这里要特别提醒一点,主键千万别用学生 id + 日期的结构,因为后续极有可能要扩展“按时间段考勤”。我现在这个字段已经上线稳定运行,踩过一次改表的坑就明白这个道理了。
光想清楚这些还不够,说实话很多项目挂掉都不是功能不行,而是性能扛不住。家校通有非常明显的波峰波谷现象,早上七点半到八点半是打卡高峰,两千个学生集中在半个小时内产生考勤记录,平均每秒要处理几十条写入,偶尔还会冲到上百。这个量级对单体应用来说并不难,但前提是数据库别乱查。我的方案是考勤记录先落到 Redis 的队列里,然后由后端异步任务批量写入 MySQL。读操作走缓存,查询当天考勤状态时直接从 Redis 读,可能几万条请求也不会把数据库打崩。
业务逻辑清晰了,架构也就顺理成章。前端 uniapp 编译成微信小程序,后端 Spring Boot 提供 RESTful API。我把 redis、MySQL、对象存储的配置独立出来,所有接口加登录状态校验。家长的权限范围被严格限制,只能获取自己孩子的数据。这种“数据权限”的控制是家校通这类系统的生命线,如果 A 家长能看到 B 孩子的信息,这产品的口碑也就毁了。
uniapp 搭家校通前端时,我碰到的第一个事儿就是怎么组织目录结构。如果只是简单的几个页面,随便放都行。但家校通这种项目页面多,大概有二十个左右,不做分层的话,页面多了以后改一个样式要全局搜索,效率很低。我的目录结构大致是这样的:pages 下面按模块分目录,比如 pages/notice、pages/attendance、pages/leave、pages/user。components 放自定义组件,比如班级选择器、日期选择器、空状态组件。api 目录专门放请求封装,每个模块一个文件。static 放静态资源。utils 放工具函数,比如日期格式化、节流防抖。
main.js 里注册全局的 request 方法和一些常用的过滤器。关于 request 封装我有个建议,一定要统一处理 token 过期和错误提示。我们后端返回的数据结构是统一的,包含 code、message、data 三个字段。前端请求封装里判断 code,如果是 401 就跳转登录页,如果是其他错误就 toast 出来。这样页面代码里就不用每个请求都去处理错误逻辑,清爽很多。
路由是 uniapp 开发中绕不开的话题。可能很多人都会碰到“从列表页跳详情页,怎么把 id 带过去”这个场景。uniapp 提供了多种方式,最基础的是在 url 后面拼接 query string,然后在目标页面的 onLoad(options) 里接收。但这种方式传的参数只能作为字符串传递,遇到复杂对象就得先 JSON.stringify 再编码,取的时候还要 decodeURIComponent,非常麻烦。
我测试以后发现一个比较靠谱的替代方案,就是通过 uni.setStorageSync 暂存数据,页面跳转后再读取。比如从通知列表点进详情,先把整条通知对象存到存储里,跳转后 onShow 的时候取出来。这方法好在不用处理 URL 编解码,且能够传递对象,并且不会因为参数太长导致微信的兼容性问题。但使用它必须记得在页面销毁前清理掉这条缓存,否则数据会残留。相较之下,eventChannel 更适合父页面和子页面之间频繁通信的场景,家用更省事的话仍然推荐用 storage 方案。
这个项目的页面跳转比较多,过程中有一个坑:路劲跳转写错,导致页面白屏。这种问题通常不会报错,很难排查。后来我制定了个约定,凡是跳转页面,一律在跳转前检查一遍路径,路径长的时候可以封装一个 navigateTo 方法统一管理,不要在每个地方手写。这样能少踩很多坑。
状态管理方面,家校通并没有特别复杂的状态共享需求,我使用的是 Vuex。全局存储的信息主要是:用户基本信息、当前绑定的孩子、未读消息数量、TabBar 的选中状态。把这三个存全局状态是因为涉及的页面多,又需要在多处修改。token 不建议放进 Vuex,因为刷新就没了,建议使用 uni.setStorageSync 持久化存储。登录的时候后端返回 token,前端存到 storage,每次请求头带上。再结合拦截器统一处理,登录状态就不会乱。
对于列表类页面,有个反复出来的问题:下拉刷新和滚动到底部加载更多。当时调这个还花了不少时间。最初我直接使用页面级的 onPullDownRefresh 来触发下拉刷新。但这个机制在页面使用 scroll-view 时会出现冲突——你往下滑想刷新时页面随之滚动,可实际上需要的是局部区域滚动,页面根本都没滚动起来,于是两个组件就在那里互相打架。
之后我切换到基于 scroll-view 的方案,开启 refresher-enabled,然后设置 refresher-triggered 来控制刷新动画。要注意的是,动态列表必须用 v-for 逐个渲染,并设置好每一个列表项的高度,避免图片加载时列表跳动。然后监听 scrolltolower 事件来加载更多。再限制 @scroll 事件的节流,不要每个像素都触发请求,否则渲染性能会被白白消耗。
页面层的交互做好之后,接下来是数据和页面怎么联系起来。最开始我都是直接在页面里调接口、写逻辑,导致每个 page 文件都几百行,维护效率非常低。后来我把接口调用封装一层,页面只负责渲染数据、监听事件、调用接口。用 async/await 替代 promise 的链式调用,代码结构清晰很多。
这里特别提一下登录注册这部分的设计。家校通的用户体系比较特殊,有家长和老师两类角色,老师的账号可能是学校批量导入的,家长则可能是注册后扫码绑定孩子。如果都走传统手机号验证码注册流程,老师会烦死。我给老师提供一个批量导入 Excel 的功能,导入后生成一个初识密码,第一次登录的时候强制改密。家长端则采用“手机号 + 验证码”登录,首次登录会引导绑定孩子。绑定关系放在 parent_child 表里,包含家长 id、孩子 id、关系类型,比如父亲母亲或其他。
后端 Spring Boot 的项目搭建相对成熟,我选择 Java 17 + Spring Boot 3.x,而不是前几年大家常用的 2.x。Spring Boot 3 基于 Jakarta EE,性能更好,而且官方支持周期更长。如果你的机器上 JDK 还没到 17,或者项目里还有一些老依赖只兼容到 javax,那老老实实用 Spring Boot 2.7 也行,没必要为了追求新版本给自己添麻烦。我见过不少人在 Spring Boot 版本太高的坑里折腾了整整半天,就是因为某个 starter 版本跟不上。
项目的分包原则是 controller 只做参数接收和权限校验,service 层处理业务逻辑,mapper 层通过 MyBatis-Plus 操作数据库,实体类使用 Lombok 减少样板代码。对于复杂查询,直接写 XML 中的自定义 SQL,而不是在 service 层各种循环 list。毕竟 MyBatis-Plus 的 QueryWrapper 只是方便简单的 CRUD,遇到多表关联统计、带条件的动态分页这种场景,代码会很难看且执行效率不高。我用@TableName 注解将 Java 实体类与数据库表对应,同时用 @TableId 标记主键。分页配置一个 MybatisPlusInterceptor 就可以全局生效。
RESTful API 的设计上,/api/v1/notice、/api/v1/attendance、/api/v1/leave 是三个最核心的资源。每个实体都要有 List、Detail、Create、Update、Delete 五个接口。
权限设计是核心中的核心。我采用了基于 RBAC 抽角色的权限模型,用户表、角色表、用户角色关联表、权限表。但在实际实现中,权限数不用控制到按钮级,因为前端页面就那么几个。到角色级就已经足够了。我在后端的拦截器里写了一个简单的 JWT 拦截,解析请求头中的 token,获得用户 id 和角色类型,然后再结合注解来区分接口是否需要对应的权限类型。注意 JWT 的 secret 不能直接硬编码在代码里,要放到配置文件用环境变量引用。
JWT 登录与普通 token 登录的核心区别在于服务端不存储状态。签发 JWT 后,服务端通过解析 token 里的签名来确认请求者身份。使用 JWT 需要特别小心密钥泄露,因为一旦密钥丢了,任何人都能伪造管理员身份。即使是这么小的项目,我都建议在网关层实现对 IP 白名单和接口限流的支持。
关于密码存储也要说明,绝对不能明文存,也不能只做一次 MD5。现在比较推荐的做法是为每个用户生成一个随机的 salt,然后迭代 HMAC 算法进行拉伸处理。虽然 BCrypt 在 JVM 上表现不算快,但这反而是个优点,攻击者想暴力破解会慢得多。
后端还有一个比较核心的点是 WebSocket 实时推送。家长在请假后希望立刻知道审批结果,老师发通知后家长端要收到提醒。这种场景如果只靠前端轮询,体验会很差。我在 Spring Boot 中集成了 WebSocket,在用户登录时建立连接,然后后端通过 Redis 订阅消息来感知是否有新通知需要推送给在线用户。家长的请假状态变化,后端在 service 层触发一个事件,然后通过 WebSocket 把推送消息发送到家长所在终端。WebSocket 连接的用户身份通过 JWT 校验,连接建立时从 URL 参数中取 token,查 redis 确认登录状态,再绑定用户 ID 到对应的 session 对象集合中。每个连接建立后不需要频繁做全量广播,可以按用户维度定向发送。
这里需要强调,小程序端使用 WebSocket 需走 wss 协议,且必须在公众平台上配置合法的域名证书。如果只是本地开发调试,开发者工具里可以勾选不校验合法域名,但真机预览就只能用域名地址。项目上线后要把 ws 服务收敛到同一个域名下并用 Nginx 做反向代理加 SSL 终止。我一开始没注意,用 IP 的 ws 地址测试,结果开发者工具能过、手机一测就断连,排查时才发现就是这个原因。
我之前在处理学生考勤到请假申请这个模块的时候,同学问我这个流程是不是该引入工作流引擎。很多 Spring Boot 开发者在提到流程审批时会不自觉把“必须用 Flowable”挂嘴边。但实际做这个项目时我犹豫了很久,最后没有用工作流引擎。原因是我这个请假流程只有两级审批。如果用 Flowable,需要额外维护流程定义、部署、实例,对双方都是一种过度设计。业务逻辑代码里反而几行就能实现状态流转。
Flowable 确实有它的应用场景。如果以后学校希望做类似“请假超过三天需要医务室审核”“调课申请需要教务处、年级组、校长办三方会签”这样的复杂流程,那时再上 Flowable 也不迟。不过我现在选择直接基于状态机的思路,把请假单设计一个 status 字段,用枚举管理这些状态:待审批、班主任通过、班主任驳回、德育处通过和德育处驳回。每次审批操作不改变原有内容,只更新状态并记录一条审批日志,这样整个链路有迹可循。
实现审批日志时我设计了一个 audit_log 表,包含单据类型、单据 id、操作人、操作动作、备注和创建时间。每次请假状态变了就插入一个日志。这个表上线后帮了大忙,某个家长打电话来问“老师是不是批了假”时,管理员点开记录一看,谁什么时候点的统统清楚了。强烈建议涉及审批状态的模块都加上这张表,成本很低但作用非常大。
下面重点说两个我觉得技术含量比较高的实战细节,一个是后端的分页查询与数据权限融合,另一个是小程序端多媒体和文件上传。
很多人写分页的常规思路是写个selectPage 方法,然后把它传给 controller 去执行。但要警惕,数据权限一旦放开后果严重。比如班主任查自己班上的考勤记录,如果不做控制,一个悟性的同学可能通过遍历参数查看全年级数据。我的做法是在 mapper 层自动带上了当前用户的班级和学校过滤条件,不把班级 id 当作前端传参。后端从 token 中解析出当前用户的 schoolId和classId 后,在 service 层把这两个路径塞进查询条件,核心逻辑上的权限就收得住了。
上传场景上面提到家校通里家长会上传请假佐证,也可以是孩子拿着病历照片。文件上传的接口里,Spring Boot 使用 MultipartFile 接收后,先做文件类型和白名单校验,例如只允许 jpg png pdf,再做大小限制。经过裁剪压缩后的图片建议限制在 5MB 以内,PDF 不得超 20MB。这里要是没限制,容易直接把服务器存储吃满。文件最终上传到阿里云 OSS 或者腾讯云 COS 等对象存储,这些对象存储服务商都会提供预签名 URL,直传到 COS 的方式很适合小程序环境。
小程序端上传图片时有一个不得不提的能力:uni.compressImage。微信开发者工具不会自动管理图片压缩,用户拍的相册原图体积动辄上十兆,如果直接上传会把带宽和服务器写满,用户等待时间长且在弱网环境极容易失败。因此每次选择图片后,不能直接调用上传接口,应该先通过 uni.compressImage 压缩到合适的质量,如果是用户相册选图,则先走 uni.getImageInfo 读取宽高,超过一定尺寸就等比缩放。我用这段代码对用户想上传的每一张照片做处理,压缩之后的文件大小一般能控制在几百 KB 以内。
然后跟后端接口取得上传凭证,再将文件直接传到云存储。返回的 fileUrl 是一个 CDN 地址,需要被保存到数据库。为了保证这个地址能正常访问,我给上传功能加了回调验证,在后端对文件 URL 做一次 HEAD 请求检查可访问性。
再有一个容易出问题的点,就是小程序里的富文本内容。老师和学校管理后台发的通知可能包含图片、表格、各种文字格式。前端拿到后端保存的富文本字符串以后,如果直接渲染会出问题,因为小程序的 view 组件根本不识别 HTML。我最初被这个问题坑过一次。通知详情页里面嵌套了带 p 标签的 HTML 字符串,页面什么都不显示,控制台也不报错。排查到最后发现就是需要 JSON 字符串解析或使用对应的富文本渲染组件。这里的解决路径有两个:第一,后端管理后台富文本编辑完保存,前端用 u-parse 这种解析组件来渲染 HTML 字符串;第二,后端直接返回简单的 JSON 结构,前端用节点树递归渲染。对于通知这种内容比较规整的场景,我更推荐第一种。
做这种项目,证书和权限是最容易被忽略的坑,尤其是发布阶段。小程序如果想要发布到线上,微信公众平台里必须配置服务器域名,包括 request 合法域名、uploadFile 合法域名、downloadFile 合法域名等。并且这些域名必须备案且支持 HTTPS,否则在小程序发布体验版时会直接提示不在以下 request 合法域名列表中。我测试的时候图方便直接把 request 域名留空,结果在开发者工具中一切正常,但手机体验版一打开就白屏。直到我去公众平台后台把接口域名加到合法域名列表里,微信官方域名校验是需要一段时间的,加完之后还要等一小会。
另外如果未来扩展到 App 端,uniapp 打包安卓应用市场上架时,必须先申请软件著作权,然后准备隐私政策网址。在应用内部,每次启动还要弹窗询问用户是否同意隐私政策,如果用户不同意,需要做好退出逻辑。应用市场审核他们很看重这两点。代码侧,App 启动时会检查本地缓存中是否已有同意隐私政策的标记。如果没有,则强制显示一个半弹窗,用户点击“同意”才能进入主界面;点击“不同意”则调 uni.exitApp 退出应用。
小程序的个人隐私保护现在同样抓得很严。在我开发时,即使只是使用 wx.login 获取用户的 openid,也会在管理后台要求填写“用户隐私保护指引”,把可能收集到的用户信息类型像用户手机号、选中的照片或视频信息等逐项列出来并说明使用目的。如果没配置完整,开发版调试时就可能触发 wx.getUserProfile 不返回数据的错误。这不是你在代码里多写一个参数能绕过的,必须在后台把隐私协议配置好,并在代码中通过 wx.requirePrivacyAuthorize 或 getPrivacySetting 主动拉起授权弹窗与用户确认。
我一边开发一边整理了一份常被问到的 bug 手册,很多都是实际遇到的问题。第一个典型问题是 uniapp 中 WebView 页面打开时会闪白。主要原因是 WebView 组件初始化需要时间,所以白屏是常态。如果体验不好,可以给 web-view 加一个 loading 遮罩,在 @load 事件触发后隐藏。
第二个是下拉刷新不灵的问题。前面提过如果页面内部使用了 scroll-view 做主要滚动,那么页面级下拉刷新确实不会生效。要么把业务逻辑完全放在 scroll-view 内部并采用它的 refresher 属性;要么去掉 scroll-view,直接使用页面滚动配合 onReachBottom 和 onPullDownRefresh。不要两种混在一起用,嵌套滚动在移动端兼容性上是个大坑。
第三个是 iOS 上 input 输入框被软键盘顶起不弹回。开发 App 或 H5 的时候,iOS Safari 的输入框弹出软键盘后页面会被顶上去,有时关闭键盘以后 input 仍悬在半空。adjust-position 属性往往能解决一部分问题,但 iOS WebView 内核比较顽固。比较稳妥的做法是记录键盘弹起前的滚动位置,在失焦后强制把页面滚回去。
第四个问题在多端上架时常见:android 设备上,通知消息推送需要自己实现厂商推送通道,比如小米、华为和 vivo 的 pushSDK 各自独立。uniapp 可以选择接入 uni-push。如果要自己实现免签或离线推送,单是各个厂商的推送 SDK 就能把人折腾疯。
最后一个常见 bug 是 renderjs 处理的 mp4 视频在部分手机端无法播放。后来发现问题是视频编码格式不符合 WebView 的要求,要使用 H.264 编码并设置正确的 moov 位置。使用 ffmpeg 压缩视频时,我用命令行 ffmpeg -i input.mp4 -vcodec h264 -profile:v main -movflags +faststart output.mp4 来重新转码,视频在渲染层就能顺利播放了。
这次项目已经跑通完整开发流程,我这边还有几个个人心得想分享。开发 uniapp + Spring Boot 的项目,最大的竞争力就是上手快。如果有一定的 Vue 和 Java 基础,每周全职做的话,从零到上线一个功能完整的小程序其实能控制在三到五周。时间主要消耗在联调和体验打磨上。
项目管理过程中的核心经验是有两点。第一,接口定义必须先行,不能前端写前端的后端写后端的,这样最后两边打起来无休无止没有尽头,项目很难推进。既然项目前后端分离,就应该先定出 API 文档然后前端可以 mock 数据并行开发,后端自主实现接口。我在项目里使用 Apifox 管理接口和数据模型,前端直接引用在线 mock。等后端接口完成,前端只需要切换 baseURL 即可。这样一个人的开发效率也足够支撑整个项目。
第二点是上线前一定要做一次完整的流程回归测试。家校通的用户有相当部分是年级比较大的长辈,他们的操作习惯和程序员并不相同。最容易出现的情况是家长在请假申请传输过程中频繁双击提交按钮,导致后端生成了两条请假记录。问题出在我没有在提交接口做幂等控制。解决方式是前端提交时把提交按钮置灰避免连点,后端保存请假数据时也严格校验该学生在同一时间段是否已经存在申请中单子。后端在表设计上给 status 字段加了一个范围约束和联合唯一索引,通过唯一索引尽量防止重复数据的产生。
关于项目实施过程,很多时候你在为某个学校定制开发完,第二个学校又会带着完全不一样的需求找过来。因此,开发时为了便于后续可复用开发,我现在将涉及的通用核心模块拆分了出来:权限认证、班级学生管理、通知公告、考勤打卡、请假审批、作业管理、成绩管理。每个模块都做成相对独立的包,新学校接进来主要调整的是个性化字段和数据初始化,并不是重写业务逻辑。这样一个项目做成了五六个学校使用,边际成本几乎没有增加,但对于接定制类项目的人来讲,这可能是利润率的正解。
最后提醒一句,后端代码接近写完时要及时在 pom.xml 做依赖清理。我一度因为要测试一个无关的需求引入了几个用不到的 starter,打包完成后 jar 有 140 多兆,启动还慢。后来把那些无用的依赖引用的依赖移除,jar 减少到不到 80 兆,启动时间也缩短了不少。优化完构建之后,顺手用 jib-maven-plugin 打了个容器镜像,之后部署就是 docker pull 加 docker run 的事,扩展服务器节点也变得无比简单。项目的运维成本低下来以后,整个交付体验会舒服很多。
