简介:本资源是一个基于Java技术栈构建的社区维修服务平台完整项目包,面向Java初学者与Web开发学习者,解决社区维修服务数字化管理的实际需求。系统采用Eclipse开发环境,以MySQL为数据库,融合JSP动态页面与Java后端逻辑,涵盖住户管理、公告发布、维修工调度、订单全流程跟踪、在线沟通及举报反馈等10大核心模块,具备典型MVC架构特征与后台权限管理体系。压缩包共811个文件,37MB,包含122个Java业务逻辑类、153个JS交互脚本、46个Vue组件(如update-password.vue.bak、IndexHeader.vue.bak)、44个CSS样式文件、43个HTML页面及配套SQL、配置文件(yml/properties)和启动脚本(bat/cmd),结构完整,便于理解前后端协作与模块化开发实践。目前已有77人下载学习,可直接导入Eclipse运行调试,获取从需求分析、分层编码到部署验证的全链路开发范例,尤其适合课程设计、毕业设计或Java Web综合实训参考。
1. 一个能跑在社区物业办公室里的 Java 维修工单系统,不是 Demo,是真能扫码派单、查进度、留痕归档的轻量级平台
你不需要部署 Kubernetes,也不用对接政务云——这个「基于 Java 社区维修平台」的核心价值,是让小区物业、业委会或小型后勤服务公司,用一台 8GB 内存的旧笔记本 + MySQL 8.0 + JDK 17 就能跑起来。它不追求高并发吞吐,但必须做到:业主微信扫码提交报修(带图片)、工单自动分派给对应楼栋维修员、维修过程可拍照上传、超时未处理自动标红提醒、所有操作留审计日志、导出 Excel 报表供季度汇报。技术栈刻意避开 Spring Cloud、Dubbo 等重型组件,主攻 Spring Boot 2.7.x + MyBatis-Plus + Thymeleaf + H2/MySQL 双模式支持,前端用纯 HTML+JS+Bootstrap 5,连 jQuery 都没引入。适合刚转行做物业信息化的 Java 初级开发者练手,也适合作为社区数字化改造项目的最小可行原型(MVP)——不是教你怎么写八股文,而是教你怎么把「业主说马桶堵了」这件事,变成数据库里一条带时间戳、坐标、状态机和操作人 trace 的记录。
2. 用 Spring Boot 2.7 搭建维修平台后端骨架:为什么选 MyBatis-Plus 而非 JPA,以及如何规避 H2 嵌入式数据库的常见陷阱
2.1 选型依据:轻量、可控、易调试,拒绝“黑盒 ORM”
社区维修场景对事务一致性要求明确(如:派单成功必须同步更新工单状态+通知消息+计时器),但对复杂关联查询需求极低。JPA 的二级缓存、懒加载代理、实体生命周期管理,在本项目中反而增加排查难度——比如@Transactional失效导致状态未持久化,或fetch = FetchType.EAGER引发 N+1 查询拖慢列表页。MyBatis-Plus 提供LambdaQueryWrapper类型安全的条件构造,SQL 完全可见,且IService接口封装的saveBatch()、updateBatchById()等方法,恰好匹配维修工单批量派发、批量结单等高频操作。更重要的是,其MetaObjectHandler可统一注入创建人、修改时间等审计字段,避免每个@InsertSQL 手动拼写create_time = now()。
提示:不要在
application.yml中直接写spring.jpa.hibernate.ddl-auto=update。社区平台需长期运行,表结构变更必须通过 Flyway 或 Liquibase 管控,否则某次重启后repair_order表被自动删列,将导致历史工单无法加载。
2.2 初始化工程:用 Spring Initializr 生成最小依赖集
执行以下命令生成基础工程(替换com.example.repair为实际包名):
curl -X POST "https://start.spring.io/starter.zip" \ -H "Content-Type: application/json" \ -d '{ "groupId": "com.example.repair", "artifactId": "community-repair-platform", "name": "community-repair-platform", "description": "Java-based community maintenance platform", "packageName": "com.example.repair", "packaging": "jar", "javaVersion": "17", "dependencies": [ "web", "thymeleaf", "mysql", "mybatis-plus", "lombok", "validation", "h2" ] }' --output community-repair-platform.zip解压后,在pom.xml中显式锁定 MyBatis-Plus 版本(避免 Spring Boot 2.7.x 自动引入不兼容的 3.5.3+):
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.3.4</version> <!-- 此版本与 Spring Boot 2.7.18 兼容性最佳 --> </dependency>2.3 数据库配置:H2 嵌入式模式用于开发,MySQL 用于生产,切换零代码修改
application-dev.yml(开发环境)启用 H2 控制台,便于实时查表:
spring: datasource: url: jdbc:h2:mem:repairdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true path: /h2-console sql: init: mode: always schema-locations: classpath:schema-h2.sql >CREATE TABLE repair_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '工单编号,格式:REPAIR-2024-00001', building VARCHAR(20) NOT NULL COMMENT '楼栋号,如:3号楼', unit VARCHAR(10) COMMENT '单元号,如:A座', floor VARCHAR(10) COMMENT '楼层,如:5F', room VARCHAR(20) COMMENT '房号,如:502', contact_name VARCHAR(20) NOT NULL, contact_phone VARCHAR(15) NOT NULL, problem_desc TEXT NOT NULL COMMENT '故障描述', image_urls TEXT COMMENT '图片 URL JSON 数组,如:["/upload/1.jpg","/upload/2.png"]', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待接单,1-已接单,2-维修中,3-已完成,4-已关闭', assignee_id BIGINT COMMENT '指派维修员 ID', assign_time DATETIME COMMENT '指派时间', finish_time DATETIME COMMENT '完成时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 创建索引加速按楼栋+状态查询 CREATE INDEX idx_building_status ON repair_order(building, status);application-prod.yml切换至 MySQL(生产环境):
spring: datasource: url: jdbc:mysql://localhost:3306/repair_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: repair_user password: your_secure_password hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 sql: init: mode: never # 生产环境禁用自动建表,由 DBA 执行 DDL2.3.1 关键陷阱:H2 的DATETIME类型与 MySQL 不一致导致update_time同步失败
H2 默认DATETIME不支持ON UPDATE CURRENT_TIMESTAMP,若在schema-h2.sql中直接写该子句,启动会报错。解决方案:在实体类中用@TableField(fill = FieldFill.UPDATE)配合MetaObjectHandler:
// RepairOrder.java @TableField(fill = FieldFill.UPDATE) private LocalDateTime updateTime; // MyMetaObjectHandler.java @Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); // 起始填充 } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); // 更新时填充 } }这样既保证 H2 和 MySQL 下行为一致,又避免 SQL 层面的方言差异。
3. 实现工单状态机与派单逻辑:用枚举约束状态流转,用定时任务扫描超时工单
3.1 定义严格的状态枚举,禁止非法状态跳转
public enum OrderStatus { WAITING_ASSIGN(0, "待接单"), ASSIGNED(1, "已接单"), IN_PROGRESS(2, "维修中"), COMPLETED(3, "已完成"), CLOSED(4, "已关闭"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } // 校验是否允许从 from 状态跳转到 to 状态 public static boolean canTransition(int from, int to) { return switch (from) { case 0 -> to == 1 || to == 4; // 待接单 → 已接单 或 关闭 case 1 -> to == 2 || to == 4; // 已接单 → 维修中 或 关闭 case 2 -> to == 3 || to == 4; // 维修中 → 已完成 或 关闭 case 3, 4 -> false; // 已完成/已关闭不可再变 default -> false; }; } }在 Service 层强制校验:
@Service public class RepairOrderService { public boolean updateStatus(Long id, int newStatus) { RepairOrder order = getById(id); if (!OrderStatus.canTransition(order.getStatus(), newStatus)) { throw new IllegalArgumentException("非法状态流转:从 " + order.getStatus() + " 到 " + newStatus); } order.setStatus(newStatus); order.setUpdateTime(LocalDateTime.now()); return updateById(order); } }3.2 基于楼栋的自动派单:用 Redis 缓存维修员负载,避免轮询式分配
不采用简单取模或随机分配,而是维护每个楼栋的「当前负载最低维修员」。使用 Redis Sorted Set 存储{member: employee_id, score: current_task_count}:
// RepairAssignmentService.java @Autowired private StringRedisTemplate redisTemplate; public Long assignToBuilding(String building) { String key = "repair:assign:load:" + building; // 获取当前负载最低的维修员(score 最小) Set<String> candidates = redisTemplate.opsForZSet().range(key, 0, 0); if (candidates == null || candidates.isEmpty()) { throw new RuntimeException("楼栋 " + building + " 无可用维修员"); } Long employeeId = Long.parseLong(candidates.iterator().next()); // 负载 +1 redisTemplate.opsForZSet().incrementScore(key, String.valueOf(employeeId), 1.0); return employeeId; } // 每当工单完成时调用此方法减负载 public void releaseLoad(String building, Long employeeId) { String key = "repair:assign:load:" + building; redisTemplate.opsForZSet().incrementScore(key, String.valueOf(employeeId), -1.0); }初始化负载数据(首次启动时):
-- 在 MySQL 中预置维修员数据 INSERT INTO employee (id, name, building_coverage) VALUES (1001, '张师傅', '1号楼,2号楼'), (1002, '李师傅', '3号楼,4号楼'), (1003, '王师傅', '5号楼,6号楼');然后在@PostConstruct方法中,为每个楼栋初始化 ZSet:
@PostConstruct public void initAssignLoad() { List<Employee> employees = employeeMapper.selectList(null); for (Employee emp : employees) { String[] buildings = emp.getBuildingCoverage().split(","); for (String b : buildings) { String key = "repair:assign:load:" + b.trim(); redisTemplate.opsForZSet().add(key, String.valueOf(emp.getId()), 0.0); } } }3.3 超时监控:用 Spring Scheduler 每 5 分钟扫描待接单超 30 分钟工单
@Component public class TimeoutMonitor { @Scheduled(fixedDelay = 300_000) // 每 5 分钟执行一次 public void checkTimeoutOrders() { LocalDateTime threshold = LocalDateTime.now().minusMinutes(30); QueryWrapper<RepairOrder> wrapper = new QueryWrapper<>(); wrapper.eq("status", OrderStatus.WAITING_ASSIGN.getCode()) .lt("create_time", threshold); List<RepairOrder> timeoutOrders = repairOrderMapper.selectList(wrapper); for (RepairOrder order : timeoutOrders) { // 发送企业微信/短信告警,并标记为“超时未处理” notifyTimeout(order); order.setStatus(OrderStatus.CLOSED.getCode()); order.setUpdateBy(0L); // 系统自动关闭 repairOrderMapper.updateById(order); } } private void notifyTimeout(RepairOrder order) { // 此处集成企业微信机器人 Webhook 或短信网关 String msg = String.format("【超时预警】工单 %s(%s栋%s单元%s室)创建已超30分钟,仍未接单,请及时处理", order.getOrderNo(), order.getBuilding(), order.getUnit(), order.getRoom()); // sendWeComAlert(msg); } }注意:
@Scheduled默认使用单线程任务调度器,若扫描耗时过长(如万级工单),需配置异步线程池:@Configuration @EnableScheduling public class SchedulingConfig { @Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); scheduler.setThreadNamePrefix("timeout-monitor-"); return scheduler; } }
4. 前端工单提交与状态展示:Thymeleaf 模板如何实现无 JS 的图片上传与状态流转按钮控制
4.1 业主端提交页面:用原生<input type="file">+ Thymeleaf 表单绑定
submit.html模板(关键部分):
<form th:action="@{/order/submit}" th:object="${orderForm}" method="post" enctype="multipart/form-data"> <div class="mb-3"> <label class="form-label">楼栋</label> <select class="form-select" th:field="*{building}" required> <option value="">请选择</option> <option th:each="b : ${buildings}" th:value="${b}" th:text="${b}">1号楼</option> </select> </div> <div class="mb-3"> <label class="form-label">故障描述</label> <textarea class="form-control" th:field="*{problemDesc}" rows="3" required></textarea> </div> <div class="mb-3"> <label class="form-label">上传图片(最多3张)</label> <input type="file" class="form-control" th:field="*{images}" accept="image/*" multiple /> <div class="form-text">支持 JPG/PNG,单张不超过 2MB</div> </div> <button type="submit" class="btn btn-primary">提交报修</button> </form>Controller 层接收文件:
@PostMapping("/order/submit") public String submitOrder( @ModelAttribute RepairOrderForm form, @RequestParam("images") MultipartFile[] images, Model model) { // 1. 保存图片到 /static/upload/ 目录 List<String> imageUrls = new ArrayList<>(); for (MultipartFile file : images) { if (!file.isEmpty()) { String filename = UUID.randomUUID() + "_" + file.getOriginalFilename(); try { file.transferTo(Paths.get("src/main/resources/static/upload/", filename)); imageUrls.add("/upload/" + filename); } catch (IOException e) { throw new RuntimeException("图片保存失败", e); } } } // 2. 构建工单实体并保存 RepairOrder order = new RepairOrder(); order.setBuilding(form.getBuilding()); order.setProblemDesc(form.getProblemDesc()); order.setImageUrls(JSON.toJSONString(imageUrls)); // 存 JSON 字符串 order.setStatus(OrderStatus.WAITING_ASSIGN.getCode()); repairOrderService.save(order); model.addAttribute("message", "报修已提交,工单号:" + order.getOrderNo()); return "result"; }4.2 维修员工作台:根据登录用户动态渲染可操作按钮
workbench.html中,用 Thymeleaf 的sec:authorize(配合 Spring Security)控制按钮显示:
<!-- 假设当前登录用户为维修员,其 ID 存在 session 中 --> <div th:if="${order.status == 0 and order.assigneeId == null}"> <button class="btn btn-success btn-sm" th:onclick="'location.href=\'/order/assign?id=' + ${order.id} + '&employeeId=' + ${session.employeeId} + '\''"> 接单 </button> </div> <div th:if="${order.status == 1 and order.assigneeId == session.employeeId}"> <button class="btn btn-warning btn-sm" th:onclick="'location.href=\'/order/start?id=' + ${order.id} + '\''"> 开始维修 </button> </div> <div th:if="${order.status == 2 and order.assigneeId == session.employeeId}"> <button class="btn btn-info btn-sm" th:onclick="'location.href=\'/order/finish?id=' + ${order.id} + '\''"> 标记完成 </button> </div>对应的 Controller:
@GetMapping("/order/assign") public String assignOrder(@RequestParam Long id, @RequestParam Long employeeId) { repairOrderService.assignOrder(id, employeeId); // 调用 Service 更新 assignee_id 和 status return "redirect:/workbench"; } @GetMapping("/order/start") public String startRepair(@RequestParam Long id) { repairOrderService.updateStatus(id, OrderStatus.IN_PROGRESS.getCode()); return "redirect:/workbench"; } @GetMapping("/order/finish") public String finishRepair(@RequestParam Long id) { repairOrderService.updateStatus(id, OrderStatus.COMPLETED.getCode()); return "redirect:/workbench"; }4.2.1 关键细节:Thymeleaf 如何安全渲染 JSON 图片数组
orderDetail.html中显示图片:
<div th:if="${order.imageUrls}"> <h6>现场照片:</h6> <div class="row"> <div class="col-4" th:each="url : ${#strings.split(order.imageUrls, ',')}"> <img th:src="${#strings.replace(url, '\"', '')}" class="img-fluid rounded" alt="现场图" style="max-height: 150px;" /> </div> </div> </div>提示:
#strings.split()会将["/upload/1.jpg","/upload/2.png"]拆成["\"/upload/1.jpg\"", "\"/upload/2.png\""],所以用#strings.replace(url, '\"', '')去掉引号,确保路径正确。
5. 生产部署与运维技巧:用 Docker Compose 一键启停,用 Actuator 暴露关键健康指标
5.1 构建可移植的 Docker 镜像:JDK 17 + Spring Boot Fat Jar
Dockerfile(放在项目根目录):
FROM openjdk:17-jre-slim VOLUME ["/tmp"] ARG JAR_FILE=target/community-repair-platform-0.0.1-SNAPSHOT.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]构建命令:
mvn clean package -DskipTests docker build -t community-repair-app .5.2 Docker Compose 编排:MySQL + 应用 + Nginx 静态资源代理
docker-compose.yml:
version: '3.8' services: db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: repair_db MYSQL_USER: repair_user MYSQL_PASSWORD: repairpass volumes: - ./mysql-data:/var/lib/mysql ports: - "3306:3306" app: image: community-repair-app depends_on: - db environment: SPRING_PROFILES_ACTIVE: prod SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/repair_db?useSSL=false&serverTimezone=Asia/Shanghai SPRING_DATASOURCE_USERNAME: repair_user SPRING_DATASOURCE_PASSWORD: repairpass ports: - "8080:8080" restart: unless-stopped nginx: image: nginx:alpine volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./src/main/resources/static:/usr/share/nginx/html ports: - "80:80" depends_on: - appnginx.conf关键配置(反向代理 API,静态资源直出):
events { worker_connections 1024; } http { server { listen 80; location /api/ { proxy_pass http://app:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } } }5.3 激活 Actuator 端点:监控内存、线程、HTTP 调用链
pom.xml添加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>application-prod.yml开放必要端点:
management: endpoints: web: exposure: include: health,info,metrics,threaddump,loggers endpoint: health: show-details: when_authorized info: git: mode: full访问http://localhost/actuator/health返回:
{ "status": "UP", "components": { "db": { "status": "UP", "details": { "database": "MySQL", "validationQuery": "isValid()" } }, "diskSpace": { "status": "UP", "details": { "total": 123456789012, "free": 9876543210 } } } }5.3.1 实用技巧:用/actuator/loggers动态调整日志级别,快速定位派单失败原因
当发现assignToBuilding方法未生效时,无需重启,直接调用:
curl -X POST "http://localhost/actuator/loggers/com.example.repair.service.RepairAssignmentService" \ -H "Content-Type: application/json" \ -d '{"configuredLevel":"DEBUG"}'然后查看logs/application.log,即可看到 Redis ZSet 的读写详情,确认是否因楼栋键名拼写错误(如"repair:assign:load:3号楼"vs"repair:assign:load:3号楼 ")导致查不到成员。
注意:生产环境务必通过 Spring Security 保护
/actuator/**端点,仅允许运维 IP 访问:@Configuration public class ActuatorSecurity extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.requestMatcher(EndpointRequest.toAnyEndpoint()) .authorizeRequests((requests) -> requests .requestMatchers(EndpointRequest.to("health", "info")).permitAll() .requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ADMIN")); } }
本文还有配套的精品资源,点击获取