简介:面向信息管理与信息系统专业的本科毕业论文,针对应县供电公司小区用电管理场景,完整呈现从需求分析、系统设计到功能实现的全过程。论文以用户管理、抄表管理、电费计算与报表生成等模块为主线,结合数据流图、逻辑结构与物理结构说明各主体属性和开发部署环境,并配有用户管理、抄表管理、电费管理、统计查询、客户服务等功能模块截图,适合电力信息化课程设计、毕业设计及同类小区用电系统开发参考。压缩包内为单个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 ) ENGINE=InnoDB 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) ) ENGINE=InnoDB 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) ) ENGINE=InnoDB 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) ) ENGINE=InnoDB 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) ) ENGINE=InnoDB 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) ) ENGINE=InnoDB COMMENT='缴费流水';这里最关键的是t_meter_read上的唯一索引uk_meter_month (meter_id, read_month)。它保证同一块电表在同一个抄表月份只能录入一次,防止前端重复点击或者人工录两次。calc_status字段放在抄表记录而不是账单,原因是批量算费是以“抄表记录”为处理单元的,算费完成后立刻把状态置为 1,下次批量任务扫不到它,就可以避免重复生成账单。rate倍率对三相表很重要,用电量 = (本期读数 - 上期读数) × 倍率,不能只存一个读数差。
各表之间的关系可以归纳为下面这张表格,论文的数据字典可以直接参考:
| 表名 | 外键关系 | 业务作用 | E-R 图上的角色 |
|---|---|---|---|
| t_community | 主表 | 维护小区基本信息 | 父实体 |
| t_user | community_id | 维护住户档案 | 子实体 |
| t_meter | user_id | 记录用户当前和历史表计 | 子实体 |
| t_meter_read | meter_id | 存每月抄表读数 | 弱实体 |
| t_bill | user_id, meter_id | 保存电费计算结果 | 子实体 |
| t_payment | bill_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 月之前使用表 T001,9 月换成 T002。如果设计成 1 对 1,T001 的历史数据就无处安放。改成 1 对 N 之后,只要在t_meter上维护status和install_date,就能用“当前表”和“历史表”两个视角查询。
3. 用 Spring Boot + Vue 把核心功能跑起来
数据库定好之后,代码重点就是抄表、算费、缴费三个动作。毕设项目不需要复杂微服务,单体应用加一个前端工程足够。后端我建议按包结构划分 controller、service、mapper、entity、common,MyBatis 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/user | POST | userNo, userName, communityId |
| 抄表录入 | /api/meterRead/save | POST | meterId, readMonth, currentReading |
| 批量算费 | /api/bill/calc | POST | month |
| 账单查询 | /api/bill/list | GET | userId, billMonth, status |
| 缴费 | /api/payment/create | POST | billId, payAmount, payMethod |
3.2 抄表录入与算费触发的前后端实现
抄表录入接口的 Controller 写得很薄,核心逻辑放在 Service 层。这段代码我保留了事务注解,因为后面需要同时完成“插入抄表记录”和“回填上期读数”。
@RestController @RequestMapping("/api/meterRead") public class MeterReadController { @Resource private MeterReadService meterReadService; @PostMapping("/save") public Result<Long> 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-format="yyyyMM"可以直接和后端月份字符串对齐。
<template> <el-form ref="form" :model="form" :rules="rules" label-width="100px"> <el-form-item label="电表" prop="meterId"> <el-select v-model="form.meterId" filterable placeholder="请选择电表"> <el-option v-for="m in meters" :key="m.id" :label="m.meterNo" :value="m.id" /> </el-select> </el-form-item> <el-form-item label="抄表月份" prop="readMonth"> <el-date-picker v-model="form.readMonth" type="month" value-format="yyyyMM" /> </el-form-item> <el-form-item label="本期读数" prop="currentReading"> <el-input-number v-model="form.currentReading" :min="0" :precision="2" :step="10" /> </el-form-item> <el-form-item> <el-button type="primary" @click="submit">保存抄表</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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8serverTimezone=Asia/Shanghai一定要带上,否则 MySQL 8 连接时会报时区错误。characterEncoding=utf8要对应数据库的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 ) ENGINE=InnoDB COMMENT='阶梯电价配置';假设采用“月度阶梯”模式,三档配置如下:
| 档位 | min_power | max_power | unit_price |
|---|---|---|---|
| 一档 | 0.00 | 180.00 | 0.520 |
| 二档 | 180.01 | 400.00 | 0.580 |
| 三档 | 400.01 | 999999.00 | 0.820 |
INSERT 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) { // 批量计算只取未计算过的记录,已算过的不会被再次处理 List<MeterRead> reads = meterReadMapper.findUnCalc(month); for (MeterRead read : reads) { BigDecimal power = read.getCurrentReading() .subtract(read.getLastReading()) .multiply(meterMapper.selectById(read.getMeterId()).getRate()); List<Tariff> 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, List<Tariff> 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_status=1 |
| 重复算费 | 第二次执行相同月份 | 不生成重复账单 |
| 上期读数大于本期 | 本期 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查询导出,而不是手敲。导出之后,对字段顺序、注释、默认值做一次人工校对,能让论文的规范性和完整性明显提升。
本文还有配套的精品资源,点击获取