Spring Boot+Vue小区用电管理系统:抄表算费与阶梯电价设计 简介面向信息管理与信息系统专业的本科毕业论文针对应县供电公司小区用电管理场景完整呈现从需求分析、系统设计到功能实现的全过程。论文以用户管理、抄表管理、电费计算与报表生成等模块为主线结合数据流图、逻辑结构与物理结构说明各主体属性和开发部署环境并配有用户管理、抄表管理、电费管理、统计查询、客户服务等功能模块截图适合电力信息化课程设计、毕业设计及同类小区用电系统开发参考。压缩包内为单个doc文档大小1.92MB包含需求描述、系统数据流图、系统实现、功能展示等完整章节。目前已有57人学习浏览读者可借此掌握需求分析方法、数据库与系统架构设计思路以及论文写作的结构化表达。1. 小区用电管理系统到底在做一个什么闭环“应县供电公司小区用电管理系统”这类题目真正让很多毕业生卡住的不是代码而是不知道供电公司内部到底要管什么。你只要把一条闭环理清楚整个系统就立得住用户档案登记完成之后给表计建档每个月按抄表周期录入起码数和止码数系统根据上期读数与本期读数算出电量再匹配电价档位生成电费账单住户缴费后生成缴费流水管理员随时能查欠费、查线损。这个闭环里的每一环都要在数据表里留下痕迹在页面上提供入口。给供电公司做系统和给普通商家做进销存有个明显区别表计、抄表、电费计算属于强业务逻辑不能让用户在页面上随便填一个“电费”字段。电费必须由系统根据电量和电价算出来否则数据完全不可信。所以设计时要把“抄表”和“算费”拆成两个独立动作算费可以手工触发也可以按月份批量执行。这套系统的常见实现路径是后端用 Spring Boot 搭配 MyBatis Plus前端用 Vue 加 Element UI/Plus数据库用 MySQL。如果你习惯 Python Django 或者其他技术栈事务边界、表结构设计、批量计算的思路照样可以平移到任何语言。这篇内容面向正在做毕业设计的学生也适合刚接触管理系统开发、想了解真实业务是怎么落到表里的从业者。2. 数据模型先行把户表、抄表、账单放进一张网毕设答辩时老师第一眼看的是 E-R 图和数据库表。很多同学一上来就写用户表、电表表结果业务写着写着发现“换表之后没地方记历史”或者“抄表记录里找不对比对周期”。这其实是实体识别阶段漏了概念。我一般会先走一遍业务用例再定表。2.1 从业务用例到实体清单小区用电系统的角色和动作可以这样分管理员维护小区和楼栋收费员创建用户档案并配表计每月按周期录入抄表读数系统自动生成账单用户查账单并缴费。把动作变成名词核心实体就是小区、楼栋、用户、电表、抄表记录、电费账单、缴费流水。这里最容易忽略的是“换表”和“历史表计”的概念。用户和电表不是简单的 1 对 1因为用户可能换过表两块表都服务于同一个用户所以电表档案里要有 user_id也要有状态标记。小区小区编号、名称、地址、物业电话楼栋楼栋编号、所属小区、楼栋名称、层数用户用户编号、姓名、身份证号、手机号、地址、所属楼栋电表电表编号、表号、用户ID、安装日期、倍率、状态抄表记录记录ID、电表ID、抄表月份、上期数、本期数、抄表时间、状态电费账单账单ID、用户ID、月份、总电量、一档电量、二档电量、三档电量、总电费、状态缴费流水流水ID、账单ID、缴费金额、缴费方式、缴费时间、操作员这些实体之间的外键关系不要全用数据库物理外键。毕设里我会建索引和逻辑外键因为 MySQL 低版本对物理外键的批量插入顺序比较挑剔演示时容易报错逻辑外键能让你在页面上更灵活地处理“先有电表还是没有用户”一类边界问题。2.2 核心表结构设计与建表 SQL下面是我会用的建表脚本为了演示清晰只保留关键字段规范性尽量靠近第三范式但适度冗余比如用户表冗余楼栋名称可以减少联表查询。每张表都有 create_time 和 update_time 字段实际代码里由 MyBatis Plus 的自动填充统一维护这里不再重复写。CREATE DATABASE IF NOT EXISTS power_mis DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE power_mis; -- 小区表 CREATE TABLE t_community ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, address VARCHAR(200), contact_phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT小区表; -- 用户表 CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, community_id BIGINT NOT NULL, building_no VARCHAR(50) NOT NULL, user_no VARCHAR(32) NOT NULL UNIQUE, user_name VARCHAR(50) NOT NULL, phone VARCHAR(20), address VARCHAR(200), status TINYINT DEFAULT 1 COMMENT 1正常 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_community (community_id) ) ENGINEInnoDB COMMENT用电用户表; -- 电表档案表 CREATE TABLE t_meter ( id BIGINT AUTO_INCREMENT PRIMARY KEY, meter_no VARCHAR(32) NOT NULL UNIQUE COMMENT 表号, user_id BIGINT NOT NULL, install_date DATE, rate DECIMAL(5,2) DEFAULT 1.00 COMMENT 互感器倍率, status TINYINT DEFAULT 1 COMMENT 1在用 0拆回, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINEInnoDB COMMENT电表档案; -- 抄表记录表 CREATE TABLE t_meter_read ( id BIGINT AUTO_INCREMENT PRIMARY KEY, meter_id BIGINT NOT NULL, read_month VARCHAR(6) NOT NULL COMMENT 月份 yyyyMM, last_reading DECIMAL(12,2) NOT NULL COMMENT 上期读数, current_reading DECIMAL(12,2) NOT NULL COMMENT 本期读数, read_time DATETIME, calc_status TINYINT DEFAULT 0 COMMENT 0未算费 1已算费, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_meter_month (meter_id, read_month) ) ENGINEInnoDB COMMENT抄表记录; -- 电费账单表 CREATE TABLE t_bill ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, bill_month VARCHAR(6) NOT NULL, meter_id BIGINT NOT NULL, total_power DECIMAL(12,2), amount DECIMAL(12,2), status TINYINT DEFAULT 1 COMMENT 1未缴 2已缴 3冲正, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_month (user_id, bill_month) ) ENGINEInnoDB COMMENT电费账单; -- 缴费流水表 CREATE TABLE t_payment ( id BIGINT AUTO_INCREMENT PRIMARY KEY, bill_id BIGINT NOT NULL, pay_amount DECIMAL(12,2) NOT NULL, pay_method VARCHAR(20), operator VARCHAR(50), pay_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_bill (bill_id) ) ENGINEInnoDB COMMENT缴费流水;这里最关键的是t_meter_read上的唯一索引uk_meter_month (meter_id, read_month)。它保证同一块电表在同一个抄表月份只能录入一次防止前端重复点击或者人工录两次。calc_status字段放在抄表记录而不是账单原因是批量算费是以“抄表记录”为处理单元的算费完成后立刻把状态置为 1下次批量任务扫不到它就可以避免重复生成账单。rate倍率对三相表很重要用电量 (本期读数 - 上期读数) × 倍率不能只存一个读数差。各表之间的关系可以归纳为下面这张表格论文的数据字典可以直接参考表名外键关系业务作用E-R 图上的角色t_community主表维护小区基本信息父实体t_usercommunity_id维护住户档案子实体t_meteruser_id记录用户当前和历史表计子实体t_meter_readmeter_id存每月抄表读数弱实体t_billuser_id, meter_id保存电费计算结果子实体t_paymentbill_id保存缴费流水从属实体2.3 E-R 图怎么画才不丢覆盖点很多同学用 MySQL Workbench 反向工程生成 E-R 图后直接截图出来的图线很乱老师看着也费劲。我一般会让工具先导出再用 Visio 或 draw.io 重新画一遍。重点是标清楚几组关系小区与用户是 1 对 N用户与电表是 1 对 N电表与抄表记录是 1 对 N用户与账单是 1 对 N账单与缴费流水是 1 对 N。另外不要把用户和电表画成 1 对 1。虽然居民小区大多一户一表但存在换表场景用户 A 在 2023 年 8 月之前使用表 T0019 月换成 T002。如果设计成 1 对 1T001 的历史数据就无处安放。改成 1 对 N 之后只要在t_meter上维护status和install_date就能用“当前表”和“历史表”两个视角查询。3. 用 Spring Boot Vue 把核心功能跑起来数据库定好之后代码重点就是抄表、算费、缴费三个动作。毕设项目不需要复杂微服务单体应用加一个前端工程足够。后端我建议按包结构划分 controller、service、mapper、entity、commonMyBatis Plus 可以省去大量手写 XML。3.1 工程结构与接口清单后端工程目录可以这样组织power-mis/ ├── sql/ # 建表与初始化数据 ├── src/main/java/com/powermis/ │ ├── controller/ # REST 接口 │ ├── service/ # 事务边界 │ ├── mapper/ # MyBatis Plus 接口 │ ├── entity/ # 实体类 │ └── common/ # 统一返回结果、异常处理 └── src/main/resources/ ├── application.yml └── mapper/ # 自定义 SQL接口清单可以控制在 5 个左右既能覆盖核心业务又不至于让自己写崩溃。功能接口路径方法请求体要点新增用户/api/userPOSTuserNo, userName, communityId抄表录入/api/meterRead/savePOSTmeterId, readMonth, currentReading批量算费/api/bill/calcPOSTmonth账单查询/api/bill/listGETuserId, billMonth, status缴费/api/payment/createPOSTbillId, payAmount, payMethod3.2 抄表录入与算费触发的前后端实现抄表录入接口的 Controller 写得很薄核心逻辑放在 Service 层。这段代码我保留了事务注解因为后面需要同时完成“插入抄表记录”和“回填上期读数”。RestController RequestMapping(/api/meterRead) public class MeterReadController { Resource private MeterReadService meterReadService; PostMapping(/save) public ResultLong save(RequestBody MeterReadDTO dto) { // 同一个电表、同一个月只能有一条有效抄表记录 // 数据库唯一索引和这里的业务校验会共同拦截重复提交 return Result.ok(meterReadService.addRead(dto)); } }Service public class MeterReadServiceImpl implements MeterReadService { Resource private MeterReadMapper meterReadMapper; Override Transactional(rollbackFor Exception.class) public Long addRead(MeterReadDTO dto) { MeterRead read new MeterRead(); read.setMeterId(dto.getMeterId()); read.setReadMonth(dto.getReadMonth()); read.setCurrentReading(dto.getCurrentReading()); // 查出该表计最近一次已入账的抄表记录作为上期数 MeterRead last meterReadMapper.findLastByMeterAndMonth( dto.getMeterId(), dto.getReadMonth()); // 首次抄表时没有上期记录按 0 处理 read.setLastReading(last null ? BigDecimal.ZERO : last.getCurrentReading()); // 如果本期读数小于上期读数说明数据异常 if (read.getCurrentReading().compareTo(read.getLastReading()) 0) { throw new BusinessException(本期读数不能小于上期读数); } meterReadMapper.insert(read); return read.getId(); } }findLastByMeterAndMonth的逻辑是取read_month 参数月份的最新一条记录用 MySQL 可以写成ORDER BY read_month DESC LIMIT 1。这里没有直接在 SQL 里写死“上个月”因为实际业务中可能月间漏抄需要回补。前端录入时也要做同样的校验否则用户改完之后接口报错体验很差。Vue 页面用 Element UI 的表单实现el-date-picker的value-formatyyyyMM可以直接和后端月份字符串对齐。template el-form refform :modelform :rulesrules label-width100px el-form-item label电表 propmeterId el-select v-modelform.meterId filterable placeholder请选择电表 el-option v-form in meters :keym.id :labelm.meterNo :valuem.id / /el-select /el-form-item el-form-item label抄表月份 propreadMonth el-date-picker v-modelform.readMonth typemonth value-formatyyyyMM / /el-form-item el-form-item label本期读数 propcurrentReading el-input-number v-modelform.currentReading :min0 :precision2 :step10 / /el-form-item el-form-item el-button typeprimary clicksubmit保存抄表/el-button /el-form-item /el-form /template这个表单的关键是readMonth的类型。很多同学从前端拿到的值中间带横杠后端String接收后直接存数据库结果read_month里面混入了2025-04和202504两种格式导致批量算费按月份匹配不上。解决办法是在 DTO 里用JsonFormat(pattern yyyyMM)或者在前端就固定成yyyyMM。3.3 本地启动与联调参数后端启动命令很常规cd backend mvn spring-boot:run前端启动cd frontend npm install npm run dev本地联调时需要注意application.yml里的数据源配置spring: datasource: url: jdbc:mysql://localhost:3306/power_mis?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8serverTimezoneAsia/Shanghai一定要带上否则 MySQL 8 连接时会报时区错误。characterEncodingutf8要对应数据库的utf8mb4否则插入中文用户名会出现乱码。这些配置单独看不起眼却是答辩现场最容易踩的坑。4. 阶梯电价与批量计费让算费结果经得起对账电费计算是系统的核心不能把单价硬编码成一堆 if。常见做法是把阶梯电价放到配置表里运行时读取这样电价调整时不用改业务代码论文里也能把“规则配置化”作为设计亮点。4.1 用配置表表达阶梯电价很多同学在 Service 里写if (power 180) amount power * 0.52; else if (power 400) amount 180 * 0.52 (power - 180) * 0.58;这种写法有两个问题一是改电价要重新编译二是论文里不好做测试用例。我建议单独建一张阶梯配置表CREATE TABLE t_tariff ( id INT AUTO_INCREMENT PRIMARY KEY, tariff_type VARCHAR(16) NOT NULL COMMENT 阶梯类型MONTH/YEAR, min_power DECIMAL(12,2) NOT NULL COMMENT 区间下限, max_power DECIMAL(12,2) NOT NULL COMMENT 区间上限, unit_price DECIMAL(6,3) NOT NULL COMMENT 单价, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT阶梯电价配置;假设采用“月度阶梯”模式三档配置如下档位min_powermax_powerunit_price一档0.00180.000.520二档180.01400.000.580三档400.01999999.000.820INSERT INTO t_tariff (tariff_type, min_power, max_power, unit_price) VALUES (MONTH, 0.00, 180.00, 0.520), (MONTH, 180.01, 400.00, 0.580), (MONTH, 400.01, 999999.00, 0.820);这里用999999.00表示“以上无上限”避免把上限写成 NULL因为循环读配置时 NULL 会导致空指针。该价格只是演示用数据真实居民电价还涉及峰谷、新能源附加等毕设不必完全复刻但一定要在论文里注明“演示电价仅供参考”。配置表用tariff_type区分月度阶梯和年度阶梯以后想扩展年度阶梯只需要新增YEAR类型的数据。4.2 批量计费的幂等与事务控制批量计费流程可以概括为按月查询所有calc_status 0的抄表记录对每一条计算电量匹配各档电价生成账单再把抄表记录置为已算费。这几个动作必须在同一个事务里完成否则会出现“账单生成了抄表状态还是未算费”的脏数据。另外还要处理并发如果管理员连续点了两遍“算费”第二批任务再次扫描时会发现记录已经被处理后但为了避免竞争条件在入口加一个防重锁。Service public class BillServiceImpl implements BillService { Resource private MeterReadMapper meterReadMapper; Resource private TariffMapper tariffMapper; Resource private BillMapper billMapper; Override Transactional(rollbackFor Exception.class) public void calcMonth(String month) { // 批量计算只取未计算过的记录已算过的不会被再次处理 ListMeterRead reads meterReadMapper.findUnCalc(month); for (MeterRead read : reads) { BigDecimal power read.getCurrentReading() .subtract(read.getLastReading()) .multiply(meterMapper.selectById(read.getMeterId()).getRate()); ListTariff tariffs tariffMapper.selectByType(MONTH); Bill bill calculateBill(read, power, tariffs); billMapper.insert(bill); read.setCalcStatus(1); meterReadMapper.updateById(read); } } private Bill calculateBill(MeterRead read, BigDecimal power, ListTariff tariffs) { BigDecimal remain power; BigDecimal total BigDecimal.ZERO; BigDecimal cursor BigDecimal.ZERO; for (Tariff t : tariffs) { // 当前档可用区间为 (cursor, maxPower] BigDecimal interval t.getMaxPower().subtract(cursor); BigDecimal use remain.min(interval); if (use.signum() 0) { continue; } total total.add(use.multiply(t.getUnitPrice())); remain remain.subtract(use); cursor t.getMaxPower(); if (remain.signum() 0) { break; } } if (remain.signum() 0) { throw new BusinessException(当前电量超过电价配置区间请检查t_tariff); } Bill bill new Bill(); bill.setUserId(read.getUserId()); bill.setMeterId(read.getMeterId()); bill.setBillMonth(read.getReadMonth()); bill.setTotalPower(power); bill.setAmount(total); return bill; } }这段代码的核心是cursor变量。它记录上一个档位已经覆盖到的电量边界每一档只计算“落在该区间内”的那一段电量。min_power在配置里虽然存在但计算时不直接读取它而是用上一档的max_power作为当前档起点这样能保证档位之间没有缝隙或重叠。最后再判断remain是否还有剩余如果还有说明配置三段没有覆盖全部电量直接抛出异常回滚事务避免生成错误账单。4.3 对账口径与测试场景设计写完批量计费之后一定要用 SQL 做一次对账不能只看页面上的电费金额。常见做法是从抄表记录重新聚合和账单表做比对SELECT r.meter_id, SUM(r.current_reading - r.last_reading) AS read_power, SUM(b.total_power) AS bill_power, SUM(b.amount) AS bill_amount FROM t_meter_read r JOIN t_bill b ON b.meter_id r.meter_id AND b.bill_month r.read_month WHERE r.read_month 202504 GROUP BY r.meter_id HAVING read_power bill_power;这条 SQL 会把“抄表读数差”和“账单电量”不一致的电表全部筛出来。如果有倍率不为 1 的表左边还得乘以t_meter.rate。对账口径其实就是三个等式电量 本期 - 上期电费 各档电量 × 单价之和缴费金额 账单金额。三个等式都能对上系统才可信。测试场景至少要覆盖以下情况场景输入期望结果单档电量电量 100一档电费 100 × 0.52跨档临界电量 180电费 180 × 0.52跨档临界1电量 181电费 180 × 0.52 1 × 0.58大批量回补同月 50 条抄表记录生成 50 张账单全部 calc_status1重复算费第二次执行相同月份不生成重复账单上期读数大于本期本期 100上期 120接口报业务异常事务回滚5. 论文成稿图、表、测试用例与演示数据的整理套路很多同学习惯先把代码写完最后才补论文结果发现截图尺寸不统一、数据字典对不上、测试用例只有一行。其实论文里的图和表应该在建完表、写完接口之后就顺手整理好。5.1 论文里的 E-R 图不一定要画得非常正式E-R 图要表达的是“业务数据关系”不是数据库外键截图。我一般用 MySQL Workbench 的 Database → Reverse Engineer 生成草图再复制到 draw.io 里重新排列。实体框内只放“实体名 主键 核心属性”关系线上标 1 和 N。重点是把“用户-电表-抄表记录-账单”这条主线画清楚缴费流水作为从属实体放在右下角不要让它压住主线。5.2 测试用例表要让老师能照着走一遍测试用例表不要只写“输入正常数据系统正常”。格式要具体到“输入什么值、点击哪个按钮、预期页面显示什么”。例如“抄表录入-跨档”用例可以写输入表号 T001、抄表月份 202504、本期读数 500点击保存随后执行批量算费系统生成一张电费账单金额等于 180 × 0.52 220 × 0.58 100 × 0.82。这样老师照着操作一遍就能复现比空泛的“通过”有说服力得多。5.3 答辩演示的预置数据脚本答辩时间通常很短我建议准备一个demo_data.sql里面有 3 个小区、10 个用户、10 块电表、最近 3 个月的抄表记录。演示时不要现场录数据直接执行脚本然后点“批量算费”按钮页面立刻出现一组账单。脚本里可以放一条明显跨档的数据方便现场展示阶梯是否正确。-- 演示用预置数据片段 SET NAMES utf8mb4; INSERT INTO t_user (id, community_id, building_no, user_no, user_name, phone, address) VALUES (1, 1, 1号楼, U001, 张伟, 13800000001, 应县电力小区1号楼101), (2, 1, 1号楼, U002, 李娜, 13800000002, 应县电力小区1号楼102); INSERT INTO t_meter (id, meter_no, user_id, install_date, rate, status) VALUES (1, M1001, 1, 2024-01-01, 1.00, 1), (2, M1002, 2, 2024-01-01, 1.00, 1);注意脚本里要加SET NAMES utf8mb4;否则在部分 Windows 命令行环境中执行会出现中文乱码影响答辩数据的可读性。最后再补一个非常实用的细节论文里的数据字典表要和建表脚本严格一致最省力的做法是直接从 MySQL 的information_schema.COLUMNS查询导出而不是手敲。导出之后对字段顺序、注释、默认值做一次人工校对能让论文的规范性和完整性明显提升。本文还有配套的精品资源点击获取