Browse Source

test(admin): S-cfc-202 admin.order.manual 契约级单测——权限流 RED(B-142-1/TR-13)

针对订单人工处理(admin.order.manual)的契约测试 AdminOrderManualContractTest:
- 13 正常流 + 5 边界流绿锚点:端点映射齐全、委托 service、售后状态守卫已实现
- 5 权限流预期红:订单 6 端点 + 售后 4 端点缺 admin 角色守卫(@RequestAttribute("role")),
  非 admin(parent/child/teacher)可越权发货/关单/审批退款/处理售后;list 非 admin 被放行返回 200
- 框架层锚点:JwtInterceptor.LOGIN_REQUIRED_PATHS 已收口 /api/admin/(匿名→401)

RED 验证:24 tests,5 failures(全部为预期权限流缺口),19 green,0 errors。
纯 Mockito+反射契约单测,无 Spring 上下文/DB/外部服务。GREEN 指引见测试类 Javadoc。
Sisyphus Agent 2 hours ago
parent
commit
4a17435d37

+ 548 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminOrderManualContractTest.java

@@ -0,0 +1,548 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.dto.ProductOrderDTO;
+import com.etotem.cfc.entity.AfterSalesRequest;
+import com.etotem.cfc.service.AfterSalesService;
+import com.etotem.cfc.service.ProductOrderService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+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.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+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;
+
+/**
+ * 契约级单测:订单/退款人工处理(能力键 {@code admin.order.manual},SC-A-12)。
+ *
+ * <p><b>溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-A-12(订单/退款人工处理——
+ *       查订单 → 手动退款/补发,边界:重复退款拦截、留审计)</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13(管理员接口需 {@code role=admin})与
+ *       §4.60.12(`AdminProductOrderController` `/api/admin/product/order`:list/detail/ship/
+ *       update-express/close/refund/approve|reject;`AdminAfterSalesController`
+ *       `/api/admin/shop/after-sales`:list/detail/review/complete)</li>
+ *   <li>契约补建:{@code docs/tdd/orchestration/admin-ops-kickoff-20261107.md} TR-13 /
+ *       {@code docs/superpowers/specs/2026-11-07-admin-ops-contract.md} §2 能力键映射
+ *       {@code admin.order.manual}(✅ 端点存在)+ §5:「补 admin 端点 MockMvc 契约 JUnit +
+ *       <b>非 admin token → 403 权限流</b>(重点)」;任务登记 S-cfc-202
+ *       (sc-key-map.md,RED / 未跑通)</li>
+ * </ul>
+ *
+ * <p><b>实现现状(origin/cfclub @ 7558d8cf4 实测)</b></p>
+ * <ul>
+ *   <li>正常流/边界流已实现:两个控制器端点齐全并委托 service;service 层已含
+ *       重复退款拦截、售后状态守卫(仅 pending 可审核 / 仅 approved 可完成)。</li>
+ *   <li><b>权限流未落地(本测试的红)</b>:参照 B-142-1 惯例({@code AdminArticleController#create}
+ *       的「无权限」守卫),管理端端点应声明 {@code @RequestAttribute("role")} 并在入口拒绝非 admin
+ *       ({@code !"admin".equals(role) → Result.error("无权限")});当前
+ *       {@code AdminProductOrderController}(除 list 外 6 个端点)与
+ *       {@code AdminAfterSalesController}(全部 4 个端点)均<em>无角色守卫</em>,登录的
+ *       parent/child/teacher 可越权发货/关单/审批退款/处理售后。框架层(JwtInterceptor
+ *       LOGIN_REQUIRED_PATHS 已收口 {@code /api/admin/})仅保证「匿名→401」,不区分角色。</li>
+ * </ul>
+ *
+ * <p><b>三流划分</b></p>
+ * <ul>
+ *   <li>正常流(已实现 → 绿锁定契约):端点映射齐全 + 委托 service(list/detail/ship/
+ *       update-express/close/refund-approve/refund-reject;售后 list/detail/review/complete)。</li>
+ *   <li>边界流(已实现 → 绿锁定契约):售后 review 状态守卫(非 pending 拒绝、无效 action 拒绝)、
+ *       complete 状态守卫(仅 approved 可完成);订单重复退款拦截既有契约覆盖见
+ *       {@code OrderRefundScP56ScA12ContractTest}。</li>
+ *   <li>权限流(契约缺口 → <b>预期红</b>):订单 6 端点 + 售后 4 端点缺 admin 角色守卫;
+ *       list 非 admin(roles=[parent])应 403 且不委托;refund/approve、售后 review
+ *       非 admin 应「无权限」且不委托。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:权限流 5 例失败(红),断言原文含「RED[admin.order.manual]」,失败原因可贴
+ * (角色守卫参数缺失 / list 被非 admin 放行 / refund/approve 与 review 被非 admin 执行)。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:按 B-142-1 惯例为上述 10 个端点增加
+ * {@code @RequestAttribute("role") String role}(list 保留 {@code @RequestAttribute("roles")}
+ * 并放行平台角色/供应商),入口 {@code !"admin".equals(role) → Result.error("无权限")};
+ * 实现后本测试自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级单测(Mockito + 反射),无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ */
+class AdminOrderManualContractTest {
+
+    /** 订单人工处理全端点(相对 {@code /api/admin/product/order}) */
+    private static final List<String> ORDER_ENDPOINTS = Arrays.asList(
+            "list", "detail", "ship", "update-express", "close", "refund/approve", "refund/reject");
+
+    /** 订单端点上应声明角色守卫参数的端点(list 已有 @RequestAttribute("roles"),属既有载体) */
+    private static final List<String> ORDER_ENDPOINTS_NEED_ROLE_GUARD = Arrays.asList(
+            "detail", "ship", "update-express", "close", "refund/approve", "refund/reject");
+
+    /** 售后人工处理全端点(相对 {@code /api/admin/shop/after-sales}) */
+    private static final List<String> AFTER_SALES_ENDPOINTS = Arrays.asList(
+            "list", "detail", "review", "complete");
+
+    private AdminProductOrderController orderController;
+    private AdminAfterSalesController afterSalesController;
+    private ProductOrderService orderService;
+    private AfterSalesService afterSalesService;
+
+    /** 边界流:真实 AfterSalesService + mock mapper(售后状态守卫绿锚点) */
+    private AfterSalesService realAfterSalesService;
+    private com.etotem.cfc.mapper.AfterSalesRequestMapper afterSalesRequestMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        orderController = new AdminProductOrderController();
+        afterSalesController = new AdminAfterSalesController();
+        orderService = mock(ProductOrderService.class);
+        afterSalesService = mock(AfterSalesService.class);
+        setField(orderController, "orderService", orderService);
+        setField(afterSalesController, "afterSalesService", afterSalesService);
+
+        realAfterSalesService = new AfterSalesService();
+        afterSalesRequestMapper = mock(com.etotem.cfc.mapper.AfterSalesRequestMapper.class);
+        setField(realAfterSalesService, "afterSalesRequestMapper", afterSalesRequestMapper);
+        setField(realAfterSalesService, "productOrderMapper",
+                mock(com.etotem.cfc.mapper.ProductOrderMapper.class));
+
+        // 正常流委托桩:各 service 方法返回成功
+        // 注意:list 的 keyword/buyerKeyword/startDate/endDate 可为 null,
+        // 桩须用 any()(可匹配 null)而非 anyString(),否则返回 null → 断言 NPE
+        when(orderService.adminOrderPage(anyInt(), anyInt(), any(), any(),
+                any(), any(), any(), any(), any()))
+                .thenReturn(Result.success(new HashMap<String, Object>()));
+        when(orderService.adminOrderDetail(anyString()))
+                .thenReturn(Result.success(new ProductOrderDTO()));
+        when(orderService.ship(anyString(), anyString(), anyString(), anyString(), anyInt(), anyString()))
+                .thenReturn(Result.success("已发货"));
+        when(orderService.updateExpress(anyString(), anyString(), anyString(), anyInt()))
+                .thenReturn(Result.success("快递信息已更新"));
+        when(orderService.adminClose(anyString(), anyLong()))
+                .thenReturn(Result.success("订单已关闭"));
+        when(orderService.approveRefund(anyString(), anyLong()))
+                .thenReturn(Result.success("退款已处理"));
+        when(orderService.rejectRefund(anyString(), anyLong(), anyString()))
+                .thenReturn(Result.success("已拒绝退款申请"));
+        when(afterSalesService.adminList(any()))
+                .thenReturn(Result.success(new ArrayList<AfterSalesRequest>()));
+        when(afterSalesService.adminDetail(any()))
+                .thenReturn(Result.success(new AfterSalesRequest()));
+        when(afterSalesService.review(any(), any(), any(), anyLong()))
+                .thenReturn(Result.success("操作成功"));
+        when(afterSalesService.complete(any()))
+                .thenReturn(Result.success("已完成"));
+    }
+
+    // ==================== 正常流:端点映射齐全(绿锚点) ====================
+
+    @Test
+    @DisplayName("正常流-1:订单人工处理 7 端点映射齐全(list/detail/ship/update-express/close/refund/approve|reject)")
+    void 正常流_订单端点映射齐全() {
+        Set<String> paths = endpointPaths(AdminProductOrderController.class);
+        assertTrue(paths.containsAll(ORDER_ENDPOINTS),
+                "[admin.order.manual] AdminProductOrderController 应提供契约端点映射:"
+                        + ORDER_ENDPOINTS + ",实际:" + paths);
+    }
+
+    @Test
+    @DisplayName("正常流-2:售后人工处理 4 端点映射齐全(list/detail/review/complete)")
+    void 正常流_售后端点映射齐全() {
+        Set<String> paths = endpointPaths(AdminAfterSalesController.class);
+        assertTrue(paths.containsAll(AFTER_SALES_ENDPOINTS),
+                "[admin.order.manual] AdminAfterSalesController 应提供契约端点映射:"
+                        + AFTER_SALES_ENDPOINTS + ",实际:" + paths);
+    }
+
+    // ==================== 正常流:订单端点委托 service(绿锚点) ====================
+
+    @Test
+    @DisplayName("正常流-3:order/list 委托 orderService.adminOrderPage(分页/筛选/供应商行级隔离参数)")
+    void 正常流_订单list_委托adminOrderPage() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("status", "paid");
+        body.put("page", 1);
+        body.put("size", 20);
+        Result<Map<String, Object>> r =
+                orderController.list(body, 1L, Collections.singletonList("admin"));
+        assertEquals(200, r.getCode());
+        // 注意:keyword/buyerKeyword/startDate/endDate 为 null,verify 须用 any()(可匹配 null)
+        verify(orderService).adminOrderPage(anyInt(), anyInt(), any(), any(),
+                any(), any(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("正常流-4:order/detail 委托 orderService.adminOrderDetail(orderNo)")
+    void 正常流_订单detail_委托adminOrderDetail() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("orderNo", "PO1");
+        Result<ProductOrderDTO> r = orderController.detail(body);
+        assertEquals(200, r.getCode());
+        verify(orderService).adminOrderDetail("PO1");
+    }
+
+    @Test
+    @DisplayName("正常流-5:order/ship 委托 orderService.ship(发货方式/单号/公司/快递费/联系人六参)")
+    void 正常流_订单ship_委托ship() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("orderNo", "PO1");
+        body.put("shipType", "express");
+        body.put("trackingNumber", "SF123456");
+        body.put("expressCompany", "顺丰速运");
+        body.put("shippingCost", 1000);
+        body.put("receiverContact", "13800000000");
+        Result<String> r = orderController.ship(body, 1L);
+        assertEquals(200, r.getCode());
+        verify(orderService).ship("PO1", "express", "SF123456", "顺丰速运", 1000, "13800000000");
+    }
+
+    @Test
+    @DisplayName("正常流-6:order/update-express 委托 orderService.updateExpress")
+    void 正常流_订单updateExpress_委托() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("orderNo", "PO1");
+        body.put("trackingNumber", "YT111");
+        body.put("expressCompany", "圆通速递");
+        body.put("shippingCost", 800);
+        Result<String> r = orderController.updateExpress(body);
+        assertEquals(200, r.getCode());
+        verify(orderService).updateExpress("PO1", "YT111", "圆通速递", 800);
+    }
+
+    @Test
+    @DisplayName("正常流-7:order/close 委托 orderService.adminClose(orderNo, adminId)")
+    void 正常流_订单close_委托adminClose() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("orderNo", "PO9");
+        Result<String> r = orderController.close(body, 1L);
+        assertEquals(200, r.getCode());
+        verify(orderService).adminClose("PO9", 1L);
+    }
+
+    @Test
+    @DisplayName("正常流-8:order/refund/approve 委托 orderService.approveRefund")
+    void 正常流_退款approve_委托() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("orderNo", "PO7");
+        Result<String> r = orderController.approveRefund(body, 1L);
+        assertEquals(200, r.getCode());
+        verify(orderService).approveRefund("PO7", 1L);
+    }
+
+    @Test
+    @DisplayName("正常流-9:order/refund/reject 委托 orderService.rejectRefund")
+    void 正常流_退款reject_委托() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("orderNo", "PO7");
+        body.put("reason", "证据不足");
+        Result<String> r = orderController.rejectRefund(body, 1L);
+        assertEquals(200, r.getCode());
+        verify(orderService).rejectRefund("PO7", 1L, "证据不足");
+    }
+
+    // ==================== 正常流:售后端点委托 service(绿锚点) ====================
+
+    @Test
+    @DisplayName("正常流-10:after-sales/list 委托 afterSalesService.adminList(status)")
+    void 正常流_售后list_委托adminList() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("status", "pending");
+        Result<List<AfterSalesRequest>> r = afterSalesController.list(body);
+        assertEquals(200, r.getCode());
+        verify(afterSalesService).adminList("pending");
+    }
+
+    @Test
+    @DisplayName("正常流-11:after-sales/detail 委托 afterSalesService.adminDetail(id)")
+    void 正常流_售后detail_委托adminDetail() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1L);
+        Result<AfterSalesRequest> r = afterSalesController.detail(body);
+        assertEquals(200, r.getCode());
+        verify(afterSalesService).adminDetail(1L);
+    }
+
+    @Test
+    @DisplayName("正常流-12:after-sales/review 委托 afterSalesService.review(approve/reject)")
+    void 正常流_售后review_委托() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1L);
+        body.put("action", "approve");
+        Result<String> r = afterSalesController.review(body, 1L);
+        assertEquals(200, r.getCode());
+        verify(afterSalesService).review(1L, "approve", null, 1L);
+    }
+
+    @Test
+    @DisplayName("正常流-13:after-sales/complete 委托 afterSalesService.complete(id)")
+    void 正常流_售后complete_委托() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1L);
+        Result<String> r = afterSalesController.complete(body);
+        assertEquals(200, r.getCode());
+        verify(afterSalesService).complete(1L);
+    }
+
+    // ==================== 边界流:售后状态守卫已实现(绿锚点) ====================
+
+    @Test
+    @DisplayName("边界流-1:售后 review 仅待审核(pending)可处理——已批准申请再次审核被拒且不落库")
+    void 边界流_售后review_非pending拒绝() {
+        when(afterSalesRequestMapper.selectById(1L)).thenReturn(requestWithStatus("approved"));
+        Result<String> r = realAfterSalesService.review(1L, "approve", null, 9L);
+        assertEquals("仅待审核的申请可处理", r.getMessage());
+        verify(afterSalesRequestMapper, never()).updateById(any(AfterSalesRequest.class));
+    }
+
+    @Test
+    @DisplayName("边界流-2:售后 review 无效 action 被拒且不落库")
+    void 边界流_售后review_无效action拒绝() {
+        when(afterSalesRequestMapper.selectById(1L)).thenReturn(requestWithStatus("pending"));
+        Result<String> r = realAfterSalesService.review(1L, "bogus", null, 9L);
+        assertEquals("无效操作", r.getMessage());
+        verify(afterSalesRequestMapper, never()).updateById(any(AfterSalesRequest.class));
+    }
+
+    @Test
+    @DisplayName("边界流-3:售后 review 申请不存在 → 拒绝")
+    void 边界流_售后review_申请不存在() {
+        when(afterSalesRequestMapper.selectById(1L)).thenReturn(null);
+        Result<String> r = realAfterSalesService.review(1L, "approve", null, 9L);
+        assertEquals("售后申请不存在", r.getMessage());
+    }
+
+    @Test
+    @DisplayName("边界流-4:售后 complete 仅已批准可完成——pending 拒绝且不落库")
+    void 边界流_售后complete_非approved拒绝() {
+        when(afterSalesRequestMapper.selectById(1L)).thenReturn(requestWithStatus("pending"));
+        Result<String> r = realAfterSalesService.complete(1L);
+        assertEquals("仅已批准的申请可完成", r.getMessage());
+        verify(afterSalesRequestMapper, never()).updateById(any(AfterSalesRequest.class));
+    }
+
+    @Test
+    @DisplayName("边界流-5:售后 complete 对 approved 申请成功闭环(approved→completed)")
+    void 边界流_售后complete_approved闭环成功() {
+        AfterSalesRequest request = requestWithStatus("approved");
+        when(afterSalesRequestMapper.selectById(1L)).thenReturn(request);
+        Result<String> r = realAfterSalesService.complete(1L);
+        assertEquals(200, r.getCode());
+        assertEquals("已完成", r.getData());
+        assertEquals("completed", request.getStatus());
+        verify(afterSalesRequestMapper).updateById(request);
+    }
+
+    // ==================== 权限流(契约缺口,预期红 ×5,B-142-1 / TR-13) ====================
+
+    @Test
+    @DisplayName("权限流-框架层:JwtInterceptor.LOGIN_REQUIRED_PATHS 已收口 /api/admin/(匿名→401,框架层已落地)")
+    void 权限流_框架层_登录门已收口apiAdmin() throws Exception {
+        Field f = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
+        f.setAccessible(true);
+        String[] paths = (String[]) f.get(null);
+        boolean covered = false;
+        for (String p : paths) {
+            if ("/api/admin/".equals(p)) {
+                covered = true;
+                break;
+            }
+        }
+        assertTrue(covered,
+                "[admin.order.manual] JwtInterceptor.LOGIN_REQUIRED_PATHS 应含 \"/api/admin/\","
+                        + "使匿名请求 admin 订单/售后端点 → 401(B-142-1 框架层;本契约关注的是框架层之下"
+                        + " controller 角色的 403 守卫,见下方 RED 用例)。");
+    }
+
+    @Test
+    @DisplayName("权限流-R1:订单人工处理 6 端点应声明 admin 角色守卫参数(缺守卫 → 红)")
+    void 权限流_订单端点_缺角色守卫() {
+        List<String> missing = new ArrayList<>();
+        for (String ep : ORDER_ENDPOINTS_NEED_ROLE_GUARD) {
+            if (!hasRoleGuardParam(AdminProductOrderController.class, methodOf(ep))) {
+                missing.add(ep);
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "RED[admin.order.manual] 订单人工处理端点应声明角色守卫参数"
+                        + "(@RequestAttribute(\"role\")/@RequestAttribute(\"roles\"),B-142-1 惯例,"
+                        + "参照 AdminArticleController#create 的「无权限」守卫),非 admin(parent/child/teacher)"
+                        + "应被拒绝且不触达 service;当前缺守卫的端点:" + missing
+                        + "。GREEN:增加 @RequestAttribute(\"role\") String role 并在入口"
+                        + " !\"admin\".equals(role) → Result.error(\"无权限\")。");
+    }
+
+    @Test
+    @DisplayName("权限流-R2:售后人工处理 4 端点应声明 admin 角色守卫参数(缺守卫 → 红)")
+    void 权限流_售后端点_缺角色守卫() {
+        List<String> missing = new ArrayList<>();
+        for (String ep : AFTER_SALES_ENDPOINTS) {
+            if (!hasRoleGuardParam(AdminAfterSalesController.class, ep)) {
+                missing.add(ep);
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "RED[admin.order.manual] 售后人工处理端点应声明角色守卫参数"
+                        + "(@RequestAttribute(\"role\"),B-142-1 惯例),非 admin(parent/child/teacher)"
+                        + "应被拒绝且不触达 service;当前缺守卫的端点:" + missing
+                        + "。GREEN:增加 @RequestAttribute(\"role\") String role 并在入口"
+                        + " !\"admin\".equals(role) → Result.error(\"无权限\")。");
+    }
+
+    @Test
+    @DisplayName("权限流-R3:order/list 非 admin 角色(roles=[parent])应 403 拒绝且不委托(→ 红)")
+    void 权限流_订单list_非admin应403拒绝() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("status", "paid");
+        Result<Map<String, Object>> r =
+                orderController.list(body, 1L, Collections.singletonList("parent"));
+        assertEquals(403, r.getCode(),
+                "RED[admin.order.manual] order/list 由非 admin 角色(roles=[parent],无供应商/平台角色)"
+                        + "调用时应返回 403 无权访问,不得委托 orderService.adminOrderPage 泄漏全量订单;"
+                        + "当前被放行(B-142-1 角色守卫缺失,TR-13 权限流)。");
+        verify(orderService, never()).adminOrderPage(anyInt(), anyInt(), any(), any(),
+                any(), any(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("权限流-R4:order/refund/approve 非 admin 角色应「无权限」拒绝且不委托(→ 红)")
+    void 权限流_退款approve_非admin拒绝() throws Exception {
+        Map<String, Object> body = new HashMap<>();
+        body.put("orderNo", "PO7");
+        Result<?> r = invokeWithRole(orderController, "approveRefund", body, 1L, "parent");
+        assertNotNull(r, "RED[admin.order.manual] refund/approve 应返回无权结果而非 null。");
+        assertEquals("无权限", r.getMessage(),
+                "RED[admin.order.manual] refund/approve 由非 admin 角色调用时应按 B-142-1 惯例返回"
+                        + "「无权限」;当前方法无角色守卫参数(@RequestAttribute(\"role\")),parent/child"
+                        + "可直接审批退款(TR-13 权限流)。GREEN:增加 role 参数并在入口守卫。");
+        verify(orderService, never()).approveRefund(anyString(), anyLong());
+    }
+
+    @Test
+    @DisplayName("权限流-R5:after-sales/review 非 admin 角色应「无权限」拒绝且不委托(→ 红)")
+    void 权限流_售后review_非admin拒绝() throws Exception {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1L);
+        body.put("action", "approve");
+        Result<?> r = invokeWithRole(afterSalesController, "review", body, 1L, "parent");
+        assertNotNull(r, "RED[admin.order.manual] 售后 review 应返回无权结果而非 null。");
+        assertEquals("无权限", r.getMessage(),
+                "RED[admin.order.manual] 售后 review 由非 admin 角色调用时应按 B-142-1 惯例返回"
+                        + "「无权限」;当前方法无角色守卫参数(@RequestAttribute(\"role\")),parent/child"
+                        + "可直接处理售后单(TR-13 权限流)。GREEN:增加 role 参数并在入口守卫。");
+        verify(afterSalesService, never()).review(anyLong(), any(), any(), anyLong());
+    }
+
+    // ==================== 辅助 ====================
+
+    private static void setField(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    /** 采集类上所有 @PostMapping 方法映射的相对路径(归一化:去掉前导斜杠) */
+    private static Set<String> endpointPaths(Class<?> clazz) {
+        Set<String> paths = new LinkedHashSet<>();
+        for (Method m : clazz.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                for (String p : pm.value()) {
+                    paths.add(p.replaceFirst("^/+", ""));
+                }
+            }
+        }
+        return paths;
+    }
+
+    /** 该方法是否声明了 admin 角色承载参数(@RequestAttribute value = "role" 或 "roles") */
+    private static boolean hasRoleGuardParam(Class<?> clazz, String methodName) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (!m.getName().equals(methodName)) {
+                continue;
+            }
+            for (Annotation[] anns : m.getParameterAnnotations()) {
+                for (Annotation a : anns) {
+                    if (a instanceof RequestAttribute) {
+                        String v = ((RequestAttribute) a).value();
+                        if ("role".equals(v) || "roles".equals(v)) {
+                            return true;
+                        }
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 反射派发任一 admin 端点方法并携带角色:兼容当前签名(RED 现状,如 refund/approve 为
+     * {@code (Map, Long)}、review 为 {@code (Map, Long)})与契约签名(GREEN 后为
+     * {@code (Map, Long, String role)}),避免 GREEN 阶段改签名导致本测试编译失败。
+     */
+    private static Result<?> invokeWithRole(Object controller, String methodName,
+                                            Map<String, Object> body, Long adminId, String role)
+            throws Exception {
+        for (Method m : controller.getClass().getDeclaredMethods()) {
+            if (!m.getName().equals(methodName)) {
+                continue;
+            }
+            Class<?>[] pts = m.getParameterTypes();
+            m.setAccessible(true);
+            if (pts.length == 3 && pts[0] == Map.class && pts[1] == Long.class
+                    && pts[2] == String.class) {
+                return (Result<?>) m.invoke(controller, body, adminId, role);
+            }
+            if (pts.length == 2 && pts[0] == Map.class && pts[1] == Long.class) {
+                return (Result<?>) m.invoke(controller, body, adminId);
+            }
+        }
+        fail("契约方法不存在: " + methodName);
+        return null;
+    }
+
+    /** 端点路径 → 控制器方法名(仅非同名路径需要映射) */
+    private static String methodOf(String endpoint) {
+        if ("refund/approve".equals(endpoint)) {
+            return "approveRefund";
+        }
+        if ("refund/reject".equals(endpoint)) {
+            return "rejectRefund";
+        }
+        if ("update-express".equals(endpoint)) {
+            return "updateExpress";
+        }
+        return endpoint;
+    }
+
+    private static AfterSalesRequest requestWithStatus(String status) {
+        AfterSalesRequest r = new AfterSalesRequest();
+        r.setId(1L);
+        r.setOrderNo("PO1");
+        r.setUserId(7L);
+        r.setStatus(status);
+        return r;
+    }
+}