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.fasterxml.jackson.databind.node.ObjectNode; import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.utils.SecurityUtils; import com.starry.common.utils.IdUtils; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; import org.junit.jupiter.api.AfterEach; 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; class PlayClerkUserInfoApiTest extends AbstractApiTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Autowired private IPlayClerkLevelInfoService clerkLevelInfoService; @Autowired private IPlayClerkUserInfoService clerkUserInfoService; private final List levelIdsToCleanup = new ArrayList<>(); private final List clerkIdsToCleanup = new ArrayList<>(); private int scenarioSequence = 0; private static final Comparator BACKEND_ORDERING = Comparator .comparing(ClerkScenario::isOnline, Comparator.reverseOrder()) .thenComparing(ClerkScenario::isPinned, Comparator.reverseOrder()) .thenComparingLong(ClerkScenario::getLevelOrder) .thenComparingInt(ClerkScenario::getSequence) .thenComparing(ClerkScenario::getId); @AfterEach void tearDown() { ensureTenantContext(); if (!clerkIdsToCleanup.isEmpty()) { clerkUserInfoService.removeByIds(clerkIdsToCleanup); clerkIdsToCleanup.clear(); } if (!levelIdsToCleanup.isEmpty()) { clerkLevelInfoService.removeByIds(levelIdsToCleanup); levelIdsToCleanup.clear(); } } @Test // 场景:不同等级的排序号不同,接口应按照排序号升序返回,验证等级排序字段生效。 void listOrdersByLevelOrderNumberAscending() throws Exception { ensureTenantContext(); PlayClerkLevelInfoEntity lowOrderLevel = createClerkLevel("low", 1L, 50); PlayClerkLevelInfoEntity highOrderLevel = createClerkLevel("high", 5L, 60); String filterToken = "order-sort-" + IdUtils.getUuid().substring(0, 8); String lowOrderClerkId = createClerk(filterToken + "-low", lowOrderLevel.getId(), "0"); String highOrderClerkId = createClerk(filterToken + "-high", highOrderLevel.getId(), "1"); MvcResult result = mockMvc.perform(get("/clerk/user/list") .param("pageNum", "1") .param("pageSize", "20") .param("nickname", filterToken) .header(TENANT_HEADER, DEFAULT_TENANT) .header(USER_HEADER, DEFAULT_USER)) .andExpect(status().isOk()) .andReturn(); String responseBody = result.getResponse().getContentAsString(); JsonNode root = objectMapper.readTree(responseBody); assertThat(root.get("code").asInt()).isEqualTo(200); JsonNode records = root.path("data"); assertThat(records.isArray()).as("Response payload: %s", responseBody).isTrue(); List orderedIds = new ArrayList<>(); for (JsonNode record : records) { orderedIds.add(record.path("id").asText()); } assertThat(orderedIds).contains(lowOrderClerkId, highOrderClerkId); assertThat(orderedIds.indexOf(highOrderClerkId)) .withFailMessage("Online clerk should appear before offline regardless of level. token=%s list=%s", filterToken, orderedIds) .isLessThan(orderedIds.indexOf(lowOrderClerkId)); } @Test // 场景:相同等级排序号相同,接口应按在线状态优先展示在线店员,验证排序二级规则。 void listOrdersByOnlineStateWhenOrderNumberMatches() throws Exception { ensureTenantContext(); PlayClerkLevelInfoEntity level = createClerkLevel("tie", 3L, 70); String filterToken = "online-priority-" + IdUtils.getUuid().substring(0, 8); String onlineClerkId = createClerk(filterToken + "-online", level.getId(), "1"); String offlineClerkId = createClerk(filterToken + "-offline", level.getId(), "0"); MvcResult result = mockMvc.perform(get("/clerk/user/list") .param("pageNum", "1") .param("pageSize", "20") .param("nickname", filterToken) .header(TENANT_HEADER, DEFAULT_TENANT) .header(USER_HEADER, DEFAULT_USER)) .andExpect(status().isOk()) .andReturn(); String responseBody = result.getResponse().getContentAsString(); JsonNode root = objectMapper.readTree(responseBody); assertThat(root.get("code").asInt()).isEqualTo(200); JsonNode records = root.path("data"); assertThat(records.isArray()).as("Response payload: %s", responseBody).isTrue(); List orderedIds = new ArrayList<>(); for (JsonNode record : records) { orderedIds.add(record.path("id").asText()); } assertThat(orderedIds).contains(onlineClerkId, offlineClerkId); assertThat(orderedIds.indexOf(onlineClerkId)) .withFailMessage("Unexpected order for token %s: %s | response: %s", filterToken, orderedIds, responseBody) .isLessThan(orderedIds.indexOf(offlineClerkId)); } @Test // 场景:两个等级都未配置排序号时,接口仍可返回且在线客服优先排序。 void listOrdersByOnlineStateWhenOrderNumberMissing() throws Exception { ensureTenantContext(); PlayClerkLevelInfoEntity onlineLevel = createClerkLevel("null-online", null, 20); PlayClerkLevelInfoEntity offlineLevel = createClerkLevel("null-offline", null, 30); String filterToken = "null-priority-" + IdUtils.getUuid().substring(0, 8); String onlineClerkId = createClerk(filterToken + "-online", onlineLevel.getId(), "1"); String offlineClerkId = createClerk(filterToken + "-offline", offlineLevel.getId(), "0"); MvcResult result = mockMvc.perform(get("/clerk/user/list") .param("pageNum", "1") .param("pageSize", "20") .param("nickname", filterToken) .header(TENANT_HEADER, DEFAULT_TENANT) .header(USER_HEADER, DEFAULT_USER)) .andExpect(status().isOk()) .andReturn(); String responseBody = result.getResponse().getContentAsString(); JsonNode root = objectMapper.readTree(responseBody); assertThat(root.get("code").asInt()).isEqualTo(200); JsonNode records = root.path("data"); assertThat(records.isArray()).as("Response payload: %s", responseBody).isTrue(); List orderedIds = new ArrayList<>(); for (JsonNode record : records) { orderedIds.add(record.path("id").asText()); } assertThat(orderedIds).contains(onlineClerkId, offlineClerkId); assertThat(orderedIds.indexOf(onlineClerkId)) .withFailMessage("Online clerk should remain prioritized even without orderNumber. token=%s order=%s response=%s", filterToken, orderedIds, responseBody) .isLessThan(orderedIds.indexOf(offlineClerkId)); } @Test // 场景:存在未填写排序号的等级时,接口仍能返回,并默认将该等级排在有序的等级之后。 void listHandlesNullOrderNumberGracefully() throws Exception { ensureTenantContext(); PlayClerkLevelInfoEntity orderedLevel = createClerkLevel("ordered", 2L, 40); PlayClerkLevelInfoEntity nullLevel = createClerkLevel("null", null, 50); String filterToken = "null-order-" + IdUtils.getUuid().substring(0, 8); String orderedClerkId = createClerk(filterToken + "-ordered", orderedLevel.getId(), "1"); String nullOrderClerkId = createClerk(filterToken + "-null", nullLevel.getId(), "1"); MvcResult result = mockMvc.perform(get("/clerk/user/list") .param("pageNum", "1") .param("pageSize", "20") .param("nickname", filterToken) .header(TENANT_HEADER, DEFAULT_TENANT) .header(USER_HEADER, DEFAULT_USER)) .andExpect(status().isOk()) .andReturn(); String responseBody = result.getResponse().getContentAsString(); JsonNode root = objectMapper.readTree(responseBody); assertThat(root.get("code").asInt()).isEqualTo(200); JsonNode records = root.path("data"); assertThat(records.isArray()).as("Response payload: %s", responseBody).isTrue(); List orderedIds = new ArrayList<>(); for (JsonNode record : records) { orderedIds.add(record.path("id").asText()); } assertThat(orderedIds).contains(orderedClerkId, nullOrderClerkId); assertThat(orderedIds.indexOf(orderedClerkId)) .withFailMessage("Null orderNumber should fall back after populated ones. token=%s, order=%s, response=%s", filterToken, orderedIds, responseBody) .isLessThan(orderedIds.indexOf(nullOrderClerkId)); } @Test // 场景:更新店员等级时传入排序号,接口应成功持久化该值,验证新增字段的写入能力。 void updateLevelPersistsOrderNumber() throws Exception { ensureTenantContext(); PlayClerkLevelInfoEntity level = createClerkLevel("update", 8L, 80); long updatedOrderNumber = 42L; ObjectNode payload = objectMapper.createObjectNode(); payload.put("id", level.getId()); payload.put("name", level.getName()); payload.put("firstRegularRatio", level.getFirstRegularRatio()); payload.put("notFirstRegularRatio", level.getNotFirstRegularRatio()); payload.put("firstRewardRatio", level.getFirstRewardRatio()); payload.put("notFirstRewardRatio", level.getNotFirstRewardRatio()); payload.put("firstRandomRadio", level.getFirstRandomRadio()); payload.put("notFirstRandomRadio", level.getNotFirstRandomRadio()); payload.put("styleType", level.getStyleType()); payload.put("orderNumber", updatedOrderNumber); mockMvc.perform(post("/clerk/level/update") .header(TENANT_HEADER, DEFAULT_TENANT) .header(USER_HEADER, DEFAULT_USER) .contentType(MediaType.APPLICATION_JSON) .content(payload.toString())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); ensureTenantContext(); PlayClerkLevelInfoEntity reloaded = clerkLevelInfoService.getById(level.getId()); assertThat(reloaded.getOrderNumber()).isEqualTo(updatedOrderNumber); } @Test // 场景:更新时清空排序号,接口应允许写入null并持久化。 void updateLevelAllowsClearingOrderNumber() throws Exception { ensureTenantContext(); PlayClerkLevelInfoEntity level = createClerkLevel("clear", 5L, 85); ObjectNode payload = objectMapper.createObjectNode(); payload.put("id", level.getId()); payload.put("name", level.getName()); payload.put("firstRegularRatio", level.getFirstRegularRatio()); payload.put("notFirstRegularRatio", level.getNotFirstRegularRatio()); payload.put("firstRewardRatio", level.getFirstRewardRatio()); payload.put("notFirstRewardRatio", level.getNotFirstRewardRatio()); payload.put("firstRandomRadio", level.getFirstRandomRadio()); payload.put("notFirstRandomRadio", level.getNotFirstRandomRadio()); payload.put("styleType", level.getStyleType()); payload.putNull("orderNumber"); mockMvc.perform(post("/clerk/level/update") .header(TENANT_HEADER, DEFAULT_TENANT) .header(USER_HEADER, DEFAULT_USER) .contentType(MediaType.APPLICATION_JSON) .content(payload.toString())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); ensureTenantContext(); PlayClerkLevelInfoEntity reloaded = clerkLevelInfoService.getById(level.getId()); assertThat(reloaded.getOrderNumber()).isNull(); } private void ensureTenantContext() { SecurityUtils.setTenantId(DEFAULT_TENANT); } private PlayClerkLevelInfoEntity createClerkLevel(String suffix, Long orderNumber, int levelValue) { ensureTenantContext(); String levelId = IdUtils.getUuid(); PlayClerkLevelInfoEntity level = new PlayClerkLevelInfoEntity(); level.setId(levelId); level.setTenantId(DEFAULT_TENANT); level.setName("API测试等级-" + suffix); level.setLevel(levelValue); level.setFirstRegularRatio(60); level.setNotFirstRegularRatio(50); level.setFirstRewardRatio(45); level.setNotFirstRewardRatio(35); level.setFirstRandomRadio(55); level.setNotFirstRandomRadio(40); level.setStyleType(levelValue); level.setOrderNumber(orderNumber); clerkLevelInfoService.save(level); levelIdsToCleanup.add(levelId); return level; } private String createClerk(String suffix, String levelId, String onlineState) { ensureTenantContext(); String clerkId = IdUtils.getUuid(); PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity(); clerk.setId(clerkId); clerk.setTenantId(DEFAULT_TENANT); clerk.setNickname("API测试店员-" + suffix); clerk.setLevelId(levelId); clerk.setClerkState("1"); clerk.setOnboardingState("1"); clerk.setListingState("1"); clerk.setDisplayState("1"); clerk.setRecommendationState("0"); clerk.setPinToTopState("0"); clerk.setRandomOrderState("1"); clerk.setFixingLevel("0"); clerk.setOnlineState(onlineState); clerk.setPhone("138" + String.format("%08d", ThreadLocalRandom.current().nextInt(0, 100_000_000))); clerk.setOpenid("openid-" + suffix + "-" + IdUtils.getUuid()); clerk.setWeiChatCode("wx-code-" + suffix); clerk.setWeiChatAvatar("https://example.com/avatar/" + suffix); clerk.setTypeId("api-type"); clerk.setProvince("API省"); clerk.setCity("API市"); clerk.setEntryTime(LocalDateTime.now()); clerk.setAddTime(LocalDateTime.now()); clerkUserInfoService.save(clerk); clerkIdsToCleanup.add(clerkId); return clerkId; } @Test void listOrderingStableWithMultipleCriteria() throws Exception { ensureTenantContext(); PlayClerkLevelInfoEntity level = createClerkLevel("stable", 10L, 80); String filterToken = "stable-" + IdUtils.getUuid().substring(0, 6); String pinnedOnline = createClerk(filterToken + "-pinned-online", level.getId(), "1"); togglePin(pinnedOnline, "1"); String pinnedOffline = createClerk(filterToken + "-pinned-offline", level.getId(), "0"); togglePin(pinnedOffline, "1"); String online1 = createClerk(filterToken + "-online-one", level.getId(), "1"); pause(50); String online2 = createClerk(filterToken + "-online-two", level.getId(), "1"); String offline = createClerk(filterToken + "-offline", level.getId(), "0"); MvcResult result = mockMvc.perform(get("/clerk/user/list") .param("pageNum", "1") .param("pageSize", "20") .param("nickname", filterToken) .header(TENANT_HEADER, DEFAULT_TENANT) .header(USER_HEADER, DEFAULT_USER)) .andExpect(status().isOk()) .andReturn(); JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); JsonNode records = root.path("data"); assertThat(records.isArray()).isTrue(); List orderedIds = new ArrayList<>(); for (JsonNode record : records) { orderedIds.add(record.path("id").asText()); } Map expectedScenarios = new HashMap<>(); expectedScenarios.put(pinnedOnline, new ClerkScenario(pinnedOnline, 1L, true, true, 0)); expectedScenarios.put(online1, new ClerkScenario(online1, 1L, true, false, 1)); expectedScenarios.put(online2, new ClerkScenario(online2, 1L, true, false, 2)); expectedScenarios.put(pinnedOffline, new ClerkScenario(pinnedOffline, 1L, false, true, 3)); expectedScenarios.put(offline, new ClerkScenario(offline, 1L, false, false, 4)); List actualScenarios = orderedIds.stream() .map(expectedScenarios::get) .collect(Collectors.toList()); for (int i = 1; i < actualScenarios.size(); i++) { ClerkScenario previous = actualScenarios.get(i - 1); ClerkScenario current = actualScenarios.get(i); assertThat(previous).isNotNull(); assertThat(current).isNotNull(); assertThat(BACKEND_ORDERING.compare(previous, current)) .withFailMessage("Ordering violation between %s and %s, list=%s", previous.getId(), current.getId(), orderedIds) .isLessThanOrEqualTo(0); } } @Test void listOrderingHandlesBulkDataset() throws Exception { ensureTenantContext(); String token = "bulk-" + IdUtils.getUuid().substring(0, 6); PlayClerkLevelInfoEntity gold = createClerkLevel(token + "-gold", 1L, 90); PlayClerkLevelInfoEntity silver = createClerkLevel(token + "-silver", 2L, 80); PlayClerkLevelInfoEntity iron = createClerkLevel(token + "-iron", 3L, 70); List scenarios = new ArrayList<>(); scenarios.add(buildScenario(token, "G-Pin-ON", gold, true, true)); scenarios.add(buildScenario(token, "G-UnPin-ON", gold, true, false)); scenarios.add(buildScenario(token, "G-Pin-Off", gold, false, true)); scenarios.add(buildScenario(token, "G-UnPin-Off", gold, false, false)); scenarios.add(buildScenario(token, "S-Pin-ON", silver, true, true)); scenarios.add(buildScenario(token, "S-UnPin-ON", silver, true, false)); scenarios.add(buildScenario(token, "S-Pin-Off", silver, false, true)); scenarios.add(buildScenario(token, "S-UnPin-Off", silver, false, false)); scenarios.add(buildScenario(token, "I-Pin-ON", iron, true, true)); scenarios.add(buildScenario(token, "I-UnPin-ON", iron, true, false)); scenarios.add(buildScenario(token, "I-Pin-Off", iron, false, true)); scenarios.add(buildScenario(token, "I-UnPin-Off", iron, false, false)); MvcResult result = mockMvc.perform(get("/clerk/user/list") .param("pageNum", "1") .param("pageSize", "80") .param("nickname", token) .header(TENANT_HEADER, DEFAULT_TENANT) .header(USER_HEADER, DEFAULT_USER)) .andExpect(status().isOk()) .andReturn(); JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); JsonNode records = root.path("data"); List orderedIds = new ArrayList<>(); for (JsonNode record : records) { orderedIds.add(record.path("id").asText()); } Map scenarioById = scenarios.stream() .collect(Collectors.toMap(ClerkScenario::getId, scenario -> scenario)); assertThat(orderedIds).containsExactlyInAnyOrderElementsOf(scenarioById.keySet()); List orderedScenarios = orderedIds.stream() .map(scenarioById::get) .collect(Collectors.toList()); for (int i = 1; i < orderedScenarios.size(); i++) { ClerkScenario previous = orderedScenarios.get(i - 1); ClerkScenario current = orderedScenarios.get(i); assertThat(BACKEND_ORDERING.compare(previous, current)) .withFailMessage("Ordering violation between %s and %s, list=%s", previous.getId(), current.getId(), orderedIds) .isLessThanOrEqualTo(0); } } private ClerkScenario buildScenario(String token, String suffix, PlayClerkLevelInfoEntity level, boolean online, boolean pinned) { String id = createClerk(token + "-" + suffix, level.getId(), online ? "1" : "0"); if (pinned) { togglePin(id, "1"); } long levelOrder = level.getOrderNumber() == null ? Long.MAX_VALUE : level.getOrderNumber(); ClerkScenario scenario = new ClerkScenario(id, levelOrder, online, pinned, scenarioSequence++); pause(15); return scenario; } private void togglePin(String clerkId, String pinState) { ensureTenantContext(); PlayClerkUserInfoEntity update = new PlayClerkUserInfoEntity(); update.setId(clerkId); update.setPinToTopState(pinState); clerkUserInfoService.update(update); } private void pause(long millis) { try { Thread.sleep(millis); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } private static class ClerkScenario { private final String id; private final long levelOrder; private final boolean online; private final boolean pinned; private final int sequence; ClerkScenario(String id, long levelOrder, boolean online, boolean pinned, int sequence) { this.id = id; this.levelOrder = levelOrder; this.online = online; this.pinned = pinned; this.sequence = sequence; } String getId() { return id; } long getLevelOrder() { return levelOrder; } boolean isOnline() { return online; } boolean isPinned() { return pinned; } int getSequence() { return sequence; } } }