Commit a9f9dcb32bbebfc40f34ae29a7999c0a6ffe083f

Authored by 杨刚
1 parent 3edd6bb9

新增自动派单服务及规则配置接口

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
... ... @@ -147,4 +147,10 @@ public class Orders {
147 147  
148 148 /** 转单时间 */
149 149 private Long transTime;
  150 +
  151 + /** 系统派单时间 */
  152 + private Long dispatchTime;
  153 +
  154 + /** 系统指派骑手ID */
  155 + private Long dispatchRiderId;
150 156 }
... ...
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
  1 +package com.diligrp.rider.service;
  2 +
  3 +import com.diligrp.rider.entity.Orders;
  4 +
  5 +public interface DispatchService {
  6 +
  7 + /**
  8 + * 为一个订单执行自动派单
  9 + * @param order 待派单的订单(status=2)
  10 + * @return 匹配到的骑手ID,null 表示无匹配
  11 + */
  12 + Long dispatch(Orders order);
  13 +}
... ...
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
... ...