Bladeren bron

feat: 新增待支付订单更换支付方式接口 POST /pay/switch(#661)

- PayMethodSwitchService:校验归属/主订单/可支付状态/方式取值,
  到付现金仅堂食自取;先回写 payType 再路由拉起支付
  (2/5→OMG create、3→LINE Pay create、1/4→线下直接成功)
- 原渠道接口与下单流程零改动;同方式跳过回写
- 旧渠道未完结尝试依赖 payStatus 守卫+补偿任务收口(边界见 docs)
- i18n:no.pay.switch.* 5 key ×6 语言文件
- 测试:PayMethodSwitchServiceTest 8 用例;docs/issue-pay-method-switch.md 记录
qmj 2 uur geleden
bovenliggende
commit
526407c415

+ 54 - 0
docs/issue-pay-method-switch.md

@@ -0,0 +1,54 @@
+# #661 待支付订单无法更换支付方式
+
+> 来源:Trello/Bug 单 #661(台湾外卖 ChaChaEat,代码错误,优先级 3,2026-09-18 创建)
+> 记录时间:2026-09-22
+> 状态:后端已实现(`POST /pay/switch`),待用户端 App 接入
+
+## 需求
+
+用户选了一个支付方式,支付不成功,返回订单是待支付状态,点击付款按钮时无法更换支付方式。期望:用户可更换支付方式。
+
+## 根因
+
+订单 `payType` 在下单时写死(`UserOrderController` 创建时 `setPayType`),之后没有任何接口能修改;而两个支付渠道的创建校验各自按 `payType` 把关:
+
+- OMG(信用卡 2 / Apple Pay 5):`OmgPaymentCreateService` 校验 `OrderLifecycleService.isCardWalletPayType(order.getPayType())`
+- LINE Pay(3):`LinePayOrderGuard` / `LinePayService` 校验 `payType == 3`,且 `/pay/line/create` 只收 `ddId` 无方式参数
+
+因此支付失败后只能原路重试。唯一已支持的切换是卡钱包组内 Credit↔ApplePay(`/pay/omg/retry` 已支持传新方式,但不回写 `payType`)。
+
+## 方案
+
+新增统一入口 `POST /pay/switch`(`PayMethodSwitchController` → `PayMethodSwitchService`),入参 `orderId + paymentMethod`:
+
+1. 校验:订单属于当前用户、主订单(非多门店子单)、`state 0-2`、`payStatus=0`、金额有效
+2. 方式取值限 `1/2/3/4/5`(6=线下转账不开放,商家侧流程);到付(1)/现金(4) 仅自取(type=1)/堂食(type=2)订单可切(与下单现金闸门同规则)
+3. **先回写 `order.payType`**(同方式跳过回写),写订单日志「用户更换支付方式:X → Y」
+4. 路由拉起支付:2/5 → `OmgPaymentCreateService.create`;3 → `LinePayService.create`;1/4 → 无支付环节直接成功
+5. 响应:`data.payParams` 携带渠道支付参数(线下方式为 null,App 刷新订单即可)
+
+**顺序约束**:必须先回写再拉起——两渠道创建校验都以 `order.payType` 为闸门。
+
+**已知边界(有意为之,非遗漏)**:旧渠道未完结的支付尝试不做主动跨渠道关闭,依赖其回调处的 `payStatus` 守卫(双通道回调只有一个能落账)与既有补偿任务(OMG `OmgPaymentAutoCompensationTask`、LINE Pay `LinePayCancellationCompensationService`)收口;极端情形(旧渠道回调晚到且已付款)走对账/退款流程。后续如需主动关闭,可在回写前接入两渠道的取消/作废能力。
+
+## 改动文件
+
+| 文件 | 改动 |
+|------|------|
+| `com.ruoyi.app.pay.PayMethodSwitchController` | 新增 `/pay/switch` 入口 |
+| `com.ruoyi.app.pay.PayMethodSwitchService` | 新增切换/校验/路由逻辑 |
+| `com.ruoyi.app.pay.dto.PayMethodSwitchRequest` | 新增请求 DTO |
+| i18n ×6(messages / vi / en_US / th_TH / zh_CN / zh_TW) | 新增 `no.pay.switch.*` 5 个 key |
+| `PayMethodSwitchServiceTest` | 新增 8 个用例 |
+
+原渠道接口(`/pay/omg/create`、`/pay/omg/retry`、`/pay/line/create`)与下单流程零改动。
+
+## 验证
+
+- `PayMethodSwitchServiceTest`:线下切换回写+日志+跳过支付、外送单拒线下、跨渠道先回写后拉起(InOrder)、同方式不重复回写、已支付/非法方式/非本人单/多门店子单拒绝
+- 全量回归:除既有已知红测试 `createRejectsUnsupportedPayType`(payType 白名单停用基线,另行处理)外全部通过
+
+## 待办
+
+- [ ] 用户端 App:付款按钮增加「更换支付方式」选择,调 `POST /pay/switch`
+- [ ] 上线后观察双渠道回调日志,确认无重复落账

+ 51 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchController.java

@@ -0,0 +1,51 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.PostMapping;
+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;
+
+/**
+ * 待支付订单更换支付方式入口(#661,2026-09-22)。
+ *
+ * <p>只服务「换方式」场景:原方式重试仍走 /pay/omg/retry 与 /pay/line/create,行为不变。
+ * 业务校验在 {@link PayMethodSwitchService},本控制器只做入参兜底与异常翻译。
+ */
+@RestController
+@RequestMapping("/pay")
+public class PayMethodSwitchController {
+
+    private final PayMethodSwitchService switchService;
+
+    public PayMethodSwitchController(PayMethodSwitchService switchService) {
+        this.switchService = switchService;
+    }
+
+    /** 用户对待支付订单更换支付方式:回写 payType 并按新方式拉起支付(到付/现金直接成功)。 */
+    @Anonymous
+    @Auth
+    @PostMapping("/switch")
+    public AjaxResult switchMethod(@RequestHeader String token,
+                                   @RequestBody(required = false) PayMethodSwitchRequest request) {
+        if (request == null || request.getOrderId() == null || request.getOrderId().trim().isEmpty()) {
+            return AjaxResult.error(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        try {
+            Long userId = Long.valueOf(new JwtUtil().getusid(token));
+            return switchService.switchMethod(userId, request);
+        } catch (ServiceException exception) {
+            // 业务拒绝(订单不可支付/方式非法/闸门限制)携带国际化文案原样返回
+            return AjaxResult.error(exception.getMessage());
+        } catch (Exception exception) {
+            return AjaxResult.error(MessageUtils.message("no.pay.switch.failed"));
+        }
+    }
+}

+ 166 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java

@@ -0,0 +1,166 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.omgpay.OmgPaymentCreateService;
+import com.ruoyi.app.omgpay.OmgPaymentMethod;
+import com.ruoyi.app.order.OrderLifecycleService;
+import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
+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.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 待支付订单更换支付方式(#661,2026-09-22)。
+ *
+ * <p><b>背景</b>:订单 payType 在下单时写死,OMG 与 LINE Pay 两侧的创建校验各自按
+ * payType 把关(OMG 仅 2/5、LINE Pay 仅 3),支付失败后用户只能原路重试,无法换方式。
+ *
+ * <p><b>职责</b>:校验订单可切换 → 回写 order.payType → 路由到对应渠道拉起支付;
+ * 换成到付(1)/现金(4) 时无支付环节,回写后直接成功。原渠道的重试(不换方式)
+ * 仍走各自原有接口(/pay/omg/retry、/pay/line/create),行为不变。
+ *
+ * <p><b>顺序约束</b>:必须先回写 payType 再拉起新渠道——两个渠道的创建校验
+ * (OmgPaymentCreateService/LinePayOrderGuard)都以 order.payType 为闸门,顺序颠倒会被拒。
+ * 旧渠道未完结的支付尝试不做主动关闭,依赖其回调处的 payStatus 守卫与既有补偿任务收口;
+ * 回调晚到的极端情形由对账/退款流程兜底。
+ */
+@Service
+public class PayMethodSwitchService {
+
+    /** LINE Pay 的 payType(与 LinePayService.PAY_TYPE_LINE 一致;OrderLifecycleService 未定义该常量) */
+    private static final String PAY_TYPE_LINE_PAY = "3";
+
+    private final IPosOrderService orderService;
+    private final OmgPaymentCreateService omgPaymentCreateService;
+    private final LinePayService linePayService;
+    private final OrderLogHelper orderLogHelper;
+    private final IInfoUserService infoUserService;
+
+    public PayMethodSwitchService(IPosOrderService orderService,
+                                  OmgPaymentCreateService omgPaymentCreateService,
+                                  LinePayService linePayService,
+                                  OrderLogHelper orderLogHelper,
+                                  IInfoUserService infoUserService) {
+        this.orderService = orderService;
+        this.omgPaymentCreateService = omgPaymentCreateService;
+        this.linePayService = linePayService;
+        this.orderLogHelper = orderLogHelper;
+        this.infoUserService = infoUserService;
+    }
+
+    /**
+     * 切换待支付订单的支付方式并拉起新渠道支付。
+     *
+     * @return data.ddId=订单号、data.payType=新方式、data.payParams=渠道支付参数
+     *         (信用卡/Apple Pay 为 OMG 表单参数、LINE Pay 为跳转结果;到付/现金为 null,App 刷新订单即可)
+     */
+    public AjaxResult switchMethod(Long userId, PayMethodSwitchRequest request) {
+        String orderId = normalizeOrderId(request == null ? null : request.getOrderId());
+        String method = normalizeMethod(request == null ? null : request.getPaymentMethod());
+        PosOrder order = requireSwitchableOrder(userId, orderId);
+        // 到付/现金是线下收款,仅自取/堂食可用(与下单现金闸门同规则,外送单不放行)
+        requireOfflineAllowed(method, order);
+
+        String previous = order.getPayType();
+        if (!method.equals(previous)) {
+            // 先回写再拉起:两个渠道的创建校验都按 order.payType 把关,顺序颠倒会被拒
+            order.setPayType(method);
+            if (!orderService.updateById(order)) {
+                throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+            }
+            writeSwitchLog(orderId, userId, previous, method);
+        }
+
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("ddId", orderId);
+        data.put("payType", method);
+        data.put("payParams", createPayment(userId, orderId, method));
+        return AjaxResult.success(data);
+    }
+
+    /** 按新方式路由到对应渠道拉起支付;到付/现金无支付环节返回 null。 */
+    private Object createPayment(Long userId, String orderId, String method) {
+        if (OrderLifecycleService.isCardWalletPayType(method)) {
+            // 信用卡(2)/Apple Pay(5) 同组同商(023),都走 OMG 创建;payType 已回写,组内校验自然通过
+            OmgPaymentMethod omgMethod = OrderLifecycleService.PAY_TYPE_APPLE_PAY.equals(method)
+                    ? OmgPaymentMethod.APPLE_PAY : OmgPaymentMethod.CREDIT;
+            return omgPaymentCreateService.create(userId, orderId, omgMethod).response();
+        }
+        if (PAY_TYPE_LINE_PAY.equals(method)) {
+            return linePayService.create(userId, orderId);
+        }
+        // 到付(1)/现金(4):线下收款,无支付尝试
+        return null;
+    }
+
+    /** 订单存在、属于当前用户、主订单(非多门店子单)且处于可支付状态。 */
+    private PosOrder requireSwitchableOrder(Long userId, String orderId) {
+        PosOrder order = orderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderId));
+        if (order == null || order.getUserId() == null || !order.getUserId().equals(userId)) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        // 多门店子单本身不发起在线支付(与 OMG 创建口径一致),不开放切换
+        if (order.getParentDdId() == null || !orderId.equals(order.getParentDdId())) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        if (order.getState() == null || order.getState() < 0 || order.getState() > 2) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        if (order.getPayStatus() == null || order.getPayStatus() != 0L) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+        }
+        if (order.getAmount() == null || order.getAmount() <= 0) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        return order;
+    }
+
+    /** 到付(1)/现金(4) 仅自取(type=1)/堂食(type=2)订单可切换,外送单保持仅商家创建时可用。 */
+    private void requireOfflineAllowed(String method, PosOrder order) {
+        boolean offline = OrderLifecycleService.PAY_TYPE_OFFLINE.equals(method)
+                || OrderLifecycleService.PAY_TYPE_CASH.equals(method);
+        if (offline && (order.getType() == null || (order.getType() != 1L && order.getType() != 2L))) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.offline.restricted"));
+        }
+    }
+
+    /** 校验目标方式取值:1/2/3/4/5 之外(含 6=线下转账)一律拒绝。 */
+    private String normalizeMethod(String paymentMethod) {
+        String method = paymentMethod == null ? "" : paymentMethod.trim();
+        boolean known = OrderLifecycleService.PAY_TYPE_OFFLINE.equals(method)
+                || OrderLifecycleService.PAY_TYPE_OMG.equals(method)
+                || PAY_TYPE_LINE_PAY.equals(method)
+                || OrderLifecycleService.PAY_TYPE_CASH.equals(method)
+                || OrderLifecycleService.PAY_TYPE_APPLE_PAY.equals(method);
+        if (!known) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.method.invalid"));
+        }
+        return method;
+    }
+
+    private String normalizeOrderId(String orderId) {
+        if (orderId == null || orderId.trim().isEmpty()) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        return orderId.trim();
+    }
+
+    /** 记录切换轨迹(operatorType=4 用户,与创建订单日志一致)。 */
+    private void writeSwitchLog(String orderId, Long userId, String previous, String method) {
+        String name = String.valueOf(userId);
+        InfoUser user = infoUserService.getById(userId);
+        if (user != null && user.getNickName() != null && !user.getNickName().isBlank()) {
+            name = user.getNickName();
+        }
+        orderLogHelper.log(orderId, 4, userId, name, "用户更换支付方式:" + previous + " → " + method);
+    }
+}

+ 30 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/PayMethodSwitchRequest.java

@@ -0,0 +1,30 @@
+package com.ruoyi.app.pay.dto;
+
+/**
+ * 待支付订单更换支付方式请求(#661 待支付订单无法更换支付方式)。
+ *
+ * <p>paymentMethod 取值与下单时的 {@code OrderCreateInput.paymentMethod} 同域:
+ * 1=货到付款、2=信用卡、3=LINE Pay、4=现金、5=Apple Pay;6=线下转账不开放切换(商家侧流程)。
+ */
+public class PayMethodSwitchRequest {
+    /** 订单号(主订单 ddId;多门店子单不支持在线支付,不开放切换) */
+    private String orderId;
+    /** 目标支付方式 */
+    private String paymentMethod;
+
+    public String getOrderId() {
+        return orderId;
+    }
+
+    public void setOrderId(String orderId) {
+        this.orderId = orderId;
+    }
+
+    public String getPaymentMethod() {
+        return paymentMethod;
+    }
+
+    public void setPaymentMethod(String paymentMethod) {
+        this.paymentMethod = paymentMethod;
+    }
+}

+ 22 - 1
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/PushMessageController.java

@@ -3,6 +3,7 @@ package com.ruoyi.web.controller.system;
 import java.util.List;
 import java.util.List;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.annotation.Anonymous;
@@ -35,7 +36,7 @@ public class PushMessageController extends BaseController
     private IPushMessageService pushMessageService;
     private IPushMessageService pushMessageService;
 
 
     /**
     /**
-     * 骑手、商家端获取推送消息分页列表
+     * 骑手、商家端获取推送消息分页列表(进入列表即全部置为已读)
      */
      */
     @Anonymous
     @Anonymous
     @Auth
     @Auth
@@ -47,9 +48,29 @@ public class PushMessageController extends BaseController
         LambdaQueryWrapper<PushMessage> queryWrapper = new LambdaQueryWrapper<>();
         LambdaQueryWrapper<PushMessage> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(PushMessage::getUserId,uid).orderByDesc(PushMessage::getTime);
         queryWrapper.eq(PushMessage::getUserId,uid).orderByDesc(PushMessage::getTime);
         IPage<PushMessage> data = pushMessageService.page(page,queryWrapper);
         IPage<PushMessage> data = pushMessageService.page(page,queryWrapper);
+        // 先查后置:本次返回保留原已读状态供前端画红点,随后将该用户全部未读置为已读(清角标)
+        LambdaUpdateWrapper<PushMessage> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(PushMessage::getIsRead, 1)
+                .eq(PushMessage::getUserId, uid)
+                .eq(PushMessage::getIsRead, 0);
+        pushMessageService.update(updateWrapper);
         return success(data);
         return success(data);
     }
     }
 
 
+    /**
+     * 骑手、商家端获取未读消息数量(用于角标/红点)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getUnreadMessageCount")
+    public AjaxResult getUnreadMessageCount(@RequestHeader String token) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String uid = jwtUtil.getusid(token);
+        LambdaQueryWrapper<PushMessage> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(PushMessage::getUserId, uid).eq(PushMessage::getIsRead, 0);
+        return success(pushMessageService.count(queryWrapper));
+    }
+
     /**
     /**
      * 查询推送消息列表
      * 查询推送消息列表
      */
      */

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

@@ -348,3 +348,10 @@ flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.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
 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
 flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended
 flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=Đơn hàng không tồn tại hoặc không thể thanh toán
+no.pay.switch.already.paid=Đơn hàng đã được thanh toán
+no.pay.switch.method.invalid=Phương thức thanh toán không hợp lệ
+no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng chỉ dành cho đơn tự lấy hoặc dùng tại chỗ
+no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau

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

@@ -351,3 +351,10 @@ 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.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
 flash.delivery.pay.type.invalid=Payment method must be cash or offline transfer
 flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended
 flash.delivery.schedule.not.open=The scheduled pickup window has not started or has already ended
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=Order not found or not payable
+no.pay.switch.already.paid=Order already paid
+no.pay.switch.method.invalid=Invalid payment method
+no.pay.switch.offline.restricted=Cash / cash-on-delivery only for pickup or dine-in orders
+no.pay.switch.failed=Failed to change payment method, please try again later

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

@@ -352,3 +352,10 @@ flash.delivery.order.version.invalid=กรุณาระบุเวอร์
 flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ
 flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ
 flash.delivery.pay.type.invalid=รองรับเฉพาะการชำระเงินสดหรือโอนแบบออฟไลน์
 flash.delivery.pay.type.invalid=รองรับเฉพาะการชำระเงินสดหรือโอนแบบออฟไลน์
 flash.delivery.schedule.not.open=ยังไม่ถึงช่วงเวลานัดรับสินค้า หรือช่วงเวลาดังกล่าวสิ้นสุดแล้ว จึงยังรับออเดอร์นี้ไม่ได้
 flash.delivery.schedule.not.open=ยังไม่ถึงช่วงเวลานัดรับสินค้า หรือช่วงเวลาดังกล่าวสิ้นสุดแล้ว จึงยังรับออเดอร์นี้ไม่ได้
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=ไม่พบคำสั่งซื้อหรือไม่สามารถชำระเงินได้
+no.pay.switch.already.paid=ชำระเงินคำสั่งซื้อแล้ว
+no.pay.switch.method.invalid=วิธีชำระเงินไม่ถูกต้อง
+no.pay.switch.offline.restricted=เงินสด / ชำระเงินปลายทางใช้ได้เฉพาะคำสั่งซื้อแบบรับสินค้าหรือทานที่ร้านเท่านั้น
+no.pay.switch.failed=เปลี่ยนวิธีชำระเงินไม่สำเร็จ กรุณาลองใหม่ภายหลัง

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

@@ -351,3 +351,10 @@ flash.delivery.order.version.invalid=Vui lòng cung cấp phiên bản đơn hà
 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.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
 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
 flash.delivery.schedule.not.open=Chưa đến hoặc đã hết thời gian nhận đơn theo lịch hẹn
 flash.delivery.schedule.not.open=Chưa đến hoặc đã hết thời gian nhận đơn theo lịch hẹn
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=Đơn hàng không tồn tại hoặc không thể thanh toán
+no.pay.switch.already.paid=Đơn hàng đã được thanh toán
+no.pay.switch.method.invalid=Phương thức thanh toán không hợp lệ
+no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng chỉ dành cho đơn tự lấy hoặc dùng tại chỗ
+no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau

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

@@ -352,3 +352,10 @@ flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=支付方式仅支持现金或线下转账
 flash.delivery.pay.type.invalid=支付方式仅支持现金或线下转账
 flash.delivery.schedule.not.open=预约取件时间未开始或已结束,暂不能接单
 flash.delivery.schedule.not.open=预约取件时间未开始或已结束,暂不能接单
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=订单不存在或当前不可支付
+no.pay.switch.already.paid=订单已支付,无法更换支付方式
+no.pay.switch.method.invalid=支付方式无效
+no.pay.switch.offline.restricted=仅自取/堂食订单可更换为现金或货到付款
+no.pay.switch.failed=更换支付方式失败,请稍后重试

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

@@ -352,3 +352,10 @@ flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳
 flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單
 flash.delivery.schedule.not.open=預約取件時間未開始或已結束,暫不能接單
+
+##待支付订单更换支付方式(#661)
+no.pay.switch.order.invalid=訂單不存在或當前不可支付
+no.pay.switch.already.paid=訂單已付款,無法更換支付方式
+no.pay.switch.method.invalid=支付方式無效
+no.pay.switch.offline.restricted=僅自取/堂食訂單可更換為現金或貨到付款
+no.pay.switch.failed=更換支付方式失敗,請稍後重試

+ 235 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java

@@ -0,0 +1,235 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.omgpay.OmgPaymentCreateOutcome;
+import com.ruoyi.app.omgpay.OmgPaymentCreateService;
+import com.ruoyi.app.omgpay.OmgPaymentMethod;
+import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
+import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
+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.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 待支付订单更换支付方式(#661)服务层测试。
+ * 覆盖:线下方式回写与日志、外送单拒线下、跨渠道委派与先回写后拉起的顺序、
+ * 同方式不重复回写、已支付/非法方式/非本人单/多门店子单拒绝。
+ */
+class PayMethodSwitchServiceTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        // MessageUtils 取 key 本身,断言直接比对 i18n key
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        org.springframework.beans.factory.support.DefaultListableBeanFactory beanFactory =
+                new org.springframework.beans.factory.support.DefaultListableBeanFactory();
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    /** 待支付 LINE Pay 自取单:ddId=dd-1、userId=7、type=1、payType=3 */
+    private static PosOrder baseOrder() {
+        PosOrder order = new PosOrder();
+        order.setDdId("dd-1");
+        order.setParentDdId("dd-1");
+        order.setUserId(7L);
+        order.setState(0L);
+        order.setPayStatus(0L);
+        order.setAmount(90);
+        order.setType(1L);
+        order.setPayType("3");
+        return order;
+    }
+
+    private static PayMethodSwitchRequest request(String orderId, String method) {
+        PayMethodSwitchRequest request = new PayMethodSwitchRequest();
+        request.setOrderId(orderId);
+        request.setPaymentMethod(method);
+        return request;
+    }
+
+    @Test
+    void switchLinePayOrderToOfflineUpdatesPayTypeWritesLogAndSkipsPayment() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        OmgPaymentCreateService omgPaymentCreateService = mock(OmgPaymentCreateService.class);
+        LinePayService linePayService = mock(LinePayService.class);
+        OrderLogHelper orderLogHelper = mock(OrderLogHelper.class);
+        IInfoUserService infoUserService = mock(IInfoUserService.class);
+        InfoUser user = new InfoUser();
+        user.setUserId(7L);
+        user.setNickName("小明");
+        when(infoUserService.getById(7L)).thenReturn(user);
+        PosOrder order = baseOrder();
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                omgPaymentCreateService, linePayService, orderLogHelper, infoUserService);
+
+        AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
+
+        ArgumentCaptor<PosOrder> saved = ArgumentCaptor.forClass(PosOrder.class);
+        verify(orderService).updateById(saved.capture());
+        assertEquals("1", saved.getValue().getPayType(), "应回写为目标方式货到付款");
+        verify(orderLogHelper).log(eq("dd-1"), eq(4), eq(7L), eq("小明"), anyString());
+        verify(linePayService, never()).create(any(), anyString());
+        assertEquals("1", ((Map<?, ?>) result.get("data")).get("payType"));
+        assertNull(((Map<?, ?>) result.get("data")).get("payParams"), "到付无支付环节,payParams 应为空");
+    }
+
+    @Test
+    void switchDeliveryOrderToCashIsRejected() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setType(0L);
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "4")));
+        assertEquals("no.pay.switch.offline.restricted", exception.getMessage());
+        verify(orderService, never()).updateById(any(PosOrder.class));
+    }
+
+    @Test
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    void switchToCreditCardRewritesPayTypeBeforeOmgCreate() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        OmgPaymentCreateService omgPaymentCreateService = mock(OmgPaymentCreateService.class);
+        when(omgPaymentCreateService.create(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT)))
+                .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
+        PosOrder order = baseOrder();
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                omgPaymentCreateService, mock(LinePayService.class), mock(OrderLogHelper.class),
+                mock(IInfoUserService.class));
+
+        AjaxResult result = service.switchMethod(7L, request("dd-1", "2"));
+
+        // 顺序约束:必须先回写 payType 再调 OMG 创建,否则创建校验按旧 payType 拒绝
+        InOrder inOrder = inOrder(orderService, omgPaymentCreateService);
+        inOrder.verify(orderService).updateById(any(PosOrder.class));
+        inOrder.verify(omgPaymentCreateService).create(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
+        Map<?, ?> data = (Map<?, ?>) result.get("data");
+        assertEquals("2", data.get("payType"));
+        assertNotNull(data.get("payParams"), "在线方式应带渠道支付参数");
+    }
+
+    @Test
+    void sameMethodSkipsRewriteButStillDelegates() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        OmgPaymentCreateService omgPaymentCreateService = mock(OmgPaymentCreateService.class);
+        when(omgPaymentCreateService.create(any(), anyString(), any(OmgPaymentMethod.class)))
+                .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
+        PosOrder order = baseOrder();
+        order.setPayType("2");
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                omgPaymentCreateService, mock(LinePayService.class), mock(OrderLogHelper.class),
+                mock(IInfoUserService.class));
+
+        service.switchMethod(7L, request("dd-1", "2"));
+
+        verify(orderService, never()).updateById(any(PosOrder.class));
+        verify(omgPaymentCreateService, times(1)).create(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
+    }
+
+    @Test
+    void alreadyPaidOrderIsRejected() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setPayStatus(1L);
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "2")));
+        assertEquals("no.pay.switch.already.paid", exception.getMessage());
+    }
+
+    @Test
+    void unknownMethodIsRejectedBeforeOrderLookup() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "9")));
+        assertEquals("no.pay.switch.method.invalid", exception.getMessage());
+        verify(orderService, never()).getOne(any(Wrapper.class));
+    }
+
+    @Test
+    void orderOfAnotherUserIsRejected() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setUserId(8L);
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "2")));
+        assertEquals("no.pay.switch.order.invalid", exception.getMessage());
+    }
+
+    @Test
+    void multiStoreSubOrderIsRejected() {
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setParentDdId("dd-parent");
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        PayMethodSwitchService service = new PayMethodSwitchService(orderService,
+                mock(OmgPaymentCreateService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> service.switchMethod(7L, request("dd-1", "2")));
+        assertEquals("no.pay.switch.order.invalid", exception.getMessage());
+    }
+}

+ 114 - 0
ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/PushMessageControllerTest.java

@@ -0,0 +1,114 @@
+package com.ruoyi.web.controller.system;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.service.IPushMessageService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.mockito.InOrder;
+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.ArrayList;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 推送消息已读标识:列表接口"先查后置已读"(进入列表即清角标,
+ * 本次返回的数据保留原已读状态供前端画红点),未读数接口按当前用户过滤。
+ */
+class PushMessageControllerTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    private final IPushMessageService pushMessageService = mock(IPushMessageService.class);
+    private final PushMessageController controller = new PushMessageController();
+
+    @BeforeAll
+    static void initializeBeanFactory() {
+        // BaseController.success 依赖 MessageUtils 取"操作成功",纯单测下需手动挂一个 messageSource
+        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);
+        // 纯单测下初始化 MP 实体元数据,否则 LambdaWrapper 解析列名时无缓存可用
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PushMessage.class);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(controller, "pushMessageService", pushMessageService);
+    }
+
+    @Test
+    void listEndpointMarksAllUserMessagesReadAfterPageQuery() {
+        Page<PushMessage> page = new Page<>(1, 10);
+        page.setRecords(new ArrayList<>());
+        when(pushMessageService.page(any(Page.class), any(Wrapper.class))).thenReturn(page);
+
+        AjaxResult result = controller.getPushMessageList(JwtUtil.token("9", "user"), 1, 10);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(page, result.get(AjaxResult.DATA_TAG));
+        // 必须先分页查询后置已读:否则返回页永远全是已读,前端画不出红点
+        InOrder inOrder = inOrder(pushMessageService);
+        inOrder.verify(pushMessageService).page(any(Page.class), any(Wrapper.class));
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        inOrder.verify(pushMessageService).update(captor.capture());
+        LambdaUpdateWrapper<PushMessage> updateWrapper = (LambdaUpdateWrapper<PushMessage>) captor.getValue();
+        assertTrue(updateWrapper.getSqlSet().contains("is_read"));
+        assertTrue(updateWrapper.getSqlSegment().contains("user_id"));
+        assertTrue(updateWrapper.getSqlSegment().contains("is_read"));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue("9"));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue(0));
+    }
+
+    @Test
+    void unreadCountEndpointCountsOnlyUnreadForTokenUser() {
+        when(pushMessageService.count(any(Wrapper.class))).thenReturn(5L);
+
+        AjaxResult result = controller.getUnreadMessageCount(JwtUtil.token("9", "user"));
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertEquals(5L, ((Number) result.get(AjaxResult.DATA_TAG)).longValue());
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(pushMessageService).count(captor.capture());
+        LambdaQueryWrapper<PushMessage> queryWrapper = (LambdaQueryWrapper<PushMessage>) captor.getValue();
+        assertTrue(queryWrapper.getSqlSegment().contains("user_id"));
+        assertTrue(queryWrapper.getSqlSegment().contains("is_read"));
+        assertTrue(queryWrapper.getParamNameValuePairs().containsValue("9"));
+        assertTrue(queryWrapper.getParamNameValuePairs().containsValue(0));
+    }
+}

+ 12 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PushMessage.java

@@ -53,6 +53,9 @@ public class PushMessage
     @Excel(name = "${comment}", readConverterExp = "$column.readConverterExp()")
     @Excel(name = "${comment}", readConverterExp = "$column.readConverterExp()")
     private Date time;
     private Date time;
 
 
+    /** 是否已读(0未读 1已读) */
+    private Integer isRead;
+
     public void setId(Long id)
     public void setId(Long id)
     {
     {
         this.id = id;
         this.id = id;
@@ -107,5 +110,14 @@ public class PushMessage
     {
     {
         return time;
         return time;
     }
     }
+    public void setIsRead(Integer isRead)
+    {
+        this.isRead = isRead;
+    }
+
+    public Integer getIsRead()
+    {
+        return isRead;
+    }
 
 
 }
 }

+ 6 - 1
ruoyi-system/src/main/resources/mapper/system/PushMessageMapper.xml

@@ -11,10 +11,11 @@
         <result property="content"    column="content"    />
         <result property="content"    column="content"    />
         <result property="body"    column="body"    />
         <result property="body"    column="body"    />
         <result property="time"    column="time"    />
         <result property="time"    column="time"    />
+        <result property="isRead"    column="is_read"    />
     </resultMap>
     </resultMap>
 
 
     <sql id="selectPushMessageVo">
     <sql id="selectPushMessageVo">
-        select id, user_id, title, content, body, time from push_message
+        select id, user_id, title, content, body, time, is_read from push_message
     </sql>
     </sql>
 
 
     <select id="selectPushMessageList" parameterType="PushMessage" resultMap="PushMessageResult">
     <select id="selectPushMessageList" parameterType="PushMessage" resultMap="PushMessageResult">
@@ -25,6 +26,7 @@
             <if test="content != null  and content != ''"> and content = #{content}</if>
             <if test="content != null  and content != ''"> and content = #{content}</if>
             <if test="body != null  and body != ''"> and body = #{body}</if>
             <if test="body != null  and body != ''"> and body = #{body}</if>
             <if test="time != null "> and time = #{time}</if>
             <if test="time != null "> and time = #{time}</if>
+            <if test="isRead != null "> and is_read = #{isRead}</if>
         </where>
         </where>
     </select>
     </select>
 
 
@@ -41,6 +43,7 @@
             <if test="content != null">content,</if>
             <if test="content != null">content,</if>
             <if test="body != null">body,</if>
             <if test="body != null">body,</if>
             <if test="time != null">time,</if>
             <if test="time != null">time,</if>
+            <if test="isRead != null">is_read,</if>
         </trim>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="userId != null">#{userId},</if>
             <if test="userId != null">#{userId},</if>
@@ -48,6 +51,7 @@
             <if test="content != null">#{content},</if>
             <if test="content != null">#{content},</if>
             <if test="body != null">#{body},</if>
             <if test="body != null">#{body},</if>
             <if test="time != null">#{time},</if>
             <if test="time != null">#{time},</if>
+            <if test="isRead != null">#{isRead},</if>
         </trim>
         </trim>
     </insert>
     </insert>
 
 
@@ -59,6 +63,7 @@
             <if test="content != null">content = #{content},</if>
             <if test="content != null">content = #{content},</if>
             <if test="body != null">body = #{body},</if>
             <if test="body != null">body = #{body},</if>
             <if test="time != null">time = #{time},</if>
             <if test="time != null">time = #{time},</if>
+            <if test="isRead != null">is_read = #{isRead},</if>
         </trim>
         </trim>
         where id = #{id}
         where id = #{id}
     </update>
     </update>

+ 7 - 0
updatesql/sql.md

@@ -1645,3 +1645,10 @@ SELECT order_no, COUNT(*) AS cnt FROM flash_delivery_order GROUP BY order_no HAV
 -- 2026-09-22 闪送订单编号唯一索引(撞号兜底)
 -- 2026-09-22 闪送订单编号唯一索引(撞号兜底)
 ALTER TABLE flash_delivery_order ADD UNIQUE KEY uk_flash_order_no (order_no);
 ALTER TABLE flash_delivery_order ADD UNIQUE KEY uk_flash_order_no (order_no);
 ```
 ```
+
+```sql
+-- 2026-09-22 推送消息已读标识(消息中心红点/未读数)
+ALTER TABLE push_message ADD COLUMN is_read TINYINT NOT NULL DEFAULT 0 COMMENT '是否已读(0未读 1已读)';
+-- 历史消息统一置为已读,避免上线当天全员角标爆量
+UPDATE push_message SET is_read = 1;
+```