diff --git a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java index df0f17e..283e34d 100644 --- a/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java +++ b/play-admin/src/main/java/com/starry/admin/common/apitest/ApiTestDataSeeder.java @@ -259,6 +259,7 @@ public class ApiTestDataSeeder implements CommandLineRunner { entity.setNotFirstRandomRadio(45); entity.setFirstRewardRatio(40); entity.setNotFirstRewardRatio(35); + entity.setOrderNumber(1L); clerkLevelInfoService.save(entity); log.info("Inserted API test clerk level {}", DEFAULT_CLERK_LEVEL_ID); } diff --git a/play-admin/src/main/resources/db/migration/V14__add_clerk_level_order_number.sql b/play-admin/src/main/resources/db/migration/V14__add_clerk_level_order_number.sql new file mode 100644 index 0000000..db72a53 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V14__add_clerk_level_order_number.sql @@ -0,0 +1,20 @@ +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'play_clerk_level_info' + AND COLUMN_NAME = 'order_number' +); + +SET @ddl := IF( + @column_exists = 0, + 'ALTER TABLE `play_clerk_level_info` ADD COLUMN `order_number` bigint NULL COMMENT ''等级排序号''', + 'SELECT 1' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `play_clerk_level_info` +SET `order_number` = COALESCE(`order_number`, `level`); diff --git a/play-admin/src/test/java/com/starry/admin/api/PlayClerkUserInfoApiTest.java b/play-admin/src/test/java/com/starry/admin/api/PlayClerkUserInfoApiTest.java new file mode 100644 index 0000000..80caa89 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/PlayClerkUserInfoApiTest.java @@ -0,0 +1,322 @@ +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.List; +import java.util.concurrent.ThreadLocalRandom; +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<>(); + + @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(lowOrderClerkId)) + .withFailMessage("Unexpected order for token %s: %s", filterToken, orderedIds) + .isLessThan(orderedIds.indexOf(highOrderClerkId)); + } + + @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; + } +}