From fffc623ab05512eb23b770672a0e1b71fd1b0495 Mon Sep 17 00:00:00 2001 From: irving Date: Sun, 18 Jan 2026 23:58:27 -0500 Subject: [PATCH] fix(withdraw): tenant filter + enums + tests --- .../weichat/controller/WxOauthController.java | 349 ++++++++++++++++++ .../controller/WxWithdrawController.java | 35 +- .../withdraw/enums/EarningsLineStatus.java | 28 ++ .../enums/WithdrawalRequestStatus.java | 29 ++ .../service/impl/WithdrawalServiceImpl.java | 50 ++- .../withdraw/vo/ClerkWithdrawBalanceVo.java | 23 ++ ...inEarningsAdjustmentControllerApiTest.java | 4 +- ...ngsDeductionBatchAuthorizationApiTest.java | 13 +- .../api/WxWithdrawControllerApiTest.java | 81 ++++ .../service/WxCustomMpServiceTest.java | 2 + 10 files changed, 593 insertions(+), 21 deletions(-) create mode 100644 play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsLineStatus.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/WithdrawalRequestStatus.java diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java index eb42d69..26f7436 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java @@ -19,12 +19,23 @@ import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; +import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; +import com.starry.admin.modules.shop.module.constant.CouponUseState; import com.starry.admin.modules.shop.module.entity.PlayCommodityAndLevelInfoEntity; +import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity; +import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity; +import com.starry.admin.modules.shop.module.enums.CouponClaimConditionType; +import com.starry.admin.modules.shop.module.enums.CouponDiscountType; +import com.starry.admin.modules.shop.module.enums.CouponObtainChannel; +import com.starry.admin.modules.shop.module.enums.CouponOnlineState; +import com.starry.admin.modules.shop.module.enums.CouponValidityPeriodType; import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService; +import com.starry.admin.modules.shop.service.IPlayCouponDetailsService; +import com.starry.admin.modules.shop.service.IPlayCouponInfoService; import com.starry.admin.modules.system.module.entity.SysTenantEntity; import com.starry.admin.modules.system.module.entity.SysUserEntity; import com.starry.admin.modules.system.service.ISysTenantService; @@ -117,6 +128,11 @@ public class WxOauthController { @Resource private IEarningsService earningsService; + @Resource + private IPlayCouponInfoService couponInfoService; + @Resource + private IPlayCouponDetailsService couponDetailsService; + @Resource private Environment environment; @@ -125,6 +141,30 @@ public class WxOauthController { private static final String TEST_AUTH_HEADER = "X-Test-Auth"; + private enum SwitchState { + DISABLED("0"), + ENABLED("1"); + + private final String code; + + SwitchState(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public static SwitchState fromCode(String code) { + for (SwitchState value : values()) { + if (value.code.equals(code)) { + return value; + } + } + throw new IllegalArgumentException("invalid switch state"); + } + } + private boolean isTestAuthEnabled() { String[] profiles = environment == null ? new String[0] : environment.getActiveProfiles(); Set active = Stream.of(profiles == null ? new String[0] : profiles) @@ -387,6 +427,111 @@ public class WxOauthController { } } + @ApiOperation(value = "测试用:种子数据(优惠券)", notes = "创建并直接发放给顾客 1 张满减券,用于 coupon E2E") + @PostMapping("/e2e/seed/coupon") + public R seedCoupon( + @RequestHeader(value = TEST_AUTH_HEADER, required = false) String testAuth, + @RequestParam(value = "customerId", required = false, defaultValue = ApiTestDataSeeder.DEFAULT_CUSTOMER_ID) + String customerId, + @RequestParam(value = "placeType", required = false, defaultValue = "0") String placeType, + @RequestParam("discountAmount") String discountAmount, + @RequestParam(value = "useMinAmount", required = false, defaultValue = "0") String useMinAmount) { + if (!isTestAuthHeaderValid(testAuth)) { + return rejectTestAuth(); + } + if (!StringUtils.hasText(customerId) || !StringUtils.hasText(discountAmount) || !StringUtils.hasText(useMinAmount)) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "missing required fields"); + } + final String tenantId = ApiTestDataSeeder.DEFAULT_TENANT_ID; + try (TenantScope ignored = TenantScope.use(tenantId)) { + PlayCustomUserInfoEntity customer = customUserInfoService.getById(customerId); + if (customer == null) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "customer not found"); + } + + OrderConstant.PlaceType parsedPlaceType; + try { + parsedPlaceType = OrderConstant.PlaceType.fromCode(placeType); + } catch (Exception ex) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "invalid placeType"); + } + + java.math.BigDecimal discount; + java.math.BigDecimal minAmount; + try { + discount = new java.math.BigDecimal(discountAmount); + minAmount = new java.math.BigDecimal(useMinAmount); + } catch (Exception ex) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "invalid amount"); + } + if (discount.signum() <= 0) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "discountAmount must be > 0"); + } + if (minAmount.signum() < 0) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "useMinAmount must be >= 0"); + } + + String couponId = "e2e-cpn-" + IdUtils.getUuid(); + LocalDateTime now = LocalDateTime.now(); + java.util.Date nowDate = java.util.Date.from(now.atZone(java.time.ZoneId.systemDefault()).toInstant()); + + PlayCouponInfoEntity coupon = new PlayCouponInfoEntity(); + coupon.setId(couponId); + coupon.setTenantId(tenantId); + coupon.setCouponName("E2E满减券-" + couponId); + coupon.setValidityPeriodType(CouponValidityPeriodType.PERMANENT.getCode()); + coupon.setUseMinAmount(minAmount); + coupon.setDiscountType(CouponDiscountType.FULL_REDUCTION.getCode()); + coupon.setDiscountContent("E2E满减"); + coupon.setDiscountAmount(discount); + coupon.setAttributionDiscounts("0"); + coupon.setPlaceType(List.of(parsedPlaceType.getCode())); + coupon.setClerkType("0"); + coupon.setCouponQuantity(1); + coupon.setIssuedQuantity(0); + coupon.setRemainingQuantity(1); + coupon.setClerkObtainedMaxQuantity(1); + coupon.setClaimConditionType(CouponClaimConditionType.ALL.code()); + coupon.setCustomWhitelist(new ArrayList<>()); + coupon.setCustomLevelCheckType("0"); + coupon.setCustomSexCheckType("0"); + coupon.setNewUser("0"); + coupon.setCouponOnLineState(CouponOnlineState.ONLINE.getCode()); + coupon.setProductiveTime(now.minusDays(1)); + coupon.setExpirationTime(now.plusDays(30)); + coupon.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + coupon.setCreatedTime(nowDate); + coupon.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + coupon.setUpdatedTime(nowDate); + couponInfoService.save(coupon); + + String detailId = "e2e-cpn-detail-" + IdUtils.getUuid(); + PlayCouponDetailsEntity detail = new PlayCouponDetailsEntity(); + detail.setId(detailId); + detail.setTenantId(tenantId); + detail.setCustomId(customer.getId()); + detail.setCouponId(couponId); + detail.setCustomNickname(customer.getNickname()); + detail.setCustomLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + detail.setObtainingChannels(CouponObtainChannel.SELF_SERVICE.getCode()); + detail.setUseState(CouponUseState.UNUSED.getCode()); + detail.setObtainingTime(now); + detail.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + detail.setCreatedTime(nowDate); + detail.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + detail.setUpdatedTime(nowDate); + couponDetailsService.save(detail); + + return R.ok(new E2eCouponSeedResponse( + customer.getId(), + couponId, + detailId, + discount.toPlainString(), + minAmount.toPlainString(), + parsedPlaceType.getCode())); + } + } + @ApiOperation(value = "测试用:种子数据(店员上下架)", notes = "修改店员 listingState,用于下架/不可用分支 E2E") @PostMapping("/e2e/seed/clerk-listing-state") public R seedClerkListingState( @@ -412,6 +557,47 @@ public class WxOauthController { } } + @ApiOperation(value = "测试用:种子数据(店员可接单状态)", notes = "更新 onlineState/listingState/randomOrderState/displayState,用于随机单成功/失败分支 E2E") + @PostMapping("/e2e/seed/clerk-availability") + public R seedClerkAvailability( + @RequestHeader(value = TEST_AUTH_HEADER, required = false) String testAuth, + @RequestParam("clerkId") String clerkId, + @RequestParam(value = "onlineState", required = false, defaultValue = "1") String onlineState, + @RequestParam(value = "listingState", required = false, defaultValue = "1") String listingState, + @RequestParam(value = "randomOrderState", required = false, defaultValue = "1") String randomOrderState, + @RequestParam(value = "displayState", required = false, defaultValue = "1") String displayState) { + if (!isTestAuthHeaderValid(testAuth)) { + return rejectTestAuth(); + } + if (!StringUtils.hasText(clerkId)) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "missing required fields"); + } + final String tenantId = ApiTestDataSeeder.DEFAULT_TENANT_ID; + try (TenantScope ignored = TenantScope.use(tenantId)) { + PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(clerkId); + if (clerk == null) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "clerk not found"); + } + SwitchState online = SwitchState.fromCode(onlineState); + ListingStatus listing = ListingStatus.fromCode(listingState); + SwitchState random = SwitchState.fromCode(randomOrderState); + SwitchState display = SwitchState.fromCode(displayState); + clerk.setOnlineState(online.getCode()); + clerk.setListingState(listing.getCode()); + clerk.setRandomOrderState(random.getCode()); + clerk.setDisplayState(display.getCode()); + clerkUserInfoService.updateById(clerk); + return R.ok(new E2eClerkAvailabilitySeedResponse( + clerkId, + online.getCode(), + listing.getCode(), + random.getCode(), + display.getCode())); + } catch (IllegalArgumentException ex) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), ex.getMessage()); + } + } + @ApiOperation(value = "测试用:种子数据(冻结收益)", notes = "插入 frozen earning line(有 order_id, amount>0),用于冻结/不可提现分支 E2E") @PostMapping("/e2e/seed/frozen-earnings") public R seedFrozenEarnings( @@ -446,6 +632,50 @@ public class WxOauthController { } } + @ApiOperation(value = "测试用:种子数据(PC端受限账号)", notes = "创建一个非超级管理员账号(无菜单权限),用于权限可见性 E2E") + @PostMapping("/e2e/seed/pc-tenant-user/no-menu") + public R seedPcTenantNoMenuUser( + @RequestHeader(value = TEST_AUTH_HEADER, required = false) String testAuth, + @RequestParam(value = "userName", required = false) String userName) { + if (!isTestAuthHeaderValid(testAuth)) { + return rejectTestAuth(); + } + final String tenantId = ApiTestDataSeeder.DEFAULT_TENANT_ID; + final String tenantKey = ApiTestDataSeeder.DEFAULT_TENANT_KEY; + final String rawPassword = "apitest-secret"; + String resolvedUserName = StringUtils.hasText(userName) + ? userName + // LoginService enforces username length <= 20. + : ("nm" + IdUtils.getUuid().substring(0, 8)); + + SysUserEntity existing = sysUserService.selectUserByUserNameAndTenantId(resolvedUserName, tenantId); + if (existing == null) { + SysUserEntity user = new SysUserEntity(); + user.setUserId("user-" + resolvedUserName); + user.setUserCode(resolvedUserName); + user.setPassWord(passwordEncoder.encode(rawPassword)); + user.setRealName("E2E NoMenu"); + user.setUserNickname("E2E-NoMenu"); + user.setStatus(0); + user.setUserType(1); + user.setTenantId(tenantId); + user.setMobile("13800000009"); + user.setAddTime(LocalDateTime.now()); + user.setSuperAdmin(Boolean.FALSE); + sysUserService.save(user); + } + + SysUserEntity created = sysUserService.selectUserByUserNameAndTenantId(resolvedUserName, tenantId); + if (created == null) { + return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "failed to create user"); + } + return R.ok(new E2ePcTenantNoMenuUserSeedResponse( + tenantKey, + created.getUserCode(), + rawPassword, + created.getUserId())); + } + @ApiOperation(value = "测试用:种子数据(tenantB)", notes = "创建 tenant-key-apitest-b 及其 admin/clerk,用于多租户隔离 E2E") @PostMapping("/e2e/seed/tenant-b") public R seedTenantB( @@ -745,6 +975,54 @@ public class WxOauthController { } } + private static final class E2eCouponSeedResponse { + private final String customerId; + private final String couponId; + private final String couponDetailId; + private final String discountAmount; + private final String useMinAmount; + private final String placeType; + + private E2eCouponSeedResponse( + String customerId, + String couponId, + String couponDetailId, + String discountAmount, + String useMinAmount, + String placeType) { + this.customerId = customerId; + this.couponId = couponId; + this.couponDetailId = couponDetailId; + this.discountAmount = discountAmount; + this.useMinAmount = useMinAmount; + this.placeType = placeType; + } + + public String getCustomerId() { + return customerId; + } + + public String getCouponId() { + return couponId; + } + + public String getCouponDetailId() { + return couponDetailId; + } + + public String getDiscountAmount() { + return discountAmount; + } + + public String getUseMinAmount() { + return useMinAmount; + } + + public String getPlaceType() { + return placeType; + } + } + private static final class E2eClerkListingSeedResponse { private final String clerkId; private final String listingState; @@ -763,6 +1041,47 @@ public class WxOauthController { } } + private static final class E2eClerkAvailabilitySeedResponse { + private final String clerkId; + private final String onlineState; + private final String listingState; + private final String randomOrderState; + private final String displayState; + + private E2eClerkAvailabilitySeedResponse( + String clerkId, + String onlineState, + String listingState, + String randomOrderState, + String displayState) { + this.clerkId = clerkId; + this.onlineState = onlineState; + this.listingState = listingState; + this.randomOrderState = randomOrderState; + this.displayState = displayState; + } + + public String getClerkId() { + return clerkId; + } + + public String getOnlineState() { + return onlineState; + } + + public String getListingState() { + return listingState; + } + + public String getRandomOrderState() { + return randomOrderState; + } + + public String getDisplayState() { + return displayState; + } + } + private static final class E2eFrozenEarningsSeedResponse { private final String clerkId; private final String orderId; @@ -787,6 +1106,36 @@ public class WxOauthController { } } + private static final class E2ePcTenantNoMenuUserSeedResponse { + private final String tenantKey; + private final String userName; + private final String password; + private final String userId; + + private E2ePcTenantNoMenuUserSeedResponse(String tenantKey, String userName, String password, String userId) { + this.tenantKey = tenantKey; + this.userName = userName; + this.password = password; + this.userId = userId; + } + + public String getTenantKey() { + return tenantKey; + } + + public String getUserName() { + return userName; + } + + public String getPassword() { + return password; + } + + public String getUserId() { + return userId; + } + } + private static final class E2eTenantSeedResponse { private final String tenantId; private final String tenantKey; diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java index 3f342f7..fe4c64d 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java @@ -13,6 +13,7 @@ import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; import com.starry.admin.modules.withdraw.enums.EarningsSourceType; +import com.starry.admin.modules.withdraw.enums.WithdrawalRequestStatus; import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService; import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IWithdrawalLogService; @@ -61,11 +62,43 @@ public class WxWithdrawController { @GetMapping("/balance") public TypedR getBalance() { String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId(); + String tenantId = SecurityUtils.getTenantId(); LocalDateTime now = LocalDateTime.now(); BigDecimal available = earningsService.getAvailableAmount(clerkId, now); BigDecimal pending = earningsService.getPendingAmount(clerkId, now); LocalDateTime nextUnlock = earningsService.getNextUnlockTime(clerkId, now); - return TypedR.ok(new ClerkWithdrawBalanceVo(available, pending, nextUnlock)); + WithdrawalRequestEntity active = withdrawalService.lambdaQuery() + .eq(WithdrawalRequestEntity::getTenantId, tenantId) + .eq(WithdrawalRequestEntity::getClerkId, clerkId) + .in(WithdrawalRequestEntity::getStatus, + WithdrawalRequestStatus.PENDING.getCode(), + WithdrawalRequestStatus.PROCESSING.getCode()) + .orderByDesc(WithdrawalRequestEntity::getCreatedTime) + .last("limit 1") + .one(); + + Boolean locked = active != null; + String lockReason = locked ? "当前已有一笔提现申请在途,请等待处理完成后再申请。" : ""; + ClerkWithdrawBalanceVo.ActiveRequest activeRequest = null; + if (active != null) { + Date createdTime = active.getCreatedTime(); + LocalDateTime createdAt = createdTime == null ? null + : LocalDateTime.ofInstant(createdTime.toInstant(), ZoneId.systemDefault()); + activeRequest = ClerkWithdrawBalanceVo.ActiveRequest.builder() + .amount(active.getAmount()) + .status(active.getStatus()) + .createdTime(createdAt == null ? "" : createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + } + + ClerkWithdrawBalanceVo vo = new ClerkWithdrawBalanceVo(); + vo.setAvailable(available); + vo.setPending(pending); + vo.setNextUnlockAt(nextUnlock); + vo.setWithdrawLocked(locked); + vo.setWithdrawLockReason(lockReason); + vo.setActiveRequest(activeRequest); + return TypedR.ok(vo); } @ClerkUserLogin diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsLineStatus.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsLineStatus.java new file mode 100644 index 0000000..bbee992 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsLineStatus.java @@ -0,0 +1,28 @@ +package com.starry.admin.modules.withdraw.enums; + +import java.util.Arrays; + +public enum EarningsLineStatus { + FROZEN("frozen"), + AVAILABLE("available"), + WITHDRAWING("withdrawing"), + WITHDRAWN("withdrawn"), + REVERSED("reversed"); + + private final String code; + + EarningsLineStatus(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public static EarningsLineStatus fromCode(String code) { + return Arrays.stream(values()) + .filter(it -> it.code.equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("invalid earnings line status: " + code)); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/WithdrawalRequestStatus.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/WithdrawalRequestStatus.java new file mode 100644 index 0000000..5934671 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/WithdrawalRequestStatus.java @@ -0,0 +1,29 @@ +package com.starry.admin.modules.withdraw.enums; + +import java.util.Arrays; + +public enum WithdrawalRequestStatus { + PENDING("pending"), + PROCESSING("processing"), + SUCCESS("success"), + FAILED("failed"), + CANCELED("canceled"), + REJECTED("rejected"); + + private final String code; + + WithdrawalRequestStatus(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public static WithdrawalRequestStatus fromCode(String code) { + return Arrays.stream(values()) + .filter(it -> it.code.equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("invalid withdrawal request status: " + code)); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/WithdrawalServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/WithdrawalServiceImpl.java index 05d982f..8a7b707 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/WithdrawalServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/WithdrawalServiceImpl.java @@ -7,6 +7,8 @@ import com.starry.admin.common.exception.CustomException; import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; +import com.starry.admin.modules.withdraw.enums.EarningsLineStatus; +import com.starry.admin.modules.withdraw.enums.WithdrawalRequestStatus; import com.starry.admin.modules.withdraw.mapper.WithdrawalRequestMapper; import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService; import com.starry.admin.modules.withdraw.service.IEarningsService; @@ -43,6 +45,20 @@ public class WithdrawalServiceImpl extends ServiceImpl lines = earningsService.lambdaQuery() .eq(EarningsLineEntity::getWithdrawalId, req.getId()) - .eq(EarningsLineEntity::getStatus, "withdrawing") + .eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode()) .list(); for (EarningsLineEntity line : lines) { LocalDateTime unlock = line.getUnlockTime(); - String restored = unlock != null && unlock.isAfter(now) ? "frozen" : "available"; + String restored = unlock != null && unlock.isAfter(now) + ? EarningsLineStatus.FROZEN.getCode() + : EarningsLineStatus.AVAILABLE.getCode(); earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class) .eq(EarningsLineEntity::getId, line.getId()) .set(EarningsLineEntity::getWithdrawalId, null) diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/ClerkWithdrawBalanceVo.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/ClerkWithdrawBalanceVo.java index 1157c3f..83fb6ee 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/ClerkWithdrawBalanceVo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/ClerkWithdrawBalanceVo.java @@ -6,6 +6,7 @@ import io.swagger.annotations.ApiModelProperty; import java.math.BigDecimal; import java.time.LocalDateTime; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -23,4 +24,26 @@ public class ClerkWithdrawBalanceVo { @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime nextUnlockAt; + + @ApiModelProperty("是否提现锁定(仅允许同时存在一笔在途申请)") + private Boolean withdrawLocked; + + @ApiModelProperty("锁定原因") + private String withdrawLockReason; + + @ApiModelProperty("当前在途申请(pending/processing)") + private ActiveRequest activeRequest; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ActiveRequest { + @ApiModelProperty("申请金额") + private BigDecimal amount; + @ApiModelProperty("状态 pending/processing") + private String status; + @ApiModelProperty("提交时间 yyyy-MM-dd HH:mm:ss") + private String createdTime; + } } diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentControllerApiTest.java index 2f4f3cc..3a19625 100644 --- a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentControllerApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentControllerApiTest.java @@ -1,6 +1,8 @@ package com.starry.admin.api; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.is; 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.header; @@ -123,7 +125,7 @@ class AdminEarningsAdjustmentControllerApiTest extends AbstractApiTest { .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.idempotencyKey").value(key)) .andExpect(jsonPath("$.data.adjustmentId").isNotEmpty()) - .andExpect(jsonPath("$.data.status").value("PROCESSING")); + .andExpect(jsonPath("$.data.status").value(anyOf(is("PROCESSING"), is("APPLIED")))); // After implementation, the system should eventually transition to APPLIED. // Poll with a bounded wait to keep the test deterministic. diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchAuthorizationApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchAuthorizationApiTest.java index 3c59325..adae801 100644 --- a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchAuthorizationApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchAuthorizationApiTest.java @@ -107,11 +107,13 @@ class AdminEarningsDeductionBatchAuthorizationApiTest extends AbstractApiTest { @Test void previewWithoutPermissionReturns403() throws Exception { - String payload = "{\"beginTime\":\"2026-01-01 00:00:00\",\"endTime\":\"2026-01-07 23:59:59\"," + + String payload = "{\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," + + "\"beginTime\":\"2026-01-01 00:00:00\",\"endTime\":\"2026-01-07 23:59:59\"," + "\"ruleType\":\"FIXED\",\"amount\":\"10.00\",\"operation\":\"BONUS\",\"reasonDescription\":\"x\"}"; mockMvc.perform(post(BASE_URL + "/preview") .header(USER_HEADER, "leader-no-perm") .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, "nope") .contentType(MediaType.APPLICATION_JSON) .content(payload)) .andExpect(status().isForbidden()); @@ -296,17 +298,20 @@ class AdminEarningsDeductionBatchAuthorizationApiTest extends AbstractApiTest { // Contract: read endpoints are protected even if the resource does not exist. mockMvc.perform(get(BASE_URL + "/idempotency/" + UUID.randomUUID()) .header(USER_HEADER, "read-no-perm") - .header(TENANT_HEADER, DEFAULT_TENANT)) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, "nope")) .andExpect(status().isForbidden()); mockMvc.perform(get(BASE_URL + "/" + UUID.randomUUID() + "/items") .header(USER_HEADER, "read-no-perm") - .header(TENANT_HEADER, DEFAULT_TENANT)) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, "nope")) .andExpect(status().isForbidden()); mockMvc.perform(get(BASE_URL + "/" + UUID.randomUUID() + "/logs") .header(USER_HEADER, "read-no-perm") - .header(TENANT_HEADER, DEFAULT_TENANT)) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, "nope")) .andExpect(status().isForbidden()); } diff --git a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java index e880cbf..9243f7b 100644 --- a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java @@ -153,6 +153,76 @@ class WxWithdrawControllerApiTest extends AbstractApiTest { .isEqualTo(now.plusHours(6).format(DATE_TIME_FORMATTER)); } + @Test + void balanceIndicatesWithdrawLockedWhenPendingRequestExists__covers_WD_001() throws Exception { + ensureTenantContext(); + WithdrawalRequestEntity req = new WithdrawalRequestEntity(); + String requestId = IdUtils.getUuid(); + req.setId(requestId); + req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + req.setAmount(new BigDecimal("10.00")); + req.setFee(BigDecimal.ZERO); + req.setNetAmount(new BigDecimal("10.00")); + req.setStatus("pending"); + req.setCreatedTime(new Date()); + withdrawalService.save(req); + withdrawalsToCleanup.add(requestId); + + MvcResult result = mockMvc.perform(get("/wx/withdraw/balance") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + JsonNode data = root.get("data"); + assertThat(data.path("withdrawLocked").asBoolean()).isTrue(); + assertThat(data.path("withdrawLockReason").asText()).isNotBlank(); + JsonNode active = data.path("activeRequest"); + assertThat(active.isMissingNode() || active.isNull()).isFalse(); + assertThat(active.path("status").asText()).isEqualTo("pending"); + assertThat(active.path("amount").decimalValue()).isEqualByComparingTo("10.00"); + assertThat(active.path("createdTime").asText()).isNotBlank(); + } + + @Test + void balanceDoesNotLockWhenActiveWithdrawalBelongsToDifferentTenant__covers_WD_001() throws Exception { + ensureTenantContext(); + String otherTenantId = ApiTestDataSeeder.DEFAULT_TENANT_ID + "-other"; + String requestId = IdUtils.getUuid(); + + try { + SecurityUtils.setTenantId(otherTenantId); + WithdrawalRequestEntity req = new WithdrawalRequestEntity(); + req.setId(requestId); + req.setTenantId(otherTenantId); + req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + req.setAmount(new BigDecimal("10.00")); + req.setFee(BigDecimal.ZERO); + req.setNetAmount(new BigDecimal("10.00")); + req.setStatus("pending"); + req.setCreatedTime(new Date()); + withdrawalService.save(req); + + ensureTenantContext(); + mockMvc.perform(get("/wx/withdraw/balance") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.withdrawLocked").value(false)) + .andExpect(jsonPath("$.data.withdrawLockReason").value("")); + } finally { + SecurityUtils.setTenantId(otherTenantId); + withdrawalService.removeById(requestId); + ensureTenantContext(); + } + } + @Test void createWithdrawRejectsNonPositiveAmount__covers_WD_003() throws Exception { ensureTenantContext(); @@ -248,14 +318,17 @@ class WxWithdrawControllerApiTest extends AbstractApiTest { refreshPayeeConfirmation(); String firstWithdrawal = createWithdraw(new BigDecimal("35")); assertLinesLocked(firstWithdrawal, lineIds[0], lineIds[1], lineIds[2]); + markWithdrawalCompleted(firstWithdrawal); refreshPayeeConfirmation(); String secondWithdrawal = createWithdraw(new BigDecimal("90")); assertLinesLocked(secondWithdrawal, lineIds[3], lineIds[4], lineIds[5]); + markWithdrawalCompleted(secondWithdrawal); refreshPayeeConfirmation(); String thirdWithdrawal = createWithdraw(new BigDecimal("135")); assertLinesLocked(thirdWithdrawal, lineIds[6], lineIds[7], lineIds[8], lineIds[9]); + markWithdrawalCompleted(thirdWithdrawal); ensureTenantContext(); BigDecimal remaining = earningsService.getAvailableAmount(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now()); @@ -549,6 +622,14 @@ class WxWithdrawControllerApiTest extends AbstractApiTest { } } + private void markWithdrawalCompleted(String withdrawalId) { + ensureTenantContext(); + WithdrawalRequestEntity patch = new WithdrawalRequestEntity(); + patch.setId(withdrawalId); + patch.setStatus("success"); + withdrawalService.updateById(patch); + } + private Date toDate(LocalDateTime value) { return Date.from(value.atZone(ZoneId.systemDefault()).toInstant()); } diff --git a/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxCustomMpServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxCustomMpServiceTest.java index 4a49924..b14f130 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxCustomMpServiceTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/weichat/service/WxCustomMpServiceTest.java @@ -40,6 +40,7 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class WxCustomMpServiceTest { @@ -294,6 +295,7 @@ class WxCustomMpServiceTest { @Test void checkSubscribeThrowsWhenUserNotSubscribed__covers_MP_008() throws WxErrorException { + ReflectionTestUtils.setField(wxCustomMpService, "subscribeCheckEnabled", true); SysTenantEntity tenant = buildTenant(); when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant); when(wxMpService.switchoverTo(tenant.getAppId())).thenReturn(wxMpService);