fix(withdraw): tenant filter + enums + tests

This commit is contained in:
irving
2026-01-18 23:58:27 -05:00
parent 6a3b4fef1f
commit fffc623ab0
10 changed files with 593 additions and 21 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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());
}

View File

@@ -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());
}

View File

@@ -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);