Commit a9f9dcb32bbebfc40f34ae29a7999c0a6ffe083f
1 parent
3edd6bb9
新增自动派单服务及规则配置接口
Showing
20 changed files
with
1185 additions
and
15 deletions
src/main/java/com/diligrp/rider/common/enums/DispatchConditionType.java
0 → 100644
| 1 | +package com.diligrp.rider.common.enums; | |
| 2 | + | |
| 3 | +import lombok.Getter; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 派单优先级条件类型枚举 | |
| 7 | + */ | |
| 8 | +@Getter | |
| 9 | +public enum DispatchConditionType { | |
| 10 | + DISTANCE("distance", "距离取单地址", true), | |
| 11 | + DETOUR("detour", "顺路距离", true), | |
| 12 | + WAIT("wait", "等待新订单时间", true), | |
| 13 | + CURRENT_LOAD("currentLoad", "当前持单量", true), | |
| 14 | + DAILY("daily", "当日总接单量", true), | |
| 15 | + DIRECTION("direction", "目的地方向一致", false), | |
| 16 | + ROOKIE("rookie", "新手骑手保护", true), | |
| 17 | + PRAISE("praise", "好评率", true), | |
| 18 | + AREA_MATCH("areaMatch", "区域匹配", false); | |
| 19 | + | |
| 20 | + private final String code; | |
| 21 | + private final String desc; | |
| 22 | + /** 是否需要阈值参数 */ | |
| 23 | + private final boolean hasThreshold; | |
| 24 | + | |
| 25 | + DispatchConditionType(String code, String desc, boolean hasThreshold) { | |
| 26 | + this.code = code; | |
| 27 | + this.desc = desc; | |
| 28 | + this.hasThreshold = hasThreshold; | |
| 29 | + } | |
| 30 | + | |
| 31 | + public static DispatchConditionType fromCode(String code) { | |
| 32 | + for (DispatchConditionType type : values()) { | |
| 33 | + if (type.code.equals(code)) return type; | |
| 34 | + } | |
| 35 | + return null; | |
| 36 | + } | |
| 37 | +} | ... | ... |
src/main/java/com/diligrp/rider/config/AuthInterceptor.java
| ... | ... | @@ -56,9 +56,11 @@ public class AuthInterceptor implements HandlerInterceptor { |
| 56 | 56 | // 分站管理员:注入 cityId 供 Service 层做城市隔离 |
| 57 | 57 | if ("substation".equals(role)) { |
| 58 | 58 | Substation sub = substationMapper.selectById(adminId); |
| 59 | - if (sub != null) { | |
| 60 | - request.setAttribute("cityId", sub.getCityId()); | |
| 59 | + if (sub == null || sub.getCityId() == null || sub.getCityId() < 1) { | |
| 60 | + writeError(response, 403, "当前分站账号未绑定有效城市"); | |
| 61 | + return false; | |
| 61 | 62 | } |
| 63 | + request.setAttribute("cityId", sub.getCityId()); | |
| 62 | 64 | } |
| 63 | 65 | |
| 64 | 66 | } else if (claims.get("riderId") != null) { | ... | ... |
src/main/java/com/diligrp/rider/controller/AdminDispatchRuleController.java
0 → 100644
| 1 | +package com.diligrp.rider.controller; | |
| 2 | + | |
| 3 | +import com.diligrp.rider.common.exception.BizException; | |
| 4 | +import com.diligrp.rider.common.result.Result; | |
| 5 | +import com.diligrp.rider.dto.DispatchRuleTemplateSaveDTO; | |
| 6 | +import com.diligrp.rider.service.DispatchRuleService; | |
| 7 | +import com.diligrp.rider.vo.DispatchRuleTemplateVO; | |
| 8 | +import jakarta.servlet.http.HttpServletRequest; | |
| 9 | +import lombok.Data; | |
| 10 | +import lombok.RequiredArgsConstructor; | |
| 11 | +import org.springframework.web.bind.annotation.*; | |
| 12 | + | |
| 13 | +import java.util.List; | |
| 14 | + | |
| 15 | +@RestController | |
| 16 | +@RequestMapping("/api/admin/dispatch/rule") | |
| 17 | +@RequiredArgsConstructor | |
| 18 | +public class AdminDispatchRuleController { | |
| 19 | + | |
| 20 | + private final DispatchRuleService dispatchRuleService; | |
| 21 | + | |
| 22 | + /** 获取城市当前生效规则 */ | |
| 23 | + @GetMapping("") | |
| 24 | + public Result<DispatchRuleTemplateVO> getActiveRule(@RequestParam(required = false) Long cityId, | |
| 25 | + HttpServletRequest request) { | |
| 26 | + return Result.success(dispatchRuleService.getActiveRule(resolveCityId(cityId, request))); | |
| 27 | + } | |
| 28 | + | |
| 29 | + /** 获取城市所有模板列表 */ | |
| 30 | + @GetMapping("/list") | |
| 31 | + public Result<List<DispatchRuleTemplateVO>> listTemplates(@RequestParam(required = false) Long cityId, | |
| 32 | + HttpServletRequest request) { | |
| 33 | + return Result.success(dispatchRuleService.listTemplates(resolveCityId(cityId, request))); | |
| 34 | + } | |
| 35 | + | |
| 36 | + /** 保存/更新模板(含条件列表) */ | |
| 37 | + @PostMapping("/save") | |
| 38 | + public Result<Long> saveTemplate(@RequestBody DispatchRuleTemplateSaveDTO dto, HttpServletRequest request) { | |
| 39 | + return Result.success(dispatchRuleService.saveTemplate(resolveCityId(dto != null ? dto.getCityId() : null, request), dto)); | |
| 40 | + } | |
| 41 | + | |
| 42 | + /** 激活模板 */ | |
| 43 | + @PostMapping("/activate") | |
| 44 | + public Result<Void> activateTemplate(@RequestBody ActivateReq req, | |
| 45 | + @RequestParam(required = false) Long cityId, | |
| 46 | + HttpServletRequest request) { | |
| 47 | + dispatchRuleService.activateTemplate(resolveCityId(cityId, request), req.getTemplateId()); | |
| 48 | + return Result.success(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + /** 删除模板 */ | |
| 52 | + @DeleteMapping("/{id}") | |
| 53 | + public Result<Void> deleteTemplate(@PathVariable Long id, | |
| 54 | + @RequestParam(required = false) Long cityId, | |
| 55 | + HttpServletRequest request) { | |
| 56 | + dispatchRuleService.deleteTemplate(resolveCityId(cityId, request), id); | |
| 57 | + return Result.success(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + /** 复制模板 */ | |
| 61 | + @PostMapping("/copy") | |
| 62 | + public Result<Long> copyTemplate(@RequestBody CopyReq req, | |
| 63 | + @RequestParam(required = false) Long cityId, | |
| 64 | + HttpServletRequest request) { | |
| 65 | + return Result.success(dispatchRuleService.copyTemplate(resolveCityId(cityId, request), req.getTemplateId(), req.getNewName())); | |
| 66 | + } | |
| 67 | + | |
| 68 | + private Long resolveCityId(Long cityId, HttpServletRequest request) { | |
| 69 | + if ("substation".equals(request.getAttribute("role"))) { | |
| 70 | + Long requestCityId = (Long) request.getAttribute("cityId"); | |
| 71 | + if (requestCityId == null || requestCityId < 1) { | |
| 72 | + throw new BizException("当前账号未绑定城市"); | |
| 73 | + } | |
| 74 | + return requestCityId; | |
| 75 | + } | |
| 76 | + if (cityId == null || cityId < 1) { | |
| 77 | + throw new BizException("城市不能为空"); | |
| 78 | + } | |
| 79 | + return cityId; | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Data | |
| 83 | + static class ActivateReq { | |
| 84 | + private Long templateId; | |
| 85 | + } | |
| 86 | + | |
| 87 | + @Data | |
| 88 | + static class CopyReq { | |
| 89 | + private Long templateId; | |
| 90 | + private String newName; | |
| 91 | + } | |
| 92 | +} | ... | ... |
src/main/java/com/diligrp/rider/controller/AdminFeePlanController.java
0 → 100644
| 1 | +package com.diligrp.rider.controller; | |
| 2 | + | |
| 3 | +import com.diligrp.rider.common.exception.BizException; | |
| 4 | +import com.diligrp.rider.common.result.Result; | |
| 5 | +import com.diligrp.rider.dto.DeliveryFeePlanPreviewDTO; | |
| 6 | +import com.diligrp.rider.dto.DeliveryFeePlanSaveDTO; | |
| 7 | +import com.diligrp.rider.service.DeliveryFeePlanService; | |
| 8 | +import com.diligrp.rider.vo.DeliveryFeePlanDetailVO; | |
| 9 | +import com.diligrp.rider.vo.DeliveryFeePlanVO; | |
| 10 | +import com.diligrp.rider.vo.DeliveryFeeResultVO; | |
| 11 | +import jakarta.servlet.http.HttpServletRequest; | |
| 12 | +import lombok.RequiredArgsConstructor; | |
| 13 | +import org.springframework.web.bind.annotation.*; | |
| 14 | + | |
| 15 | +import java.util.List; | |
| 16 | + | |
| 17 | +@RestController | |
| 18 | +@RequestMapping("/api/admin/fee-plan") | |
| 19 | +@RequiredArgsConstructor | |
| 20 | +public class AdminFeePlanController { | |
| 21 | + | |
| 22 | + private final DeliveryFeePlanService deliveryFeePlanService; | |
| 23 | + | |
| 24 | + @GetMapping("/list") | |
| 25 | + public Result<List<DeliveryFeePlanVO>> listPlans(@RequestParam(required = false) Long cityId, | |
| 26 | + HttpServletRequest request) { | |
| 27 | + return Result.success(deliveryFeePlanService.listPlans(resolveCityId(cityId, request))); | |
| 28 | + } | |
| 29 | + | |
| 30 | + @GetMapping("/{planId}") | |
| 31 | + public Result<DeliveryFeePlanDetailVO> getPlanDetail(@PathVariable Long planId, | |
| 32 | + @RequestParam(required = false) Long cityId, | |
| 33 | + HttpServletRequest request) { | |
| 34 | + Long effectiveCityId = resolveCityId(cityId, request); | |
| 35 | + return Result.success(deliveryFeePlanService.getPlanDetail(effectiveCityId, planId)); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @PostMapping("") | |
| 39 | + public Result<Long> createPlan(@RequestParam(required = false) Long cityId, | |
| 40 | + @RequestBody DeliveryFeePlanSaveDTO dto, | |
| 41 | + HttpServletRequest request) { | |
| 42 | + return Result.success(deliveryFeePlanService.createPlan(resolveCityId(cityId, request), dto)); | |
| 43 | + } | |
| 44 | + | |
| 45 | + @PostMapping("/init-default") | |
| 46 | + public Result<Long> initializeDefaultPlan(@RequestParam(required = false) Long cityId, | |
| 47 | + HttpServletRequest request) { | |
| 48 | + return Result.success(deliveryFeePlanService.initializeDefaultPlan(resolveCityId(cityId, request))); | |
| 49 | + } | |
| 50 | + | |
| 51 | + @PutMapping("/{planId}") | |
| 52 | + public Result<Void> updatePlan(@PathVariable Long planId, | |
| 53 | + @RequestParam(required = false) Long cityId, | |
| 54 | + @RequestBody DeliveryFeePlanSaveDTO dto, | |
| 55 | + HttpServletRequest request) { | |
| 56 | + deliveryFeePlanService.updatePlan(resolveCityId(cityId, request), planId, dto); | |
| 57 | + return Result.success(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @PostMapping("/{planId}/copy") | |
| 61 | + public Result<Long> copyPlan(@PathVariable Long planId, | |
| 62 | + @RequestParam(required = false) Long cityId, | |
| 63 | + HttpServletRequest request) { | |
| 64 | + return Result.success(deliveryFeePlanService.copyPlan(resolveCityId(cityId, request), planId)); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @PostMapping("/{planId}/default") | |
| 68 | + public Result<Void> setDefaultPlan(@PathVariable Long planId, | |
| 69 | + @RequestParam(required = false) Long cityId, | |
| 70 | + HttpServletRequest request) { | |
| 71 | + deliveryFeePlanService.setDefaultPlan(resolveCityId(cityId, request), planId); | |
| 72 | + return Result.success(); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @DeleteMapping("/{planId}") | |
| 76 | + public Result<Void> deletePlan(@PathVariable Long planId, | |
| 77 | + @RequestParam(required = false) Long cityId, | |
| 78 | + HttpServletRequest request) { | |
| 79 | + deliveryFeePlanService.deletePlan(resolveCityId(cityId, request), planId); | |
| 80 | + return Result.success(); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @PostMapping("/preview") | |
| 84 | + public Result<DeliveryFeeResultVO> preview(@RequestParam(required = false) Long cityId, | |
| 85 | + @RequestBody DeliveryFeePlanPreviewDTO dto, | |
| 86 | + HttpServletRequest request) { | |
| 87 | + return Result.success(deliveryFeePlanService.preview(resolveCityId(cityId, request), dto)); | |
| 88 | + } | |
| 89 | + | |
| 90 | + private Long resolveCityId(Long cityId, HttpServletRequest request) { | |
| 91 | + if ("substation".equals(request.getAttribute("role"))) { | |
| 92 | + Long requestCityId = (Long) request.getAttribute("cityId"); | |
| 93 | + if (requestCityId == null || requestCityId < 1) { | |
| 94 | + throw new BizException("当前账号未绑定城市"); | |
| 95 | + } | |
| 96 | + return requestCityId; | |
| 97 | + } | |
| 98 | + if (cityId == null || cityId < 1) { | |
| 99 | + throw new BizException("城市不能为空"); | |
| 100 | + } | |
| 101 | + return cityId; | |
| 102 | + } | |
| 103 | +} | ... | ... |
src/main/java/com/diligrp/rider/dto/DispatchRuleTemplateSaveDTO.java
0 → 100644
| 1 | +package com.diligrp.rider.dto; | |
| 2 | + | |
| 3 | +import lombok.Data; | |
| 4 | + | |
| 5 | +import java.math.BigDecimal; | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Data | |
| 9 | +public class DispatchRuleTemplateSaveDTO { | |
| 10 | + | |
| 11 | + /** 模板ID(新建时为null) */ | |
| 12 | + private Long id; | |
| 13 | + | |
| 14 | + /** 城市ID */ | |
| 15 | + private Long cityId; | |
| 16 | + | |
| 17 | + /** 模板名称 */ | |
| 18 | + private String name; | |
| 19 | + | |
| 20 | + /** 抢单模式启用 */ | |
| 21 | + private Integer grabEnabled; | |
| 22 | + | |
| 23 | + /** 抢单超时分钟数 */ | |
| 24 | + private Integer grabTimeout; | |
| 25 | + | |
| 26 | + /** 抢单可见范围:1=区域骑手 2=全部 */ | |
| 27 | + private Integer grabScope; | |
| 28 | + | |
| 29 | + /** 单人最大同时持单量 */ | |
| 30 | + private Integer grabMaxPerRider; | |
| 31 | + | |
| 32 | + /** 同步开启自动派单 */ | |
| 33 | + private Integer autoDispatch; | |
| 34 | + | |
| 35 | + /** 条件列表 */ | |
| 36 | + private List<ConditionItem> conditions; | |
| 37 | + | |
| 38 | + @Data | |
| 39 | + public static class ConditionItem { | |
| 40 | + /** 条件类型 */ | |
| 41 | + private String conditionType; | |
| 42 | + /** 是否启用 */ | |
| 43 | + private Integer enabled; | |
| 44 | + /** 阈值 */ | |
| 45 | + private BigDecimal thresholdValue; | |
| 46 | + /** 排序 */ | |
| 47 | + private Integer sortOrder; | |
| 48 | + } | |
| 49 | +} | ... | ... |
src/main/java/com/diligrp/rider/entity/DispatchRuleCondition.java
0 → 100644
| 1 | +package com.diligrp.rider.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.*; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +import java.math.BigDecimal; | |
| 7 | + | |
| 8 | +@Data | |
| 9 | +@TableName("dispatch_rule_condition") | |
| 10 | +public class DispatchRuleCondition { | |
| 11 | + | |
| 12 | + @TableId(type = IdType.AUTO) | |
| 13 | + private Long id; | |
| 14 | + | |
| 15 | + /** 所属模板ID */ | |
| 16 | + private Long templateId; | |
| 17 | + | |
| 18 | + /** 条件类型 */ | |
| 19 | + private String conditionType; | |
| 20 | + | |
| 21 | + /** 是否启用 */ | |
| 22 | + private Integer enabled; | |
| 23 | + | |
| 24 | + /** 阈值 */ | |
| 25 | + private BigDecimal thresholdValue; | |
| 26 | + | |
| 27 | + /** 优先级排序(小的优先) */ | |
| 28 | + private Integer sortOrder; | |
| 29 | + | |
| 30 | + private Long createTime; | |
| 31 | + private Long updateTime; | |
| 32 | +} | ... | ... |
src/main/java/com/diligrp/rider/entity/DispatchRuleTemplate.java
0 → 100644
| 1 | +package com.diligrp.rider.entity; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.annotation.*; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +@Data | |
| 7 | +@TableName("dispatch_rule_template") | |
| 8 | +public class DispatchRuleTemplate { | |
| 9 | + | |
| 10 | + @TableId(type = IdType.AUTO) | |
| 11 | + private Long id; | |
| 12 | + | |
| 13 | + /** 城市ID */ | |
| 14 | + private Long cityId; | |
| 15 | + | |
| 16 | + /** 模板名称 */ | |
| 17 | + private String name; | |
| 18 | + | |
| 19 | + /** 是否当前生效:0=否 1=是 */ | |
| 20 | + private Integer isActive; | |
| 21 | + | |
| 22 | + /** 抢单模式启用 */ | |
| 23 | + private Integer grabEnabled; | |
| 24 | + | |
| 25 | + /** 抢单超时分钟数 */ | |
| 26 | + private Integer grabTimeout; | |
| 27 | + | |
| 28 | + /** 抢单可见范围:1=区域骑手 2=全部 */ | |
| 29 | + private Integer grabScope; | |
| 30 | + | |
| 31 | + /** 单人最大同时持单量 */ | |
| 32 | + private Integer grabMaxPerRider; | |
| 33 | + | |
| 34 | + /** 同步开启自动派单 */ | |
| 35 | + private Integer autoDispatch; | |
| 36 | + | |
| 37 | + private Long createTime; | |
| 38 | + private Long updateTime; | |
| 39 | +} | ... | ... |
src/main/java/com/diligrp/rider/entity/Orders.java
src/main/java/com/diligrp/rider/entity/Rider.java
| ... | ... | @@ -65,6 +65,12 @@ public class Rider { |
| 65 | 65 | /** 手持身份证照片 */ |
| 66 | 66 | private String thumb; |
| 67 | 67 | |
| 68 | + /** 评分总分 */ | |
| 69 | + private Integer starTotal; | |
| 70 | + | |
| 71 | + /** 评分次数 */ | |
| 72 | + private Integer starCount; | |
| 73 | + | |
| 68 | 74 | @TableField(fill = FieldFill.INSERT) |
| 69 | 75 | private Long createTime; |
| 70 | 76 | } | ... | ... |
src/main/java/com/diligrp/rider/mapper/DispatchRuleConditionMapper.java
0 → 100644
| 1 | +package com.diligrp.rider.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.diligrp.rider.entity.DispatchRuleCondition; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | + | |
| 7 | +@Mapper | |
| 8 | +public interface DispatchRuleConditionMapper extends BaseMapper<DispatchRuleCondition> { | |
| 9 | +} | ... | ... |
src/main/java/com/diligrp/rider/mapper/DispatchRuleTemplateMapper.java
0 → 100644
| 1 | +package com.diligrp.rider.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.diligrp.rider.entity.DispatchRuleTemplate; | |
| 5 | +import org.apache.ibatis.annotations.Mapper; | |
| 6 | + | |
| 7 | +@Mapper | |
| 8 | +public interface DispatchRuleTemplateMapper extends BaseMapper<DispatchRuleTemplate> { | |
| 9 | +} | ... | ... |
src/main/java/com/diligrp/rider/service/DispatchRuleService.java
0 → 100644
| 1 | +package com.diligrp.rider.service; | |
| 2 | + | |
| 3 | +import com.diligrp.rider.dto.DispatchRuleTemplateSaveDTO; | |
| 4 | +import com.diligrp.rider.vo.DispatchRuleTemplateVO; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +public interface DispatchRuleService { | |
| 9 | + | |
| 10 | + /** 获取城市当前生效的规则模板(含条件列表) */ | |
| 11 | + DispatchRuleTemplateVO getActiveRule(Long cityId); | |
| 12 | + | |
| 13 | + /** 获取城市所有规则模板列表 */ | |
| 14 | + List<DispatchRuleTemplateVO> listTemplates(Long cityId); | |
| 15 | + | |
| 16 | + /** 保存/更新规则模板(含条件列表,整体替换) */ | |
| 17 | + Long saveTemplate(Long cityId, DispatchRuleTemplateSaveDTO dto); | |
| 18 | + | |
| 19 | + /** 激活指定模板(同城市其他模板自动失效) */ | |
| 20 | + void activateTemplate(Long cityId, Long templateId); | |
| 21 | + | |
| 22 | + /** 删除模板(非激活状态才可删) */ | |
| 23 | + void deleteTemplate(Long cityId, Long templateId); | |
| 24 | + | |
| 25 | + /** 复制模板 */ | |
| 26 | + Long copyTemplate(Long cityId, Long templateId, String newName); | |
| 27 | +} | ... | ... |
src/main/java/com/diligrp/rider/service/DispatchService.java
0 → 100644
src/main/java/com/diligrp/rider/service/impl/DispatchRuleServiceImpl.java
0 → 100644
| 1 | +package com.diligrp.rider.service.impl; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | |
| 4 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | |
| 5 | +import com.diligrp.rider.common.enums.DispatchConditionType; | |
| 6 | +import com.diligrp.rider.common.exception.BizException; | |
| 7 | +import com.diligrp.rider.dto.DispatchRuleTemplateSaveDTO; | |
| 8 | +import com.diligrp.rider.entity.DispatchRuleCondition; | |
| 9 | +import com.diligrp.rider.entity.DispatchRuleTemplate; | |
| 10 | +import com.diligrp.rider.mapper.DispatchRuleConditionMapper; | |
| 11 | +import com.diligrp.rider.mapper.DispatchRuleTemplateMapper; | |
| 12 | +import com.diligrp.rider.service.DispatchRuleService; | |
| 13 | +import com.diligrp.rider.vo.DispatchRuleTemplateVO; | |
| 14 | +import lombok.RequiredArgsConstructor; | |
| 15 | +import org.springframework.stereotype.Service; | |
| 16 | +import org.springframework.transaction.annotation.Transactional; | |
| 17 | + | |
| 18 | +import java.math.BigDecimal; | |
| 19 | +import java.util.ArrayList; | |
| 20 | +import java.util.List; | |
| 21 | + | |
| 22 | +@Service | |
| 23 | +@RequiredArgsConstructor | |
| 24 | +public class DispatchRuleServiceImpl implements DispatchRuleService { | |
| 25 | + | |
| 26 | + private final DispatchRuleTemplateMapper templateMapper; | |
| 27 | + private final DispatchRuleConditionMapper conditionMapper; | |
| 28 | + | |
| 29 | + @Override | |
| 30 | + public DispatchRuleTemplateVO getActiveRule(Long cityId) { | |
| 31 | + DispatchRuleTemplate template = templateMapper.selectOne( | |
| 32 | + new LambdaQueryWrapper<DispatchRuleTemplate>() | |
| 33 | + .eq(DispatchRuleTemplate::getCityId, cityId) | |
| 34 | + .eq(DispatchRuleTemplate::getIsActive, 1) | |
| 35 | + .last("LIMIT 1")); | |
| 36 | + if (template == null) return null; | |
| 37 | + return toVO(template); | |
| 38 | + } | |
| 39 | + | |
| 40 | + @Override | |
| 41 | + public List<DispatchRuleTemplateVO> listTemplates(Long cityId) { | |
| 42 | + List<DispatchRuleTemplate> templates = templateMapper.selectList( | |
| 43 | + new LambdaQueryWrapper<DispatchRuleTemplate>() | |
| 44 | + .eq(DispatchRuleTemplate::getCityId, cityId) | |
| 45 | + .orderByDesc(DispatchRuleTemplate::getIsActive) | |
| 46 | + .orderByDesc(DispatchRuleTemplate::getUpdateTime)); | |
| 47 | + List<DispatchRuleTemplateVO> result = new ArrayList<>(); | |
| 48 | + for (DispatchRuleTemplate t : templates) { | |
| 49 | + result.add(toVO(t)); | |
| 50 | + } | |
| 51 | + return result; | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Override | |
| 55 | + @Transactional | |
| 56 | + public Long saveTemplate(Long cityId, DispatchRuleTemplateSaveDTO dto) { | |
| 57 | + if (cityId == null || cityId < 1) throw new BizException("城市ID不能为空"); | |
| 58 | + if (dto == null) throw new BizException("请求参数不能为空"); | |
| 59 | + if (dto.getName() == null || dto.getName().isBlank()) throw new BizException("模板名称不能为空"); | |
| 60 | + | |
| 61 | + long now = System.currentTimeMillis() / 1000; | |
| 62 | + DispatchRuleTemplate template; | |
| 63 | + | |
| 64 | + if (dto.getId() != null) { | |
| 65 | + template = requireTemplate(cityId, dto.getId()); | |
| 66 | + } else { | |
| 67 | + template = new DispatchRuleTemplate(); | |
| 68 | + template.setCityId(cityId); | |
| 69 | + template.setIsActive(0); | |
| 70 | + template.setCreateTime(now); | |
| 71 | + } | |
| 72 | + | |
| 73 | + template.setName(dto.getName()); | |
| 74 | + template.setGrabEnabled(dto.getGrabEnabled() != null ? dto.getGrabEnabled() : 1); | |
| 75 | + template.setGrabTimeout(dto.getGrabTimeout() != null ? dto.getGrabTimeout() : 30); | |
| 76 | + template.setGrabScope(dto.getGrabScope() != null ? dto.getGrabScope() : 1); | |
| 77 | + template.setGrabMaxPerRider(dto.getGrabMaxPerRider() != null ? dto.getGrabMaxPerRider() : 3); | |
| 78 | + template.setAutoDispatch(dto.getAutoDispatch() != null ? dto.getAutoDispatch() : 1); | |
| 79 | + template.setUpdateTime(now); | |
| 80 | + | |
| 81 | + if (dto.getId() != null) { | |
| 82 | + templateMapper.updateById(template); | |
| 83 | + } else { | |
| 84 | + templateMapper.insert(template); | |
| 85 | + } | |
| 86 | + | |
| 87 | + // 整体替换条件列表 | |
| 88 | + conditionMapper.delete(new LambdaQueryWrapper<DispatchRuleCondition>() | |
| 89 | + .eq(DispatchRuleCondition::getTemplateId, template.getId())); | |
| 90 | + | |
| 91 | + if (dto.getConditions() != null) { | |
| 92 | + for (DispatchRuleTemplateSaveDTO.ConditionItem item : dto.getConditions()) { | |
| 93 | + DispatchConditionType type = DispatchConditionType.fromCode(item.getConditionType()); | |
| 94 | + if (type == null) continue; | |
| 95 | + | |
| 96 | + DispatchRuleCondition condition = new DispatchRuleCondition(); | |
| 97 | + condition.setTemplateId(template.getId()); | |
| 98 | + condition.setConditionType(item.getConditionType()); | |
| 99 | + condition.setEnabled(item.getEnabled() != null ? item.getEnabled() : 1); | |
| 100 | + condition.setThresholdValue(item.getThresholdValue() != null ? item.getThresholdValue() : BigDecimal.ZERO); | |
| 101 | + condition.setSortOrder(item.getSortOrder() != null ? item.getSortOrder() : 0); | |
| 102 | + condition.setCreateTime(now); | |
| 103 | + condition.setUpdateTime(now); | |
| 104 | + conditionMapper.insert(condition); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + return template.getId(); | |
| 109 | + } | |
| 110 | + | |
| 111 | + @Override | |
| 112 | + @Transactional | |
| 113 | + public void activateTemplate(Long cityId, Long templateId) { | |
| 114 | + DispatchRuleTemplate template = requireTemplate(cityId, templateId); | |
| 115 | + | |
| 116 | + templateMapper.update(null, new LambdaUpdateWrapper<DispatchRuleTemplate>() | |
| 117 | + .eq(DispatchRuleTemplate::getCityId, template.getCityId()) | |
| 118 | + .set(DispatchRuleTemplate::getIsActive, 0)); | |
| 119 | + | |
| 120 | + templateMapper.update(null, new LambdaUpdateWrapper<DispatchRuleTemplate>() | |
| 121 | + .eq(DispatchRuleTemplate::getId, templateId) | |
| 122 | + .eq(DispatchRuleTemplate::getCityId, cityId) | |
| 123 | + .set(DispatchRuleTemplate::getIsActive, 1) | |
| 124 | + .set(DispatchRuleTemplate::getUpdateTime, System.currentTimeMillis() / 1000)); | |
| 125 | + } | |
| 126 | + | |
| 127 | + @Override | |
| 128 | + @Transactional | |
| 129 | + public void deleteTemplate(Long cityId, Long templateId) { | |
| 130 | + DispatchRuleTemplate template = requireTemplate(cityId, templateId); | |
| 131 | + if (template.getIsActive() == 1) throw new BizException("不能删除当前生效的模板"); | |
| 132 | + | |
| 133 | + conditionMapper.delete(new LambdaQueryWrapper<DispatchRuleCondition>() | |
| 134 | + .eq(DispatchRuleCondition::getTemplateId, templateId)); | |
| 135 | + templateMapper.deleteById(templateId); | |
| 136 | + } | |
| 137 | + | |
| 138 | + @Override | |
| 139 | + @Transactional | |
| 140 | + public Long copyTemplate(Long cityId, Long templateId, String newName) { | |
| 141 | + DispatchRuleTemplate source = requireTemplate(cityId, templateId); | |
| 142 | + | |
| 143 | + long now = System.currentTimeMillis() / 1000; | |
| 144 | + | |
| 145 | + DispatchRuleTemplate copy = new DispatchRuleTemplate(); | |
| 146 | + copy.setCityId(cityId); | |
| 147 | + copy.setName(newName != null && !newName.isBlank() ? newName : source.getName() + "(副本)"); | |
| 148 | + copy.setIsActive(0); | |
| 149 | + copy.setGrabEnabled(source.getGrabEnabled()); | |
| 150 | + copy.setGrabTimeout(source.getGrabTimeout()); | |
| 151 | + copy.setGrabScope(source.getGrabScope()); | |
| 152 | + copy.setGrabMaxPerRider(source.getGrabMaxPerRider()); | |
| 153 | + copy.setAutoDispatch(source.getAutoDispatch()); | |
| 154 | + copy.setCreateTime(now); | |
| 155 | + copy.setUpdateTime(now); | |
| 156 | + templateMapper.insert(copy); | |
| 157 | + | |
| 158 | + // 复制条件 | |
| 159 | + List<DispatchRuleCondition> conditions = conditionMapper.selectList( | |
| 160 | + new LambdaQueryWrapper<DispatchRuleCondition>() | |
| 161 | + .eq(DispatchRuleCondition::getTemplateId, templateId)); | |
| 162 | + for (DispatchRuleCondition c : conditions) { | |
| 163 | + DispatchRuleCondition nc = new DispatchRuleCondition(); | |
| 164 | + nc.setTemplateId(copy.getId()); | |
| 165 | + nc.setConditionType(c.getConditionType()); | |
| 166 | + nc.setEnabled(c.getEnabled()); | |
| 167 | + nc.setThresholdValue(c.getThresholdValue()); | |
| 168 | + nc.setSortOrder(c.getSortOrder()); | |
| 169 | + nc.setCreateTime(now); | |
| 170 | + nc.setUpdateTime(now); | |
| 171 | + conditionMapper.insert(nc); | |
| 172 | + } | |
| 173 | + | |
| 174 | + return copy.getId(); | |
| 175 | + } | |
| 176 | + | |
| 177 | + private DispatchRuleTemplate requireTemplate(Long cityId, Long templateId) { | |
| 178 | + if (cityId == null || cityId < 1) throw new BizException("城市ID不能为空"); | |
| 179 | + DispatchRuleTemplate template = templateMapper.selectOne( | |
| 180 | + new LambdaQueryWrapper<DispatchRuleTemplate>() | |
| 181 | + .eq(DispatchRuleTemplate::getId, templateId) | |
| 182 | + .eq(DispatchRuleTemplate::getCityId, cityId) | |
| 183 | + .last("LIMIT 1")); | |
| 184 | + if (template == null) throw new BizException("模板不存在"); | |
| 185 | + return template; | |
| 186 | + } | |
| 187 | + | |
| 188 | + private DispatchRuleTemplateVO toVO(DispatchRuleTemplate template) { | |
| 189 | + DispatchRuleTemplateVO vo = new DispatchRuleTemplateVO(); | |
| 190 | + vo.setId(template.getId()); | |
| 191 | + vo.setCityId(template.getCityId()); | |
| 192 | + vo.setName(template.getName()); | |
| 193 | + vo.setIsActive(template.getIsActive()); | |
| 194 | + vo.setGrabEnabled(template.getGrabEnabled()); | |
| 195 | + vo.setGrabTimeout(template.getGrabTimeout()); | |
| 196 | + vo.setGrabScope(template.getGrabScope()); | |
| 197 | + vo.setGrabMaxPerRider(template.getGrabMaxPerRider()); | |
| 198 | + vo.setAutoDispatch(template.getAutoDispatch()); | |
| 199 | + vo.setCreateTime(template.getCreateTime()); | |
| 200 | + vo.setUpdateTime(template.getUpdateTime()); | |
| 201 | + | |
| 202 | + List<DispatchRuleCondition> conditions = conditionMapper.selectList( | |
| 203 | + new LambdaQueryWrapper<DispatchRuleCondition>() | |
| 204 | + .eq(DispatchRuleCondition::getTemplateId, template.getId()) | |
| 205 | + .orderByAsc(DispatchRuleCondition::getSortOrder)); | |
| 206 | + | |
| 207 | + List<DispatchRuleTemplateVO.ConditionItem> items = new ArrayList<>(); | |
| 208 | + for (DispatchRuleCondition c : conditions) { | |
| 209 | + DispatchRuleTemplateVO.ConditionItem item = new DispatchRuleTemplateVO.ConditionItem(); | |
| 210 | + item.setId(c.getId()); | |
| 211 | + item.setConditionType(c.getConditionType()); | |
| 212 | + item.setEnabled(c.getEnabled()); | |
| 213 | + item.setThresholdValue(c.getThresholdValue()); | |
| 214 | + item.setSortOrder(c.getSortOrder()); | |
| 215 | + | |
| 216 | + DispatchConditionType type = DispatchConditionType.fromCode(c.getConditionType()); | |
| 217 | + if (type != null) { | |
| 218 | + item.setConditionDesc(type.getDesc()); | |
| 219 | + item.setHasThreshold(type.isHasThreshold()); | |
| 220 | + } | |
| 221 | + items.add(item); | |
| 222 | + } | |
| 223 | + vo.setConditions(items); | |
| 224 | + return vo; | |
| 225 | + } | |
| 226 | +} | ... | ... |
src/main/java/com/diligrp/rider/service/impl/DispatchServiceImpl.java
0 → 100644
| 1 | +package com.diligrp.rider.service.impl; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | |
| 4 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | |
| 5 | +import com.diligrp.rider.common.enums.DispatchConditionType; | |
| 6 | +import com.diligrp.rider.entity.*; | |
| 7 | +import com.diligrp.rider.mapper.*; | |
| 8 | +import com.diligrp.rider.service.DispatchRuleService; | |
| 9 | +import com.diligrp.rider.service.DispatchService; | |
| 10 | +import com.diligrp.rider.service.WebhookService; | |
| 11 | +import com.diligrp.rider.util.GeoUtil; | |
| 12 | +import com.diligrp.rider.vo.DispatchRuleTemplateVO; | |
| 13 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 14 | +import lombok.RequiredArgsConstructor; | |
| 15 | +import lombok.extern.slf4j.Slf4j; | |
| 16 | +import org.springframework.stereotype.Service; | |
| 17 | +import org.springframework.transaction.annotation.Transactional; | |
| 18 | + | |
| 19 | +import java.math.BigDecimal; | |
| 20 | +import java.time.LocalDate; | |
| 21 | +import java.time.ZoneId; | |
| 22 | +import java.time.format.DateTimeFormatter; | |
| 23 | +import java.util.*; | |
| 24 | + | |
| 25 | +@Slf4j | |
| 26 | +@Service | |
| 27 | +@RequiredArgsConstructor | |
| 28 | +public class DispatchServiceImpl implements DispatchService { | |
| 29 | + | |
| 30 | + private final DispatchRuleService dispatchRuleService; | |
| 31 | + private final RiderMapper riderMapper; | |
| 32 | + private final RiderLocationMapper locationMapper; | |
| 33 | + private final OrdersMapper ordersMapper; | |
| 34 | + private final RiderOrderCountMapper countMapper; | |
| 35 | + private final RiderOrderRefuseMapper refuseMapper; | |
| 36 | + private final WebhookService webhookService; | |
| 37 | + private final ObjectMapper objectMapper; | |
| 38 | + | |
| 39 | + @Override | |
| 40 | + @Transactional | |
| 41 | + public Long dispatch(Orders order) { | |
| 42 | + DispatchRuleTemplateVO rule = dispatchRuleService.getActiveRule(order.getCityId()); | |
| 43 | + if (rule == null) { | |
| 44 | + log.debug("城市 {} 无生效调度规则,跳过派单", order.getCityId()); | |
| 45 | + return null; | |
| 46 | + } | |
| 47 | + if (rule.getAutoDispatch() == null || rule.getAutoDispatch() != 1) { | |
| 48 | + return null; | |
| 49 | + } | |
| 50 | + | |
| 51 | + // 1. 获取候选骑手:同城市、在线、审核通过 | |
| 52 | + List<Rider> candidates = riderMapper.selectList( | |
| 53 | + new LambdaQueryWrapper<Rider>() | |
| 54 | + .eq(Rider::getCityId, order.getCityId()) | |
| 55 | + .eq(Rider::getIsRest, 0) | |
| 56 | + .eq(Rider::getUserStatus, 1) | |
| 57 | + .eq(Rider::getStatus, 1)); | |
| 58 | + if (candidates.isEmpty()) { | |
| 59 | + log.debug("城市 {} 无在线骑手", order.getCityId()); | |
| 60 | + return null; | |
| 61 | + } | |
| 62 | + | |
| 63 | + // 排除已拒单的骑手 | |
| 64 | + List<Long> refusedRiderIds = getRefusedRiderIds(order.getId()); | |
| 65 | + if (!refusedRiderIds.isEmpty()) { | |
| 66 | + candidates.removeIf(r -> refusedRiderIds.contains(r.getId())); | |
| 67 | + } | |
| 68 | + | |
| 69 | + // 排除转出该单的骑手 | |
| 70 | + if (order.getOldRiderId() != null && order.getOldRiderId() > 0) { | |
| 71 | + candidates.removeIf(r -> r.getId().equals(order.getOldRiderId())); | |
| 72 | + } | |
| 73 | + | |
| 74 | + if (candidates.isEmpty()) return null; | |
| 75 | + | |
| 76 | + // 2. 获取骑手位置 | |
| 77 | + List<Long> riderIds = candidates.stream().map(Rider::getId).toList(); | |
| 78 | + Map<Long, RiderLocation> locationMap = getLocationMap(riderIds); | |
| 79 | + | |
| 80 | + // 3. 获取启用的条件列表(按 sortOrder 升序) | |
| 81 | + List<DispatchRuleTemplateVO.ConditionItem> enabledConditions = new ArrayList<>(); | |
| 82 | + if (rule.getConditions() != null) { | |
| 83 | + for (DispatchRuleTemplateVO.ConditionItem c : rule.getConditions()) { | |
| 84 | + if (c.getEnabled() != null && c.getEnabled() == 1) { | |
| 85 | + enabledConditions.add(c); | |
| 86 | + } | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + // 4. 获取订单取货点坐标 | |
| 91 | + double orderLat = parseDouble(order.getFLat()); | |
| 92 | + double orderLng = parseDouble(order.getFLng()); | |
| 93 | + double orderTLat = parseDouble(order.getTLat()); | |
| 94 | + double orderTLng = parseDouble(order.getTLng()); | |
| 95 | + | |
| 96 | + // 5. 预加载每个骑手的统计数据 | |
| 97 | + Map<Long, Integer> currentLoadMap = getCurrentLoadMap(riderIds); | |
| 98 | + Map<Long, Integer> dailyCountMap = getDailyCountMap(riderIds); | |
| 99 | + | |
| 100 | + // 6. 对每个候选骑手评分 | |
| 101 | + int totalConditions = enabledConditions.size(); | |
| 102 | + List<RiderScore> scores = new ArrayList<>(); | |
| 103 | + | |
| 104 | + for (Rider rider : candidates) { | |
| 105 | + RiderLocation loc = locationMap.get(rider.getId()); | |
| 106 | + if (loc == null) continue; // 无位置信息的骑手不参与派单 | |
| 107 | + | |
| 108 | + double riderLat = parseDouble(loc.getLat()); | |
| 109 | + double riderLng = parseDouble(loc.getLng()); | |
| 110 | + double distanceToPickup = GeoUtil.calcDistanceKm(riderLat, riderLng, orderLat, orderLng); | |
| 111 | + | |
| 112 | + // 持单量检查(强制过滤) | |
| 113 | + int currentLoad = currentLoadMap.getOrDefault(rider.getId(), 0); | |
| 114 | + if (rule.getGrabMaxPerRider() != null && currentLoad >= rule.getGrabMaxPerRider()) { | |
| 115 | + continue; // 持单已满,跳过 | |
| 116 | + } | |
| 117 | + | |
| 118 | + double score = 0; | |
| 119 | + boolean filtered = false; | |
| 120 | + | |
| 121 | + for (int i = 0; i < enabledConditions.size(); i++) { | |
| 122 | + DispatchRuleTemplateVO.ConditionItem cond = enabledConditions.get(i); | |
| 123 | + double weight = totalConditions - i; // 排在前面的权重更高 | |
| 124 | + double condScore = evaluateCondition(cond, rider, loc, order, | |
| 125 | + distanceToPickup, riderLat, riderLng, orderLat, orderLng, orderTLat, orderTLng, | |
| 126 | + currentLoad, dailyCountMap.getOrDefault(rider.getId(), 0)); | |
| 127 | + | |
| 128 | + if (condScore < 0) { | |
| 129 | + // 负分表示强制不通过(如距离超出阈值) | |
| 130 | + filtered = true; | |
| 131 | + break; | |
| 132 | + } | |
| 133 | + score += condScore * weight; | |
| 134 | + } | |
| 135 | + | |
| 136 | + if (!filtered) { | |
| 137 | + scores.add(new RiderScore(rider.getId(), score, distanceToPickup)); | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + if (scores.isEmpty()) { | |
| 142 | + log.debug("订单 {} 无匹配骑手", order.getId()); | |
| 143 | + return null; | |
| 144 | + } | |
| 145 | + | |
| 146 | + // 7. 选择最优骑手:分数最高,平分取距离最近 | |
| 147 | + scores.sort((a, b) -> { | |
| 148 | + int cmp = Double.compare(b.score, a.score); | |
| 149 | + if (cmp != 0) return cmp; | |
| 150 | + return Double.compare(a.distance, b.distance); | |
| 151 | + }); | |
| 152 | + | |
| 153 | + Long bestRiderId = scores.get(0).riderId; | |
| 154 | + | |
| 155 | + // 8. 指派订单 | |
| 156 | + long now = System.currentTimeMillis() / 1000; | |
| 157 | + int updated = ordersMapper.update(null, new LambdaUpdateWrapper<Orders>() | |
| 158 | + .eq(Orders::getId, order.getId()) | |
| 159 | + .eq(Orders::getStatus, 2) | |
| 160 | + .eq(Orders::getRiderId, 0) | |
| 161 | + .set(Orders::getRiderId, bestRiderId) | |
| 162 | + .set(Orders::getStatus, 3) | |
| 163 | + .set(Orders::getGrapTime, now) | |
| 164 | + .set(Orders::getDispatchTime, now) | |
| 165 | + .set(Orders::getDispatchRiderId, bestRiderId) | |
| 166 | + .set(Orders::getOldRiderId, bestRiderId)); | |
| 167 | + | |
| 168 | + if (updated == 0) { | |
| 169 | + log.debug("订单 {} 派单 CAS 失败(可能已被抢单)", order.getId()); | |
| 170 | + return null; | |
| 171 | + } | |
| 172 | + | |
| 173 | + log.info("订单 {} 自动派单给骑手 {},评分={}", order.getId(), bestRiderId, scores.get(0).score); | |
| 174 | + | |
| 175 | + // 通知接入方 | |
| 176 | + notifyOrderEvent(order.getId(), "order.dispatched"); | |
| 177 | + | |
| 178 | + // 清除拒单记录 | |
| 179 | + refuseMapper.delete(new LambdaQueryWrapper<RiderOrderRefuse>() | |
| 180 | + .eq(RiderOrderRefuse::getOid, order.getId())); | |
| 181 | + | |
| 182 | + return bestRiderId; | |
| 183 | + } | |
| 184 | + | |
| 185 | + /** | |
| 186 | + * 评估单个条件,返回得分 | |
| 187 | + * @return 得分:1.0=完全匹配, 0~1=部分匹配, -1=强制不通过 | |
| 188 | + */ | |
| 189 | + private double evaluateCondition(DispatchRuleTemplateVO.ConditionItem cond, | |
| 190 | + Rider rider, RiderLocation loc, Orders order, | |
| 191 | + double distanceToPickup, | |
| 192 | + double riderLat, double riderLng, | |
| 193 | + double orderLat, double orderLng, | |
| 194 | + double orderTLat, double orderTLng, | |
| 195 | + int currentLoad, int dailyCount) { | |
| 196 | + DispatchConditionType type = DispatchConditionType.fromCode(cond.getConditionType()); | |
| 197 | + if (type == null) return 0; | |
| 198 | + | |
| 199 | + double threshold = cond.getThresholdValue() != null ? cond.getThresholdValue().doubleValue() : 0; | |
| 200 | + | |
| 201 | + return switch (type) { | |
| 202 | + case DISTANCE -> { | |
| 203 | + // 距离取单地址 ≤ N 公里,超过则过滤 | |
| 204 | + if (threshold > 0 && distanceToPickup > threshold) yield -1; | |
| 205 | + // 距离越近分越高(归一化到 0~1) | |
| 206 | + yield threshold > 0 ? Math.max(0, 1.0 - distanceToPickup / threshold) : 1.0; | |
| 207 | + } | |
| 208 | + case DETOUR -> { | |
| 209 | + // 顺路距离:骑手当前有在途订单时,计算绕路程度 | |
| 210 | + // 简化实现:以骑手到取货点的距离作为近似 | |
| 211 | + if (threshold > 0 && distanceToPickup > threshold) yield -1; | |
| 212 | + yield threshold > 0 ? Math.max(0, 1.0 - distanceToPickup / threshold) : 1.0; | |
| 213 | + } | |
| 214 | + case WAIT -> { | |
| 215 | + // 等待新订单时间 ≥ N 分钟:骑手最后完成订单距今 | |
| 216 | + long lastCompleteTime = getLastCompleteTime(rider.getId()); | |
| 217 | + long now = System.currentTimeMillis() / 1000; | |
| 218 | + double waitMinutes = lastCompleteTime > 0 ? (now - lastCompleteTime) / 60.0 : 999; | |
| 219 | + yield waitMinutes >= threshold ? 1.0 : waitMinutes / threshold; | |
| 220 | + } | |
| 221 | + case CURRENT_LOAD -> { | |
| 222 | + // 当前持单量 < N | |
| 223 | + if (threshold > 0 && currentLoad >= threshold) yield -1; | |
| 224 | + yield threshold > 0 ? Math.max(0, 1.0 - currentLoad / threshold) : 1.0; | |
| 225 | + } | |
| 226 | + case DAILY -> { | |
| 227 | + // 当日总接单量 < N | |
| 228 | + if (threshold > 0 && dailyCount >= threshold) yield -1; | |
| 229 | + yield threshold > 0 ? Math.max(0, 1.0 - dailyCount / threshold) : 1.0; | |
| 230 | + } | |
| 231 | + case DIRECTION -> { | |
| 232 | + // 目的地方向一致性:骑手位置到订单取货点的方向 vs 取货点到收货点的方向 | |
| 233 | + double bearingToPickup = calcBearing(riderLat, riderLng, orderLat, orderLng); | |
| 234 | + double bearingPickupToDest = calcBearing(orderLat, orderLng, orderTLat, orderTLng); | |
| 235 | + double diff = Math.abs(bearingToPickup - bearingPickupToDest); | |
| 236 | + if (diff > 180) diff = 360 - diff; | |
| 237 | + // 方向差 ≤ 45度算顺路 | |
| 238 | + yield diff <= 45 ? 1.0 : (diff <= 90 ? 0.5 : 0); | |
| 239 | + } | |
| 240 | + case ROOKIE -> { | |
| 241 | + // 新手保护:入职 ≤ N 天的骑手优先 | |
| 242 | + long createTime = rider.getCreateTime() != null ? rider.getCreateTime() : 0; | |
| 243 | + long now = System.currentTimeMillis() / 1000; | |
| 244 | + double daysRegistered = (now - createTime) / 86400.0; | |
| 245 | + yield daysRegistered <= threshold ? 1.0 : 0; | |
| 246 | + } | |
| 247 | + case PRAISE -> { | |
| 248 | + // 好评率 ≥ N% | |
| 249 | + int starTotal = rider.getStarTotal() != null ? rider.getStarTotal() : 0; | |
| 250 | + int starCount = rider.getStarCount() != null ? rider.getStarCount() : 0; | |
| 251 | + if (starCount == 0) yield 0.5; // 无评价的骑手给中间分 | |
| 252 | + double avgStar = (double) starTotal / starCount; | |
| 253 | + double praiseRate = avgStar / 5.0 * 100; // 转换为百分比 | |
| 254 | + yield praiseRate >= threshold ? 1.0 : praiseRate / threshold; | |
| 255 | + } | |
| 256 | + case AREA_MATCH -> { | |
| 257 | + // 区域匹配:骑手当前所在城市与订单城市匹配 | |
| 258 | + // 目前按 city_id 匹配(候选骑手已按 cityId 过滤,所以这里总是匹配的) | |
| 259 | + // 后续可扩展为更细粒度的区域 | |
| 260 | + yield 1.0; | |
| 261 | + } | |
| 262 | + }; | |
| 263 | + } | |
| 264 | + | |
| 265 | + // ---- 辅助方法 ---- | |
| 266 | + | |
| 267 | + private List<Long> getRefusedRiderIds(Long orderId) { | |
| 268 | + List<RiderOrderRefuse> refuses = refuseMapper.selectList( | |
| 269 | + new LambdaQueryWrapper<RiderOrderRefuse>() | |
| 270 | + .eq(RiderOrderRefuse::getOid, orderId) | |
| 271 | + .select(RiderOrderRefuse::getRiderId)); | |
| 272 | + return refuses.stream().map(RiderOrderRefuse::getRiderId).toList(); | |
| 273 | + } | |
| 274 | + | |
| 275 | + private Map<Long, RiderLocation> getLocationMap(List<Long> riderIds) { | |
| 276 | + if (riderIds.isEmpty()) return Map.of(); | |
| 277 | + List<RiderLocation> locations = locationMapper.selectList( | |
| 278 | + new LambdaQueryWrapper<RiderLocation>().in(RiderLocation::getUid, riderIds)); | |
| 279 | + Map<Long, RiderLocation> map = new HashMap<>(); | |
| 280 | + for (RiderLocation loc : locations) { | |
| 281 | + map.put(loc.getUid(), loc); | |
| 282 | + } | |
| 283 | + return map; | |
| 284 | + } | |
| 285 | + | |
| 286 | + /** 获取每个骑手当前持单量(status=3 或 4 的订单数) */ | |
| 287 | + private Map<Long, Integer> getCurrentLoadMap(List<Long> riderIds) { | |
| 288 | + Map<Long, Integer> map = new HashMap<>(); | |
| 289 | + if (riderIds.isEmpty()) return map; | |
| 290 | + List<Orders> inProgress = ordersMapper.selectList( | |
| 291 | + new LambdaQueryWrapper<Orders>() | |
| 292 | + .in(Orders::getRiderId, riderIds) | |
| 293 | + .in(Orders::getStatus, List.of(3, 4)) | |
| 294 | + .select(Orders::getRiderId)); | |
| 295 | + for (Orders o : inProgress) { | |
| 296 | + map.merge(o.getRiderId(), 1, Integer::sum); | |
| 297 | + } | |
| 298 | + return map; | |
| 299 | + } | |
| 300 | + | |
| 301 | + /** 获取每个骑手当日接单量 */ | |
| 302 | + private Map<Long, Integer> getDailyCountMap(List<Long> riderIds) { | |
| 303 | + Map<Long, Integer> map = new HashMap<>(); | |
| 304 | + if (riderIds.isEmpty()) return map; | |
| 305 | + long todayStart = LocalDate.now().atStartOfDay(ZoneId.of("Asia/Shanghai")).toEpochSecond(); | |
| 306 | + int countDate = Integer.parseInt(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))); | |
| 307 | + List<RiderOrderCount> counts = countMapper.selectList( | |
| 308 | + new LambdaQueryWrapper<RiderOrderCount>() | |
| 309 | + .in(RiderOrderCount::getUid, riderIds) | |
| 310 | + .eq(RiderOrderCount::getCountDate, countDate)); | |
| 311 | + for (RiderOrderCount c : counts) { | |
| 312 | + map.put(c.getUid(), c.getOrders()); | |
| 313 | + } | |
| 314 | + return map; | |
| 315 | + } | |
| 316 | + | |
| 317 | + /** 获取骑手最后完成订单的时间 */ | |
| 318 | + private long getLastCompleteTime(Long riderId) { | |
| 319 | + Orders last = ordersMapper.selectOne( | |
| 320 | + new LambdaQueryWrapper<Orders>() | |
| 321 | + .eq(Orders::getRiderId, riderId) | |
| 322 | + .eq(Orders::getStatus, 6) | |
| 323 | + .orderByDesc(Orders::getCompleteTime) | |
| 324 | + .select(Orders::getCompleteTime) | |
| 325 | + .last("LIMIT 1")); | |
| 326 | + return last != null && last.getCompleteTime() != null ? last.getCompleteTime() : 0; | |
| 327 | + } | |
| 328 | + | |
| 329 | + /** 计算两点间的方位角(度数,0=北 90=东) */ | |
| 330 | + private double calcBearing(double lat1, double lng1, double lat2, double lng2) { | |
| 331 | + double dLng = Math.toRadians(lng2 - lng1); | |
| 332 | + double lat1Rad = Math.toRadians(lat1); | |
| 333 | + double lat2Rad = Math.toRadians(lat2); | |
| 334 | + double y = Math.sin(dLng) * Math.cos(lat2Rad); | |
| 335 | + double x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng); | |
| 336 | + double bearing = Math.toDegrees(Math.atan2(y, x)); | |
| 337 | + return (bearing + 360) % 360; | |
| 338 | + } | |
| 339 | + | |
| 340 | + private double parseDouble(String s) { | |
| 341 | + try { | |
| 342 | + return s != null ? Double.parseDouble(s) : 0; | |
| 343 | + } catch (NumberFormatException e) { | |
| 344 | + return 0; | |
| 345 | + } | |
| 346 | + } | |
| 347 | + | |
| 348 | + private void notifyOrderEvent(Long orderId, String event) { | |
| 349 | + try { | |
| 350 | + Orders order = ordersMapper.selectById(orderId); | |
| 351 | + if (order == null || order.getAppKey() == null || order.getAppKey().isBlank()) return; | |
| 352 | + Map<String, Object> payload = new HashMap<>(); | |
| 353 | + payload.put("event", event); | |
| 354 | + payload.put("outOrderNo", order.getOutOrderNo()); | |
| 355 | + payload.put("deliveryOrderId", order.getId()); | |
| 356 | + payload.put("orderNo", order.getOrderNo()); | |
| 357 | + payload.put("status", order.getStatus()); | |
| 358 | + payload.put("riderId", order.getRiderId()); | |
| 359 | + payload.put("dispatchRiderId", order.getDispatchRiderId()); | |
| 360 | + payload.put("timestamp", System.currentTimeMillis() / 1000); | |
| 361 | + webhookService.send(event, orderId, objectMapper.writeValueAsString(payload)); | |
| 362 | + } catch (Exception e) { | |
| 363 | + log.warn("派单事件通知失败 orderId={} event={}", orderId, event, e); | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 367 | + private record RiderScore(Long riderId, double score, double distance) {} | |
| 368 | +} | ... | ... |
src/main/java/com/diligrp/rider/service/impl/RiderOrderServiceImpl.java
| ... | ... | @@ -7,11 +7,11 @@ import com.diligrp.rider.mapper.*; |
| 7 | 7 | import com.fasterxml.jackson.core.type.TypeReference; |
| 8 | 8 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 9 | 9 | import com.diligrp.rider.common.exception.BizException; |
| 10 | -import com.diligrp.rider.entity.*; | |
| 11 | -import com.diligrp.rider.mapper.*; | |
| 10 | +import com.diligrp.rider.service.DispatchRuleService; | |
| 12 | 11 | import com.diligrp.rider.service.RiderLevelService; |
| 13 | 12 | import com.diligrp.rider.service.RiderOrderService; |
| 14 | 13 | import com.diligrp.rider.service.WebhookService; |
| 14 | +import com.diligrp.rider.vo.DispatchRuleTemplateVO; | |
| 15 | 15 | import com.diligrp.rider.vo.OrderVO; |
| 16 | 16 | import com.diligrp.rider.vo.RiderMonthCountVO; |
| 17 | 17 | import com.diligrp.rider.vo.RiderTodayCountVO; |
| ... | ... | @@ -39,6 +39,7 @@ public class RiderOrderServiceImpl implements RiderOrderService { |
| 39 | 39 | private final RiderBalanceMapper balanceMapper; |
| 40 | 40 | private final RiderMapper riderMapper; |
| 41 | 41 | private final RiderLevelService riderLevelService; |
| 42 | + private final DispatchRuleService dispatchRuleService; | |
| 42 | 43 | private final ObjectMapper objectMapper; |
| 43 | 44 | private final WebhookService webhookService; |
| 44 | 45 | |
| ... | ... | @@ -127,6 +128,16 @@ public class RiderOrderServiceImpl implements RiderOrderService { |
| 127 | 128 | if (order.getRiderId() != null && order.getRiderId() != 0) { |
| 128 | 129 | throw new BizException(980, "抢单失败,订单已被接"); |
| 129 | 130 | } |
| 131 | + | |
| 132 | + DispatchRuleTemplateVO rule = dispatchRuleService.getActiveRule(cityId); | |
| 133 | + if (rule != null && rule.getGrabMaxPerRider() != null && rule.getGrabMaxPerRider() > 0) { | |
| 134 | + Long currentLoad = ordersMapper.selectCount(new LambdaQueryWrapper<Orders>() | |
| 135 | + .eq(Orders::getRiderId, riderId) | |
| 136 | + .in(Orders::getStatus, List.of(3, 4))); | |
| 137 | + if (currentLoad >= rule.getGrabMaxPerRider()) { | |
| 138 | + throw new BizException(1000, "当前持单量已达上限,无法继续抢单"); | |
| 139 | + } | |
| 140 | + } | |
| 130 | 141 | if (order.getIsTrans() == 1 && riderId.equals(order.getOldRiderId())) { |
| 131 | 142 | throw new BizException(980, "抢单失败,不能抢自己转出的单"); |
| 132 | 143 | } | ... | ... |
src/main/java/com/diligrp/rider/task/DispatchScheduleTask.java
0 → 100644
| 1 | +package com.diligrp.rider.task; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | |
| 4 | +import com.diligrp.rider.entity.City; | |
| 5 | +import com.diligrp.rider.entity.Orders; | |
| 6 | +import com.diligrp.rider.mapper.CityMapper; | |
| 7 | +import com.diligrp.rider.mapper.OrdersMapper; | |
| 8 | +import com.diligrp.rider.service.DispatchRuleService; | |
| 9 | +import com.diligrp.rider.service.DispatchService; | |
| 10 | +import com.diligrp.rider.vo.DispatchRuleTemplateVO; | |
| 11 | +import lombok.RequiredArgsConstructor; | |
| 12 | +import lombok.extern.slf4j.Slf4j; | |
| 13 | +import org.springframework.scheduling.annotation.EnableScheduling; | |
| 14 | +import org.springframework.scheduling.annotation.Scheduled; | |
| 15 | +import org.springframework.stereotype.Component; | |
| 16 | + | |
| 17 | +import java.util.List; | |
| 18 | + | |
| 19 | +@Slf4j | |
| 20 | +@Component | |
| 21 | +@EnableScheduling | |
| 22 | +@RequiredArgsConstructor | |
| 23 | +public class DispatchScheduleTask { | |
| 24 | + | |
| 25 | + private final CityMapper cityMapper; | |
| 26 | + private final OrdersMapper ordersMapper; | |
| 27 | + private final DispatchRuleService dispatchRuleService; | |
| 28 | + private final DispatchService dispatchService; | |
| 29 | + | |
| 30 | + /** | |
| 31 | + * 抢单超时后自动派单 | |
| 32 | + * 每3秒执行一次 | |
| 33 | + */ | |
| 34 | + @Scheduled(fixedDelay = 3_000) | |
| 35 | + public void autoDispatchTimeoutOrders() { | |
| 36 | + try { | |
| 37 | + List<City> cities = cityMapper.selectList(new LambdaQueryWrapper<City>() | |
| 38 | + .eq(City::getStatus, 1) | |
| 39 | + .select(City::getId)); | |
| 40 | + if (cities.isEmpty()) return; | |
| 41 | + | |
| 42 | + long now = System.currentTimeMillis() / 1000; | |
| 43 | + for (City city : cities) { | |
| 44 | + DispatchRuleTemplateVO rule = dispatchRuleService.getActiveRule(city.getId()); | |
| 45 | + if (rule == null || rule.getAutoDispatch() == null || rule.getAutoDispatch() != 1) { | |
| 46 | + continue; | |
| 47 | + } | |
| 48 | + if (rule.getGrabEnabled() == null || rule.getGrabEnabled() != 1) { | |
| 49 | + continue; | |
| 50 | + } | |
| 51 | + | |
| 52 | + int timeoutMinutes = rule.getGrabTimeout() != null ? rule.getGrabTimeout() : 30; | |
| 53 | + long expireTime = now - timeoutMinutes * 60L; | |
| 54 | + | |
| 55 | + List<Orders> timeoutOrders = ordersMapper.selectList(new LambdaQueryWrapper<Orders>() | |
| 56 | + .eq(Orders::getCityId, city.getId()) | |
| 57 | + .eq(Orders::getStatus, 2) | |
| 58 | + .eq(Orders::getRiderId, 0) | |
| 59 | + .le(Orders::getAddTime, expireTime) | |
| 60 | + .orderByAsc(Orders::getAddTime) | |
| 61 | + .last("LIMIT 100")); | |
| 62 | + | |
| 63 | + for (Orders order : timeoutOrders) { | |
| 64 | + Long riderId = dispatchService.dispatch(order); | |
| 65 | + if (riderId != null) { | |
| 66 | + log.info("超时订单自动派单成功 orderId={} riderId={}", order.getId(), riderId); | |
| 67 | + } | |
| 68 | + } | |
| 69 | + } | |
| 70 | + } catch (Exception e) { | |
| 71 | + log.error("自动派单任务异常", e); | |
| 72 | + } | |
| 73 | + } | |
| 74 | +} | ... | ... |
src/main/java/com/diligrp/rider/task/OrderScheduleTask.java
| ... | ... | @@ -60,16 +60,6 @@ public class OrderScheduleTask { |
| 60 | 60 | } |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | - /** | |
| 64 | - * 检查骑手是否有新的指派订单(每3秒,dispatchNotice) | |
| 65 | - * 注:实时推送版本需 WebSocket,此处仅做日志记录 | |
| 66 | - * 后续接入 WebSocket 后可在此触发推送 | |
| 67 | - */ | |
| 68 | - @Scheduled(fixedDelay = 3_000) | |
| 69 | - public void checkDispatchOrders() { | |
| 70 | - // TODO: 接入 WebSocket 后在此推送给骑手 | |
| 71 | - // 目前骑手通过轮询 /api/rider/order/list?type=1 获取待接单列表 | |
| 72 | - } | |
| 73 | 63 | |
| 74 | 64 | private void notifyCancel(Orders order) { |
| 75 | 65 | try { | ... | ... |
src/main/java/com/diligrp/rider/vo/DispatchRuleTemplateVO.java
0 → 100644
| 1 | +package com.diligrp.rider.vo; | |
| 2 | + | |
| 3 | +import lombok.Data; | |
| 4 | + | |
| 5 | +import java.math.BigDecimal; | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +@Data | |
| 9 | +public class DispatchRuleTemplateVO { | |
| 10 | + | |
| 11 | + private Long id; | |
| 12 | + private Long cityId; | |
| 13 | + private String name; | |
| 14 | + private Integer isActive; | |
| 15 | + private Integer grabEnabled; | |
| 16 | + private Integer grabTimeout; | |
| 17 | + private Integer grabScope; | |
| 18 | + private Integer grabMaxPerRider; | |
| 19 | + private Integer autoDispatch; | |
| 20 | + private Long createTime; | |
| 21 | + private Long updateTime; | |
| 22 | + | |
| 23 | + /** 条件列表(按 sortOrder 升序) */ | |
| 24 | + private List<ConditionItem> conditions; | |
| 25 | + | |
| 26 | + @Data | |
| 27 | + public static class ConditionItem { | |
| 28 | + private Long id; | |
| 29 | + private String conditionType; | |
| 30 | + private String conditionDesc; | |
| 31 | + private Integer enabled; | |
| 32 | + private BigDecimal thresholdValue; | |
| 33 | + private Integer sortOrder; | |
| 34 | + /** 是否需要阈值 */ | |
| 35 | + private Boolean hasThreshold; | |
| 36 | + } | |
| 37 | +} | ... | ... |
src/main/resources/schema.sql
| ... | ... | @@ -453,4 +453,44 @@ CREATE TABLE `ext_store` ( |
| 453 | 453 | -- orders 表补充货物快照字段 |
| 454 | 454 | ALTER TABLE `orders` ADD COLUMN `items_json` TEXT COMMENT '货物清单快照JSON' AFTER `callback_url`; |
| 455 | 455 | ALTER TABLE `orders` ADD COLUMN `item_remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '整单货物备注' AFTER `items_json`; |
| 456 | -ALTER TABLE `orders` ADD COLUMN `ext_store_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联外部门店ID' AFTER `item_remark`; | |
| 457 | 456 | \ No newline at end of file |
| 457 | +ALTER TABLE `orders` ADD COLUMN `ext_store_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联外部门店ID' AFTER `item_remark`; | |
| 458 | + | |
| 459 | +-- orders 表补充调度字段 | |
| 460 | +ALTER TABLE `orders` ADD COLUMN `dispatch_time` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '系统派单时间' AFTER `trans_time`; | |
| 461 | +ALTER TABLE `orders` ADD COLUMN `dispatch_rider_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '系统指派骑手ID' AFTER `dispatch_time`; | |
| 462 | + | |
| 463 | +-- rider 表补充评分统计字段 | |
| 464 | +ALTER TABLE `rider` ADD COLUMN `star_total` INT NOT NULL DEFAULT 0 COMMENT '评分总分' AFTER `thumb`; | |
| 465 | +ALTER TABLE `rider` ADD COLUMN `star_count` INT NOT NULL DEFAULT 0 COMMENT '评分次数' AFTER `star_total`; | |
| 466 | + | |
| 467 | +-- 调度规则模板表 | |
| 468 | +CREATE TABLE `dispatch_rule_template` ( | |
| 469 | + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, | |
| 470 | + `city_id` BIGINT UNSIGNED NOT NULL COMMENT '城市ID', | |
| 471 | + `name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '模板名称', | |
| 472 | + `is_active` TINYINT NOT NULL DEFAULT 0 COMMENT '是否当前生效:0=否 1=是', | |
| 473 | + `grab_enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '抢单模式启用', | |
| 474 | + `grab_timeout` INT NOT NULL DEFAULT 30 COMMENT '抢单超时分钟数,超时后转自动派单', | |
| 475 | + `grab_scope` TINYINT NOT NULL DEFAULT 1 COMMENT '抢单可见范围:1=订单所属区域骑手 2=全部自营骑手', | |
| 476 | + `grab_max_per_rider` INT NOT NULL DEFAULT 3 COMMENT '单人最大同时持单量', | |
| 477 | + `auto_dispatch` TINYINT NOT NULL DEFAULT 1 COMMENT '同步开启自动派单:0=否 1=是', | |
| 478 | + `create_time` BIGINT UNSIGNED NOT NULL DEFAULT 0, | |
| 479 | + `update_time` BIGINT UNSIGNED NOT NULL DEFAULT 0, | |
| 480 | + PRIMARY KEY (`id`), | |
| 481 | + KEY `idx_city_active` (`city_id`, `is_active`) | |
| 482 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='调度规则模板表'; | |
| 483 | + | |
| 484 | +-- 派单优先级条件表 | |
| 485 | +CREATE TABLE `dispatch_rule_condition` ( | |
| 486 | + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, | |
| 487 | + `template_id` BIGINT UNSIGNED NOT NULL COMMENT '所属模板ID', | |
| 488 | + `condition_type` VARCHAR(32) NOT NULL COMMENT '条件类型', | |
| 489 | + `enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用', | |
| 490 | + `threshold_value` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '阈值', | |
| 491 | + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '优先级排序(小的优先)', | |
| 492 | + `create_time` BIGINT UNSIGNED NOT NULL DEFAULT 0, | |
| 493 | + `update_time` BIGINT UNSIGNED NOT NULL DEFAULT 0, | |
| 494 | + PRIMARY KEY (`id`), | |
| 495 | + UNIQUE KEY `uk_template_type` (`template_id`, `condition_type`), | |
| 496 | + KEY `idx_template_order` (`template_id`, `sort_order`) | |
| 497 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='派单优先级条件表'; | |
| 458 | 498 | \ No newline at end of file | ... | ... |