无人自助拼豆系统怎么落地?扫码入场、按台计时与物联网柜控的技术拆解

无人自助拼豆,是把拼豆手作这件事做成"无人值守 + 自助取用 + 自动结算"的门店形态:用户到店后自己扫码选工位,系统下发开锁/通电指令,用户自助取用色号豆、拼豆板、镊子、熨烫纸等物料,结束时在小程序确认离场,系统按时长或按用量生成订单并完成结算。要把它跑通,核心不是前端页面,而是"设备实际状态"与"订单状态"的一致性、计费口径的抽象、以及分色号耗材的库存盘点。下面按硬件层、后端层、前端层三个方向拆解一遍可落地的实现路径。

一、业务链路与三个技术难点

先梳理一遍完整链路,避免开发时漏环节:

  1. 用户扫码 → 进入门店页,展示空闲工位;
  2. 选择工位 → 校验工位是否被占用,占用则直接拦截;
  3. 下发开柜/开门指令 → 硬件回执"已开启";
  4. 开始计时(或计件)→ 订单状态置为使用中;
  5. 使用期间自助取用色号豆与工具 → 库存按称重或人工补货方式扣减;
  6. 用户点击"结束使用" → 状态进入待结算;
  7. 结算完成后设备复位 → 锁回位、电源断开、工位释放。

这条链路上容易踩的三个坑:

  • 状态不一致:指令下发了,但锁没真的打开(或打开了没回执),用户刷卡进不去,订单却已经计费。必须有"指令 → 回执 → 落库"的闭环,不能只发不收。
  • 计费口径不统一:有的门店按工位时长,有的按人头,有的把耗材单独拆出来按份数计。如果硬编码在业务逻辑里,改一次规则要动一次代码。
  • 耗材不可见:拼豆是分色号的散装耗材,某一个色号先见底是常见的情况,没有盘点手段就只能靠店员肉眼巡场。

二、硬件与物联网层:柜锁、称重与用电安全

这一层的关键词是"可观测"。设备只接受指令但不反馈状态,后面的对账会非常痛苦。

主控与通信:常见做法是 MCU(ESP32 / STM32)外接 4G Cat.1 模组或 DTU,走 MQTT 接入 EMQX 一类 Broker。协议轻、断线重连成熟,适合门店这种网络环境不稳定的场景。局域网内也可以直接用 WiFi,但要考虑断网降级:本地缓存近一条指令,恢复后补报。

主题设计建议按门店 + 设备两级划分,方便权限隔离和批量订阅:

下行指令:/perler/{storeId}/{deviceId}/cmd
上行上报:/perler/{storeId}/{deviceId}/report

下行指令体带 cmdId,用于幂等:

{
  "cmdId": "8f2c1a9e-3b7d-4c21-9a55-0d6f2e1a7c30",
  "action": "UNLOCK",
  "seatNo": "A03",
  "expireAt": 1730000030000
}

上行回执带状态与功率,便于判断设备是否真的执行:

{
  "cmdId": "8f2c1a9e-3b7d-4c21-9a55-0d6f2e1a7c30",
  "deviceId": "PERLER-A03-01",
  "lockState": "OPENED",
  "powerW": 0,
  "ts": 1730000001200
}

几个容易被忽略的细节:

  • 锁到位信号:电磁锁/电插锁要做开关量回读,只发指令不看回执,等于没有状态。
  • 熨斗用电安全:拼豆熨烫环节要用到发热设备,建议独立回路 + 定时断电 + 电流互感器检测,超时未操作自动断电并生成告警事件。
  • 耗材感知:色号盒可用称重传感器(HX711 方案)做粗粒度盘点,误差大的话退一步——只记录"上次补货时间 + 预估消耗",由店员扫码确认补货,成本更低也更稳。

三、后端领域模型与计费策略设计

后端用 Spring Boot + MyBatis Plus + MySQL 是这类系统的常规组合,重点在表结构和计费抽象。

核心表建议这样划分:

  • store:门店
  • seat:工位(工位号、绑定设备、当前状态)
  • device:设备(类型 LOCK / POWER / SCALE、在线状态、后心跳)
  • biz_order:订单(用户、工位、开始时间、结束时间、状态)
  • biz_order_item:计费明细
  • bean_stock:色号库存
  • billing_rule:计费规则(类型、参数 JSON)

计费用策略模式,规则参数走配置,不写死在代码里:

public interface BillingStrategy {
    /** 返回计费单位数,具体单价与展示由上层处理 */
    BillingItem calculate(BillingContext ctx);
    String ruleType();
}
@Component
public class SeatTimeBillingStrategy implements BillingStrategy {

    @Override
    public BillingItem calculate(BillingContext ctx) {
        long minutes = Duration.between(ctx.getStartTime(), ctx.getEndTime()).toMinutes();
        long unit = ctx.getUnitMinutes();          // 来自 billing_rule 参数
        long units = (minutes + unit - 1) / unit;  // 向上取整,不足一个单位按一个单位
        return BillingItem.ofUnits(units, unit);
    }

    @Override
    public String ruleType() {
        return "SEAT_TIME";
    }
}

工厂负责按规则类型路由,避免 if-else 堆叠:

@Component
public class BillingStrategyFactory {

    private final Map<String, BillingStrategy> registry;

    public BillingStrategyFactory(List<BillingStrategy> strategies) {
        this.registry = strategies.stream()
                .collect(Collectors.toMap(BillingStrategy::ruleType, Function.identity()));
    }

    public BillingStrategy get(String ruleType) {
        BillingStrategy strategy = registry.get(ruleType);
        if (strategy == null) {
            throw new BizException("未配置的计费规则类型:

![配图](https://i-blog.csdnimg.cn/img_convert/5b0e916c103cb4e3462d00679024cd4e.png)

更多推荐