feat(ticket): 添加水票订单配送流程功能

- 在GltTicketOrder实体类中新增配送相关的字段,包括配送状态、配送时间、收货人信息等
- 实现配送员端订单查询接口,支持按配送状态筛选和权限隔离
- 添加配送流程核心接口:接单、开始配送、确认送达、用户确认收货等功能
- 实现配送状态流转的状态机校验和并发安全的原子更新操作
- 优化数据库查询SQL,增加配送状态和租户ID的索引提升查询性能
- 添加配送员身份验证和权限检查机制,确保操作安全性
This commit is contained in:
2026-02-06 22:00:59 +08:00
parent 7191c93b4c
commit 922e7def9d
9 changed files with 551 additions and 4 deletions

View File

@@ -5,15 +5,20 @@ import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.glt.entity.GltTicketOrder;
import com.gxwebsoft.glt.param.GltTicketOrderDeliveredParam;
import com.gxwebsoft.glt.param.GltTicketOrderParam;
import com.gxwebsoft.glt.service.GltTicketOrderService;
import com.gxwebsoft.shop.entity.ShopStoreRider;
import com.gxwebsoft.shop.entity.ShopUserAddress;
import com.gxwebsoft.shop.service.ShopStoreRiderService;
import com.gxwebsoft.shop.service.ShopUserAddressService;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@@ -34,6 +39,8 @@ public class GltTicketOrderController extends BaseController {
private GltTicketOrderService gltTicketOrderService;
@Resource
private ShopUserAddressService shopUserAddressService;
@Resource
private ShopStoreRiderService shopStoreRiderService;
@Operation(summary = "分页查询送水订单")
@GetMapping("/page")
@@ -42,6 +49,30 @@ public class GltTicketOrderController extends BaseController {
return success(gltTicketOrderService.pageRel(param));
}
@PreAuthorize("isAuthenticated()")
@Operation(summary = "配送员端:分页查询我的送水订单")
@GetMapping("/rider/page")
public ApiResult<PageResult<GltTicketOrder>> riderPage(GltTicketOrderParam param) {
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录");
}
Integer tenantId = getTenantId();
param.setTenantId(tenantId);
// 仅允许配送员访问
requireActiveRider(loginUser.getUserId(), tenantId);
param.setRiderId(loginUser.getUserId());
if (param.getDeliveryStatus() == null) {
param.setDeliveryStatus(GltTicketOrderService.DELIVERY_STATUS_WAITING);
}
// 配送员端默认按期望配送时间优先
if (StrUtil.isBlank(param.getSort())) {
param.setSort("sendTime asc, createTime desc");
}
return success(gltTicketOrderService.pageRel(param));
}
@Operation(summary = "查询全部送水订单")
@GetMapping()
public ApiResult<List<GltTicketOrder>> list(GltTicketOrderParam param) {
@@ -92,6 +123,83 @@ public class GltTicketOrderController extends BaseController {
return success("下单成功");
}
@PreAuthorize("isAuthenticated()")
@Operation(summary = "配送员接单")
@PostMapping("/{id}/accept")
public ApiResult<?> accept(@PathVariable("id") Integer id) {
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录");
}
Integer tenantId = getTenantId();
requireActiveRider(loginUser.getUserId(), tenantId);
gltTicketOrderService.accept(id, loginUser.getUserId(), tenantId);
return success("接单成功");
}
@PreAuthorize("isAuthenticated()")
@Operation(summary = "配送员开始配送")
@PostMapping("/{id}/start")
public ApiResult<?> start(@PathVariable("id") Integer id) {
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录");
}
Integer tenantId = getTenantId();
requireActiveRider(loginUser.getUserId(), tenantId);
gltTicketOrderService.start(id, loginUser.getUserId(), tenantId);
return success("开始配送");
}
@PreAuthorize("isAuthenticated()")
@Operation(summary = "配送员确认送达")
@PostMapping("/{id}/delivered")
public ApiResult<?> delivered(@PathVariable("id") Integer id,
@RequestBody(required = false) GltTicketOrderDeliveredParam body) {
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录");
}
Integer tenantId = getTenantId();
requireActiveRider(loginUser.getUserId(), tenantId);
String sendEndImg = body == null ? null : body.getSendEndImg();
gltTicketOrderService.delivered(id, loginUser.getUserId(), tenantId, sendEndImg);
return success("确认送达");
}
@PreAuthorize("isAuthenticated()")
@Operation(summary = "用户确认收货")
@PostMapping("/{id}/confirm-receive")
public ApiResult<?> confirmReceive(@PathVariable("id") Integer id) {
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录");
}
gltTicketOrderService.confirmReceive(id, loginUser.getUserId(), getTenantId());
return success("确认收货成功");
}
private ShopStoreRider requireActiveRider(Integer userId, Integer tenantId) {
if (userId == null) {
throw new BusinessException("请先登录");
}
if (tenantId == null) {
throw new BusinessException("租户信息缺失");
}
ShopStoreRider rider = shopStoreRiderService.getOne(new LambdaQueryWrapper<ShopStoreRider>()
.eq(ShopStoreRider::getUserId, userId)
.eq(ShopStoreRider::getTenantId, tenantId)
.eq(ShopStoreRider::getIsDelete, 0)
.last("limit 1"));
if (rider == null) {
throw new BusinessException("非配送员,无权限操作");
}
if (rider.getStatus() == null || rider.getStatus() != 1) {
throw new BusinessException("配送员已禁用");
}
return rider;
}
private String buildAddressSnapshot(ShopUserAddress addr) {
if (addr == null) {
return null;

View File

@@ -68,6 +68,10 @@ public class GltTicketOrder implements Serializable {
@TableField(exist = false)
private String warehouseAddress;
@Schema(description = "仓库手机号")
@TableField(exist = false)
private String warehousePhone;
@Schema(description = "关联收货地址")
private Integer addressId;
@@ -99,6 +103,27 @@ public class GltTicketOrder implements Serializable {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private String sendTime;
@Schema(description = "配送状态10待配送、20配送中、30待客户确认、40已完成")
private Integer deliveryStatus;
@Schema(description = "开始配送时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime sendStartTime;
@Schema(description = "确认送达时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime sendEndTime;
@Schema(description = "送达拍照留档图片URL")
private String sendEndImg;
@Schema(description = "客户确认收货时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime receiveConfirmTime;
@Schema(description = "确认方式10客户手动确认、20配送照片自动确认、30超时自动确认")
private Integer receiveConfirmType;
@Schema(description = "用户ID")
private Integer userId;
@@ -114,6 +139,50 @@ public class GltTicketOrder implements Serializable {
@TableField(exist = false)
private String avatar;
@Schema(description = "收货人姓名")
@TableField(exist = false)
private String receiverName;
@Schema(description = "收货人手机号")
@TableField(exist = false)
private String receiverPhone;
@Schema(description = "收货省")
@TableField(exist = false)
private String receiverProvince;
@Schema(description = "收货市")
@TableField(exist = false)
private String receiverCity;
@Schema(description = "收货区")
@TableField(exist = false)
private String receiverRegion;
@Schema(description = "收货详细地址")
@TableField(exist = false)
private String receiverAddress;
@Schema(description = "收货完整地址")
@TableField(exist = false)
private String receiverFullAddress;
@Schema(description = "收货纬度")
@TableField(exist = false)
private String receiverLat;
@Schema(description = "收货经度")
@TableField(exist = false)
private String receiverLng;
@Schema(description = "门店经纬度(lng,lat)")
@TableField(exist = false)
private String storeLngAndLat;
@Schema(description = "仓库经纬度(lng,lat)")
@TableField(exist = false)
private String warehouseLngAndLat;
@Schema(description = "排序(数字越小越靠前)")
private Integer sortNumber;

View File

@@ -4,13 +4,19 @@
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*, b.name as storeName, b.address as storeAddress, b.phone as storePhone,
SELECT a.*, b.name as storeName, b.address as storeAddress, b.phone as storePhone, b.lng_and_lat as storeLngAndLat,
w.name as warehouseName, w.address as warehouseAddress, w.phone as warehousePhone, w.lng_and_lat as warehouseLngAndLat,
c.real_name as riderName, c.mobile as riderPhone,
u.nickname, u.phone, u.avatar
u.nickname, u.phone, u.avatar,
d.name as receiverName, d.phone as receiverPhone,
d.province as receiverProvince, d.city as receiverCity, d.region as receiverRegion,
d.address as receiverAddress, d.full_address as receiverFullAddress, d.lat as receiverLat, d.lng as receiverLng
FROM glt_ticket_order a
LEFT JOIN shop_store b ON a.store_id = b.id
LEFT JOIN shop_warehouse w ON a.warehouse_id = w.id
LEFT JOIN shop_store_rider c ON a.rider_id = c.user_id
LEFT JOIN gxwebsoft_core.sys_user u ON a.user_id = u.user_id
LEFT JOIN shop_user_address d ON a.address_id = d.id
<where>
<if test="param.id != null">
@@ -25,6 +31,9 @@
<if test="param.riderId != null">
AND a.rider_id = #{param.riderId}
</if>
<if test="param.deliveryStatus != null">
AND a.delivery_status = #{param.deliveryStatus}
</if>
<if test="param.warehouseId != null">
AND a.warehouse_id = #{param.warehouseId}
</if>
@@ -55,6 +64,9 @@
<if test="param.status != null">
AND a.status = #{param.status}
</if>
<if test="param.tenantId != null">
AND a.tenant_id = #{param.tenantId}
</if>
<if test="param.deleted != null">
AND a.deleted = #{param.deleted}
</if>
@@ -68,9 +80,15 @@
AND a.create_time &lt;= #{param.createTimeEnd}
</if>
<if test="param.keywords != null">
AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%')
AND (
a.address LIKE CONCAT('%', #{param.keywords}, '%')
OR a.buyer_remarks LIKE CONCAT('%', #{param.keywords}, '%')
OR a.comments LIKE CONCAT('%', #{param.keywords}, '%')
OR b.name LIKE CONCAT('%', #{param.keywords}, '%')
OR u.nickname LIKE CONCAT('%', #{param.keywords}, '%')
OR u.phone LIKE CONCAT('%', #{param.keywords}, '%')
)
</if>
</if>
</where>
</sql>

View File

@@ -0,0 +1,18 @@
package com.gxwebsoft.glt.param;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 配送员确认送达参数
*/
@Data
public class GltTicketOrderDeliveredParam implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "送达拍照留档图片URL")
private String sendEndImg;
}

View File

@@ -38,6 +38,10 @@ public class GltTicketOrderParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer riderId;
@Schema(description = "配送状态10待配送、20配送中、30待客户确认、40已完成")
@QueryField(type = QueryType.EQ)
private Integer deliveryStatus;
@Schema(description = "仓库ID")
@QueryField(type = QueryType.EQ)
private Integer warehouseId;

View File

@@ -15,6 +15,15 @@ import java.util.List;
*/
public interface GltTicketOrderService extends IService<GltTicketOrder> {
int DELIVERY_STATUS_WAITING = 10;
int DELIVERY_STATUS_DELIVERING = 20;
int DELIVERY_STATUS_WAIT_CONFIRM = 30;
int DELIVERY_STATUS_FINISHED = 40;
int RECEIVE_CONFIRM_TYPE_MANUAL = 10;
int RECEIVE_CONFIRM_TYPE_PHOTO = 20;
int RECEIVE_CONFIRM_TYPE_TIMEOUT = 30;
/**
* 分页关联查询
*
@@ -49,4 +58,24 @@ public interface GltTicketOrderService extends IService<GltTicketOrder> {
*/
GltTicketOrder createWithWriteOff(GltTicketOrder gltTicketOrder, Integer userId, Integer tenantId);
/**
* 配送员接单(原子):仅当 riderId 为空(或0)且 deliveryStatus=10 时写入 riderId。
*/
void accept(Integer id, Integer riderId, Integer tenantId);
/**
* 配送员开始配送10 -> 20并写 sendStartTime。
*/
void start(Integer id, Integer riderId, Integer tenantId);
/**
* 配送员确认送达20 -> 30并写 sendEndTime / sendEndImg。
*/
void delivered(Integer id, Integer riderId, Integer tenantId, String sendEndImg);
/**
* 用户确认收货30 -> 40并写 receiveConfirmTime / receiveConfirmType=10。
*/
void confirmReceive(Integer id, Integer userId, Integer tenantId);
}

View File

@@ -14,6 +14,7 @@ import com.gxwebsoft.glt.param.GltTicketOrderParam;
import com.gxwebsoft.glt.service.GltTicketOrderService;
import com.gxwebsoft.glt.service.GltUserTicketLogService;
import com.gxwebsoft.glt.service.GltUserTicketService;
import org.springframework.util.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -114,6 +115,9 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
gltTicketOrder.setStatus(0);
gltTicketOrder.setDeleted(0);
gltTicketOrder.setTenantId(tenantId);
if (gltTicketOrder.getDeliveryStatus() == null) {
gltTicketOrder.setDeliveryStatus(DELIVERY_STATUS_WAITING);
}
if (gltTicketOrder.getSortNumber() == null) {
gltTicketOrder.setSortNumber(0);
}
@@ -159,4 +163,189 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
return gltTicketOrder;
}
@Override
public void accept(Integer id, Integer riderId, Integer tenantId) {
if (id == null) {
throw new BusinessException("订单id不能为空");
}
if (riderId == null) {
throw new BusinessException("配送员信息缺失");
}
if (tenantId == null) {
throw new BusinessException("租户信息缺失");
}
// 原子接单:避免并发抢单
boolean ok = this.lambdaUpdate()
.set(GltTicketOrder::getRiderId, riderId)
.set(GltTicketOrder::getUpdateTime, LocalDateTime.now())
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.and(w -> w.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAITING)
.or().isNull(GltTicketOrder::getDeliveryStatus))
.and(w -> w.isNull(GltTicketOrder::getRiderId).or().eq(GltTicketOrder::getRiderId, 0))
.update();
if (ok) {
return;
}
// 回查给出更明确的错误
GltTicketOrder order = this.lambdaQuery()
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.one();
if (order == null) {
throw new BusinessException("订单不存在");
}
if (order.getRiderId() != null && order.getRiderId() > 0) {
throw new BusinessException("订单已被其他配送员接单");
}
throw new BusinessException("订单状态不允许接单");
}
@Override
public void start(Integer id, Integer riderId, Integer tenantId) {
if (id == null) {
throw new BusinessException("订单id不能为空");
}
if (riderId == null) {
throw new BusinessException("配送员信息缺失");
}
if (tenantId == null) {
throw new BusinessException("租户信息缺失");
}
LocalDateTime now = LocalDateTime.now();
boolean ok = this.lambdaUpdate()
.set(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_DELIVERING)
.set(GltTicketOrder::getSendStartTime, now)
.set(GltTicketOrder::getUpdateTime, now)
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.eq(GltTicketOrder::getRiderId, riderId)
.and(w -> w.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAITING)
.or().isNull(GltTicketOrder::getDeliveryStatus))
.update();
if (ok) {
return;
}
GltTicketOrder order = this.lambdaQuery()
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.one();
if (order == null) {
throw new BusinessException("订单不存在");
}
if (!riderId.equals(order.getRiderId())) {
throw new BusinessException("无权限操作该订单");
}
if (order.getDeliveryStatus() != null && order.getDeliveryStatus() == DELIVERY_STATUS_DELIVERING) {
// 幂等:重复开始配送视为成功
return;
}
throw new BusinessException("订单状态不允许开始配送");
}
@Override
public void delivered(Integer id, Integer riderId, Integer tenantId, String sendEndImg) {
if (id == null) {
throw new BusinessException("订单id不能为空");
}
if (riderId == null) {
throw new BusinessException("配送员信息缺失");
}
if (tenantId == null) {
throw new BusinessException("租户信息缺失");
}
LocalDateTime now = LocalDateTime.now();
var update = this.lambdaUpdate()
.set(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM)
.set(GltTicketOrder::getSendEndTime, now)
.set(GltTicketOrder::getUpdateTime, now);
if (StringUtils.hasText(sendEndImg)) {
update.set(GltTicketOrder::getSendEndImg, sendEndImg);
}
boolean ok = update
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.eq(GltTicketOrder::getRiderId, riderId)
.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_DELIVERING)
.update();
if (ok) {
return;
}
GltTicketOrder order = this.lambdaQuery()
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.one();
if (order == null) {
throw new BusinessException("订单不存在");
}
if (!riderId.equals(order.getRiderId())) {
throw new BusinessException("无权限操作该订单");
}
if (order.getDeliveryStatus() != null
&& (order.getDeliveryStatus() == DELIVERY_STATUS_WAIT_CONFIRM
|| order.getDeliveryStatus() == DELIVERY_STATUS_FINISHED)) {
// 幂等:重复送达视为成功
return;
}
throw new BusinessException("订单状态不允许确认送达");
}
@Override
public void confirmReceive(Integer id, Integer userId, Integer tenantId) {
if (id == null) {
throw new BusinessException("订单id不能为空");
}
if (userId == null) {
throw new BusinessException("请先登录");
}
if (tenantId == null) {
throw new BusinessException("租户信息缺失");
}
LocalDateTime now = LocalDateTime.now();
boolean ok = this.lambdaUpdate()
.set(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_FINISHED)
.set(GltTicketOrder::getReceiveConfirmTime, now)
.set(GltTicketOrder::getReceiveConfirmType, RECEIVE_CONFIRM_TYPE_MANUAL)
.set(GltTicketOrder::getUpdateTime, now)
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.eq(GltTicketOrder::getUserId, userId)
.eq(GltTicketOrder::getDeliveryStatus, DELIVERY_STATUS_WAIT_CONFIRM)
.update();
if (ok) {
return;
}
GltTicketOrder order = this.lambdaQuery()
.eq(GltTicketOrder::getId, id)
.eq(GltTicketOrder::getTenantId, tenantId)
.eq(GltTicketOrder::getDeleted, 0)
.one();
if (order == null) {
throw new BusinessException("订单不存在");
}
if (!userId.equals(order.getUserId())) {
throw new BusinessException("无权限操作该订单");
}
if (order.getDeliveryStatus() != null && order.getDeliveryStatus() == DELIVERY_STATUS_FINISHED) {
// 幂等:重复确认收货视为成功
return;
}
throw new BusinessException("订单状态不允许确认收货");
}
}