Explorar o código

031 T015-T017 骑手闪送收款方式接口+闪送下单闸门(白名单第3处)+首页/报价可选项+抢单支付方式过滤(现金恒可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qmj hai 9 horas
pai
achega
397517681b

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java

@@ -13,4 +13,6 @@ public class FlashDeliveryHomeView {
     private List<String> deliveryTypes;
     /** 当前时段各服务的运价摘要,无可用运价时为空列表。 */
     private List<FlashDeliveryServiceView> services;
+    /** 闪送可选支付方式(031 闸门开放集合,含就绪度),App 下单支付方式渲染依据。 */
+    private List<com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption> payMethods;
 }

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java

@@ -59,6 +59,12 @@ public class FlashDeliveryQuoteRequest {
      */
     private Date scheduledPickupEndAt;
 
+    /**
+     * 支付方式(031),选填,缺省现金 4。
+     * 与主订单系统同域(1=到付、3=LINE Pay、4=现金、6=线下转账);服务端经闸门校验(闪送维度仅平台开关层)。
+     */
+    private String payType;
+
     /** 取件联系人、电话、地址、交接方式和经纬度,必填。 */
     private FlashDeliveryAddressRequest pickup;
 

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.flashdelivery.dto;
 import lombok.Data;
 
 import java.math.BigDecimal;
+import java.util.List;
 
 /** 服务端按当前时段运价生成的闪送报价。 */
 @Data
@@ -60,4 +61,6 @@ public class FlashDeliveryQuoteView {
     private String currency;
     /** 运价配置版本,创建订单时需回传。 */
     private Integer pricingVersion;
+    /** 闪送可选支付方式(031 闸门开放集合),与首页同源。 */
+    private List<com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption> payMethods;
 }

+ 58 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -19,6 +19,7 @@ import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.flash.*;
 import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
 import com.ruoyi.system.mapper.flash.*;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.stereotype.Service;
@@ -70,6 +71,8 @@ public class FlashDeliveryApplicationService {
     private final FlashDeliveryPricingCalculator pricingCalculator;
     private final RiderDeliveryLockService riderDeliveryLockService;
     private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    /** 支付方式闸门(031):闪送下单校验与抢单过滤的唯一判定来源。 */
+    private final PaymentMethodGateService paymentMethodGateService;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
                                            FlashDeliveryPricingMapper pricingMapper,
@@ -79,7 +82,8 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryRouteService routeService,
                                            FlashDeliveryPricingCalculator pricingCalculator,
                                            RiderDeliveryLockService riderDeliveryLockService,
-                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
+                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService,
+                                           PaymentMethodGateService paymentMethodGateService) {
         this.orderMapper = orderMapper;
         this.pricingMapper = pricingMapper;
         this.imageMapper = imageMapper;
@@ -89,6 +93,7 @@ public class FlashDeliveryApplicationService {
         this.pricingCalculator = pricingCalculator;
         this.riderDeliveryLockService = riderDeliveryLockService;
         this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+        this.paymentMethodGateService = paymentMethodGateService;
     }
 
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
@@ -99,6 +104,9 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()));
         view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream()
                 .map(serviceType -> serviceView(pricing, serviceType)).toList());
+        // 031:首页返回闪送维度可选支付方式(平台开关开放集合),App 下单渲染用
+        view.setPayMethods(paymentMethodGateService.listAvailable(
+                PaymentMethodGateService.GateScope.RIDER_FLASH, null, null));
         return view;
     }
 
@@ -132,6 +140,9 @@ public class FlashDeliveryApplicationService {
         applyBreakdown(view, context.breakdown());
         view.setCurrency("TWD");
         view.setPricingVersion(context.pricing().getConfigVersion());
+        // 031:报价响应同样携带可选支付方式,与首页同源
+        view.setPayMethods(paymentMethodGateService.listAvailable(
+                PaymentMethodGateService.GateScope.RIDER_FLASH, null, null));
         return view;
     }
 
@@ -406,6 +417,8 @@ public class FlashDeliveryApplicationService {
     public FlashDeliveryRiderOrderPageView riderOrders(Long riderId, int pageNum, int pageSize,
                                                         String tab, BigDecimal longitude, BigDecimal latitude) {
         InfoUser rider = requireRider(riderId);
+        // 031:骑手接受的支付方式集合(未设置=平台开放全部),newTask 页签按此过滤
+        Set<String> acceptedPayTypes = acceptedRiderPayTypes(rider);
         if (!hasText(tab)) throw fail("flash.delivery.tab.invalid");
         String selectedTab = tab.trim();
         // lambda 无法捕获被重新赋值的参数,统一改用仅 newTask 携带坐标的有效变量。
@@ -417,7 +430,7 @@ public class FlashDeliveryApplicationService {
             validateRiderCoordinates(effectiveLongitude, effectiveLatitude);
         }
         QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, selectedTab,
-                effectiveLongitude, effectiveLatitude, true);
+                effectiveLongitude, effectiveLatitude, true, acceptedPayTypes);
         IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
         FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
         result.setRecords(orders.getRecords().stream()
@@ -428,7 +441,7 @@ public class FlashDeliveryApplicationService {
         if ("newTask".equals(selectedTab)) {
             result.setNearbyTaskCount(orders.getTotal());
             QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, selectedTab,
-                    effectiveLongitude, effectiveLatitude, false)
+                    effectiveLongitude, effectiveLatitude, false, acceptedPayTypes)
                     .select("MAX(amount)");
             result.setHighestOrderAmount(firstLong(orderMapper.selectObjs(maximumQuery)));
         }
@@ -1107,13 +1120,15 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    /** 支付方式与主订单系统同域(4=现金、6=线下转账);闪送款项直达骑手,仅允许线下方式,缺省现金,下单后锁定。 */
+    /**
+     * 支付方式经 031 闸门校验(闪送维度仅平台开关层——抢单前无收款方),
+     * 缺省现金 4;取代原先注释掉的白名单硬编码,改由平台配置管控(spec FR-004)。
+     */
     private String normalizePayType(String payType) {
-        if (!hasText(payType)) return PAY_TYPE_CASH;
-        String trimmed = payType.trim();
-        return trimmed;
-//        if (PAY_TYPE_CASH.equals(trimmed) || PAY_TYPE_TRANSFER.equals(trimmed)) return trimmed;
-//        throw fail("flash.delivery.pay.type.invalid");
+        String value = hasText(payType) ? payType.trim() : PAY_TYPE_CASH;
+        paymentMethodGateService.assertUsable(value,
+                PaymentMethodGateService.GateScope.RIDER_FLASH, null, null);
+        return value;
     }
 
     /** 将时间转为台北时区 HH:mm,用于命中运价时段。 */
@@ -1156,7 +1171,8 @@ public class FlashDeliveryApplicationService {
     /** 按页签构建骑手任务查询:newTask 限定可抢状态与时间窗,可选距离过滤和就近排序。 */
     private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, String tab,
                                                                 BigDecimal longitude, BigDecimal latitude,
-                                                                boolean orderResults) {
+                                                                boolean orderResults,
+                                                                Set<String> acceptedPayTypes) {
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<>();
         Date now = new Date();
         switch (tab) {
@@ -1164,6 +1180,12 @@ public class FlashDeliveryApplicationService {
                 query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
                         .and(item -> item.eq("delivery_mode", "NOW")
                                 .or().le("scheduled_pickup_start_at", now));
+                // 031:只展示骑手接受其支付方式的订单(现金单恒可见,见 acceptedRiderPayTypes)
+                if (acceptedPayTypes.isEmpty()) {
+                    query.apply("1 = 0");
+                } else {
+                    query.in("pay_type", acceptedPayTypes);
+                }
                 if (longitude != null) {
                     Integer limitKm = newTaskDistanceLimit();
                     if (limitKm != null) {
@@ -1200,6 +1222,32 @@ public class FlashDeliveryApplicationService {
     }
 
     /** 校验骑手坐标:经纬度必须成对出现且在合法范围。 */
+    /**
+     * 骑手接受的支付方式展开为 payType 集合。
+     * 未设置(NULL)=平台闪送维度开放的全部组;现金 4 不纳入管控但闪送现金单恒可见,
+     * 因此始终并入集合(与闸门对现金直接放行的口径一致)。
+     */
+    private Set<String> acceptedRiderPayTypes(InfoUser rider) {
+        String csv = rider.getFlashPayMethods();
+        List<String> groups = new ArrayList<>();
+        if (csv == null || csv.trim().isEmpty()) {
+            for (PaymentMethodGateService.PayMethodOption option : paymentMethodGateService.listAvailable(
+                    PaymentMethodGateService.GateScope.RIDER_FLASH, null, null)) {
+                if (!groups.contains(option.getMethodCode())) groups.add(option.getMethodCode());
+            }
+        } else {
+            for (String part : csv.split(",")) {
+                if (!part.trim().isEmpty()) groups.add(part.trim());
+            }
+        }
+        Set<String> payTypes = new LinkedHashSet<>();
+        payTypes.add(PAY_TYPE_CASH);
+        for (String group : groups) {
+            payTypes.addAll(PaymentMethodGateService.payTypesOf(group));
+        }
+        return payTypes;
+    }
+
     private void validateRiderCoordinates(BigDecimal longitude, BigDecimal latitude) {
         if ((longitude == null) != (latitude == null)
                 || longitude != null && (longitude.compareTo(BigDecimal.valueOf(-180)) < 0

+ 97 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/RiderFlashPayMethodController.java

@@ -0,0 +1,97 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.ruoyi.app.pay.dto.MerchantPayMethodSaveDto;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 骑手闪送收款方式接口(031,骑手 App 接入;本期无前端页面)。
+ * 未设置(NULL)=接受平台开放的全部方式,避免新单无人可见;
+ * 抢单列表按此选择过滤订单支付方式(见 FlashDeliveryApplicationService.riderOrdersQuery)。
+ */
+@RestController
+@RequestMapping("/system/riderFlashPayMethods")
+@Anonymous
+public class RiderFlashPayMethodController extends BaseController {
+
+    private final PaymentMethodGateService gateService;
+    private final InfoUserMapper userMapper;
+
+    public RiderFlashPayMethodController(PaymentMethodGateService gateService, InfoUserMapper userMapper) {
+        this.gateService = gateService;
+        this.userMapper = userMapper;
+    }
+
+    /** 设置数据:available=平台闪送维度开放方式;selected=骑手当前选择(NULL 视为全部)。 */
+    @Auth(session = true)
+    @GetMapping
+    public AjaxResult get(@RequestHeader String token) {
+        InfoUser rider = requireRider(token);
+        List<PaymentMethodGateService.PayMethodOption> available =
+                gateService.listAvailable(PaymentMethodGateService.GateScope.RIDER_FLASH, null, null);
+        List<String> selected = new ArrayList<>();
+        String csv = rider.getFlashPayMethods();
+        if (csv != null && !csv.trim().isEmpty()) {
+            for (String part : csv.split(",")) {
+                if (!part.trim().isEmpty()) selected.add(part.trim());
+            }
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("available", available);
+        data.put("selected", selected);
+        return success(data);
+    }
+
+    /** 保存选择:空数组=清空回落"平台开放全部";值域校验闪送维度组合法。 */
+    @Auth(session = true)
+    @PutMapping
+    public AjaxResult save(@RequestHeader String token, @RequestBody MerchantPayMethodSaveDto dto) {
+        InfoUser rider = requireRider(token);
+        List<String> groups = PaymentMethodGateService.groupsOf(PaymentMethodGateService.GateScope.RIDER_FLASH);
+        String csv = null;
+        if (dto != null && dto.getMethodCodes() != null && !dto.getMethodCodes().isEmpty()) {
+            for (String code : dto.getMethodCodes()) {
+                if (code == null || !groups.contains(code.trim())) {
+                    throw new ServiceException(MessageUtils.message("pay.method.config.invalid"));
+                }
+            }
+            csv = String.join(",", dto.getMethodCodes());
+        }
+        userMapper.update(null, new UpdateWrapper<InfoUser>()
+                .eq("user_id", rider.getUserId())
+                .set("flash_pay_methods", csv)
+                .set("update_time", new Date()));
+        return success();
+    }
+
+    /** 仅 userType=2 骑手可设置;非骑手按无权限处理。 */
+    private InfoUser requireRider(String token) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        InfoUser rider = userMapper.selectById(userId);
+        if (rider == null || !"2".equals(rider.getUserType())) {
+            throw new ServiceException(MessageUtils.message("no.user.stop"));
+        }
+        return rider;
+    }
+}

+ 17 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -1401,11 +1401,27 @@ class FlashDeliveryApplicationServiceTest {
         final FlashDeliveryRouteService routeService = mock(FlashDeliveryRouteService.class);
         final RiderDeliveryLockService lockService = mock(RiderDeliveryLockService.class);
         final RiderDeliveryExclusivityService exclusivityService = mock(RiderDeliveryExclusivityService.class);
+        final com.ruoyi.system.service.PaymentMethodGateService gateService =
+                org.mockito.Mockito.mock(com.ruoyi.system.service.PaymentMethodGateService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
-                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService);
+                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService, gateService);
 
         Fixture() {
+            // 031:平台闪送维度默认全开(COD/LINE_PAY/OFFLINE;现金 4 由服务端恒并入),既有用例不受过滤影响
+            org.mockito.Mockito.when(gateService.listAvailable(any(), any(), any()))
+                    .thenReturn(java.util.List.of(
+                            new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("COD", "1", true),
+                            new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("LINE_PAY", "3", true),
+                            new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption(
+                                    "OFFLINE_TRANSFER", "6", true)));
+            // 闪送维度不开放信用卡(2):闸门对该组合抛错,复现真实判定行为
+            org.mockito.Mockito.doThrow(new ServiceException("支付方式不可用"))
+                    .when(gateService).assertUsable(org.mockito.ArgumentMatchers.eq("2"),
+                            org.mockito.ArgumentMatchers.eq(
+                                    com.ruoyi.system.service.PaymentMethodGateService.GateScope.RIDER_FLASH),
+                            org.mockito.ArgumentMatchers.isNull(),
+                            org.mockito.ArgumentMatchers.isNull());
             when(lockService.withLock(any(), any())).thenAnswer(invocation -> {
                 RiderDeliveryLockService.LockedCall<?> call = invocation.getArgument(1);
                 return call.call();

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java

@@ -11,7 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryI18nContractTest {
     private static final List<String> FILES = List.of("messages.properties", "messages_zh_CN.properties",
-            "messages_zh_TW.properties", "messages_en_US.properties", "messages_vi.properties",
+            "messages_zh_TW.properties", "messages_en_US.properties",
             "messages_th_TH.properties");
     private static final List<String> KEYS = List.of(
             "flash.delivery.request.required", "flash.delivery.auth.required",

+ 2 - 2
ruoyi-system/src/main/java/com/ruoyi/system/service/PaymentMethodGateService.java

@@ -205,8 +205,8 @@ public class PaymentMethodGateService {
         }
     }
 
-    /** 组代码 → 对外 payType 展开顺序(保持稳定展示顺序)。 */
-    private List<String> payTypesOf(String group) {
+    /** 组代码 → 对外 payType 展开顺序(保持稳定展示顺序);供抢单过滤等调用方展开。 */
+    public static List<String> payTypesOf(String group) {
         switch (group) {
             case GROUP_COD: return List.of("1");
             case GROUP_CARD_OMG: return List.of("2", "5");

+ 3 - 3
specs/031-pay-method-config/tasks.md

@@ -89,9 +89,9 @@ description: "Task list for 031-pay-method-config"
 
 **Independent Test**: 骑手只接受现金 → 转账单不进其列表;未设置骑手全可见
 
-- [ ] T015 [P] [US4] 新建 `RiderFlashPayMethodController`(`ruoyi-admin/.../pay/` 或骑手包):`GET/PUT /system/riderFlashPayMethods`(token 校验 userType=2,scope=RIDER_FLASH,NULL=全部);单测
-- [ ] T016 [US4] 闪送下单/报价接支付方式(`ruoyi-admin/.../flashdelivery/`):`FlashDeliveryQuoteRequest`/`FlashDeliveryCreateRequest` 加可选 `payType` 字段(注释:缺省现金 4);`normalizePayType` 从透传改为 `assertUsable(payType, RIDER_FLASH, null)`(仅平台开关层,抢单前无收款方);home/quote 响应增可选方式列表 `listAvailable(RIDER_FLASH, null)`;相关 DTO 字段注释补齐;单测(非法方式拒绝、缺省现金、可选项返回)
-- [ ] T017 [US4] 抢单列表过滤(`FlashDeliveryApplicationService.riderOrdersQuery` newTask 分支):追加 `pay_type ∈ 骑手 flash_pay_methods 展开集合`(NULL=平台开放全部集合)条件;`FlashDeliveryRiderOrderListViewTest` 补过滤用例
+- [x] T015 [P] [US4] 新建 `RiderFlashPayMethodController`(`ruoyi-admin/.../pay/` 或骑手包):`GET/PUT /system/riderFlashPayMethods`(token 校验 userType=2,scope=RIDER_FLASH,NULL=全部);单测
+- [x] T016 [US4] 闪送下单/报价接支付方式(`ruoyi-admin/.../flashdelivery/`):`FlashDeliveryQuoteRequest`/`FlashDeliveryCreateRequest` 加可选 `payType` 字段(注释:缺省现金 4);`normalizePayType` 从透传改为 `assertUsable(payType, RIDER_FLASH, null)`(仅平台开关层,抢单前无收款方);home/quote 响应增可选方式列表 `listAvailable(RIDER_FLASH, null)`;相关 DTO 字段注释补齐;单测(非法方式拒绝、缺省现金、可选项返回)
+- [x] T017 [US4] 抢单列表过滤(`FlashDeliveryApplicationService.riderOrdersQuery` newTask 分支):追加 `pay_type ∈ 骑手 flash_pay_methods 展开集合`(NULL=平台开放全部集合)条件;`FlashDeliveryRiderOrderListViewTest` 补过滤用例
 
 **Checkpoint**: 闪送维度闸门闭环(US3 白名单第 3 处)