ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

springboot街道摊贩管理系统从开发到部署实战解析

springboot街道摊贩管理系统从开发到部署实战解析 前阵子接手了一个街道侧的摊贩管理项目第一反应是这种系统无非是信息的增删改查没什么技术含量。真正和街道的人聊了两三轮需求之后才发现摊贩管理最麻烦的不是写代码而是把“摊贩信息、证照检查、巡查打卡、费用收缴、居民投诉”这几条业务线串起来。Spring Boot 在这类管理系统里确实是最省事的后端框架生态成熟、一个人也能撑起整个项目。这篇文章就围绕 springboot 街道摊贩管理系统 从设计、开发到落地的全过程把业务拆分、技术选型、数据库设计、核心功能实现和常见坑位一次性讲透。我不打算给你画一堆高深的架构图而是沿着真实项目的推进顺序走一遍。哪怕你是第一次用 Spring Boot 做毕业设计也能照着把项目跑起来如果你已经有经验重点看后面“常见问题排查”和“部署建议”很多坑都是常规文档里不会写的。1. 项目到底在管什么业务拆解先行1.1 先把角色和流程理清楚做管理系统最忌讳一上来就建表写接口。街道摊贩管理系统表面上是对“摊贩”的管理实际上涉及好几个身份的人街道管理人员负责审核摊贩信息、查看统计报表、处理投诉工单。巡查队员拿着手机到现场确认摊位是否存在、是否占道、是否超范围经营。摊贩需要登记自己的身份、经营类型、摊位位置后续还要查看自己的证照状态和缴费记录。居民/投诉人看到问题后提交投诉并且能跟踪投诉处理结果。核心流程也不复杂可以拆成一条主线、三条辅线。主线是摊贩登记审核后领取摊位二维码巡查人员扫码或按定位核对现场情况街道定期生成缴费台账同时对居民投诉做闭环处理。我在设计时把整个过程分成了这几个状态节点摊贩提交登记申请。管理人员审核通过/驳回。审核通过后生成电子牌照和二维码。巡查人员日常检查记录检查结果。到缴费周期后形成缴费记录逾期自动标记欠费。投诉工单在待受理、处理中、已办结、已回访之间流转。先把这条链路想清楚后面每个功能模块的边界就清晰了。因为这条链路里既有“人”的状态又有“物”的状态数据库表之间不能只是简单的主外键关系还要保留业务状态字段。1.2 功能模块怎么切才不会做成一堆CRUD很多人的项目最后看起来像“数据库表反向生成器”就是因为没有按业务模块划分。我实际划分成了六个模块每个模块都有明确的核心对象模块核心对象主要解决的事用户与权限系统用户管理人员和巡查队员登录、分配角色、操作权限控制摊贩登记摊贩档案摊贩信息录入、审核、变更、停用生成编号证照与二维码电子牌照生成牌照记录二维码绑定摊位信息扫码可查看巡查管理巡查记录巡查队员现场巡检、拍照上传、位置定位、整改反馈费用管理缴费记录缴费登记、欠费计算、按月/按季汇总统计投诉工单投诉工单居民投诉登记、流转、处理、回访、满意度评价有人会问一个街道项目需要这么复杂吗说实话如果只是为了应付毕业答辩做一个模块也能交差。但实际甲方在验收时会重点看“业务是否闭环”比如摊贩被投诉后巡查记录里能不能体现后续重点检查摊贩欠费后管理人员名单里能不能标红。这些跨模块联动才是系统的价值所在。开发顺序上我建议先做摊贩登记和证照二维码再做巡查和缴费最后做投诉工单。因为前两个模块是基础数据来源后面的模块都要关联它们。2. 技术选型为什么是 Spring Boot 这套组合2.1 Spring Boot 版本和 JDK 不要盲目追新看到热搜里很多人问“springboot版本太高怎么办”我在这个项目里刻意选了稳定性优先的版本组合。我最终使用的是Spring Boot 2.7.18JDK 8Maven 3.8MyBatis Plus 3.5.xMySQL 5.7 / 8.0为什么不选 Spring Boot 3.x因为 3.x 强制要求 JDK 17很多第三方库的老版本不兼容网上搜到的教程八成还是基于 2.x 写的你遇到一个问题可能要踩一晚上版本坑。街道这种内部系统没有高性能压力稳定性比“新”更重要。Spring Boot 2.7 是 2.x 的最后一个版本维护周期长兼容性好用来做管理类项目非常合适。如果你一定要用 Spring Boot 3.x也至少把 JDK 换到 17并且确认 MyBatis Plus、MinIO、Redis 等依赖都有对应版本否则会很难受。2.2 数据访问MyBatis Plus 而不是 MyBatis 或 JPA项目里涉及大量条件组合查询比如按摊贩姓名、经营类型、状态、位置区域查询还要做分页。如果只用原生 MyBatis每个查询都要写 XML后面改需求会非常痛苦。MyBatis Plus 的优势在于内置IService和BaseMapper单表 CRUD 不用自己写 SQL。LambdaQueryWrapper可以很方便地拼条件避免拼接 SQL 出错。自带分页插件一行代码解决分页问题。支持逻辑删除配置删除记录时自动变成UPDATE ... SET deleted 1。代码里我大量使用了这样的写法PageVendor page new Page(pageNum, pageSize); LambdaQueryWrapperVendor wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(name), Vendor::getName, name) .eq(StringUtils.isNotBlank(bizType), Vendor::getBizType, bizType) .eq(status ! null, Vendor::getStatus, status) .orderByDesc(Vendor::getCreateTime); vendorService.page(page, wrapper);这段代码等价于一条动态 SQL但比写 XML 直观多了后续加筛选条件也很方便。2.3 Redis 和 MinIO别让“能跑”和“好用”差太远这个项目不是非用 Redis 不可但我在两个地方用了登录验证码把验证码存到 Redis设置 5 分钟过期比存 Session 更方便管理。登录 token用 Redis 存登录用户的 token做到分布式环境下也能统一校验。配置很简单spring: redis: host: localhost port: 6379 password: database: 0 timeout: 3000ms如果本地没装 Redis代码里要做降级处理或者在启动时判断连接失败不影响主流程。MinIO 也一样。巡查时需要上传现场照片摊贩登记也需要上传身份证照片。一开始图省事直接存本地磁盘后来发现换服务器时文件全都要迁移太麻烦。后来接入了 MinIO后端上传代码本质上就是四步建 bucket、配 endpoint、初始化客户端、putObject。MinioClient client MinioClient.builder() .endpoint(http://127.0.0.1:9000) .credentials(minioadmin, minioadmin) .build(); boolean exists client.bucketExists(BucketExistsArgs.builder().bucket(vendor).build()); if (!exists) { client.makeBucket(MakeBucketArgs.builder().bucket(vendor).build()); } client.putObject( PutObjectArgs.builder() .bucket(vendor) .object(photo/ fileName) .stream(inputStream, size, -1) .contentType(image/jpeg) .build());接入 MinIO 后前端拿到的是文件访问 URL和业务数据库完全解耦。以后哪怕系统换服务器只要对象存储的数据还在照片就不会丢。2.4 前端方案Thymeleaf 还是 Vue这个选择要看交付场景。如果项目是给街道内部使用使用人数不超过几十人那 Spring Boot 集成 Thymeleaf 加 Bootstrap 是最省事的部署时一个 jar 包全搞定不需要单独维护前端工程。如果是毕业设计展示或者以后要扩展外卖点单类功能我建议用 Spring Boot Vue 前后端分离。我这次用的是 Vue 2 Element UI 加后端接口理由有两点接口文档用 springdoc-openapi 生成前后端联调方便。后续移动端巡查小程序可以直接调同套接口不用重新写后端。无论选哪种后端接口设计都要考虑跨域问题。前后端分离时最容易遇到跨域报错后面我会专门讲。3. 数据库设计与工程搭建3.1 核心表结构不能只靠一个摊贩表很多人做摊贩管理数据库里就只有一张“摊贩表”加了几个字段就完事。这样后面做巡查和缴费时会非常痛苦。我建议至少要有下面这七张表sys_user系统用户表保存账号、密码、角色、状态。vendor摊贩档案表保存基本信息、位置、状态。vendor_license电子牌照表记录证照编号、有效期。attachment附件表统一存照片、文件。inspection_record巡查记录表关联摊贩、巡查员、结果和整改说明。fee_record缴费记录表记录每次缴费的金额和周期。complaint投诉工单表记录投诉内容、状态、处理结果。其中vendor表我这样设计CREATE TABLE vendor ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, vendor_no VARCHAR(32) NOT NULL COMMENT 摊贩编号, name VARCHAR(64) NOT NULL COMMENT 摊贩姓名, id_card VARCHAR(32) DEFAULT NULL COMMENT 身份证号, phone VARCHAR(20) DEFAULT NULL COMMENT 联系电话, biz_type VARCHAR(32) DEFAULT NULL COMMENT 经营类型, stall_location VARCHAR(128) DEFAULT NULL COMMENT 摊位位置描述, lat DECIMAL(10,6) DEFAULT NULL COMMENT 纬度, lng DECIMAL(10,6) DEFAULT NULL COMMENT 经度, status TINYINT DEFAULT 1 COMMENT 状态0禁用 1正常, create_time DATETIME DEFAULT NULL, update_time DATETIME DEFAULT NULL, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除, PRIMARY KEY (id), UNIQUE KEY uk_vendor_no (vendor_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT摊贩档案表;这里有两个细节值得注意一是保存经纬度后面巡查人员可以在手机端做“附近摊贩”检查二是所有表都保留deleted字段用逻辑删除而不是物理删除因为街道管理人员会经常误操作逻辑删除还能留痕迹。牌照表的核心字段是证照编号和有效期我建议和vendor分开。因为一个摊贩的经营范围或摊位位置可能会变每次变更还要保留历史记录如果直接改摊贩表历史就丢了。3.2 工程初始化和关键配置创建 Spring Boot 项目时我建议直接手动编辑pom.xml不要完全依赖 IDEA 向导否则版本容易乱。关键依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcom.google.zxing/groupId artifactIdcore/artifactId version3.5.2/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdjavase/artifactId version3.5.2/version /dependency /dependenciesapplication.yml里比较关键的配置是这些server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/vendor_mgmt?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 springdoc: api-docs: path: /v3/api-docs swagger-ui: path: /doc.html接口路径统一带上/api前缀可以避免静态资源和接口冲突。以后部署时如果要加 Nginx 反向代理也只要转发/api一个路径。3.3 代码自动生成但要做取舍MyBatis Plus 的代码生成器能根据数据库表生成实体类、Mapper、Service、Controller这个工具对提高效率很有用。我用的是MyBatisPlusGenerator生成完会做两件事把生成的 Controller 改造成“返回统一结果格式”的写法。给实体类的LocalDateTime字段自动填充create_time和update_time。统一返回格式非常重要不然前端处理总是不一致。我定义了这样一个类Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT fail(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }后面所有 Controller 都返回Result前端就能统一拦截错误。4. 核心功能实现与避坑要点4.1 摊贩建档与二维码牌照生成摊贩登记功能是基础。保存摊贩信息前要做两件事生成唯一摊贩编号以及在审核通过后生成二维码。生成编号时我不喜欢用数据库自增主键直接当编号因为容易暴露总量。更稳的做法是“日期 序号”public String genVendorNo() { String date LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); int seq vendorMapper.selectCount(new LambdaQueryWrapperVendor() .likeRight(Vendor::getVendorNo, date)) 1; return V date String.format(%04d, seq); }二维码生成用 ZXingpublic BufferedImage createQrCode(String content) throws WriterException { int width 300; int height 300; MapEncodeHintType, Object hints new HashMap(); hints.put(EncodeHintType.CHARACTER_SET, UTF-8); hints.put(EncodeHintType.MARGIN, 1); BitMatrix bitMatrix new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints); return MatrixToImageWriter.toBufferedImage(bitMatrix); }二维码内容不要直接拼一个“身份证号”或“手机号”进去。我在实际项目里用的是带签名参数的 URLhttps://manage.example.com/vendor/detail?id1001signmd5(id secret)扫码后前端先校验sign再显示摊贩详情。这样可以防止别人遍历 ID 获取全部摊位数据。如果只是校园级演示不做签名也能跑但要有这个意识。4.2 巡查打卡怎么实现“附近摊贩”巡查队员到现场后需要立刻知道这个摊位和登记的信息是否吻合。我做了两个能力通过摊贩编号或扫描二维码直接查出摊位信息。自动定位后显示“附近 50 米内”的摊贩列表。“附近摊贩”用 MySQL 的经纬度距离计算就能实现不需要引入 GeoHash。核心 SQL 是这样的select idselectNearby resultTypecom.example.entity.Vendor SELECT id, vendor_no, name, stall_location, lat, lng, (6371000 * acos( cos(radians(#{lat})) * cos(radians(lat)) * cos(radians(lng) - radians(#{lng})) sin(radians(#{lat})) * sin(radians(lat)) )) AS distance FROM vendor WHERE status 1 AND deleted 0 HAVING distance lt; #{distance} ORDER BY distance /select这里特别提醒在 MyBatis XML 中小于号必须转义成lt;或者把整段 SQL 包在![CDATA[ ... ]]里否则会报 XML 解析错误。这个坑我刚开始写的时候踩过一次当时排查了很久才搞清楚。如果以后摊贩数量超过几千条这种实时计算会变慢可以换成 Redis GEO 或者提前按网格编码。但现阶段足够用了。巡查记录表建议加一个result字段记录“正常、警告、整改、取缔”四种结果。当结果不是“正常”时表单要弹出整改说明并且关联到摊贩的后续检查计划。4.3 费用收缴和台账统计这个模块很容易被做成“缴费记录增删改查”但其实核心是“欠费计算”。比如一个摊位每月管理费 200 元缴费周期是 2024-01 到 2024-03那系统在 4 月 1 日之后要自动标记为欠费。欠费不能靠扫描全表我在fee_record表里直接设计period_start、period_end两个字段查询时用“当前时间大于period_end且没有对应缴费状态”的条件过滤。更实用的做法是维护一张摊位费用配置表再把“应缴周期”和“实缴记录”做差。我来举个例子摊贩 A 的应缴周期是每月 1 日到月底每月 200 元。缴费台账按月统计时public ListFeeSummaryVO summaryByMonth(String month) { // 查询该月所有应缴摊贩 ListVendor vendors vendorService.list(); // 查询该月已有缴费记录 ListFeeRecord records feeRecordService.list(new LambdaQueryWrapperFeeRecord() .likeRight(FeeRecord::getPeriodStart, month)); // 在内存中做差集得到欠费列表 MapLong, FeeRecord recordMap records.stream() .collect(Collectors.toMap(FeeRecord::getVendorId, Function.identity())); return vendors.stream() .filter(v - !recordMap.containsKey(v.getId())) .map(...) .collect(Collectors.toList()); }摊贩总数少时内存计算完全没问题。如果数量大了再用 SQLLEFT JOIN做。这个模块的关键不是技术而是业务状态展示已经缴费的显示“已缴”未缴的显示“欠费”超过三个月的标红。4.4 投诉工单状态机投诉模块我设计了四个状态PENDING待受理。PROCESSING处理中。DONE已办结。REVISITED已回访。这里最怕的就是状态乱跳比如从“待受理”直接跳到“已办结”。我用了枚举加状态校验public void process(Long complaintId) { Complaint complaint getById(complaintId); if (complaint.getStatus() ! ComplaintStatus.PENDING) { throw new BizException(当前状态不允许受理); } complaint.setStatus(ComplaintStatus.PROCESSING); complaint.setAcceptTime(LocalDateTime.now()); updateById(complaint); }只有创建工单的入口能直接把状态设为PENDING后面的每一个流转都是一个独立方法。这样日志里能清楚看到是谁、在什么时间、把工单状态改成了什么。街道处理投诉时最在意的就是“过程留痕”不要省这一步。5. 常见问题排查实录这个部分我整理了项目开发中真实遇到的高频问题直接按“现象、原因、处理办法”列表展示后面再补充几个典型调试过程。现象可能原因处理办法项目启动报 dataSource 相关错误没引入 MySQL 驱动或spring.datasource配置不对检查pom.xml是否有mysql-connector-java检查数据库地址、账号Redis 启动时连接被拒Redis 服务没启动或端口不是 6379本地先启动redis-server检查 yml 配置接口返回的LocalDateTime是一串数字Jackson 没有正确序列化日期配置spring.jackson.date-format或给字段加JsonFormat前端请求提示跨域后端没有允许跨域添加 CORS 过滤器或CrossOriginMyBatis Plus 分页查不到第二页数据没配置分页插件新建MybatisPlusInterceptor并添加PaginationInnerInterceptor二维码扫码中文乱码二维码内容里的中文没有 URL 编码生成二维码前对参数做URLEncoder.encode部署到服务器后图片无法访问图片上传到了本地磁盘不在服务路径内使用 MinIO 或配置静态资源映射5.1 分页插件没有生效这个非常典型。MyBatis Plus 的分页功能不是加依赖就能用必须显式配置分页插件。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }如果漏掉这段配置Page对象能返回数据但total永远是 0前端分页就乱了。5.2 跨域配置比想象中要早前后端分离开发时前端跑在 8081 端口后端跑在 8080 端口请求一进来就被浏览器拦截。我在项目里加了一个全局 CORS 配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时allowedOrigins(*)会有问题用allowedOriginPatterns(*)才能同时支持携带 Cookie 的跨域请求。这也是个小坑。5.3 Spring Boot 版本兼容性排查如果你启动的是 Spring Boot 3.x 项目引入 MyBatis Plus 要用mybatis-plus-spring-boot3-starter而不是mybatis-plus-boot-starter。我见过很多同学项目里同时引入了两个 starter启动时出现一堆 Bean 冲突。统一依赖版本的办法是使用 Maven 的dependencyManagement或者直接以 Spring Boot 父 pom 版本为准不要手动指定各个组件版本。6. 部署、运维和二次开发的建议6.1 用 Docker 部署 Spring Boot 项目现在部署 Spring Boot 项目最省心的方式是打 jar 包后用 Docker 跑。先执行mvn clean package -DskipTests然后在项目根目录写一个 DockerfileFROM openjdk:8-jre-alpine COPY target/vendor-mgmt.jar /app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app.jar]构建镜像并启动docker build -t vendor-mgmt . docker run -d --name vendor-mgmt -p 8080:8080 \ -e SPRING_DATASOURCE_URLjdbc:mysql://host.docker.internal:3306/vendor_mgmt?useUnicodetruecharacterEncodingutf8 \ -e SPRING_DATASOURCE_USERNAMEroot \ -e SPRING_DATASOURCE_PASSWORDyour_password \ vendor-mgmt这里用host.docker.internal是因为容器内部访问宿主机 MySQL 时不能直接用127.0.0.1。这个问题在本地开发机上最容易遇到部署到服务器后反而简单直接用内网数据库地址即可。6.2 前后端分离后的 Nginx 配置如果前端是 Vue 打包后的静态文件我一般用 Nginx 来统一入口静态文件由 Nginx 处理/api开头的请求反向代理到 Spring Boot。server { listen 80; server_name manage.example.com; location / { root /opt/vendor-web; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里要注意proxy_pass http://127.0.0.1:8080;末尾不要加/否则后端收到的路径会丢掉/api前缀。Spring Boot 里配置了context-path: /api的话最好保持路径原样转发。6.3 后续还能扩展什么这个项目如果后续要往更实用的方向走可以加三个能力摊贩自己的小程序端扫码查看缴费记录、申请变更摊位位置。基于规则的自动预警比如同一摊贩一个月内被投诉超过三次自动推送重点检查任务。报表可视化把巡查覆盖率、按期缴费率、投诉办结率做成图表。这些扩展都建立在最基础的数据模型之上所以现在设计表的时候不要偷懒多留几个通用字段如remark、ext等以后加需求会轻松很多。最后再分享一个我个人的实际体会做这种管理类系统技术永远是次要的真正的难度是“把业务状态理清楚”。Spring Boot 解决的是工程化问题MyBatis Plus 解决的是数据访问效率问题而你能不能把摊贩从登记到投诉归档的完整闭环串起来才决定项目能不能真正落地。开发过程中遇到慢的问题不要急着加缓存先看是不是查询逻辑有问题遇到版本兼容问题不要硬跳版本稳定跑起来的方案就是好方案。
返回列表