diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java index 3d4a383..e5f7a73 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java @@ -3,6 +3,9 @@ package com.starry.admin.modules.withdraw.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity; @@ -13,6 +16,7 @@ import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.ITenantAlipayConfigService; import com.starry.admin.modules.withdraw.service.IWithdrawalLogService; import com.starry.admin.modules.withdraw.service.IWithdrawalService; +import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo; import com.starry.admin.modules.withdraw.vo.EarningsAdminQueryVo; import com.starry.admin.modules.withdraw.vo.EarningsAdminSummaryVo; import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest; @@ -24,11 +28,15 @@ import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import javax.annotation.Resource; import org.springframework.web.bind.annotation.*; @@ -49,6 +57,8 @@ public class AdminWithdrawalController { private IEarningsBackfillService earningsBackfillService; @Resource private IEarningsBackfillLogService backfillLogService; + @Resource + private IPlayOrderInfoService orderInfoService; @ApiOperation("分页查询提现请求") @PostMapping("/requests/listByPage") @@ -72,6 +82,62 @@ public class AdminWithdrawalController { return TypedR.ok(list); } + @ApiOperation("提现请求审计") + @GetMapping("/requests/{id}/audit") + public TypedR> getRequestAudit(@PathVariable("id") String id) { + String tenantId = SecurityUtils.getTenantId(); + WithdrawalRequestEntity request = withdrawalService.getById(id); + if (request == null || !tenantId.equals(request.getTenantId())) { + throw new CustomException("提现申请不存在或无权查看"); + } + + List lines = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getTenantId, tenantId) + .eq(EarningsLineEntity::getWithdrawalId, id) + .orderByAsc(EarningsLineEntity::getCreatedTime) + .list(); + if (lines.isEmpty()) { + return TypedR.ok(Collections.emptyList()); + } + + List orderIds = lines.stream() + .map(EarningsLineEntity::getOrderId) + .filter(orderId -> orderId != null && !orderId.isEmpty()) + .distinct() + .collect(Collectors.toList()); + Map orderMap = orderIds.isEmpty() + ? Collections.emptyMap() + : orderInfoService.lambdaQuery() + .eq(PlayOrderInfoEntity::getTenantId, tenantId) + .in(PlayOrderInfoEntity::getId, orderIds) + .list() + .stream() + .collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it)); + + List vos = new ArrayList<>(lines.size()); + for (EarningsLineEntity line : lines) { + ClerkEarningLineVo vo = new ClerkEarningLineVo(); + vo.setId(line.getId()); + vo.setAmount(line.getAmount()); + vo.setStatus(line.getStatus()); + vo.setEarningType(line.getEarningType()); + vo.setWithdrawalId(line.getWithdrawalId()); + vo.setUnlockTime(line.getUnlockTime()); + vo.setCreatedTime(toLocalDateTime(line.getCreatedTime())); + vo.setOrderId(line.getOrderId()); + if (line.getOrderId() != null) { + PlayOrderInfoEntity order = orderMap.get(line.getOrderId()); + if (order != null) { + vo.setOrderNo(order.getOrderNo()); + vo.setOrderStatus(order.getOrderStatus()); + vo.setOrderEndTime(toLocalDateTime(order.getOrderEndTime())); + } + } + vos.add(vo); + } + return TypedR.ok(vos); + } + @ApiOperation("分页查询收益明细") @PostMapping("/earnings/listByPage") public TypedR> listEarnings(@RequestBody EarningsAdminQueryVo vo) { @@ -182,4 +248,17 @@ public class AdminWithdrawalController { q.orderByDesc(EarningsLineEntity::getCreatedTime); return q; } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof Date) { + return ((Date) value).toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + } + return null; + } } diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalControllerApiTest.java new file mode 100644 index 0000000..fa74965 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalControllerApiTest.java @@ -0,0 +1,292 @@ +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()); + } +}