Преглед на файлове

骑手配送类型审核与派单过滤

qmj преди 2 дни
родител
ревизия
956b802b51

+ 13 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -208,13 +208,15 @@ public class FlashDeliveryApplicationService {
 
     public FlashDeliveryRiderOrderPageView riderOrders(Long riderId, int pageNum, int pageSize,
                                                         String tab, BigDecimal longitude, BigDecimal latitude) {
-        requireRider(riderId);
+        InfoUser rider = requireRider(riderId);
         if (!hasText(tab)) throw fail("flash.delivery.tab.invalid");
         String selectedTab = tab.trim();
         // lambda 无法捕获被重新赋值的参数,统一改用仅 newTask 携带坐标的有效变量。
         BigDecimal effectiveLongitude = "newTask".equals(selectedTab) ? longitude : null;
         BigDecimal effectiveLatitude = "newTask".equals(selectedTab) ? latitude : null;
         if ("newTask".equals(selectedTab)) {
+            // 新任务派单按骑手选择的配送类型过滤;本人已接订单页签不受限制。
+            if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
             validateRiderCoordinates(effectiveLongitude, effectiveLatitude);
         }
         QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, selectedTab,
@@ -237,10 +239,13 @@ public class FlashDeliveryApplicationService {
     }
 
     public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) {
-        requireRider(riderId);
+        InfoUser rider = requireRider(riderId);
         FlashDeliveryOrder order = requireOrder(orderId);
         if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
-            if (!isAvailableAt(order, new Date())) throw fail("flash.delivery.order.not.found");
+            // 不兼容闪送的骑手按订单不存在处理,避免泄露待抢任务信息。
+            if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")) {
+                throw fail("flash.delivery.order.not.found");
+            }
             // 接单前显示取送文字地址,但隐藏联系人、电话、精确坐标、实际 PIN 和履约图片。
             return participantDetail(order, false, true);
         }
@@ -250,7 +255,9 @@ public class FlashDeliveryApplicationService {
 
     @Transactional
     public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
-        requireRider(riderId);
+        InfoUser rider = requireRider(riderId);
+        // 抢单属于获取新任务,要求骑手承接闪送配送。
+        if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
         Date now = new Date();
         // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
         if (orderMapper.accept(orderId, riderId, now) != 1) throw fail("flash.delivery.order.already.accepted");
@@ -768,9 +775,10 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    private void requireRider(Long riderId) {
+    private InfoUser requireRider(Long riderId) {
         InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId);
         if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required");
+        return rider;
     }
 
     private FlashDeliveryOrder requireOrder(Long orderId) {

+ 12 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -79,7 +79,10 @@ public class PosOrderQsOprateController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         PayPush push = new PayPush();
         String qsId = jwtUtil.getusid(token);
-        requireRider(qsId);
+        InfoUser qsRider = requireRider(qsId);
+        if (!qsRider.supportsDeliveryType("FOOD")) {
+            throw new ServiceException(MessageUtils.message("no.user.delivery.type.not.enabled"));
+        }
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
         if (order == null) {
             throw new ServiceException(MessageUtils.message("no.order.not.found"));
@@ -330,6 +333,14 @@ public class PosOrderQsOprateController extends BaseController {
         String qsId = jwtUtil.getusid(token);
         Long riderId = Long.valueOf(qsId);
 
+        // 派单按骑手审核资料选择的配送类型过滤;空类型的历史骑手视为兼容全部
+        if ("newTask".equals(tab)) {
+            InfoUser rider = requireRider(qsId);
+            if (!rider.supportsDeliveryType("FOOD")) {
+                throw new ServiceException(MessageUtils.message("no.user.delivery.type.not.enabled"));
+            }
+        }
+
         LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(PosOrder::getIsDisplay,true);
         switch (tab) {

+ 13 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -730,6 +730,7 @@ public class InfoUserController extends BaseController {
     public AjaxResult setuser(@RequestHeader String token, @RequestBody InfoUser infoUser) {
         JwtUtil jwtUtil = new JwtUtil();
         String id = jwtUtil.getusid(token);
+        normalizeDeliveryType(infoUser);
         InfoUser user = new InfoUser();
         user.setUserId(Long.valueOf(id));
         user.setUserType(infoUser.getUserType());
@@ -750,6 +751,7 @@ public class InfoUserController extends BaseController {
         user.setVehiclePhotos(infoUser.getVehiclePhotos());
         user.setLicensePlate(infoUser.getLicensePlate());
         user.setVehicleType(infoUser.getVehicleType());
+        user.setDeliveryType(infoUser.getDeliveryType());
         user.setRiderHeadPhoto(infoUser.getRiderHeadPhoto());
         user.setPreLaunchCity(infoUser.getPreLaunchCity());
         user.setBirthday(infoUser.getBirthday());
@@ -1214,6 +1216,7 @@ public class InfoUserController extends BaseController {
         if (!normalizeAuditRejectReason(infoUser)) {
             return error(MessageUtils.message("no.user.audit.reject.reason.required"));
         }
+        normalizeDeliveryType(infoUser);
         if (infoUserService.getinfouserName(infoUser.getUserName()) != null) {
             return error(MessageUtils.message("no.user.add") + infoUser.getUserName() + MessageUtils.message("no.user.login.exist"));
         } else if (infoUserService.getinfoPhone(infoUser.getPhone()) != null) {
@@ -1245,6 +1248,7 @@ public class InfoUserController extends BaseController {
         if (!normalizeAuditRejectReason(infoUser)) {
             return error(MessageUtils.message("no.user.audit.reject.reason.required"));
         }
+        normalizeDeliveryType(infoUser);
         return toAjax(infoUserService.updateInfoUser(infoUser));
     }
 
@@ -1264,6 +1268,15 @@ public class InfoUserController extends BaseController {
         return true;
     }
 
+    /** 骑手配送类型归一化;非法值返回国际化业务错误。 */
+    private void normalizeDeliveryType(InfoUser infoUser) {
+        try {
+            infoUser.setDeliveryType(InfoUser.normalizeDeliveryTypes(infoUser.getDeliveryType()));
+        } catch (IllegalArgumentException exception) {
+            throw new ServiceException(MessageUtils.message("no.user.delivery.type.invalid"));
+        }
+    }
+
     /**
      * 删除用户信息
      */

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -283,6 +283,8 @@ merchant.subaccount.access.denied=You cannot manage this subaccount
 merchant.subaccount.create.failed=Failed to create subaccount
 merchant.subaccount.update.failed=Failed to update subaccount
 merchant.subaccount.platform.managed=Subaccounts can only be managed from the dedicated merchant subaccount entry
+no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或闪送
+no.user.delivery.type.not.enabled=当前账号未开通美食外送配送
 
 # Flash delivery
 flash.delivery.request.required=Request data is required
@@ -320,3 +322,4 @@ flash.delivery.tab.invalid=Invalid flash delivery list tab
 flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
+flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -286,6 +286,8 @@ merchant.subaccount.access.denied=You cannot manage this subaccount
 merchant.subaccount.create.failed=Failed to create subaccount
 merchant.subaccount.update.failed=Failed to update subaccount
 merchant.subaccount.platform.managed=Subaccounts can only be managed from the dedicated merchant subaccount entry
+no.user.delivery.type.invalid=Invalid delivery type; only food delivery or flash delivery is supported
+no.user.delivery.type.not.enabled=This account is not enabled for food delivery orders
 
 # Flash delivery
 flash.delivery.request.required=Request data is required
@@ -323,3 +325,4 @@ flash.delivery.tab.invalid=Invalid flash delivery list tab
 flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
+flash.delivery.rider.type.not.enabled=This account is not enabled for flash delivery orders

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -286,6 +286,8 @@ merchant.subaccount.access.denied=Bạn không có quyền quản lý tài kho
 merchant.subaccount.create.failed=Tạo tài khoản phụ thất bại
 merchant.subaccount.update.failed=Cập nhật tài khoản phụ thất bại
 merchant.subaccount.platform.managed=Tài khoản phụ chỉ được quản lý tại mục quản lý chuyên dụng
+no.user.delivery.type.invalid=Loại giao hàng không hợp lệ, chỉ hỗ trợ giao đồ ăn hoặc giao nhanh
+no.user.delivery.type.not.enabled=Tài khoản này chưa mở giao đồ ăn
 
 # Giao hàng nhanh
 flash.delivery.request.required=Dữ liệu yêu cầu không được để trống
@@ -323,3 +325,4 @@ flash.delivery.tab.invalid=Tab danh sách giao hàng nhanh không hợp lệ
 flash.delivery.coordinates.invalid=Vĩ độ và kinh độ phải được cung cấp theo cặp và nằm trong phạm vi hợp lệ
 address.access.denied=Địa chỉ không tồn tại hoặc không có quyền truy cập
 address.data.invalid=Thông tin liên hệ, địa chỉ hoặc tọa độ không hợp lệ
+flash.delivery.rider.type.not.enabled=Tài khoản này chưa mở giao hàng nhanh

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -287,6 +287,8 @@ merchant.subaccount.access.denied=无权管理该分管账号
 merchant.subaccount.create.failed=创建分管账号失败
 merchant.subaccount.update.failed=更新分管账号失败
 merchant.subaccount.platform.managed=分管账号只能通过专用入口管理
+no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或闪送
+no.user.delivery.type.not.enabled=当前账号未开通美食外送配送
 
 # 闪送配送
 flash.delivery.request.required=请求数据不能为空
@@ -324,3 +326,4 @@ flash.delivery.tab.invalid=闪送列表页签无效
 flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
+flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -287,6 +287,8 @@ merchant.subaccount.access.denied=無權管理該分管帳號
 merchant.subaccount.create.failed=建立分管帳號失敗
 merchant.subaccount.update.failed=更新分管帳號失敗
 merchant.subaccount.platform.managed=分管帳號只能透過專用入口管理
+no.user.delivery.type.invalid=配送類型無效,僅支持美食外送或闁送
+no.user.delivery.type.not.enabled=當前賬號未開通美食外送配送
 
 # 閃送配送
 flash.delivery.request.required=請求資料不能為空
@@ -324,3 +326,4 @@ flash.delivery.tab.invalid=閃送列表頁籤無效
 flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
+flash.delivery.rider.type.not.enabled=當前賬號未開通闁送配送

+ 64 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -310,6 +310,70 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.orderMapper, never()).selectPage(any(), any());
     }
 
+    @Test
+    void flashNewTaskListAndAcceptRequireFlashDeliveryType() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        rider.setDeliveryType("FOOD");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+
+        assertThrows(ServiceException.class,
+                () -> fixture.service.riderOrders(8L, 1, 10, "newTask", null, null));
+        assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
+        verify(fixture.orderMapper, never()).selectPage(any(), any());
+        verify(fixture.orderMapper, never()).accept(any(), any(), any());
+    }
+
+    @Test
+    void riderDetailHidesWaitingFlashOrdersFromNonFlashRiders() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        rider.setDeliveryType("FOOD");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
+
+        assertThrows(ServiceException.class, () -> fixture.service.riderDetail(8L, 10L));
+        verify(fixture.imageMapper, never()).selectList(any());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void enabledFlashRiderStillListsNewTasks() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        rider.setDeliveryType("FLASH");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+
+        fixture.service.riderOrders(8L, 1, 10, "newTask", null, null);
+        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void ownOrderTabsRemainAccessibleRegardlessOfDeliveryType() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        rider.setDeliveryType("FOOD");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
+
+        fixture.service.riderOrders(8L, 1, 10, "toPickup", null, null);
+        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
+                any(Wrapper.class));
+    }
+
     @Test
     void assignedRiderTabsIgnoreLocationParameters() {
         Fixture fixture = new Fixture();

+ 53 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUser.java

@@ -15,6 +15,9 @@ import org.apache.commons.lang3.builder.ToStringStyle;
 
 import java.math.BigDecimal;
 import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
 
 import javax.persistence.GeneratedValue;
 
@@ -141,6 +144,10 @@ public class InfoUser
     @Excel(name = "骑手车型")
     private String vehicleType;
 
+    /** 骑手配送类型(FOOD美食外送、FLASH闪送,逗号分隔多选;空视为两者兼容) */
+    @Excel(name = "配送类型")
+    private String deliveryType;
+
     /** 骑手审核大头照(图片URL) */
     @Excel(name = "骑手审核大头照")
     private String riderHeadPhoto;
@@ -494,6 +501,52 @@ public class InfoUser
         this.vehicleType = vehicleType;
     }
 
+    public String getDeliveryType() {
+        return deliveryType;
+    }
+
+    public void setDeliveryType(String deliveryType) {
+        this.deliveryType = deliveryType;
+    }
+
+    /**
+     * 骑手是否承接指定配送类型。
+     * 配送类型为空的历史骑手视为同时兼容美食外送与闪送,保持既有派单行为不变。
+     */
+    public boolean supportsDeliveryType(String type) {
+        if (deliveryType == null || deliveryType.isBlank()) {
+            return true;
+        }
+        for (String value : deliveryType.split(",")) {
+            if (type.equalsIgnoreCase(value.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 归一化客户端提交的配送类型:大写、去空白、去重并按固定顺序输出。
+     * 为空返回 null;包含无法识别的值时抛出 IllegalArgumentException。
+     */
+    public static String normalizeDeliveryTypes(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        Set<String> types = new LinkedHashSet<>();
+        for (String token : value.split(",")) {
+            String type = token.trim().toUpperCase(Locale.ROOT);
+            if (!"FOOD".equals(type) && !"FLASH".equals(type)) {
+                throw new IllegalArgumentException("invalid delivery type: " + token);
+            }
+            types.add(type);
+        }
+        if (types.isEmpty()) {
+            return null;
+        }
+        return String.join(",", types);
+    }
+
     public String getRiderHeadPhoto() {
         return riderHeadPhoto;
     }

+ 4 - 0
ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml

@@ -34,6 +34,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="vehiclePhotos"    column="vehicle_photos"    />
         <result property="licensePlate"    column="license_plate"    />
         <result property="vehicleType"    column="vehicle_type"    />
+        <result property="deliveryType"    column="delivery_type"    />
         <result property="riderHeadPhoto"    column="rider_head_photo"    />
         <result property="preLaunchCity"    column="pre_launch_city"    />
         <result property="birthday"    column="birthday"    />
@@ -128,6 +129,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="drivingLicense != null">driving_license,</if>
             <if test="vehiclePhotos != null">vehicle_photos,</if>
             <if test="vehicleType != null">vehicle_type,</if>
+            <if test="deliveryType != null">delivery_type,</if>
             <if test="riderHeadPhoto != null">rider_head_photo,</if>
             <if test="preLaunchCity != null">pre_launch_city,</if>
             <if test="birthday != null">birthday,</if>
@@ -176,6 +178,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="drivingLicense != null">#{drivingLicense},</if>
             <if test="vehiclePhotos != null">#{vehiclePhotos},</if>
             <if test="vehicleType != null">#{vehicleType},</if>
+            <if test="deliveryType != null">#{deliveryType},</if>
             <if test="riderHeadPhoto != null">#{riderHeadPhoto},</if>
             <if test="preLaunchCity != null">#{preLaunchCity},</if>
             <if test="birthday != null">#{birthday},</if>
@@ -228,6 +231,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="drivingLicense != null">driving_license = #{drivingLicense},</if>
             <if test="vehiclePhotos != null">vehicle_photos = #{vehiclePhotos},</if>
             <if test="vehicleType != null">vehicle_type = #{vehicleType},</if>
+            <if test="deliveryType != null">delivery_type = #{deliveryType},</if>
             <if test="riderHeadPhoto != null">rider_head_photo = #{riderHeadPhoto},</if>
             <if test="preLaunchCity != null">pre_launch_city = #{preLaunchCity},</if>
             <if test="birthday != null">birthday = #{birthday},</if>

+ 46 - 0
ruoyi-system/src/test/java/com/ruoyi/system/domain/InfoUserDeliveryTypeTest.java

@@ -0,0 +1,46 @@
+package com.ruoyi.system.domain;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class InfoUserDeliveryTypeTest {
+
+    @Test
+    void blankDeliveryTypeSupportsEverythingForLegacyRiders() {
+        InfoUser rider = new InfoUser();
+        assertTrue(rider.supportsDeliveryType("FOOD"));
+        assertTrue(rider.supportsDeliveryType("FLASH"));
+        rider.setDeliveryType("   ");
+        assertTrue(rider.supportsDeliveryType("FLASH"));
+    }
+
+    @Test
+    void selectedTypesControlAccess() {
+        InfoUser rider = new InfoUser();
+        rider.setDeliveryType("FOOD");
+        assertTrue(rider.supportsDeliveryType("FOOD"));
+        assertFalse(rider.supportsDeliveryType("FLASH"));
+        rider.setDeliveryType("FLASH,FOOD");
+        assertTrue(rider.supportsDeliveryType("FLASH"));
+        assertTrue(rider.supportsDeliveryType("food"));
+    }
+
+    @Test
+    void normalizeTrimsUpperCasesAndDeduplicates() {
+        assertNull(InfoUser.normalizeDeliveryTypes(null));
+        assertNull(InfoUser.normalizeDeliveryTypes("   "));
+        assertEquals("FOOD", InfoUser.normalizeDeliveryTypes(" food "));
+        assertEquals("FLASH,FOOD", InfoUser.normalizeDeliveryTypes("flash, FOOD ,flash"));
+    }
+
+    @Test
+    void normalizeRejectsValuesWithoutAnyKnownType() {
+        assertThrows(IllegalArgumentException.class, () -> InfoUser.normalizeDeliveryTypes("TAXI"));
+        assertThrows(IllegalArgumentException.class, () -> InfoUser.normalizeDeliveryTypes(" , "));
+    }
+}

+ 27 - 0
specs/025-rider-delivery-type/plan.md

@@ -0,0 +1,27 @@
+# 实施计划:骑手配送类型与派单过滤
+
+**分支**:`test` | **日期**:2026-09-02 | **规格**:[spec.md](./spec.md)
+
+## 设计决策
+
+1. `info_user.delivery_type VARCHAR(64) NULL`,取值 `FOOD`、`FLASH` 逗号组合(归一化为大写、去空白、去重);`NULL`/空串在代码层视为同时兼容两类,保证 SQL 未执行或骑手未重新保存资料时现有行为不变。
+2. `InfoUser.supportsDeliveryType(String)` 供两端门禁复用;`InfoUser.normalizeDeliveryTypes(String)` 静态归一化,非法值抛 `IllegalArgumentException`,Controller 统一转国际化业务错误 `no.user.delivery.type.invalid`。
+3. 门禁只加在"获取新任务"路径:外卖 `orderList` 的 `newTask` 页签与 `acceptOrder` 要求兼容 `FOOD`;闪送 `riderOrders` 的 `newTask`、待抢详情与 `accept` 要求兼容 `FLASH`。骑手本人已接订单页签和取件、送达履约不限制,避免配置调整卡死进行中订单。
+4. 闪送待抢详情对不兼容骑手返回"订单不存在"错误,避免泄露任务存在性;列表与抢单返回专属错误 `flash.delivery.rider.type.not.enabled`,外卖侧使用 `no.user.delivery.type.not.enabled`。
+5. `/infouser/user/setuser` 白名单拷贝新增 `deliveryType`;平台 `POST/PUT /infouser/user` 沿用 `normalizeAuditRejectReason` 的归一化模式校验配送类型。
+6. MyBatis:`InfoUser` 为 MyBatis-Plus 实体,普通字段自动参与 CRUD;手写 `InfoUserMapper.xml` 需同步 resultMap、`insertInfoUser`、`updateInfoUser` 三处。
+7. SQL 记录于 `updatesql/sql.md`:增量加列 + `user_type='2' AND delivery_type IS NULL` 回填 `FOOD,FLASH`。
+8. 平台骑手审核页 `peisus.vue` 在交通工具下方增加配送类型复选框(`FOOD`/`FLASH`),文案进四个 `infouspeisus` 语言文件。
+
+## 验证
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-system -am -Dtest=InfoUserDeliveryTypeTest -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,FlashDeliveryI18nContractTest' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -DskipTests package
+git diff --check
+```
+
+外卖控制器为既有无测试代码,本次门禁改动仅做编译验证。

+ 48 - 0
specs/025-rider-delivery-type/spec.md

@@ -0,0 +1,48 @@
+# 功能规格:骑手配送类型与派单过滤
+
+**功能标识**:`025-rider-delivery-type`
+
+**创建日期**:2026-09-02
+
+**状态**:已确认
+
+**输入**:骑手端审核资料原型新增"配送类型"多选项(美食外送、闪送,可同时选择);派单要求按骑手选择的配送类型分配订单。
+
+## 用户故事
+
+### 用户故事 1:骑手在审核资料中选择配送类型(P1)
+
+骑手在提交审核资料时选择自己承接的配送类型:美食外送、闪送,可同时勾选两项。
+
+**验收场景**:
+
+1. **假如** 骑手提交资料时勾选一项或两项配送类型,**当** 资料保存,**那么** 系统保存骑手选择的配送类型,平台审核页可见。
+2. **假如** 骑手提交了 `FOOD`、`FLASH` 之外的配送类型值,**当** 资料保存,**那么** 系统拒绝并返回国际化业务错误。
+3. **假如** 骑手未选择配送类型(历史数据或未提交),**当** 其查询或抢单,**那么** 系统视为同时兼容美食外送与闪送,行为与现有骑手一致。
+
+### 用户故事 2:派单按配送类型分配订单(P1)
+
+骑手查询外卖或闪送的新任务列表、抢单时,系统按骑手选择的配送类型过滤。
+
+**验收场景**:
+
+1. **假如** 骑手仅选择美食外送,**当** 其查询闪送新任务或抢单,**那么** 系统拒绝并返回国际化业务错误;查询外卖新任务与接单不受影响。
+2. **假如** 骑手仅选择闪送,**当** 其查询外卖新任务或接单,**那么** 系统拒绝并返回国际化业务错误;闪送列表与抢单不受影响。
+3. **假如** 骑手同时选择两项,**当** 其分别查询外卖与闪送新任务,**那么** 两类任务均可见、均可抢。
+4. **假如** 骑手的配送类型后续被调整,**当** 其查询自己已接订单的页签(待取件、配送中等),**那么** 本人历史订单仍可见,不受新配置影响。
+
+## 需求
+
+- **FR-001**:`info_user` 新增 `delivery_type VARCHAR(64) NULL`,取值为 `FOOD`(美食外送)、`FLASH`(闪送)以逗号分隔的组合;`NULL` 或空串视为同时兼容两类。
+- **FR-002**:App 资料提交接口 `/infouser/user/setuser` 接收并保存 `deliveryType`;值非空时必须能解析为至少一个合法类型,否则返回国际化错误。
+- **FR-003**:平台新增/修改用户接口 `POST/PUT /infouser/user` 对 `deliveryType` 执行相同校验。
+- **FR-004**:`getuserinfo` 等现有用户信息接口随实体返回 `deliveryType`。
+- **FR-005**:外卖骑手新任务列表 `newTask` 页签与接单接口要求骑手兼容 `FOOD`;闪送新任务列表、待抢详情与抢单接口要求骑手兼容 `FLASH`;不满足时返回国际化业务错误。
+- **FR-006**:骑手本人已接订单的列表页签与履约操作不做配送类型限制。
+- **FR-007**:数据库变更只记录在 `updatesql/sql.md`;存量骑手回填为 `FOOD,FLASH`,保持现有行为不变。
+
+## 非目标
+
+- 不修改骑手 App 前端(表单由 App 团队按原型实现)。
+- 不实现自动派单、按城市派单或骑手在线状态过滤。
+- 不限制已完成历史订单的查询。

+ 16 - 0
specs/025-rider-delivery-type/tasks.md

@@ -0,0 +1,16 @@
+# 任务:骑手配送类型与派单过滤
+
+- [x] T001 `InfoUser` 新增 `deliveryType` 字段、`supportsDeliveryType` 兼容判断与 `normalizeDeliveryTypes` 归一化校验;`InfoUserMapper.xml` 补齐 resultMap、insert、update 映射
+- [x] T002 `updatesql/sql.md` 记录 `info_user.delivery_type` 增量与存量骑手回填 `FOOD,FLASH`
+- [x] T003 `/infouser/user/setuser` 拷贝并校验配送类型;平台 `POST/PUT /infouser/user` 增加相同校验
+- [x] T004 外卖 `orderList` `newTask` 页签与 `acceptOrder` 增加美食外送类型门禁;闪送 `riderOrders` `newTask`、待抢详情、`accept` 增加闪送类型门禁
+- [x] T005 五语言新增 `no.user.delivery.type.invalid`、`no.user.delivery.type.not.enabled`、`flash.delivery.rider.type.not.enabled`
+- [x] T006 实体归一化/兼容判断测试与闪送门禁测试转绿
+- [x] T007 平台骑手审核页 `peisus.vue` 增加配送类型复选框,四语言文件同步 `infouspeisus` 文案
+- [x] T008 JDK 21 定向测试、模块构建、前后端差异检查,分仓中文提交并推送 `test`
+
+## 完成记录(2026-09-02)
+
+- 门禁只作用于"获取新任务"(列表 newTask、待抢详情、抢单/接单);骑手本人已接订单页签与取件、送达履约不受配送类型影响。
+- 代码层 `deliveryType` 为空视为同时兼容两类,因此 SQL 未执行前现有骑手行为不变;SQL 执行后存量骑手显式回填 `FOOD,FLASH`。
+- 验证:JDK 21 定向测试通过、`mvn -pl ruoyi-admin -am -DskipTests package` 成功;`git diff --check` 清洁。外卖控制器为既有无测试代码,仅编译验证。

+ 16 - 0
updatesql/sql.md

@@ -1354,3 +1354,19 @@ WHERE NOT EXISTS (
   SELECT 1 FROM sys_job WHERE invoke_target = 'flashDeliveryAutoCompleteTask.completeDeliveredOrders()'
 );
 ```
+
+## 2026-09-02 骑手配送类型(025-rider-delivery-type)
+
+> 用途:骑手审核资料增加配送类型多选(美食外送 FOOD、闪送 FLASH),派单按骑手选择的配送类型过滤。只记录脚本,由开发者手动执行。
+
+```sql
+ALTER TABLE info_user
+  ADD COLUMN delivery_type VARCHAR(64) NULL COMMENT '骑手配送类型:FOOD美食外送、FLASH闪送,逗号分隔多选;空视为两者兼容' AFTER vehicle_type;
+
+-- 存量骑手回填为同时兼容两类,保持现有派单行为不变;应用层对空值同样按兼容处理。
+UPDATE info_user
+SET delivery_type = 'FOOD,FLASH'
+WHERE user_type = '2'
+  AND del_flag = '0'
+  AND delivery_type IS NULL;
+```