Add earnings adjustments, withdrawal reject, and auth guard

This commit is contained in:
irving
2026-01-12 12:46:42 -05:00
parent d335c577d3
commit 56239450d4
34 changed files with 3117 additions and 22 deletions

View File

@@ -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 entitys `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: <uuid>` (required)
- `X-Tenant: <tenantId>` (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`)

View File

@@ -44,6 +44,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -466,7 +467,14 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setCommodityName(commodityName); entity.setCommodityName(commodityName);
entity.setEnablingState("1"); entity.setEnablingState("1");
entity.setSort(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); 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.setState(GIFT_STATE_ACTIVE);
entity.setListingTime(LocalDateTime.now()); entity.setListingTime(LocalDateTime.now());
entity.setRemark("Seeded gift for API tests"); 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); log.info("Inserted API test gift {}", DEFAULT_GIFT_ID);
} }
@@ -564,7 +577,24 @@ public class ApiTestDataSeeder implements CommandLineRunner {
entity.setRegistrationTime(new Date()); entity.setRegistrationTime(new Date());
entity.setLastLoginTime(new Date()); entity.setLastLoginTime(new Date());
entity.setToken(token); 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); log.info("Inserted API test customer {}", DEFAULT_CUSTOMER_ID);
} }
} }

View File

@@ -42,7 +42,10 @@ public class PermissionService {
} }
LoginUser loginUser = SecurityUtils.getLoginUser(); LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) { 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); return hasPermissions(loginUser.getPermissions(), permission);
} }
@@ -70,7 +73,13 @@ public class PermissionService {
return false; return false;
} }
LoginUser loginUser = SecurityUtils.getLoginUser(); 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; return false;
} }
Set<String> authorities = loginUser.getPermissions(); Set<String> authorities = loginUser.getPermissions();

View File

@@ -8,7 +8,11 @@ import com.starry.common.utils.StringUtils;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException; 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.BindingResult;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException;
@@ -26,6 +30,16 @@ public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
public static final String PARAMETER_FORMAT_ERROR = "请求参数格式异常"; public static final String PARAMETER_FORMAT_ERROR = "请求参数格式异常";
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<R> handleAccessDenied(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(R.error(403, e.getMessage()));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<R> handleAuthentication(AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(R.error(401, e.getMessage()));
}
/** /**
* 业务异常 * 业务异常
*/ */

View File

@@ -6,10 +6,14 @@ import com.starry.admin.modules.system.module.entity.SysUserEntity;
import com.starry.common.constant.SecurityConstants; import com.starry.common.constant.SecurityConstants;
import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.context.CustomSecurityContextHolder;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.HashSet; import java.util.HashSet;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import javax.servlet.FilterChain; import javax.servlet.FilterChain;
import javax.servlet.ServletException; import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -24,6 +28,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
public class ApiTestAuthenticationFilter extends OncePerRequestFilter { public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
private final ApiTestSecurityProperties properties; 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) { public ApiTestAuthenticationFilter(ApiTestSecurityProperties properties) {
this.properties = properties; this.properties = properties;
@@ -32,6 +38,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
Map<String, Object> originalContext = new ConcurrentHashMap<>(CustomSecurityContextHolder.getLocalMap());
String requestedUser = request.getHeader(properties.getUserHeader()); String requestedUser = request.getHeader(properties.getUserHeader());
String requestedTenant = request.getHeader(properties.getTenantHeader()); String requestedTenant = request.getHeader(properties.getTenantHeader());
@@ -48,6 +55,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
try { try {
LoginUser loginUser = buildLoginUser(userId, tenantId); LoginUser loginUser = buildLoginUser(userId, tenantId);
applyOverridesFromHeaders(request, loginUser);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null,
Collections.emptyList()); Collections.emptyList());
@@ -61,7 +69,7 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} finally { } finally {
CustomSecurityContextHolder.remove(); CustomSecurityContextHolder.setLocalMap(originalContext);
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
} }
} }
@@ -93,4 +101,27 @@ public class ApiTestAuthenticationFilter extends OncePerRequestFilter {
return loginUser; 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<String> perms = Arrays.stream(permissionsHeader.split(","))
.map(String::trim)
.filter(StringUtils::hasText)
.collect(Collectors.toSet());
loginUser.setPermissions(perms);
CustomSecurityContextHolder.setPermission(String.join(",", perms));
}
} }

View File

@@ -31,6 +31,9 @@ public class ClerkPerformanceOverviewQueryVo extends PlayClerkPerformanceInfoQue
@ApiModelProperty(value = "是否包含排行列表数据") @ApiModelProperty(value = "是否包含排行列表数据")
private Boolean includeRankings = Boolean.TRUE; private Boolean includeRankings = Boolean.TRUE;
@ApiModelProperty(value = "是否包含收益调整ADJUSTMENT", allowableValues = "true,false")
private Boolean includeAdjustments = Boolean.FALSE;
@Override @Override
public void setEndOrderTime(List<String> endOrderTime) { public void setEndOrderTime(List<String> endOrderTime) {
super.setEndOrderTime(endOrderTime); super.setEndOrderTime(endOrderTime);

View File

@@ -142,6 +142,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) { public ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo) {
DateRange range = resolveDateRange(vo.getEndOrderTime()); DateRange range = resolveDateRange(vo.getEndOrderTime());
List<PlayClerkUserInfoEntity> clerks = loadAccessibleClerks(vo); List<PlayClerkUserInfoEntity> clerks = loadAccessibleClerks(vo);
boolean includeAdjustments = Boolean.TRUE.equals(vo.getIncludeAdjustments());
ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo(); ClerkPerformanceOverviewResponseVo responseVo = new ClerkPerformanceOverviewResponseVo();
if (CollectionUtil.isEmpty(clerks)) { if (CollectionUtil.isEmpty(clerks)) {
responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo()); responseVo.setSummary(new ClerkPerformanceOverviewSummaryVo());
@@ -158,7 +159,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
for (PlayClerkUserInfoEntity clerk : clerks) { for (PlayClerkUserInfoEntity clerk : clerks) {
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(), List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
range.startTime, range.endTime); 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(); int total = snapshots.size();
ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots); ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots);
@@ -194,7 +195,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(), List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
range.startTime, range.endTime); range.startTime, range.endTime);
ClerkPerformanceSnapshotVo snapshot = 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(); ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo();
responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap)); responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap));
responseVo.setSnapshot(snapshot); responseVo.setSnapshot(snapshot);
@@ -424,22 +425,42 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
private BigDecimal calculateEarningsAmount(String clerkId, List<PlayOrderInfoEntity> orders, String startTime, private BigDecimal calculateEarningsAmount(String clerkId, List<PlayOrderInfoEntity> orders, String startTime,
String endTime) { String endTime) {
if (StrUtil.isBlank(clerkId) || CollectionUtil.isEmpty(orders)) { return calculateEarningsAmount(SecurityUtils.getTenantId(), clerkId, orders, startTime, endTime, false);
return BigDecimal.ZERO; }
}
List<String> orderIds = orders.stream() private BigDecimal calculateEarningsAmount(
.filter(this::isCompletedOrder) String tenantId,
.map(PlayOrderInfoEntity::getId) String clerkId,
.filter(StrUtil::isNotBlank) List<PlayOrderInfoEntity> orders,
.collect(Collectors.toList()); String startTime,
if (CollectionUtil.isEmpty(orderIds)) { String endTime,
boolean includeAdjustments) {
if (StrUtil.isBlank(clerkId)) {
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime); String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime);
String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime); String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime);
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
normalizedEnd); BigDecimal orderSum = BigDecimal.ZERO;
return defaultZero(sum); if (CollectionUtil.isNotEmpty(orders)) {
List<String> 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) { private OrderConstant.OrderRelationType normalizeRelationType(PlayOrderInfoEntity order) {
@@ -460,7 +481,8 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
} }
private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List<PlayOrderInfoEntity> orders, private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List<PlayOrderInfoEntity> orders,
Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime) { Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime,
boolean includeAdjustments) {
ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo(); ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo();
snapshot.setClerkId(clerk.getId()); snapshot.setClerkId(clerk.getId());
snapshot.setClerkNickname(clerk.getNickname()); snapshot.setClerkNickname(clerk.getNickname());
@@ -513,7 +535,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
} }
int userCount = userIds.size(); int userCount = userIds.size();
int continuedUserCount = continuedUserIds.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.setGmv(gmv);
snapshot.setFirstOrderAmount(firstAmount); snapshot.setFirstOrderAmount(firstAmount);
snapshot.setContinuedOrderAmount(continuedAmount); snapshot.setContinuedOrderAmount(continuedAmount);

View File

@@ -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<TypedR<AdjustmentStatusResponse>> 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<AdjustmentStatusResponse> 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<TypedR<AdjustmentStatusResponse>> 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));
}
}

View File

@@ -43,6 +43,9 @@ import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.annotation.Resource; 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.*; import org.springframework.web.bind.annotation.*;
@Api(tags = "提现管理-后台") @Api(tags = "提现管理-后台")
@@ -195,6 +198,36 @@ public class AdminWithdrawalController {
return TypedR.ok(vos); 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<TypedR<Void>> 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("分页查询收益明细") @ApiOperation("分页查询收益明细")
@PostMapping("/earnings/listByPage") @PostMapping("/earnings/listByPage")
public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) { public TypedR<List<EarningsLineEntity>> listEarnings(@RequestBody EarningsAdminQueryVo vo) {

View File

@@ -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<EarningsLineAdjustmentEntity> {
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;
}

View File

@@ -2,6 +2,7 @@ package com.starry.admin.modules.withdraw.entity;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat; 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.admin.modules.withdraw.enums.EarningsType;
import com.starry.common.domain.BaseEntity; import com.starry.common.domain.BaseEntity;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -18,6 +19,15 @@ public class EarningsLineEntity extends BaseEntity<EarningsLineEntity> {
private String tenantId; private String tenantId;
private String clerkId; private String clerkId;
private String orderId; private String orderId;
/**
* Source identity for ledger line.
*
* <p>ORDER: sourceId == orderId (non-null).
* <p>ADJUSTMENT: sourceId == adjustmentId, and orderId should be null.
*/
private EarningsSourceType sourceType;
private String sourceId;
private BigDecimal amount; private BigDecimal amount;
private EarningsType earningType; private EarningsType earningType;

View File

@@ -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;
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
package com.starry.admin.modules.withdraw.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EarningsLineAdjustmentMapper extends BaseMapper<EarningsLineAdjustmentEntity> {}

View File

@@ -59,4 +59,24 @@ public interface EarningsLineMapper extends BaseMapper<EarningsLineEntity> {
@Param("orderIds") Collection<String> orderIds, @Param("orderIds") Collection<String> orderIds,
@Param("startTime") String startTime, @Param("startTime") String startTime,
@Param("endTime") String endTime); @Param("endTime") String endTime);
@Select("<script>" +
"SELECT COALESCE(SUM(amount), 0) " +
"FROM play_earnings_line " +
"WHERE deleted = 0 " +
" AND tenant_id = #{tenantId} " +
" AND clerk_id = #{clerkId} " +
" AND source_type = 'ADJUSTMENT' " +
" AND source_id IS NOT NULL " +
"<if test='startTime != null'>" +
" AND unlock_time &gt;= #{startTime}" +
"</if>" +
"<if test='endTime != null'>" +
" AND unlock_time &lt;= #{endTime}" +
"</if>" +
"</script>")
BigDecimal sumAdjustmentsByClerk(@Param("tenantId") String tenantId,
@Param("clerkId") String clerkId,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
} }

View File

@@ -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());
}
}

View File

@@ -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> {
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);
}

View File

@@ -10,4 +10,6 @@ public interface IWithdrawalService extends IService<WithdrawalRequestEntity> {
void markManualSuccess(String requestId, String operatorBy); void markManualSuccess(String requestId, String operatorBy);
void autoPayout(String requestId); void autoPayout(String requestId);
void reject(String requestId, String reason);
} }

View File

@@ -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<EarningsLineAdjustmentMapper, EarningsLineAdjustmentEntity>
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();
}
}

View File

@@ -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.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; 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.enums.EarningsType;
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper; import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IEarningsService;
@@ -48,6 +49,8 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
line.setTenantId(orderInfo.getTenantId()); line.setTenantId(orderInfo.getTenantId());
line.setClerkId(orderInfo.getAcceptBy()); line.setClerkId(orderInfo.getAcceptBy());
line.setOrderId(orderInfo.getId()); line.setOrderId(orderInfo.getId());
line.setSourceType(EarningsSourceType.ORDER);
line.setSourceId(orderInfo.getId());
line.setAmount(amount); line.setAmount(amount);
line.setEarningType(EarningsType.ORDER); line.setEarningType(EarningsType.ORDER);
line.setUnlockTime(unlockTime); line.setUnlockTime(unlockTime);
@@ -140,6 +143,8 @@ public class EarningsServiceImpl extends ServiceImpl<EarningsLineMapper, Earning
line.setOrderId(orderId); line.setOrderId(orderId);
line.setTenantId(tenantId); line.setTenantId(tenantId);
line.setClerkId(targetClerkId); line.setClerkId(targetClerkId);
line.setSourceType(EarningsSourceType.ORDER);
line.setSourceId(orderId);
line.setAmount(normalized.negate()); line.setAmount(normalized.negate());
line.setEarningType(EarningsType.ADJUSTMENT); line.setEarningType(EarningsType.ADJUSTMENT);
line.setStatus(resolvedStatus); line.setStatus(resolvedStatus);

View File

@@ -160,6 +160,43 @@ public class WithdrawalServiceImpl extends ServiceImpl<WithdrawalRequestMapper,
throw new UnsupportedOperationException("Alipay payout not implemented"); throw new UnsupportedOperationException("Alipay payout not implemented");
} }
@Override
@Transactional(rollbackFor = Exception.class)
public void reject(String requestId, String reason) {
WithdrawalRequestEntity req = this.getById(requestId);
if (req == null) throw new CustomException("请求不存在");
if ("success".equals(req.getStatus())) {
throw new CustomException("已成功的提现不可拒绝");
}
if ("canceled".equals(req.getStatus()) || "rejected".equals(req.getStatus())) {
return;
}
WithdrawalRequestEntity update = new WithdrawalRequestEntity();
update.setId(req.getId());
update.setStatus("canceled");
update.setFailureReason(StringUtils.hasText(reason) ? reason : "rejected");
this.updateById(update);
LocalDateTime now = LocalDateTime.now();
List<EarningsLineEntity> lines = earningsService.lambdaQuery()
.eq(EarningsLineEntity::getWithdrawalId, req.getId())
.eq(EarningsLineEntity::getStatus, "withdrawing")
.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) { private String buildPayeeSnapshot(ClerkPayeeProfileEntity profile, LocalDateTime confirmedAt) {
PayeeSnapshotVo snapshot = new PayeeSnapshotVo(); PayeeSnapshotVo snapshot = new PayeeSnapshotVo();
snapshot.setChannel(profile.getChannel()); snapshot.setChannel(profile.getChannel());

View File

@@ -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 '订单IDsource_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 '来源IDsource_type=ORDER 时等于 order_idsource_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 '幂等Keytenant范围内唯一为空则不幂等',
`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统一抽象';

View File

@@ -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.
*
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
*/
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<String> idempotencyKeysToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = new ArrayList<>();
private final List<String> 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);
}
}

View File

@@ -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).
*
* <p>These tests are expected to FAIL until the adjustment system is implemented end-to-end.</p>
*/
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<String> 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<String> 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<Future<String>> futures = new ArrayList<>();
futures.add(pool.submit(call));
futures.add(pool.submit(call));
String a = futures.get(0).get();
String b = futures.get(1).get();
assertThat(a).isNotBlank();
assertThat(b).isEqualTo(a);
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<String> 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);
}
}

View File

@@ -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.
*
* <p>These tests are expected to FAIL until earnings lines support orderId=null + sourceType/sourceId,
* and audit serialization handles mixed sources.</p>
*/
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<String> 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;
}
}

View File

@@ -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).
*
* <p>These tests are expected to FAIL until withdrawal reject is implemented.</p>
*/
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<String> 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;
}
}

View File

@@ -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.
*
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
*/
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<String> withdrawalIdsToCleanup = new ArrayList<>();
private final List<String> earningLineIdsToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = new ArrayList<>();
private final List<String> 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;
}
}

View File

@@ -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.
*
* <p>These tests are expected to FAIL until the stats endpoint supports includeAdjustments.</p>
*/
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<String> 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()));
}
}

View File

@@ -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.
*
* <p>These tests are expected to FAIL until adjustments are implemented.</p>
*/
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<String> 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();
}
}

View File

@@ -29,6 +29,10 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.List; 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.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -295,6 +299,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.data[1]").doesNotExist()); .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<Integer> 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<Integer> a = pool.submit(create);
Future<Integer> 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( private String insertEarningsLine(
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) { String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER); return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);

View File

@@ -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.
*
* <p>These tests are expected to FAIL until migrations add the new table/columns/indexes.</p>
*/
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<String, Object> 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;
}
}

View File

@@ -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.
*
* <p>These tests are expected to FAIL until the adjustment system is implemented.</p>
*/
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();
}
}

View File

@@ -52,6 +52,10 @@ public class TypedR<T> implements Serializable {
return new TypedR<>(ResultCodeEnum.SUCCESS.getCode(), true, msg, data); return new TypedR<>(ResultCodeEnum.SUCCESS.getCode(), true, msg, data);
} }
public static <T> TypedR<T> accepted(T data) {
return new TypedR<>(202, true, "请求处理中", data);
}
/** /**
* Build a list response from MyBatis-Plus page while flattening records/total/pageInfo. * Build a list response from MyBatis-Plus page while flattening records/total/pageInfo.
*/ */