Browse Source

fix: 修复用户发票分页鉴权异常并返回HTTP401

qmj 4 days ago
parent
commit
f9aea65405

+ 86 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserInvoiceAuthResponseTest.java

@@ -0,0 +1,86 @@
+package com.ruoyi.app.order;
+
+import com.github.pagehelper.PageHelper;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
+import com.ruoyi.system.utils.AuthAspect;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** 使用真实Controller代理复现鉴权失败时的checkcast,而不是直接调用切面绕过代理。 */
+class UserInvoiceAuthResponseTest {
+    private MockHttpServletResponse response;
+    @Test
+    void expiredAndRevokedSessionReturn401WithoutCastingPaginationToAjaxResult() {
+        RedisCache redis = mock(RedisCache.class);
+        OrderInvoiceService invoices = mock(OrderInvoiceService.class);
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader("token", "expired-fixture");
+        UserOrderController controller = proxy(request, redis, invoices);
+        try (var jwt = mockStatic(JwtUtil.class)) {
+            jwt.when(() -> JwtUtil.verifyToken("expired-fixture")).thenReturn(Map.of("valid", false));
+            var result = assertDoesNotThrow(() -> controller.myInvoices("expired-fixture", 1, 10));
+            assertEquals(401, result.getCode());
+            assertEquals(401, response.getStatus());
+            assertEquals(0, result.getTotal());
+            assertTrue(result.getRows().isEmpty());
+            assertFalse(result.getMsg().isBlank());
+            assertEquals(401, controller.loveOrgList("expired-fixture", 1, 20, null).getCode());
+            response.setStatus(200);
+            assertEquals(401, controller.getInvoice("expired-fixture", 123L).get("code"));
+            assertEquals(401, response.getStatus()); // 原AjaxResult接口也是真实HTTP401。
+
+            // 签名有效但会话被顶号/登出,也必须返回分页形状的401,不进入查询。
+            jwt.when(() -> JwtUtil.verifyToken("expired-fixture")).thenReturn(
+                    Map.of("valid", true, "id", "7", "jti", "qtw_tokens:user:7:revoked"));
+            when(redis.hasKey("qtw_tokens:user:7:revoked")).thenReturn(false);
+            response.setStatus(200);
+            assertEquals(401, controller.myInvoices("expired-fixture", 1, 10).getCode());
+            assertEquals(401, response.getStatus());
+            verifyNoInteractions(invoices);
+        }
+    }
+
+    @Test
+    void validSessionKeepsExistingUserScopedRowsTotalAndCode() {
+        RedisCache redis = mock(RedisCache.class);
+        OrderInvoiceService invoices = mock(OrderInvoiceService.class);
+        String token = JwtUtil.token("7", "user");
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader("token", token);
+        PosOrderInvoiceVo invoice = new PosOrderInvoiceVo(); invoice.setOrderId(123L);
+        when(invoices.listMyInvoices(7L)).thenReturn(List.of(invoice));
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            var result = proxy(request, redis, invoices).myInvoices(token, 1, 10);
+            assertEquals(200, result.getCode());
+            assertEquals(200, response.getStatus());
+            assertEquals(1, result.getTotal());
+            assertSame(invoice, result.getRows().get(0));
+            verify(invoices).listMyInvoices(7L);
+        } finally { PageHelper.clearPage(); }
+    }
+
+    private UserOrderController proxy(MockHttpServletRequest request, RedisCache redis, OrderInvoiceService invoices) {
+        UserOrderController target = new UserOrderController();
+        ReflectionTestUtils.setField(target, "orderInvoiceService", invoices);
+        AuthAspect aspect = new AuthAspect(redis);
+        aspect.setHttpServletRequest(request);
+        response = new MockHttpServletResponse();
+        aspect.setHttpServletResponse(response);
+        AspectJProxyFactory factory = new AspectJProxyFactory(target);
+        factory.setProxyTargetClass(true);
+        factory.addAspect(aspect);
+        return factory.getProxy();
+    }
+}

+ 30 - 2
ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java

@@ -1,6 +1,7 @@
 package com.ruoyi.system.utils;
 package com.ruoyi.system.utils;
 
 
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.core.redis.RedisCache;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.annotation.Around;
 import org.aspectj.lang.annotation.Around;
@@ -11,6 +12,7 @@ import org.springframework.stereotype.Component;
 
 
 import jakarta.annotation.Resource;
 import jakarta.annotation.Resource;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
 
 
 import java.util.Map;
 import java.util.Map;
 
 
@@ -29,6 +31,7 @@ public class AuthAspect {
     private static final String SESSION_EXPIRED_MESSAGE = "token已过期,请重新登录!";
     private static final String SESSION_EXPIRED_MESSAGE = "token已过期,请重新登录!";
 
 
     private HttpServletRequest request;
     private HttpServletRequest request;
+    private HttpServletResponse response;
     private final RedisCache redisCache;
     private final RedisCache redisCache;
 
 
     public AuthAspect(RedisCache redisCache) {
     public AuthAspect(RedisCache redisCache) {
@@ -40,6 +43,11 @@ public class AuthAspect {
         this.request = request;
         this.request = request;
     }
     }
 
 
+    @Resource
+    public void setHttpServletResponse(HttpServletResponse response) {
+        this.response = response;
+    }
+
 
 
     @Pointcut("@annotation(com.ruoyi.system.utils.Auth)")
     @Pointcut("@annotation(com.ruoyi.system.utils.Auth)")
     private void authPointcut() {
     private void authPointcut() {
@@ -57,7 +65,7 @@ public class AuthAspect {
         if (auth.token()) {
         if (auth.token()) {
             Map<String, Object> claims = JwtUtil.verifyToken(request.getHeader("token"));
             Map<String, Object> claims = JwtUtil.verifyToken(request.getHeader("token"));
             if (!Boolean.TRUE.equals(claims.get("valid"))) {
             if (!Boolean.TRUE.equals(claims.get("valid"))) {
-                return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
+                return sessionExpiredResponse(methodSignature);
             }
             }
 
 
             Object jtiValue = claims.get("jti");
             Object jtiValue = claims.get("jti");
@@ -68,7 +76,7 @@ public class AuthAspect {
             // 放行至 15 天自然过期淘汰,避免升级瞬间误伤存量登录。
             // 放行至 15 天自然过期淘汰,避免升级瞬间误伤存量登录。
             if (jti != null && !jti.isBlank() && jti.contains(":")
             if (jti != null && !jti.isBlank() && jti.contains(":")
                     && !Boolean.TRUE.equals(redisCache.hasKey(jti))) {
                     && !Boolean.TRUE.equals(redisCache.hasKey(jti))) {
-                return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
+                return sessionExpiredResponse(methodSignature);
             }
             }
 
 
             Long userId = parseUserId(claims.get("id"));
             Long userId = parseUserId(claims.get("id"));
@@ -78,6 +86,26 @@ public class AuthAspect {
         return joinPoint.proceed();
         return joinPoint.proceed();
     }
     }
 
 
+    /**
+     * 鉴权发生在Controller之前,短路返回也必须匹配方法的声明类型。
+     * 发票夹等分页接口声明TableDataInfo,直接返回AjaxResult会在CGLIB代理中checkcast失败,
+     * 导致用户看不到原本应返回的401;正常会话与原有AjaxResult接口保持原行为。
+     */
+    private Object sessionExpiredResponse(MethodSignature signature) {
+        // 业务code与HTTP状态同步为401;不能以HTTP200掩盖失效会话。
+        // 非Servlet的切面单元调用无response,真实请求由Spring注入当前响应代理。
+        if (response != null) response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+        if (TableDataInfo.class.equals(signature.getReturnType())) {
+            TableDataInfo result = new TableDataInfo();
+            result.setCode(401);
+            result.setMsg(SESSION_EXPIRED_MESSAGE);
+            result.setRows(java.util.List.of());
+            result.setTotal(0);
+            return result;
+        }
+        return AjaxResult.error(401, SESSION_EXPIRED_MESSAGE);
+    }
+
     private Long parseUserId(Object claim) {
     private Long parseUserId(Object claim) {
         if (!(claim instanceof String value) || value.isBlank() || "null".equals(value)) {
         if (!(claim instanceof String value) || value.isBlank() || "null".equals(value)) {
             return null;
             return null;

+ 3 - 0
specs/010-order-invoice/plan.md

@@ -175,3 +175,6 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 
 
 **范围边界**:交付后端 + 管理端展示;客户端 App 的结账页/发票夹/抬头管理 UI 由客户端团队(沿用 010 分工)。POS 实体打印不做(商家自打证明联)。
 **范围边界**:交付后端 + 管理端展示;客户端 App 的结账页/发票夹/抬头管理 UI 由客户端团队(沿用 010 分工)。POS 实体打印不做(商家自打证明联)。
 
 
+# 2026-10-26 增量修复执行记录
+
+用户发票夹异常定位为AuthAspect鉴权短路返回AjaxResult与Controller声明TableDataInfo不匹配。在切面匹配分页声明返回401分页对象,并通过当前Servlet响应设置HTTP401;其它AjaxResult接口保留原响应体并同步HTTP401。正常请求保持HTTP200和原查询权限。UserInvoiceAuthResponseTest使用真实CGLIB代理复现原异常,覆盖签名失效/会话撤销/正常用户分页、实际HTTP状态及既有鉴权回归;无SQL、无App调整。

+ 6 - 0
specs/010-order-invoice/spec.md

@@ -1,5 +1,11 @@
 # Feature Specification: 订单 ezPay 电子发票开立
 # Feature Specification: 订单 ezPay 电子发票开立
 
 
+## 2026-10-26 用户发票夹鉴权返回修复
+
+用户端`GET /system/userOrder/myInvoices`成功响应继续为HTTP200和`TableDataInfo`分页结构(code/msg/rows/total)。登录token过期、非法或Redis会话撤销时,在查询之前返回HTTP401,并返回同类型的`code=401, rows=[], total=0`及既有会话失效提示,不能由鉴权切面返回不兼容的AjaxResult导致强制转换异常。捐赠机构分页接口同步受保护;其它AjaxResult接口保留原JSON结构,同时明确返回HTTP401。
+
+不修改App、发票查询条件、开票/作废流程或数据库结构。验证须使用真实Controller的Spring AOP代理,覆盖失效会话、撤销会话和正常会话查询。
+
 **Feature Branch**: 不新建分支(在当前 test 分支开发)
 **Feature Branch**: 不新建分支(在当前 test 分支开发)
 
 
 **Created**: 2026-06-16
 **Created**: 2026-06-16

+ 5 - 0
specs/010-order-invoice/tasks.md

@@ -280,3 +280,8 @@ description: "Task list for 订单 ezPay 电子发票开立"
 - [x] T060 [P] i18n:结账页发票选项文案属客户端 UI(客户端团队);**后端无新增面向用户文字**,本期后端 i18n no-op。
 - [x] T060 [P] i18n:结账页发票选项文案属客户端 UI(客户端团队);**后端无新增面向用户文字**,本期后端 i18n no-op。
 
 
 **Checkpoint**: 已完成订单(开发票门店)→ 出餐自动开出个人纸本(零信息)→ 码值落库 → 商家可打印;选手機條碜下单 → checkBarCode 验真 → 出餐自动开载具发票;会员 `/myInvoices` 看到发票。
 **Checkpoint**: 已完成订单(开发票门店)→ 出餐自动开出个人纸本(零信息)→ 码值落库 → 商家可打印;选手機條碜下单 → checkBarCode 验真 → 出餐自动开载具发票;会员 `/myInvoices` 看到发票。
+# 2026-10-26 用户发票夹异常修复
+
+- [x] T061 修复AuthAspect与用户发票分页返回类型不匹配,失效会话HTTP401+code401;真实AOP代理回归覆盖失效、撤销、正常分页和HTTP状态,保留其它AjaxResult响应体。
+
+2026-10-26 本地验证:JDK21,`mvn -q -pl ruoyi-admin -am -Dtest=AuthAspectTest,UserInvoiceAuthResponseTest,MerchantSessionAuthAnnotationTest,*Invoice*Test -Dsurefire.failIfNoSpecifiedTests=false test`,14项通过、失败/错误均0;原ClassCastException已由真实代理复现后修复,`git diff --check`通过。位于test-202609v2,未提交/部署;不修改App或数据库。未执行测试环境真机登录和发票查询联调。