Compare commits
4 Commits
985b35cd90
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b9b1024c8 | ||
|
|
fffc623ab0 | ||
|
|
6a3b4fef1f | ||
|
|
e2300fc7d0 |
@@ -28,7 +28,9 @@ import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService;
|
||||
import com.starry.admin.modules.shop.service.IPlayCommodityAndLevelInfoService;
|
||||
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
|
||||
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
|
||||
import com.starry.admin.modules.system.mapper.SysMenuMapper;
|
||||
import com.starry.admin.modules.system.mapper.SysUserMapper;
|
||||
import com.starry.admin.modules.system.module.entity.SysMenuEntity;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantEntity;
|
||||
import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity;
|
||||
import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
||||
@@ -37,12 +39,14 @@ import com.starry.admin.modules.system.service.ISysTenantService;
|
||||
import com.starry.admin.modules.system.service.SysUserService;
|
||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.constant.UserConstants;
|
||||
import com.starry.common.context.CustomSecurityContextHolder;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
@@ -77,6 +81,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
public static final String DEFAULT_GIFT_ID = "gift-basic";
|
||||
public static final String DEFAULT_GIFT_NAME = "API测试礼物";
|
||||
public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00");
|
||||
public static final BigDecimal E2E_CUSTOMER_BALANCE = new BigDecimal("1000.00");
|
||||
private static final String GIFT_TYPE_REGULAR = "1";
|
||||
private static final String GIFT_STATE_ACTIVE = "0";
|
||||
private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00");
|
||||
@@ -86,6 +91,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
private final ISysTenantService tenantService;
|
||||
private final SysUserService sysUserService;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
private final SysMenuMapper sysMenuMapper;
|
||||
private final IPlayPersonnelGroupInfoService personnelGroupInfoService;
|
||||
private final IPlayClerkLevelInfoService clerkLevelInfoService;
|
||||
private final IPlayClerkUserInfoService clerkUserInfoService;
|
||||
@@ -108,6 +114,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
ISysTenantService tenantService,
|
||||
SysUserService sysUserService,
|
||||
SysUserMapper sysUserMapper,
|
||||
SysMenuMapper sysMenuMapper,
|
||||
IPlayPersonnelGroupInfoService personnelGroupInfoService,
|
||||
IPlayClerkLevelInfoService clerkLevelInfoService,
|
||||
IPlayClerkUserInfoService clerkUserInfoService,
|
||||
@@ -128,6 +135,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
this.tenantService = tenantService;
|
||||
this.sysUserService = sysUserService;
|
||||
this.sysUserMapper = sysUserMapper;
|
||||
this.sysMenuMapper = sysMenuMapper;
|
||||
this.personnelGroupInfoService = personnelGroupInfoService;
|
||||
this.clerkLevelInfoService = clerkLevelInfoService;
|
||||
this.clerkUserInfoService = clerkUserInfoService;
|
||||
@@ -149,6 +157,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
seedPcTenantWagesMenu();
|
||||
seedTenantPackage();
|
||||
seedTenant();
|
||||
|
||||
@@ -173,6 +182,98 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private void seedPcTenantWagesMenu() {
|
||||
// Minimal menu tree for pc-tenant E2E: /play/clerk/wages -> play/clerk/wages/index.vue
|
||||
// This is apitest-only; prod/dev menus are managed by ops/admin tooling.
|
||||
SysMenuEntity playRoot = ensureMenu(
|
||||
"陪聊管理",
|
||||
"PlayManage",
|
||||
0L,
|
||||
UserConstants.TYPE_DIR,
|
||||
"/play",
|
||||
UserConstants.LAYOUT,
|
||||
50);
|
||||
|
||||
SysMenuEntity clerkDir = ensureMenu(
|
||||
"店员管理",
|
||||
"ClerkManage",
|
||||
playRoot.getMenuId(),
|
||||
UserConstants.TYPE_DIR,
|
||||
"clerk",
|
||||
"",
|
||||
1);
|
||||
|
||||
ensureMenu(
|
||||
"收益管理",
|
||||
"ClerkWages",
|
||||
clerkDir.getMenuId(),
|
||||
UserConstants.TYPE_MENU,
|
||||
"wages",
|
||||
"play/clerk/wages/index",
|
||||
1);
|
||||
}
|
||||
|
||||
private SysMenuEntity ensureMenu(
|
||||
String menuName,
|
||||
String menuCode,
|
||||
Long parentId,
|
||||
String menuType,
|
||||
String path,
|
||||
String component,
|
||||
Integer sort) {
|
||||
Optional<SysMenuEntity> existing = sysMenuMapper.selectList(Wrappers.<SysMenuEntity>lambdaQuery()
|
||||
.eq(SysMenuEntity::getDeleted, false)
|
||||
.eq(SysMenuEntity::getParentId, parentId)
|
||||
.eq(SysMenuEntity::getMenuCode, menuCode)
|
||||
.last("limit 1"))
|
||||
.stream()
|
||||
.findFirst();
|
||||
if (existing.isPresent()) {
|
||||
SysMenuEntity current = existing.get();
|
||||
boolean changed = false;
|
||||
if (!Objects.equals(current.getPath(), path)) {
|
||||
current.setPath(path);
|
||||
changed = true;
|
||||
}
|
||||
if (!Objects.equals(current.getComponent(), component)) {
|
||||
current.setComponent(component);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
current.setUpdatedBy("apitest-seed");
|
||||
current.setUpdatedTime(new Date());
|
||||
sysMenuMapper.updateById(current);
|
||||
log.info("Updated apitest sys_menu '{}' path='{}' component='{}'", menuName, path, component);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
SysMenuEntity entity = new SysMenuEntity();
|
||||
entity.setMenuName(menuName);
|
||||
entity.setMenuCode(menuCode);
|
||||
entity.setIcon("el-icon-menu");
|
||||
entity.setPermission("");
|
||||
entity.setMenuLevel(parentId == 0 ? 1L : 2L);
|
||||
entity.setSort(sort);
|
||||
entity.setParentId(parentId);
|
||||
entity.setMenuType(menuType);
|
||||
entity.setStatus(0);
|
||||
entity.setRemark(menuName);
|
||||
entity.setPath(path);
|
||||
entity.setComponent(component);
|
||||
entity.setRouterQuery("");
|
||||
entity.setIsFrame(0);
|
||||
entity.setVisible(1);
|
||||
entity.setDeleted(Boolean.FALSE);
|
||||
entity.setCreatedBy("apitest-seed");
|
||||
entity.setCreatedTime(new Date());
|
||||
entity.setUpdatedBy("apitest-seed");
|
||||
entity.setUpdatedTime(new Date());
|
||||
sysMenuMapper.insert(entity);
|
||||
log.info("Inserted apitest sys_menu '{}' path='{}' parentId={}", menuName, path, parentId);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void seedTenantPackage() {
|
||||
long existing = tenantPackageService.count(Wrappers.<SysTenantPackageEntity>lambdaQuery()
|
||||
.eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID));
|
||||
@@ -496,22 +597,27 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
||||
|
||||
private void seedClerkCommodity() {
|
||||
PlayClerkCommodityEntity mapping = clerkCommodityService.getById(DEFAULT_CLERK_COMMODITY_ID);
|
||||
if (mapping != null) {
|
||||
log.info("API test clerk commodity {} already exists", DEFAULT_CLERK_COMMODITY_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
String commodityName = DEFAULT_COMMODITY_PARENT_NAME;
|
||||
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
|
||||
if (parent != null && parent.getItemName() != null) {
|
||||
commodityName = parent.getItemName();
|
||||
}
|
||||
|
||||
if (mapping != null) {
|
||||
clerkCommodityService.update(Wrappers.<PlayClerkCommodityEntity>lambdaUpdate()
|
||||
.eq(PlayClerkCommodityEntity::getId, DEFAULT_CLERK_COMMODITY_ID)
|
||||
.set(PlayClerkCommodityEntity::getCommodityId, DEFAULT_COMMODITY_PARENT_ID)
|
||||
.set(PlayClerkCommodityEntity::getCommodityName, commodityName)
|
||||
.set(PlayClerkCommodityEntity::getEnablingState, "1"));
|
||||
log.info("API test clerk commodity {} already exists, state refreshed", DEFAULT_CLERK_COMMODITY_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity();
|
||||
entity.setId(DEFAULT_CLERK_COMMODITY_ID);
|
||||
entity.setTenantId(DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(DEFAULT_CLERK_ID);
|
||||
entity.setCommodityId(DEFAULT_COMMODITY_ID);
|
||||
entity.setCommodityId(DEFAULT_COMMODITY_PARENT_ID);
|
||||
entity.setCommodityName(commodityName);
|
||||
entity.setEnablingState("1");
|
||||
entity.setSort(1);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,9 @@ public class WxCustomMpService {
|
||||
@Resource
|
||||
private WxMpService wxMpService;
|
||||
|
||||
@Value("${wechat.subscribe-check-enabled:true}")
|
||||
private boolean subscribeCheckEnabled;
|
||||
|
||||
@Resource
|
||||
private SysTenantServiceImpl tenantService;
|
||||
@Resource
|
||||
@@ -480,6 +483,9 @@ public class WxCustomMpService {
|
||||
if (StrUtil.isBlankIfStr(openId)) {
|
||||
throw new ServiceException("openId不能为空");
|
||||
}
|
||||
if (!subscribeCheckEnabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
WxMpUser wxMpUser = proxyWxMpService(tenantId).getUserService().userInfo(openId);
|
||||
if (!wxMpUser.getSubscribe()) {
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.starry.admin.modules.withdraw.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
|
||||
import com.starry.admin.modules.withdraw.security.EarningsAuthorizationService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsDeductionBatchService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.result.TypedR;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.annotation.Resource;
|
||||
import lombok.Data;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/earnings/deductions")
|
||||
public class AdminEarningsDeductionBatchController {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final DateTimeFormatter SIMPLE_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Resource
|
||||
private IEarningsDeductionBatchService batchService;
|
||||
|
||||
@Resource
|
||||
private EarningsDeductionItemMapper deductionItemMapper;
|
||||
|
||||
@Resource
|
||||
private EarningsAuthorizationService earningsAuth;
|
||||
|
||||
@Data
|
||||
public static class DeductionRequest {
|
||||
private List<String> clerkIds;
|
||||
private String beginTime;
|
||||
private String endTime;
|
||||
private EarningsDeductionRuleType ruleType;
|
||||
private BigDecimal amount;
|
||||
private BigDecimal percentage;
|
||||
private EarningsDeductionOperationType operation;
|
||||
private String reasonDescription;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PreviewItem {
|
||||
private String clerkId;
|
||||
private BigDecimal baseAmount;
|
||||
private BigDecimal applyAmount;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PreviewResponse {
|
||||
private List<PreviewItem> items;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class BatchStatusResponse {
|
||||
private String batchId;
|
||||
private String idempotencyKey;
|
||||
private String status;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/preview", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
|
||||
public ResponseEntity<TypedR<PreviewResponse>> preview(@RequestBody DeductionRequest body) {
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
|
||||
}
|
||||
List<String> clerkIds = normalizeClerkIds(body.getClerkIds());
|
||||
if (clerkIds.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkIds required"));
|
||||
}
|
||||
|
||||
LocalDateTime begin = parseDate(body.getBeginTime());
|
||||
LocalDateTime end = parseDate(body.getEndTime());
|
||||
if (begin == null || end == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "time range required"));
|
||||
}
|
||||
if (begin.isAfter(end)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "beginTime must be <= endTime"));
|
||||
}
|
||||
|
||||
EarningsDeductionRuleType ruleType = body.getRuleType();
|
||||
if (ruleType == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleType required"));
|
||||
}
|
||||
EarningsDeductionOperationType operation = body.getOperation();
|
||||
if (operation == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "operation required"));
|
||||
}
|
||||
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
|
||||
}
|
||||
|
||||
BigDecimal ruleValue = resolveRuleValue(ruleType, body.getAmount(), body.getPercentage());
|
||||
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleValue must be non-zero"));
|
||||
}
|
||||
|
||||
enforceScope(clerkIds);
|
||||
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
PreviewResponse response = new PreviewResponse();
|
||||
List<PreviewItem> items = new ArrayList<>();
|
||||
for (String clerkId : clerkIds) {
|
||||
BigDecimal base = deductionItemMapper.sumOrderPositiveBase(tenantId, clerkId, begin, end);
|
||||
BigDecimal baseAmount = base == null ? BigDecimal.ZERO : base;
|
||||
BigDecimal applyAmount = calculateApplyAmount(ruleType, ruleValue, operation, baseAmount);
|
||||
PreviewItem item = new PreviewItem();
|
||||
item.setClerkId(clerkId);
|
||||
item.setBaseAmount(baseAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
item.setApplyAmount(applyAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
items.add(item);
|
||||
}
|
||||
response.setItems(items);
|
||||
return ResponseEntity.ok(TypedR.ok(response));
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
|
||||
public ResponseEntity<TypedR<BatchStatusResponse>> create(
|
||||
@RequestHeader(value = IDEMPOTENCY_HEADER, required = false) String idempotencyKey,
|
||||
@RequestBody DeductionRequest body) {
|
||||
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "Idempotency-Key required"));
|
||||
}
|
||||
if (body == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "body required"));
|
||||
}
|
||||
List<String> clerkIds = normalizeClerkIds(body.getClerkIds());
|
||||
if (clerkIds.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "clerkIds required"));
|
||||
}
|
||||
|
||||
LocalDateTime begin = parseDate(body.getBeginTime());
|
||||
LocalDateTime end = parseDate(body.getEndTime());
|
||||
if (begin == null || end == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "time range required"));
|
||||
}
|
||||
if (begin.isAfter(end)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "beginTime must be <= endTime"));
|
||||
}
|
||||
|
||||
if (body.getRuleType() == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleType required"));
|
||||
}
|
||||
if (body.getOperation() == null) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "operation required"));
|
||||
}
|
||||
if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required"));
|
||||
}
|
||||
|
||||
BigDecimal ruleValue = resolveRuleValue(body.getRuleType(), body.getAmount(), body.getPercentage());
|
||||
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "ruleValue must be non-zero"));
|
||||
}
|
||||
|
||||
enforceScope(clerkIds);
|
||||
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, "tenant missing"));
|
||||
}
|
||||
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch;
|
||||
try {
|
||||
batch = batchService.createOrGetProcessing(
|
||||
tenantId,
|
||||
clerkIds,
|
||||
begin,
|
||||
end,
|
||||
body.getRuleType(),
|
||||
ruleValue,
|
||||
body.getOperation(),
|
||||
body.getReasonDescription(),
|
||||
idempotencyKey);
|
||||
} catch (IllegalStateException conflict) {
|
||||
return ResponseEntity.status(409).body(TypedR.error(409, conflict.getMessage()));
|
||||
} catch (CustomException bad) {
|
||||
return ResponseEntity.badRequest().body(TypedR.error(400, bad.getMessage()));
|
||||
}
|
||||
|
||||
batchService.triggerApplyAsync(batch.getId());
|
||||
|
||||
BatchStatusResponse responseBody = new BatchStatusResponse();
|
||||
responseBody.setBatchId(batch.getId());
|
||||
responseBody.setIdempotencyKey(idempotencyKey);
|
||||
responseBody.setStatus(batch.getStatus() == null ? "PROCESSING" : batch.getStatus().name());
|
||||
|
||||
TypedR<BatchStatusResponse> result = TypedR.ok(responseBody);
|
||||
result.setCode(202);
|
||||
result.setMessage("请求处理中");
|
||||
return ResponseEntity.accepted()
|
||||
.header("Location", "/admin/earnings/deductions/idempotency/" + idempotencyKey)
|
||||
.body(result);
|
||||
}
|
||||
|
||||
@GetMapping("/idempotency/{key}")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
|
||||
public ResponseEntity<TypedR<BatchStatusResponse>> getByIdempotencyKey(@PathVariable("key") String key) {
|
||||
if (!StringUtils.hasText(key)) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.getByIdempotencyKey(tenantId, key);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batch.getId());
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity reconciled = batchService.reconcileAndGet(tenantId, batch.getId());
|
||||
if (reconciled == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
BatchStatusResponse responseBody = new BatchStatusResponse();
|
||||
responseBody.setBatchId(reconciled.getId());
|
||||
responseBody.setIdempotencyKey(key);
|
||||
responseBody.setStatus(reconciled.getStatus() == null ? "PROCESSING" : reconciled.getStatus().name());
|
||||
return ResponseEntity.ok(TypedR.ok(responseBody));
|
||||
}
|
||||
|
||||
@GetMapping("/{batchId}")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
|
||||
public ResponseEntity<TypedR<BatchStatusResponse>> getById(@PathVariable("batchId") String batchId) {
|
||||
if (!StringUtils.hasText(batchId)) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batch.getId());
|
||||
BatchStatusResponse responseBody = new BatchStatusResponse();
|
||||
responseBody.setBatchId(batch.getId());
|
||||
responseBody.setIdempotencyKey(batch.getIdempotencyKey());
|
||||
responseBody.setStatus(batch.getStatus() == null ? "PROCESSING" : batch.getStatus().name());
|
||||
return ResponseEntity.ok(TypedR.ok(responseBody));
|
||||
}
|
||||
|
||||
@GetMapping("/{batchId}/items")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
|
||||
public ResponseEntity<TypedR<List<EarningsDeductionItemEntity>>> listItems(
|
||||
@PathVariable("batchId") String batchId,
|
||||
@RequestParam(value = "pageNum", required = false, defaultValue = "1") Integer pageNum,
|
||||
@RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
|
||||
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batchId);
|
||||
IPage<EarningsDeductionItemEntity> page = batchService.pageItems(tenantId, batchId, pageNum, pageSize);
|
||||
return ResponseEntity.ok(TypedR.okPage(page));
|
||||
}
|
||||
|
||||
@GetMapping("/{batchId}/logs")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:read')")
|
||||
public ResponseEntity<TypedR<List<EarningsDeductionBatchLogEntity>>> listLogs(@PathVariable("batchId") String batchId) {
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batchId);
|
||||
List<EarningsDeductionBatchLogEntity> logs = batchService.listLogs(tenantId, batchId);
|
||||
return ResponseEntity.ok(TypedR.ok(logs));
|
||||
}
|
||||
|
||||
@PostMapping("/{batchId}/retry")
|
||||
@PreAuthorize("@customSs.hasPermission('withdraw:deduction:create')")
|
||||
public ResponseEntity<TypedR<Void>> retry(@PathVariable("batchId") String batchId) {
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity batch = batchService.reconcileAndGet(tenantId, batchId);
|
||||
if (batch == null) {
|
||||
return ResponseEntity.status(404).body(TypedR.error(404, "not found"));
|
||||
}
|
||||
enforceBatchScope(tenantId, batchId);
|
||||
batchService.retryAsync(batchId);
|
||||
return ResponseEntity.ok(TypedR.ok(null));
|
||||
}
|
||||
|
||||
private void enforceScope(List<String> clerkIds) {
|
||||
for (String clerkId : clerkIds) {
|
||||
if (!earningsAuth.canManageClerk(clerkId)) {
|
||||
throw new AccessDeniedException("forbidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceBatchScope(String tenantId, String batchId) {
|
||||
if (SecurityUtils.getLoginUser() != null
|
||||
&& SecurityUtils.getLoginUser().getUser() != null
|
||||
&& SecurityUtils.isAdmin(SecurityUtils.getLoginUser().getUser())) {
|
||||
return;
|
||||
}
|
||||
List<EarningsDeductionItemEntity> items = batchService.listItems(tenantId, batchId);
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item != null && StringUtils.hasText(item.getClerkId())) {
|
||||
if (!earningsAuth.canManageClerk(item.getClerkId())) {
|
||||
throw new AccessDeniedException("forbidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime parseDate(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(value.trim(), SIMPLE_DATE_TIME);
|
||||
} catch (DateTimeParseException ex) {
|
||||
throw new CustomException("invalid datetime: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal resolveRuleValue(EarningsDeductionRuleType type, BigDecimal amount, BigDecimal percentage) {
|
||||
if (type == EarningsDeductionRuleType.FIXED) {
|
||||
return amount;
|
||||
}
|
||||
return percentage;
|
||||
}
|
||||
|
||||
private BigDecimal calculateApplyAmount(EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
BigDecimal baseAmount) {
|
||||
BigDecimal resolvedBase = baseAmount == null ? BigDecimal.ZERO : baseAmount;
|
||||
BigDecimal resolvedRule = ruleValue == null ? BigDecimal.ZERO : ruleValue;
|
||||
BigDecimal amount;
|
||||
if (ruleType == EarningsDeductionRuleType.FIXED) {
|
||||
amount = resolvedRule;
|
||||
} else {
|
||||
amount = resolvedBase.multiply(resolvedRule).divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
|
||||
}
|
||||
amount = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
if (operation == EarningsDeductionOperationType.PUNISHMENT) {
|
||||
return amount.abs().negate();
|
||||
}
|
||||
return amount.abs();
|
||||
}
|
||||
|
||||
private List<String> normalizeClerkIds(List<String> input) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (CollectionUtils.isEmpty(input)) {
|
||||
return result;
|
||||
}
|
||||
for (String id : input) {
|
||||
if (!StringUtils.hasText(id)) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = id.trim();
|
||||
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,19 @@ 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.enums.WithdrawalRequestStatus;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
|
||||
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 +45,8 @@ public class WxWithdrawController {
|
||||
@Resource
|
||||
private IEarningsService earningsService;
|
||||
@Resource
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
@Resource
|
||||
private IWithdrawalService withdrawalService;
|
||||
@Resource
|
||||
private IWithdrawalLogService withdrawalLogService;
|
||||
@@ -55,11 +62,43 @@ public class WxWithdrawController {
|
||||
@GetMapping("/balance")
|
||||
public TypedR<ClerkWithdrawBalanceVo> getBalance() {
|
||||
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
BigDecimal available = earningsService.getAvailableAmount(clerkId, now);
|
||||
BigDecimal pending = earningsService.getPendingAmount(clerkId, now);
|
||||
LocalDateTime nextUnlock = earningsService.getNextUnlockTime(clerkId, now);
|
||||
return TypedR.ok(new ClerkWithdrawBalanceVo(available, pending, nextUnlock));
|
||||
WithdrawalRequestEntity active = withdrawalService.lambdaQuery()
|
||||
.eq(WithdrawalRequestEntity::getTenantId, tenantId)
|
||||
.eq(WithdrawalRequestEntity::getClerkId, clerkId)
|
||||
.in(WithdrawalRequestEntity::getStatus,
|
||||
WithdrawalRequestStatus.PENDING.getCode(),
|
||||
WithdrawalRequestStatus.PROCESSING.getCode())
|
||||
.orderByDesc(WithdrawalRequestEntity::getCreatedTime)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
|
||||
Boolean locked = active != null;
|
||||
String lockReason = locked ? "当前已有一笔提现申请在途,请等待处理完成后再申请。" : "";
|
||||
ClerkWithdrawBalanceVo.ActiveRequest activeRequest = null;
|
||||
if (active != null) {
|
||||
Date createdTime = active.getCreatedTime();
|
||||
LocalDateTime createdAt = createdTime == null ? null
|
||||
: LocalDateTime.ofInstant(createdTime.toInstant(), ZoneId.systemDefault());
|
||||
activeRequest = ClerkWithdrawBalanceVo.ActiveRequest.builder()
|
||||
.amount(active.getAmount())
|
||||
.status(active.getStatus())
|
||||
.createdTime(createdAt == null ? "" : createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
|
||||
.build();
|
||||
}
|
||||
|
||||
ClerkWithdrawBalanceVo vo = new ClerkWithdrawBalanceVo();
|
||||
vo.setAvailable(available);
|
||||
vo.setPending(pending);
|
||||
vo.setNextUnlockAt(nextUnlock);
|
||||
vo.setWithdrawLocked(locked);
|
||||
vo.setWithdrawLockReason(lockReason);
|
||||
vo.setActiveRequest(activeRequest);
|
||||
return TypedR.ok(vo);
|
||||
}
|
||||
|
||||
@ClerkUserLogin
|
||||
@@ -101,6 +140,21 @@ public class WxWithdrawController {
|
||||
.list()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it));
|
||||
|
||||
List<String> adjustmentIds = records.stream()
|
||||
.filter(line -> line.getSourceType() == EarningsSourceType.ADJUSTMENT)
|
||||
.map(EarningsLineEntity::getSourceId)
|
||||
.filter(id -> id != null && !id.isEmpty())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<String, EarningsLineAdjustmentEntity> adjustmentMap = adjustmentIds.isEmpty() ? java.util.Collections.emptyMap()
|
||||
: adjustmentService.lambdaQuery()
|
||||
.eq(EarningsLineAdjustmentEntity::getTenantId, SecurityUtils.getTenantId())
|
||||
.in(EarningsLineAdjustmentEntity::getId, adjustmentIds)
|
||||
.list()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(EarningsLineAdjustmentEntity::getId, it -> it));
|
||||
|
||||
for (EarningsLineEntity line : records) {
|
||||
ClerkEarningLineVo vo = new ClerkEarningLineVo();
|
||||
vo.setId(line.getId());
|
||||
@@ -111,6 +165,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) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_deduction_batch")
|
||||
public class EarningsDeductionBatchEntity extends BaseEntity<EarningsDeductionBatchEntity> {
|
||||
private String id;
|
||||
private String tenantId;
|
||||
private EarningsDeductionStatus status;
|
||||
|
||||
@TableField("begin_time")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime windowBeginTime;
|
||||
|
||||
@TableField("end_time")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime windowEndTime;
|
||||
|
||||
private EarningsDeductionRuleType ruleType;
|
||||
private BigDecimal ruleValue;
|
||||
private EarningsDeductionOperationType operation;
|
||||
private String reasonDescription;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionLogEventType;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_deduction_batch_log")
|
||||
public class EarningsDeductionBatchLogEntity extends BaseEntity<EarningsDeductionBatchLogEntity> {
|
||||
private String id;
|
||||
private String batchId;
|
||||
private String tenantId;
|
||||
private EarningsDeductionLogEventType eventType;
|
||||
private String statusFrom;
|
||||
private String statusTo;
|
||||
private String message;
|
||||
private String payload;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.starry.admin.modules.withdraw.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
|
||||
import com.starry.common.domain.BaseEntity;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("play_earnings_deduction_item")
|
||||
public class EarningsDeductionItemEntity extends BaseEntity<EarningsDeductionItemEntity> {
|
||||
private String id;
|
||||
private String batchId;
|
||||
private String tenantId;
|
||||
private String clerkId;
|
||||
private BigDecimal baseAmount;
|
||||
private BigDecimal applyAmount;
|
||||
private EarningsDeductionStatus status;
|
||||
private String adjustmentId;
|
||||
private String failureReason;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public enum EarningsLineStatus {
|
||||
FROZEN("frozen"),
|
||||
AVAILABLE("available"),
|
||||
WITHDRAWING("withdrawing"),
|
||||
WITHDRAWN("withdrawn"),
|
||||
REVERSED("reversed");
|
||||
|
||||
private final String code;
|
||||
|
||||
EarningsLineStatus(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static EarningsLineStatus fromCode(String code) {
|
||||
return Arrays.stream(values())
|
||||
.filter(it -> it.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("invalid earnings line status: " + code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.starry.admin.modules.withdraw.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public enum WithdrawalRequestStatus {
|
||||
PENDING("pending"),
|
||||
PROCESSING("processing"),
|
||||
SUCCESS("success"),
|
||||
FAILED("failed"),
|
||||
CANCELED("canceled"),
|
||||
REJECTED("rejected");
|
||||
|
||||
private final String code;
|
||||
|
||||
WithdrawalRequestStatus(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static WithdrawalRequestStatus fromCode(String code) {
|
||||
return Arrays.stream(values())
|
||||
.filter(it -> it.code.equals(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("invalid withdrawal request status: " + code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionBatchLogMapper extends BaseMapper<EarningsDeductionBatchLogEntity> {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionBatchMapper extends BaseMapper<EarningsDeductionBatchEntity> {}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.starry.admin.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface EarningsDeductionItemMapper extends BaseMapper<EarningsDeductionItemEntity> {
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT COALESCE(SUM(el.amount), 0) " +
|
||||
"FROM play_earnings_line el " +
|
||||
"JOIN play_order_info oi ON oi.id = el.order_id " +
|
||||
"WHERE el.deleted = 0 " +
|
||||
" AND oi.deleted = 0 " +
|
||||
" AND el.tenant_id = #{tenantId} " +
|
||||
" AND el.clerk_id = #{clerkId} " +
|
||||
" AND el.order_id IS NOT NULL " +
|
||||
" AND el.amount > 0 " +
|
||||
" AND el.status <> 'reversed' " +
|
||||
" AND oi.order_end_time IS NOT NULL " +
|
||||
" AND oi.order_end_time >= #{begin} " +
|
||||
" AND oi.order_end_time <= #{end} " +
|
||||
"</script>")
|
||||
BigDecimal sumOrderPositiveBase(@Param("tenantId") String tenantId,
|
||||
@Param("clerkId") String clerkId,
|
||||
@Param("begin") LocalDateTime begin,
|
||||
@Param("end") LocalDateTime end);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.starry.admin.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface IEarningsDeductionBatchService extends IService<EarningsDeductionBatchEntity> {
|
||||
|
||||
EarningsDeductionBatchEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey);
|
||||
|
||||
EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey);
|
||||
|
||||
List<EarningsDeductionItemEntity> listItems(String tenantId, String batchId);
|
||||
|
||||
IPage<EarningsDeductionItemEntity> pageItems(String tenantId, String batchId, int pageNum, int pageSize);
|
||||
|
||||
List<EarningsDeductionBatchLogEntity> listLogs(String tenantId, String batchId);
|
||||
|
||||
void triggerApplyAsync(String batchId);
|
||||
|
||||
void retryAsync(String batchId);
|
||||
|
||||
EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId);
|
||||
}
|
||||
@@ -142,9 +142,6 @@ public class EarningsAdjustmentServiceImpl extends ServiceImpl<EarningsLineAdjus
|
||||
if (adjustment.getStatus() == EarningsAdjustmentStatus.APPLIED) {
|
||||
return;
|
||||
}
|
||||
if (adjustment.getStatus() == EarningsAdjustmentStatus.FAILED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
EarningsLineEntity line = new EarningsLineEntity();
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionLogEventType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionStatus;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchLogMapper;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchMapper;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsDeductionBatchService;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class EarningsDeductionBatchServiceImpl
|
||||
extends ServiceImpl<EarningsDeductionBatchMapper, EarningsDeductionBatchEntity>
|
||||
implements IEarningsDeductionBatchService {
|
||||
|
||||
@Resource
|
||||
private EarningsDeductionItemMapper itemMapper;
|
||||
|
||||
@Resource
|
||||
private EarningsDeductionBatchLogMapper logMapper;
|
||||
|
||||
@Resource
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Resource(name = "threadPoolTaskExecutor")
|
||||
private ThreadPoolTaskExecutor executor;
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity createOrGetProcessing(
|
||||
String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey) {
|
||||
|
||||
validateCreateRequest(tenantId, clerkIds, beginTime, endTime, ruleType, ruleValue, operation, reasonDescription, idempotencyKey);
|
||||
|
||||
List<String> normalizedClerks = normalizeClerkIds(clerkIds);
|
||||
String hash = computeRequestHash(tenantId, normalizedClerks, beginTime, endTime, ruleType, ruleValue, operation, reasonDescription);
|
||||
|
||||
EarningsDeductionBatchEntity existing = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (existing != null) {
|
||||
if (existing.getRequestHash() != null && !existing.getRequestHash().equals(hash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
EarningsDeductionBatchEntity created = new EarningsDeductionBatchEntity();
|
||||
created.setId(IdUtils.getUuid());
|
||||
created.setTenantId(tenantId);
|
||||
created.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
created.setWindowBeginTime(beginTime);
|
||||
created.setWindowEndTime(endTime);
|
||||
created.setRuleType(ruleType);
|
||||
created.setRuleValue(ruleValue.setScale(2, RoundingMode.HALF_UP));
|
||||
created.setOperation(operation);
|
||||
created.setReasonDescription(reasonDescription.trim());
|
||||
created.setIdempotencyKey(idempotencyKey);
|
||||
created.setRequestHash(hash);
|
||||
Date now = new Date();
|
||||
created.setCreatedTime(now);
|
||||
created.setUpdatedTime(now);
|
||||
created.setDeleted(false);
|
||||
|
||||
try {
|
||||
this.save(created);
|
||||
ensureItems(created, normalizedClerks);
|
||||
logOnce(created, EarningsDeductionLogEventType.CREATED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"批次已创建", buildPayload(created, normalizedClerks));
|
||||
return created;
|
||||
} catch (DuplicateKeyException dup) {
|
||||
EarningsDeductionBatchEntity raced = getByIdempotencyKey(tenantId, idempotencyKey);
|
||||
if (raced == null) {
|
||||
throw dup;
|
||||
}
|
||||
if (raced.getRequestHash() != null && !raced.getRequestHash().equals(hash)) {
|
||||
throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体");
|
||||
}
|
||||
ensureItems(raced, normalizedClerks);
|
||||
logOnce(raced, EarningsDeductionLogEventType.CREATED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"批次已创建", buildPayload(raced, normalizedClerks));
|
||||
return raced;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity getByIdempotencyKey(String tenantId, String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(idempotencyKey)) {
|
||||
return null;
|
||||
}
|
||||
return this.getOne(Wrappers.lambdaQuery(EarningsDeductionBatchEntity.class)
|
||||
.eq(EarningsDeductionBatchEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionBatchEntity::getIdempotencyKey, idempotencyKey)
|
||||
.eq(EarningsDeductionBatchEntity::getDeleted, false)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EarningsDeductionItemEntity> listItems(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionItemEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<EarningsDeductionItemEntity> pageItems(String tenantId, String batchId, int pageNum, int pageSize) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new Page<>(pageNum, pageSize);
|
||||
}
|
||||
return itemMapper.selectPage(new Page<>(pageNum, pageSize), Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionItemEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EarningsDeductionBatchLogEntity> listLogs(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return logMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
|
||||
.eq(EarningsDeductionBatchLogEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionBatchLogEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionBatchLogEntity::getDeleted, false)
|
||||
.orderByAsc(EarningsDeductionBatchLogEntity::getCreatedTime));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void triggerApplyAsync(String batchId) {
|
||||
if (!StringUtils.hasText(batchId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
applyOnce(batchId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void retryAsync(String batchId) {
|
||||
if (!StringUtils.hasText(batchId)) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
retryOnce(batchId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public EarningsDeductionBatchEntity reconcileAndGet(String tenantId, String batchId) {
|
||||
if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(batchId)) {
|
||||
return null;
|
||||
}
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null || !tenantId.equals(batch.getTenantId())) {
|
||||
return null;
|
||||
}
|
||||
reconcile(batch);
|
||||
return this.getById(batchId);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void applyOnce(String batchId) {
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
logOnce(batch, EarningsDeductionLogEventType.APPLY_STARTED, null, EarningsDeductionStatus.PROCESSING.name(),
|
||||
"开始执行批次", null);
|
||||
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null || !StringUtils.hasText(item.getClerkId())) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.hasText(item.getAdjustmentId())) {
|
||||
adjustmentService.triggerApplyAsync(item.getAdjustmentId());
|
||||
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,
|
||||
null);
|
||||
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setAdjustmentId(adjustment.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
|
||||
adjustmentService.triggerApplyAsync(adjustment.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void retryOnce(String batchId) {
|
||||
EarningsDeductionBatchEntity batch = this.getById(batchId);
|
||||
if (batch == null) {
|
||||
return;
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.RETRY_STARTED, batch.getStatus() == null ? null : batch.getStatus().name(),
|
||||
EarningsDeductionStatus.PROCESSING.name(), "开始重试失败项", null);
|
||||
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
continue;
|
||||
}
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
continue;
|
||||
}
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
adjustmentService.triggerApplyAsync(item.getAdjustmentId());
|
||||
}
|
||||
|
||||
EarningsDeductionBatchEntity patchBatch = new EarningsDeductionBatchEntity();
|
||||
patchBatch.setId(batch.getId());
|
||||
patchBatch.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
this.updateById(patchBatch);
|
||||
}
|
||||
|
||||
private void reconcile(EarningsDeductionBatchEntity batch) {
|
||||
if (batch == null || !StringUtils.hasText(batch.getId())) {
|
||||
return;
|
||||
}
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
if (items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
AtomicBoolean anyFailed = new AtomicBoolean(false);
|
||||
AtomicBoolean anyProcessing = new AtomicBoolean(false);
|
||||
for (EarningsDeductionItemEntity item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (!StringUtils.hasText(item.getAdjustmentId())) {
|
||||
anyProcessing.set(true);
|
||||
continue;
|
||||
}
|
||||
EarningsLineAdjustmentEntity adjustment = adjustmentService.getById(item.getAdjustmentId());
|
||||
if (adjustment == null || adjustment.getStatus() == null) {
|
||||
anyProcessing.set(true);
|
||||
continue;
|
||||
}
|
||||
if (adjustment.getStatus() == com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus.APPLIED) {
|
||||
if (item.getStatus() != EarningsDeductionStatus.APPLIED) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.APPLIED);
|
||||
patch.setFailureReason(null);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
} else if (adjustment.getStatus() == com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus.FAILED) {
|
||||
anyFailed.set(true);
|
||||
if (item.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(item.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
patch.setFailureReason(adjustment.getFailureReason());
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.ITEM_FAILED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.FAILED.name(), "明细失败", JSON.toJSONString(Objects.requireNonNullElse(adjustment.getFailureReason(), "")));
|
||||
} else {
|
||||
anyProcessing.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (anyFailed.get()) {
|
||||
if (batch.getStatus() != EarningsDeductionStatus.FAILED) {
|
||||
EarningsDeductionBatchEntity patch = new EarningsDeductionBatchEntity();
|
||||
patch.setId(batch.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
this.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.BATCH_FAILED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.FAILED.name(), "批次失败", null);
|
||||
return;
|
||||
}
|
||||
if (anyProcessing.get()) {
|
||||
return;
|
||||
}
|
||||
if (batch.getStatus() != EarningsDeductionStatus.APPLIED) {
|
||||
EarningsDeductionBatchEntity patch = new EarningsDeductionBatchEntity();
|
||||
patch.setId(batch.getId());
|
||||
patch.setStatus(EarningsDeductionStatus.APPLIED);
|
||||
this.updateById(patch);
|
||||
}
|
||||
logOnce(batch, EarningsDeductionLogEventType.BATCH_APPLIED, EarningsDeductionStatus.PROCESSING.name(),
|
||||
EarningsDeductionStatus.APPLIED.name(), "批次完成", null);
|
||||
}
|
||||
|
||||
private void ensureItems(EarningsDeductionBatchEntity batch, List<String> clerkIds) {
|
||||
if (batch == null || !StringUtils.hasText(batch.getId()) || CollectionUtils.isEmpty(clerkIds)) {
|
||||
return;
|
||||
}
|
||||
for (String clerkId : clerkIds) {
|
||||
if (!StringUtils.hasText(clerkId)) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal base = itemMapper.sumOrderPositiveBase(batch.getTenantId(), clerkId, batch.getWindowBeginTime(), batch.getWindowEndTime());
|
||||
BigDecimal baseAmount = base == null ? BigDecimal.ZERO : base;
|
||||
BigDecimal applyAmount = calculateApplyAmount(batch.getRuleType(), batch.getRuleValue(), batch.getOperation(), baseAmount);
|
||||
|
||||
EarningsDeductionItemEntity item = new EarningsDeductionItemEntity();
|
||||
item.setId(IdUtils.getUuid());
|
||||
item.setBatchId(batch.getId());
|
||||
item.setTenantId(batch.getTenantId());
|
||||
item.setClerkId(clerkId);
|
||||
item.setBaseAmount(baseAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
item.setApplyAmount(applyAmount.setScale(2, RoundingMode.HALF_UP));
|
||||
item.setStatus(EarningsDeductionStatus.PROCESSING);
|
||||
item.setDeleted(false);
|
||||
try {
|
||||
itemMapper.insert(item);
|
||||
} catch (DuplicateKeyException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void markItemFailed(String itemId, String reason) {
|
||||
if (!StringUtils.hasText(itemId)) {
|
||||
return;
|
||||
}
|
||||
EarningsDeductionItemEntity patch = new EarningsDeductionItemEntity();
|
||||
patch.setId(itemId);
|
||||
patch.setStatus(EarningsDeductionStatus.FAILED);
|
||||
patch.setFailureReason(reason);
|
||||
itemMapper.updateById(patch);
|
||||
}
|
||||
|
||||
private BigDecimal calculateApplyAmount(EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
BigDecimal baseAmount) {
|
||||
BigDecimal resolvedBase = baseAmount == null ? BigDecimal.ZERO : baseAmount;
|
||||
BigDecimal resolvedRule = ruleValue == null ? BigDecimal.ZERO : ruleValue;
|
||||
BigDecimal amount;
|
||||
if (ruleType == EarningsDeductionRuleType.FIXED) {
|
||||
amount = resolvedRule;
|
||||
} else {
|
||||
amount = resolvedBase.multiply(resolvedRule).divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
|
||||
}
|
||||
amount = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
if (operation == EarningsDeductionOperationType.PUNISHMENT) {
|
||||
return amount.abs().negate();
|
||||
}
|
||||
return amount.abs();
|
||||
}
|
||||
|
||||
private void validateCreateRequest(String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription,
|
||||
String idempotencyKey) {
|
||||
if (!StringUtils.hasText(tenantId)) {
|
||||
throw new CustomException("tenant missing");
|
||||
}
|
||||
if (CollectionUtils.isEmpty(clerkIds)) {
|
||||
throw new CustomException("clerkIds required");
|
||||
}
|
||||
if (beginTime == null || endTime == null) {
|
||||
throw new CustomException("time range required");
|
||||
}
|
||||
if (beginTime.isAfter(endTime)) {
|
||||
throw new CustomException("beginTime must be <= endTime");
|
||||
}
|
||||
if (ruleType == null) {
|
||||
throw new CustomException("ruleType required");
|
||||
}
|
||||
if (ruleValue == null || ruleValue.compareTo(BigDecimal.ZERO) == 0) {
|
||||
throw new CustomException("ruleValue must be non-zero");
|
||||
}
|
||||
if (operation == null) {
|
||||
throw new CustomException("operation required");
|
||||
}
|
||||
if (!StringUtils.hasText(idempotencyKey)) {
|
||||
throw new CustomException("Idempotency-Key required");
|
||||
}
|
||||
if (!StringUtils.hasText(reasonDescription) || !StringUtils.hasText(reasonDescription.trim())) {
|
||||
throw new CustomException("reasonDescription required");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> normalizeClerkIds(List<String> clerkIds) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (CollectionUtils.isEmpty(clerkIds)) {
|
||||
return result;
|
||||
}
|
||||
for (String id : clerkIds) {
|
||||
if (!StringUtils.hasText(id)) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = id.trim();
|
||||
if (!trimmed.isEmpty() && !result.contains(trimmed)) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
result.sort(String::compareTo);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildPayload(EarningsDeductionBatchEntity batch, List<String> clerkIds) {
|
||||
if (batch == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> resolvedClerkIds = clerkIds == null ? List.of() : clerkIds;
|
||||
return JSON.toJSONString(new Object() {
|
||||
public final String batchId = batch.getId();
|
||||
public final String ruleType = batch.getRuleType() == null ? null : batch.getRuleType().name();
|
||||
public final String operation = batch.getOperation() == null ? null : batch.getOperation().name();
|
||||
public final BigDecimal ruleValue = batch.getRuleValue();
|
||||
public final LocalDateTime beginTime = batch.getWindowBeginTime();
|
||||
public final LocalDateTime endTime = batch.getWindowEndTime();
|
||||
public final String reasonDescription = batch.getReasonDescription();
|
||||
public final List<String> clerkIds = resolvedClerkIds;
|
||||
});
|
||||
}
|
||||
|
||||
private void logOnce(EarningsDeductionBatchEntity batch,
|
||||
EarningsDeductionLogEventType eventType,
|
||||
String from,
|
||||
String to,
|
||||
String message,
|
||||
String payload) {
|
||||
if (batch == null || eventType == null) {
|
||||
return;
|
||||
}
|
||||
Long count = logMapper.selectCount(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
|
||||
.eq(EarningsDeductionBatchLogEntity::getTenantId, batch.getTenantId())
|
||||
.eq(EarningsDeductionBatchLogEntity::getBatchId, batch.getId())
|
||||
.eq(EarningsDeductionBatchLogEntity::getEventType, eventType)
|
||||
.eq(EarningsDeductionBatchLogEntity::getDeleted, false));
|
||||
if (count != null && count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
EarningsDeductionBatchLogEntity log = new EarningsDeductionBatchLogEntity();
|
||||
log.setId(IdUtils.getUuid());
|
||||
log.setTenantId(batch.getTenantId());
|
||||
log.setBatchId(batch.getId());
|
||||
log.setEventType(eventType);
|
||||
log.setStatusFrom(from);
|
||||
log.setStatusTo(to);
|
||||
log.setMessage(message);
|
||||
log.setPayload(payload);
|
||||
log.setDeleted(false);
|
||||
logMapper.insert(log);
|
||||
}
|
||||
|
||||
private String computeRequestHash(String tenantId,
|
||||
List<String> clerkIds,
|
||||
LocalDateTime beginTime,
|
||||
LocalDateTime endTime,
|
||||
EarningsDeductionRuleType ruleType,
|
||||
BigDecimal ruleValue,
|
||||
EarningsDeductionOperationType operation,
|
||||
String reasonDescription) {
|
||||
String normalizedRule = (ruleValue == null ? BigDecimal.ZERO : ruleValue).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
String raw = tenantId + "|" +
|
||||
String.join(",", clerkIds) + "|" +
|
||||
beginTime.toString() + "|" +
|
||||
endTime.toString() + "|" +
|
||||
(ruleType == null ? "" : ruleType.name()) + "|" +
|
||||
normalizedRule + "|" +
|
||||
(operation == null ? "" : operation.name()) + "|" +
|
||||
reasonDescription.trim();
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
return toHex(bytes);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("hash failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,14 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
|
||||
public List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now) {
|
||||
// pick oldest unlocked first (status in available or frozen with unlock<=now)
|
||||
List<EarningsLineEntity> list = this.baseMapper.selectWithdrawableLines(clerkId, now);
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (EarningsLineEntity line : list) {
|
||||
BigDecimal value = line.getAmount() == null ? BigDecimal.ZERO : line.getAmount();
|
||||
total = total.add(value);
|
||||
}
|
||||
if (total.compareTo(amount) < 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
BigDecimal acc = BigDecimal.ZERO;
|
||||
List<EarningsLineEntity> picked = new ArrayList<>();
|
||||
for (EarningsLineEntity e : list) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.starry.admin.common.exception.CustomException;
|
||||
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsLineStatus;
|
||||
import com.starry.admin.modules.withdraw.enums.WithdrawalRequestStatus;
|
||||
import com.starry.admin.modules.withdraw.mapper.WithdrawalRequestMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
@@ -43,6 +45,20 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String tenantId = SecurityUtils.getTenantId();
|
||||
|
||||
WithdrawalRequestEntity active = this.lambdaQuery()
|
||||
.eq(WithdrawalRequestEntity::getTenantId, tenantId)
|
||||
.eq(WithdrawalRequestEntity::getClerkId, clerkId)
|
||||
.in(WithdrawalRequestEntity::getStatus,
|
||||
WithdrawalRequestStatus.PENDING.getCode(),
|
||||
WithdrawalRequestStatus.PROCESSING.getCode())
|
||||
.orderByDesc(WithdrawalRequestEntity::getCreatedTime)
|
||||
.last("limit 1")
|
||||
.one();
|
||||
if (active != null) {
|
||||
throw new CustomException("仅可同时存在一笔提现申请,请等待当前申请处理完成后再提交");
|
||||
}
|
||||
|
||||
ClerkPayeeProfileEntity payeeProfile = clerkPayeeProfileService.getByClerk(tenantId, clerkId);
|
||||
if (payeeProfile == null || !StringUtils.hasText(payeeProfile.getQrCodeUrl())) {
|
||||
throw new CustomException("请先上传支付宝收款码");
|
||||
@@ -66,7 +82,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
boolean updated = earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.eq(EarningsLineEntity::getStatus, line.getStatus()) // Verify status unchanged
|
||||
.set(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.set(EarningsLineEntity::getWithdrawalId, tempWithdrawalId));
|
||||
if (!updated) {
|
||||
// Another request already took this line
|
||||
@@ -88,7 +104,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
req.setNetAmount(amount);
|
||||
req.setDestAccount(payeeProfile.getDisplayName());
|
||||
req.setPayeeSnapshot(snapshotJson);
|
||||
req.setStatus("pending");
|
||||
req.setStatus(WithdrawalRequestStatus.PENDING.getCode());
|
||||
req.setOutBizNo(req.getId());
|
||||
this.save(req);
|
||||
|
||||
@@ -120,22 +136,23 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void markManualSuccess(String requestId, String operatorBy) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (!"pending".equals(req.getStatus()) && !"processing".equals(req.getStatus())) {
|
||||
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())
|
||||
&& !WithdrawalRequestStatus.PROCESSING.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("当前状态不可操作");
|
||||
}
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("success");
|
||||
update.setStatus(WithdrawalRequestStatus.SUCCESS.getCode());
|
||||
this.updateById(update);
|
||||
|
||||
// Set reserved earnings lines to withdrawn
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||
.eq(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.set(EarningsLineEntity::getStatus, "withdrawn"));
|
||||
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWN.getCode()));
|
||||
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_SUCCESS", req.getStatus(), "success",
|
||||
"PAYOUT_SUCCESS", req.getStatus(), WithdrawalRequestStatus.SUCCESS.getCode(),
|
||||
"手动打款成功,操作人=" + operatorBy, null);
|
||||
}
|
||||
|
||||
@@ -144,16 +161,16 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void autoPayout(String requestId) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if (!"pending".equals(req.getStatus())) {
|
||||
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("当前状态不可自动打款");
|
||||
}
|
||||
// Transition to processing and log
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("processing");
|
||||
update.setStatus(WithdrawalRequestStatus.PROCESSING.getCode());
|
||||
this.updateById(update);
|
||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||
"PAYOUT_REQUESTED", req.getStatus(), "processing",
|
||||
"PAYOUT_REQUESTED", req.getStatus(), WithdrawalRequestStatus.PROCESSING.getCode(),
|
||||
"发起支付宝打款(未实现)", null);
|
||||
|
||||
// Not implemented yet
|
||||
@@ -165,27 +182,30 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
||||
public void reject(String requestId, String reason) {
|
||||
WithdrawalRequestEntity req = this.getById(requestId);
|
||||
if (req == null) throw new CustomException("请求不存在");
|
||||
if ("success".equals(req.getStatus())) {
|
||||
if (WithdrawalRequestStatus.SUCCESS.getCode().equals(req.getStatus())) {
|
||||
throw new CustomException("已成功的提现不可拒绝");
|
||||
}
|
||||
if ("canceled".equals(req.getStatus()) || "rejected".equals(req.getStatus())) {
|
||||
if (WithdrawalRequestStatus.CANCELED.getCode().equals(req.getStatus())
|
||||
|| WithdrawalRequestStatus.REJECTED.getCode().equals(req.getStatus())) {
|
||||
return;
|
||||
}
|
||||
|
||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||
update.setId(req.getId());
|
||||
update.setStatus("canceled");
|
||||
update.setStatus(WithdrawalRequestStatus.CANCELED.getCode());
|
||||
update.setFailureReason(StringUtils.hasText(reason) ? reason : "rejected");
|
||||
this.updateById(update);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
|
||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||
.eq(EarningsLineEntity::getStatus, "withdrawing")
|
||||
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||
.list();
|
||||
for (EarningsLineEntity line : lines) {
|
||||
LocalDateTime unlock = line.getUnlockTime();
|
||||
String restored = unlock != null && unlock.isAfter(now) ? "frozen" : "available";
|
||||
String restored = unlock != null && unlock.isAfter(now)
|
||||
? EarningsLineStatus.FROZEN.getCode()
|
||||
: EarningsLineStatus.AVAILABLE.getCode();
|
||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||
.eq(EarningsLineEntity::getId, line.getId())
|
||||
.set(EarningsLineEntity::getWithdrawalId, null)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import io.swagger.annotations.ApiModelProperty;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
@@ -23,4 +24,26 @@ public class ClerkWithdrawBalanceVo {
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private LocalDateTime nextUnlockAt;
|
||||
|
||||
@ApiModelProperty("是否提现锁定(仅允许同时存在一笔在途申请)")
|
||||
private Boolean withdrawLocked;
|
||||
|
||||
@ApiModelProperty("锁定原因")
|
||||
private String withdrawLockReason;
|
||||
|
||||
@ApiModelProperty("当前在途申请(pending/processing)")
|
||||
private ActiveRequest activeRequest;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ActiveRequest {
|
||||
@ApiModelProperty("申请金额")
|
||||
private BigDecimal amount;
|
||||
@ApiModelProperty("状态 pending/processing")
|
||||
private String status;
|
||||
@ApiModelProperty("提交时间 yyyy-MM-dd HH:mm:ss")
|
||||
private String createdTime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,14 @@ apitest:
|
||||
user-header: X-Test-User
|
||||
defaults:
|
||||
tenant-id: tenant-apitest
|
||||
user-id: apitest-user
|
||||
# Must exist in DB. ApiTestDataSeeder seeds DEFAULT_ADMIN_USER_ID=user-apitest-admin.
|
||||
user-id: user-apitest-admin
|
||||
roles:
|
||||
- ROLE_TESTER
|
||||
permissions: []
|
||||
permissions:
|
||||
- withdraw:deduction:create
|
||||
- withdraw:deduction:read
|
||||
|
||||
# E2E/ApiTest: skip real WeChat subscribe check to keep flows deterministic.
|
||||
wechat:
|
||||
subscribe-check-enabled: false
|
||||
|
||||
@@ -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='收益批量扣减日志';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.anyOf;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
@@ -123,7 +125,7 @@ class AdminEarningsAdjustmentControllerApiTest extends AbstractApiTest {
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.adjustmentId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.status").value("PROCESSING"));
|
||||
.andExpect(jsonPath("$.data.status").value(anyOf(is("PROCESSING"), is("APPLIED"))));
|
||||
|
||||
// After implementation, the system should eventually transition to APPLIED.
|
||||
// Poll with a bounded wait to keep the test deterministic.
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
|
||||
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* Authorization contract tests for admin batch deductions.
|
||||
*/
|
||||
class AdminEarningsDeductionBatchAuthorizationApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
|
||||
|
||||
private static final String PERMISSION_CREATE = "withdraw:deduction:create";
|
||||
private static final String PERMISSION_READ = "withdraw:deduction:read";
|
||||
|
||||
private static final String BASE_URL = "/admin/earnings/deductions";
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private IPlayPersonnelGroupInfoService groupInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> groupIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> orderIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> earningsIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> batchIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
groupIdsToCleanup.clear();
|
||||
clerkIdsToCleanup.clear();
|
||||
orderIdsToCleanup.clear();
|
||||
earningsIdsToCleanup.clear();
|
||||
batchIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupBatches(batchIdsToCleanup);
|
||||
if (!earningsIdsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningsIdsToCleanup);
|
||||
}
|
||||
if (!orderIdsToCleanup.isEmpty()) {
|
||||
orderInfoService.removeByIds(orderIdsToCleanup);
|
||||
}
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
if (!groupIdsToCleanup.isEmpty()) {
|
||||
groupInfoService.removeByIds(groupIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewWithoutPermissionReturns403() throws Exception {
|
||||
String payload = "{\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"2026-01-01 00:00:00\",\"endTime\":\"2026-01-07 23:59:59\"," +
|
||||
"\"ruleType\":\"FIXED\",\"amount\":\"10.00\",\"operation\":\"BONUS\",\"reasonDescription\":\"x\"}";
|
||||
mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, "leader-no-perm")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, "nope")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@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)
|
||||
.header(PERMISSIONS_HEADER, "nope"))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + UUID.randomUUID() + "/items")
|
||||
.header(USER_HEADER, "read-no-perm")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, "nope"))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + UUID.randomUUID() + "/logs")
|
||||
.header(USER_HEADER, "read-no-perm")
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, "nope"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderWithReadPermissionCannotReadOtherGroupBatchReturns403() throws Exception {
|
||||
String leaderUserId = "leader-read-" + IdUtils.getUuid();
|
||||
String otherClerkId = seedClerkInOtherGroup(
|
||||
"other-group-" + IdUtils.getUuid(),
|
||||
"other-leader-" + IdUtils.getUuid(),
|
||||
"Other Group",
|
||||
otherLeaderId());
|
||||
|
||||
String batchId = "batch-auth-" + IdUtils.getUuid();
|
||||
batchIdsToCleanup.add(batchId);
|
||||
seedBatchItemAndLogForOtherClerk(batchId, otherClerkId);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, leaderUserId)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_READ))
|
||||
.andExpect(status().isForbidden());
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, leaderUserId)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSION_READ))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private String buildFixedPayload(LocalDateTime begin, LocalDateTime end, String amount) {
|
||||
return "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"" + amount + "\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"auth\"" +
|
||||
"}";
|
||||
}
|
||||
|
||||
private void seedOrderAndLine(String clerkId, LocalDateTime endTime, BigDecimal amount) {
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
String orderId = "order-deduct-auth-" + IdUtils.getUuid();
|
||||
order.setId(orderId);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setOrderNo("AUTH-" + System.currentTimeMillis());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderType("2");
|
||||
order.setPlaceType("0");
|
||||
order.setRewardType("0");
|
||||
order.setAcceptBy(clerkId);
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
order.setOrderMoney(new BigDecimal("120.50"));
|
||||
order.setFinalAmount(order.getOrderMoney());
|
||||
order.setEstimatedRevenue(amount);
|
||||
order.setOrderSettlementState("1");
|
||||
order.setOrderEndTime(endTime);
|
||||
order.setOrderSettlementTime(endTime);
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(nowDate);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(nowDate);
|
||||
order.setDeleted(false);
|
||||
orderInfoService.save(order);
|
||||
orderIdsToCleanup.add(orderId);
|
||||
|
||||
EarningsLineEntity line = new EarningsLineEntity();
|
||||
String earningId = "earn-deduct-auth-" + IdUtils.getUuid();
|
||||
line.setId(earningId);
|
||||
line.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
line.setClerkId(clerkId);
|
||||
line.setOrderId(orderId);
|
||||
line.setSourceType(EarningsSourceType.ORDER);
|
||||
line.setSourceId(orderId);
|
||||
line.setAmount(amount);
|
||||
line.setEarningType(EarningsType.ORDER);
|
||||
line.setStatus("withdrawn");
|
||||
line.setUnlockTime(LocalDateTime.now().minusHours(1));
|
||||
line.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
line.setCreatedTime(nowDate);
|
||||
line.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
line.setUpdatedTime(nowDate);
|
||||
line.setDeleted(false);
|
||||
earningsService.save(line);
|
||||
earningsIdsToCleanup.add(earningId);
|
||||
}
|
||||
|
||||
private String seedClerkInOtherGroup(String groupId, String leaderUserId, String groupName, String groupLeaderSysUserId) {
|
||||
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
|
||||
group.setId(groupId);
|
||||
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
group.setSysUserId(groupLeaderSysUserId);
|
||||
group.setSysUserCode(leaderUserId);
|
||||
group.setGroupName(groupName);
|
||||
group.setLeaderName(leaderUserId);
|
||||
group.setAddTime(LocalDateTime.now());
|
||||
groupInfoService.save(group);
|
||||
groupIdsToCleanup.add(groupId);
|
||||
|
||||
String clerkId = "clerk-deduct-auth-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-" + clerkId);
|
||||
clerk.setNickname("Auth Clerk");
|
||||
clerk.setGroupId(groupId);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
return clerkId;
|
||||
}
|
||||
|
||||
private String otherLeaderId() {
|
||||
return "leader-scope-" + IdUtils.getUuid();
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime time) {
|
||||
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
private void cleanupBatches(List<String> batchIds) {
|
||||
if (batchIds == null || batchIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!tableExists("play_earnings_deduction_batch")) {
|
||||
return;
|
||||
}
|
||||
for (String batchId : batchIds) {
|
||||
cleanupBatch(batchId);
|
||||
}
|
||||
batchIds.clear();
|
||||
}
|
||||
|
||||
private void cleanupBatch(String batchId) {
|
||||
if (batchId == null || batchId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> adjustmentIds = new ArrayList<>();
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
adjustmentIds = jdbcTemplate.queryForList(
|
||||
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
|
||||
String.class,
|
||||
batchId);
|
||||
}
|
||||
if (!adjustmentIds.isEmpty()) {
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
|
||||
if (tableExists("play_earnings_deduction_batch_log")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
|
||||
}
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
|
||||
}
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void seedBatchItemAndLogForOtherClerk(String batchId, String clerkId) {
|
||||
Date now = new Date();
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_batch " +
|
||||
"(id, tenant_id, status, begin_time, end_time, rule_type, rule_value, reason_description, idempotency_key, request_hash, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
"FAILED",
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now().minusDays(7)),
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now()),
|
||||
"FIXED",
|
||||
"10.00",
|
||||
"seed",
|
||||
"seed-" + batchId,
|
||||
"seed",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_item " +
|
||||
"(id, batch_id, tenant_id, clerk_id, base_amount, apply_amount, status, adjustment_id, failure_reason, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"item-" + IdUtils.getUuid(),
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
clerkId,
|
||||
"100.00",
|
||||
"-10.00",
|
||||
"FAILED",
|
||||
null,
|
||||
"seed failure",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_batch_log " +
|
||||
"(id, batch_id, tenant_id, event_type, status_from, status_to, message, payload, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"log-" + IdUtils.getUuid(),
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
"ITEM_FAILED",
|
||||
"PROCESSING",
|
||||
"FAILED",
|
||||
"seed item failed",
|
||||
"{\"clerkId\":\"" + clerkId + "\"}",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
|
||||
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* End-to-end contract tests for admin batch deductions (bonus/punishment across clerks).
|
||||
*
|
||||
* <p>These tests are expected to FAIL until the batch deduction system is implemented end-to-end.</p>
|
||||
*/
|
||||
class AdminEarningsDeductionBatchControllerApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:deduction:create,withdraw:deduction:read";
|
||||
|
||||
private static final String BASE_URL = "/admin/earnings/deductions";
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IPlayClerkUserInfoService clerkUserInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> ordersToCleanup = new ArrayList<>();
|
||||
private final List<String> earningsToCleanup = new ArrayList<>();
|
||||
private final List<String> batchIdsToCleanup = new ArrayList<>();
|
||||
private final List<String> clerkIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
ordersToCleanup.clear();
|
||||
earningsToCleanup.clear();
|
||||
batchIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupBatches(batchIdsToCleanup);
|
||||
if (!earningsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningsToCleanup);
|
||||
}
|
||||
if (!ordersToCleanup.isEmpty()) {
|
||||
orderInfoService.removeByIds(ordersToCleanup);
|
||||
}
|
||||
if (!clerkIdsToCleanup.isEmpty()) {
|
||||
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewRequiresRequiredFieldsReturns400() throws Exception {
|
||||
mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewPercentageUsesOnlyOrderLinesPositiveAmountsAndOrderEndTimeWindow() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
String clerkId = ensureTestClerkInDefaultTenant();
|
||||
|
||||
// In window: order1 + order2
|
||||
String order1 = seedOrder(clerkId, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(clerkId, order1, new BigDecimal("100.00"), "withdrawn", EarningsType.ORDER);
|
||||
seedOrderEarningLine(clerkId, order1, new BigDecimal("-30.00"), "available", EarningsType.ADJUSTMENT);
|
||||
|
||||
String order2 = seedOrder(clerkId, now.minusDays(2), new BigDecimal("50.00"));
|
||||
seedOrderEarningLine(clerkId, order2, new BigDecimal("50.00"), "available", EarningsType.ORDER);
|
||||
|
||||
// Out of window: order3 should not contribute
|
||||
String order3 = seedOrder(clerkId, now.minusDays(30), new BigDecimal("999.00"));
|
||||
seedOrderEarningLine(clerkId, order3, new BigDecimal("999.00"), "withdrawn", EarningsType.ORDER);
|
||||
|
||||
// Adjustment line (order_id=null) should not contribute even if positive
|
||||
seedAdjustmentEarningLine(clerkId, new BigDecimal("1000.00"), "available");
|
||||
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + clerkId + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"PERCENTAGE\"," +
|
||||
"\"percentage\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"week bonus\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.items").isArray())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
JsonNode item = root.path("data").path("items").get(0);
|
||||
assertThat(item.path("clerkId").asText()).isEqualTo(clerkId);
|
||||
|
||||
BigDecimal baseAmount = new BigDecimal(item.path("baseAmount").asText("0"));
|
||||
BigDecimal applyAmount = new BigDecimal(item.path("applyAmount").asText("0"));
|
||||
assertThat(baseAmount).isEqualByComparingTo("150.00"); // 100 + 50 (negative & non-order excluded)
|
||||
assertThat(applyAmount).isEqualByComparingTo("15.00"); // 10% bonus
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewWindowIsInclusiveOnBoundaries() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.minusDays(1);
|
||||
String clerkId = ensureTestClerkInDefaultTenant();
|
||||
|
||||
String orderBegin = seedOrder(clerkId, begin, new BigDecimal("20.00"));
|
||||
seedOrderEarningLine(clerkId, orderBegin, new BigDecimal("20.00"), "withdrawn", EarningsType.ORDER);
|
||||
|
||||
String orderEnd = seedOrder(clerkId, end, new BigDecimal("30.00"));
|
||||
seedOrderEarningLine(clerkId, orderEnd, new BigDecimal("30.00"), "withdrawn", EarningsType.ORDER);
|
||||
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + clerkId + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"PERCENTAGE\"," +
|
||||
"\"percentage\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"boundary\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
JsonNode item = root.path("data").path("items").get(0);
|
||||
BigDecimal baseAmount = new BigDecimal(item.path("baseAmount").asText("0"));
|
||||
assertThat(baseAmount).isEqualByComparingTo("50.00"); // 20 + 30 (both boundary-included)
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewRejectsCrossTenantClerkScopeReturns403() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "withdrawn", EarningsType.ORDER);
|
||||
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"PERCENTAGE\"," +
|
||||
"\"percentage\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"tenant isolation\"" +
|
||||
"}";
|
||||
|
||||
mockMvc.perform(post(BASE_URL + "/preview")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReturns202AndProvidesPollingHandle() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"PERCENTAGE\"," +
|
||||
"\"percentage\":\"10.00\"," +
|
||||
"\"operation\":\"PUNISHMENT\"," +
|
||||
"\"reasonDescription\":\"week penalty\"" +
|
||||
"}";
|
||||
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(header().string("Location", BASE_URL + "/idempotency/" + key))
|
||||
.andExpect(jsonPath("$.code").value(202))
|
||||
.andExpect(jsonPath("$.data.batchId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||
.andExpect(jsonPath("$.data.status").value("PROCESSING"))
|
||||
.andReturn();
|
||||
|
||||
String batchId = extractBatchId(result);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createIsIdempotentWithSameKeyAndSameBody() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"fixed bonus\"" +
|
||||
"}";
|
||||
|
||||
MvcResult first = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchA = extractBatchId(first);
|
||||
|
||||
MvcResult second = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchB = extractBatchId(second);
|
||||
|
||||
assertThat(batchB).isEqualTo(batchA);
|
||||
batchIdsToCleanup.add(batchA);
|
||||
awaitApplied(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createConcurrentRequestsSameKeyOnlyOneBatchCreated() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"concurrent\"" +
|
||||
"}";
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<String> call = () -> {
|
||||
MvcResult result = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
return extractBatchId(result);
|
||||
};
|
||||
List<Future<String>> futures = new ArrayList<>();
|
||||
futures.add(pool.submit(call));
|
||||
futures.add(pool.submit(call));
|
||||
|
||||
String a = futures.get(0).get();
|
||||
String b = futures.get(1).get();
|
||||
assertThat(a).isNotBlank();
|
||||
assertThat(b).isEqualTo(a);
|
||||
batchIdsToCleanup.add(a);
|
||||
awaitApplied(key);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSameKeyDifferentBodyReturns409() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payloadA = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"fixed bonus\"" +
|
||||
"}";
|
||||
String payloadB = payloadA.replace("\"50.00\"", "\"60.00\"");
|
||||
|
||||
MvcResult first = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadA))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(first);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payloadB))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollMissingIdempotencyKeyReturns404() throws Exception {
|
||||
mockMvc.perform(get(BASE_URL + "/idempotency/" + UUID.randomUUID())
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyIsTenantScoped() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"PUNISHMENT\"," +
|
||||
"\"reasonDescription\":\"tenant scope\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/idempotency/" + key)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemsAfterAppliedHaveAdjustmentIdAndNoDuplicateEarningsLines() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"50.00\"," +
|
||||
"\"operation\":\"PUNISHMENT\"," +
|
||||
"\"reasonDescription\":\"fixed penalty\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
MvcResult items = mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data[0].adjustmentId").isNotEmpty())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(items.getResponse().getContentAsString());
|
||||
JsonNode first = root.path("data").get(0);
|
||||
String adjustmentId = first.path("adjustmentId").asText();
|
||||
assertThat(adjustmentId).isNotBlank();
|
||||
|
||||
long count = earningsService.lambdaQuery()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.eq(EarningsLineEntity::getSourceId, adjustmentId)
|
||||
.count();
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemsPaginationWorks() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String secondClerkId = ensureTestClerkInDefaultTenant();
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String order2 = seedOrder(secondClerkId, now.minusDays(1), new BigDecimal("80.00"));
|
||||
seedOrderEarningLine(secondClerkId, order2, new BigDecimal("80.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\",\"" + secondClerkId + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"pagination\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data.length()").value(1))
|
||||
.andExpect(jsonPath("$.total").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logsContainCreatedAndFinalEvents() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"audit log\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data[?(@.eventType=='CREATED')]").exists())
|
||||
.andExpect(jsonPath("$.data[?(@.eventType=='APPLY_STARTED')]").exists())
|
||||
.andExpect(jsonPath("$.data[?(@.eventType=='BATCH_APPLIED')]").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemsAndLogsAreTenantScopedToBatchTenant() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"tenant scope items\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
awaitApplied(key);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isNotFound());
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, "tenant-other")
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logsRecordOperatorInCreatedBy() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"operator audit\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
awaitApplied(key);
|
||||
|
||||
MvcResult logs = mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(logs.getResponse().getContentAsString());
|
||||
JsonNode data = root.path("data");
|
||||
boolean hasOperator = false;
|
||||
for (JsonNode node : data) {
|
||||
String createdBy = node.path("createdBy").asText();
|
||||
if (ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID.equals(createdBy)) {
|
||||
hasOperator = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(hasOperator).isTrue();
|
||||
}
|
||||
|
||||
private String extractBatchId(MvcResult result) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("batchId").asText();
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 120; i++) {
|
||||
MvcResult poll = mockMvc.perform(get(BASE_URL + "/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("batchId").asText();
|
||||
}
|
||||
if ("FAILED".equals(status)) {
|
||||
throw new AssertionError("batch failed unexpectedly: key=" + idempotencyKey);
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("batch not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private String seedOrder(String clerkId, LocalDateTime endTime, BigDecimal estimatedRevenue) {
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
String id = "order-deduct-" + IdUtils.getUuid();
|
||||
order.setId(id);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setOrderNo("DED-" + System.currentTimeMillis());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderType("2");
|
||||
order.setPlaceType("0");
|
||||
order.setRewardType("0");
|
||||
order.setAcceptBy(clerkId);
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
order.setOrderMoney(new BigDecimal("120.50"));
|
||||
order.setFinalAmount(order.getOrderMoney());
|
||||
order.setEstimatedRevenue(estimatedRevenue);
|
||||
order.setOrderSettlementState("1");
|
||||
order.setOrderEndTime(endTime);
|
||||
order.setOrderSettlementTime(endTime);
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(nowDate);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(nowDate);
|
||||
order.setDeleted(false);
|
||||
orderInfoService.save(order);
|
||||
ordersToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void seedOrderEarningLine(String clerkId, String orderId, BigDecimal amount, String status, EarningsType earningType) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-deduct-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId(orderId);
|
||||
entity.setSourceType(EarningsSourceType.ORDER);
|
||||
entity.setSourceId(orderId);
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(earningType);
|
||||
entity.setStatus(status);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(nowDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(nowDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningsToCleanup.add(id);
|
||||
}
|
||||
|
||||
private void seedAdjustmentEarningLine(String clerkId, BigDecimal amount, String status) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-deduct-adj-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId(null);
|
||||
entity.setSourceType(EarningsSourceType.ADJUSTMENT);
|
||||
entity.setSourceId("adj-seed-" + IdUtils.getUuid());
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(EarningsType.ADJUSTMENT);
|
||||
entity.setStatus(status);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(nowDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(nowDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningsToCleanup.add(id);
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime time) {
|
||||
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
private void cleanupBatches(List<String> batchIds) {
|
||||
if (batchIds == null || batchIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!tableExists("play_earnings_deduction_batch")) {
|
||||
return;
|
||||
}
|
||||
for (String batchId : batchIds) {
|
||||
cleanupBatch(batchId);
|
||||
}
|
||||
batchIds.clear();
|
||||
}
|
||||
|
||||
private void cleanupBatch(String batchId) {
|
||||
if (batchId == null || batchId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> adjustmentIds = new ArrayList<>();
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
adjustmentIds = jdbcTemplate.queryForList(
|
||||
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
|
||||
String.class,
|
||||
batchId);
|
||||
}
|
||||
|
||||
if (!adjustmentIds.isEmpty()) {
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
|
||||
if (tableExists("play_earnings_deduction_batch_log")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
|
||||
}
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
|
||||
}
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String ensureTestClerkInDefaultTenant() {
|
||||
String clerkId = "clerk-deduct-" + IdUtils.getUuid();
|
||||
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
|
||||
clerk.setId(clerkId);
|
||||
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerk.setSysUserId("sysuser-" + IdUtils.getUuid());
|
||||
clerk.setOpenid("openid-" + clerkId);
|
||||
clerk.setNickname("Batch Clerk");
|
||||
clerk.setGroupId(ApiTestDataSeeder.DEFAULT_GROUP_ID);
|
||||
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
|
||||
clerk.setFixingLevel("1");
|
||||
clerk.setSex("2");
|
||||
clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8));
|
||||
clerk.setWeiChatCode("wechat-" + IdUtils.getUuid());
|
||||
clerk.setAvatar("https://example.com/avatar.png");
|
||||
clerk.setAccountBalance(BigDecimal.ZERO);
|
||||
clerk.setOnboardingState("1");
|
||||
clerk.setListingState("1");
|
||||
clerk.setDisplayState("1");
|
||||
clerk.setOnlineState("1");
|
||||
clerk.setRandomOrderState("1");
|
||||
clerk.setClerkState("1");
|
||||
clerk.setEntryTime(LocalDateTime.now());
|
||||
clerk.setToken("token-" + IdUtils.getUuid());
|
||||
clerkUserInfoService.save(clerk);
|
||||
clerkIdsToCleanup.add(clerkId);
|
||||
return clerkId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package com.starry.admin.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* End-to-end retry/idempotency contract tests for batch deductions.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until retry semantics are implemented.</p>
|
||||
*/
|
||||
class AdminEarningsDeductionBatchRetryApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
|
||||
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
|
||||
private static final String PERMISSIONS_CREATE_READ = "withdraw:deduction:create,withdraw:deduction:read";
|
||||
|
||||
private static final String BASE_URL = "/admin/earnings/deductions";
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private IPlayOrderInfoService orderInfoService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
private final List<String> ordersToCleanup = new ArrayList<>();
|
||||
private final List<String> earningsToCleanup = new ArrayList<>();
|
||||
private final List<String> adjustmentsToCleanup = new ArrayList<>();
|
||||
private final List<String> batchIdsToCleanup = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
ordersToCleanup.clear();
|
||||
earningsToCleanup.clear();
|
||||
adjustmentsToCleanup.clear();
|
||||
batchIdsToCleanup.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
cleanupBatches(batchIdsToCleanup);
|
||||
if (!earningsToCleanup.isEmpty()) {
|
||||
earningsService.removeByIds(earningsToCleanup);
|
||||
}
|
||||
if (!ordersToCleanup.isEmpty()) {
|
||||
orderInfoService.removeByIds(ordersToCleanup);
|
||||
}
|
||||
if (!adjustmentsToCleanup.isEmpty()) {
|
||||
adjustmentService.removeByIds(adjustmentsToCleanup);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryNoopWhenAllItemsAppliedDoesNotCreateDuplicateLines() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
LocalDateTime begin = now.minusDays(7);
|
||||
LocalDateTime end = now.plusSeconds(1);
|
||||
|
||||
String order1 = seedOrder(ApiTestDataSeeder.DEFAULT_CLERK_ID, now.minusDays(1), new BigDecimal("100.00"));
|
||||
seedOrderEarningLine(ApiTestDataSeeder.DEFAULT_CLERK_ID, order1, new BigDecimal("100.00"), "available", EarningsType.ORDER);
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
String payload = "{" +
|
||||
"\"clerkIds\":[\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"]," +
|
||||
"\"beginTime\":\"" + DATE_TIME.format(begin) + "\"," +
|
||||
"\"endTime\":\"" + DATE_TIME.format(end) + "\"," +
|
||||
"\"ruleType\":\"FIXED\"," +
|
||||
"\"amount\":\"10.00\"," +
|
||||
"\"operation\":\"BONUS\"," +
|
||||
"\"reasonDescription\":\"retry noop\"" +
|
||||
"}";
|
||||
|
||||
MvcResult create = mockMvc.perform(post(BASE_URL)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.header(IDEMPOTENCY_HEADER, key)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload))
|
||||
.andExpect(status().isAccepted())
|
||||
.andReturn();
|
||||
String batchId = extractBatchId(create);
|
||||
batchIdsToCleanup.add(batchId);
|
||||
awaitApplied(key);
|
||||
|
||||
String adjustmentId = fetchFirstItemAdjustmentId(batchId);
|
||||
long before = countEarningsLinesForAdjustment(adjustmentId);
|
||||
|
||||
mockMvc.perform(post(BASE_URL + "/" + batchId + "/retry")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
long after = countEarningsLinesForAdjustment(adjustmentId);
|
||||
assertThat(after).isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryCanRecoverFailedItemByReapplyingSameAdjustmentId() throws Exception {
|
||||
// Seed a FAILED adjustment + a FAILED batch item referencing that adjustment.
|
||||
// Contract: retry should reset FAILED adjustments/items and re-apply, without creating duplicates.
|
||||
LocalDateTime now = LocalDateTime.now().withNano(0);
|
||||
String clerkId = ApiTestDataSeeder.DEFAULT_CLERK_ID;
|
||||
|
||||
String batchId = "batch-retry-" + IdUtils.getUuid();
|
||||
batchIdsToCleanup.add(batchId);
|
||||
|
||||
EarningsLineAdjustmentEntity adjustment = new EarningsLineAdjustmentEntity();
|
||||
String adjustmentId = "adj-retry-" + IdUtils.getUuid();
|
||||
adjustment.setId(adjustmentId);
|
||||
adjustment.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
adjustment.setClerkId(clerkId);
|
||||
adjustment.setAmount(new BigDecimal("-10.00"));
|
||||
adjustment.setReasonType(EarningsAdjustmentReasonType.MANUAL);
|
||||
adjustment.setReasonDescription("seed failed adjustment");
|
||||
adjustment.setStatus(EarningsAdjustmentStatus.FAILED);
|
||||
adjustment.setIdempotencyKey("deduct:" + batchId + ":" + clerkId);
|
||||
adjustment.setRequestHash("seed");
|
||||
adjustment.setEffectiveTime(now);
|
||||
adjustmentService.save(adjustment);
|
||||
adjustmentsToCleanup.add(adjustmentId);
|
||||
|
||||
seedBatchAndItemFailed(batchId, clerkId, new BigDecimal("100.00"), new BigDecimal("-10.00"), adjustmentId);
|
||||
|
||||
mockMvc.perform(post(BASE_URL + "/" + batchId + "/retry")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
awaitBatchApplied(batchId);
|
||||
|
||||
EarningsLineAdjustmentEntity refreshed = adjustmentService.getById(adjustmentId);
|
||||
assertThat(refreshed).isNotNull();
|
||||
assertThat(refreshed.getStatus()).isEqualTo(EarningsAdjustmentStatus.APPLIED);
|
||||
|
||||
assertThat(countEarningsLinesForAdjustment(adjustmentId)).isEqualTo(1);
|
||||
|
||||
mockMvc.perform(get(BASE_URL + "/" + batchId + "/logs")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data[?(@.eventType=='RETRY_STARTED')]").exists());
|
||||
}
|
||||
|
||||
private String extractBatchId(MvcResult result) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
return root.path("data").path("batchId").asText();
|
||||
}
|
||||
|
||||
private String awaitApplied(String idempotencyKey) throws Exception {
|
||||
for (int i = 0; i < 120; i++) {
|
||||
MvcResult poll = mockMvc.perform(get(BASE_URL + "/idempotency/" + idempotencyKey)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return root.path("data").path("batchId").asText();
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("batch not applied within timeout: key=" + idempotencyKey);
|
||||
}
|
||||
|
||||
private void awaitBatchApplied(String batchId) throws Exception {
|
||||
for (int i = 0; i < 120; i++) {
|
||||
MvcResult poll = mockMvc.perform(get(BASE_URL + "/" + batchId)
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
|
||||
.andReturn();
|
||||
if (poll.getResponse().getStatus() == 200) {
|
||||
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
|
||||
String status = root.path("data").path("status").asText();
|
||||
if ("APPLIED".equals(status)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Thread.sleep(50);
|
||||
}
|
||||
throw new AssertionError("batch not applied within timeout: batchId=" + batchId);
|
||||
}
|
||||
|
||||
private String fetchFirstItemAdjustmentId(String batchId) throws Exception {
|
||||
MvcResult items = mockMvc.perform(get(BASE_URL + "/" + batchId + "/items")
|
||||
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode root = objectMapper.readTree(items.getResponse().getContentAsString());
|
||||
return root.path("data").get(0).path("adjustmentId").asText();
|
||||
}
|
||||
|
||||
private long countEarningsLinesForAdjustment(String adjustmentId) {
|
||||
if (adjustmentId == null || adjustmentId.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return earningsService.lambdaQuery()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.eq(EarningsLineEntity::getSourceId, adjustmentId)
|
||||
.count();
|
||||
}
|
||||
|
||||
private void seedBatchAndItemFailed(String batchId, String clerkId, BigDecimal baseAmount, BigDecimal applyAmount, String adjustmentId) {
|
||||
Date now = new Date();
|
||||
// Batch
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_batch " +
|
||||
"(id, tenant_id, status, begin_time, end_time, rule_type, rule_value, reason_description, idempotency_key, request_hash, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
"FAILED",
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now().minusDays(7)),
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now()),
|
||||
"FIXED",
|
||||
applyAmount.abs().setScale(2).toPlainString(),
|
||||
"seed batch",
|
||||
"seed-" + batchId,
|
||||
"seed",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
|
||||
// Item
|
||||
String itemId = "item-retry-" + IdUtils.getUuid();
|
||||
jdbcTemplate.update(
|
||||
"insert into play_earnings_deduction_item " +
|
||||
"(id, batch_id, tenant_id, clerk_id, base_amount, apply_amount, status, adjustment_id, failure_reason, created_by, created_time, updated_by, updated_time, deleted, version) " +
|
||||
"values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
itemId,
|
||||
batchId,
|
||||
ApiTestDataSeeder.DEFAULT_TENANT_ID,
|
||||
clerkId,
|
||||
baseAmount.setScale(2).toPlainString(),
|
||||
applyAmount.setScale(2).toPlainString(),
|
||||
"FAILED",
|
||||
adjustmentId,
|
||||
"seed failure",
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID,
|
||||
now,
|
||||
0,
|
||||
1);
|
||||
}
|
||||
|
||||
private String seedOrder(String clerkId, LocalDateTime endTime, BigDecimal estimatedRevenue) {
|
||||
PlayOrderInfoEntity order = new PlayOrderInfoEntity();
|
||||
String id = "order-deduct-retry-" + IdUtils.getUuid();
|
||||
order.setId(id);
|
||||
order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
order.setOrderNo("RET-" + System.currentTimeMillis());
|
||||
order.setOrderStatus("3");
|
||||
order.setOrderType("2");
|
||||
order.setPlaceType("0");
|
||||
order.setRewardType("0");
|
||||
order.setAcceptBy(clerkId);
|
||||
order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID);
|
||||
order.setCommodityId(ApiTestDataSeeder.DEFAULT_COMMODITY_ID);
|
||||
order.setOrderMoney(new BigDecimal("120.50"));
|
||||
order.setFinalAmount(order.getOrderMoney());
|
||||
order.setEstimatedRevenue(estimatedRevenue);
|
||||
order.setOrderSettlementState("1");
|
||||
order.setOrderEndTime(endTime);
|
||||
order.setOrderSettlementTime(endTime);
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setCreatedTime(nowDate);
|
||||
order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
order.setUpdatedTime(nowDate);
|
||||
order.setDeleted(false);
|
||||
orderInfoService.save(order);
|
||||
ordersToCleanup.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void seedOrderEarningLine(String clerkId, String orderId, BigDecimal amount, String status, EarningsType earningType) {
|
||||
EarningsLineEntity entity = new EarningsLineEntity();
|
||||
String id = "earn-deduct-retry-" + IdUtils.getUuid();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
entity.setClerkId(clerkId);
|
||||
entity.setOrderId(orderId);
|
||||
entity.setSourceType(EarningsSourceType.ORDER);
|
||||
entity.setSourceId(orderId);
|
||||
entity.setAmount(amount);
|
||||
entity.setEarningType(earningType);
|
||||
entity.setStatus(status);
|
||||
entity.setUnlockTime(LocalDateTime.now().minusHours(1));
|
||||
Date nowDate = toDate(LocalDateTime.now());
|
||||
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setCreatedTime(nowDate);
|
||||
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
|
||||
entity.setUpdatedTime(nowDate);
|
||||
entity.setDeleted(false);
|
||||
earningsService.save(entity);
|
||||
earningsToCleanup.add(id);
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime time) {
|
||||
return Date.from(time.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
private void cleanupBatches(List<String> batchIds) {
|
||||
if (batchIds == null || batchIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!tableExists("play_earnings_deduction_batch")) {
|
||||
return;
|
||||
}
|
||||
for (String batchId : batchIds) {
|
||||
cleanupBatch(batchId);
|
||||
}
|
||||
batchIds.clear();
|
||||
}
|
||||
|
||||
private void cleanupBatch(String batchId) {
|
||||
if (batchId == null || batchId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> adjustmentIds = new ArrayList<>();
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
adjustmentIds = jdbcTemplate.queryForList(
|
||||
"select adjustment_id from play_earnings_deduction_item where batch_id = ? and adjustment_id is not null",
|
||||
String.class,
|
||||
batchId);
|
||||
}
|
||||
if (!adjustmentIds.isEmpty()) {
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
|
||||
if (tableExists("play_earnings_deduction_batch_log")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch_log where batch_id = ?", batchId);
|
||||
}
|
||||
if (tableExists("play_earnings_deduction_item")) {
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_item where batch_id = ?", batchId);
|
||||
}
|
||||
jdbcTemplate.update("delete from play_earnings_deduction_batch where id = ?", batchId);
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@TestPropertySource(properties = "test.auth.secret=apitest-secret")
|
||||
class WxOauthAdminTestAuthApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
|
||||
private static final String TEST_AUTH_SECRET = "apitest-secret";
|
||||
|
||||
@Test
|
||||
void adminLoginByUsernameRejectsWithoutSecretHeader() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/admin/loginByUsername")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{" +
|
||||
"\"userName\":\"" + ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME + "\"," +
|
||||
"\"passWord\":\"apitest-secret\"," +
|
||||
"\"tenantKey\":\"" + ApiTestDataSeeder.DEFAULT_TENANT_KEY + "\"" +
|
||||
"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminLoginByUsernameReturnsTokenWhenSecretHeaderValid() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/admin/loginByUsername")
|
||||
.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("{" +
|
||||
"\"userName\":\"" + ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME + "\"," +
|
||||
"\"passWord\":\"apitest-secret\"," +
|
||||
"\"tenantKey\":\"" + ApiTestDataSeeder.DEFAULT_TENANT_KEY + "\"" +
|
||||
"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tokenHead").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.token").isNotEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@TestPropertySource(properties = "test.auth.secret=apitest-secret")
|
||||
class WxOauthE2eSeedOrderApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
|
||||
private static final String TEST_AUTH_SECRET = "apitest-secret";
|
||||
|
||||
@Test
|
||||
void seedOrderRejectsWithoutSecretHeader() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/e2e/seed/order")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedOrderReturnsFixtureWhenSecretHeaderValid() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/e2e/seed/order")
|
||||
.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("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tenantKey").value(ApiTestDataSeeder.DEFAULT_TENANT_KEY))
|
||||
.andExpect(jsonPath("$.data.customerId").value(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID))
|
||||
.andExpect(jsonPath("$.data.customerNickname").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.clerkId").value(ApiTestDataSeeder.DEFAULT_CLERK_ID))
|
||||
.andExpect(jsonPath("$.data.clerkNickname").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.clerkLevelId").value(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID))
|
||||
.andExpect(jsonPath("$.data.clerkSex").value("2"))
|
||||
.andExpect(jsonPath("$.data.commodityId").value(ApiTestDataSeeder.DEFAULT_COMMODITY_ID))
|
||||
.andExpect(jsonPath("$.data.giftId").value(ApiTestDataSeeder.DEFAULT_GIFT_ID));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
@TestPropertySource(properties = "test.auth.secret=apitest-secret")
|
||||
class WxOauthE2eSeedWageAdjustmentApiTest extends AbstractApiTest {
|
||||
|
||||
private static final String TEST_AUTH_HEADER = "X-Test-Auth";
|
||||
private static final String TEST_AUTH_SECRET = "apitest-secret";
|
||||
|
||||
@Test
|
||||
void seedWageAdjustmentRejectsWithoutSecretHeader() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/e2e/seed/wage-adjustment")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header("User-Agent", "apitest")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedWageAdjustmentReturnsFixtureWhenSecretHeaderValid() throws Exception {
|
||||
mockMvc.perform(post("/wx/oauth2/e2e/seed/wage-adjustment")
|
||||
.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("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.tenantKey").value(ApiTestDataSeeder.DEFAULT_TENANT_KEY))
|
||||
.andExpect(jsonPath("$.data.adminUserName").value(ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME))
|
||||
.andExpect(jsonPath("$.data.clerkId").value(ApiTestDataSeeder.DEFAULT_CLERK_ID))
|
||||
.andExpect(jsonPath("$.data.beginTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.endTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.baseAmount").value("150.00"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,76 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
.isEqualTo(now.plusHours(6).format(DATE_TIME_FORMATTER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void balanceIndicatesWithdrawLockedWhenPendingRequestExists__covers_WD_001() throws Exception {
|
||||
ensureTenantContext();
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
String requestId = IdUtils.getUuid();
|
||||
req.setId(requestId);
|
||||
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("10.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(new BigDecimal("10.00"));
|
||||
req.setStatus("pending");
|
||||
req.setCreatedTime(new Date());
|
||||
withdrawalService.save(req);
|
||||
withdrawalsToCleanup.add(requestId);
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/wx/withdraw/balance")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn();
|
||||
|
||||
JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString());
|
||||
JsonNode data = root.get("data");
|
||||
assertThat(data.path("withdrawLocked").asBoolean()).isTrue();
|
||||
assertThat(data.path("withdrawLockReason").asText()).isNotBlank();
|
||||
JsonNode active = data.path("activeRequest");
|
||||
assertThat(active.isMissingNode() || active.isNull()).isFalse();
|
||||
assertThat(active.path("status").asText()).isEqualTo("pending");
|
||||
assertThat(active.path("amount").decimalValue()).isEqualByComparingTo("10.00");
|
||||
assertThat(active.path("createdTime").asText()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void balanceDoesNotLockWhenActiveWithdrawalBelongsToDifferentTenant__covers_WD_001() throws Exception {
|
||||
ensureTenantContext();
|
||||
String otherTenantId = ApiTestDataSeeder.DEFAULT_TENANT_ID + "-other";
|
||||
String requestId = IdUtils.getUuid();
|
||||
|
||||
try {
|
||||
SecurityUtils.setTenantId(otherTenantId);
|
||||
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
|
||||
req.setId(requestId);
|
||||
req.setTenantId(otherTenantId);
|
||||
req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
|
||||
req.setAmount(new BigDecimal("10.00"));
|
||||
req.setFee(BigDecimal.ZERO);
|
||||
req.setNetAmount(new BigDecimal("10.00"));
|
||||
req.setStatus("pending");
|
||||
req.setCreatedTime(new Date());
|
||||
withdrawalService.save(req);
|
||||
|
||||
ensureTenantContext();
|
||||
mockMvc.perform(get("/wx/withdraw/balance")
|
||||
.header(USER_HEADER, DEFAULT_USER)
|
||||
.header(TENANT_HEADER, DEFAULT_TENANT)
|
||||
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.withdrawLocked").value(false))
|
||||
.andExpect(jsonPath("$.data.withdrawLockReason").value(""));
|
||||
} finally {
|
||||
SecurityUtils.setTenantId(otherTenantId);
|
||||
withdrawalService.removeById(requestId);
|
||||
ensureTenantContext();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithdrawRejectsNonPositiveAmount__covers_WD_003() throws Exception {
|
||||
ensureTenantContext();
|
||||
@@ -248,14 +318,17 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
refreshPayeeConfirmation();
|
||||
String firstWithdrawal = createWithdraw(new BigDecimal("35"));
|
||||
assertLinesLocked(firstWithdrawal, lineIds[0], lineIds[1], lineIds[2]);
|
||||
markWithdrawalCompleted(firstWithdrawal);
|
||||
|
||||
refreshPayeeConfirmation();
|
||||
String secondWithdrawal = createWithdraw(new BigDecimal("90"));
|
||||
assertLinesLocked(secondWithdrawal, lineIds[3], lineIds[4], lineIds[5]);
|
||||
markWithdrawalCompleted(secondWithdrawal);
|
||||
|
||||
refreshPayeeConfirmation();
|
||||
String thirdWithdrawal = createWithdraw(new BigDecimal("135"));
|
||||
assertLinesLocked(thirdWithdrawal, lineIds[6], lineIds[7], lineIds[8], lineIds[9]);
|
||||
markWithdrawalCompleted(thirdWithdrawal);
|
||||
|
||||
ensureTenantContext();
|
||||
BigDecimal remaining = earningsService.getAvailableAmount(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now());
|
||||
@@ -549,6 +622,14 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void markWithdrawalCompleted(String withdrawalId) {
|
||||
ensureTenantContext();
|
||||
WithdrawalRequestEntity patch = new WithdrawalRequestEntity();
|
||||
patch.setId(withdrawalId);
|
||||
patch.setStatus("success");
|
||||
withdrawalService.updateById(patch);
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime value) {
|
||||
return Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.starry.admin.db;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.starry.admin.api.AbstractApiTest;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* Database-level contract tests for the batch deductions system schema.
|
||||
*
|
||||
* <p>These tests are expected to FAIL until migrations add the new tables/indexes.</p>
|
||||
*/
|
||||
class EarningsDeductionBatchDatabaseSchemaApiTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
void batchTablesExist() {
|
||||
assertThat(tableExists("play_earnings_deduction_batch")).isTrue();
|
||||
assertThat(tableExists("play_earnings_deduction_item")).isTrue();
|
||||
assertThat(tableExists("play_earnings_deduction_batch_log")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchTableHasIdempotencyAndWindowFields() {
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "tenant_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "status")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "idempotency_key")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "request_hash")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "begin_time")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "end_time")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "rule_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "rule_value")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch", "reason_description")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void itemTableHasAmountsAndAdjustmentLink() {
|
||||
assertThat(columnExists("play_earnings_deduction_item", "batch_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "tenant_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "clerk_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "base_amount")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "apply_amount")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "status")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "adjustment_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_item", "failure_reason")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logTableHasEventFields() {
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "batch_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "tenant_id")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "event_type")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "status_from")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "status_to")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "message")).isTrue();
|
||||
assertThat(columnExists("play_earnings_deduction_batch_log", "payload")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueIndexExistsForTenantIdempotencyKey() {
|
||||
assertThat(indexExists("play_earnings_deduction_batch", "uk_tenant_idempotency")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueIndexExistsForBatchClerk() {
|
||||
assertThat(indexExists("play_earnings_deduction_item", "uk_batch_clerk")).isTrue();
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
|
||||
Integer.class,
|
||||
table);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private boolean columnExists(String table, String column) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.columns where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
|
||||
Integer.class,
|
||||
table,
|
||||
column);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private boolean indexExists(String table, String indexName) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"select count(*) from information_schema.statistics " +
|
||||
"where lower(table_name)=lower(?) and lower(index_name)=lower(?)",
|
||||
Integer.class,
|
||||
table,
|
||||
indexName);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private List<String> listIndexes(String table) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"select distinct index_name from information_schema.statistics where lower(table_name)=lower(?)",
|
||||
String.class,
|
||||
table);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WxCustomMpServiceTest {
|
||||
@@ -294,6 +295,7 @@ class WxCustomMpServiceTest {
|
||||
|
||||
@Test
|
||||
void checkSubscribeThrowsWhenUserNotSubscribed__covers_MP_008() throws WxErrorException {
|
||||
ReflectionTestUtils.setField(wxCustomMpService, "subscribeCheckEnabled", true);
|
||||
SysTenantEntity tenant = buildTenant();
|
||||
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
|
||||
when(wxMpService.switchoverTo(tenant.getAppId())).thenReturn(wxMpService);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.starry.admin.modules.withdraw.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.starry.admin.api.AbstractApiTest;
|
||||
import com.starry.admin.common.apitest.ApiTestDataSeeder;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionBatchLogEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsDeductionItemEntity;
|
||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionOperationType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsDeductionRuleType;
|
||||
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionBatchLogMapper;
|
||||
import com.starry.admin.modules.withdraw.mapper.EarningsDeductionItemMapper;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
|
||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||
import com.starry.admin.utils.SecurityUtils;
|
||||
import com.starry.common.utils.IdUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
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;
|
||||
|
||||
class EarningsDeductionBatchServiceImplIdempotencyIntegrationTest extends AbstractApiTest {
|
||||
|
||||
@Autowired
|
||||
private EarningsDeductionBatchServiceImpl batchService;
|
||||
|
||||
@Autowired
|
||||
private EarningsDeductionItemMapper itemMapper;
|
||||
|
||||
@Autowired
|
||||
private EarningsDeductionBatchLogMapper logMapper;
|
||||
|
||||
@Autowired
|
||||
private IEarningsAdjustmentService adjustmentService;
|
||||
|
||||
@Autowired
|
||||
private IEarningsService earningsService;
|
||||
|
||||
private String batchId;
|
||||
private String clerkId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
clerkId = IdUtils.getUuid();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
|
||||
if (batchId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<EarningsDeductionItemEntity> items = itemMapper.selectList(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getDeleted, false));
|
||||
|
||||
List<String> adjustmentIds = items.stream()
|
||||
.map(EarningsDeductionItemEntity::getAdjustmentId)
|
||||
.filter(id -> id != null && !id.isBlank())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!adjustmentIds.isEmpty()) {
|
||||
earningsService.lambdaUpdate()
|
||||
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsLineEntity::getClerkId, clerkId)
|
||||
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
|
||||
.in(EarningsLineEntity::getSourceId, adjustmentIds)
|
||||
.remove();
|
||||
adjustmentService.removeByIds(adjustmentIds);
|
||||
}
|
||||
|
||||
itemMapper.delete(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId));
|
||||
|
||||
logMapper.delete(Wrappers.lambdaQuery(EarningsDeductionBatchLogEntity.class)
|
||||
.eq(EarningsDeductionBatchLogEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
|
||||
.eq(EarningsDeductionBatchLogEntity::getBatchId, batchId));
|
||||
|
||||
batchService.removeById(batchId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyOnce_canBeTriggeredTwiceWithoutBreakingIdempotency() {
|
||||
String tenantId = ApiTestDataSeeder.DEFAULT_TENANT_ID;
|
||||
LocalDateTime begin = LocalDateTime.now().minusDays(1);
|
||||
LocalDateTime end = LocalDateTime.now();
|
||||
String idempotencyKey = "e2e-deduct-idem-" + UUID.randomUUID();
|
||||
|
||||
EarningsDeductionBatchEntity batch = batchService.createOrGetProcessing(
|
||||
tenantId,
|
||||
List.of(clerkId),
|
||||
begin,
|
||||
end,
|
||||
EarningsDeductionRuleType.FIXED,
|
||||
new BigDecimal("1.00"),
|
||||
EarningsDeductionOperationType.PUNISHMENT,
|
||||
"idempotency test",
|
||||
idempotencyKey);
|
||||
assertNotNull(batch);
|
||||
batchId = batch.getId();
|
||||
|
||||
batchService.applyOnce(batchId);
|
||||
|
||||
EarningsDeductionItemEntity item = itemMapper.selectOne(Wrappers.lambdaQuery(EarningsDeductionItemEntity.class)
|
||||
.eq(EarningsDeductionItemEntity::getTenantId, tenantId)
|
||||
.eq(EarningsDeductionItemEntity::getBatchId, batchId)
|
||||
.eq(EarningsDeductionItemEntity::getClerkId, clerkId)
|
||||
.last("limit 1"));
|
||||
assertNotNull(item);
|
||||
assertTrue(item.getAdjustmentId() != null && !item.getAdjustmentId().isBlank());
|
||||
|
||||
assertDoesNotThrow(() -> batchService.applyOnce(batchId));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user