fix(withdraw): tenant filter + enums + tests
This commit is contained in:
@@ -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<String> 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;
|
||||
|
||||
@@ -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<ClerkWithdrawBalanceVo> 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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<WithdrawalRequestMapper,
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
|
||||
WithdrawalRequestEntity active = this.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();
|
||||
if (active != null) {
|
||||
throw new CustomException("仅可同时存在一笔提现申请,请等待当前申请处理完成后再提交");
|
||||
}
|
||||
|
||||
ClerkPayeeProfileEntity payeeProfile = clerkPayeeProfileService.getByClerk(tenantId, clerkId);
|
||||
if (payeeProfile == null || !StringUtils.hasText(payeeProfile.getQrCodeUrl())) {
|
||||
throw new CustomException("请先上传支付宝收款码");
|
||||
@@ -66,7 +82,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
boolean updated = earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.eq(EarningsLineEntity::getStatus, line.getStatus()) // Verify status unchanged
|
||||
.set(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.set(EarningsLineEntity::getWithdrawalId, tempWithdrawalId));
|
||||
if (!updated) {
|
||||
// Another request already took this line
|
||||
@@ -88,7 +104,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
req.setNetAmount(amount);
|
||||
req.setDestAccount(payeeProfile.getDisplayName());
|
||||
req.setPayeeSnapshot(snapshotJson);
|
||||
req.setStatus("pending");
|
||||
req.setStatus(WithdrawalRequestStatus.PENDING.getCode());
|
||||
req.setOutBizNo(req.getId());
|
||||
this.save(req);
|
||||
|
||||
@@ -120,22 +136,23 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void markManualSuccess(String requestId, String operatorBy) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (!"pending".equals(req.getStatus()) && !"processing".equals(req.getStatus())) {
|
||||
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())
|
||||
&& !WithdrawalRequestStatus.PROCESSING.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("当前状态不可操作");
|
||||
}
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("success");
|
||||
update.setStatus(WithdrawalRequestStatus.SUCCESS.getCode());
|
||||
this.updateById(update);
|
||||
|
||||
// Set reserved earnings lines to withdrawn
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||
.eq(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getStatus, "withdrawn"));
|
||||
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWN.getCode()));
|
||||
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_SUCCESS", req.getStatus(), "success",
|
||||
"PAYOUT_SUCCESS", req.getStatus(), WithdrawalRequestStatus.SUCCESS.getCode(),
|
||||
"手动打款成功,操作人=" + operatorBy, null);
|
||||
}
|
||||
|
||||
@@ -144,16 +161,16 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void autoPayout(String requestId) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (!"pending".equals(req.getStatus())) {
|
||||
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("当前状态不可自动打款");
|
||||
}
|
||||
// Transition to processing and log
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("processing");
|
||||
update.setStatus(WithdrawalRequestStatus.PROCESSING.getCode());
|
||||
this.updateById(update);
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_REQUESTED", req.getStatus(), "processing",
|
||||
"PAYOUT_REQUESTED", req.getStatus(), WithdrawalRequestStatus.PROCESSING.getCode(),
|
||||
"发起支付宝打款(未实现)", null);
|
||||
|
||||
// Not implemented yet
|
||||
@@ -165,27 +182,30 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void reject(String requestId, String reason) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if ("success".equals(req.getStatus())) {
|
||||
if (WithdrawalRequestStatus.SUCCESS.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("已成功的提现不可拒绝");
|
||||
}
|
||||
if ("canceled".equals(req.getStatus()) || "rejected".equals(req.getStatus())) {
|
||||
if (WithdrawalRequestStatus.CANCELED.getCode().equals(req.getStatus())
|
||||
|| WithdrawalRequestStatus.REJECTED.getCode().equals(req.getStatus())) {
|
||||
return;
|
||||
}
|
||||
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("canceled");
|
||||
update.setStatus(WithdrawalRequestStatus.CANCELED.getCode());
|
||||
update.setFailureReason(StringUtils.hasText(reason) ? reason : "rejected");
|
||||
this.updateById(update);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<EarningsLineEntity> 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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user