diff --git a/llm/earnings-adjustments-and-withdrawal-reject.md b/llm/earnings-adjustments-and-withdrawal-reject.md new file mode 100644 index 0000000..cef6a85 --- /dev/null +++ b/llm/earnings-adjustments-and-withdrawal-reject.md @@ -0,0 +1,177 @@ +# Earnings Adjustments & Withdrawal Reject — Expected Behavior + +This document defines the intended behavior for: + +- Admin-created **earnings adjustments** (positive or negative earning lines) +- Admin **withdrawal reject** (cancel a withdrawal request and release reserved earning lines) +- **Authorization** rules (permission + group leader scope + cross-tenant isolation) + +## Concepts + +### Earnings Line +An `earnings line` is an immutable money movement entry for a clerk. Amount can be positive or negative. + +### Adjustment +An `adjustment` is an admin-originated earnings line, designed to support future extensibility (many “reasons”, auditability, idempotency, async apply). + +Key semantics: + +- It **creates exactly one** earnings line when applied. +- The created earnings line uses: + - `earningType = ADJUSTMENT` + - `sourceType = ADJUSTMENT` + - `sourceId = adjustmentId` + - `orderId = null` + - `amount` can be positive or negative + - `unlockTime = effectiveTime` (adjustments are effective at their “unlock” time) + +### Withdrawal Reject +Admin reject is a cancel operation that: + +- marks the withdrawal request as canceled/rejected +- releases reserved `withdrawing` earnings lines back to `available` / `frozen` + +## Authorization Model (New Endpoints) + +Authorization is **two-layer**: + +1) **Action-level permission**: does the user have permission to call the endpoint? +2) **Object-level scope**: can the user act on the target clerk / request? + +### Permission Strings + +- Create adjustment: `withdraw:adjustment:create` +- Read/poll adjustment status: `withdraw:adjustment:read` +- Reject withdrawal request: `withdraw:request:reject` + +### Group Leader Scope + +If the current user is **not** `superAdmin`, they can only act on clerks that belong to a group where: + +- `clerk.groupId = group.id` +- `group.sysUserId = currentUserId` + +If this scope check fails, the endpoint returns **HTTP 403**. + +### Super Admin Bypass + +If `superAdmin == true`, the user bypasses permission checks and scope checks for these new endpoints. + +### Cross-Tenant Isolation + +All operations are tenant-scoped. + +- If `X-Tenant` does not match the target entity’s `tenantId`, the API returns **HTTP 404** (do not leak existence across tenants). + +## Admin Earnings Adjustments API + +### Create Adjustment + +`POST /admin/earnings/adjustments` + +Headers: + +- `Idempotency-Key: ` (required) +- `X-Tenant: ` (required) + +Body: + +```json +{ + "clerkId": "clerk-id", + "amount": "20.00", + "reasonType": "MANUAL", + "reasonDescription": "text", + "effectiveTime": "2026-01-01T12:00:00" // optional +} +``` + +Validation rules: + +- `Idempotency-Key` required +- `tenantId` required +- `clerkId` required +- `amount` must be non-zero (positive = reward-like, negative = punishment-like) +- `reasonType` required (currently hard-coded enum values, extend later) +- `reasonDescription` required, non-blank + +Idempotency behavior: + +- Same `tenantId + Idempotency-Key` with the **same request body** returns the **same** `adjustmentId`. +- Same `tenantId + Idempotency-Key` with a **different request body** returns **HTTP 409**. + +Response behavior: + +- Always returns **HTTP 202 Accepted** on success (request is “in-progress”). +- Includes `Location: /admin/earnings/adjustments/idempotency/{Idempotency-Key}` for polling. + +Response example: + +```json +{ + "code": 202, + "message": "请求处理中", + "data": { + "adjustmentId": "adj-uuid", + "idempotencyKey": "same-key", + "status": "PROCESSING" + } +} +``` + +### Poll Adjustment Status + +`GET /admin/earnings/adjustments/idempotency/{key}` + +Behavior: + +- If not found in this tenant: **HTTP 404** +- If found: + - returns **HTTP 200** + - `status` is one of: + - `PROCESSING`: accepted but not yet applied + - `APPLIED`: earnings line has been created + - `FAILED`: apply failed (and should be visible for operator debugging) + +Stress / eventual consistency note: + +- Under load (DB latency / executor backlog), polling may stay in `PROCESSING` longer, but must not create duplicate earnings lines. + +## Withdrawal Reject API + +### Reject Withdrawal Request + +`POST /admin/withdraw/requests/{id}/reject` + +Body: + +```json +{ "reason": "text (optional)" } +``` + +Behavior: + +- If request does not exist in this tenant: **HTTP 404** +- If request is already canceled/rejected: return **HTTP 200** (idempotent) +- If request is `success`: return **HTTP 400** (cannot reject a successful payout) +- Otherwise: + - request status transitions to `canceled` (or `rejected` depending on legacy naming) + - all earnings lines with: + - `withdrawalId = requestId` + - `status = withdrawing` + are released: + - `withdrawalId` set to `null` + - if `unlockTime > now` -> `status = frozen` + - else -> `status = available` + +## Stats: includeAdjustments toggle + +The statistics endpoint supports a toggle `includeAdjustments`: + +- when `includeAdjustments = false` (default): only order-derived earnings contribute +- when `includeAdjustments = true`: adjustment earnings lines (`sourceType=ADJUSTMENT`) are included in the revenue sum + +Time-window behavior: + +- adjustment inclusion is based on `unlockTime` window (equivalent to `effectiveTime`) + diff --git a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java index 3bc726e..627df51 100644 --- a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java +++ b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java @@ -44,6 +44,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; +import org.springframework.dao.DuplicateKeyException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -466,7 +467,14 @@ public class ApiTestDataSeeder implements CommandLineRunner { entity.setCommodityName(commodityName); entity.setEnablingState("1"); entity.setSort(1); - clerkCommodityService.save(entity); + try { + clerkCommodityService.save(entity); + } catch (DuplicateKeyException duplicateKeyException) { + log.info( + "API test clerk commodity {} already inserted by another test context", + DEFAULT_CLERK_COMMODITY_ID); + return; + } log.info("Inserted API test clerk commodity link {}", DEFAULT_CLERK_COMMODITY_ID); } @@ -489,7 +497,12 @@ public class ApiTestDataSeeder implements CommandLineRunner { entity.setState(GIFT_STATE_ACTIVE); entity.setListingTime(LocalDateTime.now()); entity.setRemark("Seeded gift for API tests"); - giftInfoService.save(entity); + try { + giftInfoService.save(entity); + } catch (DuplicateKeyException duplicateKeyException) { + log.info("API test gift {} already inserted by another test context", DEFAULT_GIFT_ID); + return; + } log.info("Inserted API test gift {}", DEFAULT_GIFT_ID); } @@ -564,7 +577,24 @@ public class ApiTestDataSeeder implements CommandLineRunner { entity.setRegistrationTime(new Date()); entity.setLastLoginTime(new Date()); entity.setToken(token); - customUserInfoService.save(entity); + try { + customUserInfoService.save(entity); + } catch (DuplicateKeyException duplicateKeyException) { + customUserInfoService.updateTokenById(DEFAULT_CUSTOMER_ID, token); + customUserInfoService.lambdaUpdate() + .set(PlayCustomUserInfoEntity::getAccountBalance, DEFAULT_CUSTOMER_BALANCE) + .set(PlayCustomUserInfoEntity::getAccumulatedRechargeAmount, DEFAULT_CUSTOMER_RECHARGE) + .set(PlayCustomUserInfoEntity::getAccumulatedConsumptionAmount, BigDecimal.ZERO) + .set(PlayCustomUserInfoEntity::getAccountState, "1") + .set(PlayCustomUserInfoEntity::getSubscribeState, "1") + .set(PlayCustomUserInfoEntity::getPurchaseState, "1") + .set(PlayCustomUserInfoEntity::getMobilePhoneState, "1") + .set(PlayCustomUserInfoEntity::getLastLoginTime, new Date()) + .eq(PlayCustomUserInfoEntity::getId, DEFAULT_CUSTOMER_ID) + .update(); + log.info("API test customer {} already inserted by another test context", DEFAULT_CUSTOMER_ID); + return; + } log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID); } } diff --git a/play-admin/src/main/java/com/starry/admin/common/component/PermissionService.java b/play-admin/src/main/java/com/starry/admin/common/component/PermissionService.java index 7addb6f..b81218a 100644 --- a/play-admin/src/main/java/com/starry/admin/common/component/PermissionService.java +++ b/play-admin/src/main/java/com/starry/admin/common/component/PermissionService.java @@ -42,7 +42,10 @@ public class PermissionService { } LoginUser loginUser = SecurityUtils.getLoginUser(); if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) { - return false; + return loginUser != null && loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser()); + } + if (loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser())) { + return true; } return hasPermissions(loginUser.getPermissions(), permission); } @@ -70,7 +73,13 @@ public class PermissionService { return false; } LoginUser loginUser = SecurityUtils.getLoginUser(); - if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) { + if (loginUser == null) { + return false; + } + if (loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser())) { + return true; + } + if (CollectionUtils.isEmpty(loginUser.getPermissions())) { return false; } Set authorities = loginUser.getPermissions(); diff --git a/play-admin/src/main/java/com/starry/admin/common/exception/handler/GlobalExceptionHandler.java b/play-admin/src/main/java/com/starry/admin/common/exception/handler/GlobalExceptionHandler.java index c66512c..0a89cc2 100644 --- a/play-admin/src/main/java/com/starry/admin/common/exception/handler/GlobalExceptionHandler.java +++ b/play-admin/src/main/java/com/starry/admin/common/exception/handler/GlobalExceptionHandler.java @@ -8,7 +8,11 @@ import com.starry.common.utils.StringUtils; import javax.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -26,6 +30,16 @@ public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); public static final String PARAMETER_FORMAT_ERROR = "请求参数格式异常"; + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(R.error(403, e.getMessage())); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthentication(AuthenticationException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(R.error(401, e.getMessage())); + } + /** * 业务异常 */ diff --git a/play-admin/src/main/java/com/starry/admin/common/security/filter/ApiTestAuthenticationFilter.java b/play-admin/src/main/java/com/starry/admin/common/security/filter/ApiTestAuthenticationFilter.java index 833e5dd..0152205 100644 --- a/play-admin/src/main/java/com/starry/admin/common/security/filter/ApiTestAuthenticationFilter.java +++ b/play-admin/src/main/java/com/starry/admin/common/security/filter/ApiTestAuthenticationFilter.java @@ -6,10 +6,14 @@ import com.starry.admin.modules.system.module.entity.SysUserEntity; import com.starry.common.constant.SecurityConstants; import com.starry.common.context.CustomSecurityContextHolder; import java.io.IOException; +import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashSet; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; @@ -24,6 +28,8 @@ import org.springframework.web.filter.OncePerRequestFilter; public class ApiTestAuthenticationFilter extends OncePerRequestFilter { private final ApiTestSecurityProperties properties; + private static final String PERMISSIONS_HEADER = "X-Test-Permissions"; + private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin"; public ApiTestAuthenticationFilter(ApiTestSecurityProperties properties) { this.properties = properties; @@ -32,6 +38,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + Map originalContext = new ConcurrentHashMap<>(CustomSecurityContextHolder.getLocalMap()); String requestedUser = request.getHeader(properties.getUserHeader()); String requestedTenant = request.getHeader(properties.getTenantHeader()); @@ -48,6 +55,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter { try { LoginUser loginUser = buildLoginUser(userId, tenantId); + applyOverridesFromHeaders(request, loginUser); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList()); @@ -61,7 +69,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } finally { - CustomSecurityContextHolder.remove(); + CustomSecurityContextHolder.setLocalMap(originalContext); SecurityContextHolder.clearContext(); } } @@ -93,4 +101,27 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter { return loginUser; } + + private void applyOverridesFromHeaders(HttpServletRequest request, LoginUser loginUser) { + if (loginUser == null || loginUser.getUser() == null) { + return; + } + + String superAdmin = request.getHeader(SUPER_ADMIN_HEADER); + if (StringUtils.hasText(superAdmin)) { + loginUser.getUser().setSuperAdmin(Boolean.parseBoolean(superAdmin)); + } + + String permissionsHeader = request.getHeader(PERMISSIONS_HEADER); + if (!StringUtils.hasText(permissionsHeader)) { + return; + } + + Set perms = Arrays.stream(permissionsHeader.split(",")) + .map(String::trim) + .filter(StringUtils::hasText) + .collect(Collectors.toSet()); + loginUser.setPermissions(perms); + CustomSecurityContextHolder.setPermission(String.join(",", perms)); + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewQueryVo.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewQueryVo.java index 65d5260..02c6496 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewQueryVo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/module/vo/ClerkPerformanceOverviewQueryVo.java @@ -31,6 +31,9 @@ public class ClerkPerformanceOverviewQueryVo extends PlayClerkPerformanceInfoQue @ApiModelProperty(value = "是否包含排行列表数据") private Boolean includeRankings = Boolean.TRUE; + @ApiModelProperty(value = "是否包含收益调整(ADJUSTMENT)", allowableValues = "true,false") + private Boolean includeAdjustments = Boolean.FALSE; + @Override public void setEndOrderTime(List endOrderTime) { super.setEndOrderTime(endOrderTime); diff --git a/play-admin/src/main/java/com/starry/admin/modules/statistics/service/impl/PlayClerkPerformanceServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/statistics/service/impl/PlayClerkPerformanceServiceImpl.java index bd3471a..cfa557a 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/statistics/service/impl/PlayClerkPerformanceServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/statistics/service/impl/PlayClerkPerformanceServiceImpl.java @@ -142,6 +142,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) { DateRange range = resolveDateRange(vo.getEndOrderTime()); List clerks = loadAccessibleClerks(vo); + boolean includeAdjustments = Boolean.TRUE.equals(vo.getIncludeAdjustments()); ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo(); if (CollectionUtil.isEmpty(clerks)) { responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo()); @@ -158,7 +159,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer for (PlayClerkUserInfoEntity clerk : clerks) { List orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(), range.startTime, range.endTime); - snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime)); + snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime, includeAdjustments)); } int total = snapshots.size(); ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots); @@ -194,7 +195,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer List orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(), range.startTime, range.endTime); ClerkPerformanceSnapshotVo snapshot = - buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime); + buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime, false); ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo(); responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap)); responseVo.setSnapshot(snapshot); @@ -424,22 +425,42 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer private BigDecimal calculateEarningsAmount(String clerkId, List orders, String startTime, String endTime) { - if (StrUtil.isBlank(clerkId) || CollectionUtil.isEmpty(orders)) { - return BigDecimal.ZERO; - } - List orderIds = orders.stream() - .filter(this::isCompletedOrder) - .map(PlayOrderInfoEntity::getId) - .filter(StrUtil::isNotBlank) - .collect(Collectors.toList()); - if (CollectionUtil.isEmpty(orderIds)) { + return calculateEarningsAmount(SecurityUtils.getTenantId(), clerkId, orders, startTime, endTime, false); + } + + private BigDecimal calculateEarningsAmount( + String tenantId, + String clerkId, + List orders, + String startTime, + String endTime, + boolean includeAdjustments) { + if (StrUtil.isBlank(clerkId)) { return BigDecimal.ZERO; } String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime); String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime); - BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart, - normalizedEnd); - return defaultZero(sum); + + BigDecimal orderSum = BigDecimal.ZERO; + if (CollectionUtil.isNotEmpty(orders)) { + List orderIds = orders.stream() + .filter(this::isCompletedOrder) + .map(PlayOrderInfoEntity::getId) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toList()); + if (CollectionUtil.isNotEmpty(orderIds)) { + BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart, + normalizedEnd); + orderSum = defaultZero(sum); + } + } + + if (!includeAdjustments) { + return orderSum; + } + + BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk(tenantId, clerkId, normalizedStart, normalizedEnd); + return orderSum.add(defaultZero(adjustmentSum)); } private OrderConstant.OrderRelationType normalizeRelationType(PlayOrderInfoEntity order) { @@ -460,7 +481,8 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer } private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List orders, - Map levelNameMap, Map groupNameMap, String startTime, String endTime) { + Map levelNameMap, Map groupNameMap, String startTime, String endTime, + boolean includeAdjustments) { ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo(); snapshot.setClerkId(clerk.getId()); snapshot.setClerkNickname(clerk.getNickname()); @@ -513,7 +535,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer } int userCount = userIds.size(); int continuedUserCount = continuedUserIds.size(); - BigDecimal estimatedRevenue = calculateEarningsAmount(clerk.getId(), orders, startTime, endTime); + BigDecimal estimatedRevenue = calculateEarningsAmount(SecurityUtils.getTenantId(), clerk.getId(), orders, startTime, endTime, includeAdjustments); snapshot.setGmv(gmv); snapshot.setFirstOrderAmount(firstAmount); snapshot.setContinuedOrderAmount(continuedAmount); diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminEarningsAdjustmentController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminEarningsAdjustmentController.java new file mode 100644 index 0000000..aeab526 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminEarningsAdjustmentController.java @@ -0,0 +1,128 @@ +package com.starry.admin.modules.withdraw.controller; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity; +import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType; +import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.result.TypedR; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import javax.annotation.Resource; +import lombok.Data; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/earnings/adjustments") +public class AdminEarningsAdjustmentController { + + private static final String IDEMPOTENCY_HEADER = "Idempotency-Key"; + + @Resource + private IEarningsAdjustmentService adjustmentService; + + @Data + public static class CreateAdjustmentRequest { + private String clerkId; + private BigDecimal amount; + private EarningsAdjustmentReasonType reasonType; + private String reasonDescription; + private LocalDateTime effectiveTime; + } + + @Data + public static class AdjustmentStatusResponse { + private String adjustmentId; + private String idempotencyKey; + private String status; + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("@customSs.hasPermission('withdraw:adjustment:create') and @earningsAuth.canManageClerk(#body.clerkId)") + public ResponseEntity> create( + @RequestHeader(value = IDEMPOTENCY_HEADER, required = false) String idempotencyKey, + @RequestBody CreateAdjustmentRequest body) { + + if (!StringUtils.hasText(idempotencyKey)) { + return ResponseEntity.badRequest().body(TypedR.error(400, "Idempotency-Key required")); + } + if (body == null) { + return ResponseEntity.badRequest().body(TypedR.error(400, "body required")); + } + if (!StringUtils.hasText(body.getClerkId())) { + return ResponseEntity.badRequest().body(TypedR.error(400, "clerkId required")); + } + if (body.getAmount() == null || body.getAmount().compareTo(BigDecimal.ZERO) == 0) { + return ResponseEntity.badRequest().body(TypedR.error(400, "amount must be non-zero")); + } + if (body.getReasonType() == null) { + return ResponseEntity.badRequest().body(TypedR.error(400, "reasonType required")); + } + if (!StringUtils.hasText(body.getReasonDescription()) || !StringUtils.hasText(body.getReasonDescription().trim())) { + return ResponseEntity.badRequest().body(TypedR.error(400, "reasonDescription required")); + } + + String tenantId = SecurityUtils.getTenantId(); + if (!StringUtils.hasText(tenantId)) { + return ResponseEntity.badRequest().body(TypedR.error(400, "tenant missing")); + } + + EarningsLineAdjustmentEntity adjustment; + try { + adjustment = adjustmentService.createOrGetProcessing( + tenantId, + body.getClerkId(), + body.getAmount(), + body.getReasonType(), + body.getReasonDescription(), + idempotencyKey, + body.getEffectiveTime()); + } catch (IllegalStateException conflict) { + return ResponseEntity.status(409).body(TypedR.error(409, conflict.getMessage())); + } catch (CustomException bad) { + return ResponseEntity.badRequest().body(TypedR.error(400, bad.getMessage())); + } + + adjustmentService.triggerApplyAsync(adjustment.getId()); + + AdjustmentStatusResponse responseBody = new AdjustmentStatusResponse(); + responseBody.setAdjustmentId(adjustment.getId()); + responseBody.setIdempotencyKey(idempotencyKey); + responseBody.setStatus(adjustment.getStatus() == null ? "PROCESSING" : adjustment.getStatus().name()); + + TypedR result = TypedR.ok(responseBody); + result.setCode(202); + result.setMessage("请求处理中"); + return ResponseEntity.accepted() + .header("Location", "/admin/earnings/adjustments/idempotency/" + idempotencyKey) + .body(result); + } + + @GetMapping("/idempotency/{key}") + @PreAuthorize("@customSs.hasPermission('withdraw:adjustment:read') and @earningsAuth.canReadAdjustmentByIdempotencyKey(#key)") + public ResponseEntity> getByIdempotencyKey(@PathVariable("key") String key) { + if (!StringUtils.hasText(key)) { + return ResponseEntity.status(404).body(TypedR.error(404, "not found")); + } + String tenantId = SecurityUtils.getTenantId(); + EarningsLineAdjustmentEntity adjustment = adjustmentService.getByIdempotencyKey(tenantId, key); + if (adjustment == null) { + return ResponseEntity.status(404).body(TypedR.error(404, "not found")); + } + AdjustmentStatusResponse responseBody = new AdjustmentStatusResponse(); + responseBody.setAdjustmentId(adjustment.getId()); + responseBody.setIdempotencyKey(key); + responseBody.setStatus(adjustment.getStatus() == null ? "PROCESSING" : adjustment.getStatus().name()); + return ResponseEntity.ok(TypedR.ok(responseBody)); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java index c8c43b4..e6bd8cd 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java @@ -43,6 +43,9 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @Api(tags = "提现管理-后台") @@ -195,6 +198,36 @@ public class AdminWithdrawalController { return TypedR.ok(vos); } + public static class RejectWithdrawalRequest { + private String reason; + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + } + + @ApiOperation("拒绝/取消提现请求(释放已预留收益)") + @PostMapping("/requests/{id}/reject") + @PreAuthorize("@customSs.hasPermission('withdraw:request:reject') and @earningsAuth.canRejectWithdrawal(#id)") + public ResponseEntity> reject(@PathVariable("id") String id, @RequestBody(required = false) RejectWithdrawalRequest body) { + String tenantId = SecurityUtils.getTenantId(); + WithdrawalRequestEntity req = withdrawalService.getById(id); + if (req == null || !tenantId.equals(req.getTenantId())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(TypedR.error(404, "请求不存在")); + } + String reason = body == null ? null : body.getReason(); + try { + withdrawalService.reject(req.getId(), reason); + } catch (CustomException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(TypedR.error(400, ex.getMessage())); + } + return ResponseEntity.ok(TypedR.ok(null)); + } + @ApiOperation("分页查询收益明细") @PostMapping("/earnings/listByPage") public TypedR> listEarnings(@RequestBody EarningsAdminQueryVo vo) { diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsLineAdjustmentEntity.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsLineAdjustmentEntity.java new file mode 100644 index 0000000..535dfaf --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsLineAdjustmentEntity.java @@ -0,0 +1,37 @@ +package com.starry.admin.modules.withdraw.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType; +import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus; +import com.starry.common.domain.BaseEntity; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("play_earnings_line_adjustment") +public class EarningsLineAdjustmentEntity extends BaseEntity { + private String id; + private String tenantId; + private String clerkId; + private BigDecimal amount; + private EarningsAdjustmentReasonType reasonType; + private String reasonDescription; + private EarningsAdjustmentStatus status; + private String idempotencyKey; + private String requestHash; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime effectiveTime; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime appliedTime; + + private String failureReason; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsLineEntity.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsLineEntity.java index 82de815..1971889 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsLineEntity.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsLineEntity.java @@ -2,6 +2,7 @@ package com.starry.admin.modules.withdraw.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonFormat; +import com.starry.admin.modules.withdraw.enums.EarningsSourceType; import com.starry.admin.modules.withdraw.enums.EarningsType; import com.starry.common.domain.BaseEntity; import java.math.BigDecimal; @@ -18,6 +19,15 @@ public class EarningsLineEntity extends BaseEntity { private String tenantId; private String clerkId; private String orderId; + /** + * Source identity for ledger line. + * + *

ORDER: sourceId == orderId (non-null). + *

ADJUSTMENT: sourceId == adjustmentId, and orderId should be null. + */ + private EarningsSourceType sourceType; + + private String sourceId; private BigDecimal amount; private EarningsType earningType; diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsAdjustmentReasonType.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsAdjustmentReasonType.java new file mode 100644 index 0000000..5431d4d --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsAdjustmentReasonType.java @@ -0,0 +1,23 @@ +package com.starry.admin.modules.withdraw.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Reason type for adjustments (hard-coded for now). + */ +public enum EarningsAdjustmentReasonType { + MANUAL("MANUAL"); + + @EnumValue + @JsonValue + private final String value; + + EarningsAdjustmentReasonType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsAdjustmentStatus.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsAdjustmentStatus.java new file mode 100644 index 0000000..4fd6c0d --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsAdjustmentStatus.java @@ -0,0 +1,22 @@ +package com.starry.admin.modules.withdraw.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum EarningsAdjustmentStatus { + PROCESSING("PROCESSING"), + APPLIED("APPLIED"), + FAILED("FAILED"); + + @EnumValue + @JsonValue + private final String value; + + EarningsAdjustmentStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsSourceType.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsSourceType.java new file mode 100644 index 0000000..449135b --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/enums/EarningsSourceType.java @@ -0,0 +1,24 @@ +package com.starry.admin.modules.withdraw.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Earnings line source type. + */ +public enum EarningsSourceType { + ORDER("ORDER"), + ADJUSTMENT("ADJUSTMENT"); + + @EnumValue + @JsonValue + private final String value; + + EarningsSourceType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsLineAdjustmentMapper.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsLineAdjustmentMapper.java new file mode 100644 index 0000000..c366b70 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsLineAdjustmentMapper.java @@ -0,0 +1,8 @@ +package com.starry.admin.modules.withdraw.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface EarningsLineAdjustmentMapper extends BaseMapper {} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsLineMapper.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsLineMapper.java index 4373534..3926b47 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsLineMapper.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsLineMapper.java @@ -59,4 +59,24 @@ public interface EarningsLineMapper extends BaseMapper { @Param("orderIds") Collection orderIds, @Param("startTime") String startTime, @Param("endTime") String endTime); + + @Select("") + BigDecimal sumAdjustmentsByClerk(@Param("tenantId") String tenantId, + @Param("clerkId") String clerkId, + @Param("startTime") String startTime, + @Param("endTime") String endTime); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/security/EarningsAuthorizationService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/security/EarningsAuthorizationService.java new file mode 100644 index 0000000..a6b94c2 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/security/EarningsAuthorizationService.java @@ -0,0 +1,108 @@ +package com.starry.admin.modules.withdraw.security; + +import com.starry.admin.common.domain.LoginUser; +import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; +import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; +import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; +import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; +import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity; +import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; +import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService; +import com.starry.admin.modules.withdraw.service.IWithdrawalService; +import com.starry.admin.utils.SecurityUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service("earningsAuth") +public class EarningsAuthorizationService { + + private final IPlayClerkUserInfoService clerkUserInfoService; + private final IPlayPersonnelGroupInfoService groupInfoService; + private final IEarningsAdjustmentService earningsAdjustmentService; + private final IWithdrawalService withdrawalService; + + public EarningsAuthorizationService( + IPlayClerkUserInfoService clerkUserInfoService, + IPlayPersonnelGroupInfoService groupInfoService, + IEarningsAdjustmentService earningsAdjustmentService, + IWithdrawalService withdrawalService) { + this.clerkUserInfoService = clerkUserInfoService; + this.groupInfoService = groupInfoService; + this.earningsAdjustmentService = earningsAdjustmentService; + this.withdrawalService = withdrawalService; + } + + public boolean canManageClerk(String clerkId) { + if (!StringUtils.hasText(clerkId)) { + return false; + } + LoginUser loginUser = SecurityUtils.getLoginUser(); + if (loginUser == null || loginUser.getUser() == null) { + return false; + } + if (SecurityUtils.isAdmin(loginUser.getUser())) { + return true; + } + String tenantId = SecurityUtils.getTenantId(); + if (!StringUtils.hasText(tenantId)) { + return false; + } + + PlayClerkUserInfoEntity clerk = clerkUserInfoService.getById(clerkId); + if (clerk == null || !tenantId.equals(clerk.getTenantId())) { + return false; + } + if (!StringUtils.hasText(clerk.getGroupId())) { + return false; + } + PlayPersonnelGroupInfoEntity group = groupInfoService.getById(clerk.getGroupId()); + if (group == null || !tenantId.equals(group.getTenantId())) { + return false; + } + return loginUser.getUserId() != null && loginUser.getUserId().equals(group.getSysUserId()); + } + + public boolean canReadAdjustmentByIdempotencyKey(String key) { + if (!StringUtils.hasText(key)) { + return false; + } + if (isSuperAdmin()) { + return true; + } + String tenantId = SecurityUtils.getTenantId(); + if (!StringUtils.hasText(tenantId)) { + return false; + } + EarningsLineAdjustmentEntity adjustment = earningsAdjustmentService.getByIdempotencyKey(tenantId, key); + if (adjustment == null) { + return true; + } + return canManageClerk(adjustment.getClerkId()); + } + + public boolean canRejectWithdrawal(String requestId) { + if (!StringUtils.hasText(requestId)) { + return false; + } + if (isSuperAdmin()) { + return true; + } + WithdrawalRequestEntity req = withdrawalService.getById(requestId); + if (req == null) { + return true; + } + String tenantId = SecurityUtils.getTenantId(); + if (!StringUtils.hasText(tenantId)) { + return false; + } + if (!tenantId.equals(req.getTenantId())) { + return true; + } + return canManageClerk(req.getClerkId()); + } + + private boolean isSuperAdmin() { + LoginUser loginUser = SecurityUtils.getLoginUser(); + return loginUser != null && loginUser.getUser() != null && SecurityUtils.isAdmin(loginUser.getUser()); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsAdjustmentService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsAdjustmentService.java new file mode 100644 index 0000000..9b96645 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsAdjustmentService.java @@ -0,0 +1,23 @@ +package com.starry.admin.modules.withdraw.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity; +import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public interface IEarningsAdjustmentService extends IService { + + EarningsLineAdjustmentEntity createOrGetProcessing( + String tenantId, + String clerkId, + BigDecimal amount, + EarningsAdjustmentReasonType reasonType, + String reasonDescription, + String idempotencyKey, + LocalDateTime effectiveTime); + + EarningsLineAdjustmentEntity getByIdempotencyKey(String tenantId, String idempotencyKey); + + void triggerApplyAsync(String adjustmentId); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IWithdrawalService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IWithdrawalService.java index 3a00d3b..0ad7720 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IWithdrawalService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IWithdrawalService.java @@ -10,4 +10,6 @@ public interface IWithdrawalService extends IService { void markManualSuccess(String requestId, String operatorBy); void autoPayout(String requestId); + + void reject(String requestId, String reason); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsAdjustmentServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsAdjustmentServiceImpl.java new file mode 100644 index 0000000..4f710e2 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsAdjustmentServiceImpl.java @@ -0,0 +1,216 @@ +package com.starry.admin.modules.withdraw.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType; +import com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus; +import com.starry.admin.modules.withdraw.enums.EarningsSourceType; +import com.starry.admin.modules.withdraw.enums.EarningsType; +import com.starry.admin.modules.withdraw.mapper.EarningsLineAdjustmentMapper; +import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import javax.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +public class EarningsAdjustmentServiceImpl extends ServiceImpl + implements IEarningsAdjustmentService { + + @Resource + private IEarningsService earningsService; + + @Resource(name = "threadPoolTaskExecutor") + private ThreadPoolTaskExecutor executor; + + @Override + public EarningsLineAdjustmentEntity createOrGetProcessing( + String tenantId, + String clerkId, + BigDecimal amount, + EarningsAdjustmentReasonType reasonType, + String reasonDescription, + String idempotencyKey, + LocalDateTime effectiveTime) { + + if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(clerkId)) { + throw new CustomException("参数缺失"); + } + if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) { + throw new CustomException("amount 必须非0"); + } + if (reasonType == null) { + throw new CustomException("reasonType 必填"); + } + if (!StringUtils.hasText(idempotencyKey)) { + throw new CustomException("Idempotency-Key 必填"); + } + String trimmedReason = reasonDescription == null ? "" : reasonDescription.trim(); + if (!StringUtils.hasText(trimmedReason)) { + throw new CustomException("reasonDescription 必填"); + } + + LocalDateTime resolvedEffective = effectiveTime == null ? LocalDateTime.now() : effectiveTime; + // Idempotency hash must be stable across retries; do NOT inject server "now" into the hash. + // If client omits effectiveTime, we treat it as "unspecified" for hashing. + String requestHash = computeRequestHash(tenantId, clerkId, amount, reasonType, trimmedReason, effectiveTime); + + EarningsLineAdjustmentEntity existing = getByIdempotencyKey(tenantId, idempotencyKey); + if (existing != null) { + if (existing.getRequestHash() != null && !existing.getRequestHash().equals(requestHash)) { + throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体"); + } + return existing; + } + + EarningsLineAdjustmentEntity created = new EarningsLineAdjustmentEntity(); + created.setId(IdUtils.getUuid()); + created.setTenantId(tenantId); + created.setClerkId(clerkId); + created.setAmount(amount); + created.setReasonType(reasonType); + created.setReasonDescription(trimmedReason); + created.setStatus(EarningsAdjustmentStatus.PROCESSING); + created.setIdempotencyKey(idempotencyKey); + created.setRequestHash(requestHash); + created.setEffectiveTime(resolvedEffective); + Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()); + created.setCreatedTime(now); + created.setUpdatedTime(now); + + try { + this.save(created); + return created; + } catch (DuplicateKeyException dup) { + EarningsLineAdjustmentEntity raced = getByIdempotencyKey(tenantId, idempotencyKey); + if (raced == null) { + throw dup; + } + if (raced.getRequestHash() != null && !raced.getRequestHash().equals(requestHash)) { + throw new IllegalStateException("同一个 Idempotency-Key 不允许复用不同请求体"); + } + return raced; + } + } + + @Override + public EarningsLineAdjustmentEntity getByIdempotencyKey(String tenantId, String idempotencyKey) { + if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(idempotencyKey)) { + return null; + } + return this.getOne(Wrappers.lambdaQuery(EarningsLineAdjustmentEntity.class) + .eq(EarningsLineAdjustmentEntity::getTenantId, tenantId) + .eq(EarningsLineAdjustmentEntity::getIdempotencyKey, idempotencyKey) + .eq(EarningsLineAdjustmentEntity::getDeleted, false) + .last("limit 1")); + } + + @Override + public void triggerApplyAsync(String adjustmentId) { + if (!StringUtils.hasText(adjustmentId)) { + return; + } + executor.execute(() -> { + try { + applyOnce(adjustmentId); + } catch (Exception ignored) { + // Intentionally swallow: async apply must not crash request thread. + } + }); + } + + @Transactional(rollbackFor = Exception.class) + public void applyOnce(String adjustmentId) { + EarningsLineAdjustmentEntity adjustment = this.getById(adjustmentId); + if (adjustment == null) { + return; + } + if (adjustment.getStatus() == EarningsAdjustmentStatus.APPLIED) { + return; + } + if (adjustment.getStatus() == EarningsAdjustmentStatus.FAILED) { + return; + } + + try { + EarningsLineEntity line = new EarningsLineEntity(); + line.setId(IdUtils.getUuid()); + line.setTenantId(adjustment.getTenantId()); + line.setClerkId(adjustment.getClerkId()); + line.setOrderId(null); + line.setSourceType(EarningsSourceType.ADJUSTMENT); + line.setSourceId(adjustment.getId()); + line.setAmount(adjustment.getAmount()); + line.setEarningType(EarningsType.ADJUSTMENT); + line.setStatus("available"); + LocalDateTime effective = adjustment.getEffectiveTime() == null ? LocalDateTime.now() : adjustment.getEffectiveTime(); + line.setUnlockTime(effective); + Date now = new Date(); + line.setCreatedTime(now); + line.setUpdatedTime(now); + line.setDeleted(false); + + earningsService.save(line); + + EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity(); + update.setId(adjustment.getId()); + update.setStatus(EarningsAdjustmentStatus.APPLIED); + update.setAppliedTime(LocalDateTime.now()); + this.updateById(update); + } catch (DuplicateKeyException dup) { + EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity(); + update.setId(adjustment.getId()); + update.setStatus(EarningsAdjustmentStatus.APPLIED); + update.setAppliedTime(LocalDateTime.now()); + this.updateById(update); + } catch (Exception ex) { + EarningsLineAdjustmentEntity update = new EarningsLineAdjustmentEntity(); + update.setId(adjustment.getId()); + update.setStatus(EarningsAdjustmentStatus.FAILED); + update.setFailureReason(ex.getMessage()); + this.updateById(update); + throw ex; + } + } + + private String computeRequestHash( + String tenantId, + String clerkId, + BigDecimal amount, + EarningsAdjustmentReasonType reasonType, + String reasonDescription, + LocalDateTime effectiveTime) { + String normalizedAmount = amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); + String rawEffective = effectiveTime == null ? "" : effectiveTime.toString(); + String raw = tenantId + "|" + clerkId + "|" + normalizedAmount + "|" + reasonType.name() + "|" + reasonDescription + "|" + rawEffective; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); + return toHex(bytes); + } catch (Exception e) { + throw new IllegalStateException("hash failed", e); + } + } + + private String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java index 3b15fcb..733a707 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.enums.EarningsSourceType; import com.starry.admin.modules.withdraw.enums.EarningsType; import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper; import com.starry.admin.modules.withdraw.service.IEarningsService; @@ -48,6 +49,8 @@ public class EarningsServiceImpl extends ServiceImpl lines = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getWithdrawalId, req.getId()) + .eq(EarningsLineEntity::getStatus, "withdrawing") + .list(); + for (EarningsLineEntity line : lines) { + LocalDateTime unlock = line.getUnlockTime(); + String restored = unlock != null && unlock.isAfter(now) ? "frozen" : "available"; + earningsService.update(Wrappers.lambdaUpdate(EarningsLineEntity.class) + .eq(EarningsLineEntity::getId, line.getId()) + .set(EarningsLineEntity::getWithdrawalId, null) + .set(EarningsLineEntity::getStatus, restored)); + } + + withdrawalLogService.log(req.getTenantId(), req.getClerkId(), req.getId(), + "PAYOUT_REJECTED", req.getStatus(), update.getStatus(), + "拒绝提现,原因=" + (reason == null ? "" : reason), null); + } + private String buildPayeeSnapshot(ClerkPayeeProfileEntity profile, LocalDateTime confirmedAt) { PayeeSnapshotVo snapshot = new PayeeSnapshotVo(); snapshot.setChannel(profile.getChannel()); diff --git a/play-admin/src/main/resources/db/migration/V24__earnings_adjustments.sql b/play-admin/src/main/resources/db/migration/V24__earnings_adjustments.sql new file mode 100644 index 0000000..ec0ebac --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V24__earnings_adjustments.sql @@ -0,0 +1,50 @@ +-- Earnings adjustments: introduce generic adjustment header table + source identity for earnings lines. + +-- 1) Extend ledger to support non-order sources (e.g. admin adjustments / rewards / punishments). +-- We keep order_id for ORDER source for backward compatibility, but allow it to be NULL for adjustment lines. + +-- Drop legacy uniqueness keyed by order_id, since order_id can now be NULL and we want uniqueness by source identity. +ALTER TABLE `play_earnings_line` + DROP INDEX `uk_tenant_order_clerk_type`; + +ALTER TABLE `play_earnings_line` + MODIFY COLUMN `order_id` varchar(32) NULL COMMENT '订单ID(source_type=ORDER 时必填)', + ADD COLUMN `source_type` varchar(16) NOT NULL DEFAULT 'ORDER' COMMENT '来源类型(ORDER/ADJUSTMENT)' AFTER `order_id`, + ADD COLUMN `source_id` varchar(32) DEFAULT NULL COMMENT '来源ID(source_type=ORDER 时等于 order_id;source_type=ADJUSTMENT 时等于 adjustment_id)' AFTER `source_type`; + +-- Backfill existing rows to ORDER source. +UPDATE `play_earnings_line` +SET `source_id` = `order_id` +WHERE (`source_id` IS NULL) + AND `order_id` IS NOT NULL + AND `deleted` = 0; + +-- New uniqueness: one line per source identity per clerk/type (ignoring logical delete). +ALTER TABLE `play_earnings_line` + ADD UNIQUE KEY `uk_tenant_source_clerk_type` (`tenant_id`, `source_type`, `source_id`, `clerk_id`, `earning_type`, `deleted`); + +-- 2) Adjustment header table (idempotent, async lifecycle). +CREATE TABLE IF NOT EXISTS `play_earnings_line_adjustment` ( + `id` varchar(32) NOT NULL COMMENT 'UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID', + `clerk_id` varchar(32) NOT NULL COMMENT '店员ID', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(可正可负,不允许0)', + `reason_type` varchar(32) NOT NULL COMMENT '原因类型(Enum, hard-coded for now)', + `reason_description` varchar(512) NOT NULL COMMENT '原因描述(操作时输入)', + `status` varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/APPLIED/FAILED', + `idempotency_key` varchar(64) DEFAULT NULL COMMENT '幂等Key(tenant范围内唯一;为空则不幂等)', + `request_hash` varchar(64) NOT NULL DEFAULT '' COMMENT '请求摘要(用于检测同key不同body)', + `effective_time` datetime NOT NULL COMMENT '生效时间(用于统计窗口;默认 now)', + `applied_time` datetime DEFAULT NULL COMMENT '落账完成时间', + `failure_reason` varchar(512) DEFAULT NULL COMMENT '失败原因(FAILED 时)', + `created_by` varchar(32) DEFAULT NULL, + `created_time` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_by` varchar(32) DEFAULT NULL, + `updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除', + `version` int NOT NULL DEFAULT '1' COMMENT '数据版本', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_tenant_idempotency` (`tenant_id`, `idempotency_key`, `deleted`) USING BTREE, + KEY `idx_adjustment_tenant_clerk_time` (`tenant_id`, `clerk_id`, `effective_time`) USING BTREE, + KEY `idx_adjustment_tenant_status` (`tenant_id`, `status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益调整(Reward/Punishment/Correction统一抽象)'; diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentAuthorizationApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentAuthorizationApiTest.java new file mode 100644 index 0000000..f4a42c9 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentAuthorizationApiTest.java @@ -0,0 +1,267 @@ +package com.starry.admin.api; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; +import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; +import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; +import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; +import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.enums.EarningsSourceType; +import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Authorization contract tests for admin earnings adjustments. + * + *

Expected to fail until permissions + group-leader scope are enforced for new endpoints.

+ */ +class AdminEarningsAdjustmentAuthorizationApiTest extends AbstractApiTest { + + private static final String IDEMPOTENCY_HEADER = "Idempotency-Key"; + private static final String PERMISSIONS_HEADER = "X-Test-Permissions"; + private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin"; + private static final String PERMISSION_ADJUSTMENT_CREATE = "withdraw:adjustment:create"; + private static final String PERMISSION_ADJUSTMENT_READ = "withdraw:adjustment:read"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private IEarningsAdjustmentService adjustmentService; + + @Autowired + private IEarningsService earningsService; + + @Autowired + private IPlayPersonnelGroupInfoService groupInfoService; + + @Autowired + private IPlayClerkUserInfoService clerkUserInfoService; + + private final List idempotencyKeysToCleanup = new ArrayList<>(); + private final List clerkIdsToCleanup = new ArrayList<>(); + private final List groupIdsToCleanup = new ArrayList<>(); + + @BeforeEach + void setUp() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + idempotencyKeysToCleanup.clear(); + clerkIdsToCleanup.clear(); + groupIdsToCleanup.clear(); + } + + @AfterEach + void tearDown() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + cleanupAdjustmentsByIdempotencyKeys(); + if (!clerkIdsToCleanup.isEmpty()) { + clerkUserInfoService.removeByIds(clerkIdsToCleanup); + } + if (!groupIdsToCleanup.isEmpty()) { + groupInfoService.removeByIds(groupIdsToCleanup); + } + } + + @Test + void createWithoutPermissionReturns403() throws Exception { + String key = UUID.randomUUID().toString(); + idempotencyKeysToCleanup.add(key); + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "20.00"))) + .andExpect(status().isForbidden()); + } + + @Test + void createLeaderWithPermissionCannotManageOtherGroupClerkReturns403() throws Exception { + String otherClerkId = seedOtherGroupClerk(); + + String key = UUID.randomUUID().toString(); + idempotencyKeysToCleanup.add(key); + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(createPayload(otherClerkId, "20.00"))) + .andExpect(status().isForbidden()); + } + + @Test + void createSuperAdminBypassesPermissionAndScopeReturns202() throws Exception { + String otherClerkId = seedOtherGroupClerk(); + + String key = UUID.randomUUID().toString(); + idempotencyKeysToCleanup.add(key); + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid()) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(SUPER_ADMIN_HEADER, "true") + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(createPayload(otherClerkId, "20.00"))) + .andExpect(status().isAccepted()); + } + + @Test + void pollWithoutReadPermissionReturns403() throws Exception { + String key = UUID.randomUUID().toString(); + idempotencyKeysToCleanup.add(key); + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "-10.00"))) + .andExpect(status().isAccepted()) + .andReturn(); + + awaitApplied(key); + + mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isForbidden()); + } + + @Test + void crossTenantPollReturns404EvenWithPermission() throws Exception { + String key = UUID.randomUUID().toString(); + idempotencyKeysToCleanup.add(key); + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_CREATE) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(createPayload(ApiTestDataSeeder.DEFAULT_CLERK_ID, "1.00"))) + .andExpect(status().isAccepted()); + + mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, "tenant-other") + .header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_READ)) + .andExpect(status().isNotFound()); + } + + private String createPayload(String clerkId, String amount) { + return "{" + + "\"clerkId\":\"" + clerkId + "\"," + + "\"amount\":\"" + amount + "\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"auth-test\"" + + "}"; + } + + private String seedOtherGroupClerk() { + String groupId = "group-auth-" + IdUtils.getUuid(); + PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity(); + group.setId(groupId); + group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + group.setSysUserId("leader-auth-" + IdUtils.getUuid()); + group.setSysUserCode("leader-auth"); + group.setGroupName("Auth Test Group"); + group.setLeaderName("Leader Auth"); + group.setAddTime(LocalDateTime.now()); + groupInfoService.save(group); + groupIdsToCleanup.add(groupId); + + String clerkId = "clerk-auth-" + IdUtils.getUuid(); + PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity(); + clerk.setId(clerkId); + clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid()); + clerk.setOpenid("openid-auth-" + clerkId); + clerk.setNickname("Auth Clerk"); + clerk.setGroupId(groupId); + clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + clerk.setFixingLevel("1"); + clerk.setSex("2"); + clerk.setPhone("139" + String.valueOf(System.nanoTime()).substring(0, 8)); + clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid()); + clerk.setAvatar("https://example.com/avatar.png"); + clerk.setAccountBalance(BigDecimal.ZERO); + clerk.setOnboardingState("1"); + clerk.setListingState("1"); + clerk.setDisplayState("1"); + clerk.setOnlineState("1"); + clerk.setRandomOrderState("1"); + clerk.setClerkState("1"); + clerk.setEntryTime(LocalDateTime.now()); + clerk.setToken("token-auth-" + IdUtils.getUuid()); + clerkUserInfoService.save(clerk); + clerkIdsToCleanup.add(clerkId); + + return clerkId; + } + + private void cleanupAdjustmentsByIdempotencyKeys() { + for (String key : idempotencyKeysToCleanup) { + EarningsLineAdjustmentEntity adjustment = adjustmentService.getByIdempotencyKey( + ApiTestDataSeeder.DEFAULT_TENANT_ID, key); + if (adjustment != null) { + cleanupAdjustment(adjustment.getId()); + } + } + idempotencyKeysToCleanup.clear(); + } + + private void cleanupAdjustment(String adjustmentId) { + if (adjustmentId == null) { + return; + } + earningsService.lambdaUpdate() + .eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID) + .eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT) + .eq(EarningsLineEntity::getSourceId, adjustmentId) + .remove(); + adjustmentService.removeById(adjustmentId); + } + + private void awaitApplied(String idempotencyKey) throws Exception { + for (int i = 0; i < 80; i++) { + MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_ADJUSTMENT_READ)) + .andReturn(); + if (poll.getResponse().getStatus() == 200) { + JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString()); + if ("APPLIED".equals(root.path("data").path("status").asText())) { + return; + } + } + Thread.sleep(50); + } + throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentControllerApiTest.java new file mode 100644 index 0000000..2f4f3cc --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminEarningsAdjustmentControllerApiTest.java @@ -0,0 +1,417 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.enums.EarningsSourceType; +import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.utils.SecurityUtils; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +/** + * TDD contract tests for earnings adjustments (reward/punishment/unified adjustment model). + * + *

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

+ */ +class AdminEarningsAdjustmentControllerApiTest extends AbstractApiTest { + + private static final String IDEMPOTENCY_HEADER = "Idempotency-Key"; + private static final String PERMISSIONS_HEADER = "X-Test-Permissions"; + private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private IEarningsService earningsService; + + @Autowired + private IEarningsAdjustmentService adjustmentService; + + private final List createdAdjustmentIds = new ArrayList<>(); + + @BeforeEach + void setUp() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } + + @AfterEach + void tearDown() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + deleteAdjustmentsAndLines(createdAdjustmentIds); + createdAdjustmentIds.clear(); + } + + @Test + void createAdjustmentReturns202AndProvidesPollingHandle() throws Exception { + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"20.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"manual reward for testing\"" + + "}"; + + MvcResult result = mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()) + .andExpect(header().string("Location", "/admin/earnings/adjustments/idempotency/" + key)) + .andExpect(jsonPath("$.code").value(202)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.adjustmentId").isNotEmpty()) + .andExpect(jsonPath("$.data.idempotencyKey").value(key)) + .andExpect(jsonPath("$.data.status").value("PROCESSING")) + .andReturn(); + + createdAdjustmentIds.add(extractAdjustmentId(result)); + awaitApplied(key); + } + + @Test + void pollByIdempotencyKeyReturnsProcessingThenApplied() throws Exception { + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"-10.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"manual punishment for testing\"" + + "}"; + + MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()) + .andReturn(); + createdAdjustmentIds.add(extractAdjustmentId(create)); + + // Immediately polling should return a stable representation (at least PROCESSING). + mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.idempotencyKey").value(key)) + .andExpect(jsonPath("$.data.adjustmentId").isNotEmpty()) + .andExpect(jsonPath("$.data.status").value("PROCESSING")); + + // After implementation, the system should eventually transition to APPLIED. + // Poll with a bounded wait to keep the test deterministic. + boolean applied = false; + for (int i = 0; i < 40; i++) { + MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)) + .andExpect(status().isOk()) + .andReturn(); + JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString()); + String status = root.path("data").path("status").asText(); + if ("APPLIED".equals(status)) { + applied = true; + break; + } + Thread.sleep(50); + } + assertThat(applied).isTrue(); + } + + @Test + void sameIdempotencyKeySameBodyIsIdempotent() throws Exception { + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"30.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"idempotent create\"" + + "}"; + + MvcResult first = mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()) + .andReturn(); + + MvcResult second = mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()) + .andReturn(); + + JsonNode firstRoot = objectMapper.readTree(first.getResponse().getContentAsString()); + JsonNode secondRoot = objectMapper.readTree(second.getResponse().getContentAsString()); + String firstId = firstRoot.path("data").path("adjustmentId").asText(); + String secondId = secondRoot.path("data").path("adjustmentId").asText(); + assertThat(firstId).isNotBlank(); + assertThat(secondId).isEqualTo(firstId); + createdAdjustmentIds.add(firstId); + awaitApplied(key); + } + + @Test + void sameIdempotencyKeyDifferentBodyReturns409() throws Exception { + String key = UUID.randomUUID().toString(); + String payloadA = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"30.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"first payload\"" + + "}"; + String payloadB = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"31.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"different payload\"" + + "}"; + + MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadA)) + .andExpect(status().isAccepted()) + .andReturn(); + createdAdjustmentIds.add(extractAdjustmentId(create)); + awaitApplied(key); + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadB)) + .andExpect(status().isConflict()); + } + + @Test + void missingIdempotencyKeyReturns400() throws Exception { + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"20.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"missing idempotency\"" + + "}"; + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsZeroAmount() throws Exception { + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"0.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"zero amount\"" + + "}"; + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsMissingReasonType() throws Exception { + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"10.00\"," + + "\"reasonDescription\":\"missing reason type\"" + + "}"; + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsBlankReasonDescription() throws Exception { + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"10.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\" \"" + + "}"; + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isBadRequest()); + } + + @Test + void concurrentCreatesWithSameKeyReturnSameAdjustmentIdAndDoNotDuplicate() throws Exception { + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"9.99\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"concurrent\"" + + "}"; + + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Callable call = () -> { + MvcResult result = mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()) + .andReturn(); + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + return root.path("data").path("adjustmentId").asText(); + }; + List> futures = new ArrayList<>(); + futures.add(pool.submit(call)); + futures.add(pool.submit(call)); + + String a = futures.get(0).get(); + String b = futures.get(1).get(); + assertThat(a).isNotBlank(); + assertThat(b).isEqualTo(a); + createdAdjustmentIds.add(a); + awaitApplied(key); + } finally { + pool.shutdownNow(); + } + } + + @Test + void pollMissingIdempotencyKeyReturns404() throws Exception { + mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + UUID.randomUUID()) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)) + .andExpect(status().isNotFound()); + } + + @Test + void idempotencyKeyIsTenantScoped() throws Exception { + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"1.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"tenant scope\"" + + "}"; + + MvcResult create = mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()) + .andReturn(); + createdAdjustmentIds.add(extractAdjustmentId(create)); + awaitApplied(key); + + mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + key) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, "tenant-other") + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)) + .andExpect(status().isNotFound()); + } + + private String extractAdjustmentId(MvcResult result) throws Exception { + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + return root.path("data").path("adjustmentId").asText(); + } + + private String awaitApplied(String idempotencyKey) throws Exception { + for (int i = 0; i < 80; i++) { + MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)) + .andExpect(status().isOk()) + .andReturn(); + JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString()); + String status = root.path("data").path("status").asText(); + if ("APPLIED".equals(status)) { + return root.path("data").path("adjustmentId").asText(); + } + Thread.sleep(50); + } + throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey); + } + + private void deleteAdjustmentsAndLines(Collection adjustmentIds) { + if (adjustmentIds == null || adjustmentIds.isEmpty()) { + return; + } + earningsService.lambdaUpdate() + .eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID) + .eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) + .eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT) + .in(EarningsLineEntity::getSourceId, adjustmentIds) + .remove(); + adjustmentService.removeByIds(adjustmentIds); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalAuditWithAdjustmentsApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalAuditWithAdjustmentsApiTest.java new file mode 100644 index 0000000..2fb67c4 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalAuditWithAdjustmentsApiTest.java @@ -0,0 +1,180 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IPlayOrderInfoService; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; +import com.starry.admin.modules.withdraw.enums.EarningsType; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.modules.withdraw.service.IWithdrawalService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MvcResult; + +/** + * TDD tests: withdrawal audit must tolerate adjustment lines (orderId=null) and still return a stable payload. + * + *

These tests are expected to FAIL until earnings lines support orderId=null + sourceType/sourceId, + * and audit serialization handles mixed sources.

+ */ +class AdminWithdrawalAuditWithAdjustmentsApiTest extends AbstractApiTest { + + @Autowired + private IEarningsService earningsService; + + @Autowired + private IWithdrawalService withdrawalService; + + @Autowired + private IPlayOrderInfoService orderInfoService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String withdrawalId; + private String orderId; + private String orderLineId; + private String adjustmentLineId; + + @BeforeEach + void setUp() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } + + @AfterEach + void tearDown() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + List toDelete = new ArrayList<>(); + if (orderLineId != null) { + toDelete.add(orderLineId); + } + if (adjustmentLineId != null) { + toDelete.add(adjustmentLineId); + } + if (!toDelete.isEmpty()) { + earningsService.removeByIds(toDelete); + } + if (withdrawalId != null) { + withdrawalService.removeById(withdrawalId); + } + if (orderId != null) { + orderInfoService.removeById(orderId); + } + } + + @Test + void auditReturnsOrderDetailsForOrderLinesAndLeavesOrderFieldsEmptyForAdjustments() throws Exception { + seedOrderWithdrawalAndLines(); + + MvcResult result = mockMvc.perform(get("/admin/withdraw/requests/" + withdrawalId + "/audit") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andReturn(); + + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + JsonNode first = data.get(0); + JsonNode second = data.get(1); + + // First entry is ORDER: should have orderNo present. + boolean firstIsOrder = "ORDER".equals(first.path("earningType").asText()); + JsonNode orderNode = firstIsOrder ? first : second; + JsonNode adjustmentNode = firstIsOrder ? second : first; + + assertThat(orderNode.path("earningType").asText()).isEqualTo("ORDER"); + assertThat(orderNode.path("orderNo").asText()).isNotBlank(); + + assertThat(adjustmentNode.path("earningType").asText()).isEqualTo("ADJUSTMENT"); + assertThat(adjustmentNode.path("orderId").isMissingNode() || adjustmentNode.path("orderId").isNull()).isTrue(); + assertThat(adjustmentNode.path("orderNo").isMissingNode() || adjustmentNode.path("orderNo").isNull()).isTrue(); + } + + private void seedOrderWithdrawalAndLines() { + LocalDateTime now = LocalDateTime.now().withNano(0); + + // order + PlayOrderInfoEntity order = new PlayOrderInfoEntity(); + orderId = "order-audit-adj-" + IdUtils.getUuid(); + order.setId(orderId); + order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID); + order.setOrderNo("audit-adj-" + IdUtils.getUuid()); + order.setOrderStatus("3"); + order.setOrderEndTime(now.minusHours(1)); + order.setFinalAmount(new BigDecimal("120.00")); + order.setEstimatedRevenue(new BigDecimal("60.00")); + order.setDeleted(false); + Date dt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + order.setCreatedTime(dt); + order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + order.setUpdatedTime(dt); + orderInfoService.save(order); + + // withdrawal request + WithdrawalRequestEntity req = new WithdrawalRequestEntity(); + withdrawalId = "withdraw-audit-adj-" + IdUtils.getUuid(); + req.setId(withdrawalId); + req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + req.setAmount(new BigDecimal("88.00")); + req.setFee(BigDecimal.ZERO); + req.setNetAmount(req.getAmount()); + req.setDestAccount("alipay:audit-adj@test.com"); + req.setStatus("processing"); + req.setPayeeSnapshot("{\"displayName\":\"审计专用\"}"); + req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + req.setCreatedTime(dt); + req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + req.setUpdatedTime(dt); + withdrawalService.save(req); + + // order line + orderLineId = seedEarningLine(withdrawalId, orderId, new BigDecimal("60.00"), "withdrawn", EarningsType.ORDER, now.minusMinutes(30)); + + // adjustment line (intended future: orderId=null + sourceType/sourceId) + adjustmentLineId = seedEarningLine(withdrawalId, null, new BigDecimal("-10.00"), "withdrawing", EarningsType.ADJUSTMENT, now.minusMinutes(10)); + } + + private String seedEarningLine(String withdrawalId, String orderIdOrNull, BigDecimal amount, String status, EarningsType type, LocalDateTime createdAt) { + EarningsLineEntity entity = new EarningsLineEntity(); + String id = "earn-audit-adj-" + IdUtils.getUuid(); + entity.setId(id); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + entity.setOrderId(orderIdOrNull); + entity.setAmount(amount); + entity.setStatus(status); + entity.setEarningType(type); + entity.setUnlockTime(createdAt.minusHours(1)); + entity.setWithdrawalId(withdrawalId); + Date createdDate = Date.from(createdAt.atZone(ZoneId.systemDefault()).toInstant()); + entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setCreatedTime(createdDate); + entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setUpdatedTime(createdDate); + entity.setDeleted(false); + earningsService.save(entity); + return id; + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalRejectApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalRejectApiTest.java new file mode 100644 index 0000000..7e32657 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalRejectApiTest.java @@ -0,0 +1,244 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; +import com.starry.admin.modules.withdraw.enums.EarningsType; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.modules.withdraw.service.IWithdrawalService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +/** + * TDD contract tests for rejecting/canceling a withdrawal request (release reserved earnings lines). + * + *

These tests are expected to FAIL until withdrawal reject is implemented.

+ */ +class AdminWithdrawalRejectApiTest extends AbstractApiTest { + + private static final String PERMISSIONS_HEADER = "X-Test-Permissions"; + private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject"; + + @Autowired + private IEarningsService earningsService; + + @Autowired + private IWithdrawalService withdrawalService; + + private String withdrawalId; + private String lineA; + private String lineB; + + @BeforeEach + void setUp() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + withdrawalId = null; + lineA = null; + lineB = null; + } + + @AfterEach + void tearDown() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + if (withdrawalId != null) { + withdrawalService.removeById(withdrawalId); + } + List toDelete = new ArrayList<>(); + if (lineA != null) { + toDelete.add(lineA); + } + if (lineB != null) { + toDelete.add(lineB); + } + if (!toDelete.isEmpty()) { + earningsService.removeByIds(toDelete); + } + } + + @Test + void rejectPendingWithdrawalReleasesWithdrawingLines() throws Exception { + seedWithdrawingWithdrawalAndLines(); + + String payload = "{\"reason\":\"bank account mismatch\"}"; + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + EarningsLineEntity afterA = earningsService.getById(lineA); + EarningsLineEntity afterB = earningsService.getById(lineB); + assertThat(afterA.getWithdrawalId()).isNull(); + assertThat(afterB.getWithdrawalId()).isNull(); + assertThat(afterA.getStatus()).isIn("available", "frozen"); + assertThat(afterB.getStatus()).isIn("available", "frozen"); + + WithdrawalRequestEntity req = withdrawalService.getById(withdrawalId); + assertThat(req.getStatus()).isIn("canceled", "rejected"); + } + + @Test + void rejectIsIdempotent() throws Exception { + seedWithdrawingWithdrawalAndLines(); + + String payload = "{\"reason\":\"duplicate\"}"; + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()); + } + + @Test + void rejectRestoresFrozenWhenUnlockTimeInFuture() throws Exception { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + + WithdrawalRequestEntity req = new WithdrawalRequestEntity(); + withdrawalId = "withdraw-reject-frozen-" + IdUtils.getUuid(); + req.setId(withdrawalId); + req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + req.setAmount(new BigDecimal("10.00")); + req.setFee(BigDecimal.ZERO); + req.setNetAmount(req.getAmount()); + req.setDestAccount("alipay:test"); + req.setStatus("pending"); + req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}"); + Date now = new Date(); + req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + req.setCreatedTime(now); + req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + req.setUpdatedTime(now); + withdrawalService.save(req); + + lineA = seedLineWithUnlock("reject-future", new BigDecimal("10.00"), LocalDateTime.now().plusDays(1)); + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"future unlock\"}")) + .andExpect(status().isOk()); + + EarningsLineEntity after = earningsService.getById(lineA); + assertThat(after.getStatus()).isEqualTo("frozen"); + assertThat(after.getWithdrawalId()).isNull(); + } + + @Test + void rejectSuccessWithdrawalIsRejected() throws Exception { + seedWithdrawingWithdrawalAndLines(); + WithdrawalRequestEntity req = withdrawalService.getById(withdrawalId); + req.setStatus("success"); + withdrawalService.updateById(req); + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cannot reject success\"}")) + .andExpect(status().isBadRequest()); + } + + private void seedWithdrawingWithdrawalAndLines() { + WithdrawalRequestEntity req = new WithdrawalRequestEntity(); + withdrawalId = "withdraw-reject-" + IdUtils.getUuid(); + req.setId(withdrawalId); + req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + req.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + req.setAmount(new BigDecimal("80.00")); + req.setFee(BigDecimal.ZERO); + req.setNetAmount(req.getAmount()); + req.setDestAccount("alipay:test"); + req.setStatus("pending"); + req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}"); + Date now = new Date(); + req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + req.setCreatedTime(now); + req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + req.setUpdatedTime(now); + withdrawalService.save(req); + + lineA = seedLine("reject-a", new BigDecimal("50.00")); + lineB = seedLine("reject-b", new BigDecimal("30.00")); + } + + private String seedLine(String suffix, BigDecimal amount) { + EarningsLineEntity entity = new EarningsLineEntity(); + String rawId = "earn-reject-" + suffix + "-" + IdUtils.getUuid(); + String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32); + entity.setId(id); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + entity.setOrderId("order-reject-" + IdUtils.getUuid()); + entity.setAmount(amount); + entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT); + entity.setStatus("withdrawing"); + entity.setWithdrawalId(withdrawalId); + entity.setUnlockTime(LocalDateTime.now().minusDays(1)); + Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()); + entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setCreatedTime(now); + entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setUpdatedTime(now); + entity.setDeleted(false); + earningsService.save(entity); + return id; + } + + private String seedLineWithUnlock(String suffix, BigDecimal amount, LocalDateTime unlockAt) { + EarningsLineEntity entity = new EarningsLineEntity(); + String rawId = "earn-reject-" + suffix + "-" + IdUtils.getUuid(); + String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32); + entity.setId(id); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + entity.setOrderId("order-reject-" + IdUtils.getUuid()); + entity.setAmount(amount); + entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT); + entity.setStatus("withdrawing"); + entity.setWithdrawalId(withdrawalId); + entity.setUnlockTime(unlockAt); + Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()); + entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setCreatedTime(now); + entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setUpdatedTime(now); + entity.setDeleted(false); + earningsService.save(entity); + return id; + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalRejectAuthorizationApiTest.java b/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalRejectAuthorizationApiTest.java new file mode 100644 index 0000000..6eb15bb --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/AdminWithdrawalRejectAuthorizationApiTest.java @@ -0,0 +1,227 @@ +package com.starry.admin.api; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; +import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; +import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; +import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; +import com.starry.admin.modules.withdraw.enums.EarningsType; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.modules.withdraw.service.IWithdrawalService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +/** + * Authorization contract tests for rejecting withdrawals. + * + *

Expected to fail until permissions + group-leader scope are enforced for new endpoints.

+ */ +class AdminWithdrawalRejectAuthorizationApiTest extends AbstractApiTest { + + private static final String PERMISSIONS_HEADER = "X-Test-Permissions"; + private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin"; + private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject"; + + @Autowired + private IEarningsService earningsService; + + @Autowired + private IWithdrawalService withdrawalService; + + @Autowired + private IPlayPersonnelGroupInfoService groupInfoService; + + @Autowired + private IPlayClerkUserInfoService clerkUserInfoService; + + private final List withdrawalIdsToCleanup = new ArrayList<>(); + private final List earningLineIdsToCleanup = new ArrayList<>(); + private final List clerkIdsToCleanup = new ArrayList<>(); + private final List groupIdsToCleanup = new ArrayList<>(); + + @BeforeEach + void setUp() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + withdrawalIdsToCleanup.clear(); + earningLineIdsToCleanup.clear(); + clerkIdsToCleanup.clear(); + groupIdsToCleanup.clear(); + } + + @AfterEach + void tearDown() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + if (!withdrawalIdsToCleanup.isEmpty()) { + withdrawalService.removeByIds(withdrawalIdsToCleanup); + } + if (!earningLineIdsToCleanup.isEmpty()) { + earningsService.removeByIds(earningLineIdsToCleanup); + } + if (!clerkIdsToCleanup.isEmpty()) { + clerkUserInfoService.removeByIds(clerkIdsToCleanup); + } + if (!groupIdsToCleanup.isEmpty()) { + groupInfoService.removeByIds(groupIdsToCleanup); + } + } + + @Test + void rejectWithoutPermissionReturns403() throws Exception { + String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID); + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"auth-test\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void rejectLeaderWithPermissionCannotManageOtherGroupReturns403() throws Exception { + String otherClerkId = seedOtherGroupClerk(); + String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId); + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"auth-test\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void rejectSuperAdminBypassesPermissionAndScopeReturns200() throws Exception { + String otherClerkId = seedOtherGroupClerk(); + String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId); + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid()) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(SUPER_ADMIN_HEADER, "true") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"auth-test\"}")) + .andExpect(status().isOk()); + } + + @Test + void crossTenantRejectReturns404EvenWithPermission() throws Exception { + String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID); + + mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, "tenant-other") + .header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"auth-test\"}")) + .andExpect(status().isNotFound()); + } + + private String seedWithdrawingWithdrawalAndLines(String clerkId) { + WithdrawalRequestEntity req = new WithdrawalRequestEntity(); + String withdrawalId = "withdraw-auth-" + IdUtils.getUuid(); + req.setId(withdrawalId); + req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + req.setClerkId(clerkId); + req.setAmount(new BigDecimal("80.00")); + req.setFee(BigDecimal.ZERO); + req.setNetAmount(req.getAmount()); + req.setDestAccount("alipay:test"); + req.setStatus("pending"); + req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}"); + Date now = new Date(); + req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + req.setCreatedTime(now); + req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + req.setUpdatedTime(now); + withdrawalService.save(req); + withdrawalIdsToCleanup.add(withdrawalId); + + earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "a", new BigDecimal("50.00"))); + earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "b", new BigDecimal("30.00"))); + + return withdrawalId; + } + + private String seedLine(String withdrawalId, String clerkId, String suffix, BigDecimal amount) { + EarningsLineEntity entity = new EarningsLineEntity(); + String rawId = "earn-auth-reject-" + suffix + "-" + IdUtils.getUuid(); + String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32); + entity.setId(id); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setClerkId(clerkId); + entity.setOrderId("order-auth-reject-" + IdUtils.getUuid()); + entity.setAmount(amount); + entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT); + entity.setStatus("withdrawing"); + entity.setWithdrawalId(withdrawalId); + entity.setUnlockTime(LocalDateTime.now().minusDays(1)); + Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()); + entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setCreatedTime(now); + entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setUpdatedTime(now); + entity.setDeleted(false); + earningsService.save(entity); + return id; + } + + private String seedOtherGroupClerk() { + String groupId = "group-auth-withdraw-" + IdUtils.getUuid(); + PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity(); + group.setId(groupId); + group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + group.setSysUserId("leader-auth-" + IdUtils.getUuid()); + group.setSysUserCode("leader-auth"); + group.setGroupName("Auth Test Group"); + group.setLeaderName("Leader Auth"); + group.setAddTime(LocalDateTime.now()); + groupInfoService.save(group); + groupIdsToCleanup.add(groupId); + + String clerkId = "clerk-auth-withdraw-" + IdUtils.getUuid(); + PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity(); + clerk.setId(clerkId); + clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid()); + clerk.setOpenid("openid-auth-" + clerkId); + clerk.setNickname("Auth Clerk"); + clerk.setGroupId(groupId); + clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID); + clerk.setFixingLevel("1"); + clerk.setSex("2"); + clerk.setPhone("138" + String.valueOf(System.nanoTime()).substring(0, 8)); + clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid()); + clerk.setAvatar("https://example.com/avatar.png"); + clerk.setAccountBalance(BigDecimal.ZERO); + clerk.setOnboardingState("1"); + clerk.setListingState("1"); + clerk.setDisplayState("1"); + clerk.setOnlineState("1"); + clerk.setRandomOrderState("1"); + clerk.setClerkState("1"); + clerk.setEntryTime(LocalDateTime.now()); + clerk.setToken("token-auth-" + IdUtils.getUuid()); + clerkUserInfoService.save(clerk); + clerkIdsToCleanup.add(clerkId); + + return clerkId; + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/StatisticsPerformanceOverviewIncludeAdjustmentsApiTest.java b/play-admin/src/test/java/com/starry/admin/api/StatisticsPerformanceOverviewIncludeAdjustmentsApiTest.java new file mode 100644 index 0000000..293b4b9 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/StatisticsPerformanceOverviewIncludeAdjustmentsApiTest.java @@ -0,0 +1,276 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.common.domain.LoginUser; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IPlayOrderInfoService; +import com.starry.admin.modules.system.module.entity.SysUserEntity; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.enums.EarningsSourceType; +import com.starry.admin.modules.withdraw.enums.EarningsType; +import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MvcResult; + +/** + * TDD contract tests for statistics toggle: include/exclude earnings adjustments. + * + *

These tests are expected to FAIL until the stats endpoint supports includeAdjustments.

+ */ +class StatisticsPerformanceOverviewIncludeAdjustmentsApiTest extends AbstractApiTest { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2011, 1, 1, 12, 0, 0); + + @Autowired + private IPlayOrderInfoService orderInfoService; + + @Autowired + private IEarningsService earningsService; + + @Autowired + private EarningsLineMapper earningsLineMapper; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String orderId; + private final List earningLineIdsToCleanup = new ArrayList<>(); + + @BeforeEach + void setUp() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + orderId = null; + earningLineIdsToCleanup.clear(); + setAuthentication(); + } + + @AfterEach + void tearDown() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + if (!earningLineIdsToCleanup.isEmpty()) { + earningsService.removeByIds(earningLineIdsToCleanup); + } + if (orderId != null) { + orderInfoService.removeById(orderId); + } + SecurityContextHolder.clearContext(); + } + + @Test + void overviewExcludesAdjustmentsByDefaultAndIncludesWhenToggledOn() throws Exception { + seedOneOrderAndLines(); + + LocalDateTime start = BASE_TIME.minusMinutes(5); + LocalDateTime end = BASE_TIME.plusMinutes(5); + + BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk( + ApiTestDataSeeder.DEFAULT_TENANT_ID, + ApiTestDataSeeder.DEFAULT_CLERK_ID, + start.format(DATE_TIME_FORMATTER), + end.format(DATE_TIME_FORMATTER)); + assertThat(adjustmentSum.setScale(2, RoundingMode.HALF_UP)).isEqualByComparingTo(new BigDecimal("-20.00")); + + String payloadDefault = "{" + + "\"includeSummary\":true," + + "\"includeRankings\":true," + + "\"limit\":5," + + "\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" + + "}"; + + MvcResult defaultResult = mockMvc.perform(post("/statistics/performance/overview") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadDefault)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode defaultJson = objectMapper.readTree(defaultResult.getResponse().getContentAsString()); + BigDecimal defaultRevenue = new BigDecimal(defaultJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0")) + .setScale(2, RoundingMode.HALF_UP); + assertThat(defaultRevenue).isEqualByComparingTo(new BigDecimal("100.00")); + + String payloadInclude = "{" + + "\"includeSummary\":true," + + "\"includeRankings\":true," + + "\"includeAdjustments\":true," + + "\"limit\":5," + + "\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" + + "}"; + + MvcResult includeResult = mockMvc.perform(post("/statistics/performance/overview") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadInclude)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode includeJson = objectMapper.readTree(includeResult.getResponse().getContentAsString()); + BigDecimal includeRevenue = new BigDecimal(includeJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0")) + .setScale(2, RoundingMode.HALF_UP); + assertThat(includeRevenue).isEqualByComparingTo(new BigDecimal("80.00")); + } + + @Test + void includeAdjustmentsRespectsTimeWindow() throws Exception { + seedOneOrderAndLines(); + + // Seed another adjustment intended to be outside the window. + // Intended future behavior: adjustment is filtered by its effectiveTime/createdTime within the same window. + seedOutOfWindowAdjustmentLine(); + + LocalDateTime start = BASE_TIME.minusMinutes(5); + LocalDateTime end = BASE_TIME.plusMinutes(5); + + BigDecimal adjustmentSum = earningsLineMapper.sumAdjustmentsByClerk( + ApiTestDataSeeder.DEFAULT_TENANT_ID, + ApiTestDataSeeder.DEFAULT_CLERK_ID, + start.format(DATE_TIME_FORMATTER), + end.format(DATE_TIME_FORMATTER)); + assertThat(adjustmentSum.setScale(2, RoundingMode.HALF_UP)).isEqualByComparingTo(new BigDecimal("-20.00")); + + String payloadInclude = "{" + + "\"includeSummary\":true," + + "\"includeRankings\":true," + + "\"includeAdjustments\":true," + + "\"limit\":5," + + "\"endOrderTime\":[\"" + start.format(DATE_TIME_FORMATTER) + "\",\"" + end.format(DATE_TIME_FORMATTER) + "\"]" + + "}"; + + MvcResult includeResult = mockMvc.perform(post("/statistics/performance/overview") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(payloadInclude)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode includeJson = objectMapper.readTree(includeResult.getResponse().getContentAsString()); + BigDecimal includeRevenue = new BigDecimal(includeJson.path("data").path("summary").path("totalEstimatedRevenue").asText("0")) + .setScale(2, RoundingMode.HALF_UP); + + // Base is 100.00 order revenue, -20.00 in-window adjustment; the out-of-window adjustment should NOT be counted. + assertThat(includeRevenue).isEqualByComparingTo(new BigDecimal("80.00")); + } + + private void seedOneOrderAndLines() { + LocalDateTime now = BASE_TIME.withNano(0); + + PlayOrderInfoEntity order = new PlayOrderInfoEntity(); + orderId = "order-stats-" + IdUtils.getUuid(); + order.setId(orderId); + order.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + order.setAcceptBy(ApiTestDataSeeder.DEFAULT_CLERK_ID); + order.setOrderNo("stats-" + IdUtils.getUuid()); + order.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode()); + order.setPurchaserBy(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + order.setPurchaserTime(now.minusMinutes(1)); + order.setPlaceType(OrderConstant.PlaceType.REWARD.getCode()); + order.setRefundType(OrderConstant.OrderRefundFlag.NOT_REFUNDED.getCode()); + order.setOrdersExpiredState(OrderConstant.OrdersExpiredState.NOT_EXPIRED.getCode()); + order.setOrderRelationType(OrderConstant.OrderRelationType.FIRST); + order.setFinalAmount(new BigDecimal("200.00")); + order.setEstimatedRevenue(new BigDecimal("100.00")); + order.setOrderEndTime(now); + order.setDeleted(false); + Date created = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + order.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + order.setCreatedTime(created); + order.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + order.setUpdatedTime(created); + orderInfoService.save(order); + + seedEarningsLine("earn-order", orderId, new BigDecimal("100.00"), EarningsType.ORDER, now.minusMinutes(1)); + + // Intended future behavior: adjustment lines are not ORDER-sourced and should only affect stats when includeAdjustments=true. + // This will FAIL until play_earnings_line supports orderId=null + sourceType/sourceId, and stats toggle is implemented. + seedEarningsLine("earn-adjustment", null, new BigDecimal("-20.00"), EarningsType.ADJUSTMENT, now.minusMinutes(1)); + } + + private String seedEarningsLine( + String prefix, String orderIdOrNull, BigDecimal amount, EarningsType type, LocalDateTime unlockTime) { + EarningsLineEntity entity = new EarningsLineEntity(); + String rawId = prefix + "-" + IdUtils.getUuid(); + String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32); + entity.setId(id); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + entity.setOrderId(orderIdOrNull); + if (orderIdOrNull == null) { + entity.setSourceType(EarningsSourceType.ADJUSTMENT); + entity.setSourceId("adj-stats-" + IdUtils.getUuid()); + } else { + entity.setSourceType(EarningsSourceType.ORDER); + entity.setSourceId(orderIdOrNull); + } + entity.setAmount(amount); + entity.setEarningType(type); + entity.setStatus("available"); + entity.setUnlockTime(unlockTime); + Date created = Date.from(BASE_TIME.atZone(ZoneId.systemDefault()).toInstant()); + entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setCreatedTime(created); + entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + entity.setUpdatedTime(created); + entity.setDeleted(false); + earningsService.save(entity); + earningLineIdsToCleanup.add(id); + return id; + } + + private void seedOutOfWindowAdjustmentLine() { + seedEarningsLine( + "earn-adjustment-outside", + null, + new BigDecimal("999.00"), + EarningsType.ADJUSTMENT, + BASE_TIME.minusDays(30)); + } + + private void setAuthentication() { + SysUserEntity user = new SysUserEntity(); + user.setUserId(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID); + user.setUserCode(ApiTestDataSeeder.DEFAULT_ADMIN_USERNAME); + user.setPassWord("apitest"); + user.setStatus(0); + user.setSuperAdmin(true); + user.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + + LoginUser loginUser = new LoginUser(); + loginUser.setUserId(user.getUserId()); + loginUser.setUserName(user.getUserCode()); + loginUser.setUser(user); + + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, java.util.Collections.emptyList())); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawAdjustmentIntegrationApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawAdjustmentIntegrationApiTest.java new file mode 100644 index 0000000..24b4f56 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawAdjustmentIntegrationApiTest.java @@ -0,0 +1,274 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; +import com.starry.admin.modules.weichat.service.WxTokenService; +import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; +import com.starry.admin.modules.withdraw.enums.EarningsSourceType; +import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService; +import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.modules.withdraw.service.IWithdrawalService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.constant.Constants; +import com.starry.common.context.CustomSecurityContextHolder; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +/** + * End-to-end Web/API tests that prove adjustments affect clerk withdraw balance and eligibility. + * + *

These tests are expected to FAIL until adjustments are implemented.

+ */ +class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest { + + private static final String IDEMPOTENCY_HEADER = "Idempotency-Key"; + private static final String PERMISSIONS_HEADER = "X-Test-Permissions"; + private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read"; + + @Autowired + private IEarningsService earningsService; + + @Autowired + private IWithdrawalService withdrawalService; + + @Autowired + private IEarningsAdjustmentService adjustmentService; + + @MockBean + private IClerkPayeeProfileService clerkPayeeProfileService; + + @Autowired + private IPlayClerkUserInfoService clerkUserInfoService; + + @Autowired + private WxTokenService wxTokenService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String clerkToken; + private ClerkPayeeProfileEntity payeeProfile; + private final List createdAdjustmentIds = new ArrayList<>(); + + @BeforeEach + void setUp() { + ensureTenantContext(); + + payeeProfile = new ClerkPayeeProfileEntity(); + payeeProfile.setId("payee-" + IdUtils.getUuid()); + payeeProfile.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + payeeProfile.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID); + payeeProfile.setChannel("ALIPAY_QR"); + payeeProfile.setQrCodeUrl("https://example.com/test-payee.png"); + payeeProfile.setDisplayName("API测试收款码"); + payeeProfile.setLastConfirmedAt(LocalDateTime.now()); + + Mockito.when(clerkPayeeProfileService.getByClerk( + ApiTestDataSeeder.DEFAULT_TENANT_ID, ApiTestDataSeeder.DEFAULT_CLERK_ID)) + .thenAnswer(invocation -> payeeProfile); + Mockito.when(clerkPayeeProfileService.updateById(Mockito.any(ClerkPayeeProfileEntity.class))) + .thenAnswer(invocation -> { + payeeProfile = invocation.getArgument(0); + return true; + }); + + clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID); + clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken); + } + + @AfterEach + void tearDown() { + ensureTenantContext(); + Mockito.reset(clerkPayeeProfileService); + cleanupAdjustments(); + CustomSecurityContextHolder.remove(); + } + + @Test + void appliedPositiveAdjustmentIncreasesWithdrawableBalance() throws Exception { + ensureTenantContext(); + BigDecimal before = fetchAvailableBalance(); + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"50.00\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"bonus\"" + + "}"; + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()); + + createdAdjustmentIds.add(awaitApplied(key)); + + BigDecimal after = fetchAvailableBalance(); + BigDecimal delta = after.subtract(before).setScale(2, RoundingMode.HALF_UP); + assertThat(delta).isEqualByComparingTo("50.00"); + } + + @Test + void appliedNegativeAdjustmentCanBlockWithdrawal() throws Exception { + ensureTenantContext(); + String key = UUID.randomUUID().toString(); + BigDecimal before = fetchAvailableBalance(); + BigDecimal amount = before.add(new BigDecimal("20.00")).negate().setScale(2, RoundingMode.HALF_UP); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"" + amount + "\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"penalty\"" + + "}"; + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()); + + createdAdjustmentIds.add(awaitApplied(key)); + + // Attempting to withdraw any positive amount should fail when net balance is negative. + mockMvc.perform(post("/wx/withdraw/requests") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\":10}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(500)); + } + + @Test + void earningsListIncludesAdjustmentLineAfterApplied() throws Exception { + ensureTenantContext(); + String key = UUID.randomUUID().toString(); + String payload = "{" + + "\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," + + "\"amount\":\"12.34\"," + + "\"reasonType\":\"MANUAL\"," + + "\"reasonDescription\":\"show in list\"" + + "}"; + + mockMvc.perform(post("/admin/earnings/adjustments") + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ) + .header(IDEMPOTENCY_HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isAccepted()); + + createdAdjustmentIds.add(awaitApplied(key)); + + MvcResult earnings = mockMvc.perform(get("/wx/withdraw/earnings") + .param("pageNum", "1") + .param("pageSize", "10") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").isArray()) + .andReturn(); + + JsonNode root = objectMapper.readTree(earnings.getResponse().getContentAsString()); + JsonNode rows = root.path("data"); + boolean found = false; + if (rows.isArray()) { + for (JsonNode row : rows) { + if ("ADJUSTMENT".equals(row.path("earningType").asText()) + && "12.34".equals(row.path("amount").asText())) { + found = true; + break; + } + } + } + assertThat(found).isTrue(); + } + + private BigDecimal fetchAvailableBalance() throws Exception { + MvcResult balance = mockMvc.perform(get("/wx/withdraw/balance") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn(); + + JsonNode root = objectMapper.readTree(balance.getResponse().getContentAsString()); + return root.path("data").path("available").decimalValue().setScale(2, RoundingMode.HALF_UP); + } + + private String awaitApplied(String idempotencyKey) throws Exception { + for (int i = 0; i < 80; i++) { + MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey) + .header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)) + .andExpect(status().isOk()) + .andReturn(); + JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString()); + String status = root.path("data").path("status").asText(); + if ("APPLIED".equals(status)) { + return root.path("data").path("adjustmentId").asText(); + } + Thread.sleep(50); + } + throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey); + } + + private void ensureTenantContext() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } + + private void cleanupAdjustments() { + if (createdAdjustmentIds.isEmpty()) { + return; + } + earningsService.lambdaUpdate() + .eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID) + .eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) + .eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT) + .in(EarningsLineEntity::getSourceId, createdAdjustmentIds) + .remove(); + adjustmentService.removeByIds(createdAdjustmentIds); + createdAdjustmentIds.clear(); + + withdrawalService.lambdaUpdate() + .eq(WithdrawalRequestEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID) + .eq(WithdrawalRequestEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID) + .remove(); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java index 5eaae14..f239cd4 100644 --- a/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/WxWithdrawControllerApiTest.java @@ -29,6 +29,10 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -295,6 +299,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest { .andExpect(jsonPath("$.data[1]").doesNotExist()); } + @Test + void concurrentWithdrawRequestsCompeteForSameEarningsLines() throws Exception { + ensureTenantContext(); + String firstLine = insertEarningsLine( + "concurrent-one", + new BigDecimal("50.00"), + EarningsStatus.AVAILABLE, + LocalDateTime.now().minusDays(1)); + String secondLine = insertEarningsLine( + "concurrent-two", + new BigDecimal("30.00"), + EarningsStatus.AVAILABLE, + LocalDateTime.now().minusHours(2)); + earningsToCleanup.add(firstLine); + earningsToCleanup.add(secondLine); + + refreshPayeeConfirmation(); + + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Callable create = () -> { + MvcResult result = mockMvc.perform(post("/wx/withdraw/requests") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\":80}")) + .andExpect(status().isOk()) + .andReturn(); + JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); + return root.path("code").asInt(); + }; + + Future a = pool.submit(create); + Future b = pool.submit(create); + + int codeA = a.get(); + int codeB = b.get(); + assertThat(codeA == 200 || codeA == 500).isTrue(); + assertThat(codeB == 200 || codeB == 500).isTrue(); + assertThat(codeA + codeB).isEqualTo(700); + } finally { + pool.shutdownNow(); + } + } + private String insertEarningsLine( String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) { return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER); diff --git a/play-admin/src/test/java/com/starry/admin/db/EarningsAdjustmentsDatabaseSchemaApiTest.java b/play-admin/src/test/java/com/starry/admin/db/EarningsAdjustmentsDatabaseSchemaApiTest.java new file mode 100644 index 0000000..ce87a69 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/db/EarningsAdjustmentsDatabaseSchemaApiTest.java @@ -0,0 +1,77 @@ +package com.starry.admin.db; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.starry.admin.api.AbstractApiTest; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Database-level contract tests for the adjustment system schema. + * + *

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

+ */ +class EarningsAdjustmentsDatabaseSchemaApiTest extends AbstractApiTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void earningsLineHasSourceColumnsAndOrderIdIsNullable() { + assertThat(columnExists("play_earnings_line", "source_type")).isTrue(); + assertThat(columnExists("play_earnings_line", "source_id")).isTrue(); + + Map orderIdMeta = jdbcTemplate.queryForMap( + "select is_nullable as nullable " + + "from information_schema.columns " + + "where lower(table_name)=lower(?) and lower(column_name)=lower(?)", + "play_earnings_line", + "order_id"); + String nullable = String.valueOf(orderIdMeta.get("nullable")); + assertThat(nullable).isIn("YES", "yes", "Y", "y", "1", "TRUE", "true"); + } + + @Test + void adjustmentTableExistsAndHasIdempotencyFields() { + assertThat(tableExists("play_earnings_line_adjustment")).isTrue(); + assertThat(columnExists("play_earnings_line_adjustment", "idempotency_key")).isTrue(); + assertThat(columnExists("play_earnings_line_adjustment", "request_hash")).isTrue(); + assertThat(columnExists("play_earnings_line_adjustment", "status")).isTrue(); + assertThat(columnExists("play_earnings_line_adjustment", "reason_type")).isTrue(); + assertThat(columnExists("play_earnings_line_adjustment", "reason_description")).isTrue(); + assertThat(columnExists("play_earnings_line_adjustment", "effective_time")).isTrue(); + } + + @Test + void uniqueIndexExistsForTenantIdempotencyKey() { + // Lock the index name so future migrations are deterministic. + Integer count = jdbcTemplate.queryForObject( + "select count(*) " + + "from information_schema.statistics " + + "where lower(table_name)=lower(?) and lower(index_name)=lower(?)", + Integer.class, + "play_earnings_line_adjustment", + "uk_tenant_idempotency"); + assertThat(count).isNotNull(); + assertThat(count).isGreaterThan(0); + } + + private boolean tableExists(String table) { + Integer count = jdbcTemplate.queryForObject( + "select count(*) from information_schema.tables where lower(table_name)=lower(?)", + Integer.class, + table); + return count != null && count > 0; + } + + private boolean columnExists(String table, String column) { + Integer count = jdbcTemplate.queryForObject( + "select count(*) from information_schema.columns where lower(table_name)=lower(?) and lower(column_name)=lower(?)", + Integer.class, + table, + column); + return count != null && count > 0; + } +} diff --git a/play-admin/src/test/java/com/starry/admin/modules/withdraw/contract/EarningsAdjustmentSchemaContractTest.java b/play-admin/src/test/java/com/starry/admin/modules/withdraw/contract/EarningsAdjustmentSchemaContractTest.java new file mode 100644 index 0000000..6454296 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/withdraw/contract/EarningsAdjustmentSchemaContractTest.java @@ -0,0 +1,47 @@ +package com.starry.admin.modules.withdraw.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +/** + * Unit-level contract tests that lock the intended public schema/API surface. + * + *

These tests are expected to FAIL until the adjustment system is implemented.

+ */ +class EarningsAdjustmentSchemaContractTest { + + @Test + void earningsLineEntityExposesSourceTypeAndSourceId() throws Exception { + assertHasField(EarningsLineEntity.class, "sourceType"); + assertHasField(EarningsLineEntity.class, "sourceId"); + } + + @Test + void adjustmentEntityAndEnumExist() { + assertDoesNotThrow(() -> { + Class entity = Class.forName("com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity"); + assertHasField(entity, "id"); + assertHasField(entity, "tenantId"); + assertHasField(entity, "clerkId"); + assertHasField(entity, "amount"); + assertHasField(entity, "reasonType"); + assertHasField(entity, "reasonDescription"); + assertHasField(entity, "idempotencyKey"); + assertHasField(entity, "requestHash"); + assertHasField(entity, "status"); + assertHasField(entity, "effectiveTime"); + assertHasField(entity, "appliedTime"); + }); + assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType")); + assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus")); + } + + private void assertHasField(Class clazz, String fieldName) throws Exception { + Field field = clazz.getDeclaredField(fieldName); + assertThat(field).isNotNull(); + } +} diff --git a/play-common/src/main/java/com/starry/common/result/TypedR.java b/play-common/src/main/java/com/starry/common/result/TypedR.java index f3d90fe..dc9a685 100644 --- a/play-common/src/main/java/com/starry/common/result/TypedR.java +++ b/play-common/src/main/java/com/starry/common/result/TypedR.java @@ -52,6 +52,10 @@ public class TypedR implements Serializable { return new TypedR<>(ResultCodeEnum.SUCCESS.getCode(), true, msg, data); } + public static TypedR accepted(T data) { + return new TypedR<>(202, true, "请求处理中", data); + } + /** * Build a list response from MyBatis-Plus page while flattening records/total/pageInfo. */