Add earnings adjustments, withdrawal reject, and auth guard
This commit is contained in:
177
llm/earnings-adjustments-and-withdrawal-reject.md
Normal file
177
llm/earnings-adjustments-and-withdrawal-reject.md
Normal 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 entity’s `tenantId`, the API returns **HTTP 404** (do not leak existence across tenants).
|
||||||
|
|
||||||
|
## Admin Earnings Adjustments API
|
||||||
|
|
||||||
|
### Create Adjustment
|
||||||
|
|
||||||
|
`POST /admin/earnings/adjustments`
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
|
||||||
|
- `Idempotency-Key: <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`)
|
||||||
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 业务异常
|
* 业务异常
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {}
|
||||||
@@ -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 >= #{startTime}" +
|
||||||
|
"</if>" +
|
||||||
|
"<if test='endTime != null'>" +
|
||||||
|
" AND unlock_time <= #{endTime}" +
|
||||||
|
"</if>" +
|
||||||
|
"</script>")
|
||||||
|
BigDecimal sumAdjustmentsByClerk(@Param("tenantId") String tenantId,
|
||||||
|
@Param("clerkId") String clerkId,
|
||||||
|
@Param("startTime") String startTime,
|
||||||
|
@Param("endTime") String endTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- Earnings adjustments: introduce generic adjustment header table + source identity for earnings lines.
|
||||||
|
|
||||||
|
-- 1) Extend ledger to support non-order sources (e.g. admin adjustments / rewards / punishments).
|
||||||
|
-- We keep order_id for ORDER source for backward compatibility, but allow it to be NULL for adjustment lines.
|
||||||
|
|
||||||
|
-- Drop legacy uniqueness keyed by order_id, since order_id can now be NULL and we want uniqueness by source identity.
|
||||||
|
ALTER TABLE `play_earnings_line`
|
||||||
|
DROP INDEX `uk_tenant_order_clerk_type`;
|
||||||
|
|
||||||
|
ALTER TABLE `play_earnings_line`
|
||||||
|
MODIFY COLUMN `order_id` varchar(32) NULL COMMENT '订单ID(source_type=ORDER 时必填)',
|
||||||
|
ADD COLUMN `source_type` varchar(16) NOT NULL DEFAULT 'ORDER' COMMENT '来源类型(ORDER/ADJUSTMENT)' AFTER `order_id`,
|
||||||
|
ADD COLUMN `source_id` varchar(32) DEFAULT NULL COMMENT '来源ID(source_type=ORDER 时等于 order_id;source_type=ADJUSTMENT 时等于 adjustment_id)' AFTER `source_type`;
|
||||||
|
|
||||||
|
-- Backfill existing rows to ORDER source.
|
||||||
|
UPDATE `play_earnings_line`
|
||||||
|
SET `source_id` = `order_id`
|
||||||
|
WHERE (`source_id` IS NULL)
|
||||||
|
AND `order_id` IS NOT NULL
|
||||||
|
AND `deleted` = 0;
|
||||||
|
|
||||||
|
-- New uniqueness: one line per source identity per clerk/type (ignoring logical delete).
|
||||||
|
ALTER TABLE `play_earnings_line`
|
||||||
|
ADD UNIQUE KEY `uk_tenant_source_clerk_type` (`tenant_id`, `source_type`, `source_id`, `clerk_id`, `earning_type`, `deleted`);
|
||||||
|
|
||||||
|
-- 2) Adjustment header table (idempotent, async lifecycle).
|
||||||
|
CREATE TABLE IF NOT EXISTS `play_earnings_line_adjustment` (
|
||||||
|
`id` varchar(32) NOT NULL COMMENT 'UUID',
|
||||||
|
`tenant_id` varchar(32) NOT NULL COMMENT '租户ID',
|
||||||
|
`clerk_id` varchar(32) NOT NULL COMMENT '店员ID',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(可正可负,不允许0)',
|
||||||
|
`reason_type` varchar(32) NOT NULL COMMENT '原因类型(Enum, hard-coded for now)',
|
||||||
|
`reason_description` varchar(512) NOT NULL COMMENT '原因描述(操作时输入)',
|
||||||
|
`status` varchar(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '状态:PROCESSING/APPLIED/FAILED',
|
||||||
|
`idempotency_key` varchar(64) DEFAULT NULL COMMENT '幂等Key(tenant范围内唯一;为空则不幂等)',
|
||||||
|
`request_hash` varchar(64) NOT NULL DEFAULT '' COMMENT '请求摘要(用于检测同key不同body)',
|
||||||
|
`effective_time` datetime NOT NULL COMMENT '生效时间(用于统计窗口;默认 now)',
|
||||||
|
`applied_time` datetime DEFAULT NULL COMMENT '落账完成时间',
|
||||||
|
`failure_reason` varchar(512) DEFAULT NULL COMMENT '失败原因(FAILED 时)',
|
||||||
|
`created_by` varchar(32) DEFAULT NULL,
|
||||||
|
`created_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_by` varchar(32) DEFAULT NULL,
|
||||||
|
`updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除',
|
||||||
|
`version` int NOT NULL DEFAULT '1' COMMENT '数据版本',
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
UNIQUE KEY `uk_tenant_idempotency` (`tenant_id`, `idempotency_key`, `deleted`) USING BTREE,
|
||||||
|
KEY `idx_adjustment_tenant_clerk_time` (`tenant_id`, `clerk_id`, `effective_time`) USING BTREE,
|
||||||
|
KEY `idx_adjustment_tenant_status` (`tenant_id`, `status`) USING BTREE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='收益调整(Reward/Punishment/Correction统一抽象)';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user