06-设备管理接口-从分页到心跳
系列:URM Ultra 商品/设备/订单/支付/视觉 业务实战
主角:urm-server 后端 · device 域 · AdminDeviceController + DeviceService
上篇(第 05 篇)我们把Device实体拆透:它是双门智能柜的电子档案,deviceNo是唯一身份证,状态机三态,心跳即脉搏。结尾预告过:下一篇打开AdminDeviceController和DeviceService,看设备增删改查接口,尤其是heartbeat和listLowStock这两个带"坑"的实现。
这一篇就把这两个类摊开。它们既是管理端入口,也是订单扣库存、补货计划生成的地基。
读完你会明白三件事:
- 管理端对设备做了哪些接口,每个接口背后对应
DeviceService的哪个方法; create / heartbeat / bindProduct / deductStock / listLowStock这五个方法各自藏着什么规则与坑;- 为什么"分页"是 CRUD 的标配,而"心跳"和"低库存"却是物联网特有的灵魂。
一、先上接口全景:AdminDeviceController 干了啥
AdminDeviceController(位于com.urm.ultra.device.controller)是所有"管理端操作设备"的 HTTP 入口,统一挂在/admin-api/device下,把请求转交DeviceService处理,结果包成统一Result返回。
先放完整接口清单(与《API 接口文档》一致):
| 方法 | 路径 | 入参 | 对应 Service 方法 | 作用 |
|---|---|---|---|---|
| GET | /admin-api/device/page | pageNum=1, pageSize=10 | page(pageNum, pageSize) | 分页查询设备 |
| GET | /admin-api/device/list | 无 | list() | 设备列表(不分页) |
| GET | /admin-api/device/{id} | 路径 id | get(id) | 设备详情 |
| POST | /admin-api/device | Device 对象 | create(device) | 新增设备 |
| PUT | /admin-api/device/{id} | id + Device 对象 | update(id, device) | 编辑设备 |
| DELETE | /admin-api/device/{id} | 路径 id | delete(id) | 删除设备 |
| GET | /admin-api/device/{deviceId}/products | 路径 deviceId | listDeviceProducts(deviceId) | 货道/商品列表 |
| POST | /admin-api/device/{deviceId}/products | slotNo, productId, capacity?, price?, enabled=1 | bindProduct(...) | 绑定货道商品 |
| PUT | /admin-api/device/{deviceId}/products/{slotNo}/stock | stock | updateStock(...) | 更新货道库存 |
| GET | /admin-api/device/low-stock | threshold=5 | listLowStock(threshold) | 低库存货道 |
注意:路径里{id}是自增主键、{deviceId}同为 id,另有products子路径管"货道"。设备本体与货道商品是两套资源,用子路径区分,是 RESTful 常规写法。
二、分页:CRUD 的"开胃菜"
先看最基础也最易被轻视的page方法(【项目源码】):
// 项目源码:com.urm.ultra.device.service.DeviceServicepublicPageResult<Device>page(intpageNum,intpageSize){PageRequestpageable=PageRequest.of(pageNum-1,pageSize,Sort.by(Sort.Direction.DESC,"id"));Page<Device>page=deviceRepository.findAll(pageable);returnPageResult.of(page,d->d);}三行逻辑藏三个点:
pageNum - 1:前端页码从 1 开始(人类习惯"第 1 页"),Spring Data 分页从 0 开始(“第 0 页”)。所以这里减一换算(PageResult里pageNum又加回,保证前后端对齐)。Sort.by(DESC, "id"):默认按 id 倒序,最新的排最前。PageResult.of(page, d -> d):把Page连同total/list/pageNum/pageSize包成统一分页体返回。
PageResult是common包里的通用分页壳,结构如下:
| 字段 | 类型 | 含义 |
|---|---|---|
| list | List<T> | 当前页数据 |
| total | long | 总条数(用于算总页数) |
| pageNum | int | 当前页码(从 1) |
| pageSize | int | 每页大小 |
list()是不分页全量列表,给下拉框/批量面板用(如补货选设备)。get(id)按主键查详情,查不到抛 404。
查不到直接抛 404,由全局异常处理器转成前端能懂的错误提示,而不是让
null悄悄漏到后面引发空指针。这是"异常即业务信号"的一贯风格。
三、create:新建设备,默认离线
新增设备是管理端常用动作,看create(【项目源码】):
// 项目源码:com.urm.ultra.device.service.DeviceServicepublicDevicecreate(Devicedevice){if(deviceRepository.findByDeviceNo(device.getDeviceNo()).isPresent()){thrownewBusinessException("设备编号已存在");}if(device.getStatus()==null){device.setStatus(DeviceStatus.OFFLINE);}returndeviceRepository.save(device);}两个守卫缺一不可:
- 编号唯一校验:
findByDeviceNo(...).isPresent()先查一遍,存在就抛"设备编号已存在"——业务层兜底,比等数据库抛ConstraintViolationException更友好。 - 默认离线:
status == null时设为OFFLINE。刚录入系统的柜子还没通电联网,凭什么在线?必须等真正上报一次心跳才翻ONLINE。
update(id, source)是"取旧设备 → 覆盖可写字段 → 保存",只改name/model/doorCount/lockCount/cameraCount/network/location/address/firmwareVersion这些硬件档案,不改deviceNo/status/lastHeartbeat——编号不能改、状态归心跳、时间归心跳逻辑。delete(id)直接deleteById。
四、heartbeat:让一台柜子"活过来"
这是物联网设备的命门(【项目源码】):
// 项目源码:com.urm.ultra.device.service.DeviceService/** 设备心跳:更新在线状态与心跳时间。 */publicDeviceheartbeat(StringdeviceNo){Devicedevice=getByNo(deviceNo);device.setStatus(DeviceStatus.ONLINE);device.setLastHeartbeat(LocalDateTime.now());returndeviceRepository.save(device);}它接收deviceNo(业务编号非 id),getByNo查设备后置ONLINE、lastHeartbeat刷当前时间并保存。调用方是VendingMachineAgentController,路径POST /device-api/vending/{deviceNo}/heartbeat:柜子每约 30 秒发一次"我还活着",后端收到即翻ONLINE。
getByNo和get的差别:get(id)用主键、getByNo(deviceNo)用业务编号,查不到都抛 404,只是消息不同。心跳、识别这类"设备自报"场景,固件只认deviceNo,故用getByNo。
五、bindProduct:把商品"塞"进货道
柜子空壳没用,得往货道放商品——bindProduct即干此活(【项目源码】):
// 项目源码:com.urm.ultra.device.service.DeviceServicepublicDeviceProductbindProduct(LongdeviceId,StringslotNo,LongproductId,Integercapacity,BigDecimalprice,Integerenabled){get(deviceId);Productproduct=productRepository.findById(productId).orElseThrow(()->newBusinessException(404,"商品不存在"));DeviceProductdp=deviceProductRepository.findByDeviceIdAndSlotNo(deviceId,slotNo).orElseGet(DeviceProduct::new);dp.setDeviceId(deviceId);dp.setSlotNo(slotNo);dp.setProductId(productId);dp.setCapacity(capacity);dp.setStock(capacity!=null?capacity:0);dp.setPrice(price!=null?price:product.getPrice());dp.setEnabled(enabled!=null?enabled:1);returndeviceProductRepository.save(dp);}逐行看规则:
get(deviceId):先确认设备存在,不存在直接 404。- 商品校验:
productId必须指向真实存在的商品(pms_product),否则 404。 - 存在则覆盖,不存在则新建:
findByDeviceIdAndSlotNo(...).orElseGet(DeviceProduct::new)——同一个deviceId + slotNo再次绑定,会覆盖旧绑定,而不是插重复行。这避免了一个货道出现两条记录的脏数据。 - 库存初始化:
stock = capacity != null ? capacity : 0。绑定时若给了容量capacity,库存直接填满到容量;没给容量就先记 0。 - 价格回落:
price = price != null ? price : product.getPrice()。货道可设"设备端特价"覆盖标准售价,没设就用商品标准价。 - 启用默认 1:
enabled默认 1(启用)。下面第 17 篇讲 Mock 视觉时你会看到,视觉识别只从enabled=1的货道里挑。
updateStock(deviceId, slotNo, stock)是补货上架后手动改库存的入口:按deviceId + slotNo找到货道设新值,找不到抛"货道不存在"。
六、deductStock:订单扣库存的"唯一正门"
这个方法不起眼,却是下单链路的"守门员"(【项目源码】):
// 项目源码:com.urm.ultra.device.service.DeviceService/** 扣减库存(识别出商品后调用)。 */publicvoiddeductStock(LongdeviceId,StringslotNo,intquantity){DeviceProductdp=deviceProductRepository.findByDeviceIdAndSlotNo(deviceId,slotNo).orElseThrow(()->newBusinessException(404,"货道不存在"));intremain=(dp.getStock()==null?0:dp.getStock())-quantity;if(remain<0){thrownewBusinessException("库存不足");}dp.setStock(remain);deviceProductRepository.save(dp);}它做了两层保护:
- 货道必须存在:找不到
deviceId + slotNo组合直接 404。 - 库存守卫:
remain = 当前库存 - 要扣数量,若< 0抛"库存不足"。绝不允许扣成负数——这是库存铁律,否则超卖(用户付钱却没货)。
更重要的:这才是订单真正扣库存的地方。回看第 13 篇createAndPay,扣库存并非订单自己改,而是调deviceService.deductStock(deviceId, slotNo, quantity)。库存这个敏感资源的改法被收口在DeviceService一处,订单域只"告诉它扣多少"。
为什么要收口?库存是下单扣减、补货回补、人工改库存共享的状态。把"怎么扣、扣之前校验什么"集中在一处,才能避免并发下对不上账。
七、listLowStock:低库存的"报警器"
最后一个方法,也是上篇预告过的"带坑"实现。【项目源码】
// 项目源码:com.urm.ultra.device.service.DeviceService/** 低库存货道(供补货计划使用)。 */publicList<DeviceProductView>listLowStock(intthreshold){returndeviceProductRepository.findAll().stream().filter(dp->dp.getStock()!=null&&dp.getStock()<=threshold).map(dp->toView(deviceRepository.findById(dp.getDeviceId()).orElse(null),dp)).toList();}逻辑简单:把dms_device_product全表拉出,用流过滤stock != null 且 <= threshold(默认 5)的货道,转成DeviceProductView返回。
但坑就在这"全表拉出来":
- 没走 SQL
WHERE stock <= ?,而是findAll()全表加载内存过滤;Demo 才 10 条货道无所谓,上万货道就难看。 - 它被
admin-api/device/low-stock直接调用,也是ReplenishService.generate的地基(那篇用Integer.MAX_VALUE当阈值取全部货道再筛)。一处实现,两处复用。
这个"纯内存过滤"是 Demo 的有意简化,《二次开发文档》点明大数据量应下推为findByStockLessThanEqual(threshold)之类——"能跑"和"能扛"是两码事。
toView(...)把DeviceProduct拼上设备编号、商品名、条码,组装成DeviceProductView(含slotNo/productName/barcode/capacity/stock/price/enabled);商品名条码按productId从pms_product取——又一次"货道→标准库"回落,第 18 篇详讲。
八、十个接口,一张调用地图
把本篇方法收成一张"调用地图":page/list/get→ 查;create(编号唯一+默认 OFFLINE)→ 增;update→ 改;delete→ 删;listDeviceProducts→ 货道视图;bindProduct(覆盖/容量/价回落/默认启用);updateStock;listLowStock(内存过滤);设备端心跳 →heartbeat(OFFLINE→ONLINE)。
更重要的是跨域调用:OrderService.createAndPay调deductStock,ReplenishService.generate调listLowStock。DeviceService还被订单域、补货域反向依赖——设备域是整个系统的"地基层"。
九、本篇小结
AdminDeviceController挂在/admin-api/device,提供设备 CRUD + 货道绑定/改库存 + 低库存查询共11 个接口,本质是DeviceService的 HTTP 投影。- 五个核心方法各有门道:
create编号唯一校验、默认OFFLINE;heartbeat翻ONLINE并刷新lastHeartbeat;bindProduct同货道"存在则覆盖"、库存初始化为容量、售价回落标准价、默认启用;deductStock是下单扣库存唯一正门,带"不允许负库存"守卫;listLowStock内存流过滤低库存,是补货地基(大数据量需下推 SQL)。 - "分页"是 CRUD 标配(
pageNum-1+ 倒序 +PageResult);"心跳"与"低库存"才是物联网/零售灵魂,前者管在线、后者管补货。 - 设备域不止服务管理端,还被订单域、补货域反向依赖——它是整个系统的地基。
下一篇离开设备域,跳到支付域的 Mock 实现:不接微信,凭什么也能把"扣款"跑通?MockWechatPayScoreService如何用@ConditionalOnProperty和内存Map演示整条支付闭环(第 15 篇)。