Explorar el Código

Merge branch 'test' into test-202609v1

冲突解决:闪送下单 DataIntegrityViolationException 兜底取并集——
外层保留 test 的 order_no 撞号换号重试,内层幂等复查沿用 v1 的
FOR UPDATE selectOne + lockOrder 锁单;适配对应单测桩。
合并后全量 614 测试通过。
qmj hace 2 días
padre
commit
00dcc4c45c
Se han modificado 35 ficheros con 1363 adiciones y 231 borrados
  1. 54 0
      docs/issue-pay-method-switch.md
  2. 50 0
      docs/issue-single-device-login.md
  3. 34 6
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  4. 7 7
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java
  5. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java
  6. 5 5
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java
  7. 6 6
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  8. 58 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  9. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  10. 11 11
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  11. 51 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchController.java
  12. 166 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java
  13. 30 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/PayMethodSwitchRequest.java
  14. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  15. 5 5
      ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountController.java
  16. 47 0
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/PushMessageController.java
  17. 10 3
      ruoyi-admin/src/main/resources/i18n/messages.properties
  18. 8 1
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  19. 8 1
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  20. 8 1
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  21. 16 9
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  22. 14 7
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  23. 70 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  24. 70 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  25. 235 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java
  26. 143 16
      ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java
  27. 145 0
      ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/PushMessageControllerTest.java
  28. 12 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PushMessage.java
  29. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  30. 0 7
      ruoyi-system/src/main/java/com/ruoyi/system/utils/Auth.java
  31. 13 29
      ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java
  32. 6 1
      ruoyi-system/src/main/resources/mapper/system/PushMessageMapper.xml
  33. 46 109
      ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java
  34. 8 0
      specs/024-flash-delivery/spec.md
  35. 20 0
      updatesql/sql.md

+ 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`
+- [ ] 上线后观察双渠道回调日志,确认无重复落账

+ 50 - 0
docs/issue-single-device-login.md

@@ -0,0 +1,50 @@
+# 用户/骑手/商家 App 单设备登录
+
+> 来源:需求讨论(参照 cte_server 已有实现),2026-09-22
+> 状态:后端已实现
+> 记录人:开发
+
+## 需求
+
+用户、骑手、商家三个 App,一个账号只能在一台设备登录;新设备登录后旧设备被踢下线。
+
+## cte_server 参考实现(两个半环)
+
+1. **登录删旧会话**:三端登录时 `deleteKeys(tokenKey + userId + ":*")`;商家按 User-Agent 分 APP/PC 两个 key,只踢 APP(PC 独立并存,即商家 = 1 App + 1 PC)。
+2. **鉴权强制验会话**(`AuthAspect`):JWT 验签后,jti 含冒号(新格式会话 token)→ Redis 必须存在该键,否则 401;无冒号的历史 token 跳过(兼容)。
+
+## foodie-server 改造前现状
+
+- 登录删旧会话:**已齐全**(与 cte 同构代码):`shanglodeing`(APP 踢 APP/PC 独立)、`syslodeing` 踢 QS、`lodeing` 踢 USER、oauthLogin/LineCallback/注册路径均踢。
+- 鉴权侧:`AuthAspect` 被改造为 `@Auth(session=true)` 才校验,且仅对商家账号(userType 1/3/4/5)拒绝;**用户(0)/骑手(2) 显式豁免**——登录虽删了旧会话,但旧设备 token(15 天内)在所有接口照常放行,单设备实际不生效(半成品)。
+
+## 本次改动
+
+| 文件 | 改动 |
+|------|------|
+| `Auth.java` | 删除 `session()` 属性(全局校验后无存在意义;8 个商家控制器 40 处 `@Auth(session = true)` 同步改为 `@Auth`) |
+| `AuthAspect.java` | 改为 cte 同款全局规则:jti 含冒号 → `hasKey(jti)` 必须为真否则返回 401(与 token 过期同响应,App 走既有登出路径);无冒号 → 放行。删除 `isConsumerUser` 豁免及每次请求的用户表查询 |
+| `MerchantSessionAuthAnnotationTest` | 重写:商家端点 @Auth 声明断言(不含 session)+ 切面行为用例(会话被删 → 401、会话存活 → 放行并写入可信身份、无冒号旧 token → 放行且不查 Redis) |
+
+商家行为对账:改前 session=true 路径就是 hasKey 校验,改后语义一致;变化仅在用户/骑手从"豁免"变"校验",即本次需求本身。
+
+## 风险评估(已逐项核对)
+
+1. **存量 token 兼容**:全部 7 处 token 签发都走写会话的 `JwtUtil.setToken(tokenKey, dto)`,15 天内的 token 会话都在 Redis,开启校验不掉线;无冒号历史 token 走兼容分支放行。
+2. **App 端无需升级**:被踢返回与 token 过期相同的 401 响应,App 走既有处理。
+3. **商家被踢体验变化**:改前商家会话失效抛 ServiceException(500 语义),改后统一 401——更一致,商家 App 按 401 重新登录。
+4. **共享账号互顶**:单设备需求本身预期内。
+5. **推送 cid**:登录覆盖 cid,单设备后与设备一一对应,推送不再串设备(改善)。
+6. **029 设备信任**:DeviceTrustService 信任记录与会话删除独立,无冲突。
+7. **管理后台**:ruoyi-framework 独立 TokenService,不受影响。
+8. **Redis 故障**:会话丢失 → 全端重登(cte 线上同款风险,可接受)。
+9. **骑手在线标识**:被踢旧设备的 offline 标志保持到下次登录,推送 cid 已切到新设备,接单不受影响。
+
+## 验证
+
+- `MerchantSessionAuthAnnotationTest`:注解声明 + 切面行为(401/放行/可信身份写入)全绿
+- 全量回归:除既有已知红测试 `createRejectsUnsupportedPayType` 外全部通过
+
+## 待办
+
+- [ ] 观察上线后 401 量与登录量变化,确认无异常掉线投诉

+ 34 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -34,6 +34,7 @@ import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Function;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
@@ -65,6 +66,8 @@ public class FlashDeliveryApplicationService {
     private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
     private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
     private static final SecureRandom PIN_RANDOM = new SecureRandom();
+    /** 订单号同毫秒递增序列(01-99 循环):同一毫秒内的并发由原子递增区分,避免同号。 */
+    private static final AtomicLong ORDER_NO_SEQ = new AtomicLong();
     private final FlashDeliveryOrderMapper orderMapper;
     private final FlashDeliveryOrderStopMapper stopMapper;
     private final FlashDeliveryTargetAdjustmentMapper adjustmentMapper;
@@ -183,7 +186,7 @@ public class FlashDeliveryApplicationService {
         String payType = normalizePayType(request.getPayType());
         List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
         FlashDeliveryOrder order = new FlashDeliveryOrder();
-        order.setOrderNo("FD" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase(Locale.ROOT));
+        order.setOrderNo(generateOrderNo());
         order.setClientRequestId(requestId);
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
@@ -229,11 +232,18 @@ public class FlashDeliveryApplicationService {
         try {
             orderMapper.insert(order);
         } catch (DataIntegrityViolationException duplicate) {
-            // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
-            FlashDeliveryOrder concurrent = orderMapper.selectOne(new QueryWrapper<FlashDeliveryOrder>()
-                    .eq("user_id", userId).eq("client_request_id", requestId).last("FOR UPDATE"));
-            if (concurrent != null) return participantDetail(lockOrder(concurrent.getId()), true, false);
-            throw duplicate;
+            // order_no 撞号兜底(时钟回拨/多实例等理论情形):唯一索引 uk_flash_order_no 拒绝后换号重插一次。
+            if (isOrderNoConflict(duplicate)) {
+                order.setId(null);
+                order.setOrderNo(generateOrderNo());
+                orderMapper.insert(order);
+            } else {
+                // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
+                FlashDeliveryOrder concurrent = orderMapper.selectOne(new QueryWrapper<FlashDeliveryOrder>()
+                        .eq("user_id", userId).eq("client_request_id", requestId).last("FOR UPDATE"));
+                if (concurrent != null) return participantDetail(lockOrder(concurrent.getId()), true, false);
+                throw duplicate;
+            }
         }
         saveStops(order, quote.pickups(), request.getDelivery(), now);
         saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
@@ -244,6 +254,24 @@ public class FlashDeliveryApplicationService {
         return participantDetail(order, true, false);
     }
 
+    /**
+     * 闪送订单号:98 + 13 位毫秒时间戳 + 2 位同毫秒递增序列,共 17 位纯数字(2026-09-22 起)。
+     * 与外卖订单(99 + 毫秒时间戳)风格同构,便于电话报单与按时间排查;
+     * 同毫秒并发由 JVM 内原子递增序列区分,时钟回拨/多实例等理论撞号由
+     * order_no 唯一索引(uk_flash_order_no)兜底,插入冲突换号重试。
+     * 2026-09-22 之前的存量单为「FD + UUID 前 24 位大写」旧格式,保持不变。
+     */
+    static String generateOrderNo() {
+        long seq = ORDER_NO_SEQ.updateAndGet(current -> (current + 1) % 100);
+        return "98" + System.currentTimeMillis() + String.format(Locale.ROOT, "%02d", seq);
+    }
+
+    /** 判断唯一键冲突是否来自订单号(区别于 userId+clientRequestId 幂等键)。 */
+    private static boolean isOrderNoConflict(DataIntegrityViolationException exception) {
+        String message = exception.getMostSpecificCause().getMessage();
+        return message != null && message.contains("uk_flash_order_no");
+    }
+
     /** 分页查询当前用户参与的订单卡片;role=sender 按寄件人、receiver 按收件人过滤。 */
     public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize, String role) {
         String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender";

+ 7 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java

@@ -45,7 +45,7 @@ public class FoodSpecController extends BaseController
      * 规格组分页列表(每条带 foodSpecsItems)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/foodSpecPageList")
     public AjaxResult foodSpecPageList(@RequestHeader String token,
                                        @RequestParam(defaultValue = "1") Integer pageNum,
@@ -73,7 +73,7 @@ public class FoodSpecController extends BaseController
      * 保存规格组(含级联规格值):id<=0 新增,否则更新
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/saveFoodSpec")
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult saveFoodSpec(@RequestHeader String token, @RequestBody List<FoodSpecs> list) {
@@ -160,7 +160,7 @@ public class FoodSpecController extends BaseController
      * 规格详情(带全部规格值)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getSpecs")
     public AjaxResult getSpecs(@RequestHeader String token, @RequestParam Long id) {
         requireSpecsAccess(AuthContext.requireUserId(), id);
@@ -177,7 +177,7 @@ public class FoodSpecController extends BaseController
      * 软删除规格组(is_delete=1)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/deleteFoodSpec")
     public AjaxResult deleteFoodSpec(@RequestHeader String token, @RequestParam Long id) {
         requireSpecsAccess(AuthContext.requireUserId(), id);
@@ -191,7 +191,7 @@ public class FoodSpecController extends BaseController
      * 商品编辑页:本门店可用规格(is_open=1 & is_delete=0,仅含 is_open=1 的规格值)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getAvailableSpecsList")
     public AjaxResult getAvailableSpecsList(@RequestHeader String token, @RequestParam Long mdId,
                                             @RequestParam(required = false) String language) {
@@ -214,7 +214,7 @@ public class FoodSpecController extends BaseController
      * 启停规格组
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/changeOpen")
     public AjaxResult changeOpen(@RequestHeader String token,
                                  @RequestParam Long id, @RequestParam Boolean isOpen) {
@@ -229,7 +229,7 @@ public class FoodSpecController extends BaseController
      * 启停规格值
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/changeSpecValueOpen")
     public AjaxResult changeSpecValueOpen(@RequestHeader String token,
                                           @RequestParam Long id, @RequestParam Boolean isOpen) {

+ 2 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java

@@ -47,7 +47,7 @@ public class PosFenleiController extends BaseController
      * 删除商品分类
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/delefenlei")
     public AjaxResult delefenlei(@RequestHeader String token, @RequestParam String id){
         PosFenlei existing = posFenleiService.getById(Long.valueOf(id));
@@ -69,7 +69,7 @@ public class PosFenleiController extends BaseController
      * 添加或修改商品分类
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/addfenlei")
     public AjaxResult addfenlei(@RequestHeader String token, @RequestBody PosFenlei posFenlei){
         Long userId = AuthContext.requireUserId();

+ 5 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -79,7 +79,7 @@ public class PosFoodController extends BaseController {
     private MerchantStoreAccessService merchantStoreAccessService;
     //删除商品
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/delefood")
     public AjaxResult delefood(@RequestHeader String token, @RequestParam String id) {
         merchantStoreAccessService.requireFoodAccess(
@@ -89,7 +89,7 @@ public class PosFoodController extends BaseController {
 
     //推荐商品
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PutMapping("/changerecommend")
     public AjaxResult changerecommend(@RequestHeader String token, @RequestBody PosFood posFood) {
         PosFood existing = merchantStoreAccessService.requireFoodAccess(
@@ -102,7 +102,7 @@ public class PosFoodController extends BaseController {
      * 添加或修改商品
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/setposfood")
     public AjaxResult setposfood(@RequestHeader String token, @RequestBody PosFood posFood) {
         Long userId = AuthContext.requireUserId();
@@ -170,7 +170,7 @@ public class PosFoodController extends BaseController {
      * 通过分类查询商品列表
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getidlist")
     public AjaxResult getidlist(@RequestHeader String token, @RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp, @RequestParam(required = false) String name) {
         Long userId = AuthContext.requireUserId();
@@ -443,7 +443,7 @@ public class PosFoodController extends BaseController {
 
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getFoodPageList")
     public AjaxResult getFoodPageList(@RequestHeader String token,
                                       @RequestParam Integer page,

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

@@ -123,7 +123,7 @@ public class PosStoreController extends BaseController {
 
     //删除我的门店
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @Transactional(rollbackFor = Exception.class)
     @GetMapping("/delemendian")
     public AjaxResult delemendian(@RequestHeader String token, @RequestParam String id) {
@@ -148,7 +148,7 @@ public class PosStoreController extends BaseController {
      * H5端门店列表
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/storelistlist")
     public AjaxResult storelistlist(@RequestHeader String token,
                                     @RequestParam Integer page,
@@ -191,7 +191,7 @@ public class PosStoreController extends BaseController {
 
     //查询我的门店列表
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getmystorelist")
     public AjaxResult getmystorelist(@RequestHeader String token) {
         Long id = AuthContext.requireUserId();
@@ -309,7 +309,7 @@ public class PosStoreController extends BaseController {
 
     //添加门店
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/addmendian")
     @Transactional
     public AjaxResult addmendian(@RequestHeader String token, @RequestBody PosStore posStore) {
@@ -403,7 +403,7 @@ public class PosStoreController extends BaseController {
      * @return
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getMdBusinessHours")
     public AjaxResult getMdBusinessHours(@RequestHeader String token, @RequestParam Long mdId){
         Long userId = AuthContext.requireUserId();
@@ -424,7 +424,7 @@ public class PosStoreController extends BaseController {
      * @return
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/saveMdBusinessHours")
     public AjaxResult saveMdBusinessHours(@RequestHeader String token,
                                           @RequestBody List<StoreOperatOutput> businessHours) {

+ 58 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -5,16 +5,24 @@ import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.order.MerchantNotificationRouter;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
@@ -25,6 +33,7 @@ import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.util.Date;
 import java.util.List;
+import java.util.Locale;
 
 /** Verifies and applies one final OMG payment notification transactionally. */
 @Service
@@ -47,6 +56,10 @@ public class OmgPaymentNotifyService {
     private DeliveryOrderNotificationService deliveryOrderNotificationService;
     @Autowired(required = false)
     private MerchantNotificationRouter merchantNotificationRouter;
+    @Autowired(required = false)
+    private IInfoUserService infoUserService;
+    @Autowired(required = false)
+    private PushEventService pushEventService;
 
     public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
         this.attempts = attempts;
@@ -156,6 +169,7 @@ public class OmgPaymentNotifyService {
         if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
             requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
             openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
+            pushPaymentSuccessToUser(order);
         }
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
         if (order.getState() != null && order.getState() == 4L) {
@@ -195,6 +209,50 @@ public class OmgPaymentNotifyService {
         }
     }
 
+    /**
+     * 首次支付成功后给下单用户推"支付成功"(对齐 LINE Pay 链路);重复回调/补单查询
+     * 已支付的订单不会重复推(payStatus 窗口保证)。先入消息中心库再发 iOS 推送,
+     * 推送外呼失败不影响消息中心落库。
+     */
+    private void pushPaymentSuccessToUser(OmgPaymentOrderSnapshot order) {
+        if (infoUserService == null || pushEventService == null
+                || order == null || order.getUserId() == null) {
+            return;
+        }
+        runAfterCommit(() -> {
+            try {
+                InfoUser user = infoUserService.getById(order.getUserId());
+                if (user == null) {
+                    return;
+                }
+                // 回调线程无请求级 locale,必须按收件人用户语言解析(同 LINE Pay 链路)
+                Locale userLocale = LocaleUtils.getUserLocale(user.getUserId());
+                String title = MessageUtils.message("no.message.push.message", userLocale);
+                String content = MessageUtils.message("no.message.push.payment.success", userLocale);
+                String body = OrderPushBodyDto.getJson(order.getDdId(), String.valueOf(order.getState()), 0);
+                pushEventService.PublisherEvent(user.getUserId(), title, content, body);
+                if (user.getCid() != null && !user.getCid().trim().isEmpty()) {
+                    new PayPush().apppush(user.getCid(), title, content, body);
+                }
+            } catch (Exception exception) {
+                log.error("OMG payment success push failed, ddId={}", order.getDdId(), exception);
+            }
+        });
+    }
+
+    private static void runAfterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                action.run();
+            }
+        });
+    }
+
     private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
         if (!request.value("MerchantID").equals(attempt.getMerchantId())) {
             return false;

+ 3 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -228,7 +228,7 @@ public class PosOrderController extends BaseController {
 
     //修改订单
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @RepeatSubmit(interval = 1000, message = "请求过于频繁")
     @PostMapping("/setorderuzt")
     @Transactional(rollbackFor = Exception.class)
@@ -698,7 +698,7 @@ public class PosOrderController extends BaseController {
      */
     //查询订单详情
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getorderxq")
     public AjaxResult getorderxq(@RequestHeader String token, @RequestParam String ddid) {
         QueryWrapper<PosOrder> queryWrapper = new QueryWrapper<>();
@@ -877,7 +877,7 @@ public class PosOrderController extends BaseController {
 
     //查询商家的订单
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/getstoreorderlist")
     public AjaxResult getstoreorderlist(@RequestHeader String token, @RequestParam Integer page, @RequestParam Integer size, @RequestParam String mdId, @RequestParam(defaultValue = "") String state, @RequestParam(defaultValue = "") String diningStatus,@RequestParam(defaultValue = "")String type) {
         Long loginUserId = AuthContext.requireUserId();

+ 11 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -83,7 +83,7 @@ public class PosOrderShOprateController extends BaseController {
      */
 
     @GetMapping("/getInvoice")
-    @Auth(session = true)
+    @Auth
     @Anonymous
     public AjaxResult getInvoice(@RequestHeader String token, @RequestParam Long orderId) {
         MerchantAccessContext context = requireManagedOrder(orderId);
@@ -95,7 +95,7 @@ public class PosOrderShOprateController extends BaseController {
      */
 
     @PutMapping("/invalidInvoice/{orderId}")
-    @Auth(session = true)
+    @Auth
     @Anonymous
     public AjaxResult invalidInvoice(@RequestHeader String token, @PathVariable Long orderId,
                                      @RequestBody(required = false) InvalidInvoiceDto dto) {
@@ -108,7 +108,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家端下单
      */
     @PostMapping("/createOrder")
-    @Auth(session = true)
+    @Auth
     @Anonymous
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult createOrder(@RequestHeader String token, @RequestBody OrderCreateInput input)
@@ -299,7 +299,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家接单:state 从 0 改为 1
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/acceptOrder")
     public AjaxResult acceptOrder(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -324,7 +324,7 @@ public class PosOrderShOprateController extends BaseController {
      * 外送订单保持 deliveryStatus=1(骑手已接单)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/dispatchOrder")
     public AjaxResult dispatchOrder(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -355,7 +355,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家完成(自取/堂食):state 从 2 改为 3,payStatus 从 0 改为 1
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @Transactional(rollbackFor = Exception.class)
     @GetMapping("/completeOrder")
     public AjaxResult completeOrder(@RequestHeader String token, @RequestParam Long id) {
@@ -393,7 +393,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家确认现金收款:payType=4 的订单 payStatus 0→1(现金单创建时未支付,商家收到现金后在App触发)
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/confirmCashPayment")
     public AjaxResult confirmCashPayment(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -426,7 +426,7 @@ public class PosOrderShOprateController extends BaseController {
      * 外送单确认后推送附近骑手开放接单;与现金确认(confirmCashPayment)相互独立
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/confirmTransferPayment")
     public AjaxResult confirmTransferPayment(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -462,7 +462,7 @@ public class PosOrderShOprateController extends BaseController {
      * 商家取消订单:校验 state IN (0,1),设 state=4
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/cancelOrder")
     public AjaxResult cancelOrder(@RequestHeader String token, @RequestParam Long id) {
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
@@ -597,7 +597,7 @@ public class PosOrderShOprateController extends BaseController {
      * ta
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/orderList")
     public AjaxResult orderList(@RequestHeader String token,
                                 @RequestParam(defaultValue = "1") int page,
@@ -677,7 +677,7 @@ public class PosOrderShOprateController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping("/orderDetail")
     public AjaxResult orderDetail(@RequestHeader String token, @RequestParam Long id) {
         requireManagedOrder(id);

+ 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;
+    }
+}

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

@@ -589,7 +589,7 @@ public class InfoUserController extends BaseController {
      * 商家端退出登录,仅撤销当前 JWT 对应的会话。
      */
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping("/merchantLogout")
     public AjaxResult merchantLogout(@RequestHeader String token) {
         merchantTokenSessionService.logoutCurrent(AuthContext.requireJti());

+ 5 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountController.java

@@ -29,14 +29,14 @@ public class MerchantSubaccountController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @GetMapping
     public AjaxResult list(@RequestHeader String token) {
         return success(applicationService.listForOwner(AuthContext.requireUserId()));
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PostMapping
     public AjaxResult create(@RequestHeader String token,
                              @RequestBody MerchantSubaccountCreateRequest request) {
@@ -44,7 +44,7 @@ public class MerchantSubaccountController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PutMapping("/{subaccountUserId}")
     public AjaxResult update(@RequestHeader String token,
                              @PathVariable Long subaccountUserId,
@@ -54,7 +54,7 @@ public class MerchantSubaccountController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PutMapping("/{subaccountUserId}/password")
     public AjaxResult updatePassword(@RequestHeader String token,
                                      @PathVariable Long subaccountUserId,
@@ -65,7 +65,7 @@ public class MerchantSubaccountController extends BaseController {
     }
 
     @Anonymous
-    @Auth(session = true)
+    @Auth
     @PutMapping("/{subaccountUserId}/status")
     public AjaxResult updateStatus(@RequestHeader String token,
                                    @PathVariable Long subaccountUserId,

+ 47 - 0
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 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.extension.plugins.pagination.Page;
 import com.ruoyi.common.annotation.Anonymous;
@@ -50,6 +51,52 @@ public class PushMessageController extends BaseController
         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));
+    }
+
+    /**
+     * 骑手、商家端单条消息置为已读
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/readMessage")
+    public AjaxResult readMessage(@RequestHeader String token, @RequestParam Long id) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String uid = jwtUtil.getusid(token);
+        LambdaUpdateWrapper<PushMessage> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(PushMessage::getIsRead, 1)
+                .eq(PushMessage::getId, id)
+                .eq(PushMessage::getUserId, uid);
+        return toAjax(pushMessageService.update(updateWrapper));
+    }
+
+    /**
+     * 骑手、商家端全部消息置为已读(清角标)
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/readAllMessage")
+    public AjaxResult readAllMessage(@RequestHeader String token) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String uid = jwtUtil.getusid(token);
+        LambdaUpdateWrapper<PushMessage> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.set(PushMessage::getIsRead, 1)
+                .eq(PushMessage::getUserId, uid)
+                .eq(PushMessage::getIsRead, 0);
+        return toAjax(pushMessageService.update(updateWrapper));
+    }
+
     /**
      * 查询推送消息列表
      */

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

@@ -169,7 +169,7 @@ no.points.update.fail=Cập nhật ví điểm thất bại, vui lòng thử l
 no.points.use.fail=Khấu trừ điểm chưa được bật
 no.points.not.exist=Ví điểm của bạn không tồn tại
 no.message.push.merchant.ready.title=Thương gia đã chuẩn bị xong
-no.message.push.merchant.ready.content=Thương gia đã chuẩn bị xong, mã đơn hàng:
+no.message.push.merchant.ready.content=Thương gia đã chuẩn bị xong
 no.posorder.md.yh.mc.messag=优惠券:{};
 no.posorder.md.yh.jiner.messag=优惠抵扣:{};
 no.posorder.md.cx.mc.messag=促销:{};
@@ -293,7 +293,7 @@ merchant.subaccount.access.denied=You cannot manage this subaccount
 merchant.subaccount.create.failed=Failed to create subaccount
 merchant.subaccount.update.failed=Failed to update subaccount
 merchant.subaccount.platform.managed=Subaccounts can only be managed from the dedicated merchant subaccount entry
-no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或闪送
+no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或快送
 no.user.delivery.type.not.enabled=当前账号未开通美食外送配送
 
 # Flash delivery
@@ -340,7 +340,7 @@ flash.delivery.tab.invalid=Invalid flash delivery list tab
 flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
-flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.type.not.enabled=当前账号未开通快送配送
 flash.delivery.rider.vehicle.not.match=The rider vehicle type does not match this order
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
 rider.accept.offline=The rider is offline and cannot accept orders
@@ -366,3 +366,10 @@ flash.delivery.stop.id.required=多取货点订单必须指定取货站点编号
 flash.delivery.intervention.required=请选择问题站点并记录平台处理结果。
 flash.delivery.handoff.required=请记录已取物品去向及交接凭证。
 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

+ 8 - 1
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -173,7 +173,7 @@ no.user.state.no.audit=Account status is not audited
 no.system.busy.try.again=System is busy, please try again later
 no.operation.interrupted.try.again=Operation was interrupted, please try again
 no.message.push.merchant.ready.title=Merchant has prepared the order
-no.message.push.merchant.ready.content=Merchant has prepared the order, Order ID:
+no.message.push.merchant.ready.content=Merchant has prepared the order
 no.posorder.md.yh.mc.messag=Coupon: {};
 no.posorder.md.yh.jiner.messag=Discount: {};
 no.posorder.md.cx.mc.messag=Promo: {};
@@ -369,3 +369,10 @@ flash.delivery.stop.id.required=A pickup station ID is required for this order.
 flash.delivery.intervention.required=Select the problem station and record the intervention result.
 flash.delivery.handoff.required=Record the destination of collected goods and handoff evidence.
 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

+ 8 - 1
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -173,7 +173,7 @@ no.user.state.no.audit=สถานะบัญชียังไม่ได้
 no.system.busy.try.again=ระบบไม่ว่าง โปรดลองอีกครั้งภายหลัง
 no.operation.interrupted.try.again=การดำเนินการถูกขัดจังหวะ โปรดลองอีกครั้ง
 no.message.push.merchant.ready.title=ร้านค้าเตรียมอาหารเสร็จแล้ว
-no.message.push.merchant.ready.content=ร้านค้าเตรียมอาหารเสร็จแล้ว หมายเลขคำสั่งซื้อ:
+no.message.push.merchant.ready.content=ร้านค้าเตรียมอาหารเสร็จแล้ว
 no.posorder.md.yh.mc.messag=คูปอง: {};
 no.posorder.md.yh.jiner.messag=ส่วนลด: {};
 no.posorder.md.cx.mc.messag=โปรโมชัน: {};
@@ -370,3 +370,10 @@ flash.delivery.stop.id.required=คำสั่งซื้อหลายจุ
 flash.delivery.intervention.required=เลือกจุดที่มีปัญหาและบันทึกผลการจัดการของแพลตฟอร์ม
 flash.delivery.handoff.required=บันทึกปลายทางของสินค้าที่รับแล้วและหลักฐานส่งมอบ
 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=เปลี่ยนวิธีชำระเงินไม่สำเร็จ กรุณาลองใหม่ภายหลัง

+ 8 - 1
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -173,7 +173,7 @@ no.user.state.no.audit=Trạng thái tài khoản chưa được xét duyệt
 no.system.busy.try.again=Hệ thống đang bận, vui lòng thử lại sau
 no.operation.interrupted.try.again=Thao tác đã bị gián đoạn, vui lòng thử lại
 no.message.push.merchant.ready.title=Thương gia đã chuẩn bị xong
-no.message.push.merchant.ready.content=Thương gia đã chuẩn bị xong, mã đơn hàng:
+no.message.push.merchant.ready.content=Thương gia đã chuẩn bị xong
 no.posorder.md.yh.mc.messag=Phiếu giảm giá: {};
 no.posorder.md.yh.jiner.messag=Giảm giá: {};
 no.posorder.md.cx.mc.messag=Khuyến mãi: {};
@@ -369,3 +369,10 @@ flash.delivery.stop.id.required=Đơn nhiều điểm lấy hàng phải chỉ 
 flash.delivery.intervention.required=Chọn điểm gặp sự cố và ghi kết quả xử lý của nền tảng.
 flash.delivery.handoff.required=Ghi nơi chuyển hàng đã lấy và bằng chứng bàn giao.
 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

+ 16 - 9
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -111,7 +111,7 @@ no.message.push.order.cancelled=订单已取消
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.message.push.new.order=有新订单
 no.message.push.new.food.order=有新的外卖订单
-no.message.push.new.flash.order=有新的闪送订单
+no.message.push.new.flash.order=有新的快送订单
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失败
@@ -173,7 +173,7 @@ no.user.state.no.audit=账号状态未审核
 no.system.busy.try.again=系统繁忙,请稍后重试
 no.operation.interrupted.try.again=操作被中断,请重试
 no.message.push.merchant.ready.title=商家已经出餐
-no.message.push.merchant.ready.content=商家已经出餐,订单号:
+no.message.push.merchant.ready.content=商家已经出餐
 no.posorder.md.yh.mc.messag=优惠券:{};
 no.posorder.md.yh.jiner.messag=优惠抵扣:{};
 no.posorder.md.cx.mc.messag=促销:{};
@@ -297,19 +297,19 @@ merchant.subaccount.access.denied=无权管理该分管账号
 merchant.subaccount.create.failed=创建分管账号失败
 merchant.subaccount.update.failed=更新分管账号失败
 merchant.subaccount.platform.managed=分管账号只能通过专用入口管理
-no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或闪送
+no.user.delivery.type.invalid=配送类型无效,仅支持美食外送或快送
 no.user.delivery.type.not.enabled=当前账号未开通美食外送配送
 
-# 闪送配送
+# 快送配送
 flash.delivery.request.required=请求数据不能为空
 flash.delivery.auth.required=请先登录
 flash.delivery.client.request.id.invalid=客户端请求号不能为空且不能超过64个字符
 flash.delivery.note.too.long=备注不能超过500个字符
-flash.delivery.service.type.invalid=不支持的闪送服务类型
-flash.delivery.service.unavailable=当前闪送服务暂不可用
+flash.delivery.service.type.invalid=不支持的快送服务类型
+flash.delivery.service.unavailable=当前快送服务暂不可用
 flash.delivery.address.invalid=联系人、地址或经纬度信息无效
 flash.delivery.address.same=取件地址和收件地址不能相同
-flash.delivery.order.not.found=闪送订单不存在或无权访问
+flash.delivery.order.not.found=快送订单不存在或无权访问
 flash.delivery.order.already.accepted=订单已被其他骑手接走
 flash.delivery.rider.required=只有骑手账号可以执行此操作
 flash.delivery.proof.required=请至少上传一张凭证图片
@@ -340,11 +340,11 @@ flash.delivery.vehicle.type.invalid=不支持的配送车型
 flash.delivery.item.invalid=物品数量、重量范围或规格信息不正确
 flash.delivery.tip.invalid=骑手小费必须是非负整数新台币金额
 flash.delivery.quote.changed=报价已变化,请重新确认最新费用
-flash.delivery.tab.invalid=闪送列表页签无效
+flash.delivery.tab.invalid=快送列表页签无效
 flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
-flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.type.not.enabled=当前账号未开通快送配送
 flash.delivery.rider.vehicle.not.match=骑手配送车型与该订单不匹配
 flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单
 rider.accept.offline=骑手当前已离线,不能接单
@@ -370,3 +370,10 @@ flash.delivery.stop.id.required=多取货点订单必须指定取货站点编号
 flash.delivery.intervention.required=请选择问题站点并记录平台处理结果。
 flash.delivery.handoff.required=请记录已取物品去向及交接凭证。
 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=更换支付方式失败,请稍后重试

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

@@ -111,7 +111,7 @@ no.message.push.order.cancelled=訂單已取消
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
 no.message.push.new.food.order=有新的外送訂單
-no.message.push.new.flash.order=有新的閃送訂單
+no.message.push.new.flash.order=有新的快送訂單
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功
 no.message.push.payment.fail=支付失敗
@@ -173,7 +173,7 @@ no.user.state.no.audit=帳號狀態未審核
 no.system.busy.try.again=系統繁忙,請稍後重試
 no.operation.interrupted.try.again=操作被中斷,請重試
 no.message.push.merchant.ready.title=商家已經出餐
-no.message.push.merchant.ready.content=商家已經出餐,訂單號:
+no.message.push.merchant.ready.content=商家已經出餐
 no.posorder.md.yh.mc.messag=優惠券:{};
 no.posorder.md.yh.jiner.messag=優惠抵扣:{};
 no.posorder.md.cx.mc.messag=促銷:{};
@@ -300,16 +300,16 @@ merchant.subaccount.platform.managed=分管帳號只能透過專用入口管理
 no.user.delivery.type.invalid=配送類型無效,僅支持美食外送或闁送
 no.user.delivery.type.not.enabled=當前賬號未開通美食外送配送
 
-# 閃送配送
+# 快送配送
 flash.delivery.request.required=請求資料不能為空
 flash.delivery.auth.required=請先登入
 flash.delivery.client.request.id.invalid=客戶端請求號不能為空且不能超過64個字元
 flash.delivery.note.too.long=備註不能超過500個字元
-flash.delivery.service.type.invalid=不支援的閃送服務類型
-flash.delivery.service.unavailable=目前閃送服務暫不可用
+flash.delivery.service.type.invalid=不支援的快送服務類型
+flash.delivery.service.unavailable=目前快送服務暫不可用
 flash.delivery.address.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.address.same=取件地址和收件地址不能相同
-flash.delivery.order.not.found=閃送訂單不存在或無權存取
+flash.delivery.order.not.found=快送訂單不存在或無權存取
 flash.delivery.order.already.accepted=訂單已被其他騎手接走
 flash.delivery.rider.required=只有騎手帳號可以執行此操作
 flash.delivery.proof.required=請至少上傳一張憑證圖片
@@ -340,7 +340,7 @@ flash.delivery.vehicle.type.invalid=不支援的配送車型
 flash.delivery.item.invalid=物品數量、重量範圍或規格資訊不正確
 flash.delivery.tip.invalid=騎手小費必須是非負整數新台幣金額
 flash.delivery.quote.changed=報價已變更,請重新確認最新費用
-flash.delivery.tab.invalid=閃送列表頁籤無效
+flash.delivery.tab.invalid=快送列表頁籤無效
 flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
@@ -370,3 +370,10 @@ flash.delivery.stop.id.required=多取貨點訂單必須指定取貨站點編號
 flash.delivery.intervention.required=請選擇問題站點並記錄平台處理結果。
 flash.delivery.handoff.required=請記錄已取物品去向及交接憑證。
 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=更換支付方式失敗,請稍後重試

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

@@ -50,6 +50,7 @@ import java.util.Calendar;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -1896,6 +1897,75 @@ class FlashDeliveryApplicationServiceTest {
         return input;
     }
 
+    @Test
+    void generateOrderNoUsesTimestampAndSequenceFormat() {
+        String first = FlashDeliveryApplicationService.generateOrderNo();
+        String second = FlashDeliveryApplicationService.generateOrderNo();
+
+        assertTrue(isFlashOrderNo(first), "应为 98 前缀 17 位纯数字,实际: " + first);
+        assertTrue(isFlashOrderNo(second), "应为 98 前缀 17 位纯数字,实际: " + second);
+        assertNotEquals(first, second, "同毫秒连续生成应由序列位区分,不得同号");
+    }
+
+    @Test
+    void createRegeneratesOrderNoWhenUniqueKeyConflicts() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone(anyString())).thenReturn(List.of());
+        org.springframework.dao.DataIntegrityViolationException orderNoConflict =
+                new org.springframework.dao.DataIntegrityViolationException("insert failed",
+                        new java.sql.SQLException(
+                                "Duplicate entry '98001' for key 'flash_delivery_order.uk_flash_order_no'"));
+        // 捕获器拿到的是同一对象引用,重试改单号后两次取值相同;改为每次调用时记录当时单号
+        java.util.List<String> attempted = new java.util.ArrayList<>();
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            attempted.add(order.getOrderNo());
+            if (attempted.size() == 1) throw orderNoConflict;
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+
+        fixture.service.create(7L, createRequest());
+
+        verify(fixture.orderMapper, times(2)).insert(any(FlashDeliveryOrder.class));
+        assertEquals(2, attempted.size());
+        assertNotEquals(attempted.get(0), attempted.get(1), "撞号重试后应换新单号");
+        assertTrue(isFlashOrderNo(attempted.get(1)), "重试后仍应为新格式单号");
+    }
+
+    @Test
+    void createKeepsIdempotencyFallbackForNonOrderNoConflicts() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone(anyString())).thenReturn(List.of());
+        org.springframework.dao.DataIntegrityViolationException idempotentConflict =
+                new org.springframework.dao.DataIntegrityViolationException("insert failed",
+                        new java.sql.SQLException(
+                                "Duplicate entry '7-req-1' for key 'flash_delivery_order.uk_user_request'"));
+        doThrow(idempotentConflict).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+        // 前置幂等查询放行(null);插入冲突后 catch 内按 v1 实现走 FOR UPDATE 复查 + lockOrder 锁单
+        FlashDeliveryOrder concurrent = new FlashDeliveryOrder();
+        concurrent.setId(55L);
+        concurrent.setOrderNo("981789000000011");
+        when(fixture.orderMapper.selectByUserRequestId(eq(7L), anyString())).thenReturn(null);
+        when(fixture.orderMapper.selectOne(any(Wrapper.class))).thenReturn(concurrent);
+        when(fixture.orderMapper.selectById(55L)).thenReturn(concurrent);
+
+        var detail = fixture.service.create(7L, createRequest());
+
+        assertEquals(55L, detail.getId());
+        verify(fixture.orderMapper, times(1)).insert(any(FlashDeliveryOrder.class));
+    }
+
+    /** 闪送新格式单号:98 开头、17 位纯数字。 */
+    private static boolean isFlashOrderNo(String orderNo) {
+        return orderNo != null && orderNo.startsWith("98") && orderNo.length() == 17
+                && orderNo.chars().allMatch(Character::isDigit);
+    }
+
     private static class Fixture {
         final com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper stopMapper = mock(com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper.class);
         final com.ruoyi.system.mapper.flash.FlashDeliveryTargetAdjustmentMapper adjustmentMapper = mock(com.ruoyi.system.mapper.flash.FlashDeliveryTargetAdjustmentMapper.class);

+ 70 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java

@@ -3,31 +3,64 @@ package com.ruoyi.app.omgpay;
 import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.omgpay.dto.OmgNotifyField;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
+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.ArrayList;
 import java.math.BigDecimal;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 
 class OmgPaymentNotifyServiceTest {
     private static final String HASH_KEY = "5294y06JbISpM5x9";
     private static final String HASH_IV = "v77hoKGq4kWxNNIS";
 
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
     private IOmgPaymentAttemptService attempts;
     private OmgPaymentNotifyService service;
+
+    @BeforeAll
+    static void initializeBeanFactory() {
+        // 支付成功推送按收件人用户语言解析文案(zh_TW 为无上下文时的默认语言),纯单测下需手动挂 messageSource
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        Locale defaultLocale = new Locale("zh", "TW");
+        messageSource.addMessage("no.message.push.message", defaultLocale, "消息");
+        messageSource.addMessage("no.message.push.payment.success", defaultLocale, "支付成功");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
     private OmgPaymentAttempt attempt;
 
     @BeforeEach
@@ -145,6 +178,43 @@ class OmgPaymentNotifyServiceTest {
         verify(notifications).notifyOrderAvailable(paidOrder);
     }
 
+    @Test
+    void firstPaidCallbackPushesPaymentSuccessToOrderUser() {
+        IInfoUserService users = mock(IInfoUserService.class);
+        PushEventService pushEvents = mock(PushEventService.class);
+        ReflectionTestUtils.setField(service, "infoUserService", users);
+        ReflectionTestUtils.setField(service, "pushEventService", pushEvents);
+        OmgPaymentOrderSnapshot order = order(0L, 0L);
+        order.setUserId(9L);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order);
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+        InfoUser user = new InfoUser();
+        user.setUserId(9L); // cid 为空 → 跳过 iOS 推送外呼,仅入消息中心
+        when(users.getById(9L)).thenReturn(user);
+
+        assertTrue(service.process(signedRequest(1, 0, 100)));
+
+        verify(pushEvents).PublisherEvent(eq(9L), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void duplicatePaidCallbackDoesNotPushPaymentSuccessAgain() {
+        IInfoUserService users = mock(IInfoUserService.class);
+        PushEventService pushEvents = mock(PushEventService.class);
+        ReflectionTestUtils.setField(service, "infoUserService", users);
+        ReflectionTestUtils.setField(service, "pushEventService", pushEvents);
+        attempt.setAttemptStatus(1); // 已支付 attempt:重复回调走 duplicate 分支
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(0L, 1L));
+        InfoUser user = new InfoUser();
+        user.setUserId(9L);
+        when(users.getById(9L)).thenReturn(user);
+
+        assertTrue(service.process(signedRequest(1, 0, 100)));
+
+        verify(pushEvents, never()).PublisherEvent(any(), anyString(), anyString(), anyString());
+    }
+
     @Test
     void rejectsSignatureMerchantAndAmountMismatchWithoutMutation() {
         OmgNotifyRequest badSignature = signedRequest(1, 0, 100);

+ 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());
+    }
+}

+ 143 - 16
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java

@@ -6,50 +6,177 @@ import com.ruoyi.app.mendian.PosFoodController;
 import com.ruoyi.app.mendian.PosStoreController;
 import com.ruoyi.app.order.PosOrderController;
 import com.ruoyi.app.order.PosOrderShOprateController;
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.model.LoginUserDto;
+import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.AuthAspect;
+import com.ruoyi.system.utils.AuthContext;
+import com.ruoyi.system.utils.JwtUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.lang.reflect.Method;
 import java.util.Arrays;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
+/**
+ * 会话校验语义测试(单设备登录,2026-09-22):会话校验已全局化,
+ * 本测试守护两件事——商家关键端点仍声明 @Auth;切面对「新格式会话被删(被顶号/登出)」
+ * 的 token 返回 401、对历史无冒号 jti 的旧 token 放行。
+ */
 class MerchantSessionAuthAnnotationTest {
 
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void installRedisBean() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        beanFactory.registerSingleton("redisCache", mock(RedisCache.class));
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
     @Test
-    void merchantEndpointsRequireServerSessionValidation() {
-        assertSessionAuth(MerchantSubaccountController.class,
+    void merchantEndpointsDeclareAuth() {
+        assertAuth(MerchantSubaccountController.class,
                 "list", "create", "update", "updatePassword", "updateStatus");
-        assertSessionAuth(InfoUserController.class, "merchantLogout");
-        assertSessionAuth(FoodSpecController.class,
+        assertAuth(InfoUserController.class, "merchantLogout");
+        assertAuth(FoodSpecController.class,
                 "foodSpecPageList", "saveFoodSpec", "getSpecs", "deleteFoodSpec",
                 "getAvailableSpecsList", "changeOpen", "changeSpecValueOpen");
-        assertSessionAuth(PosFenleiController.class, "delefenlei", "addfenlei");
-        assertSessionAuth(PosFoodController.class,
+        assertAuth(PosFenleiController.class, "delefenlei", "addfenlei");
+        assertAuth(PosFoodController.class,
                 "delefood", "changerecommend", "setposfood", "getidlist", "getFoodPageList");
-        assertSessionAuth(PosStoreController.class,
+        assertAuth(PosStoreController.class,
                 "delemendian", "storelistlist", "getmystorelist", "addmendian",
                 "getMdBusinessHours", "saveMdBusinessHours");
-        assertSessionAuth(PosOrderController.class,
+        assertAuth(PosOrderController.class,
                 "setorderuzt", "getorderxq", "getstoreorderlist");
-        assertSessionAuth(PosOrderShOprateController.class,
+        assertAuth(PosOrderShOprateController.class,
                 "getInvoice", "invalidInvoice", "createOrder", "acceptOrder", "dispatchOrder",
                 "completeOrder", "confirmCashPayment", "cancelOrder", "orderList", "orderDetail");
     }
 
-    private void assertSessionAuth(Class<?> controllerType, String... methodNames) {
+    private void assertAuth(Class<?> controllerType, String... methodNames) {
         for (String methodName : methodNames) {
             Method method = Arrays.stream(controllerType.getDeclaredMethods())
                     .filter(candidate -> candidate.getName().equals(methodName))
                     .findFirst()
                     .orElseThrow(() -> new AssertionError(controllerType.getSimpleName()
                             + "." + methodName + " not found"));
-            Auth auth = method.getAnnotation(Auth.class);
-            assertNotNull(auth, controllerType.getSimpleName() + "." + methodName
-                    + " must declare @Auth");
-            assertTrue(auth.session(), controllerType.getSimpleName() + "." + methodName
-                    + " must validate the Redis session");
+            assertNotNull(method.getAnnotation(Auth.class), controllerType.getSimpleName()
+                    + "." + methodName + " must declare @Auth");
+        }
+    }
+
+    // ==================== AuthAspect 会话校验行为 ====================
+
+    /** 供切面反射获取 @Auth 注解的样例方法 */
+    static class DummyEndpoint {
+        @Auth
+        public AjaxResult endpoint() {
+            return AjaxResult.success("ok");
         }
     }
+
+    /** 签发新格式会话 token(jti 含冒号、已写 Redis) */
+    private String sessionToken() {
+        LoginUserDto dto = new LoginUserDto();
+        dto.setUserId(7L);
+        dto.setUserName("user7");
+        return JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
+    }
+
+    private MockHttpServletRequest requestWithToken(String token) {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader("token", token);
+        return request;
+    }
+
+    private AuthAspect aspectBoundTo(HttpServletRequest request, boolean sessionExists) {
+        RedisCache redisCache = mock(RedisCache.class);
+        when(redisCache.hasKey(anyString())).thenReturn(sessionExists);
+        AuthAspect aspect = new AuthAspect(redisCache);
+        ReflectionTestUtils.setField(aspect, "request", request);
+        return aspect;
+    }
+
+    private ProceedingJoinPoint joinPointOnDummy() throws Throwable {
+        ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
+        MethodSignature signature = mock(MethodSignature.class);
+        Method method = DummyEndpoint.class.getDeclaredMethod("endpoint");
+        Mockito.when(signature.getMethod()).thenReturn(method);
+        Mockito.when(joinPoint.getSignature()).thenReturn(signature);
+        return joinPoint;
+    }
+
+    @Test
+    void deletedSessionIsRejectedWith401() throws Throwable {
+        // hasKey=false:会话已被登录踢键/登出删除
+        MockHttpServletRequest request = requestWithToken(sessionToken());
+        AuthAspect aspect = aspectBoundTo(request, false);
+
+        Object result = aspect.around(joinPointOnDummy());
+
+        assertEquals(401, ((AjaxResult) result).get("code"), "会话被删(被顶号/登出)应返回 401");
+    }
+
+    @Test
+    void liveSessionProceedsAndExposesTrustedIdentity() throws Throwable {
+        MockHttpServletRequest request = requestWithToken(sessionToken());
+        AuthAspect aspect = aspectBoundTo(request, true);
+        ProceedingJoinPoint joinPoint = joinPointOnDummy();
+        Object expected = new Object();
+        Mockito.when(joinPoint.proceed()).thenReturn(expected);
+
+        Object actual = aspect.around(joinPoint);
+
+        assertSame(expected, actual);
+        assertEquals(7L, request.getAttribute(AuthContext.USER_ID_ATTRIBUTE),
+                "切面应把可信 userId 写入请求属性供 AuthContext 读取");
+        assertNotNull(request.getAttribute(AuthContext.JTI_ATTRIBUTE));
+    }
+
+    @Test
+    void legacyTokenWithoutColonJtiStillPasses() throws Throwable {
+        // 历史无会话重载签发的 token:jti 为纯 UUID,不含冒号
+        MockHttpServletRequest request = requestWithToken(JwtUtil.token("7", "user7"));
+        RedisCache redisCache = mock(RedisCache.class);
+        AuthAspect aspect = new AuthAspect(redisCache);
+        ReflectionTestUtils.setField(aspect, "request", request);
+        ProceedingJoinPoint joinPoint = joinPointOnDummy();
+        Object expected = new Object();
+        Mockito.when(joinPoint.proceed()).thenReturn(expected);
+
+        Object actual = aspect.around(joinPoint);
+
+        assertSame(expected, actual, "历史无冒号 jti 的旧 token 应放行至自然过期");
+        verify(redisCache, never()).hasKey(anyString());
+    }
 }

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

@@ -0,0 +1,145 @@
+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.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.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 推送消息已读标识:列表接口只查不改;已读由 readMessage(单条)
+ * 与 readAllMessage(全部)显式变更;未读数接口按当前用户过滤。
+ */
+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 listEndpointReturnsPageQueryWithoutChangingReadState() {
+        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));
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(pushMessageService).page(any(Page.class), captor.capture());
+        LambdaQueryWrapper<PushMessage> queryWrapper = (LambdaQueryWrapper<PushMessage>) captor.getValue();
+        assertTrue(queryWrapper.getSqlSegment().contains("user_id"));
+        // 列表接口只查不改:已读由 readMessage(单条)/ readAllMessage(全部)显式变更
+        verify(pushMessageService, never()).update(any(Wrapper.class));
+    }
+
+    @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));
+    }
+
+    @Test
+    void readMessageEndpointMarksSingleMessageReadForTokenUserOnly() {
+        when(pushMessageService.update(any(Wrapper.class))).thenReturn(true);
+
+        AjaxResult result = controller.readMessage(JwtUtil.token("9", "user"), 5L);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(pushMessageService).update(captor.capture());
+        LambdaUpdateWrapper<PushMessage> updateWrapper = (LambdaUpdateWrapper<PushMessage>) captor.getValue();
+        assertTrue(updateWrapper.getSqlSet().contains("is_read"));
+        // 必须同时按消息 id 与当前用户过滤:既定位单条,也防越权把别人消息置已读
+        assertTrue(updateWrapper.getSqlSegment().contains("user_id"));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue(5L));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue("9"));
+    }
+
+    @Test
+    void readAllMessageEndpointMarksAllUnreadForTokenUser() {
+        when(pushMessageService.update(any(Wrapper.class))).thenReturn(true);
+
+        AjaxResult result = controller.readAllMessage(JwtUtil.token("9", "user"));
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<PushMessage>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(pushMessageService).update(captor.capture());
+        LambdaUpdateWrapper<PushMessage> updateWrapper = (LambdaUpdateWrapper<PushMessage>) captor.getValue();
+        assertTrue(updateWrapper.getSqlSet().contains("is_read"));
+        // 全部已读 = 该用户所有 is_read=0 的记录置 1,不夹带其它条件
+        assertTrue(updateWrapper.getSqlSegment().contains("user_id"));
+        assertTrue(updateWrapper.getSqlSegment().contains("is_read"));
+        assertTrue(updateWrapper.getParamNameValuePairs().containsValue("9"));
+        assertTrue(updateWrapper.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()")
     private Date time;
 
+    /** 是否已读(0未读 1已读) */
+    private Integer isRead;
+
     public void setId(Long id)
     {
         this.id = id;
@@ -107,5 +110,14 @@ public class PushMessage
     {
         return time;
     }
+    public void setIsRead(Integer isRead)
+    {
+        this.isRead = isRead;
+    }
+
+    public Integer getIsRead()
+    {
+        return isRead;
+    }
 
 }

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

@@ -15,7 +15,7 @@ public class FlashDeliveryOrder {
     @TableId(type = IdType.AUTO)
     /** 主键 ID,数据库自增。 */
     private Long id;
-    /** 订单号,FD 前缀加 24 位随机大写串,用于展示与检索。 */
+    /** 订单号,98 前缀加 13 位毫秒时间戳加 2 位递增序列(17 位纯数字),用于展示与检索;2026-09-22 前的存量单为 FD 前缀 24 位随机串。 */
     private String orderNo;
     /** 客户端请求幂等号,与 userId 共同建立唯一约束。 */
     private String clientRequestId;

+ 0 - 7
ruoyi-system/src/main/java/com/ruoyi/system/utils/Auth.java

@@ -14,11 +14,4 @@ import java.lang.annotation.*;
 @Documented
 public @interface Auth {
     boolean token() default true;
-
-    /**
-     * 是否要求 Token 对应的服务端 Redis 会话仍然有效。
-     *
-     * <p>默认关闭以保持普通用户和骑手接口的现有认证行为;商家业务接口按需显式开启。</p>
-     */
-    boolean session() default false;
 }

+ 13 - 29
ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java

@@ -2,10 +2,6 @@ package com.ruoyi.system.utils;
 
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.redis.RedisCache;
-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 org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.annotation.Around;
 import org.aspectj.lang.annotation.Aspect;
@@ -29,13 +25,14 @@ import java.util.Map;
 @Aspect
 public class AuthAspect {
 
+    /** 会话失效(被顶号/登出/撤销)与 token 过期返回同一响应,App 按既有 401 逻辑踢回登录页 */
+    private static final String SESSION_EXPIRED_MESSAGE = "token已过期,请重新登录!";
+
     private HttpServletRequest request;
     private final RedisCache redisCache;
-    private final InfoUserMapper infoUserMapper;
 
-    public AuthAspect(RedisCache redisCache, InfoUserMapper infoUserMapper) {
+    public AuthAspect(RedisCache redisCache) {
         this.redisCache = redisCache;
-        this.infoUserMapper = infoUserMapper;
     }
 
     @Resource
@@ -60,40 +57,27 @@ public class AuthAspect {
         if (auth.token()) {
             Map<String, Object> claims = JwtUtil.verifyToken(request.getHeader("token"));
             if (!Boolean.TRUE.equals(claims.get("valid"))) {
-                return AjaxResult.error(401,"token已过期,请重新登录!");
+                return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
             }
 
-            Long userId = parseUserId(claims.get("id"));
             Object jtiValue = claims.get("jti");
             String jti = jtiValue instanceof String ? (String) jtiValue : null;
-            if (auth.session() && (userId == null || jti == null || jti.isBlank()
-                    || !Boolean.TRUE.equals(redisCache.hasKey(jti)))) {
-                // Redis 会话校验只服务商家侧账号的即时撤销;普通用户和骑手没有会话撤销用途,
-                // 旧 token(jti 从未写入 Redis)放行,凭已验签的 JWT 通过即可。
-                if (!isConsumerUser(userId)) {
-                    throw new ServiceException(MessageUtils.message("merchant.session.invalid"));
-                }
+            // 单设备登录(2026-09-22,与 cte_server 同构):登录时按 tokenKey+userId:* 删除旧会话,
+            // 此处强制新格式会话存在,使被顶号/登出/撤销的设备下一个请求即 401 掉线。
+            // 会话 jti 形如 "qtw_tokens:user:1:uuid"(含冒号);历史无冒号 jti 的旧 token 未写会话,
+            // 放行至 15 天自然过期淘汰,避免升级瞬间误伤存量登录。
+            if (jti != null && !jti.isBlank() && jti.contains(":")
+                    && !Boolean.TRUE.equals(redisCache.hasKey(jti))) {
+                return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
             }
 
+            Long userId = parseUserId(claims.get("id"));
             request.setAttribute(AuthContext.USER_ID_ATTRIBUTE, userId);
             request.setAttribute(AuthContext.JTI_ATTRIBUTE, jti);
         }
         return joinPoint.proceed();
     }
 
-    /**
-     * 会话缺失时判断是否为无需商家会话的账号类型:0=普通用户、2=骑手。
-     * 商家侧类型(1/3/4/5)、用户不存在或类型未知时一律不放行。
-     */
-    private boolean isConsumerUser(Long userId) {
-        if (userId == null) {
-            return false;
-        }
-        InfoUser user = infoUserMapper.selectById(userId);
-        String userType = user == null ? null : user.getUserType();
-        return "0".equals(userType) || "2".equals(userType);
-    }
-
     private Long parseUserId(Object claim) {
         if (!(claim instanceof String value) || value.isBlank() || "null".equals(value)) {
             return null;

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

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

+ 46 - 109
ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java

@@ -2,36 +2,34 @@ package com.ruoyi.system.utils;
 
 import com.auth0.jwt.JWT;
 import com.auth0.jwt.algorithms.Algorithm;
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.redis.RedisCache;
-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 jakarta.servlet.http.HttpServletRequest;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.reflect.MethodSignature;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.mockito.MockedStatic;
 
 import java.lang.reflect.Method;
 import java.util.Date;
-import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertSame;
-import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+/**
+ * 单设备登录(2026-09-22)会话校验切面测试:
+ * 新格式会话 token(jti 含冒号)必须存在 Redis 会话,被顶号/登出删除后即 401;
+ * 历史无冒号 jti(旧无会话重载签发)放行至自然过期;无 jti 的手工 token 同样按历史处理。
+ */
 class AuthAspectTest {
 
     private RedisCache redisCache;
-    private InfoUserMapper infoUserMapper;
     private HttpServletRequest request;
     private ProceedingJoinPoint joinPoint;
     private MethodSignature signature;
@@ -40,36 +38,41 @@ class AuthAspectTest {
     @BeforeEach
     void setUp() {
         redisCache = mock(RedisCache.class);
-        infoUserMapper = mock(InfoUserMapper.class);
         request = mock(HttpServletRequest.class);
         joinPoint = mock(ProceedingJoinPoint.class);
         signature = mock(MethodSignature.class);
-        aspect = new AuthAspect(redisCache, infoUserMapper);
+        aspect = new AuthAspect(redisCache);
         aspect.setHttpServletRequest(request);
         when(joinPoint.getSignature()).thenReturn(signature);
     }
 
+    /** 手工签发新格式会话 token:jti 含冒号但不落 Redis,会话存在性由 redisCache 桩控制 */
+    private String colonJtiToken(String jti) {
+        return JWT.create()
+                .withClaim("id", "936")
+                .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
+                .withJWTId(jti)
+                .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
+    }
+
     @Test
-    void jwtOnlyAuthDoesNotRequireRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "merchant");
-        Object expected = new Object();
-        prepareInvocation("jwtOnly", token);
-        when(joinPoint.proceed()).thenReturn(expected);
+    void deletedSessionIsRejectedBeforeController() throws Throwable {
+        String jti = CacheConstants.USER_TOKEN_KEY + "936:deleted-session";
+        prepareInvocation("endpoint", colonJtiToken(jti));
+        when(redisCache.hasKey(jti)).thenReturn(false);
 
-        Object actual = aspect.around(joinPoint);
+        Object result = aspect.around(joinPoint);
 
-        assertSame(expected, actual);
-        verify(redisCache, never()).hasKey(anyString());
-        verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
+        assertEquals(401, ((AjaxResult) result).get("code"), "会话被删(被顶号/登出)应返回 401");
+        verify(joinPoint, never()).proceed();
     }
 
     @Test
-    void merchantSessionAuthAllowsExistingRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "merchant");
-        String jti = tokenJti(token);
-        Object expected = new Object();
-        prepareInvocation("merchantSession", token);
+    void liveSessionProceedsAndExposesTrustedIdentity() throws Throwable {
+        String jti = CacheConstants.USER_TOKEN_KEY + "936:live-session";
+        prepareInvocation("endpoint", colonJtiToken(jti));
         when(redisCache.hasKey(jti)).thenReturn(true);
+        Object expected = new Object();
         when(joinPoint.proceed()).thenReturn(expected);
 
         Object actual = aspect.around(joinPoint);
@@ -80,95 +83,44 @@ class AuthAspectTest {
     }
 
     @Test
-    void merchantSessionAuthAllowsOrdinaryUserWithoutRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "user");
-        prepareInvocation("merchantSession", token);
-        loginUser("0");
-        when(redisCache.hasKey(anyString())).thenReturn(false);
+    void legacyNoColonJtiPassesWithoutRedisLookup() throws Throwable {
+        // 历史无会话重载 JwtUtil.token(id, userName) 签发:jti 为纯 UUID,不含冒号
+        prepareInvocation("endpoint", JwtUtil.token("936", "user"));
         Object expected = new Object();
         when(joinPoint.proceed()).thenReturn(expected);
 
         Object actual = aspect.around(joinPoint);
 
         assertSame(expected, actual);
+        verify(redisCache, never()).hasKey(anyString());
         verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
     }
 
     @Test
-    void merchantSessionAuthAllowsRiderWithoutRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "rider");
-        prepareInvocation("merchantSession", token);
-        loginUser("2");
-        when(redisCache.hasKey(anyString())).thenReturn(false);
+    void tokenWithoutJtiTreatedAsLegacyAndProceeds() throws Throwable {
+        String token = JWT.create()
+                .withClaim("id", "936")
+                .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
+                .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
+        prepareInvocation("endpoint", token);
         Object expected = new Object();
         when(joinPoint.proceed()).thenReturn(expected);
 
         Object actual = aspect.around(joinPoint);
 
         assertSame(expected, actual);
-        verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
-    }
-
-    @Test
-    void merchantSessionAuthRejectsMissingRedisSessionBeforeController() throws Throwable {
-        String token = JwtUtil.token("936", "merchant");
-        String jti = tokenJti(token);
-        prepareInvocation("merchantSession", token);
-        loginUser("1");
-        when(redisCache.hasKey(jti)).thenReturn(false);
-
-        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
-            messages.when(() -> MessageUtils.message("merchant.session.invalid"))
-                    .thenReturn("session invalid");
-
-            ServiceException exception = assertThrows(ServiceException.class,
-                    () -> aspect.around(joinPoint));
-
-            assertEquals("session invalid", exception.getMessage());
-            verify(joinPoint, never()).proceed();
-        }
+        verify(redisCache, never()).hasKey(anyString());
     }
 
     @Test
-    void merchantSessionAuthRejectsUnknownUserWithoutRedisSession() throws Throwable {
-        String token = JwtUtil.token("936", "stranger");
-        String jti = tokenJti(token);
-        prepareInvocation("merchantSession", token);
-        when(infoUserMapper.selectById(936L)).thenReturn(null);
-        when(redisCache.hasKey(jti)).thenReturn(false);
-
-        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
-            messages.when(() -> MessageUtils.message("merchant.session.invalid"))
-                    .thenReturn("session invalid");
+    void invalidSignatureRejectedBeforeSessionLookup() throws Throwable {
+        prepareInvocation("endpoint", "not-a-jwt");
 
-            ServiceException exception = assertThrows(ServiceException.class,
-                    () -> aspect.around(joinPoint));
+        Object result = aspect.around(joinPoint);
 
-            assertEquals("session invalid", exception.getMessage());
-            verify(joinPoint, never()).proceed();
-        }
-    }
-
-    @Test
-    void merchantSessionAuthRejectsTokenWithoutJti() throws Throwable {
-        String token = JWT.create()
-                .withClaim("id", "936")
-                .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
-                .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
-        prepareInvocation("merchantSession", token);
-        loginUser("1");
-
-        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
-            messages.when(() -> MessageUtils.message("merchant.session.invalid"))
-                    .thenReturn("session invalid");
-
-            ServiceException exception = assertThrows(ServiceException.class,
-                    () -> aspect.around(joinPoint));
-
-            assertEquals("session invalid", exception.getMessage());
-            verify(redisCache, never()).hasKey(anyString());
-            verify(joinPoint, never()).proceed();
-        }
+        assertEquals(401, ((AjaxResult) result).get("code"));
+        verify(redisCache, never()).hasKey(anyString());
+        verify(joinPoint, never()).proceed();
     }
 
     private void prepareInvocation(String methodName, String token) throws Exception {
@@ -177,24 +129,9 @@ class AuthAspectTest {
         when(request.getHeader("token")).thenReturn(token);
     }
 
-    private void loginUser(String userType) {
-        InfoUser user = new InfoUser();
-        user.setUserType(userType);
-        when(infoUserMapper.selectById(936L)).thenReturn(user);
-    }
-
-    private String tokenJti(String token) {
-        Map<String, Object> claims = JwtUtil.verifyToken(token);
-        return (String) claims.get("jti");
-    }
-
     static class SecuredMethods {
         @Auth
-        public void jwtOnly() {
-        }
-
-        @Auth(session = true)
-        public void merchantSession() {
+        public void endpoint() {
         }
     }
 }

+ 8 - 0
specs/024-flash-delivery/spec.md

@@ -377,3 +377,11 @@
 - 骑手沿用既有 `info_user.vehicle_type`(`1`=机车、`2`=轿车);待抢列表、待抢详情和接单均要求与订单车型一致。历史骑手、运价和订单均默认机车,保证迁移后既有行为不变。
 - 重量范围与体积/规格说明改为选填;未传时保存 `NULL`,不参与本期运费计算。物品类别、数量、小费及地址校验保持必填规则。
 - 本期不修改用户 App 界面;后续 App 仅需在报价和创建请求都传相同的 `vehicleType`。
+
+## 2026-09-22:订单编号规则调整(FD+UUID → 98+时间戳+序列)
+
+订单号 orderNo 由「FD + UUID 前 24 位大写」(26 位无语义十六进制串)调整为
+「98 + 13 位毫秒时间戳 + 2 位同毫秒递增序列」(17 位纯数字),与外卖订单(99 + 毫秒时间戳)
+风格同构,便于电话报单与按时间排查。同毫秒并发由 JVM 内原子递增序列区分;
+时钟回拨、多实例等理论撞号由 order_no 唯一索引 uk_flash_order_no 兜底(updatesql/sql.md 2026-09-22 节),
+插入冲突时换号重试一次,userId+clientRequestId 幂等键行为不变。存量订单编号保持不变,不做迁移。

+ 20 - 0
updatesql/sql.md

@@ -1738,3 +1738,23 @@ ALTER TABLE flash_delivery_order ADD INDEX idx_receiver_ctime (receiver_user_id,
 -- 清理参考:DELETE p1 FROM rider_position p1 INNER JOIN rider_position p2 ON p1.rider_id = p2.rider_id AND p1.id < p2.id;
 ALTER TABLE rider_position ADD UNIQUE INDEX uk_rider_id (rider_id);
 ```
+
+## 2026-09-22 闪送订单编号唯一索引
+
+订单号规则调整为「98 + 毫秒时间戳 + 2 位序列」(见 FlashDeliveryApplicationService.generateOrderNo),
+新增唯一索引作为时钟回拨/多实例等理论撞号的兜底。执行前先查重:
+
+```sql
+-- 预检查(应返回 0 行;如有重复需先人工处理再建索引)
+SELECT order_no, COUNT(*) AS cnt FROM flash_delivery_order GROUP BY order_no HAVING cnt > 1;
+
+-- 2026-09-22 闪送订单编号唯一索引(撞号兜底)
+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;
+```