|
|
@@ -0,0 +1,275 @@
|
|
|
+package com.payment.platform.module.payment.account.service;
|
|
|
+
|
|
|
+import com.alipay.api.AlipayClient;
|
|
|
+import com.alipay.api.domain.AlipayDataBillAccountbookereceiptQueryModel;
|
|
|
+import com.alipay.api.request.AlipayDataBillAccountbookereceiptQueryRequest;
|
|
|
+import com.alipay.api.response.AlipayDataBillAccountbookereceiptQueryResponse;
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.core.alipay.AlipayClientFactory;
|
|
|
+import com.payment.platform.module.payment.account.entity.AccountEntity;
|
|
|
+import com.payment.platform.module.payment.account.entity.TransferEntity;
|
|
|
+import com.payment.platform.module.payment.account.mapper.AccountMapper;
|
|
|
+import com.payment.platform.module.payment.account.mapper.DepositMapper;
|
|
|
+import com.payment.platform.module.payment.account.mapper.TransferMapper;
|
|
|
+import com.payment.platform.module.payment.account.mapper.WithdrawMapper;
|
|
|
+import com.payment.platform.module.payment.facetoface.mapper.F2fTradeRecordMapper;
|
|
|
+import com.payment.platform.module.payment.notification.mapper.PayBillMapper;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+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.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
|
+import org.springframework.data.redis.core.ValueOperations;
|
|
|
+
|
|
|
+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.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 「下载回单」把文件交给浏览器的后半段。
|
|
|
+ *
|
|
|
+ * <p>前端原来的做法是拿后端给的 {@code download_url} 去 {@code window.open} —— 那一步失败
|
|
|
+ * 是静默的(返回 null,调用方没查),用户看到的是「回单下载成功」却什么都没发生。
|
|
|
+ * 现在这一段搬到服务端:由服务端取回字节,前端再落盘。所以这里钉的是
|
|
|
+ * <b>「后端说 SUCCESS」与「真的取到字节」之间不许再有缝</b> —— 状态不是 SUCCESS 时
|
|
|
+ * 一个字节都不许去取,取不到就抛,让前端把失败照实显示出来。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class AccountServiceReceiptFileTest {
|
|
|
+
|
|
|
+ private static final String ORDER_NO = "ORDER-1";
|
|
|
+ private static final String ENTERPRISE_ID = "ENT-1";
|
|
|
+ private static final String DOWNLOAD_URL = "https://files.example.com/receipt/abc.pdf";
|
|
|
+
|
|
|
+ @Mock private AccountMapper accountMapper;
|
|
|
+ @Mock private TransferMapper transferMapper;
|
|
|
+ @Mock private DepositMapper depositMapper;
|
|
|
+ @Mock private WithdrawMapper withdrawMapper;
|
|
|
+ @Mock private AlipayClientFactory alipayClientFactory;
|
|
|
+ @Mock private RedisTemplate<String, Object> redisTemplate;
|
|
|
+ @Mock private ValueOperations<String, Object> valueOperations;
|
|
|
+ @Mock private PayBillMapper payBillMapper;
|
|
|
+ @Mock private F2fTradeRecordMapper f2fTradeRecordMapper;
|
|
|
+ @Mock private AlipayTransferService alipayTransferService;
|
|
|
+ @Mock private ReceiptFileFetcher receiptFileFetcher;
|
|
|
+ @Mock private AlipayClient alipayClient;
|
|
|
+
|
|
|
+ private AccountService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new AccountService(accountMapper, transferMapper, depositMapper, withdrawMapper,
|
|
|
+ alipayClientFactory, redisTemplate, payBillMapper, f2fTradeRecordMapper,
|
|
|
+ alipayTransferService, receiptFileFetcher);
|
|
|
+ // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper 可在无 Spring 上下文的单测里构造
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TransferEntity.class);
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), AccountEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常路径 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void receiptReady_returnsBytesAndUpstreamFilename() throws Exception {
|
|
|
+ givenTransfer();
|
|
|
+ givenSignedAccount("AGMT-1");
|
|
|
+ givenCachedFileId("FILE-1");
|
|
|
+ givenQuery(SUCCESS_RESPONSE());
|
|
|
+
|
|
|
+ when(receiptFileFetcher.fetch(eq(DOWNLOAD_URL), anyString()))
|
|
|
+ .thenReturn(new ReceiptFileFetcher.Fetched(new byte[]{1, 2, 3}, "abc.pdf"));
|
|
|
+
|
|
|
+ ReceiptFileFetcher.Fetched got = service.downloadReceiptFile(ORDER_NO);
|
|
|
+
|
|
|
+ assertArrayEquals(new byte[]{1, 2, 3}, got.content());
|
|
|
+ assertEquals("abc.pdf", got.filename());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void enterpriseIdComesFromTransferRecord_notFromCaller() throws Exception {
|
|
|
+ givenTransfer();
|
|
|
+ givenSignedAccount("AGMT-1");
|
|
|
+ givenCachedFileId("FILE-1");
|
|
|
+ givenQuery(SUCCESS_RESPONSE());
|
|
|
+ when(receiptFileFetcher.fetch(eq(DOWNLOAD_URL), anyString()))
|
|
|
+ .thenReturn(new ReceiptFileFetcher.Fetched(new byte[]{1}, "abc.pdf"));
|
|
|
+
|
|
|
+ service.downloadReceiptFile(ORDER_NO);
|
|
|
+
|
|
|
+ ArgumentCaptor<AlipayDataBillAccountbookereceiptQueryRequest> cap =
|
|
|
+ ArgumentCaptor.forClass(AlipayDataBillAccountbookereceiptQueryRequest.class);
|
|
|
+ verify(alipayClient).certificateExecute(cap.capture());
|
|
|
+ // 协议号只能由「转账记录里的 enterprise_id」查出来 —— 查单请求里带的就是这个企业
|
|
|
+ assertEquals("AGMT-1",
|
|
|
+ ((AlipayDataBillAccountbookereceiptQueryModel) cap.getValue().getBizModel()).getAgreementNo());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 兜底名由调用方给,且带单号:上游文件名不可信时用它 —— 我们在转账列表里就是这么叫这笔
|
|
|
+ * 回单的,一次下载多张时也能对上列表里的行。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void fallbackFilenameCarriesOrderNo() throws Exception {
|
|
|
+ givenTransfer();
|
|
|
+ givenSignedAccount("AGMT-1");
|
|
|
+ givenCachedFileId("FILE-1");
|
|
|
+ givenQuery(SUCCESS_RESPONSE());
|
|
|
+ when(receiptFileFetcher.fetch(anyString(), anyString()))
|
|
|
+ .thenReturn(new ReceiptFileFetcher.Fetched(new byte[]{1}, "abc.pdf"));
|
|
|
+
|
|
|
+ service.downloadReceiptFile(ORDER_NO);
|
|
|
+
|
|
|
+ verify(receiptFileFetcher).fetch(DOWNLOAD_URL, "回单_" + ORDER_NO + ".pdf");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 状态不是 SUCCESS:一个字节都不许取 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void receiptStillProcessing_throws_andNeverFetchesAnything() throws Exception {
|
|
|
+ givenTransfer();
|
|
|
+ givenSignedAccount("AGMT-1");
|
|
|
+ givenCachedFileId("FILE-1");
|
|
|
+ AlipayDataBillAccountbookereceiptQueryResponse resp = new AlipayDataBillAccountbookereceiptQueryResponse();
|
|
|
+ resp.setCode("10000");
|
|
|
+ resp.setStatus("PROCESS");
|
|
|
+ givenQuery(resp);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("PROCESS"), "提示里要带上状态,否则用户无从判断该不该重试: " + e.getMessage());
|
|
|
+ // 409 是前端「值得再点一次」的判据(见 account/index.vue downloadReceiptWithRetry)。
|
|
|
+ // 这个码要是被改成 400,前端就会把「还没生成好」当终局失败 —— 用户得自己再点,
|
|
|
+ // 而这正是原来那套轮询要避免的。
|
|
|
+ assertEquals(409, e.getCode(), "「还没生成好」必须与真失败用不同的码区分开");
|
|
|
+ verifyNoInteractions(receiptFileFetcher);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void receiptFailed_surfacesUpstreamErrorMessage_andNeverFetches() throws Exception {
|
|
|
+ givenTransfer();
|
|
|
+ givenSignedAccount("AGMT-1");
|
|
|
+ givenCachedFileId("FILE-1");
|
|
|
+ AlipayDataBillAccountbookereceiptQueryResponse resp = new AlipayDataBillAccountbookereceiptQueryResponse();
|
|
|
+ resp.setCode("10000");
|
|
|
+ resp.setStatus("FAIL");
|
|
|
+ resp.setErrorMessage("账单不存在");
|
|
|
+ givenQuery(resp);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("账单不存在"), e.getMessage());
|
|
|
+ assertNotEquals(409, e.getCode(), "真失败不能用「重试」那个码,否则前端会白转三次");
|
|
|
+ verifyNoInteractions(receiptFileFetcher);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SUCCESS 但没给下载地址 —— 这是最容易被写成「静默当成成功」的一格:
|
|
|
+ * 原前端正是在这里 `if (status === "SUCCESS" && download_url)` 落空后什么都不做。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void successStatusWithoutDownloadUrl_throws_andNeverFetches() throws Exception {
|
|
|
+ givenTransfer();
|
|
|
+ givenSignedAccount("AGMT-1");
|
|
|
+ givenCachedFileId("FILE-1");
|
|
|
+ AlipayDataBillAccountbookereceiptQueryResponse resp = new AlipayDataBillAccountbookereceiptQueryResponse();
|
|
|
+ resp.setCode("10000");
|
|
|
+ resp.setStatus("SUCCESS");
|
|
|
+ givenQuery(resp);
|
|
|
+
|
|
|
+ assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
|
|
|
+
|
|
|
+ verifyNoInteractions(receiptFileFetcher);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownOrder_throwsWithoutTouchingUpstream() {
|
|
|
+ when(transferMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
|
|
|
+
|
|
|
+ // 文案沿用既有链路 downloadReceiptByOrderNo(它是「查不到转账记录」的唯一出口,
|
|
|
+ // 这里不另写一份);断言钉的是「用户被告知没找到转账记录」,不是某一串具体措辞。
|
|
|
+ assertTrue(e.getMessage().contains("转账记录"), e.getMessage());
|
|
|
+ verifyNoInteractions(alipayClientFactory, receiptFileFetcher);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void blankOrderNo_throwsWithoutQuerying() {
|
|
|
+ assertThrows(BusinessException.class, () -> service.downloadReceiptFile(" "));
|
|
|
+
|
|
|
+ verifyNoInteractions(transferMapper, alipayClientFactory, receiptFileFetcher);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void transferWithoutFundOrderId_throwsBeforeCallingUpstream() {
|
|
|
+ TransferEntity transfer = new TransferEntity();
|
|
|
+ transfer.setOrderNo(ORDER_NO);
|
|
|
+ transfer.setEnterpriseId(ENTERPRISE_ID);
|
|
|
+ when(transferMapper.selectOne(any())).thenReturn(transfer);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("支付宝资金单号"), e.getMessage());
|
|
|
+ verifyNoInteractions(alipayClientFactory, receiptFileFetcher);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 取文件的失败要照实冒出来 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void fetchFailure_propagatesInsteadOfReportingSuccess() throws Exception {
|
|
|
+ givenTransfer();
|
|
|
+ givenSignedAccount("AGMT-1");
|
|
|
+ givenCachedFileId("FILE-1");
|
|
|
+ givenQuery(SUCCESS_RESPONSE());
|
|
|
+ when(receiptFileFetcher.fetch(eq(DOWNLOAD_URL), anyString()))
|
|
|
+ .thenThrow(new BusinessException(400, "回单文件获取失败(HTTP 403)"));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("403"), e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== fixtures ====================
|
|
|
+
|
|
|
+ private void givenTransfer() {
|
|
|
+ TransferEntity transfer = new TransferEntity();
|
|
|
+ transfer.setOrderNo(ORDER_NO);
|
|
|
+ transfer.setEnterpriseId(ENTERPRISE_ID);
|
|
|
+ transfer.setFundOrderId("FUND-1");
|
|
|
+ when(transferMapper.selectOne(any())).thenReturn(transfer);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void givenSignedAccount(String agreementNo) {
|
|
|
+ AccountEntity account = new AccountEntity();
|
|
|
+ account.setEnterpriseId(ENTERPRISE_ID);
|
|
|
+ account.setAgreementNo(agreementNo);
|
|
|
+ when(accountMapper.selectOne(any())).thenReturn(account);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 申请回单命中 Redis 缓存 ⇒ 跳过 alipay.data.bill.ereceiptagent.apply 那一次出站。 */
|
|
|
+ private void givenCachedFileId(String fileId) {
|
|
|
+ when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
|
|
+ when(valueOperations.get(anyString())).thenReturn(fileId);
|
|
|
+ }
|
|
|
+
|
|
|
+ private AlipayDataBillAccountbookereceiptQueryResponse SUCCESS_RESPONSE() {
|
|
|
+ AlipayDataBillAccountbookereceiptQueryResponse resp = new AlipayDataBillAccountbookereceiptQueryResponse();
|
|
|
+ resp.setCode("10000");
|
|
|
+ resp.setStatus("SUCCESS");
|
|
|
+ resp.setDownloadUrl(DOWNLOAD_URL);
|
|
|
+ return resp;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void givenQuery(AlipayDataBillAccountbookereceiptQueryResponse resp) throws Exception {
|
|
|
+ when(alipayClientFactory.getClient(ENTERPRISE_ID)).thenReturn(alipayClient);
|
|
|
+ when(alipayClient.certificateExecute(any(AlipayDataBillAccountbookereceiptQueryRequest.class)))
|
|
|
+ .thenReturn(resp);
|
|
|
+ }
|
|
|
+}
|