Explorar el Código

031 T018/T019/T021 银行卡CRUD+启用互斥+bankInfo改读启用卡套闸门(白名单第4处)+闪送确认收款(幂等+日志);67测试全过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qmj hace 9 horas
padre
commit
77dd3a9f85
Se han modificado 18 ficheros con 330 adiciones y 122 borrados
  1. 8 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  2. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  3. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  4. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  5. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  6. 27 6
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  7. 170 113
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/BankCardController.java
  8. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/BankCardSaveDto.java
  9. 4 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  10. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  11. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  12. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  13. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  14. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  15. 45 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  16. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  17. 1 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  18. 3 3
      specs/031-pay-method-config/tasks.md

+ 8 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java

@@ -55,6 +55,14 @@ public class FlashDeliveryRiderController extends BaseController {
         return success(service.accept(userId(token), id));
     }
 
+    /** 骑手确认已实际收款:收款状态 0→1 并记日志,不影响订单状态;重复调用幂等。 */
+    @PostMapping("/orders/{id}/confirmPayment")
+    @Auth
+    public AjaxResult confirmPayment(@RequestHeader String token, @PathVariable Long id) {
+        service.confirmPayment(userId(token), id);
+        return success();
+    }
+
     /** 上传取件凭证并将已接单订单推进为已取件。 */
     @PostMapping("/orders/{id}/pickup")
     @Auth

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

@@ -57,6 +57,8 @@ public class FlashDeliveryOrderView {
     private String currency;
     /** 支付方式:4=现金、6=线下转账;款项直达骑手,用户与骑手详情均返回。 */
     private String payType;
+    /** 收款状态:0=未收款,1=骑手已确认收款。 */
+    private Integer paymentStatus;
     /** 用户备注,骑手接单前不返回。 */
     private String userNote;
     /** 骑手接单时间。 */

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

@@ -61,6 +61,8 @@ public class FlashDeliveryRiderOrderListView {
     private String currency;
     /** 支付方式:4=现金、6=线下转账;骑手抢单前需知道如何收钱。 */
     private String payType;
+    /** 收款状态:0=未收款,1=骑手已确认收款。 */
+    private Integer paymentStatus;
     /** 创建时间。 */
     private Date createTime;
 }

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

@@ -51,6 +51,8 @@ public class FlashDeliveryUserOrderListView {
     private Long amount;
     /** 币种。 */
     private String currency;
+    /** 收款状态:0=未收款,1=骑手已确认收款。 */
+    private Integer paymentStatus;
     /** 送达时间。 */
     private Date deliveredAt;
     /** 创建时间。 */

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -626,6 +626,31 @@ public class FlashDeliveryApplicationService {
     }
 
     /** 查询送达时间早于截止点且未完成的订单,供自动完成任务分批处理。 */
+    /**
+     * 骑手确认收款(031):本人中单且订单已达已送达(或之后)才可确认;
+     * 条件更新 0→1 保证幂等与并发安全,仅首次确认写审计日志;
+     * 收款状态不影响订单状态流转(转账到账有时差,平台不经手资金不设硬闸门,spec FR-013)。
+     */
+    @Transactional
+    public void confirmPayment(Long riderId, Long orderId) {
+        requireRider(riderId);
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
+        if (!DELIVERED.equals(order.getStatus()) && !COMPLETED.equals(order.getStatus())) {
+            throw fail("flash.delivery.payment.confirm.not.allowed");
+        }
+        if (Integer.valueOf(1).equals(order.getPaymentStatus())) return;
+        Date now = new Date();
+        // 条件更新 0→1;并发重复确认只有一方成功,另一方按幂等成功返回
+        if (orderMapper.update(null, new UpdateWrapper<FlashDeliveryOrder>()
+                .eq("id", orderId)
+                .eq("payment_status", 0)
+                .set("payment_status", 1)
+                .set("update_time", now)) == 1) {
+            writeLog(orderId, order.getStatus(), order.getStatus(), "RIDER", riderId, "PAYMENT_CONFIRMED", now);
+        }
+    }
+
     public List<FlashDeliveryOrder> autoCompleteCandidates(Date deadline, int limit) {
         return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
     }
@@ -838,6 +863,7 @@ public class FlashDeliveryApplicationService {
         view.setTipAmount(order.getTipAmount());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
+        view.setPaymentStatus(order.getPaymentStatus());
         view.setDeliveredAt(order.getDeliveredAt());
         view.setCreateTime(order.getCreateTime());
         return view;
@@ -865,6 +891,7 @@ public class FlashDeliveryApplicationService {
         view.setDeliveryAddress(order.getDeliveryAddress());
         view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
         view.setPayType(order.getPayType());
+        view.setPaymentStatus(order.getPaymentStatus());
         view.setPickupDistanceMeters(distanceMeters(longitude, latitude,
                 order.getPickupLongitude(), order.getPickupLatitude()));
         view.setDistanceMeters(order.getDistanceMeters());
@@ -947,6 +974,7 @@ public class FlashDeliveryApplicationService {
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
         view.setPayType(order.getPayType());
+        view.setPaymentStatus(order.getPaymentStatus());
         view.setUserNote(includeSensitiveFields ? order.getUserNote() : null);
         view.setAcceptedAt(order.getAcceptedAt());
         view.setPickedUpAt(order.getPickedUpAt());

+ 27 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java

@@ -62,6 +62,12 @@ import java.util.stream.Collectors;
 public class PosStoreController extends BaseController {
     @Autowired
     private IPosStoreService posStoreService;
+    /** 支付方式闸门(031):bankInfo 展示前置判定。 */
+    @Autowired
+    private com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService;
+    /** 收款银行卡(031 列表化):bankInfo 数据源。 */
+    @Autowired
+    private com.ruoyi.system.mapper.InfoBankCardMapper infoBankCardMapper;
 
     @Autowired
     private PosStoreMapper posStoreMapper;
@@ -252,15 +258,30 @@ public class PosStoreController extends BaseController {
         if (store == null || store.getUserId() == null) {
             return success(null);
         }
-        InfoUser merchant = infoUserService.selectInfoUserByUserId(store.getUserId());
-        if (merchant == null || StringUtils.isBlank(merchant.getBankAccountName())
-                || StringUtils.isBlank(merchant.getBankName()) || StringUtils.isBlank(merchant.getBankAccountNo())) {
+        // 031:先套闸门(平台开 线下转账×商家 ∩ 商家接受),不满足按无信息返回,老客户端自然隐藏
+        try {
+            paymentMethodGateService.assertUsable("6",
+                    com.ruoyi.system.service.PaymentMethodGateService.GateScope.MERCHANT,
+                    store.getId().longValue(), store.getUserId());
+        } catch (com.ruoyi.common.exception.ServiceException gateRejected) {
+            return success(null);
+        }
+        // 031:数据源改收款银行卡启用卡(027 存量三字段已迁移);判据从"三字段齐全"改为"存在启用卡"。
+        // 防御多行启用脏数据:取第一张而非 selectOne(多行会抛底层异常)。
+        java.util.List<com.ruoyi.system.domain.InfoBankCard> cards = infoBankCardMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.ruoyi.system.domain.InfoBankCard>()
+                        .eq(com.ruoyi.system.domain.InfoBankCard::getUserId, store.getUserId())
+                        .eq(com.ruoyi.system.domain.InfoBankCard::getIsActive, 1)
+                        .orderByDesc(com.ruoyi.system.domain.InfoBankCard::getId)
+                        .last("LIMIT 1"));
+        if (cards.isEmpty()) {
             return success(null);
         }
+        com.ruoyi.system.domain.InfoBankCard card = cards.get(0);
         JSONObject data = new JSONObject();
-        data.put("accountName", merchant.getBankAccountName().trim());
-        data.put("bankName", merchant.getBankName().trim());
-        data.put("accountNo", merchant.getBankAccountNo().trim());
+        data.put("accountName", card.getAccountName().trim());
+        data.put("bankName", card.getBankName().trim());
+        data.put("accountNo", card.getAccountNo().trim());
         return success(data);
     }
 

+ 170 - 113
ruoyi-admin/src/main/java/com/ruoyi/app/pay/BankCardController.java

@@ -1,142 +1,199 @@
 package com.ruoyi.app.pay;
 
-import java.util.List;
-import jakarta.servlet.http.HttpServletResponse;
-
-import com.alibaba.fastjson.JSONObject;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.ruoyi.app.pay.dto.BankCardSaveDto;
 import com.ruoyi.common.annotation.Anonymous;
-import com.ruoyi.system.domain.InfoUser;
-import com.ruoyi.system.utils.Auth;
-import com.ruoyi.system.utils.JwtUtil;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.*;
-import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.enums.BusinessType;
-import com.ruoyi.system.domain.BankCard;
-import com.ruoyi.system.service.IBankCardService;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.utils.MessageUtils;
-import com.ruoyi.common.utils.poi.ExcelUtil;
-import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.domain.InfoBankCard;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoBankCardMapper;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+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.Date;
+import java.util.List;
 
 /**
- * BankCardController
- * 
- * @author ruoyi
- * @date 2023-08-06
+ * 收款银行卡管理接口(031,商家/骑手通用;foodie-store 支付设置页与骑手 App 共用)。
+ * 卡归属:骑手=本人;商家(含子账号)=主账号(连锁共享,与支付方式选择同语义)。
+ * 同一归属同时最多一张启用卡;付款方仅能看到启用卡(bankInfo 已改读启用卡)。
  */
 @RestController
-@RequestMapping("/system/card")
-public class BankCardController extends BaseController
-{
-    @Autowired
-    private IBankCardService bankCardService;
+@RequestMapping("/system/bankCard")
+@Anonymous
+public class BankCardController extends BaseController {
 
+    /** 单人卡数上限,防止无限囤卡。 */
+    private static final int MAX_CARDS = 10;
 
-    //删除银行卡
-    @Anonymous
-    @Auth
-    @GetMapping("/delebankcard")
-    public AjaxResult delebankcard(@RequestParam Long id)
-    {
-        int org = bankCardService.deleteBankCardById(id);
-        if(org==1){
-            return success(MessageUtils.message("no.delete.success"));
-        }else {
-            return error(MessageUtils.message("no.delete.fail"));
-        }
+    private final InfoBankCardMapper bankCardMapper;
+    private final InfoUserMapper userMapper;
+    private final MerchantStoreAccessService merchantStoreAccessService;
+
+    public BankCardController(InfoBankCardMapper bankCardMapper,
+                              InfoUserMapper userMapper,
+                              MerchantStoreAccessService merchantStoreAccessService) {
+        this.bankCardMapper = bankCardMapper;
+        this.userMapper = userMapper;
+        this.merchantStoreAccessService = merchantStoreAccessService;
     }
-    //用户银行卡列表
-    @Anonymous
-    @Auth
-    @GetMapping("/getbankcard")
-    public AjaxResult getbankcard(@RequestHeader String token)
-    {
-        JwtUtil jwtUtil = new JwtUtil();
-        String userid = jwtUtil.getusid(token);
-        BankCard bankCard = new BankCard();
-        bankCard.setUserId(Long.valueOf(userid));
-        List<BankCard> list = bankCardService.selectBankCardList(bankCard);
-        return success(MessageUtils.message("no.load.success"), list);
+
+    /** 本人(商家为主账号)卡列表,含启用标记。 */
+    @Auth(session = true)
+    @GetMapping
+    public AjaxResult list(@RequestHeader String token) {
+        Long ownerId = resolveOwner(token);
+        List<InfoBankCard> cards = bankCardMapper.selectList(
+                new LambdaQueryWrapper<InfoBankCard>()
+                        .eq(InfoBankCard::getUserId, ownerId)
+                        .orderByDesc(InfoBankCard::getIsActive)
+                        .orderByDesc(InfoBankCard::getId));
+        return success(cards);
     }
-    //添加银行卡
-    @Anonymous
-    @Auth
-    @PostMapping("/addbankcard")
-    public AjaxResult addbankcard(@RequestHeader String token, @RequestBody BankCard bankCard)
-    {
-        JwtUtil jwtUtil = new JwtUtil();
-        bankCard.setUserId(Long.valueOf(jwtUtil.getusid(token)));
-        return toAjax(bankCardService.insertBankCard(bankCard));
+
+    /** 新增卡;首张卡自动启用,否则默认停用。 */
+    @Auth(session = true)
+    @PostMapping
+    @Transactional(rollbackFor = Exception.class)
+    public AjaxResult add(@RequestHeader String token, @RequestBody BankCardSaveDto dto) {
+        Long ownerId = resolveOwner(token);
+        InfoBankCard card = validated(dto, ownerId);
+        long count = bankCardMapper.selectCount(
+                new LambdaQueryWrapper<InfoBankCard>().eq(InfoBankCard::getUserId, ownerId));
+        if (count >= MAX_CARDS) {
+            throw new ServiceException(MessageUtils.message("pay.bankcard.limit"));
+        }
+        card.setUserId(ownerId);
+        // 首张卡自动启用,避免迁移遗漏或新收款方忘记启用导致线下转账长期不就绪
+        card.setIsActive(count == 0 ? 1 : 0);
+        Date now = new Date();
+        card.setCreateTime(now);
+        card.setUpdateTime(now);
+        bankCardMapper.insert(card);
+        return success(card);
     }
-    /**
-     * 查询BankCard列表
-     */
-    @PreAuthorize("@ss.hasPermi('system:card:list')")
-    @GetMapping("/list")
-    public TableDataInfo list(BankCard bankCard)
-    {
-        startPage();
-        List<BankCard> list = bankCardService.selectBankCardList(bankCard);
-        return getDataTable(list);
+
+    /** 修改本人卡信息(不改启用状态)。 */
+    @Auth(session = true)
+    @PutMapping
+    public AjaxResult update(@RequestHeader String token, @RequestBody BankCardSaveDto dto) {
+        Long ownerId = resolveOwner(token);
+        if (dto == null || dto.getId() == null) {
+            throw new ServiceException(MessageUtils.message("pay.bankcard.invalid"));
+        }
+        InfoBankCard existing = requireOwnCard(dto.getId(), ownerId);
+        InfoBankCard validated = validated(dto, ownerId);
+        existing.setBankName(validated.getBankName());
+        existing.setAccountNo(validated.getAccountNo());
+        existing.setAccountName(validated.getAccountName());
+        existing.setUpdateTime(new Date());
+        bankCardMapper.updateById(existing);
+        return success();
     }
 
-    /**
-     * 导出BankCard列表
-     */
-    @PreAuthorize("@ss.hasPermi('system:card:export')")
-    @Log(title = "BankCard", businessType = BusinessType.EXPORT)
-    @PostMapping("/export")
-    public void export(HttpServletResponse response, BankCard bankCard)
-    {
-        List<BankCard> list = bankCardService.selectBankCardList(bankCard);
-        ExcelUtil<BankCard> util = new ExcelUtil<BankCard>(BankCard.class);
-        util.exportExcel(response, list, MessageUtils.message("no.export.excel.bankcard"));
+    /** 删除本人卡;删的是启用卡则该归属回到"无启用卡"(线下转账不就绪)。 */
+    @Auth(session = true)
+    @DeleteMapping("/{id}")
+    public AjaxResult delete(@RequestHeader String token, @PathVariable Long id) {
+        Long ownerId = resolveOwner(token);
+        requireOwnCard(id, ownerId);
+        bankCardMapper.deleteById(id);
+        return success();
     }
 
     /**
-     * 获取BankCard详细信息
+     * 启用某卡并同事务停旧卡,保证同一归属最多一张启用;
+     * 付款方下次查询即看到新卡(无缓存)。
      */
-    @PreAuthorize("@ss.hasPermi('system:card:query')")
-    @GetMapping(value = "/{id}")
-    public AjaxResult getInfo(@PathVariable("id") Long id)
-    {
-        return success(bankCardService.selectBankCardById(id));
+    @Auth(session = true)
+    @PutMapping("/activate/{id}")
+    @Transactional(rollbackFor = Exception.class)
+    public AjaxResult activate(@RequestHeader String token, @PathVariable Long id) {
+        Long ownerId = resolveOwner(token);
+        requireOwnCard(id, ownerId);
+        Date now = new Date();
+        bankCardMapper.update(null, new LambdaUpdateWrapper<InfoBankCard>()
+                .eq(InfoBankCard::getUserId, ownerId)
+                .set(InfoBankCard::getIsActive, 0)
+                .set(InfoBankCard::getUpdateTime, now));
+        bankCardMapper.update(null, new LambdaUpdateWrapper<InfoBankCard>()
+                .eq(InfoBankCard::getId, id)
+                .set(InfoBankCard::getIsActive, 1)
+                .set(InfoBankCard::getUpdateTime, now));
+        return success();
     }
 
-    /**
-     * 新增BankCard
-     */
-    @PreAuthorize("@ss.hasPermi('system:card:add')")
-    @Log(title = "BankCard", businessType = BusinessType.INSERT)
-    @PostMapping
-    public AjaxResult add(@RequestBody BankCard bankCard)
-    {
-        return toAjax(bankCardService.insertBankCard(bankCard));
+    /** 字段校验:必填、长度、银行名必须属于字典 taiwan_bank_list(与商家审核资料同源)。 */
+    private InfoBankCard validated(BankCardSaveDto dto, Long ownerId) {
+        if (dto == null || StringUtils.isBlank(dto.getBankName())
+                || StringUtils.isBlank(dto.getAccountNo()) || StringUtils.isBlank(dto.getAccountName())
+                || dto.getBankName().trim().length() > 64
+                || dto.getAccountNo().trim().length() > 64
+                || dto.getAccountName().trim().length() > 64) {
+            throw new ServiceException(MessageUtils.message("pay.bankcard.invalid"));
+        }
+        boolean bankKnown = false;
+        List<SysDictData> dict = DictUtils.getDictCache("taiwan_bank_list");
+        if (dict != null) {
+            for (SysDictData item : dict) {
+                if (item != null && dto.getBankName().trim().equals(item.getDictLabel())) {
+                    bankKnown = true;
+                    break;
+                }
+            }
+        }
+        if (!bankKnown) {
+            throw new ServiceException(MessageUtils.message("pay.bankcard.name.invalid"));
+        }
+        InfoBankCard card = new InfoBankCard();
+        card.setBankName(dto.getBankName().trim());
+        card.setAccountNo(dto.getAccountNo().trim());
+        card.setAccountName(dto.getAccountName().trim());
+        return card;
     }
 
-    /**
-     * 修改BankCard
-     */
-    @PreAuthorize("@ss.hasPermi('system:card:edit')")
-    @Log(title = "BankCard", businessType = BusinessType.UPDATE)
-    @PutMapping
-    public AjaxResult edit(@RequestBody BankCard bankCard)
-    {
-        return toAjax(bankCardService.updateBankCard(bankCard));
+    /** 只能操作归属自己的卡,统一按无权限处理避免枚举他人卡 ID。 */
+    private InfoBankCard requireOwnCard(Long id, Long ownerId) {
+        InfoBankCard card = id == null ? null : bankCardMapper.selectById(id);
+        if (card == null || !ownerId.equals(card.getUserId())) {
+            throw new ServiceException(MessageUtils.message("no.user.stop"));
+        }
+        return card;
     }
 
-    /**
-     * 删除BankCard
-     */
-    @PreAuthorize("@ss.hasPermi('system:card:remove')")
-    @Log(title = "BankCard", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{ids}")
-    public AjaxResult remove(@PathVariable Long[] ids)
-    {
-        return toAjax(bankCardService.deleteBankCardByIds(ids));
+    /** token → 卡归属:骑手(2)=本人;商家系(1/3/4/5)=主账号;普通用户不可维护收款卡。 */
+    private Long resolveOwner(String token) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        InfoUser user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new ServiceException(MessageUtils.message("no.user.stop"));
+        }
+        if ("2".equals(user.getUserType())) {
+            return userId;
+        }
+        if ("1".equals(user.getUserType()) || "3".equals(user.getUserType())
+                || "4".equals(user.getUserType()) || "5".equals(user.getUserType())) {
+            return merchantStoreAccessService.resolve(userId).ownerUserId();
+        }
+        throw new ServiceException(MessageUtils.message("no.user.stop"));
     }
 }

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/BankCardSaveDto.java

@@ -0,0 +1,16 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.Data;
+
+/** 收款银行卡新增/修改请求(031)。 */
+@Data
+public class BankCardSaveDto {
+    /** 卡记录 ID;新增不传,修改必传且必须属于本人。 */
+    private Long id;
+    /** 银行名称,必须取字典 taiwan_bank_list 的银行中文名。 */
+    private String bankName;
+    /** 银行账号。 */
+    private String accountNo;
+    /** 户名。 */
+    private String accountName;
+}

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

@@ -79,6 +79,9 @@ no.user.not.exist=Người dùng không tồn tại
 no.user.phone.duplicate=该手机号关联多个账号,请联系平台
 pay.method.not.available=Phương thức thanh toán này hiện không khả dụng. Vui lòng chọn phương thức khác.
 pay.method.config.invalid=Phương thức thanh toán không hợp lệ
+pay.bankcard.invalid=Thông tin thẻ không hợp lệ
+pay.bankcard.limit=Chỉ được lưu tối đa 10 thẻ
+pay.bankcard.name.invalid=Tên ngân hàng không hợp lệ
 no.user.login.success=Đăng nhập thành công
 no.user.password.error=Mật khẩu sai
 no.system.error=Lỗi hệ thống
@@ -358,6 +361,7 @@ no.message.push.merchant.accepted.content=Cửa hàng đã nhận đơn và đan
 no.order.not.found=Không tìm thấy đơn hàng
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
+flash.delivery.payment.confirm.not.allowed=Đơn hàng chưa được giao, không thể xác nhận thanh toán
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến

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

@@ -79,6 +79,9 @@ no.user.not.exist=User does not exist
 no.user.phone.duplicate=This phone number is linked to multiple accounts. Please contact the platform.
 pay.method.not.available=This payment method is currently unavailable. Please choose another.
 pay.method.config.invalid=Invalid payment method settings
+pay.bankcard.invalid=Invalid bank card information
+pay.bankcard.limit=You can save up to 10 bank cards
+pay.bankcard.name.invalid=Invalid bank name
 no.user.login.success=Login successful
 no.user.password.error=Password error
 no.system.error=System error
@@ -361,6 +364,7 @@ no.message.push.merchant.accepted.content=The merchant has accepted your order a
 no.order.not.found=Order not found
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
 flash.delivery.edit.not.allowed=Only orders awaiting acceptance with no assigned rider can be edited
+flash.delivery.payment.confirm.not.allowed=The order has not been delivered yet. Payment confirmation is not allowed.
 flash.delivery.order.version.invalid=Please provide a valid order version
 flash.delivery.tip.additional.invalid=Additional tip must be a positive integer within the supported amount range
 flash.delivery.pay.type.invalid=Payment method must be cash or offline transfer

+ 4 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -78,6 +78,9 @@ no.user.not.exist=ไม่มีผู้ใช้นี้
 no.user.phone.duplicate=หมายเลขโทรศัพท์นี้เชื่อมโยงกับบัญชีหลายบัญชี โปรดติดต่อแพลตฟอร์ม
 pay.method.not.available=วิธีการชำระเงินนี้ไม่พร้อมใช้งานในขณะนี้ โปรดเลือกวิธีอื่น
 pay.method.config.invalid=การตั้งค่าวิธีชำระเงินไม่ถูกต้อง
+pay.bankcard.invalid=ข้อมูลบัตรธนาคารไม่ถูกต้อง
+pay.bankcard.limit=บันทึกบัตรได้สูงสุด 10 ใบ
+pay.bankcard.name.invalid=ชื่อธนาคารไม่ถูกต้อง
 no.user.login.success=เข้าสู่ระบบสำเร็จ
 no.user.password.error=รหัสผ่านไม่ถูกต้อง
 no.system.error=เกิดข้อผิดพลาดของระบบ
@@ -341,6 +344,7 @@ address.data.invalid=ข้อมูลผู้ติดต่อ ที่อ
 flash.delivery.rider.type.not.enabled=บัญชีนี้ยังไม่ได้เปิดใช้บริการส่งด่วน
 flash.delivery.rider.exclusive.conflict=ไรเดอร์มีงานจัดส่งแบบเฉพาะที่กำลังดำเนินการ จึงยังไม่สามารถรับคำสั่งซื้อนี้ได้
 flash.delivery.edit.not.allowed=แก้ไขได้เฉพาะคำสั่งซื้อที่รอรับงานและยังไม่มีไรเดอร์
+flash.delivery.payment.confirm.not.allowed=คำสั่งซื้อยังไม่ได้จัดส่ง จึงยืนยันการชำระเงินไม่ได้
 flash.delivery.order.version.invalid=กรุณาระบุเวอร์ชันคำสั่งซื้อที่ถูกต้อง
 flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ
 flash.delivery.pay.type.invalid=รองรับเฉพาะการชำระเงินสดหรือโอนแบบออฟไลน์

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

@@ -79,6 +79,9 @@ no.user.not.exist=Người dùng không tồn tại
 no.user.phone.duplicate=Số điện thoại này được liên kết với nhiều tài khoản. Vui lòng liên hệ nền tảng.
 pay.method.not.available=Phương thức thanh toán này hiện không khả dụng. Vui lòng chọn phương thức khác.
 pay.method.config.invalid=Phương thức thanh toán không hợp lệ
+pay.bankcard.invalid=Thông tin thẻ không hợp lệ
+pay.bankcard.limit=Chỉ được lưu tối đa 10 thẻ
+pay.bankcard.name.invalid=Tên ngân hàng không hợp lệ
 no.user.login.success=Đăng nhập thành công
 no.user.password.error=Mật khẩu sai
 no.system.error=Lỗi hệ thống
@@ -361,6 +364,7 @@ no.message.push.merchant.accepted.content=Cửa hàng đã nhận đơn và đan
 no.order.not.found=Không tìm thấy đơn hàng
 flash.delivery.rider.exclusive.conflict=Tài xế đang có nhiệm vụ giao hàng độc quyền và tạm thời không thể nhận đơn này
 flash.delivery.edit.not.allowed=Chỉ có thể sửa đơn đang chờ nhận và chưa có tài xế
+flash.delivery.payment.confirm.not.allowed=Đơn hàng chưa được giao, không thể xác nhận thanh toán
 flash.delivery.order.version.invalid=Vui lòng cung cấp phiên bản đơn hàng hợp lệ
 flash.delivery.tip.additional.invalid=Tiền boa thêm phải là số nguyên dương trong phạm vi số tiền được hỗ trợ
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến

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

@@ -79,6 +79,9 @@ no.user.not.exist=用户不存在
 no.user.phone.duplicate=该手机号关联多个账号,请联系平台
 pay.method.not.available=该支付方式当前不可用,请更换支付方式
 pay.method.config.invalid=支付方式设置参数不合法
+pay.bankcard.invalid=银行卡信息不合法
+pay.bankcard.limit=最多只能保存10张银行卡
+pay.bankcard.name.invalid=银行名称不合法
 no.user.login.success=登录成功
 no.user.password.error=密码错误
 no.system.error=系统错误
@@ -362,6 +365,7 @@ no.message.push.merchant.accepted.content=商家已接单,正在为您备餐
 no.order.not.found=订单不存在
 flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
+flash.delivery.payment.confirm.not.allowed=订单尚未送达,不能确认收款
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=支付方式仅支持现金或线下转账

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

@@ -79,6 +79,9 @@ no.user.not.exist=用戶不存在
 no.user.phone.duplicate=該手機號關聯多個帳號,請聯繫平台
 pay.method.not.available=該支付方式當前不可用,請更換支付方式
 pay.method.config.invalid=支付方式設定參數不合法
+pay.bankcard.invalid=銀行卡資訊不合法
+pay.bankcard.limit=最多只能保存10張銀行卡
+pay.bankcard.name.invalid=銀行名稱不合法
 no.user.login.success=登入成功
 no.user.password.error=密碼錯誤
 no.system.error=系統錯誤
@@ -362,6 +365,7 @@ no.message.push.merchant.accepted.content=商家已接單,正在為您備餐
 no.order.not.found=訂單不存在
 flash.delivery.rider.exclusive.conflict=騎手存在進行中的獨佔配送任務,暫時不能接此訂單
 flash.delivery.edit.not.allowed=僅待接單且未分配騎手的訂單可以修改
+flash.delivery.payment.confirm.not.allowed=訂單尚未送達,不能確認收款
 flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳

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

@@ -1035,6 +1035,51 @@ class FlashDeliveryApplicationServiceTest {
         for (String status : statuses) assertTrue(parameters.contains(status), tab + ":" + status);
     }
 
+    @Test
+    void confirmPaymentRequiresDeliveredState() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 9L);
+        FlashDeliveryOrder pickedUp = order(10L, "PICKED_UP");
+        pickedUp.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(pickedUp);
+
+        assertThrows(ServiceException.class, () -> fixture.service.confirmPayment(9L, 10L));
+        verify(fixture.orderMapper, never()).update(any(), any());
+    }
+
+    @Test
+    void confirmPaymentMarksPaidAndWritesLog() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 9L);
+        FlashDeliveryOrder delivered = order(10L, "DELIVERED");
+        delivered.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(delivered);
+        when(fixture.orderMapper.update(any(), any())).thenReturn(1);
+        when(fixture.logMapper.insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class))).thenReturn(1);
+
+        fixture.service.confirmPayment(9L, 10L);
+
+        verify(fixture.orderMapper).update(any(), any());
+        ArgumentCaptor<com.ruoyi.system.domain.flash.FlashDeliveryOrderLog> logCaptor = ArgumentCaptor.forClass(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class);
+        verify(fixture.logMapper).insert(logCaptor.capture());
+        assertEquals("PAYMENT_CONFIRMED", logCaptor.getValue().getReason());
+    }
+
+    @Test
+    void confirmPaymentIsIdempotentOnceConfirmed() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 9L);
+        FlashDeliveryOrder confirmed = order(10L, "DELIVERED");
+        confirmed.setRiderId(9L);
+        confirmed.setPaymentStatus(1);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(confirmed);
+
+        fixture.service.confirmPayment(9L, 10L);
+
+        verify(fixture.orderMapper, never()).update(any(), any());
+        verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
+    }
+
     private void rider(Fixture fixture, Long riderId) {
         InfoUser rider = new InfoUser();
         rider.setUserId(riderId);

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -80,6 +80,8 @@ public class FlashDeliveryOrder {
     private String currency;
     /** 支付方式与主订单系统同域:4=现金、6=线下转账;闪送款项直达骑手,仅允许线下方式,下单时锁定。 */
     private String payType;
+    /** 收款状态(031):0=未收款,1=骑手已确认收款;不阻塞订单状态流转。 */
+    private Integer paymentStatus;
     /** 下单时命中的运价配置 ID。 */
     private Long pricingId;
     /** 下单时命中的运价配置版本。 */

+ 1 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -44,6 +44,7 @@
         <result property="amount" column="amount"/>
         <result property="currency" column="currency"/>
         <result property="payType" column="pay_type"/>
+        <result property="paymentStatus" column="payment_status"/>
         <result property="pricingId" column="pricing_id"/>
         <result property="pricingVersion" column="pricing_version"/>
         <result property="pricingStartTime" column="pricing_start_time"/>

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

@@ -103,8 +103,8 @@ description: "Task list for 031-pay-method-config"
 
 **Independent Test**: 迁移后存量商家转账信息照常展示;启用新卡付款方只见新卡
 
-- [ ] T018 [US5] 新建 `BankCardController`(`ruoyi-admin/.../pay/`):CRUD + `PUT /system/bankCard/activate/{id}`(同事务停旧卡);校验 bankName∈字典 `taiwan_bank_list`(读取先例 `InfoUserController.java:815`)、上限 10 张、仅本人卡;i18n key `pay.bankcard.*`;单测(启用停旧、越权、上限、字典校验)
-- [ ] T019 [US5] 027 bankInfo 改造(定位 `ChantingStoreController.bankInfo`,契约 `specs/027-offline-transfer-payment/contracts/api.md`):数据源改 `info_bank_card is_active=1`,返回三字段名结构完全不变;前置套闸门 `assertUsable("6", MERCHANT, 商家userId)` 不满足返回 data=null;单测(迁移卡透出、无启用卡 null、闸门关闭 null、字段名不变)
+- [x] T018 [US5] 新建 `BankCardController`(`ruoyi-admin/.../pay/`):CRUD + `PUT /system/bankCard/activate/{id}`(同事务停旧卡);校验 bankName∈字典 `taiwan_bank_list`(读取先例 `InfoUserController.java:815`)、上限 10 张、仅本人卡;i18n key `pay.bankcard.*`;单测(启用停旧、越权、上限、字典校验)
+- [x] T019 [US5] 027 bankInfo 改造(定位 `ChantingStoreController.bankInfo`,契约 `specs/027-offline-transfer-payment/contracts/api.md`):数据源改 `info_bank_card is_active=1`,返回三字段名结构完全不变;前置套闸门 `assertUsable("6", MERCHANT, 商家userId)` 不满足返回 data=null;单测(迁移卡透出、无启用卡 null、闸门关闭 null、字段名不变)
 - [ ] T020 [P] [US5] foodie-store 支付设置页银行卡管理区(`PayMethodSettings.vue` 第二块:卡列表/新增/编辑/删除/启用,银行名下拉取 taiwan_bank_list 字典接口)+ i18n 补 key
 
 **Checkpoint**: US3 白名单第 4 处(bankInfo);027 平滑迁移
@@ -117,7 +117,7 @@ description: "Task list for 031-pay-method-config"
 
 **Independent Test**: 未确认不影响送达/完成;确认后 0→1 + 日志;重复确认幂等
 
-- [ ] T021 [US6] 闪送确认收款:`FlashDeliveryOrder` 加 `paymentStatus`(注释:0未收1已确认)+ 同步其 mapper XML;`FlashDeliveryRiderController` 增 `POST /orders/{id}/confirmPayment`(本人中单+状态≥已送达;幂等;写 `flash_delivery_order_log` operator_type=RIDER);用户/骑手订单视图返回 `paymentStatus`;单测(条件校验、幂等、日志、不阻塞状态流转)
+- [x] T021 [US6] 闪送确认收款:`FlashDeliveryOrder` 加 `paymentStatus`(注释:0未收1已确认)+ 同步其 mapper XML;`FlashDeliveryRiderController` 增 `POST /orders/{id}/confirmPayment`(本人中单+状态≥已送达;幂等;写 `flash_delivery_order_log` operator_type=RIDER);用户/骑手订单视图返回 `paymentStatus`;单测(条件校验、幂等、日志、不阻塞状态流转)
 
 **Checkpoint**: 全部用户故事完成