一、参与设计

介绍:我之前做的是一个智能货柜的项目,部署在在商场、小区等场所。该货柜使用的流程是,用户通过扫码确认免密支付后,货柜打开,用户拿完东西后关闭货柜,自动扣减钱


二、核心流程梳理

2. 1 具体购买流程

image-20251107134217733

用户扫码,后端会对参数进行校验,判断用户是不是在黑名单中;校验成功后,通过加分布式锁将订单落库,再请求到支付宝/微信做一个预约下单,授权预约下单后会给前端发送一个签约的package包,用户同意后,我们会收到支付宝/微信的回调,之后会调用设备服务开门,在开门之前会记录所有货仓的重量,用户卖完东西后,再次记录所有货仓的重量,用这两个做差值得出用户买了什么商品;通过MQ发送消息调用账户服务结算,释放掉分布式锁,在账户服务中会涉及到优惠券、积分、余额的处理,同时这个消息会发送给一个触发补货的服务,这个服务会去查看设备是否需要补货,如果需要,通知调度人员去补货

更具体地流程

image-20251107135258460

数据库表

image-20251107153321968

2.2 设备上线

image-20251107140226600

设备服务收到设备登陆服务器的请求,会查看是否有该设备:

  • 如果有该设备:修改设备表的设备状态为在线,更新设备缓存状态,更新设备服务中设备通信信息,更新通信服务中设备、Channel信息
  • 如果没有该设备:添加该设备,修改设备表的设备状态为初始化,添加设备缓存

2.3 心跳/下线流程

最开始的设计是

  • 每个设备每30s向Redis存入一条消息,key:sn码,value:null,过期时间设置为60s
  • 如果key过期了,就判断设备下线

但这存在一个问题:设备可能因为网络波动频繁下线


于是我们又引入了心跳保护机制,为了避免因为网络波动问题,导致的大面积设备下线,造成误判

  • 设备每 30s 发送一次心跳,设备服务器收到设备发来的心跳,对缓存中的心跳计数器+1,存入心跳表,然后更新缓存中设备最后一次收到心跳的时间
  • 开启一个定时任务每一分钟去扫描设备服务最后一次心跳时间,如果最后一次心跳时间距离现在相差一分钟,就会判断是否满足心跳保护机制,决定设备是否下线
  • 心跳保护机制:我们的设备有2500台左右,一分钟正常能收到5000次心跳,我们设置了一个阈值80%,如果一分钟收到的心跳总数小于5000 * 80% = 4000 次,就会认为是服务器故障,不会让设备下线,通知运维人员检查
  • 如果不满足心跳保护机制,将该服务下线,更新数据库设备的状态,更新缓存状态

2. 4 重量

image-20251110103543768

  • 仓门没有打开之前是每60s上传一次重量信息,在仓门打开5s之后,每300ms上传一次重量
  • 发送的数据{大key:重量,小key:sn码 ,值:时间}
  • 服务器接收到数据后,更新缓存,发送MQ,目的是进行削峰,通过MQ再落库
  • 我们还有一个服务式定期删除这些数据,只会保留七天的重量信息

2.5 设备补货、调度流程

image-20251110105942344

  • 每一次设备服务向MQ发送消息结单,对商品的库存扣减的同时,会触发货道是否需要补货
  • 如果达到补货的阈值,会通过飞书通知补货人员前去补货
  • 补货人员先检查设备状态,再通过h5页面扫描二维码调用补货的接口,加分布式锁,创建补货订单并落库,设备服务调用开门,缓存开关门的重量来判断商品数量,增加库存,更新补货订单状态

2.6 对账流程

image-20251110134913575

外部对账

每天第三方支付公司 支付宝 / 微信 都会给我们一个账单,叫做结算账单。结算账单会告诉我们昨天结了多少钱,什么时候到账,到账到哪里,那个支付公司都会给我们。同时支付公司还会给我们一个交易账单,需要我们每一笔都去对,主要是交易订单和系统订单的订单状态,看支付公司和我们数据库记录的状态是否一致;对订单金额对他收取的手续费是否一致


内部对账

每天晚上0点看一次重量,算出库存量,加上白天的补货计算出【库存的减少量】与【订单的商品数】是否一致,如果不对,会调取监控、查看一些重量变更记录

2.7 生成报表流程

报表就是账单

  • 通过线程池 + CountDownLatch,生成不同维度的报表,每个线程处理一个维度,等每一个线程都处理完,再进行汇总,处理接下来的任务

不同的维度

  • 以商品数据
  • 以地区统计数据
  • 不同时间的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ExecutorService pool = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);

pool.submit(() -> {
// ...
latch.countDown();
});

pool.submit(() -> {
// ...
latch.countDown();
});

pool.submit(() -> {
// ...
latch.countDown();
});

latch.await();
// 等待latch都执行完了后,再执行对应的任务

2.8 营销体系

image-20251110141238203

  • 优惠券分类
    • 优惠券大概分为三种,满减券、现金券、团购券;满减券就是达到多少金额就可以减一部分钱;现金券就是无门槛卷;团购券是购买指定商品不需要钱
  • 优惠券的获取方式
    • 我们可以通过活动,秒杀、新人用户、拉新人的方式获取优惠券
  • 优惠券的使用规则
    • 优惠券的使用规则可以简单配置,比如团购券可以一次性多使用几张,而现金券、满减券的配置规则只能一次使用一张

2.9 优惠券秒杀

image-20251110142317867

优惠券的秒杀功能主要使用Redis减少数据库的压力,mq异步消峰处理落库

在秒杀之前,向Redis中加一个set结构的数据做缓存预热,key就是秒杀券id,value就是库存量

前端会采用滑动验证防止用户连续点击,后端采用Redis的decrement命令扣减Redis的库存,返回值如果 $\ge 0$表示抢购成功,同时往Redis的Set集合中加一条用户抢到的记录,限制一人一单,发送mq进行后续业务操作。

2.10 充值流程

image-20251110143009175

2.11 退款流程

image-20251110143133453

一般退款都是因为商品质量问题,或者说拿了别人放错货架的商品,导致扣款扣多了,系统仅支持七天内退款(数据库只会存七天内的重量信息),退款需要客户联系客服,我们后台进行退款

  • 解决超退
    • 一个订单多次退款会有限制:where 条件 已退金额 + 本次退金额 $<$ 支付金额
  • 退优惠券
    • 我们采用的是先退优惠券再退钱
  • 退积分
    • 我们会按照比例扣除积分,如果积分已经被用户使用了,那就只能算给用户的补偿,因为大多数退款都是因为商品的问题

2.12 扫码登陆流程

小程序如何获取用户唯一标识(openid)?

前端组装appid调用微信接口得到jscode,把jscode给后端,后端用jscode请求wx可以得到用户的唯一标识(openid)

支付宝文档

image-20251110144610337

微信文档

image-20251110144644939

2.13 区域价格设置

分散剂来存储商品价格

  • 货柜价格表(一级):货柜sn码——货道id——商品id——价格
  • 区域价格表(二级):区域——商品id——价格
  • 商品价格表(三级):商品——价格

查询价格时,先从一级价格表查询,如果没有该商品的价格的话再去二级商品区间表查询,如果还没有的话,就去查询三级价格表(三级价格表式一定有的)

2.14 MQTT协议

image-20251110145027292

设备服务调用通信服务给设备发指令:

image-20251110145049429

设备给通信服务发送报文:

image-20251110145109942

2.15 设备发送命令不稳定处理

image-20251110145150934

2.16 xxl-job兜底

用户扫码登录确认授权的同时会发送延时消息,一分钟后检查该订单是否授权,还没授权就视为取消订单, mq 延迟消息发失败了怎么办

  • xxl-job 定时任务兜底,看哪些订单长时间未授权,取消掉。

三、数据库

设备表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CREATE TABLE `device_info` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`uid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '设备id',
`device_sn` varchar(64) NOT NULL COMMENT '设备sn',
`device_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '设备code',
`device_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '设备名称',
`device_status` char(1) NOT NULL COMMENT '设备状态 0在线 1离线 2异常 3初始化',
`device_type` char(1) NOT NULL COMMENT '1可耐 2益诺 3mqtt',
`heating_switch` char(1) NOT NULL DEFAULT '1' COMMENT '加热开关0开 1关',
`heating_threshold` int DEFAULT NULL COMMENT '开启加热阈值温度',
`close_heating_threshold` int DEFAULT NULL COMMENT '关闭加热阈值温度',
`province` varchar(32) DEFAULT NULL COMMENT '所在省',
`city` varchar(32) DEFAULT NULL COMMENT '所在市',
`county` varchar(32) DEFAULT NULL COMMENT '所在县',
`community` varchar(32) DEFAULT NULL COMMENT '所在社区',
`address` varchar(255) DEFAULT NULL COMMENT '详细地址',
`device_desc` varchar(255) DEFAULT NULL COMMENT '设备描述',
`lgt_on_time` varchar(255) DEFAULT NULL COMMENT '灯箱开启时间段',
`addr_location_gd` varchar(255) DEFAULT NULL COMMENT '高德经纬度',
`addr_location_tx` varchar(255) DEFAULT NULL COMMENT '腾讯经纬度',
`device_version` varchar(64) DEFAULT NULL COMMENT '设备版本号',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
`update_date` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '删除标记0不删 1删',
`remarks` varchar(255) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='设备详情表';

仓门表

1