feat: earnings deduction batch + test auth hardening

This commit is contained in:
irving
2026-01-16 13:30:04 -05:00
parent 985b35cd90
commit e2300fc7d0
25 changed files with 3512 additions and 7 deletions

View File

@@ -21,6 +21,7 @@ import com.starry.admin.modules.weichat.service.WxTokenService;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.redis.RedisCache; import com.starry.common.redis.RedisCache;
import com.starry.common.result.R; import com.starry.common.result.R;
import com.starry.common.result.ResultCodeEnum;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam; import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
@@ -28,10 +29,16 @@ import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses; import io.swagger.annotations.ApiResponses;
import java.util.Date; import java.util.Date;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Resource; import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.bean.WxJsapiSignature; import me.chanjar.weixin.common.bean.WxJsapiSignature;
import me.chanjar.weixin.common.error.WxErrorException; import me.chanjar.weixin.common.error.WxErrorException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -60,6 +67,38 @@ public class WxOauthController {
@Resource @Resource
private RedisCache redisCache; private RedisCache redisCache;
@Resource
private Environment environment;
@Value("${test.auth.secret:}")
private String testAuthSecret;
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
private boolean isTestAuthEnabled() {
String[] profiles = environment == null ? new String[0] : environment.getActiveProfiles();
Set<String> active = Stream.of(profiles == null ? new String[0] : profiles)
.filter(StringUtils::hasText)
.map(String::trim)
.collect(Collectors.toSet());
boolean devOrTest = active.contains("dev") || active.contains("test") || active.contains("apitest");
return devOrTest && StringUtils.hasText(testAuthSecret);
}
private R rejectTestAuth() {
// Hide test-only endpoints when not enabled (prod or secret missing).
if (!isTestAuthEnabled()) {
return R.error(ResultCodeEnum.VALIDATE_FAILED.getCode(), "not found");
}
return R.error(ResultCodeEnum.FORBIDDEN.getCode(), "forbidden");
}
private boolean isTestAuthHeaderValid(String headerValue) {
return StringUtils.hasText(headerValue)
&& StringUtils.hasText(testAuthSecret)
&& testAuthSecret.equals(headerValue);
}
@ApiOperation(value = "获取配置地址", notes = "获取微信JSAPI配置签名") @ApiOperation(value = "获取配置地址", notes = "获取微信JSAPI配置签名")
@ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = WxJsapiSignature.class)}) @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = WxJsapiSignature.class)})
@PostMapping("/getConfigAddress") @PostMapping("/getConfigAddress")
@@ -130,7 +169,12 @@ public class WxOauthController {
@ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = JSONObject.class), @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = JSONObject.class),
@ApiResponse(code = 401, message = "登录失败"), @ApiResponse(code = 500, message = "用户不存在")}) @ApiResponse(code = 401, message = "登录失败"), @ApiResponse(code = 500, message = "用户不存在")})
@PostMapping("/clerk/login/dev") @PostMapping("/clerk/login/dev")
public R clerkLoginDev(@ApiParam(value = "登录信息", required = true) @Validated @RequestBody WxUserLoginVo vo) { public R clerkLoginDev(
@RequestHeader(value = TEST_AUTH_HEADER, required = false) String testAuth,
@ApiParam(value = "登录信息", required = true) @Validated @RequestBody WxUserLoginVo vo) {
if (!isTestAuthHeaderValid(testAuth)) {
return rejectTestAuth();
}
try { try {
String userId = "a4471ef596a1"; String userId = "a4471ef596a1";
PlayClerkUserInfoEntity entity = clerkUserInfoService.selectById(userId); PlayClerkUserInfoEntity entity = clerkUserInfoService.selectById(userId);
@@ -162,7 +206,12 @@ public class WxOauthController {
@ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = JSONObject.class), @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = JSONObject.class),
@ApiResponse(code = 500, message = "用户不存在")}) @ApiResponse(code = 500, message = "用户不存在")})
@PostMapping("/clerk/loginById") @PostMapping("/clerk/loginById")
public R loginById(@ApiParam(value = "登录信息", required = true) @Validated @RequestBody WxUserLoginVo vo) { public R loginById(
@RequestHeader(value = TEST_AUTH_HEADER, required = false) String testAuth,
@ApiParam(value = "登录信息", required = true) @Validated @RequestBody WxUserLoginVo vo) {
if (!isTestAuthHeaderValid(testAuth)) {
return rejectTestAuth();
}
PlayClerkUserInfoEntity entity = clerkUserInfoService.selectById(vo.getCode()); PlayClerkUserInfoEntity entity = clerkUserInfoService.selectById(vo.getCode());
if (entity == null) { if (entity == null) {
throw new CustomException("用户不存在"); throw new CustomException("用户不存在");

View File

@@ -0,0 +1,384 @@
package com.starry.admin.modules.withdraw.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
import com.starry.admin.modules.withdraw.security.EarningsAuthorizationService;
import com.starry.admin.modules.withdraw.service.IEarningsDeductionBatchService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.TypedR;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Resource;
import lombok.Data;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.CollectionUtils;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin/earnings/deductions")
public class AdminEarningsDeductionBatchController {
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
private static final DateTimeFormatter SIMPLE_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Resource
private IEarningsDeductionBatchService batchService;
@Resource
private EarningsDeductionItemMapper deductionItemMapper;
@Resource
private EarningsAuthorizationService earningsAuth;
@Data
public static class DeductionRequest {
private List<String> clerkIds;
private String beginTime;
private String endTime;
private EarningsDeductionRuleType ruleType;
private BigDecimal amount;
private BigDecimal percentage;
private EarningsDeductionOperationType operation;
private String reasonDescription;
}
@Data
public static class PreviewItem {
private String clerkId;
private BigDecimal baseAmount;
private BigDecimal applyAmount;
}
@Data
public static class PreviewResponse {
private List<PreviewItem> items;
}
@Data
public static class BatchStatusResponse {
private String batchId;
private String idempotencyKey;
private String status;
}
@PostMapping(value = "/preview", consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
public ResponseEntity<TypedR<PreviewResponse>> preview(@RequestBody DeductionRequest body) {
if (body == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
}
List<String> clerkIds = normalizeClerkIds(body.getClerkIds());
if (clerkIds.isEmpty()) {
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkIds required"));
}
LocalDateTime begin = parseDate(body.getBeginTime());
LocalDateTime end = parseDate(body.getEndTime());
if (begin == null || end == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "time range required"));
}
if (begin.isAfter(end)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "beginTime must be <= endTime"));
}
EarningsDeductionRuleType ruleType = body.getRuleType();
if (ruleType == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleType required"));
}
EarningsDeductionOperationType operation = body.getOperation();
if (operation == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "operation required"));
}
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
}
BigDecimal ruleValue = resolveRuleValue(ruleType, body.getAmount(), body.getPercentage());
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleValue must be non-zero"));
}
enforceScope(clerkIds);
String tenantId = SecurityUtils.getTenantId();
PreviewResponse response = new PreviewResponse();
List<PreviewItem> items = new ArrayList<>();
for (String clerkId : clerkIds) {
BigDecimal base = deductionItemMapper.sumOrderPositiveBase(tenantId, clerkId, begin, end);
BigDecimal baseAmount = base == null ? BigDecimal.ZERO : base;
BigDecimal applyAmount = calculateApplyAmount(ruleType, ruleValue, operation, baseAmount);
PreviewItem item = new PreviewItem();
item.setClerkId(clerkId);
item.setBaseAmount(baseAmount.setScale(2, RoundingMode.HALF_UP));
item.setApplyAmount(applyAmount.setScale(2, RoundingMode.HALF_UP));
items.add(item);
}
response.setItems(items);
return ResponseEntity.ok(TypedR.ok(response));
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
public ResponseEntity<TypedR<BatchStatusResponse>> create(
@RequestHeader(value = IDEMPOTENCY_HEADER, required = false) String idempotencyKey,
@RequestBody DeductionRequest 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"));
}
List<String> clerkIds = normalizeClerkIds(body.getClerkIds());
if (clerkIds.isEmpty()) {
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkIds required"));
}
LocalDateTime begin = parseDate(body.getBeginTime());
LocalDateTime end = parseDate(body.getEndTime());
if (begin == null || end == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "time range required"));
}
if (begin.isAfter(end)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "beginTime must be <= endTime"));
}
if (body.getRuleType() == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleType required"));
}
if (body.getOperation() == null) {
return ResponseEntity.badRequest().body(TypedR.error(400, "operation required"));
}
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
}
BigDecimal ruleValue = resolveRuleValue(body.getRuleType(), body.getAmount(), body.getPercentage());
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleValue must be non-zero"));
}
enforceScope(clerkIds);
String tenantId = SecurityUtils.getTenantId();
if (!StringUtils.hasText(tenantId)) {
return ResponseEntity.badRequest().body(TypedR.error(400, "tenant missing"));
}
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch;
try {
batch = batchService.createOrGetProcessing(
tenantId,
clerkIds,
begin,
end,
body.getRuleType(),
ruleValue,
body.getOperation(),
body.getReasonDescription(),
idempotencyKey);
} 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()));
}
batchService.triggerApplyAsync(batch.getId());
BatchStatusResponse responseBody = new BatchStatusResponse();
responseBody.setBatchId(batch.getId());
responseBody.setIdempotencyKey(idempotencyKey);
responseBody.setStatus(batch.getStatus() == null ? "PROCESSING" : batch.getStatus().name());
TypedR<BatchStatusResponse> result = TypedR.ok(responseBody);
result.setCode(202);
result.setMessage("请求处理中");
return ResponseEntity.accepted()
.header("Location", "/admin/earnings/deductions/idempotency/" + idempotencyKey)
.body(result);
}
@GetMapping("/idempotency/{key}")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
public ResponseEntity<TypedR<BatchStatusResponse>> getByIdempotencyKey(@PathVariable("key") String key) {
if (!StringUtils.hasText(key)) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.getByIdempotencyKey(tenantId, key);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batch.getId());
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity reconciled = batchService.reconcileAndGet(tenantId, batch.getId());
if (reconciled == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
BatchStatusResponse responseBody = new BatchStatusResponse();
responseBody.setBatchId(reconciled.getId());
responseBody.setIdempotencyKey(key);
responseBody.setStatus(reconciled.getStatus() == null ? "PROCESSING" : reconciled.getStatus().name());
return ResponseEntity.ok(TypedR.ok(responseBody));
}
@GetMapping("/{batchId}")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
public ResponseEntity<TypedR<BatchStatusResponse>> getById(@PathVariable("batchId") String batchId) {
if (!StringUtils.hasText(batchId)) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batch.getId());
BatchStatusResponse responseBody = new BatchStatusResponse();
responseBody.setBatchId(batch.getId());
responseBody.setIdempotencyKey(batch.getIdempotencyKey());
responseBody.setStatus(batch.getStatus() == null ? "PROCESSING" : batch.getStatus().name());
return ResponseEntity.ok(TypedR.ok(responseBody));
}
@GetMapping("/{batchId}/items")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
public ResponseEntity<TypedR<List<EarningsDeductionItemEntity>>> listItems(
@PathVariable("batchId") String batchId,
@RequestParam(value = "pageNum", required = false, defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batchId);
IPage<EarningsDeductionItemEntity> page = batchService.pageItems(tenantId, batchId, pageNum, pageSize);
return ResponseEntity.ok(TypedR.okPage(page));
}
@GetMapping("/{batchId}/logs")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
public ResponseEntity<TypedR<List<EarningsDeductionBatchLogEntity>>> listLogs(@PathVariable("batchId") String batchId) {
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batchId);
List<EarningsDeductionBatchLogEntity> logs = batchService.listLogs(tenantId, batchId);
return ResponseEntity.ok(TypedR.ok(logs));
}
@PostMapping("/{batchId}/retry")
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
public ResponseEntity<TypedR<Void>> retry(@PathVariable("batchId") String batchId) {
String tenantId = SecurityUtils.getTenantId();
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
if (batch == null) {
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
}
enforceBatchScope(tenantId, batchId);
batchService.retryAsync(batchId);
return ResponseEntity.ok(TypedR.ok(null));
}
private void enforceScope(List<String> clerkIds) {
for (String clerkId : clerkIds) {
if (!earningsAuth.canManageClerk(clerkId)) {
throw new AccessDeniedException("forbidden");
}
}
}
private void enforceBatchScope(String tenantId, String batchId) {
if (SecurityUtils.getLoginUser() != null
&& SecurityUtils.getLoginUser().getUser() != null
&& SecurityUtils.isAdmin(SecurityUtils.getLoginUser().getUser())) {
return;
}
List<EarningsDeductionItemEntity> items = batchService.listItems(tenantId, batchId);
for (EarningsDeductionItemEntity item : items) {
if (item != null && StringUtils.hasText(item.getClerkId())) {
if (!earningsAuth.canManageClerk(item.getClerkId())) {
throw new AccessDeniedException("forbidden");
}
}
}
}
private LocalDateTime parseDate(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
try {
return LocalDateTime.parse(value.trim(), SIMPLE_DATE_TIME);
} catch (DateTimeParseException ex) {
throw new CustomException("invalid datetime: " + value);
}
}
private BigDecimal resolveRuleValue(EarningsDeductionRuleType type, BigDecimal amount, BigDecimal percentage) {
if (type == EarningsDeductionRuleType.FIXED) {
return amount;
}
return percentage;
}
private BigDecimal calculateApplyAmount(EarningsDeductionRuleType ruleType,
BigDecimal ruleValue,
EarningsDeductionOperationType operation,
BigDecimal baseAmount) {
BigDecimal resolvedBase = baseAmount == null ? BigDecimal.ZERO : baseAmount;
BigDecimal resolvedRule = ruleValue == null ? BigDecimal.ZERO : ruleValue;
BigDecimal amount;
if (ruleType == EarningsDeductionRuleType.FIXED) {
amount = resolvedRule;
} else {
amount = resolvedBase.multiply(resolvedRule).divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
}
amount = amount.setScale(2, RoundingMode.HALF_UP);
if (operation == EarningsDeductionOperationType.PUNISHMENT) {
return amount.abs().negate();
}
return amount.abs();
}
private List<String> normalizeClerkIds(List<String> input) {
List<String> result = new ArrayList<>();
if (CollectionUtils.isEmpty(input)) {
return result;
}
for (String id : input) {
if (!StringUtils.hasText(id)) {
continue;
}
String trimmed = id.trim();
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
result.add(trimmed);
}
}
return result;
}
}

View File

@@ -8,14 +8,18 @@ import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException; import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
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.modules.withdraw.service.IEarningsService;
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService; import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService; import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo; import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo;
import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo; import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.result.TypedR; import com.starry.common.result.TypedR;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -40,6 +44,8 @@ public class WxWithdrawController {
@Resource @Resource
private IEarningsService earningsService; private IEarningsService earningsService;
@Resource @Resource
private IEarningsAdjustmentService adjustmentService;
@Resource
private IWithdrawalService withdrawalService; private IWithdrawalService withdrawalService;
@Resource @Resource
private IWithdrawalLogService withdrawalLogService; private IWithdrawalLogService withdrawalLogService;
@@ -101,6 +107,21 @@ public class WxWithdrawController {
.list() .list()
.stream() .stream()
.collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it)); .collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it));
List<String> adjustmentIds = records.stream()
.filter(line -> line.getSourceType() == EarningsSourceType.ADJUSTMENT)
.map(EarningsLineEntity::getSourceId)
.filter(id -> id != null && !id.isEmpty())
.distinct()
.collect(Collectors.toList());
Map<String, EarningsLineAdjustmentEntity> adjustmentMap = adjustmentIds.isEmpty() ? java.util.Collections.emptyMap()
: adjustmentService.lambdaQuery()
.eq(EarningsLineAdjustmentEntity::getTenantId, SecurityUtils.getTenantId())
.in(EarningsLineAdjustmentEntity::getId, adjustmentIds)
.list()
.stream()
.collect(Collectors.toMap(EarningsLineAdjustmentEntity::getId, it -> it));
for (EarningsLineEntity line : records) { for (EarningsLineEntity line : records) {
ClerkEarningLineVo vo = new ClerkEarningLineVo(); ClerkEarningLineVo vo = new ClerkEarningLineVo();
vo.setId(line.getId()); vo.setId(line.getId());
@@ -111,6 +132,14 @@ public class WxWithdrawController {
vo.setUnlockTime(line.getUnlockTime()); vo.setUnlockTime(line.getUnlockTime());
vo.setCreatedTime(toLocalDateTime(line.getCreatedTime())); vo.setCreatedTime(toLocalDateTime(line.getCreatedTime()));
vo.setOrderId(line.getOrderId()); vo.setOrderId(line.getOrderId());
if (line.getSourceType() == EarningsSourceType.ADJUSTMENT && line.getSourceId() != null) {
EarningsLineAdjustmentEntity adjustment = adjustmentMap.get(line.getSourceId());
if (adjustment != null) {
vo.setAdjustmentReasonType(adjustment.getReasonType());
vo.setAdjustmentReasonDescription(adjustment.getReasonDescription());
vo.setAdjustmentEffectiveTime(adjustment.getEffectiveTime());
}
}
if (line.getOrderId() != null) { if (line.getOrderId() != null) {
PlayOrderInfoEntity order = orderMap.get(line.getOrderId()); PlayOrderInfoEntity order = orderMap.get(line.getOrderId());
if (order != null) { if (order != null) {

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
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_deduction_batch")
public class EarningsDeductionBatchEntity extends BaseEntity<EarningsDeductionBatchEntity> {
private String id;
private String tenantId;
private EarningsDeductionStatus status;
@TableField("begin_time")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime windowBeginTime;
@TableField("end_time")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime windowEndTime;
private EarningsDeductionRuleType ruleType;
private BigDecimal ruleValue;
private EarningsDeductionOperationType operation;
private String reasonDescription;
private String idempotencyKey;
private String requestHash;
}

View File

@@ -0,0 +1,21 @@
package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionLogEventType;
import com.starry.common.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_earnings_deduction_batch_log")
public class EarningsDeductionBatchLogEntity extends BaseEntity<EarningsDeductionBatchLogEntity> {
private String id;
private String batchId;
private String tenantId;
private EarningsDeductionLogEventType eventType;
private String statusFrom;
private String statusTo;
private String message;
private String payload;
}

View File

@@ -0,0 +1,23 @@
package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
import com.starry.common.domain.BaseEntity;
import java.math.BigDecimal;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("play_earnings_deduction_item")
public class EarningsDeductionItemEntity extends BaseEntity<EarningsDeductionItemEntity> {
private String id;
private String batchId;
private String tenantId;
private String clerkId;
private BigDecimal baseAmount;
private BigDecimal applyAmount;
private EarningsDeductionStatus status;
private String adjustmentId;
private String failureReason;
}

View File

@@ -0,0 +1,26 @@
package com.starry.admin.modules.withdraw.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
public enum EarningsDeductionLogEventType {
CREATED("CREATED"),
APPLY_STARTED("APPLY_STARTED"),
ITEM_APPLIED("ITEM_APPLIED"),
ITEM_FAILED("ITEM_FAILED"),
BATCH_APPLIED("BATCH_APPLIED"),
BATCH_FAILED("BATCH_FAILED"),
RETRY_STARTED("RETRY_STARTED");
@EnumValue
@JsonValue
private final String value;
EarningsDeductionLogEventType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View File

@@ -0,0 +1,21 @@
package com.starry.admin.modules.withdraw.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
public enum EarningsDeductionOperationType {
BONUS("BONUS"),
PUNISHMENT("PUNISHMENT");
@EnumValue
@JsonValue
private final String value;
EarningsDeductionOperationType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View File

@@ -0,0 +1,21 @@
package com.starry.admin.modules.withdraw.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
public enum EarningsDeductionRuleType {
FIXED("FIXED"),
PERCENTAGE("PERCENTAGE");
@EnumValue
@JsonValue
private final String value;
EarningsDeductionRuleType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View File

@@ -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 EarningsDeductionStatus {
PROCESSING("PROCESSING"),
APPLIED("APPLIED"),
FAILED("FAILED");
@EnumValue
@JsonValue
private final String value;
EarningsDeductionStatus(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View File

@@ -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.EarningsDeductionBatchLogEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EarningsDeductionBatchLogMapper extends BaseMapper<EarningsDeductionBatchLogEntity> {}

View File

@@ -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.EarningsDeductionBatchEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EarningsDeductionBatchMapper extends BaseMapper<EarningsDeductionBatchEntity> {}

View File

@@ -0,0 +1,33 @@
package com.starry.admin.modules.withdraw.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface EarningsDeductionItemMapper extends BaseMapper<EarningsDeductionItemEntity> {
@Select("<script>" +
"SELECT COALESCE(SUM(el.amount), 0) " +
"FROM play_earnings_line el " +
"JOIN play_order_info oi ON oi.id = el.order_id " +
"WHERE el.deleted = 0 " +
" AND oi.deleted = 0 " +
" AND el.tenant_id = #{tenantId} " +
" AND el.clerk_id = #{clerkId} " +
" AND el.order_id IS NOT NULL " +
" AND el.amount &gt; 0 " +
" AND el.status &lt;&gt; 'reversed' " +
" AND oi.order_end_time IS NOT NULL " +
" AND oi.order_end_time &gt;= #{begin} " +
" AND oi.order_end_time &lt;= #{end} " +
"</script>")
BigDecimal sumOrderPositiveBase(@Param("tenantId") String tenantId,
@Param("clerkId") String clerkId,
@Param("begin") LocalDateTime begin,
@Param("end") LocalDateTime end);
}

View File

@@ -0,0 +1,40 @@
package com.starry.admin.modules.withdraw.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public interface IEarningsDeductionBatchService extends IService<EarningsDeductionBatchEntity> {
EarningsDeductionBatchEntity createOrGetProcessing(
String tenantId,
List<String> clerkIds,
LocalDateTime beginTime,
LocalDateTime endTime,
EarningsDeductionRuleType ruleType,
BigDecimal ruleValue,
EarningsDeductionOperationType operation,
String reasonDescription,
String idempotencyKey);
EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey);
List<EarningsDeductionItemEntity> listItems(String tenantId, String batchId);
IPage<EarningsDeductionItemEntity> pageItems(String tenantId, String batchId, int pageNum, int pageSize);
List<EarningsDeductionBatchLogEntity> listLogs(String tenantId, String batchId);
void triggerApplyAsync(String batchId);
void retryAsync(String batchId);
EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId);
}

View File

@@ -142,9 +142,6 @@ public class EarningsAdjustmentServiceImpl extends ServiceImpl<EarningsLineAdjus
if (adjustment.getStatus() == EarningsAdjustmentStatus.APPLIED) { if (adjustment.getStatus() == EarningsAdjustmentStatus.APPLIED) {
return; return;
} }
if (adjustment.getStatus() == EarningsAdjustmentStatus.FAILED) {
return;
}
try { try {
EarningsLineEntity line = new EarningsLineEntity(); EarningsLineEntity line = new EarningsLineEntity();

View File

@@ -0,0 +1,566 @@
package com.starry.admin.modules.withdraw.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionLogEventType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchLogMapper;
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchMapper;
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
import com.starry.admin.modules.withdraw.service.IEarningsDeductionBatchService;
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.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
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.CollectionUtils;
import org.springframework.util.StringUtils;
@Service
public class EarningsDeductionBatchServiceImpl
extends ServiceImpl<EarningsDeductionBatchMapper, EarningsDeductionBatchEntity>
implements IEarningsDeductionBatchService {
@Resource
private EarningsDeductionItemMapper itemMapper;
@Resource
private EarningsDeductionBatchLogMapper logMapper;
@Resource
private IEarningsAdjustmentService adjustmentService;
@Resource(name = "threadPoolTaskExecutor")
private ThreadPoolTaskExecutor executor;
@Override
public EarningsDeductionBatchEntity createOrGetProcessing(
String tenantId,
List<String> clerkIds,
LocalDateTime beginTime,
LocalDateTime endTime,
EarningsDeductionRuleType ruleType,
BigDecimal ruleValue,
EarningsDeductionOperationType operation,
String reasonDescription,
String idempotencyKey) {
validateCreateRequest(tenantId, clerkIds, beginTime, endTime, ruleType, ruleValue, operation, reasonDescription, idempotencyKey);
List<String> normalizedClerks = normalizeClerkIds(clerkIds);
String hash = computeRequestHash(tenantId, normalizedClerks, beginTime, endTime, ruleType, ruleValue, operation, reasonDescription);
EarningsDeductionBatchEntity existing = getByIdempotencyKey(tenantId, idempotencyKey);
if (existing != null) {
if (existing.getRequestHash() != null && !existing.getRequestHash().equals(hash)) {
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
}
return existing;
}
EarningsDeductionBatchEntity created = new EarningsDeductionBatchEntity();
created.setId(IdUtils.getUuid());
created.setTenantId(tenantId);
created.setStatus(EarningsDeductionStatus.PROCESSING);
created.setWindowBeginTime(beginTime);
created.setWindowEndTime(endTime);
created.setRuleType(ruleType);
created.setRuleValue(ruleValue.setScale(2, RoundingMode.HALF_UP));
created.setOperation(operation);
created.setReasonDescription(reasonDescription.trim());
created.setIdempotencyKey(idempotencyKey);
created.setRequestHash(hash);
Date now = new Date();
created.setCreatedTime(now);
created.setUpdatedTime(now);
created.setDeleted(false);
try {
this.save(created);
ensureItems(created, normalizedClerks);
logOnce(created, EarningsDeductionLogEventType.CREATED, null, EarningsDeductionStatus.PROCESSING.name(),
"批次已创建", buildPayload(created, normalizedClerks));
return created;
} catch (DuplicateKeyException dup) {
EarningsDeductionBatchEntity raced = getByIdempotencyKey(tenantId, idempotencyKey);
if (raced == null) {
throw dup;
}
if (raced.getRequestHash() != null && !raced.getRequestHash().equals(hash)) {
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
}
ensureItems(raced, normalizedClerks);
logOnce(raced, EarningsDeductionLogEventType.CREATED, null, EarningsDeductionStatus.PROCESSING.name(),
"批次已创建", buildPayload(raced, normalizedClerks));
return raced;
}
}
@Override
public EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey) {
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(idempotencyKey)) {
return null;
}
return this.getOne(Wrappers.lambdaQuery(EarningsDeductionBatchEntity.class)
.eq(EarningsDeductionBatchEntity::getTenantId, tenantId)
.eq(EarningsDeductionBatchEntity::getIdempotencyKey, idempotencyKey)
.eq(EarningsDeductionBatchEntity::getDeleted, false)
.last("limit 1"));
}
@Override
public List<EarningsDeductionItemEntity> listItems(String tenantId, String batchId) {
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
return new ArrayList<>();
}
return itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
.eq(EarningsDeductionItemEntity::getDeleted, false)
.orderByAsc(EarningsDeductionItemEntity::getCreatedTime));
}
@Override
public IPage<EarningsDeductionItemEntity> pageItems(String tenantId, String batchId, int pageNum, int pageSize) {
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
return new Page<>(pageNum, pageSize);
}
return itemMapper.selectPage(new Page<>(pageNum, pageSize), Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
.eq(EarningsDeductionItemEntity::getDeleted, false)
.orderByAsc(EarningsDeductionItemEntity::getCreatedTime));
}
@Override
public List<EarningsDeductionBatchLogEntity> listLogs(String tenantId, String batchId) {
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
return new ArrayList<>();
}
return logMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
.eq(EarningsDeductionBatchLogEntity::getTenantId, tenantId)
.eq(EarningsDeductionBatchLogEntity::getBatchId, batchId)
.eq(EarningsDeductionBatchLogEntity::getDeleted, false)
.orderByAsc(EarningsDeductionBatchLogEntity::getCreatedTime));
}
@Override
public void triggerApplyAsync(String batchId) {
if (!StringUtils.hasText(batchId)) {
return;
}
executor.execute(() -> {
try {
applyOnce(batchId);
} catch (Exception ignored) {
}
});
}
@Override
public void retryAsync(String batchId) {
if (!StringUtils.hasText(batchId)) {
return;
}
executor.execute(() -> {
try {
retryOnce(batchId);
} catch (Exception ignored) {
}
});
}
@Override
public EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId) {
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
return null;
}
EarningsDeductionBatchEntity batch = this.getById(batchId);
if (batch == null || !tenantId.equals(batch.getTenantId())) {
return null;
}
reconcile(batch);
return this.getById(batchId);
}
@Transactional(rollbackFor = Exception.class)
public void applyOnce(String batchId) {
EarningsDeductionBatchEntity batch = this.getById(batchId);
if (batch == null) {
return;
}
logOnce(batch, EarningsDeductionLogEventType.APPLY_STARTED, null, EarningsDeductionStatus.PROCESSING.name(),
"开始执行批次", null);
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
.eq(EarningsDeductionItemEntity::getDeleted, false));
if (items.isEmpty()) {
return;
}
for (EarningsDeductionItemEntity item : items) {
if (item == null || !StringUtils.hasText(item.getClerkId())) {
continue;
}
BigDecimal amount = item.getApplyAmount() == null ? BigDecimal.ZERO : item.getApplyAmount();
if (amount.compareTo(BigDecimal.ZERO) == 0) {
markItemFailed(item.getId(), "applyAmount=0");
continue;
}
String idempotencyKey = "deduct:" + batch.getId() + ":" + item.getClerkId();
EarningsLineAdjustmentEntity adjustment = adjustmentService.createOrGetProcessing(
batch.getTenantId(),
item.getClerkId(),
amount,
EarningsAdjustmentReasonType.MANUAL,
batch.getReasonDescription(),
idempotencyKey,
LocalDateTime.now());
if (!StringUtils.hasText(item.getAdjustmentId())) {
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
patch.setId(item.getId());
patch.setAdjustmentId(adjustment.getId());
patch.setStatus(EarningsDeductionStatus.PROCESSING);
patch.setFailureReason(null);
itemMapper.updateById(patch);
}
adjustmentService.triggerApplyAsync(adjustment.getId());
}
}
@Transactional(rollbackFor = Exception.class)
public void retryOnce(String batchId) {
EarningsDeductionBatchEntity batch = this.getById(batchId);
if (batch == null) {
return;
}
logOnce(batch, EarningsDeductionLogEventType.RETRY_STARTED, batch.getStatus() == null ? null : batch.getStatus().name(),
EarningsDeductionStatus.PROCESSING.name(), "开始重试失败项", null);
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
.eq(EarningsDeductionItemEntity::getDeleted, false));
for (EarningsDeductionItemEntity item : items) {
if (item == null) {
continue;
}
if (item.getStatus() != EarningsDeductionStatus.FAILED) {
continue;
}
if (!StringUtils.hasText(item.getAdjustmentId())) {
continue;
}
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
patch.setId(item.getId());
patch.setStatus(EarningsDeductionStatus.PROCESSING);
patch.setFailureReason(null);
itemMapper.updateById(patch);
adjustmentService.triggerApplyAsync(item.getAdjustmentId());
}
EarningsDeductionBatchEntity patchBatch = new EarningsDeductionBatchEntity();
patchBatch.setId(batch.getId());
patchBatch.setStatus(EarningsDeductionStatus.PROCESSING);
this.updateById(patchBatch);
}
private void reconcile(EarningsDeductionBatchEntity batch) {
if (batch == null || !StringUtils.hasText(batch.getId())) {
return;
}
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
.eq(EarningsDeductionItemEntity::getDeleted, false));
if (items.isEmpty()) {
return;
}
AtomicBoolean anyFailed = new AtomicBoolean(false);
AtomicBoolean anyProcessing = new AtomicBoolean(false);
for (EarningsDeductionItemEntity item : items) {
if (item == null) {
continue;
}
if (!StringUtils.hasText(item.getAdjustmentId())) {
anyProcessing.set(true);
continue;
}
EarningsLineAdjustmentEntity adjustment = adjustmentService.getById(item.getAdjustmentId());
if (adjustment == null || adjustment.getStatus() == null) {
anyProcessing.set(true);
continue;
}
if (adjustment.getStatus() == com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus.APPLIED) {
if (item.getStatus() != EarningsDeductionStatus.APPLIED) {
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
patch.setId(item.getId());
patch.setStatus(EarningsDeductionStatus.APPLIED);
patch.setFailureReason(null);
itemMapper.updateById(patch);
}
} else if (adjustment.getStatus() == com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus.FAILED) {
anyFailed.set(true);
if (item.getStatus() != EarningsDeductionStatus.FAILED) {
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
patch.setId(item.getId());
patch.setStatus(EarningsDeductionStatus.FAILED);
patch.setFailureReason(adjustment.getFailureReason());
itemMapper.updateById(patch);
}
logOnce(batch, EarningsDeductionLogEventType.ITEM_FAILED, EarningsDeductionStatus.PROCESSING.name(),
EarningsDeductionStatus.FAILED.name(), "明细失败", JSON.toJSONString(Objects.requireNonNullElse(adjustment.getFailureReason(), "")));
} else {
anyProcessing.set(true);
}
}
if (anyFailed.get()) {
if (batch.getStatus() != EarningsDeductionStatus.FAILED) {
EarningsDeductionBatchEntity patch = new EarningsDeductionBatchEntity();
patch.setId(batch.getId());
patch.setStatus(EarningsDeductionStatus.FAILED);
this.updateById(patch);
}
logOnce(batch, EarningsDeductionLogEventType.BATCH_FAILED, EarningsDeductionStatus.PROCESSING.name(),
EarningsDeductionStatus.FAILED.name(), "批次失败", null);
return;
}
if (anyProcessing.get()) {
return;
}
if (batch.getStatus() != EarningsDeductionStatus.APPLIED) {
EarningsDeductionBatchEntity patch = new EarningsDeductionBatchEntity();
patch.setId(batch.getId());
patch.setStatus(EarningsDeductionStatus.APPLIED);
this.updateById(patch);
}
logOnce(batch, EarningsDeductionLogEventType.BATCH_APPLIED, EarningsDeductionStatus.PROCESSING.name(),
EarningsDeductionStatus.APPLIED.name(), "批次完成", null);
}
private void ensureItems(EarningsDeductionBatchEntity batch, List<String> clerkIds) {
if (batch == null || !StringUtils.hasText(batch.getId()) || CollectionUtils.isEmpty(clerkIds)) {
return;
}
for (String clerkId : clerkIds) {
if (!StringUtils.hasText(clerkId)) {
continue;
}
BigDecimal base = itemMapper.sumOrderPositiveBase(batch.getTenantId(), clerkId, batch.getWindowBeginTime(), batch.getWindowEndTime());
BigDecimal baseAmount = base == null ? BigDecimal.ZERO : base;
BigDecimal applyAmount = calculateApplyAmount(batch.getRuleType(), batch.getRuleValue(), batch.getOperation(), baseAmount);
EarningsDeductionItemEntity item = new EarningsDeductionItemEntity();
item.setId(IdUtils.getUuid());
item.setBatchId(batch.getId());
item.setTenantId(batch.getTenantId());
item.setClerkId(clerkId);
item.setBaseAmount(baseAmount.setScale(2, RoundingMode.HALF_UP));
item.setApplyAmount(applyAmount.setScale(2, RoundingMode.HALF_UP));
item.setStatus(EarningsDeductionStatus.PROCESSING);
item.setDeleted(false);
try {
itemMapper.insert(item);
} catch (DuplicateKeyException ignored) {
}
}
}
private void markItemFailed(String itemId, String reason) {
if (!StringUtils.hasText(itemId)) {
return;
}
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
patch.setId(itemId);
patch.setStatus(EarningsDeductionStatus.FAILED);
patch.setFailureReason(reason);
itemMapper.updateById(patch);
}
private BigDecimal calculateApplyAmount(EarningsDeductionRuleType ruleType,
BigDecimal ruleValue,
EarningsDeductionOperationType operation,
BigDecimal baseAmount) {
BigDecimal resolvedBase = baseAmount == null ? BigDecimal.ZERO : baseAmount;
BigDecimal resolvedRule = ruleValue == null ? BigDecimal.ZERO : ruleValue;
BigDecimal amount;
if (ruleType == EarningsDeductionRuleType.FIXED) {
amount = resolvedRule;
} else {
amount = resolvedBase.multiply(resolvedRule).divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
}
amount = amount.setScale(2, RoundingMode.HALF_UP);
if (operation == EarningsDeductionOperationType.PUNISHMENT) {
return amount.abs().negate();
}
return amount.abs();
}
private void validateCreateRequest(String tenantId,
List<String> clerkIds,
LocalDateTime beginTime,
LocalDateTime endTime,
EarningsDeductionRuleType ruleType,
BigDecimal ruleValue,
EarningsDeductionOperationType operation,
String reasonDescription,
String idempotencyKey) {
if (!StringUtils.hasText(tenantId)) {
throw new CustomException("tenant missing");
}
if (CollectionUtils.isEmpty(clerkIds)) {
throw new CustomException("clerkIds required");
}
if (beginTime == null || endTime == null) {
throw new CustomException("time range required");
}
if (beginTime.isAfter(endTime)) {
throw new CustomException("beginTime must be <= endTime");
}
if (ruleType == null) {
throw new CustomException("ruleType required");
}
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
throw new CustomException("ruleValue must be non-zero");
}
if (operation == null) {
throw new CustomException("operation required");
}
if (!StringUtils.hasText(idempotencyKey)) {
throw new CustomException("Idempotency-Key required");
}
if (!StringUtils.hasText(reasonDescription) || !StringUtils.hasText(reasonDescription.trim())) {
throw new CustomException("reasonDescription required");
}
}
private List<String> normalizeClerkIds(List<String> clerkIds) {
List<String> result = new ArrayList<>();
if (CollectionUtils.isEmpty(clerkIds)) {
return result;
}
for (String id : clerkIds) {
if (!StringUtils.hasText(id)) {
continue;
}
String trimmed = id.trim();
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
result.add(trimmed);
}
}
result.sort(String::compareTo);
return result;
}
private String buildPayload(EarningsDeductionBatchEntity batch, List<String> clerkIds) {
if (batch == null) {
return null;
}
List<String> resolvedClerkIds = clerkIds == null ? List.of() : clerkIds;
return JSON.toJSONString(new Object() {
public final String batchId = batch.getId();
public final String ruleType = batch.getRuleType() == null ? null : batch.getRuleType().name();
public final String operation = batch.getOperation() == null ? null : batch.getOperation().name();
public final BigDecimal ruleValue = batch.getRuleValue();
public final LocalDateTime beginTime = batch.getWindowBeginTime();
public final LocalDateTime endTime = batch.getWindowEndTime();
public final String reasonDescription = batch.getReasonDescription();
public final List<String> clerkIds = resolvedClerkIds;
});
}
private void logOnce(EarningsDeductionBatchEntity batch,
EarningsDeductionLogEventType eventType,
String from,
String to,
String message,
String payload) {
if (batch == null || eventType == null) {
return;
}
Long count = logMapper.selectCount(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
.eq(EarningsDeductionBatchLogEntity::getTenantId, batch.getTenantId())
.eq(EarningsDeductionBatchLogEntity::getBatchId, batch.getId())
.eq(EarningsDeductionBatchLogEntity::getEventType, eventType)
.eq(EarningsDeductionBatchLogEntity::getDeleted, false));
if (count != null && count > 0) {
return;
}
EarningsDeductionBatchLogEntity log = new EarningsDeductionBatchLogEntity();
log.setId(IdUtils.getUuid());
log.setTenantId(batch.getTenantId());
log.setBatchId(batch.getId());
log.setEventType(eventType);
log.setStatusFrom(from);
log.setStatusTo(to);
log.setMessage(message);
log.setPayload(payload);
log.setDeleted(false);
logMapper.insert(log);
}
private String computeRequestHash(String tenantId,
List<String> clerkIds,
LocalDateTime beginTime,
LocalDateTime endTime,
EarningsDeductionRuleType ruleType,
BigDecimal ruleValue,
EarningsDeductionOperationType operation,
String reasonDescription) {
String normalizedRule = (ruleValue == null ? BigDecimal.ZERO : ruleValue).setScale(2, RoundingMode.HALF_UP).toPlainString();
String raw = tenantId + "|" +
String.join(",", clerkIds) + "|" +
beginTime.toString() + "|" +
endTime.toString() + "|" +
(ruleType == null ? "" : ruleType.name()) + "|" +
normalizedRule + "|" +
(operation == null ? "" : operation.name()) + "|" +
reasonDescription.trim();
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();
}
}

View File

@@ -88,6 +88,14 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
public List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now) { public List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now) {
// pick oldest unlocked first (status in available or frozen with unlock<=now) // pick oldest unlocked first (status in available or frozen with unlock<=now)
List<EarningsLineEntity> list = this.baseMapper.selectWithdrawableLines(clerkId, now); List<EarningsLineEntity> list = this.baseMapper.selectWithdrawableLines(clerkId, now);
BigDecimal total = BigDecimal.ZERO;
for (EarningsLineEntity line : list) {
BigDecimal value = line.getAmount() == null ? BigDecimal.ZERO : line.getAmount();
total = total.add(value);
}
if (total.compareTo(amount) < 0) {
return new ArrayList<>();
}
BigDecimal acc = BigDecimal.ZERO; BigDecimal acc = BigDecimal.ZERO;
List<EarningsLineEntity> picked = new ArrayList<>(); List<EarningsLineEntity> picked = new ArrayList<>();
for (EarningsLineEntity e : list) { for (EarningsLineEntity e : list) {

View File

@@ -1,6 +1,7 @@
package com.starry.admin.modules.withdraw.vo; package com.starry.admin.modules.withdraw.vo;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
import com.starry.admin.modules.withdraw.enums.EarningsType; import com.starry.admin.modules.withdraw.enums.EarningsType;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -15,6 +16,13 @@ public class ClerkEarningLineVo {
private EarningsType earningType; private EarningsType earningType;
private String withdrawalId; private String withdrawalId;
private EarningsAdjustmentReasonType adjustmentReasonType;
private String adjustmentReasonDescription;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime adjustmentEffectiveTime;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime unlockTime; private LocalDateTime unlockTime;

View File

@@ -0,0 +1,67 @@
-- Earnings deduction batches: apply fixed/percentage bonus or punishment across clerks in a time window.
CREATE TABLE IF NOT EXISTS `play_earnings_deduction_batch` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`status` varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态PROCESSING/APPLIED/FAILED',
`begin_time` datetime NOT NULL COMMENT '统计起始时间(按订单结束时间)',
`end_time` datetime NOT NULL COMMENT '统计结束时间(按订单结束时间)',
`rule_type` varchar(16) NOT NULL COMMENT '规则类型FIXED/PERCENTAGE',
`rule_value` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '规则值FIXED=金额PERCENTAGE=百分比)',
`operation` varchar(16) NOT NULL DEFAULT 'PUNISHMENT' COMMENT '操作类型BONUS/PUNISHMENT',
`reason_description` varchar(512) NOT NULL COMMENT '原因描述(操作时输入)',
`idempotency_key` varchar(64) DEFAULT NULL COMMENT '幂等Keytenant范围内唯一',
`request_hash` varchar(64) NOT NULL DEFAULT '' COMMENT '请求摘要用于检测同key不同body',
`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_deduct_batch_tenant_status_time` (`tenant_id`, `status`, `created_time`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益批量扣减批次';
CREATE TABLE IF NOT EXISTS `play_earnings_deduction_item` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`batch_id` varchar(32) NOT NULL COMMENT '批次ID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
`base_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '基数金额(仅订单收益正数)',
`apply_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '执行金额可正可负不允许0',
`status` varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态PROCESSING/APPLIED/FAILED',
`adjustment_id` varchar(32) DEFAULT NULL COMMENT '关联收益调整IDplay_earnings_line_adjustment.id',
`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_batch_clerk` (`batch_id`, `clerk_id`, `deleted`) USING BTREE,
KEY `idx_deduct_item_batch_status` (`batch_id`, `status`) USING BTREE,
KEY `idx_deduct_item_tenant_clerk` (`tenant_id`, `clerk_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益批量扣减明细';
CREATE TABLE IF NOT EXISTS `play_earnings_deduction_batch_log` (
`id` varchar(32) NOT NULL COMMENT 'UUID',
`batch_id` varchar(32) NOT NULL COMMENT '批次ID',
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
`event_type` varchar(32) NOT NULL COMMENT '事件类型CREATED/APPLY_STARTED/ITEM_APPLIED/ITEM_FAILED/BATCH_APPLIED/BATCH_FAILED/RETRY_STARTED',
`status_from` varchar(16) DEFAULT NULL COMMENT '状态变更前',
`status_to` varchar(16) DEFAULT NULL COMMENT '状态变更后',
`message` varchar(512) DEFAULT NULL COMMENT '事件说明',
`payload` text COMMENT '事件载荷JSON',
`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,
KEY `idx_deduct_log_batch_time` (`batch_id`, `created_time`) USING BTREE,
KEY `idx_deduct_log_tenant_batch` (`tenant_id`, `batch_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益批量扣减日志';

View File

@@ -0,0 +1,564 @@
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.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
import com.starry.admin.modules.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.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.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MvcResult;
/**
* Authorization contract tests for admin batch deductions.
*/
class AdminEarningsDeductionBatchAuthorizationApiTest 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_CREATE = "withdraw:deduction:create";
private static final String PERMISSION_READ = "withdraw:deduction:read";
private static final String BASE_URL = "/admin/earnings/deductions";
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private IPlayPersonnelGroupInfoService groupInfoService;
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
@Autowired
private IPlayOrderInfoService orderInfoService;
@Autowired
private IEarningsService earningsService;
@Autowired
private IEarningsAdjustmentService adjustmentService;
private final List<String> groupIdsToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = new ArrayList<>();
private final List<String> orderIdsToCleanup = new ArrayList<>();
private final List<String> earningsIdsToCleanup = new ArrayList<>();
private final List<String> batchIdsToCleanup = new ArrayList<>();
@BeforeEach
void setUp() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
groupIdsToCleanup.clear();
clerkIdsToCleanup.clear();
orderIdsToCleanup.clear();
earningsIdsToCleanup.clear();
batchIdsToCleanup.clear();
}
@AfterEach
void tearDown() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
cleanupBatches(batchIdsToCleanup);
if (!earningsIdsToCleanup.isEmpty()) {
earningsService.removeByIds(earningsIdsToCleanup);
}
if (!orderIdsToCleanup.isEmpty()) {
orderInfoService.removeByIds(orderIdsToCleanup);
}
if (!clerkIdsToCleanup.isEmpty()) {
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
}
if (!groupIdsToCleanup.isEmpty()) {
groupInfoService.removeByIds(groupIdsToCleanup);
}
}
@Test
void previewWithoutPermissionReturns403() throws Exception {
String payload = "{\"beginTime\":\"2026-01-01 00:00:00\",\"endTime\":\"2026-01-07 23:59:59\"," +
"\"ruleType\":\"FIXED\",\"amount\":\"10.00\",\"operation\":\"BONUS\",\"reasonDescription\":\"x\"}";
mockMvc.perform(post(BASE_URL + "/preview")
.header(USER_HEADER, "leader-no-perm")
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isForbidden());
}
@Test
void createWithoutPermissionReturns403() throws Exception {
seedOrderAndLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now().minusDays(1), new BigDecimal("100.00"));
String key = UUID.randomUUID().toString();
String payload = buildFixedPayload(LocalDateTime.now().minusDays(7), LocalDateTime.now(), "10.00");
mockMvc.perform(post(BASE_URL)
.header(USER_HEADER, "leader-no-perm")
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(IDEMPOTENCY_HEADER, key)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isForbidden());
}
@Test
void previewLeaderWithPermissionCannotManageOtherGroupClerkReturns403() throws Exception {
String leaderUserId = "leader-preview-" + IdUtils.getUuid();
String otherClerkId = seedClerkInOtherGroup(
"other-group-" + IdUtils.getUuid(),
"other-leader-" + IdUtils.getUuid(),
"Other Group",
otherLeaderId());
String payload = "{" +
"\"clerkIds\":[\"" + otherClerkId + "\"]," +
"\"beginTime\":\"2026-01-01 00:00:00\"," +
"\"endTime\":\"2026-01-07 23:59:59\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"scope\"" +
"}";
mockMvc.perform(post(BASE_URL + "/preview")
.header(USER_HEADER, leaderUserId)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSION_CREATE)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isForbidden());
}
@Test
void leaderWithPermissionCanManageOwnGroupClerkReturns202() throws Exception {
String leaderUserId = "leader-own-" + IdUtils.getUuid();
String groupId = "group-own-" + IdUtils.getUuid();
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
group.setId(groupId);
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
group.setSysUserId(leaderUserId);
group.setSysUserCode(leaderUserId);
group.setGroupName("Leader Group");
group.setLeaderName(leaderUserId);
group.setAddTime(LocalDateTime.now());
groupInfoService.save(group);
groupIdsToCleanup.add(groupId);
String clerkId = "clerk-own-" + IdUtils.getUuid();
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
clerk.setId(clerkId);
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
clerk.setOpenid("openid-" + clerkId);
clerk.setNickname("Own 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-" + 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-" + IdUtils.getUuid());
clerkUserInfoService.save(clerk);
clerkIdsToCleanup.add(clerkId);
seedOrderAndLine(clerkId, LocalDateTime.now().minusDays(1), new BigDecimal("100.00"));
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + clerkId + "\"]," +
"\"beginTime\":\"2026-01-01 00:00:00\"," +
"\"endTime\":\"2026-01-07 23:59:59\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"own\"" +
"}";
MvcResult result = mockMvc.perform(post(BASE_URL)
.header(USER_HEADER, leaderUserId)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSION_CREATE + "," + PERMISSION_READ)
.header(IDEMPOTENCY_HEADER, key)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isAccepted())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
String batchId = root.path("data").path("batchId").asText();
if (batchId != null && !batchId.isEmpty()) {
batchIdsToCleanup.add(batchId);
}
}
@Test
void leaderWithPermissionCannotManageOtherGroupClerkReturns403() throws Exception {
String leaderUserId = "leader-deduct-" + IdUtils.getUuid();
String otherClerkId = seedClerkInOtherGroup("other-group-" + IdUtils.getUuid(), "other-leader-" + IdUtils.getUuid(), "Other Group", otherLeaderId());
// Ensure the other clerk has base so that authorization is the only blocker.
seedOrderAndLine(otherClerkId, LocalDateTime.now().minusDays(1), new BigDecimal("100.00"));
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + otherClerkId + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(LocalDateTime.now().minusDays(7)) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(LocalDateTime.now().plusSeconds(1)) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"PUNISHMENT\"," +
"\"reasonDescription\":\"scope\"" +
"}";
mockMvc.perform(post(BASE_URL)
.header(USER_HEADER, leaderUserId)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSION_CREATE)
.header(IDEMPOTENCY_HEADER, key)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isForbidden());
}
@Test
void superAdminBypassesPermissionAndScopeReturns202() throws Exception {
String otherClerkId = seedClerkInOtherGroup("other-group-" + IdUtils.getUuid(), "other-leader-" + IdUtils.getUuid(), "Other Group", otherLeaderId());
seedOrderAndLine(otherClerkId, LocalDateTime.now().minusDays(1), new BigDecimal("100.00"));
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + otherClerkId + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(LocalDateTime.now().minusDays(7)) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(LocalDateTime.now().plusSeconds(1)) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"superadmin\"" +
"}";
MvcResult result = mockMvc.perform(post(BASE_URL)
.header(USER_HEADER, "super-admin-deduct")
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(SUPER_ADMIN_HEADER, "true")
.header(IDEMPOTENCY_HEADER, key)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isAccepted())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
String batchId = root.path("data").path("batchId").asText();
if (batchId != null && !batchId.isEmpty()) {
batchIdsToCleanup.add(batchId);
}
}
@Test
void readWithoutPermissionReturns403() throws Exception {
// Contract: read endpoints are protected even if the resource does not exist.
mockMvc.perform(get(BASE_URL + "/idempotency/" + UUID.randomUUID())
.header(USER_HEADER, "read-no-perm")
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isForbidden());
mockMvc.perform(get(BASE_URL + "/" + UUID.randomUUID() + "/items")
.header(USER_HEADER, "read-no-perm")
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isForbidden());
mockMvc.perform(get(BASE_URL + "/" + UUID.randomUUID() + "/logs")
.header(USER_HEADER, "read-no-perm")
.header(TENANT_HEADER, DEFAULT_TENANT))
.andExpect(status().isForbidden());
}
@Test
void leaderWithReadPermissionCannotReadOtherGroupBatchReturns403() throws Exception {
String leaderUserId = "leader-read-" + IdUtils.getUuid();
String otherClerkId = seedClerkInOtherGroup(
"other-group-" + IdUtils.getUuid(),
"other-leader-" + IdUtils.getUuid(),
"Other Group",
otherLeaderId());
String batchId = "batch-auth-" + IdUtils.getUuid();
batchIdsToCleanup.add(batchId);
seedBatchItemAndLogForOtherClerk(batchId, otherClerkId);
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
.header(USER_HEADER, leaderUserId)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSION_READ))
.andExpect(status().isForbidden());
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
.header(USER_HEADER, leaderUserId)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSION_READ))
.andExpect(status().isForbidden());
}
private String buildFixedPayload(LocalDateTime begin, LocalDateTime end, String amount) {
return "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"" + amount + "\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"auth\"" +
"}";
}
private void seedOrderAndLine(String clerkId, LocalDateTime endTime, BigDecimal amount) {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
String orderId = "order-deduct-auth-" + IdUtils.getUuid();
order.setId(orderId);
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
order.setOrderNo("AUTH-" + System.currentTimeMillis());
order.setOrderStatus("3");
order.setOrderType("2");
order.setPlaceType("0");
order.setRewardType("0");
order.setAcceptBy(clerkId);
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
order.setOrderMoney(new BigDecimal("120.50"));
order.setFinalAmount(order.getOrderMoney());
order.setEstimatedRevenue(amount);
order.setOrderSettlementState("1");
order.setOrderEndTime(endTime);
order.setOrderSettlementTime(endTime);
Date nowDate = toDate(LocalDateTime.now());
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setCreatedTime(nowDate);
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setUpdatedTime(nowDate);
order.setDeleted(false);
orderInfoService.save(order);
orderIdsToCleanup.add(orderId);
EarningsLineEntity line = new EarningsLineEntity();
String earningId = "earn-deduct-auth-" + IdUtils.getUuid();
line.setId(earningId);
line.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
line.setClerkId(clerkId);
line.setOrderId(orderId);
line.setSourceType(EarningsSourceType.ORDER);
line.setSourceId(orderId);
line.setAmount(amount);
line.setEarningType(EarningsType.ORDER);
line.setStatus("withdrawn");
line.setUnlockTime(LocalDateTime.now().minusHours(1));
line.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
line.setCreatedTime(nowDate);
line.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
line.setUpdatedTime(nowDate);
line.setDeleted(false);
earningsService.save(line);
earningsIdsToCleanup.add(earningId);
}
private String seedClerkInOtherGroup(String groupId, String leaderUserId, String groupName, String groupLeaderSysUserId) {
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
group.setId(groupId);
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
group.setSysUserId(groupLeaderSysUserId);
group.setSysUserCode(leaderUserId);
group.setGroupName(groupName);
group.setLeaderName(leaderUserId);
group.setAddTime(LocalDateTime.now());
groupInfoService.save(group);
groupIdsToCleanup.add(groupId);
String clerkId = "clerk-deduct-auth-" + IdUtils.getUuid();
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
clerk.setId(clerkId);
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
clerk.setOpenid("openid-" + 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-" + 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-" + IdUtils.getUuid());
clerkUserInfoService.save(clerk);
clerkIdsToCleanup.add(clerkId);
return clerkId;
}
private String otherLeaderId() {
return "leader-scope-" + IdUtils.getUuid();
}
private Date toDate(LocalDateTime time) {
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
}
private void cleanupBatches(List<String> batchIds) {
if (batchIds == null || batchIds.isEmpty()) {
return;
}
if (!tableExists("play_earnings_deduction_batch")) {
return;
}
for (String batchId : batchIds) {
cleanupBatch(batchId);
}
batchIds.clear();
}
private void cleanupBatch(String batchId) {
if (batchId == null || batchId.isEmpty()) {
return;
}
List<String> adjustmentIds = new ArrayList<>();
if (tableExists("play_earnings_deduction_item")) {
adjustmentIds = jdbcTemplate.queryForList(
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
String.class,
batchId);
}
if (!adjustmentIds.isEmpty()) {
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
.in(EarningsLineEntity::getSourceId, adjustmentIds)
.remove();
adjustmentService.removeByIds(adjustmentIds);
}
if (tableExists("play_earnings_deduction_batch_log")) {
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
}
if (tableExists("play_earnings_deduction_item")) {
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
}
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
}
private boolean tableExists(String table) {
try {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
Integer.class,
table);
return count != null && count > 0;
} catch (Exception ignored) {
return false;
}
}
private void seedBatchItemAndLogForOtherClerk(String batchId, String clerkId) {
Date now = new Date();
jdbcTemplate.update(
"insert into play_earnings_deduction_batch " +
"(id, tenant_id, status, begin_time, end_time, rule_type, rule_value, reason_description, idempotency_key, request_hash, created_by, created_time, updated_by, updated_time, deleted, version) " +
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
batchId,
ApiTestDataSeeder.DEFAULT_TENANT_ID,
"FAILED",
java.sql.Timestamp.valueOf(LocalDateTime.now().minusDays(7)),
java.sql.Timestamp.valueOf(LocalDateTime.now()),
"FIXED",
"10.00",
"seed",
"seed-" + batchId,
"seed",
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
0,
1);
jdbcTemplate.update(
"insert into play_earnings_deduction_item " +
"(id, batch_id, tenant_id, clerk_id, base_amount, apply_amount, status, adjustment_id, failure_reason, created_by, created_time, updated_by, updated_time, deleted, version) " +
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"item-" + IdUtils.getUuid(),
batchId,
ApiTestDataSeeder.DEFAULT_TENANT_ID,
clerkId,
"100.00",
"-10.00",
"FAILED",
null,
"seed failure",
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
0,
1);
jdbcTemplate.update(
"insert into play_earnings_deduction_batch_log " +
"(id, batch_id, tenant_id, event_type, status_from, status_to, message, payload, created_by, created_time, updated_by, updated_time, deleted, version) " +
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"log-" + IdUtils.getUuid(),
batchId,
ApiTestDataSeeder.DEFAULT_TENANT_ID,
"ITEM_FAILED",
"PROCESSING",
"FAILED",
"seed item failed",
"{\"clerkId\":\"" + clerkId + "\"}",
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
0,
1);
}
}

View File

@@ -0,0 +1,913 @@
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.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
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.enums.EarningsSourceType;
import com.starry.admin.modules.withdraw.enums.EarningsType;
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.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MvcResult;
/**
* End-to-end contract tests for admin batch deductions (bonus/punishment across clerks).
*
* <p>These tests are expected to FAIL until the batch deduction system is implemented end-to-end.</p>
*/
class AdminEarningsDeductionBatchControllerApiTest 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:deduction:create,withdraw:deduction:read";
private static final String BASE_URL = "/admin/earnings/deductions";
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private IPlayOrderInfoService orderInfoService;
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
@Autowired
private IEarningsService earningsService;
@Autowired
private IEarningsAdjustmentService adjustmentService;
private final List<String> ordersToCleanup = new ArrayList<>();
private final List<String> earningsToCleanup = new ArrayList<>();
private final List<String> batchIdsToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = new ArrayList<>();
@BeforeEach
void setUp() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
ordersToCleanup.clear();
earningsToCleanup.clear();
batchIdsToCleanup.clear();
}
@AfterEach
void tearDown() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
cleanupBatches(batchIdsToCleanup);
if (!earningsToCleanup.isEmpty()) {
earningsService.removeByIds(earningsToCleanup);
}
if (!ordersToCleanup.isEmpty()) {
orderInfoService.removeByIds(ordersToCleanup);
}
if (!clerkIdsToCleanup.isEmpty()) {
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
}
}
@Test
void previewRequiresRequiredFieldsReturns400() throws Exception {
mockMvc.perform(post(BASE_URL + "/preview")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
void previewPercentageUsesOnlyOrderLinesPositiveAmountsAndOrderEndTimeWindow() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String clerkId = ensureTestClerkInDefaultTenant();
// In window: order1 + order2
String order1 = seedOrder(clerkId, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(clerkId, order1, new BigDecimal("100.00"), "withdrawn", EarningsType.ORDER);
seedOrderEarningLine(clerkId, order1, new BigDecimal("-30.00"), "available", EarningsType.ADJUSTMENT);
String order2 = seedOrder(clerkId, now.minusDays(2), new BigDecimal("50.00"));
seedOrderEarningLine(clerkId, order2, new BigDecimal("50.00"), "available", EarningsType.ORDER);
// Out of window: order3 should not contribute
String order3 = seedOrder(clerkId, now.minusDays(30), new BigDecimal("999.00"));
seedOrderEarningLine(clerkId, order3, new BigDecimal("999.00"), "withdrawn", EarningsType.ORDER);
// Adjustment line (order_id=null) should not contribute even if positive
seedAdjustmentEarningLine(clerkId, new BigDecimal("1000.00"), "available");
String payload = "{" +
"\"clerkIds\":[\"" + clerkId + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"PERCENTAGE\"," +
"\"percentage\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"week bonus\"" +
"}";
MvcResult result = mockMvc.perform(post(BASE_URL + "/preview")
.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().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.items").isArray())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode item = root.path("data").path("items").get(0);
assertThat(item.path("clerkId").asText()).isEqualTo(clerkId);
BigDecimal baseAmount = new BigDecimal(item.path("baseAmount").asText("0"));
BigDecimal applyAmount = new BigDecimal(item.path("applyAmount").asText("0"));
assertThat(baseAmount).isEqualByComparingTo("150.00"); // 100 + 50 (negative & non-order excluded)
assertThat(applyAmount).isEqualByComparingTo("15.00"); // 10% bonus
}
@Test
void previewWindowIsInclusiveOnBoundaries() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.minusDays(1);
String clerkId = ensureTestClerkInDefaultTenant();
String orderBegin = seedOrder(clerkId, begin, new BigDecimal("20.00"));
seedOrderEarningLine(clerkId, orderBegin, new BigDecimal("20.00"), "withdrawn", EarningsType.ORDER);
String orderEnd = seedOrder(clerkId, end, new BigDecimal("30.00"));
seedOrderEarningLine(clerkId, orderEnd, new BigDecimal("30.00"), "withdrawn", EarningsType.ORDER);
String payload = "{" +
"\"clerkIds\":[\"" + clerkId + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"PERCENTAGE\"," +
"\"percentage\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"boundary\"" +
"}";
MvcResult result = mockMvc.perform(post(BASE_URL + "/preview")
.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().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
JsonNode item = root.path("data").path("items").get(0);
BigDecimal baseAmount = new BigDecimal(item.path("baseAmount").asText("0"));
assertThat(baseAmount).isEqualByComparingTo("50.00"); // 20 + 30 (both boundary-included)
}
@Test
void previewRejectsCrossTenantClerkScopeReturns403() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "withdrawn", EarningsType.ORDER);
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"PERCENTAGE\"," +
"\"percentage\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"tenant isolation\"" +
"}";
mockMvc.perform(post(BASE_URL + "/preview")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isForbidden());
}
@Test
void createReturns202AndProvidesPollingHandle() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"PERCENTAGE\"," +
"\"percentage\":\"10.00\"," +
"\"operation\":\"PUNISHMENT\"," +
"\"reasonDescription\":\"week penalty\"" +
"}";
MvcResult result = mockMvc.perform(post(BASE_URL)
.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", BASE_URL + "/idempotency/" + key))
.andExpect(jsonPath("$.code").value(202))
.andExpect(jsonPath("$.data.batchId").isNotEmpty())
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
.andExpect(jsonPath("$.data.status").value("PROCESSING"))
.andReturn();
String batchId = extractBatchId(result);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
}
@Test
void createIsIdempotentWithSameKeyAndSameBody() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"50.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"fixed bonus\"" +
"}";
MvcResult first = mockMvc.perform(post(BASE_URL)
.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();
String batchA = extractBatchId(first);
MvcResult second = mockMvc.perform(post(BASE_URL)
.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();
String batchB = extractBatchId(second);
assertThat(batchB).isEqualTo(batchA);
batchIdsToCleanup.add(batchA);
awaitApplied(key);
}
@Test
void createConcurrentRequestsSameKeyOnlyOneBatchCreated() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"concurrent\"" +
"}";
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<String> call = () -> {
MvcResult result = mockMvc.perform(post(BASE_URL)
.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();
return extractBatchId(result);
};
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);
batchIdsToCleanup.add(a);
awaitApplied(key);
} finally {
pool.shutdownNow();
}
}
@Test
void createSameKeyDifferentBodyReturns409() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payloadA = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"50.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"fixed bonus\"" +
"}";
String payloadB = payloadA.replace("\"50.00\"", "\"60.00\"");
MvcResult first = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(first);
batchIdsToCleanup.add(batchId);
mockMvc.perform(post(BASE_URL)
.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 pollMissingIdempotencyKeyReturns404() throws Exception {
mockMvc.perform(get(BASE_URL + "/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 {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"PUNISHMENT\"," +
"\"reasonDescription\":\"tenant scope\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
mockMvc.perform(get(BASE_URL + "/idempotency/" + key)
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andExpect(status().isNotFound());
}
@Test
void itemsAfterAppliedHaveAdjustmentIdAndNoDuplicateEarningsLines() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"50.00\"," +
"\"operation\":\"PUNISHMENT\"," +
"\"reasonDescription\":\"fixed penalty\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
MvcResult items = mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.param("pageNum", "1")
.param("pageSize", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].adjustmentId").isNotEmpty())
.andReturn();
JsonNode root = objectMapper.readTree(items.getResponse().getContentAsString());
JsonNode first = root.path("data").get(0);
String adjustmentId = first.path("adjustmentId").asText();
assertThat(adjustmentId).isNotBlank();
long count = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
.eq(EarningsLineEntity::getSourceId, adjustmentId)
.count();
assertThat(count).isEqualTo(1);
}
@Test
void itemsPaginationWorks() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String secondClerkId = ensureTestClerkInDefaultTenant();
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String order2 = seedOrder(secondClerkId, now.minusDays(1), new BigDecimal("80.00"));
seedOrderEarningLine(secondClerkId, order2, new BigDecimal("80.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\",\"" + secondClerkId + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"pagination\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.param("pageNum", "1")
.param("pageSize", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.total").value(2));
}
@Test
void logsContainCreatedAndFinalEvents() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"audit log\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
.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").isArray())
.andExpect(jsonPath("$.data[?(@.eventType=='CREATED')]").exists())
.andExpect(jsonPath("$.data[?(@.eventType=='APPLY_STARTED')]").exists())
.andExpect(jsonPath("$.data[?(@.eventType=='BATCH_APPLIED')]").exists());
}
@Test
void itemsAndLogsAreTenantScopedToBatchTenant() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"tenant scope items\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.param("pageNum", "1")
.param("pageSize", "10"))
.andExpect(status().isNotFound());
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andExpect(status().isNotFound());
}
@Test
void logsRecordOperatorInCreatedBy() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"operator audit\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
MvcResult logs = mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
.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(logs.getResponse().getContentAsString());
JsonNode data = root.path("data");
boolean hasOperator = false;
for (JsonNode node : data) {
String createdBy = node.path("createdBy").asText();
if (ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID.equals(createdBy)) {
hasOperator = true;
break;
}
}
assertThat(hasOperator).isTrue();
}
private String extractBatchId(MvcResult result) throws Exception {
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
return root.path("data").path("batchId").asText();
}
private String awaitApplied(String idempotencyKey) throws Exception {
for (int i = 0; i < 120; i++) {
MvcResult poll = mockMvc.perform(get(BASE_URL + "/idempotency/" + idempotencyKey)
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andReturn();
if (poll.getResponse().getStatus() == 200) {
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
String status = root.path("data").path("status").asText();
if ("APPLIED".equals(status)) {
return root.path("data").path("batchId").asText();
}
if ("FAILED".equals(status)) {
throw new AssertionError("batch failed unexpectedly: key=" + idempotencyKey);
}
}
Thread.sleep(50);
}
throw new AssertionError("batch not applied within timeout: key=" + idempotencyKey);
}
private String seedOrder(String clerkId, LocalDateTime endTime, BigDecimal estimatedRevenue) {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
String id = "order-deduct-" + IdUtils.getUuid();
order.setId(id);
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
order.setOrderNo("DED-" + System.currentTimeMillis());
order.setOrderStatus("3");
order.setOrderType("2");
order.setPlaceType("0");
order.setRewardType("0");
order.setAcceptBy(clerkId);
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
order.setOrderMoney(new BigDecimal("120.50"));
order.setFinalAmount(order.getOrderMoney());
order.setEstimatedRevenue(estimatedRevenue);
order.setOrderSettlementState("1");
order.setOrderEndTime(endTime);
order.setOrderSettlementTime(endTime);
Date nowDate = toDate(LocalDateTime.now());
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setCreatedTime(nowDate);
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setUpdatedTime(nowDate);
order.setDeleted(false);
orderInfoService.save(order);
ordersToCleanup.add(id);
return id;
}
private void seedOrderEarningLine(String clerkId, String orderId, BigDecimal amount, String status, EarningsType earningType) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-deduct-" + IdUtils.getUuid();
entity.setId(id);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(clerkId);
entity.setOrderId(orderId);
entity.setSourceType(EarningsSourceType.ORDER);
entity.setSourceId(orderId);
entity.setAmount(amount);
entity.setEarningType(earningType);
entity.setStatus(status);
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
entity.setDeleted(false);
earningsService.save(entity);
earningsToCleanup.add(id);
}
private void seedAdjustmentEarningLine(String clerkId, BigDecimal amount, String status) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-deduct-adj-" + IdUtils.getUuid();
entity.setId(id);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(clerkId);
entity.setOrderId(null);
entity.setSourceType(EarningsSourceType.ADJUSTMENT);
entity.setSourceId("adj-seed-" + IdUtils.getUuid());
entity.setAmount(amount);
entity.setEarningType(EarningsType.ADJUSTMENT);
entity.setStatus(status);
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
entity.setDeleted(false);
earningsService.save(entity);
earningsToCleanup.add(id);
}
private Date toDate(LocalDateTime time) {
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
}
private void cleanupBatches(List<String> batchIds) {
if (batchIds == null || batchIds.isEmpty()) {
return;
}
if (!tableExists("play_earnings_deduction_batch")) {
return;
}
for (String batchId : batchIds) {
cleanupBatch(batchId);
}
batchIds.clear();
}
private void cleanupBatch(String batchId) {
if (batchId == null || batchId.isEmpty()) {
return;
}
List<String> adjustmentIds = new ArrayList<>();
if (tableExists("play_earnings_deduction_item")) {
adjustmentIds = jdbcTemplate.queryForList(
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
String.class,
batchId);
}
if (!adjustmentIds.isEmpty()) {
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
.in(EarningsLineEntity::getSourceId, adjustmentIds)
.remove();
adjustmentService.removeByIds(adjustmentIds);
}
if (tableExists("play_earnings_deduction_batch_log")) {
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
}
if (tableExists("play_earnings_deduction_item")) {
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
}
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
}
private boolean tableExists(String table) {
try {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
Integer.class,
table);
return count != null && count > 0;
} catch (Exception ignored) {
return false;
}
}
private String ensureTestClerkInDefaultTenant() {
String clerkId = "clerk-deduct-" + IdUtils.getUuid();
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
clerk.setId(clerkId);
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
clerk.setOpenid("openid-" + clerkId);
clerk.setNickname("Batch Clerk");
clerk.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
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-" + 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-" + IdUtils.getUuid());
clerkUserInfoService.save(clerk);
clerkIdsToCleanup.add(clerkId);
return clerkId;
}
}

View File

@@ -0,0 +1,416 @@
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.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
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.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.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MvcResult;
/**
* End-to-end retry/idempotency contract tests for batch deductions.
*
* <p>These tests are expected to FAIL until retry semantics are implemented.</p>
*/
class AdminEarningsDeductionBatchRetryApiTest 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:deduction:create,withdraw:deduction:read";
private static final String BASE_URL = "/admin/earnings/deductions";
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private IPlayOrderInfoService orderInfoService;
@Autowired
private IEarningsService earningsService;
@Autowired
private IEarningsAdjustmentService adjustmentService;
private final List<String> ordersToCleanup = new ArrayList<>();
private final List<String> earningsToCleanup = new ArrayList<>();
private final List<String> adjustmentsToCleanup = new ArrayList<>();
private final List<String> batchIdsToCleanup = new ArrayList<>();
@BeforeEach
void setUp() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
ordersToCleanup.clear();
earningsToCleanup.clear();
adjustmentsToCleanup.clear();
batchIdsToCleanup.clear();
}
@AfterEach
void tearDown() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
cleanupBatches(batchIdsToCleanup);
if (!earningsToCleanup.isEmpty()) {
earningsService.removeByIds(earningsToCleanup);
}
if (!ordersToCleanup.isEmpty()) {
orderInfoService.removeByIds(ordersToCleanup);
}
if (!adjustmentsToCleanup.isEmpty()) {
adjustmentService.removeByIds(adjustmentsToCleanup);
}
}
@Test
void retryNoopWhenAllItemsAppliedDoesNotCreateDuplicateLines() throws Exception {
LocalDateTime now = LocalDateTime.now().withNano(0);
LocalDateTime begin = now.minusDays(7);
LocalDateTime end = now.plusSeconds(1);
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
"\"ruleType\":\"FIXED\"," +
"\"amount\":\"10.00\"," +
"\"operation\":\"BONUS\"," +
"\"reasonDescription\":\"retry noop\"" +
"}";
MvcResult create = mockMvc.perform(post(BASE_URL)
.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();
String batchId = extractBatchId(create);
batchIdsToCleanup.add(batchId);
awaitApplied(key);
String adjustmentId = fetchFirstItemAdjustmentId(batchId);
long before = countEarningsLinesForAdjustment(adjustmentId);
mockMvc.perform(post(BASE_URL + "/" + batchId + "/retry")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andExpect(status().isOk());
long after = countEarningsLinesForAdjustment(adjustmentId);
assertThat(after).isEqualTo(before);
}
@Test
void retryCanRecoverFailedItemByReapplyingSameAdjustmentId() throws Exception {
// Seed a FAILED adjustment + a FAILED batch item referencing that adjustment.
// Contract: retry should reset FAILED adjustments/items and re-apply, without creating duplicates.
LocalDateTime now = LocalDateTime.now().withNano(0);
String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
String batchId = "batch-retry-" + IdUtils.getUuid();
batchIdsToCleanup.add(batchId);
EarningsLineAdjustmentEntity adjustment = new EarningsLineAdjustmentEntity();
String adjustmentId = "adj-retry-" + IdUtils.getUuid();
adjustment.setId(adjustmentId);
adjustment.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
adjustment.setClerkId(clerkId);
adjustment.setAmount(new BigDecimal("-10.00"));
adjustment.setReasonType(EarningsAdjustmentReasonType.MANUAL);
adjustment.setReasonDescription("seed failed adjustment");
adjustment.setStatus(EarningsAdjustmentStatus.FAILED);
adjustment.setIdempotencyKey("deduct:" + batchId + ":" + clerkId);
adjustment.setRequestHash("seed");
adjustment.setEffectiveTime(now);
adjustmentService.save(adjustment);
adjustmentsToCleanup.add(adjustmentId);
seedBatchAndItemFailed(batchId, clerkId, new BigDecimal("100.00"), new BigDecimal("-10.00"), adjustmentId);
mockMvc.perform(post(BASE_URL + "/" + batchId + "/retry")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andExpect(status().isOk());
awaitBatchApplied(batchId);
EarningsLineAdjustmentEntity refreshed = adjustmentService.getById(adjustmentId);
assertThat(refreshed).isNotNull();
assertThat(refreshed.getStatus()).isEqualTo(EarningsAdjustmentStatus.APPLIED);
assertThat(countEarningsLinesForAdjustment(adjustmentId)).isEqualTo(1);
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[?(@.eventType=='RETRY_STARTED')]").exists());
}
private String extractBatchId(MvcResult result) throws Exception {
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
return root.path("data").path("batchId").asText();
}
private String awaitApplied(String idempotencyKey) throws Exception {
for (int i = 0; i < 120; i++) {
MvcResult poll = mockMvc.perform(get(BASE_URL + "/idempotency/" + idempotencyKey)
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andReturn();
if (poll.getResponse().getStatus() == 200) {
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
String status = root.path("data").path("status").asText();
if ("APPLIED".equals(status)) {
return root.path("data").path("batchId").asText();
}
}
Thread.sleep(50);
}
throw new AssertionError("batch not applied within timeout: key=" + idempotencyKey);
}
private void awaitBatchApplied(String batchId) throws Exception {
for (int i = 0; i < 120; i++) {
MvcResult poll = mockMvc.perform(get(BASE_URL + "/" + batchId)
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andReturn();
if (poll.getResponse().getStatus() == 200) {
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
String status = root.path("data").path("status").asText();
if ("APPLIED".equals(status)) {
return;
}
}
Thread.sleep(50);
}
throw new AssertionError("batch not applied within timeout: batchId=" + batchId);
}
private String fetchFirstItemAdjustmentId(String batchId) throws Exception {
MvcResult items = mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.param("pageNum", "1")
.param("pageSize", "10"))
.andExpect(status().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(items.getResponse().getContentAsString());
return root.path("data").get(0).path("adjustmentId").asText();
}
private long countEarningsLinesForAdjustment(String adjustmentId) {
if (adjustmentId == null || adjustmentId.isEmpty()) {
return 0;
}
return earningsService.lambdaQuery()
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
.eq(EarningsLineEntity::getSourceId, adjustmentId)
.count();
}
private void seedBatchAndItemFailed(String batchId, String clerkId, BigDecimal baseAmount, BigDecimal applyAmount, String adjustmentId) {
Date now = new Date();
// Batch
jdbcTemplate.update(
"insert into play_earnings_deduction_batch " +
"(id, tenant_id, status, begin_time, end_time, rule_type, rule_value, reason_description, idempotency_key, request_hash, created_by, created_time, updated_by, updated_time, deleted, version) " +
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
batchId,
ApiTestDataSeeder.DEFAULT_TENANT_ID,
"FAILED",
java.sql.Timestamp.valueOf(LocalDateTime.now().minusDays(7)),
java.sql.Timestamp.valueOf(LocalDateTime.now()),
"FIXED",
applyAmount.abs().setScale(2).toPlainString(),
"seed batch",
"seed-" + batchId,
"seed",
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
0,
1);
// Item
String itemId = "item-retry-" + IdUtils.getUuid();
jdbcTemplate.update(
"insert into play_earnings_deduction_item " +
"(id, batch_id, tenant_id, clerk_id, base_amount, apply_amount, status, adjustment_id, failure_reason, created_by, created_time, updated_by, updated_time, deleted, version) " +
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
itemId,
batchId,
ApiTestDataSeeder.DEFAULT_TENANT_ID,
clerkId,
baseAmount.setScale(2).toPlainString(),
applyAmount.setScale(2).toPlainString(),
"FAILED",
adjustmentId,
"seed failure",
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
now,
0,
1);
}
private String seedOrder(String clerkId, LocalDateTime endTime, BigDecimal estimatedRevenue) {
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
String id = "order-deduct-retry-" + IdUtils.getUuid();
order.setId(id);
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
order.setOrderNo("RET-" + System.currentTimeMillis());
order.setOrderStatus("3");
order.setOrderType("2");
order.setPlaceType("0");
order.setRewardType("0");
order.setAcceptBy(clerkId);
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
order.setOrderMoney(new BigDecimal("120.50"));
order.setFinalAmount(order.getOrderMoney());
order.setEstimatedRevenue(estimatedRevenue);
order.setOrderSettlementState("1");
order.setOrderEndTime(endTime);
order.setOrderSettlementTime(endTime);
Date nowDate = toDate(LocalDateTime.now());
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setCreatedTime(nowDate);
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
order.setUpdatedTime(nowDate);
order.setDeleted(false);
orderInfoService.save(order);
ordersToCleanup.add(id);
return id;
}
private void seedOrderEarningLine(String clerkId, String orderId, BigDecimal amount, String status, EarningsType earningType) {
EarningsLineEntity entity = new EarningsLineEntity();
String id = "earn-deduct-retry-" + IdUtils.getUuid();
entity.setId(id);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(clerkId);
entity.setOrderId(orderId);
entity.setSourceType(EarningsSourceType.ORDER);
entity.setSourceId(orderId);
entity.setAmount(amount);
entity.setEarningType(earningType);
entity.setStatus(status);
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
Date nowDate = toDate(LocalDateTime.now());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(nowDate);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(nowDate);
entity.setDeleted(false);
earningsService.save(entity);
earningsToCleanup.add(id);
}
private Date toDate(LocalDateTime time) {
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
}
private void cleanupBatches(List<String> batchIds) {
if (batchIds == null || batchIds.isEmpty()) {
return;
}
if (!tableExists("play_earnings_deduction_batch")) {
return;
}
for (String batchId : batchIds) {
cleanupBatch(batchId);
}
batchIds.clear();
}
private void cleanupBatch(String batchId) {
if (batchId == null || batchId.isEmpty()) {
return;
}
List<String> adjustmentIds = new ArrayList<>();
if (tableExists("play_earnings_deduction_item")) {
adjustmentIds = jdbcTemplate.queryForList(
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
String.class,
batchId);
}
if (!adjustmentIds.isEmpty()) {
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
.in(EarningsLineEntity::getSourceId, adjustmentIds)
.remove();
adjustmentService.removeByIds(adjustmentIds);
}
if (tableExists("play_earnings_deduction_batch_log")) {
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
}
if (tableExists("play_earnings_deduction_item")) {
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
}
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
}
private boolean tableExists(String table) {
try {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
Integer.class,
table);
return count != null && count > 0;
} catch (Exception ignored) {
return false;
}
}
}

View File

@@ -0,0 +1,128 @@
package com.starry.admin.api;
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.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.utils.IdUtils;
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.context.TestPropertySource;
@TestPropertySource(properties = "test.auth.secret=apitest-secret")
class WxOauthTestAuthApiTest extends AbstractApiTest {
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
private static final String TEST_AUTH_SECRET = "apitest-secret";
private static final String DEV_FIXED_CLERK_ID = "a4471ef596a1";
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
@BeforeEach
void setUp() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
ensureDevFixedClerkExists();
}
@Test
void clerkLoginByIdRejectsWithoutSecretHeader() throws Exception {
mockMvc.perform(post("/wx/oauth2/clerk/loginById")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header("User-Agent", "apitest")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403));
}
@Test
void clerkLoginByIdReturnsTokenWhenSecretHeaderValid() throws Exception {
mockMvc.perform(post("/wx/oauth2/clerk/loginById")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header("User-Agent", "apitest")
.header(TEST_AUTH_HEADER, TEST_AUTH_SECRET)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.tokenName").value(Constants.CLERK_USER_LOGIN_TOKEN))
.andExpect(jsonPath("$.data.tokenValue").value(org.hamcrest.Matchers.startsWith(Constants.TOKEN_PREFIX)));
}
@Test
void clerkLoginDevRejectsWithoutSecretHeader() throws Exception {
mockMvc.perform(post("/wx/oauth2/clerk/login/dev")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header("User-Agent", "apitest")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"test\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403));
}
@Test
void clerkLoginDevReturnsTokenWhenSecretHeaderValid() throws Exception {
mockMvc.perform(post("/wx/oauth2/clerk/login/dev")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header("User-Agent", "apitest")
.header(TEST_AUTH_HEADER, TEST_AUTH_SECRET)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"code\":\"test\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.tokenName").value(Constants.CLERK_USER_LOGIN_TOKEN))
.andExpect(jsonPath("$.data.tokenValue").value(org.hamcrest.Matchers.startsWith(Constants.TOKEN_PREFIX)));
}
private void ensureDevFixedClerkExists() {
PlayClerkUserInfoEntity existing = clerkUserInfoService.getById(DEV_FIXED_CLERK_ID);
if (existing != null) {
return;
}
PlayClerkUserInfoEntity template = clerkUserInfoService.getById(ApiTestDataSeeder.DEFAULT_CLERK_ID);
if (template == null) {
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
entity.setId(DEV_FIXED_CLERK_ID);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setOpenid("openid-dev-" + IdUtils.getUuid());
entity.setNickname("Dev Login Clerk");
entity.setAvatar("https://example.com/avatar.png");
entity.setSysUserId("");
entity.setOnboardingState("1");
entity.setListingState("1");
entity.setClerkState("1");
entity.setOnlineState("1");
entity.setToken("empty");
entity.setDeleted(Boolean.FALSE);
clerkUserInfoService.save(entity);
return;
}
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
entity.setId(DEV_FIXED_CLERK_ID);
entity.setTenantId(template.getTenantId());
entity.setOpenid("openid-dev-" + IdUtils.getUuid());
entity.setNickname(template.getNickname());
entity.setAvatar(template.getAvatar());
entity.setSysUserId(template.getSysUserId());
entity.setOnboardingState("1");
entity.setListingState("1");
entity.setClerkState("1");
entity.setOnlineState("1");
entity.setToken("empty");
entity.setDeleted(Boolean.FALSE);
clerkUserInfoService.save(entity);
}
}

View File

@@ -174,11 +174,13 @@ class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest {
void earningsListIncludesAdjustmentLineAfterApplied() throws Exception { void earningsListIncludesAdjustmentLineAfterApplied() throws Exception {
ensureTenantContext(); ensureTenantContext();
String key = UUID.randomUUID().toString(); String key = UUID.randomUUID().toString();
String effectiveTime = "2026-01-01T00:00:00";
String payload = "{" + String payload = "{" +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"amount\":\"12.34\"," + "\"amount\":\"12.34\"," +
"\"reasonType\":\"MANUAL\"," + "\"reasonType\":\"MANUAL\"," +
"\"reasonDescription\":\"show in list\"" + "\"reasonDescription\":\"show in list\"," +
"\"effectiveTime\":\"" + effectiveTime + "\"" +
"}"; "}";
mockMvc.perform(post("/admin/earnings/adjustments") mockMvc.perform(post("/admin/earnings/adjustments")
@@ -209,7 +211,10 @@ class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest {
if (rows.isArray()) { if (rows.isArray()) {
for (JsonNode row : rows) { for (JsonNode row : rows) {
if ("ADJUSTMENT".equals(row.path("earningType").asText()) if ("ADJUSTMENT".equals(row.path("earningType").asText())
&& "12.34".equals(row.path("amount").asText())) { && "12.34".equals(row.path("amount").asText())
&& "MANUAL".equals(row.path("adjustmentReasonType").asText())
&& "show in list".equals(row.path("adjustmentReasonDescription").asText())
&& "2026-01-01 00:00:00".equals(row.path("adjustmentEffectiveTime").asText())) {
found = true; found = true;
break; break;
} }

View File

@@ -0,0 +1,108 @@
package com.starry.admin.db;
import static org.assertj.core.api.Assertions.assertThat;
import com.starry.admin.api.AbstractApiTest;
import java.util.List;
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 batch deductions system schema.
*
* <p>These tests are expected to FAIL until migrations add the new tables/indexes.</p>
*/
class EarningsDeductionBatchDatabaseSchemaApiTest extends AbstractApiTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void batchTablesExist() {
assertThat(tableExists("play_earnings_deduction_batch")).isTrue();
assertThat(tableExists("play_earnings_deduction_item")).isTrue();
assertThat(tableExists("play_earnings_deduction_batch_log")).isTrue();
}
@Test
void batchTableHasIdempotencyAndWindowFields() {
assertThat(columnExists("play_earnings_deduction_batch", "tenant_id")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch", "status")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch", "idempotency_key")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch", "request_hash")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch", "begin_time")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch", "end_time")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch", "rule_type")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch", "rule_value")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch", "reason_description")).isTrue();
}
@Test
void itemTableHasAmountsAndAdjustmentLink() {
assertThat(columnExists("play_earnings_deduction_item", "batch_id")).isTrue();
assertThat(columnExists("play_earnings_deduction_item", "tenant_id")).isTrue();
assertThat(columnExists("play_earnings_deduction_item", "clerk_id")).isTrue();
assertThat(columnExists("play_earnings_deduction_item", "base_amount")).isTrue();
assertThat(columnExists("play_earnings_deduction_item", "apply_amount")).isTrue();
assertThat(columnExists("play_earnings_deduction_item", "status")).isTrue();
assertThat(columnExists("play_earnings_deduction_item", "adjustment_id")).isTrue();
assertThat(columnExists("play_earnings_deduction_item", "failure_reason")).isTrue();
}
@Test
void logTableHasEventFields() {
assertThat(columnExists("play_earnings_deduction_batch_log", "batch_id")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch_log", "tenant_id")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch_log", "event_type")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch_log", "status_from")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch_log", "status_to")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch_log", "message")).isTrue();
assertThat(columnExists("play_earnings_deduction_batch_log", "payload")).isTrue();
}
@Test
void uniqueIndexExistsForTenantIdempotencyKey() {
assertThat(indexExists("play_earnings_deduction_batch", "uk_tenant_idempotency")).isTrue();
}
@Test
void uniqueIndexExistsForBatchClerk() {
assertThat(indexExists("play_earnings_deduction_item", "uk_batch_clerk")).isTrue();
}
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;
}
private boolean indexExists(String table, String indexName) {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from information_schema.statistics " +
"where lower(table_name)=lower(?) and lower(index_name)=lower(?)",
Integer.class,
table,
indexName);
return count != null && count > 0;
}
@SuppressWarnings("unused")
private List<String> listIndexes(String table) {
return jdbcTemplate.queryForList(
"select distinct index_name from information_schema.statistics where lower(table_name)=lower(?)",
String.class,
table);
}
}