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.IPlayCommodityAndLevelInfoService;
|
||||||
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
|
import com.starry.admin.modules.shop.service.IPlayCommodityInfoService;
|
||||||
import com.starry.admin.modules.shop.service.IPlayGiftInfoService;
|
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.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.SysTenantEntity;
|
||||||
import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity;
|
import com.starry.admin.modules.system.module.entity.SysTenantPackageEntity;
|
||||||
import com.starry.admin.modules.system.module.entity.SysUserEntity;
|
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.system.service.SysUserService;
|
||||||
import com.starry.admin.modules.weichat.service.WxTokenService;
|
import com.starry.admin.modules.weichat.service.WxTokenService;
|
||||||
import com.starry.admin.utils.SecurityUtils;
|
import com.starry.admin.utils.SecurityUtils;
|
||||||
|
import com.starry.common.constant.UserConstants;
|
||||||
import com.starry.common.context.CustomSecurityContextHolder;
|
import com.starry.common.context.CustomSecurityContextHolder;
|
||||||
import com.starry.common.utils.IdUtils;
|
import com.starry.common.utils.IdUtils;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
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_ID = "gift-basic";
|
||||||
public static final String DEFAULT_GIFT_NAME = "API测试礼物";
|
public static final String DEFAULT_GIFT_NAME = "API测试礼物";
|
||||||
public static final BigDecimal DEFAULT_COMMODITY_PRICE = new BigDecimal("120.00");
|
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_TYPE_REGULAR = "1";
|
||||||
private static final String GIFT_STATE_ACTIVE = "0";
|
private static final String GIFT_STATE_ACTIVE = "0";
|
||||||
private static final BigDecimal DEFAULT_CUSTOMER_BALANCE = new BigDecimal("200.00");
|
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 ISysTenantService tenantService;
|
||||||
private final SysUserService sysUserService;
|
private final SysUserService sysUserService;
|
||||||
private final SysUserMapper sysUserMapper;
|
private final SysUserMapper sysUserMapper;
|
||||||
|
private final SysMenuMapper sysMenuMapper;
|
||||||
private final IPlayPersonnelGroupInfoService personnelGroupInfoService;
|
private final IPlayPersonnelGroupInfoService personnelGroupInfoService;
|
||||||
private final IPlayClerkLevelInfoService clerkLevelInfoService;
|
private final IPlayClerkLevelInfoService clerkLevelInfoService;
|
||||||
private final IPlayClerkUserInfoService clerkUserInfoService;
|
private final IPlayClerkUserInfoService clerkUserInfoService;
|
||||||
@@ -108,6 +114,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
|||||||
ISysTenantService tenantService,
|
ISysTenantService tenantService,
|
||||||
SysUserService sysUserService,
|
SysUserService sysUserService,
|
||||||
SysUserMapper sysUserMapper,
|
SysUserMapper sysUserMapper,
|
||||||
|
SysMenuMapper sysMenuMapper,
|
||||||
IPlayPersonnelGroupInfoService personnelGroupInfoService,
|
IPlayPersonnelGroupInfoService personnelGroupInfoService,
|
||||||
IPlayClerkLevelInfoService clerkLevelInfoService,
|
IPlayClerkLevelInfoService clerkLevelInfoService,
|
||||||
IPlayClerkUserInfoService clerkUserInfoService,
|
IPlayClerkUserInfoService clerkUserInfoService,
|
||||||
@@ -128,6 +135,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
|||||||
this.tenantService = tenantService;
|
this.tenantService = tenantService;
|
||||||
this.sysUserService = sysUserService;
|
this.sysUserService = sysUserService;
|
||||||
this.sysUserMapper = sysUserMapper;
|
this.sysUserMapper = sysUserMapper;
|
||||||
|
this.sysMenuMapper = sysMenuMapper;
|
||||||
this.personnelGroupInfoService = personnelGroupInfoService;
|
this.personnelGroupInfoService = personnelGroupInfoService;
|
||||||
this.clerkLevelInfoService = clerkLevelInfoService;
|
this.clerkLevelInfoService = clerkLevelInfoService;
|
||||||
this.clerkUserInfoService = clerkUserInfoService;
|
this.clerkUserInfoService = clerkUserInfoService;
|
||||||
@@ -149,6 +157,7 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void run(String... args) {
|
public void run(String... args) {
|
||||||
|
seedPcTenantWagesMenu();
|
||||||
seedTenantPackage();
|
seedTenantPackage();
|
||||||
seedTenant();
|
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() {
|
private void seedTenantPackage() {
|
||||||
long existing = tenantPackageService.count(Wrappers.<SysTenantPackageEntity>lambdaQuery()
|
long existing = tenantPackageService.count(Wrappers.<SysTenantPackageEntity>lambdaQuery()
|
||||||
.eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID));
|
.eq(SysTenantPackageEntity::getPackageId, DEFAULT_PACKAGE_ID));
|
||||||
@@ -496,22 +597,27 @@ public class ApiTestDataSeeder implements CommandLineRunner {
|
|||||||
|
|
||||||
private void seedClerkCommodity() {
|
private void seedClerkCommodity() {
|
||||||
PlayClerkCommodityEntity mapping = clerkCommodityService.getById(DEFAULT_CLERK_COMMODITY_ID);
|
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;
|
String commodityName = DEFAULT_COMMODITY_PARENT_NAME;
|
||||||
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
|
PlayCommodityInfoEntity parent = commodityInfoService.getById(DEFAULT_COMMODITY_PARENT_ID);
|
||||||
if (parent != null && parent.getItemName() != null) {
|
if (parent != null && parent.getItemName() != null) {
|
||||||
commodityName = parent.getItemName();
|
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();
|
PlayClerkCommodityEntity entity = new PlayClerkCommodityEntity();
|
||||||
entity.setId(DEFAULT_CLERK_COMMODITY_ID);
|
entity.setId(DEFAULT_CLERK_COMMODITY_ID);
|
||||||
entity.setTenantId(DEFAULT_TENANT_ID);
|
entity.setTenantId(DEFAULT_TENANT_ID);
|
||||||
entity.setClerkId(DEFAULT_CLERK_ID);
|
entity.setClerkId(DEFAULT_CLERK_ID);
|
||||||
entity.setCommodityId(DEFAULT_COMMODITY_ID);
|
entity.setCommodityId(DEFAULT_COMMODITY_PARENT_ID);
|
||||||
entity.setCommodityName(commodityName);
|
entity.setCommodityName(commodityName);
|
||||||
entity.setEnablingState("1");
|
entity.setEnablingState("1");
|
||||||
entity.setSort(1);
|
entity.setSort(1);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,9 @@ public class WxCustomMpService {
|
|||||||
@Resource
|
@Resource
|
||||||
private WxMpService wxMpService;
|
private WxMpService wxMpService;
|
||||||
|
|
||||||
|
@Value("${wechat.subscribe-check-enabled:true}")
|
||||||
|
private boolean subscribeCheckEnabled;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysTenantServiceImpl tenantService;
|
private SysTenantServiceImpl tenantService;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -480,6 +483,9 @@ public class WxCustomMpService {
|
|||||||
if (StrUtil.isBlankIfStr(openId)) {
|
if (StrUtil.isBlankIfStr(openId)) {
|
||||||
throw new ServiceException("openId不能为空");
|
throw new ServiceException("openId不能为空");
|
||||||
}
|
}
|
||||||
|
if (!subscribeCheckEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
WxMpUser wxMpUser = proxyWxMpService(tenantId).getUserService().userInfo(openId);
|
WxMpUser wxMpUser = proxyWxMpService(tenantId).getUserService().userInfo(openId);
|
||||||
if (!wxMpUser.getSubscribe()) {
|
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.common.exception.CustomException;
|
||||||
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
|
||||||
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
|
||||||
|
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
|
||||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||||
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
|
import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity;
|
||||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
||||||
|
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
|
||||||
|
import com.starry.admin.modules.withdraw.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.IEarningsService;
|
||||||
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
|
import com.starry.admin.modules.withdraw.service.IWithdrawalLogService;
|
||||||
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
|
||||||
import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo;
|
import com.starry.admin.modules.withdraw.vo.ClerkEarningLineVo;
|
||||||
import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo;
|
import com.starry.admin.modules.withdraw.vo.ClerkWithdrawBalanceVo;
|
||||||
|
import com.starry.admin.utils.SecurityUtils;
|
||||||
import com.starry.common.result.TypedR;
|
import com.starry.common.result.TypedR;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -40,6 +45,8 @@ public class WxWithdrawController {
|
|||||||
@Resource
|
@Resource
|
||||||
private IEarningsService earningsService;
|
private IEarningsService earningsService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private IEarningsAdjustmentService adjustmentService;
|
||||||
|
@Resource
|
||||||
private IWithdrawalService withdrawalService;
|
private IWithdrawalService withdrawalService;
|
||||||
@Resource
|
@Resource
|
||||||
private IWithdrawalLogService withdrawalLogService;
|
private IWithdrawalLogService withdrawalLogService;
|
||||||
@@ -55,11 +62,43 @@ public class WxWithdrawController {
|
|||||||
@GetMapping("/balance")
|
@GetMapping("/balance")
|
||||||
public TypedR<ClerkWithdrawBalanceVo> getBalance() {
|
public TypedR<ClerkWithdrawBalanceVo> getBalance() {
|
||||||
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
|
String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId();
|
||||||
|
String tenantId = SecurityUtils.getTenantId();
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
BigDecimal available = earningsService.getAvailableAmount(clerkId, now);
|
BigDecimal available = earningsService.getAvailableAmount(clerkId, now);
|
||||||
BigDecimal pending = earningsService.getPendingAmount(clerkId, now);
|
BigDecimal pending = earningsService.getPendingAmount(clerkId, now);
|
||||||
LocalDateTime nextUnlock = earningsService.getNextUnlockTime(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
|
@ClerkUserLogin
|
||||||
@@ -101,6 +140,21 @@ public class WxWithdrawController {
|
|||||||
.list()
|
.list()
|
||||||
.stream()
|
.stream()
|
||||||
.collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it));
|
.collect(Collectors.toMap(PlayOrderInfoEntity::getId, it -> it));
|
||||||
|
|
||||||
|
List<String> adjustmentIds = records.stream()
|
||||||
|
.filter(line -> line.getSourceType() == EarningsSourceType.ADJUSTMENT)
|
||||||
|
.map(EarningsLineEntity::getSourceId)
|
||||||
|
.filter(id -> id != null && !id.isEmpty())
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
Map<String, EarningsLineAdjustmentEntity> adjustmentMap = adjustmentIds.isEmpty() ? java.util.Collections.emptyMap()
|
||||||
|
: adjustmentService.lambdaQuery()
|
||||||
|
.eq(EarningsLineAdjustmentEntity::getTenantId, SecurityUtils.getTenantId())
|
||||||
|
.in(EarningsLineAdjustmentEntity::getId, adjustmentIds)
|
||||||
|
.list()
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.toMap(EarningsLineAdjustmentEntity::getId, it -> it));
|
||||||
|
|
||||||
for (EarningsLineEntity line : records) {
|
for (EarningsLineEntity line : records) {
|
||||||
ClerkEarningLineVo vo = new ClerkEarningLineVo();
|
ClerkEarningLineVo vo = new ClerkEarningLineVo();
|
||||||
vo.setId(line.getId());
|
vo.setId(line.getId());
|
||||||
@@ -111,6 +165,14 @@ public class WxWithdrawController {
|
|||||||
vo.setUnlockTime(line.getUnlockTime());
|
vo.setUnlockTime(line.getUnlockTime());
|
||||||
vo.setCreatedTime(toLocalDateTime(line.getCreatedTime()));
|
vo.setCreatedTime(toLocalDateTime(line.getCreatedTime()));
|
||||||
vo.setOrderId(line.getOrderId());
|
vo.setOrderId(line.getOrderId());
|
||||||
|
if (line.getSourceType() == EarningsSourceType.ADJUSTMENT && line.getSourceId() != null) {
|
||||||
|
EarningsLineAdjustmentEntity adjustment = adjustmentMap.get(line.getSourceId());
|
||||||
|
if (adjustment != null) {
|
||||||
|
vo.setAdjustmentReasonType(adjustment.getReasonType());
|
||||||
|
vo.setAdjustmentReasonDescription(adjustment.getReasonDescription());
|
||||||
|
vo.setAdjustmentEffectiveTime(adjustment.getEffectiveTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
if (line.getOrderId() != null) {
|
if (line.getOrderId() != null) {
|
||||||
PlayOrderInfoEntity order = orderMap.get(line.getOrderId());
|
PlayOrderInfoEntity order = orderMap.get(line.getOrderId());
|
||||||
if (order != null) {
|
if (order != null) {
|
||||||
|
|||||||
@@ -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) {
|
if (adjustment.getStatus() == EarningsAdjustmentStatus.APPLIED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (adjustment.getStatus() == EarningsAdjustmentStatus.FAILED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
EarningsLineEntity line = new EarningsLineEntity();
|
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) {
|
public List<EarningsLineEntity> findWithdrawable(String clerkId, BigDecimal amount, LocalDateTime now) {
|
||||||
// pick oldest unlocked first (status in available or frozen with unlock<=now)
|
// pick oldest unlocked first (status in available or frozen with unlock<=now)
|
||||||
List<EarningsLineEntity> list = this.baseMapper.selectWithdrawableLines(clerkId, now);
|
List<EarningsLineEntity> list = this.baseMapper.selectWithdrawableLines(clerkId, now);
|
||||||
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
for (EarningsLineEntity line : list) {
|
||||||
|
BigDecimal value = line.getAmount() == null ? BigDecimal.ZERO : line.getAmount();
|
||||||
|
total = total.add(value);
|
||||||
|
}
|
||||||
|
if (total.compareTo(amount) < 0) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
BigDecimal acc = BigDecimal.ZERO;
|
BigDecimal acc = BigDecimal.ZERO;
|
||||||
List<EarningsLineEntity> picked = new ArrayList<>();
|
List<EarningsLineEntity> picked = new ArrayList<>();
|
||||||
for (EarningsLineEntity e : list) {
|
for (EarningsLineEntity e : list) {
|
||||||
|
|||||||
@@ -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.ClerkPayeeProfileEntity;
|
||||||
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
|
||||||
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
|
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.mapper.WithdrawalRequestMapper;
|
||||||
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
|
||||||
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
import com.starry.admin.modules.withdraw.service.IEarningsService;
|
||||||
@@ -43,6 +45,20 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
|||||||
}
|
}
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
String tenantId = SecurityUtils.getTenantId();
|
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);
|
ClerkPayeeProfileEntity payeeProfile = clerkPayeeProfileService.getByClerk(tenantId, clerkId);
|
||||||
if (payeeProfile == null || !StringUtils.hasText(payeeProfile.getQrCodeUrl())) {
|
if (payeeProfile == null || !StringUtils.hasText(payeeProfile.getQrCodeUrl())) {
|
||||||
throw new CustomException("请先上传支付宝收款码");
|
throw new CustomException("请先上传支付宝收款码");
|
||||||
@@ -66,7 +82,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
|||||||
boolean updated = earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
boolean updated = earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||||
.eq(EarningsLineEntity::getId, line.getId())
|
.eq(EarningsLineEntity::getId, line.getId())
|
||||||
.eq(EarningsLineEntity::getStatus, line.getStatus()) // Verify status unchanged
|
.eq(EarningsLineEntity::getStatus, line.getStatus()) // Verify status unchanged
|
||||||
.set(EarningsLineEntity::getStatus, "withdrawing")
|
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||||
.set(EarningsLineEntity::getWithdrawalId, tempWithdrawalId));
|
.set(EarningsLineEntity::getWithdrawalId, tempWithdrawalId));
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
// Another request already took this line
|
// Another request already took this line
|
||||||
@@ -88,7 +104,7 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
|||||||
req.setNetAmount(amount);
|
req.setNetAmount(amount);
|
||||||
req.setDestAccount(payeeProfile.getDisplayName());
|
req.setDestAccount(payeeProfile.getDisplayName());
|
||||||
req.setPayeeSnapshot(snapshotJson);
|
req.setPayeeSnapshot(snapshotJson);
|
||||||
req.setStatus("pending");
|
req.setStatus(WithdrawalRequestStatus.PENDING.getCode());
|
||||||
req.setOutBizNo(req.getId());
|
req.setOutBizNo(req.getId());
|
||||||
this.save(req);
|
this.save(req);
|
||||||
|
|
||||||
@@ -120,22 +136,23 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
|||||||
public void markManualSuccess(String requestId, String operatorBy) {
|
public void markManualSuccess(String requestId, String operatorBy) {
|
||||||
WithdrawalRequestEntity req = this.getById(requestId);
|
WithdrawalRequestEntity req = this.getById(requestId);
|
||||||
if (req == null) throw new CustomException("请求不存在");
|
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("当前状态不可操作");
|
throw new CustomException("当前状态不可操作");
|
||||||
}
|
}
|
||||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||||
update.setId(req.getId());
|
update.setId(req.getId());
|
||||||
update.setStatus("success");
|
update.setStatus(WithdrawalRequestStatus.SUCCESS.getCode());
|
||||||
this.updateById(update);
|
this.updateById(update);
|
||||||
|
|
||||||
// Set reserved earnings lines to withdrawn
|
// Set reserved earnings lines to withdrawn
|
||||||
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||||
.eq(EarningsLineEntity::getStatus, "withdrawing")
|
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||||
.set(EarningsLineEntity::getStatus, "withdrawn"));
|
.set(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWN.getCode()));
|
||||||
|
|
||||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||||
"PAYOUT_SUCCESS", req.getStatus(), "success",
|
"PAYOUT_SUCCESS", req.getStatus(), WithdrawalRequestStatus.SUCCESS.getCode(),
|
||||||
"手动打款成功,操作人=" + operatorBy, null);
|
"手动打款成功,操作人=" + operatorBy, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,16 +161,16 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
|||||||
public void autoPayout(String requestId) {
|
public void autoPayout(String requestId) {
|
||||||
WithdrawalRequestEntity req = this.getById(requestId);
|
WithdrawalRequestEntity req = this.getById(requestId);
|
||||||
if (req == null) throw new CustomException("请求不存在");
|
if (req == null) throw new CustomException("请求不存在");
|
||||||
if (!"pending".equals(req.getStatus())) {
|
if (!WithdrawalRequestStatus.PENDING.getCode().equals(req.getStatus())) {
|
||||||
throw new CustomException("当前状态不可自动打款");
|
throw new CustomException("当前状态不可自动打款");
|
||||||
}
|
}
|
||||||
// Transition to processing and log
|
// Transition to processing and log
|
||||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||||
update.setId(req.getId());
|
update.setId(req.getId());
|
||||||
update.setStatus("processing");
|
update.setStatus(WithdrawalRequestStatus.PROCESSING.getCode());
|
||||||
this.updateById(update);
|
this.updateById(update);
|
||||||
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(),
|
||||||
"PAYOUT_REQUESTED", req.getStatus(), "processing",
|
"PAYOUT_REQUESTED", req.getStatus(), WithdrawalRequestStatus.PROCESSING.getCode(),
|
||||||
"发起支付宝打款(未实现)", null);
|
"发起支付宝打款(未实现)", null);
|
||||||
|
|
||||||
// Not implemented yet
|
// Not implemented yet
|
||||||
@@ -165,27 +182,30 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
|
|||||||
public void reject(String requestId, String reason) {
|
public void reject(String requestId, String reason) {
|
||||||
WithdrawalRequestEntity req = this.getById(requestId);
|
WithdrawalRequestEntity req = this.getById(requestId);
|
||||||
if (req == null) throw new CustomException("请求不存在");
|
if (req == null) throw new CustomException("请求不存在");
|
||||||
if ("success".equals(req.getStatus())) {
|
if (WithdrawalRequestStatus.SUCCESS.getCode().equals(req.getStatus())) {
|
||||||
throw new CustomException("已成功的提现不可拒绝");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
|
||||||
update.setId(req.getId());
|
update.setId(req.getId());
|
||||||
update.setStatus("canceled");
|
update.setStatus(WithdrawalRequestStatus.CANCELED.getCode());
|
||||||
update.setFailureReason(StringUtils.hasText(reason) ? reason : "rejected");
|
update.setFailureReason(StringUtils.hasText(reason) ? reason : "rejected");
|
||||||
this.updateById(update);
|
this.updateById(update);
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
|
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
|
||||||
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
|
||||||
.eq(EarningsLineEntity::getStatus, "withdrawing")
|
.eq(EarningsLineEntity::getStatus, EarningsLineStatus.WITHDRAWING.getCode())
|
||||||
.list();
|
.list();
|
||||||
for (EarningsLineEntity line : lines) {
|
for (EarningsLineEntity line : lines) {
|
||||||
LocalDateTime unlock = line.getUnlockTime();
|
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)
|
earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class)
|
||||||
.eq(EarningsLineEntity::getId, line.getId())
|
.eq(EarningsLineEntity::getId, line.getId())
|
||||||
.set(EarningsLineEntity::getWithdrawalId, null)
|
.set(EarningsLineEntity::getWithdrawalId, null)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.starry.admin.modules.withdraw.vo;
|
package com.starry.admin.modules.withdraw.vo;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType;
|
||||||
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
import com.starry.admin.modules.withdraw.enums.EarningsType;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -15,6 +16,13 @@ public class ClerkEarningLineVo {
|
|||||||
private EarningsType earningType;
|
private EarningsType earningType;
|
||||||
private String withdrawalId;
|
private String withdrawalId;
|
||||||
|
|
||||||
|
private EarningsAdjustmentReasonType adjustmentReasonType;
|
||||||
|
private String adjustmentReasonDescription;
|
||||||
|
|
||||||
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
|
private LocalDateTime adjustmentEffectiveTime;
|
||||||
|
|
||||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
private LocalDateTime unlockTime;
|
private LocalDateTime unlockTime;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import io.swagger.annotations.ApiModelProperty;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
@@ -23,4 +24,26 @@ public class ClerkWithdrawBalanceVo {
|
|||||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||||
private LocalDateTime nextUnlockAt;
|
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
|
user-header: X-Test-User
|
||||||
defaults:
|
defaults:
|
||||||
tenant-id: tenant-apitest
|
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:
|
roles:
|
||||||
- ROLE_TESTER
|
- 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;
|
package com.starry.admin.api;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.header;
|
||||||
@@ -123,7 +125,7 @@ class AdminEarningsAdjustmentControllerApiTest extends AbstractApiTest {
|
|||||||
.andExpect(jsonPath("$.code").value(200))
|
.andExpect(jsonPath("$.code").value(200))
|
||||||
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
.andExpect(jsonPath("$.data.idempotencyKey").value(key))
|
||||||
.andExpect(jsonPath("$.data.adjustmentId").isNotEmpty())
|
.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.
|
// After implementation, the system should eventually transition to APPLIED.
|
||||||
// Poll with a bounded wait to keep the test deterministic.
|
// 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 {
|
void earningsListIncludesAdjustmentLineAfterApplied() throws Exception {
|
||||||
ensureTenantContext();
|
ensureTenantContext();
|
||||||
String key = UUID.randomUUID().toString();
|
String key = UUID.randomUUID().toString();
|
||||||
|
String effectiveTime = "2026-01-01T00:00:00";
|
||||||
String payload = "{" +
|
String payload = "{" +
|
||||||
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
|
||||||
"\"amount\":\"12.34\"," +
|
"\"amount\":\"12.34\"," +
|
||||||
"\"reasonType\":\"MANUAL\"," +
|
"\"reasonType\":\"MANUAL\"," +
|
||||||
"\"reasonDescription\":\"show in list\"" +
|
"\"reasonDescription\":\"show in list\"," +
|
||||||
|
"\"effectiveTime\":\"" + effectiveTime + "\"" +
|
||||||
"}";
|
"}";
|
||||||
|
|
||||||
mockMvc.perform(post("/admin/earnings/adjustments")
|
mockMvc.perform(post("/admin/earnings/adjustments")
|
||||||
@@ -209,7 +211,10 @@ class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest {
|
|||||||
if (rows.isArray()) {
|
if (rows.isArray()) {
|
||||||
for (JsonNode row : rows) {
|
for (JsonNode row : rows) {
|
||||||
if ("ADJUSTMENT".equals(row.path("earningType").asText())
|
if ("ADJUSTMENT".equals(row.path("earningType").asText())
|
||||||
&& "12.34".equals(row.path("amount").asText())) {
|
&& "12.34".equals(row.path("amount").asText())
|
||||||
|
&& "MANUAL".equals(row.path("adjustmentReasonType").asText())
|
||||||
|
&& "show in list".equals(row.path("adjustmentReasonDescription").asText())
|
||||||
|
&& "2026-01-01 00:00:00".equals(row.path("adjustmentEffectiveTime").asText())) {
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,76 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
|||||||
.isEqualTo(now.plusHours(6).format(DATE_TIME_FORMATTER));
|
.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
|
@Test
|
||||||
void createWithdrawRejectsNonPositiveAmount__covers_WD_003() throws Exception {
|
void createWithdrawRejectsNonPositiveAmount__covers_WD_003() throws Exception {
|
||||||
ensureTenantContext();
|
ensureTenantContext();
|
||||||
@@ -248,14 +318,17 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
|
|||||||
refreshPayeeConfirmation();
|
refreshPayeeConfirmation();
|
||||||
String firstWithdrawal = createWithdraw(new BigDecimal("35"));
|
String firstWithdrawal = createWithdraw(new BigDecimal("35"));
|
||||||
assertLinesLocked(firstWithdrawal, lineIds[0], lineIds[1], lineIds[2]);
|
assertLinesLocked(firstWithdrawal, lineIds[0], lineIds[1], lineIds[2]);
|
||||||
|
markWithdrawalCompleted(firstWithdrawal);
|
||||||
|
|
||||||
refreshPayeeConfirmation();
|
refreshPayeeConfirmation();
|
||||||
String secondWithdrawal = createWithdraw(new BigDecimal("90"));
|
String secondWithdrawal = createWithdraw(new BigDecimal("90"));
|
||||||
assertLinesLocked(secondWithdrawal, lineIds[3], lineIds[4], lineIds[5]);
|
assertLinesLocked(secondWithdrawal, lineIds[3], lineIds[4], lineIds[5]);
|
||||||
|
markWithdrawalCompleted(secondWithdrawal);
|
||||||
|
|
||||||
refreshPayeeConfirmation();
|
refreshPayeeConfirmation();
|
||||||
String thirdWithdrawal = createWithdraw(new BigDecimal("135"));
|
String thirdWithdrawal = createWithdraw(new BigDecimal("135"));
|
||||||
assertLinesLocked(thirdWithdrawal, lineIds[6], lineIds[7], lineIds[8], lineIds[9]);
|
assertLinesLocked(thirdWithdrawal, lineIds[6], lineIds[7], lineIds[8], lineIds[9]);
|
||||||
|
markWithdrawalCompleted(thirdWithdrawal);
|
||||||
|
|
||||||
ensureTenantContext();
|
ensureTenantContext();
|
||||||
BigDecimal remaining = earningsService.getAvailableAmount(ApiTestDataSeeder.DEFAULT_CLERK_ID, LocalDateTime.now());
|
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) {
|
private Date toDate(LocalDateTime value) {
|
||||||
return Date.from(value.atZone(ZoneId.systemDefault()).toInstant());
|
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.Mockito;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class WxCustomMpServiceTest {
|
class WxCustomMpServiceTest {
|
||||||
@@ -294,6 +295,7 @@ class WxCustomMpServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void checkSubscribeThrowsWhenUserNotSubscribed__covers_MP_008() throws WxErrorException {
|
void checkSubscribeThrowsWhenUserNotSubscribed__covers_MP_008() throws WxErrorException {
|
||||||
|
ReflectionTestUtils.setField(wxCustomMpService, "subscribeCheckEnabled", true);
|
||||||
SysTenantEntity tenant = buildTenant();
|
SysTenantEntity tenant = buildTenant();
|
||||||
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
|
when(tenantService.selectSysTenantByTenantId(TENANT_ID)).thenReturn(tenant);
|
||||||
when(wxMpService.switchoverTo(tenant.getAppId())).thenReturn(wxMpService);
|
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