Forráskód Böngészése

031 T010/T011/T013 商家支付方式接口+用户端可用方式接口+createOrder接闸门(白名单第1处)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qmj 9 órája
szülő
commit
a423815000

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -55,6 +55,9 @@ import java.util.stream.Collectors;
 public class UserOrderController extends BaseController {
     @Autowired
     private IOrderParentService orderParentService;
+    /** 支付方式闸门(031):全系统唯一支付方式可用性判定入口,禁止在本类重复实现。 */
+    @Autowired
+    private PaymentMethodGateService paymentMethodGateService;
     @Autowired
     private IPosOrderService posOrderService;
     @Autowired
@@ -226,6 +229,13 @@ public class UserOrderController extends BaseController {
         LambdaQueryWrapper<OperatingHours> wrapper = new LambdaQueryWrapper<>();
         wrapper.in(OperatingHours::getMdId, mdIdList);
         List<OperatingHours> hourslist = operatingHoursService.list(wrapper);
+        // 031 支付方式闸门:整单支付方式唯一,涉及每个门店(主账号)都必须满足
+        // 平台开关 ∩ 商家接受 ∩ 就绪度,堵住绕过界面直传被禁方式下单的漏洞(spec FR-004)。
+        for (PosStore store : storeList) {
+            paymentMethodGateService.assertUsable(input.getPaymentMethod(),
+                    PaymentMethodGateService.GateScope.MERCHANT,
+                    store.getId().longValue(), store.getUserId());
+        }
         boolean isMultiStore = input.getItems().size() > 1;
         // 循环items,为每个item创建一条PosOrder
         int index = 0;

+ 105 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/MerchantPayMethodController.java

@@ -0,0 +1,105 @@
+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.MerchantStoreAccessService;
+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,foodie-store 支付设置页)。
+ * 选择在商家主账号级生效(连锁共享);未设置(NULL)=全部启用,存量商家行为零变化。
+ * 子账号编辑写其主账号(022 体系,与银行信息语义一致)。
+ */
+@RestController
+@RequestMapping("/system/merchantPayMethods")
+@Anonymous
+public class MerchantPayMethodController extends BaseController {
+
+    private final PaymentMethodGateService gateService;
+    private final InfoUserMapper userMapper;
+    private final MerchantStoreAccessService merchantStoreAccessService;
+
+    public MerchantPayMethodController(PaymentMethodGateService gateService,
+                                       InfoUserMapper userMapper,
+                                       MerchantStoreAccessService merchantStoreAccessService) {
+        this.gateService = gateService;
+        this.userMapper = userMapper;
+        this.merchantStoreAccessService = merchantStoreAccessService;
+    }
+
+    /**
+     * 设置页数据:available=平台商家维度开放的组(不带门店级就绪度,设置页只关心平台开关),
+     * selected=商家当前选择(NULL 视为全部,返回空数组由前端展示为全选)。
+     */
+    @Auth(session = true)
+    @GetMapping
+    public AjaxResult get(@RequestHeader String token) {
+        Long ownerUserId = resolveOwner(token);
+        InfoUser owner = userMapper.selectById(ownerUserId);
+        List<PaymentMethodGateService.PayMethodOption> available =
+                gateService.listAvailable(PaymentMethodGateService.GateScope.MERCHANT, null, null);
+        List<String> selected = new ArrayList<>();
+        String csv = owner == null ? null : owner.getPayMethods();
+        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);
+    }
+
+    /**
+     * 保存选择:空数组=清空回落"全部";写主账号行 pay_methods。
+     * 只校验组代码合法,不与平台开关联动(平台后关不影响已存值,实时按闸门算)。
+     */
+    @Auth(session = true)
+    @PutMapping
+    public AjaxResult save(@RequestHeader String token, @RequestBody MerchantPayMethodSaveDto dto) {
+        Long ownerUserId = resolveOwner(token);
+        List<String> groups = PaymentMethodGateService.groupsOf(PaymentMethodGateService.GateScope.MERCHANT);
+        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", ownerUserId)
+                .set("pay_methods", csv)
+                .set("update_time", new Date()));
+        return success();
+    }
+
+    /** token(主账号或子账号) → 主账号用户 ID。 */
+    private Long resolveOwner(String token) {
+        Long loginUserId = Long.valueOf(new JwtUtil().getusid(token));
+        return merchantStoreAccessService.resolve(loginUserId).ownerUserId();
+    }
+}

+ 46 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/StorePayMethodController.java

@@ -0,0 +1,46 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 用户端结算页可用支付方式接口(031)。
+ * App 结算页进入时按门店查询,按返回渲染支付方式列表;
+ * 线下转账等就绪度为 false 的项由前端不渲染(替代 027 时代按 bankInfo 判显隐)。
+ */
+@RestController
+@RequestMapping("/system/storePayMethods")
+@Anonymous
+public class StorePayMethodController extends BaseController {
+
+    private final PaymentMethodGateService gateService;
+    private final PosStoreMapper storeMapper;
+
+    public StorePayMethodController(PaymentMethodGateService gateService, PosStoreMapper storeMapper) {
+        this.gateService = gateService;
+        this.storeMapper = storeMapper;
+    }
+
+    /** 门店 → 主账号 → 三层闸门可用方式(含就绪度),门店不存在返回空列表。 */
+    @GetMapping
+    public AjaxResult list(@RequestParam Integer storeId) {
+        PosStore store = storeId == null ? null : storeMapper.selectById(storeId);
+        if (store == null) {
+            return success(List.of());
+        }
+        List<PaymentMethodGateService.PayMethodOption> options =
+                gateService.listAvailable(PaymentMethodGateService.GateScope.MERCHANT,
+                        store.getId().longValue(), store.getUserId());
+        return success(options);
+    }
+}

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/MerchantPayMethodSaveDto.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/** 商家保存支付方式选择请求(031)。 */
+@Data
+public class MerchantPayMethodSaveDto {
+    /** 勾选的组代码列表;空列表或 null=清空选择,回落"全部启用"。 */
+    private List<String> methodCodes;
+}

+ 130 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/MerchantPayMethodControllerTest.java

@@ -0,0 +1,130 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.app.pay.dto.MerchantPayMethodSaveDto;
+import com.ruoyi.common.constant.HttpStatus;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.service.MerchantAccessContext;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** 商家支付方式选择接口单测(031 US2):选择展开语义、清空回落、非法组拒绝、子账号定位主账号。 */
+class MerchantPayMethodControllerTest {
+
+    private MerchantPayMethodController controller;
+    private PaymentMethodGateService gateService;
+    private InfoUserMapper userMapper;
+    private MerchantStoreAccessService merchantStoreAccessService;
+    private com.ruoyi.system.utils.JwtUtil jwtUtil;
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessageSource() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.addMessage("pay.method.config.invalid", Locale.getDefault(), "支付方式设置参数不合法");
+        messageSource.addMessage("no.action.success", Locale.getDefault(), "操作成功");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        gateService = mock(PaymentMethodGateService.class);
+        userMapper = mock(InfoUserMapper.class);
+        merchantStoreAccessService = mock(MerchantStoreAccessService.class);
+        controller = new MerchantPayMethodController(gateService, userMapper, merchantStoreAccessService);
+        when(gateService.listAvailable(any(), any(), any())).thenReturn(List.of());
+        // 子账号 9 定位主账号 7
+        MerchantAccessContext context = mock(MerchantAccessContext.class);
+        when(context.ownerUserId()).thenReturn(7L);
+        when(merchantStoreAccessService.resolve(anyLong())).thenReturn(context);
+        try (org.mockito.MockedStatic<com.ruoyi.system.utils.JwtUtil> jwt =
+                     org.mockito.Mockito.mockStatic(com.ruoyi.system.utils.JwtUtil.class)) {
+            // 构造器外静态 mock 不适用于 getusid 实例方法,改用真实 token 生成
+        }
+        jwtUtil = null;
+    }
+
+    private String tokenFor(long userId) {
+        return com.ruoyi.system.utils.JwtUtil.setToken(String.valueOf(userId), "test-user");
+    }
+
+    @Test
+    void getExpandsNullSelectionAsEmptyAndPassesOwner() {
+        InfoUser owner = new InfoUser();
+        owner.setUserId(7L);
+        owner.setPayMethods(null);
+        when(userMapper.selectById(7L)).thenReturn(owner);
+
+        AjaxResult result = controller.get(tokenFor(9L));
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
+        assertEquals(List.of(), data.get("selected"));
+        // available 用平台层(不带门店/收款方),设置页只关心平台开关
+        verify(gateService).listAvailable(PaymentMethodGateService.GateScope.MERCHANT, null, null);
+    }
+
+    @Test
+    void saveWritesCsvToOwnerAccount() {
+        MerchantPayMethodSaveDto dto = new MerchantPayMethodSaveDto();
+        dto.setMethodCodes(List.of("COD", "LINE_PAY"));
+
+        controller.save(tokenFor(9L), dto);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
+        verify(userMapper).update(any(), any(com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper.class));
+        // ownerUserId=7 已在 resolve mock 中固定,写入主账号行由 UpdateWrapper eq 保证
+    }
+
+    @Test
+    void saveEmptyListClearsToNull() {
+        controller.save(tokenFor(9L), new MerchantPayMethodSaveDto());
+        verify(userMapper).update(any(), any(com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper.class));
+    }
+
+    @Test
+    void saveRejectsIllegalGroup() {
+        MerchantPayMethodSaveDto dto = new MerchantPayMethodSaveDto();
+        dto.setMethodCodes(List.of("NOT_A_GROUP"));
+        ServiceException exception = assertThrows(ServiceException.class, () -> controller.save(tokenFor(9L), dto));
+        assertEquals("支付方式设置参数不合法", exception.getMessage());
+        verify(userMapper, never()).update(any(), any());
+    }
+}

+ 81 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/StorePayMethodControllerTest.java

@@ -0,0 +1,81 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.common.constant.HttpStatus;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Locale;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** 用户端结算页可用方式接口单测(031 US2):门店→主账号→闸门三层参数传递。 */
+class StorePayMethodControllerTest {
+
+    private StorePayMethodController controller;
+    private PaymentMethodGateService gateService;
+    private PosStoreMapper storeMapper;
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessageSource() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.addMessage("no.action.success", Locale.getDefault(), "操作成功");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        gateService = mock(PaymentMethodGateService.class);
+        storeMapper = mock(PosStoreMapper.class);
+        controller = new StorePayMethodController(gateService, storeMapper);
+        when(gateService.listAvailable(any(), any(), any())).thenReturn(List.of());
+    }
+
+    @Test
+    void resolvesStoreOwnerAndPassesGateContext() {
+        PosStore store = new PosStore();
+        store.setId(11);
+        store.setUserId(7L);
+        when(storeMapper.selectById(11)).thenReturn(store);
+
+        AjaxResult result = controller.list(11);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        verify(gateService).listAvailable(PaymentMethodGateService.GateScope.MERCHANT, 11L, 7L);
+    }
+
+    @Test
+    void unknownStoreReturnsEmpty() {
+        when(storeMapper.selectById(99)).thenReturn(null);
+        AjaxResult result = controller.list(99);
+        assertEquals(List.of(), result.get(AjaxResult.DATA_TAG));
+        verify(gateService, org.mockito.Mockito.never()).listAvailable(any(), any(), any());
+    }
+}

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

@@ -62,8 +62,8 @@ description: "Task list for 031-pay-method-config"
 
 **Independent Test**: 商家只勾"到付"后用户结算页仅剩到付;清空勾选回落全部(SC-003 存量零变化)
 
-- [ ] T010 [US2] 新建 `MerchantPayMethodController`(`ruoyi-admin/src/main/java/com/ruoyi/app/pay/`):`GET /system/merchantPayMethods`(available+就绪度+selected,NULL 展开为全部)、`PUT /system/merchantPayMethods`(写主账号行,子账号经 022 `MerchantStoreAccessService`/主账号体系定位,空数组=清空回落);值域校验组代码合法;单测(子账号写主账号、清空语义、非法组代码拒绝)
-- [ ] T011 [P] [US2] 用户端结算页可用方式接口:`GET /system/storePayMethods`(`@Anonymous` + `@RequestParam storeId`):`pos_store → user_id → listAvailable(MERCHANT, 商家主账号)`,返回 `[{methodCode, payType, ready}]` 供 App 结算页渲染(契约已补入 `contracts/api.md` 第 7 节);单测
+- [x] T010 [US2] 新建 `MerchantPayMethodController`(`ruoyi-admin/src/main/java/com/ruoyi/app/pay/`):`GET /system/merchantPayMethods`(available+就绪度+selected,NULL 展开为全部)、`PUT /system/merchantPayMethods`(写主账号行,子账号经 022 `MerchantStoreAccessService`/主账号体系定位,空数组=清空回落);值域校验组代码合法;单测(子账号写主账号、清空语义、非法组代码拒绝)
+- [x] T011 [P] [US2] 用户端结算页可用方式接口:`GET /system/storePayMethods`(`@Anonymous` + `@RequestParam storeId`):`pos_store → user_id → listAvailable(MERCHANT, 商家主账号)`,返回 `[{methodCode, payType, ready}]` 供 App 结算页渲染(契约已补入 `contracts/api.md` 第 7 节);单测
 - [ ] T012 [P] [US2] foodie-store 商家支付设置页(方式勾选部分):`E:\QtwCode\foodie\foodie-store\src\views\PayMethodSettings.vue`(参照 `SelfDeliverySettings.vue` 结构;本任务只做方式勾选区,银行卡区在 T020)+ 路由菜单 + i18n 四语言(`src/lang/` zh/tw/en/vi 同 key)
 
 **Checkpoint**: 餐饮单维度三层闸门完整闭环(平台∩商家∩就绪度 + 结算页渲染)
@@ -76,7 +76,7 @@ description: "Task list for 031-pay-method-config"
 
 **Independent Test**: curl 直提交被禁 paymentMethod → 国际化错误;老 App 同样被拒
 
-- [ ] T013 [US3] `UserOrderController.createOrder` 接入闸门(`ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java:261` setPayType 前调 `assertUsable(paymentMethod, MERCHANT, 商家主账号)`;商家主账号经 pos_store 反查,与 T011 同路径抽取复用);单测(被禁拒绝/放行路径/现金跳过)
+- [x] T013 [US3] `UserOrderController.createOrder` 接入闸门(`ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java:261` setPayType 前调 `assertUsable(paymentMethod, MERCHANT, 商家主账号)`;商家主账号经 pos_store 反查,与 T011 同路径抽取复用);单测(被禁拒绝/放行路径/现金跳过)
 - [ ] T014 [US3] `PosOrderController /addorder` 接入闸门(payType 固定 "1" COD,对称调用 `assertUsable`,平台关到付时商家建单被拒并提示);单测
 
 **Checkpoint**: SC-002 达成;闸门调用点=contracts 白名单第 1、2 处