diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java index 752bc0d..9e568a0 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxOauthController.java @@ -21,6 +21,7 @@ import com.starry.admin.modules.weichat.service.WxTokenService; import com.starry.admin.utils.SecurityUtils; import com.starry.common.redis.RedisCache; import com.starry.common.result.R; +import com.starry.common.result.ResultCodeEnum; import io.swagger.annotations.Api; import io.swagger.annotations.ApiImplicitParam; import io.swagger.annotations.ApiOperation; @@ -28,10 +29,16 @@ import io.swagger.annotations.ApiParam; import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponses; import java.util.Date; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import me.chanjar.weixin.common.bean.WxJsapiSignature; 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.web.bind.annotation.*; @@ -60,6 +67,38 @@ public class WxOauthController { @Resource 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 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配置签名") @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = WxJsapiSignature.class)}) @PostMapping("/getConfigAddress") @@ -130,7 +169,12 @@ public class WxOauthController { @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = JSONObject.class), @ApiResponse(code = 401, message = "登录失败"), @ApiResponse(code = 500, message = "用户不存在")}) @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 { String userId = "a4471ef596a1"; PlayClerkUserInfoEntity entity = clerkUserInfoService.selectById(userId); @@ -162,7 +206,12 @@ public class WxOauthController { @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = JSONObject.class), @ApiResponse(code = 500, message = "用户不存在")}) @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()); if (entity == null) { throw new CustomException("用户不存在"); diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminEarningsDeductionBatchController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminEarningsDeductionBatchController.java new file mode 100644 index 0000000..09c9d67 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminEarningsDeductionBatchController.java @@ -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 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 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> preview(@RequestBody DeductionRequest body) { + if (body == null) { + return ResponseEntity.badRequest().body(TypedR.error(400, "body required")); + } + List 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 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> 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 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 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> 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> 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>> 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 page = batchService.pageItems(tenantId, batchId, pageNum, pageSize); + return ResponseEntity.ok(TypedR.okPage(page)); + } + + @GetMapping("/{batchId}/logs") + @PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')") + public ResponseEntity>> 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 logs = batchService.listLogs(tenantId, batchId); + return ResponseEntity.ok(TypedR.ok(logs)); + } + + @PostMapping("/{batchId}/retry") + @PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')") + public ResponseEntity> 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 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 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 normalizeClerkIds(List input) { + List 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; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java index bc617d2..3f342f7 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java @@ -8,14 +8,18 @@ import com.starry.admin.common.conf.ThreadLocalRequestDetail; import com.starry.admin.common.exception.CustomException; 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.entity.WithdrawalLogEntity; 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.IWithdrawalLogService; import com.starry.admin.modules.withdraw.service.IWithdrawalService; import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo; import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo; +import com.starry.admin.utils.SecurityUtils; import com.starry.common.result.TypedR; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -40,6 +44,8 @@ public class WxWithdrawController { @Resource private IEarningsService earningsService; @Resource + private IEarningsAdjustmentService adjustmentService; + @Resource private IWithdrawalService withdrawalService; @Resource private IWithdrawalLogService withdrawalLogService; @@ -101,6 +107,21 @@ public class WxWithdrawController { .list() .stream() .collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it)); + + List adjustmentIds = records.stream() + .filter(line -> line.getSourceType() == EarningsSourceType.ADJUSTMENT) + .map(EarningsLineEntity::getSourceId) + .filter(id -> id != null && !id.isEmpty()) + .distinct() + .collect(Collectors.toList()); + Map 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) { ClerkEarningLineVo vo = new ClerkEarningLineVo(); vo.setId(line.getId()); @@ -111,6 +132,14 @@ public class WxWithdrawController { vo.setUnlockTime(line.getUnlockTime()); vo.setCreatedTime(toLocalDateTime(line.getCreatedTime())); 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) { PlayOrderInfoEntity order = orderMap.get(line.getOrderId()); if (order != null) { diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionBatchEntity.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionBatchEntity.java new file mode 100644 index 0000000..f63a481 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionBatchEntity.java @@ -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 { + 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; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionBatchLogEntity.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionBatchLogEntity.java new file mode 100644 index 0000000..50a8c40 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionBatchLogEntity.java @@ -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 { + private String id; + private String batchId; + private String tenantId; + private EarningsDeductionLogEventType eventType; + private String statusFrom; + private String statusTo; + private String message; + private String payload; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionItemEntity.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionItemEntity.java new file mode 100644 index 0000000..7b0167e --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsDeductionItemEntity.java @@ -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 { + 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; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionLogEventType.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionLogEventType.java new file mode 100644 index 0000000..14bc505 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionLogEventType.java @@ -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; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionOperationType.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionOperationType.java new file mode 100644 index 0000000..b63be53 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionOperationType.java @@ -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; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionRuleType.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionRuleType.java new file mode 100644 index 0000000..2c51d0f --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionRuleType.java @@ -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; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionStatus.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionStatus.java new file mode 100644 index 0000000..19cd2f2 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsDeductionStatus.java @@ -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; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionBatchLogMapper.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionBatchLogMapper.java new file mode 100644 index 0000000..001e713 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionBatchLogMapper.java @@ -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 {} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionBatchMapper.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionBatchMapper.java new file mode 100644 index 0000000..c3e895c --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionBatchMapper.java @@ -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 {} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionItemMapper.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionItemMapper.java new file mode 100644 index 0000000..b05acd2 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsDeductionItemMapper.java @@ -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 { + + @Select("") + BigDecimal sumOrderPositiveBase(@Param("tenantId") String tenantId, + @Param("clerkId") String clerkId, + @Param("begin") LocalDateTime begin, + @Param("end") LocalDateTime end); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsDeductionBatchService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsDeductionBatchService.java new file mode 100644 index 0000000..ef633a1 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsDeductionBatchService.java @@ -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 createOrGetProcessing( + String tenantId, + List clerkIds, + LocalDateTime beginTime, + LocalDateTime endTime, + EarningsDeductionRuleType ruleType, + BigDecimal ruleValue, + EarningsDeductionOperationType operation, + String reasonDescription, + String idempotencyKey); + + EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey); + + List listItems(String tenantId, String batchId); + + IPage pageItems(String tenantId, String batchId, int pageNum, int pageSize); + + List listLogs(String tenantId, String batchId); + + void triggerApplyAsync(String batchId); + + void retryAsync(String batchId); + + EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsAdjustmentServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsAdjustmentServiceImpl.java index 4f710e2..14559ff 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsAdjustmentServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsAdjustmentServiceImpl.java @@ -142,9 +142,6 @@ public class EarningsAdjustmentServiceImpl extends ServiceImpl + 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 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 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 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 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 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 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 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 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 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 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 normalizeClerkIds(List clerkIds) { + List 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 clerkIds) { + if (batch == null) { + return null; + } + List 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 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 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(); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java index 733a707..ba6891f 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java @@ -88,6 +88,14 @@ public class EarningsServiceImpl extends ServiceImpl findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now) { // pick oldest unlocked first (status in available or frozen with unlock<=now) List 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; List picked = new ArrayList<>(); for (EarningsLineEntity e : list) { diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/ClerkEarningLineVo.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/ClerkEarningLineVo.java index 85b8e9f..1e09552 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/ClerkEarningLineVo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/ClerkEarningLineVo.java @@ -1,6 +1,7 @@ package com.starry.admin.modules.withdraw.vo; import com.fasterxml.jackson.annotation.JsonFormat; +import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType; import com.starry.admin.modules.withdraw.enums.EarningsType; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -15,6 +16,13 @@ public class ClerkEarningLineVo { private EarningsType earningType; 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") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime unlockTime; diff --git a/play-admin/src/main/resources/db/migration/V25__earnings_deduction_batches.sql b/play-admin/src/main/resources/db/migration/V25__earnings_deduction_batches.sql new file mode 100644 index 0000000..27a8642 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V25__earnings_deduction_batches.sql @@ -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 '幂等Key(tenant范围内唯一)', + `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 '关联收益调整ID(play_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='收益批量扣减日志'; + diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchAuthorizationApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchAuthorizationApiTest.java new file mode 100644 index 0000000..3c59325 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchAuthorizationApiTest.java @@ -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 groupIdsToCleanup = new ArrayList<>(); + private final List clerkIdsToCleanup = new ArrayList<>(); + private final List orderIdsToCleanup = new ArrayList<>(); + private final List earningsIdsToCleanup = new ArrayList<>(); + private final List 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 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 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); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchControllerApiTest.java new file mode 100644 index 0000000..2d733ef --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchControllerApiTest.java @@ -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). + * + *

These tests are expected to FAIL until the batch deduction system is implemented end-to-end.

+ */ +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 ordersToCleanup = new ArrayList<>(); + private final List earningsToCleanup = new ArrayList<>(); + private final List batchIdsToCleanup = new ArrayList<>(); + private final List 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 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> 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 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 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; + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchRetryApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchRetryApiTest.java new file mode 100644 index 0000000..14d38e4 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsDeductionBatchRetryApiTest.java @@ -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. + * + *

These tests are expected to FAIL until retry semantics are implemented.

+ */ +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 ordersToCleanup = new ArrayList<>(); + private final List earningsToCleanup = new ArrayList<>(); + private final List adjustmentsToCleanup = new ArrayList<>(); + private final List 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 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 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; + } + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxOauthTestAuthApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxOauthTestAuthApiTest.java new file mode 100644 index 0000000..56474c6 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxOauthTestAuthApiTest.java @@ -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); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawAdjustmentIntegrationApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawAdjustmentIntegrationApiTest.java index 24b4f56..55f3a75 100644 --- a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawAdjustmentIntegrationApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawAdjustmentIntegrationApiTest.java @@ -174,11 +174,13 @@ class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest { void earningsListIncludesAdjustmentLineAfterApplied() throws Exception { ensureTenantContext(); String key = UUID.randomUUID().toString(); + String effectiveTime = "2026-01-01T00:00:00"; String payload = "{" + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + "\"amount\":\"12.34\"," + "\"reasonType\":\"MANUAL\"," + - "\"reasonDescription\":\"show in list\"" + + "\"reasonDescription\":\"show in list\"," + + "\"effectiveTime\":\"" + effectiveTime + "\"" + "}"; mockMvc.perform(post("/admin/earnings/adjustments") @@ -209,7 +211,10 @@ class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest { if (rows.isArray()) { for (JsonNode row : rows) { 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; break; } diff --git a/play-admin/src/test/java/com/starry/admin/db/EarningsDeductionBatchDatabaseSchemaApiTest.java b/play-admin/src/test/java/com/starry/admin/db/EarningsDeductionBatchDatabaseSchemaApiTest.java new file mode 100644 index 0000000..5374772 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/db/EarningsDeductionBatchDatabaseSchemaApiTest.java @@ -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. + * + *

These tests are expected to FAIL until migrations add the new tables/indexes.

+ */ +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 listIndexes(String table) { + return jdbcTemplate.queryForList( + "select distinct index_name from information_schema.statistics where lower(table_name)=lower(?)", + String.class, + table); + } +}