1. 项目概述:先弄清楚这是个什么系统
有没有发现,很多“基于Spring Boot的XXX管理系统”最后都做成了给人添麻烦的后台表格?这个“社区智能垃圾管理系统”如果只停在“增删改查、导出报表”,那就把项目做窄了。我做这套系统的第一反应是:它表面是个 Spring Boot 后端项目,实际上是在管理“居民投放、设备上报、分类识别、督导巡检、清运回收、积分激励”这六件事组成的闭环。
我先说一个容易被标题误导的点:这里的“智能”,不一定是让你从零训练一个垃圾分类模型。真实落地时,“智能”主要体现在三个地方:
- 垃圾投放点有摄像头和传感器,能自动识别投入的是哪类垃圾,检测桶是不是快满了;
- 后端拿到设备上报数据后,能自动生成满溢预警和清运工单,而不是等居民投诉;
- 管理者能从投递记录里算出分类正确率、时段高峰、常见误投类型,用数据反向优化宣传和巡检路线。
所以 Spring Boot 在这里的核心角色,不是做图像识别算法,而是做“可信的业务平台”:接收设备侧结果、保证数据不重复、分配积分、生成工单、提供统计接口。想明白这一点,再开始设计表和写接口,思路会顺很多。
1.1 别被“智能”两个字带偏
我做这套系统时最怕的事,是甲方张口就要“人脸识别垃圾桶”“语音提示”“自动称重”“满溢报警”,最后全堆在 PPT 上,结果后台连一条投递记录都查不清楚。真正应该先把稳的,是下面这条业务链路:
- 居民扫码或刷卡,打开对应分类的桶门;
- 居民投入垃圾,设备拍照并做本地识别;
- 设备把识别结果、重量、时间、桶状态一起上报给 Spring Boot 后端;
- 后端校验数据是否合法,生成投递流水,给居民加积分或推送错误提示;
- 垃圾桶满溢时,系统生成清运工单,清运员上门清运;
- 督导员处理识别置信度低或居民反复投错的记录;
- 管理后台按社区、按点位、按分类类型做统计。
这套链路里,1 和 2 是硬件和边缘推理的事,3 到 7 才是我们做 Spring Boot 要负责的。很多项目把精力浪费在人脸识别、语音提示这些花边上,忽略了最核心的“投递记录不丢、不重、不乱”这三个基本要求。
1.2 功能清单不是越多越好
实际我最终确定的功能模块大概是这样:
| 角色 | 核心功能 |
|---|---|
| 居民 | 扫码投放、投递结果查询、积分明细、错误纠正查看 |
| 督导员/巡检员 | 待处理任务列表、现场拍照上传、标记整改结果 |
| 清运人员 | 满溢点位列表、清运工单、完成打卡 |
| 社区管理员 | 数据大屏、用户管理、设备管理、积分规则配置、黑白名单 |
| 平台维护方 | 设备密钥管理、消息重推、系统日志 |
我砍掉了几个看着很酷的功能,比如“邻里排行版”“积分兑换商城”“AI 客服”。原因很简单:第一版要解决的是“垃圾有没有分对、桶有没有满、工单有没有处理”,而不是流量运营。排行榜和商城一旦积分模型没想清楚,日常运维会被刷分问题烦死。
1.3 适合谁参考
如果你正在做 Spring Boot 相关的毕设或小项目,这个标题是很好的练手对象,但它不等于简单 CRUD。它更适合那些已经能写登录注册、分页查询,但想再往前走一步的同学:
- 想了解设备端上传类业务怎么做幂等;
- 想了解定时任务如何避免重复执行;
- 想了解积分账户这类资金敏感数据怎么设计;
- 想了解一个项目从单体起步,如何给后面留扩展位。
下面我会把“为什么这样设计”讲得更细,而不是只给一堆表结构。
2. 总体架构与核心表设计
这个项目里,我选了非常“稳”的一套组合:Spring Boot 2.x + MySQL 8.0 + Redis + RabbitMQ,前端管理端用 Vue,居民端用微信小程序。这不算什么新潮架构,但足够支撑一个几千户居民的小区。
2.1 一次“扔垃圾”动作如何走完整套系统
我用一个最简单的“可回收物投递”来串一下数据流。
用户扫码后,小程序调用/api/user/openDoor,后端先判断这个用户是否在允许名单里,再向设备代理层下发开锁指令。这里的“设备代理层”不是直接控制硬件,而是转发给本地的设备网关。因为垃圾桶设备通常走内网或 NB-IoT,后端不能直接连硬件,所以中间有一层网关更安全。
设备门打开后,居民投放垃圾,顶部摄像头拍两张照片。边缘盒子先跑一次目标检测,判断出“这看起来是塑料瓶”还是“这看起来是剩饭剩菜”。然后设备通过 HTTP 上报给后端,上报内容类似:
{ "requestId": "DEV-20250601-000123", "deviceNo": "RJ-001-02", "bucketNo": 2, "bucketType": 1, "imageUrl": "http://storage.local/images/xxx.jpg", "recognitionType": "plastic_bottle", "confidence": 0.92, "weight": 0.85, "reportTime": "2025-06-01 08:32:10" }注意这个requestId,它是设备端生成的唯一流水号。后端最优先做的事,不是加积分,而是检查这个requestId有没有处理过。如果设备离线时重试上报,同一个requestId会重复到达,数据库唯一索引能直接挡住,从而避免一条投递记录被重复创建。
后端确认这是一条新记录后,会做三件事:更新投递流水、更新桶状态、触发积分计算。接着,如果桶达到满溢阈值,系统还会发一个“满溢预警”消息;如果识别置信度很低,系统会生成一条“人工复核”任务给督导员。
这一步流程理顺之后,后面所有模块都变得很简单:积分、工单、统计,本质都是在“投递流水”这张表上做衍生操作。
2.2 为什么选 Spring Boot + MySQL + Redis
这套组合不是最先进的,但在这个场景下最合适。Spring Boot 的起步依赖、自动配置和事务支持,让我能把多数精力放在业务规则上;MySQL 用来存投递流水、用户、积分这种强一致数据;Redis 用于设备状态缓存、防刷计数、分布式锁和热点数据。
有人可能会问:设备上报那么多,不是应该用 TimescaleDB 或 ClickHouse 吗?一个小区的设备数量通常不超过几十台,每台每天上报几百次,日流水也就是万级。MySQL 完全扛得住,而且查询管理端报表也很方便。真正到十万级日流水时,再做历史分表也不迟。
RabbitMQ 在这个项目里不是必需,但我还是加了。因为设备上报是异步且零散的事件,后端处理完主流程后,还要发通知、写统计、触发工单。如果不做解耦,设备一多,接口响应时间就会被这些旁路逻辑拖慢。使用消息队列后,主链路只做“保存投递记录 + 更新桶状态”,其他消费内容异步处理。
2.3 核心数据库表与索引
我最终留下的核心表不算多,但每张表都踩过不少坑。设计时最重要的原则是:状态变化要留痕,金额/积分变化要可追溯。
第一张表是用户:
CREATE TABLE `t_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `open_id` VARCHAR(64) NOT NULL, `phone` VARCHAR(20), `real_name` VARCHAR(32), `community_id` BIGINT NOT NULL, `points_balance` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL, UNIQUE KEY `uk_open_id` (`open_id`) ) ENGINE=InnoDB;第二张是设备表。设备表要区分“硬件设备”和“桶位”。一个设备可能管多个投放口,比如四个桶,所以我把桶位单独拆出来。
CREATE TABLE `t_device` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `device_no` VARCHAR(32) NOT NULL, `community_id` BIGINT NOT NULL, `location_name` VARCHAR(64), `longitude` DECIMAL(10, 7), `latitude` DECIMAL(10, 7), `secret_key` VARCHAR(64), `online_status` TINYINT DEFAULT 0, `last_online_time` DATETIME, UNIQUE KEY `uk_device_no` (`device_no`) ) ENGINE=InnoDB;第三张是投递记录,这是全系统最核心的表。它保存每一次投放的设备侧数据和最终分类结果。
CREATE TABLE `t_delivery_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `request_id` VARCHAR(64) NOT NULL, `user_id` BIGINT NOT NULL, `device_no` VARCHAR(32) NOT NULL, `bucket_no` INT NOT NULL, `bucket_type` TINYINT NOT NULL COMMENT '1可回收 2厨余 3其他 4有害', `image_url` VARCHAR(255), `recognition_type` VARCHAR(32), `confidence` DECIMAL(5, 4), `final_type` TINYINT COMMENT '人工复核后的最终分类', `weight` DECIMAL(6, 2), `put_at` DATETIME NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL, UNIQUE KEY `uk_request_id` (`request_id`), KEY `idx_user_put_at` (`user_id`, `put_at`) ) ENGINE=InnoDB;后面还有积分流水表、工单表、巡检表,但核心逻辑都在用户、设备、投递记录这三张表上。索引设计上,我特别克制:
- 投递记录上加
request_id唯一索引,用在幂等判断; - 加
(user_id, put_at)联合索引,用在“用户投递历史”查询; - 不在所有字段上加索引,因为上报表是写多读少,索引太多会拖慢写入速度。
2.4 角色与权限边界
这套系统里权限并不复杂,但最容易搞混的是“居民能看到什么”和“管理员能看到什么”。居民端不需要看到其他居民的名字,不需要知道清运人员是谁;管理员端要能看投诉记录、设备状态、积分流水。
我用 Spring Security + 自定义注解做了一套很轻量的鉴权,把用户分成居民、督导员、清运员、管理员四种角色。接口上的权限控制主要靠@PreAuthorize:
@PreAuthorize("hasAnyRole('ADMIN', 'INSPECTOR')") @GetMapping("/inspection/tasks") public Result<List<InspectionTaskVO>> listTasks() { // ... }真正要注意的不是角色枚举,而是“数据范围”。比如督导员只能看到他所在社区的任务,不能看到别的社区。所以我所有查询都强制拼接community_id,而不是单纯靠前端传参过滤。这一步在一开始没做好,后面权限漏洞会很难补。
3. 智能识别与业务规则实现
标题里有“智能”,所以这一章专门讲设备识别结果、满溢检测、积分规则和工单生成。业务规则不写清楚,系统就是一堆没法解释的按钮。
3.1 设备上报与AI识别结果怎么接入
AI 识别不是我们后端训练出来的,而是边缘侧一个推理服务提供的。设备传上来的recognition_type和confidence就是它的结论。后端要做的,是接受这个结论,并判断要不要人工复核。
我定的规则是:
confidence >= 0.85:直接采用识别结果作为最终分类;0.60 <= confidence < 0.85:标记为“待复核”,推送给督导员;confidence < 0.60:标记为“无法识别”,同时给居民推送一条“请重新分类投放”的提示。
设备上报接口的验签也特别重要。如果在公网上部署,随便构造一个 HTTP 请求就能刷积分,这是相当常见的事故。我用的方案是:设备端用secretKey对报文做 HMAC-SHA256 签名,后端在拦截器里校验时间戳和签名。放一个精简版代码:
public boolean verifySignature(DeviceReportDTO dto) { String payload = dto.getRequestId() + dto.getDeviceNo() + dto.getReportTime(); String expected = HmacUtils.hmacSha256Hex(device.getSecretKey(), payload); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), dto.getSign().getBytes(StandardCharsets.UTF_8) ); }这里一定要用MessageDigest.isEqual做摘要比较,直接equals会存在时间侧信道问题,虽然在小项目里风险不高,但习惯要养好。
3.2 满溢检测:不要把状态更新写成无脑覆盖
满溢检测看起来很简单——设备上报一个“当前高度”或“当前重量”,超过阈值就变满。但真实设备数据并不是理想曲线。桶盖没关、居民手扶着桶边、阴雨天气、设备离线重传,都会导致异常数据。
我最初的做法是收到一条上报就直接更新桶状态,结果一天能收到几十条误报,清运员骂街。后来改成“滑动窗口确认”:
- 每条上报先把“传感器读数”存到临时表或 Redis;
- 统计最近 10 分钟内连续 3 次上报都超过阈值,才把桶状态置为“已满”;
- 桶状态从“已满”变回“正常”,也需要连续 2 次低读数确认。
也就是说,后端不是直接用上报值覆盖状态,而是把上报值当成“证据”。
boolean shouldFull = fullDetector.shouldMarkFull(deviceNo, bucketNo); if (shouldFull && bucket.getStatus() != BucketStatus.FULL) { bucket.setStatus(BucketStatus.FULL); bucketMapper.updateById(bucket); collectionNotifyService.notifyFull(bucket); }这个细节看着小,但直接决定了清运员每天是“按指令出车”还是“来回跑空车”。满溢检测的准确性,才是智能管理的可信度来源。
3.3 积分激励与防刷:先想清楚用户体验
积分规则我设为可配置。后台管理员可以设置:正确投放 +10 分,错误投放 +1 分鼓励分,每日有效计分上限 3 次。为什么不鼓励多投?因为垃圾管理的目标是“分对”,不是“多扔”。如果按投放次数无限加分,反而会助长多制造垃圾。
积分发放的坑主要在防刷。设备上报接口只要验签通过,就能触发积分。但有些居民会在垃圾桶前反复投递小物件来凑积分。我的处理是加了多层限制:
- 同一用户每天最多累计 3 次有效计分;
- 同一设备同一桶位、同一用户,10 分钟内的重复投放只计一次;
- 使用 Redis 计数器做小时级限流:同一用户每小时投递次数超过 5 次,只记录不积分。
积分账户的更新不能用先查询再更新,因为并发情况下会丢更新。我会在事务里直接做条件更新:
UPDATE t_user SET points_balance = points_balance + #{delta} WHERE id = #{userId} AND (points_balance + #{delta}) >= 0;这条 SQL 的意义是:把余额检查和变更放到一次原子操作里。如果返回的影响行数是 0,说明积分不够扣,直接抛业务异常,事务回滚。所有积分变动同时要写一条流水到t_integral_ledger,方便以后对账。
3.4 巡检任务和清运工单生成
巡检任务有两个来源:
- 每天凌晨按巡检规则批量生成,比如每个投放点每天至少检查一次;
- 实时生成,比如识别置信度过低、居民被连续纠正多次、设备离线超过 24 小时。
清运工单来源也很清晰:桶状态为已满,或到达固定清运周期。生成工单时,我不是一台一台生成,而是把同一片区内所有“已满”桶聚合到一张工单里,再按位置排好顺序。
位置排序我一开始想得很复杂,又是图算法又是动态规划。后来发现对一个小区来说,投放点之间距离很近,最值得做的是“按片区聚合,再按经纬度计算顺序”。我写了一个基于球面距离的排序函数,实际效果已经够用。
工单状态机是:待清运 -> 清运中 -> 已完成。清运员到达后点击“开始”,清运后点击“完成”,并上传照片。这个操作最好放到小程序或 App 里,因为 Web 后台在现场不方便用。
4. 关键代码和服务实现拆解
前面讲完了设计,这一章讲实现。我从实际项目中挑出四个最关键的服务代码来拆解。
4.1 投递接口的幂等与事务处理
设备上报接口是整套系统的入口,我把它设计成“先幂等,再处理”。上报接口大致是这样:
@PostMapping("/devices/report") public Result<Long> report(@RequestBody @Valid DeviceReportDTO dto) { if (!deviceAuth.verify(dto)) { return Result.fail("设备签名校验失败"); } Long recordId = deliveryService.report(dto); return Result.ok(recordId); }deliveryService.report里有几个关键点:
@Transactional(rollbackFor = Exception.class) public Long report(DeviceReportDTO dto) { Long existId = deliveryRecordMapper.selectIdByRequestId(dto.getRequestId()); if (existId != null) { return existId; } DeliveryRecord record = new DeliveryRecord(); // ... 字段赋值 deliveryRecordMapper.insert(record); bucketService.updateSensorInfo(record); integralService.grantForDelivery(record); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { if (record.getConfidence() < 0.85) { mqTemplate.convertAndSend("exchange.task", "task.review", record.getId()); } if (record.isFull()) { mqTemplate.convertAndSend("exchange.task", "task.collection", record.getId()); } } }); return record.getId(); }这里最值得学的习惯是:事务提交成功后再发消息。如果把消息发送放在事务内,事务回滚后消息可能已经发出去,消费者读到一条不存在的记录,会产生脏任务。用afterCommit可以避免这个问题。
4.2 积分账户的扣减与并发
积分账户是典型的“多读少写但必须强一致”的数据。我做得比较笨但很可靠:用数据库条件更新代替分布式锁。
给用户加分:
@Transactional(rollbackFor = Exception.class) public void grantForDelivery(DeliveryRecord record) { int delta = ruleService.getPoints(record); if (delta <= 0) { return; } int updated = userMapper.increaseBalanceWithCheck(record.getUserId(), delta); if (updated == 0) { throw new BizException("积分账户更新失败"); } IntegralLedger ledger = new IntegralLedger(); ledger.setUserId(record.getUserId()); ledger.setChangeType("DELIVERY"); ledger.setAmount(delta); ledger.setBizId(record.getId()); ledger.setBalanceAfter(userMapper.getBalance(record.getUserId())); integralLedgerMapper.insert(ledger); }扣分场景类似,比如管理员责令整改扣 20 分。扣分时increaseBalanceWithCheck的 SQL 会带上AND points_balance + #{delta} >= 0,这样负数不会出现。
有人坚持要用 Redis 分布式锁来防并发,我也试过。但积分类业务最后还是要落库,数据库条件更新本身已经保证了原子性,锁反而把问题复杂化。只有跨表复杂事务才建议引入锁。
4.3 管理端统计大屏的数据计算
管理端的大屏无非是几个数字:今日投递次数、今日分类正确率、满溢桶数量、各类型垃圾占比。如果每次刷新都COUNT(*)一次,数据量稍微上来就会卡。
我的做法是“分层统计”:
- 投递发生时,把
t_delivery_record的增量更新到小时表t_stat_hourly; - 凌晨定时任务把小时表聚合成天表
t_stat_daily; - 大屏只查
t_stat_daily和t_stat_hourly。
真正要跑明细查询的场景是“管理员想看某个人最近 7 天的投递记录”,那才走线上表并加 limit。大屏上的数字允许有最多几分钟的延迟,不需要实时到秒。
聚合统计核心 SQL 大概是:
SELECT DATE_FORMAT(put_at, '%Y-%m-%d') AS day, SUM(CASE WHEN final_type = bucket_type THEN 1 ELSE 0 END) / COUNT(*) AS correct_rate, COUNT(*) AS total_count FROM t_delivery_record WHERE put_at >= #{startTime} GROUP BY day ORDER BY day;这种方法听起来不高级,但非常稳。我见过很多团队把统计大屏做出实时数仓,最后维护成本远超收益。
4.4 定时任务在集群下的重复执行问题
项目部署成多实例后,Spring Boot 自带的@Scheduled会在每个实例上都执行一遍。如果定时任务只是“清理过期日志”还好,但如果是“生成清运工单”,重复执行会生成两批工单。
我的方案是 Redis 分布式锁。每个节点执行定时任务前,先尝试设置一个带过期时间的 key:
String lockKey = "job:generate:collection:" + LocalDate.now(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(locked)) { log.info("工单生成任务已被其他节点执行,本次跳过"); return; } try { generateCollectionOrder(); } finally { redisTemplate.delete(lockKey); }这里有个细节:如果任务执行时间超过锁的过期时间,第二次执行会拿到锁,导致重复。所以锁过期时间要设得比任务最大执行时间更长,并且任务最好拆成小批量执行,避免单次跑太久。更稳妥的方案是引入分布式任务调度组件,但对于这种单类定时任务,Redis 锁完全够用。
5. 部署、性能与稳定性细节
项目能跑起来和能稳定跑下去是两码事。这套系统涉及设备端上报,稳定性要求比普通管理系统高。
5.1 单体起步的部署方式
第一版部署我建议直接一台 Spring Boot + MySQL + Redis。用一个docker-compose.yml把 MySQL、Redis、RabbitMQ 拉起来,Spring Boot 打成一个 jar 跑在宿主机上。初期用户量小,不需要上 K8s。
生产环境要区分配置:
spring: datasource: url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} data: redis: host: ${REDIS_HOST} port: ${REDIS_PORT}环境变量不要在配置文件里写死,否则一换环境就要重新打包。我吃过这个亏:开发环境连的是本机 MySQL,生产环境连的是另一台机器,因为配置写死,上线时才现改,结果手滑改漏了端口,排查了半天。
5.2 缓存、消息队列和削峰
设备上报集中在早晚高峰,比如早上 7 点到 9 点。这个时段如果清运工单、积分通知、统计更新全部同步处理,接口 P99 会明显变差。所以我用 RabbitMQ 做削峰。
主服务收到上报后,只做三件最要紧的事:
- 幂等校验;
- 插入投递记录;
- 更新桶状态。
然后发一条消息到delivery.report.queue,由另一个消费者服务去处理积分发放、通知推送、统计更新。这样做的好处是主链路响应很快,消息堆积也不怕,消费者慢慢消费即可。
消费者要做好失败重试。我把 RabbitMQ 的消费者设为手动 ack,业务处理成功才确认消息。如果异常,重试三次后进入死信队列,方便人工排查。否则一条坏消息会把整个消费队列堵住。
5.3 日志与接口安全
设备上报接口的日志是排查问题的关键。我给每个请求都生成了一个traceId,并放到 MDC 里:
String traceId = UUID.randomUUID().toString().replace("-", ""); MDC.put("traceId", traceId);日志配置里把traceId打印出来,后面查“某台设备某次上报为什么没有积分”,直接按 traceId 搜日志,比翻数据库快得多。
接口安全除了验签,还要做访问限流。我基于 Redis 对设备号做了单机限流,比如同一设备每秒最多上报 10 次。防止设备端程序 bug 导致突发流量打垮后端。
6. 常见问题与排查实录
这个项目做完后,我总结了几类最常见的问题。这些问题不是从书本上抄来的,是运行过程中真实出现过的,每一项都可以直接抄答案。
6.1 重复积分:看起来是Redis问题,其实是业务幂等没做
现象:居民反馈投一次垃圾加了两次积分;后台查流水,同一条requestId出现了两条记录。
原因:设备侧断网重试。设备上报超时后,业务端其实已经把记录写入了,但设备没收到响应,于是又发了一次。如果后端只看是否查到记录,不加唯一索引,就会重复插入。
排查过程:先打开投递记录表,按request_id分组,发现有 ID 不同但request_id相同的记录。然后检查接口日志,确认同一时间点收到两次上报。最终处理方式是给request_id加唯一索引,并在插入前先查一次。这个唯一索引才是最后一道兜底。
6.2 满溢状态误报:设备数据也要“滤波”
现象:清运员到达现场后,桶只有三分之一,根本不满。管理端设备状态却是“已满”。
原因:设备传感器读到了瞬时噪声,比如居民掀桶盖时盖子挡了一下传感器,或者雨水附着在探头表面。后端收到一次超阈值上报就直接改状态,没有做连续性判断。
处理:改成滑动窗口确认,最近 10 分钟内至少 3 次超阈值才标记为满。同时在前端显示“最近上报时间”,如果设备长时间没上报,状态自动置为“离线”,防止用旧数据误导调度。
6.3 分配路线不合理:先解决“桶是否真的满了”
现象:系统生成工单要求清运员去 5 个点位,结果其中 2 个实际是空的,清运员觉得自己被系统耍了。
原因:本质上不是路线算法的问题,是“桶满”这一状态本身就不准确。路线分配再怎么优化,喂进去的数据是脏的,出来也是错的。把满溢判断改稳定后,路线问题基本消失。
所以我建议做“智能调度”之前,先花时间保证“状态数据可信”。数据不准,算法再高级都是空中楼阁。
6.4 一个我没想清楚的坑:图片上传直接进了业务库
我最初图省事,把设备上报的图片转成 Base64 直接存到t_delivery_record.image_url这个字段里。后来发现这个字段越来越大,备份一次要十分钟,查询也变慢。
处理:图片只存对象存储路径,数据库字段只存 URL。我切到本地对象存储服务后,把历史图片做了一次迁移,删除掉数据库里的 Base64 大字段。这里得到的教训是:数据库存状态,存关系,存金额,但不要存大文件内容。
7. 复盘与后续扩展
项目做到这里,已经不只是“管理系统”了。它更像一个轻量物联网平台的雏形。我最后说一下做完之后的一些感受。
7.1 做得比较顺手的地方
让我最满意的是业务边界的划分。我没有去纠结“智能”这个词,而是把能力拆成“设备提供识别结果,后端处理业务动作”。这种分层让后端代码很干净,识别模型升级时,后端接口完全不用改。
幂等设计和积分账本设计也给我后面省了很多事。投递记录有唯一索引,积分流水可追溯,这些基础规范看着不起眼,但在出问题时能救命。
7.2 如果可以再来一次,我会调整什么
第一,我会在一开始就引入多租户字段。哪怕第一版只有一个社区,也会给t_user、t_device、t_delivery_record都加上community_id。后边扩展到第二个社区时,不需要改表和迁移数据。
第二,设备注册流程会做得更完整。第一版我把设备信息直接写死在数据库,没有设备上线自动注册的概念。设备换一台维修机,就要手动配device_no和secretKey,很麻烦。
第三,我会早一点写好接口文档。Spring Boot 后端千万别等到联调前才补文档,设备端是外部团队对接的,他们需要稳定的接口约定。我中间因为“字段含义理解不一致”改了好几次参数。
7.3 后续还能往哪扩展
如果继续做下去,最自然的扩展方向是:
- 设备端从 HTTP 改成 MQTT 上报,断线重连和消息推送更合适;
- 增加预约上门回收,让居民可以在小程序下单,清运员接单;
- 把积分跟个人环保档案绑定,做长期环保行为的可视化;
- 增加可回收物的“按重量计价”模式,让居民投放饮料瓶、纸箱时获得更直接的反馈。
这里面每一步其实都可以独立成一个小项目。但从这一版系统的经验来说,最应该保住的,是“投递记录可信、桶状态可信、积分账本可信”。只要这三根柱子稳了,后面往上盖多少层功能都不怕。
我个人实际感受是,这类系统做久了之后,最值钱的不是写代码,而是能看懂一个小区里“垃圾从产生到清运”的完整过程。把这个过程中的每个坑都解决掉,比堆一堆炫酷技术更有成就感。