ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

设备管理接口-从分页到心跳

设备管理接口-从分页到心跳

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/pagepageNum=1, pageSize=10page(pageNum, pageSize)分页查询设备
GET/admin-api/device/list无list()设备列表(不分页)
GET/admin-api/device/{id}路径 idget(id)设备详情
POST/admin-api/deviceDevice 对象create(device)新增设备
PUT/admin-api/device/{id}id + Device 对象update(id, device)编辑设备
DELETE/admin-api/device/{id}路径 iddelete(id)删除设备
GET/admin-api/device/{deviceId}/products路径 deviceIdlistDeviceProducts(deviceId)货道/商品列表
POST/admin-api/device/{deviceId}/productsslotNo, productId, capacity?, price?, enabled=1bindProduct(...)绑定货道商品
PUT/admin-api/device/{deviceId}/products/{slotNo}/stockstockupdateStock(...)更新货道库存
GET/admin-api/device/low-stockthreshold=5listLowStock(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);}

三行逻辑藏三个点:

  1. pageNum - 1:前端页码从 1 开始(人类习惯"第 1 页"),Spring Data 分页从 0 开始(“第 0 页”)。所以这里减一换算(PageResult里pageNum又加回,保证前后端对齐)。
  2. Sort.by(DESC, "id"):默认按 id 倒序,最新的排最前。
  3. PageResult.of(page, d -> d):把Page连同total/list/pageNum/pageSize包成统一分页体返回。

PageResult是common包里的通用分页壳,结构如下:

字段类型含义
listList<T>当前页数据
totallong总条数(用于算总页数)
pageNumint当前页码(从 1)
pageSizeint每页大小

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);}

逐行看规则:

  1. get(deviceId):先确认设备存在,不存在直接 404。
  2. 商品校验:productId必须指向真实存在的商品(pms_product),否则 404。
  3. 存在则覆盖,不存在则新建:findByDeviceIdAndSlotNo(...).orElseGet(DeviceProduct::new)——同一个deviceId + slotNo再次绑定,会覆盖旧绑定,而不是插重复行。这避免了一个货道出现两条记录的脏数据。
  4. 库存初始化:stock = capacity != null ? capacity : 0。绑定时若给了容量capacity,库存直接填满到容量;没给容量就先记 0。
  5. 价格回落:price = price != null ? price : product.getPrice()。货道可设"设备端特价"覆盖标准售价,没设就用商品标准价。
  6. 启用默认 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返回。

但坑就在这"全表拉出来":

  • 没走 SQLWHERE 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 篇)。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进