骑手配送

This commit is contained in:
zjf committed 2026-04-08 17:37:11 +08:00
1 parent 24ca3f57cb
commit 09b880ed8a
38 files changed
+1422 -6

No files matched your search

@@ -0,0 +1,26 @@
package com.bsd.pjcloud.canteen.api.feign;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
/**
* 订单服务调用:同步外送单 / 取消外送单
*/
@FeignClient(contextId = "remoteCanteenDeliveryOrderService", value = ServiceNameConstants.CANTEEN_SERVICE)
public interface RemoteCanteenDeliveryOrderService {
@PostMapping("/canteenDeliveryOrder/inner/sync-from-order")
@NoToken
R<Long> syncFromOrder(@RequestBody CanteenDeliveryOrderSyncDTO dto);
@PostMapping("/canteenDeliveryOrder/inner/cancel-by-source-id")
@NoToken
R<Void> cancelBySourceOrderId(@RequestParam("sourceOrderId") String sourceOrderId);
}
@@ -19,12 +19,14 @@ public interface RemoteCanteenProductionTemplateService {
/**
* 根据菜品id列表获取加工模版详情
*/
@NoToken
@PostMapping("/canteenProductionTemplate/inner/getDetailsByDishIds")
R<List<ProductTemplateVO>> getDetailsByDishIds(@RequestBody List<Long> dishIds);
/**
* 根据菜品id列表获取线上加工模版详情(仅 template_type=2,用于线上订餐统计)
*/
@NoToken
@PostMapping("/canteenProductionTemplate/inner/getDetailsByDishIdsForOnline")
R<List<ProductTemplateVO>> getDetailsByDishIdsForOnline(@RequestBody List<Long> dishIds);
}
@@ -0,0 +1,11 @@
package com.bsd.pjcloud.canteen.constants;
public final class CanteenDeliveryAssignType {
private CanteenDeliveryAssignType() {
}
public static final String NONE = "NONE";
public static final String BIND = "BIND";
public static final String ROUND_ROBIN = "ROUND_ROBIN";
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.canteen.constants;
/**
* 外送单状态:待分配 → 待取餐 → 配送中 → 完成/取消
*/
public final class CanteenDeliveryOrderStatus {
private CanteenDeliveryOrderStatus() {
}
/** 待分配骑手 */
public static final int PENDING_ASSIGN = 0;
/** 已指派,待到店取餐(上传取餐照后进入配送中) */
public static final int WAIT_PICKUP = 1;
/** 配送中 */
public static final int DELIVERING = 2;
/** 已完成 */
public static final int COMPLETED = 3;
/** 已取消 */
public static final int CANCELLED = 4;
}
@@ -0,0 +1,18 @@
package com.bsd.pjcloud.canteen.constants;
/**
* 食堂外送 — 推送给骑手的 WebSocket 业务类型(messageText JSON 内字段 bizType)
*/
public final class CanteenDeliveryPushConstants {
private CanteenDeliveryPushConstants() {
}
/**
* 新外送单已指派给当前骑手(支付同步成功且默认池派单成功)
*/
public static final String BIZ_TYPE_ORDER_ASSIGNED = "CANTEEN_DELIVERY_ORDER_ASSIGNED";
/** messageText 内 schema 版本,前端兼容用 */
public static final int PAYLOAD_VERSION = 1;
}
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.canteen.dto.delivery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Schema(description = "外送购物车行")
public class CanteenDeliveryCartItemDTO {
private String id;
private String parentCarId;
/** 商品/SPU id */
private String productId;
private String productName;
private String productPic;
/** 单价,分 */
private Integer productPrice;
private Integer productCount;
private BigDecimal productWeight;
/** 行总价,分(兼容字符串) */
private String totalPrice;
}
@@ -0,0 +1,62 @@
package com.bsd.pjcloud.canteen.dto.delivery;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 订单支付成功后由订单服务 Inner 调用同步外送单。
* <p>
* 派单规则:
* 1) 若传入 buildingId,则优先按「单位 + 楼栋绑定」指派骑手;
* 2) 否则(或绑定无效)按单位默认骑手池轮询指派。
* <p>
* 收货展示用 fullAddress 快照;性别匹配用 {@link #buyerUserId} 查询用户表,不要求订单侧传 gender。
*/
@Data
@Schema(description = "外送单同步(订单回调)")
public class CanteenDeliveryOrderSyncDTO {
@NotBlank
@Schema(description = "来源订单号(业务唯一)", requiredMode = Schema.RequiredMode.REQUIRED)
private String id;
@Schema(description = "商品摘要")
private String productDesc;
@Schema(description = "收货人姓名")
private String username;
@Schema(description = "收货人手机")
private String userMobile;
@Schema(description = "商户名称")
private String merchantName;
@Schema(description = "商户logo")
private String logoPic;
@Schema(description = "购物车")
private List<CanteenDeliveryCartItemDTO> carList;
// --------- 骑手/派单补充字段(由订单服务尽量传入)---------
@NotNull
@Schema(description = "所属单位部门ID(仅该单位下骑手可配送)", requiredMode = Schema.RequiredMode.REQUIRED)
private Long deptId;
@Schema(description = "下单用户ID")
private Long buyerUserId;
@Schema(description = "商户ID(字符串)")
private String merchantId;
@Schema(description = "配送楼栋ID(可选;有则优先按楼栋绑定骑手)")
private Long buildingId;
@Schema(description = "完整收货地址(展示用)")
private String fullAddress;
}
@@ -0,0 +1,18 @@
package com.bsd.pjcloud.canteen.dto.delivery;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "外送上传照片")
public class CanteenDeliveryPhotoDTO {
@NotNull
private Long id;
@NotBlank
@Schema(description = "图片URL(先走你们统一上传拿地址)")
private String photoUrl;
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 按单位轮询派单序号(无外键,不按租户拆分时以 dept_id 唯一)
*/
@Data
@TableName("canteen_delivery_assign_seq")
@EqualsAndHashCode(callSuper = true)
public class CanteenDeliveryAssignSeqEntity extends Model<CanteenDeliveryAssignSeqEntity> {
@TableId(value = "dept_id", type = IdType.INPUT)
private Long deptId;
private Long seq;
}
@@ -0,0 +1,60 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_delivery_order")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "食堂外送单")
public class CanteenDeliveryOrderEntity extends Model<CanteenDeliveryOrderEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String sourceOrderId;
private Long deptId;
private Long buyerUserId;
private String merchantId;
private String productDesc;
private String receiverName;
private String receiverMobile;
private String merchantName;
private String merchantLogo;
private Long buildingId;
private Long unitId;
private Long roomId;
private String fullAddress;
private Integer buyerGender;
private String carListJson;
private String assignType;
private Long riderUserId;
private Integer status;
private String pickupPhotoUrl;
private String deliveryPhotoUrl;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
private Long tenantId;
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_rider_building_bind")
@EqualsAndHashCode(callSuper = true)
public class CanteenRiderBuildingBindEntity extends Model<CanteenRiderBuildingBindEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long deptId;
private Long buildingId;
private Long riderUserId;
private Long tenantId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_rider_pool")
@EqualsAndHashCode(callSuper = true)
public class CanteenRiderPoolEntity extends Model<CanteenRiderPoolEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long deptId;
private Long riderUserId;
private Integer sortOrder;
private Long tenantId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -0,0 +1,30 @@
package com.bsd.pjcloud.canteen.vo.app;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryCartItemDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Schema(description = "骑手端外送单摘要")
public class CanteenDeliveryOrderAppVO {
private Long id;
private String sourceOrderId;
private Long deptId;
private String productDesc;
private String receiverName;
private String receiverMobile;
private String merchantName;
private String merchantLogo;
private String fullAddress;
private Integer status;
private String pickupPhotoUrl;
private String deliveryPhotoUrl;
private LocalDateTime createTime;
@Schema(description = "购物车行项目(由 car_list_json 反序列化)")
private List<CanteenDeliveryCartItemDTO> carList;
}
@@ -3,4 +3,6 @@ com.bsd.pjcloud.canteen.api.feign.RemoteCanteenOnlineCategoryService
com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService
com.bsd.pjcloud.canteen.api.feign.RemoteOnlineCanteenSpuService
com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService
com.bsd.pjcloud.canteen.api.feign.RemoteCanteenDeliveryOrderService
com.bsd.pjcloud.canteen.api.feign.RemoteCanteenProductionTemplateService
@@ -0,0 +1,98 @@
package com.bsd.pjcloud.canteen.controller;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryPhotoDTO;
import com.bsd.pjcloud.canteen.push.CanteenDeliveryRiderPushPublisher;
import com.bsd.pjcloud.canteen.service.CanteenDeliveryOrderService;
import com.bsd.pjcloud.canteen.vo.app.CanteenDeliveryOrderAppVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 食堂外送:订单同步 + 骑手 App
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/canteenDeliveryOrder")
@Tag(name = "食堂外送订单")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class CanteenDeliveryOrderController {
private final CanteenDeliveryOrderService canteenDeliveryOrderService;
private final CanteenDeliveryRiderPushPublisher riderPushPublisher;
/**
* 联调:模拟「新单已指派」的 WebSocket 推送(与正式推送同一 Redis 频道与 messageText 结构)。
* 骑手端须已用相同 userId 建立 WS;需管理端权限 {@code canteen_canteenDelivery_config}。
*/
@SysLog("测试骑手WS推送")
@PostMapping("/test/mock-rider-push")
@PreAuthorize("@pms.hasPermission('canteen_canteenDelivery_config')")
@Operation(summary = "测试-模拟向骑手推送新单 WS 消息", description = "参数 riderUserId 为骑手用户主键;deliveryOrderId 可选,不传则用时间戳占位(详情页可能 404,仅测消息与跳转)")
public R<Void> mockRiderPush(@RequestParam Long riderUserId,
@RequestParam(required = false) Long deliveryOrderId) {
long fakeId = deliveryOrderId != null ? deliveryOrderId : System.currentTimeMillis();
riderPushPublisher.publishOrderAssigned(riderUserId, fakeId, "MOCK-TEST-" + fakeId, "【测试】食堂", "【测试】收货地址");
return R.ok();
}
@Inner
@PostMapping("/inner/sync-from-order")
@Operation(summary = "订单支付成功回调(内部服务)",
description = "由订单服务 Feign 调用:落库、按单位默认骑手池指派骑手;指派成功且事务提交后,经 Redis 向骑手 WebSocket 下发新单通知(见食堂文档「骑手推送与联调规范」)。未指派(无池)则不推送。")
public R<Long> syncFromOrder(@Valid @RequestBody CanteenDeliveryOrderSyncDTO dto) {
return canteenDeliveryOrderService.syncFromOrder(dto);
}
@Inner
@PostMapping("/inner/cancel-by-source-id")
@Operation(summary = "订单取消回调(内部服务)")
public R<Void> cancelBySourceOrderId(@RequestParam("sourceOrderId") String sourceOrderId) {
return canteenDeliveryOrderService.cancelBySourceOrderId(sourceOrderId);
}
@GetMapping("/app/list")
@Operation(summary = "骑手端-我的配送单", description = "tab: pending待配送 | all全部 | completed已完成 | cancelled已取消")
public R<List<CanteenDeliveryOrderAppVO>> appList(@RequestParam(defaultValue = "pending") String tab) {
Long uid = SecurityUtils.getUser().getId();
return R.ok(canteenDeliveryOrderService.listForRider(uid, tab));
}
@GetMapping("/app/{id}")
@Operation(summary = "骑手端-配送单详情")
public R<CanteenDeliveryOrderAppVO> appDetail(@PathVariable Long id) {
Long uid = SecurityUtils.getUser().getId();
CanteenDeliveryOrderAppVO vo = canteenDeliveryOrderService.detailForRider(uid, id);
if (vo == null) {
return R.failed("订单不存在或无权查看");
}
return R.ok(vo);
}
@SysLog("取餐拍照")
@PostMapping("/app/pickup")
@Operation(summary = "骑手端-到店取餐拍照")
public R<Void> pickup(@Valid @RequestBody CanteenDeliveryPhotoDTO dto) {
return canteenDeliveryOrderService.pickup(SecurityUtils.getUser().getId(), dto);
}
@SysLog("送达拍照")
@PostMapping("/app/deliver")
@Operation(summary = "骑手端-送达拍照")
public R<Void> deliver(@Valid @RequestBody CanteenDeliveryPhotoDTO dto) {
return canteenDeliveryOrderService.deliver(SecurityUtils.getUser().getId(), dto);
}
}
@@ -0,0 +1,72 @@
package com.bsd.pjcloud.canteen.controller;
import com.bsd.pjcloud.canteen.entity.CanteenRiderBuildingBindEntity;
import com.bsd.pjcloud.canteen.entity.CanteenRiderPoolEntity;
import com.bsd.pjcloud.canteen.service.CanteenRiderConfigService;
import com.bsd.pjcloud.common.core.util.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 骑手与楼栋绑定、默认骑手池(管理端)
* <p>权限码 canteen_canteenDelivery_config 需在 sys_menu/sys_role_menu 中配置</p>
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/canteenRiderConfig")
@Tag(name = "食堂骑手配置")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class CanteenRiderConfigController {
private final CanteenRiderConfigService canteenRiderConfigService;
@GetMapping("/building-bind/list")
@PreAuthorize("@pms.hasPermission('canteen_canteenDelivery_config')")
@Operation(summary = "楼栋绑定列表")
public R<List<CanteenRiderBuildingBindEntity>> listBind(@RequestParam Long deptId) {
return R.ok(canteenRiderConfigService.listBuildingBind(deptId));
}
@PostMapping("/building-bind")
@PreAuthorize("@pms.hasPermission('canteen_canteenDelivery_config')")
@Operation(summary = "新增楼栋-骑手绑定")
public R<Long> saveBind(@RequestParam Long deptId, @RequestParam Long buildingId, @RequestParam Long riderUserId) {
return canteenRiderConfigService.saveBuildingBind(deptId, buildingId, riderUserId);
}
@DeleteMapping("/building-bind/{id}")
@PreAuthorize("@pms.hasPermission('canteen_canteenDelivery_config')")
@Operation(summary = "删除楼栋绑定")
public R<Void> removeBind(@PathVariable Long id) {
return canteenRiderConfigService.removeBuildingBind(id);
}
@GetMapping("/pool/list")
@PreAuthorize("@pms.hasPermission('canteen_canteenDelivery_config')")
@Operation(summary = "默认骑手池列表")
public R<List<CanteenRiderPoolEntity>> listPool(@RequestParam Long deptId) {
return R.ok(canteenRiderConfigService.listPool(deptId));
}
@PostMapping("/pool")
@PreAuthorize("@pms.hasPermission('canteen_canteenDelivery_config')")
@Operation(summary = "新增默认骑手(轮询顺序)")
public R<Long> savePool(@RequestParam Long deptId, @RequestParam Long riderUserId,
@RequestParam(required = false, defaultValue = "0") Integer sortOrder) {
return canteenRiderConfigService.savePool(deptId, riderUserId, sortOrder);
}
@DeleteMapping("/pool/{id}")
@PreAuthorize("@pms.hasPermission('canteen_canteenDelivery_config')")
@Operation(summary = "从默认池移除骑手")
public R<Void> removePool(@PathVariable Long id) {
return canteenRiderConfigService.removePool(id);
}
}
@@ -0,0 +1,14 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.CanteenDeliveryAssignSeqEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface CanteenDeliveryAssignSeqMapper extends BaseMapper<CanteenDeliveryAssignSeqEntity> {
@Update("UPDATE canteen_delivery_assign_seq SET seq = seq + 1 WHERE dept_id = #{deptId}")
int incrSeq(@Param("deptId") Long deptId);
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.CanteenDeliveryOrderEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CanteenDeliveryOrderMapper extends PjcloudBaseMapper<CanteenDeliveryOrderEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.CanteenRiderBuildingBindEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CanteenRiderBuildingBindMapper extends PjcloudBaseMapper<CanteenRiderBuildingBindEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.CanteenRiderPoolEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CanteenRiderPoolMapper extends PjcloudBaseMapper<CanteenRiderPoolEntity> {
}
@@ -0,0 +1,75 @@
package com.bsd.pjcloud.canteen.push;
import com.bsd.pjcloud.canteen.constants.CanteenDeliveryPushConstants;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 向骑手 WebSocket 会话发通知:通过 Redis 频道广播,由已连接 WebSocket 的网关服务(通常为 UPMS)订阅并下发。
* 频道名与 {@code com.bsd.pjcloud.common.websocket.distribute.RedisWebsocketMessageListener#CHANNEL} 一致。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CanteenDeliveryRiderPushPublisher {
/** 与 pjcloud-common-websocket 中 RedisWebsocketMessageListener.CHANNEL 保持一致 */
public static final String REDIS_CHANNEL_WEBSOCKET_SEND = "websocket-send";
private final ObjectProvider<StringRedisTemplate> stringRedisTemplate;
private final ObjectMapper objectMapper;
/**
* 新外送单已指派:推给指定骑手 userId(与 WS SessionKey 一致,为字符串形式的用户主键)
*/
public void publishOrderAssigned(Long riderUserId,
Long deliveryOrderId,
String sourceOrderId,
String merchantName,
String fullAddress) {
if (riderUserId == null) {
return;
}
StringRedisTemplate redis = stringRedisTemplate.getIfAvailable();
if (redis == null) {
log.debug("StringRedisTemplate 不可用,跳过外送骑手 WS 推送");
return;
}
try {
ObjectNode envelope = objectMapper.createObjectNode();
envelope.put("bizType", CanteenDeliveryPushConstants.BIZ_TYPE_ORDER_ASSIGNED);
envelope.put("version", CanteenDeliveryPushConstants.PAYLOAD_VERSION);
envelope.put("title", "新配送任务");
envelope.put("body", "您有一单新的食堂外送,请及时处理");
envelope.set("payload", objectMapper.valueToTree(Map.of(
"deliveryOrderId", deliveryOrderId,
"sourceOrderId", sourceOrderId != null ? sourceOrderId : "",
"merchantName", merchantName != null ? merchantName : "",
"fullAddress", fullAddress != null ? fullAddress : ""
)));
ObjectNode route = objectMapper.createObjectNode();
route.put("type", "navigate");
route.put("path", "/pages/canteen/rider/delivery-detail");
route.set("query", objectMapper.valueToTree(Map.of("id", String.valueOf(deliveryOrderId))));
envelope.set("route", route);
String messageText = objectMapper.writeValueAsString(envelope);
Map<String, Object> messageDo = Map.of(
"needBroadcast", false,
"sessionKeys", List.of(String.valueOf(riderUserId)),
"messageText", messageText
);
redis.convertAndSend(REDIS_CHANNEL_WEBSOCKET_SEND, objectMapper.writeValueAsString(messageDo));
} catch (Exception e) {
log.warn("外送单指派推送 Redis 发布失败 riderUserId={} deliveryOrderId={}", riderUserId, deliveryOrderId, e);
}
}
}
@@ -0,0 +1,31 @@
package com.bsd.pjcloud.canteen.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryPhotoDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDeliveryOrderEntity;
import com.bsd.pjcloud.canteen.vo.app.CanteenDeliveryOrderAppVO;
import com.bsd.pjcloud.common.core.util.R;
import java.util.List;
public interface CanteenDeliveryOrderService extends IService<CanteenDeliveryOrderEntity> {
/**
* 订单服务支付成功回调,落库并自动派单
*/
R<Long> syncFromOrder(CanteenDeliveryOrderSyncDTO dto);
R<Void> cancelBySourceOrderId(String sourceOrderId);
/**
* @param tab pending | all | completed | cancelled
*/
List<CanteenDeliveryOrderAppVO> listForRider(Long riderUserId, String tab);
CanteenDeliveryOrderAppVO detailForRider(Long riderUserId, Long id);
R<Void> pickup(Long riderUserId, CanteenDeliveryPhotoDTO dto);
R<Void> deliver(Long riderUserId, CanteenDeliveryPhotoDTO dto);
}
@@ -0,0 +1,22 @@
package com.bsd.pjcloud.canteen.service;
import com.bsd.pjcloud.canteen.entity.CanteenRiderBuildingBindEntity;
import com.bsd.pjcloud.canteen.entity.CanteenRiderPoolEntity;
import com.bsd.pjcloud.common.core.util.R;
import java.util.List;
public interface CanteenRiderConfigService {
R<Long> saveBuildingBind(Long deptId, Long buildingId, Long riderUserId);
R<Void> removeBuildingBind(Long id);
List<CanteenRiderBuildingBindEntity> listBuildingBind(Long deptId);
R<Long> savePool(Long deptId, Long riderUserId, Integer sortOrder);
R<Void> removePool(Long id);
List<CanteenRiderPoolEntity> listPool(Long deptId);
}
@@ -0,0 +1,331 @@
package com.bsd.pjcloud.canteen.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.canteen.constants.CanteenDeliveryAssignType;
import com.bsd.pjcloud.canteen.constants.CanteenDeliveryOrderStatus;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryCartItemDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryPhotoDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDeliveryAssignSeqEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDeliveryOrderEntity;
import com.bsd.pjcloud.canteen.entity.CanteenRiderBuildingBindEntity;
import com.bsd.pjcloud.canteen.entity.CanteenRiderPoolEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenDeliveryAssignSeqMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDeliveryOrderMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenRiderBuildingBindMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenRiderPoolMapper;
import com.bsd.pjcloud.canteen.push.CanteenDeliveryRiderPushPublisher;
import com.bsd.pjcloud.canteen.service.CanteenDeliveryOrderService;
import com.bsd.pjcloud.canteen.vo.app.CanteenDeliveryOrderAppVO;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class CanteenDeliveryOrderServiceImpl extends ServiceImpl<CanteenDeliveryOrderMapper, CanteenDeliveryOrderEntity>
implements CanteenDeliveryOrderService {
private final CanteenRiderBuildingBindMapper riderBuildingBindMapper;
private final CanteenRiderPoolMapper riderPoolMapper;
private final CanteenDeliveryAssignSeqMapper assignSeqMapper;
private final RemoteUserService remoteUserService;
private final RemoteDeptService remoteDeptService;
private final ObjectMapper objectMapper;
private final CanteenDeliveryRiderPushPublisher riderPushPublisher;
@Override
@Transactional(rollbackFor = Exception.class)
public R<Long> syncFromOrder(CanteenDeliveryOrderSyncDTO dto) {
long exists = count(Wrappers.<CanteenDeliveryOrderEntity>lambdaQuery()
.eq(CanteenDeliveryOrderEntity::getSourceOrderId, dto.getId()));
if (exists > 0) {
return R.failed("订单已同步过");
}
CanteenDeliveryOrderEntity entity = new CanteenDeliveryOrderEntity();
entity.setSourceOrderId(dto.getId());
entity.setDeptId(dto.getDeptId());
entity.setBuyerUserId(dto.getBuyerUserId());
entity.setMerchantId(dto.getMerchantId());
entity.setProductDesc(dto.getProductDesc());
// 收货人信息:优先使用订单侧回传;缺失时根据 buyerUserId 自动补齐
entity.setReceiverName(dto.getUsername());
entity.setReceiverMobile(dto.getUserMobile());
if ((StrUtil.isBlank(entity.getReceiverName()) || StrUtil.isBlank(entity.getReceiverMobile()))
&& dto.getBuyerUserId() != null) {
R<UserInfo> r = remoteUserService.getInnerByUserId(dto.getBuyerUserId());
if (r != null && r.isOk() && r.getData() != null && r.getData().getSysUser() != null) {
SysUser u = r.getData().getSysUser();
if (StrUtil.isBlank(entity.getReceiverName())) {
entity.setReceiverName(u.getName());
}
if (StrUtil.isBlank(entity.getReceiverMobile())) {
entity.setReceiverMobile(u.getPhone());
}
}
}
entity.setMerchantName(dto.getMerchantName());
entity.setMerchantLogo(dto.getLogoPic());
entity.setBuildingId(dto.getBuildingId());
entity.setFullAddress(dto.getFullAddress());
try {
entity.setCarListJson(objectMapper.writeValueAsString(dto.getCarList()));
} catch (JsonProcessingException e) {
return R.failed("购物车序列化失败");
}
assignRiderOnCreate(entity);
save(entity);
scheduleRiderPushAfterAssign(entity);
return R.ok(entity.getId());
}
/**
* 事务提交后再推送,避免回滚后骑手仍收到通知。
*/
private void scheduleRiderPushAfterAssign(CanteenDeliveryOrderEntity entity) {
if (entity.getRiderUserId() == null
|| !Objects.equals(entity.getStatus(), CanteenDeliveryOrderStatus.WAIT_PICKUP)) {
return;
}
final Long riderId = entity.getRiderUserId();
final Long deliveryId = entity.getId();
final String sourceOrderId = entity.getSourceOrderId();
final String merchantName = entity.getMerchantName();
final String fullAddress = entity.getFullAddress();
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
riderPushPublisher.publishOrderAssigned(riderId, deliveryId, sourceOrderId, merchantName, fullAddress);
}
});
} else {
riderPushPublisher.publishOrderAssigned(riderId, deliveryId, sourceOrderId, merchantName, fullAddress);
}
}
private void assignRiderOnCreate(CanteenDeliveryOrderEntity entity) {
Long deptId = entity.getDeptId();
entity.setAssignType(CanteenDeliveryAssignType.NONE);
entity.setRiderUserId(null);
entity.setStatus(CanteenDeliveryOrderStatus.PENDING_ASSIGN);
// 1) 优先楼栋绑定(有 buildingId 才有意义)
if (entity.getBuildingId() != null) {
CanteenRiderBuildingBindEntity bind = riderBuildingBindMapper.selectOne(
Wrappers.<CanteenRiderBuildingBindEntity>lambdaQuery()
.eq(CanteenRiderBuildingBindEntity::getDeptId, deptId)
.eq(CanteenRiderBuildingBindEntity::getBuildingId, entity.getBuildingId()));
if (bind != null && verifyRiderInUnit(bind.getRiderUserId(), deptId)) {
entity.setRiderUserId(bind.getRiderUserId());
entity.setAssignType(CanteenDeliveryAssignType.BIND);
entity.setStatus(CanteenDeliveryOrderStatus.WAIT_PICKUP);
return;
}
}
// 2) 默认骑手池轮询
List<CanteenRiderPoolEntity> pool = riderPoolMapper.selectList(
Wrappers.<CanteenRiderPoolEntity>lambdaQuery()
.eq(CanteenRiderPoolEntity::getDeptId, deptId)
.orderByAsc(CanteenRiderPoolEntity::getSortOrder)
.orderByAsc(CanteenRiderPoolEntity::getId));
if (pool.isEmpty()) {
return;
}
Integer buyerGender = fetchUserGender(entity.getBuyerUserId());
List<CanteenRiderPoolEntity> filtered = filterPoolByBuyerGender(pool, buyerGender);
long seq = nextSeq(deptId);
int idx = (int) ((seq - 1) % filtered.size());
if (idx < 0) {
idx = 0;
}
CanteenRiderPoolEntity chosen = filtered.get(idx);
if (!verifyRiderInUnit(chosen.getRiderUserId(), deptId)) {
for (CanteenRiderPoolEntity p : filtered) {
if (verifyRiderInUnit(p.getRiderUserId(), deptId)) {
chosen = p;
break;
}
}
}
if (!verifyRiderInUnit(chosen.getRiderUserId(), deptId)) {
return;
}
entity.setRiderUserId(chosen.getRiderUserId());
entity.setAssignType(CanteenDeliveryAssignType.ROUND_ROBIN);
entity.setStatus(CanteenDeliveryOrderStatus.WAIT_PICKUP);
}
private List<CanteenRiderPoolEntity> filterPoolByBuyerGender(List<CanteenRiderPoolEntity> pool, Integer buyerGender) {
if (buyerGender == null || (buyerGender != 1 && buyerGender != 2)) {
return pool;
}
List<CanteenRiderPoolEntity> matched = new ArrayList<>();
for (CanteenRiderPoolEntity p : pool) {
Integer g = fetchUserGender(p.getRiderUserId());
if (buyerGender.equals(g)) {
matched.add(p);
}
}
return matched.isEmpty() ? pool : matched;
}
private Integer fetchUserGender(Long userId) {
if (userId == null) {
return 0;
}
R<UserInfo> r = remoteUserService.getInnerByUserId(userId);
if (r == null || !r.isOk() || r.getData() == null || r.getData().getSysUser() == null) {
return 0;
}
Integer g = r.getData().getSysUser().getGender();
return g == null ? 0 : g;
}
private boolean verifyRiderInUnit(Long riderUserId, Long unitDeptId) {
if (riderUserId == null || unitDeptId == null) {
return false;
}
R<UserInfo> r = remoteUserService.getInnerByUserId(riderUserId);
if (r == null || !r.isOk() || r.getData() == null || r.getData().getSysUser() == null) {
return false;
}
SysUser u = r.getData().getSysUser();
Long riderDept = u.getDeptId();
if (riderDept == null) {
return false;
}
if (unitDeptId.equals(riderDept)) {
return true;
}
R<List<SysDept>> parents = remoteDeptService.getParentDeptList(riderDept);
if (parents == null || !parents.isOk() || parents.getData() == null) {
return false;
}
return parents.getData().stream().anyMatch(d -> unitDeptId.equals(d.getDeptId()));
}
private long nextSeq(Long deptId) {
CanteenDeliveryAssignSeqEntity row = assignSeqMapper.selectById(deptId);
if (row == null) {
row = new CanteenDeliveryAssignSeqEntity();
row.setDeptId(deptId);
row.setSeq(0L);
assignSeqMapper.insert(row);
}
assignSeqMapper.incrSeq(deptId);
CanteenDeliveryAssignSeqEntity after = assignSeqMapper.selectById(deptId);
return after != null && after.getSeq() != null ? after.getSeq() : 1L;
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Void> cancelBySourceOrderId(String sourceOrderId) {
if (StrUtil.isBlank(sourceOrderId)) {
return R.failed("订单号不能为空");
}
CanteenDeliveryOrderEntity one = getOne(Wrappers.<CanteenDeliveryOrderEntity>lambdaQuery()
.eq(CanteenDeliveryOrderEntity::getSourceOrderId, sourceOrderId));
if (one == null) {
return R.ok();
}
one.setStatus(CanteenDeliveryOrderStatus.CANCELLED);
updateById(one);
return R.ok();
}
@Override
public List<CanteenDeliveryOrderAppVO> listForRider(Long riderUserId, String tab) {
String t = StrUtil.emptyToDefault(tab, "all");
var q = Wrappers.<CanteenDeliveryOrderEntity>lambdaQuery()
.eq(CanteenDeliveryOrderEntity::getRiderUserId, riderUserId);
switch (t) {
case "pending" ->
q.in(CanteenDeliveryOrderEntity::getStatus, CanteenDeliveryOrderStatus.WAIT_PICKUP, CanteenDeliveryOrderStatus.DELIVERING);
case "completed" -> q.eq(CanteenDeliveryOrderEntity::getStatus, CanteenDeliveryOrderStatus.COMPLETED);
case "cancelled" -> q.eq(CanteenDeliveryOrderEntity::getStatus, CanteenDeliveryOrderStatus.CANCELLED);
default -> {
}
}
q.orderByDesc(CanteenDeliveryOrderEntity::getCreateTime);
List<CanteenDeliveryOrderEntity> list = list(q);
return list.stream().map(this::toAppVoWithCart).toList();
}
@Override
public CanteenDeliveryOrderAppVO detailForRider(Long riderUserId, Long id) {
CanteenDeliveryOrderEntity e = getById(id);
if (e == null || !Objects.equals(e.getRiderUserId(), riderUserId)) {
return null;
}
return toAppVoWithCart(e);
}
private CanteenDeliveryOrderAppVO toAppVoWithCart(CanteenDeliveryOrderEntity e) {
CanteenDeliveryOrderAppVO vo = BeanUtils.toBean(e, CanteenDeliveryOrderAppVO.class);
vo.setCarList(parseCartList(e.getCarListJson()));
return vo;
}
private List<CanteenDeliveryCartItemDTO> parseCartList(String carListJson) {
if (StrUtil.isBlank(carListJson)) {
return List.of();
}
try {
return objectMapper.readValue(carListJson, new TypeReference<List<CanteenDeliveryCartItemDTO>>() {});
} catch (Exception ex) {
return List.of();
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Void> pickup(Long riderUserId, CanteenDeliveryPhotoDTO dto) {
CanteenDeliveryOrderEntity e = getById(dto.getId());
if (e == null || !Objects.equals(e.getRiderUserId(), riderUserId)) {
return R.failed("订单不存在或无权操作");
}
if (e.getStatus() != CanteenDeliveryOrderStatus.WAIT_PICKUP) {
return R.failed("当前状态不可上传取餐照");
}
e.setPickupPhotoUrl(dto.getPhotoUrl());
e.setStatus(CanteenDeliveryOrderStatus.DELIVERING);
updateById(e);
return R.ok();
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Void> deliver(Long riderUserId, CanteenDeliveryPhotoDTO dto) {
CanteenDeliveryOrderEntity e = getById(dto.getId());
if (e == null || !Objects.equals(e.getRiderUserId(), riderUserId)) {
return R.failed("订单不存在或无权操作");
}
if (e.getStatus() != CanteenDeliveryOrderStatus.DELIVERING) {
return R.failed("当前状态不可上传送达照");
}
e.setDeliveryPhotoUrl(dto.getPhotoUrl());
e.setStatus(CanteenDeliveryOrderStatus.COMPLETED);
updateById(e);
return R.ok();
}
}
@@ -0,0 +1,74 @@
package com.bsd.pjcloud.canteen.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.canteen.entity.CanteenRiderBuildingBindEntity;
import com.bsd.pjcloud.canteen.entity.CanteenRiderPoolEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenRiderBuildingBindMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenRiderPoolMapper;
import com.bsd.pjcloud.canteen.service.CanteenRiderConfigService;
import com.bsd.pjcloud.common.core.util.R;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class CanteenRiderConfigServiceImpl implements CanteenRiderConfigService {
private final CanteenRiderBuildingBindMapper buildingBindMapper;
private final CanteenRiderPoolMapper poolMapper;
@Override
public R<Long> saveBuildingBind(Long deptId, Long buildingId, Long riderUserId) {
long dup = buildingBindMapper.selectCount(Wrappers.<CanteenRiderBuildingBindEntity>lambdaQuery()
.eq(CanteenRiderBuildingBindEntity::getDeptId, deptId)
.eq(CanteenRiderBuildingBindEntity::getBuildingId, buildingId));
if (dup > 0) {
return R.failed("该单位下楼栋已绑定骑手,请先解绑");
}
CanteenRiderBuildingBindEntity e = new CanteenRiderBuildingBindEntity();
e.setDeptId(deptId);
e.setBuildingId(buildingId);
e.setRiderUserId(riderUserId);
buildingBindMapper.insert(e);
return R.ok(e.getId());
}
@Override
public R<Void> removeBuildingBind(Long id) {
buildingBindMapper.deleteById(id);
return R.ok();
}
@Override
public List<CanteenRiderBuildingBindEntity> listBuildingBind(Long deptId) {
return buildingBindMapper.selectList(Wrappers.<CanteenRiderBuildingBindEntity>lambdaQuery()
.eq(CanteenRiderBuildingBindEntity::getDeptId, deptId)
.orderByAsc(CanteenRiderBuildingBindEntity::getId));
}
@Override
public R<Long> savePool(Long deptId, Long riderUserId, Integer sortOrder) {
CanteenRiderPoolEntity e = new CanteenRiderPoolEntity();
e.setDeptId(deptId);
e.setRiderUserId(riderUserId);
e.setSortOrder(sortOrder == null ? 0 : sortOrder);
poolMapper.insert(e);
return R.ok(e.getId());
}
@Override
public R<Void> removePool(Long id) {
poolMapper.deleteById(id);
return R.ok();
}
@Override
public List<CanteenRiderPoolEntity> listPool(Long deptId) {
return poolMapper.selectList(Wrappers.<CanteenRiderPoolEntity>lambdaQuery()
.eq(CanteenRiderPoolEntity::getDeptId, deptId)
.orderByAsc(CanteenRiderPoolEntity::getSortOrder)
.orderByAsc(CanteenRiderPoolEntity::getId));
}
}
@@ -0,0 +1,185 @@
package com.bsd.pjcloud.canteen.controller;
import com.bsd.pjcloud.canteen.service.CanteenDeliveryOrderService;
import com.bsd.pjcloud.canteen.vo.app.CanteenDeliveryOrderAppVO;
import com.bsd.pjcloud.common.core.constant.SecurityConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Collections;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 本地可跑:不连库、不连 Nacos,仅校验 Controller 入参与路由。
* <pre>
* cd pjcloud-canteen/pjcloud-canteen-biz
* mvn test -Dtest=CanteenDeliveryOrderControllerTest
* </pre>
*/
@WebMvcTest(controllers = CanteenDeliveryOrderController.class)
@AutoConfigureMockMvc(addFilters = false)
class CanteenDeliveryOrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private CanteenDeliveryOrderService canteenDeliveryOrderService;
private static final long RIDER_USER_ID = 9001L;
@BeforeEach
void loginRider() {
PjcloudUser rider = new PjcloudUser(
RIDER_USER_ID,
"rider_test",
1L,
"13800000000",
null,
null,
"测试骑手",
null,
1L,
"nop",
true,
true,
null,
true,
true,
Collections.emptyList());
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(rider, rider.getPassword(), rider.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
@AfterEach
void clearSecurity() {
SecurityContextHolder.clearContext();
}
@Test
void innerSyncFromOrder_ok() throws Exception {
when(canteenDeliveryOrderService.syncFromOrder(any())).thenReturn(R.ok(12345L));
String body = """
{"id":"TEST-ORDER-001","deptId":100}
""";
mockMvc.perform(post("/canteenDeliveryOrder/inner/sync-from-order")
.header(SecurityConstants.FROM, SecurityConstants.FROM_IN)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true))
.andExpect(jsonPath("$.data").value(12345));
verify(canteenDeliveryOrderService).syncFromOrder(any());
}
@Test
void innerSyncFromOrder_validationFail() throws Exception {
mockMvc.perform(post("/canteenDeliveryOrder/inner/sync-from-order")
.header(SecurityConstants.FROM, SecurityConstants.FROM_IN)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
void innerCancel_ok() throws Exception {
when(canteenDeliveryOrderService.cancelBySourceOrderId("TEST-ORDER-001")).thenReturn(R.ok());
mockMvc.perform(post("/canteenDeliveryOrder/inner/cancel-by-source-id")
.header(SecurityConstants.FROM, SecurityConstants.FROM_IN)
.param("sourceOrderId", "TEST-ORDER-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true));
verify(canteenDeliveryOrderService).cancelBySourceOrderId("TEST-ORDER-001");
}
@Test
void appList_delegatesToService() throws Exception {
CanteenDeliveryOrderAppVO vo = new CanteenDeliveryOrderAppVO();
vo.setId(1L);
vo.setSourceOrderId("SO-1");
when(canteenDeliveryOrderService.listForRider(RIDER_USER_ID, "pending")).thenReturn(List.of(vo));
mockMvc.perform(get("/canteenDeliveryOrder/app/list").param("tab", "pending"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true))
.andExpect(jsonPath("$.data[0].id").value(1))
.andExpect(jsonPath("$.data[0].sourceOrderId").value("SO-1"));
verify(canteenDeliveryOrderService).listForRider(RIDER_USER_ID, "pending");
}
@Test
void appDetail_ok() throws Exception {
CanteenDeliveryOrderAppVO vo = new CanteenDeliveryOrderAppVO();
vo.setId(99L);
when(canteenDeliveryOrderService.detailForRider(eq(RIDER_USER_ID), eq(99L))).thenReturn(vo);
mockMvc.perform(get("/canteenDeliveryOrder/app/99"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true))
.andExpect(jsonPath("$.data.id").value(99));
}
@Test
void appDetail_notFound() throws Exception {
when(canteenDeliveryOrderService.detailForRider(eq(RIDER_USER_ID), eq(404L))).thenReturn(null);
mockMvc.perform(get("/canteenDeliveryOrder/app/404"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(false));
}
@Test
void pickup_ok() throws Exception {
when(canteenDeliveryOrderService.pickup(eq(RIDER_USER_ID), any())).thenReturn(R.ok());
mockMvc.perform(post("/canteenDeliveryOrder/app/pickup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
java.util.Map.of("id", 1L, "photoUrl", "https://example.com/a.jpg"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true));
}
@Test
void deliver_ok() throws Exception {
when(canteenDeliveryOrderService.deliver(eq(RIDER_USER_ID), any())).thenReturn(R.ok());
mockMvc.perform(post("/canteenDeliveryOrder/app/deliver")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
java.util.Map.of("id", 1L, "photoUrl", "https://example.com/b.jpg"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true));
}
}
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.hard.api.dto.order.app;
import cn.hutool.core.util.ObjectUtil;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@@ -46,6 +47,11 @@ public class HardAppTakeOutAddress {
*/
private String roomName;
/**
* 详细地址
*/
private String fullAddress;
public String getAddressName() {
String addressName = "";
if (ObjectUtil.isNotEmpty(buildingName)) {
@@ -92,6 +92,13 @@ public class HardOrderAddressEntity extends Model<HardOrderAddressEntity> {
@Schema(description = "房间名")
private String roomName;
/**
* 详细地址
*/
@Schema(description = "详细地址")
private String fullAddress;
/**
* 创建人
*/
@@ -17,8 +17,11 @@ import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenDeliveryOrderService;
import com.bsd.pjcloud.canteen.api.feign.RemoteOnlineCanteenSpuService;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryCartItemDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
@@ -30,6 +33,7 @@ import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
@@ -162,6 +166,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private final StringRedisTemplate redisTemplate;
private final HardThirdMiOrderMapper hardThirdMiOrderMapper;
private final RemoteCanteenDeliveryOrderService remoteCanteenDeliveryOrderService;
@Override
public Page<HardOrderVo> pagejoin(Page page, HardOrderDto dto) {
@@ -679,8 +684,24 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
addressEntity.setFloorNo(dto.getAddress().getFloorNo());
addressEntity.setRoomId(dto.getAddress().getRoomId());
addressEntity.setRoomName(dto.getAddress().getRoomName());
addressEntity.setFullAddress(dto.getAddress().getFullAddress());
hardOrderAddressService.save(addressEntity);
}
//调用外卖下单
CanteenDeliveryOrderSyncDTO canteenDeliveryOrderSyncDTO = new CanteenDeliveryOrderSyncDTO();
canteenDeliveryOrderSyncDTO.setBuyerUserId(dto.getUserId());
canteenDeliveryOrderSyncDTO.setMerchantId(String.valueOf(hardManager.getId()));
canteenDeliveryOrderSyncDTO.setMerchantName(hardManager.getName());
canteenDeliveryOrderSyncDTO.setLogoPic(hardManager.getLogoPic());
canteenDeliveryOrderSyncDTO.setFullAddress(dto.getAddress().getFullAddress());
List<CanteenDeliveryCartItemDTO> orderCarList = BeanUtils.toBean(carList, CanteenDeliveryCartItemDTO.class);
canteenDeliveryOrderSyncDTO.setCarList(orderCarList);
canteenDeliveryOrderSyncDTO.setDeptId(SecurityUtils.getUser().getDeptId());
canteenDeliveryOrderSyncDTO.setId(String.valueOf(orderObj.getId()));
canteenDeliveryOrderSyncDTO.setFullAddress(dto.getAddress().getFullAddress());
canteenDeliveryOrderSyncDTO.setBuildingId(dto.getAddress().getBuildingId());
remoteCanteenDeliveryOrderService.syncFromOrder(canteenDeliveryOrderSyncDTO);
return orderObj.getId();
}
@@ -157,6 +157,13 @@ public class SysUser extends Model<SysUser> implements Serializable {
@Schema(description = "手机号")
private String phone;
/**
* 性别:0未知 1男 2女(骑手派单等)
*/
@Schema(description = "性别:0未知 1男 2女")
@TableField("gender")
private Integer gender;
/**
* 头像
*/
@@ -164,6 +164,12 @@ public class UserVO implements BaseFacesInterface, Serializable {
@Schema(description = "手机号")
private String phone;
/**
* 性别:0未知 1男 2女
*/
@Schema(description = "性别:0未知 1男 2女")
private Integer gender;
/**
* 头像
*/
+5
View File
@@ -33,6 +33,11 @@
<description>pjcloud 通用用户权限管理系统业务处理模块</description>
<dependencies>
<!--websocket 支持-->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
@@ -74,6 +74,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.Objects;
/**
* @author pjcloud
@@ -569,7 +570,7 @@ public class SysUserController {
public R<List<UserSimpleVo>> getUserListByRoleCode(@RequestParam("code") String code) {
SysRole sysRole = sysRoleService.getOne(Wrappers.<SysRole>lambdaQuery().eq(SysRole::getRoleCode, code));
if (sysRole == null) {
return R.failed("不存在共富商城商户角色的用户!");
return R.failed("不存在角色用户!");
}
List<SysUserRole> list = sysUserRoleService.list(Wrappers.<SysUserRole>lambdaQuery().eq(SysUserRole::getRoleId, sysRole.getRoleId()));
List<Long> userIds = list.stream().map(sysUserRole -> sysUserRole.getUserId()).collect(Collectors.toList());
@@ -585,4 +586,41 @@ public class SysUserController {
}).collect(Collectors.toList());
return R.ok(userVOList);
}
/**
* 根据角色编码与单位部门(含其子部门)获取用户列表,用于下拉等场景
*/
@Operation(summary = "根据角色编码与单位部门查询用户简要列表", description = "仅返回 deptId 为该单位或其下级部门内的用户,返回字段同 getUserListByRoleCode")
@GetMapping("/getUserListByRoleCodeAndDept")
public R<List<UserSimpleVo>> getUserListByRoleCodeAndDept(@RequestParam("code") String code,
@RequestParam("deptId") Long deptId) {
if (deptId == null) {
return R.failed("deptId不能为空");
}
SysRole sysRole = sysRoleService.getOne(Wrappers.<SysRole>lambdaQuery().eq(SysRole::getRoleCode, code));
if (sysRole == null) {
return R.failed("不存在该角色!");
}
List<SysUserRole> roleRows = sysUserRoleService.list(Wrappers.<SysUserRole>lambdaQuery()
.eq(SysUserRole::getRoleId, sysRole.getRoleId()));
List<Long> userIds = roleRows.stream().map(SysUserRole::getUserId).collect(Collectors.toList());
if (CollUtil.isEmpty(userIds)) {
return R.ok(new ArrayList<>());
}
Set<Long> scopeDeptIds = sysDeptService.listDescendant(deptId).stream()
.map(SysDept::getDeptId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
List<SysUser> listData = userService.list(Wrappers.<SysUser>lambdaQuery().in(SysUser::getUserId, userIds));
List<UserSimpleVo> userVOList = listData.stream()
.filter(u -> u.getDeptId() != null && scopeDeptIds.contains(u.getDeptId()))
.map(sysUser -> {
UserSimpleVo userVO = new UserSimpleVo();
userVO.setUserId(sysUser.getUserId());
userVO.setPhone(sysUser.getPhone());
userVO.setName(sysUser.getName());
return userVO;
}).collect(Collectors.toList());
return R.ok(userVOList);
}
}
@@ -1,9 +1,21 @@
package com.bsd.pjcloud.admin.mapper.stats;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.bsd.pjcloud.admin.api.entity.stats.AppAccessAggDayEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
@Mapper
public interface AppAccessAggDayMapper extends PjcloudBaseMapper<AppAccessAggDayEntity> {
/**
* 物理删除该统计日下所有维度行(同分钟聚合,避免逻辑删除导致 uk_day_dim 重复)。
*/
@InterceptorIgnore(tenantLine = "true")
@Delete("DELETE FROM app_access_agg_day WHERE stat_date = #{statDate}")
int deletePhysicallyByStatDate(@Param("statDate") LocalDate statDate);
}
@@ -1,9 +1,21 @@
package com.bsd.pjcloud.admin.mapper.stats;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.bsd.pjcloud.admin.api.entity.stats.AppAccessAggMinuteEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
@Mapper
public interface AppAccessAggMinuteMapper extends PjcloudBaseMapper<AppAccessAggMinuteEntity> {
/**
* 物理删除该分钟桶下所有维度行。聚合任务不可用 {@link #delete},否则逻辑删除后唯一键仍冲突。
*/
@InterceptorIgnore(tenantLine = "true")
@Delete("DELETE FROM app_access_agg_minute WHERE bucket_time = #{bucketTime}")
int deletePhysicallyByBucketTime(@Param("bucketTime") LocalDateTime bucketTime);
}
@@ -285,8 +285,7 @@ public class AppAccessStatsServiceImpl implements AppAccessStatsService {
queryWrapper.groupBy("tenant_id", "unit_id", "dept_id", "page_code");
List<Map<String, Object>> rows = appAccessEventMapper.selectMaps(queryWrapper);
appAccessAggMinuteMapper.delete(new LambdaQueryWrapper<AppAccessAggMinuteEntity>()
.eq(AppAccessAggMinuteEntity::getBucketTime, start));
appAccessAggMinuteMapper.deletePhysicallyByBucketTime(start);
if (CollUtil.isEmpty(rows)) {
return;
}
@@ -336,8 +335,7 @@ public class AppAccessStatsServiceImpl implements AppAccessStatsService {
queryWrapper.groupBy("tenant_id", "unit_id", "dept_id", "page_code");
List<Map<String, Object>> rows = appAccessEventMapper.selectMaps(queryWrapper);
appAccessAggDayMapper.delete(new LambdaQueryWrapper<AppAccessAggDayEntity>()
.eq(AppAccessAggDayEntity::getStatDate, date));
appAccessAggDayMapper.deletePhysicallyByStatDate(date);
for (Map<String, Object> row : rows) {
AppAccessAggDayEntity entity = new AppAccessAggDayEntity();
entity.setStatDate(date);
@@ -23,4 +23,14 @@ qn:
accessKey: IVrAjjdrTeZi14pqDd2s5kb_iAIGXATEMtOgmQUF
secretKey: SNhPscR3_xqX3E_B1XtMMWmLMrz7ipcguzNykQvo
bucketName: bizcabinet
domain: https://bizcabinet.zianxin.com
domain: https://bizcabinet.zianxin.com
pjcloud:
websocket:
path: /ws/info # WebSocket 端点路径 (默认: /ws/info)
allow-origins: "*" # 允许的跨域源 (默认: *)
heartbeat: true # 是否启用心跳 (默认: true)
map-session: true # 是否启用 Session 映射 (默认: true)
message-distributor: redis # 消息分发器类型: local | redis (默认: local)
send-time-limit: 10000 # 发送时间限制,单位毫秒 (默认: 10000)
send-buffer-size-limit: 64000 # 发送缓冲区大小限制 (默认: 64000)
@@ -30,6 +30,7 @@
<result column="password" property="password"/>
<result column="salt" property="salt"/>
<result column="phone" property="phone"/>
<result column="gender" property="gender"/>
<result column="avatar" property="avatar"/>
<result column="wx_openid" property="wxOpenid"/>
<result column="qq_openid" property="qqOpenid"/>
@@ -69,6 +70,7 @@
u.password,
u.salt,
u.phone,
u.gender,
u.avatar,
u.wx_openid,
u.qq_openid,
@@ -102,6 +104,7 @@
u.password,
u.salt,
u.phone,
u.gender,
u.avatar,
u.wx_openid,
u.qq_openid,
@@ -157,6 +160,7 @@
u.password,
u.salt,
u.phone,
u.gender,
u.avatar,
u.wx_openid,
u.qq_openid,