Add earnings adjustments, withdrawal reject, and auth guard

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,227 @@
package com.starry.admin.api;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
import com.starry.admin.modules.personnel.service.IPlayPersonnelGroupInfoService;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
import com.starry.admin.modules.withdraw.enums.EarningsType;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
/**
* Authorization contract tests for rejecting withdrawals.
*
* <p>Expected to fail until permissions + group-leader scope are enforced for new endpoints.</p>
*/
class AdminWithdrawalRejectAuthorizationApiTest extends AbstractApiTest {
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
private static final String SUPER_ADMIN_HEADER = "X-Test-Super-Admin";
private static final String PERMISSION_WITHDRAWAL_REJECT = "withdraw:request:reject";
@Autowired
private IEarningsService earningsService;
@Autowired
private IWithdrawalService withdrawalService;
@Autowired
private IPlayPersonnelGroupInfoService groupInfoService;
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
private final List<String> withdrawalIdsToCleanup = new ArrayList<>();
private final List<String> earningLineIdsToCleanup = new ArrayList<>();
private final List<String> clerkIdsToCleanup = new ArrayList<>();
private final List<String> groupIdsToCleanup = new ArrayList<>();
@BeforeEach
void setUp() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
withdrawalIdsToCleanup.clear();
earningLineIdsToCleanup.clear();
clerkIdsToCleanup.clear();
groupIdsToCleanup.clear();
}
@AfterEach
void tearDown() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
if (!withdrawalIdsToCleanup.isEmpty()) {
withdrawalService.removeByIds(withdrawalIdsToCleanup);
}
if (!earningLineIdsToCleanup.isEmpty()) {
earningsService.removeByIds(earningLineIdsToCleanup);
}
if (!clerkIdsToCleanup.isEmpty()) {
clerkUserInfoService.removeByIds(clerkIdsToCleanup);
}
if (!groupIdsToCleanup.isEmpty()) {
groupInfoService.removeByIds(groupIdsToCleanup);
}
}
@Test
void rejectWithoutPermissionReturns403() throws Exception {
String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID);
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"auth-test\"}"))
.andExpect(status().isForbidden());
}
@Test
void rejectLeaderWithPermissionCannotManageOtherGroupReturns403() throws Exception {
String otherClerkId = seedOtherGroupClerk();
String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId);
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"auth-test\"}"))
.andExpect(status().isForbidden());
}
@Test
void rejectSuperAdminBypassesPermissionAndScopeReturns200() throws Exception {
String otherClerkId = seedOtherGroupClerk();
String withdrawalId = seedWithdrawingWithdrawalAndLines(otherClerkId);
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
.header(USER_HEADER, "apitest-superadmin-" + IdUtils.getUuid())
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(SUPER_ADMIN_HEADER, "true")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"auth-test\"}"))
.andExpect(status().isOk());
}
@Test
void crossTenantRejectReturns404EvenWithPermission() throws Exception {
String withdrawalId = seedWithdrawingWithdrawalAndLines(ApiTestDataSeeder.DEFAULT_CLERK_ID);
mockMvc.perform(post("/admin/withdraw/requests/" + withdrawalId + "/reject")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, "tenant-other")
.header(PERMISSIONS_HEADER, PERMISSION_WITHDRAWAL_REJECT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"auth-test\"}"))
.andExpect(status().isNotFound());
}
private String seedWithdrawingWithdrawalAndLines(String clerkId) {
WithdrawalRequestEntity req = new WithdrawalRequestEntity();
String withdrawalId = "withdraw-auth-" + IdUtils.getUuid();
req.setId(withdrawalId);
req.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
req.setClerkId(clerkId);
req.setAmount(new BigDecimal("80.00"));
req.setFee(BigDecimal.ZERO);
req.setNetAmount(req.getAmount());
req.setDestAccount("alipay:test");
req.setStatus("pending");
req.setPayeeSnapshot("{\"displayName\":\"reject-test\"}");
Date now = new Date();
req.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
req.setCreatedTime(now);
req.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
req.setUpdatedTime(now);
withdrawalService.save(req);
withdrawalIdsToCleanup.add(withdrawalId);
earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "a", new BigDecimal("50.00")));
earningLineIdsToCleanup.add(seedLine(withdrawalId, clerkId, "b", new BigDecimal("30.00")));
return withdrawalId;
}
private String seedLine(String withdrawalId, String clerkId, String suffix, BigDecimal amount) {
EarningsLineEntity entity = new EarningsLineEntity();
String rawId = "earn-auth-reject-" + suffix + "-" + IdUtils.getUuid();
String id = rawId.length() <= 32 ? rawId : rawId.substring(rawId.length() - 32);
entity.setId(id);
entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
entity.setClerkId(clerkId);
entity.setOrderId("order-auth-reject-" + IdUtils.getUuid());
entity.setAmount(amount);
entity.setEarningType(amount.compareTo(BigDecimal.ZERO) >= 0 ? EarningsType.ORDER : EarningsType.ADJUSTMENT);
entity.setStatus("withdrawing");
entity.setWithdrawalId(withdrawalId);
entity.setUnlockTime(LocalDateTime.now().minusDays(1));
Date now = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());
entity.setCreatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setCreatedTime(now);
entity.setUpdatedBy(ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID);
entity.setUpdatedTime(now);
entity.setDeleted(false);
earningsService.save(entity);
return id;
}
private String seedOtherGroupClerk() {
String groupId = "group-auth-withdraw-" + IdUtils.getUuid();
PlayPersonnelGroupInfoEntity group = new PlayPersonnelGroupInfoEntity();
group.setId(groupId);
group.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
group.setSysUserId("leader-auth-" + IdUtils.getUuid());
group.setSysUserCode("leader-auth");
group.setGroupName("Auth Test Group");
group.setLeaderName("Leader Auth");
group.setAddTime(LocalDateTime.now());
groupInfoService.save(group);
groupIdsToCleanup.add(groupId);
String clerkId = "clerk-auth-withdraw-" + IdUtils.getUuid();
PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity();
clerk.setId(clerkId);
clerk.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
clerk.setSysUserId("sysuser-auth-" + IdUtils.getUuid());
clerk.setOpenid("openid-auth-" + clerkId);
clerk.setNickname("Auth Clerk");
clerk.setGroupId(groupId);
clerk.setLevelId(ApiTestDataSeeder.DEFAULT_CLERK_LEVEL_ID);
clerk.setFixingLevel("1");
clerk.setSex("2");
clerk.setPhone("138" + String.valueOf(System.nanoTime()).substring(0, 8));
clerk.setWeiChatCode("wechat-auth-" + IdUtils.getUuid());
clerk.setAvatar("https://example.com/avatar.png");
clerk.setAccountBalance(BigDecimal.ZERO);
clerk.setOnboardingState("1");
clerk.setListingState("1");
clerk.setDisplayState("1");
clerk.setOnlineState("1");
clerk.setRandomOrderState("1");
clerk.setClerkState("1");
clerk.setEntryTime(LocalDateTime.now());
clerk.setToken("token-auth-" + IdUtils.getUuid());
clerkUserInfoService.save(clerk);
clerkIdsToCleanup.add(clerkId);
return clerkId;
}
}

View File

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

View File

@@ -0,0 +1,274 @@
package com.starry.admin.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.starry.admin.common.apitest.ApiTestDataSeeder;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.weichat.service.WxTokenService;
import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity;
import com.starry.admin.modules.withdraw.enums.EarningsSourceType;
import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService;
import com.starry.admin.modules.withdraw.service.IEarningsAdjustmentService;
import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.modules.withdraw.service.IWithdrawalService;
import com.starry.admin.utils.SecurityUtils;
import com.starry.common.constant.Constants;
import com.starry.common.context.CustomSecurityContextHolder;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
/**
* End-to-end Web/API tests that prove adjustments affect clerk withdraw balance and eligibility.
*
* <p>These tests are expected to FAIL until adjustments are implemented.</p>
*/
class WxWithdrawAdjustmentIntegrationApiTest extends AbstractApiTest {
private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
private static final String PERMISSIONS_HEADER = "X-Test-Permissions";
private static final String PERMISSIONS_CREATE_READ = "withdraw:adjustment:create,withdraw:adjustment:read";
@Autowired
private IEarningsService earningsService;
@Autowired
private IWithdrawalService withdrawalService;
@Autowired
private IEarningsAdjustmentService adjustmentService;
@MockBean
private IClerkPayeeProfileService clerkPayeeProfileService;
@Autowired
private IPlayClerkUserInfoService clerkUserInfoService;
@Autowired
private WxTokenService wxTokenService;
private final ObjectMapper objectMapper = new ObjectMapper();
private String clerkToken;
private ClerkPayeeProfileEntity payeeProfile;
private final List<String> createdAdjustmentIds = new ArrayList<>();
@BeforeEach
void setUp() {
ensureTenantContext();
payeeProfile = new ClerkPayeeProfileEntity();
payeeProfile.setId("payee-" + IdUtils.getUuid());
payeeProfile.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
payeeProfile.setClerkId(ApiTestDataSeeder.DEFAULT_CLERK_ID);
payeeProfile.setChannel("ALIPAY_QR");
payeeProfile.setQrCodeUrl("https://example.com/test-payee.png");
payeeProfile.setDisplayName("API测试收款码");
payeeProfile.setLastConfirmedAt(LocalDateTime.now());
Mockito.when(clerkPayeeProfileService.getByClerk(
ApiTestDataSeeder.DEFAULT_TENANT_ID, ApiTestDataSeeder.DEFAULT_CLERK_ID))
.thenAnswer(invocation -> payeeProfile);
Mockito.when(clerkPayeeProfileService.updateById(Mockito.any(ClerkPayeeProfileEntity.class)))
.thenAnswer(invocation -> {
payeeProfile = invocation.getArgument(0);
return true;
});
clerkToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CLERK_ID);
clerkUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CLERK_ID, clerkToken);
}
@AfterEach
void tearDown() {
ensureTenantContext();
Mockito.reset(clerkPayeeProfileService);
cleanupAdjustments();
CustomSecurityContextHolder.remove();
}
@Test
void appliedPositiveAdjustmentIncreasesWithdrawableBalance() throws Exception {
ensureTenantContext();
BigDecimal before = fetchAvailableBalance();
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"amount\":\"50.00\"," +
"\"reasonType\":\"MANUAL\"," +
"\"reasonDescription\":\"bonus\"" +
"}";
mockMvc.perform(post("/admin/earnings/adjustments")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.header(IDEMPOTENCY_HEADER, key)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isAccepted());
createdAdjustmentIds.add(awaitApplied(key));
BigDecimal after = fetchAvailableBalance();
BigDecimal delta = after.subtract(before).setScale(2, RoundingMode.HALF_UP);
assertThat(delta).isEqualByComparingTo("50.00");
}
@Test
void appliedNegativeAdjustmentCanBlockWithdrawal() throws Exception {
ensureTenantContext();
String key = UUID.randomUUID().toString();
BigDecimal before = fetchAvailableBalance();
BigDecimal amount = before.add(new BigDecimal("20.00")).negate().setScale(2, RoundingMode.HALF_UP);
String payload = "{" +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"amount\":\"" + amount + "\"," +
"\"reasonType\":\"MANUAL\"," +
"\"reasonDescription\":\"penalty\"" +
"}";
mockMvc.perform(post("/admin/earnings/adjustments")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.header(IDEMPOTENCY_HEADER, key)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isAccepted());
createdAdjustmentIds.add(awaitApplied(key));
// Attempting to withdraw any positive amount should fail when net balance is negative.
mockMvc.perform(post("/wx/withdraw/requests")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"amount\":10}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500));
}
@Test
void earningsListIncludesAdjustmentLineAfterApplied() throws Exception {
ensureTenantContext();
String key = UUID.randomUUID().toString();
String payload = "{" +
"\"clerkId\":\"" + ApiTestDataSeeder.DEFAULT_CLERK_ID + "\"," +
"\"amount\":\"12.34\"," +
"\"reasonType\":\"MANUAL\"," +
"\"reasonDescription\":\"show in list\"" +
"}";
mockMvc.perform(post("/admin/earnings/adjustments")
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ)
.header(IDEMPOTENCY_HEADER, key)
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isAccepted());
createdAdjustmentIds.add(awaitApplied(key));
MvcResult earnings = mockMvc.perform(get("/wx/withdraw/earnings")
.param("pageNum", "1")
.param("pageSize", "10")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").isArray())
.andReturn();
JsonNode root = objectMapper.readTree(earnings.getResponse().getContentAsString());
JsonNode rows = root.path("data");
boolean found = false;
if (rows.isArray()) {
for (JsonNode row : rows) {
if ("ADJUSTMENT".equals(row.path("earningType").asText())
&& "12.34".equals(row.path("amount").asText())) {
found = true;
break;
}
}
}
assertThat(found).isTrue();
}
private BigDecimal fetchAvailableBalance() throws Exception {
MvcResult balance = mockMvc.perform(get("/wx/withdraw/balance")
.header(USER_HEADER, DEFAULT_USER)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(Constants.CLERK_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + clerkToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
JsonNode root = objectMapper.readTree(balance.getResponse().getContentAsString());
return root.path("data").path("available").decimalValue().setScale(2, RoundingMode.HALF_UP);
}
private String awaitApplied(String idempotencyKey) throws Exception {
for (int i = 0; i < 80; i++) {
MvcResult poll = mockMvc.perform(get("/admin/earnings/adjustments/idempotency/" + idempotencyKey)
.header(USER_HEADER, ApiTestDataSeeder.DEFAULT_ADMIN_USER_ID)
.header(TENANT_HEADER, DEFAULT_TENANT)
.header(PERMISSIONS_HEADER, PERMISSIONS_CREATE_READ))
.andExpect(status().isOk())
.andReturn();
JsonNode root = objectMapper.readTree(poll.getResponse().getContentAsString());
String status = root.path("data").path("status").asText();
if ("APPLIED".equals(status)) {
return root.path("data").path("adjustmentId").asText();
}
Thread.sleep(50);
}
throw new AssertionError("adjustment not applied within timeout: key=" + idempotencyKey);
}
private void ensureTenantContext() {
SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID);
}
private void cleanupAdjustments() {
if (createdAdjustmentIds.isEmpty()) {
return;
}
earningsService.lambdaUpdate()
.eq(EarningsLineEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(EarningsLineEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
.eq(EarningsLineEntity::getSourceType, EarningsSourceType.ADJUSTMENT)
.in(EarningsLineEntity::getSourceId, createdAdjustmentIds)
.remove();
adjustmentService.removeByIds(createdAdjustmentIds);
createdAdjustmentIds.clear();
withdrawalService.lambdaUpdate()
.eq(WithdrawalRequestEntity::getTenantId, ApiTestDataSeeder.DEFAULT_TENANT_ID)
.eq(WithdrawalRequestEntity::getClerkId, ApiTestDataSeeder.DEFAULT_CLERK_ID)
.remove();
}
}

View File

@@ -29,6 +29,10 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -295,6 +299,52 @@ class WxWithdrawControllerApiTest extends AbstractApiTest {
.andExpect(jsonPath("$.data[1]").doesNotExist());
}
@Test
void concurrentWithdrawRequestsCompeteForSameEarningsLines() throws Exception {
ensureTenantContext();
String firstLine = insertEarningsLine(
"concurrent-one",
new BigDecimal("50.00"),
EarningsStatus.AVAILABLE,
LocalDateTime.now().minusDays(1));
String secondLine = insertEarningsLine(
"concurrent-two",
new BigDecimal("30.00"),
EarningsStatus.AVAILABLE,
LocalDateTime.now().minusHours(2));
earningsToCleanup.add(firstLine);
earningsToCleanup.add(secondLine);
refreshPayeeConfirmation();
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<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(
String suffix, BigDecimal amount, EarningsStatus status, LocalDateTime unlockAt) {
return insertEarningsLine(suffix, amount, status, unlockAt, EarningsType.ORDER);

View File

@@ -0,0 +1,77 @@
package com.starry.admin.db;
import static org.assertj.core.api.Assertions.assertThat;
import com.starry.admin.api.AbstractApiTest;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* Database-level contract tests for the adjustment system schema.
*
* <p>These tests are expected to FAIL until migrations add the new table/columns/indexes.</p>
*/
class EarningsAdjustmentsDatabaseSchemaApiTest extends AbstractApiTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void earningsLineHasSourceColumnsAndOrderIdIsNullable() {
assertThat(columnExists("play_earnings_line", "source_type")).isTrue();
assertThat(columnExists("play_earnings_line", "source_id")).isTrue();
Map<String, Object> orderIdMeta = jdbcTemplate.queryForMap(
"select is_nullable as nullable " +
"from information_schema.columns " +
"where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
"play_earnings_line",
"order_id");
String nullable = String.valueOf(orderIdMeta.get("nullable"));
assertThat(nullable).isIn("YES", "yes", "Y", "y", "1", "TRUE", "true");
}
@Test
void adjustmentTableExistsAndHasIdempotencyFields() {
assertThat(tableExists("play_earnings_line_adjustment")).isTrue();
assertThat(columnExists("play_earnings_line_adjustment", "idempotency_key")).isTrue();
assertThat(columnExists("play_earnings_line_adjustment", "request_hash")).isTrue();
assertThat(columnExists("play_earnings_line_adjustment", "status")).isTrue();
assertThat(columnExists("play_earnings_line_adjustment", "reason_type")).isTrue();
assertThat(columnExists("play_earnings_line_adjustment", "reason_description")).isTrue();
assertThat(columnExists("play_earnings_line_adjustment", "effective_time")).isTrue();
}
@Test
void uniqueIndexExistsForTenantIdempotencyKey() {
// Lock the index name so future migrations are deterministic.
Integer count = jdbcTemplate.queryForObject(
"select count(*) " +
"from information_schema.statistics " +
"where lower(table_name)=lower(?) and lower(index_name)=lower(?)",
Integer.class,
"play_earnings_line_adjustment",
"uk_tenant_idempotency");
assertThat(count).isNotNull();
assertThat(count).isGreaterThan(0);
}
private boolean tableExists(String table) {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from information_schema.tables where lower(table_name)=lower(?)",
Integer.class,
table);
return count != null && count > 0;
}
private boolean columnExists(String table, String column) {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from information_schema.columns where lower(table_name)=lower(?) and lower(column_name)=lower(?)",
Integer.class,
table,
column);
return count != null && count > 0;
}
}

View File

@@ -0,0 +1,47 @@
package com.starry.admin.modules.withdraw.contract;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import java.lang.reflect.Field;
import org.junit.jupiter.api.Test;
/**
* Unit-level contract tests that lock the intended public schema/API surface.
*
* <p>These tests are expected to FAIL until the adjustment system is implemented.</p>
*/
class EarningsAdjustmentSchemaContractTest {
@Test
void earningsLineEntityExposesSourceTypeAndSourceId() throws Exception {
assertHasField(EarningsLineEntity.class, "sourceType");
assertHasField(EarningsLineEntity.class, "sourceId");
}
@Test
void adjustmentEntityAndEnumExist() {
assertDoesNotThrow(() -> {
Class<?> entity = Class.forName("com.starry.admin.modules.withdraw.entity.EarningsLineAdjustmentEntity");
assertHasField(entity, "id");
assertHasField(entity, "tenantId");
assertHasField(entity, "clerkId");
assertHasField(entity, "amount");
assertHasField(entity, "reasonType");
assertHasField(entity, "reasonDescription");
assertHasField(entity, "idempotencyKey");
assertHasField(entity, "requestHash");
assertHasField(entity, "status");
assertHasField(entity, "effectiveTime");
assertHasField(entity, "appliedTime");
});
assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentReasonType"));
assertDoesNotThrow(() -> Class.forName("com.starry.admin.modules.withdraw.enums.EarningsAdjustmentStatus"));
}
private void assertHasField(Class<?> clazz, String fieldName) throws Exception {
Field field = clazz.getDeclaredField(fieldName);
assertThat(field).isNotNull();
}
}