Просмотр исходного кода

feat: OMG支付成功补推用户消息;闪送改名快送;单设备登录

- OMG 支付成功回调/补单查询首次确认支付后,按用户语言推"支付成功"(先入消息中心再 iOS 推送,重复回调不重推)
- 推送文案:i18n 中"闪送/閃送"统一改为"快送";商家出餐推送内容去掉多余的订单号尾巴(保留统一 ,NO: 拼接)
- 用户/骑手/商家 App 单设备登录(Auth/AuthAspect 重构 + 会话校验,含文档与测试)
qmj 4 часов с этого момента
Родитель
Сommit
f818d84198
21 измененных файлов с 442 добавлено и 223 удалено
  1. 50 0
      docs/issue-single-device-login.md
  2. 7 7
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java
  3. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFenleiController.java
  4. 5 5
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java
  5. 6 6
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  6. 58 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  7. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  8. 11 11
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  9. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  10. 5 5
      ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountController.java
  11. 3 3
      ruoyi-admin/src/main/resources/i18n/messages.properties
  12. 1 1
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  13. 1 1
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  14. 1 1
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  15. 9 9
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  16. 7 7
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  17. 70 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  18. 143 16
      ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java
  19. 0 7
      ruoyi-system/src/main/java/com/ruoyi/system/utils/Auth.java
  20. 13 29
      ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java
  21. 46 109
      ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java

+ 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 量与登录量变化,确认无异常掉线投诉

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

+ 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,

+ 3 - 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
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改

+ 1 - 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: {};

+ 1 - 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=โปรโมชัน: {};

+ 1 - 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: {};

+ 9 - 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=骑手存在进行中的独占配送任务,暂时不能接此订单
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改

+ 7 - 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=聯絡人、地址或經緯度資訊無效

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

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

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

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