1 项目背景
据住建部《2023年城市基础设施监测报告》显示,我国城市井盖数量已突破 3.2亿个,年均增长率达4.5%。但传统管理模式存在三大致命缺陷:
- 巡检效率低下:人工巡检覆盖率不足40%,平均响应时间超72小时
- 安全隐患突出:2022年全国井盖相关事故达 12.7万起,直接经济损失超18亿元
- 数据孤岛严重:市政、水务、电力等多部门数据割裂,协同效率低
2021年国家发改委《新型城镇化建设重点任务》明确提出:“2025年前实现城市公共设施智能化监控覆盖率不低于60%”,标志着井盖管理已从“人防时代”迈入“技防时代”。
随着城市化进程的加速,井盖作为城市基础设施的重要组成部分,其管理效率与安全性面临严峻挑战。传统管理模式依赖人工巡检,存在响应延迟、定位模糊、流程不透明等问题,导致事故频发、维护成本高昂。
2 项目介绍
本项目旨在开发一套完整的井盖管理系统,主要功能包括井盖信息的录入、智能检测、维修工单的生成与管理、用户信息管理与推送等。系统采用前后端分离的设计模式,后端使用Spring Boot框架,前端使用Vue.js框架,并结合了MyBatis、MySQL、RabbitMQ、Redis等技术,确保系统的稳定性和高效性。
3 技术选型
-
后端技术:
Spring Boot: 构建RESTful API,简化开发和部署过程。
Redis: 分布式缓存系统,提高数据读取速度和系统性能。
Spring Security: 实现细粒度的访问控制和身份验证。
MyBatis: 作为ORM框架,用于数据库操作。
MySQL: 关系型数据库,存储系统数据。
RabbitMQ: 消息队列,用于异步任务处理和高并发场景下的消息传递。 -
前端技术:
Vue.js: 构建用户界面,支持组件化和数据绑定。
UniApp: 跨平台移动应用开发框架,实现iOS和Android应用的统一开发。 -
其他技术:
YOLOv5: 目标检测算法,用于自动识别井盖的损坏情况。
WebSocket: 实现实时的全双工通信。
GeoJSON: 地理空间数据格式,接入高德地图API实现精准定位
4 核心功能模块
4.1 用户管理:
用户注册与登录
权限控制(管理员、检修员、维修员、普通用户)
4.2井盖信息管理:
井盖位置的上报与记录
YOLOv5模型对井盖状态的智能检测
4.3维修工单管理:
自动生成维修工单
工单分配与状态跟踪
4.4实时通知与推送:
通过WebSocket实现实时通信
用户附近井盖状态的推送提醒
4.5日志与监控:
系统操作的详细日志记录
异常情况的实时监控与报警
5 成果展示
5.1 数据看板(所有角色可见)
动态展示当前城市井盖管辖区下井盖档案
5.2 井盖档案(系统角色为检修员可见)
通过无人机或者巡逻车动态上传街边井盖信息
5.3 数据上传(系统角色为检修员可见)
接入高德地图api实现井盖“米级”定位
5.4 工单管理(普通管理员可见)
后台系统调用python脚本 根据井盖破损情况生成预工单,交由派单人员
5.4 派发工单(普通管理员可见)
按井盖周围就近施工人员进行推荐派单
5.5 维修人员接单(uniapp端)
维修人员接单完成后
5.6当然这个系统还有很多有趣的部分~~~
在线用户(浏览系统当前在线用户情况)
定时任务(不用写代码就可以发布定时任务,告别cron表达式和springTask)
数据监控(检测sql的执行情况,更快定位慢sql)
服务监控(查看自己的电脑配置等信息)‘
缓存监控(模拟redis客户端,无需再开一个redis的客户端工具,更快查看redis内部信息)
还有很多很多,当然这些就留给大家去下载代码后探索了
6 技术亮点(高性能,高并发,高可用)
高性能:
6.1 使用GEO数据类型,引入高德地图辅助定位,实现井盖“米级”定位
使用MySQL的decimal类型存储井盖坐标:
create table manhole_cover
(
id bigint auto_increment comment '井盖唯一ID,主键'
primary key,
city_id char(6) not null comment '所属城市代码',
image varchar(255) not null comment '图片地址',
latitude decimal(9, 6) not null comment '纬度坐标,精度到米级',
longitude decimal(9, 6) not null comment '经度坐标,精度到米级',
...
// Other definition
...
)
comment '井盖档案表';
接入高德API应用:
<template>
<div id="container" style="width: 100%; height: 100vh;">
<button class="refresh-button" @click="refresh">刷新</button>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { listCover, getCover, delCover, addCover, updateCover } from "@/api/managent/cover";
const apiKey = ref('${你的API_KEY}');
const coverList = ref([]);
let map;
onMounted(() => {
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=1.4.15&key=${apiKey.value}`;
script.onload = () => {
getCoverList();
initMap();
};
document.head.appendChild(script);
});
</script>
6.2 WebSocket全双工通信
连接管理:使用 ConcurrentHashMap
维护在线会话;心跳包检测(30秒无响应自动断开)
@OnMessage
public void onMessage(String message, Session session) {
if ("HEARTBEAT".equals(message)) {
session.getAsyncRemote().sendText("PONG");
}
}
消息广播:管理员派单后触发全局通知,STOMP协议订阅/发布模式:
// Uniapp端订阅工单通知
stompClient.subscribe('/topic/order', function(msg) {
console.log("新工单:" + msg.body);
});
6.3 使用redis+本地缓存,构建多级缓存,达到信息毫秒级相应
缓存策略:
一级缓存:Caffeine(最大10000条,过期时间5分钟)
二级缓存:Redis Cluster(过期时间30分钟)
@Cacheable(value = "user", key = "#userId", cacheManager = "multiCache")
public User getUser(String userId) {
return userMapper.selectById(userId);
}
6.4 使用canal实现缓存同步,保证修改数据库的同时保证用户信息时效性,准确性
基于Canal+RabbitMQ的缓存同步架构设计
通过MySQL Binlog实时监听+消息队列分发,实现数据库与缓存的最终一致性
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.34</version>
</dependency>
消息反序列化
public class CanalMessageDeserializer implements MessageConverter {
@Override
public Message fromMessage(org.springframework.amqp.core.Message message, String s) {
String json = new String(message.getBody(), StandardCharsets.UTF_8);
return JSON.parseObject(json, CanalMessage.class);
}
}
消费者逻辑
@Component
public class UserCacheConsumer {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@RabbitListener(queues = "cache.user")
public void handleUserUpdate(CanalMessage message) {
// 1. 仅处理UPDATE/INSERT事件
if (!Arrays.asList("INSERT", "UPDATE").contains(message.getType())) return;
// 2. 遍历变更数据
message.getData().forEach(userData -> {
String userId = userData.getString("id");
// 3. 逻辑删除检查
if ("1".equals(userData.getString("is_deleted"))) {
redisTemplate.delete("user:" + userId);
} else {
// 4. 构造缓存Key并更新
String cacheKey = "user:" + userId;
redisTemplate.opsForValue().set(cacheKey, userData, 30, TimeUnit.MINUTES);
}
});
}
}
高并发
6.5 RabbitMQ削峰填谷
消息队列配置
spring:
rabbitmq:
host: 192.168.0.5
port: 5672
username: liwei
password: 123456
publisher-returns: true
publisher-confirm-type: correlated
listener:
simple:
acknowledge-mode: manual
prefetch: 1
消费者处理未ACK数据,实现削峰填谷
public static final String EXCHANGE_DIRECT = "exchange.direct.cover";
public static final String ROUTING_KEY = "cover";
public static final String QUEUE_NAME = "queue.cover";
ExecutorService TASK_SERVICE = Executors.newFixedThreadPool(10);
@RabbitListener(queues = QUEUE_NAME)
public void processMessage(String dataString, Message message, Channel channel) throws Exception {
try{
handleMessage(dataString);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
Boolean redelivered = message.getMessageProperties().getRedelivered();
if(redelivered){
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
}else{
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
}
throw new RuntimeException(e);
}
}
6.6 mysql读写分离 + 冷热数据分离,迁移过时数据
读写分离:使用Sharding-JDBC路由查询,写操作主库,读操作从库
spring:
shardingsphere:
datasource:
names: master,slave
master:
url: jdbc:mysql://master:3306/manhole
slave:
url: jdbc:mysql://slave:3306/manhole
rules:
replica-query:
load-balancers:
type: ROUND_ROBIN
冷热分离:
热数据:近一个月的操作日志
冷数据:一个月前的操作日志
@Scheduled(cron = "0 0 0 30 * ?")
public void monthlyTask(){
List<SysOperLog> list = opeLogMapper.selectOperLogList(new SysOperLog());
opeLogMapper.insert(list);
Long[] ll = new Long[list.size()];
for(int i=0;i<list.size();i++){
ll[i] = list.get(i).getOperId();
}
opeLogMapper.deleteOperLogByIds(ll);
}
6.7 使用redission分布式锁保障系统资源互斥,基于lua脚本实现系统原子性奖励扣减
锁设计:
锁Key按stockID分片:lock:reward:${stockId}+
看门狗机制自动续期
RLock lock = redissonClient.getLock("lock:reward:" + stockId);
lock.lock(30, TimeUnit.SECONDS); // 自动续期直到释放
Lua原子操作:保证奖励发放的原子性
local key = KEYS[1]
local delta = tonumber(ARGV[1])
if redis.call('GET', key) >= delta then
return redis.call('DECRBY', key, delta)
else
return 0
end
高可用
6.8 基于Spring Security的RABC权限控制,全面安全认证和授权支持
动态权限模型:
角色层级:管理员、检修员、维修员,普通用户 — 权限粒度细化到按钮级别
-- 权限表结构
CREATE TABLE sys_role (
role_id BIGINT PRIMARY KEY COMMENT '角色ID',
role_name VARCHAR(50) NOT NULL COMMENT '角色名称'
);
CREATE TABLE sys_menu (
menu_id BIGINT PRIMARY KEY COMMENT '菜单ID',
menu_name VARCHAR(50) COMMENT '菜单名称',
perms VARCHAR(100) COMMENT '权限标识(如sys:user:add)'
);
安全拦截:
自定义AccessDeniedHandler
处理无权限访问异常
使用@PreAuthorize
注解动态控制接口权限:
/**
* 查询井盖档案列表
*/
@PreAuthorize("@ss.hasPermi('managent:cover:list')")
@GetMapping("/list")
public TableDataInfo list(Cover cover)
{
startPage();
List<Cover> list = coverService.selectCoverList(cover);
return getDataTable(list);
}
/**
* 导出井盖档案列表
*/
@PreAuthorize("@ss.hasPermi('managent:cover:export')")
@Log(title = "井盖档案", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, Cover cover)
{
List<Cover> list = coverService.selectCoverList(cover);
ExcelUtil<Cover> util = new ExcelUtil<Cover>(Cover.class);
util.exportExcel(response, list, "井盖档案数据");
}
6.9 @Scheduled + 操作日志记录 + 切面注解,及时响应异常情况
操作日志切面:
自定义注解@Log
记录操作耗时与异常堆栈
protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult)
{
try
{
// 获取当前的用户
LoginUser loginUser = SecurityUtils.getLoginUser();
// *========数据库日志=========*//
SysOperLog operLog = new SysOperLog();
operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
// 请求的地址
String ip = IpUtils.getIpAddr();
operLog.setOperIp(ip);
operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255));
if (loginUser != null)
{
operLog.setOperName(loginUser.getUserId().toString());
SysUser currentUser = loginUser.getUser();
if (StringUtils.isNotNull(currentUser) && StringUtils.isNotNull(currentUser.getDept()))
{
operLog.setDeptName(currentUser.getDept().getDeptName());
}
}
if (e != null)
{
operLog.setStatus(BusinessStatus.FAIL.ordinal());
operLog.setErrorMsg(StringUtils.substring(Convert.toStr(e.getMessage(), ExceptionUtil.getExceptionMessage(e)), 0, 2000));
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
operLog.setMethod(className + "." + methodName + "()");
// 设置请求方式
operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
// 设置消耗时间
operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get());
// 保存数据库
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
}
catch (Exception exp)
{
// 记录本地异常日志
log.error("异常信息:{}", exp.getMessage());
exp.printStackTrace();
}
finally
{
TIME_THREADLOCAL.remove();
}
}
定时任务:
@Scheduled(cron = "0 0 * * * ?")
public void dailyTask() {
List<String> count = opeLogMapper.selectPersonalCount(COUNT_MAX);
for(String i:count){
Long id = Long.valueOf(i);
SysUser user = new SysUser();
user.setUserId(id);
user.setStatus("1");
sysUserMapper.updateUser(user);
}
}
7 项目地址
- 目前我已经将项目代码(uniapp,后端,网页端,python代码部分)开源到了我的git仓库:https://gitee.com/liwei0619/ug.git
https://gitee.com/liwei0619/ug.git
- 同时有想用这个做二次封装,但有部署,运行问题的朋友,可以email我:19352705344@163.com