简介:这是一套面向Java毕业设计与充电桩管理系统实战场景的完整项目包,采用SpringBoot+Vue前后端分离架构,后端以MySQL存储数据、Tomcat运行服务,覆盖用户、维修员、电桩类别、充电桩、报修与维修回复、系统管理等模块,适合需要完成毕业设计、课程设计或学习管理系统开发的学生与初中级开发者。压缩包共1322个文件,约32.33MB,包含107个java源码、43个vue页面、72个html模板、101个css样式、307个js脚本,以及SQL建库脚本、论文文档、Maven与部署配置等,前后端代码、静态资源和运行脚本相对齐全。已有190人学习,可据此参考完整功能划分、数据库设计与前后端交互实现,快速搭建运行环境,并对照论文梳理需求分析、模块设计与测试思路,减少从零摸索的成本。
1. 答辩前一晚还在改 bug 的充电桩系统,到底是个什么项目
新能源充电桩管理系统这个题目,在 Java 毕业设计里属于“业务闭环完整、表关系不复杂、演示效果好”的一类。这份 SpringBoot+Vue 新能源充电桩管理系统源码,把车主端和管理端放在同一个工程里,用前后端分离的方式跑起来:车主能查站、选桩、启动充电、结算支付、看历史订单;管理员能管充电站、管桩、管订单、管报修、看营收图表。我第一次接触这类项目是帮某实验室的同学调启动,印象最深的是充电订单的状态和充电桩状态要对得上,对不上就会出现“桩显示空闲,点进去提示被占用”的玄学现象。这篇文章就按“能跑、能改、能讲清楚”的路线,把这份 java 毕业设计源码从技术选型、数据库建模、后端核心链路、前端联调,一路拆到二次开发怎么改。
2. 系统全景与技术选型:一套前后端分离的充电桩业务怎么拆
先把这套 SpringBoot 充电桩系统的骨架说清楚。它不是那种一个 JSP 页面塞到底的老结构,而是标准的前后端分离:后端负责 REST 接口和业务规则,前端负责页面渲染和交互,两边靠 JSON 通信,靠一个 JWT 令牌维持登录态。这种结构在毕业设计里有个明显好处——答辩时你可以单独讲后端业务,也可以单独讲前端页面,不会一坨糊在一起讲不清。
2.1 技术栈清单:为什么是 SpringBoot 2.7 + Vue 2.6 这套组合
目录里后端是 Maven 工程,前端是独立的 Vue 工程,数据库脚本单独放一个sql目录。技术版本我按这份源码里最常见的搭配列一下,实际以你解压后pom.xml和package.json为准,改版本前先对一遍这份表:
| 技术 | 常见版本 | 在这套系统里干什么 | 换版本的代价 |
|---|---|---|---|
| SpringBoot | 2.7.x | 提供 Web 容器、自动装配、事务、定时任务 | 升 3.x 要改 javax→jakarta,不建议动 |
| MyBatis-Plus | 3.5.x | 单表 CRUD 免写 SQL,分页插件 | 低,改依赖即可 |
| MySQL | 8.0 | 存用户、桩、订单、预约、报修数据 | 低,注意时区参数 |
| JWT (jjwt) | 0.9.x | 登录后下发 token,接口鉴权 | 中,0.11 之后 API 变了 |
| Redis | 5.0+(可选) | 缓存桩状态、登录令牌、验证码 | 中,没装也能跑,只是部分功能降级 |
| Vue | 2.6.x | 前端框架 | 高,2→3 是语法级迁移 |
| Element UI | 2.15.x | 表格、表单、弹窗、分页 | 高,与 Vue 3 的 Element Plus 不通用 |
| ECharts | 5.x | 营收统计、桩使用率图表 | 低 |
| Axios | 0.27.x | 前端请求封装、拦截器 | 低 |
这套组合的核心思路是“后端稳稳当当,前端快速出页面”。SpringBoot 的自动装配把数据源、事务、MVC 都配好了,MyBatis-Plus 的BaseMapper把单表增删改查直接免掉,剩下的精力全花在业务规则上;前端 Element UI 自带表格分页、表单校验、日期选择器,管理端那种“一页十个字段的列表页”半小时能拼一个出来。对于毕业设计这种要在有限时间里做出完整闭环的场景,这个选择是合理的,不是偷懒。
要注意一个现实问题:Vue 2 已经在维护末期,如果你的目的是把它当简历项目,前端可以留着不动先跑通,等业务理解了再考虑迁移到 Vue 3 + Element Plus,那时候你改的是语法不是业务,难度会低很多。
2.2 角色与业务域:三个角色、八个模块撑起的业务闭环
这套汽车充电桩系统的角色一般分三种:系统管理员、普通车主(用户)、运维人员。有些精简版会把运维合并进管理员,跑之前最好在sys_user表里看一眼role字段到底存了几个值,别自己臆想出一个前端没有入口的角色。
业务闭环大致是这样一条线:
- 车主注册登录,进个人中心充值余额;
- 在地图或列表里按站点筛选,看到某个桩“空闲”;
- 点“启动充电”,系统生成一条充电订单,同时把桩状态改成“占用”;
- 充电结束(Demo 里通常是前端点“结束充电”,或者定时任务模拟),系统按充电度数算电费和服务费;
- 生成待支付订单,从余额扣款,桩状态回到“空闲”;
- 订单进入历史记录,管理员端统计页能看到这笔营收。
模块划分上,后端大致是这几块:
- 认证模块:登录、注册、token 校验、密码加密;
- 基础数据模块:充电站、充电桩、电价配置;
- 交易模块:充电订单、支付、充值记录;
- 预约模块:预约桩、预约超时释放;
- 运维模块:故障报修、处理记录;
- 统计模块:按日/按站点的营收、订单量、桩使用率。
前端页面基本是“一个模块两个页面”:一个列表页,一个表单弹窗。管理端常见的是首页看板 + 六个管理页,用户端是首页 + 桩列表 + 我的订单 + 个人中心。
2.3 目录结构:后端分层与前端的页面归位
解压之后建议先花五分钟把目录过一遍,这决定了你后面找代码快不快。后端是标准三层结构,前端按视图和接口分开:
charging-system/ ├── sql/ │ └── charging_system.sql # 建库建表 + 初始数据(管理员、几个桩) ├── charging-server/ # 后端 Maven 工程 │ ├── src/main/java/com/example/charging/ │ │ ├── ChargingApplication.java # 启动类 │ │ ├── common/ # Result、异常、常量、工具类 │ │ ├── config/ # 跨域、MyBatis-Plus、拦截器注册 │ │ ├── controller/ # 对外接口层 │ │ ├── service/ # 业务接口 │ │ │ └── impl/ # 业务实现,计费和状态流转都在这里 │ │ ├── mapper/ # MyBatis-Plus Mapper 接口 │ │ ├── entity/ # 数据库实体 │ │ └── interceptor/ # JWT 登录拦截器 │ └── src/main/resources/ │ ├── application.yml # 数据源、JWT 密钥、端口 │ └── mapper/ # 少量自定义 XML(统计 SQL 一般放这) └── charging-web/ # 前端 Vue 工程 ├── src/api/ # 按模块拆的接口文件 ├── src/utils/request.js # axios 封装 + 拦截器 ├── src/router/index.js # 路由 + 前置守卫 └── src/views/ ├── admin/ # 管理端页面 └── user/ # 车主端页面后端service/impl是重点,订单状态流转、计费、桩状态更新这些“答辩老师会问”的逻辑全在这里,controller基本只做参数接收和结果包装。前端utils/request.js是第二个重点,token 怎么带、401 怎么跳登录,都在这一个文件里,改登录逻辑先看它。
3. 数据库与实体建模:桩、订单、预约三张表的字段怎么定
这套系统的数据关系并不复杂,但有几个字段设计得很关键:桩的状态、订单的状态、订单的金额拆分。设计得糙一点也能跑,但后面做统计和退款的时侯就要返工。下面按建表、实体映射、状态枚举三块拆。
3.1 建表脚本:从充电桩表到订单表的完整 DDL
sql目录里那份脚本是整套系统的地基,导入顺序错了会直接报外键错误。核心表是充电桩表和充电订单表,我把关键字段抽出来,你看的时候重点盯注释:
-- 充电桩表:一个桩对应一个物理设备,状态字段是整个系统的中枢 CREATE TABLE `charging_pile` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `station_id` BIGINT NOT NULL COMMENT '所属充电站ID', `pile_no` VARCHAR(32) NOT NULL COMMENT '桩编号,扫码和页面展示都用它', `pile_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1-快充 2-慢充', `rated_power` DECIMAL(6,2) NOT NULL COMMENT '额定功率,单位kW', `price_per_kwh` DECIMAL(6,4) NOT NULL COMMENT '电费单价,元/度', `service_fee_rate` DECIMAL(6,4) NOT NULL COMMENT '服务费单价,元/度', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-空闲 1-占用 2-故障 3-离线', `qr_code` VARCHAR(128) DEFAULT NULL COMMENT '桩二维码内容', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_pile_no` (`pile_no`), KEY `idx_station` (`station_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电桩'; -- 充电订单表:金额拆成电费、服务费、合计三列,别只存一个 total CREATE TABLE `charge_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号,业务唯一', `user_id` BIGINT NOT NULL COMMENT '下单车主', `pile_id` BIGINT NOT NULL COMMENT '使用的桩', `start_time` DATETIME NOT NULL COMMENT '开始充电时间', `end_time` DATETIME DEFAULT NULL COMMENT '结束充电时间', `charge_kwh` DECIMAL(10,3) DEFAULT 0.000 COMMENT '充电度数', `electric_fee` DECIMAL(10,2) DEFAULT 0.00 COMMENT '电费', `service_fee` DECIMAL(10,2) DEFAULT 0.00 COMMENT '服务费', `total_amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '合计金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-充电中 1-待支付 2-已完成 3-已取消 4-已退款', `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充电订单';几处设计取舍值得说清楚。charge_order里的金额拆成三列不是冗余,而是为了退款和统计:退款时服务费退不退、退多少,是一个业务规则问题;统计页要按“电费收入”和“服务费收入”分开算,也是一个报表需求,只存一个total_amount后面全要重算。pile_no加了唯一索引,是因为扫码解析时它会被当成业务主键用,重复了就会解析到两个桩。
提示:金额字段一律用
DECIMAL,别图省事用DOUBLE。0.1+0.2 在浮点里不等于 0.3,累积几百笔订单后统计页会和账单对不上,这个后悔药不好吃。
建站表charging_station、用户表sys_user、预约表reservation、报修表fault_report、充值表recharge_record结构类似,主键自增,时间字段统一DATETIME,状态字段统一TINYINT。导入前先建库CREATE DATABASE charging_system DEFAULT CHARSET utf8mb4;,再按“基础表 → 业务表”的顺序执行,避免外键依赖报错。
3.2 实体与 Mapper:MyBatis-Plus 怎么少写一半 SQL
实体类的写法决定了你后面改字段顺不顺手。这份源码用的是 MyBatis-Plus 注解风格,字段名驼峰、表字段下划线,靠全局配置自动映射:
@Data @TableName("charging_pile") public class ChargingPile { @TableId(type = IdType.AUTO) private Long id; private Long stationId; private String pileNo; private Integer pileType; /** 额定功率 kW,前端的"快充/慢充"标签就是靠这个和 pileType 一起算的 */ private BigDecimal ratedPower; /** 电费单价,注意和 serviceFeeRate 分开存 */ private BigDecimal pricePerKwh; private BigDecimal serviceFeeRate; /** 0-空闲 1-占用 2-故障 3-离线,改动这个字段必须和订单状态同步 */ private Integer status; private String qrCode; private LocalDateTime createTime; }对应的 Mapper 只有一个接口,没有 XML:
@Mapper public interface ChargingPileMapper extends BaseMapper<ChargingPile> { // 单表 CRUD 由 BaseMapper 提供,这里只放统计类自定义 SQL @Select("SELECT station_id AS stationId, COUNT(*) AS total, " + "SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS busy " + "FROM charging_pile GROUP BY station_id") List<Map<String, Object>> countGroupByStation(); }这段的说明分两点。第一,BaseMapper<ChargingPile>已经把insert / updateById / selectById / selectList都给好了,ServiceImpl里再套一层业务就能用,不用为每张表写一遍 XML。第二,统计类 SQL 不要硬塞进 Wrapper,像上面这种带CASE WHEN的分组统计,写成注解 SQL 或者独立 XML 更好维护,也方便你在答辩时直接指着说“这块是手写 SQL,因为 Wrapper 表达不了”。
有个前提要确认:application.yml里得有mybatis-plus.configuration.map-underscore-to-camel-case: true。这份配置默认是开的,但如果你手动改过配置文件把它关了,stationId会全查成 null,页面上一片空白,排查起来很费时间。
3.3 状态枚举与分时电价:枚举落库而不是裸数字
新手最容易犯的错是代码里到处写if (status == 0)。跑是能跑,但过一个月你自己都想不起来 2 是“故障”还是“离线”。比较稳妥的做法是用枚举承载含义,数据库里仍然存TINYINT:
public enum PileStatus { FREE(0, "空闲"), BUSY(1, "占用"), FAULT(2, "故障"), OFFLINE(3, "离线"); private final Integer code; private final String desc; PileStatus(Integer code, String desc) { this.code = code; this.desc = desc; } public Integer getCode() { return code; } public String getDesc() { return desc; } /** 前端下拉框直接用这个,避免前后端各写一份状态字典 */ public static List<Map<String, Object>> options() { return Arrays.stream(values()) .map(e -> Collections.<String, Object>singletonMap("value", e.code)) .collect(Collectors.toList()); } }订单状态同理,ChargeOrderStatus里定义“充电中 / 待支付 / 已完成 / 已取消 / 已退款”,所有状态变更必须走setStatus(ChargeOrderStatus.PAID.getCode())这种写法,而不是魔法数字。这样做的收益在联调期特别明显:前端拿到的status是数字,但字典接口能返回中文,页面不用再写一份映射表。
电价这块,如果源码里只有单一单价,那就是price_per_kwh × 度数;如果带分时电价(峰平谷),一般会多一张electricity_price表,按小时段存价格,计费时用开始时间和结束时间做区间加权。你想加分就可以往这个方向扩,因为它涉及时间区间计算,是个能讲出东西的点。
4. 后端核心链路:一次扫码充电从请求到结算的完整实现
后端部分我按请求流向拆:先进拦截器验身份,再进 Service 做业务,最后用定时任务兜住异常情况。这条链路跑通了,整套系统的主干就通了。
4.1 登录鉴权:JWT + 拦截器怎么把角色串起来
登录接口做两件事:校验密码、签发 token。密码一般用 BCrypt 或者 MD5 加盐,源码里如果用的是明文比较,建议你先改成加盐再演示,这是个很容易被问到的点。
@PostMapping("/login") public Result<Map<String, Object>> login(@RequestBody LoginDTO dto) { SysUser user = userService.lambdaQuery() .eq(SysUser::getUsername, dto.getUsername()) .one(); // 密码校验:BCrypt 用 matches,MD5 加盐要自己拼盐再比 if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.fail("账号或密码不对"); } if (user.getStatus() == 0) { return Result.fail("账号已被禁用"); } // token 里放 userId 和 role,前端路由守卫靠 role 区分管理端和车主端 String token = JwtUtil.create(user.getId(), user.getRole()); Map<String, Object> data = new HashMap<>(); data.put("token", token); data.put("role", user.getRole()); data.put("username", user.getUsername()); return Result.ok(data); }拦截器负责把 token 解出来塞进ThreadLocal,Controller 里就能直接拿当前用户,不用每个接口都传userId:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 预检请求直接放行,否则浏览器跨域会在这里被拦成 401 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.startsWith("Bearer ") == false) { throw new BizException(401, "未登录"); } Claims claims = JwtUtil.parse(token.substring(7)); UserContext.set(claims.get("userId", Long.class), claims.get("role", Integer.class)); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { UserContext.clear(); // 线程池复用,不清会串号 } }参数上要盯三处:Authorization头的前缀(Bearer有没有空格)、JwtUtil.create里的过期时间(常见 2 小时,调试时可以临时调长)、拦截器注册时的excludePathPatterns(登录、注册、静态资源必须放行)。这三处任意一处对不上,表现都是“登录成功但一刷新就掉线”。
4.2 下单与计费:并发抢桩与金额计算的两个关键点
这是整套系统里最容易翻车的地方。两个用户同时点同一个空闲桩,如果只是select出来判断status == 0然后再update,两个人都会通过校验,最后两条订单挂同一个桩。
正确的做法是用一条带条件的 UPDATE 做原子占位:
@Transactional(rollbackFor = Exception.class) public Long startCharge(Long userId, Long pileId) { ChargingPile pile = pileMapper.selectById(pileId); if (pile == null) { throw new BizException("充电桩不存在"); } // 关键:只有当前状态还是"空闲"才更新成功,靠数据库行锁保证原子性 int rows = pileMapper.update(null, new LambdaUpdateWrapper<ChargingPile>() .eq(ChargingPile::getId, pileId) .eq(ChargingPile::getStatus, PileStatus.FREE.getCode()) .set(ChargingPile::getStatus, PileStatus.BUSY.getCode())); if (rows == 0) { throw new BizException("手慢了,这个桩刚被占用"); } ChargeOrder order = new ChargeOrder(); order.setOrderNo(OrderNoUtil.next()); // 时间戳 + 4 位随机 order.setUserId(userId); order.setPileId(pileId); order.setStartTime(LocalDateTime.now()); order.setStatus(ChargeOrderStatus.CHARGING.getCode()); order.setChargeKwh(BigDecimal.ZERO); order.setTotalAmount(BigDecimal.ZERO); orderMapper.insert(order); return order.getId(); }结束充电时的计费逻辑,核心是“谁算钱、算几位小数”:
@Transactional(rollbackFor = Exception.class) public void finishCharge(Long orderId, BigDecimal kwh) { ChargeOrder order = orderMapper.selectById(orderId); if (order.getStatus() != ChargeOrderStatus.CHARGING.getCode()) { throw new BizException("订单状态不对,不能结算"); } ChargingPile pile = pileMapper.selectById(order.getPileId()); // 金额统一保留 2 位、四舍五入,避免前后端计算口径不一致 BigDecimal electricFee = kwh.multiply(pile.getPricePerKwh()).setScale(2, RoundingMode.HALF_UP); BigDecimal serviceFee = kwh.multiply(pile.getServiceFeeRate()).setScale(2, RoundingMode.HALF_UP); order.setEndTime(LocalDateTime.now()); order.setChargeKwh(kwh); order.setElectricFee(electricFee); order.setServiceFee(serviceFee); order.setTotalAmount(electricFee.add(serviceFee)); order.setStatus(ChargeOrderStatus.UNPAID.getCode()); orderMapper.updateById(order); // 桩释放回空闲,和上面的占位逻辑要成对出现 pileMapper.update(null, new LambdaUpdateWrapper<ChargingPile>() .eq(ChargingPile::getId, pile.getId()) .set(ChargingPile::getStatus, PileStatus.FREE.getCode())); }两份代码对应两个参数要留神:charge_kwh的精度是 3 位小数(度数到毫安时级别),金额是 2 位小数。如果前端传的是字符串"12.5",DTO 里要用BigDecimal接,别用Double,否则12.5 × 1.2可能算出15.000000000000002,页面直接显示一串小数。
注意:
@Transactional只在同类内部调用时失效,如果你的结算方法被同一个类里的另一个方法直接this.finishCharge()调用,事务是不生效的,扣款和桩释放不会回滚。这是个典型的血泪经验点。
4.3 定时任务与异常兜底:超时订单关闭、故障桩自动下线
Demo 里“结束充电”通常靠前端点按钮,但真实场景用户可能直接关页面走人。所以需要一个定时任务兜底,把长时间没结束的订单关掉、把桩释放出来:
@Component public class OrderTimeoutTask { @Resource private ChargeOrderMapper orderMapper; @Resource private ChargingPileMapper pileMapper; /** 每 5 分钟扫一次,超过 12 小时还在充电中的订单强制结束 */ @Scheduled(cron = "0 0/5 * * * ?") public void closeTimeoutOrder() { LocalDateTime deadline = LocalDateTime.now().minusHours(12); List<ChargeOrder> list = orderMapper.selectList(new LambdaQueryWrapper<ChargeOrder>() .eq(ChargeOrder::getStatus, ChargeOrderStatus.CHARGING.getCode()) .lt(ChargeOrder::getStartTime, deadline)); for (ChargeOrder order : list) { order.setStatus(ChargeOrderStatus.CANCELLED.getCode()); order.setEndTime(LocalDateTime.now()); orderMapper.updateById(order); // 桩必须跟着释放,否则这个桩会永远显示"占用" pileMapper.update(null, new LambdaUpdateWrapper<ChargingPile>() .eq(ChargingPile::getId, order.getPileId()) .set(ChargingPile::getStatus, PileStatus.FREE.getCode())); } } }启动类上要记得加@EnableScheduling,不然任务不会执行,表现是“桩一直占用、订单一直充电中”,查代码半天查不出问题,因为根本没有报错——这就是黑匣子最难查的地方。除了订单超时,报修模块一般也有一个逻辑:桩被标记“故障”后,所有下单接口都要拒绝,这个判断写在startCharge开头加两行就够。
5. 启动与排查:从环境搭建到 5 类高频翻车现场
这一章是我认为最该先看的部分。这份源码能跑起来的人很多,但第一次跑不通的人更多,报错类型高度集中,基本都是环境、时区、跨域三类问题。
5.1 启动顺序:先 SQL 后 Redis 再前后端
按这个顺序走,能省掉一半的无效排查:
# 1. 建库并导入脚本(库名以脚本里的 CREATE DATABASE 为准) mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS charging_system DEFAULT CHARSET utf8mb4;" mysql -uroot -p charging_system < sql/charging_system.sql # 2. 改后端配置:数据源账号密码、Redis(没装可以注释掉相关配置) # 文件位置:charging-server/src/main/resources/application.yml # 3. 后端编译启动,默认 8080 cd charging-server mvn clean package -DskipTests java -jar target/charging-server-1.0.0.jar # 4. 前端装依赖并启动,默认 8081 cd ../charging-web npm install --registry=https://registry.npmmirror.com npm run serveapplication.yml里有三处必须核对:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/charging_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你的密码 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 jwt: secret: charging-system-2024-please-change-me # 改了这里,之前登录拿到的 token 全部失效 expire: 7200serverTimezone=Asia/Shanghai这个参数别删,删了之后订单时间会差 8 小时,表现在页面上就是“刚下的单显示 8 小时前”。allowPublicKeyRetrieval=true是 MySQL 8 默认加密方式下必须加的,不加会报公钥检索失败。
5.2 前端联调:路由守卫、axios 封装与跨域那点事
前端两个文件决定联调顺不顺,一个是utils/request.js,一个是router/index.js。
// utils/request.js:统一加 token、统一处理 401 import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 开发环境指向 /api,靠 vue.config.js 代理 timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token // 注意 Bearer 后面有一个空格 } return config }) service.interceptors.response.use( res => { const data = res.data if (data.code === 401) { localStorage.clear() router.push('/login') return Promise.reject(new Error('登录过期')) } if (data.code !== 200) { Message.error(data.msg || '请求失败') return Promise.reject(new Error(data.msg)) } return data }, err => { Message.error(err.message || '网络异常') return Promise.reject(err) } ) export default service路由守卫负责把没登录的用户挡在页面外,同时按角色分流:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requireAuth && !token) { next('/login') // 没登录直接踢回登录页 } else if (to.meta.role && to.meta.role !== Number(role)) { next('/403') // 车主访问管理端页面 } else { next() } })跨域建议统一用vue.config.js的代理,别在后端到处加@CrossOrigin:
// vue.config.js module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } // 后端接口没有 /api 前缀,这里要剥掉 } } } }pathRewrite是最容易漏的一行。漏了它,前端请求/api/pile/list,后端收到的是/api/pile/list,而接口实际路径是/pile/list,报 404,然后你以为是 Controller 没扫到。
5.3 五类高频翻车现场:现象、原因、解决
下面这五条是我在类似项目里反复见到的,按发生频率排。
第一类:登录成功,刷新页面就掉线。现象是登录后能进首页,F5 一下跳回登录页。原因通常是 token 存的位置和读的位置不一致——登录时存sessionStorage,守卫里读localStorage,或者反过来。解决方法是全局搜一遍localStorage.setItem和getItem,确保 key 名完全一致,建议统一成一个utils/auth.js里两个方法setToken/getToken,所有地方只调这两个。
第二类:接口 401,但浏览器 Network 里明明带了 Authorization。现象是请求头有 token 还是被拦。原因多半是拦截器里token.substring(7)的偏移算错了,或者前端拼的是Bearer没空格,后端按Bearer判断失败。解决方法是把前端'Bearer ' + token和后端token.startsWith("Bearer ")这两行对一遍,空格一个都不能少;再不行就在拦截器里打一行日志把原始 header 打出来。
第三类:跨域报错,且 OPTIONS 请求返回 401。现象是控制台报has been blocked by CORS policy,Network 里有一个 OPTIONS 请求红着。原因是拦截器把预检请求也拦了。解决方法是在preHandle开头加if ("OPTIONS".equalsIgnoreCase(request.getMethod())) return true;,同时确认WebMvcConfigurer里配置了allowedOriginPatterns("*")和allowedHeaders("*")。
第四类:列表页字段全是 null。现象是接口返回 200,data里有记录,但每个对象的stationId、pileNo都是 null。原因是数据库下划线字段没映射到 Java 驼峰属性。解决方法是确认application.yml里mybatis-plus.configuration.map-underscore-to-camel-case: true;如果用的是自定义 XML,检查resultType是不是实体类,以及有没有手写resultMap但漏了字段。
第五类:时间差 8 小时。现象是刚下的订单,列表里显示的时间比现在早 8 小时。这是三处时区任意一处没对齐:JDBC 连接串的serverTimezone、Jackson 的time-zone、MySQL 服务端时区。解决方法是连接串加serverTimezone=Asia/Shanghai,spring.jackson.time-zone: GMT+8,然后SELECT NOW();确认数据库时区是东八区。改完记得重启,缓存不会自动刷新。
6. 二次开发:把「能跑」改成「能讲、能扩、能加分」
跑通只是及格线,真正拉开差距的是你能不能在这套新能源充电桩管理系统上做出属于自己的东西。我的建议是不要一上来就动核心链路,先从外围加一个完整的小功能,把前后端一整套流程走一遍,再回头改核心。
6.1 三个投入产出比最高的改造方向
| 方向 | 改哪里 | 难度 | 答辩可讲点 |
|---|---|---|---|
| 分时电价计费 | 新增electricity_price表 + 改finishCharge | 中 | 时间区间加权、金额精度 |
| 桩状态 WebSocket 推送 | 后端加@ServerEndpoint,前端订阅 | 中高 | 长连接、状态同步 |
| 报表导出 Excel | 统计接口 + EasyExcel | 低 | 大数据量分页导出 |
分时电价是最推荐的一个。做法是在electricity_price表里存start_hour / end_hour / price,结算时把充电时间段按小时切段,逐段乘电价再累加。改完之后你可以指着代码说:“峰谷电价是按时间段加权算的,不是一刀切”,这比“我做了个 CRUD”有说服力得多。
6.2 改完怎么验证,别只看页面
改计费逻辑最容易出的问题是页面看着对、实际算错。我一般会写一个临时单测,把三组边界数据跑一遍:
@Test public void testFinishCharge() { // 3.5 度,电费 1.2 元/度,服务费 0.8 元/度 → 电费 4.20,服务费 2.80,合计 7.00 orderService.finishCharge(1L, new BigDecimal("3.5")); ChargeOrder order = orderMapper.selectById(1L); Assert.assertEquals(new BigDecimal("4.20"), order.getElectricFee()); Assert.assertEquals(new BigDecimal("2.80"), order.getServiceFee()); Assert.assertEquals(new BigDecimal("7.00"), order.getTotalAmount()); }跑完这个再去看页面,逻辑通了页面基本不会错。反过来只测页面,金额差一分钱你都要从头翻。
6.3 我的习惯
从那以后我每次拿到这类毕业设计源码,都强制走一遍“导入 SQL → 起后端 → 起前端 → 登录 → 下一个单 → 结算 → 看数据库三张表”的完整链路,任何一步卡住就先解决再谈改造,绝不在跑不稳的工程上堆功能。这套充电桩系统把订单、桩状态、金额三条线串得比较清楚,适合拿来练这种链路意识。希望帮到你。
本文还有配套的精品资源,点击获取