|
|
@@ -0,0 +1,622 @@
|
|
|
+package com.payment.platform.module.payment.leshouying.controller;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.payment.platform.common.config.JacksonConfig;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.common.exception.GlobalExceptionHandler;
|
|
|
+import com.payment.platform.core.security.LoginUser;
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
|
|
|
+import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus;
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
|
|
|
+import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
|
|
|
+import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.InOrder;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
|
|
+import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
|
+import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.util.Collection;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.regex.Pattern;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 扫码收款(管理端收银台)的三个端点。
|
|
|
+ *
|
|
|
+ * <p>本类钉的五件事,按重要性排:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>归属只来自登录身份</b> —— 上下文里没有租户 ⇒ 403,且**一个上游调用都不发**;
|
|
|
+ * 查单/关单的 WHERE 里必须自带 {@code tenant_id}(超管主体下拦截器不追加,
|
|
|
+ * 这条断言是那个场景唯一的守卫)。</li>
|
|
|
+ * <li><b>两张闸的先后</b> —— 商户映射闸(本地)必须排在**任何上游调用**之前。
|
|
|
+ * 反了不是「同样安全」:那等于拿一个还没证明属于本租户的商户号去发起上游调用。</li>
|
|
|
+ * <li><b>落库装配的不变量</b> —— 这一份是 {@code LeshouyingOpenController.newOrder} 的孪生,
|
|
|
+ * 两份**有意不合并**(见控制器 javadoc)。不变量分头钉:这里钉的是「收银单必须长这样」。</li>
|
|
|
+ * <li><b>状态端点主动查上游**且**会降级</b> —— 一次查询失败不得变成操作员屏幕上的报错;
|
|
|
+ * 节流与终态短路都要实测(否则「每 3 秒一次」会退化成「每次轮询一次」)。</li>
|
|
|
+ * <li><b>关单的守卫只能写在 WHERE 里</b>,命中 0 行时必须报「未生效」而不是报成功。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>【为什么 mock 服务而不是起容器】本类全部走 {@code standaloneSetup} + 真
|
|
|
+ * {@code GlobalExceptionHandler} + 应用真实的 ObjectMapper({@code JacksonConfig})——
|
|
|
+ * 出口的**形态**(HTTP 码 / 业务码 / 字段的 snake_case)由它们决定,mock 掉就等于把
|
|
|
+ * 这三件事一起 mock 掉。形态照 {@code LeshouyingOnboardingControllerTest}。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class LeshouyingCashierControllerTest {
|
|
|
+
|
|
|
+ /** 认证上下文里的租户 —— 「归属只许来自这里」的那个值。 */
|
|
|
+ private static final Long TENANT = 7L;
|
|
|
+
|
|
|
+ /** 三个端点共用的路径前缀(菜单 SQL 与前端 API 层按它对齐)。 */
|
|
|
+ private static final String PREFIX = "/payment/collection/cashier";
|
|
|
+
|
|
|
+ private static final String OTN = "SK1789012345678901234";
|
|
|
+
|
|
|
+ private static final String MCH_NO = "Y52211090004";
|
|
|
+ private static final String SHOP_NO = "S001";
|
|
|
+
|
|
|
+ /** 收款金额(**分**)—— 与 {@code pay_lsy_order.total_amount} 同一量纲。 */
|
|
|
+ private static final long AMOUNT = 12345L;
|
|
|
+
|
|
|
+ @Mock private LsyOrderService orderService;
|
|
|
+ @Mock private LsyQueryService queryService;
|
|
|
+ @Mock private LsyCancelService cancelService;
|
|
|
+ @Mock private LsyMerchantService merchantService;
|
|
|
+ @Mock private LsyOrderMapper orderMapper;
|
|
|
+
|
|
|
+ private MockMvc mockMvc;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ LeshouyingCashierController controller = new LeshouyingCashierController(
|
|
|
+ orderService, queryService, cancelService, merchantService, orderMapper);
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
|
|
+ .setControllerAdvice(new GlobalExceptionHandler())
|
|
|
+ .setMessageConverters(new MappingJackson2HttpMessageConverter(
|
|
|
+ new JacksonConfig().objectMapper()))
|
|
|
+ .build();
|
|
|
+ // LambdaQueryWrapper 的元数据缓存(无 Spring 上下文时必需),同 LsyMerchantServiceTest。
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
|
|
+ TableInfoHelper.initTableInfo(assistant, LsyOrderEntity.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, LsyMerchantEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @AfterEach
|
|
|
+ void clearContext() {
|
|
|
+ SecurityContextHolder.clearContext();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 一、归属只来自登录身份 ─────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上下文里没有租户 ⇒ 403,且**连锁都不锁**。
|
|
|
+ *
|
|
|
+ * <p>同时钉住「先闸后调用」:若实现把商户查询/上游调用提到闸之前,本用例的
|
|
|
+ * {@code verifyNoInteractions} 立刻变红 —— 那时 403 只是「发晚了」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void create_withoutTenantInContext_is403_andTouchesNothingElse() throws Exception {
|
|
|
+ login(null, true);
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/qrcode")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"amount\":" + AMOUNT + "}"))
|
|
|
+ .andExpect(jsonPath("$.code").value(403));
|
|
|
+
|
|
|
+ verifyNoInteractions(merchantService, orderService, queryService, cancelService, orderMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 商户映射闸 + 落库 + 下单,三步的**相对顺序**。 */
|
|
|
+ @Test
|
|
|
+ void create_merchantGate_runsBeforeInsert_andInsertBeforeUpstream() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(merchantService.requireByTenant(TENANT)).thenReturn(merchantRow());
|
|
|
+ when(orderService.createQrOrder(any(), any())).thenReturn(null);
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/qrcode")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"amount\":" + AMOUNT + "}"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+
|
|
|
+ InOrder order = inOrder(merchantService, orderMapper, orderService);
|
|
|
+ order.verify(merchantService).requireByTenant(TENANT);
|
|
|
+ order.verify(orderMapper).insert(any(LsyOrderEntity.class));
|
|
|
+ order.verify(orderService).createQrOrder(any(LsyOrderEntity.class), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 本租户没有商户映射 ⇒ 404,**一行都不落**、一次上游都不发。 */
|
|
|
+ @Test
|
|
|
+ void create_withoutMerchantMapping_is404_andInsertsNothing() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(merchantService.requireByTenant(TENANT))
|
|
|
+ .thenThrow(new BusinessException(404, "该租户未配置商户号"));
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/qrcode")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"amount\":" + AMOUNT + "}"))
|
|
|
+ .andExpect(jsonPath("$.code").value(404));
|
|
|
+
|
|
|
+ verify(orderMapper, never()).insert(any(LsyOrderEntity.class));
|
|
|
+ verifyNoInteractions(orderService, queryService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 二、金额校验 ─────────────────────────
|
|
|
+
|
|
|
+ /** 金额是操作员唯一的输入 ⇒ 它的边界必须当场拒掉,不能带着 0 或负数去上游。 */
|
|
|
+ @Test
|
|
|
+ void create_rejectsNonPositiveOrMissingAmount_withoutTouchingAnything() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+
|
|
|
+ for (String body : new String[]{"{\"amount\":0}", "{\"amount\":-1}", "{}"}) {
|
|
|
+ mockMvc.perform(post(PREFIX + "/qrcode")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content(body))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
+ }
|
|
|
+
|
|
|
+ verifyNoInteractions(merchantService, orderService, orderMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 请求体里出现服务端决定的键({@code tenant_id})⇒ 400 + 一句人话,
|
|
|
+ * **不是** HTTP 500「服务器内部错误」。
|
|
|
+ *
|
|
|
+ * <p>依据同 {@code LeshouyingOpenController.handleUnreadableBody}:本仓
|
|
|
+ * {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true,未知键原样冒到兜底就是 500,
|
|
|
+ * 而写错一个键名的调用方看到的是「我们崩了」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void create_rejectsUnknownBodyField_asBadRequestNotServerError() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/qrcode")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"amount\":" + AMOUNT + ",\"tenant_id\":999}"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
+
|
|
|
+ verifyNoInteractions(merchantService, orderService, orderMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 三、落库装配的不变量 ─────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 交给 {@code createQrOrder} 的那一行必须是这个形状。
|
|
|
+ *
|
|
|
+ * <p>★ {@code notifyUrl} 必须是 {@code null}:本单**有意不设** notify_url —— 收银端
|
|
|
+ * 靠状态端点主动查上游拿结果,而 {@code notify_url} 这一列同时是「我们推给下游」的地址
|
|
|
+ * ({@code LsyCallbackService.resolveUrl} 第一个就读订单快照)⇒ 填我们自己的回调入口
|
|
|
+ * 会造出一条自环(我们把回调推给自己,415 + 最多 8 次重试 + {@code notify_status=FAIL})。
|
|
|
+ * 理由全文在控制器 {@code newOrder} 的 javadoc。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void create_assemblesTheOrder_rowShapeAndServerDecidedFields() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(merchantService.requireByTenant(TENANT)).thenReturn(merchantRow());
|
|
|
+ when(orderService.createQrOrder(any(), any())).thenReturn(null);
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/qrcode")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"amount\":" + AMOUNT + ",\"remark\":\"3 号桌\"}"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+
|
|
|
+ LsyOrderEntity o = insertedOrder();
|
|
|
+ assertEquals(TENANT, o.getTenantId(), "归属必须来自认证上下文");
|
|
|
+ assertNull(o.getApiKeyId(), "收银台不走开放 API ⇒ 没有 ApiKey");
|
|
|
+ assertEquals(MCH_NO, o.getMchNo(), "商户号来自本租户的映射(不是调用方传的)");
|
|
|
+ assertEquals(SHOP_NO, o.getShopNo(), "门店号同上");
|
|
|
+ assertEquals("QRCODE", o.getPayScene());
|
|
|
+ assertNull(o.getPayType(), "聚合码不传 pay_type,上游自动识别支付宝/微信");
|
|
|
+ assertEquals("CREATED", o.getStatus(), "落库那一刻还没下单,状态只能是 CREATED");
|
|
|
+ assertEquals(AMOUNT, o.getTotalAmount());
|
|
|
+ assertEquals("3 号桌", o.getOrderBody(), "备注进 order_body(上游那一侧的订单标题)");
|
|
|
+ assertEquals(0L, o.getRefundedAmount());
|
|
|
+ assertEquals(0, o.getQueryCount());
|
|
|
+ assertEquals("PENDING", o.getNotifyStatus());
|
|
|
+ assertEquals(0, o.getNotifyCount());
|
|
|
+ assertNull(o.getNotifyUrl(), "有意不设 —— 见本方法 javadoc");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 单号由服务端生成:操作员不输入任何单号。
|
|
|
+ *
|
|
|
+ * <p>三条一起钉:① 满足上游对 {@code out_trade_no} 的字符集与长度上界;
|
|
|
+ * ② 两笔不同;③ 与 {@code pay_trace_no} 不是同一个值(两个字段各有各的用途,
|
|
|
+ * 抄成一份会让「按 pay_trace_no 定位回调」查到错误的一行)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void create_generatesOutTradeNo_perRequest_distinctAndWithinUpstreamLimit() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(merchantService.requireByTenant(TENANT)).thenReturn(merchantRow());
|
|
|
+ when(orderService.createQrOrder(any(), any())).thenReturn(null);
|
|
|
+
|
|
|
+ for (int i = 0; i < 2; i++) {
|
|
|
+ mockMvc.perform(post(PREFIX + "/qrcode")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"amount\":" + AMOUNT + "}"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+ }
|
|
|
+
|
|
|
+ ArgumentCaptor<LsyOrderEntity> captor = ArgumentCaptor.forClass(LsyOrderEntity.class);
|
|
|
+ verify(orderMapper, times(2)).insert(captor.capture());
|
|
|
+ LsyOrderEntity first = captor.getAllValues().get(0);
|
|
|
+ LsyOrderEntity second = captor.getAllValues().get(1);
|
|
|
+
|
|
|
+ for (LsyOrderEntity o : captor.getAllValues()) {
|
|
|
+ String otn = o.getOutTradeNo();
|
|
|
+ assertNotNull(otn);
|
|
|
+ assertTrue(Pattern.matches(LsyBizNoPattern.REGEXP, otn),
|
|
|
+ "单号必须过上游那条字符集/长度规则,实测: " + otn);
|
|
|
+ assertTrue(otn.length() <= 64, "上限 64(与 pay_lsy_order.out_trade_no 列一致): " + otn);
|
|
|
+ assertNotEquals(otn, o.getPayTraceNo(), "单号与 pay_trace_no 是两个字段,不得同值");
|
|
|
+ assertNotNull(o.getPayTraceNo());
|
|
|
+ assertNotNull(o.getPayTime(), "pay_time 是上游定位交易的另一半,不得为空");
|
|
|
+ }
|
|
|
+ assertNotEquals(first.getOutTradeNo(), second.getOutTradeNo(), "两笔单不得同号");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 四、状态轮询只回本租户 ─────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询必须自带 {@code tenant_id} 谓词,且绑定的就是上下文里的租户。
|
|
|
+ *
|
|
|
+ * <p>为什么不能只靠 {@code TenantInnerInterceptor}:超管主体下它不追加谓词
|
|
|
+ * (见 {@code LeshouyingOnboardingController} 的同类断言),而「别人的单号」在这里
|
|
|
+ * 只是一个字符串 —— 少了这条谓词,任何人拿一个单号就能读到别家租户的收款单。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void status_queryCarriesExplicitTenantPredicate() throws Exception {
|
|
|
+ login(TENANT, true);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(orderRow("SUCCESS"));
|
|
|
+
|
|
|
+ mockMvc.perform(get(PREFIX + "/" + OTN))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+
|
|
|
+ AbstractWrapper<?, ?, ?> w = capturedSelectOne();
|
|
|
+ assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
|
|
|
+ "查询必须自带 tenant_id 谓词(超管主体下拦截器不会追加)。实测: " + w.getSqlSegment());
|
|
|
+ assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
|
|
|
+ "谓词占位符必须绑定上下文里的租户 " + TENANT + "。实测: " + w.getParamNameValuePairs());
|
|
|
+ assertTrue(w.getSqlSegment().contains("out_trade_no = #{"),
|
|
|
+ "定位键是单号。实测: " + w.getSqlSegment());
|
|
|
+ assertTrue(w.getParamNameValuePairs().containsValue(OTN),
|
|
|
+ "单号必须原样进谓词。实测: " + w.getParamNameValuePairs());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 别家租户的单:查不到 ⇒ 404,**且不去打扰上游**(否则连单号存不存在都能被探出来)。 */
|
|
|
+ @Test
|
|
|
+ void status_ofAnotherTenantsOrder_is404_andNeverAsksUpstream() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ mockMvc.perform(get(PREFIX + "/" + OTN))
|
|
|
+ .andExpect(jsonPath("$.code").value(404));
|
|
|
+
|
|
|
+ verifyNoInteractions(queryService, orderService, cancelService);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 订单还挂着 ⇒ 主动问一次上游,并把**查完之后**的状态回给页面。
|
|
|
+ *
|
|
|
+ * <p>★ 这条是本端点的存在理由:不设 notify_url 之后,「顾客付完马上变绿」全靠这一问。
|
|
|
+ * 它同时钉住「顺序」——先查上游再落库,不能反过来。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void status_pendingOrder_asksUpstream_andReportsTheSettledState() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyOrderEntity row = orderRow("PAYING");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(row);
|
|
|
+ Map<String, Object> upstream = new LinkedHashMap<>();
|
|
|
+ upstream.put("result_code", "PAY_SUCCESS");
|
|
|
+ when(queryService.queryOrder(row)).thenReturn(upstream);
|
|
|
+ // 真 settleFromQuery 的文档化副作用就是「命中就把这一行写成终态」,桩按同一形状回。
|
|
|
+ when(orderService.settleFromQuery(eq(row), any())).thenAnswer(inv -> {
|
|
|
+ row.setStatus("SUCCESS");
|
|
|
+ row.setReceiptAmount(AMOUNT);
|
|
|
+ return "SUCCESS";
|
|
|
+ });
|
|
|
+
|
|
|
+ mockMvc.perform(get(PREFIX + "/" + OTN))
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status").value("SUCCESS"))
|
|
|
+ .andExpect(jsonPath("$.data.receipt_amount").value(AMOUNT))
|
|
|
+ .andExpect(jsonPath("$.data.out_trade_no").value(OTN));
|
|
|
+
|
|
|
+ InOrder order = inOrder(queryService, orderService);
|
|
|
+ order.verify(queryService).queryOrder(row);
|
|
|
+ order.verify(orderService).settleFromQuery(eq(row), any());
|
|
|
+ verify(orderMapper).updateById(any(LsyOrderEntity.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 上游这一跳失败 ⇒ **降级**:照常回库里的状态,页面继续轮询,不是一屏报错。 */
|
|
|
+ @Test
|
|
|
+ void status_upstreamFailure_degradesToDbStatus_notAnError() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyOrderEntity row = orderRow("PAYING");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(row);
|
|
|
+ when(queryService.queryOrder(row))
|
|
|
+ .thenThrow(new LeshouyingApiException("NETWORK_ERROR", "网络异常", null));
|
|
|
+
|
|
|
+ mockMvc.perform(get(PREFIX + "/" + OTN))
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status").value("PAYING"));
|
|
|
+
|
|
|
+ verify(orderService, never()).settleFromQuery(any(), any());
|
|
|
+ verify(orderMapper, never()).updateById(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 节流:上一问还没到期 ⇒ 这一轮不问上游(否则「每 3 秒一问」会退化成「每次轮询一问」)。 */
|
|
|
+ @Test
|
|
|
+ void status_throttled_skipsUpstream() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyOrderEntity row = orderRow("PAYING");
|
|
|
+ row.setNextQueryTime(OffsetDateTime.now().plusSeconds(2));
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(row);
|
|
|
+
|
|
|
+ mockMvc.perform(get(PREFIX + "/" + OTN))
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status").value("PAYING"));
|
|
|
+
|
|
|
+ verifyNoInteractions(queryService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 已经是终态 ⇒ 永不再问上游(这条腿是「轮询不烧上游配额」的唯一保证)。 */
|
|
|
+ @Test
|
|
|
+ void status_terminalOrder_neverAsksUpstream() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(orderRow("CLOSED"));
|
|
|
+
|
|
|
+ mockMvc.perform(get(PREFIX + "/" + OTN))
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status").value("CLOSED"));
|
|
|
+
|
|
|
+ verifyNoInteractions(queryService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 二维码剩余秒数:页面靠它倒计时,到点自己切到「已过期」。 */
|
|
|
+ @Test
|
|
|
+ void status_reportsRemainingQrSeconds() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyOrderEntity row = orderRow("PAYING");
|
|
|
+ row.setCodeUrlExpireTime(OffsetDateTime.now().plusSeconds(600));
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(row);
|
|
|
+ row.setNextQueryTime(OffsetDateTime.now().plusSeconds(30)); // 短路掉上游那一问
|
|
|
+
|
|
|
+ mockMvc.perform(get(PREFIX + "/" + OTN))
|
|
|
+ .andExpect(jsonPath("$.data.expire_seconds").value(org.hamcrest.Matchers.allOf(
|
|
|
+ org.hamcrest.Matchers.greaterThan(590),
|
|
|
+ org.hamcrest.Matchers.lessThanOrEqualTo(600))));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 五、关单 ─────────────────────────
|
|
|
+
|
|
|
+ /** 关单成功:状态**由服务层给**,且守卫写在 WHERE 里(放行集合是「还没终态」)。 */
|
|
|
+ @Test
|
|
|
+ void close_appliesGuardedUpdate_andReportsTheNewStatus() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyOrderEntity row = orderRow("PAYING");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(row);
|
|
|
+ when(cancelService.closeOrder(row)).thenReturn("CANCELED");
|
|
|
+ when(orderMapper.update(any(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.status").value("CANCELED"));
|
|
|
+
|
|
|
+ AbstractWrapper<?, ?, ?> w = capturedUpdate();
|
|
|
+ assertTrue(w.getSqlSegment().contains("status IN"),
|
|
|
+ "守卫只能写在 WHERE 里(返回值不是守卫)。实测: " + w.getSqlSegment());
|
|
|
+ // 同时接受两种渲染:MP 把集合摊成多个占位符,或整个集合作一个参数。
|
|
|
+ // 断言的是「PENDING 那两个值都在谓词参数里」这件事本身,不是 MP 的渲染方式
|
|
|
+ //(钉渲染方式会让一次库升级变成一条假红)。
|
|
|
+ Collection<Object> values = w.getParamNameValuePairs().values();
|
|
|
+ boolean flat = values.containsAll(LsyOrderStatus.PENDING);
|
|
|
+ boolean nested = values.stream()
|
|
|
+ .anyMatch(v -> v instanceof Collection<?> c && c.containsAll(LsyOrderStatus.PENDING));
|
|
|
+ assertTrue(flat || nested,
|
|
|
+ "放行集合必须是「还没终态」那两个值。实测: " + w.getParamNameValuePairs());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 守卫命中 0 行 ⇒ **不得**报成功。
|
|
|
+ *
|
|
|
+ * <p>这一格与 {@code requireOrder} 的快照之间隔着一次完整的上游往返,期间支付成功回调
|
|
|
+ * 可能已经把这一行写成 SUCCESS;此时报「成功 + 已撤销」等于报一个库里不存在的状态。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void close_whenGuardMisses_reportsNotApplied_neverSuccess() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyOrderEntity row = orderRow("PAYING");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(row);
|
|
|
+ when(cancelService.closeOrder(row)).thenReturn("CANCELED");
|
|
|
+ when(orderMapper.update(any(), any())).thenReturn(0);
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("未生效")));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 关单也必须带租户谓词:定位不到就 404,**一个上游关单都不发**。 */
|
|
|
+ @Test
|
|
|
+ void close_isTenantScoped_andNeverClosesAnotherTenantsOrder() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
|
|
|
+ .andExpect(jsonPath("$.code").value(404));
|
|
|
+
|
|
|
+ verifyNoInteractions(cancelService);
|
|
|
+ AbstractWrapper<?, ?, ?> w = capturedSelectOne();
|
|
|
+ assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
|
|
|
+ "关单的定位也必须自带租户谓词。实测: " + w.getSqlSegment());
|
|
|
+ assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
|
|
|
+ "谓词占位符必须绑定上下文里的租户。实测: " + w.getParamNameValuePairs());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上游关单失败 ⇒ 业务码 500 + 可重试文案,**不是** HTTP 500「服务器内部错误」。
|
|
|
+ *
|
|
|
+ * <p>照 {@code LeshouyingOpenController.orderClose} 的出口(同一句服务调用、同一句文案):
|
|
|
+ * 上游文档 §5.5 要求调用方在收到失败时**先查订单状态再说**,而这个页面每 3 秒自己就在查
|
|
|
+ * —— 抹成「服务器内部错误」等于把那条指引删掉。文案不与开放 API 那条腿共用「调用退款接口」
|
|
|
+ * 的后半句:收银台这版没有退款入口,写了等于指一条不存在的路。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void close_upstreamFailure_isRetryable_thenReflectedByTheNextPoll() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyOrderEntity row = orderRow("PAYING");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(row);
|
|
|
+ when(cancelService.closeOrder(row))
|
|
|
+ .thenThrow(new LeshouyingApiException("HTTP_500", "关单未成功", null));
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("请稍后重试")));
|
|
|
+
|
|
|
+ verify(orderMapper, never()).update(any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 顾客已经付过了 ⇒ **不得**报「已撤销」。
|
|
|
+ *
|
|
|
+ * <p>{@code cancelService.closeOrder} 对已支付的单返回 {@code "SUCCESS"}(含义是
|
|
|
+ * 「转退款流程」,不是「关单成功」)。收银台没有退款入口 ⇒ 这条只能如实告诉操作员。
|
|
|
+ * 报成「已撤销」的后果是一笔真实收款在操作员眼里消失了。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void close_whenAlreadyPaid_neverReportsClosed() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyOrderEntity row = orderRow("PAYING");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(row);
|
|
|
+ when(cancelService.closeOrder(row)).thenReturn("SUCCESS");
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/" + OTN + "/close"))
|
|
|
+ .andExpect(jsonPath("$.code").value(500))
|
|
|
+ .andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("已经付款")));
|
|
|
+
|
|
|
+ verify(orderMapper, never()).update(any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 六、权限串 ─────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 三个端点各自的权限串**逐字**钉死。
|
|
|
+ *
|
|
|
+ * <p>为什么不能只靠「注解在不在」:权限判定是**全等匹配**({@code PermissionCheck.hasAny}
|
|
|
+ * 用 Set.contains),差一个字符即 403 —— 而菜单按 {@code sys_menu.permission} 授权,
|
|
|
+ * 两处不一致的后果是「授了但永远 403」。跨文件的那一半由
|
|
|
+ * {@code LeshouyingAdminWiringTest} 负责(它把注解里的串与菜单 SQL 的清单对撞)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void everyEndpoint_carriesItsExactPermissionLiteral() {
|
|
|
+ Map<String, String> expected = Map.of(
|
|
|
+ "createQr", "module_payment:leshouying:cashier:create",
|
|
|
+ "status", "module_payment:leshouying:cashier:list",
|
|
|
+ "close", "module_payment:leshouying:cashier:close");
|
|
|
+
|
|
|
+ Set<String> seen = new java.util.LinkedHashSet<>();
|
|
|
+ for (Map.Entry<String, String> e : expected.entrySet()) {
|
|
|
+ Method m = java.util.Arrays.stream(LeshouyingCashierController.class.getDeclaredMethods())
|
|
|
+ .filter(x -> x.getName().equals(e.getKey()))
|
|
|
+ .findFirst()
|
|
|
+ .orElseThrow(() -> new AssertionError("端点方法不见了: " + e.getKey()));
|
|
|
+ PreAuthorize pa = m.getAnnotation(PreAuthorize.class);
|
|
|
+ assertNotNull(pa, e.getKey() + " 缺 @PreAuthorize ⇒ 该端点同时失去认证与租户条件");
|
|
|
+ String literal = "@perm.hasAny('" + e.getValue() + "')";
|
|
|
+ assertEquals(literal, pa.value(), e.getKey() + " 的权限串被改动了");
|
|
|
+ seen.add(e.getValue());
|
|
|
+ }
|
|
|
+ assertEquals(expected.size(), seen.size(), "三条串必须互不相同(一个菜单行只装一个串)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── helpers ─────────────────────────
|
|
|
+
|
|
|
+ private static void login(Long tenantId, boolean isSuperuser) {
|
|
|
+ LoginUser u = new LoginUser();
|
|
|
+ u.setUserId(1L);
|
|
|
+ u.setUsername("tester");
|
|
|
+ u.setStatus("0");
|
|
|
+ u.setTenantId(tenantId);
|
|
|
+ u.setIsSuperuser(isSuperuser);
|
|
|
+ u.setAuthorities(java.util.List.of());
|
|
|
+ SecurityContextHolder.getContext().setAuthentication(
|
|
|
+ new UsernamePasswordAuthenticationToken(u, null, java.util.List.of()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LsyMerchantEntity merchantRow() {
|
|
|
+ LsyMerchantEntity m = new LsyMerchantEntity();
|
|
|
+ m.setId(1L);
|
|
|
+ m.setTenantId(TENANT);
|
|
|
+ m.setMchNo(MCH_NO);
|
|
|
+ m.setShopNo(SHOP_NO);
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LsyOrderEntity orderRow(String status) {
|
|
|
+ LsyOrderEntity o = new LsyOrderEntity();
|
|
|
+ o.setId(1001L);
|
|
|
+ o.setTenantId(TENANT);
|
|
|
+ o.setOutTradeNo(OTN);
|
|
|
+ o.setMchNo(MCH_NO);
|
|
|
+ o.setShopNo(SHOP_NO);
|
|
|
+ o.setPayScene("QRCODE");
|
|
|
+ o.setStatus(status);
|
|
|
+ o.setTotalAmount(AMOUNT);
|
|
|
+ o.setPayTraceNo("trace-1");
|
|
|
+ o.setPayTime("20260923120000");
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ private LsyOrderEntity insertedOrder() {
|
|
|
+ ArgumentCaptor<LsyOrderEntity> captor = ArgumentCaptor.forClass(LsyOrderEntity.class);
|
|
|
+ verify(orderMapper).insert(captor.capture());
|
|
|
+ return captor.getValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ private AbstractWrapper<?, ?, ?> capturedSelectOne() {
|
|
|
+ ArgumentCaptor<Wrapper<LsyOrderEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
|
|
+ verify(orderMapper).selectOne(captor.capture());
|
|
|
+ return (AbstractWrapper<?, ?, ?>) captor.getValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ private AbstractWrapper<?, ?, ?> capturedUpdate() {
|
|
|
+ ArgumentCaptor<Wrapper<LsyOrderEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
|
|
+ verify(orderMapper).update(any(LsyOrderEntity.class), captor.capture());
|
|
|
+ return (AbstractWrapper<?, ?, ?>) captor.getValue();
|
|
|
+ }
|
|
|
+}
|