本项目专栏:
建议先看这期:
物流项目第九期(MongoDB的应用之作业范围)-CSDN博客
业务需求
快递员取件成功后,需要将订单转成运单,用户会比较关注目前包裹走到哪里,类似这样:
需求描述
轨迹微服务是一个独立的微服务,主要提供创建运单轨迹点、记录最新位置、查询轨迹这些服务,具体要求如下:
- 运单创建成功后,为运单创建轨迹点数据,并且将此数据持久化,以供后续的查询
- 司机端在运输途中,需要上报最新的位置,更新相应的运单的最新位置数据
- 快递员端在运输途中,需要上报最新的位置,更新相应的运单的最新位置数据
- 提供查询轨迹的接口服务
- 为查询轨迹接口服务设置缓存
- 用户签收后,需要关闭轨迹的更新,不再更新最新位置数据
业务流程
MQListener
@Slf4j
@Component
public class MQListener {
@Resource
private TrackService trackService;
/**
* 创建运单后创建轨迹
*
* @param msg 消息
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = Constants.MQ.Queues.TRACK_TRANSPORT_ORDER_CREATED),
exchange = @Exchange(name = Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED, type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED),
key = Constants.MQ.RoutingKeys.TRANSPORT_ORDER_CREATE
))
public void listenTransportOrderCreatedMsg(String msg) {
log.info("接收到新增运单的消息 ({})-> {}", Constants.MQ.Queues.TRACK_TRANSPORT_ORDER_CREATED, msg);
TransportOrderMsg transportOrderMsg = JSONUtil.toBean(msg, TransportOrderMsg.class);
this.trackService.create(transportOrderMsg.getId());
}
/**
* 运单签收后完成轨迹
*
* @param msg 消息
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = Constants.MQ.Queues.TRACK_TRANSPORT_ORDER_UPDATE_STATUS),
exchange = @Exchange(name = Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED, type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED),
key = Constants.MQ.RoutingKeys.TRANSPORT_ORDER_UPDATE_STATUS_PREFIX + "RECEIVED"
))
public void listenTransportOrderUpdateStatusMsg(String msg) {
log.info("接收到更新运单状态的消息 ({})-> {}", Constants.MQ.Queues.TRACK_TRANSPORT_ORDER_UPDATE_STATUS, msg);
TransportOrderStatusMsg transportOrderStatusMsg = JSONUtil.toBean(msg, TransportOrderStatusMsg.class);
this.trackService.complete(transportOrderStatusMsg.getIdList());
}
}
util
public class TrackEntityUtil {
/**
* 将 TrackEntity 转换为 TrackDTO
* 主要用于接口返回或业务层使用,脱敏敏感字段并格式化数据结构
*
* @param trackEntity 原始实体对象(来自数据库)
* @return 转换后的 DTO 对象(用于返回给前端或其他服务)
*/
public static TrackDTO toDTO(TrackEntity trackEntity) {
// 创建 CopyOptions 配置项,指定忽略某些字段的复制
// 这里忽略了 planGeoJsonLine 和 lastPoint 字段,因为这两个字段是 GeoJSON 类型,不适合直接暴露给外部
// ignoreNullValue 表示不复制 null 值属性,避免空值污染 DTO
CopyOptions copyOptions = CopyOptions.create()
.setIgnoreProperties("planGeoJsonLine", "lastPoint")
.ignoreNullValue();
// 使用 Hutool 的 BeanUtil.toBean 方法进行基础字段拷贝
// 会自动将 trackEntity 中非忽略字段复制到 TrackDTO 中
TrackDTO trackDTO = BeanUtil.toBean(trackEntity, TrackDTO.class, copyOptions);
// 处理计划轨迹点(planGeoJsonLine):这是一个 GeoJsonLineString 类型的地理轨迹线
GeoJsonLineString planGeoJsonLine = trackEntity.getPlanGeoJsonLine();
if (ObjectUtil.isAllNotEmpty(planGeoJsonLine, planGeoJsonLine.getCoordinates())) {
// 如果轨迹线及其坐标列表存在且非空,则进行转换
// 将 GeoJsonLineString 中的坐标点列表转换为 MarkerPointDTO 列表
List<MarkerPointDTO> coordinateList = planGeoJsonLine.getCoordinates().stream()
// 每个 Coordinate 点转成 MarkerPointDTO(只包含 x 和 y 坐标)
.map(point -> new MarkerPointDTO(point.getX(), point.getY()))
// 收集为 List<MarkerPointDTO>
.collect(Collectors.toList());
// 设置到 DTO 的 pointList 字段中,供前端展示使用
trackDTO.setPointList(coordinateList);
}
// 处理最新位置坐标(lastPoint):是一个 GeoJsonPoint 类型的点
if (ObjectUtil.isNotEmpty(trackEntity.getLastPoint())) {
// 如果 lastPoint 存在
// 获取该点的经纬度信息
GeoJsonPoint point = trackEntity.getLastPoint();
// 转换成 MarkerPointDTO 并设置到 DTO 中
trackDTO.setLastPoint(new MarkerPointDTO(point.getX(), point.getY()));
}
// 返回最终处理好的 DTO 对象
return trackDTO;
}
}
entity
/**
* 轨迹数据
*
* @author zzj
* @version 1.0
*/
@Data
@Document("sl_track")
public class TrackEntity {
@Id
@JsonIgnore
private ObjectId id;
/**
* 运单id
*/
@Indexed
private String transportOrderId;
/**
* 规划的轨迹坐标点线(通过地图服务商规划出来的轨迹点)
*/
private GeoJsonLineString planGeoJsonLine;
/**
* 距离,单位:米
*/
private Double distance;
/**
* 最新的位置坐标,x:经度,y:纬度
*/
private GeoJsonPoint lastPoint;
/**
* 状态
*/
private TrackStatusEnum status;
/**
* 类型
*/
private TrackTypeEnum type;
/**
* 创建时间
*/
private Long created;
/**
* 更新时间
*/
private Long updated;
}
enums
/**
* 异常枚举
*
* @author zzj
* @version 1.0
*/
public enum TrackExceptionEnum implements BaseExceptionEnum {
TRACK_ALREADY_EXISTS(1001, "轨迹已经存在");
private Integer code;
private Integer status;
private String value;
TrackExceptionEnum(Integer code, String value) {
this.code = code;
this.value = value;
this.status = 500;
}
TrackExceptionEnum(Integer code, Integer status, String value) {
this.code = code;
this.value = value;
this.status = status;
}
@Override
public Integer getCode() {
return this.code;
}
@Override
public String getValue() {
return this.value;
}
@Override
public Integer getStatus() {
return this.status;
}
public static TrackExceptionEnum codeOf(Integer code) {
return EnumUtil.getBy(TrackExceptionEnum::getCode, code);
}
}
controller
@RestController
@Api(tags = "轨迹管理")
@RequestMapping("track")
public class TrackController {
@Resource
private TrackService trackService;
@PostMapping
@ApiOperation(value = "创建轨迹", notes = "创建轨迹,会完成路线规划")
@ApiImplicitParams({
@ApiImplicitParam(name = "transportOrderId", value = "运单号", required = true)
})
public boolean create(@RequestParam("transportOrderId") String transportOrderId) {
return this.trackService.create(transportOrderId);
}
@PutMapping("complete")
@ApiOperation(value = "完成轨迹", notes = " 完成轨迹,修改为完成状态")
@ApiImplicitParams({
@ApiImplicitParam(name = "transportOrderIds", value = "运单号列表", required = true)
})
public boolean complete(@RequestParam("transportOrderIds") List<String> transportOrderIds) {
return this.trackService.complete(transportOrderIds);
}
/**
* 通过运单号查询轨迹
*
* @param transportOrderId 运单号
* @return 轨迹数据
*/
@GetMapping("{transportOrderId}")
@ApiOperation(value = "查询轨迹", notes = "通过运单号查询轨迹")
@ApiImplicitParams({
@ApiImplicitParam(name = "transportOrderId", value = "运单号", required = true)
})
public TrackDTO queryByTransportOrderId(@PathVariable("transportOrderId") String transportOrderId) {
TrackEntity trackEntity = this.trackService.queryByTransportOrderId(transportOrderId);
return TrackEntityUtil.toDTO(trackEntity);
}
@PutMapping("upload/truck")
@ApiOperation(value = "车辆上报位置", notes = "车辆上报位置")
@ApiImplicitParams({
@ApiImplicitParam(name = "transportTaskId", value = "运输任务id", required = true),
@ApiImplicitParam(name = "lng", value = "经度", required = true),
@ApiImplicitParam(name = "lat", value = "纬度", required = true),
})
public boolean uploadFromTruck(@RequestParam("transportTaskId") Long transportTaskId,
@RequestParam("lng") double lng,
@RequestParam("lat") double lat) {
return this.trackService.uploadFromTruck(transportTaskId, lng, lat);
}
@PutMapping("upload/courier")
@ApiOperation(value = "快递员上报位置", notes = "快递员上报位置")
@ApiImplicitParams({
@ApiImplicitParam(name = "transportOrderIds", value = "运单号列表", required = true),
@ApiImplicitParam(name = "lng", value = "经度", required = true),
@ApiImplicitParam(name = "lat", value = "纬度", required = true),
})
public boolean uploadFromCourier(@RequestParam("transportOrderIds") List<String> transportOrderIds,
@RequestParam("lng") double lng,
@RequestParam("lat") double lat) {
return this.trackService.uploadFromCourier(transportOrderIds, lng, lat);
}
}
service
/**
* 轨迹服务
*
* @author zzj
* @version 1.0
*/
public interface TrackService {
/**
* 创建轨迹,会完成路线规划
*
* @param transportOrderId 运单号
* @return 是否成功
*/
boolean create(String transportOrderId);
/**
* 完成轨迹,修改为完成状态
*
* @param transportOrderIds 运单号列表
* @return 是否成功
*/
boolean complete(List<String> transportOrderIds);
/**
* 通过运单号查询轨迹
*
* @param transportOrderId 运单号
* @return 轨迹数据
*/
TrackEntity queryByTransportOrderId(String transportOrderId);
/**
* 车辆上报位置
*
* @param transportTaskId 运输任务id
* @param lng 经度
* @param lat 纬度
* @return 是否成功
*/
boolean uploadFromTruck(Long transportTaskId, double lng, double lat);
/**
* 快递员上报位置
*
* @param transportOrderIds 运单号列表
* @param lng 经度
* @param lat 纬度
* @return 是否成功
*/
boolean uploadFromCourier(List<String> transportOrderIds, double lng, double lat);
}
实现接口
/**
* TrackServiceImpl 是一个服务实现类,用于处理物流轨迹相关的业务逻辑。
* 包括创建轨迹、更新状态、路径规划、位置上报等功能。
*/
@Service // Spring 注解,表示这是一个服务层 Bean,可被自动注入使用
public class TrackServiceImpl implements TrackService {
/**
* MongoDB 操作模板,用于持久化 TrackEntity 数据
*/
@Resource
private MongoTemplate mongoTemplate;
/**
* 自定义的地图服务模板(如封装了高德地图 API),用于路径规划等地理操作
*/
@Resource
private EagleMapTemplate eagleMapTemplate;
/**
* Feign 客户端,用于调用运输订单微服务接口
*/
@Resource
private TransportOrderFeign transportOrderFeign;
/**
* Feign 客户端,用于调用运输任务微服务接口
*/
@Resource
private TransportTaskFeign transportTaskFeign;
/**
* Feign 客户端,用于调用订单微服务接口
*/
@Resource
private OrderFeign orderFeign;
/**
* Feign 客户端,用于调用组织机构微服务接口
*/
@Resource
private OrganFeign organFeign;
/**
* 创建一个新的物流轨迹记录
*
* @param transportOrderId 运单 ID
* @return 是否创建成功
*/
@Override
public boolean create(String transportOrderId) {
// 构造查询条件:根据 transportOrderId 查询是否已有对应轨迹记录
Query query = Query.query(Criteria.where("transportOrderId").is(transportOrderId));
// 从 MongoDB 中查找是否存在该运单的轨迹信息
TrackEntity trackEntity = mongoTemplate.findOne(query, TrackEntity.class);
// 如果已经存在轨迹数据,则抛出自定义异常 TRACK_ALREADY_EXISTS
if (ObjectUtil.isNotEmpty(trackEntity)) {
throw new SLException(TrackExceptionEnum.TRACK_ALREADY_EXISTS);
}
// 创建新的轨迹实体对象
TrackEntity track = new TrackEntity();
track.setTransportOrderId(transportOrderId); // 设置运单 ID
track.setStatus(TrackStatusEnum.NEW); // 设置初始状态为“新建”
track.setCreated(System.currentTimeMillis()); // 设置创建时间戳
track.setUpdated(track.getCreated()); // 初始更新时间等于创建时间
// 调用方法获取计划路线 GeoJSON 线段,并设置到轨迹实体中
track.setPlanGeoJsonLine(this.queryPlanGeoJsonLineString(transportOrderId, track));
// 将新创建的轨迹实体保存到 MongoDB
this.mongoTemplate.save(track);
// 返回 true 表示创建成功
return true;
}
/**
* 获取计划路线的 GeoJsonLineString(即路径线)
*
* @param transportOrderId 运单 ID
* @param track 当前轨迹实体(用于设置距离等属性)
* @return GeoJsonLineString 表示路径线
*/
private GeoJsonLineString queryPlanGeoJsonLineString(String transportOrderId, TrackEntity track) {
// 通过 Feign 接口获取运输订单详情
TransportOrderDTO transportOrderDTO = transportOrderFeign.findById(transportOrderId);
// 获取运输线路字符串(通常是 JSON 格式的运输节点列表)
String transportLine = transportOrderDTO.getTransportLine();
String wayPoints = null;
// 如果运输线路不为空
if (StrUtil.isNotEmpty(transportLine)) {
// 解析成 JSON 对象
JSONObject transportLineJson = JSONUtil.parseObj(transportLine);
// 获取节点列表 JSONArray
JSONArray nodeList = transportLineJson.getJSONArray("nodeList");
// 遍历每个节点,提取经纬度并格式化为 "lng,lat" 字符串
List<String> pointList = nodeList.stream()
.map(obj -> {
JSONObject json = (JSONObject) obj;
double longitude = json.getDouble("longitude", 0d);
double latitude = json.getDouble("latitude", 0d);
// 如果经纬度为 0,标记为错误点
if (ObjectUtil.equalsAny(0d, longitude, latitude)) {
return "err";
}
// 正常点则格式化为字符串
return StrUtil.format("{},{}", longitude, latitude);
})
// 过滤掉错误点
.filter(o -> ObjectUtil.notEqual(o, "err"))
.collect(Collectors.toList());
// 多个坐标点之间用分号拼接,作为途经点参数
wayPoints = StrUtil.join(";", pointList);
} else {
// 如果没有运输线路信息,则尝试获取当前网点的经纬度作为起点
OrganDTO organDTO = this.organFeign.queryById(transportOrderDTO.getCurrentAgencyId());
if (ObjectUtil.isNotEmpty(organDTO)) {
// 使用网点坐标作为途经点
wayPoints = StrUtil.format("{},{}", organDTO.getLongitude(), organDTO.getLatitude());
} else {
// 理论上不会出现这种情况,如果没有就不设置途经点
}
}
// 获取订单的位置信息(发货地和收货地)
OrderLocationDTO orderLocationDTO = this.orderFeign.findOrderLocationByOrderId(transportOrderDTO.getOrderId());
// 格式化发货地和收货地坐标
CoordinateUtil.Coordinate origin = CoordinateUtil.format(orderLocationDTO.getSendLocation());
CoordinateUtil.Coordinate destination = CoordinateUtil.format(orderLocationDTO.getReceiveLocation());
// 构建请求参数 Map
Map<String, Object> param = MapUtil.<String, Object>builder()
.put(ObjectUtil.isNotEmpty(wayPoints), "waypoints", wayPoints) // 添加途经点(如果有的话)
.put("show_fields", "polyline") // 请求返回 polyline 字段
.build();
// 调用地图服务,获取驾车路线规划结果
String driving = this.eagleMapTemplate.opsForDirection()
.driving(ProviderEnum.AMAP, new Coordinate(origin), new Coordinate(destination), param);
// 如果返回为空,说明调用失败或无结果
if (StrUtil.isEmpty(driving)) {
return null;
}
// 解析返回的 JSON 数据
JSONObject jsonObject = JSONUtil.parseObj(driving);
// 提取总距离
Double distance = Convert.toDouble(jsonObject.getByPath("route.paths[0].distance"), -1d);
track.setDistance(distance); // 设置到轨迹实体中
// 提取步骤列表
JSONArray steps = jsonObject.getByPath("route.paths[0].steps", JSONArray.class);
// 收集所有坐标点
List<Point> points = new ArrayList<>();
// 获取所有 polyline 字段值(每一步的坐标点集合)
List<Object> polyLines = CollUtil.getFieldValues(steps, "polyline");
for (Object polyLine : polyLines) {
// 将 polyline 字符串拆分成多个坐标点
List<GeoJsonPoint> list = StrUtil.split(Convert.toStr(polyLine), ';')
.stream().map(coordinateStr -> {
// 拆分为 lng 和 lat
double[] ds = Convert.convert(double[].class, StrUtil.splitTrim(coordinateStr, ','));
return new GeoJsonPoint(ds[0], ds[1]);
}).collect(Collectors.toList());
// 添加到总坐标点列表中
points.addAll(list);
}
// 返回构造好的 GeoJsonLineString(代表整条路径线)
return new GeoJsonLineString(points);
}
/**
* 批量完成轨迹记录(更新状态为 COMPLETE)
*
* @param transportOrderIds 运单 ID 列表
* @return 是否有记录被修改
*/
@Override
public boolean complete(List<String> transportOrderIds) {
// 构造查询条件:匹配 transportOrderId 在传入列表中的记录,且状态为 NEW
Query query = Query.query(Criteria.where("transportOrderId").in(transportOrderIds));
// 构造更新语句:将 status 更新为 COMPLETE
Update update = Update.update("status", TrackStatusEnum.COMPLETE);
// 执行批量更新
UpdateResult updateResult = this.mongoTemplate.updateMulti(query, update, TrackEntity.class);
// 如果修改了至少一条记录,返回 true
return updateResult.getModifiedCount() > 0;
}
/**
* 根据运单 ID 查询轨迹实体
*
* @param transportOrderId 运单 ID
* @return 查询到的 TrackEntity 实体对象 或 null
*/
@Override
public TrackEntity queryByTransportOrderId(String transportOrderId) {
// 构造查询条件:按 transportOrderId 查询
Query query = Query.query(Criteria.where("transportOrderId").is(transportOrderId));
// 查询唯一匹配的记录
return this.mongoTemplate.findOne(query, TrackEntity.class);
}
/**
* 司机端上报当前位置
*
* @param transportTaskId 运输任务 ID
* @param lng 经度
* @param lat 纬度
* @return 是否上报成功
*/
@Override
public boolean uploadFromTruck(Long transportTaskId, double lng, double lat) {
// 根据运输任务 ID 查询对应的运单 ID 列表
List<String> list = this.transportTaskFeign.queryTransportOrderIdListById(transportTaskId);
// 调用通用上传方法,类型为 DRIVER
return upload(list, lng, lat, TrackTypeEnum.DRIVER);
}
/**
* 快递员端上报当前位置
*
* @param transportOrderIds 运单 ID 列表
* @param lng 经度
* @param lat 纬度
* @return 是否上报成功
*/
@Override
public boolean uploadFromCourier(List<String> transportOrderIds, double lng, double lat) {
// 直接调用通用上传方法,类型为 COURIER
return upload(transportOrderIds, lng, lat, TrackTypeEnum.COURIER);
}
/**
* 通用位置上报逻辑
*
* @param transportOrderIds 运单 ID 列表
* @param lng 经度
* @param lat 纬度
* @param trackTypeEnum 上报类型(DRIVER/COURIER)
* @return 是否更新成功
*/
private boolean upload(List<String> transportOrderIds, double lng, double lat, TrackTypeEnum trackTypeEnum) {
// 如果运单列表为空,直接返回 false
if (CollUtil.isEmpty(transportOrderIds)) {
return false;
}
// 构造查询条件:transportOrderId 在列表中,且状态为 NEW
Query query = Query.query(Criteria.where("transportOrderId")
.in(transportOrderIds)
.and("status").is(TrackStatusEnum.NEW));
// 构造更新内容:更新 lastPoint(最新位置)、type(轨迹类型)、updated(更新时间)
Update update = Update.update("lastPoint", new GeoJsonPoint(lng, lat))
.set("type", trackTypeEnum)
.set("updated", System.currentTimeMillis());
// 执行批量更新
UpdateResult updateResult = this.mongoTemplate.updateMulti(query, update, TrackEntity.class);
// 返回是否成功更新(是否有记录被修改)
return updateResult.getModifiedCount() > 0;
}
}