Add earnings adjustments, withdrawal reject, and auth guard
This commit is contained in:
@@ -44,6 +44,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -466,7 +467,14 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setCommodityName(commodityName);
|
||||
entity.setEnablingState("1");
|
||||
entity.setSort(1);
|
||||
clerkCommodityService.save(entity);
|
||||
try {
|
||||
clerkCommodityService.save(entity);
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
log.info(
|
||||
"API test clerk commodity {} already inserted by another test context",
|
||||
DEFAULT_CLERK_COMMODITY_ID);
|
||||
return;
|
||||
}
|
||||
log.info("Inserted API test clerk commodity link {}", DEFAULT_CLERK_COMMODITY_ID);
|
||||
}
|
||||
|
||||
@@ -489,7 +497,12 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setState(GIFT_STATE_ACTIVE);
|
||||
entity.setListingTime(LocalDateTime.now());
|
||||
entity.setRemark("Seeded gift for API tests");
|
||||
giftInfoService.save(entity);
|
||||
try {
|
||||
giftInfoService.save(entity);
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
log.info("API test gift {} already inserted by another test context", DEFAULT_GIFT_ID);
|
||||
return;
|
||||
}
|
||||
log.info("Inserted API test gift {}", DEFAULT_GIFT_ID);
|
||||
}
|
||||
|
||||
@@ -564,7 +577,24 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
entity.setRegistrationTime(new Date());
|
||||
entity.setLastLoginTime(new Date());
|
||||
entity.setToken(token);
|
||||
customUserInfoService.save(entity);
|
||||
try {
|
||||
customUserInfoService.save(entity);
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
customUserInfoService.updateTokenById(DEFAULT_CUSTOMER_ID, token);
|
||||
customUserInfoService.lambdaUpdate()
|
||||
.set(PlayCustomUserInfoEntity::getAccountBalance, DEFAULT_CUSTOMER_BALANCE)
|
||||
.set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, DEFAULT_CUSTOMER_RECHARGE)
|
||||
.set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO)
|
||||
.set(PlayCustomUserInfoEntity::getAccountState, "1")
|
||||
.set(PlayCustomUserInfoEntity::getSubscribeState, "1")
|
||||
.set(PlayCustomUserInfoEntity::getPurchaseState, "1")
|
||||
.set(PlayCustomUserInfoEntity::getMobilePhoneState, "1")
|
||||
.set(PlayCustomUserInfoEntity::getLastLoginTime, new Date())
|
||||
.eq(PlayCustomUserInfoEntity::getId, DEFAULT_CUSTOMER_ID)
|
||||
.update();
|
||||
log.info("API test customer {} already inserted by another test context", DEFAULT_CUSTOMER_ID);
|
||||
return;
|
||||
}
|
||||
log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ public class PermissionService {
|
||||
}
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) {
|
||||
return false;
|
||||
return loginUser != null && loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser());
|
||||
}
|
||||
if (loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser())) {
|
||||
return true;
|
||||
}
|
||||
return hasPermissions(loginUser.getPermissions(), permission);
|
||||
}
|
||||
@@ -70,7 +73,13 @@ public class PermissionService {
|
||||
return false;
|
||||
}
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) {
|
||||
if (loginUser == null) {
|
||||
return false;
|
||||
}
|
||||
if (loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser())) {
|
||||
return true;
|
||||
}
|
||||
if (CollectionUtils.isEmpty(loginUser.getPermissions())) {
|
||||
return false;
|
||||
}
|
||||
Set<String> authorities = loginUser.getPermissions();
|
||||
|
||||
@@ -8,7 +8,11 @@ import com.starry.common.utils.StringUtils;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
@@ -26,6 +30,16 @@ public class GlobalExceptionHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
public static final String PARAMETER_FORMAT_ERROR = "请求参数格式异常";
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<R> handleAccessDenied(AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(R.error(403, e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<R> handleAuthentication(AuthenticationException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(R.error(401, e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务异常
|
||||
*/
|
||||
|
||||
@@ -6,10 +6,14 @@ import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
||||
import com.starry.common.constant.SecurityConstants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -24,6 +28,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ApiTestSecurityProperties properties;
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
|
||||
public ApiTestAuthenticationFilter(ApiTestSecurityProperties properties) {
|
||||
this.properties = properties;
|
||||
@@ -32,6 +38,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
Map<String, Object> originalContext = new ConcurrentHashMap<>(CustomSecurityContextHolder.getLocalMap());
|
||||
String requestedUser = request.getHeader(properties.getUserHeader());
|
||||
String requestedTenant = request.getHeader(properties.getTenantHeader());
|
||||
|
||||
@@ -48,6 +55,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
try {
|
||||
LoginUser loginUser = buildLoginUser(userId, tenantId);
|
||||
applyOverridesFromHeaders(request, loginUser);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null,
|
||||
Collections.emptyList());
|
||||
@@ -61,7 +69,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
CustomSecurityContextHolder.remove();
|
||||
CustomSecurityContextHolder.setLocalMap(originalContext);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
@@ -93,4 +101,27 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
private void applyOverridesFromHeaders(HttpServletRequest request, LoginUser loginUser) {
|
||||
if (loginUser == null || loginUser.getUser() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String superAdmin = request.getHeader(SUPER_ADMIN_HEADER);
|
||||
if (StringUtils.hasText(superAdmin)) {
|
||||
loginUser.getUser().setSuperAdmin(Boolean.parseBoolean(superAdmin));
|
||||
}
|
||||
|
||||
String permissionsHeader = request.getHeader(PERMISSIONS_HEADER);
|
||||
if (!StringUtils.hasText(permissionsHeader)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> perms = Arrays.stream(permissionsHeader.split(","))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::hasText)
|
||||
.collect(Collectors.toSet());
|
||||
loginUser.setPermissions(perms);
|
||||
CustomSecurityContextHolder.setPermission(String.join(",", perms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ public class ClerkPerformanceOverviewQueryVo extends PlayClerkPerformanceInfoQue
|
||||
@ApiModelProperty(value = "是否包含排行列表数据")
|
||||
private Boolean includeRankings = Boolean.TRUE;
|
||||
|
||||
@ApiModelProperty(value = "是否包含收益调整(ADJUSTMENT)", allowableValues = "true,false")
|
||||
private Boolean includeAdjustments = Boolean.FALSE;
|
||||
|
||||
@Override
|
||||
public void setEndOrderTime(List<String> endOrderTime) {
|
||||
super.setEndOrderTime(endOrderTime);
|
||||
|
||||
@@ -142,6 +142,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) {
|
||||
DateRange range = resolveDateRange(vo.getEndOrderTime());
|
||||
List<PlayClerkUserInfoEntity> clerks = loadAccessibleClerks(vo);
|
||||
boolean includeAdjustments = Boolean.TRUE.equals(vo.getIncludeAdjustments());
|
||||
ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo();
|
||||
if (CollectionUtil.isEmpty(clerks)) {
|
||||
responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo());
|
||||
@@ -158,7 +159,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
for (PlayClerkUserInfoEntity clerk : clerks) {
|
||||
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
|
||||
range.startTime, range.endTime);
|
||||
snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime));
|
||||
snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime, includeAdjustments));
|
||||
}
|
||||
int total = snapshots.size();
|
||||
ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots);
|
||||
@@ -194,7 +195,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
|
||||
range.startTime, range.endTime);
|
||||
ClerkPerformanceSnapshotVo snapshot =
|
||||
buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime);
|
||||
buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime, false);
|
||||
ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo();
|
||||
responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap));
|
||||
responseVo.setSnapshot(snapshot);
|
||||
@@ -424,22 +425,42 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
|
||||
private BigDecimal calculateEarningsAmount(String clerkId, List<PlayOrderInfoEntity> orders, String startTime,
|
||||
String endTime) {
|
||||
if (StrUtil.isBlank(clerkId) || CollectionUtil.isEmpty(orders)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
List<String> orderIds = orders.stream()
|
||||
.filter(this::isCompletedOrder)
|
||||
.map(PlayOrderInfoEntity::getId)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.collect(Collectors.toList());
|
||||
if (CollectionUtil.isEmpty(orderIds)) {
|
||||
return calculateEarningsAmount(SecurityUtils.getTenantId(), clerkId, orders, startTime, endTime, false);
|
||||
}
|
||||
|
||||
private BigDecimal calculateEarningsAmount(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
List<PlayOrderInfoEntity> orders,
|
||||
String startTime,
|
||||
String endTime,
|
||||
boolean includeAdjustments) {
|
||||
if (StrUtil.isBlank(clerkId)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime);
|
||||
String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime);
|
||||
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
|
||||
normalizedEnd);
|
||||
return defaultZero(sum);
|
||||
|
||||
BigDecimal orderSum = BigDecimal.ZERO;
|
||||
if (CollectionUtil.isNotEmpty(orders)) {
|
||||
List<String> orderIds = orders.stream()
|
||||
.filter(this::isCompletedOrder)
|
||||
.map(PlayOrderInfoEntity::getId)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.collect(Collectors.toList());
|
||||
if (CollectionUtil.isNotEmpty(orderIds)) {
|
||||
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
|
||||
normalizedEnd);
|
||||
orderSum = defaultZero(sum);
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeAdjustments) {
|
||||
return orderSum;
|
||||
}
|
||||
|
||||
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(tenantId, clerkId, normalizedStart, normalizedEnd);
|
||||
return orderSum.add(defaultZero(adjustmentSum));
|
||||
}
|
||||
|
||||
private OrderConstant.OrderRelationType normalizeRelationType(PlayOrderInfoEntity order) {
|
||||
@@ -460,7 +481,8 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
}
|
||||
|
||||
private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List<PlayOrderInfoEntity> orders,
|
||||
Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime) {
|
||||
Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime,
|
||||
boolean includeAdjustments) {
|
||||
ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo();
|
||||
snapshot.setClerkId(clerk.getId());
|
||||
snapshot.setClerkNickname(clerk.getNickname());
|
||||
@@ -513,7 +535,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
|
||||
}
|
||||
int userCount = userIds.size();
|
||||
int continuedUserCount = continuedUserIds.size();
|
||||
BigDecimal estimatedRevenue = calculateEarningsAmount(clerk.getId(), orders, startTime, endTime);
|
||||
BigDecimal estimatedRevenue = calculateEarningsAmount(SecurityUtils.getTenantId(), clerk.getId(), orders, startTime, endTime, includeAdjustments);
|
||||
snapshot.setGmv(gmv);
|
||||
snapshot.setFirstOrderAmount(firstAmount);
|
||||
snapshot.setContinuedOrderAmount(continuedAmount);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.starry.admin.modules.withdraw.controller;
|
||||
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.result.TypedR;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import javax.annotation.Resource;
|
||||
import lombok.Data;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/earnings/adjustments")
|
||||
public class AdminEarningsAdjustmentController {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
|
||||
@Resource
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Data
|
||||
public static class CreateAdjustmentRequest {
|
||||
private String clerkId;
|
||||
private BigDecimal amount;
|
||||
private EarningsAdjustmentReasonType reasonType;
|
||||
private String reasonDescription;
|
||||
private LocalDateTime effectiveTime;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AdjustmentStatusResponse {
|
||||
private String adjustmentId;
|
||||
private String idempotencyKey;
|
||||
private String status;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:adjustment:create') and @earningsAuth.canManageClerk(#body.clerkId)")
|
||||
public ResponseEntity<TypedR<AdjustmentStatusResponse>> create(
|
||||
@RequestHeader(value = IDEMPOTENCY_HEADER, required = false) String idempotencyKey,
|
||||
@RequestBody CreateAdjustmentRequest body) {
|
||||
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "Idempotency-Key required"));
|
||||
}
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
|
||||
}
|
||||
if (!StringUtils.hasText(body.getClerkId())) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkId required"));
|
||||
}
|
||||
if (body.getAmount() == null || body.getAmount().compareTo(BigDecimal.ZERO) == 0) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "amount must be non-zero"));
|
||||
}
|
||||
if (body.getReasonType() == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonType required"));
|
||||
}
|
||||
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
|
||||
}
|
||||
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "tenant missing"));
|
||||
}
|
||||
|
||||
EarningsLineAdjustmentEntity adjustment;
|
||||
try {
|
||||
adjustment = adjustmentService.createOrGetProcessing(
|
||||
tenantId,
|
||||
body.getClerkId(),
|
||||
body.getAmount(),
|
||||
body.getReasonType(),
|
||||
body.getReasonDescription(),
|
||||
idempotencyKey,
|
||||
body.getEffectiveTime());
|
||||
} catch (IllegalStateException conflict) {
|
||||
return ResponseEntity.status(409).body(TypedR.error(409, conflict.getMessage()));
|
||||
} catch (CustomException bad) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, bad.getMessage()));
|
||||
}
|
||||
|
||||
adjustmentService.triggerApplyAsync(adjustment.getId());
|
||||
|
||||
AdjustmentStatusResponse responseBody = new AdjustmentStatusResponse();
|
||||
responseBody.setAdjustmentId(adjustment.getId());
|
||||
responseBody.setIdempotencyKey(idempotencyKey);
|
||||
responseBody.setStatus(adjustment.getStatus() == null ? "PROCESSING" : adjustment.getStatus().name());
|
||||
|
||||
TypedR<AdjustmentStatusResponse> result = TypedR.ok(responseBody);
|
||||
result.setCode(202);
|
||||
result.setMessage("请求处理中");
|
||||
return ResponseEntity.accepted()
|
||||
.header("Location", "/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.body(result);
|
||||
}
|
||||
|
||||
@GetMapping("/idempotency/{key}")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:adjustment:read') and @earningsAuth.canReadAdjustmentByIdempotencyKey(#key)")
|
||||
public ResponseEntity<TypedR<AdjustmentStatusResponse>> getByIdempotencyKey(@PathVariable("key") String key) {
|
||||
if (!StringUtils.hasText(key)) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.getByIdempotencyKey(tenantId, key);
|
||||
if (adjustment == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
AdjustmentStatusResponse responseBody = new AdjustmentStatusResponse();
|
||||
responseBody.setAdjustmentId(adjustment.getId());
|
||||
responseBody.setIdempotencyKey(key);
|
||||
responseBody.setStatus(adjustment.getStatus() == null ? "PROCESSING" : adjustment.getStatus().name());
|
||||
return ResponseEntity.ok(TypedR.ok(responseBody));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Api(tags = "提现管理-后台")
|
||||
@@ -195,6 +198,36 @@ public class AdminWithdrawalController {
|
||||
return TypedR.ok(vos);
|
||||
}
|
||||
|
||||
public static class RejectWithdrawalRequest {
|
||||
private String reason;
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperation("拒绝/取消提现请求(释放已预留收益)")
|
||||
@PostMapping("/requests/{id}/reject")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:request:reject') and @earningsAuth.canRejectWithdrawal(#id)")
|
||||
public ResponseEntity<TypedR<Void>> reject(@PathVariable("id") String id, @RequestBody(required = false) RejectWithdrawalRequest body) {
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(id);
|
||||
if (req == null || !tenantId.equals(req.getTenantId())) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(TypedR.error(404, "请求不存在"));
|
||||
}
|
||||
String reason = body == null ? null : body.getReason();
|
||||
try {
|
||||
withdrawalService.reject(req.getId(), reason);
|
||||
} catch (CustomException ex) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(TypedR.error(400, ex.getMessage()));
|
||||
}
|
||||
return ResponseEntity.ok(TypedR.ok(null));
|
||||
}
|
||||
|
||||
@ApiOperation("分页查询收益明细")
|
||||
@PostMapping("/earnings/listByPage")
|
||||
public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_line_adjustment")
|
||||
public class EarningsLineAdjustmentEntity extends BaseEntity<EarningsLineAdjustmentEntity> {
|
||||
private String id;
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private BigDecimal amount;
|
||||
private EarningsAdjustmentReasonType reasonType;
|
||||
private String reasonDescription;
|
||||
private EarningsAdjustmentStatus status;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime effectiveTime;
|
||||
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime appliedTime;
|
||||
|
||||
private String failureReason;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
@@ -18,6 +19,15 @@ public class EarningsLineEntity extends BaseEntity<EarningsLineEntity> {
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private String orderId;
|
||||
/**
|
||||
* Source identity for ledger line.
|
||||
*
|
||||
* <p>ORDER: sourceId == orderId (non-null).
|
||||
* <p>ADJUSTMENT: sourceId == adjustmentId, and orderId should be null.
|
||||
*/
|
||||
private EarningsSourceType sourceType;
|
||||
|
||||
private String sourceId;
|
||||
private BigDecimal amount;
|
||||
private EarningsType earningType;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Reason type for adjustments (hard-coded for now).
|
||||
*/
|
||||
public enum EarningsAdjustmentReasonType {
|
||||
MANUAL("MANUAL");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsAdjustmentReasonType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum EarningsAdjustmentStatus {
|
||||
PROCESSING("PROCESSING"),
|
||||
APPLIED("APPLIED"),
|
||||
FAILED("FAILED");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsAdjustmentStatus(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Earnings line source type.
|
||||
*/
|
||||
public enum EarningsSourceType {
|
||||
ORDER("ORDER"),
|
||||
ADJUSTMENT("ADJUSTMENT");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
EarningsSourceType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsLineAdjustmentMapper extends BaseMapper<EarningsLineAdjustmentEntity> {}
|
||||
@@ -59,4 +59,24 @@ public interface EarningsLineMapper extends BaseMapper<EarningsLineEntity> {
|
||||
@Param("orderIds") Collection<String> orderIds,
|
||||
@Param("startTime") String startTime,
|
||||
@Param("endTime") String endTime);
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT COALESCE(SUM(amount), 0) " +
|
||||
"FROM play_earnings_line " +
|
||||
"WHERE deleted = 0 " +
|
||||
" AND tenant_id = #{tenantId} " +
|
||||
" AND clerk_id = #{clerkId} " +
|
||||
" AND source_type = 'ADJUSTMENT' " +
|
||||
" AND source_id IS NOT NULL " +
|
||||
"<if test='startTime != null'>" +
|
||||
" AND unlock_time >= #{startTime}" +
|
||||
"</if>" +
|
||||
"<if test='endTime != null'>" +
|
||||
" AND unlock_time <= #{endTime}" +
|
||||
"</if>" +
|
||||
"</script>")
|
||||
BigDecimal sumAdjustmentsByClerk(@Param("tenantId") String tenantId,
|
||||
@Param("clerkId") String clerkId,
|
||||
@Param("startTime") String startTime,
|
||||
@Param("endTime") String endTime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.starry.admin.modules.withdraw.security;
|
||||
|
||||
import com.starry.admin.common.domain.LoginUser;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service("earningsAuth")
|
||||
public class EarningsAuthorizationService {
|
||||
|
||||
private final IPlayClerkUserInfoService clerkUserInfoService;
|
||||
private final IPlayPersonnelGroupInfoService groupInfoService;
|
||||
private final IEarningsAdjustmentService earningsAdjustmentService;
|
||||
private final IWithdrawalService withdrawalService;
|
||||
|
||||
public EarningsAuthorizationService(
|
||||
IPlayClerkUserInfoService clerkUserInfoService,
|
||||
IPlayPersonnelGroupInfoService groupInfoService,
|
||||
IEarningsAdjustmentService earningsAdjustmentService,
|
||||
IWithdrawalService withdrawalService) {
|
||||
this.clerkUserInfoService = clerkUserInfoService;
|
||||
this.groupInfoService = groupInfoService;
|
||||
this.earningsAdjustmentService = earningsAdjustmentService;
|
||||
this.withdrawalService = withdrawalService;
|
||||
}
|
||||
|
||||
public boolean canManageClerk(String clerkId) {
|
||||
if (!StringUtils.hasText(clerkId)) {
|
||||
return false;
|
||||
}
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser == null || loginUser.getUser() == null) {
|
||||
return false;
|
||||
}
|
||||
if (SecurityUtils.isAdmin(loginUser.getUser())) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(clerkId);
|
||||
if (clerk == null || !tenantId.equals(clerk.getTenantId())) {
|
||||
return false;
|
||||
}
|
||||
if (!StringUtils.hasText(clerk.getGroupId())) {
|
||||
return false;
|
||||
}
|
||||
PlayPersonnelGroupInfoEntity group = groupInfoService.getById(clerk.getGroupId());
|
||||
if (group == null || !tenantId.equals(group.getTenantId())) {
|
||||
return false;
|
||||
}
|
||||
return loginUser.getUserId() != null && loginUser.getUserId().equals(group.getSysUserId());
|
||||
}
|
||||
|
||||
public boolean canReadAdjustmentByIdempotencyKey(String key) {
|
||||
if (!StringUtils.hasText(key)) {
|
||||
return false;
|
||||
}
|
||||
if (isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
EarningsLineAdjustmentEntity adjustment = earningsAdjustmentService.getByIdempotencyKey(tenantId, key);
|
||||
if (adjustment == null) {
|
||||
return true;
|
||||
}
|
||||
return canManageClerk(adjustment.getClerkId());
|
||||
}
|
||||
|
||||
public boolean canRejectWithdrawal(String requestId) {
|
||||
if (!StringUtils.hasText(requestId)) {
|
||||
return false;
|
||||
}
|
||||
if (isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(requestId);
|
||||
if (req == null) {
|
||||
return true;
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return false;
|
||||
}
|
||||
if (!tenantId.equals(req.getTenantId())) {
|
||||
return true;
|
||||
}
|
||||
return canManageClerk(req.getClerkId());
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin() {
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
return loginUser != null && loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public interface IEarningsAdjustmentService extends IService<EarningsLineAdjustmentEntity> {
|
||||
|
||||
EarningsLineAdjustmentEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
String idempotencyKey,
|
||||
LocalDateTime effectiveTime);
|
||||
|
||||
EarningsLineAdjustmentEntity getByIdempotencyKey(String tenantId, String idempotencyKey);
|
||||
|
||||
void triggerApplyAsync(String adjustmentId);
|
||||
}
|
||||
@@ -10,4 +10,6 @@ public interface IWithdrawalService extends IService<WithdrawalRequestEntity> {
|
||||
void markManualSuccess(String requestId, String operatorBy);
|
||||
|
||||
void autoPayout(String requestId);
|
||||
|
||||
void reject(String requestId, String reason);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineAdjustmentMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class EarningsAdjustmentServiceImpl extends ServiceImpl<EarningsLineAdjustmentMapper, EarningsLineAdjustmentEntity>
|
||||
implements IEarningsAdjustmentService {
|
||||
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Resource(name = "threadPoolTaskExecutor")
|
||||
private ThreadPoolTaskExecutor executor;
|
||||
|
||||
@Override
|
||||
public EarningsLineAdjustmentEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
String idempotencyKey,
|
||||
LocalDateTime effectiveTime) {
|
||||
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(clerkId)) {
|
||||
throw new CustomException("参数缺失");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) {
|
||||
throw new CustomException("amount 必须非0");
|
||||
}
|
||||
if (reasonType == null) {
|
||||
throw new CustomException("reasonType 必填");
|
||||
}
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
throw new CustomException("Idempotency-Key 必填");
|
||||
}
|
||||
String trimmedReason = reasonDescription == null ? "" : reasonDescription.trim();
|
||||
if (!StringUtils.hasText(trimmedReason)) {
|
||||
throw new CustomException("reasonDescription 必填");
|
||||
}
|
||||
|
||||
LocalDateTime resolvedEffective = effectiveTime == null ? LocalDateTime.now() : effectiveTime;
|
||||
// Idempotency hash must be stable across retries; do NOT inject server "now" into the hash.
|
||||
// If client omits effectiveTime, we treat it as "unspecified" for hashing.
|
||||
String requestHash = computeRequestHash(tenantId, clerkId, amount, reasonType, trimmedReason, effectiveTime);
|
||||
|
||||
EarningsLineAdjustmentEntity existing = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (existing != null) {
|
||||
if (existing.getRequestHash() != null && !existing.getRequestHash().equals(requestHash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
EarningsLineAdjustmentEntity created = new EarningsLineAdjustmentEntity();
|
||||
created.setId(IdUtils.getUuid());
|
||||
created.setTenantId(tenantId);
|
||||
created.setClerkId(clerkId);
|
||||
created.setAmount(amount);
|
||||
created.setReasonType(reasonType);
|
||||
created.setReasonDescription(trimmedReason);
|
||||
created.setStatus(EarningsAdjustmentStatus.PROCESSING);
|
||||
created.setIdempotencyKey(idempotencyKey);
|
||||
created.setRequestHash(requestHash);
|
||||
created.setEffectiveTime(resolvedEffective);
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
created.setCreatedTime(now);
|
||||
created.setUpdatedTime(now);
|
||||
|
||||
try {
|
||||
this.save(created);
|
||||
return created;
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsLineAdjustmentEntity raced = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (raced == null) {
|
||||
throw dup;
|
||||
}
|
||||
if (raced.getRequestHash() != null && !raced.getRequestHash().equals(requestHash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return raced;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsLineAdjustmentEntity getByIdempotencyKey(String tenantId, String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(idempotencyKey)) {
|
||||
return null;
|
||||
}
|
||||
return this.getOne(Wrappers.lambdaQuery(EarningsLineAdjustmentEntity.class)
|
||||
.eq(EarningsLineAdjustmentEntity::getTenantId, tenantId)
|
||||
.eq(EarningsLineAdjustmentEntity::getIdempotencyKey, idempotencyKey)
|
||||
.eq(EarningsLineAdjustmentEntity::getDeleted, false)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void triggerApplyAsync(String adjustmentId) {
|
||||
if (!StringUtils.hasText(adjustmentId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
applyOnce(adjustmentId);
|
||||
} catch (Exception ignored) {
|
||||
// Intentionally swallow: async apply must not crash request thread.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyOnce(String adjustmentId) {
|
||||
EarningsLineAdjustmentEntity adjustment = this.getById(adjustmentId);
|
||||
if (adjustment == null) {
|
||||
return;
|
||||
}
|
||||
if (adjustment.getStatus() == EarningsAdjustmentStatus.APPLIED) {
|
||||
return;
|
||||
}
|
||||
if (adjustment.getStatus() == EarningsAdjustmentStatus.FAILED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
EarningsLineEntity line = new EarningsLineEntity();
|
||||
line.setId(IdUtils.getUuid());
|
||||
line.setTenantId(adjustment.getTenantId());
|
||||
line.setClerkId(adjustment.getClerkId());
|
||||
line.setOrderId(null);
|
||||
line.setSourceType(EarningsSourceType.ADJUSTMENT);
|
||||
line.setSourceId(adjustment.getId());
|
||||
line.setAmount(adjustment.getAmount());
|
||||
line.setEarningType(EarningsType.ADJUSTMENT);
|
||||
line.setStatus("available");
|
||||
LocalDateTime effective = adjustment.getEffectiveTime() == null ? LocalDateTime.now() : adjustment.getEffectiveTime();
|
||||
line.setUnlockTime(effective);
|
||||
Date now = new Date();
|
||||
line.setCreatedTime(now);
|
||||
line.setUpdatedTime(now);
|
||||
line.setDeleted(false);
|
||||
|
||||
earningsService.save(line);
|
||||
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.APPLIED);
|
||||
update.setAppliedTime(LocalDateTime.now());
|
||||
this.updateById(update);
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.APPLIED);
|
||||
update.setAppliedTime(LocalDateTime.now());
|
||||
this.updateById(update);
|
||||
} catch (Exception ex) {
|
||||
EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity();
|
||||
update.setId(adjustment.getId());
|
||||
update.setStatus(EarningsAdjustmentStatus.FAILED);
|
||||
update.setFailureReason(ex.getMessage());
|
||||
this.updateById(update);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private String computeRequestHash(
|
||||
String tenantId,
|
||||
String clerkId,
|
||||
BigDecimal amount,
|
||||
EarningsAdjustmentReasonType reasonType,
|
||||
String reasonDescription,
|
||||
LocalDateTime effectiveTime) {
|
||||
String normalizedAmount = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
String rawEffective = effectiveTime == null ? "" : effectiveTime.toString();
|
||||
String raw = tenantId + "|" + clerkId + "|" + normalizedAmount + "|" + reasonType.name() + "|" + reasonDescription + "|" + rawEffective;
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
return toHex(bytes);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("hash failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
@@ -48,6 +49,8 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
line.setTenantId(orderInfo.getTenantId());
|
||||
line.setClerkId(orderInfo.getAcceptBy());
|
||||
line.setOrderId(orderInfo.getId());
|
||||
line.setSourceType(EarningsSourceType.ORDER);
|
||||
line.setSourceId(orderInfo.getId());
|
||||
line.setAmount(amount);
|
||||
line.setEarningType(EarningsType.ORDER);
|
||||
line.setUnlockTime(unlockTime);
|
||||
@@ -140,6 +143,8 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
line.setOrderId(orderId);
|
||||
line.setTenantId(tenantId);
|
||||
line.setClerkId(targetClerkId);
|
||||
line.setSourceType(EarningsSourceType.ORDER);
|
||||
line.setSourceId(orderId);
|
||||
line.setAmount(normalized.negate());
|
||||
line.setEarningType(EarningsType.ADJUSTMENT);
|
||||
line.setStatus(resolvedStatus);
|
||||
|
||||
@@ -160,6 +160,43 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
throw new UnsupportedOperationException("Alipay payout not implemented");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reject(String requestId, String reason) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if ("success".equals(req.getStatus())) {
|
||||
throw new CustomException("已成功的提现不可拒绝");
|
||||
}
|
||||
if ("canceled".equals(req.getStatus()) || "rejected".equals(req.getStatus())) {
|
||||
return;
|
||||
}
|
||||
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("canceled");
|
||||
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")
|
||||
.list();
|
||||
for (EarningsLineEntity line : lines) {
|
||||
LocalDateTime unlock = line.getUnlockTime();
|
||||
String restored = unlock != null && unlock.isAfter(now) ? "frozen" : "available";
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.set(EarningsLineEntity::getWithdrawalId, null)
|
||||
.set(EarningsLineEntity::getStatus, restored));
|
||||
}
|
||||
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_REJECTED", req.getStatus(), update.getStatus(),
|
||||
"拒绝提现,原因=" + (reason == null ? "" : reason), null);
|
||||
}
|
||||
|
||||
private String buildPayeeSnapshot(ClerkPayeeProfileEntity profile, LocalDateTime confirmedAt) {
|
||||
PayeeSnapshotVo snapshot = new PayeeSnapshotVo();
|
||||
snapshot.setChannel(profile.getChannel());
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Earnings adjustments: introduce generic adjustment header table + source identity for earnings lines.
|
||||
|
||||
-- 1) Extend ledger to support non-order sources (e.g. admin adjustments / rewards / punishments).
|
||||
-- We keep order_id for ORDER source for backward compatibility, but allow it to be NULL for adjustment lines.
|
||||
|
||||
-- Drop legacy uniqueness keyed by order_id, since order_id can now be NULL and we want uniqueness by source identity.
|
||||
ALTER TABLE `play_earnings_line`
|
||||
DROP INDEX `uk_tenant_order_clerk_type`;
|
||||
|
||||
ALTER TABLE `play_earnings_line`
|
||||
MODIFY COLUMN `order_id` varchar(32) NULL COMMENT '订单ID(source_type=ORDER 时必填)',
|
||||
ADD COLUMN `source_type` varchar(16) NOT NULL DEFAULT 'ORDER' COMMENT '来源类型(ORDER/ADJUSTMENT)' AFTER `order_id`,
|
||||
ADD COLUMN `source_id` varchar(32) DEFAULT NULL COMMENT '来源ID(source_type=ORDER 时等于 order_id;source_type=ADJUSTMENT 时等于 adjustment_id)' AFTER `source_type`;
|
||||
|
||||
-- Backfill existing rows to ORDER source.
|
||||
UPDATE `play_earnings_line`
|
||||
SET `source_id` = `order_id`
|
||||
WHERE (`source_id` IS NULL)
|
||||
AND `order_id` IS NOT NULL
|
||||
AND `deleted` = 0;
|
||||
|
||||
-- New uniqueness: one line per source identity per clerk/type (ignoring logical delete).
|
||||
ALTER TABLE `play_earnings_line`
|
||||
ADD UNIQUE KEY `uk_tenant_source_clerk_type` (`tenant_id`, `source_type`, `source_id`, `clerk_id`, `earning_type`, `deleted`);
|
||||
|
||||
-- 2) Adjustment header table (idempotent, async lifecycle).
|
||||
CREATE TABLE IF NOT EXISTS `play_earnings_line_adjustment` (
|
||||
`id` varchar(32) NOT NULL COMMENT 'UUID',
|
||||
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
|
||||
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
|
||||
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(可正可负,不允许0)',
|
||||
`reason_type` varchar(32) NOT NULL COMMENT '原因类型(Enum, hard-coded for now)',
|
||||
`reason_description` varchar(512) NOT NULL COMMENT '原因描述(操作时输入)',
|
||||
`status` varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/APPLIED/FAILED',
|
||||
`idempotency_key` varchar(64) DEFAULT NULL COMMENT '幂等Key(tenant范围内唯一;为空则不幂等)',
|
||||
`request_hash` varchar(64) NOT NULL DEFAULT '' COMMENT '请求摘要(用于检测同key不同body)',
|
||||
`effective_time` datetime NOT NULL COMMENT '生效时间(用于统计窗口;默认 now)',
|
||||
`applied_time` datetime DEFAULT NULL COMMENT '落账完成时间',
|
||||
`failure_reason` varchar(512) DEFAULT NULL COMMENT '失败原因(FAILED 时)',
|
||||
`created_by` varchar(32) DEFAULT NULL,
|
||||
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_by` varchar(32) DEFAULT NULL,
|
||||
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除',
|
||||
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_tenant_idempotency` (`tenant_id`, `idempotency_key`, `deleted`) USING BTREE,
|
||||
KEY `idx_adjustment_tenant_clerk_time` (`tenant_id`, `clerk_id`, `effective_time`) USING BTREE,
|
||||
KEY `idx_adjustment_tenant_status` (`tenant_id`, `status`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益调整(Reward/Punishment/Correction统一抽象)';
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
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.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* Authorization contract tests for admin earnings adjustments.
|
||||
*
|
||||
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
|
||||
*/
|
||||
class AdminEarningsAdjustmentAuthorizationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
private static final String PERMISSION_ADJUSTMENT_CREATE = "withdraw:adjustment:create";
|
||||
private static final String PERMISSION_ADJUSTMENT_READ = "withdraw:adjustment:read";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService groupInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
private final List<String> idempotencyKeysToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> groupIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
idempotencyKeysToCleanup.clear();
|
||||
clerkIdsToCleanup.clear();
|
||||
groupIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupAdjustmentsByIdempotencyKeys();
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
if (!groupIdsToCleanup.isEmpty()) {
|
||||
groupInfoService.removeByIds(groupIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithoutPermissionReturns403() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "20.00")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createLeaderWithPermissionCannotManageOtherGroupClerkReturns403() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(otherClerkId, "20.00")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSuperAdminBypassesPermissionAndScopeReturns202() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid())
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(SUPER_ADMIN_HEADER, "true")
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(otherClerkId, "20.00")))
|
||||
.andExpect(status().isAccepted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollWithoutReadPermissionReturns403() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "-10.00")))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossTenantPollReturns404EvenWithPermission() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
idempotencyKeysToCleanup.add(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "1.00")))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String createPayload(String clerkId, String amount) {
|
||||
return "{" +
|
||||
"\"clerkId\":\"" + clerkId + "\"," +
|
||||
"\"amount\":\"" + amount + "\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"auth-test\"" +
|
||||
"}";
|
||||
}
|
||||
|
||||
private String seedOtherGroupClerk() {
|
||||
String groupId = "group-auth-" + IdUtils.getUuid();
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId("leader-auth-" + IdUtils.getUuid());
|
||||
group.setSysUserCode("leader-auth");
|
||||
group.setGroupName("Auth Test Group");
|
||||
group.setLeaderName("Leader Auth");
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-auth-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-auth-" + clerkId);
|
||||
clerk.setNickname("Auth Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-auth-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
|
||||
return clerkId;
|
||||
}
|
||||
|
||||
private void cleanupAdjustmentsByIdempotencyKeys() {
|
||||
for (String key : idempotencyKeysToCleanup) {
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.getByIdempotencyKey(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID, key);
|
||||
if (adjustment != null) {
|
||||
cleanupAdjustment(adjustment.getId());
|
||||
}
|
||||
}
|
||||
idempotencyKeysToCleanup.clear();
|
||||
}
|
||||
|
||||
private void cleanupAdjustment(String adjustmentId) {
|
||||
if (adjustmentId == null) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.eq(EarningsLineEntity::getSourceId, adjustmentId)
|
||||
.remove();
|
||||
adjustmentService.removeById(adjustmentId);
|
||||
}
|
||||
|
||||
private void awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
if ("APPLIED".equals(root.path("data").path("status").asText())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD contract tests for earnings adjustments (reward/punishment/unified adjustment model).
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the adjustment system is implemented end-to-end.</p>
|
||||
*/
|
||||
class AdminEarningsAdjustmentControllerApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> createdAdjustmentIds = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
deleteAdjustmentsAndLines(createdAdjustmentIds);
|
||||
createdAdjustmentIds.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAdjustmentReturns202AndProvidesPollingHandle() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"20.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"manual reward for testing\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(header().string("Location", "/admin/earnings/adjustments/idempotency/" + key))
|
||||
.andExpect(jsonPath("$.code").value(202))
|
||||
.andExpect(jsonPath("$.data").exists())
|
||||
.andExpect(jsonPath("$.data.adjustmentId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.status").value("PROCESSING"))
|
||||
.andReturn();
|
||||
|
||||
createdAdjustmentIds.add(extractAdjustmentId(result));
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollByIdempotencyKeyReturnsProcessingThenApplied() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"-10.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"manual punishment for testing\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
|
||||
// Immediately polling should return a stable representation (at least PROCESSING).
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.adjustmentId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.status").value("PROCESSING"));
|
||||
|
||||
// After implementation, the system should eventually transition to APPLIED.
|
||||
// Poll with a bounded wait to keep the test deterministic.
|
||||
boolean applied = false;
|
||||
for (int i = 0; i < 40; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
applied = true;
|
||||
break;
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
assertThat(applied).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeySameBodyIsIdempotent() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"30.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"idempotent create\"" +
|
||||
"}";
|
||||
|
||||
MvcResult first = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
MvcResult second = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
|
||||
JsonNode firstRoot = objectMapper.readTree(first.getResponse().getContentAsString());
|
||||
JsonNode secondRoot = objectMapper.readTree(second.getResponse().getContentAsString());
|
||||
String firstId = firstRoot.path("data").path("adjustmentId").asText();
|
||||
String secondId = secondRoot.path("data").path("adjustmentId").asText();
|
||||
assertThat(firstId).isNotBlank();
|
||||
assertThat(secondId).isEqualTo(firstId);
|
||||
createdAdjustmentIds.add(firstId);
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyDifferentBodyReturns409() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payloadA = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"30.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"first payload\"" +
|
||||
"}";
|
||||
String payloadB = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"31.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"different payload\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadA))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadB))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingIdempotencyKeyReturns400() throws Exception {
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"20.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"missing idempotency\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsZeroAmount() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"0.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"zero amount\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingReasonType() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"reasonDescription\":\"missing reason type\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankReasonDescription() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\" \"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentCreatesWithSameKeyReturnSameAdjustmentIdAndDoNotDuplicate() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"9.99\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"concurrent\"" +
|
||||
"}";
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<String> call = () -> {
|
||||
MvcResult result = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
};
|
||||
List<Future<String>> futures = new ArrayList<>();
|
||||
futures.add(pool.submit(call));
|
||||
futures.add(pool.submit(call));
|
||||
|
||||
String a = futures.get(0).get();
|
||||
String b = futures.get(1).get();
|
||||
assertThat(a).isNotBlank();
|
||||
assertThat(b).isEqualTo(a);
|
||||
createdAdjustmentIds.add(a);
|
||||
awaitApplied(key);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollMissingIdempotencyKeyReturns404() throws Exception {
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + UUID.randomUUID())
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyIsTenantScoped() throws Exception {
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"1.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"tenant scope\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
createdAdjustmentIds.add(extractAdjustmentId(create));
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String extractAdjustmentId(MvcResult result) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private void deleteAdjustmentsAndLines(Collection<String> adjustmentIds) {
|
||||
if (adjustmentIds == null || adjustmentIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD tests: withdrawal audit must tolerate adjustment lines (orderId=null) and still return a stable payload.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until earnings lines support orderId=null + sourceType/sourceId,
|
||||
* and audit serialization handles mixed sources.</p>
|
||||
*/
|
||||
class AdminWithdrawalAuditWithAdjustmentsApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String withdrawalId;
|
||||
private String orderId;
|
||||
private String orderLineId;
|
||||
private String adjustmentLineId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
List<String> toDelete = new ArrayList<>();
|
||||
if (orderLineId != null) {
|
||||
toDelete.add(orderLineId);
|
||||
}
|
||||
if (adjustmentLineId != null) {
|
||||
toDelete.add(adjustmentLineId);
|
||||
}
|
||||
if (!toDelete.isEmpty()) {
|
||||
earningsService.removeByIds(toDelete);
|
||||
}
|
||||
if (withdrawalId != null) {
|
||||
withdrawalService.removeById(withdrawalId);
|
||||
}
|
||||
if (orderId != null) {
|
||||
orderInfoService.removeById(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditReturnsOrderDetailsForOrderLinesAndLeavesOrderFieldsEmptyForAdjustments() throws Exception {
|
||||
seedOrderWithdrawalAndLines();
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/admin/withdraw/requests/" + withdrawalId + "/audit")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andReturn();
|
||||
|
||||
JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data");
|
||||
JsonNode first = data.get(0);
|
||||
JsonNode second = data.get(1);
|
||||
|
||||
// First entry is ORDER: should have orderNo present.
|
||||
boolean firstIsOrder = "ORDER".equals(first.path("earningType").asText());
|
||||
JsonNode orderNode = firstIsOrder ? first : second;
|
||||
JsonNode adjustmentNode = firstIsOrder ? second : first;
|
||||
|
||||
assertThat(orderNode.path("earningType").asText()).isEqualTo("ORDER");
|
||||
assertThat(orderNode.path("orderNo").asText()).isNotBlank();
|
||||
|
||||
assertThat(adjustmentNode.path("earningType").asText()).isEqualTo("ADJUSTMENT");
|
||||
assertThat(adjustmentNode.path("orderId").isMissingNode() || adjustmentNode.path("orderId").isNull()).isTrue();
|
||||
assertThat(adjustmentNode.path("orderNo").isMissingNode() || adjustmentNode.path("orderNo").isNull()).isTrue();
|
||||
}
|
||||
|
||||
private void seedOrderWithdrawalAndLines() {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
|
||||
// order
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
orderId = "order-audit-adj-" + IdUtils.getUuid();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
order.setOrderNo("audit-adj-" + IdUtils.getUuid());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderEndTime(now.minusHours(1));
|
||||
order.setFinalAmount(new BigDecimal("120.00"));
|
||||
order.setEstimatedRevenue(new BigDecimal("60.00"));
|
||||
order.setDeleted(false);
|
||||
Date dt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(dt);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(dt);
|
||||
orderInfoService.save(order);
|
||||
|
||||
// withdrawal request
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-audit-adj-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("88.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:audit-adj@test.com");
|
||||
req.setStatus("processing");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"审计专用\"}");
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(dt);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(dt);
|
||||
withdrawalService.save(req);
|
||||
|
||||
// order line
|
||||
orderLineId = seedEarningLine(withdrawalId, orderId, new BigDecimal("60.00"), "withdrawn", EarningsType.ORDER, now.minusMinutes(30));
|
||||
|
||||
// adjustment line (intended future: orderId=null + sourceType/sourceId)
|
||||
adjustmentLineId = seedEarningLine(withdrawalId, null, new BigDecimal("-10.00"), "withdrawing", EarningsType.ADJUSTMENT, now.minusMinutes(10));
|
||||
}
|
||||
|
||||
private String seedEarningLine(String withdrawalId, String orderIdOrNull, BigDecimal amount, String status, EarningsType type, LocalDateTime createdAt) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-audit-adj-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId(orderIdOrNull);
|
||||
entity.setAmount(amount);
|
||||
entity.setStatus(status);
|
||||
entity.setEarningType(type);
|
||||
entity.setUnlockTime(createdAt.minusHours(1));
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
Date createdDate = Date.from(createdAt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(createdDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(createdDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
/**
|
||||
* TDD contract tests for rejecting/canceling a withdrawal request (release reserved earnings lines).
|
||||
*
|
||||
* <p>These tests are expected to FAIL until withdrawal reject is implemented.</p>
|
||||
*/
|
||||
class AdminWithdrawalRejectApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
private String withdrawalId;
|
||||
private String lineA;
|
||||
private String lineB;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
withdrawalId = null;
|
||||
lineA = null;
|
||||
lineB = null;
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (withdrawalId != null) {
|
||||
withdrawalService.removeById(withdrawalId);
|
||||
}
|
||||
List<String> toDelete = new ArrayList<>();
|
||||
if (lineA != null) {
|
||||
toDelete.add(lineA);
|
||||
}
|
||||
if (lineB != null) {
|
||||
toDelete.add(lineB);
|
||||
}
|
||||
if (!toDelete.isEmpty()) {
|
||||
earningsService.removeByIds(toDelete);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectPendingWithdrawalReleasesWithdrawingLines() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
|
||||
String payload = "{\"reason\":\"bank account mismatch\"}";
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
EarningsLineEntity afterA = earningsService.getById(lineA);
|
||||
EarningsLineEntity afterB = earningsService.getById(lineB);
|
||||
assertThat(afterA.getWithdrawalId()).isNull();
|
||||
assertThat(afterB.getWithdrawalId()).isNull();
|
||||
assertThat(afterA.getStatus()).isIn("available", "frozen");
|
||||
assertThat(afterB.getStatus()).isIn("available", "frozen");
|
||||
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(withdrawalId);
|
||||
assertThat(req.getStatus()).isIn("canceled", "rejected");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectIsIdempotent() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
|
||||
String payload = "{\"reason\":\"duplicate\"}";
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectRestoresFrozenWhenUnlockTimeInFuture() throws Exception {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-reject-frozen-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("10.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
|
||||
lineA = seedLineWithUnlock("reject-future", new BigDecimal("10.00"), LocalDateTime.now().plusDays(1));
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"future unlock\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
EarningsLineEntity after = earningsService.getById(lineA);
|
||||
assertThat(after.getStatus()).isEqualTo("frozen");
|
||||
assertThat(after.getWithdrawalId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectSuccessWithdrawalIsRejected() throws Exception {
|
||||
seedWithdrawingWithdrawalAndLines();
|
||||
WithdrawalRequestEntity req = withdrawalService.getById(withdrawalId);
|
||||
req.setStatus("success");
|
||||
withdrawalService.updateById(req);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"cannot reject success\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
private void seedWithdrawingWithdrawalAndLines() {
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
withdrawalId = "withdraw-reject-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("80.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
|
||||
lineA = seedLine("reject-a", new BigDecimal("50.00"));
|
||||
lineB = seedLine("reject-b", new BigDecimal("30.00"));
|
||||
}
|
||||
|
||||
private String seedLine(String suffix, BigDecimal amount) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId("order-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusDays(1));
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
|
||||
private String seedLineWithUnlock(String suffix, BigDecimal amount, LocalDateTime unlockAt) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId("order-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(unlockAt);
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
/**
|
||||
* Authorization contract tests for rejecting withdrawals.
|
||||
*
|
||||
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
|
||||
*/
|
||||
class AdminWithdrawalRejectAuthorizationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService groupInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
private final List<String> withdrawalIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> earningLineIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> groupIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
withdrawalIdsToCleanup.clear();
|
||||
earningLineIdsToCleanup.clear();
|
||||
clerkIdsToCleanup.clear();
|
||||
groupIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!withdrawalIdsToCleanup.isEmpty()) {
|
||||
withdrawalService.removeByIds(withdrawalIdsToCleanup);
|
||||
}
|
||||
if (!earningLineIdsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningLineIdsToCleanup);
|
||||
}
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
if (!groupIdsToCleanup.isEmpty()) {
|
||||
groupInfoService.removeByIds(groupIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectWithoutPermissionReturns403() throws Exception {
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectLeaderWithPermissionCannotManageOtherGroupReturns403() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectSuperAdminBypassesPermissionAndScopeReturns200() throws Exception {
|
||||
String otherClerkId = seedOtherGroupClerk();
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid())
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(SUPER_ADMIN_HEADER, "true")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossTenantRejectReturns404EvenWithPermission() throws Exception {
|
||||
String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
|
||||
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"auth-test\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private String seedWithdrawingWithdrawalAndLines(String clerkId) {
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
String withdrawalId = "withdraw-auth-" + IdUtils.getUuid();
|
||||
req.setId(withdrawalId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(clerkId);
|
||||
req.setAmount(new BigDecimal("80.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(req.getAmount());
|
||||
req.setDestAccount("alipay:test");
|
||||
req.setStatus("pending");
|
||||
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
|
||||
Date now = new Date();
|
||||
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setCreatedTime(now);
|
||||
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
req.setUpdatedTime(now);
|
||||
withdrawalService.save(req);
|
||||
withdrawalIdsToCleanup.add(withdrawalId);
|
||||
|
||||
earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "a", new BigDecimal("50.00")));
|
||||
earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "b", new BigDecimal("30.00")));
|
||||
|
||||
return withdrawalId;
|
||||
}
|
||||
|
||||
private String seedLine(String withdrawalId, String clerkId, String suffix, BigDecimal amount) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = "earn-auth-reject-" + suffix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId("order-auth-reject-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
|
||||
entity.setStatus("withdrawing");
|
||||
entity.setWithdrawalId(withdrawalId);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusDays(1));
|
||||
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(now);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(now);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
return id;
|
||||
}
|
||||
|
||||
private String seedOtherGroupClerk() {
|
||||
String groupId = "group-auth-withdraw-" + IdUtils.getUuid();
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId("leader-auth-" + IdUtils.getUuid());
|
||||
group.setSysUserCode("leader-auth");
|
||||
group.setGroupName("Auth Test Group");
|
||||
group.setLeaderName("Leader Auth");
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-auth-withdraw-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-auth-" + clerkId);
|
||||
clerk.setNickname("Auth Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("138" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-auth-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
|
||||
return clerkId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.common.domain.LoginUser;
|
||||
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.system.module.entity.SysUserEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* TDD contract tests for statistics toggle: include/exclude earnings adjustments.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the stats endpoint supports includeAdjustments.</p>
|
||||
*/
|
||||
class StatisticsPerformanceOverviewIncludeAdjustmentsApiTest extends AbstractApiTest {
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final LocalDateTime BASE_TIME = LocalDateTime.of(2011, 1, 1, 12, 0, 0);
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private EarningsLineMapper earningsLineMapper;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String orderId;
|
||||
private final List<String> earningLineIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
orderId = null;
|
||||
earningLineIdsToCleanup.clear();
|
||||
setAuthentication();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (!earningLineIdsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningLineIdsToCleanup);
|
||||
}
|
||||
if (orderId != null) {
|
||||
orderInfoService.removeById(orderId);
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewExcludesAdjustmentsByDefaultAndIncludesWhenToggledOn() throws Exception {
|
||||
seedOneOrderAndLines();
|
||||
|
||||
LocalDateTime start = BASE_TIME.minusMinutes(5);
|
||||
LocalDateTime end = BASE_TIME.plusMinutes(5);
|
||||
|
||||
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
ApiTestDataSeeder.DEFAULT_CLERK_ID,
|
||||
start.format(DATE_TIME_FORMATTER),
|
||||
end.format(DATE_TIME_FORMATTER));
|
||||
assertThat(adjustmentSum.setScale(2, RoundingMode.HALF_UP)).isEqualByComparingTo(new BigDecimal("-20.00"));
|
||||
|
||||
String payloadDefault = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult defaultResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadDefault))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode defaultJson = objectMapper.readTree(defaultResult.getResponse().getContentAsString());
|
||||
BigDecimal defaultRevenue = new BigDecimal(defaultJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(defaultRevenue).isEqualByComparingTo(new BigDecimal("100.00"));
|
||||
|
||||
String payloadInclude = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"includeAdjustments\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult includeResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadInclude))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode includeJson = objectMapper.readTree(includeResult.getResponse().getContentAsString());
|
||||
BigDecimal includeRevenue = new BigDecimal(includeJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(includeRevenue).isEqualByComparingTo(new BigDecimal("80.00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void includeAdjustmentsRespectsTimeWindow() throws Exception {
|
||||
seedOneOrderAndLines();
|
||||
|
||||
// Seed another adjustment intended to be outside the window.
|
||||
// Intended future behavior: adjustment is filtered by its effectiveTime/createdTime within the same window.
|
||||
seedOutOfWindowAdjustmentLine();
|
||||
|
||||
LocalDateTime start = BASE_TIME.minusMinutes(5);
|
||||
LocalDateTime end = BASE_TIME.plusMinutes(5);
|
||||
|
||||
BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
ApiTestDataSeeder.DEFAULT_CLERK_ID,
|
||||
start.format(DATE_TIME_FORMATTER),
|
||||
end.format(DATE_TIME_FORMATTER));
|
||||
assertThat(adjustmentSum.setScale(2, RoundingMode.HALF_UP)).isEqualByComparingTo(new BigDecimal("-20.00"));
|
||||
|
||||
String payloadInclude = "{" +
|
||||
"\"includeSummary\":true," +
|
||||
"\"includeRankings\":true," +
|
||||
"\"includeAdjustments\":true," +
|
||||
"\"limit\":5," +
|
||||
"\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" +
|
||||
"}";
|
||||
|
||||
MvcResult includeResult = mockMvc.perform(post("/statistics/performance/overview")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadInclude))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode includeJson = objectMapper.readTree(includeResult.getResponse().getContentAsString());
|
||||
BigDecimal includeRevenue = new BigDecimal(includeJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0"))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
// Base is 100.00 order revenue, -20.00 in-window adjustment; the out-of-window adjustment should NOT be counted.
|
||||
assertThat(includeRevenue).isEqualByComparingTo(new BigDecimal("80.00"));
|
||||
}
|
||||
|
||||
private void seedOneOrderAndLines() {
|
||||
LocalDateTime now = BASE_TIME.withNano(0);
|
||||
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
orderId = "order-stats-" + IdUtils.getUuid();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
order.setOrderNo("stats-" + IdUtils.getUuid());
|
||||
order.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode());
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setPurchaserTime(now.minusMinutes(1));
|
||||
order.setPlaceType(OrderConstant.PlaceType.REWARD.getCode());
|
||||
order.setRefundType(OrderConstant.OrderRefundFlag.NOT_REFUNDED.getCode());
|
||||
order.setOrdersExpiredState(OrderConstant.OrdersExpiredState.NOT_EXPIRED.getCode());
|
||||
order.setOrderRelationType(OrderConstant.OrderRelationType.FIRST);
|
||||
order.setFinalAmount(new BigDecimal("200.00"));
|
||||
order.setEstimatedRevenue(new BigDecimal("100.00"));
|
||||
order.setOrderEndTime(now);
|
||||
order.setDeleted(false);
|
||||
Date created = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(created);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(created);
|
||||
orderInfoService.save(order);
|
||||
|
||||
seedEarningsLine("earn-order", orderId, new BigDecimal("100.00"), EarningsType.ORDER, now.minusMinutes(1));
|
||||
|
||||
// Intended future behavior: adjustment lines are not ORDER-sourced and should only affect stats when includeAdjustments=true.
|
||||
// This will FAIL until play_earnings_line supports orderId=null + sourceType/sourceId, and stats toggle is implemented.
|
||||
seedEarningsLine("earn-adjustment", null, new BigDecimal("-20.00"), EarningsType.ADJUSTMENT, now.minusMinutes(1));
|
||||
}
|
||||
|
||||
private String seedEarningsLine(
|
||||
String prefix, String orderIdOrNull, BigDecimal amount, EarningsType type, LocalDateTime unlockTime) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String rawId = prefix + "-" + IdUtils.getUuid();
|
||||
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
entity.setOrderId(orderIdOrNull);
|
||||
if (orderIdOrNull == null) {
|
||||
entity.setSourceType(EarningsSourceType.ADJUSTMENT);
|
||||
entity.setSourceId("adj-stats-" + IdUtils.getUuid());
|
||||
} else {
|
||||
entity.setSourceType(EarningsSourceType.ORDER);
|
||||
entity.setSourceId(orderIdOrNull);
|
||||
}
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(type);
|
||||
entity.setStatus("available");
|
||||
entity.setUnlockTime(unlockTime);
|
||||
Date created = Date.from(BASE_TIME.atZone(ZoneId.systemDefault()).toInstant());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(created);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(created);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningLineIdsToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void seedOutOfWindowAdjustmentLine() {
|
||||
seedEarningsLine(
|
||||
"earn-adjustment-outside",
|
||||
null,
|
||||
new BigDecimal("999.00"),
|
||||
EarningsType.ADJUSTMENT,
|
||||
BASE_TIME.minusDays(30));
|
||||
}
|
||||
|
||||
private void setAuthentication() {
|
||||
SysUserEntity user = new SysUserEntity();
|
||||
user.setUserId(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
user.setUserCode(ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME);
|
||||
user.setPassWord("apitest");
|
||||
user.setStatus(0);
|
||||
user.setSuperAdmin(true);
|
||||
user.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUserId(user.getUserId());
|
||||
loginUser.setUserName(user.getUserCode());
|
||||
loginUser.setUser(user);
|
||||
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, java.util.Collections.emptyList()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
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.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.Constants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* End-to-end Web/API tests that prove adjustments affect clerk withdraw balance and eligibility.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until adjustments are implemented.</p>
|
||||
*/
|
||||
class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read";
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IWithdrawalService withdrawalService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@MockBean
|
||||
private IClerkPayeeProfileService clerkPayeeProfileService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private WxTokenService wxTokenService;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private String clerkToken;
|
||||
private ClerkPayeeProfileEntity payeeProfile;
|
||||
private final List<String> createdAdjustmentIds = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ensureTenantContext();
|
||||
|
||||
payeeProfile = new ClerkPayeeProfileEntity();
|
||||
payeeProfile.setId("payee-" + IdUtils.getUuid());
|
||||
payeeProfile.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
payeeProfile.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
payeeProfile.setChannel("ALIPAY_QR");
|
||||
payeeProfile.setQrCodeUrl("https://example.com/test-payee.png");
|
||||
payeeProfile.setDisplayName("API测试收款码");
|
||||
payeeProfile.setLastConfirmedAt(LocalDateTime.now());
|
||||
|
||||
Mockito.when(clerkPayeeProfileService.getByClerk(
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID, ApiTestDataSeeder.DEFAULT_CLERK_ID))
|
||||
.thenAnswer(invocation -> payeeProfile);
|
||||
Mockito.when(clerkPayeeProfileService.updateById(Mockito.any(ClerkPayeeProfileEntity.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
payeeProfile = invocation.getArgument(0);
|
||||
return true;
|
||||
});
|
||||
|
||||
clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ensureTenantContext();
|
||||
Mockito.reset(clerkPayeeProfileService);
|
||||
cleanupAdjustments();
|
||||
CustomSecurityContextHolder.remove();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedPositiveAdjustmentIncreasesWithdrawableBalance() throws Exception {
|
||||
ensureTenantContext();
|
||||
BigDecimal before = fetchAvailableBalance();
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"bonus\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
BigDecimal after = fetchAvailableBalance();
|
||||
BigDecimal delta = after.subtract(before).setScale(2, RoundingMode.HALF_UP);
|
||||
assertThat(delta).isEqualByComparingTo("50.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedNegativeAdjustmentCanBlockWithdrawal() throws Exception {
|
||||
ensureTenantContext();
|
||||
String key = UUID.randomUUID().toString();
|
||||
BigDecimal before = fetchAvailableBalance();
|
||||
BigDecimal amount = before.add(new BigDecimal("20.00")).negate().setScale(2, RoundingMode.HALF_UP);
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"" + amount + "\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"penalty\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
// Attempting to withdraw any positive amount should fail when net balance is negative.
|
||||
mockMvc.perform(post("/wx/withdraw/requests")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":10}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void earningsListIncludesAdjustmentLineAfterApplied() throws Exception {
|
||||
ensureTenantContext();
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||
"\"amount\":\"12.34\"," +
|
||||
"\"reasonType\":\"MANUAL\"," +
|
||||
"\"reasonDescription\":\"show in list\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
createdAdjustmentIds.add(awaitApplied(key));
|
||||
|
||||
MvcResult earnings = mockMvc.perform(get("/wx/withdraw/earnings")
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "10")
|
||||
.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").isArray())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(earnings.getResponse().getContentAsString());
|
||||
JsonNode rows = root.path("data");
|
||||
boolean found = false;
|
||||
if (rows.isArray()) {
|
||||
for (JsonNode row : rows) {
|
||||
if ("ADJUSTMENT".equals(row.path("earningType").asText())
|
||||
&& "12.34".equals(row.path("amount").asText())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(found).isTrue();
|
||||
}
|
||||
|
||||
private BigDecimal fetchAvailableBalance() throws Exception {
|
||||
MvcResult balance = 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(balance.getResponse().getContentAsString());
|
||||
return root.path("data").path("available").decimalValue().setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 80; i++) {
|
||||
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("adjustmentId").asText();
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private void ensureTenantContext() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
}
|
||||
|
||||
private void cleanupAdjustments() {
|
||||
if (createdAdjustmentIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, createdAdjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(createdAdjustmentIds);
|
||||
createdAdjustmentIds.clear();
|
||||
|
||||
withdrawalService.lambdaUpdate()
|
||||
.eq(WithdrawalRequestEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(WithdrawalRequestEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.remove();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -295,6 +299,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
.andExpect(jsonPath("$.data[1]").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentWithdrawRequestsCompeteForSameEarningsLines() throws Exception {
|
||||
ensureTenantContext();
|
||||
String firstLine = insertEarningsLine(
|
||||
"concurrent-one",
|
||||
new BigDecimal("50.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
LocalDateTime.now().minusDays(1));
|
||||
String secondLine = insertEarningsLine(
|
||||
"concurrent-two",
|
||||
new BigDecimal("30.00"),
|
||||
EarningsStatus.AVAILABLE,
|
||||
LocalDateTime.now().minusHours(2));
|
||||
earningsToCleanup.add(firstLine);
|
||||
earningsToCleanup.add(secondLine);
|
||||
|
||||
refreshPayeeConfirmation();
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<Integer> create = () -> {
|
||||
MvcResult result = mockMvc.perform(post("/wx/withdraw/requests")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":80}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("code").asInt();
|
||||
};
|
||||
|
||||
Future<Integer> a = pool.submit(create);
|
||||
Future<Integer> b = pool.submit(create);
|
||||
|
||||
int codeA = a.get();
|
||||
int codeB = b.get();
|
||||
assertThat(codeA == 200 || codeA == 500).isTrue();
|
||||
assertThat(codeB == 200 || codeB == 500).isTrue();
|
||||
assertThat(codeA + codeB).isEqualTo(700);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private String insertEarningsLine(
|
||||
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
|
||||
return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.starry.admin.db;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.starry.admin.api.AbstractApiTest;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* Database-level contract tests for the adjustment system schema.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until migrations add the new table/columns/indexes.</p>
|
||||
*/
|
||||
class EarningsAdjustmentsDatabaseSchemaApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
void earningsLineHasSourceColumnsAndOrderIdIsNullable() {
|
||||
assertThat(columnExists("play_earnings_line", "source_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line", "source_id")).isTrue();
|
||||
|
||||
Map<String, Object> orderIdMeta = jdbcTemplate.queryForMap(
|
||||
"select is_nullable as nullable " +
|
||||
"from information_schema.columns " +
|
||||
"where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
|
||||
"play_earnings_line",
|
||||
"order_id");
|
||||
String nullable = String.valueOf(orderIdMeta.get("nullable"));
|
||||
assertThat(nullable).isIn("YES", "yes", "Y", "y", "1", "TRUE", "true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentTableExistsAndHasIdempotencyFields() {
|
||||
assertThat(tableExists("play_earnings_line_adjustment")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "idempotency_key")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "request_hash")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "status")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "reason_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "reason_description")).isTrue();
|
||||
assertThat(columnExists("play_earnings_line_adjustment", "effective_time")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueIndexExistsForTenantIdempotencyKey() {
|
||||
// Lock the index name so future migrations are deterministic.
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) " +
|
||||
"from information_schema.statistics " +
|
||||
"where lower(table_name)=lower(?) and lower(index_name)=lower(?)",
|
||||
Integer.class,
|
||||
"play_earnings_line_adjustment",
|
||||
"uk_tenant_idempotency");
|
||||
assertThat(count).isNotNull();
|
||||
assertThat(count).isGreaterThan(0);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private boolean columnExists(String table, String column) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.columns where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
|
||||
Integer.class,
|
||||
table,
|
||||
column);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.starry.admin.modules.withdraw.contract;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import java.lang.reflect.Field;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit-level contract tests that lock the intended public schema/API surface.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the adjustment system is implemented.</p>
|
||||
*/
|
||||
class EarningsAdjustmentSchemaContractTest {
|
||||
|
||||
@Test
|
||||
void earningsLineEntityExposesSourceTypeAndSourceId() throws Exception {
|
||||
assertHasField(EarningsLineEntity.class, "sourceType");
|
||||
assertHasField(EarningsLineEntity.class, "sourceId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentEntityAndEnumExist() {
|
||||
assertDoesNotThrow(() -> {
|
||||
Class<?> entity = Class.forName("com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity");
|
||||
assertHasField(entity, "id");
|
||||
assertHasField(entity, "tenantId");
|
||||
assertHasField(entity, "clerkId");
|
||||
assertHasField(entity, "amount");
|
||||
assertHasField(entity, "reasonType");
|
||||
assertHasField(entity, "reasonDescription");
|
||||
assertHasField(entity, "idempotencyKey");
|
||||
assertHasField(entity, "requestHash");
|
||||
assertHasField(entity, "status");
|
||||
assertHasField(entity, "effectiveTime");
|
||||
assertHasField(entity, "appliedTime");
|
||||
});
|
||||
assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType"));
|
||||
assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus"));
|
||||
}
|
||||
|
||||
private void assertHasField(Class<?> clazz, String fieldName) throws Exception {
|
||||
Field field = clazz.getDeclaredField(fieldName);
|
||||
assertThat(field).isNotNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user