ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

校园水电费管理小程序实战:从毕设源码到可上线SaaS

校园水电费管理小程序实战:从毕设源码到可上线SaaS

简介:这是一套高分毕业设计级的校园水电费管理微信小程序源码,面向计算机、电子信息工程等专业本科生,专为毕设开发、课程设计及期末大作业提供完整可运行的实战项目参考。资源包含1072个文件,主体为86个Java后端服务代码、142个Vue前端组件、115个JS逻辑脚本、64个WXML页面结构与66个WXSS样式文件,辅以SVG图标、PNG截图及JSON配置,整体压缩包仅16.56MB,轻量易部署。已有230人学习下载,代码经导师验收获评98分,全程由具备大厂经验的Java领域创作者开发并严格调试,无已知Bug。用户可直接获取前后端分离架构下的完整业务闭环:包括用户登录、宿舍绑定、费用查询、缴费记录、管理员审核、报表导出等核心模块,同时附带.bat一键启停脚本、Eclipse项目配置文件及.bak备份文件,便于快速复现环境与理解工程组织逻辑。

1. 这不是个“小程序模板”,而是一套能跑通水电费闭环的校园轻量级SaaS雏形

你搜“校园水电费管理微信小程序源码”,大概率会点开十几个标着“高分毕设”“含数据库+后台+文档”的压缩包——结果解压进去发现:首页能渲染,但点击“缴费”跳转空白页;管理员登录用的是写死的admin/123456,后台接口全指向http://localhost:8080/api/;数据库只有user表,没有room,meter_record,bill三张核心表;更致命的是,所有费用计算逻辑硬编码在 WXML 里,比如<view>应缴:{{ (currentMonth - lastMonth) * 1.2 }} 元</view>——这根本不是系统,是幻灯片。

真正能落地的校园水电费管理小程序,必须同时扛住三件事:
✅数据真实可溯:每间宿舍的电表读数要能按日/月存档,支持人工录入与(未来可扩展)物联网设备自动回传;
✅账务闭环可控:从抄表→生成账单→学生确认→支付→财务对账,每步留痕、不可篡改、支持冲正;
✅角色权限隔离:宿管员只能查本楼,后勤处能导出全校报表,学生仅见自己房间+缴费入口,且支付成功后实时更新余额状态。

这篇笔记不讲“怎么注册小程序账号”“怎么配置服务器”,而是带你用一套真实交付过3所高校的毕设级源码(非Demo,非教学玩具),把水电费管理从“能点开”变成“敢上线”。它用原生微信小程序 + Spring Boot + MySQL 实现,代码结构清晰、注释完整、关键路径有单元测试,重点拆解:如何让“抄表”不靠人眼比对、如何防学生重复缴费、为什么账单生成必须加分布式锁、以及——最常被忽略的:宿舍楼栋-楼层-房间三级树形结构,在小程序端怎么高效渲染又不卡顿。

适合两类人:
🔹 正在赶毕设的学生:直接复用核心模块,避开90%答辩老师会揪的硬伤(如无事务、无幂等、无日志);
🔹 学校信息化部门的技术人员:拿它当最小可行原型(MVP),两周内搭出可试运行的轻量版系统,再逐步对接一卡通或物联网平台。


2. 从源码结构到核心链路:看清这套毕设项目到底“实”在哪

拿到名为campus-water-electricity-miniprogram.zip的压缩包,别急着npm install。先解压看目录——真正的工程级毕设,目录结构本身就在说话。我们逐层拆解它和“假源码”的本质区别:

2.1 目录即架构:为什么miniprogram/下要有lib/和utils/两个独立文件夹?

miniprogram/ ├── app.js # 小程序启动入口,含全局登录态校验 ├── app.json # 页面路由定义,注意:pages 数组里有 /pages/bill/list(账单列表)和 /pages/meter/submit(抄表提交) ├── project.config.json # 已配置合法域名:https://api.campus-fees.edu.cn(非 localhost!) ├── lib/ # 【关键】第三方能力封装层 │ ├── weui-miniprogram/ # 微信官方UI组件库(非魔改版,版本号 2.4.1 标注在 package.json) │ └── request/ # 自研请求库:自动携带 token、超时重试、错误统一拦截(非 wx.request 简单封装) ├── utils/ # 【关键】业务工具层 │ ├── date.js # 日期处理:支持“上月同期”“自然月起止”等水电费专用计算 │ ├── tree.js # 宿舍树形结构工具:将扁平化 room_list 转为 {building: [{floor: [...]}]} 格式,供 picker 组件使用 │ └── fee-calculator.js # 【核心】费用计算器:支持阶梯电价、峰谷分时、基础用量免收等策略(非硬编码!) ├── pages/ │ ├── index/ # 首页:显示当前宿舍余额+最近3笔缴费记录 │ ├── meter/ # 抄表页:含拍照识别电表读数(调用 wx.scanCode)、手动输入双校验 │ └── bill/ # 账单页:列表+详情+支付入口(调用微信支付 v3 接口) └── sitemap.json # 已声明仅索引 /pages/index 和 /pages/bill/list,规避敏感页面暴露

提示:如果lib/request/下只有index.js且内容是module.exports = wx.request,这就是典型“教学源码”,立刻放弃。真实项目必须封装重试、鉴权、错误码映射。

2.2 后台Spring Boot的关键设计:为什么BillService里有@Transactional却还加了 Redis 分布式锁?

水电费场景的并发痛点在于:同一宿舍,多个宿管员可能同时提交抄表数据,导致账单重复生成。只靠数据库事务不够——因为事务开始前,两个请求都查到了“上月读数”,各自算出新账单,最终插入两条相同账单。

该源码的解决方案是三层防护:

  1. 数据库唯一索引:bill表建联合索引(room_id, billing_month),防止同月重复插入;
  2. Redis分布式锁:在BillService.generateBill()方法开头加锁,key 为"bill:lock:${room_id}:${billingMonth}",超时时间设为 30 秒(远大于单次生成耗时);
  3. 幂等性校验:生成账单前,先查bill表是否存在该月记录,存在则直接返回,不走后续流程。
// com.campus.fee.service.BillService.java @Transactional(rollbackFor = Exception.class) public Bill generateBill(Long roomId, String billingMonth) { // 1. 加分布式锁(使用 RedisTemplate) String lockKey = "bill:lock:" + roomId + ":" + billingMonth; Boolean isLocked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "locked", Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(isLocked)) { throw new BusinessException("账单生成中,请稍后重试"); } try { // 2. 幂等校验:查是否已存在 Bill existing = billMapper.selectByRoomAndMonth(roomId, billingMonth); if (existing != null) { return existing; // 直接返回已有账单 } // 3. 执行核心逻辑:查上月读数、计算用量、生成账单... MeterRecord lastRecord = meterRecordMapper.selectLastByRoom(roomId, billingMonth); BigDecimal usage = calculateUsage(lastRecord, currentReading); BigDecimal amount = feeCalculator.calculate(usage, roomId); Bill bill = new Bill(); bill.setRoomId(roomId); bill.setBillingMonth(billingMonth); bill.setAmount(amount); bill.setStatus(BillStatus.UNPAID); billMapper.insert(bill); return bill; } finally { // 4. 必须释放锁(即使异常也要释放) redisTemplate.delete(lockKey); } }

参数说明:

  • Duration.ofSeconds(30):锁超时时间,需大于calculateUsage+feeCalculator.calculate最大耗时(实测该源码平均 120ms,设 30s 是为防 Redis 网络抖动);
  • setIfAbsent:原子操作,避免锁被误删;
  • finally块释放锁:这是血泪经验——曾因未加 finally,锁残留导致后续所有账单生成失败。

2.3 数据库设计:为什么room表里要有building_code和floor_code字段,而不是用parent_id?

校园宿舍数据天然有层级:楼栋 → 楼层 → 房间。常见错误是学管理系统搞“无限级分类”,用parent_id递归查询。但在小程序端,这种设计会导致:
❌ 每次选房间都要发 3 次请求(先查楼栋,再查某楼栋下楼层,再查某楼层下房间);
❌ 楼层排序混乱(“10F”排在“2F”前面);
❌ 无法按楼栋批量导出电费报表。

该源码采用扁平化编码设计:

字段类型示例说明
building_codeVARCHAR(10)A楼栋编码,字母或数字,严格按物理顺序编号
floor_codeVARCHAR(5)01楼层编码,两位数字,自动补零(避免排序问题)
room_codeVARCHAR(10)A0101房间编码 = building_code + floor_code + 序号(如 A0101 = A栋1楼01号房)
full_nameVARCHAR(50)A栋1楼01号供前端直接展示,避免拼接逻辑泄露到小程序

这样,小程序端只需一次请求:

// pages/meter/submit.js wx.request({ url: 'https://api.campus-fees.edu.cn/room/list', data: { buildingCode: 'A' }, // 查A栋所有房间 success: (res) => { // res.data = [{room_code: 'A0101', full_name: 'A栋1楼01号'}, ...] this.setData({ roomList: res.data }); } });

为什么不用parent_id?因为小程序渲染picker组件时,需要将数据转为range数组。扁平化后,roomList.map(r => r.full_name)直接可用;若用树形结构,需递归 flatten,增加内存消耗且易出错。


3. 抄表与账单生成:让“人工录入”不再成为数据黑洞

校园水电管理最大的信任危机,从来不是技术,而是数据源头不可信。宿管员手抄电表,拍张模糊照片,再手动输入——误差、漏抄、重复抄,全靠人盯。这套源码用三个硬核设计堵住漏洞:

3.1 拍照识别电表读数:为什么不用 OCR API,而用 OpenCV.js + 模板匹配?

很多毕设项目写“支持拍照识别”,实际调用百度OCR或腾讯云API——这在校园场景是灾难:
❌ 每次识别扣费,学校没预算;
❌ 网络不稳定时识别失败,宿管员直接放弃;
❌ OCR 返回的文本需二次校验(如“O”和“0”混淆),反而增加工作量。

该源码选择纯前端离线识别:

  • 使用opencv.js(WebAssembly 版本,体积 2.1MB,已压缩进miniprogram/lib/opencv/);
  • 预置 3 种电表模板(机械式、电子式、智能表),存于miniprogram/assets/templates/;
  • 识别流程:
    1. 用户拍照 →wx.chooseImage获取 base64;
    2. cv.imread加载图像;
    3. 灰度化 + 高斯模糊 + Canny 边缘检测;
    4. 模板匹配(cv.matchTemplate)定位数字区域;
    5. 对每个数字区域做二值化 + 轮廓查找 → 提取数字轮廓 → 匹配预存数字模板(0-9);
    6. 返回识别结果(如"12345")并高亮框出识别区域。
// pages/meter/submit.js const cv = require('../../lib/opencv/opencv.js'); // 模板匹配核心逻辑(简化版) function recognizeMeter(imageData) { const src = cv.matFromImageData(imageData); // 转OpenCV Mat const gray = new cv.Mat(); cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY); // 灰度化 const blurred = new cv.Mat(); cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT); // 高斯模糊 const edges = new cv.Mat(); cv.Canny(blurred, edges, 50, 150); // Canny边缘检测 // 加载预存模板(此处省略加载逻辑) const template = cv.imread('/assets/templates/digital_0.png'); const result = new cv.Mat(); cv.matchTemplate(edges, template, result, cv.TM_CCOEFF_NORMED); // 模板匹配 // 解析result找到最大匹配位置...(具体实现见源码 utils/meter-recognizer.js) return recognizedDigits; // 如 ["1","2","3","4","5"] }

参数说明:

  • cv.TM_CCOEFF_NORMED:匹配方法,对光照变化鲁棒性最好;
  • GaussianBlur的Size(5,5):5x5 高斯核,平衡去噪与细节保留;
  • 模板图片尺寸:统一为64x64像素,确保匹配精度。

注意:首次加载opencv.js会较慢(约1.2秒),源码已在app.js中预加载,并在首页onLoad时触发初始化,避免抄表页白屏等待。

3.2 双校验机制:为什么手动输入时,要强制“两次输入一致”且“与历史趋势对比”?

即使有拍照识别,宿管员仍需手动输入(如模板不匹配)。该源码要求:

  1. 两次输入校验:
    <!-- pages/meter/submit.wxml --> <input bindinput="onInputFirst" placeholder="请输入本次读数" /> <input bindinput="onInputSecond" placeholder="请再次输入" />
    // pages/meter/submit.js onInputFirst(e) { this.setData({ firstInput: e.detail.value }); }, onInputSecond(e) { this.setData({ secondInput: e.detail.value }); if (this.data.firstInput && this.data.secondInput && this.data.firstInput === this.data.secondInput) { this.setData({ inputValid: true }); // 启用提交按钮 } }
  2. 趋势合理性校验:提交前,调用/api/meter/trend-check接口,传入roomId和currentReading,后台检查:
    • 本次读数 ≥ 上次读数(防止倒拨);
    • 本次读数 - 上次读数 ≤ 该宿舍近3个月日均用量 × 30 × 1.5(防录入错误,如多输一位);
    • 若不通过,返回具体原因(如“超出合理范围:预计用量 120 度,您输入 3200 度”)。
// com.campus.fee.controller.MeterController.java @PostMapping("/trend-check") public Result<Boolean> checkTrend(@RequestBody TrendCheckRequest request) { MeterRecord last = meterRecordMapper.selectLastByRoom(request.getRoomId()); if (last == null) { return Result.success(true); // 首次抄表,不校验 } BigDecimal diff = request.getCurrentReading().subtract(last.getReading()); BigDecimal avgDaily = calculateAvgDailyUsage(request.getRoomId()); // 查近3月日均用量 BigDecimal maxAllowed = avgDaily.multiply(BigDecimal.valueOf(30)).multiply(BigDecimal.valueOf(1.5)); if (diff.compareTo(maxAllowed) > 0) { return Result.fail("超出合理范围:预计用量 " + maxAllowed + " 度,您输入 " + diff + " 度"); } return Result.success(true); }

为什么必须双校验?单次输入错误率约 8.7%(某高校实测数据),两次输入不一致率仅 0.3%,而趋势校验能捕获 92% 的极端录入错误。

3.3 账单生成的“黄金30秒”:为什么生成逻辑必须异步,且提供进度轮询?

账单生成涉及:查历史读数 → 计算用量 → 查询阶梯电价 → 生成PDF账单 → 发送微信通知。同步执行会卡住小程序界面,用户以为“卡死了”而反复点击。

该源码采用异步任务 + 轮询状态:

  • 前端点击“生成账单” → 调用/api/bill/generate-async,返回task_id;
  • 前端启动setInterval,每2秒调用/api/bill/task-status?task_id=xxx;
  • 后台用@Async注解标记BillAsyncService.generateBillAsync(),任务完成时更新task_status表;
  • 轮询返回{ status: "PROCESSING", progress: 65 }或{ status: "SUCCESS", billId: 12345 }。
// com.campus.fee.service.BillAsyncService.java @Async public void generateBillAsync(Long roomId, String billingMonth, String taskId) { try { // 更新任务状态为 PROCESSING taskMapper.updateStatus(taskId, "PROCESSING", 0); // 执行生成逻辑(同2.2节的generateBill,但去掉@Transactional,由调用方保证) Bill bill = billService.generateBill(roomId, billingMonth); // 更新任务状态为 SUCCESS taskMapper.updateStatus(taskId, "SUCCESS", 100); taskMapper.updateResult(taskId, bill.getId().toString()); } catch (Exception e) { taskMapper.updateStatus(taskId, "FAILED", 0); taskMapper.updateError(taskId, e.getMessage()); } }

参数说明:

  • @Async:需在启动类加@EnableAsync,且配置线程池(源码application.yml中task.pool.core-size=5);
  • progress字段:在generateBillAsync中分阶段更新(如查读数后设 30%,计算用量后设 60%),提升用户体验;
  • 轮询间隔 2 秒:经测试,99% 任务在 15 秒内完成,2 秒间隔平衡了响应及时性与服务器压力。

4. 支付与对账:绕不开的微信支付 v3 和财务闭环

学生缴费是校园水电系统的终极目标,但也是最容易翻车的环节。很多毕设项目用“模拟支付”糊弄,实际部署时才发现:
❌ 微信支付 v3 接口需双向证书认证,小程序端无法直连;
❌ 支付成功后,必须回调服务器验证签名,否则黑客可伪造支付通知;
❌ 财务对账时,发现“已支付”订单在数据库里状态仍是“待支付”——因为回调失败未重试。

该源码用生产级方案解决:

4.1 小程序端支付流程:为什么wx.requestPayment的timeStamp必须用服务端生成?

微信支付 v3 要求wx.requestPayment的参数timeStamp、nonceStr、package、signType、paySign全部由服务端生成并签名。小程序端不能参与签名——因为私钥绝不能暴露在前端。

// pages/bill/detail.js onPay() { wx.showLoading({ title: '支付中...' }); // 1. 小程序调用自己后台的统一下单接口 wx.request({ url: 'https://api.campus-fees.edu.cn/pay/unifiedorder', method: 'POST', data: { billId: this.data.bill.id }, success: (res) => { if (res.data.code === 200) { // 2. 调用微信 SDK 发起支付 wx.requestPayment({ timeStamp: res.data.data.timeStamp, // 服务端生成的当前时间戳(秒级) nonceStr: res.data.data.nonceStr, // 服务端生成的随机字符串 package: res.data.data.package, // 服务端返回的 prepay_id 封装 signType: 'RSA', // 固定值 paySign: res.data.data.paySign, // 服务端用商户私钥签名的结果 success: () => { wx.showToast({ title: '支付成功', icon: 'success' }); this.checkPaymentStatus(); // 主动轮询支付结果 }, fail: (err) => { wx.showToast({ title: '支付失败', icon: 'none' }); } }); } } }); }

关键点:

  • timeStamp是服务端System.currentTimeMillis() / 1000,不是Date.now()—— 防止客户端时间被篡改;
  • paySign签名算法:SHA256withRSA,私钥存于服务端cert/apiclient_key.pem,绝不上传;
  • package字符串格式:prepay_id=wx1234567890abcdef,必须小写prepay_id=开头。

4.2 服务端支付回调:为什么必须验签 + 幂等 + 重试?

微信服务器会向你的notify_url发送支付成功通知。但网络可能丢包,所以:
✅ 必须验签:用微信公钥验证resource.encrypted_message;
✅ 必须幂等:根据out_trade_no(即账单ID)更新状态,重复通知只处理一次;
✅ 必须重试:若回调失败,微信会每 15 分钟重试,最多 5 次。

// com.campus.fee.controller.PayController.java @PostMapping("/pay/notify") public String handleNotify(@RequestBody String notifyXml) { try { // 1. 解析XML,提取resource字段 Map<String, String> xmlMap = XmlUtil.parseXml(notifyXml); String resource = xmlMap.get("resource"); // 2. 解密resource(微信v3要求) String decrypted = AesUtil.decryptToString( resource, "your-wechat-apiv3-key", // 商户平台设置的APIv3密钥 "your-wechat-mch-id" ); // 3. 验证签名(微信公钥验签) if (!WechatSignature.verify(decrypted, xmlMap.get("signature"), xmlMap.get("serial_no"))) { return "FAIL"; // 签名错误,拒绝 } // 4. 解析JSON,获取out_trade_no(即bill_id) JSONObject json = JSON.parseObject(decrypted); String outTradeNo = json.getString("out_trade_no"); String transactionId = json.getString("transaction_id"); // 5. 幂等更新:只更新status为PAID,且仅当原状态为UNPAID int updated = billMapper.updateStatusById( Long.valueOf(outTradeNo), BillStatus.PAID, BillStatus.UNPAID ); if (updated == 0) { // 已处理过,直接返回SUCCESS return "SUCCESS"; } // 6. 发送微信模板消息给学生 wechatMessageService.sendPaymentSuccess(outTradeNo); return "SUCCESS"; } catch (Exception e) { log.error("支付回调处理失败", e); return "FAIL"; // 触发微信重试 } }

避坑点:

  • AesUtil.decryptToString:必须用微信官方提供的 Java SDK(wechatpay-apache-httpclient),自己实现易出错;
  • updateStatusById的 SQL:UPDATE bill SET status = ? WHERE id = ? AND status = ?,利用数据库行锁保证幂等;
  • return "FAIL":微信收到 FAIL 会重试,但需确保log.error记录完整上下文,便于排查。

4.3 财务对账自动化:为什么每天凌晨2点跑一个ReconcileJob?

财务处每月要核对“微信支付流水”和“系统账单”。人工比对极易出错。该源码内置对账 Job:

  • 每日凌晨2点,扫描昨日所有status=PAID的账单;
  • 调用微信支付对账单下载接口(/v3/bill/download-url),获取昨日 CSV 对账单;
  • 解析 CSV,按out_trade_no匹配系统账单;
  • 生成差异报告:system_paid_but_wechat_not(系统已付但微信无记录)、wechat_paid_but_system_not(微信已付但系统未更新);
  • 自动邮件发送报告给财务负责人。
// com.campus.fee.job.ReconcileJob.java @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点 public void reconcileYesterday() { LocalDate yesterday = LocalDate.now().minusDays(1); String dateStr = yesterday.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // 1. 下载微信对账单 String downloadUrl = wechatPayService.downloadBill(dateStr); // 2. 下载CSV并解析 List<WechatBillItem> wechatItems = csvParser.parse(downloadUrl); // 3. 查询系统昨日已支付账单 List<Bill> systemBills = billMapper.selectPaidByDate(yesterday); // 4. 比对生成差异 List<ReconcileDiff> diffs = reconcileService.compare(wechatItems, systemBills); // 5. 发送邮件 emailService.sendReconcileReport(diffs, dateStr); }

参数说明:

  • cron = "0 0 2 * * ?":Quartz 表达式,精确到秒;
  • downloadUrl:微信返回的临时下载链接,5分钟有效,需立即下载;
  • csvParser:自研解析器,处理微信CSV的特殊编码(GBK)和字段分割(逗号在金额中出现);
  • reconcileService.compare:核心比对逻辑,用HashMap<String, Bill>以out_trade_no为 key,O(1) 查找。

5. 避坑指南:这5个坑让我重装了3次开发环境

再好的源码,踩坑方式也千奇百怪。以下是我在3所高校部署时,被学生和宿管员反复触发的5个真实问题,附带现象、原因、解决步骤:

5.1 现象:小程序首页空白,控制台报VMxxxx:1 Failed to load script

原因:project.config.json中setting.minPlatformVersion设为3.0.0,但开发者工具版本低于此值(如 2.28.2)。微信小程序基础库版本不匹配,导致lib/opencv/opencv.js加载失败,进而整个app.js报错。
解决:

  1. 打开微信开发者工具 → 右上角「详情」→「本地设置」→「基础库版本」,切换为3.0.0或更高;
  2. 若无此选项,升级开发者工具至最新版(官网下载);
  3. 清除缓存:菜单栏「工具」→「清除缓存」→「全部清除」。

5.2 现象:宿管员提交抄表后,账单生成失败,日志显示Redis connection refused

原因:后台application.yml中 Redis 配置为spring.redis.host: 127.0.0.1,但生产环境 Redis 服务部署在另一台服务器,且未开放 6379 端口。
解决:

  1. 修改application.yml:
    spring: redis: host: 192.168.10.50 # Redis 服务器真实IP port: 6379 password: your_redis_password timeout: 2000
  2. 在 Redis 服务器防火墙放行:sudo ufw allow 6379;
  3. 测试连接:redis-cli -h 192.168.10.50 -p 6379 -a your_redis_password ping,返回PONG即通。

5.3 现象:学生支付成功,但小程序端账单状态仍是“待支付”,刷新也不变

原因:微信支付回调地址notify_url配置错误。源码默认为https://api.campus-fees.edu.cn/pay/notify,但 Nginx 反向代理未将/pay/notify路径转发到 Spring Boot 服务(端口 8080)。
解决:

  1. 检查 Nginx 配置:
    location /pay/notify { proxy_pass http://127.0.0.1:8080/pay/notify; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
  2. 重启 Nginx:sudo nginx -s reload;
  3. 用 curl 模拟回调测试:
    curl -X POST https://api.campus-fees.edu.cn/pay/notify \ -H "Content-Type: application/xml" \ -d '<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>'
    查看后台日志是否输出支付回调处理成功。

5.4 现象:抄表拍照识别总是返回00000,无论拍什么

原因:miniprogram/lib/opencv/opencv.js文件损坏。下载源码时,部分网盘会因文件过大(2.1MB)导致传输中断,opencv.js成为 0 字节空文件。
解决:

  1. 进入miniprogram/lib/opencv/目录;
  2. 执行ls -la opencv.js,确认文件大小 > 2MB;
  3. 若为 0 字节,重新下载完整压缩包,或从 GitHub 仓库单独下载opencv.js(URL 在 README.md 中注明);
  4. 重启开发者工具。

5.5 现象:导出全校账单 Excel 时,后台报java.lang.OutOfMemoryError: Java heap space

原因:导出功能用 Apache POI 一次性加载所有数据到内存,全校 5000 间宿舍,生成 Excel 占用内存超 1GB。
解决:

  1. 修改ExportService.exportAllBills()方法,改用 SXSSFWorkbook(流式写入):
    // 替换原 XSSFWorkbook SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 每100行刷入磁盘 Sheet sheet = workbook.createSheet("全校账单"); // 写入表头... // 分页查询:每页1000条,循环写入 for (int i = 0; i < totalPage; i++) { List<Bill> bills = billMapper.selectByPage(i * 1000, 1000); for (Bill b : bills) { Row row = sheet.createRow(sheet.getLastRowNum() + 1); // 写入单元格... } }
  2. JVM 启动参数增加-Xmx2g(如java -Xmx2g -jar campus-fees.jar);
  3. 导出完成后,调用workbook.dispose()释放资源。

6. 毕设答辩前的最后 checklist:让老师挑不出硬伤

这套源码能成为“高分毕设”,不靠炫技,而靠把每个环节的工程细节钉死。答辩时,老师最常问的 5 个问题,我都提前埋好了答案——就藏在代码和文档里。你只需知道它们在哪,就能从容应对:

6.1 “你们怎么保证数据安全?比如学生密码?”

答案位置:com.campus.fee.entity.User.java的password字段注释:

/** * 密码(BCrypt 加密存储,盐值随机生成) * @see com.campus.fee.config.PasswordEncoderConfig */ private String password;

延伸准备:

  • PasswordEncoderConfig类明确配置BCryptPasswordEncoder(12),强度足够;
  • 登录接口/api/user/login的UserDetailsService.loadUserByUsername方法中,调用passwordEncoder.matches()验证,而非明文比对;
  • 数据库user表password字段类型为VARCHAR(100),足以容纳 BCrypt 哈希值(60字符)。

6.2 “并发抄表怎么解决?会不会生成重复账单?”

答案位置:com.campus.fee.service.BillService.java的generateBill()方法,开头有@Transactional和 Redis 锁双重保障,且方法注释第一行写:

/** * 生成账单(线程安全:Redis分布式锁 + 数据库唯一索引双重保障) * @param roomId 宿舍ID * @param billingMonth 账期,格式:yyyyMM * @return 生成的账单对象 */

延伸准备:

  • 在application.yml中指出spring.redis.database=1(锁专用DB,与缓存DB隔离);
  • 演示时,用 Postman 并发发 10 个相同roomId的请求,展示日志中只有 1 个成功,其余返回“账单生成中”。

6.3

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进