package com.starry.admin.api; import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.starry.admin.common.apitest.ApiTestDataSeeder; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; import com.starry.admin.modules.withdraw.enums.EarningsType; import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IWithdrawalService; import com.starry.admin.utils.SecurityUtils; import com.starry.common.utils.IdUtils; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Date; import java.util.List; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.MvcResult; class AdminWithdrawalControllerApiTest extends AbstractApiTest { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Autowired private IEarningsService earningsService; @Autowired private IWithdrawalService withdrawalService; @Autowired private IPlayOrderInfoService orderInfoService; private final ObjectMapper objectMapper = new ObjectMapper(); private final List earningsToCleanup = new ArrayList<>(); private final List withdrawalsToCleanup = new ArrayList<>(); private final List ordersToCleanup = new ArrayList<>(); @AfterEach void tearDown() { if (!earningsToCleanup.isEmpty()) { earningsService.removeByIds(earningsToCleanup); earningsToCleanup.clear(); } if (!withdrawalsToCleanup.isEmpty()) { withdrawalService.removeByIds(withdrawalsToCleanup); withdrawalsToCleanup.clear(); } if (!ordersToCleanup.isEmpty()) { orderInfoService.removeByIds(ordersToCleanup); ordersToCleanup.clear(); } } @Test void auditReturnsEarningLinesWithOrderDetails() throws Exception { ensureTenantContext(); PlayOrderInfoEntity order = seedOrder(LocalDateTime.now().minusHours(2)); WithdrawalRequestEntity withdrawal = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("88.60")); LocalDateTime now = LocalDateTime.now(); seedEarningLine(withdrawal.getId(), order.getId(), new BigDecimal("50.30"), "withdrawn", now.minusMinutes(30), EarningsType.ORDER); seedEarningLine(withdrawal.getId(), null, new BigDecimal("38.30"), "withdrawing", now.minusMinutes(10), EarningsType.ORDER); MvcResult result = mockMvc.perform(get("/admin/withdraw/requests/" + withdrawal.getId() + "/audit") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").isArray()) .andExpect(jsonPath("$.data.length()").value(2)) .andReturn(); JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).get("data"); boolean foundOrder = false; boolean foundMissing = false; for (JsonNode node : data) { String orderNo = node.path("orderNo").isMissingNode() ? null : node.path("orderNo").asText(null); if (order.getOrderNo().equals(orderNo)) { Assertions.assertThat(node.path("orderStatus").asText()).isEqualTo(order.getOrderStatus()); Assertions.assertThat(node.path("earningType").asText()).isEqualTo(EarningsType.ORDER.name()); foundOrder = true; } if (node.path("orderNo").isNull()) { foundMissing = true; } } Assertions.assertThat(foundOrder).isTrue(); Assertions.assertThat(foundMissing).isTrue(); } @Test void auditRejectsMissingRequest() throws Exception { ensureTenantContext(); mockMvc.perform(get("/admin/withdraw/requests/missing-request/audit") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(500)) .andExpect(jsonPath("$.message").value("提现申请不存在或无权查看")); } @Test void auditRejectsCrossTenantAccess() throws Exception { ensureTenantContext(); WithdrawalRequestEntity outsider = seedWithdrawal("tenant-other", new BigDecimal("30.00")); mockMvc.perform(get("/admin/withdraw/requests/" + outsider.getId() + "/audit") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(500)) .andExpect(jsonPath("$.message").value("提现申请不存在或无权查看")); } @Test void auditReturnsEmptyListWhenNoEarningsFound() throws Exception { ensureTenantContext(); WithdrawalRequestEntity request = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("22.00")); mockMvc.perform(get("/admin/withdraw/requests/" + request.getId() + "/audit") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.length()").value(0)); } @Test void auditHandlesMissingOrderRecordsGracefully() throws Exception { ensureTenantContext(); WithdrawalRequestEntity request = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("40.00")); String orphanOrderId = "order-orphan-" + IdUtils.getUuid(); seedEarningLine(request.getId(), orphanOrderId, new BigDecimal("15.00"), "withdrawn", LocalDateTime.now().minusMinutes(5), EarningsType.ORDER); mockMvc.perform(get("/admin/withdraw/requests/" + request.getId() + "/audit") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data[0].orderNo").value(nullValue())) .andExpect(jsonPath("$.data[0].orderId").value(orphanOrderId)); } @Test void auditReturnsLinesSortedByCreatedTime() throws Exception { ensureTenantContext(); PlayOrderInfoEntity order = seedOrder(LocalDateTime.now().minusHours(1)); WithdrawalRequestEntity request = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("90.00")); LocalDateTime now = LocalDateTime.now(); String firstId = seedEarningLine(request.getId(), order.getId(), new BigDecimal("10"), "withdrawn", now.minusMinutes(20), EarningsType.ORDER); Thread.sleep(5L); String secondId = seedEarningLine(request.getId(), null, new BigDecimal("20"), "withdrawn", now.minusMinutes(10), EarningsType.ORDER); Thread.sleep(5L); String thirdId = seedEarningLine(request.getId(), null, new BigDecimal("30"), "withdrawn", now.minusMinutes(5), EarningsType.ORDER); MvcResult result = mockMvc.perform(get("/admin/withdraw/requests/" + request.getId() + "/audit") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.length()").value(3)) .andReturn(); JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).get("data"); List ids = new ArrayList<>(); LocalDateTime previous = null; for (JsonNode node : data) { ids.add(node.get("id").asText()); String createdText = node.path("createdTime").asText(); if (createdText != null && !createdText.isEmpty()) { LocalDateTime created = LocalDateTime.parse(createdText, DATE_TIME_FORMATTER); if (previous != null) { Assertions.assertThat(created.isBefore(previous)).isFalse(); } previous = created; } } Assertions.assertThat(ids).containsExactlyInAnyOrder(firstId, secondId, thirdId); } @Test void auditSupportsCommissionEarnings() throws Exception { ensureTenantContext(); WithdrawalRequestEntity request = seedWithdrawal(ApiTestDataSeeder.DEFAULT_TENANT_ID, new BigDecimal("55.00")); seedEarningLine(request.getId(), null, new BigDecimal("55.00"), "withdrawn", LocalDateTime.now().minusMinutes(2), EarningsType.COMMISSION); mockMvc.perform(get("/admin/withdraw/requests/" + request.getId() + "/audit") .header(USER_HEADER, DEFAULT_USER) .header(TENANT_HEADER, DEFAULT_TENANT)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data[0].earningType").value(EarningsType.COMMISSION.name())); } @SuppressWarnings("deprecation") private PlayOrderInfoEntity seedOrder(LocalDateTime endTime) { PlayOrderInfoEntity order = new PlayOrderInfoEntity(); String id = "order-audit-" + IdUtils.getUuid(); order.setId(id); order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); order.setOrderNo("ORD-" + System.currentTimeMillis()); order.setOrderStatus("3"); order.setOrderType("2"); order.setPlaceType("0"); order.setRewardType("0"); order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID); order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID); order.setOrderMoney(new BigDecimal("120.50")); order.setFinalAmount(order.getOrderMoney()); order.setEstimatedRevenue(new BigDecimal("80.25")); order.setOrderSettlementState("1"); order.setOrderEndTime(endTime); order.setOrderSettlementTime(endTime); Date nowDate = toDate(LocalDateTime.now()); order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); order.setCreatedTime(nowDate); order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); order.setUpdatedTime(nowDate); order.setDeleted(false); orderInfoService.save(order); ordersToCleanup.add(id); return order; } private WithdrawalRequestEntity seedWithdrawal(String tenantId, BigDecimal amount) { WithdrawalRequestEntity entity = new WithdrawalRequestEntity(); String id = "withdraw-audit-" + IdUtils.getUuid(); entity.setId(id); entity.setTenantId(tenantId); entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); entity.setAmount(amount); entity.setFee(BigDecimal.ZERO); entity.setNetAmount(amount); entity.setDestAccount("alipay:audit@test.com"); entity.setStatus("processing"); entity.setPayeeSnapshot("{\"displayName\":\"审计专用\"}"); Date nowDate = toDate(LocalDateTime.now()); entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); entity.setCreatedTime(nowDate); entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); entity.setUpdatedTime(nowDate); withdrawalService.save(entity); withdrawalsToCleanup.add(id); return entity; } private String seedEarningLine(String withdrawalId, String orderId, BigDecimal amount, String status, LocalDateTime createdAt, EarningsType earningType) { EarningsLineEntity entity = new EarningsLineEntity(); String id = "earn-audit-" + IdUtils.getUuid(); entity.setId(id); entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); String resolvedOrderId = orderId != null ? orderId : "order-missing-" + IdUtils.getUuid(); entity.setOrderId(resolvedOrderId); entity.setAmount(amount); entity.setStatus(status); entity.setEarningType(earningType); entity.setUnlockTime(createdAt.minusHours(1)); entity.setWithdrawalId(withdrawalId); Date createdDate = toDate(createdAt); entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); entity.setCreatedTime(createdDate); entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); entity.setUpdatedTime(createdDate); entity.setDeleted(false); earningsService.save(entity); earningsToCleanup.add(id); return id; } private void ensureTenantContext() { SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); } private Date toDate(LocalDateTime value) { return Date.from(value.atZone(ZoneId.systemDefault()).toInstant()); } }